From 4bf0aa603ba61f838493088104bb7a649b5af279 Mon Sep 17 00:00:00 2001 From: Aman Dua Date: Mon, 24 Aug 2026 22:20:56 -0700 Subject: [PATCH] Task 13: release plumbing Publish a tagged dbprofiler.py, unmodified, with a checksum and a signed provenance attestation. The point of a single-file tool is that a reviewer can read what they are about to run, which only holds if the download is byte-identical to the tag. So nothing here rewrites the script -- not even to stamp a version into it. VERSION is committed before the tag and the job refuses to publish when the two disagree. The safety audit and the unit suite run again before anything is uploaded, on Python 3.9, because a tag is the one moment the boundary stops being reviewable by reading the repository. Eleven guard tests cover the workflow the way the rest of the suite covers the tool: trigger shape, step ordering, the tag/version gate, checksum generation and re-verification, both assets attached, the absence of any step that could rewrite the script, provenance, permissions scoped to the publishing job, no secret beyond the workflow token, action pinning, and a README that documents the filenames the workflow actually produces. Six mutations were tried against them and each was caught by its own test. Co-Authored-By: roachdev-claude --- .github/workflows/release.yaml | 84 +++++++++++++++++ README.md | 35 +++++++ .../plans/2026-08-21-dbprofile-mvp-python.md | 50 +++++++++- test_dbprofiler.py | 94 +++++++++++++++++++ 4 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/release.yaml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..2bfe454 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,84 @@ +# Publishes a tagged dbprofiler.py, unmodified, with a checksum and a signed +# provenance attestation. +# +# The whole value of a single-file tool is that a security reviewer can read +# what they are about to run. That only holds if the download is byte-identical +# to the tag, so no step here rewrites the script -- not even to stamp a version +# into it. The version is committed before the tag, and the job refuses to +# publish if the two disagree. +# +# To cut a release: +# +# 1. Edit VERSION in dbprofiler.py, commit, merge. +# 2. git tag -a v1.2.3 -m 'v1.2.3' && git push origin v1.2.3 +# +# Anything else -- a tag on an unmerged branch, a tag that does not match +# VERSION, a file that fails the safety audit -- fails here rather than reaching +# a customer. + +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write # create the release and attach its assets + id-token: write # sign the provenance attestation + attestations: write # record it against this repository + steps: + - uses: actions/checkout@v4 + + # The oldest version the tool claims to support. If the release artifact + # only works on something newer, that is a release-blocking bug. + - uses: actions/setup-python@v5 + with: + python-version: "3.9" + + - name: Safety audit + run: python dbprofiler.py --check-safety + + - name: Unit tests + run: python -m unittest + + - name: Tag must agree with the version in the script + run: | + set -euo pipefail + version="$(python dbprofiler.py --version)" + if [ "v${version}" != "${GITHUB_REF_NAME}" ]; then + echo "tag ${GITHUB_REF_NAME} does not match VERSION ${version}" >&2 + echo "bump VERSION in dbprofiler.py, merge, then re-tag" >&2 + exit 1 + fi + + # Written with a bare filename so `sha256sum -c` works from whatever + # directory the customer downloaded into. + - name: Checksum + run: sha256sum dbprofiler.py > dbprofiler.py.sha256 + + - name: Verify the checksum the way a customer will + run: sha256sum -c dbprofiler.py.sha256 + + - name: Attest build provenance + uses: actions/attest-build-provenance@v4 + with: + subject-path: dbprofiler.py + + # --verify-tag refuses to invent a tag that is not already pushed, so a + # typo cannot create a release pointing at the wrong commit. + - name: Publish + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh release create "${GITHUB_REF_NAME}" \ + --title "${GITHUB_REF_NAME}" \ + --verify-tag \ + --generate-notes \ + dbprofiler.py dbprofiler.py.sha256 diff --git a/README.md b/README.md index f8cfc95..30e26b1 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,22 @@ curl -LO https://github.com/cockroachlabs/dbprofiler/releases/latest/download/db sha256sum -c dbprofiler.py.sha256 ``` +On macOS, `shasum -a 256 -c dbprofiler.py.sha256` does the same thing. + +The checksum proves the file survived the transfer. To prove it came from this +repository's release workflow and not from someone who could write to the release page, +verify the provenance attestation as well: + +```bash +gh attestation verify dbprofiler.py --repo cockroachlabs/dbprofiler +``` + +That checks a signature made by GitHub's own OIDC identity for this repository, recording +which workflow built the artifact and from which commit. The release workflow publishes +the tagged file unmodified — no version stamping, no rewriting — so the bytes you verify +are the bytes in the tag, and `git show :dbprofiler.py | diff - dbprofiler.py` is +empty. + It is a single file with no dependencies, so you can read all of it before you run it. ## Usage @@ -151,6 +167,25 @@ python3 -m unittest integration_test -v See `docs/TESTING.md` for the server and the configuration it needs. +## Releasing + +```bash +# 1. Bump VERSION in dbprofiler.py, commit, merge to main. +# 2. Tag the merged commit. +git tag -a v1.2.3 -m 'v1.2.3' +git push origin v1.2.3 +``` + +`.github/workflows/release.yaml` takes it from there: it runs the safety audit and the +unit suite on Python 3.9, refuses to continue if the tag disagrees with `VERSION`, +computes and re-verifies the checksum, attests build provenance, and creates the release +with `dbprofiler.py` and `dbprofiler.py.sha256` attached. It never edits the script, so +the asset is byte-identical to the tag. + +`test_dbprofiler.py` holds guard tests over that workflow — trigger, step order, +permissions scope, action pinning, and the absence of any step that could rewrite the +script — so the release path is covered by the same suite as the tool. + ## License MIT. See [LICENSE](LICENSE). diff --git a/docs/superpowers/plans/2026-08-21-dbprofile-mvp-python.md b/docs/superpowers/plans/2026-08-21-dbprofile-mvp-python.md index b409d0c..448ef46 100644 --- a/docs/superpowers/plans/2026-08-21-dbprofile-mvp-python.md +++ b/docs/superpowers/plans/2026-08-21-dbprofile-mvp-python.md @@ -801,11 +801,51 @@ satisfied while still testing the parser. **Files:** `.github/workflows/release.yaml`. -- [ ] Trigger on `v*` tags. -- [ ] Compute `sha256sum dbprofiler.py > dbprofiler.py.sha256`. -- [ ] Upload `dbprofiler.py` + `dbprofiler.py.sha256` to the GitHub Release for that tag. -- [ ] README documents the download-and-verify flow: `curl -O /dbprofiler.py`, `curl -O /dbprofiler.py.sha256`, `sha256sum -c dbprofiler.py.sha256`. -- [ ] Optional: also publish a signed provenance attestation via GitHub's built-in `actions/attest-build-provenance` — cheap to add, meaningful for security reviewers. +- [x] Trigger on `v*` tags. +- [x] Compute `sha256sum dbprofiler.py > dbprofiler.py.sha256`. +- [x] Upload `dbprofiler.py` + `dbprofiler.py.sha256` to the GitHub Release for that tag. +- [x] README documents the download-and-verify flow: `curl -O /dbprofiler.py`, `curl -O /dbprofiler.py.sha256`, `sha256sum -c dbprofiler.py.sha256`. +- [x] Optional: also publish a signed provenance attestation via GitHub's built-in `actions/attest-build-provenance` — cheap to add, meaningful for security reviewers. + +**Deviations and additions beyond the checklist** + +- **The download is byte-identical to the tag.** The obvious thing to do at release time + is stamp the version into `dbprofiler.py`, and that is exactly what this must not do: + the tool's value is that a reviewer reads the file before running it, which only holds + if the file they read and the file they ran are the same bytes. `VERSION` is committed + before the tag instead, and the workflow refuses to publish when + `v$(python dbprofiler.py --version)` disagrees with `GITHUB_REF_NAME`. A guard test + fails if any step could rewrite the script — `sed -i`, `tee`, `patch`, or a redirect + onto it, with a lookahead sparing the `> dbprofiler.py.sha256` that writes the checksum. +- **The safety audit gates the release, not just CI.** A tag is the one moment the + boundary stops being reviewable by reading the repository, so `--check-safety` and the + unit suite run again before anything is uploaded, on Python 3.9 — the oldest version the + tool claims to support. A guard test asserts the ordering by line index, so moving the + publish step above the audit turns the unit suite red. +- **11 guard tests over the workflow, written first and mutation-tested.** Trigger shape + (`v*` tags only, no `branches:`, no `workflow_dispatch:` back door), step ordering, + the tag/version gate, checksum generation and re-verification, both assets attached, + no rewriting step, provenance attested with the token permissions it needs, + `contents: read` by default and `contents: write` only on the publishing job, no + `secrets.` reference beyond `github.token`, every action pinned to a major version, and + the README documenting the filenames the workflow actually produces. Six mutations + tried — a `workflow_dispatch` back door, a version-stamping `sed -i`, dropping the + tag/version gate, unpinning the attest action to `@main`, publishing before the audit, + and widening the top-level permission to `contents: write` — each caught by the test + written for it and by no other. +- **`--verify-tag` on `gh release create`.** Without it, `gh` will happily create the tag + it was asked to release, so a typo becomes a release pointing at whatever `main` was. +- **`actions/attest-build-provenance@v4`.** v4 is a thin wrapper over `actions/attest`, + which upstream now recommends directly, but the wrapper remains the documented path for + build provenance and needs no predicate wiring. +- **Two additions to the README.** `gh attestation verify` alongside the checksum — the + checksum proves the file survived the transfer, the attestation proves it came from this + repository's workflow — plus `shasum -a 256` for macOS, and a note that + `git show :dbprofiler.py | diff - dbprofiler.py` is empty. A `## Releasing` section + records the bump-then-tag ritual the version gate requires. +- **Both workflow files were parsed before commit.** `ruff` and the unit suite do not read + YAML, and a syntax error in a workflow does not fail CI — it silently means the workflow + never runs, which for a release path would only be discovered at the tag. ### Task 14: Verification and uncommitted handoff diff --git a/test_dbprofiler.py b/test_dbprofiler.py index 52b24c2..2d378af 100644 --- a/test_dbprofiler.py +++ b/test_dbprofiler.py @@ -3195,5 +3195,99 @@ def test_it_never_prints_a_configured_value(self): self.assertNotRegex(self.text, r"print\(") +# --- release plumbing ------------------------------------------------------- +# +# What a customer downloads is what a reviewer read. These tests exist to keep +# that sentence true: the release workflow publishes the tagged file unmodified, +# refuses to publish one that has not passed the safety audit, and refuses to +# publish under a tag the file does not claim. + +RELEASE_WORKFLOW = REPO / ".github" / "workflows" / "release.yaml" +SCRIPT_NAME = "dbprofiler.py" +CHECKSUM_NAME = "dbprofiler.py.sha256" + + +class TestReleaseWorkflow(unittest.TestCase): + def setUp(self): + self.text = RELEASE_WORKFLOW.read_text() + self.lines = self.text.splitlines() + + def index_of(self, needle): + for number, line in enumerate(self.lines): + if needle in line: + return number + raise AssertionError(f"{needle!r} not found in {RELEASE_WORKFLOW.name}") + + def test_it_triggers_on_version_tags_only(self): + self.assertRegex(self.text, r"tags:\s*\[\s*[\"']v\*[\"']\s*\]") + self.assertNotIn("branches:", self.text) + self.assertNotIn("workflow_dispatch", self.text) + + def test_nothing_ships_before_the_safety_audit(self): + """A release is the one moment the boundary stops being reviewable by + reading the repository, so the audit gates it.""" + self.assertLess(self.index_of("--check-safety"), self.index_of("gh release create")) + self.assertLess(self.index_of("unittest"), self.index_of("gh release create")) + + def test_the_tag_must_agree_with_the_version_in_the_script(self): + """Publishing v1.2.0 from a file that reports 1.1.0 would put the wrong + tool_version in every manifest produced by that download.""" + self.assertIn("--version", self.text) + self.assertIn("GITHUB_REF_NAME", self.text) + self.assertLess( + self.index_of("GITHUB_REF_NAME"), self.index_of("gh release create") + ) + + def test_the_checksum_is_taken_over_the_script_and_verified(self): + self.assertIn(f"sha256sum {SCRIPT_NAME} > {CHECKSUM_NAME}", self.text) + self.assertIn(f"sha256sum -c {CHECKSUM_NAME}", self.text) + + def test_both_assets_are_uploaded(self): + publish = self.lines[self.index_of("gh release create"):] + uploaded = "\n".join(publish) + self.assertIn(SCRIPT_NAME, uploaded) + self.assertIn(CHECKSUM_NAME, uploaded) + + def test_the_published_file_is_the_tagged_file(self): + """No step may rewrite the script on its way out. The download has to be + byte-identical to what is in the tag, or reading the repository tells a + reviewer nothing about what they ran.""" + for rewrite in (r"sed\s+-i", r"tee\s+dbprofiler\.py", r"\bpatch\b", r"\bapply\b"): + self.assertNotRegex(self.text, rewrite) + # A redirect onto the script itself. The negative lookahead spares + # `> dbprofiler.py.sha256`, which is the checksum, not the script. + self.assertNotRegex(self.text, r">>?\s*dbprofiler\.py(?!\.)") + + def test_provenance_is_attested(self): + self.assertIn("actions/attest-build-provenance@", self.text) + self.assertIn("id-token: write", self.text) + self.assertIn("attestations: write", self.text) + + def test_write_permission_is_scoped_to_the_publishing_job(self): + """Default read-only at the top of the file, widened only where the + release is actually created.""" + jobs = self.index_of("jobs:") + self.assertIn("contents: read", "\n".join(self.lines[:jobs])) + self.assertNotIn("contents: write", "\n".join(self.lines[:jobs])) + self.assertIn("contents: write", "\n".join(self.lines[jobs:])) + + def test_it_uses_no_secret_beyond_the_workflow_token(self): + """A single-file tool needs no signing key and no registry credential. + Anything referencing secrets. here would be a new thing to trust.""" + self.assertNotIn("secrets.", self.text) + self.assertIn("github.token", self.text) + + def test_every_action_is_pinned_to_a_major_version(self): + for match in re.finditer(r"uses:\s*(\S+)", self.text): + with self.subTest(action=match.group(1)): + self.assertRegex(match.group(1), r"@v\d+$") + + def test_the_readme_documents_the_flow_it_actually_publishes(self): + readme = (REPO / "README.md").read_text() + self.assertIn(f"releases/latest/download/{SCRIPT_NAME}", readme) + self.assertIn(f"releases/latest/download/{CHECKSUM_NAME}", readme) + self.assertIn(f"sha256sum -c {CHECKSUM_NAME}", readme) + + if __name__ == "__main__": unittest.main()