From 04861bfbf5b5963f5869f00cc37ffe6fc647b3f2 Mon Sep 17 00:00:00 2001 From: CometAPI Date: Tue, 28 Jul 2026 19:49:32 +0800 Subject: [PATCH] fix: preserve PyPI publisher workflow identity --- .github/workflows/publish.yml | 201 ++++++++++++- .github/workflows/release-please.yml | 91 ------ .github/workflows/release-recovery.yml | 90 ------ AGENTS.md | 38 ++- ARCHITECTURE.md | 30 +- CHANGELOG.md | 7 +- RELEASING.md | 68 +++-- ROADMAP.md | 57 ++-- SECURITY.md | 16 +- scripts/check_secrets.py | 6 +- scripts/check_workflows.py | 385 ++++++++++++------------- tests/test_release_workflow.py | 146 +++++++--- tests/test_secrets.py | 13 +- 13 files changed, 616 insertions(+), 532 deletions(-) delete mode 100644 .github/workflows/release-please.yml delete mode 100644 .github/workflows/release-recovery.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 845a496..e4f1fb1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,15 +1,17 @@ name: Publish immutable release on: - workflow_call: + push: + branches: + - main + workflow_dispatch: inputs: release-tag: + description: Exact immutable GitHub release tag required: true type: string release-sha: - required: true - type: string - default-branch: + description: Exact commit resolved by the release tag required: true type: string @@ -24,9 +26,188 @@ env: UV_VERSION: 0.11.8 jobs: + release-please: + name: Maintain the reviewed release PR and release + if: >- + github.run_attempt == 1 && + github.event_name == 'push' && + vars.RELEASE_PLEASE_ENABLED == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + release-created: ${{ steps.release.outputs.release_created }} + release-sha: ${{ steps.verify-release.outputs.release-sha }} + release-tag: ${{ steps.verify-release.outputs.release-tag }} + release-verified: ${{ steps.verify-release.outputs.release-verified }} + permissions: + contents: write + pull-requests: write + steps: + - name: Open or update the release PR, or create its approved release + id: release + uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + - name: Verify the immutable release created by Release Please + id: verify-release + if: steps.release.outputs.release_created == 'true' + env: + EXPECTED_SHA: ${{ steps.release.outputs.sha }} + EXPECTED_TAG: ${{ steps.release.outputs.tag_name }} + GH_TOKEN: ${{ github.token }} + run: | + test -n "$EXPECTED_TAG" + test -n "$EXPECTED_SHA" + release="" + for attempt in $(seq 1 12); do + release=$(gh api "repos/${{ github.repository }}/releases/tags/$EXPECTED_TAG") || true + if test -n "$release" && test "$(jq -r .immutable <<<"$release")" = "true"; then + break + fi + if test "$attempt" -ge 12; then + echo "release did not become immutable" >&2 + exit 1 + fi + sleep 5 + done + test "$(jq -r .tag_name <<<"$release")" = "$EXPECTED_TAG" + test "$(jq -r .draft <<<"$release")" = "false" + test "$(jq -r .prerelease <<<"$release")" = "false" + test "$(jq -r .immutable <<<"$release")" = "true" + ref=$(gh api "repos/${{ github.repository }}/git/ref/tags/$EXPECTED_TAG") + tag_type=$(jq -r .object.type <<<"$ref") + tag_sha=$(jq -r .object.sha <<<"$ref") + if test "$tag_type" = "tag"; then + tag_sha=$(gh api "repos/${{ github.repository }}/git/tags/$tag_sha" --jq .object.sha) + else + test "$tag_type" = "commit" + fi + test "$tag_sha" = "$EXPECTED_SHA" + { + echo "release-tag=$EXPECTED_TAG" + echo "release-sha=$EXPECTED_SHA" + echo "release-verified=true" + } >> "$GITHUB_OUTPUT" + + verify-recovery: + name: Verify the authorized immutable release recovery + if: >- + github.run_attempt == 1 && + github.event_name == 'workflow_dispatch' && + github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && + vars.RELEASE_RECOVERY_TAG == inputs.release-tag && + vars.RELEASE_RECOVERY_SHA == inputs.release-sha + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + release-sha: ${{ steps.verify-release.outputs.release-sha }} + release-tag: ${{ steps.verify-release.outputs.release-tag }} + permissions: + contents: read + steps: + - name: Verify the immutable release selected for recovery + id: verify-release + env: + EXPECTED_SHA: ${{ inputs.release-sha }} + EXPECTED_TAG: ${{ inputs.release-tag }} + GH_TOKEN: ${{ github.token }} + run: | + test -n "$EXPECTED_TAG" + test -n "$EXPECTED_SHA" + release="" + for attempt in $(seq 1 12); do + release=$(gh api "repos/${{ github.repository }}/releases/tags/$EXPECTED_TAG") || true + if test -n "$release" && test "$(jq -r .immutable <<<"$release")" = "true"; then + break + fi + if test "$attempt" -ge 12; then + echo "release did not become immutable" >&2 + exit 1 + fi + sleep 5 + done + test "$(jq -r .tag_name <<<"$release")" = "$EXPECTED_TAG" + test "$(jq -r .draft <<<"$release")" = "false" + test "$(jq -r .prerelease <<<"$release")" = "false" + test "$(jq -r .immutable <<<"$release")" = "true" + ref=$(gh api "repos/${{ github.repository }}/git/ref/tags/$EXPECTED_TAG") + tag_type=$(jq -r .object.type <<<"$ref") + tag_sha=$(jq -r .object.sha <<<"$ref") + if test "$tag_type" = "tag"; then + tag_sha=$(gh api "repos/${{ github.repository }}/git/tags/$tag_sha" --jq .object.sha) + else + test "$tag_type" = "commit" + fi + test "$tag_sha" = "$EXPECTED_SHA" + { + echo "release-tag=$EXPECTED_TAG" + echo "release-sha=$EXPECTED_SHA" + echo "release-verified=true" + } >> "$GITHUB_OUTPUT" + + select-release: + name: Select one independently verified release identity + needs: + - release-please + - verify-recovery + if: >- + always() && + github.run_attempt == 1 && + ( + ( + github.event_name == 'push' && + needs.release-please.result == 'success' && + needs.release-please.outputs.release-created == 'true' && + needs.release-please.outputs.release-verified == 'true' + ) || + ( + github.event_name == 'workflow_dispatch' && + needs.verify-recovery.result == 'success' + ) + ) + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + release-sha: ${{ steps.select.outputs.release-sha }} + release-tag: ${{ steps.select.outputs.release-tag }} + permissions: + contents: read + steps: + - name: Select the independently verified release identity + id: select + env: + EVENT_NAME: ${{ github.event_name }} + RECOVERY_SHA: ${{ needs.verify-recovery.outputs.release-sha }} + RECOVERY_TAG: ${{ needs.verify-recovery.outputs.release-tag }} + RELEASE_PLEASE_SHA: ${{ needs.release-please.outputs.release-sha }} + RELEASE_PLEASE_TAG: ${{ needs.release-please.outputs.release-tag }} + run: | + case "$EVENT_NAME" in + push) + release_sha=$RELEASE_PLEASE_SHA + release_tag=$RELEASE_PLEASE_TAG + ;; + workflow_dispatch) + release_sha=$RECOVERY_SHA + release_tag=$RECOVERY_TAG + ;; + *) + exit 1 + ;; + esac + test -n "$release_sha" + test -n "$release_tag" + { + echo "release-sha=$release_sha" + echo "release-tag=$release_tag" + } >> "$GITHUB_OUTPUT" + build: name: Verify and build the immutable default-branch release if: github.run_attempt == 1 + needs: + - select-release runs-on: ubuntu-latest timeout-minutes: 25 outputs: @@ -38,16 +219,16 @@ jobs: - name: Check out the published release tag uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: refs/tags/${{ inputs.release-tag }} + ref: refs/tags/${{ needs.select-release.outputs.release-tag }} fetch-depth: 0 persist-credentials: false - name: Reject an untrusted release target id: trust env: - DEFAULT_BRANCH: ${{ inputs.default-branch }} - EXPECTED_RELEASE_SHA: ${{ inputs.release-sha }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EXPECTED_RELEASE_SHA: ${{ needs.select-release.outputs.release-sha }} RELEASE_IMMUTABLE: "true" - RELEASE_TAG: ${{ inputs.release-tag }} + RELEASE_TAG: ${{ needs.select-release.outputs.release-tag }} run: bash scripts/verify_release_trust.sh - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -60,7 +241,7 @@ jobs: - name: Verify project, manifest, changelog, release docs, and tag agreement id: version env: - RELEASE_TAG: ${{ inputs.release-tag }} + RELEASE_TAG: ${{ needs.select-release.outputs.release-tag }} run: | version=$(uv run python scripts/check_version.py --tag "$RELEASE_TAG" --require-changelog --require-releasable-docs --print-version) echo "version=$version" >> "$GITHUB_OUTPUT" @@ -72,7 +253,7 @@ jobs: run: uv build - name: Verify artifact versions against the tag env: - RELEASE_TAG: ${{ inputs.release-tag }} + RELEASE_TAG: ${{ needs.select-release.outputs.release-tag }} run: uv run python scripts/check_version.py --tag "$RELEASE_TAG" --require-changelog --require-releasable-docs dist/* - name: Check package metadata rendering run: uv run twine check dist/* diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml deleted file mode 100644 index facb634..0000000 --- a/.github/workflows/release-please.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Release Please - -on: - push: - branches: - - main - -permissions: - contents: read - -concurrency: - group: release-please-${{ github.ref }} - cancel-in-progress: false - -jobs: - release-please: - name: Maintain the reviewed release PR and release - if: vars.RELEASE_PLEASE_ENABLED == 'true' - runs-on: ubuntu-latest - timeout-minutes: 15 - outputs: - release-created: ${{ steps.release.outputs.release_created }} - release-sha: ${{ steps.verify-release.outputs.release-sha }} - release-tag: ${{ steps.verify-release.outputs.release-tag }} - release-verified: ${{ steps.verify-release.outputs.release-verified }} - permissions: - contents: write - pull-requests: write - steps: - - name: Open or update the release PR, or create its approved release - id: release - uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1 - with: - config-file: release-please-config.json - manifest-file: .release-please-manifest.json - - name: Verify the immutable release created by Release Please - id: verify-release - if: steps.release.outputs.release_created == 'true' - env: - EXPECTED_SHA: ${{ steps.release.outputs.sha }} - EXPECTED_TAG: ${{ steps.release.outputs.tag_name }} - GH_TOKEN: ${{ github.token }} - run: | - test -n "$EXPECTED_TAG" - test -n "$EXPECTED_SHA" - release="" - for attempt in $(seq 1 12); do - release=$(gh api "repos/${{ github.repository }}/releases/tags/$EXPECTED_TAG") || true - if test -n "$release" && test "$(jq -r .immutable <<<"$release")" = "true"; then - break - fi - if test "$attempt" -ge 12; then - echo "release did not become immutable" >&2 - exit 1 - fi - sleep 5 - done - test "$(jq -r .tag_name <<<"$release")" = "$EXPECTED_TAG" - test "$(jq -r .draft <<<"$release")" = "false" - test "$(jq -r .prerelease <<<"$release")" = "false" - test "$(jq -r .immutable <<<"$release")" = "true" - ref=$(gh api "repos/${{ github.repository }}/git/ref/tags/$EXPECTED_TAG") - tag_type=$(jq -r .object.type <<<"$ref") - tag_sha=$(jq -r .object.sha <<<"$ref") - if test "$tag_type" = "tag"; then - tag_sha=$(gh api "repos/${{ github.repository }}/git/tags/$tag_sha" --jq .object.sha) - else - test "$tag_type" = "commit" - fi - test "$tag_sha" = "$EXPECTED_SHA" - { - echo "release-tag=$EXPECTED_TAG" - echo "release-sha=$EXPECTED_SHA" - echo "release-verified=true" - } >> "$GITHUB_OUTPUT" - - publish-release: - name: Run the protected publication chain - needs: release-please - if: >- - needs.release-please.outputs.release-created == 'true' && - needs.release-please.outputs.release-verified == 'true' - permissions: - contents: read - id-token: write - uses: ./.github/workflows/publish.yml - with: - release-tag: ${{ needs.release-please.outputs.release-tag }} - release-sha: ${{ needs.release-please.outputs.release-sha }} - default-branch: ${{ github.event.repository.default_branch }} - secrets: inherit diff --git a/.github/workflows/release-recovery.yml b/.github/workflows/release-recovery.yml deleted file mode 100644 index a080490..0000000 --- a/.github/workflows/release-recovery.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: Recover immutable release publication - -on: - workflow_dispatch: - inputs: - release-tag: - description: Exact immutable GitHub release tag - required: true - type: string - release-sha: - description: Exact commit resolved by the release tag - required: true - type: string - -permissions: - contents: read - -concurrency: - group: release-recovery - cancel-in-progress: false - -jobs: - verify-recovery: - name: Verify the authorized immutable release recovery - if: >- - github.run_attempt == 1 && - github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && - vars.RELEASE_RECOVERY_TAG == inputs.release-tag && - vars.RELEASE_RECOVERY_SHA == inputs.release-sha - runs-on: ubuntu-latest - timeout-minutes: 5 - outputs: - release-sha: ${{ steps.verify-release.outputs.release-sha }} - release-tag: ${{ steps.verify-release.outputs.release-tag }} - permissions: - contents: read - steps: - - name: Verify the immutable release selected for recovery - id: verify-release - env: - EXPECTED_SHA: ${{ inputs.release-sha }} - EXPECTED_TAG: ${{ inputs.release-tag }} - GH_TOKEN: ${{ github.token }} - run: | - test -n "$EXPECTED_TAG" - test -n "$EXPECTED_SHA" - release="" - for attempt in $(seq 1 12); do - release=$(gh api "repos/${{ github.repository }}/releases/tags/$EXPECTED_TAG") || true - if test -n "$release" && test "$(jq -r .immutable <<<"$release")" = "true"; then - break - fi - if test "$attempt" -ge 12; then - echo "release did not become immutable" >&2 - exit 1 - fi - sleep 5 - done - test "$(jq -r .tag_name <<<"$release")" = "$EXPECTED_TAG" - test "$(jq -r .draft <<<"$release")" = "false" - test "$(jq -r .prerelease <<<"$release")" = "false" - test "$(jq -r .immutable <<<"$release")" = "true" - ref=$(gh api "repos/${{ github.repository }}/git/ref/tags/$EXPECTED_TAG") - tag_type=$(jq -r .object.type <<<"$ref") - tag_sha=$(jq -r .object.sha <<<"$ref") - if test "$tag_type" = "tag"; then - tag_sha=$(gh api "repos/${{ github.repository }}/git/tags/$tag_sha" --jq .object.sha) - else - test "$tag_type" = "commit" - fi - test "$tag_sha" = "$EXPECTED_SHA" - { - echo "release-tag=$EXPECTED_TAG" - echo "release-sha=$EXPECTED_SHA" - echo "release-verified=true" - } >> "$GITHUB_OUTPUT" - - publish-release: - name: Run the protected publication recovery - needs: verify-recovery - if: github.run_attempt == 1 - permissions: - contents: read - id-token: write - uses: ./.github/workflows/publish.yml - with: - release-tag: ${{ needs.verify-recovery.outputs.release-tag }} - release-sha: ${{ needs.verify-recovery.outputs.release-sha }} - default-branch: ${{ github.event.repository.default_branch }} - secrets: inherit diff --git a/AGENTS.md b/AGENTS.md index 6d487c6..6a42357 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,6 +112,12 @@ Post-alpha invariants: Any later live request, tag, release, Trusted Publisher change, publication, or other registry mutation requires separate explicit maintainer authorization. +7. Keep `pypa/gh-action-pypi-publish` in the top-level `publish.yml` workflow + that PyPI records as the Trusted Publisher. Do not move publication into a + reusable workflow or call it from another workflow: PyPI requires an + attestation's Build Config URI to match the workflow identity used for the + Trusted Publisher exchange. The workflow inventory and semantic tests must + fail if this single-publisher boundary changes. ## Repository independence @@ -246,8 +252,9 @@ committed. and PyPI OIDC Trusted Publishing. - The release commit must equal the tag target and belong to the protected default branch. Release Please must independently confirm that the exact tag - and commit are immutable before directly calling the protected publication - workflow; do not rely on workflow-token release events to trigger it. A + and commit are immutable before the top-level publication workflow selects + them for downstream jobs; do not rely on workflow-token release events to + trigger it. A protected live-smoke job must check out that exact commit and succeed before the protected PyPI job can become eligible. - Scheduled/default-branch live smoke is monitoring evidence only and cannot @@ -256,12 +263,12 @@ committed. publisher configuration, or approval blocks publication; no conditional skip or mock may bypass it. - Arbitrary-branch publication is forbidden. Manual publication is permitted - only through the reviewed `release-recovery.yml` workflow from the protected - default branch, with `RELEASE_RECOVERY_TAG` and `RELEASE_RECOVERY_SHA` equal - to its exact inputs, after separately verifying the existing immutable tag - and commit. The recovery and reusable publication jobs must reject every - workflow rerun. Delete both variables immediately after the recovery succeeds - or stops. + only through the reviewed `workflow_dispatch` path in `publish.yml` from the + protected default branch, with `RELEASE_RECOVERY_TAG` and + `RELEASE_RECOVERY_SHA` equal to its exact inputs, after separately verifying + the existing immutable tag and commit. Recovery verification, release + selection, and all downstream release jobs must reject every workflow rerun. + Delete both variables immediately after the recovery succeeds or stops. - A successful build or upload is not a release. Registry installation, import, mocked-call smoke, and provenance must be verified separately. - Every distribution `Project-URL` must use HTTPS. The canonical Support URL @@ -277,13 +284,14 @@ committed. `last-release-sha` bridge because the recovery tag's build metadata could not be inferred from the manifest. The human-finalized stable release PR removed that bridge and its prerelease-versioning controls; keep them absent. -- Keep third-party Actions pinned to full commit SHAs. Every local caller of - the reusable publication workflow must use `secrets: inherit`; GitHub-hosted - runners otherwise can resolve its job-level environment secret as empty. - Keep the semantic workflow checker's inheritance regression coverage. Grant - `id-token: write` only to a reviewed reusable publication caller and the - protected publishing job; callers pass this maximum permission and only the - publishing job uses the OIDC token. +- Keep third-party Actions pinned to full commit SHAs. Keep release creation, + recovery, build, protected live smoke, OIDC publication, and registry + verification in the single top-level `publish.yml` workflow. Grant + `id-token: write` only to its protected publishing job, and keep + `COMETAPI_KEY` scoped only to the protected live credential preflight and + test. The semantic checker must reject reusable publication, split workflow + identities, additional OIDC consumers, and raw dispatch inputs downstream of + the verified release selector. - Keep README, roadmap, compatibility matrix, examples, and changelog aligned with shipped behavior. Use currently supported model IDs. - All repository documentation is written in English. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ceff43d..75d8384 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -134,18 +134,24 @@ protected exact-release live job. OIDC permission is exposed only to the protected publish job. Missing credentials, environments, approvals, or remote configuration block publication. -The protected publication chain is reusable, and every repository-local caller -must declare `secrets: inherit`. GitHub-hosted runners can otherwise bind the -called job to the `live-smoke` environment while silently resolving its -environment secret as empty. `scripts/check_workflows.py` rejects a caller that -omits inheritance and requires a credential preflight before any live request. -`release-recovery.yml` is the sole manual recovery path for an already-created -immutable release: it runs only from the protected default branch behind a -temporary tag-and-commit identity opt-in, independently verifies that exact -release identity, and then calls the same protected build, live, OIDC, -provenance, and registry chain. Both the recovery caller and reusable -publication jobs reject rerun attempts so an old authorization cannot be -replayed through GitHub's rerun controls. +The complete release chain has one top-level workflow identity: `publish.yml`. +It owns the gated Release Please push path and the sole manual recovery +dispatch, independently verifies either release identity, selects exactly one +successful path, and then runs the shared build, live, OIDC, provenance, and +registry jobs. The PyPI action executes directly in that file. This is a trust +boundary, not a refactoring preference: PyPI requires every uploaded +attestation's Build Config URI to match the Trusted Publisher workflow used for +the upload. Reusable publishing is unsupported by the +[PyPA action](https://github.com/pypa/gh-action-pypi-publish/issues/166), and +[Warehouse enforces the identity match](https://github.com/pypi/warehouse/issues/19814). + +The recovery dispatch runs only from the protected default branch behind a +temporary tag-and-commit identity opt-in. Release verification, selection, and +every downstream release job reject rerun attempts so an old authorization +cannot be replayed through GitHub's rerun controls. The protected live job +checks its credential before checkout or any request. `scripts/check_workflows.py` +rejects split or reusable publisher identities, unverified selector inputs, +additional OIDC consumers, and missing first-attempt guards. The initial alpha has one release-identity exception. GitHub's immutable release tombstone permanently reserves `v0.1.0-alpha.1`, so the reviewed diff --git a/CHANGELOG.md b/CHANGELOG.md index 63e6c03..45a409b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,11 @@ automation. ### Fixed -- Require environment-secret inheritance for every reusable publication caller - and add a fail-closed immutable-release recovery path. +- Add a fail-closed immutable-release recovery path with an environment-secret + preflight before any live request. +- Execute PyPI Trusted Publishing directly in the single top-level + `publish.yml` identity, and add regression gates that reject reusable + publication, split attestation identities, and unverified recovery inputs. ## [0.1.0] - 2026-07-28 diff --git a/RELEASING.md b/RELEASING.md index b42166a..51dd205 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -184,26 +184,26 @@ violations in one run and still returns non-zero when any violation exists. per generation, a 30-second request timeout, concurrency one, a ten-minute workflow timeout, and stop on the first failure. Every trigger requires `LIVE_SMOKE_ENABLED=true`. -- `release-please.yml` maintains a human-reviewed version and changelog pull - request from Conventional Commits after maintainers enable the - `RELEASE_PLEASE_ENABLED` repository variable. A reviewed one-time +- The `release-please` job in `publish.yml` maintains a human-reviewed version + and changelog pull request from Conventional Commits after maintainers enable + the `RELEASE_PLEASE_ENABLED` repository variable. A reviewed one-time `last-release-sha` bridge established the recovery release boundary and was removed during human finalization of the stable release PR. Keep the variable disabled except while executing an explicitly authorized release sequence. When it creates an approved release with the GitHub workflow token, it polls the GitHub API until that exact tag and commit are independently reported as - immutable, then invokes the protected publication chain directly; - workflow-token release events do not trigger a second workflow run. -- `release-recovery.yml` is the only manual publication path. It requires an - exact immutable tag and commit, the protected default branch, and the - temporary `RELEASE_RECOVERY_TAG` and `RELEASE_RECOVERY_SHA` identity opt-in - before it calls the same protected publication chain. Delete both variables - immediately after success or failure. The workflow and reusable publication - jobs reject every rerun attempt. -- `publish.yml` is called only with the independently verified immutable tag, - commit, and default branch. It resolves the tag to the checked-out commit, - fetches the protected default branch, and rejects a commit that is not - reachable from that branch. A protected + immutable, then selects it for the downstream chain in the same workflow; + workflow-token release events do not trigger a second workflow. +- The `verify-recovery` path in `publish.yml` is the only manual publication + path. It requires an exact immutable tag and commit, the protected default + branch, and the temporary `RELEASE_RECOVERY_TAG` and `RELEASE_RECOVERY_SHA` + identity opt-in before the shared release selector can continue. Delete both + variables immediately after success or failure. +- `publish.yml` is the single top-level release and Trusted Publisher identity. + It accepts only a protected `main` push or an exact manual recovery dispatch, + selects only an independently verified immutable tag and commit, resolves the + tag to the checked-out commit, fetches the protected default branch, and + rejects a commit that is not reachable from that branch. A protected `live-smoke` job then checks out that exact verified commit and must succeed before the protected `pypi` job becomes eligible. The workflow publishes the previously verified artifacts with OIDC, then checks the public package @@ -212,13 +212,13 @@ violations in one run and still returns non-zero when any violation exists. or empty live-model repository variable resolves to `gpt-5.4`. Third-party Actions are pinned to full commit SHAs. Workflow permissions are -read-only by default. The reusable publication caller and protected publishing -job declare `id-token: write`; the caller passes the maximum permission and -only the publishing job requests the OIDC token. Every repository-local caller -of `publish.yml` declares `secrets: inherit`; without it, GitHub-hosted runners -can silently resolve the called job's environment secret as empty. The semantic -workflow checker enforces inheritance and the live job checks the credential -before making a request. +read-only by default, and only the protected publishing job declares +`id-token: write`. The PyPI action must remain directly in `publish.yml`, the +workflow filename configured in the Trusted Publisher. PyPI validates uploaded +attestations against that workflow identity, so publishing from a reusable +workflow is unsupported and must fail static review. The semantic workflow +checker enforces the single top-level identity, exact selector bindings, and +OIDC scope; the live job checks the credential before making a request. Publishing uses a protected `pypi` environment and concurrency control. Arbitrary-branch publication is forbidden. Manual publication is limited to the reviewed immutable-release recovery described below. @@ -310,7 +310,7 @@ feature or fix pull request -> required release-PR CI, review, and merge -> immutable tag and GitHub release -> bounded API verification of immutable tag and commit identity - -> direct call to the protected publication workflow + -> same top-level workflow selects the independently verified release -> verify immutable tag commit and protected-default-branch ancestry -> rebuild and verify exact artifacts -> protected live smoke against that exact commit @@ -341,14 +341,15 @@ failed run. Before dispatch, verify that the exact PyPI version is absent, the release is immutable and non-draft, its tag resolves to the supplied commit, that commit is -reachable from protected `main`, and the repository-local caller uses -`secrets: inherit`. Then enable only the one-time recovery gate and dispatch the -workflow from `main` with the exact immutable identity: +reachable from protected `main`, and PyPI's Trusted Publisher names +`publish.yml`. Confirm that the PyPI action still executes directly in that +top-level workflow. Then enable only the one-time recovery gate and dispatch +the workflow from `main` with the exact immutable identity: ```bash gh variable set RELEASE_RECOVERY_TAG --body '' gh variable set RELEASE_RECOVERY_SHA --body '' -gh workflow run release-recovery.yml --ref main \ +gh workflow run publish.yml --ref main \ -f release-tag='' \ -f release-sha='' ``` @@ -366,5 +367,14 @@ gh variable delete RELEASE_RECOVERY_SHA A recovery failure stops the sequence. Diagnose and land a separate reviewed fix before requesting another explicit recovery authorization; do not rerun a failed job merely to obtain a different result. The workflow enforces this by -allowing only `github.run_attempt == 1` at both the recovery and publication -boundaries. +allowing only `github.run_attempt == 1` for verification, selection, build, +live smoke, publication, and registry verification. + +[Recovery run 30353657522](https://github.com/cometapi-dev/cometapi-python/actions/runs/30353657522) +passed immutable identity verification, the exact artifact rebuild, credential +preflight, four-request live suite, and protected `pypi` approval. PyPI then +rejected the upload before accepting any distribution because the reusable +caller produced an attestation Build Config URI for `release-recovery.yml` +while the Trusted Publisher expected `publish.yml`. The permanent correction +keeps attestations enabled and moves the PyPI action into the single top-level +`publish.yml`; it does not weaken or reconfigure the Trusted Publisher. diff --git a/ROADMAP.md b/ROADMAP.md index e285d2d..ab6b9c7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,9 +1,10 @@ # CometAPI Python SDK Roadmap -Status: `0.1.0a1` released -Last updated: 2026-07-27 +Status: `0.1.0a1` released; `0.1.0` recovery in progress +Last updated: 2026-07-28 Repository contract: this roadmap is self-contained. -Current gate: `0.1.0a1` Registry Alpha complete; `0.1.0` stable planned. +Current gate: land and remotely verify the single-workflow stable publisher, +then complete the explicitly authorized `0.1.0` recovery. ## Product target @@ -26,7 +27,7 @@ the evidence defined in this roadmap and `COMPATIBILITY.md`. | Private Remote Validation | Complete | The sanitized private repository passes real credential-free default-branch CI; public-only controls and live tests remain disabled. | | Public Preview | Complete | The public repository has blocking CI, repository rules, security reporting, protected environments, immutable releases, and authorized live-smoke evidence. | | `0.1.0a1` Registry Alpha | Complete | Early adopters can install the functional prerelease from PyPI; every release and registry gate passed. | -| `0.1.0` stable | Planned | Complete runtime, release-PR, example, provenance, and registry gates pass. | +| `0.1.0` stable | Recovery in progress | Complete runtime, release-PR, example, provenance, and registry gates pass. | | `0.2.0` provider-native text | Planned | Optional official Anthropic and Gemini adapters. | | `0.3.0` CometAPI resources | Planned | First schema-backed typed CometAPI-specific resource. | | Media and task APIs | Planned | Coherent task lifecycle precedes individual media helpers. | @@ -442,12 +443,25 @@ The first stable publication attempt created immutable release `v0.1.0` at exact artifact construction, but [stopped before any live request](https://github.com/cometapi-dev/cometapi-python/actions/runs/30348177128) because the reusable workflow caller omitted `secrets: inherit` and GitHub resolved the `live-smoke` environment secret as empty. PyPI publication and -registry verification were skipped. The permanent correction requires -inheritance on every publish caller, checks the credential before any request, -and provides a default-branch-only, explicitly enabled recovery of that exact -immutable identity through the unchanged protected publication chain. Stable -remains unreleased until the recovery live, OIDC, provenance, and registry gates -pass. +registry verification were skipped. [PR #21](https://github.com/cometapi-dev/cometapi-python/pull/21) +added secret inheritance, a credential preflight, exact recovery identity +gates, and rerun rejection. + +[Recovery run 30353657522](https://github.com/cometapi-dev/cometapi-python/actions/runs/30353657522) +then passed exact release verification, artifact construction, the credential +preflight, the bounded four-request live suite, and protected `pypi` approval. +PyPI rejected the upload with HTTP 400 before accepting either distribution: +the attestation certificate's Build Config URI named +`release-recovery.yml@refs/heads/main`, while the configured Trusted Publisher +expected `publish.yml`. This is a platform constraint: reusable workflows are +[unsupported by the PyPA publisher action](https://github.com/pypa/gh-action-pypi-publish/issues/166), +and [Warehouse requires the attestation identity to match the publisher](https://github.com/pypi/warehouse/issues/19814). +The permanent correction consolidates release creation, recovery, selection, +build, protected live smoke, direct PyPI publication, and registry verification +in the single top-level `publish.yml` identity. It keeps attestations and the +existing Trusted Publisher intact. Stable remains unreleased until this change +passes pull-request CI, reaches `main`, and a newly authorized recovery passes +OIDC, provenance, and registry gates. ## `0.2.0`: Provider-native text adapters @@ -470,21 +484,20 @@ separated under `resources/` and `types/` when this milestone begins. ## CI/CD contract -The repository maintains five independently auditable workflows: +The repository maintains three independently auditable workflows: - `ci.yml`: offline lint, type, unit, contract, build, artifact, and clean install checks for pull requests and default-branch pushes. - `live-smoke.yml`: scheduled and manual default-branch monitoring capped at four requests, 16 output tokens per generation, a 30-second request timeout, concurrency one, a ten-minute workflow timeout, and stop on first failure. -- `release-please.yml`: a human-reviewed version and changelog pull request, - followed by bounded API verification of the exact immutable release and a - direct call into the protected publication chain. -- `release-recovery.yml`: an explicitly enabled, protected-default-branch-only - recovery of an independently verified existing immutable release. -- `publish.yml`: reusable immutable-tag, commit, and default-branch ancestry - verification, exact-release protected live smoke, artifact rebuild and - verification, protected PyPI OIDC publication, provenance, and registry +- `publish.yml`: the single top-level release and PyPI Trusted Publisher + identity. Its gated push path maintains the human-reviewed Release Please PR + and independently verifies a created release. Its explicitly enabled manual + path recovers an independently verified existing immutable release only from + the protected default branch. An exact selector feeds both paths into tag, + commit, and default-branch ancestry verification, artifact rebuild, protected + live smoke, direct PyPI OIDC publication, provenance, and registry verification. All workflow files must pass local `actionlint` 1.7.12. This is static @@ -502,8 +515,10 @@ attempt a request with an empty model. Immutable-release recovery additionally requires `RELEASE_RECOVERY_TAG` and `RELEASE_RECOVERY_SHA` to equal the exact dispatch inputs; keep both variables absent except for one explicitly authorized identity and delete them immediately after success or failure. Recovery and -publication jobs reject rerun attempts. Every reusable publish caller must use -`secrets: inherit`. +publication jobs reject rerun attempts. The PyPI action must execute directly in +top-level `publish.yml`; workflow inventory, semantic checks, and mutation tests +must reject reusable publishing, split publisher identities, additional OIDC +consumers, or downstream use of raw dispatch inputs. ## Maintenance cadence diff --git a/SECURITY.md b/SECURITY.md index 86d1bed..4a95544 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -46,14 +46,14 @@ Long-lived PyPI tokens are not an accepted publication path. A successful upload is incomplete until provenance and a clean public-registry installation have been verified. -Third-party GitHub Actions must be pinned to full commit SHAs. The reusable -workflow callers and protected publishing job may declare `id-token: write`, -but only the publishing job may request the OIDC token. Repository-local -publication callers must use `secrets: inherit` so the called `live-smoke` -environment can resolve its scoped credential; the reusable workflow may -reference that credential only in its protected preflight and live-test steps. -Recovery and reusable publication jobs must reject workflow reruns so an old -authorization cannot be replayed. +Third-party GitHub Actions must be pinned to full commit SHAs. The PyPI action +must execute directly in the top-level `publish.yml` workflow configured as the +Trusted Publisher; a reusable or split publication workflow is forbidden +because its attestation identity can differ from the publisher identity. Only +the protected publishing job may declare `id-token: write`. The protected live +job may reference `COMETAPI_KEY` only in its credential preflight and live-test +steps. Recovery verification, release selection, and downstream publication +jobs must reject workflow reruns so an old authorization cannot be replayed. ## Scope diff --git a/scripts/check_secrets.py b/scripts/check_secrets.py index 1c09f8b..849ea0a 100644 --- a/scripts/check_secrets.py +++ b/scripts/check_secrets.py @@ -115,11 +115,7 @@ def scan_workflow_scope(root: Path) -> list[str]: findings.append( ".github/workflows/publish.yml: exactly one job must receive id-token: write" ) - allowed_id_token_counts = { - "publish.yml": 1, - "release-please.yml": 1, - "release-recovery.yml": 1, - } + allowed_id_token_counts = {"publish.yml": 1} for path in sorted( candidate for candidate in workflow_root.iterdir() diff --git a/scripts/check_workflows.py b/scripts/check_workflows.py index 029ade2..284ca7f 100644 --- a/scripts/check_workflows.py +++ b/scripts/check_workflows.py @@ -56,6 +56,54 @@ echo "release-verified=true" } >> "$GITHUB_OUTPUT" """ +PUBLISH_JOB_NAMES = { + "release-please", + "verify-recovery", + "select-release", + "build", + "release-live-smoke", + "publish", + "verify-registry", +} +RELEASE_PLEASE_JOB_CONDITION = ( + "github.run_attempt == 1 && github.event_name == 'push' && " + "vars.RELEASE_PLEASE_ENABLED == 'true'" +) +RECOVERY_JOB_CONDITION = ( + "github.run_attempt == 1 && github.event_name == 'workflow_dispatch' && " + "github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && " + "vars.RELEASE_RECOVERY_TAG == inputs.release-tag && " + "vars.RELEASE_RECOVERY_SHA == inputs.release-sha" +) +SELECT_RELEASE_CONDITION = ( + "always() && github.run_attempt == 1 && " + "( ( github.event_name == 'push' && needs.release-please.result == 'success' && " + "needs.release-please.outputs.release-created == 'true' && " + "needs.release-please.outputs.release-verified == 'true' ) || " + "( github.event_name == 'workflow_dispatch' && " + "needs.verify-recovery.result == 'success' ) )" +) +SELECT_RELEASE_COMMAND = """\ +case "$EVENT_NAME" in + push) + release_sha=$RELEASE_PLEASE_SHA + release_tag=$RELEASE_PLEASE_TAG + ;; + workflow_dispatch) + release_sha=$RECOVERY_SHA + release_tag=$RECOVERY_TAG + ;; + *) + exit 1 + ;; +esac +test -n "$release_sha" +test -n "$release_tag" +{ + echo "release-sha=$release_sha" + echo "release-tag=$release_tag" +} >> "$GITHUB_OUTPUT" +""" def _mapping(value: object, label: str) -> dict[str, object]: @@ -623,46 +671,71 @@ def check_ci_workflow(text: str) -> None: ) -def check_release_please_workflow(text: str) -> None: - """Require Release Please to remain explicitly disabled by default.""" - workflow = _load_workflow(text, "Release Please workflow") +def _check_publish_envelope(workflow: dict[str, object], source: str) -> None: + """Require one top-level workflow identity for release creation and publication.""" _require_exact_keys( workflow, - {"name", "on", "permissions", "concurrency", "jobs"}, - "Release Please workflow", - ) - _require_permissions(workflow, {"contents": "read"}, "Release Please workflow") - if "env" in workflow: - raise CheckError("Release Please workflow must not override the action environment") - triggers = _mapping(workflow.get("on"), "Release Please triggers") - if set(triggers) != {"push"}: - raise CheckError("Release Please must run only for default-branch pushes") - push = _mapping(triggers["push"], "Release Please push trigger") + {"name", "on", "permissions", "concurrency", "env", "jobs"}, + source, + ) + if workflow.get("name") != "Publish immutable release": + raise CheckError("publication must retain its canonical top-level workflow identity") + _require_permissions(workflow, {"contents": "read"}, source) + if "defaults" in workflow: + raise CheckError("publication workflow must not override command defaults") + triggers = _mapping(workflow.get("on"), f"{source} triggers") + if set(triggers) != {"push", "workflow_dispatch"}: + raise CheckError("publication must run only for main pushes or explicit recovery dispatch") + push = _mapping(triggers["push"], f"{source} push trigger") if set(push) != {"branches"}: - raise CheckError("Release Please push trigger must not use path or tag filters") + raise CheckError("publication push trigger must not use path or tag filters") branches = [ - _scalar(item, "Release Please push branch") - for item in _sequence(push.get("branches"), "Release Please push branches") + _scalar(item, "publication push branch") + for item in _sequence(push.get("branches"), "publication push branches") ] if branches != ["main"]: - raise CheckError("Release Please must run only for default-branch pushes") - concurrency = _mapping(workflow.get("concurrency"), "Release Please concurrency") - if concurrency != { - "group": "release-please-${{ github.ref }}", - "cancel-in-progress": "false", - }: - raise CheckError("Release Please must serialize updates per ref without cancellation") - jobs = _mapping(workflow.get("jobs"), "Release Please jobs") - if set(jobs) != {"release-please", "publish-release"}: - raise CheckError("Release Please must contain only its gated release and publish chain") + raise CheckError("publication push trigger must use only main") + dispatch = _mapping(triggers["workflow_dispatch"], f"{source} recovery dispatch") + _require_exact_keys(dispatch, {"inputs"}, "publication recovery dispatch") + inputs = _mapping(dispatch["inputs"], "publication recovery inputs") + expected_descriptions = { + "release-tag": "Exact immutable GitHub release tag", + "release-sha": "Exact commit resolved by the release tag", + } + if set(inputs) != set(expected_descriptions): + raise CheckError("publication recovery must accept only the exact release identity") + for name, description in expected_descriptions.items(): + if _mapping(inputs[name], f"publication recovery input {name}") != { + "description": description, + "required": "true", + "type": "string", + }: + raise CheckError(f"publication recovery input {name} must be an exact required string") + concurrency = _mapping(workflow.get("concurrency"), f"{source} concurrency") + if concurrency != {"group": "pypi-publish", "cancel-in-progress": "false"}: + raise CheckError("publication must serialize the complete release workflow") + if _mapping(workflow.get("env"), f"{source} environment") != {"UV_VERSION": "0.11.8"}: + raise CheckError("publication must retain its pinned uv frontend version") + jobs = _mapping(workflow.get("jobs"), f"{source} jobs") + if set(jobs) != PUBLISH_JOB_NAMES: + raise CheckError("publication jobs must match the reviewed top-level release chain") + + +def check_release_please_workflow(text: str) -> None: + """Require Release Please to remain explicitly disabled by default.""" + workflow = _load_workflow(text, "Release Please workflow") + _check_publish_envelope(workflow, "Release Please workflow") release_job = _workflow_job(workflow, "release-please", "Release Please workflow") _require_exact_keys( release_job, {"name", "if", "runs-on", "timeout-minutes", "outputs", "permissions", "steps"}, "Release Please job", ) - if release_job.get("if") != "vars.RELEASE_PLEASE_ENABLED == 'true'": - raise CheckError("Release Please must require RELEASE_PLEASE_ENABLED=true") + release_condition = " ".join(_scalar(release_job.get("if"), "Release Please condition").split()) + if release_condition != RELEASE_PLEASE_JOB_CONDITION: + raise CheckError( + "Release Please must require a first-attempt main push and RELEASE_PLEASE_ENABLED=true" + ) if release_job.get("runs-on") != "ubuntu-latest": raise CheckError("Release Please must use the reviewed GitHub-hosted runner") if release_job.get("timeout-minutes") != "15": @@ -742,87 +815,14 @@ def check_release_please_workflow(text: str) -> None: raise CheckError("Release Please must verify only the release it just created") if any(key in verify_step for key in ("continue-on-error", "shell", "working-directory")): raise CheckError("Release Please immutable-release verification must fail closed") - - publish_job = _workflow_job(workflow, "publish-release", "Release Please workflow") - _require_exact_keys( - publish_job, - {"name", "needs", "if", "permissions", "uses", "with", "secrets"}, - "Release Please publish caller", - ) - expected_publish_condition = ( - "needs.release-please.outputs.release-created == 'true' && " - "needs.release-please.outputs.release-verified == 'true'" - ) - if ( - publish_job["needs"] != "release-please" - or " ".join(_scalar(publish_job["if"], "Release Please publish condition").split()) - != expected_publish_condition - ): - raise CheckError("protected publication must require a newly verified release") - _require_permissions( - publish_job, - {"contents": "read", "id-token": "write"}, - "Release Please publish caller", - ) - if publish_job["uses"] != "./.github/workflows/publish.yml": - raise CheckError("Release Please must call the reviewed protected publish workflow") - if publish_job["secrets"] != "inherit": - raise CheckError( - "Release Please publish caller must inherit environment secrets for the " - "reusable workflow" - ) - if _mapping(publish_job["with"], "Release Please publish inputs") != { - "release-tag": "${{ needs.release-please.outputs.release-tag }}", - "release-sha": "${{ needs.release-please.outputs.release-sha }}", - "default-branch": "${{ github.event.repository.default_branch }}", - }: - raise CheckError("protected publication must receive only the verified release identity") - if _secret_references(workflow): - raise CheckError("Release Please must not depend on explicit repository credentials") + if _secret_references(release_job): + raise CheckError("Release Please must not depend on repository credentials") def check_release_recovery_workflow(text: str) -> None: """Require a default-branch-only, explicitly enabled immutable release recovery.""" workflow = _load_workflow(text, "release recovery workflow") - _require_exact_keys( - workflow, - {"name", "on", "permissions", "concurrency", "jobs"}, - "release recovery workflow", - ) - _require_permissions(workflow, {"contents": "read"}, "release recovery workflow") - if "env" in workflow or "defaults" in workflow: - raise CheckError("release recovery workflow must not override execution context") - - triggers = _mapping(workflow.get("on"), "release recovery triggers") - if set(triggers) != {"workflow_dispatch"}: - raise CheckError("release recovery must run only by explicit manual dispatch") - dispatch = _mapping(triggers["workflow_dispatch"], "release recovery dispatch") - _require_exact_keys(dispatch, {"inputs"}, "release recovery dispatch") - inputs = _mapping(dispatch["inputs"], "release recovery inputs") - if set(inputs) != {"release-tag", "release-sha"}: - raise CheckError("release recovery must accept only the exact release identity") - expected_descriptions = { - "release-tag": "Exact immutable GitHub release tag", - "release-sha": "Exact commit resolved by the release tag", - } - for name, description in expected_descriptions.items(): - if _mapping(inputs[name], f"release recovery input {name}") != { - "description": description, - "required": "true", - "type": "string", - }: - raise CheckError(f"release recovery input {name} must be an exact required string") - - concurrency = _mapping(workflow.get("concurrency"), "release recovery concurrency") - if concurrency != { - "group": "release-recovery", - "cancel-in-progress": "false", - }: - raise CheckError("release recovery must serialize all attempts without cancellation") - - jobs = _mapping(workflow.get("jobs"), "release recovery jobs") - if set(jobs) != {"verify-recovery", "publish-release"}: - raise CheckError("release recovery must contain only verification and publication") + _check_publish_envelope(workflow, "release recovery workflow") verify = _workflow_job(workflow, "verify-recovery", "release recovery workflow") _require_exact_keys( verify, @@ -837,18 +837,12 @@ def check_release_recovery_workflow(text: str) -> None: }, "release recovery verification job", ) - expected_condition = ( - "github.run_attempt == 1 && " - "github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && " - "vars.RELEASE_RECOVERY_TAG == inputs.release-tag && " - "vars.RELEASE_RECOVERY_SHA == inputs.release-sha" - ) if " ".join(_scalar(verify["if"], "release recovery condition").split()) != ( - expected_condition + RECOVERY_JOB_CONDITION ): raise CheckError( - "release recovery must require the first workflow attempt, protected default " - "branch, and exact authorized release tag and commit" + "release recovery must require an explicit first-attempt dispatch from the " + "protected default branch and the exact authorized release tag and commit" ) if verify["runs-on"] != "ubuntu-latest" or verify["timeout-minutes"] != "5": raise CheckError("release recovery verification must use the reviewed bounded runner") @@ -885,36 +879,61 @@ def check_release_recovery_workflow(text: str) -> None: if verify_step.get("id") != "verify-release": raise CheckError("release recovery verification must expose its exact outputs") - publish = _workflow_job(workflow, "publish-release", "release recovery workflow") + selector = _workflow_job(workflow, "select-release", "release recovery workflow") _require_exact_keys( - publish, - {"name", "needs", "if", "permissions", "uses", "with", "secrets"}, - "release recovery publish caller", + selector, + {"name", "needs", "if", "runs-on", "timeout-minutes", "outputs", "permissions", "steps"}, + "release identity selector", ) - if publish["needs"] != "verify-recovery": - raise CheckError("release recovery publication must depend on immutable verification") - if publish["if"] != "github.run_attempt == 1": - raise CheckError("release recovery publication must run only on the first workflow attempt") - _require_permissions( - publish, - {"contents": "read", "id-token": "write"}, - "release recovery publish caller", + _require_needs( + selector, + ["release-please", "verify-recovery"], + "release identity selector", + ) + selector_condition = " ".join( + _scalar(selector["if"], "release identity selector condition").split() ) - if publish["uses"] != "./.github/workflows/publish.yml": - raise CheckError("release recovery must call the reviewed protected publish workflow") - if publish["secrets"] != "inherit": + if selector_condition != SELECT_RELEASE_CONDITION: raise CheckError( - "release recovery publish caller must inherit environment secrets for the " - "reusable workflow" + "release identity selector must accept only one successfully verified path" ) - if _mapping(publish["with"], "release recovery publish inputs") != { - "release-tag": "${{ needs.verify-recovery.outputs.release-tag }}", - "release-sha": "${{ needs.verify-recovery.outputs.release-sha }}", - "default-branch": "${{ github.event.repository.default_branch }}", + if selector["runs-on"] != "ubuntu-latest" or selector["timeout-minutes"] != "5": + raise CheckError("release identity selector must use the reviewed bounded runner") + _require_permissions(selector, {"contents": "read"}, "release identity selector") + if _mapping(selector["outputs"], "release identity selector outputs") != { + "release-sha": "${{ steps.select.outputs.release-sha }}", + "release-tag": "${{ steps.select.outputs.release-tag }}", }: - raise CheckError("release recovery publication must use only verified outputs") - if _secret_references(workflow): - raise CheckError("release recovery must not reference explicit repository credentials") + raise CheckError("release identity selector must expose only the selected tag and commit") + _require_step_names( + selector, + ["Select the independently verified release identity"], + "release identity selector", + ) + _require_step_environments( + selector, + { + "Select the independently verified release identity": { + "EVENT_NAME": "${{ github.event_name }}", + "RECOVERY_SHA": "${{ needs.verify-recovery.outputs.release-sha }}", + "RECOVERY_TAG": "${{ needs.verify-recovery.outputs.release-tag }}", + "RELEASE_PLEASE_SHA": "${{ needs.release-please.outputs.release-sha }}", + "RELEASE_PLEASE_TAG": "${{ needs.release-please.outputs.release-tag }}", + } + }, + "release identity selector", + ) + _require_step_working_directories(selector, {}, "release identity selector") + _, selector_step = _named_run_step( + selector, + "Select the independently verified release identity", + SELECT_RELEASE_COMMAND, + "release identity selector", + ) + if selector_step.get("id") != "select": + raise CheckError("release identity selector must expose its exact selected outputs") + if _secret_references(verify) or _secret_references(selector): + raise CheckError("release identity verification must not reference credentials") def check_release_please_config(text: str, manifest_text: str) -> None: @@ -997,39 +1016,15 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None: """Validate fail-closed publication, live, permission, and evidence ordering.""" workflow = _load_workflow(text, "publish workflow") live_workflow = _load_workflow(live_smoke_text, "live-smoke workflow") - _require_exact_keys( - workflow, - {"name", "on", "permissions", "concurrency", "env", "jobs"}, - "publish workflow", - ) + _check_publish_envelope(workflow, "publish workflow") + check_release_please_workflow(text) + check_release_recovery_workflow(text) _require_exact_keys( live_workflow, {"name", "on", "permissions", "concurrency", "env", "jobs"}, "live-smoke workflow", ) - publish_triggers = _mapping(workflow.get("on"), "publish workflow triggers") - if set(publish_triggers) != {"workflow_call"}: - raise CheckError("publication must run only through the verified reusable workflow call") - workflow_call = _mapping(publish_triggers["workflow_call"], "publish workflow call") - if set(workflow_call) != {"inputs"}: - raise CheckError("publication workflow call must accept only reviewed release inputs") - call_inputs = _mapping(workflow_call["inputs"], "publish workflow call inputs") - if set(call_inputs) != {"release-tag", "release-sha", "default-branch"}: - raise CheckError("publication workflow call inputs must match the verified identity") - for name in ("release-tag", "release-sha", "default-branch"): - if _mapping(call_inputs[name], f"publish workflow input {name}") != { - "required": "true", - "type": "string", - }: - raise CheckError(f"publication workflow input {name} must be a required string") - _require_permissions(workflow, {"contents": "read"}, "publish workflow") - concurrency = _mapping(workflow.get("concurrency"), "publish workflow concurrency") - if concurrency != {"group": "pypi-publish", "cancel-in-progress": "false"}: - raise CheckError("publication must serialize all releases without cancellation") - if _mapping(workflow.get("env"), "publish workflow environment") != {"UV_VERSION": "0.11.8"}: - raise CheckError("publish workflow must retain its pinned uv frontend version") - live_triggers = _mapping(live_workflow.get("on"), "live-smoke triggers") if set(live_triggers) != {"schedule", "workflow_dispatch"}: raise CheckError("monitoring live smoke must run only on schedule or manual dispatch") @@ -1058,20 +1053,25 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None: }: raise CheckError("monitoring live smoke must retain its bounded execution budget") - first_attempt_guards = 0 + conditions: list[str] = [] for mapping in _walk_mappings(workflow, "publish workflow"): if "if" in mapping: - if mapping["if"] != "github.run_attempt == 1": - raise CheckError( - "release gates may be conditional only on the first workflow attempt" - ) - first_attempt_guards += 1 + conditions.append(" ".join(_scalar(mapping["if"], "release condition").split())) if "continue-on-error" in mapping: raise CheckError("release gates must not be allowed to continue on error") if "defaults" in mapping or "shell" in mapping: raise CheckError("release gates must not override command execution") - if first_attempt_guards != 4: - raise CheckError("every release job must reject workflow reruns") + expected_conditions = [ + RELEASE_PLEASE_JOB_CONDITION, + "steps.release.outputs.release_created == 'true'", + RECOVERY_JOB_CONDITION, + SELECT_RELEASE_CONDITION, + *(["github.run_attempt == 1"] * 4), + ] + if sorted(conditions) != sorted(expected_conditions): + raise CheckError( + "publication must retain only the reviewed first-attempt release conditions" + ) monitoring_job = _workflow_job(live_workflow, "smoke", "live-smoke workflow") _require_exact_keys( @@ -1175,7 +1175,7 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None: raise CheckError("monitoring live smoke must use only its scoped COMETAPI_KEY") jobs = _mapping(workflow.get("jobs"), "publish workflow jobs") - if set(jobs) != {"build", "release-live-smoke", "publish", "verify-registry"}: + if set(jobs) != PUBLISH_JOB_NAMES: raise CheckError("publish workflow jobs must match the reviewed release chain") build = _workflow_job(workflow, "build", "publish workflow") release_live = _workflow_job(workflow, "release-live-smoke", "publish workflow") @@ -1185,6 +1185,7 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None: "build": { "name", "if", + "needs", "runs-on", "timeout-minutes", "outputs", @@ -1252,6 +1253,7 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None: raise CheckError(f"release {name} job must not override the workflow environment") _require_permissions(build, {"contents": "read"}, "release build job") + _require_needs(build, ["select-release"], "release build job") outputs = _mapping(build.get("outputs"), "release build outputs") if outputs != { "release-commit": "${{ steps.trust.outputs.release-commit }}", @@ -1283,16 +1285,16 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None: build, { "Reject an untrusted release target": { - "DEFAULT_BRANCH": "${{ inputs.default-branch }}", - "EXPECTED_RELEASE_SHA": "${{ inputs.release-sha }}", + "DEFAULT_BRANCH": "${{ github.event.repository.default_branch }}", + "EXPECTED_RELEASE_SHA": "${{ needs.select-release.outputs.release-sha }}", "RELEASE_IMMUTABLE": "true", - "RELEASE_TAG": "${{ inputs.release-tag }}", + "RELEASE_TAG": "${{ needs.select-release.outputs.release-tag }}", }, "Verify project, manifest, changelog, release docs, and tag agreement": { - "RELEASE_TAG": "${{ inputs.release-tag }}" + "RELEASE_TAG": "${{ needs.select-release.outputs.release-tag }}" }, "Verify artifact versions against the tag": { - "RELEASE_TAG": "${{ inputs.release-tag }}" + "RELEASE_TAG": "${{ needs.select-release.outputs.release-tag }}" }, }, "release build job", @@ -1306,7 +1308,7 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None: _require_options( build_checkout, { - "ref": "refs/tags/${{ inputs.release-tag }}", + "ref": "refs/tags/${{ needs.select-release.outputs.release-tag }}", "fetch-depth": "0", "persist-credentials": "false", }, @@ -1322,10 +1324,10 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None: raise CheckError("release trust step must expose the trust output id") trust_environment = _mapping(trust_step.get("env"), "release trust environment") if trust_environment != { - "DEFAULT_BRANCH": "${{ inputs.default-branch }}", - "EXPECTED_RELEASE_SHA": "${{ inputs.release-sha }}", + "DEFAULT_BRANCH": "${{ github.event.repository.default_branch }}", + "EXPECTED_RELEASE_SHA": "${{ needs.select-release.outputs.release-sha }}", "RELEASE_IMMUTABLE": "true", - "RELEASE_TAG": "${{ inputs.release-tag }}", + "RELEASE_TAG": "${{ needs.select-release.outputs.release-tag }}", }: raise CheckError("release trust step must receive only the immutable release identity") _, build_setup = _named_action_step( @@ -1365,7 +1367,7 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None: ) if version_step.get("id") != "version" or _mapping( version_step.get("env"), "release version environment" - ) != {"RELEASE_TAG": "${{ inputs.release-tag }}"}: + ) != {"RELEASE_TAG": "${{ needs.select-release.outputs.release-tag }}"}: raise CheckError("release version step must expose the verified tag-derived version") _, artifact_upload = _named_action_step( build, @@ -1718,8 +1720,6 @@ def check_workflow_inventory(directory: Path) -> list[Path]: "ci.yml", "live-smoke.yml", "publish.yml", - "release-please.yml", - "release-recovery.yml", } actual = {path.name for path in paths} if actual != expected: @@ -1747,16 +1747,6 @@ def main() -> int: type=Path, default=PROJECT_ROOT / ".github" / "workflows" / "live-smoke.yml", ) - parser.add_argument( - "--release-please-workflow", - type=Path, - default=PROJECT_ROOT / ".github" / "workflows" / "release-please.yml", - ) - parser.add_argument( - "--release-recovery-workflow", - type=Path, - default=PROJECT_ROOT / ".github" / "workflows" / "release-recovery.yml", - ) parser.add_argument( "--ci-workflow", type=Path, @@ -1774,12 +1764,11 @@ def main() -> int: ) args = parser.parse_args() paths = check_workflow_inventory(args.ci_workflow.parent) + publish_text = args.publish_workflow.read_text(encoding="utf-8") check_publish_workflow( - args.publish_workflow.read_text(encoding="utf-8"), + publish_text, args.live_smoke_workflow.read_text(encoding="utf-8"), ) - check_release_please_workflow(args.release_please_workflow.read_text(encoding="utf-8")) - check_release_recovery_workflow(args.release_recovery_workflow.read_text(encoding="utf-8")) check_release_please_config( args.release_please_config.read_text(encoding="utf-8"), args.release_please_manifest.read_text(encoding="utf-8"), diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index b33eeab..5dc4300 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -25,8 +25,8 @@ CI_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "ci.yml" PUBLISH_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "publish.yml" LIVE_SMOKE_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "live-smoke.yml" -RELEASE_PLEASE_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "release-please.yml" -RELEASE_RECOVERY_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "release-recovery.yml" +RELEASE_PLEASE_WORKFLOW = PUBLISH_WORKFLOW +RELEASE_RECOVERY_WORKFLOW = PUBLISH_WORKFLOW RELEASE_PLEASE_CONFIG = PROJECT_ROOT / "release-please-config.json" RELEASE_PLEASE_MANIFEST = PROJECT_ROOT / ".release-please-manifest.json" TRUST_SCRIPT = PROJECT_ROOT / "scripts" / "verify_release_trust.sh" @@ -313,6 +313,28 @@ def _allow_publication_rerun(text: str) -> str: return text.replace(" if: github.run_attempt == 1\n", " if: always()\n", 1) +def _remove_release_selector_dependency(text: str) -> str: + build_start = text.index(" build:") + return text[:build_start] + text[build_start:].replace( + " needs:\n - select-release\n", + "", + 1, + ) + + +def _use_unverified_release_input(text: str) -> str: + build_start = text.index(" build:") + return text[:build_start] + text[build_start:].replace( + "needs.select-release.outputs.release-tag", + "inputs.release-tag", + 1, + ) + + +def _disable_publish_attestations(text: str) -> str: + return text.replace(" attestations: true\n", " attestations: false\n", 1) + + PUBLICATION_BYPASSES: list[Callable[[str], str]] = [ _bypass_immutable_event, _remove_live_dependency, @@ -340,6 +362,9 @@ def _allow_publication_rerun(text: str) -> str: _remove_live_model_fallback, _remove_live_credential_preflight, _allow_publication_rerun, + _remove_release_selector_dependency, + _use_unverified_release_input, + _disable_publish_attestations, ] @@ -451,6 +476,9 @@ def test_workflow_contract_rejects_unpinned_docker_action() -> None: "empty-live-model-bypass", "missing-live-credential-preflight", "publication-rerun-bypass", + "release-selector-dependency-bypass", + "unverified-release-input-bypass", + "disabled-publish-attestations-bypass", ], ) def test_semantic_contract_rejects_publication_bypasses( @@ -782,9 +810,9 @@ def test_current_release_recovery_workflow_is_disabled_by_default() -> None: ("needle", "replacement", "message"), [ ( - "github.run_attempt == 1 &&", - "github.run_attempt >= 1 &&", - "first workflow attempt", + "github.run_attempt == 1 &&\n github.event_name == 'workflow_dispatch'", + "github.run_attempt >= 1 &&\n github.event_name == 'workflow_dispatch'", + "first-attempt", ), ( "github.ref == format('refs/heads/{0}', github.event.repository.default_branch)", @@ -802,19 +830,9 @@ def test_current_release_recovery_workflow_is_disabled_by_default() -> None: "exact authorized release tag and commit", ), ( - " if: github.run_attempt == 1\n permissions:", - " if: always()\n permissions:", - "first workflow attempt", - ), - ( - "release-tag: ${{ needs.verify-recovery.outputs.release-tag }}", - "release-tag: ${{ inputs.release-tag }}", - "verified outputs", - ), - ( - "secrets: inherit", - "secrets:\n COMETAPI_KEY: ${{ secrets.COMETAPI_KEY }}", - "inherit environment secrets", + "needs.verify-recovery.result == 'success'", + "inputs.release-tag != ''", + "successfully verified path", ), ( 'test "$(jq -r .immutable <<<"$release")" = "true"', @@ -827,9 +845,7 @@ def test_current_release_recovery_workflow_is_disabled_by_default() -> None: "arbitrary-branch", "unbound-tag", "unbound-sha", - "rerun-publication", - "unverified-input", - "explicit-secret", + "unverified-selector-path", "immutable-release-bypass", ], ) @@ -837,21 +853,69 @@ def test_release_recovery_rejects_trust_bypasses( needle: str, replacement: str, message: str ) -> None: text = RELEASE_RECOVERY_WORKFLOW.read_text(encoding="utf-8") - assert needle in text + recovery_start = text.index(" verify-recovery:") + recovery = text[recovery_start:] + assert needle in recovery with pytest.raises(RuntimeError, match=message): - check_release_recovery_workflow(text.replace(needle, replacement, 1)) + check_release_recovery_workflow( + text[:recovery_start] + recovery.replace(needle, replacement, 1) + ) -def test_release_please_publish_caller_requires_secret_inheritance() -> None: - text = RELEASE_PLEASE_WORKFLOW.read_text(encoding="utf-8").replace( - " secrets: inherit\n", - " secrets:\n COMETAPI_KEY: ${{ secrets.COMETAPI_KEY }}\n", +def test_publisher_rejects_reusable_workflow_call_identity() -> None: + text = PUBLISH_WORKFLOW.read_text(encoding="utf-8").replace( + "on:\n push:\n", + "on:\n workflow_call:\n push:\n", 1, ) - with pytest.raises(RuntimeError, match="inherit environment secrets"): + with pytest.raises(RuntimeError, match="main pushes or explicit recovery dispatch"): check_release_please_workflow(text) +@pytest.mark.parametrize( + ("needle", "replacement", "message"), + [ + ( + "always() &&\n github.run_attempt == 1", + "github.run_attempt == 1", + "successfully verified path", + ), + ( + " - release-please\n - verify-recovery", + " - verify-recovery", + "must depend on", + ), + ( + "RECOVERY_SHA: ${{ needs.verify-recovery.outputs.release-sha }}", + "RECOVERY_SHA: ${{ inputs.release-sha }}", + "environment", + ), + ( + "RELEASE_PLEASE_TAG: ${{ needs.release-please.outputs.release-tag }}", + "RELEASE_PLEASE_TAG: ${{ needs.verify-recovery.outputs.release-tag }}", + "environment", + ), + ], + ids=[ + "missing-always", + "missing-release-please-dependency", + "raw-recovery-input", + "cross-bound-release-please-tag", + ], +) +def test_release_selector_rejects_identity_bypasses( + needle: str, replacement: str, message: str +) -> None: + text = PUBLISH_WORKFLOW.read_text(encoding="utf-8") + selector_start = text.index(" select-release:") + build_start = text.index(" build:") + selector = text[selector_start:build_start] + assert needle in selector + mutated = selector.replace(needle, replacement, 1) + with pytest.raises(RuntimeError, match=message): + check_release_recovery_workflow(text[:selector_start] + mutated + text[build_start:]) + + def test_current_release_please_config_has_reviewed_stable_cleanup() -> None: check_release_please_config( RELEASE_PLEASE_CONFIG.read_text(encoding="utf-8"), @@ -944,13 +1008,13 @@ def test_release_please_config_rejects_bridge_cleanup_before_stable() -> None: @pytest.mark.parametrize( "replacement", [ - "if: vars.RELEASE_PLEASE_ENABLED != 'false'", - "if: github.ref == 'refs/heads/main'", + "vars.RELEASE_PLEASE_ENABLED != 'false'", + "github.ref == 'refs/heads/main'", ], ) def test_release_please_requires_exact_enable_opt_in(replacement: str) -> None: text = RELEASE_PLEASE_WORKFLOW.read_text(encoding="utf-8").replace( - "if: vars.RELEASE_PLEASE_ENABLED == 'true'", + "vars.RELEASE_PLEASE_ENABLED == 'true'", replacement, 1, ) @@ -960,8 +1024,8 @@ def test_release_please_requires_exact_enable_opt_in(replacement: str) -> None: def test_release_please_checks_opt_in_on_real_job() -> None: text = RELEASE_PLEASE_WORKFLOW.read_text(encoding="utf-8").replace( - "if: vars.RELEASE_PLEASE_ENABLED == 'true'", - "if: github.ref == 'refs/heads/main'", + "vars.RELEASE_PLEASE_ENABLED == 'true'", + "github.ref == 'refs/heads/main'", 1, ) text = text.replace( @@ -986,22 +1050,22 @@ def test_release_please_rejects_an_additional_ungated_job() -> None: - run: echo bypass """ ) - with pytest.raises(RuntimeError, match="only its gated"): + with pytest.raises(RuntimeError, match="reviewed top-level release chain"): check_release_please_workflow(text) def test_release_please_rejects_trigger_text_hidden_in_name() -> None: text = RELEASE_PLEASE_WORKFLOW.read_text(encoding="utf-8").replace( - "name: Release Please", + "name: Publish immutable release", "name: |\n push:\n branches:\n - main", 1, ) text = text.replace( "on:\n push:\n branches:\n - main", - 'on:\n schedule:\n - cron: "0 0 * * *"', + 'on:\n schedule:\n - cron: "0 0 * * *"\n workflow_dispatch:', 1, ) - with pytest.raises(RuntimeError, match="default-branch pushes"): + with pytest.raises(RuntimeError, match="canonical top-level workflow identity"): check_release_please_workflow(text) @@ -1354,16 +1418,12 @@ def test_workflow_inventory_rejects_unreviewed_workflow(tmp_path: Path) -> None: "ci.yml", "live-smoke.yml", "publish.yml", - "release-please.yml", - "release-recovery.yml", ): (tmp_path / name).write_text("name: reviewed\n", encoding="utf-8") assert {path.name for path in check_workflow_inventory(tmp_path)} == { "ci.yml", "live-smoke.yml", "publish.yml", - "release-please.yml", - "release-recovery.yml", } (tmp_path / "rogue.yaml").write_text( @@ -1380,8 +1440,6 @@ def test_workflow_inventory_rejects_expected_name_symlink(tmp_path: Path) -> Non for name in ( "live-smoke.yml", "publish.yml", - "release-please.yml", - "release-recovery.yml", ): (workflow_root / name).write_text("name: reviewed\n", encoding="utf-8") outside = tmp_path / "outside-ci.yml" @@ -1401,8 +1459,6 @@ def test_workflow_inventory_rejects_linked_directory(tmp_path: Path, linked_comp "ci.yml", "live-smoke.yml", "publish.yml", - "release-please.yml", - "release-recovery.yml", ): (outside_workflows / name).write_text("name: outside\n", encoding="utf-8") diff --git a/tests/test_secrets.py b/tests/test_secrets.py index 516964b..13bdbf5 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -14,7 +14,7 @@ def test_current_workflows_retain_reviewed_oidc_scope() -> None: def test_scope_scan_rejects_oidc_on_an_unreviewed_workflow(tmp_path: Path) -> None: workflows = tmp_path / ".github" / "workflows" workflows.mkdir(parents=True) - for name in ("ci.yml", "publish.yml", "release-please.yml"): + for name in ("ci.yml", "publish.yml"): (workflows / name).write_text( (PROJECT_ROOT / ".github" / "workflows" / name).read_text(encoding="utf-8"), encoding="utf-8", @@ -30,16 +30,17 @@ def test_scope_scan_rejects_oidc_on_an_unreviewed_workflow(tmp_path: Path) -> No ] -def test_scope_scan_rejects_missing_reusable_caller_oidc(tmp_path: Path) -> None: +def test_scope_scan_rejects_missing_top_level_publisher_oidc(tmp_path: Path) -> None: workflows = tmp_path / ".github" / "workflows" workflows.mkdir(parents=True) - for name in ("ci.yml", "publish.yml", "release-please.yml"): + for name in ("ci.yml", "publish.yml"): text = (PROJECT_ROOT / ".github" / "workflows" / name).read_text(encoding="utf-8") - if name == "release-please.yml": + if name == "publish.yml": text = text.replace(" id-token: write\n", "", 1) (workflows / name).write_text(text, encoding="utf-8") assert scan_workflow_scope(tmp_path) == [ - ".github/workflows/release-please.yml: id-token: write must match the reviewed " - "publication chain count (1)" + ".github/workflows/publish.yml: exactly one job must receive id-token: write", + ".github/workflows/publish.yml: id-token: write must match the reviewed " + "publication chain count (1)", ]