diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9678b40..c84b318 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,36 @@ name: Release & Publish +run-name: "Release ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} (${{ github.event_name }})" + on: + # Normal release path: pushing a version tag. push: tags: - 'v*' + # Manual path for a tag that already exists (for example, a release whose tag-push run used an + # older version of this workflow). It must be dispatched from `main`. Both the validation and + # build jobs check out the requested tag, never the `main` tree. + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to validate, build and publish (for example v1.2.0). Dispatch from main." + required: true + type: string permissions: contents: read +env: + # The release tag for this run: the pushed tag, or the `tag` input of a manual dispatch. + # Shell steps read it only through this environment variable, never through `${{ }}` + # interpolation inside a script. + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + +# Never run two releases of the same tag at the same time (for example a tag push and a dispatch). +concurrency: + group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + cancel-in-progress: false + jobs: validate: name: Validate Version & Run Release Test Suite @@ -15,10 +38,74 @@ jobs: # Test job: read-only, no OIDC token, no publishing credentials. permissions: contents: read + outputs: + commit: ${{ steps.release-commit.outputs.commit }} steps: - - name: Checkout repository + - name: Check release trigger and tag name + run: | + set -euo pipefail + tag_pattern='^v[0-9]+\.[0-9]+\.[0-9]+((a|b|rc)[0-9]+)?(\.post[0-9]+)?$' + if ! [[ "${RELEASE_TAG}" =~ $tag_pattern ]]; then + echo "::error::Release tag must look like v1.2.3 (optionally with a1/b1/rc1 or .post1); refusing to release." + exit 1 + fi + echo "event=${GITHUB_EVENT_NAME} ref=${GITHUB_REF} release_tag=${RELEASE_TAG}" + case "${GITHUB_EVENT_NAME}" in + push) + if [ "${GITHUB_REF}" != "refs/tags/${RELEASE_TAG}" ]; then + echo "::error::Tag-push run for ${GITHUB_REF} does not match release tag ${RELEASE_TAG}." + exit 1 + fi + ;; + workflow_dispatch) + if [ "${GITHUB_REF}" != "refs/heads/main" ]; then + echo "::error::Manual releases must be dispatched from main, not ${GITHUB_REF}." + exit 1 + fi + ;; + *) + echo "::error::Unsupported release trigger: ${GITHUB_EVENT_NAME}." + exit 1 + ;; + esac + + - name: Checkout the release tag (full history and tags) uses: actions/checkout@v4 + with: + # Full history and all tags: the test suite verifies historical tags and commits + # (for example v0.1.0-baseline), which a shallow checkout does not contain. + ref: refs/tags/${{ env.RELEASE_TAG }} + fetch-depth: 0 + persist-credentials: false + + - name: Verify the checked-out commit is the release tag + id: release-commit + run: | + set -euo pipefail + if ! tag_commit="$(git rev-parse --verify --quiet "refs/tags/${RELEASE_TAG}^{commit}")"; then + echo "::error::Tag ${RELEASE_TAG} does not exist." + exit 1 + fi + head_commit="$(git rev-parse HEAD)" + if [ "${tag_commit}" != "${head_commit}" ]; then + echo "::error::Checked-out HEAD ${head_commit} is not tag ${RELEASE_TAG} (${tag_commit})." + exit 1 + fi + if [ "${GITHUB_EVENT_NAME}" = "push" ]; then + pushed_commit="$(git rev-parse --verify --quiet "${GITHUB_SHA}^{commit}")" || pushed_commit="" + if [ "${pushed_commit}" != "${head_commit}" ]; then + echo "::error::Tag ${RELEASE_TAG} now points to ${head_commit}, not the pushed commit ${GITHUB_SHA}." + exit 1 + fi + else + if ! git merge-base --is-ancestor "${head_commit}" refs/remotes/origin/main; then + echo "::error::Tag ${RELEASE_TAG} (${head_commit}) is not on main; refusing a manual release." + exit 1 + fi + fi + echo "Release ${RELEASE_TAG} -> ${head_commit}" + echo "commit=${head_commit}" >> "$GITHUB_OUTPUT" - name: Set up Python uses: actions/setup-python@v5 @@ -32,13 +119,11 @@ jobs: pip install -e ".[dev,mcp,api]" - name: Verify tag, pyproject version and package __version__ match - env: - TAG_NAME: ${{ github.ref_name }} run: | python - <<'EOF' import os, sys, tomllib import reponeedle - tag = os.environ["TAG_NAME"] + tag = os.environ["RELEASE_TAG"] tag_version = tag[1:] if tag.startswith("v") else tag project_version = tomllib.load(open("pyproject.toml", "rb"))["project"]["version"] package_version = reponeedle.__version__ @@ -70,10 +155,28 @@ jobs: contents: read steps: - - name: Checkout repository + - name: Checkout the release tag (full history and tags) uses: actions/checkout@v4 with: + ref: refs/tags/${{ env.RELEASE_TAG }} fetch-depth: 0 + persist-credentials: false + + - name: Verify the checked-out commit is the validated release commit + env: + VALIDATED_COMMIT: ${{ needs.validate.outputs.commit }} + run: | + set -euo pipefail + if ! tag_commit="$(git rev-parse --verify --quiet "refs/tags/${RELEASE_TAG}^{commit}")"; then + echo "::error::Tag ${RELEASE_TAG} does not exist." + exit 1 + fi + head_commit="$(git rev-parse HEAD)" + if [ -z "${VALIDATED_COMMIT}" ] || [ "${head_commit}" != "${tag_commit}" ] || [ "${head_commit}" != "${VALIDATED_COMMIT}" ]; then + echo "::error::Build checkout ${head_commit} does not match tag ${RELEASE_TAG} (${tag_commit}) and the validated commit (${VALIDATED_COMMIT:-none})." + exit 1 + fi + echo "Building ${RELEASE_TAG} from ${head_commit}" - name: Set up Python uses: actions/setup-python@v5 @@ -95,10 +198,8 @@ jobs: python -m twine check --strict dist/* - name: Verify built artifacts carry the tagged version - env: - TAG_NAME: ${{ github.ref_name }} run: | - VERSION="${TAG_NAME#v}" + VERSION="${RELEASE_TAG#v}" test -f "dist/reponeedle-${VERSION}-py3-none-any.whl" || { echo "Wheel for ${VERSION} not found"; ls dist; exit 1; } test -f "dist/reponeedle-${VERSION}.tar.gz" || { echo "sdist for ${VERSION} not found"; ls dist; exit 1; } diff --git a/docs/pypi_trusted_publishing.md b/docs/pypi_trusted_publishing.md index f9dd4d7..5d73a85 100644 --- a/docs/pypi_trusted_publishing.md +++ b/docs/pypi_trusted_publishing.md @@ -49,45 +49,57 @@ In GitHub, protect the deployment target using the environment specified in `rel 3. Click **New environment**. 4. Set the name to exactly `pypi` and click **Configure environment**. 5. *(Recommended Security Rules)*: - - **Deployment branches and tags**: Select **Selected tags** and specify rule `v*` (ensuring only release tags can deploy). - - **Required reviewers**: Optionally add `krishika08` for explicit deployment confirmation. + - **Deployment branches and tags**: Select **Selected branches and tags** and add the tag rule `v*` and the branch rule `main`. Tag pushes deploy from `refs/tags/v*`; a [manual release of an existing tag](#6-manually-releasing-an-existing-tag) runs from `refs/heads/main`. A tags-only rule would block manual releases at the deployment gate. + - **Required reviewers**: Optionally add `krishika08` for explicit deployment confirmation. Every publish, tag-push or manual, then waits for approval. --- ## 4. Step 3: Automated Workflow Architecture (`.github/workflows/release.yml`) -The release pipeline consists of two decoupled jobs: +The release pipeline has three jobs. Each job starts only after the previous one succeeds. ``` -[ Git Tag: v* ] +[ Tag push v* | manual dispatch from main with tag input ] │ ▼ -┌──────────────────────────────────────────────┐ -│ Job 1: build │ -│ - Checkout full git history │ -│ - Set up Python 3.11 │ -│ - python -m build │ -│ - python -m twine check --strict dist/* │ -│ - sha256sum * > SHA256SUMS │ -│ - Upload artifacts (packages & checksums) │ -└──────────────────────┬───────────────────────┘ - │ Validated Artifacts - ▼ -┌──────────────────────────────────────────────┐ -│ Job 2: publish │ -│ - Environment: pypi │ -│ - Permissions: id-token: write │ -│ - Download validated distributions │ -│ - pypa/gh-action-pypi-publish@release/v1 │ -└──────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────┐ +│ Job 1: validate (contents: read) │ +│ - Check trigger and tag format; dispatch only from main │ +│ - Checkout refs/tags/ with full history and tags │ +│ - Verify HEAD == tag commit (== pushed commit for push; │ +│ tag commit on main for dispatch) │ +│ - Verify tag == pyproject version == __version__ │ +│ - Full test suite, strict MCP/security suites │ +└──────────────────────────┬───────────────────────────────┘ + │ validated commit SHA + ▼ +┌──────────────────────────────────────────────────────────┐ +│ Job 2: build (contents: read) │ +│ - Checkout refs/tags/ with full history and tags │ +│ - Verify HEAD == tag commit == validated commit │ +│ - python -m build; twine check --strict │ +│ - Verify artifact file names carry the tag version │ +│ - sha256sum > SHA256SUMS; upload artifacts │ +└──────────────────────────┬───────────────────────────────┘ + │ validated artifacts + ▼ +┌──────────────────────────────────────────────────────────┐ +│ Job 3: publish │ +│ - Environment: pypi │ +│ - Permissions: id-token: write, contents: read │ +│ - Download artifacts from build (no checkout) │ +│ - pypa/gh-action-pypi-publish@release/v1 │ +└──────────────────────────────────────────────────────────┘ ``` ### Security & Integrity Controls: - **Zero API Tokens**: No API tokens, passwords, or secrets are stored in GitHub repository secrets. -- **Strict Triggering**: Triggers exclusively on `push` of tags matching `v*`. Normal branch pushes and Pull Requests never invoke this workflow. -- **Least-Privilege Permissions**: Top-level permissions are read-only (`contents: read`). The `publish` job explicitly requests only `id-token: write` and `contents: read`. +- **Restricted Triggering**: The workflow runs only on a push of a tag matching `v*`, or on a manual `workflow_dispatch` from `main` naming an existing release tag. Normal branch pushes and pull requests never invoke it. A dispatch from any other branch, or with a tag that is malformed, does not exist or is not on `main`, fails in `validate`. +- **Exact Release Ref**: `validate` and `build` both check out `refs/tags/` (never the branch tree) with full history, verify that HEAD is the tag's commit, and `build` refuses to build anything other than the commit `validate` tested. +- **Least-Privilege Permissions**: Top-level permissions are read-only (`contents: read`). `validate` and `build` are read-only and do not persist checkout credentials. Only `publish` requests `id-token: write`. - **Pre-Publication Twine Audit**: Distributions must pass `twine check --strict` before the publish job is scheduled. - **Deterministic Checksums**: SHA-256 hashes are recorded in `checksums/SHA256SUMS` and emitted to the GitHub Step Summary. +- **No Concurrent Releases**: Runs for the same tag are serialised (`concurrency: release-`). --- @@ -108,3 +120,24 @@ git push origin v1.1.0 ``` GitHub Actions will automatically run `.github/workflows/release.yml`, build the validated distributions, exchange OIDC claims with PyPI, and publish `reponeedle==1.1.0` to PyPI. + +--- + +## 6. Manually Releasing an Existing Tag + +Use this when a release tag already exists but its tag-push run failed for a reason unrelated to the tagged code. For example, `v1.2.0` was pushed while `release.yml` still used a shallow checkout, which broke the history tests. Re-running that old run would reuse the old workflow definition, so dispatch a new run instead. Do **not** delete, move or recreate the tag. + +```bash +# Dispatch from main, so that the current release.yml is used. The jobs check out the tag, not main. +gh workflow run release.yml --ref main -f tag=v1.2.0 +``` + +Or in the GitHub UI: **Actions** → **Release & Publish** → **Run workflow**, keep **Branch: main**, and enter the tag. + +The run is rejected unless all of these hold: +- it was dispatched from `main`; +- the tag looks like `vX.Y.Z` (optionally `aN`/`bN`/`rcN` and `.postN`) and exists; +- the tag's commit is on `main`; +- the tag, `pyproject.toml` and `reponeedle.__version__` at that commit agree. + +PyPI Trusted Publishing accepts the run unchanged: the OIDC token still comes from `krishika08/RepoNeedle`, workflow `release.yml`, environment `pypi`. PyPI does not restrict the triggering event or ref. If the `pypi` environment has deployment branch/tag rules, they must allow `main` (see [Step 2](#3-step-2-configure-the-github-environment)). diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py new file mode 100644 index 0000000..9c52b4c --- /dev/null +++ b/tests/test_release_workflow.py @@ -0,0 +1,150 @@ +""" +Structural checks for .github/workflows/release.yml. + +Guards the release security model (read-only test and build jobs, OIDC only in the publish job, +publish gated on validate and build, protected `pypi` environment) and the release-ref handling: +full-history checkouts of the exact release tag for both tag pushes and manual dispatches. + +Not part of the strict no-skip Linux job: PyYAML is installed with the `api` extra +(`uvicorn[standard]`), which the strict job does not install. +""" + +import re +from pathlib import Path + +import pytest + +yaml = pytest.importorskip("yaml") + +WORKFLOW_PATH = Path(__file__).resolve().parent.parent / ".github" / "workflows" / "release.yml" +TAG_EXPR = "github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name" + + +@pytest.fixture(scope="module") +def workflow(): + return yaml.safe_load(WORKFLOW_PATH.read_text(encoding="utf-8")) + + +def _triggers(wf): + # PyYAML (YAML 1.1) parses the bare key `on` as boolean True. + return wf.get("on", wf.get(True)) + + +def _step(job, name_prefix): + matches = [s for s in job["steps"] if s.get("name", "").startswith(name_prefix)] + assert len(matches) == 1, f"expected one step starting with {name_prefix!r}, found {len(matches)}" + return matches[0] + + +def _checkout_steps(job): + return [s for s in job["steps"] if str(s.get("uses", "")).startswith("actions/checkout@")] + + +def test_tag_push_trigger_is_preserved(workflow): + triggers = _triggers(workflow) + assert triggers["push"] == {"tags": ["v*"]} + assert set(triggers) == {"push", "workflow_dispatch"} + + +def test_manual_dispatch_requires_a_tag_input(workflow): + inputs = _triggers(workflow)["workflow_dispatch"]["inputs"] + assert set(inputs) == {"tag"} + assert inputs["tag"]["required"] is True + assert inputs["tag"]["type"] == "string" + + +def test_release_tag_is_derived_from_the_event(workflow): + assert workflow["env"]["RELEASE_TAG"] == "${{ " + TAG_EXPR + " }}" + assert workflow["concurrency"]["group"] == "release-${{ " + TAG_EXPR + " }}" + assert workflow["concurrency"]["cancel-in-progress"] is False + + +@pytest.mark.parametrize("job_name", ["validate", "build"]) +def test_validate_and_build_check_out_the_release_tag_with_full_history(workflow, job_name): + checkouts = _checkout_steps(workflow["jobs"][job_name]) + assert len(checkouts) == 1 + opts = checkouts[0]["with"] + assert opts["ref"] == "refs/tags/${{ env.RELEASE_TAG }}" + assert opts["fetch-depth"] == 0 + assert opts["persist-credentials"] is False + + +def test_validate_guards_trigger_tag_format_and_commit(workflow): + steps = workflow["jobs"]["validate"]["steps"] + names = [s.get("name", "") for s in steps] + guard = names.index("Check release trigger and tag name") + checkout = names.index("Checkout the release tag (full history and tags)") + verify = names.index("Verify the checked-out commit is the release tag") + assert guard < checkout < verify, "trigger guard must run before checkout, commit check right after" + + guard_script = steps[guard]["run"] + assert 'refs/heads/main' in guard_script + assert '"refs/tags/${RELEASE_TAG}"' in guard_script + pattern = re.search(r"tag_pattern='([^']+)'", guard_script).group(1) + for good in ["v1.2.0", "v10.0.1", "v1.3.0rc1", "v1.2.0.post1"]: + assert re.fullmatch(pattern, good), good + for bad in ["main", "1.2.0", "v1.2", "v0.1.0-baseline", "v1.2.0; rm -rf /", "v1.2.0\nx", ""]: + assert not re.fullmatch(pattern.rstrip("$") + r"\Z", bad), bad + + verify_script = steps[verify]["run"] + assert 'refs/tags/${RELEASE_TAG}^{commit}' in verify_script + assert "git rev-parse HEAD" in verify_script + assert "${GITHUB_SHA}^{commit}" in verify_script + assert "git merge-base --is-ancestor" in verify_script and "refs/remotes/origin/main" in verify_script + assert workflow["jobs"]["validate"]["outputs"]["commit"] == "${{ steps.release-commit.outputs.commit }}" + + +def test_build_verifies_it_builds_the_validated_commit(workflow): + step = _step(workflow["jobs"]["build"], "Verify the checked-out commit is the validated release commit") + assert step["env"]["VALIDATED_COMMIT"] == "${{ needs.validate.outputs.commit }}" + assert 'refs/tags/${RELEASE_TAG}^{commit}' in step["run"] + assert "${VALIDATED_COMMIT}" in step["run"] + + +def test_version_checks_use_the_release_tag(workflow): + validate_step = _step(workflow["jobs"]["validate"], "Verify tag, pyproject version") + assert 'os.environ["RELEASE_TAG"]' in validate_step["run"] + assert "reponeedle.__version__" in validate_step["run"] and "pyproject.toml" in validate_step["run"] + build_step = _step(workflow["jobs"]["build"], "Verify built artifacts carry the tagged version") + assert 'VERSION="${RELEASE_TAG#v}"' in build_step["run"] + + +def test_no_expression_interpolation_inside_shell_scripts(workflow): + # Untrusted or user-supplied values reach scripts through environment variables only. + for job_name, job in workflow["jobs"].items(): + for step in job["steps"]: + assert "${{" not in step.get("run", ""), f"{job_name}: {step.get('name')}" + + +def test_permissions_and_job_dependencies(workflow): + assert workflow["permissions"] == {"contents": "read"} + jobs = workflow["jobs"] + assert set(jobs) == {"validate", "build", "publish"} + assert jobs["validate"]["permissions"] == {"contents": "read"} + assert jobs["build"]["permissions"] == {"contents": "read"} + assert jobs["build"]["needs"] == "validate" + assert jobs["publish"]["needs"] == ["validate", "build"] + assert jobs["publish"]["permissions"] == {"id-token": "write", "contents": "read"} + assert jobs["publish"]["environment"]["name"] == "pypi" + for name in ("validate", "build"): + assert "id-token" not in jobs[name]["permissions"] + assert "environment" not in jobs[name] + + +def test_release_safeguards_are_kept(workflow): + validate = workflow["jobs"]["validate"] + strict = _step(validate, "Run MCP and security suites") + assert strict["env"]["REPONEEDLE_FAIL_ON_SKIP"] == "1" + assert "tests/test_indexing_security.py" in strict["run"] + assert _step(validate, "Run full test suite")["run"].strip() == "pytest -q -rs" + + build = workflow["jobs"]["build"] + assert "twine check --strict dist/*" in _step(build, "Validate distribution metadata")["run"] + assert "sha256sum" in _step(build, "Generate and record SHA-256 checksums")["run"] + + publish = workflow["jobs"]["publish"] + assert not _checkout_steps(publish), "publish must only use the artifacts built and verified by build" + assert [s["uses"] for s in publish["steps"]] == [ + "actions/download-artifact@v4", + "pypa/gh-action-pypi-publish@release/v1", + ]