Skip to content

Commit 1f17a23

Browse files
perryqhclaude
andcommitted
chore(ci): harden the release supply chain
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>
1 parent 99d25cd commit 1f17a23

5 files changed

Lines changed: 218 additions & 54 deletions

File tree

.github/dependabot.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
version: 2
2+
updates:
3+
# Actions are pinned to full-length commit SHAs, which is the only way to
4+
# consume an action as an immutable release. Dependabot bumps both the SHA
5+
# and the trailing `# vX.Y.Z` comment, so the pins do not go stale.
6+
# https://docs.github.com/en/actions/reference/security/secure-use
7+
- package-ecosystem: github-actions
8+
directory: /
9+
schedule:
10+
interval: weekly
11+
groups:
12+
github-actions:
13+
patterns:
14+
- '*'

.github/workflows/audit.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
env:
2626
RUSTUP_TOOLCHAIN: stable
2727
steps:
28-
- uses: actions/checkout@v4
29-
- uses: rustsec/audit-check@v2.0.0
28+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
29+
- uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2.0.0
3030
with:
3131
token: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/ci.yml

Lines changed: 116 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,22 @@ env:
2020
permissions:
2121
contents: read
2222

23+
# Two pushes to main in quick succession would otherwise be able to run two
24+
# `release` jobs concurrently, and a draft release is not protected by tag
25+
# uniqueness the way a published one is (see the release job below).
26+
# On main, queue rather than cancel -- cancelling mid-release would leave a
27+
# partial draft. Everywhere else, superseding an in-flight run is what you want.
28+
concurrency:
29+
group: ${{ github.workflow }}-${{ github.ref }}
30+
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
31+
2332
jobs:
2433
check:
2534
name: Check
2635
runs-on: ubuntu-latest
2736
steps:
2837
- name: Checkout sources
29-
uses: actions/checkout@v4
38+
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
3039

3140
- name: Run cargo check
3241
run: cargo check
@@ -35,7 +44,7 @@ jobs:
3544
runs-on: ubuntu-latest
3645
steps:
3746
- name: Checkout sources
38-
uses: actions/checkout@v4
47+
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
3948

4049
- name: Run cargo test with backtrace
4150
run: cargo test -- --nocapture
@@ -48,16 +57,22 @@ jobs:
4857
RUSTFLAGS: "-Dwarnings"
4958
steps:
5059
- name: Checkout sources
51-
uses: actions/checkout@v4
60+
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
5261

5362
- name: Run cargo fmt
5463
run: cargo fmt --all -- --check
5564

5665
- name: Run cargo clippy
5766
run: cargo clippy --all-targets --all-features
5867

68+
# The release is created as a DRAFT and only published once every asset --
69+
# binaries plus the generated DotSlash files -- has been uploaded. This
70+
# ordering is required for immutable releases: once a release is published,
71+
# GitHub rejects further asset uploads with
72+
# "Cannot upload assets to an immutable release".
73+
# See https://github.com/actions/attest-build-provenance/issues/734
5974
release:
60-
runs-on: macos-latest
75+
runs-on: ubuntu-latest
6176
permissions:
6277
contents: write
6378
needs:
@@ -69,19 +84,12 @@ jobs:
6984
changed: ${{ steps.check_for_version_changes.outputs.changed }}
7085
if: github.ref == 'refs/heads/main'
7186
steps:
72-
- uses: actions/checkout@v4
87+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
7388
with:
7489
# https://stackoverflow.com/questions/65944700/how-to-run-git-diff-in-github-actions
7590
# TLDR – By default this action fetches no history.
7691
# We need a bit of history to be able to check if we've recently updated the version in Cargo.toml
7792
fetch-depth: 2
78-
- name: Toolchain info
79-
run: |
80-
cargo --version --verbose
81-
rustc --version
82-
cargo clippy --version
83-
- name: Build
84-
run: cargo build --release --target aarch64-apple-darwin --target x86_64-apple-darwin
8593
- name: Check for version changes in Cargo.toml
8694
id: check_for_version_changes
8795
run: |
@@ -97,59 +105,121 @@ jobs:
97105
echo "changed=false" >> $GITHUB_OUTPUT
98106
fi
99107
100-
- name: Create GitHub Release if current commit has updated the version in Cargo.toml
108+
- name: Create draft GitHub Release if current commit has updated the version in Cargo.toml
101109
if: steps.check_for_version_changes.outputs.changed == 'true'
102110
run: |
103-
gh release create ${{steps.check_for_version_changes.outputs.new_version}} --target "${{ github.sha }}" --generate-notes
111+
# A draft release does not create a git tag, so GitHub does NOT enforce
112+
# tag uniqueness for drafts -- `gh release create` would happily make a
113+
# second draft with the same pending tag name, and the DotSlash
114+
# generator resolves whichever one the API returns first. That is
115+
# reachable via "Re-run all jobs" after a failed upload, so reuse an
116+
# existing draft instead of creating a duplicate.
117+
if [[ "$(gh release view "$NEW_VERSION" --json isDraft --jq .isDraft 2>/dev/null)" == "true" ]]; then
118+
echo "Draft $NEW_VERSION already exists; reusing it."
119+
else
120+
gh release create "$NEW_VERSION" --target "$COMMIT_SHA" --generate-notes --draft
121+
fi
104122
env:
105123
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
124+
NEW_VERSION: ${{ steps.check_for_version_changes.outputs.new_version }}
125+
COMMIT_SHA: ${{ github.sha }}
126+
106127
upload-mac-universal-bin:
107128
needs: release
108129
runs-on: macos-latest
109130
permissions:
110131
contents: write
111-
if: ${{needs.release.outputs.new_version}}
132+
# Required by actions/attest-build-provenance: id-token to mint the OIDC
133+
# token for the Sigstore signing certificate, attestations to persist the
134+
# resulting attestation.
135+
id-token: write
136+
attestations: write
137+
if: needs.release.outputs.new_version != ''
112138
steps:
113-
- uses: actions/checkout@v4
139+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
114140
- name: Build
115141
run: cargo build --release --target aarch64-apple-darwin --target x86_64-apple-darwin
116142

