返回 DeepSeek-Reasonix
release-candidate.yml
根目录 / .github / workflows / release-candidate.yml
1 name: Prepare release candidate
2 run-name: Prepare release candidate ${{ inputs.version || github.sha }}
3
4 on:
5 workflow_dispatch:
6 inputs:
7 version:
8 description: "Official version without a tag prefix"
9 required: true
10 type: string
11 rehearsal:
12 description: "Build and accept isolated files that cannot be published"
13 required: false
14 default: false
15 type: boolean
16 push:
17 branches: [main-v2]
18 paths:
19 - release-notes/releases.json
20
21 concurrency:
22 group: release-candidate-${{ inputs.rehearsal && 'rehearsal' || 'release' }}-${{ inputs.version || github.sha }}
23 cancel-in-progress: false
24
25 permissions:
26 actions: read
27 contents: read
28
29 jobs:
30 resolve:
31 name: validate candidate inputs before native builds
32 runs-on: ubuntu-latest
33 outputs:
34 version: ${{ steps.candidate.outputs.version }}
35 candidate_id: ${{ steps.candidate.outputs.candidate_id }}
36 catalog_sha256: ${{ steps.candidate.outputs.catalog_sha256 }}
37 reuse: ${{ steps.existing.outputs.reuse }}
38 artifact_namespace: ${{ steps.candidate.outputs.artifact_namespace }}
39 purpose: ${{ steps.candidate.outputs.purpose }}
40 permissions:
41 actions: read
42 attestations: read
43 contents: read
44 steps:
45 - uses: actions/checkout@v7
46 with:
47 fetch-depth: 0
48 ref: ${{ github.sha }}
49 - uses: actions/setup-node@v7
50 with:
51 node-version: "22"
52 - name: Resolve reviewed source
53 id: candidate
54 env:
55 EVENT_NAME: ${{ github.event_name }}
56 INPUT_VERSION: ${{ inputs.version }}
57 PUSH_SHA: ${{ github.sha }}
58 REHEARSAL: ${{ inputs.rehearsal || false }}
59 GH_TOKEN: ${{ github.token }}
60 RELEASE_REPOSITORY: ${{ github.repository }}
61 RELEASE_REVOKED_CANDIDATES: ${{ vars.RELEASE_REVOKED_CANDIDATES }}
62 run: |
63 set -euo pipefail
64 git fetch origin main-v2 --tags
65 if [ "$EVENT_NAME" = push ]; then
66 version="$(jq -r '.releases[0].version // empty' release-notes/releases.json)"
67 source_sha="$PUSH_SHA"
68 else
69 version="$INPUT_VERSION"
70 source_sha="$PUSH_SHA"
71 fi
72 purpose=release
73 artifact_namespace=release-candidate
74 if [ "$REHEARSAL" = true ]; then
75 purpose=rehearsal
76 artifact_namespace=release-candidate-rehearsal
77 fi
78 bash scripts/validate-release-candidate-source.sh "$version" "$source_sha" "$purpose"
79 git show "$source_sha:release-notes/releases.json" > /tmp/release-catalog.json
80 catalog_sha256="$(sha256sum /tmp/release-catalog.json | awk '{print $1}')"
81 candidate_id="$(node scripts/release-candidate.mjs id "$version" "$source_sha" "$catalog_sha256")"
82 node scripts/resolve-release-candidate.mjs active "$candidate_id"
83 {
84 echo "version=$version"
85 echo "source_sha=$source_sha"
86 echo "catalog_sha256=$catalog_sha256"
87 echo "candidate_id=$candidate_id"
88 echo "purpose=$purpose"
89 echo "artifact_namespace=$artifact_namespace"
90 } >> "$GITHUB_OUTPUT"
91 {
92 echo "### Candidate input"
93 echo
94 echo "- ID: \`$candidate_id\`"
95 echo "- Product: \`$source_sha\`"
96 echo "- Control: \`$GITHUB_SHA\`"
97 echo "- Purpose: \`$purpose\`"
98 } >> "$GITHUB_STEP_SUMMARY"
99 - name: Verify release control helpers and signing configuration
100 env:
101 OFFICIAL_REPOSITORY: ${{ github.repository == 'esengine/DeepSeek-Reasonix' }}
102 HAS_APPLE_SIGNING: ${{ secrets.APPLE_CERT_P12 != '' && secrets.APPLE_CERT_PASSWORD != '' && secrets.APPLE_API_KEY_P8 != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER_ID != '' }}
103 HAS_CERTUM_SIGNING: ${{ secrets.CERTUM_USERNAME != '' && secrets.CERTUM_OTP_URI != '' && secrets.CERTUM_KEY_ID != '' }}
104 HAS_MINISIGN: ${{ secrets.MINISIGN_PRIVATE_KEY != '' && secrets.MINISIGN_PASSWORD != '' }}
105 run: |
106 bash scripts/validate-release-control-plane.sh
107 if [ "$OFFICIAL_REPOSITORY" = true ]; then
108 for configured in "$HAS_APPLE_SIGNING" "$HAS_CERTUM_SIGNING" "$HAS_MINISIGN"; do
109 [ "$configured" = true ] || { echo "::error::release signing configuration is incomplete"; exit 1; }
110 done
111 fi
112 - name: Reuse an existing sealed candidate
113 if: inputs.rehearsal != true
114 id: existing
115 env:
116 GH_TOKEN: ${{ github.token }}
117 CANDIDATE_ID: ${{ steps.candidate.outputs.candidate_id }}
118 RELEASE_REVOKED_CANDIDATES: ${{ vars.RELEASE_REVOKED_CANDIDATES }}
119 run: |
120 set -euo pipefail
121 resolved="$RUNNER_TEMP/existing-candidate.outputs"
122 GITHUB_OUTPUT="$resolved" node scripts/resolve-release-candidate.mjs resolve-optional "$CANDIDATE_ID"
123 output() { sed -n "s/^$1=//p" "$resolved" | tail -1; }
124 if [ "$(output found)" != true ]; then
125 echo "reuse=false" >> "$GITHUB_OUTPUT"
126 exit 0
127 fi
128 record_id="$(output record_artifact_id)"
129 run_id="$(output producer_run_id)"
130 mkdir -p "$RUNNER_TEMP/existing-record"
131 gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$record_id" > "$RUNNER_TEMP/existing-record-artifact.json"
132 gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id" > "$RUNNER_TEMP/existing-run.json"
133 gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$record_id/zip" > "$RUNNER_TEMP/existing-record.zip"
134 node scripts/verify-release-artifact-archive.mjs "$RUNNER_TEMP/existing-record-artifact.json" "$RUNNER_TEMP/existing-record.zip"
135 unzip -q "$RUNNER_TEMP/existing-record.zip" -d "$RUNNER_TEMP/existing-record"
136 jq -e '
137 .validity.revoked == false and
138 ((.validity.createdAt | fromdateiso8601) < (.validity.expiresAt | fromdateiso8601))
139 ' "$RUNNER_TEMP/existing-record/record.json" >/dev/null
140 if ! jq -e '(.validity.expiresAt | fromdateiso8601) > now' \
141 "$RUNNER_TEMP/existing-record/record.json" >/dev/null; then
142 echo "reuse=false" >> "$GITHUB_OUTPUT"
143 echo "The sealed record expired; preparing fresh files." >> "$GITHUB_STEP_SUMMARY"
144 exit 0
145 fi
146 signer_sha="$(jq -r .head_sha "$RUNNER_TEMP/existing-run.json")"
147 gh attestation verify "$RUNNER_TEMP/existing-record/record.json" --repo "$GITHUB_REPOSITORY" \
148 --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-candidate.yml" \
149 --signer-digest "$signer_sha" --source-ref refs/heads/main-v2 --deny-self-hosted-runners
150 inspected="$RUNNER_TEMP/existing-inspected.outputs"
151 GITHUB_OUTPUT="$inspected" node scripts/resolve-release-candidate.mjs inspect "$CANDIDATE_ID" \
152 "$RUNNER_TEMP/existing-record/record.json" "$RUNNER_TEMP/existing-record-artifact.json" \
153 "$RUNNER_TEMP/existing-run.json"
154 payload_id="$(sed -n 's/^payload_artifact_id=//p' "$inspected" | tail -1)"
155 if ! gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$payload_id" \
156 > "$RUNNER_TEMP/existing-payload.json" 2> "$RUNNER_TEMP/existing-payload.error"; then
157 if grep -Eiq 'HTTP 404|Not Found' "$RUNNER_TEMP/existing-payload.error"; then
158 echo "reuse=false" >> "$GITHUB_OUTPUT"
159 echo "The sealed record exists but its payload is unavailable; preparing fresh files." >> "$GITHUB_STEP_SUMMARY"
160 exit 0
161 fi
162 cat "$RUNNER_TEMP/existing-payload.error" >&2
163 exit 1
164 fi
165 test "$(jq -r .expired "$RUNNER_TEMP/existing-payload.json")" = false || {
166 echo "reuse=false" >> "$GITHUB_OUTPUT"
167 echo "The sealed record exists but its payload expired; preparing fresh files." >> "$GITHUB_STEP_SUMMARY"
168 exit 0
169 }
170 test "$(jq -r .name "$RUNNER_TEMP/existing-payload.json")" = "release-candidate-payload-$CANDIDATE_ID"
171 test "$(jq -r .workflow_run.id "$RUNNER_TEMP/existing-payload.json")" = "$run_id"
172 echo "reuse=true" >> "$GITHUB_OUTPUT"
173 echo "A sealed candidate with an active exact payload already exists; native builds are skipped." >> "$GITHUB_STEP_SUMMARY"
174 - name: Verify source CI after control preflight
175 if: steps.existing.outputs.reuse != 'true'
176 env:
177 GH_TOKEN: ${{ github.token }}
178 RELEASE_REPOSITORY: ${{ github.repository }}
179 RELEASE_SOURCE_SHA: ${{ github.sha }}
180 RELEASE_CI_WAIT_SECONDS: 1800
181 run: bash scripts/verify-release-push-ci.sh "$RELEASE_SOURCE_SHA"
182 - name: Checkout immutable product for notes validation
183 if: steps.existing.outputs.reuse != 'true'
184 uses: actions/checkout@v7
185 with:
186 ref: ${{ github.sha }}
187 path: product
188 persist-credentials: false
189 - name: Validate embedded notes and cache identity inputs
190 if: steps.existing.outputs.reuse != 'true'
191 working-directory: product
192 env:
193 DOCS_BUILD_VERSION: v${{ steps.candidate.outputs.version }}
194 DOCS_SOURCE_REVISION: ${{ github.sha }}
195 run: |
196 node scripts/release-notes.mjs validate
197 node scripts/release-notes.mjs render --version "$DOCS_BUILD_VERSION" --output /tmp/release-notes.md
198 bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION" "$DOCS_SOURCE_REVISION"
199 ./scripts/cache-guard.sh
200
201 cli-npm:
202 name: build shared CLI and npm candidate
203 needs: resolve
204 if: needs.resolve.outputs.reuse != 'true'
205 runs-on: ubuntu-latest
206 steps:
207 - uses: actions/checkout@v7
208 with:
209 fetch-depth: 0
210 ref: ${{ github.sha }}
211 - uses: actions/setup-go@v7
212 with:
213 go-version-file: go.mod
214 cache: true
215 - uses: actions/setup-node@v7
216 with:
217 node-version: "22"
218 - name: Build each CLI binary once and package both surfaces
219 env:
220 RELEASE_SOURCE_SHA: ${{ github.sha }}
221 run: |
222 RELEASE_BUILD_TIME="$(git show -s --format=%cI "$RELEASE_SOURCE_SHA")"
223 export RELEASE_BUILD_TIME
224 node scripts/build-release-cli-candidate.mjs "${{ needs.resolve.outputs.version }}" candidate-cli
225 - uses: actions/upload-artifact@v7
226 with:
227 name: candidate-cli-${{ needs.resolve.outputs.candidate_id }}
228 path: |
229 candidate-cli/cli
230 candidate-cli/npm
231 if-no-files-found: error
232 overwrite: true
233 retention-days: 30
234
235 desktop:
236 name: build, sign, and accept Desktop candidate
237 needs: resolve
238 if: needs.resolve.outputs.reuse != 'true'
239 # GitHub validates every nested job before evaluating its condition, even
240 # though candidate preparation skips the publisher and mirror jobs.
241 permissions:
242 actions: write
243 contents: write
244 uses: ./.github/workflows/release-desktop.yml
245 with:
246 channel: stable
247 tag: desktop-v${{ needs.resolve.outputs.version }}
248 approved_cli_tag: v${{ needs.resolve.outputs.version }}
249 approved_sha: ${{ github.sha }}
250 orchestrated: true
251 orchestrator: candidate
252 signing_preflight: true
253 candidate_preparation: true
254 candidate_rehearsal: ${{ inputs.rehearsal || false }}
255 secrets: inherit
256
257 seal:
258 name: seal reusable release candidate
259 needs: [resolve, cli-npm, desktop]
260 if: >-
261 always() && !cancelled() && needs.resolve.outputs.reuse != 'true' &&
262 needs.resolve.result == 'success' && needs.cli-npm.result == 'success' && needs.desktop.result == 'success'
263 runs-on: ubuntu-latest
264 permissions:
265 actions: read
266 attestations: write
267 contents: read
268 id-token: write
269 steps:
270 - uses: actions/checkout@v7
271 with:
272 fetch-depth: 0
273 ref: ${{ github.sha }}
274 - uses: actions/setup-node@v7
275 with:
276 node-version: "22"
277 - uses: actions/download-artifact@v8
278 with:
279 name: candidate-cli-${{ needs.resolve.outputs.candidate_id }}
280 path: payload
281 - uses: actions/download-artifact@v8
282 with:
283 name: ${{ needs.desktop.outputs.artifact_prefix }}-darwin-arm64
284 path: payload/desktop/darwin-arm64
285 - uses: actions/download-artifact@v8
286 with:
287 name: ${{ needs.desktop.outputs.artifact_prefix }}-darwin-amd64
288 path: payload/desktop/darwin-amd64
289 - uses: actions/download-artifact@v8
290 with:
291 name: ${{ needs.desktop.outputs.artifact_prefix }}-darwin-universal
292 path: payload/desktop/darwin-universal
293 - uses: actions/download-artifact@v8
294 with:
295 name: ${{ needs.desktop.outputs.artifact_prefix }}-windows-amd64
296 path: payload/desktop/windows-amd64
297 - uses: actions/download-artifact@v8
298 with:
299 name: ${{ needs.desktop.outputs.artifact_prefix }}-windows-arm64
300 path: payload/desktop/windows-arm64
301 - uses: actions/download-artifact@v8
302 with:
303 name: ${{ needs.desktop.outputs.artifact_prefix }}-linux-amd64
304 path: payload/desktop/linux-amd64
305 - uses: actions/download-artifact@v8
306 with:
307 name: ${{ needs.desktop.outputs.artifact_prefix }}-receipt-windows-amd64
308 path: payload/evidence
309 - uses: actions/download-artifact@v8
310 with:
311 name: ${{ needs.desktop.outputs.artifact_prefix }}-receipt-windows-arm64
312 path: payload/evidence
313 - uses: actions/download-artifact@v8
314 with:
315 name: ${{ needs.desktop.outputs.artifact_prefix }}-receipt-macos-universal-intel
316 path: payload/evidence
317 - name: Normalize acceptance receipt names
318 run: |
319 test -s payload/evidence/windows-amd64.json
320 test -s payload/evidence/windows-arm64.json
321 test -s payload/evidence/macos-universal-intel.json
322 - uses: actions/attest-build-provenance@v3
323 with:
324 subject-path: payload/**/*
325 - name: Upload immutable candidate payload
326 id: payload
327 uses: actions/upload-artifact@v7
328 with:
329 name: ${{ needs.resolve.outputs.artifact_namespace }}-payload-${{ needs.resolve.outputs.candidate_id }}
330 path: payload
331 if-no-files-found: error
332 overwrite: true
333 retention-days: 30
334 - name: Retain native acceptance evidence
335 id: evidence
336 uses: actions/upload-artifact@v7
337 with:
338 name: ${{ needs.resolve.outputs.artifact_namespace }}-evidence-${{ needs.resolve.outputs.candidate_id }}
339 path: payload/evidence
340 if-no-files-found: error
341 overwrite: true
342 retention-days: 90
343 - name: Render reviewed notes from the immutable product
344 run: |
345 git show "${{ github.sha }}:release-notes/releases.json" > /tmp/releases.json
346 worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
347 git worktree add --detach "$worktree" "${{ github.sha }}"
348 node "$worktree/scripts/release-notes.mjs" render --version "v${{ needs.resolve.outputs.version }}" --output /tmp/release-notes.md
349 git worktree remove "$worktree"
350 - name: Seal candidate record
351 env:
352 RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
353 RELEASE_CANDIDATE_PURPOSE: ${{ needs.resolve.outputs.purpose }}
354 RELEASE_SOURCE_SHA: ${{ github.sha }}
355 RELEASE_BUILD_CONTROL_SHA: ${{ github.sha }}
356 RELEASE_ACCEPTANCE_CONTROL_SHA: ${{ github.sha }}
357 RELEASE_NOTES_SOURCE_SHA: ${{ github.sha }}
358 RELEASE_CATALOG_SHA256: ${{ needs.resolve.outputs.catalog_sha256 }}
359 RELEASE_DESKTOP_PREFIX: ${{ needs.desktop.outputs.artifact_prefix }}
360 RELEASE_DESKTOP_FINGERPRINT: ${{ needs.desktop.outputs.signing_fingerprint }}
361 RELEASE_PAYLOAD_ARTIFACT_ID: ${{ steps.payload.outputs.artifact-id }}
362 RELEASE_PAYLOAD_ARTIFACT_NAME: ${{ needs.resolve.outputs.artifact_namespace }}-payload-${{ needs.resolve.outputs.candidate_id }}
363 RELEASE_EVIDENCE_ARTIFACT_ID: ${{ steps.evidence.outputs.artifact-id }}
364 RELEASE_EVIDENCE_ARTIFACT_NAME: ${{ needs.resolve.outputs.artifact_namespace }}-evidence-${{ needs.resolve.outputs.candidate_id }}
365 RELEASE_WORKFLOW: .github/workflows/release-candidate.yml
366 RELEASE_ACCEPTANCE_JSON: >-
367 [{"kind":"windows-amd64","status":"passed","evidencePath":"evidence/windows-amd64.json"},{"kind":"windows-arm64","status":"passed","evidencePath":"evidence/windows-arm64.json"},{"kind":"macos-universal-intel","status":"passed","evidencePath":"evidence/macos-universal-intel.json"}]
368 run: |
369 RELEASE_RENDERED_NOTES_SHA256="$(sha256sum /tmp/release-notes.md | awk '{print $1}')"
370 RELEASE_CREATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
371 RELEASE_EXPIRES_AT="$(date -u -d '+30 days' +%Y-%m-%dT%H:%M:%SZ)"
372 export RELEASE_RENDERED_NOTES_SHA256 RELEASE_CREATED_AT RELEASE_EXPIRES_AT
373 node scripts/release-candidate.mjs seal payload record/record.json
374 if [ "$RELEASE_CANDIDATE_PURPOSE" = rehearsal ]; then
375 node scripts/release-candidate.mjs verify-rehearsal payload record/record.json
376 else
377 node scripts/release-candidate.mjs verify payload record/record.json
378 fi
379 - uses: actions/attest-build-provenance@v3
380 with:
381 subject-path: record/record.json
382 - uses: actions/upload-artifact@v7
383 with:
384 name: ${{ needs.resolve.outputs.artifact_namespace }}-record-${{ needs.resolve.outputs.candidate_id }}
385 path: record/record.json
386 if-no-files-found: error
387 overwrite: true
388 retention-days: 90
389 - name: Report publish command
390 env:
391 PURPOSE: ${{ needs.resolve.outputs.purpose }}
392 run: |
393 {
394 echo "### Candidate ready"
395 echo
396 echo "\`${{ needs.resolve.outputs.candidate_id }}\`"
397 echo
398 echo '```sh'
399 if [ "$PURPOSE" = rehearsal ]; then
400 echo "gh workflow run release-candidate-verify.yml --ref main-v2 -f candidate_id='${{ needs.resolve.outputs.candidate_id }}'"
401 else
402 echo "./scripts/release-stable.sh '${{ needs.resolve.outputs.candidate_id }}'"
403 fi
404 echo '```'
405 } >> "$GITHUB_STEP_SUMMARY"
406
407 metrics:
408 name: record candidate timing
409 needs: [resolve, cli-npm, desktop, seal]
410 if: ${{ always() && !cancelled() }}
411 continue-on-error: true
412 runs-on: ubuntu-latest
413 permissions:
414 actions: read
415 contents: read
416 steps:
417 - uses: actions/checkout@v7
418 with:
419 ref: ${{ github.sha }}
420 - name: Record queue, execution, and critical-path evidence
421 env:
422 GH_TOKEN: ${{ github.token }}
423 run: |
424 gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" > "$RUNNER_TEMP/release-run.json"
425 gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/jobs?filter=all&per_page=100" > "$RUNNER_TEMP/release-jobs.json"
426 node scripts/ci-timings.mjs \
427 --run "$RUNNER_TEMP/release-run.json" \
428 --jobs "$RUNNER_TEMP/release-jobs.json" \
429 --summary "$GITHUB_STEP_SUMMARY" \
430 --output "$RUNNER_TEMP/candidate-timing.json" \
431 --title "Release candidate timing"
432 - uses: actions/upload-artifact@v7
433 with:
434 name: release-candidate-timing-${{ needs.resolve.outputs.candidate_id || github.run_id }}-${{ github.run_attempt }}
435 path: ${{ runner.temp }}/candidate-timing.json
436 if-no-files-found: ignore
437 overwrite: true
438 retention-days: 90
439
439 lines YAML