Skip to content

ci(release): attest build provenance for release artifacts#574

Open
arcaven wants to merge 4 commits into
Zious11:developfrom
ArcavenAE:ci/attest-provenance
Open

ci(release): attest build provenance for release artifacts#574
arcaven wants to merge 4 commits into
Zious11:developfrom
ArcavenAE:ci/attest-provenance

Conversation

@arcaven

@arcaven arcaven commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds an attest job to .github/workflows/release.yml that produces a
GitHub Artifact Attestation with SLSA build provenance for the release
archives. It fans in from the same immutable build-job artifacts that
release uploads, so the attestation covers the exact bytes users
download. Gated on vars.ATTESTATIONS_ENABLED so forks carry it verbatim
as a no-op.

Reworked from the initial download-after-publish design per @Zious11's
review — see the review thread for the item-by-item mapping.

Why

  • mise install github:<owner>/jira-cli and equivalent flows verify
    GitHub Artifact Attestations natively when present. Today mise reports
    "SLSA provenance: no provenance found" for jr releases and falls back
    to unsigned tarball extraction. Adding provenance closes that gap for
    every downstream that verifies (mise, gh attestation verify, and
    Sigstore-aware clients).
  • The one-time per-release cost is a single Ubuntu job that downloads the
    build artifacts and calls actions/attest-build-provenance. No changes
    to build matrices, asset names, or the release job itself.
  • Fork-transparent: gated on a repository variable and using OIDC identity,
    so on a fork with the variable set it attests the fork's assets under the
    fork's identity; with the variable unset the file is a byte-identical
    no-op.

Design decisions

  • Attest the built artifacts in-workflow, before/independent of publish
    — not download-and-attest after publish.
    The attest job fans in from
    the build matrix's uploaded artifacts (actions/download-artifact,
    SHA-pinned, merge-multiple: true) and attests those. Those are the same
    immutable objects release downloads and uploads verbatim (softprops
    copies bytes unchanged), so gh attestation verify / mise match on
    sha256 digest regardless of storage location. This makes the provenance
    claim builder-side and eliminates the publish-then-attest window
    (TOCTOU / CWE-362) — relevant on a signing-enabled fork where
    sign-and-publish.yml fires on the same workflow completion and
    --clobbers re-signed darwin assets.
  • Parallel to release, not strictly upstream of it. Both jobs
    needs: build. Keeping attest out of release's needs means the
    fork gate can skip attest without cascading a skip onto the fork's
    release (a skipped dependency is treated as unmet). Both jobs read the
    same immutable build artifacts, so the attested bytes still match the
    uploaded bytes. (Open to a linear build→attest→release chain with an
    always()-style guard on release if the linear DAG is preferred.)
  • Fork opt-in gate. if: vars.ATTESTATIONS_ENABLED == 'true', matching
    the release-ops convention (SIGNING_ENABLED, HOMEBREW_TAP_REPO,
    RELEASE_GAP_FILL_ENABLED, SYNC_UPSTREAM_REPO). Attestations need
    id-token: write and publish to the public Rekor log; they're
    unavailable on GHES and on private forks without GitHub Enterprise Cloud.
    Documented in the spec's variables table + CLAUDE.md gate line.
  • Explicit job-scoped permissions (id-token: write,
    attestations: write, contents: read) — none is inherited.
  • Archives only. subject-path covers *.tar.gz + *.zip; the
    .sha256 sidecars are integrity metadata already covered by the archive
    attestation, so attesting them would be circular.
  • SHA-pinned actions/attest-build-provenance@v4.1.1
    (0f67c3f4856b2e3261c31976d6725780e5e4c373).
  • Retains harden-runner at the workflow's current v2.19.4 pin — no
    version drift introduced.

What this is not

  • Not a change to sign-and-publish.yml. That fork-optional workflow
    re-signs the darwin binaries and emits .pkg/.dmg with different
    digests after notarization. Those need their own attest step there
    (after codesign/notarize, before upload), gated on the same variable —
    a follow-up PR. The coverage boundary is called out in an inline comment
    on the attest step.
  • Not a change to backfill-release.yml or release-gap-fill.yml.
  • Not a Sigstore-Rekor cosign step (mise doesn't require it; the native
    GitHub Attestations path is sufficient).
  • No changes to the build matrix, asset names, or Cargo.toml.