117-
- name: Upload mac universal binary
143+
- name: Create universal binary
118144
run: |
119145
# This combines the intel and m1 binaries into a single binary
120146
lipo -create -output target/codeowners target/aarch64-apple-darwin/release/codeowners target/x86_64-apple-darwin/release/codeowners
121147
148+
# lipo carries over the per-architecture linker (ad-hoc) signatures,
149+
# but Apple recommends re-signing the merged binary so it carries a
150+
# single coherent signature. All code on Apple silicon must be at
151+
# least ad-hoc signed or it is SIGKILLed on launch, so verify rather
152+
# than assume. https://developer.apple.com/forums/thread/708552
153+
#
154+
# `--sign -` is the ad-hoc identity, not a placeholder for a cert
155+
# name: no certificate, no keychain, no Developer ID, nothing to
156+
# provision in CI. It only makes the binary internally consistent and
157+
# asserts nothing about who built it -- provenance comes from the
158+
# attestation step below, not from codesign.
159+
codesign --force --sign - target/codeowners
160+
codesign --verify --verbose target/codeowners
161+
122162
# Creates artifact for homebrew. -C means run from `target` directory
163+
# NOTE: any mutation of the binary must happen BEFORE signing, and
164+
# any repackaging must happen BEFORE attestation, or the digest the
165+
# attestation covers will not match what users download.
123166
tar -czf target/codeowners-mac.tar.gz -C target codeowners
124167
125-
# This tarball is a binary that is executable
126-
gh release upload $NEW_VERSION target/codeowners-mac.tar.gz
168+
- name: Attest build provenance
169+
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
170+
with:
171+
subject-path: target/codeowners-mac.tar.gz
127172

173+
- name: Upload mac universal binary
174+
run: |
175+
# This tarball is a binary that is executable.
176+
# --clobber so re-running after a partial failure replaces the asset
177+
# instead of failing on "asset already exists".
178+
gh release upload "$NEW_VERSION" target/codeowners-mac.tar.gz --clobber
128179
env:
129180
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
130181
NEW_VERSION: ${{ needs.release.outputs.new_version }}
131182

