返回 DeepSeek-Reasonix
release-desktop.yml
根目录 / .github / workflows / release-desktop.yml
1 name: Release desktop
2
3 # Desktop (Wails) release line. Official releases are called by
4 # release-stable.yml after its single GitHub environment approval. Production
5 # SignPath requests therefore have the exact protected main-v2 origin.
6 #
7 # Wails cannot cross-compile a CGO/WebKit binary, so build/ fans out to one native
8 # runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a
9 # latest.json manifest is generated, and everything is published to a GitHub
10 # release and mirrored to R2 (the updater reads R2 first, then the crash worker
11 # release gateway; stable desktop releases own GitHub's repository-wide "latest").
12 #
13 # Historical workflow-call inputs still understand Preview artifacts so old
14 # runs remain diagnosable. They are not exposed by manual dispatch and cannot
15 # create a new public Preview release.
16 on:
17 workflow_dispatch:
18 inputs:
19 channel:
20 description: "Recovery channel"
21 type: choice
22 options: [stable]
23 default: stable
24 tag:
25 description: "stable: tag to publish (e.g. desktop-v1.1.0)"
26 required: false
27 type: string
28 production_signing_smoke:
29 description: "Wait for external SignPath approval, verify trust, and do not publish"
30 required: false
31 default: false
32 type: boolean
33 signing_preflight:
34 description: "Auto-approve through CI, verify the full signing path, attest it, and do not publish"
35 required: false
36 default: false
37 type: boolean
38 workflow_call:
39 inputs:
40 channel:
41 description: "Release channel selected by the approved orchestrator"
42 required: true
43 type: string
44 tag:
45 description: "Existing desktop tag to publish"
46 required: false
47 default: ""
48 type: string
49 base_version:
50 description: "Base version used for preview builds"
51 required: false
52 default: ""
53 type: string
54 approved_cli_tag:
55 description: "Stable CLI tag recorded by the approved orchestrator"
56 required: true
57 type: string
58 approved_sha:
59 description: "Immutable commit recorded by the approved orchestrator"
60 required: true
61 type: string
62 orchestrated:
63 description: "True only when called by an approved release orchestrator"
64 required: false
65 default: false
66 type: boolean
67 orchestrator:
68 description: "Trusted release orchestrator (legacy Preview calls remain readable)"
69 required: false
70 default: stable
71 type: string
72 preview_number:
73 description: "Legacy Preview ordinal for old workflow-call compatibility"
74 required: false
75 default: ""
76 type: string
77 signing_preflight:
78 description: "Verify both Windows signing stages without publishing"
79 required: false
80 default: false
81 type: boolean
82 signing_preflight_verified:
83 description: "The approved stable caller completed signing_preflight in this run"
84 required: false
85 default: false
86 type: boolean
87
88 concurrency:
89 # A channel pointer is a monotonic public state machine. Serialize every
90 # publisher for the same normalized channel, including the legacy canary alias.
91 group: release-desktop-${{ (inputs.channel == 'preview' || inputs.channel == 'canary') && 'preview' || 'stable' }}
92 cancel-in-progress: false
93
94 permissions:
95 contents: write # create the release and upload artifacts
96
97 jobs:
98 resolve:
99 name: resolve Desktop release
100 runs-on: ubuntu-latest
101 permissions:
102 contents: read
103 outputs:
104 tag: ${{ steps.release.outputs.tag }}
105 version: ${{ steps.release.outputs.version }}
106 channel: ${{ steps.release.outputs.channel }}
107 prerelease: ${{ steps.release.outputs.prerelease }}
108 notes_version: ${{ steps.release.outputs.notes_version }}
109 sha: ${{ steps.candidate.outputs.sha }}
110 steps:
111 - uses: actions/checkout@v7
112 with:
113 fetch-depth: 0
114 ref: ${{ github.sha }}
115
116 - name: Resolve version and channel
117 id: release
118 env:
119 EVENT_NAME: ${{ github.event_name }}
120 IN_ORCHESTRATED: ${{ inputs.orchestrated }}
121 IN_CHANNEL: ${{ inputs.channel }}
122 IN_TAG: ${{ inputs.tag }}
123 IN_BASE_VERSION: ${{ inputs.base_version }}
124 IN_PRODUCTION_SIGNING_SMOKE: ${{ inputs.production_signing_smoke }}
125 IN_SIGNING_PREFLIGHT: ${{ inputs.signing_preflight }}
126 REF_NAME: ${{ github.ref_name }}
127 RUN_NUMBER: ${{ github.run_number }}
128 IN_PREVIEW_NUMBER: ${{ inputs.preview_number }}
129 run: bash scripts/resolve-desktop-release.sh
130
131 - name: Resolve immutable candidate
132 id: candidate
133 env:
134 RELEASE_CHANNEL: ${{ steps.release.outputs.channel }}
135 RELEASE_TAG: ${{ steps.release.outputs.tag }}
136 IN_ORCHESTRATED: ${{ inputs.orchestrated }}
137 IN_ORCHESTRATOR: ${{ inputs.orchestrator }}
138 APPROVED_SHA: ${{ inputs.approved_sha }}
139 CALLER_EVENT_NAME: ${{ github.event_name }}
140 CALLER_REF: ${{ github.ref }}
141 CALLER_REF_PROTECTED: ${{ github.ref_protected }}
142 CALLER_SHA: ${{ github.sha }}
143 CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
144 run: bash scripts/resolve-desktop-candidate.sh
145
146 orchestration-guard:
147 name: verify approved orchestrator
148 needs: resolve
149 if: ${{ inputs.orchestrated }}
150 runs-on: ubuntu-latest
151 permissions:
152 contents: read
153 steps:
154 - uses: actions/checkout@v7
155 with:
156 fetch-depth: 0
157 ref: ${{ github.sha }}
158 - name: Verify caller and approved release ref
159 env:
160 ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
161 EXPECTED_CALLER_WORKFLOW_REF: ${{ format('{0}/.github/workflows/release-{1}.yml@{2}', github.repository, inputs.orchestrator, github.ref) }}
162 CALLER_EVENT_NAME: ${{ github.event_name }}
163 CALLER_REF: ${{ github.ref }}
164 CALLER_REF_PROTECTED: ${{ github.ref_protected }}
165 CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
166 CALLER_SHA: ${{ github.sha }}
167 APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
168 APPROVED_SHA: ${{ inputs.approved_sha }}
169 APPROVED_CHANNEL: ${{ inputs.orchestrator }}
170 RELEASE_TAG: ${{ inputs.approved_cli_tag }}
171 VERIFY_RELEASE_CHECKOUT: false
172 run: |
173 bash scripts/verify-release-authorization.sh
174 bash scripts/verify-release-tag.sh
175
176 release-gate:
177 name: approve standalone desktop release
178 needs: resolve
179 if: ${{ !inputs.orchestrated }}
180 runs-on: ubuntu-latest
181 permissions:
182 contents: read
183 # Standalone Preview is limited to non-publishing signing checks, but still
184 # exercises the production policy behind the protected `canary` environment.
185 environment: ${{ needs.resolve.outputs.channel == 'preview' && 'canary' || 'release' }}
186 steps:
187 - env:
188 RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
189 RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
190 run: echo "Approved standalone desktop $RELEASE_CHANNEL release $RELEASE_TAG"
191
192 signing-contract:
193 name: validate SignPath release contract
194 needs: [resolve, orchestration-guard, release-gate]
195 if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && needs.release-gate.result == 'success')) }}
196 runs-on: ubuntu-latest
197 permissions:
198 contents: read
199 outputs:
200 fingerprint: ${{ steps.contract.outputs.fingerprint }}
201 steps:
202 - uses: actions/checkout@v7
203 with:
204 # Validate the protected control-plane files that GitHub and SignPath
205 # execute, including during recovery of an older candidate.
206 ref: ${{ github.sha }}
207
208 - uses: actions/setup-go@v7
209 with:
210 go-version-file: go.mod
211 cache: true
212
213 - name: Validate signing mode
214 run: |
215 if [ "${{ inputs.production_signing_smoke }}" = "true" ] && [ "${{ inputs.signing_preflight }}" = "true" ]; then
216 echo "::error::production_signing_smoke and signing_preflight are mutually exclusive"
217 exit 1
218 fi
219 if [ "${{ inputs.signing_preflight_verified }}" = "true" ] && [ "${{ inputs.orchestrated }}" != "true" ]; then
220 echo "::error::only the approved stable orchestrator can assert signing_preflight_verified"
221 exit 1
222 fi
223
224 - name: Validate and fingerprint SignPath contract
225 id: contract
226 run: |
227 go run ./cmd/signpath-contract validate
228 fingerprint="$(go run ./cmd/signpath-contract fingerprint)"
229 echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT"
230
231 - name: Require current standalone signing attestation
232 if: ${{ github.repository == 'esengine/DeepSeek-Reasonix' && !inputs.signing_preflight && !inputs.production_signing_smoke && !(inputs.orchestrated && inputs.signing_preflight_verified) }}
233 env:
234 ACTUAL: ${{ vars.SIGNPATH_RELEASE_SIGNING_ATTESTATION }}
235 EXPECTED: ${{ steps.contract.outputs.fingerprint }}
236 run: |
237 if [ "$ACTUAL" != "$EXPECTED" ]; then
238 echo "::error::SIGNPATH_RELEASE_SIGNING_ATTESTATION does not match the current protected signing contract"
239 echo "::error::Run release-desktop.yml with signing_preflight=true before publishing"
240 echo "expected=$EXPECTED"
241 exit 1
242 fi
243
244 cache-guard:
245 name: cache hit guard
246 needs: [resolve, signing-contract]
247 if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.signing-contract.result == 'success' }}
248 runs-on: ubuntu-latest
249 permissions:
250 contents: read
251 steps:
252 - uses: actions/checkout@v7
253 with:
254 ref: ${{ needs.resolve.outputs.sha }}
255
256 - uses: actions/setup-go@v7
257 with:
258 go-version-file: go.mod
259 cache: true
260
261 - name: Cache hit guard
262 run: ./scripts/cache-guard.sh
263
264 - name: Verify embedded documentation identity
265 env:
266 DOCS_BUILD_VERSION: ${{ needs.resolve.outputs.version }}
267 DOCS_SOURCE_REVISION: ${{ needs.resolve.outputs.sha }}
268 run: |
269 if [ ! -f scripts/verify-embedded-docs.sh ]; then
270 echo "Legacy candidate predates the embedded docs contract; skipping."
271 exit 0
272 fi
273 bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION" "$DOCS_SOURCE_REVISION"
274
275 build:
276 name: build (${{ matrix.name }})
277 needs: [resolve, cache-guard]
278 if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.cache-guard.result == 'success' }}
279 permissions:
280 contents: read # checkout only; the publish job holds contents: write
281 actions: read # SignPath reads run details + downloads the unsigned artifact
282 strategy:
283 fail-fast: false
284 matrix:
285 include:
286 # Keep preflight on the same complete native matrix as publication so
287 # it cannot attest a release whose adjacent platform build is broken.
288 - { runner: macos-14, platform: darwin/universal, name: darwin-universal }
289 - { runner: windows-latest, platform: windows/amd64, name: windows-amd64 }
290 - { runner: windows-11-arm, platform: windows/arm64, name: windows-arm64 }
291 - { runner: ubuntu-22.04, platform: linux/amd64, name: linux-amd64 }
292 runs-on: ${{ matrix.runner }}
293 env:
294 # Windows Authenticode signing engages only when the SignPath token is set, so
295 # forks / token-less runs still build (unsigned), mirroring the APPLE_* gate.
296 HAS_SIGNPATH: ${{ secrets.SIGNPATH_API_TOKEN != '' }}
297 defaults:
298 run:
299 shell: bash # desktop-build.sh is bash; windows runners default to pwsh otherwise
300 steps:
301 - uses: actions/checkout@v7
302 with:
303 ref: ${{ needs.resolve.outputs.sha }}
304
305 - uses: actions/setup-go@v7
306 with:
307 go-version-file: desktop/go.mod
308 cache: true
309 cache-dependency-path: desktop/go.sum
310
311 - uses: actions/setup-node@v7
312 with:
313 node-version: "22"
314 - uses: pnpm/action-setup@v6.0.9
315 with:
316 version: 10
317
318 # Linux: WebKitGTK 4.1 toolchain (-tags webkit2_41 in desktop-build.sh).
319 # 4.1 ships from ubuntu-22.04 on and is the only one present on 24.04+/Fedora 40+.
320 - name: Install Linux build deps
321 if: runner.os == 'Linux'
322 run: |
323 sudo apt-get update
324 sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev
325
326 # Linux: nfpm builds the .deb in desktop-build.sh's linux branch. go install
327 # drops it in ~/go/bin, already on PATH (same place the wails CLI lands below).
328 - name: Install nfpm
329 if: runner.os == 'Linux'
330 run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.46.3
331
332 # Windows: NSIS provides makensis for `wails build -nsis`.
333 - name: Install NSIS
334 if: runner.os == 'Windows'
335 run: pwsh -NoProfile -File scripts/install-nsis.ps1
336
337 # macOS: create-dmg packages the .app into a drag-to-Applications .dmg.
338 - name: Install create-dmg
339 if: runner.os == 'macOS'
340 run: brew install create-dmg
341
342 # macOS signing: import the Developer ID cert into a throwaway keychain and
343 # stage the notarization key. No-ops (and the build ad-hoc signs) when the
344 # APPLE_* secrets aren't set, so forks still build.
345 - name: Import Apple signing certificate
346 if: runner.os == 'macOS'
347 env:
348 APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }}
349 APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
350 APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
351 run: |
352 if [ -z "$APPLE_CERT_P12" ]; then
353 echo "APPLE_CERT_P12 unset — desktop build will ad-hoc sign (un-notarized)"
354 exit 0
355 fi
356 KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
357 KEYCHAIN_PASS="$(uuidgen)"
358 security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
359 security set-keychain-settings -lut 21600 "$KEYCHAIN"
360 security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
361 echo "$APPLE_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12"
362 security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign
363 security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASS" "$KEYCHAIN" >/dev/null
364 # Prepend the signing keychain to the search list so codesign / find-identity see it.
365 existing_keychains=()
366 while IFS= read -r keychain; do
367 [ -n "$keychain" ] && existing_keychains+=("$keychain")
368 done < <(security list-keychains -d user | sed -E 's/^[[:space:]]*"//; s/"[[:space:]]*$//')
369 security list-keychains -d user -s "$KEYCHAIN" "${existing_keychains[@]}"
370 echo "$APPLE_API_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary.p8"
371 rm -f "$RUNNER_TEMP/cert.p12"
372
373 - name: Install Wails CLI
374 run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0
375
376 # Official Windows releases must fail closed. Forks can still exercise
377 # unsigned packaging without repository secrets, but esengine releases
378 # may never publish an unsigned payload or installer.
379 - name: Require Windows Authenticode signing
380 if: runner.os == 'Windows' && github.repository == 'esengine/DeepSeek-Reasonix'
381 run: |
382 if [ "$HAS_SIGNPATH" != "true" ]; then
383 echo "::error::SIGNPATH_API_TOKEN is required for public Windows Preview and Stable releases"
384 exit 1
385 fi
386
387 - name: Build and package
388 env:
389 # macOS Developer ID + notarization path turns on only when all five
390 # APPLE_* secrets are present; otherwise desktop-build.sh ad-hoc signs.
391 # Harmless on Windows/Linux runners (only the darwin branch reads these).
392 HAS_APPLE_CERT: ${{ secrets.APPLE_CERT_P12 != '' && secrets.APPLE_CERT_PASSWORD != '' && secrets.APPLE_API_KEY_P8 != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER_ID != '' }}
393 APPLE_API_KEY_PATH: ${{ runner.temp }}/notary.p8
394 APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
395 APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
396 run: scripts/desktop-build.sh "${{ matrix.platform }}" "${{ needs.resolve.outputs.version }}" "${{ needs.resolve.outputs.channel }}"
397
398 # Sign every executable that users actually run before rebuilding the
399 # portable archive and NSIS installer. Signing only the finished NSIS
400 # container leaves reasonix-desktop.exe and its sidecars unsigned after
401 # installation, which can trigger Defender reputation/ML quarantine.
402 - name: Upload unsigned Windows payload for SignPath
403 if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
404 id: unsigned-windows-payload
405 uses: actions/upload-artifact@v7
406 with:
407 name: unsigned-payload-${{ matrix.name }}
408 path: desktop/build/windows/signing-payload/*.exe
409 if-no-files-found: error
410 retention-days: 1
411
412 - name: Submit Windows payload for Authenticode signing
413 if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
414 id: submit-windows-payload
415 uses: signpath/github-action-submit-signing-request@v2
416 with:
417 api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
418 organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
419 project-slug: DeepSeek-Reasonix
420 signing-policy-slug: release-signing
421 artifact-configuration-slug: windows-payload
422 github-artifact-id: ${{ steps.unsigned-windows-payload.outputs.artifact-id }}
423 github-token: ${{ github.token }}
424 wait-for-completion: false
425
426 # The release certificate requires an approval for every request. Public
427 # builds reach this step only after their GitHub environment has been
428 # approved; the dedicated SignPath CI identity records the corresponding
429 # request approval without adding another human gate.
430 - name: Approve and download signed Windows payload
431 if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
432 shell: pwsh
433 env:
434 SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
435 run: |
436 $waitForExternalApproval = "${{ inputs.production_signing_smoke }}" -eq "true"
437 ./scripts/complete-signpath-request.ps1 `
438 -OrganizationId "${{ secrets.SIGNPATH_ORGANIZATION_ID }}" `
439 -SigningRequestId "${{ steps.submit-windows-payload.outputs.signing-request-id }}" `
440 -ExpectedSigningPolicySlug "release-signing" `
441 -OutputArtifactDirectory "signed-payload" `
442 -TimeoutSeconds 1800 `
443 -WaitForExternalApproval:$waitForExternalApproval
444
445 - name: Bind signed Windows payload to release manifest
446 if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
447 working-directory: desktop
448 env:
449 MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
450 MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
451 run: |
452 go run ./cmd/sign windows-payload ../signed-payload "${{ needs.resolve.outputs.version }}"
453 go run ./cmd/sign sign ../signed-payload/reasonix-payload.json
454 go run ./cmd/sign verify ../signed-payload/reasonix-payload.json
455
456 - name: Rebuild Windows packages from signed payload
457 if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
458 env:
459 REASONIX_REQUIRE_PAYLOAD_MANIFEST: "1"
460 run: scripts/package-windows-desktop.sh "${{ matrix.platform == 'windows/arm64' && 'arm64' || 'amd64' }}" signed-payload
461
462 # The second request signs the rebuilt NSIS container. Release signing
463 # also verifies every payload signature against a Windows trusted root.
464 - name: Upload unsigned installer for SignPath
465 if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
466 id: unsigned-installer
467 uses: actions/upload-artifact@v7
468 with:
469 name: unsigned-${{ matrix.name }}
470 path: desktop/build/windows/installer-signing-bundle/*.exe
471 if-no-files-found: error
472 retention-days: 1
473
474 - name: Submit installer for Authenticode signing
475 if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
476 id: submit-windows-installer
477 uses: signpath/github-action-submit-signing-request@v2
478 with:
479 api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
480 organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
481 project-slug: DeepSeek-Reasonix
482 signing-policy-slug: release-signing
483 artifact-configuration-slug: windows-installer-v2
484 github-artifact-id: ${{ steps.unsigned-installer.outputs.artifact-id }}
485 github-token: ${{ github.token }}
486 wait-for-completion: false
487
488 - name: Approve and download signed Windows installer
489 if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
490 shell: pwsh
491 env:
492 SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
493 run: |
494 $waitForExternalApproval = "${{ inputs.production_signing_smoke }}" -eq "true"
495 ./scripts/complete-signpath-request.ps1 `
496 -OrganizationId "${{ secrets.SIGNPATH_ORGANIZATION_ID }}" `
497 -SigningRequestId "${{ steps.submit-windows-installer.outputs.signing-request-id }}" `
498 -ExpectedSigningPolicySlug "release-signing" `
499 -OutputArtifactDirectory "signed-installer" `
500 -TimeoutSeconds 1800 `
501 -WaitForExternalApproval:$waitForExternalApproval
502
503 - name: Replace installer with signed build
504 if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
505 run: cp signed-installer/*installer*.exe dist/
506
507 # Recovery builds intentionally check out the immutable product SHA,
508 # which can predate a verifier repair. Fetch only the verifier from the
509 # protected workflow commit so recovery can validate old immutable
510 # artifacts without rebuilding them from a moving product branch.
511 - name: Checkout protected release verifier
512 if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
513 uses: actions/checkout@v7
514 with:
515 ref: ${{ github.sha }}
516 path: release-control
517 sparse-checkout: scripts/verify-windows-authenticode.ps1
518 sparse-checkout-cone-mode: false
519 persist-credentials: false
520
521 - name: Verify Windows Authenticode release contract
522 if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
523 shell: pwsh
524 run: |
525 $arch = if ("${{ matrix.platform }}" -eq "windows/arm64") { "arm64" } else { "amd64" }
526 ./release-control/scripts/verify-windows-authenticode.ps1 `
527 -PayloadDirectory signed-payload `
528 -InstallerPath "dist/Reasonix-windows-$arch-installer.exe" `
529 -PortableArchivePath "dist/Reasonix-windows-$arch.zip" `
530 -RequireTrusted:$true
531
532 - name: Sign artifacts (minisign)
533 working-directory: desktop
534 env:
535 MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
536 MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
537 run: go run ./cmd/sign sign ../dist/*
538
539 - uses: actions/upload-artifact@v7
540 with:
541 name: dist-${{ matrix.name }}
542 path: dist/*
543 if-no-files-found: error
544 # Same-run handoff to the publish job only; 7 days covers debugging.
545 retention-days: 7
546
547 publish:
548 name: publish release
549 needs: [resolve, build]
550 if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.build.result == 'success' && !inputs.production_signing_smoke && !inputs.signing_preflight }}
551 runs-on: ubuntu-latest
552 permissions:
553 contents: write
554 # Approved orchestrators have already passed the matching GitHub environment.
555 # Direct prereleases and manual Stable recovery pass release-gate above. The
556 # SignPath CI identity records each payload and installer approval after it.
557 steps:
558 - uses: actions/checkout@v7
559 with:
560 fetch-depth: 0
561 ref: ${{ needs.resolve.outputs.sha }}
562
563 # A standalone recovery may build an older Stable tag. Keep the release
564 # control plane on the protected workflow commit so newly-added
565 # authorization and recovery scripts remain available, while the source
566 # tree above stays pinned to the immutable candidate.
567 - uses: actions/checkout@v7
568 with:
569 fetch-depth: 0
570 path: release-control
571 ref: ${{ github.workflow_sha }}
572
573 - name: Revalidate immutable Desktop candidate
574 env:
575 RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
576 RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
577 IN_ORCHESTRATED: ${{ inputs.orchestrated }}
578 IN_ORCHESTRATOR: ${{ inputs.orchestrator }}
579 APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
580 CALLER_EVENT_NAME: ${{ github.event_name }}
581 CALLER_REF: ${{ github.ref }}
582 CALLER_REF_PROTECTED: ${{ github.ref_protected }}
583 CALLER_SHA: ${{ github.sha }}
584 CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
585 REQUIRE_CURRENT_MAIN: false
586 VERIFY_RELEASE_CHECKOUT: true
587 run: bash release-control/scripts/resolve-desktop-candidate.sh
588
589 - uses: actions/setup-go@v7
590 with:
591 go-version-file: desktop/go.mod
592 cache: true
593 cache-dependency-path: desktop/go.sum
594
595 - uses: actions/setup-node@v7
596 with:
597 node-version: "22"
598
599 - uses: actions/download-artifact@v8
600 with:
601 path: dist
602 pattern: dist-*
603 merge-multiple: true
604
605 # Generate latest.json with GitHub release download URLs; the mirror step
606 # rewrites them to R2 afterwards. GITHUB_REPOSITORY is provided by the runner.
607 - name: Generate manifest
608 working-directory: desktop
609 run: >-
610 go run ./cmd/sign manifest ../dist
611 "${{ needs.resolve.outputs.version }}"
612 "${{ needs.resolve.outputs.tag }}"
613 "${{ needs.resolve.outputs.notes_version }}"
614
615 - name: Validate generated manifest before publication
616 env:
617 CHANNEL: ${{ needs.resolve.outputs.channel }}
618 PRERELEASE: ${{ needs.resolve.outputs.prerelease }}
619 TAG: ${{ needs.resolve.outputs.tag }}
620 VERSION: ${{ needs.resolve.outputs.version }}
621 NOTES_VERSION: ${{ needs.resolve.outputs.notes_version }}
622 run: |
623 set -euo pipefail
624 validation_channel="$CHANNEL"
625 if [ "$CHANNEL" != "preview" ] && [ "$PRERELEASE" = "true" ]; then
626 validation_channel="any"
627 fi
628 bash release-control/scripts/validate-desktop-release-manifest.sh \
629 "$validation_channel" "$VERSION" \
630 "https://github.com/esengine/DeepSeek-Reasonix/releases/download/${TAG}/" \
631 dist/latest.json "$NOTES_VERSION"
632
633 - name: Download orchestrator-reviewed release notes
634 if: ${{ inputs.orchestrated && needs.resolve.outputs.channel != 'preview' }}
635 uses: actions/download-artifact@v8
636 with:
637 name: orchestrator-reviewed-release-notes
638 path: /tmp/orchestrator-reviewed-release-notes
639
640 - name: Use orchestrator-reviewed release notes
641 if: ${{ inputs.orchestrated && needs.resolve.outputs.channel != 'preview' }}
642 run: |
643 test -s /tmp/orchestrator-reviewed-release-notes/release-notes.md
644 cp /tmp/orchestrator-reviewed-release-notes/release-notes.md /tmp/release-notes.md
645
646 # Preview never appears on the GitHub releases page; the mirror job picks up
647 # the signed dist via the preview-dist artifact below. Stable publishes a
648 # GitHub release as usual.
649 - name: Render reviewed release notes
650 if: ${{ !inputs.orchestrated && needs.resolve.outputs.channel != 'preview' }}
651 run: node scripts/release-notes.mjs render --version "${{ needs.resolve.outputs.notes_version }}" --output /tmp/release-notes.md
652
653 - name: Revalidate approved release ref
654 if: ${{ inputs.orchestrated }}
655 env:
656 RELEASE_TAG: ${{ inputs.approved_cli_tag }}
657 APPROVED_SHA: ${{ inputs.approved_sha }}
658 run: bash scripts/verify-release-tag.sh
659
660 - name: Publish GitHub release
661 if: needs.resolve.outputs.channel != 'preview'
662 env:
663 GH_TOKEN: ${{ github.token }}
664 # Keep the repository homepage focused on the installable desktop app;
665 # the CLI release line is configured not to claim repository-wide latest.
666 run: >-
667 bash release-control/scripts/publish-desktop-github-release.sh
668 "${{ needs.resolve.outputs.tag }}"
669 "${{ needs.resolve.outputs.version }}"
670 "${{ needs.resolve.outputs.prerelease }}"
671 /tmp/release-notes.md
672 dist
673
674 - name: Upload preview dist for mirror
675 if: needs.resolve.outputs.channel == 'preview'
676 uses: actions/upload-artifact@v7
677 with:
678 name: preview-dist
679 path: dist/*
680 if-no-files-found: error
681 # Same-run handoff to the mirror step only; 7 days covers debugging.
682 retention-days: 7
683
684 attest-signing-contract:
685 name: record standalone SignPath attestation
686 needs: [signing-contract, build]
687 if: ${{ always() && !cancelled() && inputs.signing_preflight && !inputs.orchestrated && github.repository == 'esengine/DeepSeek-Reasonix' && needs.signing-contract.result == 'success' && needs.build.result == 'success' }}
688 runs-on: ubuntu-latest
689 permissions:
690 actions: write
691 contents: read
692 env:
693 GH_TOKEN: ${{ github.token }}
694 VARIABLE_NAME: SIGNPATH_RELEASE_SIGNING_ATTESTATION
695 VARIABLE_VALUE: ${{ needs.signing-contract.outputs.fingerprint }}
696 steps:
697 - name: Record verified signing contract
698 run: |
699 endpoint="repos/$GITHUB_REPOSITORY/actions/variables/$VARIABLE_NAME"
700 if gh api "$endpoint" >/dev/null 2>&1; then
701 gh api --method PATCH "$endpoint" -f value="$VARIABLE_VALUE" >/dev/null
702 else
703 gh api --method POST "repos/$GITHUB_REPOSITORY/actions/variables" \
704 -f name="$VARIABLE_NAME" -f value="$VARIABLE_VALUE" >/dev/null
705 fi
706 echo "Recorded $VARIABLE_NAME=$VARIABLE_VALUE"
707
708 mirror:
709 name: mirror to R2
710 needs: [resolve, publish]
711 runs-on: ubuntu-latest
712 permissions:
713 contents: write # gh release download + compatibility manifest upload
714 actions: write # dispatch pages.yml to re-bake the site version
715 # Stable keeps GitHub as a fallback when R2 is unavailable. Preview has no
716 # GitHub release, so it must fail closed before attempting publication.
717 if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.publish.result == 'success' && github.repository_owner == 'esengine' }}
718 env:
719 HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
720 steps:
721 - uses: actions/checkout@v7
722 with:
723 fetch-depth: 0
724 ref: ${{ needs.resolve.outputs.sha }}
725
726 - uses: actions/checkout@v7
727 with:
728 fetch-depth: 0
729 path: release-control
730 ref: ${{ github.workflow_sha }}
731
732 - uses: actions/setup-go@v7
733 with:
734 go-version-file: release-control/desktop/go.mod
735 cache: true
736 cache-dependency-path: release-control/desktop/go.sum
737
738 - name: Revalidate immutable Desktop candidate
739 env:
740 RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
741 RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
742 IN_ORCHESTRATED: ${{ inputs.orchestrated }}
743 IN_ORCHESTRATOR: ${{ inputs.orchestrator }}
744 APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
745 CALLER_EVENT_NAME: ${{ github.event_name }}
746 CALLER_REF: ${{ github.ref }}
747 CALLER_REF_PROTECTED: ${{ github.ref_protected }}
748 CALLER_SHA: ${{ github.sha }}
749 CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
750 REQUIRE_CURRENT_MAIN: false
751 VERIFY_RELEASE_CHECKOUT: true
752 run: bash release-control/scripts/resolve-desktop-candidate.sh
753
754 - name: Require R2 for Preview
755 if: needs.resolve.outputs.channel == 'preview' && env.HAS_R2 != 'true'
756 run: |
757 echo "::error::R2 credentials are required because Preview has no GitHub release fallback"
758 exit 1
759
760 - name: Revalidate approved release ref
761 if: ${{ inputs.orchestrated }}
762 env:
763 RELEASE_TAG: ${{ inputs.approved_cli_tag }}
764 APPROVED_SHA: ${{ inputs.approved_sha }}
765 run: bash scripts/verify-release-tag.sh
766
767 # Preview has no GitHub release — pull the signed dist from the workflow
768 # artifact. Stable pulls from the published release.
769 - name: Download preview dist
770 if: env.HAS_R2 == 'true' && needs.resolve.outputs.channel == 'preview'
771 uses: actions/download-artifact@v8
772 with:
773 name: preview-dist
774 path: assets
775
776 - name: Download release assets
777 if: env.HAS_R2 == 'true' && needs.resolve.outputs.channel != 'preview'
778 env:
779 GH_TOKEN: ${{ github.token }}
780 run: |
781 mkdir -p assets
782 gh release download "${{ needs.resolve.outputs.tag }}" -R "${{ github.repository }}" -D assets
783
784 # Rewrite both url and sig inside latest.json from github.com to the R2 CDN,
785 # so the updater pulls the manifest AND the heavy artifacts from R2.
786 - name: Rewrite latest.json URLs to R2
787 if: env.HAS_R2 == 'true'
788 env:
789 R2_PUBLIC_BASE: https://dl.reasonix.io
790 TAG: ${{ needs.resolve.outputs.tag }}
791 run: |
792 f=assets/latest.json
793 jq --arg base "$R2_PUBLIC_BASE" --arg tag "$TAG" '
794 def rewrite_asset:
795 .url |= sub("https://github.com/[^/]+/[^/]+/releases/download/[^/]+/"; "\($base)/\($tag)/")
796 | .sig |= sub("https://github.com/[^/]+/[^/]+/releases/download/[^/]+/"; "\($base)/\($tag)/");
797 .platforms |= with_entries(.value |= rewrite_asset)
798 | if .native_packages then
799 .native_packages |= with_entries(.value |= rewrite_asset)
800 else . end
801 | if .downloads then
802 .downloads |= with_entries(.value |= rewrite_asset)
803 else . end
804 ' "$f" > "$f.new"
805 mv "$f.new" "$f"
806 cat "$f"
807
808 - name: Validate R2 manifest before upload
809 if: env.HAS_R2 == 'true'
810 env:
811 CHANNEL: ${{ needs.resolve.outputs.channel }}
812 PRERELEASE: ${{ needs.resolve.outputs.prerelease }}
813 TAG: ${{ needs.resolve.outputs.tag }}
814 VERSION: ${{ needs.resolve.outputs.version }}
815 NOTES_VERSION: ${{ needs.resolve.outputs.notes_version }}
816 run: |
817 set -euo pipefail
818 validation_channel="$CHANNEL"
819 if [ "$CHANNEL" != "preview" ] && [ "$PRERELEASE" = "true" ]; then
820 validation_channel="any"
821 fi
822 bash release-control/scripts/validate-desktop-release-manifest.sh \
823 "$validation_channel" "$VERSION" \
824 "https://dl.reasonix.io/${TAG}/" \
825 assets/latest.json "$NOTES_VERSION"
826
827 - name: Configure AWS CLI for R2
828 if: env.HAS_R2 == 'true'
829 run: |
830 aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
831 aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
832 aws configure set region auto
833
834 - name: Mirror immutable assets and advance R2 pointer
835 id: mirror_r2
836 if: env.HAS_R2 == 'true'
837 env:
838 CHANNEL: ${{ needs.resolve.outputs.channel }}
839 PRERELEASE: ${{ needs.resolve.outputs.prerelease }}
840 R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
841 R2_BUCKET: ${{ secrets.R2_BUCKET }}
842 TAG: ${{ needs.resolve.outputs.tag }}
843 VERSION: ${{ needs.resolve.outputs.version }}
844 NOTES_VERSION: ${{ needs.resolve.outputs.notes_version }}
845 run: |
846 set -euo pipefail
847 ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
848
849 download_optional() {
850 local key="$1"
851 local output="$2"
852 local error_file
853 error_file="$(mktemp)"
854 if aws s3 cp "s3://${R2_BUCKET}/${key}" "$output" \
855 --endpoint-url "$ENDPOINT" >/dev/null 2>"$error_file"; then
856 rm -f "$error_file"
857 return 0
858 fi
859 if grep -Eiq '404|NoSuchKey|Not Found' "$error_file"; then
860 rm -f "$error_file"
861 return 3
862 fi
863 cat "$error_file" >&2
864 rm -f "$error_file"
865 return 1
866 }
867
868 validation_channel="$CHANNEL"
869 if [ "$CHANNEL" != "preview" ] && [ "$PRERELEASE" = "true" ]; then
870 validation_channel="any"
871 fi
872 asset_base="https://dl.reasonix.io/${TAG}/"
873 existing_manifest=false
874
875 signature_verifier=/tmp/reasonix-desktop-sign
876 go -C release-control/desktop build -o "$signature_verifier" ./cmd/sign
877 verify_signature_directory() {
878 local directory="$1"
879 local signature payload
880 while IFS= read -r -d '' signature; do
881 payload="${signature%.minisig}"
882 if [ ! -f "$payload" ]; then
883 echo "::error::Desktop signature has no matching payload: $signature"
884 return 1
885 fi
886 "$signature_verifier" verify "$payload"
887 done < <(find "$directory" -type f -name '*.minisig' -print0)
888 }
889 require_signature_coverage() {
890 local directory="$1"
891 local payload
892 while IFS= read -r -d '' payload; do
893 if [ ! -s "$payload.minisig" ]; then
894 echo "::error::Desktop payload has no non-empty signature: $payload"
895 return 1
896 fi
897 done < <(find "$directory" -type f ! -name '*.minisig' ! -name 'latest.json' -print0)
898 }
899 verify_signature_directory assets
900 require_signature_coverage assets
901
902 # A version directory is immutable once written. Recovery may fill an
903 # incomplete candidate subset, but it may never replace conflicting or
904 # unexpected content, including an already-written latest.json.
905 existing_directory="$(mktemp -d)"
906 existing_keys="$(
907 aws s3api list-objects-v2 \
908 --bucket "$R2_BUCKET" \
909 --prefix "${TAG}/" \
910 --query 'Contents[].Key' \
911 --output text \
912 --endpoint-url "$ENDPOINT"
913 )"
914 if [ -n "$existing_keys" ] && [ "$existing_keys" != "None" ]; then
915 aws s3 cp "s3://${R2_BUCKET}/${TAG}/" "$existing_directory/" \
916 --recursive --endpoint-url "$ENDPOINT"
917 verify_signature_directory "$existing_directory"
918 if [ -f "$existing_directory/latest.json" ]; then
919 existing_manifest=true
920 bash release-control/scripts/validate-desktop-release-manifest.sh \
921 "legacy-${validation_channel}" "$VERSION" "$asset_base" \
922 "$existing_directory/latest.json" "$NOTES_VERSION"
923 bash release-control/scripts/verify-desktop-release-manifest-assets.sh \
924 "$existing_directory/latest.json" "$existing_directory"
925 cp "$existing_directory/latest.json" assets/latest.json
926 fi
927 bash release-control/scripts/verify-desktop-release-directory.sh \
928 --allow-missing --allow-legacy-manifest \
929 --allow-authenticated-payload-differences assets "$existing_directory"
930
931 # Preserve every already-published authenticated payload/signature
932 # pair. Platform signing and packaging are non-deterministic, so a
933 # recovery may fill missing pairs but must not replace valid ones.
934 while IFS= read -r -d '' signature; do
935 relative="${signature#"$existing_directory"/}"
936 payload="${signature%.minisig}"
937 payload_relative="${payload#"$existing_directory"/}"
938 mkdir -p "assets/$(dirname "$relative")"
939 cp "$payload" "assets/$payload_relative"
940 cp "$signature" "assets/$relative"
941 done < <(find "$existing_directory" -type f -name '*.minisig' -print0)
942 fi
943
944 aws s3 cp assets/ "s3://${R2_BUCKET}/${TAG}/" \
945 --recursive \
946 --exclude latest.json \
947 --endpoint-url "$ENDPOINT" \
948 --cache-control "public, max-age=31536000, immutable"
949 if [ "$existing_manifest" != "true" ]; then
950 aws s3 cp assets/latest.json "s3://${R2_BUCKET}/${TAG}/latest.json" \
951 --endpoint-url "$ENDPOINT" \
952 --content-type "application/json; charset=utf-8" \
953 --cache-control "public, max-age=31536000, immutable"
954 fi
955 published_directory="$(mktemp -d)"
956 aws s3 cp "s3://${R2_BUCKET}/${TAG}/" "$published_directory/" \
957 --recursive --endpoint-url "$ENDPOINT"
958 bash release-control/scripts/verify-desktop-release-directory.sh \
959 --allow-legacy-manifest assets "$published_directory"
960 verify_signature_directory "$published_directory"
961 require_signature_coverage "$published_directory"
962 bash release-control/scripts/verify-desktop-release-manifest-assets.sh \
963 "$published_directory/latest.json" "$published_directory"
964
965 aws s3 cp "s3://${R2_BUCKET}/${TAG}/latest.json" \
966 /tmp/reasonix-desktop-tag-latest.json --endpoint-url "$ENDPOINT"
967 bash release-control/scripts/validate-desktop-release-manifest.sh \
968 "legacy-${validation_channel}" "$VERSION" "$asset_base" \
969 /tmp/reasonix-desktop-tag-latest.json "$NOTES_VERSION"
970 bash release-control/scripts/compare-desktop-release-manifests.sh \
971 assets/latest.json /tmp/reasonix-desktop-tag-latest.json
972
973 # Internal RCs retain their immutable record but never move a public
974 # channel pointer.
975 if [ "$PRERELEASE" = "true" ] && [ "$CHANNEL" != "preview" ]; then
976 echo "pointer_moved=false" >> "$GITHUB_OUTPUT"
977 echo "internal Desktop prerelease $VERSION; public pointers remain unchanged"
978 exit 0
979 fi
980
981 validate_current_pointer() {
982 local current_channel="$1"
983 local current_version="$2"
984 local current_file="$3"
985 local current_base="https://dl.reasonix.io/desktop-${current_version}/"
986 if bash release-control/scripts/validate-desktop-release-manifest.sh \
987 "$current_channel" "$current_version" "$current_base" \
988 "$current_file"; then
989 return 0
990 fi
991
992 if bash release-control/scripts/validate-desktop-release-manifest.sh \
993 "legacy-${current_channel}" "$current_version" "$current_base" \
994 "$current_file"; then
995 echo "using legacy $current_channel manifest $current_version at its immutable base only as the monotonic migration baseline"
996 return 0
997 fi
998
999 # Early Preview pointers referenced the mutable desktop-preview/
1000 # directory. Try that layout only after the immutable legacy layout
1001 # so later legacy pointers retain their version-bound asset URLs.
1002 local legacy_preview_base="https://dl.reasonix.io/desktop-preview/"
1003 if [ "$current_channel" = "preview" ] && \
1004 bash release-control/scripts/validate-desktop-release-manifest.sh \
1005 legacy-preview "$current_version" "$legacy_preview_base" \
1006 "$current_file"; then
1007 echo "using legacy Preview manifest $current_version at the rolling base only as the monotonic migration baseline"
1008 return 0
1009 fi
1010 echo "::error::existing Desktop $current_channel pointer is invalid"
1011 return 1
1012 }
1013
1014 pointer_decision=""
1015 pointer_state=""
1016 if [ "$CHANNEL" = "preview" ]; then
1017 preview_manifest=-
1018 preview_version=""
1019 preview_download=/tmp/reasonix-desktop-current-preview.json
1020 if download_optional "preview/latest.json" "$preview_download"; then
1021 preview_version="$(jq -er '.version | strings' "$preview_download")"
1022 validate_current_pointer preview "$preview_version" "$preview_download"
1023 preview_manifest="$preview_download"
1024 else
1025 status=$?
1026 if [ "$status" -ne 3 ]; then
1027 exit "$status"
1028 fi
1029 fi
1030
1031 canary_manifest=-
1032 canary_version=""
1033 canary_download=/tmp/reasonix-desktop-current-canary.json
1034 if download_optional "canary/latest.json" "$canary_download"; then
1035 canary_version="$(jq -er '.version | strings' "$canary_download")"
1036 validate_current_pointer preview "$canary_version" "$canary_download"
1037 canary_manifest="$canary_download"
1038 else
1039 status=$?
1040 if [ "$status" -ne 3 ]; then
1041 exit "$status"
1042 fi
1043 fi
1044
1045 pointer_decision="$(
1046 bash release-control/scripts/decide-desktop-pointer-update.sh \
1047 preview assets/latest.json "$preview_manifest" "$canary_manifest"
1048 )"
1049 pointer_state="preview=${preview_version:-unset}, canary=${canary_version:-unset}"
1050 else
1051 current_version=""
1052 current_manifest=/tmp/reasonix-desktop-current-pointer.json
1053 if download_optional "latest/latest.json" "$current_manifest"; then
1054 current_version="$(jq -er '.version | strings' "$current_manifest")"
1055 validate_current_pointer stable "$current_version" "$current_manifest"
1056 else
1057 status=$?
1058 if [ "$status" -ne 3 ]; then
1059 exit "$status"
1060 fi
1061 fi
1062 pointer_decision="$(
1063 bash release-control/scripts/decide-desktop-pointer-update.sh \
1064 stable assets/latest.json \
1065 "$([ -n "$current_version" ] && printf '%s' "$current_manifest" || printf '%s' -)"
1066 )"
1067 pointer_state="${current_version:-unset}"
1068 fi
1069
1070 IFS=$'\t' read -r pointer_action pointer_source <<< "$pointer_decision"
1071 if [ "$pointer_action" = "skip" ]; then
1072 echo "pointer_moved=false" >> "$GITHUB_OUTPUT"
1073 echo "Desktop $CHANNEL pointer remains $pointer_state; candidate $VERSION is not newer and needs no repair"
1074 exit 0
1075 fi
1076 if [ "$pointer_action" != "update" ] || [ ! -f "$pointer_source" ]; then
1077 echo "::error::invalid Desktop pointer decision: $pointer_decision"
1078 exit 1
1079 fi
1080 pointer_version="$(jq -er '.version | strings' "$pointer_source")"
1081
1082 publish_pointer() {
1083 local destination="$1"
1084 local downloaded="/tmp/reasonix-desktop-${destination}-latest.json"
1085 aws s3 cp "$pointer_source" "s3://${R2_BUCKET}/${destination}/latest.json" \
1086 --endpoint-url "$ENDPOINT" \
1087 --content-type "application/json; charset=utf-8" \
1088 --cache-control "public, max-age=300, stale-if-error=86400"
1089 aws s3 cp "s3://${R2_BUCKET}/${destination}/latest.json" "$downloaded" \
1090 --endpoint-url "$ENDPOINT"
1091 validate_current_pointer "$CHANNEL" "$pointer_version" "$downloaded"
1092 cmp -s "$pointer_source" "$downloaded"
1093 }
1094
1095 if [ "$CHANNEL" = "preview" ]; then
1096 # Write compatibility first. If the primary write fails, a rerun
1097 # still observes the old primary and safely retries both writes.
1098 publish_pointer canary
1099 publish_pointer preview
1100 if ! cmp -s /tmp/reasonix-desktop-canary-latest.json /tmp/reasonix-desktop-preview-latest.json; then
1101 echo "::error::Desktop Preview and Canary pointers diverged after publication"
1102 exit 1
1103 fi
1104 else
1105 publish_pointer latest
1106 fi
1107 echo "pointer_moved=true" >> "$GITHUB_OUTPUT"
1108 echo "pointer_version=$pointer_version" >> "$GITHUB_OUTPUT"
1109 echo "Desktop $CHANNEL pointer -> $pointer_version"
1110
1111 # dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
1112 # protection), so smoke the mirrored objects over the authenticated S3 API
1113 # instead of the public edge. This verifies the mirror landed; the public
1114 # edge itself is not reachable from CI and is covered by end users' traffic.
1115 - name: Smoke desktop release pointers
1116 if: env.HAS_R2 == 'true'
1117 env:
1118 TAG: ${{ needs.resolve.outputs.tag }}
1119 VERSION: ${{ needs.resolve.outputs.version }}
1120 CHANNEL: ${{ needs.resolve.outputs.channel }}
1121 PRERELEASE: ${{ needs.resolve.outputs.prerelease }}
1122 NOTES_VERSION: ${{ needs.resolve.outputs.notes_version }}
1123 POINTER_MOVED: ${{ steps.mirror_r2.outputs.pointer_moved }}
1124 POINTER_VERSION: ${{ steps.mirror_r2.outputs.pointer_version }}
1125 R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
1126 R2_BUCKET: ${{ secrets.R2_BUCKET }}
1127 run: |
1128 set -euo pipefail
1129 ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
1130 f=assets/latest.json
1131 validation_channel="$CHANNEL"
1132 if [ "$CHANNEL" != "preview" ] && [ "$PRERELEASE" = "true" ]; then
1133 validation_channel="any"
1134 fi
1135 asset_base="https://dl.reasonix.io/${TAG}/"
1136 bash release-control/scripts/validate-desktop-release-manifest.sh \
1137 "$validation_channel" "$VERSION" "$asset_base" "$f" "$NOTES_VERSION"
1138
1139 aws s3 cp "s3://${R2_BUCKET}/${TAG}/latest.json" /tmp/reasonix-desktop-tag-latest.json --endpoint-url "$ENDPOINT"
1140 bash release-control/scripts/validate-desktop-release-manifest.sh \
1141 "legacy-${validation_channel}" "$VERSION" "$asset_base" \
1142 /tmp/reasonix-desktop-tag-latest.json "$NOTES_VERSION"
1143 bash release-control/scripts/compare-desktop-release-manifests.sh \
1144 "$f" /tmp/reasonix-desktop-tag-latest.json
1145
1146 if [ "$POINTER_MOVED" = "true" ]; then
1147 pointer="latest"
1148 [ "$CHANNEL" = "preview" ] && pointer="preview"
1149 aws s3 cp "s3://${R2_BUCKET}/${pointer}/latest.json" /tmp/reasonix-desktop-pointer-latest.json --endpoint-url "$ENDPOINT"
1150 pointer_base="https://dl.reasonix.io/desktop-${POINTER_VERSION}/"
1151 if ! bash release-control/scripts/validate-desktop-release-manifest.sh \
1152 "$CHANNEL" "$POINTER_VERSION" "$pointer_base" \
1153 /tmp/reasonix-desktop-pointer-latest.json; then
1154 legacy_base="$pointer_base"
1155 [ "$CHANNEL" = "preview" ] && legacy_base="https://dl.reasonix.io/desktop-preview/"
1156 bash release-control/scripts/validate-desktop-release-manifest.sh \
1157 "legacy-${CHANNEL}" "$POINTER_VERSION" "$legacy_base" \
1158 /tmp/reasonix-desktop-pointer-latest.json
1159 fi
1160 if [ "$CHANNEL" = "preview" ]; then
1161 aws s3 cp "s3://${R2_BUCKET}/canary/latest.json" /tmp/reasonix-desktop-canary-latest.json --endpoint-url "$ENDPOINT"
1162 cmp -s /tmp/reasonix-desktop-pointer-latest.json /tmp/reasonix-desktop-canary-latest.json
1163 fi
1164 fi
1165
1166 jq -r '(.platforms[] | .url, .sig), ((.native_packages // {})[] | .url, .sig), ((.downloads // {})[] | .url, .sig)' "$f" | while IFS= read -r asset; do
1167 [ -n "$asset" ] || continue
1168 key="${asset#https://dl.reasonix.io/}"
1169 aws s3api head-object --bucket "$R2_BUCKET" --key "$key" --endpoint-url "$ENDPOINT" >/dev/null
1170 done
1171
1172 # Best-effort probe of the release gateway — the updater's second
1173 # manifest source — over the same public edge and Go client UA end users
1174 # hit. A 403 here is the known Cloudflare bot-protection gap (#6005:
1175 # datacenter/proxy egress gets blocked before the worker runs) and must
1176 # not fail the release until a WAF skip rule for /v1/desktop/releases/*
1177 # lands; it is surfaced as a warning so the run shows whether the edge
1178 # is open. Anything else unexpected (404, 5xx, wrong version) means the
1179 # gateway route or pointer regressed and fails hard.
1180 - name: Probe public release gateway
1181 if: env.HAS_R2 == 'true'
1182 env:
1183 VERSION: ${{ needs.resolve.outputs.version }}
1184 CHANNEL: ${{ needs.resolve.outputs.channel }}
1185 PRERELEASE: ${{ needs.resolve.outputs.prerelease }}
1186 POINTER_MOVED: ${{ steps.mirror_r2.outputs.pointer_moved }}
1187 POINTER_VERSION: ${{ steps.mirror_r2.outputs.pointer_version }}
1188 run: |
1189 set -euo pipefail
1190 if [ "$POINTER_MOVED" != "true" ]; then
1191 echo "Desktop $CHANNEL pointer did not move; skipping gateway probe"
1192 exit 0
1193 fi
1194 chan="stable"
1195 [ "$CHANNEL" = "preview" ] && chan="preview"
1196 url="https://crash.reasonix.io/v1/desktop/releases/${chan}/latest.json"
1197 # curl already prints 000 for a transport failure; || true keeps -e
1198 # from killing the step so the case below can route it.
1199 code="$(curl -sS -A "Go-http-client/2.0" -o /tmp/gateway-latest.json -w '%{http_code}' "$url" || true)"
1200 case "$code" in
1201 200)
1202 if jq -e --arg version "$POINTER_VERSION" '.version == $version' /tmp/gateway-latest.json >/dev/null; then
1203 echo "gateway serves $POINTER_VERSION on $chan"
1204 else
1205 echo "::error::gateway responded 200 but serves $(jq -r '.version // "<none>"' /tmp/gateway-latest.json), want $POINTER_VERSION — stale or wrong pointer"
1206 exit 1
1207 fi
1208 ;;
1209 403)
1210 echo "::warning::gateway returned 403 to CI egress — known bot-protection gap (#6005), not failing the release"
1211 ;;
1212 000|"")
1213 echo "::warning::gateway unreachable from CI (transport error), not failing the release"
1214 ;;
1215 *)
1216 echo "::error::gateway returned $code for $url — route or pointer regression"
1217 exit 1
1218 ;;
1219 esac
1220
1221 - name: Attach desktop manifest to matching CLI release
1222 if: env.HAS_R2 == 'true' && needs.resolve.outputs.channel != 'preview' && needs.resolve.outputs.prerelease != 'true'
1223 env:
1224 GH_TOKEN: ${{ github.token }}
1225 VERSION: ${{ needs.resolve.outputs.version }}
1226 run: |
1227 set -euo pipefail
1228 if gh release view "$VERSION" >/dev/null 2>&1; then
1229 gh release upload "$VERSION" assets/latest.json --clobber
1230 else
1231 echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes."
1232 fi
1233
1234 # Stable release moved R2 latest/ — rebuild the site so its build-time baked
1235 # version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO).
1236 - name: Refresh site to the new version
1237 if: env.HAS_R2 == 'true' && needs.resolve.outputs.channel != 'preview' && needs.resolve.outputs.prerelease != 'true' && steps.mirror_r2.outputs.pointer_moved == 'true'
1238 env:
1239 GH_TOKEN: ${{ github.token }}
1240 run: gh workflow run pages.yml --ref main-v2
1241
1241 lines YAML