chore(ci): harden the release supply chain - #128
Conversation
|
Applied the review findings in 81a4041:
I verified the idempotency shell logic against all three states (no release / existing draft / existing published release), including that Not applied, deliberately:
|
There was a problem hiding this comment.
Pull request overview
This PR hardens the release supply chain for codeowners distribution via DotSlash by making the DotSlash file’s provenance reviewable and by strengthening the CI release workflow (draft-first publishing, provenance attestations, and pinned actions).
Changes:
- Updated README guidance to commit the DotSlash file in-repo and explain why that’s necessary for digest verification to be meaningful.
- Restructured the release workflow to create draft releases first, upload/attest packaged assets, generate DotSlash files, then publish the release.
- Pinned third-party GitHub Actions to commit SHAs and added Dependabot configuration to keep those pins updated.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| README.md | Updates DotSlash usage and adds guidance for independently verifying release provenance. |
| .github/workflows/dotslash-config.json | Switches DotSlash generator selection from regex matching to exact asset names. |
| .github/workflows/ci.yml | Implements draft→upload/attest→dotslash→publish flow; adds concurrency control; pins actions to SHAs. |
| .github/workflows/audit.yml | Pins actions to SHAs for the security audit workflow. |
| .github/dependabot.yml | Adds Dependabot updates for GitHub Actions SHA pins. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| ```sh | ||
| gh attestation verify codeowners-mac.tar.gz \ | ||
| --repo rubyatscale/codeowners-rs \ | ||
| --signer-workflow rubyatscale/codeowners-rs/.github/workflows/ci.yml |
There was a problem hiding this comment.
Checked this against a live attestation rather than the docs, and the current README form is the correct one — the suggested change would break verification.
gh's own help text specifies the format as owner/repo/path, not a repo-relative path:
--signer-workflow string Enforce that the workflow that signed the attestation
matches the provided value
([host/]<owner>/<repo>/<path>/<to>/<workflow>)
I ran both forms against a real attested codeowners-mac.tar.gz from a full pipeline run in a throwaway repo:
# README's form — passes
gh attestation verify codeowners-mac.tar.gz \
--repo OWNER/REPO --signer-workflow OWNER/REPO/.github/workflows/ci.yml
# exit 0
# suggested form — fails
gh attestation verify codeowners-mac.tar.gz \
--repo OWNER/REPO --signer-workflow .github/workflows/ci.yml
# Error: verifying with issuer "sigstore.dev"
# exit 1The reason is that --signer-workflow is matched against the SAN in the Fulcio signing certificate, which is a full URI identifying the workflow including its repository — it is not a path lookup within --repo. So the owner/repo/ prefix is load-bearing, and --repo does not supply it.
Leaving the README as-is. Thanks for the flag though — this one is worth having pinned down explicitly, since the failure mode is a verification command that looks stricter but silently never matches.
Binaries are distributed via DotSlash, which verifies only the `size` and BLAKE3 `digest` recorded in the DotSlash file -- there is no signature check anywhere in the tool. The digest is the whole mechanism, and it is only as trustworthy as the file holding it. This reworks how that file is produced and consumed, and closes the gaps around it. README: - Commit the DotSlash file and upgrade via a reviewed diff. Re-fetching it next to the binary means trusting whatever the release currently claims, so the digest verifies nothing. This is DotSlash's own guidance. - Document `gh attestation verify`, and note that ad-hoc signing does not satisfy Gatekeeper for browser downloads. Pipeline: - Create the release as a draft, publish only once every asset has landed. Required for immutable releases, which reject uploads to a published release. - Attest build provenance after packaging, so the digest the attestation covers matches what users download. - Pin third-party actions to commit SHAs; add dependabot to keep the pins fresh. Pin `cross` to 0.2.5. - Match DotSlash assets by exact name. An unanchored prefix regex could select the generated DotSlash file itself as a binary on a re-run. - Set `exclude-http-provider`. Generating against a draft bakes an `untagged-<hash>` asset URL into the published file; it 404s once the real tag exists and cannot be corrected under immutable releases. - Re-sign after `lipo` so the universal binary carries one coherent ad-hoc signature, and verify rather than assume. - Reuse an existing draft instead of creating a duplicate, `--clobber` on uploads, and a concurrency group that queues on main. Known gap: macOS Developer ID signing and notarization. Ad-hoc signing asserts nothing about who built the binary; provenance currently comes from the attestations. Tracked separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7d3c19b to
1f17a23
Compare
Why
We distribute
codeownersvia DotSlash. Readingverify_artifact()settled what actually protects the binaries: DotSlash checks thesizeand BLAKE3/SHA-256digestrecorded in the DotSlash file, and nothing else. There is no signature verification anywhere in the tool.So for the DotSlash path the digest is the mechanism, and it is only as trustworthy as the file holding it. That reframes the work around how that file is generated and consumed.
The main fix is documentation
README.mdtold users to download the DotSlash file from a release and run it. If you re-fetch the DotSlash file next to the binary every time, the digest verifies nothing — you're trusting whatever the release currently claims, and an attacker who can modify the release swaps both.DotSlash's own motivation doc is explicit: "the provenance of a DotSlash file is paramount" and DotSlash files "are meant to live in source control." The README now says to commit the file and upgrade via a reviewed diff, and explains why.
Pipeline changes
--draftand published only after every asset lands. Prerequisite for immutable releases: once published,gh release uploadfails withCannot upload assets to an immutable release(attest-build-provenance#734).actions/attest-build-provenance, attested after packaging so the covered digest matches what users download.exclude-http-provideron the DotSlash config. Generating against a draft bakes a permanently dead provider URL into the published file. A draft has no git tag, so GitHub serves assets under anuntagged-<hash>path and the generator records that verbatim. Publishing rewrites the real URLs, but the DotSlash file is already uploaded — and under immutable releases it cannot be replaced. Found by running the pipeline, not by reading it (see Testing).^codeowners-macis prefix-only and unanchored, and the generator takes the first match over dict order. The generated DotSlash file is itself namedcodeowners, so on a re-run a prefix pattern could select it as the binary..github/dependabot.ymlso the pins don't go stale. Onlycodeql.ymlstays on@main, which already carries a documentedzizmor: ignore.crosspinned to 0.2.5.lipo, per Apple DTS guidance, withcodesign --verifyso a regression fails in CI rather than for a user.--clobberon uploads, and a concurrency group that queues onmainrather than cancelling mid-release.if: success() && ${{ ... }}on the DotSlash job — mixing a bare expression with an interpolation doesn't evaluate as written.Testing
Local: clippy,
cargo fmt --check, and the full test suite pass (this PR touches no Rust).The release path doesn't run outside
main, so I ran the whole pipeline twice in a throwaway public repo with Immutable releases enabled:dotslash-publish-releasedoes read assets from a draft release. This was the main open question.check/test/lints→release(draft) → both uploads + attest →generate-dotslash-files→publish-release.draft: false, immutable: truewith all four assets, confirming the ordering is what makes immutable releases workable.gh attestation verifypasses against the published macOS tarball.dotslash codeowners --version→codeowners 0.3.6. That covers the consumer path end to end — provider resolution,sizeand BLAKE3 enforcement, unpack, run. It also confirms the post-lipore-sign is valid, since unsigned code is SIGKILLed on Apple silicon rather than failing quietly.--generate-notesand the resolve-draft-by-pending-tag path were exercised with history rather than on an empty repo.The first run is what surfaced the
untagged-provider bug: it shipped a DotSlash file whose primary provider 404'd on all four platforms. Afterexclude-http-provider, the regenerated file carries exactly onegithub-releaseprovider per platform.Still unverified: the re-run-after-partial-failure path. The idempotency logic is written for it, but a green run never takes that branch.
Known gap: Developer ID signing
macOS binaries are ad-hoc signed only. Ad-hoc signing satisfies Apple silicon's requirement that all code be signed, but it asserts nothing about who built the binary and does not satisfy Gatekeeper — so a browser download (which sets
com.apple.quarantine, unlike DotSlash orcurl) is blocked. The README now documents this and thexattr -dworkaround.Real Developer ID signing plus notarization is the fix, and
Gusto/gusto-clialready does it from a Linux runner viarcodesign. Porting that here needs a decision about where the signing credential lives, since this is a public repo outside the Gusto org. Tracked separately from this PR.Requires a manual step
Enable Settings → General → Immutable releases after merge. The draft→publish restructure exists to make that safe; before this change, enabling it would have broken the next version bump.