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