132183
upload-linux-bin:
133184
needs: release
134-
if: ${{needs.release.outputs.new_version}}
185+
if: needs.release.outputs.new_version != ''
135186
runs-on: ubuntu-latest
136187
permissions:
137188
contents: write
189+
id-token: write
190+
attestations: write
138191
steps:
139-
- uses: actions/checkout@v4
192+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
140193
- name: Update local toolchain
141194
run: |
142-
cargo install cross
195+
# Pinned so a future cross release cannot change how release binaries
196+
# are built without a reviewed commit. 0.2.5 is what an unpinned
197+
# `cargo install cross` resolves to today, so this is not a version
198+
# bump. Deliberately not --locked: cross 0.2.5's lockfile predates
199+
# current rustc and this build cannot be exercised outside `main`.
200+
cargo install cross --version 0.2.5
143201
- name: Build linux binaries
144202
run: |
145203
cross build --release --target x86_64-unknown-linux-gnu
146204
cross build --release --target aarch64-unknown-linux-gnu
147-
- name: Upload linux binaries
205+
- name: Package linux binaries
148206
run: |
149207
tar -czf target/x86_64-unknown-linux-gnu.tar.gz -C target/x86_64-unknown-linux-gnu/release codeowners
150208
tar -czf target/aarch64-unknown-linux-gnu.tar.gz -C target/aarch64-unknown-linux-gnu/release codeowners
151-
gh release upload $NEW_VERSION target/x86_64-unknown-linux-gnu.tar.gz
152-
gh release upload $NEW_VERSION target/aarch64-unknown-linux-gnu.tar.gz
209+
210+
- name: Attest build provenance
211+
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
212+
with:
213+
subject-path: |
214+
target/x86_64-unknown-linux-gnu.tar.gz
215+
target/aarch64-unknown-linux-gnu.tar.gz
216+
217+
- name: Upload linux binaries
218+
run: |
219+
# --clobber so re-running after a partial failure replaces the assets
220+
# instead of failing on "asset already exists".
221+
gh release upload "$NEW_VERSION" target/x86_64-unknown-linux-gnu.tar.gz --clobber
222+
gh release upload "$NEW_VERSION" target/aarch64-unknown-linux-gnu.tar.gz --clobber
153223
env:
154224
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
155225
NEW_VERSION: ${{ needs.release.outputs.new_version }}
@@ -160,13 +230,13 @@ jobs:
160230
- release
161231
- upload-linux-bin
162232
- upload-mac-universal-bin
163-
if: success() && ${{needs.release.outputs.new_version}}
233+
if: needs.release.outputs.new_version != ''
164234
runs-on: ubuntu-latest
165235
permissions:
166236
contents: write
167237

168238
steps:
169-
- uses: facebook/dotslash-publish-release@v1
239+
- uses: facebook/dotslash-publish-release@2539c4d8ae00a42773306c8731d2dd3724d979d2 # v1
170240
# This is necessary because the action uses
171241
# `gh release upload` to publish the generated DotSlash file(s)
172242
# as part of the release.
@@ -178,3 +248,22 @@ jobs:
178248
config: .github/workflows/dotslash-config.json
179249
# Tag for the release to target.
180250
tag: ${{ needs.release.outputs.new_version }}
251+
252+
# Publishing last is what makes immutable releases workable: every asset is
253+
# in place before the release becomes visible and frozen.
254+
publish-release:
255+
name: Publish the release
256+
needs:
257+
- release
258+
- generate-dotslash-files
259+
if: needs.release.outputs.new_version != ''
260+
runs-on: ubuntu-latest
261+
permissions:
262+
contents: write
263+
steps:
264+
- name: Undraft the release
265+
run: |
266+
gh release edit "$NEW_VERSION" --draft=false --repo "$GITHUB_REPOSITORY"
267+
env:
268+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
269+
NEW_VERSION: ${{ needs.release.outputs.new_version }}
Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,29 @@
11
{
2-
"outputs": {
3-
"codeowners": {
4-
"platforms": {
5-
"macos-x86_64": {
6-
"regex": "^codeowners-mac",
7-
"path": "codeowners",
8-
"format": "tar.gz"
9-
},
10-
"macos-aarch64": {
11-
"regex": "^codeowners-mac",
12-
"path": "codeowners",
13-
"format": "tar.gz"
14-
},
15-
"linux-x86_64": {
16-
"regex": "^x86_64-unknown-linux",
17-
"path": "codeowners",
18-
"format": "tar.gz"
19-
},
20-
"linux-aarch64": {
21-
"regex": "^aarch64-unknown-linux",
22-
"path": "codeowners",
23-
"format": "tar.gz"
24-
}
2+
"exclude-http-provider": true,
3+
"outputs": {
4+
"codeowners": {
5+
"platforms": {
6+
"macos-x86_64": {
7+
"name": "codeowners-mac.tar.gz",
8+
"path": "codeowners",
9+
"format": "tar.gz"
10+
},
11+
"macos-aarch64": {
12+
"name": "codeowners-mac.tar.gz",
13+
"path": "codeowners",
14+
"format": "tar.gz"
15+
},
16+
"linux-x86_64": {
17+
"name": "x86_64-unknown-linux-gnu.tar.gz",
18+
"path": "codeowners",
19+
"format": "tar.gz"
20+
},
21+
"linux-aarch64": {
22+
"name": "aarch64-unknown-linux-gnu.tar.gz",
23+
"path": "codeowners",
24+
"format": "tar.gz"
2525
}
2626
}
2727
}
2828
}
29+
}

0 commit comments

Comments
 (0)