Files
DeepSeek-Reasonix/.github/workflows/release-studio.yml
T
esengine 7ac19b90c5 feat(release): npm and Homebrew follow the Studio line
The CLI had no install channel on the 2.x line: release-studio.yml builds the
CLI archives for Studio's remote installs, and npm's `reasonix` and the
Homebrew tap were published only by the retired 1.x release machinery. The
2.x line now takes both over, and 1.x no longer publishes the CLI.

A cli-channels job runs after the release is published:
- npm: the existing npm/build.mjs builds the per-platform packages and the
  launcher and publishes them through publish.mjs's staged dist-tag flow. A
  2.x version outranks every 1.x one, so `latest` moves to it; a candidate
  goes to `next`.
- Homebrew: scripts/render-brew-cask.sh writes the cask from the digests in
  the release's own SHA256SUMS — a missing digest fails the render rather
  than shipping an empty one — and the job pushes it to
  esengine/homebrew-reasonix. Candidates leave the tap alone, since brew has
  no prerelease channel.
2026-09-24 22:20:04 -07:00

799 lines
37 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: Release Studio
run-name: Release Studio ${{ inputs.tag || github.ref_name }}
# The only release line this branch publishes. It has its own tag namespace
# (studio-v*), its own environment gate, and its own rollback catalog at
# dl.reasonix.io/studio/versions.json.
#
# What it must never touch: the repository-wide "latest" release (every Studio
# release is a prerelease) and the root dl.reasonix.io/versions.json, which is
# the desktop line's catalog. Studio artifacts land under the tag prefix, which
# cannot collide because the tag name itself differs.
#
# Windows artifacts are Authenticode-signed through SignPath in two stages: the
# executables a user runs, then the installer built around them. Signing only
# the container leaves them unsigned on disk after installation, which is what
# reputation heuristics quarantine. Signing engages only when SIGNPATH_API_TOKEN
# is set, so forks and token-less runs still build unsigned.
#
# Windows and macOS both package in two steps because both need something done
# to the bundle in between — a signature there, notarization here.
on:
push:
tags: ['studio-v*']
workflow_dispatch:
inputs:
tag:
description: "Existing studio tag to recover (for example studio-v0.1.0)"
required: true
type: string
permissions:
contents: write # create the release and upload artifacts
concurrency:
group: release-studio-${{ inputs.tag || github.ref_name }}
cancel-in-progress: false
jobs:
resolve:
name: resolve Studio release
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
sha: ${{ steps.release.outputs.sha }}
candidate: ${{ steps.release.outputs.candidate }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Resolve tag and version
id: release
env:
IN_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
run: |
tag="${IN_TAG:-$REF_NAME}"
semver='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+)(\.[0-9A-Za-z-]+)*)?$'
if [[ ! "$tag" =~ ^studio-v(.+)$ ]] || [[ ! "${BASH_REMATCH[1]}" =~ $semver ]]; then
echo "::error::studio release tag must be studio-vMAJOR.MINOR.PATCH[-PRERELEASE], got: $tag"
exit 1
fi
# A dispatch recovers an existing tag; it must not invent one.
sha="$(git rev-parse --verify "refs/tags/$tag^{commit}")"
# The tag has to be on the studio line. Publishing a main-v2 commit
# from here would produce a Studio build without Studio in it.
if ! git merge-base --is-ancestor "$sha" origin/studio; then
echo "::error::$tag is not on studio history"
exit 1
fi
version="${tag#studio-}"
# Semver's prerelease segment, group 4, read from the grammar rather
# than the spelling. Every Studio release carries GitHub's prerelease
# flag, so the tag-level distinction needs a name of its own.
candidate=false
if [[ "${version#v}" =~ $semver ]] && [ -n "${BASH_REMATCH[4]}" ]; then
candidate=true
fi
{ echo "tag=$tag"; echo "version=$version"; echo "sha=$sha"
echo "candidate=$candidate"; } >> "$GITHUB_OUTPUT"
echo "resolved $tag -> $version at $sha (candidate=$candidate)"
# The contract is this repository's mirror of what SignPath's console trusts:
# which branch and which workflow may request the release certificate. It is
# checked before anything is built so a widened origin fails here rather than
# at the signing request, and fingerprinted so the protected files cannot move
# under an attestation that was recorded against different bytes.
signing-contract:
name: validate SignPath release contract
needs: resolve
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: Validate and fingerprint the contract
run: |
go run ./cmd/signpath-contract validate
echo "fingerprint=$(go run ./cmd/signpath-contract fingerprint)" >> "$GITHUB_STEP_SUMMARY"
build:
name: build (${{ matrix.platform }})
needs: [resolve, signing-contract]
runs-on: ${{ matrix.runner }}
permissions:
contents: read
actions: read # SignPath reads run details and downloads the unsigned artifact
env:
# Two conditions, because they answer different questions. The token says
# a fork cannot try — that is the APPLE_* gate's shape. The variable says
# SignPath's console has been told to trust this line's origin, which no
# secret can express: SIGNPATH_API_TOKEN is a repository secret, so it is
# already set here, and without the second gate merging this file would
# make the next release request a signature the policy still refuses.
# Set STUDIO_SIGNING_ENABLED=true once the studio origin is provisioned.
HAS_SIGNPATH: ${{ secrets.SIGNPATH_API_TOKEN != '' && vars.STUDIO_SIGNING_ENABLED == 'true' }}
strategy:
fail-fast: false
matrix:
# ebarch is electron-builder's spelling of the same architecture, stated
# beside the release one rather than mapped in a step: the two
# vocabularies disagree (x64 / amd64) and a reviewer has to see both.
# Absent means the runner's own, which is what Windows and Linux build.
include:
- platform: windows/amd64
runner: windows-latest
- platform: darwin/amd64
runner: macos-latest
ebarch: x64
- platform: darwin/arm64
runner: macos-latest
ebarch: arm64
- platform: linux/amd64
runner: ubuntu-22.04
steps:
# Real-time scanning inspects every file the job writes, and packaging
# writes a quarter of a gigabyte twice. The runner is thrown away after
# the job; if the image refuses the switch the build is only slower.
- name: Stop Defender scanning the build
if: runner.os == 'Windows'
continue-on-error: true
shell: pwsh
run: Set-MpPreference -DisableRealtimeMonitoring $true
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve.outputs.sha }}
# The kernel this shell ships is a main-module binary now; the desktop
# module is not on this job's path at all.
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
cache-dependency-path: go.sum
- uses: pnpm/[email protected]
with:
version: 10
run_install: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: |
desktop/frontend-next/pnpm-lock.yaml
desktop/electron/pnpm-lock.yaml
# electron-builder shells out to dpkg-deb through fakeroot for the .deb,
# and carries its own NSIS -- the toolchain the Wails line installed by
# hand (GTK, WebKitGTK, nfpm, a pinned NSIS) is not this shell's.
- name: Install Linux packaging deps
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y fakeroot
# The same Developer ID and notary credentials the desktop line uses: one
# certificate signs any bundle identifier its team owns, so Studio does not
# need a second one. electron-builder discovers the identity in the
# keychain, and ad-hoc signs when it finds none — which is what Gatekeeper
# refuses to open.
- 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 — Studio 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"
# Studio declares SelfUpdate: true, which promises a replacement bundle
# Gatekeeper still opens. An official build that reached the users without
# a Developer ID would break the next update rather than the first launch,
# so this fails closed the way the desktop line's Authenticode gate does.
- name: Require Apple signing
if: runner.os == 'macOS' && github.repository == 'esengine/DeepSeek-Reasonix'
env:
HAS_APPLE: ${{ secrets.APPLE_CERT_P12 != '' && secrets.APPLE_CERT_PASSWORD != '' && secrets.APPLE_API_KEY_P8 != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER_ID != '' }}
run: |
if [ "$HAS_APPLE" != "true" ]; then
echo "::error::all five APPLE_* secrets are required to publish Studio for macOS"
exit 1
fi
- name: Build the SPA
working-directory: desktop/frontend-next
shell: bash
run: pnpm install --frozen-lockfile && pnpm build
# The kernel the shell speaks to. It is a main-module binary and stays
# CGO-free, so no platform needs a toolchain for it.
- name: Build the kernel
working-directory: desktop/electron
shell: bash
# The kernel states its own version: it is the side that installs a
# kernel on a remote machine, and those routes name a release by it.
env:
REASONIX_VERSION: ${{ needs.resolve.outputs.version }}
run: pnpm install --frozen-lockfile && pnpm run build:host
# The helper the Linux self-update runs under pkexec, staged where the
# .deb's fpm arguments look for it. A no-op off Linux.
- name: Stage the Linux package payload
working-directory: desktop/electron
shell: bash
run: pnpm run build:linux-payload
# The bundle, before anything is packaged around it. The version has to be
# injected: app.getVersion() reads package.json, which carries 0.0.0 in the
# tree, and a shell that states 0.0.0 ranks behind every published release
# and offers itself an update on every launch.
- name: Build the bundle
working-directory: desktop/electron
shell: bash
run: |
version="${{ needs.resolve.outputs.version }}"
arch=""
if [ -n "${{ matrix.ebarch }}" ]; then arch="--${{ matrix.ebarch }}"; fi
pnpm exec electron-builder --dir $arch -c.extraMetadata.version="${version#v}"
# Notarization happens to the bundle, so it sits between the two packaging
# steps the way Authenticode does on Windows. Stapling here is what lets a
# first launch work offline; a ticket Apple has but the bundle does not is
# a Gatekeeper prompt on a machine that cannot reach the notary.
- name: Notarize and staple the macOS bundle
if: runner.os == 'macOS'
shell: bash
env:
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: |
# One architecture per job, so one bundle: dist/mac for x64 and
# dist/mac-arm64 for the other, and a job that produced neither has
# nothing to staple rather than the wrong thing.
app="$(find dist -maxdepth 2 -name '*.app' | sort | head -1)"
test -n "$app" || { echo "::error::no .app in dist to notarize"; exit 1; }
if [ -z "${APPLE_API_KEY_ID:-}" ]; then
echo "::warning::no notarytool credentials; the bundle is signed but not notarized"
exit 0
fi
# notarytool takes an archive, not a bundle, so the app is zipped only
# to be submitted; what ships is the stapled bundle itself.
ditto -c -k --keepParent "$app" "$RUNNER_TEMP/notarize.zip"
xcrun notarytool submit "$RUNNER_TEMP/notarize.zip" \
--key "$APPLE_API_KEY_PATH" \
--key-id "$APPLE_API_KEY_ID" \
--issuer "$APPLE_API_ISSUER_ID" \
--wait
xcrun stapler staple "$app"
xcrun stapler validate "$app"
# Windows packages after its signature instead, so it is packaged below.
- name: Package
if: runner.os != 'Windows'
working-directory: desktop/electron
shell: bash
run: |
version="${{ needs.resolve.outputs.version }}"
arch=""
# The flag has to be repeated here. --prepackaged builds targets and
# never reads what is inside the bundle, so without it they are named
# for the runner's own architecture: 2.11.0 shipped an x64 build as
# ReasonixStudio-darwin-arm64.zip, which studio-manifest keys as the
# platform it is not.
if [ -n "${{ matrix.ebarch }}" ]; then arch="--${{ matrix.ebarch }}"; fi
app="$(cd ../.. && find dist -maxdepth 2 \( -name '*.app' -o -name '*-unpacked' \) | sort | head -1)"
pnpm exec electron-builder --prepackaged "../../$app" $arch \
-c.extraMetadata.version="${version#v}"
# Stage one: the executables a user actually runs, signed while they are
# still loose files. This shell ships two of its own -- the window and the
# kernel it spawns -- where the Wails one was a single binary. Everything
# else in the bundle belongs to Chromium or to electron-builder (the NSIS
# elevate.exe it writes in during the packaging step below, after this), so
# it is not listed: this request must not re-sign somebody else's binary.
- name: Stage the Windows payload
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
shell: bash
run: |
mkdir -p payload
cp "dist/win-unpacked/Reasonix Studio.exe" payload/
cp dist/win-unpacked/resources/bin/reasonix-studio-host.exe payload/
- name: Upload unsigned Windows payload for SignPath
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
id: unsigned-payload
uses: actions/upload-artifact@v5
with:
name: unsigned-studio-payload-amd64
path: 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-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: studio-windows-payload
github-artifact-id: ${{ steps.unsigned-payload.outputs.artifact-id }}
github-token: ${{ github.token }}
wait-for-completion: false
# The release certificate requires an approval per request. The publish
# job's environment gate is the human one; the SignPath CI identity records
# the request approval so this does not become a second prompt.
- name: Approve and download signed payload
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
run: |
./scripts/complete-signpath-request.ps1 `
-OrganizationId "${{ secrets.SIGNPATH_ORGANIZATION_ID }}" `
-SigningRequestId "${{ steps.submit-payload.outputs.signing-request-id }}" `
-ExpectedSigningPolicySlug "release-signing" `
-OutputArtifactDirectory "signed-payload" `
-TimeoutSeconds 1800
- name: Put the signed executables back in the bundle
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
shell: bash
run: |
cp "signed-payload/Reasonix Studio.exe" "dist/win-unpacked/Reasonix Studio.exe"
cp signed-payload/reasonix-studio-host.exe dist/win-unpacked/resources/bin/reasonix-studio-host.exe
# The installer and the portable archive are both built from the bundle
# that now holds the signed executables, which is what makes the archive
# carry them too rather than a second unsigned copy. They are built at
# once, the zip from its own copy: NSIS writes elevate.exe into the tree
# it packs, and a zip read beside that would hold it or not by timing.
- name: Package Windows from the bundle
if: runner.os == 'Windows'
working-directory: desktop/electron
shell: bash
run: |
version="${{ needs.resolve.outputs.version }}"
rm -rf ../../zip-src ../../dist-zip
cp -r ../../dist/win-unpacked ../../zip-src
pnpm exec electron-builder --prepackaged ../../zip-src --win zip \
-c.extraMetadata.version="${version#v}" -c.directories.output=../../dist-zip &
zip_pid=$!
pnpm exec electron-builder --prepackaged ../../dist/win-unpacked --win nsis \
-c.extraMetadata.version="${version#v}"
wait "$zip_pid"
mv ../../dist-zip/ReasonixStudio-windows-amd64.zip ../../dist/
# Stage two: the finished NSIS container, signed only once the payload it
# was built from is present and already signed — the artifact
# configuration verifies that rather than trusting this job's ordering.
- name: Upload unsigned installer for SignPath
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
id: unsigned-installer
shell: bash
run: |
mkdir -p installer-bundle
cp dist/ReasonixStudio-windows-amd64-installer.exe installer-bundle/
cp "dist/win-unpacked/Reasonix Studio.exe" installer-bundle/
cp dist/win-unpacked/resources/bin/reasonix-studio-host.exe installer-bundle/
- name: Upload installer bundle
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
id: installer-artifact
uses: actions/upload-artifact@v5
with:
name: unsigned-studio-installer-amd64
path: installer-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-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: studio-windows-installer
github-artifact-id: ${{ steps.installer-artifact.outputs.artifact-id }}
github-token: ${{ github.token }}
wait-for-completion: false
- name: Approve and download signed installer
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
run: |
./scripts/complete-signpath-request.ps1 `
-OrganizationId "${{ secrets.SIGNPATH_ORGANIZATION_ID }}" `
-SigningRequestId "${{ steps.submit-installer.outputs.signing-request-id }}" `
-ExpectedSigningPolicySlug "release-signing" `
-OutputArtifactDirectory "signed-installer" `
-TimeoutSeconds 1800
- name: Replace the installer with the signed build
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
shell: bash
run: cp signed-installer/*installer*.exe dist/
# Both stages are read back off the artifacts that will actually ship. A
# signature this job believes it applied but that is not on the bytes in
# dist/ is the one failure the two requests above cannot catch themselves.
- name: Verify the Windows Authenticode chain
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
shell: pwsh
run: |
./scripts/verify-windows-authenticode.ps1 `
-PayloadDirectory signed-payload `
-InstallerPath "dist/ReasonixStudio-windows-amd64-installer.exe" `
-PortableArchivePath "dist/ReasonixStudio-windows-amd64.zip"
- uses: actions/upload-artifact@v5
with:
name: studio-${{ strategy.job-index }}
path: dist/ReasonixStudio-*
if-no-files-found: error
retention-days: 7
# The kernel a Studio installs on another machine. This line publishes it
# under its own tag, because the install routes name a release by the version
# the local build reports — and a line that ships no archive leaves every one
# of them with nothing to name but npm's, which is another line's.
cli:
name: build the CLI archives
needs: resolve
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
# Every target the release line claims to support, from one runner: the
# CLI is CGO-free, so none of them needs a toolchain of its own.
- name: Cross-compile and archive
env:
VERSION: ${{ needs.resolve.outputs.version }}
run: |
set -euo pipefail
mkdir -p dist
for target in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do
os="${target%/*}"; arch="${target#*/}"; ext=""
[ "$os" = windows ] && ext=".exe"
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath \
-ldflags "-s -w -X main.version=$VERSION" -o "reasonix$ext" ./cmd/reasonix
if [ "$os" = windows ]; then
zip -qj "dist/reasonix-$os-$arch.zip" "reasonix$ext"
else
tar czf "dist/reasonix-$os-$arch.tar.gz" "reasonix$ext"
fi
rm -f "reasonix$ext"
done
# The digest list the remote verifies against. Resolved on the
# installing machine and handed to the remote with the URL, so the
# host fetching the archive never also supplies its own checksum.
(cd dist && sha256sum reasonix-* > SHA256SUMS)
ls -la dist
# A release that published the archives without the digest list is worse
# than one that published neither: the routes resolve SHA256SUMS first.
- name: Check the set is complete
run: |
set -euo pipefail
for want in reasonix-darwin-amd64.tar.gz reasonix-darwin-arm64.tar.gz \
reasonix-linux-amd64.tar.gz reasonix-linux-arm64.tar.gz \
reasonix-windows-amd64.zip reasonix-windows-arm64.zip SHA256SUMS; do
[ -f "dist/$want" ] || { echo "::error::no dist/$want"; exit 1; }
done
- uses: actions/upload-artifact@v5
with:
name: cli-archives
path: dist/*
if-no-files-found: error
retention-days: 7
publish:
name: sign, publish and mirror
needs: [resolve, build, cli]
runs-on: ubuntu-latest
# Owner approval for the whole line. The gate sits here rather than before
# build so the approval is given with the artifacts already proven to build.
environment: studio-release
permissions:
contents: write
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
# One directory per artifact, because both sets below are taken from the
# job that produced them. Merging them flattens that away, and the name
# alone cannot tell a package apart from an input to signing it.
- uses: actions/download-artifact@v6
with:
path: staging
- name: Collect artifacts
run: |
mkdir -p dist
# The packages, from the build jobs. The SignPath flow uploads the
# unsigned copies it hands over under their own artifacts, and those
# match ReasonixStudio-* as well — taking them by name would publish
# whichever the move happened to write last.
find staging/studio-* -type f -name 'ReasonixStudio-*' -exec mv {} dist/ \;
# The CLI archives and their digest list, from the job that produced
# them: a reasonix-studio-host.exe left in staging matched reasonix-*.
find staging/cli-archives -type f -exec mv {} dist/ \;
ls -la dist
# Two Windows artifacts (installer, portable zip), two per macOS
# architecture (dmg, zip) and the Linux .deb. Fewer means an artifact
# was lost between jobs, and a partial release is worse than a failed
# one.
count=$(find dist -type f -name 'ReasonixStudio-*' | wc -l)
if [ "$count" -lt 7 ]; then
echo "::error::expected 7 artifacts (Windows installer + zip, macOS dmg + zip per architecture, Linux .deb), found $count"
exit 1
fi
# Each platform's update channel, and there is exactly one per
# platform: dpkg on Linux, the next installer on Windows, and the
# bundle swapped out of the archive on macOS. Missing one degrades the
# release silently instead of failing it — it strands that platform on
# the version it is running.
# Each macOS architecture by name, not darwin-*: a glob passes on
# whichever one shipped, and the missing one is a platform left on
# the version it is running with nothing saying so.
for required in 'ReasonixStudio-linux-*.deb' 'ReasonixStudio-windows-*-installer.exe' \
'ReasonixStudio-darwin-amd64.zip' 'ReasonixStudio-darwin-arm64.zip'; do
find dist -type f -name "$required" | grep -q . || {
echo "::error::no $required in the release; its packager did not run"
exit 1
}
done
# The same minisign key the desktop line uses, because the public key that
# verifies it is compiled into the client (update.PublicKey).
- name: Sign
working-directory: desktop
env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
run: |
go run ./cmd/sign sign ../dist/ReasonixStudio-*
for f in ../dist/ReasonixStudio-*; do
case "$f" in *.minisig) continue ;; esac
go run ./cmd/sign verify "$f"
done
# A chunked update beside the installer: the index of the signed portable
# tree and the chunks it is made of. It stays out of dist, which is what
# is counted and published as the release's artifacts.
- name: Build the Windows delta
env:
VERSION: ${{ needs.resolve.outputs.version }}
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
run: |
go run ./cmd/studio-delta build dist/ReasonixStudio-windows-amd64.zip "$VERSION" windows-amd64 delta
cd desktop
go run ./cmd/sign sign ../delta/windows-amd64/index.json.zst
go run ./cmd/sign verify ../delta/windows-amd64/index.json.zst
# Deltas are served from the mirror only, so a release that cannot reach
# it does not offer one: a client would fetch an index nobody uploaded.
- name: Generate manifest
working-directory: desktop
env:
GITHUB_REPOSITORY: ${{ github.repository }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
run: |
delta_dir=""
if [ "$HAS_R2" = "true" ]; then delta_dir=../delta; fi
go run ./cmd/studio-manifest ../dist "${{ needs.resolve.outputs.version }}" "${{ needs.resolve.outputs.tag }}" $delta_dir
# Always a prerelease: this line is not the public product line, and
# GitHub never promotes a prerelease to the repository-wide "latest".
- name: Publish GitHub release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
VERSION: ${{ needs.resolve.outputs.version }}
SIGNED_WINDOWS: ${{ secrets.SIGNPATH_API_TOKEN != '' && vars.STUDIO_SIGNING_ENABLED == 'true' }}
run: |
# A version's own notes live in release-notes/studio/<semver>.md, named
# without the v the tag carries. The release still publishes without
# one, carrying only the standing text.
version_notes="release-notes/studio/${VERSION#v}.md"
notes="$RUNNER_TEMP/release-notes.md"
: > "$notes"
if [ -f "$version_notes" ]; then
cat "$version_notes" >> "$notes"
printf '\n' >> "$notes"
else
echo "::warning::no $version_notes; publishing the standing text only"
fi
# The Windows sentence follows the condition that decides it, read
# here from the same pair the build job gates signing on. Asserting a
# constant is how this line came to tell users to click through
# SmartScreen for a package that was signed.
if [ "$SIGNED_WINDOWS" = "true" ]; then
windows='**Windows** 的窗口、内核与安装器都经 SignPath 做了 Authenticode 签名。'
else
windows='**Windows 包未签名**,SmartScreen 会告警:选「更多信息」,再选「仍要运行」。'
fi
{
printf '%s\n\n' '---'
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** 从 \`.deb\` 安装。\n\n" "$windows"
printf '%s\n' '三个平台都能自更新:Linux 走自己的包,Windows 运行下一个安装器,macOS 替换自身 bundle。'
} >> "$notes"
if gh release view "$TAG" >/dev/null 2>&1; then
gh release upload "$TAG" dist/* --clobber
else
gh release create "$TAG" dist/* \
--title "Reasonix Studio $VERSION" \
--notes-file "$notes" \
--prerelease \
--latest=false
fi
- name: Mirror to R2 and update the Studio catalog
env:
R2_BUCKET: ${{ secrets.R2_BUCKET }}
TAG: ${{ needs.resolve.outputs.tag }}
VERSION: ${{ needs.resolve.outputs.version }}
CANDIDATE: ${{ needs.resolve.outputs.candidate }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
run: |
if [ "$HAS_R2" != "true" ]; then
echo "::warning::R2 credentials are not configured; the GitHub release is published but the Studio catalog was not updated, so running builds will not see this version"
exit 0
fi
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 default.region auto
# A release's delta is tens of thousands of chunks of about 10 KB, so
# the sync is bound by request round trips, not bandwidth.
aws configure set default.s3.max_concurrent_requests 64
endpoint="https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com"
# Chunks before anything that names them: they are named by their
# content and never change, so the sync only sends the new ones, and an
# index published ahead of its chunks is one no client can apply.
aws s3 sync delta/chunks "s3://${R2_BUCKET}/studio/chunks/" --size-only \
--endpoint-url "$endpoint" \
--content-type application/zstd \
--cache-control "public, max-age=31536000, immutable"
aws s3 cp delta/ "s3://${R2_BUCKET}/${TAG}/delta/" --recursive --exclude "chunks/*" --endpoint-url "$endpoint"
# Immutable per-tag objects. The tag prefix cannot collide with the
# desktop line's because the tag names differ (studio-v* vs desktop-v*).
aws s3 cp dist/ "s3://${R2_BUCKET}/${TAG}/" --recursive --endpoint-url "$endpoint"
# A candidate is mirrored so it can be installed from its own manifest
# and tested, but it never enters the catalog: the catalog is what a
# running install reads to decide its next version.
if [ "$CANDIDATE" = "true" ]; then
echo "$TAG mirrored; the Studio catalog stays at its released version"
exit 0
fi
# Studio's own catalog. Writing the ROOT versions.json here would hand
# desktop users Studio releases, so the prefix is not optional.
current=/tmp/studio-versions-current.json
next=/tmp/studio-versions-next.json
source=-
if aws s3 cp "s3://${R2_BUCKET}/studio/versions.json" "$current" --endpoint-url "$endpoint" 2>/dev/null; then
source="$current"
else
echo "no existing Studio catalog; starting a new one"
fi
bash scripts/update-versions-index.sh "$source" "$VERSION" "$TAG" studio \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$next"
jq -e --arg v "$VERSION" '.versions | map(select(.version == $v)) | length == 1' "$next" >/dev/null
aws s3 cp "$next" "s3://${R2_BUCKET}/studio/versions.json" \
--endpoint-url "$endpoint" \
--content-type "application/json; charset=utf-8" \
--cache-control "public, max-age=300, stale-if-error=86400"
echo "Studio catalog -> $VERSION"
# The CLI's install channels. This line is the only one that publishes the
# CLI, so npm's `reasonix` and the Homebrew tap follow it. They run once the
# release exists, because the cask points at its archives. A candidate goes
# to npm's `next` tag and leaves the tap alone: brew has no prerelease
# channel, so a candidate cask would become the default install.
cli-channels:
name: publish the CLI to npm and Homebrew
needs: [resolve, publish]
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
- uses: actions/setup-node@v7
with:
node-version: "24"
registry-url: https://registry.npmjs.org
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.resolve.outputs.version }}
run: node npm/build.mjs "$VERSION" --publish
- uses: actions/download-artifact@v6
if: needs.resolve.outputs.candidate != 'true'
with:
name: cli-archives
path: cli-archives
- name: Update the Homebrew cask
if: needs.resolve.outputs.candidate != 'true'
env:
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
VERSION: ${{ needs.resolve.outputs.version }}
TAG: ${{ needs.resolve.outputs.tag }}
run: |
set -euo pipefail
git clone --depth 1 "https://x-access-token:${TAP_TOKEN}@github.com/esengine/homebrew-reasonix.git" tap
bash scripts/render-brew-cask.sh "${VERSION#v}" "$TAG" cli-archives/SHA256SUMS > tap/Casks/reasonix.rb
cd tap
git add Casks/reasonix.rb
if git diff --cached --quiet; then
echo "cask already at ${VERSION#v}"
exit 0
fi
git -c user.name=reasonix -c [email protected] commit -m "reasonix ${VERSION#v}"
git push origin HEAD:main