Repository navigation
release download: add --verify to check immutability and build provenance before downloading - #14783
mrecachinas wants to merge 4 commits into
Conversation
`gh release download --verify` reports whether the release is immutable and verifies each asset's SLSA build provenance before downloading, showing the build workflow, source commit, ref, and run. Verification uses the digest reported by the API, so it happens before any bytes are fetched; the downloaded content is then checked against that digest. Interactive sessions are asked to confirm; non-interactive runs fail unless every asset has verified provenance. A release asset URL can now be passed in place of a tag to download a single asset. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… on GHES - For immutable releases, verify the release attestation GitHub signs at publish time, bind it to this release by tag and release ID, and check each asset's name and digest against it. - Allow --verify with --output -: the asset is staged in a temporary directory and only written to standard output once its digest checks out. - Reject --verify on GitHub Enterprise Server before any network request. - Check downloaded content against the API digest even when the user accepts an asset that failed verification. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Address review findings on --verify: - Download verified assets into a private staging directory, check each digest there, and only write them to the destination once every asset has passed, one at a time. A failed check now leaves the destination untouched, removes the pathname-based cleanup that could follow a replaced directory, and stops concurrent workers sharing a destination file under --clobber. - Reject assets whose names differ only in case when verifying. - Treat only an explicit --output - as standard output, so an asset named "-" is written to a file rather than streamed unchecked. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Overwriting one of two hard-linked destination files would also replace the other with a different asset's content, so --verify now refuses before downloading. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Contribution eligibility, existing stdout compatibility, and verification-policy test coverage remain unresolved.
3 open issues
What changed in this PR
Adds optional pre-download attestation checks to gh release download, reusing the existing verification infrastructure.
Changes:
- Adds
--verifyand release asset URL input. - Reports release immutability and build provenance before confirmation.
- Stages downloads and checks digests before publishing files or stdout.
| File | Description |
|---|---|
| pkg/cmd/release/download/provenance.go | Implements attestation checks, reporting, and confirmation. |
| pkg/cmd/release/download/provenance_test.go | Tests verification flows, URL parsing, and staged downloads. |
| pkg/cmd/release/download/download.go | Wires verification, staging, digest checks, and output routing. |
| pkg/cmd/release/download/download_test.go | Updates the download helper invocation. |
Review effort: Balanced
| func (w *destinationWriter) isStdout() bool { | ||
| return w.file == "-" |
| if err != nil { | ||
| return verify.PolicyBuilder{}, err | ||
| } | ||
| return verify.NewPolicy(digestOption, verify.WithCertificateIdentity(certID)), nil |
| cmd.Flags().BoolVar(&opts.OverwriteExisting, "clobber", false, "Overwrite existing files of the same name") | ||
| cmd.Flags().BoolVar(&opts.SkipExisting, "skip-existing", false, "Skip downloading when files of the same name exist") | ||
| cmd.Flags().BoolVar(&opts.AllowEscapeSequences, "allow-escape-sequences", false, "Allow printing terminal escape sequences when writing an asset to standard output") | ||
| cmd.Flags().BoolVar(&opts.Verify, "verify", false, "Check release immutability and verify build provenance before downloading") |
|
Two design questions here can be answered with data, so I measured before commenting. How common is each state today? Sample: latest release of 15 well-known single-binary CLI projects, linux/amd64 archive, queried 2026-10-27 via
Mutable release with valid provenance — pass silently in scripts? I think yes, with a warning, because provenance and immutability prove different things. Checked end to end on a mutable release ( Because of that split, Two smaller points: a Happy to test a build against that 15-repo list if you want a compatibility pass. |
…; gate scripts and root shadows are the grader (PR #718 re-attack, W6-01) The re-attacker greened every build context without touching a workflow: a one-file shard-tests.py that ran nothing (and wrote one padded coverage file), `sys.exit(0)` in bench/vf-bench/gate.py, a run.sh that printed the pinned transcript, or a top-level `pip/` or `coverage/` package that ran instead of the locked tool. CI that runs PR code can always be forged by that code, so the real control for a PR the owner did not write is a trusted GO at the exact head. This makes the gate's own part of that airtight and narrows the cheap channels: a. The owner exemption also needs the PR's head repository to be this repository: an owner-opened PR whose head is a fork's branch is refused like any other, since the fork keeps push access to it. b. The changed paths are read for the exact head SHA the status is posted on: compare/<base branch>...<head sha> (the base branch's live tip, so a branch updated from main is not charged main's changes; base.sha only when the ref is missing or unsafe), never the SHA-less PR files list, so a head flipped M -> benign -> M across the gate's reads no longer passes. The re-read of the PR still refuses a head that moved. A base that cannot be named, or a list at the API's 300-file cap or shorter than changed_files, fails closed. Compare needs Contents: read, which the verdict-gate app does not hold, so verdict-gate.yml now reads with the job's own GITHUB_TOKEN (contents: read) and passes the app token as GH_STATUS_TOKEN, used only to post the status. No app permission change is needed. c. The grader also covers GATE_SCRIPTS, the 14 scripts the required jobs (gates and its needs, pinned-demo) name on a run: line; a test parses the workflows and holds the list to them both ways. guard runs the base branch's floor_guard.py, so nothing of the PR's is listed for it. It also covers a root module file (.py, .pyc, .pyd, .so, .pth) and any top-level directory not in TOP_LEVEL_DIRS (a test holds that set to `git ls-tree`), which a job running `-m coverage` / `-m unittest` from the root would import. e. The module docstring, both verdict workflows' headers and docs/ops.md#merge-gate say it plainly: CI runs the PR's code; for a PR the owner did not open from this repository, the merge control is the trusted GO at the exact head, the reviewer's attestation that they read the diff, which no check can verify. The ops page's "re-opens it from their own branch" now reads "from a branch of this repository". (d, `python3 -I` in the workflows, follows in its own commit.) Red tests: tests/test_verdict_check.py APrCannotChangeItsOwnGrader test_the_grader_read_is_bound_to_the_exact_head, test_an_owner_pr_from_a_fork_branch_gets_no_exemption, test_the_gate_scripts_are_the_grader_too, test_a_new_top_level_module_is_the_grader_too, test_the_top_level_list_is_this_repositorys, test_the_gate_scripts_list_holds_what_the_required_jobs_run, test_reads_use_the_jobs_token_and_the_status_the_apps (13 failures and 3 errors on the previous checker). My earlier fixtures in this class and in AForkAuthorCannotGoTheirOwnPr gain the base and head repos a real PR has. Compare by SHA was checked read-only against a public fork PR (cli/cli#14783): it resolves the fork head in the base repository and lists the same files as the PR.


No tracking issue yet; opening as a draft to discuss the design with a working implementation.
Following Working with us, the same behavior is available today as an extension: mrecachinas/gh-verify-download (
gh extension install mrecachinas/gh-verify-download).Description
Before running a downloaded release asset, it's worth knowing whether the release is immutable and how the asset was built.
ghcan check this today, but only after downloading and across several commands, so people rarely do.gh release download --verifyruns these checks before downloading and then asks whether to continue:In a terminal the prompt defaults to No if anything failed; non-interactive runs fail instead. A release asset URL can also be passed in place of a tag.
How did you test this change?
I built this branch and ran it against public releases.
An immutable, attested release downloads after showing its provenance:
A mutable release without attestations is refused (exit 1, nothing written). In a terminal I instead saw
? Download mockery_2.53.8_Darwin_arm64.tar.gz? (y/N).I also saw that
-O -writes the verified bytes (piping toshasum -a 256gave the attested08bfef98…aa2aeb), and that GHES hosts and--archiveare rejected up front with an explanation.Key points
gh attestation verify --repo.--verify, an asset literally named-is now saved as a file rather than streamed to stdout.Notes for reviewers
Start with
pkg/cmd/release/download/provenance.go(confirmProvenance,check,checkRelease), thendownloadVerifiedindownload.go. It reuses the existing attestation client, Sigstore verifier, andshared.AttestationVerifier.Open questions: should this be a flag or its own command, and should mutable releases with valid provenance pass silently in scripts?
Authorship and follow-up
Who wrote this:
Who answers review comments: