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