From f072ab9806a9d2d8fadf8a99edd3d21a104347b4 Mon Sep 17 00:00:00 2001 From: Chelsea Kelly-Reif <3114598+ChelseaKR@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:26:39 -0700 Subject: [PATCH 1/3] fix: harden release publication flow --- .github/workflows/pypi-publish.yml | 121 ++++++++++++++++++++++++----- .github/workflows/verify.yml | 6 ++ CHANGELOG.md | 4 + 3 files changed, 111 insertions(+), 20 deletions(-) diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 92d1f99..89e47a7 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@cef221092ed1bacb1cc03d23a2d87d1d172e277b # 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@cef221092ed1bacb1cc03d23a2d87d1d172e277b # 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/verify.yml b/.github/workflows/verify.yml index f33f4c9..0ce6f58 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@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f9424b..23d226a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ 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. - 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 From 6822cffa6cf25d0fbd025aafb297825073b46da7 Mon Sep 17 00:00:00 2001 From: Chelsea Kelly-Reif <3114598+ChelseaKR@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:52:29 -0700 Subject: [PATCH 2/3] fix(release): keep docker and corpus publishable under the dispatch-only flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hardened flow creates the GitHub release with the workflow's own GITHUB_TOKEN, and events raised by that token do not trigger other workflows — so docker.yml and release-corpus.yml would silently never run for a release. docker.yml already supports dispatch-with-tag; give release-corpus.yml the same tag input (verify at the tag, build the tag's tree, upload to the release), and update the release checklist and changelog entry to document the dispatch sequence. Co-Authored-By: Claude Fable 5 --- .github/workflows/release-corpus.yml | 20 +++++++++++++++++--- CHANGELOG.md | 6 +++++- docs/roadmap.md | 22 ++++++++++++++-------- 3 files changed, 36 insertions(+), 12 deletions(-) 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/CHANGELOG.md b/CHANGELOG.md index 23d226a..0bdcd40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,11 @@ 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. + 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. From c8f5b6e9ee0f9c94e8156d9ab165cdd018d6485a Mon Sep 17 00:00:00 2001 From: Chelsea Kelly-Reif <3114598+ChelseaKR@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:55:03 -0700 Subject: [PATCH 3/3] build(npm): bump brace-expansion past GHSA-mh99-v99m-4gvg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blocking `npm audit --audit-level=high` step in `make a11y` began failing on 2026-07-31 when this advisory (DoS via unbounded expansion in brace-expansion <1.1.17) was published against the locked transitive dependency — unrelated to this branch's changes, and it would fail any fresh CI run on main too. Lockfile-only bump via `npm audit fix`; `make a11y` (pa11y-ci, both surfaces) verified green locally after. Co-Authored-By: Claude Fable 5 --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ca88697..295a8be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -331,9 +331,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": {