1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
name: Release
on:
push:
branches:
- main
# Manual re-publish escape hatch: lets the publish job run for the version
# currently on main when an automated release published the tag but the
# publish job failed downstream (e.g. a transient Sigstore/Fulcio outage).
workflow_dispatch:
permissions:
contents: write
pull-requests: write
# Serialise release runs so a push and a manual dispatch can't race into two
# concurrent cargo publish attempts. Never cancel an in-flight publish.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
# GitHub-hosted: this job inherits contents+PR write and mints an App
# installation token, so it must not share a writable build cache with other
# jobs — job-level `permissions:` does not isolate a filesystem, and untrusted
# build-script/proc-macro code could poison a cache a later credentialed job
# runs against. A fresh hosted VM has no shared cache to poison, and
# release-please needs no warm cache anyway.
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
- uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: googleapis/release-please-action@8b8fd2cc23b2e18957157a9d923d75aa0c6f6ad5 # v4
id: release
with:
token: ${{ steps.app-token.outputs.token }}
# release-please's config schema has no assignees option, so assign the
# release PR here. outputs.pr is set whenever the release PR was created
# OR updated, so this re-runs on every push to main while a release PR is
# open — adding an already-present assignee is a no-op, so that's safe.
# github-script, NOT `gh`: it calls the REST API in-process, so the step
# does not depend on the gh CLI being on the runner image.
# outputs.pr is passed raw via env and parsed in JS — never
# through template-position fromJson, which is evaluated even when if: is
# false and crashes on '' for no-release pushes (LAB-865).
- name: Assign release PR to 27Bslash6
if: ${{ steps.release.outputs.pr }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
PR_JSON: ${{ steps.release.outputs.pr }}
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
try {
const pr = JSON.parse(process.env.PR_JSON);
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
assignees: ['27Bslash6'],
});
core.info(`Assigned 27Bslash6 to release PR #${pr.number}`);
} catch (error) {
core.setFailed(`Failed to assign release PR in ${context.repo.owner}/${context.repo.repo} (PR_JSON length ${process.env.PR_JSON.length}): ${error.message}`);
}
# The untrusted-code half of a release. Tests and the verifying package
# build run dependency build scripts and proc-macros, so they run here, with
# no id-token and no crates.io token. `publish` below runs no dependency
# code. GitHub-hosted, like every job in this workflow.
verify:
needs: release-please
# Same gate as `publish`: an automated release, or a manual dispatch from
# main (workflow_dispatch can fire from any ref; requiring main stops a
# feature branch from driving a publish). Every job checks out github.sha,
# which is fixed for the run, so all three build the same commit.
# Explicit == 'true' avoids relying on string-coercion of the action output.
if: ${{ needs.release-please.outputs.release_created == 'true' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') }}
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Cache cargo registry
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Run tests before publish
run: cargo test --locked --all-features
- name: Package and verify the crate
# Builds the packaged crate — the build `publish` skips with --no-verify.
run: cargo package --locked
# cargo-sbom only reads `cargo metadata`, so this job builds nothing from the
# project: no dependency build script can rewrite the SBOM before upload.
sbom:
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
# `publish` downloads this exact upload and checks this digest, so an
# artifact replaced later in the run is refused rather than attested.
outputs:
artifact-id: ${{ steps.upload.outputs.artifact-id }}
sha256: ${{ steps.sbom.outputs.sha256 }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- name: Install Rust toolchain
run: |
rustup toolchain install stable --profile minimal
rustup default stable
- name: Install cargo-sbom
run: cargo install cargo-sbom --version 0.10.0 --locked
- name: Generate SBOM
id: sbom
# An empty SBOM fails the release: attesting it would claim a
# dependency-free crate.
run: |
cargo sbom --output-format cyclone_dx_json_1_6 > sbom.cdx.json
count=$(jq -e '.components | length' sbom.cdx.json) || {
echo "::error::SBOM is empty or not JSON"; exit 1; }
echo "SBOM components: $count"
if [ "${count:-0}" -lt 1 ]; then
echo "::error::SBOM lists no components"
exit 1
fi
echo "sha256=$(sha256sum sbom.cdx.json | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
id: upload
with:
name: sbom
path: sbom.cdx.json
if-no-files-found: error
publish:
# With no status function in `if:`, GitHub adds an implicit success(), so
# a failed or skipped verify or sbom skips the publish.
needs:
if: ${{ needs.release-please.outputs.release_created == 'true' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') }}
runs-on: ubuntu-latest
timeout-minutes: 30
# crates.io trusted publishing matches owner, repository, workflow file
# and this environment, never the branch; the environment's deployment
# branch policy admits main alone.
environment: release
permissions:
contents: read
id-token: write
attestations: write
steps:
# Every step here can mint a crates.io publish token, so nothing runs
# that doesn't need it: no dependency code (verification happened in
# `verify`), no cache, and no third-party action besides
# rust-lang/crates-io-auth-action, which mints the token. The rest are
# first-party actions/*.
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- name: Install Rust toolchain
run: |
rustup toolchain install stable --profile minimal
rustup default stable
- name: Package crate
# --no-verify: packaging alone runs no dependency code. cargo packages
# deterministically, so these are the bytes `cargo publish` uploads —
# the check after publishing proves it against crates.io.
run: |
cargo package --locked --no-verify
(cd target/package && sha256sum -- *.crate) > "$RUNNER_TEMP/attested.sha256"
cat "$RUNNER_TEMP/attested.sha256"
# Into runner.temp, never the checkout: cargo refuses to package or
# publish a tree with untracked files.
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
artifact-ids: ${{ needs.sbom.outputs.artifact-id }}
path: ${{ runner.temp }}/sbom
- name: Check the SBOM is the one `sbom` generated
env:
SBOM_SHA256: ${{ needs.sbom.outputs.sha256 }}
run: |
cd "$RUNNER_TEMP/sbom"
printf '%s %s\n' "$SBOM_SHA256" sbom.cdx.json | sha256sum --strict -c
# Attest before publishing: a crates.io upload is irreversible, so a
# Sigstore outage blocks the release instead of shipping it unattested.
- name: Attest Build Provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: target/package/*.crate
- name: Attest SBOM
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
with:
subject-path: target/package/*.crate
sbom-path: ${{ runner.temp }}/sbom/sbom.cdx.json
- name: Get crates.io token
# Exchanges this job's OIDC token for a 30-minute crates.io token; the
# action's post step revokes it when the job ends.
uses: rust-lang/crates-io-auth-action@3268eb413133e2960fa4ae0e963ca76380c54cd9 # v1.0.6
id: auth
- name: Publish to crates.io
run: cargo publish --locked --no-verify
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
- name: Check the uploaded crate is the attested file
# Downloads what crates.io now serves and compares it with the digest
# the attestations name. A mismatch means the attestations cover a
# file nobody can download. The crate is already live and a version
# cannot be re-uploaded, so a re-run cannot repair it: release a
# patch, then yank the version. This step makes the mismatch loud.
run: |
mkdir "$RUNNER_TEMP/uploaded"
for path in target/package/cachekit-core-[0-9]*.crate; do
file=${path##*/}
curl -sSf --connect-timeout 10 --max-time 60 --retry 5 --retry-all-errors --retry-delay 10 \
-o "$RUNNER_TEMP/uploaded/$file" "https://static.crates.io/crates/cachekit-core/$file"
done
cd "$RUNNER_TEMP/uploaded"
if ! sha256sum --strict -c ../attested.sha256; then
echo "::error::crates.io serves a .crate that differs from the attested file. The version is live and cannot be re-uploaded: do not re-run. Release a patch, then yank the version."
exit 1
fi