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