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