| 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 | issues: read # granted to the nested release workflows, which can render notes |
| 27 | pull-requests: read |
| 28 | |
| 29 | jobs: |
| 30 | preflight: |
| 31 | name: verify sealed candidate before approval |
| 32 | if: github.repository == 'esengine/DeepSeek-Reasonix' && github.ref == 'refs/heads/main-v2' && github.ref_protected |
| 33 | runs-on: ubuntu-latest |
| 34 | permissions: |
| 35 | actions: read |
| 36 | attestations: read |
| 37 | contents: read |
| 38 | outputs: |
| 39 | tag_actor_id: ${{ steps.identity.outputs.actor_id }} |
| 40 | candidate_id: ${{ steps.record.outputs.candidate_id }} |
| 41 | version: ${{ steps.record.outputs.version }} |
| 42 | source_sha: ${{ steps.record.outputs.source_sha }} |
| 43 | producer_run_id: ${{ steps.record.outputs.producer_run_id }} |
| 44 | producer_run_attempt: ${{ steps.record.outputs.producer_run_attempt }} |
| 45 | candidate_control_sha: ${{ steps.record.outputs.candidate_control_sha }} |
| 46 | signing_fingerprint: ${{ steps.record.outputs.signing_fingerprint }} |
| 47 | desktop_prefix: ${{ steps.record.outputs.desktop_prefix }} |
| 48 | approved_artifact: approved-release-candidate-${{ github.run_id }}-${{ github.run_attempt }} |
| 49 | steps: |
| 50 | - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 |
| 51 | with: |
| 52 | fetch-depth: 0 |
| 53 | ref: ${{ github.sha }} |
| 54 | - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 |
| 55 | with: |
| 56 | node-version: "22" |
| 57 | - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 |
| 58 | with: |
| 59 | go-version-file: go.mod |
| 60 | cache: false |
| 61 | - name: Validate executable contracts and public access before payload or approval |
| 62 | env: |
| 63 | CANDIDATE_ID: ${{ inputs.candidate_id }} |
| 64 | run: | |
| 65 | bash scripts/validate-release-control-plane.sh |
| 66 | version="${CANDIDATE_ID#v}" |
| 67 | bash scripts/check-release-public-access.sh "${version%%-*}" |
| 68 | - name: Resolve candidate record artifact |
| 69 | id: artifact |
| 70 | env: |
| 71 | GH_TOKEN: ${{ github.token }} |
| 72 | RELEASE_REVOKED_CANDIDATES: ${{ vars.RELEASE_REVOKED_CANDIDATES }} |
| 73 | run: node scripts/resolve-release-candidate.mjs resolve "${{ inputs.candidate_id }}" |
| 74 | - name: Download exact candidate record |
| 75 | env: |
| 76 | GH_TOKEN: ${{ github.token }} |
| 77 | ARTIFACT_ID: ${{ steps.artifact.outputs.record_artifact_id }} |
| 78 | PRODUCER_RUN_ID: ${{ steps.artifact.outputs.producer_run_id }} |
| 79 | run: | |
| 80 | mkdir -p record |
| 81 | gh api "repos/${{ github.repository }}/actions/artifacts/$ARTIFACT_ID" > /tmp/record-artifact.json |
| 82 | gh api "repos/${{ github.repository }}/actions/runs/$PRODUCER_RUN_ID" > /tmp/producer-run.json |
| 83 | gh api "repos/${{ github.repository }}/actions/artifacts/$ARTIFACT_ID/zip" > /tmp/record.zip |
| 84 | node scripts/verify-release-artifact-archive.mjs /tmp/record-artifact.json /tmp/record.zip |
| 85 | unzip -q /tmp/record.zip -d record |
| 86 | test -s record/record.json |
| 87 | - name: Verify record provenance and resolve exact payload |
| 88 | id: record |
| 89 | env: |
| 90 | GH_TOKEN: ${{ github.token }} |
| 91 | run: | |
| 92 | signer_sha="$(jq -r .head_sha /tmp/producer-run.json)" |
| 93 | gh attestation verify record/record.json --repo "$GITHUB_REPOSITORY" \ |
| 94 | --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-candidate.yml" \ |
| 95 | --signer-digest "$signer_sha" --source-ref refs/heads/main-v2 --deny-self-hosted-runners |
| 96 | node scripts/resolve-release-candidate.mjs inspect "${{ inputs.candidate_id }}" \ |
| 97 | record/record.json /tmp/record-artifact.json /tmp/producer-run.json |
| 98 | - name: Check the configured tag publisher before approval |
| 99 | id: identity |
| 100 | env: |
| 101 | GH_TOKEN: ${{ secrets.RELEASE_TAG_TOKEN }} |
| 102 | RELEASE_TAG_ACTOR: ${{ vars.RELEASE_TAG_ACTOR }} |
| 103 | run: node scripts/verify-release-tag-identity.mjs "${{ steps.record.outputs.version }}" |
| 104 | - name: Download exact candidate payload |
| 105 | env: |
| 106 | GH_TOKEN: ${{ github.token }} |
| 107 | PAYLOAD_ID: ${{ steps.record.outputs.payload_artifact_id }} |
| 108 | run: | |
| 109 | gh api "repos/${{ github.repository }}/actions/artifacts/$PAYLOAD_ID" > /tmp/payload-artifact.json |
| 110 | test "$(jq -r .name /tmp/payload-artifact.json)" = "${{ steps.record.outputs.payload_artifact_name }}" |
| 111 | test "$(jq -r .expired /tmp/payload-artifact.json)" = false |
| 112 | test "$(jq -r .workflow_run.id /tmp/payload-artifact.json)" = "${{ steps.record.outputs.producer_run_id }}" |
| 113 | mkdir -p payload |
| 114 | gh api "repos/${{ github.repository }}/actions/artifacts/$PAYLOAD_ID/zip" > /tmp/payload.zip |
| 115 | node scripts/verify-release-artifact-archive.mjs /tmp/payload-artifact.json /tmp/payload.zip |
| 116 | unzip -q /tmp/payload.zip -d payload |
| 117 | - name: Verify payload provenance, bytes, source, and operation |
| 118 | env: |
| 119 | GH_TOKEN: ${{ github.token }} |
| 120 | OPERATION: ${{ inputs.operation }} |
| 121 | VERSION: ${{ steps.record.outputs.version }} |
| 122 | SOURCE_SHA: ${{ steps.record.outputs.source_sha }} |
| 123 | run: | |
| 124 | set -euo pipefail |
| 125 | # One provenance check per payload file; each is a network round trip, |
| 126 | # so they run eight at a time and any single failure fails the step. |
| 127 | signer_digest="${{ steps.record.outputs.candidate_control_sha }}" |
| 128 | find payload -type f -print0 | xargs -0 -P 8 -I{} \ |
| 129 | gh attestation verify {} --repo "$GITHUB_REPOSITORY" \ |
| 130 | --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-candidate.yml" \ |
| 131 | --signer-digest "$signer_digest" --source-ref refs/heads/main-v2 \ |
| 132 | --deny-self-hosted-runners >/dev/null |
| 133 | node scripts/release-candidate.mjs verify payload record/record.json >/dev/null |
| 134 | git fetch origin main-v2 --tags |
| 135 | git merge-base --is-ancestor "$SOURCE_SHA" origin/main-v2 |
| 136 | git show "$SOURCE_SHA:release-notes/releases.json" > /tmp/catalog.json |
| 137 | test "$(sha256sum /tmp/catalog.json | awk '{print $1}')" = "$(jq -r .notes.catalogSha256 record/record.json)" |
| 138 | bash scripts/release-candidate-tags.sh check "$OPERATION" "$VERSION" "$SOURCE_SHA" |
| 139 | { |
| 140 | echo "### Sealed release approval" |
| 141 | echo |
| 142 | echo "- Candidate: \`${{ inputs.candidate_id }}\`" |
| 143 | echo "- Operation: \`$OPERATION\`" |
| 144 | echo "- Product SHA: \`$SOURCE_SHA\`" |
| 145 | echo "- Candidate record SHA-256: \`$(sha256sum record/record.json | awk '{print $1}')\`" |
| 146 | echo "- Notes SHA-256: \`$(jq -r .notes.renderedSha256 record/record.json)\`" |
| 147 | echo "- Signing policy: \`$(jq -r .signing.desktopFingerprint record/record.json)\`" |
| 148 | echo "- Payload files: \`$(jq '.files | length' record/record.json)\`" |
| 149 | echo "- Native acceptance: \`$(jq -r '[.acceptance[] | select(.status == "passed") | .kind] | join(", ")' record/record.json)\`" |
| 150 | } >> "$GITHUB_STEP_SUMMARY" |
| 151 | # A candidate sealed with its notes publishes those bytes without touching |
| 152 | # the network. One sealed before that renders offline from its own source. |
| 153 | - name: Bind reviewed release notes |
| 154 | run: | |
| 155 | sealed="$(jq -r '.notes.renderedPath // empty' record/record.json)" |
| 156 | if [ -n "$sealed" ]; then |
| 157 | cp "payload/$sealed" release-notes.md |
| 158 | else |
| 159 | worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")" |
| 160 | git worktree add --detach "$worktree" "${{ steps.record.outputs.source_sha }}" |
| 161 | node "$worktree/scripts/release-notes.mjs" render --version "v${{ steps.record.outputs.version }}" --output release-notes.md |
| 162 | git worktree remove "$worktree" |
| 163 | fi |
| 164 | test "$(sha256sum release-notes.md | awk '{print $1}')" = "$(jq -r .notes.renderedSha256 record/record.json)" |
| 165 | - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 |
| 166 | with: |
| 167 | name: orchestrator-reviewed-release-notes |
| 168 | path: release-notes.md |
| 169 | if-no-files-found: error |
| 170 | retention-days: 1 |
| 171 | - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 |
| 172 | with: |
| 173 | name: approved-release-candidate-${{ github.run_id }}-${{ github.run_attempt }} |
| 174 | path: payload |
| 175 | if-no-files-found: error |
| 176 | retention-days: 7 |
| 177 | - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 |
| 178 | with: |
| 179 | name: ${{ steps.record.outputs.desktop_prefix }}-darwin-arm64 |
| 180 | path: payload/desktop/darwin-arm64 |
| 181 | if-no-files-found: error |
| 182 | retention-days: 7 |
| 183 | - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 |
| 184 | with: |
| 185 | name: ${{ steps.record.outputs.desktop_prefix }}-darwin-amd64 |
| 186 | path: payload/desktop/darwin-amd64 |
| 187 | if-no-files-found: error |
| 188 | retention-days: 7 |
| 189 | - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 |
| 190 | with: |
| 191 | name: ${{ steps.record.outputs.desktop_prefix }}-darwin-universal |
| 192 | path: payload/desktop/darwin-universal |
| 193 | if-no-files-found: error |
| 194 | retention-days: 7 |
| 195 | - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 |
| 196 | with: |
| 197 | name: ${{ steps.record.outputs.desktop_prefix }}-windows-amd64 |
| 198 | path: payload/desktop/windows-amd64 |
| 199 | if-no-files-found: error |
| 200 | retention-days: 7 |
| 201 | - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 |
| 202 | with: |
| 203 | name: ${{ steps.record.outputs.desktop_prefix }}-windows-arm64 |
| 204 | path: payload/desktop/windows-arm64 |
| 205 | if-no-files-found: error |
| 206 | retention-days: 7 |
| 207 | - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 |
| 208 | with: |
| 209 | name: ${{ steps.record.outputs.desktop_prefix }}-linux-amd64 |
| 210 | path: payload/desktop/linux-amd64 |
| 211 | if-no-files-found: error |
| 212 | retention-days: 7 |
| 213 | |
| 214 | authorize: |
| 215 | name: approve sealed Stable candidate |
| 216 | needs: preflight |
| 217 | runs-on: ubuntu-latest |
| 218 | environment: release |
| 219 | permissions: |
| 220 | contents: read |
| 221 | outputs: |
| 222 | version: ${{ needs.preflight.outputs.version }} |
| 223 | source_sha: ${{ needs.preflight.outputs.source_sha }} |
| 224 | steps: |
| 225 | - run: echo "Approved ${{ needs.preflight.outputs.candidate_id }} at ${{ needs.preflight.outputs.source_sha }}" |
| 226 | |
| 227 | activate: |
| 228 | name: atomically activate release identity |
| 229 | needs: [preflight, authorize] |
| 230 | runs-on: ubuntu-latest |
| 231 | permissions: |
| 232 | contents: read |
| 233 | steps: |
| 234 | - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 |
| 235 | with: |
| 236 | fetch-depth: 0 |
| 237 | ref: ${{ github.sha }} |
| 238 | persist-credentials: false |
| 239 | - name: Create or verify all implementation tags |
| 240 | env: |
| 241 | GH_TOKEN: ${{ secrets.RELEASE_TAG_TOKEN }} |
| 242 | RELEASE_TAG_ACTOR: ${{ vars.RELEASE_TAG_ACTOR }} |
| 243 | RELEASE_TAG_ACTOR_ID: ${{ needs.preflight.outputs.tag_actor_id }} |
| 244 | OPERATION: ${{ inputs.operation }} |
| 245 | VERSION: ${{ needs.preflight.outputs.version }} |
| 246 | SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }} |
| 247 | CANDIDATE_ID: ${{ needs.preflight.outputs.candidate_id }} |
| 248 | RELEASE_REVOKED_CANDIDATES: ${{ vars.RELEASE_REVOKED_CANDIDATES }} |
| 249 | run: | |
| 250 | set -euo pipefail |
| 251 | node scripts/verify-release-tag-identity.mjs "$VERSION" |
| 252 | gh auth setup-git --hostname github.com |
| 253 | node scripts/resolve-release-candidate.mjs active "$CANDIDATE_ID" |
| 254 | git fetch origin main-v2 --tags |
| 255 | bash scripts/release-candidate-tags.sh activate "$OPERATION" "$VERSION" "$SOURCE_SHA" |
| 256 | |
| 257 | cli: |
| 258 | name: publish CLI and Homebrew from candidate |
| 259 | needs: [preflight, activate] |
| 260 | uses: ./.github/workflows/release.yml |
| 261 | with: |
| 262 | tag: v${{ needs.preflight.outputs.version }} |
| 263 | approved_cli_tag: v${{ needs.preflight.outputs.version }} |
| 264 | approved_sha: ${{ needs.preflight.outputs.source_sha }} |
| 265 | orchestrated: true |
| 266 | orchestrator: promote |
| 267 | candidate_artifact_name: ${{ needs.preflight.outputs.approved_artifact }} |
| 268 | candidate_verified: true |
| 269 | secrets: inherit |
| 270 | |
| 271 | npm: |
| 272 | name: publish npm from candidate |
| 273 | needs: [preflight, activate] |
| 274 | permissions: |
| 275 | contents: read |
| 276 | id-token: write |
| 277 | uses: ./.github/workflows/release-npm.yml |
| 278 | with: |
| 279 | channel: stable |
| 280 | base_version: ${{ needs.preflight.outputs.version }} |
| 281 | tag: npm-v${{ needs.preflight.outputs.version }} |
| 282 | approved_cli_tag: v${{ needs.preflight.outputs.version }} |
| 283 | approved_sha: ${{ needs.preflight.outputs.source_sha }} |
| 284 | orchestrated: true |
| 285 | orchestrator: promote |
| 286 | candidate_artifact_name: ${{ needs.preflight.outputs.approved_artifact }} |
| 287 | candidate_verified: true |
| 288 | secrets: inherit |
| 289 | |
| 290 | desktop: |
| 291 | name: publish Desktop from candidate |
| 292 | needs: [preflight, activate] |
| 293 | uses: ./.github/workflows/release-desktop.yml |
| 294 | with: |
| 295 | channel: stable |
| 296 | tag: desktop-v${{ needs.preflight.outputs.version }} |
| 297 | approved_cli_tag: v${{ needs.preflight.outputs.version }} |
| 298 | approved_sha: ${{ needs.preflight.outputs.source_sha }} |
| 299 | orchestrated: true |
| 300 | orchestrator: promote |
| 301 | signing_preflight_verified: true |
| 302 | preflight_artifact_prefix: ${{ needs.preflight.outputs.desktop_prefix }} |
| 303 | candidate_id: ${{ needs.preflight.outputs.candidate_id }} |
| 304 | candidate_source_run_id: ${{ needs.preflight.outputs.producer_run_id }} |
| 305 | candidate_source_run_attempt: ${{ needs.preflight.outputs.producer_run_attempt }} |
| 306 | candidate_control_sha: ${{ needs.preflight.outputs.candidate_control_sha }} |
| 307 | candidate_signing_fingerprint: ${{ needs.preflight.outputs.signing_fingerprint }} |
| 308 | candidate_verified: true |
| 309 | secrets: inherit |
| 310 | |
| 311 | postflight: |
| 312 | name: verify every public Stable surface |
| 313 | timeout-minutes: 45 |
| 314 | needs: [preflight, cli, npm, desktop] |
| 315 | if: ${{ always() && !cancelled() }} |
| 316 | runs-on: ubuntu-latest |
| 317 | permissions: |
| 318 | contents: write |
| 319 | steps: |
| 320 | - name: Require every publisher |
| 321 | env: |
| 322 | CLI_RESULT: ${{ needs.cli.result }} |
| 323 | NPM_RESULT: ${{ needs.npm.result }} |
| 324 | DESKTOP_RESULT: ${{ needs.desktop.result }} |
| 325 | run: | |
| 326 | for result in "$CLI_RESULT" "$NPM_RESULT" "$DESKTOP_RESULT"; do test "$result" = success; done |
| 327 | - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 |
| 328 | with: |
| 329 | ref: ${{ github.sha }} |
| 330 | - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 |
| 331 | with: |
| 332 | node-version: "22" |
| 333 | - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 |
| 334 | with: |
| 335 | go-version-file: go.mod |
| 336 | cache: false |
| 337 | - name: Reconcile public artifacts, release event, and durable ledger |
| 338 | env: |
| 339 | GH_TOKEN: ${{ github.token }} |
| 340 | RELEASE_REPOSITORY: ${{ github.repository }} |
| 341 | RELEASE_VERSION: ${{ needs.preflight.outputs.version }} |
| 342 | RELEASE_OPERATION: ${{ inputs.operation }} |
| 343 | RELEASE_EXPECTED_SHA: ${{ needs.preflight.outputs.source_sha }} |
| 344 | RELEASE_LEDGER_OUTPUT: /tmp/publication-ledger.json |
| 345 | CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }} |
| 346 | run: bash scripts/reconcile-release-publication.sh |
| 347 | - name: Upload publication ledger |
| 348 | if: always() |
| 349 | uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 |
| 350 | with: |
| 351 | name: release-publication-ledger-${{ needs.preflight.outputs.candidate_id }}-${{ github.run_attempt }} |
| 352 | path: /tmp/publication-ledger.json |
| 353 | if-no-files-found: error |
| 354 | overwrite: true |
| 355 | retention-days: 90 |
| 356 | |
| 357 | metrics: |
| 358 | name: record publication timing |
| 359 | needs: [preflight, authorize, activate, cli, npm, desktop, postflight] |
| 360 | if: ${{ always() && !cancelled() }} |
| 361 | continue-on-error: true |
| 362 | runs-on: ubuntu-latest |
| 363 | permissions: |
| 364 | actions: read |
| 365 | contents: read |
| 366 | steps: |
| 367 | - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 |
| 368 | with: |
| 369 | ref: ${{ github.sha }} |
| 370 | - name: Record queue, execution, and critical-path evidence |
| 371 | env: |
| 372 | GH_TOKEN: ${{ github.token }} |
| 373 | run: | |
| 374 | gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" > "$RUNNER_TEMP/release-run.json" |
| 375 | gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/jobs?filter=all&per_page=100" > "$RUNNER_TEMP/release-jobs.json" |
| 376 | node scripts/ci-timings.mjs \ |
| 377 | --run "$RUNNER_TEMP/release-run.json" \ |
| 378 | --jobs "$RUNNER_TEMP/release-jobs.json" \ |
| 379 | --summary "$GITHUB_STEP_SUMMARY" \ |
| 380 | --output "$RUNNER_TEMP/publication-timing.json" \ |
| 381 | --title "Release publication timing" |
| 382 | - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 |
| 383 | with: |
| 384 | name: release-publication-timing-${{ needs.preflight.outputs.candidate_id || github.run_id }}-${{ github.run_attempt }} |
| 385 | path: ${{ runner.temp }}/publication-timing.json |
| 386 | if-no-files-found: ignore |
| 387 | overwrite: true |
| 388 | retention-days: 90 |
| 389 |