Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 101 additions & 20 deletions .github/workflows/pypi-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,35 +20,80 @@ 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:
contents: read

# 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:
Expand Down Expand Up @@ -79,38 +124,74 @@ 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
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
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:
Expand Down
20 changes: 17 additions & 3 deletions .github/workflows/release-corpus.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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]
Expand All @@ -36,19 +47,22 @@ 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:
# shell (CICD-21 / zizmor template-injection): the tag name is
# 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
6 changes: 6 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 14 additions & 8 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading