diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index eb9932b..80a41bc 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -20,9 +20,12 @@ name: Publish to PyPI # on PyPI is the one that was verified, not merely that upload didn't error. on: - release: - types: [published] workflow_dispatch: + inputs: + tag: + description: "Existing signed stable SemVer tag (vX.Y.Z)" + required: true + type: string # Least-privilege default; the publish job escalates only what it needs. permissions: @@ -30,25 +33,67 @@ permissions: # Never run two publishes of the same ref at once (Section 6.4). concurrency: - group: pypi-publish-${{ github.ref }} + group: pypi-publish cancel-in-progress: false jobs: + authorize: + name: verify trusted main and signed release tag + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + release_commit: ${{ steps.tag.outputs.commit }} + release_tag: ${{ steps.tag.outputs.tag }} + tag_object_sha: ${{ steps.tag.outputs.tag_object_sha }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + - name: Resolve and verify the reviewed signed tag + id: tag + env: + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + TAG="${INPUT_TAG}" + test "${GITHUB_REF}" = refs/heads/main + test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + test "$(git rev-parse origin/main)" = "${GITHUB_SHA}" + [[ "${TAG}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}" + test "$(git cat-file -t "refs/tags/${TAG}")" = tag + RELEASE_COMMIT="$(git rev-parse --verify "${TAG}^{commit}")" + git merge-base --is-ancestor "${RELEASE_COMMIT}" origin/main + git config gpg.format ssh + git config gpg.ssh.allowedSignersFile "${GITHUB_WORKSPACE}/.github/allowed_signers" + git verify-tag -- "${TAG}" + { + echo "tag=${TAG}" + echo "commit=${RELEASE_COMMIT}" + echo "tag_object_sha=$(git rev-parse "refs/tags/${TAG}")" + } >> "${GITHUB_OUTPUT}" + verify: + needs: authorize uses: ./.github/workflows/verify.yml with: - tag: ${{ github.event_name == 'release' && github.event.release.tag_name || '' }} + tag: ${{ needs.authorize.outputs.release_tag }} + commit: ${{ needs.authorize.outputs.release_commit }} - publish: - needs: [verify] + build: + needs: [authorize, verify] runs-on: ubuntu-latest permissions: - contents: write # attach the SBOM to the GitHub release - id-token: write # OIDC for PyPI Trusted Publishing + provenance + contents: read + id-token: write attestations: write # SLSA build provenance attestation steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: + ref: ${{ needs.authorize.outputs.release_commit }} persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: @@ -79,6 +124,12 @@ jobs: name: sbom-cyclonedx path: tods-validate-sbom.cdx.json if-no-files-found: error + - name: Upload verified distributions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pypi-distributions + path: dist/* + if-no-files-found: error # SLSA build provenance for the wheel + sdist (Section 6.4, SLSA Build L2). - name: Attest build provenance @@ -86,31 +137,61 @@ jobs: with: subject-path: "dist/*" - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # release/v1 + publish-release: + needs: [authorize, build] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: sbom-cyclonedx + - name: Recheck immutable tag object and publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.authorize.outputs.release_tag }} + TAG_OBJECT_SHA: ${{ needs.authorize.outputs.tag_object_sha }} + run: | + set -euo pipefail + LIVE_TAG_OBJECT="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq .object.sha)" + test "${LIVE_TAG_OBJECT}" = "${TAG_OBJECT_SHA}" + gh release create "${TAG}" tods-validate-sbom.cdx.json \ + --title "tods-validate ${TAG}" --generate-notes --verify-tag - # Attach the SBOM to the GitHub release for consumer verification. - - name: Upload SBOM to the release - if: github.event_name == 'release' + publish-pypi: + needs: [authorize, build, publish-release] + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: pypi-distributions + path: dist + - name: Recheck immutable tag object before PyPI publication env: GH_TOKEN: ${{ github.token }} - # Routed through env: rather than interpolated into the run: shell - # (CICD-21): see the note in release-corpus.yml. - TAG: ${{ github.event.release.tag_name }} - run: gh release upload "$TAG" tods-validate-sbom.cdx.json --clobber + TAG: ${{ needs.authorize.outputs.release_tag }} + TAG_OBJECT_SHA: ${{ needs.authorize.outputs.tag_object_sha }} + run: | + set -euo pipefail + LIVE_TAG_OBJECT="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq .object.sha)" + test "${LIVE_TAG_OBJECT}" = "${TAG_OBJECT_SHA}" + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # release/v1 verify-published: # Re-fetch what actually landed on PyPI and check its attestation, rather # than trusting that `publish` exiting 0 means the right bits are public # (REL-16). Only meaningful for a real release, not workflow_dispatch. - needs: [publish] - if: github.event_name == 'release' + needs: [authorize, publish-pypi] runs-on: ubuntu-latest permissions: contents: read env: GH_TOKEN: ${{ github.token }} - TAG: ${{ github.event.release.tag_name }} + TAG: ${{ needs.authorize.outputs.release_tag }} steps: - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: diff --git a/.github/workflows/release-corpus.yml b/.github/workflows/release-corpus.yml index 1a91af7..338964e 100644 --- a/.github/workflows/release-corpus.yml +++ b/.github/workflows/release-corpus.yml @@ -7,7 +7,18 @@ name: Conformance corpus on: release: types: [published] + # Releases are now created by pypi-publish.yml with the workflow's own + # GITHUB_TOKEN, and events raised by that token do not trigger other + # workflows — so the corpus upload for a new release arrives by dispatching + # this workflow with the tag (same pattern as docker.yml). An empty tag + # builds the corpus without uploading, for a quick check. workflow_dispatch: + inputs: + tag: + description: "Existing release tag to upload the corpus to (e.g. v0.9.0). Empty builds without uploading." + required: false + type: string + default: "" # Least-privilege default; the corpus job escalates only what it needs # (CICD-04: write scopes job-level only). @@ -25,7 +36,7 @@ jobs: verify: uses: ./.github/workflows/verify.yml with: - tag: ${{ github.event_name == 'release' && github.event.release.tag_name || '' }} + tag: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag || '' }} corpus: needs: [verify] @@ -36,13 +47,16 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false + # On a release event inputs.tag is empty, so this stays github.ref + # (the tag); a dispatch with a tag builds that tag's tree. + ref: ${{ inputs.tag || github.ref }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: "3.13" - run: python -m pip install -e . - run: python scripts/build_conformance_corpus.py dist/tods-conformance-corpus.zip - name: Upload to the release - if: github.event_name == 'release' + if: github.event_name == 'release' || inputs.tag != '' env: GH_TOKEN: ${{ github.token }} # Routed through env rather than interpolated directly into the run: @@ -50,5 +64,5 @@ jobs: # attacker-influenceable in principle (anyone who can create a # release chooses it) and this avoids splicing it into the script # text before the shell ever sees it. - TAG: ${{ github.event.release.tag_name }} + TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag }} run: gh release upload "$TAG" dist/tods-conformance-corpus.zip --clobber diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 5cc8486..b71ecde 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -21,6 +21,11 @@ on: required: false type: string default: "" + commit: + description: Exact verified commit to check out for a release. + required: false + type: string + default: "" permissions: contents: read @@ -39,6 +44,7 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: + ref: ${{ inputs.commit || github.ref }} persist-credentials: false fetch-depth: 0 # full history + tags: needed for the tag/version checks below - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f9424b..0bdcd40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ new checks may be added in minor releases. Changed: +- PyPI/GitHub releases now run only from the reviewed workflow on `main` with + an existing SSH-signed stable tag. A read-only job verifies signer and main + ancestry, the reusable gate checks the exact verified commit, and both + checkout-free publishers recheck the tag object before publication. Because + the GitHub release is created by the workflow's own token, `docker.yml` and + `release-corpus.yml` no longer start from a `release: published` event; both + are dispatched with the release tag (the corpus workflow gained the same + `tag` input `docker.yml` already had), per the updated release checklist. - Supplement rows known to add a GTFS entry now require every field the GTFS reference marks Required for that file. Updates and deletes still require only their primary-key fields. The check stays permissive when no companion diff --git a/docs/roadmap.md b/docs/roadmap.md index b938765..b415471 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -105,18 +105,24 @@ Rows marked "not-yet-built" are honest gaps, not silent omissions; see ## Release checklist (QM-17) -Run through before creating a GitHub release (tagging triggers -`pypi-publish.yml`, `docker.yml`, `release-corpus.yml`, each of which -independently re-runs `make verify` at the tagged commit before publishing): +Run through before publishing a release. Publication is now dispatch-only: +`pypi-publish.yml` is run by hand against an existing signed tag, and it — +not the maintainer — creates the GitHub release. Each release workflow still +independently re-runs `make verify` at the tagged commit before publishing: 1. `CHANGELOG.md` has a dated section for the version being released (`## vX.Y.Z - YYYY-MM-DD`), and `## Unreleased` items have moved into it. 2. `pyproject.toml` `version` and `CITATION.cff` `version`/`date-released` match the tag you are about to create. 3. Tag it **annotated and signed**: `git tag -s vX.Y.Z -m "release: vX.Y.Z"` - (a lightweight or unsigned tag now fails `verify.yml`'s REL-08 check). -4. Push the tag, then create the GitHub release from it. The three release - workflows run automatically; watch that `verify` (and, downstream, - `verify-published`) succeed before considering the release done. -5. Confirm the SBOM, provenance attestation, and (for the image) cosign + (a lightweight or unsigned tag now fails `verify.yml`'s REL-08 check; + the signer must be listed in `.github/allowed_signers`). +4. Push the tag, then dispatch **Publish to PyPI** from `main` with the tag. + It verifies signer and main ancestry, re-runs the gates at the tagged + commit, creates the GitHub release, publishes to PyPI, and re-verifies + what landed. Watch `verify` and `verify-published` succeed. +5. Dispatch **Docker image** and **Conformance corpus** with the same tag. + A release created by the publish workflow's own token does not raise a + `release: published` event, so these no longer start automatically. +6. Confirm the SBOM, provenance attestation, and (for the image) cosign signature are attached/verifiable, per `SECURITY.md` §Supply chain.