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