| 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 |