Test plan

  • On a fork with vars.ATTESTATIONS_ENABLED=true and id-token: write
    permitted: cut a tag — the build matrix runs, attest and release both
    fan in from the build artifacts, attest emits provenance for the
    archives. Verify with gh attestation verify <asset> --repo <fork> and
    mise install github:<fork>/jira-cli@<tag> (mise reports "verify SLSA
    provenance: success").
  • With the variable unset: attest is skipped, release runs unchanged —
    the file is a no-op on forks that haven't opted in.

Zious11
Zious11 previously requested changes Jul 8, 2026

@Zious11 Zious11 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks @arcaven — this is a well-constructed contribution: both action SHAs check out against the upstream tags (we verified a2bbfa2… == attest-build-provenance v4.1.0 and the harden-runner pin matches our existing v2.19.4 pin exactly), the permissions are correctly job-scoped, and the fork-transparency intent is appreciated. We researched the placement question against GitHub's docs, SLSA guidance, and real-world pipelines before writing this up, and it points to a design change rather than a polish pass — details below.

Required

1. Move attestation into the build workflow, before upload — not download-and-attest after publish.

GitHub's canonical pattern places attest-build-provenance in the workflow that produced the bytes, before distribution (docs, blog); jdx/mise's own releases attest in-workflow before upload too. Two concrete reasons this matters here:

  • The "attest the exact bytes users download" rationale is moot: gh attestation verify (and mise's verifier) match on sha256 digest, not storage location. An attestation created from workspace artifacts verifies identically against the release-page download — softprops/action-gh-release uploads bytes verbatim, and GitHub now exposes assets[].digest as an independent cross-check. So the post-publish download buys nothing…
  • …and costs a real window (TOCTOU, CWE-362): the job attests whatever is on the release page at download time. On a signing-enabled fork this race is deterministic — sign-and-publish.yml fires on the same workflow completion and --clobbers re-signed darwin assets, so the job can attest bytes users never receive. It also downgrades the claim from builder provenance to consumer re-attestation (weaker SLSA Build L2 semantics — the attesting workflow didn't build the subjects).

Suggested shape: a fan-in job with actions/download-artifact (SHA-pinned, merge-multiple: true) that attests, sitting between the build matrix and the release upload job — or attest per-matrix-job on the built files. Either eliminates the window, drops the GH_TOKEN/contents: read need in that step, and makes the provenance claim exact.

2. Add a fork opt-in gate, matching this repo's release-ops convention.

Every other release-ops job here is gated on a repository variable so forks carry the file verbatim as a no-op (vars.SIGNING_ENABLED, HOMEBREW_TAP_REPO, RELEASE_GAP_FILL_ENABLED, SYNC_UPSTREAM_REPO — see docs/specs/fork-friendly-release-ops.md). As written, the attest job runs unconditionally on any fork's v* tag: it fails hard where attestations aren't available (private forks need GitHub Enterprise Cloud; GHES doesn't support attestations at all; org policies can withhold id-token: write) and publishes fork commit SHAs to the public Rekor transparency log without opt-in. Please gate it (e.g. vars.ATTESTATIONS_ENABLED == 'true') and add the row to the spec's variables table.

3. Bind github.repository through env: rather than inline ${{ }} in the run: block.

Not exploitable here (github.repository is platform-constrained), but this repo enforces the env-binding rule for expressions in shell (see the CWE-77 rule comments in sign-and-publish.yml), and CI review will keep flagging it. One-line change — and it disappears entirely if you adopt the download-artifact design from item 1.

Recommended

4. Exclude .sha256 files from subject-path — the archive attestations already cover integrity; attesting checksum files is circular noise. (release-assets/*.tar.gz + *.zip, or a files list.)

5. Bump to v4.1.1 (current latest, 2026-06-26) while you're in here — SHA-pin it the same way; we'll verify the pin on re-review.

6. Add an inline comment marking the coverage boundary for signed macOS assets: the darwin bare binaries and .pkg/.dmg that sign-and-publish.yml uploads have different digests post-signing, so they need their own attest step in that workflow (after codesign/notarize, before upload). Keeping that out of this PR is a fine scope call — but the boundary should be visible in the code, not just the PR description. A follow-up PR gated on the same variable would complete the story.

7. A short CHANGELOG entry under [Unreleased] > Added — provenance verification is a user-facing capability (gh attestation verify, mise's native verification).

Small notes

  • set -euo pipefail in any remaining download script so an empty asset dir fails loudly (moot under item 1).
  • The !cancelled() && needs.release.result == 'success' guard is fine; the inline comment slightly overstates what it covers (a cancel mid-attest can still interrupt submission) — no change needed.

The mechanics of what you wrote are solid, and with the placement moved in-workflow plus the fork gate this is a clear improvement to the release pipeline. Happy to re-review quickly. This also pairs with #573 — once this lands, the README's attestation paragraph there becomes accurate with a small rewording (commented separately on that PR).

Zious11 added a commit that referenced this pull request Jul 8, 2026
…sted (SHA pins verified; TOCTOU/fork-gate/CWE-77 findings); review artifacts committed
arcaven added a commit to ArcavenAE/jira-cli that referenced this pull request Jul 15, 2026
- Fix the attestation sentence: mise's native verification already ships;
  what is pending is this repo publishing attestations (Zious11#574). Drop the
  'SLSA provenance runs by default' overstatement.
- Make the mise use / mise.toml examples runnable (Zious11/jira-cli),
  with one fork-owner substitution sentence.
- Narrow the prerelease glob to the only channel this repo cuts (v*-dev.*).
- Note mise activate is assumed for the binary to land on PATH.
- Add the Windows-has-no-stable-asset-yet caveat (needs prerelease=true).
- Give an activation-free xattr fallback via 'mise where'.
…_ENABLED (Zious11#574)

Address review: move provenance from download-after-publish to an
in-workflow attestation of the built bytes, and make the job fork-safe.

- Attest fans in from the immutable `build` artifacts (parallel to
  `release`, both `needs: build`) instead of `gh release download` after
  publish. The attested bytes are identical to what softprops uploads
  verbatim, so there is no publish-then-attest window (TOCTOU/CWE-362) and
  no consumer re-attestation of possibly-clobbered assets — the claim is
  builder-side provenance. Kept parallel rather than strictly upstream of
  `release` so the fork gate can skip `attest` without cascading a skip
  onto the fork's release.
- Gate on `vars.ATTESTATIONS_ENABLED == 'true'`, matching the release-ops
  convention (SIGNING_ENABLED, RELEASE_GAP_FILL_ENABLED, ...). Forks carry
  the file verbatim as a no-op; attestations need id-token: write + a public
  Rekor entry and are unavailable on GHES / private non-GHEC forks.
- Drop `.sha256` sidecars from subject-path (archive attestation already
  covers integrity; attesting checksums is circular).
- Bump attest-build-provenance v4.1.0 -> v4.1.1 (SHA-pinned).
- Remove the inline ${{ github.repository }} shell interpolation — no
  download script remains, so the CWE-77 env-binding concern is gone.
- Comment the coverage boundary: sign-and-publish.yml re-signs darwin
  binaries to DIFFERENT digests post-notarization; those need their own
  attest step in that workflow, out of scope here.
- Add CHANGELOG entry, spec variables-table row, and CLAUDE.md gate line.
Zious11 pushed a commit that referenced this pull request Jul 15, 2026
* docs(readme): document mise install path

* docs(readme): address review on mise section (#573)

- Fix the attestation sentence: mise's native verification already ships;
  what is pending is this repo publishing attestations (#574). Drop the
  'SLSA provenance runs by default' overstatement.
- Make the mise use / mise.toml examples runnable (Zious11/jira-cli),
  with one fork-owner substitution sentence.
- Narrow the prerelease glob to the only channel this repo cuts (v*-dev.*).
- Note mise activate is assumed for the binary to land on PATH.
- Add the Windows-has-no-stable-asset-yet caveat (needs prerelease=true).
- Give an activation-free xattr fallback via 'mise where'.
Zious11 added a commit that referenced this pull request Jul 15, 2026
…-PR triage (D-178)

- BC-3.5.006 SATISFIED: spec v1.3.42 (bc-3-issue-write.md); EJ probe confirmed
  green by nightly run 29398774009 (test_e2e_comment_edit_visibility_merge_semantics
  ok on develop @ 56d5126); CHANGELOG MERGE-claim now empirically backed.
- Docs PR #625 (docs/577-ej-probe-closeout) CI 14/14 green; HELD for human
  review+merge (independent-review guard — classifier blocked pr-manager and
  orchestrator reviewer dispatch; session authored the PR; SELF-APPROVAL-GUARD
  2nd positive datapoint).
- Fresh PR reviews: #573 APPROVE @ 046ee4f (pr-review-2.md); #574 code review
  APPROVE-pending-rebase + security review APPROVE @ 3c37948 (pr-review-2.md +
  security-review-2.md; CHANGELOG-only conflict needs contributor rebase).
- PR triage: #591 MERGE-READY (9-day soak); #598/#599 HOLD-SOAK-2026-07-16;
  #612 HOLD-SOAK-2026-07-20; #624 HOLD-SOAK-2026-07-22.
- DEC-178: all-dependabot soak broadened to ALL dependabot PRs (cargo + Actions),
  not just third-party Actions bumps (broadens DEC-133).
- Drift items: FACTORY-DISPATCHER-HOOK-TIMEOUT (engine-side, LOW);
  SELF-APPROVAL-GUARD-2ND-DATAPOINT (positive control, CLOSED).
- STATE.md: 341 lines (cap 500); burst-log.md: F5-CONVERGED + F4-COMPLETE step
  rows archived per keep-5 rule; sidecar-learning.md session timestamps added.
@Zious11

Zious11 commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Thanks for the rework — this addresses the earlier feedback well. Fresh code and security reviews of head 3c37948 both pass:

  • ✅ The needs: build + download-artifact fan-in resolves the publish-then-attest (TOCTOU) concern — attested digests now provably match the published assets.
  • ATTESTATIONS_ENABLED opt-in gate is fail-safe and correctly decoupled from the release job.
  • attest-build-provenance pin verified as the genuine v4.1.1 tag commit.
  • ✅ Subject globs correctly scoped to *.tar.gz/*.zip.

One thing needed before merge: please rebase onto current develop and resolve the merge conflict. It's CHANGELOG-only — the v0.6.0-dev.10 release moved all entries into a dated section, leaving ## [Unreleased] empty, so your ### Added hunk no longer applies. Relocating your entry under the now-empty ## [Unreleased]### Added should be all it takes; the release.yml, CLAUDE.md, and spec hunks are unaffected.

Optional nit while you're in there (non-blocking): contents: read on the attest job appears unused — attest-build-provenance only needs id-token: write + attestations: write, and the artifact download goes through the Actions artifacts API. Feel free to drop it or reword the comment above it.

We'll approve the held workflow run and take a final look once the rebase lands. Note the attest job itself only triggers on v* tags, so it will first exercise for real on the next release tag after merge.

@arcaven

arcaven commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — the placement critique is right. Reworked in 3c37948.

Required

  1. In-workflow attestation, not download-after-publish. The attest job now fans in from the immutable build artifacts via actions/download-artifact (SHA-pinned, merge-multiple: true) and attests those — the same objects release uploads verbatim, so the attested digests match what users download, there's no publish-then-attest window, and the claim is builder-side provenance rather than consumer re-attestation. The GH_TOKEN/contents: read-for-download need is gone.

    One deviation from the literal "between build and release" shape, flagging for your call: I kept attest parallel to release (both needs: build) rather than upstream of it. With the fork gate in item 2, an attest sitting in release's needs would, when skipped on a fork, cascade and skip the fork's entire release (GitHub treats a skipped dependency as unmet). Parallel-from-build attests the identical immutable bytes without that failure mode. There's a comment on the job explaining this; happy to switch to a strict build→attest→release chain with an always()-style guard on release instead if you'd prefer the linear DAG.

  2. Fork opt-in gate. Gated on vars.ATTESTATIONS_ENABLED == 'true', and added the row to the variables table in docs/specs/fork-friendly-release-ops.md (plus the CLAUDE.md release-ops gate line, per the same-commit doc convention).

  3. Env-binding. Dissolved — with no gh release download there's no ${{ github.repository }} interpolation in a run: block anymore.

Recommended

  1. subject-path now lists release-assets/*.tar.gz + *.zip only; .sha256 sidecars excluded.
  2. Bumped to actions/attest-build-provenance@v4.1.1 (0f67c3f4856b2e3261c31976d6725780e5e4c373).
  3. Coverage boundary is now an inline comment on the attest step: sign-and-publish.yml re-signs the darwin binaries to different digests post-notarization, so those need their own attest step in that workflow (after codesign/notarize, before upload), gated on the same variable — out of scope here.
  4. CHANGELOG entry under [Unreleased] > Added.

Small notes

  • set -euo pipefail point is moot — no download script remains.
  • The old !cancelled() guard is gone with the redesign; the gate is now the fork variable plus needs: build.

@arcaven
arcaven dismissed Zious11’s stale review July 22, 2026 22:39

changes made, conflicts resolved best understanding

… contents:read

The update-branch merge auto-resolved the CHANGELOG hunk into the
already-released 0.6.0-dev.10 section; the entry belongs under the
now-empty [Unreleased] > Added per review. Also the optional nit:
attest-build-provenance needs only id-token + attestations — the
artifact download uses the Actions artifacts API, so contents: read
was unused.
@arcaven
arcaven requested a review from Zious11 July 23, 2026 00:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants