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
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
# The production release workflow.
#
# This is the only workflow that may enter the `release` environment,
# and the only one configured as a crates.io Trusted Publisher. Its scope comes
# entirely from the reviewed `.release/release.toml` at the commit being
# released; there is deliberately no version, unit, or scope input, so a
# dispatch cannot widen or narrow what was reviewed.
#
# The phases exist to put every reversible action before every irreversible one:
#
# B plan and validate nothing has happened yet
# C build, stage drafts drafts can be deleted; no tag, no publication
# -- approval gate -- the point of no return
# D tag and publish irreversible from the first uploaded crate
# E finalize releases become immutable
name: release
# No inputs, deliberately. The scope comes from the reviewed
# `.release/release.toml` at the commit being released, so a dispatch cannot
# widen or narrow what was reviewed.
#
# There is no "resume" input either: resuming *is* re-dispatching. Every attempt
# reconciles against live registry and release state first and does only what is
# missing, so a first attempt and a resume are the same run. An input offering a
# mode that behaves no differently would be a control that does nothing.
on:
workflow_dispatch:
permissions:
# One release at a time, repository-wide. Note that this is a convenience, not
# the guard: GitHub cancels a *pending* run when a newer one enters the group,
# so correctness rests on the executor's own overlap check against live state.
concurrency:
group: release
cancel-in-progress: false
jobs:
# ---------------------------------------------------------------- Phase B --
plan:
name: plan
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
subject: ${{ steps.subject.outputs.sha }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
persist-credentials: false
-
name: Install Rust
run: |
rustup update --no-self-update
rustc --version
-
name: Build release-tool
run: cargo install --locked --force miden-release-tool
# The subject is GITHUB_SHA -- the commit this run checked out -- and it
# must be main's tip. Attestations are built from the run's own claims, so
# defining the subject as anything else would attest the wrong commit.
- name: Validate the subject
id: subject
run: |
set -euo pipefail
tip=$(git rev-parse origin/main)
if [ "${GITHUB_SHA}" != "${tip}" ]; then
echo "subject ${GITHUB_SHA} is not main's tip (${tip}); refresh the candidate" >&2
exit 1
fi
# Nothing may have landed since the reviewed candidate. --first-parent
# asks the question that matters -- which commit on main changed this
# file -- rather than following into merged branches.
declared=$(git log --first-parent -1 --format=%H -- .release/release.toml)
if [ "${declared}" != "${GITHUB_SHA}" ]; then
echo "the release declaration was last changed in ${declared}, not ${GITHUB_SHA};" >&2
echo "something landed on main after the candidate merged" >&2
exit 1
fi
echo "sha=${GITHUB_SHA}" >> "${GITHUB_OUTPUT}"
- name: Lint the candidate
run: release-tool lint
- name: Generate the intent
run: |
release-tool plan \
--subject "${GITHUB_SHA}" --output "${RUNNER_TEMP}/intent.json"
cat "${RUNNER_TEMP}/intent.json"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: release-intent
path: ${{ runner.temp }}/intent.json
if-no-files-found: error
verify:
name: verify
needs: plan
# The ceiling for every job in the called workflow; see release-ci.yml.
permissions:
contents: read
uses: ./.github/workflows/release-verify.yml
with:
ref: ${{ needs.plan.outputs.subject }}
full: true
# ---------------------------------------------------------------- Phase C --
stage:
name: seal and stage
needs:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: ${{ needs.plan.outputs.subject }}
persist-credentials: false
- *install-rust
- *install-release-tool
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: release-intent
path: ${{ runner.temp }}
# Packages every selected crate and seals their digests into the plan.
# This job holds no credentials: packaging runs subject build scripts, and
# they must never run beside a token.
- name: Seal the plan
run: |
release-tool seal \
--intent "${RUNNER_TEMP}/intent.json" \
--output "${RUNNER_TEMP}/plan.json" \
--cache-dir "${RUNNER_TEMP}/index-cache"
#- name: Build the template bundle
# run: |
# release-tool bundle \
# --output "${RUNNER_TEMP}/templates.tar.gz"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: release-plan
path: |
${{ runner.temp }}/plan.json
# ${{ runner.temp }}/templates.tar.gz
if-no-files-found: error
# Build each executable on its own native runner. These jobs hold no
# credentials and no OIDC: they run subject build scripts, which must never
# happen beside a token or a token-minting capability.
artifacts:
name: build ${{ matrix.binary }} (${{ matrix.target }})
needs:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
# Explicit runner labels rather than `*-latest`, so an image rotation
# does not silently change what the released bytes were built on.
include:
-
-
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: ${{ needs.plan.outputs.subject }}
persist-credentials: false
- name: Install Rust
run: |
rustup update --no-self-update
rustup target add ${{ matrix.target }}
rustc --version
- *install-release-tool
- name: Build
env:
# Incremental output is not reproducible and is pointless for a
# one-shot build.
CARGO_INCREMENTAL: "0"
run: cargo build --release --locked --target ${{ matrix.target }} -p ${{ matrix.binary }} --bin ${{ matrix.binary }}
- name: Smoke test
# Only meaningful when the host can run what was built.
if: ${{ matrix.target == 'x86_64-unknown-linux-gnu' || matrix.runner == 'macos-15' }}
env:
BINARY: ${{ matrix.binary }}
TARGET: ${{ matrix.target }}
run: |
set -euo pipefail
"./target/${TARGET}/release/${BINARY}" --version
- name: Archive
run: |
release-tool archive-binary \
--binary "target/${{ matrix.target }}/release/${{ matrix.binary }}" \
--name "${{ matrix.binary }}" \
--output "${RUNNER_TEMP}/${{ matrix.binary }}-${{ matrix.target }}.tar.gz"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: artifact-${{ matrix.binary }}-${{ matrix.target }}
path: ${{ runner.temp }}/${{ matrix.binary }}-${{ matrix.target }}.tar.gz
if-no-files-found: error
# Attestation is separated from building on purpose: this job needs
# `id-token: write`, and it must not be a job that has executed subject code.
# It only downloads bytes and attests them.
attest:
name: attest artifacts
# Depends on the build, not on staging: attestation needs the bytes, and
# coupling it to draft creation would serialize two independent things.
needs:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
attestations: write
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: artifact-*
merge-multiple: true
path: ${{ runner.temp }}/artifacts
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
with:
subject-path: ${{ runner.temp }}/artifacts/*.tar.gz
# Collect the artifacts and populate the drafts. Still reversible: the drafts
# can be deleted, no tag exists, and nothing has been published.
stage-artifacts:
name: stage drafts
needs:
runs-on: ubuntu-latest
permissions:
contents: write # draft creation and asset upload
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: ${{ needs.plan.outputs.subject }}
persist-credentials: false
- *install-rust
- *install-release-tool
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: release-plan
path: ${{ runner.temp }}
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: artifact-*
merge-multiple: true
path: ${{ runner.temp }}/artifacts
#- name: Collect the template bundle
# run: cp "${RUNNER_TEMP}/templates.tar.gz" "${RUNNER_TEMP}/artifacts/"
# Uploads every asset and reads each one back, while the release is still
# a draft and a mismatch can still be fixed.
- name: Create and populate drafts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
release-tool stage \
--plan "${RUNNER_TEMP}/plan.json" \
--artifacts "${RUNNER_TEMP}/artifacts"
# ---------------------------------------------------------------- Phase D --
publish:
name: publish
needs:
runs-on: ubuntu-latest
# The approval gate. Everything before this is reversible; nothing after it
# is. Deployment is restricted to protected main by the environment itself.
environment: release
permissions:
contents: write # tag creation
id-token: write # Trusted Publishing
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: ${{ needs.plan.outputs.subject }}
persist-credentials: false
- *install-rust
- *install-release-tool
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: release-plan
path: ${{ runner.temp }}
# Exchanged immediately before publication and revoked by the action's
# post-step, because the token lives 30 minutes and Cargo spends most of a
# stage waiting for index confirmation.
- name: Obtain a Trusted Publishing token
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
id: auth
# Tokens are passed through the environment, never as arguments: process
# arguments are readable by every other process on the machine.
#
# GITHUB_TOKEN is here because each unit's tag is created immediately
# before that unit's crates are published -- not for all units up front,
# which would let a permanent failure in the SDK stage burn the compiler's
# tag as well.
- name: Tag and publish
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
release-tool publish \
--plan "${RUNNER_TEMP}/plan.json" \
--journal "${RUNNER_TEMP}/journal.json"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: always()
with:
name: release-journal
path: ${{ runner.temp }}/journal.json
if-no-files-found: warn
# ---------------------------------------------------------------- Phase E --
# Verify every staged draft and publish it. A published release is immutable,
# so this is the one step that cannot be undone even in principle -- every
# unit is verified before any unit is published.
#
# No crates.io credential and no OIDC: nothing here touches the registry.
finalize:
name: finalize
needs:
runs-on: ubuntu-latest
environment: release
permissions:
contents: write # publishing a release
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: ${{ needs.plan.outputs.subject }}
persist-credentials: false
- *install-rust
- *install-release-tool
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: release-plan
path: ${{ runner.temp }}
- name: Verify and publish the drafts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
release-tool finalize --plan "${RUNNER_TEMP}/plan.json"