name: Release desktop # Desktop (Wails) release line. Tag namespace `desktop-v` triggers this — # plain `v` tags are the CLI release (release.yml) and intentionally do NOT # trigger here (GitHub glob `v*` does not match `desktop-v*`). Bump with: # git tag desktop-vX.Y.Z && git push origin desktop-vX.Y.Z # # 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"). # # The same build/sign/manifest pipeline serves two channels. A `desktop-v*` tag # (or manual stable dispatch) publishes a GitHub release and moves R2's latest/ # pointer. A manual `channel: canary` dispatch builds with -X main.channel=canary # and uploads only to R2's canary/ pointer — no GitHub release, so canary never # shows on the releases page and never touches latest/. on: push: tags: ["desktop-v*"] workflow_dispatch: inputs: channel: description: "Release channel" type: choice options: [stable, canary] default: stable tag: description: "stable: tag to publish (e.g. desktop-v1.1.0)" required: false type: string base_version: description: "canary: intended next stable version, e.g. 1.5.0" required: false type: string permissions: contents: write # create the release and upload artifacts jobs: cache-guard: name: cache hit guard runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true - name: Cache hit guard run: ./scripts/cache-guard.sh build: name: build (${{ matrix.name }}) needs: cache-guard 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: # One universal macOS build (Intel + Apple Silicon) on a single arm64 # runner — avoids the scarce/slow macos-13 Intel runner. - { 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 - uses: actions/setup-go@v6 with: go-version-file: desktop/go.mod cache: true cache-dependency-path: desktop/go.sum - uses: actions/setup-node@v6 with: node-version: "22" - uses: pnpm/action-setup@v6 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/nfpm@v2.46.3 # Windows: NSIS provides makensis for `wails build -nsis`. - name: Install NSIS if: runner.os == 'Windows' run: | choco install nsis -y --no-progress echo "C:\Program Files (x86)\NSIS" >> "$GITHUB_PATH" # 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. security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') 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@v2.12.0 - name: Resolve version id: ver run: bash scripts/resolve-desktop-release.sh env: EVENT_NAME: ${{ github.event_name }} IN_CHANNEL: ${{ inputs.channel }} IN_TAG: ${{ inputs.tag }} IN_BASE_VERSION: ${{ inputs.base_version }} REF_NAME: ${{ github.ref_name }} RUN_NUMBER: ${{ github.run_number }} - 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 }}" "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.channel }}" # Authenticode-sign the Windows installer through SignPath. Runs BEFORE minisign: # the signature rewrites the installer's bytes, so the updater's minisign sig and # the manifest SHA must be computed over the signed build. SignPath pulls the file # from the Actions artifact API, so it must be uploaded first. Canary signs with the # test certificate; stable/rc sign with the Foundation release certificate, but only # once SIGNPATH_RELEASE_SIGNING_READY is set — until that cert clears CSR-pending, # stable ships unsigned instead of failing the whole release on an invalid policy. - name: Upload unsigned installer for SignPath if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true') id: unsigned-installer uses: actions/upload-artifact@v7 with: name: unsigned-${{ matrix.name }} path: dist/*installer*.exe if-no-files-found: error retention-days: 1 - name: Submit installer for Authenticode signing if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true') 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: ${{ steps.ver.outputs.channel == 'canary' && 'test-signing' || 'release-signing' }} artifact-configuration-slug: windows-installer github-artifact-id: ${{ steps.unsigned-installer.outputs.artifact-id }} github-token: ${{ github.token }} wait-for-completion: true # The policy uses an approval process — leave headroom for a human to approve # the request (the approver gets an email on submit) before the job gives up. wait-for-completion-timeout-in-seconds: 1800 output-artifact-directory: signed - name: Replace installer with signed build if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true') run: cp signed/*installer*.exe dist/ - 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 publish: name: publish release needs: build runs-on: ubuntu-latest # Stable publishes go through the `release` environment (esengine must # approve); canary uses the open `canary` environment so maintainers self-serve. environment: ${{ (github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') && 'canary' || 'release' }} steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: go-version-file: desktop/go.mod cache: true cache-dependency-path: desktop/go.sum - uses: actions/download-artifact@v8 with: path: dist pattern: dist-* merge-multiple: true - name: Resolve version id: ver run: bash scripts/resolve-desktop-release.sh env: EVENT_NAME: ${{ github.event_name }} IN_CHANNEL: ${{ inputs.channel }} IN_TAG: ${{ inputs.tag }} IN_BASE_VERSION: ${{ inputs.base_version }} REF_NAME: ${{ github.ref_name }} RUN_NUMBER: ${{ github.run_number }} # 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 "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.tag }}" # Canary never appears on the GitHub releases page; the mirror job picks up # the signed dist via the canary-dist artifact below. Stable publishes a # GitHub release as usual. - name: Publish GitHub release if: steps.ver.outputs.channel != 'canary' env: GH_TOKEN: ${{ github.token }} run: | # Keep the repository homepage focused on the installable desktop app; # the CLI release line is configured not to claim repository-wide latest. args=(--title "Reasonix Desktop ${{ steps.ver.outputs.version }}" --generate-notes) if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then args+=(--prerelease --latest=false) else args+=(--latest) fi gh release create "${{ steps.ver.outputs.tag }}" dist/* "${args[@]}" - name: Upload canary dist for mirror if: steps.ver.outputs.channel == 'canary' uses: actions/upload-artifact@v7 with: name: canary-dist path: dist/* if-no-files-found: error mirror: name: mirror to R2 needs: 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 # Skip cleanly when R2 isn't configured; GitHub release still works as fallback. if: ${{ 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 - name: Resolve version id: ver run: bash scripts/resolve-desktop-release.sh env: EVENT_NAME: ${{ github.event_name }} IN_CHANNEL: ${{ inputs.channel }} IN_TAG: ${{ inputs.tag }} IN_BASE_VERSION: ${{ inputs.base_version }} REF_NAME: ${{ github.ref_name }} RUN_NUMBER: ${{ github.run_number }} # Canary has no GitHub release — pull the signed dist from the workflow # artifact. Stable pulls from the published release. - name: Download canary dist if: env.HAS_R2 == 'true' && steps.ver.outputs.channel == 'canary' uses: actions/download-artifact@v8 with: name: canary-dist path: assets - name: Download release assets if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' env: GH_TOKEN: ${{ github.token }} run: | mkdir -p assets gh release download "${{ steps.ver.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: ${{ steps.ver.outputs.tag }} run: | f=assets/latest.json jq --arg base "$R2_PUBLIC_BASE" --arg tag "$TAG" ' .platforms |= with_entries( .value.url |= sub("https://github.com/[^/]+/[^/]+/releases/download/[^/]+/"; "\($base)/\($tag)/") | .value.sig |= sub("https://github.com/[^/]+/[^/]+/releases/download/[^/]+/"; "\($base)/\($tag)/") ) ' "$f" > "$f.new" mv "$f.new" "$f" cat "$f" - 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 to R2 if: env.HAS_R2 == 'true' env: R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} R2_BUCKET: ${{ secrets.R2_BUCKET }} TAG: ${{ steps.ver.outputs.tag }} run: | ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" aws s3 cp assets/ "s3://${R2_BUCKET}/${TAG}/" --recursive --endpoint-url "$ENDPOINT" # The pointer a release moves is its channel's alone: canary writes # canary/, stable writes latest/. A stable prerelease (rc) moves neither. if [ "${{ steps.ver.outputs.channel }}" = "canary" ]; then aws s3 cp assets/ "s3://${R2_BUCKET}/canary/" --recursive --endpoint-url "$ENDPOINT" elif [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then echo "prerelease — leaving R2 latest/ untouched" else aws s3 cp assets/ "s3://${R2_BUCKET}/latest/" --recursive --endpoint-url "$ENDPOINT" fi # 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: ${{ steps.ver.outputs.tag }} VERSION: ${{ steps.ver.outputs.version }} CHANNEL: ${{ steps.ver.outputs.channel }} PRERELEASE: ${{ steps.ver.outputs.prerelease }} 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 jq -e --arg version "$VERSION" --arg prefix "https://dl.reasonix.io/${TAG}/" ' .version == $version and .download_page == "https://reasonix.io/#start" and ([.platforms[] | (.url, .sig)] | all(type == "string" and startswith($prefix) and (contains("/releases/latest/") | not))) ' "$f" >/dev/null aws s3 cp "s3://${R2_BUCKET}/${TAG}/latest.json" /tmp/reasonix-desktop-tag-latest.json --endpoint-url "$ENDPOINT" jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-tag-latest.json >/dev/null if [ "$CHANNEL" = "canary" ]; then pointer="canary" elif [ "$PRERELEASE" = "true" ]; then pointer="" else pointer="latest" fi if [ -n "$pointer" ]; then aws s3 cp "s3://${R2_BUCKET}/${pointer}/latest.json" /tmp/reasonix-desktop-pointer-latest.json --endpoint-url "$ENDPOINT" jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-pointer-latest.json >/dev/null fi jq -r '.platforms[] | .url, .sig' "$f" | while IFS= read -r asset; do 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: ${{ steps.ver.outputs.version }} CHANNEL: ${{ steps.ver.outputs.channel }} PRERELEASE: ${{ steps.ver.outputs.prerelease }} run: | set -euo pipefail if [ "$PRERELEASE" = "true" ]; then echo "prerelease — no pointer moved; skipping gateway probe" exit 0 fi chan="stable" [ "$CHANNEL" = "canary" ] && chan="canary" 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 "$VERSION" '.version == $version' /tmp/gateway-latest.json >/dev/null; then echo "gateway serves $VERSION on $chan" else echo "::error::gateway responded 200 but serves $(jq -r '.version // ""' /tmp/gateway-latest.json), want $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' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true' env: GH_TOKEN: ${{ github.token }} VERSION: ${{ steps.ver.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' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true' env: GH_TOKEN: ${{ github.token }} run: gh workflow run pages.yml --ref main-v2