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()