Skip to content

release download: add --verify to check immutability and build provenance before downloading - #14783

Draft
mrecachinas wants to merge 4 commits into
cli:trunkfrom
mrecachinas:release-download-verify
Draft

mrecachinas wants to merge 4 commits into
cli:trunkfrom
mrecachinas:release-download-verify

Conversation

@mrecachinas

@mrecachinas mrecachinas commented Oct 21, 2026 •

Copy link
Copy Markdown

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. gh can check this today, but only after downloading and across several commands, so people rarely do.

gh release download --verify runs these checks before downloading and then asks whether to continue:

  • Immutable releases: GitHub's signed release attestation must verify, match this release, and list each asset's name and digest.
  • Build provenance: each asset's SLSA attestation must be signed by GitHub Actions and built from the release's repository. The workflow, commit, ref, and run are shown.
  • After downloading, each file must match its expected sha256.

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:

$ gh release download --verify https://github.com/cli/cli/releases/download/v2.102.1/gh_2.102.1_macOS_arm64.zip
cli/cli v2.102.1
✓ Immutable release, verified by GitHub's signed release attestation

gh_2.102.1_macOS_arm64.zip
✓ Listed in the signed release attestation
✓ Verified SLSA build provenance
  - Build instructions: https://github.com/cli/cli/blob/89d7dcec017194af1d6f8853176b2dae9c1da762/.github/workflows/deployment.yml
  - Source:             https://github.com/cli/cli/tree/89d7dcec017194af1d6f8853176b2dae9c1da762
  - Source ref:         refs/heads/bagtoad/go-1-27-3-patch-on-v2-102-0
  - Build run:          https://github.com/cli/cli/actions/runs/39482998765/attempts/1
  - Runner:             github-hosted

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

$ gh release download --verify https://github.com/vektra/mockery/releases/download/v2.53.8/mockery_2.53.8_Darwin_arm64.tar.gz
vektra/mockery v2.53.8
! Not an immutable release; assets can be replaced after publishing

mockery_2.53.8_Darwin_arm64.tar.gz
X No build provenance attestation found

refusing to download: 1 asset failed verification; run without `--verify` to download anyway

I also saw that -O - writes the verified bytes (piping to shasum -a 256 gave the attested 08bfef98…aa2aeb), and that GHES hosts and --archive are rejected up front with an explanation.

Key points

  • Assets are downloaded to a private temp directory and checked there; they're only written to the destination (or stdout) if every asset passes. A failed check leaves existing files untouched.
  • Provenance may be signed by any Actions workflow on the host (so reusable workflows work), but the source repository is pinned, matching gh attestation verify --repo.
  • Mutable releases only warn, since they have no release attestation; build provenance decides.
  • Without --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), then downloadVerified in download.go. It reuses the existing attestation client, Sigstore verifier, and shared.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:

  • A human wrote it.
  • An agent wrote it under close human direction.
  • An agent wrote it independently, and no human has guided the implementation beyond the initial prompt.

Who answers review comments:

  • @mrecachinas will read and reply directly.
  • An agent will draft replies and @username will read them before they are posted.
  • Nobody has explicitly committed to replying.

mrecachinas and others added 4 commits October 21, 2026 11:29
`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>
Copilot AI balanced review requested due to automatic review settings October 21, 2026 17:05
@github-actions github-actions Bot added external pull request originating outside of the CLI core team needs-triage needs to be reviewed labels Oct 21, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 --verify and 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

Comment on lines +599 to +600
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")
@xiemocun

Copy link
Copy Markdown

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 gh api (releases/latest + GET /repos/{r}/attestations/{digest}):

  • immutable release: 3/15 (cli/cli, astral-sh/uv, mikefarah/yq)
  • attestation present for the advertised asset digest: 6/15 (cli/cli, jqlang/jq, sharkdp/fd, astral-sh/uv, atuinsh/atuin, mikefarah/yq); the other 9 return 404 (ripgrep, bat, delta, neovim, helix, fzf, zellij, task, k9s)
  • .digest populated on the asset object: 15/15 — so "does provenance exist for this asset?" can be answered before downloading, without trusting any bytes yet.

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 (sharkdp/fd v10.5.0, immutable=false): gh attestation verify binds the file to sha256 a1259cd…, sourceRepositoryURI https://github.com/sharkdp/fd, workflow CICD.yml@refs/tags/v10.5.0, source commit 4f81778…. That already rules out a swapped asset. What it does not rule out is the release page being repointed later (to an older or differently-built but attested artifact of the same repo), which is exactly what immutability/the release attestation covers. So: provenance pass + mutable release → warn and continue; make hard failure opt-in (--verify=required or a separate --require-immutable). Failing on mutable by default would reject 12/15 of the sample above, and those users would learn to pass the "skip" flag by reflex.

Because of that split, --verify looks like two phases: a pre-download check (release attestation / immutable, and whether an attestation exists for the asset digest) plus the actual cryptographic check after the bytes are on disk. Streaming to stdout (--output -) can't be verified before use — worth stating in the help text rather than silently passing.

Two smaller points: a *checksums.txt from the same release is not a trust anchor (same publisher, same mutable surface), so it shouldn't be displayed as a verified signal; and "no attestation found via the API" should be worded distinctly from "attestation failed" — jqlang/jq ships its bundle inside the release (jq-attestation.json) instead of the attestation store, so a user there gets a 404 today and would read it as tampering.

Happy to test a build against that 15-repo list if you want a compatibility pass.

ravidsrk added a commit to ravidsrk/open-fleet that referenced this pull request Oct 27, 2026
…; 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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

external pull request originating outside of the CLI core team needs-triage needs to be reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants