mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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.
1278 lines
58 KiB
YAML
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
|