Files
DeepSeek-Reasonix/.github/workflows/release-desktop.yml
T
yhh 1db3c9082b feat(desktop): let a bad release be rolled back from inside the app
A release that turns out to be bad currently leaves the user hunting for an
old installer. The version panel lists what has been published and installs
any of them — the same code path forward or back, because a rollback that took
its own path would be the less-tested one. The chosen version is pinned before
the download starts: if the machine dies mid-install, the next launch must not
helpfully update past what the user picked.

The update core moves out of the old shell into desktop/internal/update so
both shells share it. Beyond deduplication that finally makes it compile on
every platform — updater_mac.go could not even be type-checked from Windows,
so the macOS half was only ever verified by CI.

Publishing writes a per-tag index alongside the artifacts, so the catalog the
panel reads is produced by the release itself rather than maintained by hand.

Two things the transport learned the hard way, now covered by tests: a 404 is
not transient and must not burn the full retry budget, and a resumed download
has to verify the range it was actually given — a middle box that answers
`bytes 0-` to a range request will otherwise append the whole file to what is
already on disk.
2026-08-14 05:03:02 -07:00

1278 lines
58 KiB
YAML

name: Release desktop
# Desktop (Wails) release line. Official releases are called by
# release-stable.yml after its single GitHub environment approval. Production
# SignPath requests therefore have the exact protected main-v2 origin.
#
# Wails cannot cross-compile a CGO/WebKit binary, so build/ fans out to one native
# runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a
# latest.json manifest is generated, and everything is published to a GitHub
# release and mirrored to R2 (the updater reads R2 first, then the crash worker
# release gateway; stable desktop releases own GitHub's repository-wide "latest").
#
# Historical workflow-call inputs still understand Preview artifacts so old
# runs remain diagnosable. They are not exposed by manual dispatch and cannot
# create a new public Preview release.
on:
workflow_dispatch:
inputs:
channel:
description: "Recovery channel"
type: choice
options: [stable]
default: stable
tag:
description: "stable: tag to publish (e.g. desktop-v1.1.0)"
required: false
type: string
production_signing_smoke:
description: "Wait for external SignPath approval, verify trust, and do not publish"
required: false
default: false
type: boolean
signing_preflight:
description: "Auto-approve through CI, verify the full signing path, attest it, and do not publish"
required: false
default: false
type: boolean
workflow_call:
inputs:
channel:
description: "Release channel selected by the approved orchestrator"
required: true
type: string
tag:
description: "Existing desktop tag to publish"
required: false
default: ""
type: string
base_version:
description: "Base version used for preview builds"
required: false
default: ""
type: string
approved_cli_tag:
description: "Stable CLI tag recorded by the approved orchestrator"
required: true
type: string
approved_sha:
description: "Immutable commit recorded by the approved orchestrator"
required: true
type: string
orchestrated:
description: "True only when called by an approved release orchestrator"
required: false
default: false
type: boolean
orchestrator:
description: "Trusted release orchestrator (legacy Preview calls remain readable)"
required: false
default: stable
type: string
preview_number:
description: "Legacy Preview ordinal for old workflow-call compatibility"
required: false
default: ""
type: string
signing_preflight:
description: "Verify both Windows signing stages without publishing"
required: false
default: false
type: boolean
signing_preflight_verified:
description: "The approved stable caller completed signing_preflight in this run"
required: false
default: false
type: boolean
concurrency:
# A channel pointer is a monotonic public state machine. Serialize every
# publisher for the same normalized channel, including the legacy canary alias.
group: release-desktop-${{ (inputs.channel == 'preview' || inputs.channel == 'canary') && 'preview' || 'stable' }}
cancel-in-progress: false
permissions:
contents: write # create the release and upload artifacts
jobs:
resolve:
name: resolve Desktop release
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
channel: ${{ steps.release.outputs.channel }}
prerelease: ${{ steps.release.outputs.prerelease }}
notes_version: ${{ steps.release.outputs.notes_version }}
sha: ${{ steps.candidate.outputs.sha }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- name: Resolve version and channel
id: release
env:
EVENT_NAME: ${{ github.event_name }}
IN_ORCHESTRATED: ${{ inputs.orchestrated }}
IN_CHANNEL: ${{ inputs.channel }}
IN_TAG: ${{ inputs.tag }}
IN_BASE_VERSION: ${{ inputs.base_version }}
IN_PRODUCTION_SIGNING_SMOKE: ${{ inputs.production_signing_smoke }}
IN_SIGNING_PREFLIGHT: ${{ inputs.signing_preflight }}
REF_NAME: ${{ github.ref_name }}
RUN_NUMBER: ${{ github.run_number }}
IN_PREVIEW_NUMBER: ${{ inputs.preview_number }}
run: bash scripts/resolve-desktop-release.sh
- name: Resolve immutable candidate
id: candidate
env:
RELEASE_CHANNEL: ${{ steps.release.outputs.channel }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
IN_ORCHESTRATED: ${{ inputs.orchestrated }}
IN_ORCHESTRATOR: ${{ inputs.orchestrator }}
APPROVED_SHA: ${{ inputs.approved_sha }}
CALLER_EVENT_NAME: ${{ github.event_name }}
CALLER_REF: ${{ github.ref }}
CALLER_REF_PROTECTED: ${{ github.ref_protected }}
CALLER_SHA: ${{ github.sha }}
CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
run: bash scripts/resolve-desktop-candidate.sh
orchestration-guard:
name: verify approved orchestrator
needs: resolve
if: ${{ inputs.orchestrated }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- name: Verify caller and approved release ref
env:
ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
EXPECTED_CALLER_WORKFLOW_REF: ${{ format('{0}/.github/workflows/release-{1}.yml@{2}', github.repository, inputs.orchestrator, github.ref) }}
CALLER_EVENT_NAME: ${{ github.event_name }}
CALLER_REF: ${{ github.ref }}
CALLER_REF_PROTECTED: ${{ github.ref_protected }}
CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
CALLER_SHA: ${{ github.sha }}
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
APPROVED_CHANNEL: ${{ inputs.orchestrator }}
RELEASE_TAG: ${{ inputs.approved_cli_tag }}
VERIFY_RELEASE_CHECKOUT: false
run: |
bash scripts/verify-release-authorization.sh
bash scripts/verify-release-tag.sh
release-gate:
name: approve standalone desktop release
needs: resolve
if: ${{ !inputs.orchestrated }}
runs-on: ubuntu-latest
permissions:
contents: read
# Standalone Preview is limited to non-publishing signing checks, but still
# exercises the production policy behind the protected `canary` environment.
environment: ${{ needs.resolve.outputs.channel == 'preview' && 'canary' || 'release' }}
steps:
- env:
RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
run: echo "Approved standalone desktop $RELEASE_CHANNEL release $RELEASE_TAG"
signing-contract:
name: validate SignPath release contract
needs: [resolve, orchestration-guard, release-gate]
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && needs.release-gate.result == 'success')) }}
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
fingerprint: ${{ steps.contract.outputs.fingerprint }}
steps:
- uses: actions/checkout@v7
with:
# Validate the protected control-plane files that GitHub and SignPath
# execute, including during recovery of an older candidate.
ref: ${{ github.sha }}
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Validate signing mode
run: |
if [ "${{ inputs.production_signing_smoke }}" = "true" ] && [ "${{ inputs.signing_preflight }}" = "true" ]; then
echo "::error::production_signing_smoke and signing_preflight are mutually exclusive"
exit 1
fi
if [ "${{ inputs.signing_preflight_verified }}" = "true" ] && [ "${{ inputs.orchestrated }}" != "true" ]; then
echo "::error::only the approved stable orchestrator can assert signing_preflight_verified"
exit 1
fi
- name: Validate and fingerprint SignPath contract
id: contract
run: |
go run ./cmd/signpath-contract validate
fingerprint="$(go run ./cmd/signpath-contract fingerprint)"
echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT"
- name: Require current standalone signing attestation
if: ${{ github.repository == 'esengine/DeepSeek-Reasonix' && !inputs.signing_preflight && !inputs.production_signing_smoke && !(inputs.orchestrated && inputs.signing_preflight_verified) }}
env:
ACTUAL: ${{ vars.SIGNPATH_RELEASE_SIGNING_ATTESTATION }}
EXPECTED: ${{ steps.contract.outputs.fingerprint }}
run: |
if [ "$ACTUAL" != "$EXPECTED" ]; then
echo "::error::SIGNPATH_RELEASE_SIGNING_ATTESTATION does not match the current protected signing contract"
echo "::error::Run release-desktop.yml with signing_preflight=true before publishing"
echo "expected=$EXPECTED"
exit 1
fi
cache-guard:
name: cache hit guard
needs: [resolve, signing-contract]
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.signing-contract.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Cache hit guard
run: ./scripts/cache-guard.sh
- name: Verify embedded documentation identity
env:
DOCS_BUILD_VERSION: ${{ needs.resolve.outputs.version }}
DOCS_SOURCE_REVISION: ${{ needs.resolve.outputs.sha }}
run: |
if [ ! -f scripts/verify-embedded-docs.sh ]; then
echo "Legacy candidate predates the embedded docs contract; skipping."
exit 0
fi
bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION" "$DOCS_SOURCE_REVISION"
build:
name: build (${{ matrix.name }})
needs: [resolve, cache-guard]
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.cache-guard.result == 'success' }}
permissions:
contents: read # checkout only; the publish job holds contents: write
actions: read # SignPath reads run details + downloads the unsigned artifact
strategy:
fail-fast: false
matrix:
include:
# Keep preflight on the same complete native matrix as publication so
# it cannot attest a release whose adjacent platform build is broken.
- { runner: macos-14, platform: darwin/universal, name: darwin-universal }
- { runner: windows-latest, platform: windows/amd64, name: windows-amd64 }
- { runner: windows-11-arm, platform: windows/arm64, name: windows-arm64 }
- { runner: ubuntu-22.04, platform: linux/amd64, name: linux-amd64 }
runs-on: ${{ matrix.runner }}
env:
# Windows Authenticode signing engages only when the SignPath token is set, so
# forks / token-less runs still build (unsigned), mirroring the APPLE_* gate.
HAS_SIGNPATH: ${{ secrets.SIGNPATH_API_TOKEN != '' }}
defaults:
run:
shell: bash # desktop-build.sh is bash; windows runners default to pwsh otherwise
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-go@v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: actions/setup-node@v7
with:
node-version: "24"
- uses: pnpm/[email protected]
with:
version: 10
# Linux: WebKitGTK 4.1 toolchain (-tags webkit2_41 in desktop-build.sh).
# 4.1 ships from ubuntu-22.04 on and is the only one present on 24.04+/Fedora 40+.
- name: Install Linux build deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev
# Linux: nfpm builds the .deb in desktop-build.sh's linux branch. go install
# drops it in ~/go/bin, already on PATH (same place the wails CLI lands below).
- name: Install nfpm
if: runner.os == 'Linux'
run: go install github.com/goreleaser/nfpm/v2/cmd/[email protected]
# Windows: NSIS provides makensis for `wails build -nsis`.
- name: Install NSIS
if: runner.os == 'Windows'
run: pwsh -NoProfile -File scripts/install-nsis.ps1
# macOS: create-dmg packages the .app into a drag-to-Applications .dmg.
- name: Install create-dmg
if: runner.os == 'macOS'
run: brew install create-dmg
# macOS signing: import the Developer ID cert into a throwaway keychain and
# stage the notarization key. No-ops (and the build ad-hoc signs) when the
# APPLE_* secrets aren't set, so forks still build.
- name: Import Apple signing certificate
if: runner.os == 'macOS'
env:
APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }}
APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
run: |
if [ -z "$APPLE_CERT_P12" ]; then
echo "APPLE_CERT_P12 unset — desktop build will ad-hoc sign (un-notarized)"
exit 0
fi
KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
KEYCHAIN_PASS="$(uuidgen)"
security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
echo "$APPLE_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASS" "$KEYCHAIN" >/dev/null
# Prepend the signing keychain to the search list so codesign / find-identity see it.
existing_keychains=()
while IFS= read -r keychain; do
[ -n "$keychain" ] && existing_keychains+=("$keychain")
done < <(security list-keychains -d user | sed -E 's/^[[:space:]]*"//; s/"[[:space:]]*$//')
security list-keychains -d user -s "$KEYCHAIN" "${existing_keychains[@]}"
echo "$APPLE_API_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary.p8"
rm -f "$RUNNER_TEMP/cert.p12"
- name: Install Wails CLI
run: go install "github.com/wailsapp/wails/v2/cmd/wails@$(cat "$GITHUB_WORKSPACE/.wails-version")"
# Official Windows releases must fail closed. Forks can still exercise
# unsigned packaging without repository secrets, but esengine releases
# may never publish an unsigned payload or installer.
- name: Require Windows Authenticode signing
if: runner.os == 'Windows' && github.repository == 'esengine/DeepSeek-Reasonix'
run: |
if [ "$HAS_SIGNPATH" != "true" ]; then
echo "::error::SIGNPATH_API_TOKEN is required for public Windows Preview and Stable releases"
exit 1
fi
- name: Build and package
env:
# macOS Developer ID + notarization path turns on only when all five
# APPLE_* secrets are present; otherwise desktop-build.sh ad-hoc signs.
# Harmless on Windows/Linux runners (only the darwin branch reads these).
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 != '' }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/notary.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
run: scripts/desktop-build.sh "${{ matrix.platform }}" "${{ needs.resolve.outputs.version }}" "${{ needs.resolve.outputs.channel }}"
# Candidate code is immutable, but release validation belongs to the
# protected workflow control plane. Reuse this sparse checkout later for
# Authenticode verification rather than trusting a candidate-owned test.
- name: Checkout protected release verifier
if: runner.os == 'Windows' && (matrix.platform == 'windows/amd64' || env.HAS_SIGNPATH == 'true')
uses: actions/checkout@v7
with:
ref: ${{ github.workflow_sha }}
path: release-control
sparse-checkout: |
scripts/test-webview2-native-smoke.ps1
scripts/verify-windows-authenticode.ps1
sparse-checkout-cone-mode: false
persist-credentials: false
# Exercise the exact production Wails binary built for Stable on
# Microsoft's real WebView2 runtime before signing or publication.
- name: Smoke-test Wails/WebView2 native startup
if: matrix.platform == 'windows/amd64'
shell: pwsh
run: |
./release-control/scripts/test-webview2-native-smoke.ps1 `
-ExecutablePath "desktop/build/bin/reasonix-desktop.exe" `
-TimeoutSeconds 60 `
-HealthySeconds 5
# Sign every executable that users actually run before rebuilding the
# portable archive and NSIS installer. Signing only the finished NSIS
# container leaves reasonix-desktop.exe and its sidecars unsigned after
# installation, which can trigger Defender reputation/ML quarantine.
- name: Upload unsigned Windows payload for SignPath
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
id: unsigned-windows-payload
uses: actions/upload-artifact@v7
with:
name: unsigned-payload-${{ matrix.name }}
path: desktop/build/windows/signing-payload/*.exe
if-no-files-found: error
retention-days: 1
- name: Submit Windows payload for Authenticode signing
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
id: submit-windows-payload
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
project-slug: DeepSeek-Reasonix
signing-policy-slug: release-signing
artifact-configuration-slug: windows-payload
github-artifact-id: ${{ steps.unsigned-windows-payload.outputs.artifact-id }}
github-token: ${{ github.token }}
wait-for-completion: false
# The release certificate requires an approval for every request. Public
# builds reach this step only after their GitHub environment has been
# approved; the dedicated SignPath CI identity records the corresponding
# request approval without adding another human gate.
- name: Approve and download signed Windows payload
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
run: |
$waitForExternalApproval = "${{ inputs.production_signing_smoke }}" -eq "true"
./scripts/complete-signpath-request.ps1 `
-OrganizationId "${{ secrets.SIGNPATH_ORGANIZATION_ID }}" `
-SigningRequestId "${{ steps.submit-windows-payload.outputs.signing-request-id }}" `
-ExpectedSigningPolicySlug "release-signing" `
-OutputArtifactDirectory "signed-payload" `
-TimeoutSeconds 1800 `
-WaitForExternalApproval:$waitForExternalApproval
- name: Bind signed Windows payload to release manifest
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
working-directory: desktop
env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
run: |
go run ./cmd/sign windows-payload ../signed-payload "${{ needs.resolve.outputs.version }}"
go run ./cmd/sign sign ../signed-payload/reasonix-payload.json
go run ./cmd/sign verify ../signed-payload/reasonix-payload.json
- name: Rebuild Windows packages from signed payload
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
env:
REASONIX_REQUIRE_PAYLOAD_MANIFEST: "1"
run: scripts/package-windows-desktop.sh "${{ matrix.platform == 'windows/arm64' && 'arm64' || 'amd64' }}" signed-payload
# The second request signs the rebuilt NSIS container. Release signing
# also verifies every payload signature against a Windows trusted root.
- name: Upload unsigned installer for SignPath
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
id: unsigned-installer
uses: actions/upload-artifact@v7
with:
name: unsigned-${{ matrix.name }}
path: desktop/build/windows/installer-signing-bundle/*.exe
if-no-files-found: error
retention-days: 1
- name: Submit installer for Authenticode signing
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
id: submit-windows-installer
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
project-slug: DeepSeek-Reasonix
signing-policy-slug: release-signing
artifact-configuration-slug: windows-installer-v2
github-artifact-id: ${{ steps.unsigned-installer.outputs.artifact-id }}
github-token: ${{ github.token }}
wait-for-completion: false
- name: Approve and download signed Windows installer
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
run: |
$waitForExternalApproval = "${{ inputs.production_signing_smoke }}" -eq "true"
./scripts/complete-signpath-request.ps1 `
-OrganizationId "${{ secrets.SIGNPATH_ORGANIZATION_ID }}" `
-SigningRequestId "${{ steps.submit-windows-installer.outputs.signing-request-id }}" `
-ExpectedSigningPolicySlug "release-signing" `
-OutputArtifactDirectory "signed-installer" `
-TimeoutSeconds 1800 `
-WaitForExternalApproval:$waitForExternalApproval
- name: Replace installer with signed build
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
run: cp signed-installer/*installer*.exe dist/
- name: Verify Windows Authenticode release contract
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
shell: pwsh
run: |
$arch = if ("${{ matrix.platform }}" -eq "windows/arm64") { "arm64" } else { "amd64" }
./release-control/scripts/verify-windows-authenticode.ps1 `
-PayloadDirectory signed-payload `
-InstallerPath "dist/Reasonix-windows-$arch-installer.exe" `
-PortableArchivePath "dist/Reasonix-windows-$arch.zip" `
-RequireTrusted:$true
- name: Sign artifacts (minisign)
working-directory: desktop
env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
run: go run ./cmd/sign sign ../dist/*
- uses: actions/upload-artifact@v7
with:
name: dist-${{ matrix.name }}
path: dist/*
if-no-files-found: error
# Same-run handoff to the publish job only; 7 days covers debugging.
retention-days: 7
publish:
name: publish release
needs: [resolve, build]
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.build.result == 'success' && !inputs.production_signing_smoke && !inputs.signing_preflight }}
runs-on: ubuntu-latest
permissions:
contents: write
# Approved orchestrators have already passed the matching GitHub environment.
# Direct prereleases and manual Stable recovery pass release-gate above. The
# SignPath CI identity records each payload and installer approval after it.
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ needs.resolve.outputs.sha }}
# A standalone recovery may build an older Stable tag. Keep the release
# control plane on the protected workflow commit so newly-added
# authorization and recovery scripts remain available, while the source
# tree above stays pinned to the immutable candidate.
- uses: actions/checkout@v7
with:
fetch-depth: 0
path: release-control
ref: ${{ github.workflow_sha }}
- name: Revalidate immutable Desktop candidate
env:
RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
IN_ORCHESTRATED: ${{ inputs.orchestrated }}
IN_ORCHESTRATOR: ${{ inputs.orchestrator }}
APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
CALLER_EVENT_NAME: ${{ github.event_name }}
CALLER_REF: ${{ github.ref }}
CALLER_REF_PROTECTED: ${{ github.ref_protected }}
CALLER_SHA: ${{ github.sha }}
CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
REQUIRE_CURRENT_MAIN: false
VERIFY_RELEASE_CHECKOUT: true
run: bash release-control/scripts/resolve-desktop-candidate.sh
- uses: actions/setup-go@v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: actions/setup-node@v7
with:
node-version: "24"
- uses: actions/download-artifact@v8
with:
path: dist
pattern: dist-*
merge-multiple: true
# Generate latest.json with GitHub release download URLs; the mirror step
# rewrites them to R2 afterwards. GITHUB_REPOSITORY is provided by the runner.
- name: Generate manifest
working-directory: desktop
run: >-
go run ./cmd/sign manifest ../dist
"${{ needs.resolve.outputs.version }}"
"${{ needs.resolve.outputs.tag }}"
"${{ needs.resolve.outputs.notes_version }}"
- name: Validate generated manifest before publication
env:
CHANNEL: ${{ needs.resolve.outputs.channel }}
PRERELEASE: ${{ needs.resolve.outputs.prerelease }}
TAG: ${{ needs.resolve.outputs.tag }}
VERSION: ${{ needs.resolve.outputs.version }}
NOTES_VERSION: ${{ needs.resolve.outputs.notes_version }}
run: |
set -euo pipefail
validation_channel="$CHANNEL"
if [ "$CHANNEL" != "preview" ] && [ "$PRERELEASE" = "true" ]; then
validation_channel="any"
fi
bash release-control/scripts/validate-desktop-release-manifest.sh \
"$validation_channel" "$VERSION" \
"https://github.com/esengine/DeepSeek-Reasonix/releases/download/${TAG}/" \
dist/latest.json "$NOTES_VERSION"
- name: Download orchestrator-reviewed release notes
if: ${{ inputs.orchestrated && needs.resolve.outputs.channel != 'preview' }}
uses: actions/download-artifact@v8
with:
name: orchestrator-reviewed-release-notes
path: /tmp/orchestrator-reviewed-release-notes
- name: Use orchestrator-reviewed release notes
if: ${{ inputs.orchestrated && needs.resolve.outputs.channel != 'preview' }}
run: |
test -s /tmp/orchestrator-reviewed-release-notes/release-notes.md
cp /tmp/orchestrator-reviewed-release-notes/release-notes.md /tmp/release-notes.md
# Preview never appears on the GitHub releases page; the mirror job picks up
# the signed dist via the preview-dist artifact below. Stable publishes a
# GitHub release as usual.
- name: Render reviewed release notes
if: ${{ !inputs.orchestrated && needs.resolve.outputs.channel != 'preview' }}
run: node scripts/release-notes.mjs render --version "${{ needs.resolve.outputs.notes_version }}" --output /tmp/release-notes.md
- name: Revalidate approved release ref
if: ${{ inputs.orchestrated }}
env:
RELEASE_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
run: bash scripts/verify-release-tag.sh
- name: Publish GitHub release
if: needs.resolve.outputs.channel != 'preview'
env:
GH_TOKEN: ${{ github.token }}
# Keep the repository homepage focused on the installable desktop app;
# the CLI release line is configured not to claim repository-wide latest.
run: >-
bash release-control/scripts/publish-desktop-github-release.sh
"${{ needs.resolve.outputs.tag }}"
"${{ needs.resolve.outputs.version }}"
"${{ needs.resolve.outputs.prerelease }}"
/tmp/release-notes.md
dist
- name: Upload preview dist for mirror
if: needs.resolve.outputs.channel == 'preview'
uses: actions/upload-artifact@v7
with:
name: preview-dist
path: dist/*
if-no-files-found: error
# Same-run handoff to the mirror step only; 7 days covers debugging.
retention-days: 7
attest-signing-contract:
name: record standalone SignPath attestation
needs: [signing-contract, build]
if: ${{ always() && !cancelled() && inputs.signing_preflight && !inputs.orchestrated && github.repository == 'esengine/DeepSeek-Reasonix' && needs.signing-contract.result == 'success' && needs.build.result == 'success' }}
runs-on: ubuntu-latest
permissions:
actions: write
contents: read
env:
GH_TOKEN: ${{ github.token }}
VARIABLE_NAME: SIGNPATH_RELEASE_SIGNING_ATTESTATION
VARIABLE_VALUE: ${{ needs.signing-contract.outputs.fingerprint }}
steps:
- name: Record verified signing contract
run: |
endpoint="repos/$GITHUB_REPOSITORY/actions/variables/$VARIABLE_NAME"
if gh api "$endpoint" >/dev/null 2>&1; then
gh api --method PATCH "$endpoint" -f value="$VARIABLE_VALUE" >/dev/null
else
gh api --method POST "repos/$GITHUB_REPOSITORY/actions/variables" \
-f name="$VARIABLE_NAME" -f value="$VARIABLE_VALUE" >/dev/null
fi
echo "Recorded $VARIABLE_NAME=$VARIABLE_VALUE"
mirror:
name: mirror to R2
needs: [resolve, publish]
runs-on: ubuntu-latest
permissions:
contents: write # gh release download + compatibility manifest upload
actions: write # dispatch pages.yml to re-bake the site version
# Stable keeps GitHub as a fallback when R2 is unavailable. Preview has no
# GitHub release, so it must fail closed before attempting publication.
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.publish.result == 'success' && github.repository_owner == 'esengine' }}
env:
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/checkout@v7
with:
fetch-depth: 0
path: release-control
ref: ${{ github.workflow_sha }}
- uses: actions/setup-go@v7
with:
go-version-file: release-control/desktop/go.mod
cache: true
cache-dependency-path: release-control/desktop/go.sum
- name: Revalidate immutable Desktop candidate
env:
RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
IN_ORCHESTRATED: ${{ inputs.orchestrated }}
IN_ORCHESTRATOR: ${{ inputs.orchestrator }}
APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
CALLER_EVENT_NAME: ${{ github.event_name }}
CALLER_REF: ${{ github.ref }}
CALLER_REF_PROTECTED: ${{ github.ref_protected }}
CALLER_SHA: ${{ github.sha }}
CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
REQUIRE_CURRENT_MAIN: false
VERIFY_RELEASE_CHECKOUT: true
run: bash release-control/scripts/resolve-desktop-candidate.sh
- name: Require R2 for Preview
if: needs.resolve.outputs.channel == 'preview' && env.HAS_R2 != 'true'
run: |
echo "::error::R2 credentials are required because Preview has no GitHub release fallback"
exit 1
- name: Revalidate approved release ref
if: ${{ inputs.orchestrated }}
env:
RELEASE_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
run: bash scripts/verify-release-tag.sh
# Preview has no GitHub release — pull the signed dist from the workflow
# artifact. Stable pulls from the published release.
- name: Download preview dist
if: env.HAS_R2 == 'true' && needs.resolve.outputs.channel == 'preview'
uses: actions/download-artifact@v8
with:
name: preview-dist
path: assets
- name: Download release assets
if: env.HAS_R2 == 'true' && needs.resolve.outputs.channel != 'preview'
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdir -p assets
gh release download "${{ needs.resolve.outputs.tag }}" -R "${{ github.repository }}" -D assets
# Rewrite both url and sig inside latest.json from github.com to the R2 CDN,
# so the updater pulls the manifest AND the heavy artifacts from R2.
- name: Rewrite latest.json URLs to R2
if: env.HAS_R2 == 'true'
env:
R2_PUBLIC_BASE: https://dl.reasonix.io
TAG: ${{ needs.resolve.outputs.tag }}
run: |
f=assets/latest.json
jq --arg base "$R2_PUBLIC_BASE" --arg tag "$TAG" '
def rewrite_asset:
.url |= sub("https://github.com/[^/]+/[^/]+/releases/download/[^/]+/"; "\($base)/\($tag)/")
| .sig |= sub("https://github.com/[^/]+/[^/]+/releases/download/[^/]+/"; "\($base)/\($tag)/");
.platforms |= with_entries(.value |= rewrite_asset)
| if .native_packages then
.native_packages |= with_entries(.value |= rewrite_asset)
else . end
| if .downloads then
.downloads |= with_entries(.value |= rewrite_asset)
else . end
' "$f" > "$f.new"
mv "$f.new" "$f"
cat "$f"
- name: Validate R2 manifest before upload
if: env.HAS_R2 == 'true'
env:
CHANNEL: ${{ needs.resolve.outputs.channel }}
PRERELEASE: ${{ needs.resolve.outputs.prerelease }}
TAG: ${{ needs.resolve.outputs.tag }}
VERSION: ${{ needs.resolve.outputs.version }}
NOTES_VERSION: ${{ needs.resolve.outputs.notes_version }}
run: |
set -euo pipefail
validation_channel="$CHANNEL"
if [ "$CHANNEL" != "preview" ] && [ "$PRERELEASE" = "true" ]; then
validation_channel="any"
fi
bash release-control/scripts/validate-desktop-release-manifest.sh \
"$validation_channel" "$VERSION" \
"https://dl.reasonix.io/${TAG}/" \
assets/latest.json "$NOTES_VERSION"
- name: Configure AWS CLI for R2
if: env.HAS_R2 == 'true'
run: |
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
aws configure set region auto
- name: Mirror immutable assets and advance R2 pointer
id: mirror_r2
if: env.HAS_R2 == 'true'
env:
CHANNEL: ${{ needs.resolve.outputs.channel }}
PRERELEASE: ${{ needs.resolve.outputs.prerelease }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
TAG: ${{ needs.resolve.outputs.tag }}
VERSION: ${{ needs.resolve.outputs.version }}
NOTES_VERSION: ${{ needs.resolve.outputs.notes_version }}
run: |
set -euo pipefail
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
download_optional() {
local key="$1"
local output="$2"
local error_file
error_file="$(mktemp)"
if aws s3 cp "s3://${R2_BUCKET}/${key}" "$output" \
--endpoint-url "$ENDPOINT" >/dev/null 2>"$error_file"; then
rm -f "$error_file"
return 0
fi
if grep -Eiq '404|NoSuchKey|Not Found' "$error_file"; then
rm -f "$error_file"
return 3
fi
cat "$error_file" >&2
rm -f "$error_file"
return 1
}
validation_channel="$CHANNEL"
if [ "$CHANNEL" != "preview" ] && [ "$PRERELEASE" = "true" ]; then
validation_channel="any"
fi
asset_base="https://dl.reasonix.io/${TAG}/"
existing_manifest=false
signature_verifier=/tmp/reasonix-desktop-sign
go -C release-control/desktop build -o "$signature_verifier" ./cmd/sign
verify_signature_directory() {
local directory="$1"
local signature payload
while IFS= read -r -d '' signature; do
payload="${signature%.minisig}"
if [ ! -f "$payload" ]; then
echo "::error::Desktop signature has no matching payload: $signature"
return 1
fi
"$signature_verifier" verify "$payload"
done < <(find "$directory" -type f -name '*.minisig' -print0)
}
require_signature_coverage() {
local directory="$1"
local payload
while IFS= read -r -d '' payload; do
if [ ! -s "$payload.minisig" ]; then
echo "::error::Desktop payload has no non-empty signature: $payload"
return 1
fi
done < <(find "$directory" -type f ! -name '*.minisig' ! -name 'latest.json' -print0)
}
verify_signature_directory assets
require_signature_coverage assets
# A version directory is immutable once written. Recovery may fill an
# incomplete candidate subset, but it may never replace conflicting or
# unexpected content, including an already-written latest.json.
existing_directory="$(mktemp -d)"
existing_keys="$(
aws s3api list-objects-v2 \
--bucket "$R2_BUCKET" \
--prefix "${TAG}/" \
--query 'Contents[].Key' \
--output text \
--endpoint-url "$ENDPOINT"
)"
if [ -n "$existing_keys" ] && [ "$existing_keys" != "None" ]; then
aws s3 cp "s3://${R2_BUCKET}/${TAG}/" "$existing_directory/" \
--recursive --endpoint-url "$ENDPOINT"
verify_signature_directory "$existing_directory"
if [ -f "$existing_directory/latest.json" ]; then
existing_manifest=true
bash release-control/scripts/validate-desktop-release-manifest.sh \
"legacy-${validation_channel}" "$VERSION" "$asset_base" \
"$existing_directory/latest.json" "$NOTES_VERSION"
bash release-control/scripts/verify-desktop-release-manifest-assets.sh \
"$existing_directory/latest.json" "$existing_directory"
cp "$existing_directory/latest.json" assets/latest.json
fi
bash release-control/scripts/verify-desktop-release-directory.sh \
--allow-missing --allow-legacy-manifest \
--allow-authenticated-payload-differences assets "$existing_directory"
# Preserve every already-published authenticated payload/signature
# pair. Platform signing and packaging are non-deterministic, so a
# recovery may fill missing pairs but must not replace valid ones.
while IFS= read -r -d '' signature; do
relative="${signature#"$existing_directory"/}"
payload="${signature%.minisig}"
payload_relative="${payload#"$existing_directory"/}"
mkdir -p "assets/$(dirname "$relative")"
cp "$payload" "assets/$payload_relative"
cp "$signature" "assets/$relative"
done < <(find "$existing_directory" -type f -name '*.minisig' -print0)
fi
aws s3 cp assets/ "s3://${R2_BUCKET}/${TAG}/" \
--recursive \
--exclude latest.json \
--endpoint-url "$ENDPOINT" \
--cache-control "public, max-age=31536000, immutable"
if [ "$existing_manifest" != "true" ]; then
aws s3 cp assets/latest.json "s3://${R2_BUCKET}/${TAG}/latest.json" \
--endpoint-url "$ENDPOINT" \
--content-type "application/json; charset=utf-8" \
--cache-control "public, max-age=31536000, immutable"
fi
published_directory="$(mktemp -d)"
aws s3 cp "s3://${R2_BUCKET}/${TAG}/" "$published_directory/" \
--recursive --endpoint-url "$ENDPOINT"
bash release-control/scripts/verify-desktop-release-directory.sh \
--allow-legacy-manifest assets "$published_directory"
verify_signature_directory "$published_directory"
require_signature_coverage "$published_directory"
bash release-control/scripts/verify-desktop-release-manifest-assets.sh \
"$published_directory/latest.json" "$published_directory"
aws s3 cp "s3://${R2_BUCKET}/${TAG}/latest.json" \
/tmp/reasonix-desktop-tag-latest.json --endpoint-url "$ENDPOINT"
bash release-control/scripts/validate-desktop-release-manifest.sh \
"legacy-${validation_channel}" "$VERSION" "$asset_base" \
/tmp/reasonix-desktop-tag-latest.json "$NOTES_VERSION"
bash release-control/scripts/compare-desktop-release-manifests.sh \
assets/latest.json /tmp/reasonix-desktop-tag-latest.json
# Internal RCs retain their immutable record but never move a public
# channel pointer.
if [ "$PRERELEASE" = "true" ] && [ "$CHANNEL" != "preview" ]; then
echo "pointer_moved=false" >> "$GITHUB_OUTPUT"
echo "internal Desktop prerelease $VERSION; public pointers remain unchanged"
exit 0
fi
# The rollback index records which versions were ever public and where
# each one's immutable manifest lives, so going back verifies through
# the same path a forward update does. It is written here rather than
# beside the pointer advance on purpose: a rerun whose pointer decision
# is "skip" (the candidate is already current) must still be able to
# repair a missing index entry. Preview/canary keep their immutable
# record but never enter it — rolling back means the stable line.
if [ "$CHANNEL" != "preview" ]; then
index_current=/tmp/reasonix-versions-current.json
index_next=/tmp/reasonix-versions-next.json
index_source=-
if download_optional "versions.json" "$index_current"; then
index_source="$index_current"
else
status=$?
if [ "$status" -ne 3 ]; then
exit "$status"
fi
fi
bash release-control/scripts/update-versions-index.sh "$index_source" "$VERSION" "$TAG" "$CHANNEL" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$index_next"
jq -e --arg v "$VERSION" '.versions | map(select(.version == $v)) | length == 1' "$index_next" >/dev/null
aws s3 cp "$index_next" "s3://${R2_BUCKET}/versions.json" --endpoint-url "$ENDPOINT" --content-type "application/json; charset=utf-8" --cache-control "public, max-age=300, stale-if-error=86400"
echo "rollback index -> $VERSION"
fi
validate_current_pointer() {
local current_channel="$1"
local current_version="$2"
local current_file="$3"
local current_base="https://dl.reasonix.io/desktop-${current_version}/"
if bash release-control/scripts/validate-desktop-release-manifest.sh \
"$current_channel" "$current_version" "$current_base" \
"$current_file"; then
return 0
fi
if bash release-control/scripts/validate-desktop-release-manifest.sh \
"legacy-${current_channel}" "$current_version" "$current_base" \
"$current_file"; then
echo "using legacy $current_channel manifest $current_version at its immutable base only as the monotonic migration baseline"
return 0
fi
# Early Preview pointers referenced the mutable desktop-preview/
# directory. Try that layout only after the immutable legacy layout
# so later legacy pointers retain their version-bound asset URLs.
local legacy_preview_base="https://dl.reasonix.io/desktop-preview/"
if [ "$current_channel" = "preview" ] && \
bash release-control/scripts/validate-desktop-release-manifest.sh \
legacy-preview "$current_version" "$legacy_preview_base" \
"$current_file"; then
echo "using legacy Preview manifest $current_version at the rolling base only as the monotonic migration baseline"
return 0
fi
echo "::error::existing Desktop $current_channel pointer is invalid"
return 1
}
pointer_decision=""
pointer_state=""
if [ "$CHANNEL" = "preview" ]; then
preview_manifest=-
preview_version=""
preview_download=/tmp/reasonix-desktop-current-preview.json
if download_optional "preview/latest.json" "$preview_download"; then
preview_version="$(jq -er '.version | strings' "$preview_download")"
validate_current_pointer preview "$preview_version" "$preview_download"
preview_manifest="$preview_download"
else
status=$?
if [ "$status" -ne 3 ]; then
exit "$status"
fi
fi
canary_manifest=-
canary_version=""
canary_download=/tmp/reasonix-desktop-current-canary.json
if download_optional "canary/latest.json" "$canary_download"; then
canary_version="$(jq -er '.version | strings' "$canary_download")"
validate_current_pointer preview "$canary_version" "$canary_download"
canary_manifest="$canary_download"
else
status=$?
if [ "$status" -ne 3 ]; then
exit "$status"
fi
fi
pointer_decision="$(
bash release-control/scripts/decide-desktop-pointer-update.sh \
preview assets/latest.json "$preview_manifest" "$canary_manifest"
)"
pointer_state="preview=${preview_version:-unset}, canary=${canary_version:-unset}"
else
current_version=""
current_manifest=/tmp/reasonix-desktop-current-pointer.json
if download_optional "latest/latest.json" "$current_manifest"; then
current_version="$(jq -er '.version | strings' "$current_manifest")"
validate_current_pointer stable "$current_version" "$current_manifest"
else
status=$?
if [ "$status" -ne 3 ]; then
exit "$status"
fi
fi
pointer_decision="$(
bash release-control/scripts/decide-desktop-pointer-update.sh \
stable assets/latest.json \
"$([ -n "$current_version" ] && printf '%s' "$current_manifest" || printf '%s' -)"
)"
pointer_state="${current_version:-unset}"
fi
IFS=$'\t' read -r pointer_action pointer_source <<< "$pointer_decision"
if [ "$pointer_action" = "skip" ]; then
echo "pointer_moved=false" >> "$GITHUB_OUTPUT"
echo "Desktop $CHANNEL pointer remains $pointer_state; candidate $VERSION is not newer and needs no repair"
exit 0
fi
if [ "$pointer_action" != "update" ] || [ ! -f "$pointer_source" ]; then
echo "::error::invalid Desktop pointer decision: $pointer_decision"
exit 1
fi
pointer_version="$(jq -er '.version | strings' "$pointer_source")"
publish_pointer() {
local destination="$1"
local downloaded="/tmp/reasonix-desktop-${destination}-latest.json"
aws s3 cp "$pointer_source" "s3://${R2_BUCKET}/${destination}/latest.json" \
--endpoint-url "$ENDPOINT" \
--content-type "application/json; charset=utf-8" \
--cache-control "public, max-age=300, stale-if-error=86400"
aws s3 cp "s3://${R2_BUCKET}/${destination}/latest.json" "$downloaded" \
--endpoint-url "$ENDPOINT"
validate_current_pointer "$CHANNEL" "$pointer_version" "$downloaded"
cmp -s "$pointer_source" "$downloaded"
}
if [ "$CHANNEL" = "preview" ]; then
# Write compatibility first. If the primary write fails, a rerun
# still observes the old primary and safely retries both writes.
publish_pointer canary
publish_pointer preview
if ! cmp -s /tmp/reasonix-desktop-canary-latest.json /tmp/reasonix-desktop-preview-latest.json; then
echo "::error::Desktop Preview and Canary pointers diverged after publication"
exit 1
fi
else
publish_pointer latest
fi
echo "pointer_moved=true" >> "$GITHUB_OUTPUT"
echo "pointer_version=$pointer_version" >> "$GITHUB_OUTPUT"
echo "Desktop $CHANNEL pointer -> $pointer_version"
# dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
# protection), so smoke the mirrored objects over the authenticated S3 API
# instead of the public edge. This verifies the mirror landed; the public
# edge itself is not reachable from CI and is covered by end users' traffic.
- name: Smoke desktop release pointers
if: env.HAS_R2 == 'true'
env:
TAG: ${{ needs.resolve.outputs.tag }}
VERSION: ${{ needs.resolve.outputs.version }}
CHANNEL: ${{ needs.resolve.outputs.channel }}
PRERELEASE: ${{ needs.resolve.outputs.prerelease }}
NOTES_VERSION: ${{ needs.resolve.outputs.notes_version }}
POINTER_MOVED: ${{ steps.mirror_r2.outputs.pointer_moved }}
POINTER_VERSION: ${{ steps.mirror_r2.outputs.pointer_version }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
f=assets/latest.json
validation_channel="$CHANNEL"
if [ "$CHANNEL" != "preview" ] && [ "$PRERELEASE" = "true" ]; then
validation_channel="any"
fi
asset_base="https://dl.reasonix.io/${TAG}/"
bash release-control/scripts/validate-desktop-release-manifest.sh \
"$validation_channel" "$VERSION" "$asset_base" "$f" "$NOTES_VERSION"
aws s3 cp "s3://${R2_BUCKET}/${TAG}/latest.json" /tmp/reasonix-desktop-tag-latest.json --endpoint-url "$ENDPOINT"
bash release-control/scripts/validate-desktop-release-manifest.sh \
"legacy-${validation_channel}" "$VERSION" "$asset_base" \
/tmp/reasonix-desktop-tag-latest.json "$NOTES_VERSION"
bash release-control/scripts/compare-desktop-release-manifests.sh \
"$f" /tmp/reasonix-desktop-tag-latest.json
if [ "$POINTER_MOVED" = "true" ]; then
pointer="latest"
[ "$CHANNEL" = "preview" ] && pointer="preview"
aws s3 cp "s3://${R2_BUCKET}/${pointer}/latest.json" /tmp/reasonix-desktop-pointer-latest.json --endpoint-url "$ENDPOINT"
pointer_base="https://dl.reasonix.io/desktop-${POINTER_VERSION}/"
if ! bash release-control/scripts/validate-desktop-release-manifest.sh \
"$CHANNEL" "$POINTER_VERSION" "$pointer_base" \
/tmp/reasonix-desktop-pointer-latest.json; then
legacy_base="$pointer_base"
[ "$CHANNEL" = "preview" ] && legacy_base="https://dl.reasonix.io/desktop-preview/"
bash release-control/scripts/validate-desktop-release-manifest.sh \
"legacy-${CHANNEL}" "$POINTER_VERSION" "$legacy_base" \
/tmp/reasonix-desktop-pointer-latest.json
fi
if [ "$CHANNEL" = "preview" ]; then
aws s3 cp "s3://${R2_BUCKET}/canary/latest.json" /tmp/reasonix-desktop-canary-latest.json --endpoint-url "$ENDPOINT"
cmp -s /tmp/reasonix-desktop-pointer-latest.json /tmp/reasonix-desktop-canary-latest.json
fi
fi
jq -r '(.platforms[] | .url, .sig), ((.native_packages // {})[] | .url, .sig), ((.downloads // {})[] | .url, .sig)' "$f" | while IFS= read -r asset; do
[ -n "$asset" ] || continue
key="${asset#https://dl.reasonix.io/}"
aws s3api head-object --bucket "$R2_BUCKET" --key "$key" --endpoint-url "$ENDPOINT" >/dev/null
done
# Best-effort probe of the release gateway — the updater's second
# manifest source — over the same public edge and Go client UA end users
# hit. A 403 here is the known Cloudflare bot-protection gap (#6005:
# datacenter/proxy egress gets blocked before the worker runs) and must
# not fail the release until a WAF skip rule for /v1/desktop/releases/*
# lands; it is surfaced as a warning so the run shows whether the edge
# is open. Anything else unexpected (404, 5xx, wrong version) means the
# gateway route or pointer regressed and fails hard.
- name: Probe public release gateway
if: env.HAS_R2 == 'true'
env:
VERSION: ${{ needs.resolve.outputs.version }}
CHANNEL: ${{ needs.resolve.outputs.channel }}
PRERELEASE: ${{ needs.resolve.outputs.prerelease }}
POINTER_MOVED: ${{ steps.mirror_r2.outputs.pointer_moved }}
POINTER_VERSION: ${{ steps.mirror_r2.outputs.pointer_version }}
run: |
set -euo pipefail
if [ "$POINTER_MOVED" != "true" ]; then
echo "Desktop $CHANNEL pointer did not move; skipping gateway probe"
exit 0
fi
chan="stable"
[ "$CHANNEL" = "preview" ] && chan="preview"
url="https://crash.reasonix.io/v1/desktop/releases/${chan}/latest.json"
# curl already prints 000 for a transport failure; || true keeps -e
# from killing the step so the case below can route it.
code="$(curl -sS -A "Go-http-client/2.0" -o /tmp/gateway-latest.json -w '%{http_code}' "$url" || true)"
case "$code" in
200)
if jq -e --arg version "$POINTER_VERSION" '.version == $version' /tmp/gateway-latest.json >/dev/null; then
echo "gateway serves $POINTER_VERSION on $chan"
else
echo "::error::gateway responded 200 but serves $(jq -r '.version // "<none>"' /tmp/gateway-latest.json), want $POINTER_VERSION — stale or wrong pointer"
exit 1
fi
;;
403)
echo "::warning::gateway returned 403 to CI egress — known bot-protection gap (#6005), not failing the release"
;;
000|"")
echo "::warning::gateway unreachable from CI (transport error), not failing the release"
;;
*)
echo "::error::gateway returned $code for $url — route or pointer regression"
exit 1
;;
esac
- name: Attach desktop manifest to matching CLI release
if: env.HAS_R2 == 'true' && needs.resolve.outputs.channel != 'preview' && needs.resolve.outputs.prerelease != 'true'
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.resolve.outputs.version }}
run: |
set -euo pipefail
if gh release view "$VERSION" >/dev/null 2>&1; then
gh release upload "$VERSION" assets/latest.json --clobber
else
echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes."
fi
# Stable release moved R2 latest/ — rebuild the site so its build-time baked
# version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO).
- name: Refresh site to the new version
if: env.HAS_R2 == 'true' && needs.resolve.outputs.channel != 'preview' && needs.resolve.outputs.prerelease != 'true' && steps.mirror_r2.outputs.pointer_moved == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run pages.yml --ref main-v2