返回 DeepSeek-Reasonix
release-desktop.yml
根目录 / .github / workflows / release-desktop.yml
1 name: Release desktop
2
3 # Desktop (Electron) release line. Official releases are called by
4 # release-stable.yml after its single GitHub environment approval. Production
5 # Certum signing therefore runs from the protected main-v2 control plane.
6 #
7 # The Electron shell cannot cross-compile its native targets from one host, so
8 # build/ fans out to one native runner per platform. Artifacts are
9 # minisign-signed (MINISIGN_* secrets), a latest.json manifest is generated, and
10 # everything is published to a GitHub release and mirrored to R2 (the updater
11 # reads R2 first, then the crash worker release gateway; stable desktop releases
12 # own GitHub's repository-wide "latest").
13 #
14 # Historical workflow-call inputs still understand Preview artifacts so old
15 # runs remain diagnosable. They are not exposed by manual dispatch and cannot
16 # create a new public Preview release.
17 on:
18 workflow_dispatch:
19 inputs:
20 channel:
21 description: "Recovery channel"
22 type: choice
23 options: [stable]
24 default: stable
25 tag:
26 description: "stable: tag to publish (e.g. desktop-v1.1.0)"
27 required: false
28 type: string
29 production_signing_smoke:
30 description: "Verify production signing and trust without publishing"
31 required: false
32 default: false
33 type: boolean
34 signing_preflight:
35 description: "Auto-approve through CI, verify the full signing path, attest it, and do not publish"
36 required: false
37 default: false
38 type: boolean
39 workflow_call:
40 outputs:
41 artifact_prefix:
42 description: "Signed artifacts produced by this candidate invocation"
43 value: ${{ jobs.resolve.outputs.artifact_prefix }}
44 signing_fingerprint:
45 description: "Validated Certum and packaging policy fingerprint"
46 value: ${{ jobs.signing-contract.outputs.fingerprint }}
47 inputs:
48 channel:
49 description: "Release channel selected by the approved orchestrator"
50 required: true
51 type: string
52 tag:
53 description: "Existing desktop tag to publish"
54 required: false
55 default: ""
56 type: string
57 base_version:
58 description: "Base version used for preview builds"
59 required: false
60 default: ""
61 type: string
62 approved_cli_tag:
63 description: "Stable CLI tag recorded by the approved orchestrator"
64 required: true
65 type: string
66 approved_sha:
67 description: "Immutable commit recorded by the approved orchestrator"
68 required: true
69 type: string
70 orchestrated:
71 description: "True only when called by an approved release orchestrator"
72 required: false
73 default: false
74 type: boolean
75 orchestrator:
76 description: "Trusted release orchestrator (legacy Preview calls remain readable)"
77 required: false
78 default: stable
79 type: string
80 preview_number:
81 description: "Legacy Preview ordinal for old workflow-call compatibility"
82 required: false
83 default: ""
84 type: string
85 signing_preflight:
86 description: "Verify both Windows signing stages without publishing"
87 required: false
88 default: false
89 type: boolean
90 candidate_preparation:
91 description: "Build, sign, and accept an untagged Stable candidate without publishing"
92 required: false
93 default: false
94 type: boolean
95 candidate_rehearsal:
96 description: "Isolated non-publishing candidate rehearsal"
97 required: false
98 default: false
99 type: boolean
100 signing_preflight_verified:
101 description: "The approved stable caller completed signing_preflight in this run"
102 required: false
103 default: false
104 type: boolean
105 preflight_artifact_prefix:
106 description: "Signed artifact set returned by a verified candidate preparation"
107 required: false
108 default: ""
109 type: string
110 candidate_id:
111 description: "Sealed release candidate identity"
112 required: false
113 default: ""
114 type: string
115 candidate_source_run_id:
116 description: "Trusted candidate producer run"
117 required: false
118 default: ""
119 type: string
120 candidate_source_run_attempt:
121 description: "Trusted candidate producer attempt"
122 required: false
123 default: ""
124 type: string
125 candidate_control_sha:
126 description: "Control-plane SHA that built the sealed candidate"
127 required: false
128 default: ""
129 type: string
130 candidate_signing_fingerprint:
131 description: "Signing policy fingerprint sealed with the candidate"
132 required: false
133 default: ""
134 type: string
135 candidate_verified:
136 description: "Protected Stable preflight verified candidate provenance and bytes"
137 required: false
138 default: false
139 type: boolean
140 desktop_manual_only:
141 description: "Approved manual Desktop distribution: unsigned Windows, no update pointer move"
142 required: false
143 default: false
144 type: boolean
145 reuse_manual_artifacts:
146 description: "Reuse the exact verified v1.38.8 manual producer run (v1.38.8 only)"
147 required: false
148 default: false
149 type: boolean
150
151 concurrency:
152 # A channel pointer is a monotonic public state machine. Serialize every
153 # publisher for the same normalized channel, including the legacy canary alias.
154 group: release-desktop-${{ (inputs.channel == 'preview' || inputs.channel == 'canary') && 'preview' || 'stable' }}
155 cancel-in-progress: false
156
157 permissions:
158 contents: write # create the release and upload artifacts
159
160 jobs:
161 resolve:
162 name: resolve Desktop release
163 runs-on: ubuntu-latest
164 permissions:
165 contents: read
166 outputs:
167 tag: ${{ steps.release.outputs.tag }}
168 version: ${{ steps.release.outputs.version }}
169 channel: ${{ steps.release.outputs.channel }}
170 prerelease: ${{ steps.release.outputs.prerelease }}
171 notes_version: ${{ steps.release.outputs.notes_version }}
172 sha: ${{ steps.candidate.outputs.sha }}
173 artifact_prefix: ${{ inputs.reuse_manual_artifacts && 'desktop-34816299501-1-preflight' || format('desktop-{0}-{1}-{2}', github.run_id, github.run_attempt, inputs.signing_preflight && 'preflight' || 'release') }}
174 steps:
175 - uses: actions/checkout@v7
176 with:
177 fetch-depth: 0
178 ref: ${{ github.sha }}
179
180 - name: Resolve version and channel
181 id: release
182 env:
183 EVENT_NAME: ${{ github.event_name }}
184 IN_ORCHESTRATED: ${{ inputs.orchestrated }}
185 IN_CHANNEL: ${{ inputs.channel }}
186 IN_TAG: ${{ inputs.tag }}
187 IN_BASE_VERSION: ${{ inputs.base_version }}
188 IN_PRODUCTION_SIGNING_SMOKE: ${{ inputs.production_signing_smoke }}
189 IN_SIGNING_PREFLIGHT: ${{ inputs.signing_preflight }}
190 REF_NAME: ${{ github.ref_name }}
191 RUN_NUMBER: ${{ github.run_number }}
192 IN_PREVIEW_NUMBER: ${{ inputs.preview_number }}
193 run: bash scripts/resolve-desktop-release.sh
194
195 - name: Resolve immutable candidate
196 id: candidate
197 env:
198 RELEASE_CHANNEL: ${{ steps.release.outputs.channel }}
199 RELEASE_TAG: ${{ steps.release.outputs.tag }}
200 IN_ORCHESTRATED: ${{ inputs.orchestrated }}
201 IN_ORCHESTRATOR: ${{ inputs.orchestrator }}
202 APPROVED_SHA: ${{ inputs.approved_sha }}
203 CALLER_EVENT_NAME: ${{ github.event_name }}
204 CALLER_REF: ${{ github.ref }}
205 CALLER_REF_PROTECTED: ${{ github.ref_protected }}
206 CALLER_SHA: ${{ github.sha }}
207 CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
208 CANDIDATE_PREPARATION: ${{ inputs.candidate_preparation }}
209 CANDIDATE_REHEARSAL: ${{ inputs.candidate_rehearsal }}
210 run: bash scripts/resolve-desktop-candidate.sh
211
212 orchestration-guard:
213 name: verify approved orchestrator
214 needs: resolve
215 if: ${{ inputs.orchestrated }}
216 runs-on: ubuntu-latest
217 permissions:
218 contents: read
219 steps:
220 - uses: actions/checkout@v7
221 with:
222 fetch-depth: 0
223 ref: ${{ github.sha }}
224 - name: Verify caller and approved release ref
225 env:
226 ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
227 EXPECTED_CALLER_WORKFLOW_REF: ${{ format('{0}/.github/workflows/release-{1}.yml@{2}', github.repository, inputs.orchestrator, github.ref) }}
228 CALLER_EVENT_NAME: ${{ github.event_name }}
229 CALLER_REF: ${{ github.ref }}
230 CALLER_REF_PROTECTED: ${{ github.ref_protected }}
231 CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
232 CALLER_SHA: ${{ github.sha }}
233 APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
234 APPROVED_SHA: ${{ inputs.approved_sha }}
235 APPROVED_CHANNEL: ${{ (inputs.orchestrator == 'candidate' || inputs.orchestrator == 'promote') && 'stable' || inputs.orchestrator }}
236 RELEASE_TAG: ${{ inputs.approved_cli_tag }}
237 VERIFY_RELEASE_CHECKOUT: false
238 run: |
239 bash scripts/verify-release-authorization.sh
240 if [ "${{ inputs.candidate_preparation }}" != "true" ]; then
241 bash scripts/verify-release-tag.sh
242 fi
243
244 release-gate:
245 name: approve standalone desktop release
246 needs: resolve
247 if: ${{ !inputs.orchestrated }}
248 runs-on: ubuntu-latest
249 permissions:
250 contents: read
251 # Standalone Preview is limited to non-publishing signing checks, but still
252 # exercises the production policy behind the protected `canary` environment.
253 environment: ${{ needs.resolve.outputs.channel == 'preview' && 'canary' || 'release' }}
254 steps:
255 - env:
256 RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
257 RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
258 run: echo "Approved standalone desktop $RELEASE_CHANNEL release $RELEASE_TAG"
259
260 signing-contract:
261 name: validate Windows release signing contract
262 needs: [resolve, orchestration-guard, release-gate]
263 if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && needs.release-gate.result == 'success')) }}
264 runs-on: ubuntu-latest
265 permissions:
266 contents: read
267 actions: read
268 outputs:
269 fingerprint: ${{ steps.contract.outputs.fingerprint }}
270 steps:
271 - uses: actions/checkout@v7
272 with:
273 # Validate the protected control-plane files that GitHub and SignPath
274 # execute, including during recovery of an older candidate.
275 ref: ${{ github.sha }}
276
277 - uses: actions/setup-go@v7
278 with:
279 go-version-file: go.mod
280 cache: true
281
282 - name: Validate signing mode
283 env:
284 PREFLIGHT_ARTIFACT_PREFIX: ${{ inputs.preflight_artifact_prefix }}
285 run: |
286 if [ "${{ inputs.reuse_manual_artifacts }}" = "true" ] && [ "${{ inputs.desktop_manual_only }}" != "true" ]; then
287 echo "::error::artifact recovery requires the scoped manual Desktop exception"
288 exit 1
289 fi
290 if [ "${{ inputs.desktop_manual_only }}" = "true" ]; then
291 if [ "${{ inputs.orchestrated }}" != "true" ] || [ "${{ inputs.channel }}" != "stable" ]; then
292 echo "::error::manual Desktop exception requires the approved stable orchestrator"
293 exit 1
294 fi
295 if ! bash scripts/manual-desktop-exception.sh validate \
296 "${{ needs.resolve.outputs.tag }}" "${{ needs.resolve.outputs.sha }}"; then
297 echo "::error::manual Desktop exception is restricted to approved candidates"
298 exit 1
299 fi
300 fi
301 if [ "${{ inputs.production_signing_smoke }}" = "true" ] && [ "${{ inputs.signing_preflight }}" = "true" ]; then
302 echo "::error::production_signing_smoke and signing_preflight are mutually exclusive"
303 exit 1
304 fi
305 if [ "${{ inputs.candidate_preparation }}" = "true" ] && { [ "${{ inputs.signing_preflight }}" != "true" ] || [ "${{ inputs.orchestrator }}" != "candidate" ]; }; then
306 echo "::error::candidate preparation must use the protected candidate orchestrator and signing preflight"
307 exit 1
308 fi
309 if [ "${{ inputs.signing_preflight_verified }}" = "true" ] && [ "${{ inputs.orchestrated }}" != "true" ]; then
310 echo "::error::only the approved stable orchestrator can assert signing_preflight_verified"
311 exit 1
312 fi
313 if [ -n "$PREFLIGHT_ARTIFACT_PREFIX" ]; then
314 if [ "${{ inputs.orchestrated }}" != "true" ] || [ "${{ inputs.signing_preflight_verified }}" != "true" ] || [ "${{ inputs.signing_preflight }}" = "true" ] || [ "${{ inputs.channel }}" != "stable" ]; then
315 echo "::error::artifact reuse requires a verified stable orchestrator preflight"
316 exit 1
317 fi
318 if [ "${{ inputs.reuse_manual_artifacts }}" = "true" ]; then
319 test "$PREFLIGHT_ARTIFACT_PREFIX" = desktop-34816299501-1-preflight
320 elif [ "${{ inputs.candidate_verified }}" = "true" ]; then
321 [[ "${{ inputs.candidate_id }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-[0-9a-f]{12}-[0-9a-f]{12}$ ]] || exit 1
322 [[ "${{ inputs.candidate_source_run_id }}" =~ ^[1-9][0-9]*$ ]] || exit 1
323 [[ "${{ inputs.candidate_source_run_attempt }}" =~ ^[1-9][0-9]*$ ]] || exit 1
324 [[ "${{ inputs.candidate_control_sha }}" =~ ^[0-9a-f]{40}$ ]] || exit 1
325 test -n "${{ inputs.candidate_signing_fingerprint }}"
326 test "$PREFLIGHT_ARTIFACT_PREFIX" = "desktop-${{ inputs.candidate_source_run_id }}-${{ inputs.candidate_source_run_attempt }}-preflight"
327 else
328 [[ "$PREFLIGHT_ARTIFACT_PREFIX" =~ ^desktop-${GITHUB_RUN_ID}-[1-9][0-9]*-preflight$ ]] || exit 1
329 fi
330 fi
331
332 - name: Validate and fingerprint SignPath contract
333 id: contract
334 env:
335 GH_TOKEN: ${{ github.token }}
336 run: |
337 go run ./cmd/signpath-contract validate
338 fingerprint="$(go run ./cmd/signpath-contract fingerprint)"
339 if [ "${{ inputs.candidate_verified }}" = "true" ] && [ "$fingerprint" != "${{ inputs.candidate_signing_fingerprint }}" ]; then
340 echo "::error::sealed candidate signing policy no longer matches the protected publisher"
341 exit 1
342 fi
343 if [ "${{ inputs.reuse_manual_artifacts }}" = "true" ]; then
344 node scripts/verify-manual-desktop-producer.mjs
345 fingerprint=v1:48c45e7bb52e5a9d0883b917c36e8cb0f4e7d34b6703ff44019d8ef5d52ebf21
346 fi
347 echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT"
348
349 - name: Require current standalone signing attestation
350 if: ${{ github.repository == 'esengine/DeepSeek-Reasonix' && !inputs.signing_preflight && !inputs.production_signing_smoke && !(inputs.orchestrated && inputs.signing_preflight_verified) }}
351 env:
352 ACTUAL: ${{ vars.SIGNPATH_RELEASE_SIGNING_ATTESTATION }}
353 EXPECTED: ${{ steps.contract.outputs.fingerprint }}
354 run: |
355 if [ "$ACTUAL" != "$EXPECTED" ]; then
356 echo "::error::SIGNPATH_RELEASE_SIGNING_ATTESTATION does not match the current protected signing contract"
357 echo "::error::Run release-desktop.yml with signing_preflight=true before publishing"
358 echo "expected=$EXPECTED"
359 exit 1
360 fi
361
362 build:
363 name: build non-Windows (${{ matrix.name }}, ${{ inputs.signing_preflight && 'preflight' || 'release' }})
364 needs: [resolve, signing-contract]
365 if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.signing-contract.result == 'success' && inputs.preflight_artifact_prefix == '' && !inputs.reuse_manual_artifacts }}
366 permissions:
367 contents: read # checkout only; the publish job holds contents: write
368 actions: read # SignPath reads run details + downloads the unsigned artifact
369 strategy:
370 fail-fast: false
371 matrix:
372 include:
373 # Keep preflight on the same complete native matrix as publication so
374 # it cannot attest a release whose adjacent platform build is broken.
375 - { runner: macos-15, platform: darwin/arm64, name: darwin-arm64 }
376 - { runner: macos-15-intel, platform: darwin/amd64, name: darwin-amd64 }
377 - { runner: macos-15, platform: darwin/universal, name: darwin-universal }
378 - { runner: ubuntu-22.04, platform: linux/amd64, name: linux-amd64 }
379 runs-on: ${{ matrix.runner }}
380 defaults:
381 run:
382 shell: bash # desktop-build.sh is bash; windows runners default to pwsh otherwise
383 steps:
384 - uses: actions/checkout@v7
385 with:
386 ref: ${{ needs.resolve.outputs.sha }}
387
388 - uses: actions/setup-go@v7
389 with:
390 go-version-file: desktop/go.mod
391 cache: true
392 cache-dependency-path: desktop/go.sum
393
394 - uses: pnpm/action-setup@v6.1.0
395 with:
396 version: 10
397 run_install: false
398 - uses: actions/setup-node@v7
399 with:
400 node-version: "24"
401 cache: pnpm
402 cache-dependency-path: desktop/pnpm-lock.yaml
403
404 # nfpm builds the .deb in desktop-build.sh's linux branch; go install
405 # drops it in ~/go/bin, already on PATH.
406 - name: Install nfpm
407 if: runner.os == 'Linux'
408 run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.46.3
409
410 # macOS: create-dmg packages the .app into a drag-to-Applications .dmg.
411 - name: Install create-dmg
412 if: runner.os == 'macOS'
413 run: brew install create-dmg
414
415 # macOS signing: import the Developer ID cert into a throwaway keychain and
416 # stage the notarization key. No-ops (and the build ad-hoc signs) when the
417 # APPLE_* secrets aren't set, so forks still build.
418 - name: Import Apple signing certificate
419 if: runner.os == 'macOS'
420 env:
421 APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }}
422 APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
423 APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
424 run: |
425 if [ -z "$APPLE_CERT_P12" ]; then
426 echo "APPLE_CERT_P12 unset — desktop build will ad-hoc sign (un-notarized)"
427 exit 0
428 fi
429 KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
430 KEYCHAIN_PASS="$(uuidgen)"
431 security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
432 security set-keychain-settings -lut 21600 "$KEYCHAIN"
433 security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
434 echo "$APPLE_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12"
435 security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign
436 security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASS" "$KEYCHAIN" >/dev/null
437 # Prepend the signing keychain to the search list so codesign / find-identity see it.
438 existing_keychains=()
439 while IFS= read -r keychain; do
440 [ -n "$keychain" ] && existing_keychains+=("$keychain")
441 done < <(security list-keychains -d user | sed -E 's/^[[:space:]]*"//; s/"[[:space:]]*$//')
442 security list-keychains -d user -s "$KEYCHAIN" "${existing_keychains[@]}"
443 echo "$APPLE_API_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary.p8"
444 rm -f "$RUNNER_TEMP/cert.p12"
445
446 - name: Build and package
447 env:
448 # macOS Developer ID + notarization path turns on only when all five
449 # APPLE_* secrets are present; otherwise desktop-build.sh ad-hoc signs.
450 # Harmless on Windows/Linux runners (only the darwin branch reads these).
451 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 != '' }}
452 APPLE_API_KEY_PATH: ${{ runner.temp }}/notary.p8
453 APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
454 APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
455 APPLE_NOTARIZATION_LOG_DIR: ${{ runner.temp }}/apple-notarization
456 run: scripts/desktop-build.sh "${{ matrix.platform }}" "${{ needs.resolve.outputs.version }}" "${{ needs.resolve.outputs.channel }}"
457
458 - name: Upload Apple notarization diagnostics
459 if: ${{ always() && runner.os == 'macOS' }}
460 uses: actions/upload-artifact@v7
461 with:
462 name: apple-notarization-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.name }}
463 path: ${{ runner.temp }}/apple-notarization/*.json
464 if-no-files-found: ignore
465 retention-days: 7
466
467 # Candidate code is immutable, but release validation belongs to the
468 # protected workflow control plane. Reuse this sparse checkout later for
469 # Authenticode verification rather than trusting a candidate-owned test.
470 - name: Checkout protected release verifier
471 uses: actions/checkout@v7
472 with:
473 ref: ${{ github.workflow_sha }}
474 path: release-control
475 persist-credentials: false
476
477 # Exercise the exact production Electron package built for Stable: launch
478 # the packaged shell and require the shell -> Go service handshake before
479 # signing or publication.
480 - name: Smoke-test native macOS archive startup
481 if: runner.os == 'macOS' && matrix.name != 'darwin-universal'
482 run: |
483 ditto -xk "dist/Reasonix-${{ matrix.name }}.zip" "$RUNNER_TEMP/desktop-startup"
484 node desktop/packaging/verify.mjs "$RUNNER_TEMP/desktop-startup/Reasonix.app" --kind darwin-app-dir
485 node desktop/packaging/smoke.mjs "$RUNNER_TEMP/desktop-startup/Reasonix.app"
486
487 - name: Smoke-test Universal macOS candidate on Apple Silicon
488 if: runner.os == 'macOS' && matrix.name == 'darwin-universal'
489 run: |
490 node desktop/packaging/verify.mjs desktop/build/candidate/darwin-universal/Reasonix.app --kind darwin-app-dir
491 node desktop/packaging/smoke.mjs desktop/build/candidate/darwin-universal/Reasonix.app
492
493 - name: Smoke-test packaged Linux startup
494 if: runner.os == 'Linux'
495 run: |
496 xvfb-run -a node desktop/packaging/smoke.mjs \
497 desktop/build/bin/app --service desktop/build/bin/reasonix-desktop
498
499 # Same tree, no handed-over service path: a shell started directly (pinned
500 # taskbar icon, double-click) must find reasonix-desktop beside app/.
501 - name: Smoke-test packaged Linux startup without a configured service
502 if: runner.os == 'Linux'
503 run: |
504 xvfb-run -a node desktop/packaging/smoke.mjs desktop/build/bin/app
505
506 - name: Upload package size report
507 if: ${{ always() }}
508 uses: actions/upload-artifact@v7
509 with:
510 name: package-size-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.name }}
511 path: desktop/build/reports/${{ matrix.name }}
512 if-no-files-found: error
513 retention-days: 30
514
515 - name: Upload desktop source maps
516 if: ${{ always() }}
517 uses: actions/upload-artifact@v7
518 with:
519 name: desktop-sourcemaps-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.name }}
520 path: |
521 desktop/build/sourcemaps/${{ matrix.name }}
522 desktop/frontend/sourcemaps
523 if-no-files-found: error
524 retention-days: 90
525
526 - name: Sign artifacts (minisign)
527 working-directory: desktop
528 env:
529 MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
530 MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
531 run: go run ./cmd/sign sign ../dist/*
532
533 - name: Bind signed artifacts to candidate and workflow
534 env:
535 RELEASE_SOURCE_SHA: ${{ needs.resolve.outputs.sha }}
536 RELEASE_CONTROL_SHA: ${{ inputs.candidate_control_sha || github.workflow_sha }}
537 RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
538 RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
539 RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
540 RELEASE_SIGNING_FINGERPRINT: ${{ needs.signing-contract.outputs.fingerprint }}
541 RELEASE_ARTIFACT_PREFIX: ${{ needs.resolve.outputs.artifact_prefix }}
542 run: node release-control/scripts/desktop-release-artifacts.mjs pack dist "$RUNNER_TEMP/desktop-bundle" "${{ matrix.name }}"
543
544 - uses: actions/upload-artifact@v7
545 with:
546 name: ${{ needs.resolve.outputs.artifact_prefix }}-${{ matrix.name }}
547 path: ${{ runner.temp }}/desktop-bundle
548 # A failed-job retry replaces only this fully revalidated platform.
549 # The resolved invocation prefix remains stable across that retry.
550 overwrite: true
551 if-no-files-found: error
552 # Same-run handoff to the publish job only; 7 days covers debugging.
553 retention-days: 7
554
555 windows-build:
556 name: build Windows candidate (${{ matrix.arch }})
557 needs: [resolve, signing-contract]
558 if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.signing-contract.result == 'success' && inputs.preflight_artifact_prefix == '' && !inputs.reuse_manual_artifacts }}
559 permissions:
560 contents: read
561 strategy:
562 fail-fast: false
563 matrix:
564 include:
565 - { runner: windows-latest, platform: windows/amd64, name: windows-amd64, arch: amd64 }
566 - { runner: windows-11-arm, platform: windows/arm64, name: windows-arm64, arch: arm64 }
567 runs-on: ${{ matrix.runner }}
568 env:
569 HAS_CERTUM: ${{ secrets.CERTUM_USERNAME != '' && secrets.CERTUM_OTP_URI != '' && secrets.CERTUM_KEY_ID != '' && !inputs.desktop_manual_only }}
570 defaults:
571 run:
572 shell: bash
573 steps:
574 - uses: actions/checkout@v7
575 with:
576 ref: ${{ needs.resolve.outputs.sha }}
577 - uses: actions/setup-go@v7
578 with:
579 go-version-file: desktop/go.mod
580 cache: true
581 cache-dependency-path: desktop/go.sum
582 - uses: pnpm/action-setup@v6.1.0
583 with:
584 version: 10
585 run_install: false
586 - uses: actions/setup-node@v7
587 with:
588 node-version: "24"
589 cache: pnpm
590 cache-dependency-path: desktop/pnpm-lock.yaml
591 - name: Install NSIS
592 run: pwsh -NoProfile -File scripts/install-nsis.ps1
593 - name: Require Windows Authenticode signing
594 if: github.repository == 'esengine/DeepSeek-Reasonix' && !inputs.desktop_manual_only
595 run: |
596 if [ "$HAS_CERTUM" != "true" ]; then
597 echo "::error::Certum credentials are required for public Windows releases"
598 exit 1
599 fi
600 - name: Build and package
601 run: scripts/desktop-build.sh "${{ matrix.platform }}" "${{ needs.resolve.outputs.version }}" "${{ needs.resolve.outputs.channel }}"
602 - name: Checkout protected release verifier
603 uses: actions/checkout@v7
604 with:
605 ref: ${{ github.workflow_sha }}
606 path: release-control
607 persist-credentials: false
608 - name: Smoke-test packaged Electron startup
609 run: |
610 node desktop/packaging/smoke.mjs \
611 desktop/build/electron/${{ matrix.name }}/app \
612 --service desktop/build/bin/reasonix-desktop.exe
613 - name: Archive Windows signing inputs
614 if: env.HAS_CERTUM == 'true'
615 run: |
616 signing_archive="$(cygpath -u "$RUNNER_TEMP")/windows-signing-inputs.tar"
617 tar -cf "$signing_archive" desktop/build/windows/signing-payload desktop/build/windows/installer/reasonix_project.nsh
618 - name: Upload Windows signing inputs
619 if: env.HAS_CERTUM == 'true'
620 uses: actions/upload-artifact@v7
621 with:
622 name: ${{ needs.resolve.outputs.artifact_prefix }}-unsigned-${{ matrix.name }}
623 path: ${{ runner.temp }}/windows-signing-inputs.tar
624 overwrite: true
625 if-no-files-found: error
626 retention-days: 7
627 - name: Finalize manual Windows package
628 if: inputs.desktop_manual_only
629 env:
630 MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
631 MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
632 REASONIX_REQUIRE_PAYLOAD_MANIFEST: "1"
633 run: |
634 (
635 cd desktop
636 go run ./cmd/sign windows-payload build/windows/signing-payload "${{ needs.resolve.outputs.version }}"
637 go run ./cmd/sign sign build/windows/signing-payload/reasonix-payload.json
638 go run ./cmd/sign verify build/windows/signing-payload/reasonix-payload.json
639 )
640 scripts/package-windows-desktop.sh "${{ matrix.arch }}" desktop/build/windows/signing-payload
641 - name: Install and smoke-test final manual Windows installer
642 if: inputs.desktop_manual_only
643 shell: pwsh
644 run: >-
645 ./release-control/scripts/test-windows-installer-startup.ps1
646 -InstallerPath "dist/Reasonix-windows-${{ matrix.arch }}-installer.exe"
647 -ExpectedVersion "${{ needs.resolve.outputs.version }}"
648 -EvidenceDirectory "$env:RUNNER_TEMP/reasonix-installer-acceptance"
649 - name: Sign manual artifacts (minisign)
650 if: inputs.desktop_manual_only
651 working-directory: desktop
652 env:
653 MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
654 MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
655 run: go run ./cmd/sign sign ../dist/*
656 - name: Bind manual artifacts to candidate and workflow
657 if: inputs.desktop_manual_only
658 env:
659 RELEASE_SOURCE_SHA: ${{ needs.resolve.outputs.sha }}
660 RELEASE_CONTROL_SHA: ${{ github.workflow_sha }}
661 RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
662 RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
663 RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
664 RELEASE_SIGNING_FINGERPRINT: ${{ needs.signing-contract.outputs.fingerprint }}
665 RELEASE_ARTIFACT_PREFIX: ${{ needs.resolve.outputs.artifact_prefix }}
666 run: node release-control/scripts/desktop-release-artifacts.mjs pack dist "$RUNNER_TEMP/desktop-bundle" "${{ matrix.name }}"
667 - uses: actions/upload-artifact@v7
668 if: inputs.desktop_manual_only
669 with:
670 name: ${{ needs.resolve.outputs.artifact_prefix }}-${{ matrix.name }}
671 path: ${{ runner.temp }}/desktop-bundle
672 overwrite: true
673 if-no-files-found: error
674 retention-days: 7
675 - name: Upload package size report
676 if: always()
677 uses: actions/upload-artifact@v7
678 with:
679 name: package-size-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.name }}
680 path: desktop/build/reports/${{ matrix.name }}
681 if-no-files-found: error
682 retention-days: 30
683 - name: Upload desktop source maps
684 if: always()
685 uses: actions/upload-artifact@v7
686 with:
687 name: desktop-sourcemaps-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.name }}
688 path: |
689 desktop/build/sourcemaps/${{ matrix.name }}
690 desktop/frontend/sourcemaps
691 if-no-files-found: error
692 retention-days: 30
693
694 windows-sign:
695 name: sign Windows candidate
696 needs: [resolve, windows-build, signing-contract]
697 if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.windows-build.result == 'success' && needs.signing-contract.result == 'success' && github.repository == 'esengine/DeepSeek-Reasonix' && !inputs.desktop_manual_only }}
698 runs-on: windows-2022
699 timeout-minutes: 45
700 permissions:
701 contents: read
702 actions: read
703 # Both architectures use one proven x64 virtual-card session. Native build
704 # and final startup acceptance still run on their original architectures.
705 concurrency:
706 group: certum-signing
707 cancel-in-progress: false
708 defaults:
709 run:
710 shell: bash
711 steps:
712 - uses: actions/checkout@v7
713 with:
714 ref: ${{ needs.resolve.outputs.sha }}
715 persist-credentials: false
716 - uses: actions/checkout@v7
717 with:
718 ref: ${{ github.workflow_sha }}
719 path: release-control
720 persist-credentials: false
721 - uses: actions/setup-go@v7
722 with:
723 go-version-file: desktop/go.mod
724 cache-dependency-path: desktop/go.sum
725 - uses: actions/setup-node@v7
726 with:
727 node-version: "24"
728 - name: Reuse completed signed architectures from an earlier attempt
729 id: reuse
730 shell: bash
731 env:
732 GH_TOKEN: ${{ github.token }}
733 RELEASE_SOURCE_SHA: ${{ needs.resolve.outputs.sha }}
734 RELEASE_CONTROL_SHA: ${{ github.workflow_sha }}
735 RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
736 RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
737 RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
738 RELEASE_SIGNING_FINGERPRINT: ${{ needs.signing-contract.outputs.fingerprint }}
739 RELEASE_ARTIFACT_PREFIX: ${{ needs.resolve.outputs.artifact_prefix }}
740 RELEASE_RUNNER_TEMP: ${{ runner.temp }}
741 run: |
742 set -euo pipefail
743 runner_temp="$(cygpath -u "$RELEASE_RUNNER_TEMP")"
744 mkdir -p "$runner_temp/completed"
745 missing=()
746 for arch in amd64 arm64; do
747 name="${{ needs.resolve.outputs.artifact_prefix }}-windows-$arch"
748 artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts?per_page=100" \
749 --jq ".artifacts | map(select(.name == \"$name\" and .expired == false)) | sort_by(.id) | last | .id // empty")"
750 if [ -z "$artifact_id" ]; then
751 echo "$arch=false" >> "$GITHUB_OUTPUT"
752 missing+=("$arch")
753 continue
754 fi
755 mkdir -p "$runner_temp/completed/$arch"
756 gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$artifact_id/zip" > "$runner_temp/completed/$arch.zip"
757 unzip -q "$runner_temp/completed/$arch.zip" -d "$runner_temp/completed/$arch"
758 node release-control/scripts/desktop-release-artifacts.mjs verify \
759 "$runner_temp/completed/$arch" "windows-$arch"
760 echo "$arch=true" >> "$GITHUB_OUTPUT"
761 echo "Reusing signed Windows $arch bundle from artifact $artifact_id." >> "$GITHUB_STEP_SUMMARY"
762 done
763 if [ "${#missing[@]}" -eq 0 ]; then
764 echo "needs_signing=false" >> "$GITHUB_OUTPUT"
765 else
766 echo "needs_signing=true" >> "$GITHUB_OUTPUT"
767 printf 'Architectures requiring signing: %s\n' "${missing[*]}" >> "$GITHUB_STEP_SUMMARY"
768 fi
769 - uses: actions/download-artifact@v8
770 if: steps.reuse.outputs.needs_signing == 'true'
771 with:
772 name: ${{ needs.resolve.outputs.artifact_prefix }}-unsigned-windows-amd64
773 path: ${{ runner.temp }}/signing-inputs/amd64
774 - uses: actions/download-artifact@v8
775 if: steps.reuse.outputs.needs_signing == 'true'
776 with:
777 name: ${{ needs.resolve.outputs.artifact_prefix }}-unsigned-windows-arm64
778 path: ${{ runner.temp }}/signing-inputs/arm64
779 - name: Restore both native-tested Windows payloads
780 if: steps.reuse.outputs.needs_signing == 'true'
781 env:
782 RELEASE_RUNNER_TEMP: ${{ runner.temp }}
783 run: |
784 runner_temp="$(cygpath -u "$RELEASE_RUNNER_TEMP")"
785 for arch in amd64 arm64; do
786 [ ! -d "$runner_temp/completed/$arch" ] || continue
787 mkdir -p "$runner_temp/signing-work/$arch"
788 tar -xf "$runner_temp/signing-inputs/$arch/windows-signing-inputs.tar" -C "$runner_temp/signing-work/$arch"
789 cp -R "$runner_temp/signing-work/$arch/desktop/build/windows/signing-payload" "signed-payload-$arch"
790 node desktop/packaging/signing-files.mjs "signed-payload-$arch" --check
791 done
792 - name: Install NSIS
793 if: steps.reuse.outputs.needs_signing == 'true'
794 shell: pwsh
795 run: ./release-control/scripts/install-nsis.ps1
796 - name: Connect to Certum
797 if: steps.reuse.outputs.needs_signing == 'true'
798 uses: ./release-control/.github/actions/setup-certum
799 with:
800 username: ${{ secrets.CERTUM_USERNAME }}
801 otp-uri: ${{ secrets.CERTUM_OTP_URI }}
802 thumbprint: ${{ secrets.CERTUM_KEY_ID }}
803 - name: Finalize amd64 in the shared Certum session
804 if: steps.reuse.outputs.amd64 != 'true'
805 env:
806 CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
807 MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
808 MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
809 RELEASE_SOURCE_SHA: ${{ needs.resolve.outputs.sha }}
810 RELEASE_CONTROL_SHA: ${{ github.workflow_sha }}
811 RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
812 RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
813 RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
814 RELEASE_SIGNING_FINGERPRINT: ${{ needs.signing-contract.outputs.fingerprint }}
815 RELEASE_ARTIFACT_PREFIX: ${{ needs.resolve.outputs.artifact_prefix }}
816 RELEASE_RUNNER_TEMP: ${{ runner.temp }}
817 run: |
818 runner_temp="$(cygpath -u "$RELEASE_RUNNER_TEMP")"
819 bash release-control/scripts/finalize-windows-signed-candidate.sh amd64 \
820 "$runner_temp/signing-work/amd64" signed-payload-amd64 dist-amd64 \
821 "$runner_temp/desktop-bundle-amd64" "${{ needs.resolve.outputs.version }}"
822 - uses: actions/upload-artifact@v7
823 if: steps.reuse.outputs.amd64 != 'true'
824 with:
825 name: ${{ needs.resolve.outputs.artifact_prefix }}-windows-amd64
826 path: ${{ runner.temp }}/desktop-bundle-amd64
827 overwrite: true
828 if-no-files-found: error
829 retention-days: 7
830 - name: Finalize arm64 in the shared Certum session
831 if: steps.reuse.outputs.arm64 != 'true'
832 env:
833 CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
834 MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
835 MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
836 RELEASE_SOURCE_SHA: ${{ needs.resolve.outputs.sha }}
837 RELEASE_CONTROL_SHA: ${{ github.workflow_sha }}
838 RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
839 RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
840 RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
841 RELEASE_SIGNING_FINGERPRINT: ${{ needs.signing-contract.outputs.fingerprint }}
842 RELEASE_ARTIFACT_PREFIX: ${{ needs.resolve.outputs.artifact_prefix }}
843 RELEASE_RUNNER_TEMP: ${{ runner.temp }}
844 run: |
845 runner_temp="$(cygpath -u "$RELEASE_RUNNER_TEMP")"
846 bash release-control/scripts/finalize-windows-signed-candidate.sh arm64 \
847 "$runner_temp/signing-work/arm64" signed-payload-arm64 dist-arm64 \
848 "$runner_temp/desktop-bundle-arm64" "${{ needs.resolve.outputs.version }}"
849 - uses: actions/upload-artifact@v7
850 if: steps.reuse.outputs.arm64 != 'true'
851 with:
852 name: ${{ needs.resolve.outputs.artifact_prefix }}-windows-arm64
853 path: ${{ runner.temp }}/desktop-bundle-arm64
854 overwrite: true
855 if-no-files-found: error
856 retention-days: 7
857 - name: Upload signed package size reports
858 if: steps.reuse.outputs.needs_signing == 'true'
859 uses: actions/upload-artifact@v7
860 with:
861 name: package-size-${{ github.run_id }}-${{ github.run_attempt }}-windows-signed
862 path: desktop/build/reports/windows-*
863 overwrite: true
864 if-no-files-found: error
865 retention-days: 30
866
867 windows-runtime-acceptance:
868 name: verify signed Windows installer (${{ matrix.arch }})
869 needs: [resolve, windows-sign]
870 if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.windows-sign.result == 'success' }}
871 permissions:
872 contents: read
873 actions: read
874 strategy:
875 fail-fast: false
876 matrix:
877 include:
878 - { runner: windows-latest, arch: amd64 }
879 - { runner: windows-11-arm, arch: arm64 }
880 runs-on: ${{ matrix.runner }}
881 steps:
882 - uses: actions/checkout@v7
883 with:
884 ref: ${{ github.workflow_sha }}
885 path: release-control
886 persist-credentials: false
887 - uses: actions/download-artifact@v8
888 with:
889 name: ${{ needs.resolve.outputs.artifact_prefix }}-windows-${{ matrix.arch }}
890 path: ${{ runner.temp }}/windows-bundle
891 - name: Install and smoke-test exact signed installer
892 shell: pwsh
893 run: |
894 $installer = @(Get-ChildItem -LiteralPath "$env:RUNNER_TEMP/windows-bundle/files" -Filter '*installer.exe' -File)
895 if ($installer.Count -ne 1) { throw "Expected one signed installer, found $($installer.Count)" }
896 ./release-control/scripts/test-windows-installer-startup.ps1 `
897 -InstallerPath $installer[0].FullName `
898 -ExpectedVersion "${{ needs.resolve.outputs.version }}" `
899 -EvidenceDirectory "$env:RUNNER_TEMP/reasonix-installer-acceptance"
900 - name: Upload signed Windows installer acceptance evidence
901 if: always()
902 uses: actions/upload-artifact@v7
903 with:
904 name: windows-installer-acceptance-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.arch }}-signed
905 path: |
906 ${{ runner.temp }}/reasonix-installer-acceptance/**/*.json
907 ${{ runner.temp }}/reasonix-installer-acceptance/**/*.png
908 ${{ runner.temp }}/reasonix-installer-acceptance/**/*.log
909 !${{ runner.temp }}/reasonix-installer-acceptance/installed/**
910 !${{ runner.temp }}/reasonix-installer-acceptance/**/cache/**
911 if-no-files-found: ignore
912 retention-days: 90
913 - name: Record signed Windows acceptance receipt
914 shell: pwsh
915 run: |
916 $bundle = "$env:RUNNER_TEMP/windows-bundle/files"
917 $installer = @(Get-ChildItem -LiteralPath $bundle -Filter '*installer.exe' -File)
918 if ($installer.Count -ne 1) { throw "Expected one signed installer, found $($installer.Count)" }
919 @{ schema = 1; kind = "windows-${{ matrix.arch }}"; status = 'passed'; version = "${{ needs.resolve.outputs.version }}"; sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $installer[0].FullName).Hash.ToLowerInvariant() } |
920 ConvertTo-Json | Set-Content -LiteralPath "$env:RUNNER_TEMP/windows-${{ matrix.arch }}.json" -Encoding utf8
921 - uses: actions/upload-artifact@v7
922 with:
923 name: ${{ needs.resolve.outputs.artifact_prefix }}-receipt-windows-${{ matrix.arch }}
924 path: ${{ runner.temp }}/windows-${{ matrix.arch }}.json
925 overwrite: true
926 if-no-files-found: error
927 retention-days: 90
928
929 mac-universal-intel:
930 name: verify Universal candidate on Intel
931 needs: [resolve, build, signing-contract]
932 if: ${{ always() && !cancelled() && needs.signing-contract.result == 'success' && (needs.build.result == 'success' || (needs.build.result == 'skipped' && inputs.reuse_manual_artifacts)) && inputs.preflight_artifact_prefix == '' }}
933 runs-on: macos-15-intel
934 permissions:
935 contents: read
936 actions: read
937 steps:
938 - uses: actions/checkout@v7
939 with:
940 ref: ${{ needs.resolve.outputs.sha }}
941 - uses: actions/setup-node@v7
942 with:
943 node-version: "24"
944 - uses: pnpm/action-setup@v6.1.0
945 with:
946 version: 10
947 - name: Install packaged smoke dependencies
948 env:
949 ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
950 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
951 run: pnpm --dir desktop install --frozen-lockfile
952 - uses: actions/download-artifact@v8
953 with:
954 name: ${{ needs.resolve.outputs.artifact_prefix }}-darwin-universal
955 path: ${{ runner.temp }}/universal-bundle
956 github-token: ${{ github.token }}
957 run-id: ${{ inputs.reuse_manual_artifacts && '34816299501' || github.run_id }}
958 - name: Mount and smoke-test the exact Universal DMG
959 run: |
960 mount_dir="$RUNNER_TEMP/reasonix-universal"
961 app_dir="$RUNNER_TEMP/reasonix-universal-app/Reasonix.app"
962 mkdir -p "$mount_dir" "$(dirname "$app_dir")"
963 hdiutil attach -nobrowse -readonly -mountpoint "$mount_dir" "$RUNNER_TEMP/universal-bundle/files/Reasonix-darwin-universal.dmg"
964 trap 'hdiutil detach "$mount_dir"' EXIT
965 ditto "$mount_dir/Reasonix.app" "$app_dir"
966 codesign --verify --deep --strict "$app_dir"
967 node desktop/packaging/verify.mjs "$app_dir" --kind darwin-app-dir
968 node desktop/packaging/smoke.mjs "$app_dir"
969 - name: Record Intel macOS acceptance receipt
970 run: |
971 jq -n --arg version "${{ needs.resolve.outputs.version }}" \
972 --arg sha256 "$(shasum -a 256 "$RUNNER_TEMP/universal-bundle/files/Reasonix-darwin-universal.dmg" | awk '{print $1}')" \
973 '{schema: 1, kind: "macos-universal-intel", status: "passed", version: $version, sha256: $sha256}' \
974 > "$RUNNER_TEMP/macos-universal-intel.json"
975 - uses: actions/upload-artifact@v7
976 with:
977 name: ${{ needs.resolve.outputs.artifact_prefix }}-receipt-macos-universal-intel
978 path: ${{ runner.temp }}/macos-universal-intel.json
979 overwrite: true
980 if-no-files-found: error
981 retention-days: 90
982
983 publish:
984 name: publish release
985 needs: [resolve, signing-contract, build, windows-build, windows-sign, windows-runtime-acceptance, mac-universal-intel]
986 if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && (needs.build.result == 'success' || (needs.build.result == 'skipped' && inputs.preflight_artifact_prefix != '' && inputs.orchestrated && inputs.signing_preflight_verified)) && (needs.windows-build.result == 'success' || (needs.windows-build.result == 'skipped' && inputs.preflight_artifact_prefix != '' && inputs.orchestrated && inputs.signing_preflight_verified)) && ((inputs.preflight_artifact_prefix == '' && needs.mac-universal-intel.result == 'success') || (inputs.preflight_artifact_prefix != '' && needs.mac-universal-intel.result == 'skipped')) && needs.signing-contract.result == 'success' && (needs.windows-sign.result == 'success' || (needs.windows-sign.result == 'skipped' && (inputs.desktop_manual_only || inputs.preflight_artifact_prefix != ''))) && (needs.windows-sign.result != 'success' || needs.windows-runtime-acceptance.result == 'success') && !inputs.production_signing_smoke && !inputs.signing_preflight }}
987 runs-on: ubuntu-latest
988 permissions:
989 contents: write
990 actions: read
991 # Approved orchestrators have already passed the matching GitHub environment.
992 # Direct prereleases and manual Stable recovery pass release-gate above. The
993 # Certum signing completes before either platform bundle reaches publication.
994 steps:
995 - uses: actions/checkout@v7
996 with:
997 fetch-depth: 0
998 ref: ${{ needs.resolve.outputs.sha }}
999
1000 # A standalone recovery may build an older Stable tag. Keep the release
1001 # control plane on the protected workflow commit so newly-added
1002 # authorization and recovery scripts remain available, while the source
1003 # tree above stays pinned to the immutable candidate.
1004 - uses: actions/checkout@v7
1005 with:
1006 fetch-depth: 0
1007 path: release-control
1008 ref: ${{ github.workflow_sha }}
1009
1010 - name: Revalidate immutable Desktop candidate
1011 env:
1012 RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
1013 RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
1014 IN_ORCHESTRATED: ${{ inputs.orchestrated }}
1015 IN_ORCHESTRATOR: ${{ inputs.orchestrator }}
1016 APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
1017 CALLER_EVENT_NAME: ${{ github.event_name }}
1018 CALLER_REF: ${{ github.ref }}
1019 CALLER_REF_PROTECTED: ${{ github.ref_protected }}
1020 CALLER_SHA: ${{ github.sha }}
1021 CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
1022 REQUIRE_CURRENT_MAIN: false
1023 VERIFY_RELEASE_CHECKOUT: true
1024 run: bash release-control/scripts/resolve-desktop-candidate.sh
1025
1026 - uses: actions/setup-go@v7
1027 with:
1028 go-version-file: desktop/go.mod
1029 cache: true
1030 cache-dependency-path: desktop/go.sum
1031
1032 - uses: actions/setup-node@v7
1033 with:
1034 node-version: "24"
1035
1036 - uses: actions/download-artifact@v8
1037 with:
1038 path: ${{ runner.temp }}/desktop-bundles
1039 pattern: ${{ inputs.preflight_artifact_prefix || needs.resolve.outputs.artifact_prefix }}-{darwin-arm64,darwin-amd64,darwin-universal,windows-amd64,windows-arm64,linux-amd64}
1040 github-token: ${{ github.token }}
1041 run-id: ${{ inputs.reuse_manual_artifacts && '34816299501' || github.run_id }}
1042
1043 - name: Verify complete signed artifact handoff
1044 env:
1045 RELEASE_SOURCE_SHA: ${{ needs.resolve.outputs.sha }}
1046 RELEASE_CONTROL_SHA: ${{ inputs.reuse_manual_artifacts && '09cdab3866d77c6ff0d007ee61b6aca3128ebe54' || inputs.candidate_control_sha || github.workflow_sha }}
1047 RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
1048 RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
1049 RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
1050 RELEASE_SIGNING_FINGERPRINT: ${{ needs.signing-contract.outputs.fingerprint }}
1051 RELEASE_ARTIFACT_PREFIX: ${{ inputs.preflight_artifact_prefix || needs.resolve.outputs.artifact_prefix }}
1052 RELEASE_PRODUCER_RUN_ID: ${{ inputs.candidate_source_run_id || github.run_id }}
1053 RELEASE_PRODUCER_RUN_ATTEMPT: ${{ inputs.candidate_source_run_attempt || github.run_attempt }}
1054 run: |
1055 if [ "${{ inputs.reuse_manual_artifacts }}" = "true" ]; then
1056 GITHUB_RUN_ID=34816299501 GITHUB_RUN_ATTEMPT=1 node release-control/scripts/desktop-release-artifacts.mjs collect "$RUNNER_TEMP/desktop-bundles" dist
1057 else
1058 node release-control/scripts/desktop-release-artifacts.mjs collect "$RUNNER_TEMP/desktop-bundles" dist
1059 fi
1060
1061 # Generate latest.json with GitHub release download URLs; the mirror step
1062 # rewrites them to R2 afterwards. GITHUB_REPOSITORY is provided by the runner.
1063 - name: Generate manifest
1064 working-directory: desktop
1065 run: >-
1066 go run ./cmd/sign manifest ../dist
1067 "${{ needs.resolve.outputs.version }}"
1068 "${{ needs.resolve.outputs.tag }}"
1069 "${{ needs.resolve.outputs.notes_version }}"
1070
1071 - name: Validate generated manifest before publication
1072 env:
1073 CHANNEL: ${{ needs.resolve.outputs.channel }}
1074 PRERELEASE: ${{ needs.resolve.outputs.prerelease }}
1075 TAG: ${{ needs.resolve.outputs.tag }}
1076 VERSION: ${{ needs.resolve.outputs.version }}
1077 NOTES_VERSION: ${{ needs.resolve.outputs.notes_version }}
1078 run: |
1079 set -euo pipefail
1080 validation_channel="$CHANNEL"
1081 if [ "$CHANNEL" != "preview" ] && [ "$PRERELEASE" = "true" ]; then
1082 validation_channel="any"
1083 fi
1084 bash release-control/scripts/validate-desktop-release-manifest.sh \
1085 "$validation_channel" "$VERSION" \
1086 "https://github.com/esengine/DeepSeek-Reasonix/releases/download/${TAG}/" \
1087 dist/latest.json "$NOTES_VERSION"
1088
1089 - name: Download orchestrator-reviewed release notes
1090 if: ${{ inputs.orchestrated && needs.resolve.outputs.channel != 'preview' }}
1091 uses: actions/download-artifact@v8
1092 with:
1093 name: orchestrator-reviewed-release-notes
1094 path: /tmp/orchestrator-reviewed-release-notes
1095
1096 - name: Use orchestrator-reviewed release notes
1097 if: ${{ inputs.orchestrated && needs.resolve.outputs.channel != 'preview' }}
1098 run: |
1099 test -s /tmp/orchestrator-reviewed-release-notes/release-notes.md
1100 cp /tmp/orchestrator-reviewed-release-notes/release-notes.md /tmp/release-notes.md
1101
1102 # Preview never appears on the GitHub releases page; the mirror job picks up
1103 # the signed dist via the preview-dist artifact below. Stable publishes a
1104 # GitHub release as usual.
1105 - name: Render reviewed release notes
1106 if: ${{ !inputs.orchestrated && needs.resolve.outputs.channel != 'preview' }}
1107 run: node scripts/release-notes.mjs render --version "${{ needs.resolve.outputs.notes_version }}" --output /tmp/release-notes.md
1108
1109 - name: Revalidate approved release ref
1110 if: ${{ inputs.orchestrated }}
1111 env:
1112 RELEASE_TAG: ${{ inputs.approved_cli_tag }}
1113 APPROVED_SHA: ${{ inputs.approved_sha }}
1114 run: bash scripts/verify-release-tag.sh
1115
1116 # Name the release being published instead of a fixed version, so the
1117 # disclosure cannot outlive or misdescribe the exception it belongs to.
1118 - name: Disclose manual Desktop distribution
1119 if: ${{ inputs.desktop_manual_only }}
1120 env:
1121 MANUAL_VERSION: ${{ needs.resolve.outputs.version }}
1122 run: |
1123 cat >> /tmp/release-notes.md <<NOTES
1124
1125 ## Manual desktop downloads / 桌面版手动下载
1126
1127 This v${MANUAL_VERSION} desktop release is manual-download only on every platform. Windows packages do not carry Reasonix Authenticode signatures because SignPath signing is unavailable; Windows may show an unknown-publisher warning. Detached minisign signatures and SHA-256 checksums remain available. Desktop automatic-update channels are unchanged by this release and keep serving their previous version. CLI and npm distribution are unaffected.
1128
1129 本次 v${MANUAL_VERSION} 桌面版所有平台均需手动下载安装。因 SignPath 签名服务暂不可用,Windows 包不含 Reasonix Authenticode 签名,系统可能显示未知发布者提示;仍提供 minisign 签名和 SHA-256 校验。本次发布不改变桌面自动更新通道,其仍指向此前版本;CLI 和 npm 正常发布。
1130 NOTES
1131
1132 - name: Publish GitHub release
1133 if: needs.resolve.outputs.channel != 'preview'
1134 env:
1135 GH_TOKEN: ${{ github.token }}
1136 DESKTOP_MANUAL_ONLY: ${{ inputs.desktop_manual_only || false }}
1137 # Keep the repository homepage focused on the installable desktop app;
1138 # the CLI release line is configured not to claim repository-wide latest.
1139 run: >-
1140 bash release-control/scripts/publish-desktop-github-release.sh
1141 "${{ needs.resolve.outputs.tag }}"
1142 "${{ needs.resolve.outputs.version }}"
1143 "${{ needs.resolve.outputs.prerelease }}"
1144 /tmp/release-notes.md
1145 dist
1146
1147 - name: Upload preview dist for mirror
1148 if: needs.resolve.outputs.channel == 'preview'
1149 uses: actions/upload-artifact@v7
1150 with:
1151 name: preview-dist
1152 path: dist/*
1153 if-no-files-found: error
1154 # Same-run handoff to the mirror step only; 7 days covers debugging.
1155 retention-days: 7
1156
1157 attest-signing-contract:
1158 name: record standalone Windows signing attestation
1159 needs: [signing-contract, build, windows-build, windows-sign, windows-runtime-acceptance]
1160 if: ${{ always() && !cancelled() && inputs.signing_preflight && !inputs.orchestrated && github.repository == 'esengine/DeepSeek-Reasonix' && needs.signing-contract.result == 'success' && needs.build.result == 'success' && needs.windows-build.result == 'success' && needs.windows-sign.result == 'success' && needs.windows-runtime-acceptance.result == 'success' }}
1161 runs-on: ubuntu-latest
1162 permissions:
1163 contents: read
1164 env:
1165 VARIABLE_NAME: SIGNPATH_RELEASE_SIGNING_ATTESTATION
1166 VARIABLE_VALUE: ${{ needs.signing-contract.outputs.fingerprint }}
1167 steps:
1168 - name: Record verified signing contract
1169 run: |
1170 mkdir -p signing-attestation
1171 jq -n --arg fingerprint "$VARIABLE_VALUE" --arg workflow_sha "$GITHUB_SHA" \
1172 --arg run_id "$GITHUB_RUN_ID" --arg run_attempt "$GITHUB_RUN_ATTEMPT" \
1173 '{fingerprint: $fingerprint, workflow_sha: $workflow_sha, run_id: $run_id, run_attempt: $run_attempt}' \
1174 > signing-attestation/verified-contract.json
1175 {
1176 echo "Both Windows signing jobs verified this contract: $VARIABLE_VALUE"
1177 echo 'For standalone recovery, a maintainer must promote this verified fingerprint:'
1178 echo '```sh'
1179 echo "gh variable set $VARIABLE_NAME --repo $GITHUB_REPOSITORY --body '$VARIABLE_VALUE'"
1180 echo '```'
1181 echo 'GITHUB_TOKEN cannot write repository variables. Orchestrated releases use same-run preflight evidence.'
1182 } >> "$GITHUB_STEP_SUMMARY"
1183 - uses: actions/upload-artifact@v7
1184 with:
1185 name: verified-signing-contract-${{ github.run_id }}-${{ github.run_attempt }}
1186 path: signing-attestation/verified-contract.json
1187 if-no-files-found: error
1188 retention-days: 90
1189
1190 mirror:
1191 name: mirror to R2
1192 needs: [resolve, publish]
1193 runs-on: ubuntu-latest
1194 permissions:
1195 contents: write # gh release download + compatibility manifest upload
1196 actions: write # dispatch pages.yml to re-bake the site version
1197 # Stable keeps GitHub as a fallback when R2 is unavailable. Preview has no
1198 # GitHub release, so it must fail closed before attempting publication.
1199 if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.publish.result == 'success' && github.repository_owner == 'esengine' }}
1200 env:
1201 HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
1202 steps:
1203 - uses: actions/checkout@v7
1204 with:
1205 fetch-depth: 0
1206 ref: ${{ needs.resolve.outputs.sha }}
1207
1208 - uses: actions/checkout@v7
1209 with:
1210 fetch-depth: 0
1211 path: release-control
1212 ref: ${{ github.workflow_sha }}
1213
1214 - uses: actions/setup-go@v7
1215 with:
1216 go-version-file: release-control/desktop/go.mod
1217 cache: true
1218 cache-dependency-path: release-control/desktop/go.sum
1219
1220 - name: Revalidate immutable Desktop candidate
1221 env:
1222 RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
1223 RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
1224 IN_ORCHESTRATED: ${{ inputs.orchestrated }}
1225 IN_ORCHESTRATOR: ${{ inputs.orchestrator }}
1226 APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
1227 CALLER_EVENT_NAME: ${{ github.event_name }}
1228 CALLER_REF: ${{ github.ref }}
1229 CALLER_REF_PROTECTED: ${{ github.ref_protected }}
1230 CALLER_SHA: ${{ github.sha }}
1231 CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
1232 REQUIRE_CURRENT_MAIN: false
1233 VERIFY_RELEASE_CHECKOUT: true
1234 run: bash release-control/scripts/resolve-desktop-candidate.sh
1235
1236 - name: Require R2 for Preview
1237 if: needs.resolve.outputs.channel == 'preview' && env.HAS_R2 != 'true'
1238 run: |
1239 echo "::error::R2 credentials are required because Preview has no GitHub release fallback"
1240 exit 1
1241
1242 - name: Revalidate approved release ref
1243 if: ${{ inputs.orchestrated }}
1244 env:
1245 RELEASE_TAG: ${{ inputs.approved_cli_tag }}
1246 APPROVED_SHA: ${{ inputs.approved_sha }}
1247 run: bash scripts/verify-release-tag.sh
1248
1249 # Preview has no GitHub release — pull the signed dist from the workflow
1250 # artifact. Stable pulls from the published release.
1251 - name: Download preview dist
1252 if: env.HAS_R2 == 'true' && needs.resolve.outputs.channel == 'preview'
1253 uses: actions/download-artifact@v8
1254 with:
1255 name: preview-dist
1256 path: assets
1257
1258 - name: Download release assets
1259 if: env.HAS_R2 == 'true' && needs.resolve.outputs.channel != 'preview'
1260 env:
1261 GH_TOKEN: ${{ github.token }}
1262 run: |
1263 mkdir -p assets
1264 gh release download "${{ needs.resolve.outputs.tag }}" -R "${{ github.repository }}" -D assets
1265
1266 # Rewrite both url and sig inside latest.json from github.com to the R2 CDN,
1267 # so the updater pulls the manifest AND the heavy artifacts from R2.
1268 - name: Rewrite latest.json URLs to R2
1269 if: env.HAS_R2 == 'true'
1270 env:
1271 R2_PUBLIC_BASE: https://dl.reasonix.io
1272 TAG: ${{ needs.resolve.outputs.tag }}
1273 run: |
1274 f=assets/latest.json
1275 jq --arg base "$R2_PUBLIC_BASE" --arg tag "$TAG" '
1276 def rewrite_asset:
1277 .url |= sub("https://github.com/[^/]+/[^/]+/releases/download/[^/]+/"; "\($base)/\($tag)/")
1278 | .sig |= sub("https://github.com/[^/]+/[^/]+/releases/download/[^/]+/"; "\($base)/\($tag)/");
1279 .platforms |= with_entries(.value |= rewrite_asset)
1280 | if .native_packages then
1281 .native_packages |= with_entries(.value |= rewrite_asset)
1282 else . end
1283 | if .downloads then
1284 .downloads |= with_entries(.value |= rewrite_asset)
1285 else . end
1286 ' "$f" > "$f.new"
1287 mv "$f.new" "$f"
1288 cat "$f"
1289
1290 - name: Validate R2 manifest before upload
1291 if: env.HAS_R2 == 'true'
1292 env:
1293 CHANNEL: ${{ needs.resolve.outputs.channel }}
1294 PRERELEASE: ${{ needs.resolve.outputs.prerelease }}
1295 TAG: ${{ needs.resolve.outputs.tag }}
1296 VERSION: ${{ needs.resolve.outputs.version }}
1297 NOTES_VERSION: ${{ needs.resolve.outputs.notes_version }}
1298 run: |
1299 set -euo pipefail
1300 validation_channel="$CHANNEL"
1301 if [ "$CHANNEL" != "preview" ] && [ "$PRERELEASE" = "true" ]; then
1302 validation_channel="any"
1303 fi
1304 bash release-control/scripts/validate-desktop-release-manifest.sh \
1305 "$validation_channel" "$VERSION" \
1306 "https://dl.reasonix.io/${TAG}/" \
1307 assets/latest.json "$NOTES_VERSION"
1308
1309 - name: Configure AWS CLI for R2
1310 if: env.HAS_R2 == 'true'
1311 run: |
1312 aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
1313 aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
1314 aws configure set region auto
1315
1316 - name: Mirror immutable assets and advance R2 pointer
1317 id: mirror_r2
1318 if: env.HAS_R2 == 'true'
1319 env:
1320 DESKTOP_MANUAL_ONLY: ${{ inputs.desktop_manual_only || false }}
1321 CHANNEL: ${{ needs.resolve.outputs.channel }}
1322 PRERELEASE: ${{ needs.resolve.outputs.prerelease }}
1323 R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
1324 R2_BUCKET: ${{ secrets.R2_BUCKET }}
1325 TAG: ${{ needs.resolve.outputs.tag }}
1326 VERSION: ${{ needs.resolve.outputs.version }}
1327 NOTES_VERSION: ${{ needs.resolve.outputs.notes_version }}
1328 run: |
1329 set -euo pipefail
1330 ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
1331
1332 download_optional() {
1333 local key="$1"
1334 local output="$2"
1335 local error_file
1336 error_file="$(mktemp)"
1337 if aws s3 cp "s3://${R2_BUCKET}/${key}" "$output" \
1338 --endpoint-url "$ENDPOINT" >/dev/null 2>"$error_file"; then
1339 rm -f "$error_file"
1340 return 0
1341 fi
1342 if grep -Eiq '404|NoSuchKey|Not Found' "$error_file"; then
1343 rm -f "$error_file"
1344 return 3
1345 fi
1346 cat "$error_file" >&2
1347 rm -f "$error_file"
1348 return 1
1349 }
1350
1351 validation_channel="$CHANNEL"
1352 if [ "$CHANNEL" != "preview" ] && [ "$PRERELEASE" = "true" ]; then
1353 validation_channel="any"
1354 fi
1355 asset_base="https://dl.reasonix.io/${TAG}/"
1356 existing_manifest=false
1357
1358 signature_verifier=/tmp/reasonix-desktop-sign
1359 go -C release-control/desktop build -o "$signature_verifier" ./cmd/sign
1360 verify_signature_directory() {
1361 local directory="$1"
1362 local signature payload
1363 while IFS= read -r -d '' signature; do
1364 payload="${signature%.minisig}"
1365 if [ ! -f "$payload" ]; then
1366 echo "::error::Desktop signature has no matching payload: $signature"
1367 return 1
1368 fi
1369 "$signature_verifier" verify "$payload"
1370 done < <(find "$directory" -type f -name '*.minisig' -print0)
1371 }
1372 require_signature_coverage() {
1373 local directory="$1"
1374 local payload
1375 while IFS= read -r -d '' payload; do
1376 if [ ! -s "$payload.minisig" ]; then
1377 echo "::error::Desktop payload has no non-empty signature: $payload"
1378 return 1
1379 fi
1380 done < <(find "$directory" -type f ! -name '*.minisig' ! -name 'latest.json' -print0)
1381 }
1382 verify_signature_directory assets
1383 require_signature_coverage assets
1384
1385 # A version directory is immutable once written. Recovery may fill an
1386 # incomplete candidate subset, but it may never replace conflicting or
1387 # unexpected content, including an already-written latest.json.
1388 existing_directory="$(mktemp -d)"
1389 existing_keys="$(
1390 aws s3api list-objects-v2 \
1391 --bucket "$R2_BUCKET" \
1392 --prefix "${TAG}/" \
1393 --query 'Contents[].Key' \
1394 --output text \
1395 --endpoint-url "$ENDPOINT"
1396 )"
1397 if [ -n "$existing_keys" ] && [ "$existing_keys" != "None" ]; then
1398 aws s3 cp "s3://${R2_BUCKET}/${TAG}/" "$existing_directory/" \
1399 --recursive --endpoint-url "$ENDPOINT"
1400 verify_signature_directory "$existing_directory"
1401 if [ -f "$existing_directory/latest.json" ]; then
1402 existing_manifest=true
1403 bash release-control/scripts/validate-desktop-release-manifest.sh \
1404 "legacy-${validation_channel}" "$VERSION" "$asset_base" \
1405 "$existing_directory/latest.json" "$NOTES_VERSION"
1406 bash release-control/scripts/verify-desktop-release-manifest-assets.sh \
1407 "$existing_directory/latest.json" "$existing_directory"
1408 cp "$existing_directory/latest.json" assets/latest.json
1409 fi
1410 bash release-control/scripts/verify-desktop-release-directory.sh \
1411 --allow-missing --allow-legacy-manifest \
1412 --allow-authenticated-payload-differences assets "$existing_directory"
1413
1414 # Preserve every already-published authenticated payload/signature
1415 # pair. Platform signing and packaging are non-deterministic, so a
1416 # recovery may fill missing pairs but must not replace valid ones.
1417 while IFS= read -r -d '' signature; do
1418 relative="${signature#"$existing_directory"/}"
1419 payload="${signature%.minisig}"
1420 payload_relative="${payload#"$existing_directory"/}"
1421 mkdir -p "assets/$(dirname "$relative")"
1422 cp "$payload" "assets/$payload_relative"
1423 cp "$signature" "assets/$relative"
1424 done < <(find "$existing_directory" -type f -name '*.minisig' -print0)
1425 fi
1426
1427 aws s3 cp assets/ "s3://${R2_BUCKET}/${TAG}/" \
1428 --recursive \
1429 --exclude latest.json \
1430 --endpoint-url "$ENDPOINT" \
1431 --cache-control "public, max-age=31536000, immutable"
1432 if [ "$existing_manifest" != "true" ]; then
1433 aws s3 cp assets/latest.json "s3://${R2_BUCKET}/${TAG}/latest.json" \
1434 --endpoint-url "$ENDPOINT" \
1435 --content-type "application/json; charset=utf-8" \
1436 --cache-control "public, max-age=31536000, immutable"
1437 fi
1438 published_directory="$(mktemp -d)"
1439 aws s3 cp "s3://${R2_BUCKET}/${TAG}/" "$published_directory/" \
1440 --recursive --endpoint-url "$ENDPOINT"
1441 bash release-control/scripts/verify-desktop-release-directory.sh \
1442 --allow-legacy-manifest assets "$published_directory"
1443 verify_signature_directory "$published_directory"
1444 require_signature_coverage "$published_directory"
1445 bash release-control/scripts/verify-desktop-release-manifest-assets.sh \
1446 "$published_directory/latest.json" "$published_directory"
1447
1448 aws s3 cp "s3://${R2_BUCKET}/${TAG}/latest.json" \
1449 /tmp/reasonix-desktop-tag-latest.json --endpoint-url "$ENDPOINT"
1450 bash release-control/scripts/validate-desktop-release-manifest.sh \
1451 "legacy-${validation_channel}" "$VERSION" "$asset_base" \
1452 /tmp/reasonix-desktop-tag-latest.json "$NOTES_VERSION"
1453 bash release-control/scripts/compare-desktop-release-manifests.sh \
1454 assets/latest.json /tmp/reasonix-desktop-tag-latest.json
1455
1456 if [ "$DESKTOP_MANUAL_ONLY" = "true" ]; then
1457 echo "pointer_moved=false" >> "$GITHUB_OUTPUT"
1458 echo "Manual Desktop release: immutable downloads verified; automatic update pointers unchanged"
1459 exit 0
1460 fi
1461
1462 # Internal RCs retain their immutable record but never move a public
1463 # channel pointer.
1464 if [ "$PRERELEASE" = "true" ] && [ "$CHANNEL" != "preview" ]; then
1465 echo "pointer_moved=false" >> "$GITHUB_OUTPUT"
1466 echo "internal Desktop prerelease $VERSION; public pointers remain unchanged"
1467 exit 0
1468 fi
1469
1470 validate_current_pointer() {
1471 local current_channel="$1"
1472 local current_version="$2"
1473 local current_file="$3"
1474 local current_base="https://dl.reasonix.io/desktop-${current_version}/"
1475 if bash release-control/scripts/validate-desktop-release-manifest.sh \
1476 "$current_channel" "$current_version" "$current_base" \
1477 "$current_file"; then
1478 return 0
1479 fi
1480
1481 if bash release-control/scripts/validate-desktop-release-manifest.sh \
1482 "legacy-${current_channel}" "$current_version" "$current_base" \
1483 "$current_file"; then
1484 echo "using legacy $current_channel manifest $current_version at its immutable base only as the monotonic migration baseline"
1485 return 0
1486 fi
1487
1488 # Early Preview pointers referenced the mutable desktop-preview/
1489 # directory. Try that layout only after the immutable legacy layout
1490 # so later legacy pointers retain their version-bound asset URLs.
1491 local legacy_preview_base="https://dl.reasonix.io/desktop-preview/"
1492 if [ "$current_channel" = "preview" ] && \
1493 bash release-control/scripts/validate-desktop-release-manifest.sh \
1494 legacy-preview "$current_version" "$legacy_preview_base" \
1495 "$current_file"; then
1496 echo "using legacy Preview manifest $current_version at the rolling base only as the monotonic migration baseline"
1497 return 0
1498 fi
1499 echo "::error::existing Desktop $current_channel pointer is invalid"
1500 return 1
1501 }
1502
1503 pointer_decision=""
1504 pointer_state=""
1505 if [ "$CHANNEL" = "preview" ]; then
1506 preview_manifest=-
1507 preview_version=""
1508 preview_download=/tmp/reasonix-desktop-current-preview.json
1509 if download_optional "preview/latest.json" "$preview_download"; then
1510 preview_version="$(jq -er '.version | strings' "$preview_download")"
1511 validate_current_pointer preview "$preview_version" "$preview_download"
1512 preview_manifest="$preview_download"
1513 else
1514 status=$?
1515 if [ "$status" -ne 3 ]; then
1516 exit "$status"
1517 fi
1518 fi
1519
1520 canary_manifest=-
1521 canary_version=""
1522 canary_download=/tmp/reasonix-desktop-current-canary.json
1523 if download_optional "canary/latest.json" "$canary_download"; then
1524 canary_version="$(jq -er '.version | strings' "$canary_download")"
1525 validate_current_pointer preview "$canary_version" "$canary_download"
1526 canary_manifest="$canary_download"
1527 else
1528 status=$?
1529 if [ "$status" -ne 3 ]; then
1530 exit "$status"
1531 fi
1532 fi
1533
1534 pointer_decision="$(
1535 bash release-control/scripts/decide-desktop-pointer-update.sh \
1536 preview assets/latest.json "$preview_manifest" "$canary_manifest"
1537 )"
1538 pointer_state="preview=${preview_version:-unset}, canary=${canary_version:-unset}"
1539 else
1540 current_version=""
1541 current_manifest=/tmp/reasonix-desktop-current-pointer.json
1542 if download_optional "latest/latest.json" "$current_manifest"; then
1543 current_version="$(jq -er '.version | strings' "$current_manifest")"
1544 validate_current_pointer stable "$current_version" "$current_manifest"
1545 else
1546 status=$?
1547 if [ "$status" -ne 3 ]; then
1548 exit "$status"
1549 fi
1550 fi
1551 pointer_decision="$(
1552 bash release-control/scripts/decide-desktop-pointer-update.sh \
1553 stable assets/latest.json \
1554 "$([ -n "$current_version" ] && printf '%s' "$current_manifest" || printf '%s' -)"
1555 )"
1556 pointer_state="${current_version:-unset}"
1557 fi
1558
1559 IFS=$'\t' read -r pointer_action pointer_source <<< "$pointer_decision"
1560 if [ "$pointer_action" = "skip" ]; then
1561 echo "pointer_moved=false" >> "$GITHUB_OUTPUT"
1562 echo "Desktop $CHANNEL pointer remains $pointer_state; candidate $VERSION is not newer and needs no repair"
1563 exit 0
1564 fi
1565 if [ "$pointer_action" != "update" ] || [ ! -f "$pointer_source" ]; then
1566 echo "::error::invalid Desktop pointer decision: $pointer_decision"
1567 exit 1
1568 fi
1569 pointer_version="$(jq -er '.version | strings' "$pointer_source")"
1570
1571 publish_pointer() {
1572 local destination="$1"
1573 local downloaded="/tmp/reasonix-desktop-${destination}-latest.json"
1574 aws s3 cp "$pointer_source" "s3://${R2_BUCKET}/${destination}/latest.json" \
1575 --endpoint-url "$ENDPOINT" \
1576 --content-type "application/json; charset=utf-8" \
1577 --cache-control "public, max-age=300, stale-if-error=86400"
1578 aws s3 cp "s3://${R2_BUCKET}/${destination}/latest.json" "$downloaded" \
1579 --endpoint-url "$ENDPOINT"
1580 validate_current_pointer "$CHANNEL" "$pointer_version" "$downloaded"
1581 cmp -s "$pointer_source" "$downloaded"
1582 }
1583
1584 if [ "$CHANNEL" = "preview" ]; then
1585 # Write compatibility first. If the primary write fails, a rerun
1586 # still observes the old primary and safely retries both writes.
1587 publish_pointer canary
1588 publish_pointer preview
1589 if ! cmp -s /tmp/reasonix-desktop-canary-latest.json /tmp/reasonix-desktop-preview-latest.json; then
1590 echo "::error::Desktop Preview and Canary pointers diverged after publication"
1591 exit 1
1592 fi
1593 else
1594 publish_pointer latest
1595 fi
1596 echo "pointer_moved=true" >> "$GITHUB_OUTPUT"
1597 echo "pointer_version=$pointer_version" >> "$GITHUB_OUTPUT"
1598 echo "Desktop $CHANNEL pointer -> $pointer_version"
1599
1600 # dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
1601 # protection), so smoke the mirrored objects over the authenticated S3 API
1602 # instead of the public edge. This verifies the mirror landed; the public
1603 # edge itself is not reachable from CI and is covered by end users' traffic.
1604 - name: Smoke desktop release pointers
1605 if: env.HAS_R2 == 'true'
1606 env:
1607 TAG: ${{ needs.resolve.outputs.tag }}
1608 VERSION: ${{ needs.resolve.outputs.version }}
1609 CHANNEL: ${{ needs.resolve.outputs.channel }}
1610 PRERELEASE: ${{ needs.resolve.outputs.prerelease }}
1611 NOTES_VERSION: ${{ needs.resolve.outputs.notes_version }}
1612 POINTER_MOVED: ${{ steps.mirror_r2.outputs.pointer_moved }}
1613 POINTER_VERSION: ${{ steps.mirror_r2.outputs.pointer_version }}
1614 R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
1615 R2_BUCKET: ${{ secrets.R2_BUCKET }}
1616 run: |
1617 set -euo pipefail
1618 ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
1619 f=assets/latest.json
1620 validation_channel="$CHANNEL"
1621 if [ "$CHANNEL" != "preview" ] && [ "$PRERELEASE" = "true" ]; then
1622 validation_channel="any"
1623 fi
1624 asset_base="https://dl.reasonix.io/${TAG}/"
1625 bash release-control/scripts/validate-desktop-release-manifest.sh \
1626 "$validation_channel" "$VERSION" "$asset_base" "$f" "$NOTES_VERSION"
1627
1628 aws s3 cp "s3://${R2_BUCKET}/${TAG}/latest.json" /tmp/reasonix-desktop-tag-latest.json --endpoint-url "$ENDPOINT"
1629 bash release-control/scripts/validate-desktop-release-manifest.sh \
1630 "legacy-${validation_channel}" "$VERSION" "$asset_base" \
1631 /tmp/reasonix-desktop-tag-latest.json "$NOTES_VERSION"
1632 bash release-control/scripts/compare-desktop-release-manifests.sh \
1633 "$f" /tmp/reasonix-desktop-tag-latest.json
1634
1635 if [ "$POINTER_MOVED" = "true" ]; then
1636 pointer="latest"
1637 [ "$CHANNEL" = "preview" ] && pointer="preview"
1638 aws s3 cp "s3://${R2_BUCKET}/${pointer}/latest.json" /tmp/reasonix-desktop-pointer-latest.json --endpoint-url "$ENDPOINT"
1639 pointer_base="https://dl.reasonix.io/desktop-${POINTER_VERSION}/"
1640 if ! bash release-control/scripts/validate-desktop-release-manifest.sh \
1641 "$CHANNEL" "$POINTER_VERSION" "$pointer_base" \
1642 /tmp/reasonix-desktop-pointer-latest.json; then
1643 legacy_base="$pointer_base"
1644 [ "$CHANNEL" = "preview" ] && legacy_base="https://dl.reasonix.io/desktop-preview/"
1645 bash release-control/scripts/validate-desktop-release-manifest.sh \
1646 "legacy-${CHANNEL}" "$POINTER_VERSION" "$legacy_base" \
1647 /tmp/reasonix-desktop-pointer-latest.json
1648 fi
1649 if [ "$CHANNEL" = "preview" ]; then
1650 aws s3 cp "s3://${R2_BUCKET}/canary/latest.json" /tmp/reasonix-desktop-canary-latest.json --endpoint-url "$ENDPOINT"
1651 cmp -s /tmp/reasonix-desktop-pointer-latest.json /tmp/reasonix-desktop-canary-latest.json
1652 fi
1653 fi
1654
1655 jq -r '(.platforms[] | .url, .sig), ((.native_packages // {})[] | .url, .sig), ((.downloads // {})[] | .url, .sig)' "$f" | while IFS= read -r asset; do
1656 [ -n "$asset" ] || continue
1657 key="${asset#https://dl.reasonix.io/}"
1658 aws s3api head-object --bucket "$R2_BUCKET" --key "$key" --endpoint-url "$ENDPOINT" >/dev/null
1659 done
1660
1661 # Best-effort probe of the release gateway — the updater's second
1662 # manifest source — over the same public edge and Go client UA end users
1663 # hit. A 403 here is the known Cloudflare bot-protection gap (#6005:
1664 # datacenter/proxy egress gets blocked before the worker runs) and must
1665 # not fail the release until a WAF skip rule for /v1/desktop/releases/*
1666 # lands; it is surfaced as a warning so the run shows whether the edge
1667 # is open. Anything else unexpected (404, 5xx, wrong version) means the
1668 # gateway route or pointer regressed and fails hard.
1669 - name: Probe public release gateway
1670 if: env.HAS_R2 == 'true'
1671 env:
1672 VERSION: ${{ needs.resolve.outputs.version }}
1673 CHANNEL: ${{ needs.resolve.outputs.channel }}
1674 PRERELEASE: ${{ needs.resolve.outputs.prerelease }}
1675 POINTER_MOVED: ${{ steps.mirror_r2.outputs.pointer_moved }}
1676 POINTER_VERSION: ${{ steps.mirror_r2.outputs.pointer_version }}
1677 run: |
1678 set -euo pipefail
1679 if [ "$POINTER_MOVED" != "true" ]; then
1680 echo "Desktop $CHANNEL pointer did not move; skipping gateway probe"
1681 exit 0
1682 fi
1683 chan="stable"
1684 [ "$CHANNEL" = "preview" ] && chan="preview"
1685 url="https://crash.reasonix.io/v1/desktop/releases/${chan}/latest.json"
1686 # curl already prints 000 for a transport failure; || true keeps -e
1687 # from killing the step so the case below can route it.
1688 code="$(curl -sS -A "Go-http-client/2.0" -o /tmp/gateway-latest.json -w '%{http_code}' "$url" || true)"
1689 case "$code" in
1690 200)
1691 if jq -e --arg version "$POINTER_VERSION" '.version == $version' /tmp/gateway-latest.json >/dev/null; then
1692 echo "gateway serves $POINTER_VERSION on $chan"
1693 else
1694 echo "::error::gateway responded 200 but serves $(jq -r '.version // "<none>"' /tmp/gateway-latest.json), want $POINTER_VERSION — stale or wrong pointer"
1695 exit 1
1696 fi
1697 ;;
1698 403)
1699 echo "::warning::gateway returned 403 to CI egress — known bot-protection gap (#6005), not failing the release"
1700 ;;
1701 000|"")
1702 echo "::warning::gateway unreachable from CI (transport error), not failing the release"
1703 ;;
1704 *)
1705 echo "::error::gateway returned $code for $url — route or pointer regression"
1706 exit 1
1707 ;;
1708 esac
1709
1710 - name: Attach desktop manifest to matching CLI release
1711 if: env.HAS_R2 == 'true' && needs.resolve.outputs.channel != 'preview' && needs.resolve.outputs.prerelease != 'true' && !inputs.desktop_manual_only
1712 env:
1713 GH_TOKEN: ${{ github.token }}
1714 VERSION: ${{ needs.resolve.outputs.version }}
1715 run: |
1716 set -euo pipefail
1717 if gh release view "$VERSION" >/dev/null 2>&1; then
1718 gh release upload "$VERSION" assets/latest.json --clobber
1719 else
1720 echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes."
1721 fi
1722
1723 # Stable release moved R2 latest/ — rebuild the site so its build-time baked
1724 # version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO).
1725 - name: Refresh site to the new version
1726 if: env.HAS_R2 == 'true' && needs.resolve.outputs.channel != 'preview' && needs.resolve.outputs.prerelease != 'true' && steps.mirror_r2.outputs.pointer_moved == 'true'
1727 env:
1728 GH_TOKEN: ${{ github.token }}
1729 run: gh workflow run pages.yml --ref main-v2
1730
1730 lines YAML