返回 DeepSeek-Reasonix
release-promote.yml
根目录 / .github / workflows / release-promote.yml
1 name: Publish release candidate
2 run-name: ${{ inputs.operation }} ${{ inputs.candidate_id }}
3
4 on:
5 workflow_dispatch:
6 inputs:
7 candidate_id:
8 description: "Sealed candidate from Prepare release candidate"
9 required: true
10 type: string
11 operation:
12 description: "Publish a new identity or recover an interrupted publication"
13 required: true
14 default: publish
15 type: choice
16 options: [publish, recover]
17
18 concurrency:
19 group: stable-release-publication
20 cancel-in-progress: false
21
22 permissions:
23 actions: write
24 attestations: read
25 contents: write
26
27 jobs:
28 preflight:
29 name: verify sealed candidate before approval
30 runs-on: ubuntu-latest
31 permissions:
32 actions: read
33 attestations: read
34 contents: read
35 outputs:
36 candidate_id: ${{ steps.record.outputs.candidate_id }}
37 version: ${{ steps.record.outputs.version }}
38 source_sha: ${{ steps.record.outputs.source_sha }}
39 producer_run_id: ${{ steps.record.outputs.producer_run_id }}
40 producer_run_attempt: ${{ steps.record.outputs.producer_run_attempt }}
41 candidate_control_sha: ${{ steps.record.outputs.candidate_control_sha }}
42 signing_fingerprint: ${{ steps.record.outputs.signing_fingerprint }}
43 desktop_prefix: ${{ steps.record.outputs.desktop_prefix }}
44 approved_artifact: approved-release-candidate-${{ github.run_id }}-${{ github.run_attempt }}
45 steps:
46 - uses: actions/checkout@v7
47 with:
48 fetch-depth: 0
49 ref: ${{ github.sha }}
50 - uses: actions/setup-node@v7
51 with:
52 node-version: "22"
53 - name: Resolve candidate record artifact
54 id: artifact
55 env:
56 GH_TOKEN: ${{ github.token }}
57 RELEASE_REVOKED_CANDIDATES: ${{ vars.RELEASE_REVOKED_CANDIDATES }}
58 run: node scripts/resolve-release-candidate.mjs resolve "${{ inputs.candidate_id }}"
59 - name: Download exact candidate record
60 env:
61 GH_TOKEN: ${{ github.token }}
62 ARTIFACT_ID: ${{ steps.artifact.outputs.record_artifact_id }}
63 PRODUCER_RUN_ID: ${{ steps.artifact.outputs.producer_run_id }}
64 run: |
65 mkdir -p record
66 gh api "repos/${{ github.repository }}/actions/artifacts/$ARTIFACT_ID" > /tmp/record-artifact.json
67 gh api "repos/${{ github.repository }}/actions/runs/$PRODUCER_RUN_ID" > /tmp/producer-run.json
68 gh api "repos/${{ github.repository }}/actions/artifacts/$ARTIFACT_ID/zip" > /tmp/record.zip
69 node scripts/verify-release-artifact-archive.mjs /tmp/record-artifact.json /tmp/record.zip
70 unzip -q /tmp/record.zip -d record
71 test -s record/record.json
72 - name: Verify record provenance and resolve exact payload
73 id: record
74 env:
75 GH_TOKEN: ${{ github.token }}
76 run: |
77 signer_sha="$(jq -r .head_sha /tmp/producer-run.json)"
78 gh attestation verify record/record.json --repo "$GITHUB_REPOSITORY" \
79 --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-candidate.yml" \
80 --signer-digest "$signer_sha" --source-ref refs/heads/main-v2 --deny-self-hosted-runners
81 node scripts/resolve-release-candidate.mjs inspect "${{ inputs.candidate_id }}" \
82 record/record.json /tmp/record-artifact.json /tmp/producer-run.json
83 - name: Download exact candidate payload
84 env:
85 GH_TOKEN: ${{ github.token }}
86 PAYLOAD_ID: ${{ steps.record.outputs.payload_artifact_id }}
87 run: |
88 gh api "repos/${{ github.repository }}/actions/artifacts/$PAYLOAD_ID" > /tmp/payload-artifact.json
89 test "$(jq -r .name /tmp/payload-artifact.json)" = "${{ steps.record.outputs.payload_artifact_name }}"
90 test "$(jq -r .expired /tmp/payload-artifact.json)" = false
91 test "$(jq -r .workflow_run.id /tmp/payload-artifact.json)" = "${{ steps.record.outputs.producer_run_id }}"
92 mkdir -p payload
93 gh api "repos/${{ github.repository }}/actions/artifacts/$PAYLOAD_ID/zip" > /tmp/payload.zip
94 node scripts/verify-release-artifact-archive.mjs /tmp/payload-artifact.json /tmp/payload.zip
95 unzip -q /tmp/payload.zip -d payload
96 - name: Verify payload provenance, bytes, source, and operation
97 env:
98 GH_TOKEN: ${{ github.token }}
99 OPERATION: ${{ inputs.operation }}
100 VERSION: ${{ steps.record.outputs.version }}
101 SOURCE_SHA: ${{ steps.record.outputs.source_sha }}
102 run: |
103 set -euo pipefail
104 while IFS= read -r -d '' file; do
105 gh attestation verify "$file" --repo "$GITHUB_REPOSITORY" \
106 --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-candidate.yml" \
107 --signer-digest "${{ steps.record.outputs.candidate_control_sha }}" \
108 --source-ref refs/heads/main-v2 --deny-self-hosted-runners >/dev/null
109 done < <(find payload -type f -print0)
110 node scripts/release-candidate.mjs verify payload record/record.json >/dev/null
111 git fetch origin main-v2 --tags
112 git merge-base --is-ancestor "$SOURCE_SHA" origin/main-v2
113 git show "$SOURCE_SHA:release-notes/releases.json" > /tmp/catalog.json
114 test "$(sha256sum /tmp/catalog.json | awk '{print $1}')" = "$(jq -r .notes.catalogSha256 record/record.json)"
115 bash scripts/release-candidate-tags.sh check "$OPERATION" "$VERSION" "$SOURCE_SHA"
116 {
117 echo "### Sealed release approval"
118 echo
119 echo "- Candidate: \`${{ inputs.candidate_id }}\`"
120 echo "- Operation: \`$OPERATION\`"
121 echo "- Product SHA: \`$SOURCE_SHA\`"
122 echo "- Candidate record SHA-256: \`$(sha256sum record/record.json | awk '{print $1}')\`"
123 echo "- Notes SHA-256: \`$(jq -r .notes.renderedSha256 record/record.json)\`"
124 echo "- Signing policy: \`$(jq -r .signing.desktopFingerprint record/record.json)\`"
125 echo "- Payload files: \`$(jq '.files | length' record/record.json)\`"
126 echo "- Native acceptance: \`$(jq -r '[.acceptance[] | select(.status == "passed") | .kind] | join(", ")' record/record.json)\`"
127 } >> "$GITHUB_STEP_SUMMARY"
128 - name: Render and bind reviewed release notes
129 run: |
130 worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
131 git worktree add --detach "$worktree" "${{ steps.record.outputs.source_sha }}"
132 node "$worktree/scripts/release-notes.mjs" render --version "v${{ steps.record.outputs.version }}" --output release-notes.md
133 git worktree remove "$worktree"
134 test "$(sha256sum release-notes.md | awk '{print $1}')" = "$(jq -r .notes.renderedSha256 record/record.json)"
135 - uses: actions/upload-artifact@v7
136 with:
137 name: orchestrator-reviewed-release-notes
138 path: release-notes.md
139 if-no-files-found: error
140 retention-days: 1
141 - uses: actions/upload-artifact@v7
142 with:
143 name: approved-release-candidate-${{ github.run_id }}-${{ github.run_attempt }}
144 path: payload
145 if-no-files-found: error
146 retention-days: 7
147 - uses: actions/upload-artifact@v7
148 with:
149 name: ${{ steps.record.outputs.desktop_prefix }}-darwin-arm64
150 path: payload/desktop/darwin-arm64
151 if-no-files-found: error
152 retention-days: 7
153 - uses: actions/upload-artifact@v7
154 with:
155 name: ${{ steps.record.outputs.desktop_prefix }}-darwin-amd64
156 path: payload/desktop/darwin-amd64
157 if-no-files-found: error
158 retention-days: 7
159 - uses: actions/upload-artifact@v7
160 with:
161 name: ${{ steps.record.outputs.desktop_prefix }}-darwin-universal
162 path: payload/desktop/darwin-universal
163 if-no-files-found: error
164 retention-days: 7
165 - uses: actions/upload-artifact@v7
166 with:
167 name: ${{ steps.record.outputs.desktop_prefix }}-windows-amd64
168 path: payload/desktop/windows-amd64
169 if-no-files-found: error
170 retention-days: 7
171 - uses: actions/upload-artifact@v7
172 with:
173 name: ${{ steps.record.outputs.desktop_prefix }}-windows-arm64
174 path: payload/desktop/windows-arm64
175 if-no-files-found: error
176 retention-days: 7
177 - uses: actions/upload-artifact@v7
178 with:
179 name: ${{ steps.record.outputs.desktop_prefix }}-linux-amd64
180 path: payload/desktop/linux-amd64
181 if-no-files-found: error
182 retention-days: 7
183
184 authorize:
185 name: approve sealed Stable candidate
186 needs: preflight
187 runs-on: ubuntu-latest
188 environment: release
189 permissions:
190 contents: read
191 outputs:
192 version: ${{ needs.preflight.outputs.version }}
193 source_sha: ${{ needs.preflight.outputs.source_sha }}
194 steps:
195 - run: echo "Approved ${{ needs.preflight.outputs.candidate_id }} at ${{ needs.preflight.outputs.source_sha }}"
196
197 activate:
198 name: atomically activate release identity
199 needs: [preflight, authorize]
200 runs-on: ubuntu-latest
201 permissions:
202 contents: write
203 steps:
204 - uses: actions/checkout@v7
205 with:
206 fetch-depth: 0
207 ref: ${{ github.sha }}
208 - name: Create or verify all implementation tags
209 env:
210 OPERATION: ${{ inputs.operation }}
211 VERSION: ${{ needs.preflight.outputs.version }}
212 SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }}
213 CANDIDATE_ID: ${{ needs.preflight.outputs.candidate_id }}
214 RELEASE_REVOKED_CANDIDATES: ${{ vars.RELEASE_REVOKED_CANDIDATES }}
215 run: |
216 set -euo pipefail
217 node scripts/resolve-release-candidate.mjs active "$CANDIDATE_ID"
218 git fetch origin main-v2 --tags
219 bash scripts/release-candidate-tags.sh activate "$OPERATION" "$VERSION" "$SOURCE_SHA"
220
221 cli:
222 name: publish CLI and Homebrew from candidate
223 needs: [preflight, activate]
224 uses: ./.github/workflows/release.yml
225 with:
226 tag: v${{ needs.preflight.outputs.version }}
227 approved_cli_tag: v${{ needs.preflight.outputs.version }}
228 approved_sha: ${{ needs.preflight.outputs.source_sha }}
229 orchestrated: true
230 orchestrator: promote
231 candidate_artifact_name: ${{ needs.preflight.outputs.approved_artifact }}
232 candidate_verified: true
233 secrets: inherit
234
235 npm:
236 name: publish npm from candidate
237 needs: [preflight, activate]
238 permissions:
239 contents: read
240 id-token: write
241 uses: ./.github/workflows/release-npm.yml
242 with:
243 channel: stable
244 base_version: ${{ needs.preflight.outputs.version }}
245 tag: npm-v${{ needs.preflight.outputs.version }}
246 approved_cli_tag: v${{ needs.preflight.outputs.version }}
247 approved_sha: ${{ needs.preflight.outputs.source_sha }}
248 orchestrated: true
249 orchestrator: promote
250 candidate_artifact_name: ${{ needs.preflight.outputs.approved_artifact }}
251 candidate_verified: true
252 secrets: inherit
253
254 desktop:
255 name: publish Desktop from candidate
256 needs: [preflight, activate]
257 uses: ./.github/workflows/release-desktop.yml
258 with:
259 channel: stable
260 tag: desktop-v${{ needs.preflight.outputs.version }}
261 approved_cli_tag: v${{ needs.preflight.outputs.version }}
262 approved_sha: ${{ needs.preflight.outputs.source_sha }}
263 orchestrated: true
264 orchestrator: promote
265 signing_preflight_verified: true
266 preflight_artifact_prefix: ${{ needs.preflight.outputs.desktop_prefix }}
267 candidate_id: ${{ needs.preflight.outputs.candidate_id }}
268 candidate_source_run_id: ${{ needs.preflight.outputs.producer_run_id }}
269 candidate_source_run_attempt: ${{ needs.preflight.outputs.producer_run_attempt }}
270 candidate_control_sha: ${{ needs.preflight.outputs.candidate_control_sha }}
271 candidate_signing_fingerprint: ${{ needs.preflight.outputs.signing_fingerprint }}
272 candidate_verified: true
273 secrets: inherit
274
275 postflight:
276 name: verify every public Stable surface
277 needs: [preflight, cli, npm, desktop]
278 if: ${{ always() && !cancelled() }}
279 runs-on: ubuntu-latest
280 permissions:
281 actions: write
282 contents: write
283 steps:
284 - name: Require every publisher
285 env:
286 CLI_RESULT: ${{ needs.cli.result }}
287 NPM_RESULT: ${{ needs.npm.result }}
288 DESKTOP_RESULT: ${{ needs.desktop.result }}
289 run: |
290 for result in "$CLI_RESULT" "$NPM_RESULT" "$DESKTOP_RESULT"; do test "$result" = success; done
291 - uses: actions/checkout@v7
292 with:
293 ref: ${{ github.sha }}
294 - uses: actions/setup-node@v7
295 with:
296 node-version: "22"
297 - name: Verify public artifacts
298 id: immutable
299 env:
300 GH_TOKEN: ${{ github.token }}
301 RELEASE_REPOSITORY: ${{ github.repository }}
302 RELEASE_VERSION: ${{ needs.preflight.outputs.version }}
303 RELEASE_OPERATION: ${{ inputs.operation }}
304 RELEASE_LEDGER_OUTPUT: /tmp/publication-ledger-core.json
305 CLI_TAG: v${{ needs.preflight.outputs.version }}
306 DESKTOP_TAG: desktop-v${{ needs.preflight.outputs.version }}
307 run: bash scripts/verify-stable-release-artifacts.sh
308 - name: Publish exact Stable release record
309 env:
310 GH_TOKEN: ${{ github.token }}
311 VERSION: ${{ needs.preflight.outputs.version }}
312 CLI_TAG: v${{ needs.preflight.outputs.version }}
313 RELEASE_SHA: ${{ needs.preflight.outputs.source_sha }}
314 run: |
315 node scripts/release-event.mjs generate --version "$VERSION" --sha "$RELEASE_SHA" \
316 --published-at "$(gh api "repos/${{ github.repository }}/releases/tags/$CLI_TAG" --jq .published_at)" \
317 --output /tmp/release-event.json
318 if ! gh release download "$CLI_TAG" --pattern release-event.json --output /tmp/existing-release-event.json 2>/dev/null; then
319 gh release upload "$CLI_TAG" /tmp/release-event.json
320 else
321 cmp -s /tmp/release-event.json /tmp/existing-release-event.json
322 fi
323 - name: Decide whether this candidate owns the current public site
324 id: site
325 env:
326 OPERATION: ${{ inputs.operation }}
327 VERSION: ${{ needs.preflight.outputs.version }}
328 run: |
329 set -euo pipefail
330 update="$(bash scripts/observe-release-site.sh "$VERSION" "$OPERATION")"
331 echo "update=$update" >> "$GITHUB_OUTPUT"
332 if [ "$update" = false ]; then
333 echo "A verified newer Stable release owns the site; its deployment is preserved." >> "$GITHUB_STEP_SUMMARY"
334 fi
335 - name: Dispatch and wait for the owned Pages deployment
336 id: pages
337 if: steps.site.outputs.update == 'true'
338 env:
339 GH_TOKEN: ${{ github.token }}
340 VERSION: ${{ needs.preflight.outputs.version }}
341 run: |
342 set -euo pipefail
343 baseline="$(gh run list --workflow pages.yml --limit 1 --json databaseId --jq '.[0].databaseId // 0')"
344 gh workflow run pages.yml --ref main-v2 -f "release_version=$VERSION"
345 pages_run=""
346 for _ in $(seq 1 30); do
347 pages_run="$(gh run list --workflow pages.yml --event workflow_dispatch --branch main-v2 --limit 20 \
348 --json databaseId,displayTitle \
349 --jq "map(select(.databaseId > $baseline and .displayTitle == \"Deploy site v$VERSION\")) | sort_by(.databaseId) | last | .databaseId // empty")"
350 [ -z "$pages_run" ] || break
351 sleep 2
352 done
353 if [ -z "$pages_run" ]; then
354 echo "::error::public-sync-pending: the owned Pages deployment was not created"
355 exit 1
356 fi
357 echo "Waiting for Pages run $pages_run." >> "$GITHUB_STEP_SUMMARY"
358 gh run watch "$pages_run" --exit-status
359 - name: Verify hydrated-site inputs and public download routes
360 id: public_site
361 if: steps.site.outputs.update == 'true'
362 env:
363 GH_TOKEN: ${{ github.token }}
364 RELEASE_REPOSITORY: ${{ github.repository }}
365 RELEASE_VERSION: ${{ needs.preflight.outputs.version }}
366 CLI_TAG: v${{ needs.preflight.outputs.version }}
367 DESKTOP_TAG: desktop-v${{ needs.preflight.outputs.version }}
368 RELEASE_OPERATION: ${{ inputs.operation }}
369 RELEASE_LEDGER_OUTPUT: /tmp/publication-ledger-site.json
370 VERIFY_HOMEPAGE: "true"
371 VERIFY_PUBLIC_SITE_ONLY: "true"
372 run: bash scripts/verify-stable-release-artifacts.sh
373 - name: Finalize observed publication ledger
374 if: ${{ always() && steps.immutable.outcome == 'success' }}
375 env:
376 UPDATE_SITE: ${{ steps.site.outputs.update }}
377 run: |
378 if [ -s /tmp/publication-ledger-site.json ]; then
379 node scripts/release-publication-ledger.mjs merge \
380 /tmp/publication-ledger-core.json /tmp/publication-ledger-site.json \
381 /tmp/publication-ledger.json
382 state=complete
383 elif [ "$UPDATE_SITE" = false ]; then
384 cp /tmp/publication-ledger-core.json /tmp/publication-ledger.json
385 state=immutable-complete-newer-pointer-preserved
386 else
387 cp /tmp/publication-ledger-core.json /tmp/publication-ledger.json
388 state=public-sync-pending
389 fi
390 jq --arg state "$state" '. + {completionState: $state}' \
391 /tmp/publication-ledger.json > /tmp/publication-ledger.final.json
392 mv /tmp/publication-ledger.final.json /tmp/publication-ledger.json
393 - name: Upload publication ledger
394 if: ${{ always() && steps.immutable.outcome == 'success' }}
395 uses: actions/upload-artifact@v7
396 with:
397 name: release-publication-ledger-${{ needs.preflight.outputs.candidate_id }}-${{ github.run_attempt }}
398 path: /tmp/publication-ledger.json
399 if-no-files-found: error
400 overwrite: true
401 retention-days: 90
402
403 metrics:
404 name: record publication timing
405 needs: [preflight, authorize, activate, cli, npm, desktop, postflight]
406 if: ${{ always() && !cancelled() }}
407 continue-on-error: true
408 runs-on: ubuntu-latest
409 permissions:
410 actions: read
411 contents: read
412 steps:
413 - uses: actions/checkout@v7
414 with:
415 ref: ${{ github.sha }}
416 - name: Record queue, execution, and critical-path evidence
417 env:
418 GH_TOKEN: ${{ github.token }}
419 run: |
420 gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" > "$RUNNER_TEMP/release-run.json"
421 gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/jobs?filter=all&per_page=100" > "$RUNNER_TEMP/release-jobs.json"
422 node scripts/ci-timings.mjs \
423 --run "$RUNNER_TEMP/release-run.json" \
424 --jobs "$RUNNER_TEMP/release-jobs.json" \
425 --summary "$GITHUB_STEP_SUMMARY" \
426 --output "$RUNNER_TEMP/publication-timing.json" \
427 --title "Release publication timing"
428 - uses: actions/upload-artifact@v7
429 with:
430 name: release-publication-timing-${{ needs.preflight.outputs.candidate_id || github.run_id }}-${{ github.run_attempt }}
431 path: ${{ runner.temp }}/publication-timing.json
432 if-no-files-found: ignore
433 overwrite: true
434 retention-days: 90
435
435 lines YAML