2020permissions :
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+
2332jobs :
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
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 }}
0 commit comments