Files
DeepSeek-Reasonix/.github/workflows/release-studio.yml
T

1519 lines
71 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 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. The highest stable tag
# is the repository's GitHub latest and a candidate is a prerelease; the GitHub
# release carries no root latest.json, because the 1.x updaters' last-resort
# URL is releases/latest/download/latest.json and must keep answering 404.
#
# Windows artifacts are Authenticode-signed with a Certum SimplySign cloud
# certificate in two stages: every PE file in the application tree, then the
# installer built around them. Signing only the container leaves them unsigned on disk
# after installation, which is what reputation heuristics quarantine. The
# repository variable STUDIO_SIGNING_ENABLED=true turns it on; once on, a
# release that cannot sign does not publish.
#
# 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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.
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)"
- name: Require versioned release notes
env:
SHA: ${{ steps.release.outputs.sha }}
VERSION: ${{ steps.release.outputs.version }}
run: |
notes="release-notes/studio/${VERSION#v}.md"
if ! git cat-file -e "$SHA:$notes"; then
echo "::error::$notes must exist in the tagged commit before a Studio release can build"
exit 1
fi
# The contract names which branch and which workflows may reach the release
# certificate. The call graph is read from the workflows themselves, so a
# second workflow that learns to use the credentials fails here before
# anything is built, and the fingerprint pins the files the signing runs.
# Both trees are checked: the tag's, which is built, and the workflow's,
# whose scripts do the signing.
signing-contract:
name: validate release signing contract
needs: resolve
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
persist-credentials: false
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.workflow_sha }}
path: release-control
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- name: Validate and fingerprint the contract
run: |
go run ./cmd/signpath-contract validate
echo "release fingerprint=$(go run ./cmd/signpath-contract fingerprint)" >> "$GITHUB_STEP_SUMMARY"
cd release-control
go run ./cmd/signpath-contract validate
echo "workflow 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
env:
# The switch alone decides the path, never the presence of credentials:
# with it on, the Windows leg hands its bundle to windows-sign-payload,
# and a missing secret fails there instead of shipping an unsigned build.
SIGN_WINDOWS: ${{ 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.
# artifact is the exact name publish downloads this leg's packages by.
# goarch and unpacked belong to the Windows legs: the release
# architecture the package is named for, and the directory
# electron-builder leaves its bundle in (win-unpacked for x64 alone).
include:
- platform: windows/amd64
runner: windows-latest
goarch: amd64
unpacked: win-unpacked
artifact: studio-build-windows-amd64
- platform: windows/arm64
runner: windows-11-arm
ebarch: arm64
goarch: arm64
unpacked: win-arm64-unpacked
artifact: studio-build-windows-arm64
- platform: darwin/amd64
runner: macos-latest
ebarch: x64
artifact: studio-build-darwin-amd64
- platform: darwin/arm64
runner: macos-latest
ebarch: arm64
artifact: studio-build-darwin-arm64
- platform: linux/amd64
runner: ubuntu-22.04
artifact: studio-build-linux-amd64
- platform: linux/arm64
runner: ubuntu-22.04-arm
artifact: studio-build-linux-arm64
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
cache-dependency-path: go.sum
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # 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
# to rpmbuild for the .rpm, 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 rpm cpio xvfb xauth policykit-1
# The fpm electron-builder carries is an x86_64 program, so on an ARM64
# runner the .deb and .rpm are built with one installed from RubyGems.
- name: Use a native fpm on ARM64 Linux
if: runner.os == 'Linux' && runner.arch == 'ARM64'
run: |
sudo apt-get install -y ruby ruby-dev build-essential
sudo gem install --no-document fpm -v 1.16.0
echo "USE_SYSTEM_FPM=true" >> "$GITHUB_ENV"
# 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 }}
REASONIX_GOARCH: ${{ matrix.goarch }}
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}"
# An emulated x64 program runs on ARM64 Windows, so a bundle that starts
# proves nothing about its architecture: every PE image in it has to be
# built for the architecture the package is named for.
- name: Every PE image is built for the package's architecture
if: runner.os == 'Windows'
shell: bash
run: node desktop/electron/packaging/pe.js "dist/${{ matrix.unpacked }}" "${{ matrix.goarch }}"
# 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}"
- name: Verify both Linux package formats
if: runner.os == 'Linux'
shell: bash
run: |
platform="${{ matrix.platform }}"
arch="${platform#linux/}"
bash scripts/verify-linux-package.sh "dist/ReasonixStudio-linux-$arch.deb" "dist/ReasonixStudio-linux-$arch.rpm" "$arch"
- name: Accept the installed Linux package
if: runner.os == 'Linux'
shell: bash
run: |
platform="${{ matrix.platform }}"
arch="${platform#linux/}"
version="${{ needs.resolve.outputs.version }}"
bash scripts/linux-install-smoke.sh "dist/ReasonixStudio-linux-$arch.deb" "$arch" "${version#v}"
- name: Verify the AppImage
if: runner.os == 'Linux'
shell: bash
run: |
platform="${{ matrix.platform }}"
arch="${platform#linux/}"
bash scripts/verify-linux-appimage.sh "dist/ReasonixStudio-linux-$arch.AppImage" "$arch"
# With signing on, this leg only hands the bundle on: the signing jobs
# run nothing but the signer, and windows-package packages in between.
- name: Upload the unsigned Windows bundle for signing
if: runner.os == 'Windows' && env.SIGN_WINDOWS == 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: unsigned-studio-windows-${{ matrix.goarch }}-bundle
path: dist/${{ matrix.unpacked }}
if-no-files-found: error
retention-days: 1
# The installer and the portable archive 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' && env.SIGN_WINDOWS != 'true'
working-directory: desktop/electron
shell: bash
env:
# The arm64 installer has to be packed with the BCJ filter: 7-Zip
# applies an ARM64 filter to ARM64 executables by default, the NSIS
# extractor cannot decode it, and the installer then exits 0 with
# every executable missing.
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.goarch == 'arm64' && 'BCJ' || '' }}
run: |
version="${{ needs.resolve.outputs.version }}"
arch="--${{ matrix.ebarch || 'x64' }}"
rm -rf ../../zip-src ../../dist-zip
cp -r ../../dist/${{ matrix.unpacked }} ../../zip-src
pnpm exec electron-builder --prepackaged ../../zip-src --win zip $arch \
-c.extraMetadata.version="${version#v}" -c.directories.output=../../dist-zip &
zip_pid=$!
pnpm exec electron-builder --prepackaged ../../dist/${{ matrix.unpacked }} --win nsis $arch \
-c.extraMetadata.version="${version#v}"
wait "$zip_pid"
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.goarch }}.zip ../../dist/
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: runner.os != 'Windows' || env.SIGN_WINDOWS != 'true'
with:
name: ${{ matrix.artifact }}
path: dist/ReasonixStudio-*
if-no-files-found: error
retention-days: 7
# Windows signing is four jobs so the SimplySign session is never up while
# third-party code runs or untrusted archives are parsed: the two signing jobs
# install no toolchain and run only the signer, while packaging and the
# package check run between them on their own runners with no secrets. Both
# signing jobs share one group with the Studio smoke test, so no two runs
# drive the same SimplySign account at once.
#
# Each signing stage is a matrix over the Windows architectures with one tree
# per job. max-parallel: 1 keeps the legs strictly serial, which the shared
# certum-signing group needs: a group holds one running and one waiting job,
# so two legs started together would have the waiting one cancelled by the
# next arrival. A failed leg reruns alone; the others' results are kept.
windows-sign-payload:
name: sign the Windows PE files (Certum, ${{ matrix.arch }})
needs: [resolve, signing-contract, build]
if: vars.STUDIO_SIGNING_ENABLED == 'true'
runs-on: windows-2022
timeout-minutes: 30
strategy:
fail-fast: false
max-parallel: 1
matrix:
arch: [amd64, arm64]
environment: studio-release
permissions:
contents: read
concurrency:
group: certum-signing
cancel-in-progress: false
# One output per architecture, because matrix legs share a job's output map:
# a leg writes only its own and an empty value never replaces another's.
# Recorded here because no later job can write this job's outputs: the
# installer signing job refuses a payload artifact that no longer hashes to it.
outputs:
payload-digest-amd64: ${{ matrix.arch == 'amd64' && steps.verify.outputs.digest || '' }}
payload-digest-arm64: ${{ matrix.arch == 'arm64' && steps.verify.outputs.digest || '' }}
steps:
# Only the workflow's own commit is checked out: these jobs run the signer
# and its checks, never anything the release tag builds.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.workflow_sha }}
persist-credentials: false
# The switch is on, so nothing below may fall back to an unsigned build.
# Every missing value is reported under its own code before any fails.
- name: Require Certum configuration
shell: pwsh
env:
CERTUM_USERNAME: ${{ secrets.CERTUM_USERNAME }}
CERTUM_OTP_URI: ${{ secrets.CERTUM_OTP_URI }}
CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
EXPECTED_SUBJECT: ${{ vars.STUDIO_SIGNING_SUBJECT }}
run: |
$ErrorActionPreference = 'Stop'
$failed = $false
function Refuse([string]$code, [string]$message) {
Write-Output "::error title=$code::$message"
Set-Variable -Scope Script -Name failed -Value $true
}
if ([string]::IsNullOrWhiteSpace($env:CERTUM_USERNAME)) {
Refuse 'studio-signing.missing-username' 'Secret CERTUM_USERNAME is not set'
}
if ($env:CERTUM_OTP_URI -notmatch '^otpauth://totp/') {
Refuse 'studio-signing.invalid-otp-uri' 'Secret CERTUM_OTP_URI must be a complete otpauth://totp/ URI'
}
if ($env:CERTUM_KEY_ID -notmatch '^[0-9a-fA-F]{40}$') {
Refuse 'studio-signing.invalid-key-id' 'Secret CERTUM_KEY_ID must be the 40-character SHA-1 certificate thumbprint'
}
if ([string]::IsNullOrWhiteSpace($env:EXPECTED_SUBJECT)) {
Refuse 'studio-signing.subject-unset' 'Variable STUDIO_SIGNING_SUBJECT is empty; run the Studio Certum signing smoke test and copy the subject it reports'
}
if ($failed) { exit 1 }
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: unsigned-studio-windows-${{ matrix.arch }}-bundle
path: signing/app
# Stage one: every PE file in the application tree that Microsoft has
# not signed, Electron's DLLs included. Windows loads each of them, and
# Smart App Control judges an unsigned one on its own reputation. The
# tree has to hold exactly the PE files the signing library declares.
- name: List the PE files to sign
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
. ./scripts/windows-signing-lib.ps1
Assert-DeclaredPeSet -Root signing/app
$script:ReleaseSignedPe | ForEach-Object { "app/$_" } | Set-Content signing/signing-files.txt
Get-Content signing/signing-files.txt
- name: Connect to Certum
uses: ./.github/actions/setup-certum
with:
username: ${{ secrets.CERTUM_USERNAME }}
otp-uri: ${{ secrets.CERTUM_OTP_URI }}
thumbprint: ${{ secrets.CERTUM_KEY_ID }}
- name: Sign the PE files
shell: pwsh
env:
CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
run: ./scripts/sign-certum.ps1 -PayloadDirectory signing
# The session signs for any process on the runner while it is up.
- name: Disconnect from Certum
if: always()
shell: pwsh
run: Get-Process -Name '*SimplySign*' -ErrorAction SilentlyContinue | Stop-Process -Force
# Enumerates the tree again rather than reading the list it signed from,
# so a PE file the list missed fails here unsigned.
- name: Verify the signed tree
id: verify
shell: pwsh
env:
CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
EXPECTED_SUBJECT: ${{ vars.STUDIO_SIGNING_SUBJECT }}
run: |
$ErrorActionPreference = 'Stop'
./scripts/verify-windows-authenticode.ps1 `
-PayloadDirectory signing/app `
-Architecture ${{ matrix.arch }} `
-DigestPath "$env:RUNNER_TEMP/payload-digest" `
-ExpectedThumbprint $env:CERTUM_KEY_ID `
-ExpectedSubject $env:EXPECTED_SUBJECT
"digest=$(Get-Content -Raw "$env:RUNNER_TEMP/payload-digest")" >> $env:GITHUB_OUTPUT
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: signed-studio-windows-payload-${{ matrix.arch }}
path: signing/app
if-no-files-found: error
retention-days: 1
# No environment and no secrets: electron-builder and its dependencies run
# here, on a runner that never held a signing session.
windows-package:
name: package Windows from the signed tree (${{ matrix.arch }})
needs: [resolve, windows-sign-payload]
runs-on: windows-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
ebarch: x64
unpacked: win-unpacked
- arch: arm64
ebarch: arm64
unpacked: win-arm64-unpacked
permissions:
contents: read
steps:
- name: Stop Defender scanning the build
continue-on-error: true
shell: pwsh
run: Set-MpPreference -DisableRealtimeMonitoring $true
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
persist-credentials: false
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/electron/pnpm-lock.yaml
- name: Install the packager
working-directory: desktop/electron
shell: bash
run: pnpm install --frozen-lockfile
# The signed payload is the whole bundle, so it is packaged as it came.
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: signed-studio-windows-payload-${{ matrix.arch }}
path: dist/${{ matrix.unpacked }}
# Both packages come from the signed tree, so the archive carries the
# signed files too rather than a second unsigned copy. The zip is packed
# from its own copy for the reason the build job gives.
- name: Package Windows from the signed bundle
working-directory: desktop/electron
shell: bash
env:
# See the build job: the arm64 installer must be packed with BCJ.
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.arch == 'arm64' && 'BCJ' || '' }}
run: |
version="${{ needs.resolve.outputs.version }}"
rm -rf ../../zip-src ../../dist-zip
cp -r ../../dist/${{ matrix.unpacked }} ../../zip-src
pnpm exec electron-builder --prepackaged ../../zip-src --win zip --${{ matrix.ebarch }} \
-c.extraMetadata.version="${version#v}" -c.directories.output=../../dist-zip &
zip_pid=$!
pnpm exec electron-builder --prepackaged ../../dist/${{ matrix.unpacked }} --win nsis --${{ matrix.ebarch }} \
-c.extraMetadata.version="${version#v}"
wait "$zip_pid"
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.arch }}.zip ../../dist/
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: packaged-studio-windows-${{ matrix.arch }}
path: |
dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe
dist/ReasonixStudio-windows-${{ matrix.arch }}.zip
if-no-files-found: error
retention-days: 1
# Unpacks what windows-package built and compares it with the signed payload.
# Archive parsing happens here, on a runner with no environment and no
# secrets, never in a job that holds the Certum credentials. Its outputs are
# the hashes of the two files it checked, which the signing job requires.
windows-verify-package:
name: check the Windows packages against the signed tree (${{ matrix.arch }})
needs: [resolve, windows-sign-payload, windows-package]
runs-on: windows-2022
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
arch: [amd64, arm64]
permissions:
contents: read
outputs:
installer-sha256-amd64: ${{ matrix.arch == 'amd64' && steps.check.outputs.installer-sha256 || '' }}
installer-sha256-arm64: ${{ matrix.arch == 'arm64' && steps.check.outputs.installer-sha256 || '' }}
archive-sha256-amd64: ${{ matrix.arch == 'amd64' && steps.check.outputs.archive-sha256 || '' }}
archive-sha256-arm64: ${{ matrix.arch == 'arm64' && steps.check.outputs.archive-sha256 || '' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.workflow_sha }}
persist-credentials: false
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: signed-studio-windows-payload-${{ matrix.arch }}
path: payload
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: packaged-studio-windows-${{ matrix.arch }}
path: dist
# 7-Zip is the runner image's, not the tag's.
- name: Compare both packages with the signed payload
id: check
shell: pwsh
env:
PAYLOAD_DIGEST: ${{ needs.windows-sign-payload.outputs[format('payload-digest-{0}', matrix.arch)] }}
run: |
./scripts/verify-windows-package.ps1 `
-PayloadDirectory payload `
-ExpectedPayloadDigest $env:PAYLOAD_DIGEST `
-InstallerPath "dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
-PortableArchivePath "dist/ReasonixStudio-windows-${{ matrix.arch }}.zip" `
-OutputPath $env:GITHUB_OUTPUT `
-Architecture ${{ matrix.arch }}
windows-sign-installer:
name: sign the Windows installer (Certum, ${{ matrix.arch }})
needs: [resolve, windows-package, windows-verify-package]
runs-on: windows-2022
timeout-minutes: 30
strategy:
fail-fast: false
max-parallel: 1
matrix:
arch: [amd64, arm64]
environment: studio-release
permissions:
contents: read
concurrency:
group: certum-signing
cancel-in-progress: false
# The signed installer's hash, which publish requires of what it downloads.
outputs:
installer-sha256-amd64: ${{ matrix.arch == 'amd64' && steps.signed.outputs.installer-sha256 || '' }}
installer-sha256-arm64: ${{ matrix.arch == 'arm64' && steps.signed.outputs.installer-sha256 || '' }}
steps:
# Only the workflow's own commit is checked out: these jobs run the signer
# and its checks, never anything the release tag builds.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.workflow_sha }}
persist-credentials: false
# The switch is on, so nothing below may fall back to an unsigned build.
# Every missing value is reported under its own code before any fails.
- name: Require Certum configuration
shell: pwsh
env:
CERTUM_USERNAME: ${{ secrets.CERTUM_USERNAME }}
CERTUM_OTP_URI: ${{ secrets.CERTUM_OTP_URI }}
CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
EXPECTED_SUBJECT: ${{ vars.STUDIO_SIGNING_SUBJECT }}
run: |
$ErrorActionPreference = 'Stop'
$failed = $false
function Refuse([string]$code, [string]$message) {
Write-Output "::error title=$code::$message"
Set-Variable -Scope Script -Name failed -Value $true
}
if ([string]::IsNullOrWhiteSpace($env:CERTUM_USERNAME)) {
Refuse 'studio-signing.missing-username' 'Secret CERTUM_USERNAME is not set'
}
if ($env:CERTUM_OTP_URI -notmatch '^otpauth://totp/') {
Refuse 'studio-signing.invalid-otp-uri' 'Secret CERTUM_OTP_URI must be a complete otpauth://totp/ URI'
}
if ($env:CERTUM_KEY_ID -notmatch '^[0-9a-fA-F]{40}$') {
Refuse 'studio-signing.invalid-key-id' 'Secret CERTUM_KEY_ID must be the 40-character SHA-1 certificate thumbprint'
}
if ([string]::IsNullOrWhiteSpace($env:EXPECTED_SUBJECT)) {
Refuse 'studio-signing.subject-unset' 'Variable STUDIO_SIGNING_SUBJECT is empty; run the Studio Certum signing smoke test and copy the subject it reports'
}
if ($failed) { exit 1 }
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: packaged-studio-windows-${{ matrix.arch }}
path: dist
# Only the two files windows-verify-package checked, byte for byte. This
# job opens no archive: what is inside was settled on a runner without
# the credentials, so Certum never signs an installer that did not match.
- name: Require the checked packages
shell: pwsh
env:
INSTALLER_SHA256: ${{ needs.windows-verify-package.outputs[format('installer-sha256-{0}', matrix.arch)] }}
ARCHIVE_SHA256: ${{ needs.windows-verify-package.outputs[format('archive-sha256-{0}', matrix.arch)] }}
run: |
$ErrorActionPreference = 'Stop'
$expected = [ordered]@{
'ReasonixStudio-windows-${{ matrix.arch }}-installer.exe' = $env:INSTALLER_SHA256
'ReasonixStudio-windows-${{ matrix.arch }}.zip' = $env:ARCHIVE_SHA256
}
$present = @(Get-ChildItem -LiteralPath dist -Recurse -Force | ForEach-Object { $_.Name })
if (Compare-Object -CaseSensitive @($expected.Keys) $present) { throw "dist holds $($present -join ', ')" }
foreach ($entry in $expected.GetEnumerator()) {
if ($entry.Value -notmatch '^[0-9a-f]{64}$') { throw "No checked hash for $($entry.Key)" }
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path dist $entry.Key)).Hash.ToLowerInvariant()
if ($hash -cne $entry.Value) { throw "$($entry.Key) is not the file windows-verify-package checked" }
}
- name: Connect to Certum
uses: ./.github/actions/setup-certum
with:
username: ${{ secrets.CERTUM_USERNAME }}
otp-uri: ${{ secrets.CERTUM_OTP_URI }}
thumbprint: ${{ secrets.CERTUM_KEY_ID }}
# Stage two: the NSIS container, only once the payload inside it is signed.
- name: Sign the installer
shell: pwsh
env:
CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
run: ./scripts/sign-certum.ps1 -FilePath dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe
# The session signs for any process on the runner while it is up.
- name: Disconnect from Certum
if: always()
shell: pwsh
run: Get-Process -Name '*SimplySign*' -ErrorAction SilentlyContinue | Stop-Process -Force
- name: Verify the installer signature
id: signed
shell: pwsh
env:
CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
EXPECTED_SUBJECT: ${{ vars.STUDIO_SIGNING_SUBJECT }}
run: |
$ErrorActionPreference = 'Stop'
$installer = 'dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe'
./scripts/verify-windows-authenticode.ps1 `
-FilePath $installer `
-ExpectedThumbprint $env:CERTUM_KEY_ID `
-ExpectedSubject $env:EXPECTED_SUBJECT
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installer).Hash.ToLowerInvariant()
"installer-sha256=$hash" >> $env:GITHUB_OUTPUT
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: studio-windows-signed-${{ matrix.arch }}
path: dist/ReasonixStudio-*
if-no-files-found: error
retention-days: 7
# Installs each Windows installer where it is meant to run (the same script
# the Studio workflow runs on pull requests, against the unsigned installer).
# The package check unpacks with the runner's modern 7-Zip, which reads
# archives the NSIS extractor cannot, so only running the installer shows that
# it installs every executable. No environment and no secrets.
windows-install-smoke:
name: install the Windows package (${{ matrix.arch }})
needs: [resolve, build, windows-sign-installer]
if: >-
${{ !cancelled() && needs.resolve.result == 'success' && needs.build.result == 'success' &&
(needs.windows-sign-installer.result == 'success' ||
(vars.STUDIO_SIGNING_ENABLED != 'true' && needs.windows-sign-installer.result == 'skipped')) }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: windows-latest
- arch: arm64
runner: windows-11-arm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
persist-credentials: false
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result == 'success'
with:
name: studio-windows-signed-${{ matrix.arch }}
path: dist
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result != 'success'
with:
name: studio-build-windows-${{ matrix.arch }}
path: dist
- name: Install, check and uninstall
shell: pwsh
run: |
./scripts/install-smoke.ps1 `
-Installer "dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
-Architecture ${{ matrix.arch }}
# 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
# The page every archive carries beside the executable, built once here
# rather than once per target: serve.FindPage looks for
# frontend-next/dist next to the binary.
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
- name: Build the page
working-directory: desktop/frontend-next
run: pnpm install --frozen-lockfile && pnpm build
# 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 }}
SHA: ${{ needs.resolve.outputs.sha }}
PAGE_DIR: desktop/frontend-next/dist
run: |
set -euo pipefail
mkdir -p dist
built="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
pkg=reasonix/internal/tools/productdocs
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 -X main.gitCommit=${SHA:0:12} -X main.buildTimeUTC=$built -X $pkg.linkedVersion=$VERSION -X $pkg.linkedRevision=$SHA" \
-o "reasonix$ext" ./cmd/reasonix
scripts/package-cli-archive.sh "$os" "$arch" "reasonix$ext" "$PAGE_DIR" dist
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: python3 scripts/check-cli-archives.py dist
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: cli-archives
path: dist/*
if-no-files-found: error
retention-days: 7
publish:
name: sign, publish and mirror
needs: [resolve, build, cli, windows-sign-payload, windows-package, windows-verify-package, windows-sign-installer, windows-install-smoke]
# The Windows signing jobs may be skipped only while the switch is off; with
# it on, any of them failing, cancelled or absent blocks the release.
if: >-
${{ !cancelled() && needs.resolve.result == 'success' && needs.build.result == 'success' &&
needs.cli.result == 'success' && needs.windows-install-smoke.result == 'success' &&
((needs.windows-sign-payload.result == 'success' &&
needs.windows-package.result == 'success' && needs.windows-verify-package.result == 'success' &&
needs.windows-sign-installer.result == 'success') ||
(vars.STUDIO_SIGNING_ENABLED != 'true' && needs.windows-sign-payload.result == 'skipped' &&
needs.windows-package.result == 'skipped' && needs.windows-verify-package.result == 'skipped' &&
needs.windows-sign-installer.result == 'skipped')) }}
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
issues: read # release-notes credits read PR and issue authors
pull-requests: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
fetch-depth: 0
# Before signing, so a credit lookup GitHub cannot answer stops the
# release while nothing has been published.
- name: Render version notes
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.resolve.outputs.version }}
run: |
version_notes="release-notes/studio/${VERSION#v}.md"
if [ -f "$version_notes" ]; then
node scripts/studio-release-notes.mjs "$version_notes" "$RUNNER_TEMP/version-notes.md"
fi
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # 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.
# Every package by its exact name, never a pattern: any job without
# secrets can upload an artifact, and a pattern would publish it. Windows
# comes from the installer signing job when it ran, else the build leg.
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: studio-build-darwin-amd64
path: staging/studio-build-darwin-amd64
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: studio-build-darwin-arm64
path: staging/studio-build-darwin-arm64
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: studio-build-linux-amd64
path: staging/studio-build-linux-amd64
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: studio-build-linux-arm64
path: staging/studio-build-linux-arm64
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result == 'success'
with:
name: studio-windows-signed-amd64
path: staging/studio-windows-amd64
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result == 'success'
with:
name: studio-windows-signed-arm64
path: staging/studio-windows-arm64
# Any job can upload an artifact, so the signed Windows packages are
# taken only if they are the bytes the signing jobs recorded: each
# installer as signed, each zip as windows-verify-package checked it.
- name: Require the recorded Windows packages
if: needs.windows-sign-installer.result == 'success'
env:
INSTALLER_SHA256_AMD64: ${{ needs.windows-sign-installer.outputs[format('installer-sha256-{0}', 'amd64')] }}
ARCHIVE_SHA256_AMD64: ${{ needs.windows-verify-package.outputs[format('archive-sha256-{0}', 'amd64')] }}
INSTALLER_SHA256_ARM64: ${{ needs.windows-sign-installer.outputs[format('installer-sha256-{0}', 'arm64')] }}
ARCHIVE_SHA256_ARM64: ${{ needs.windows-verify-package.outputs[format('archive-sha256-{0}', 'arm64')] }}
run: |
check() {
local file="$1/$2" want="$3" got
if [[ ! "$want" =~ ^[0-9a-f]{64}$ ]]; then
echo "::error title=studio-signing.package-hash-missing::no recorded SHA-256 for $2"
return 1
fi
got="$(sha256sum "$file" | awk '{print $1}')" || return 1
if [ "$got" != "$want" ]; then
echo "::error title=studio-signing.package-hash-mismatch::$2 is $got, the signing jobs recorded $want"
return 1
fi
}
for arch in amd64 arm64; do
dir="staging/studio-windows-$arch"
files="$(cd "$dir" && find . -type f | sed 's|^\./||' | LC_ALL=C sort | tr '\n' ' ')"
if [ "$files" != "ReasonixStudio-windows-$arch-installer.exe ReasonixStudio-windows-$arch.zip " ]; then
echo "::error title=studio-signing.package-set-mismatch::studio-windows-signed-$arch holds: $files"
exit 1
fi
upper="${arch^^}"
installer="INSTALLER_SHA256_$upper"
archive="ARCHIVE_SHA256_$upper"
check "$dir" "ReasonixStudio-windows-$arch-installer.exe" "${!installer}"
check "$dir" "ReasonixStudio-windows-$arch.zip" "${!archive}"
done
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result != 'success'
with:
name: studio-build-windows-amd64
path: staging/studio-windows-amd64
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result != 'success'
with:
name: studio-build-windows-arm64
path: staging/studio-windows-arm64
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: cli-archives
path: staging/cli-archives
- name: Collect artifacts
run: |
mkdir -p dist
# The packages, from the build and signing jobs, then the CLI archives
# and their digest list. Two artifacts carrying one file name fail the
# release rather than letting move order pick.
while IFS= read -r -d '' f; do
name="$(basename "$f")"
if [ -e "dist/$name" ]; then
echo "::error::$name arrived in more than one artifact"
exit 1
fi
mv "$f" "dist/$name"
done < <(find staging/studio-* -type f -name 'ReasonixStudio-*' -print0
find staging/cli-archives -type f -print0)
ls -la dist
bash scripts/verify-studio-artifacts.sh dist
# 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.
# The same gate puts the mirror ahead of GitHub for every full artifact.
- 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
# The manifest stays out of the GitHub release (it is mirrored to R2
# under <tag>/ below), and a stable release takes the repository's latest
# only when it is the highest stable studio tag.
- name: Publish GitHub release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
CANDIDATE: ${{ needs.resolve.outputs.candidate }}
VERSION: ${{ needs.resolve.outputs.version }}
SIGNED_WINDOWS: ${{ needs.windows-sign-installer.result == 'success' }}
run: |
# A version's own notes live in release-notes/studio/<semver>.md, named
# without the v the tag carries, and were rendered with their authors
# above. 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 "$RUNNER_TEMP/version-notes.md" >> "$notes"
printf '\n' >> "$notes"
else
echo "::error::no $version_notes; every Studio release requires user-visible notes"
exit 1
fi
previous_tag="$(git tag --merged "${{ needs.resolve.outputs.sha }}" --sort=-version:refname 'studio-v*' | grep -Fxv "$TAG" | head -n 1)"
if [ -z "$previous_tag" ]; then
echo "::error::cannot resolve the previous Studio tag for $TAG"
exit 1
fi
gh api -X POST "repos/${{ github.repository }}/releases/generate-notes" \
-f tag_name="$TAG" \
-f previous_tag_name="$previous_tag" \
--jq '.body' >> "$notes"
printf '\n\n' >> "$notes"
# The Windows sentence follows the job that signed, not the switch
# that asked for it: a constant here tells users to click through
# SmartScreen for a signed package, or the reverse.
if [ "$SIGNED_WINDOWS" = "true" ]; then
windows='**Windows** 的窗口、内核与安装器都经 Certum 代码签名证书做了 Authenticode 签名。'
else
windows='**Windows 包未签名**,SmartScreen 会告警:选「更多信息」,再选「仍要运行」。'
fi
{
printf '%s\n\n' '---'
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** Debian 系从 \`.deb\` 安装,Fedora、RHEL、openSUSE 等从 \`.rpm\` 安装(\`sudo dnf install ./ReasonixStudio-linux-amd64.rpm\`)。x86_64 用文件名带 amd64 的包,aarch64(统信 UOS、麒麟等)用带 arm64 的包。也提供 \`.AppImage\`(只提供下载,不会自更新;需要 FUSE 2,或用 \`--appimage-extract-and-run\` 启动)。\n\n" "$windows"
printf "%s\n" "三个平台都能自更新:Linux 的 \`.deb\` 走自己的包(\`.rpm\` 的自更新尚未提供,请手动下载新包),Windows 运行下一个安装器,macOS 替换自身 bundle。"
} >> "$notes"
assets=()
for f in dist/*; do
[ "$(basename "$f")" = latest.json ] || assets+=("$f")
done
if gh release view "$TAG" >/dev/null 2>&1; then
gh release edit "$TAG" --notes-file "$notes"
gh release upload "$TAG" "${assets[@]}" --clobber
else
decided="$(git tag -l 'studio-v*' | bash scripts/studio-release-flags.sh "$TAG" "$CANDIDATE")"
mapfile -t flags <<<"$decided"
gh release create "$TAG" "${assets[@]}" \
--title "Reasonix Studio $VERSION" \
--notes-file "$notes" \
"${flags[@]}"
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
# The notes object goes up before the catalog entry that names it, so
# no client reads a pointer to an object that is not there yet. Notes
# are optional: a failure here publishes the version without a pointer
# rather than keeping the new version out of the catalog.
notes_key="studio/notes/${VERSION#v}.md"
notes_arg=()
if aws s3 cp "$RUNNER_TEMP/version-notes.md" "s3://${R2_BUCKET}/${notes_key}" \
--endpoint-url "$endpoint" \
--content-type "text/markdown; charset=utf-8" \
--cache-control "public, max-age=31536000, immutable" &&
aws s3api head-object --bucket "$R2_BUCKET" --key "$notes_key" --endpoint-url "$endpoint" >/dev/null; then
notes_arg=(20 "https://dl.reasonix.io/${notes_key}")
else
echo "::warning::release notes for ${VERSION} were not uploaded to the mirror; the catalog entry is published without notes"
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)" "${notes_arg[@]}" > "$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"
# A CLI channel that moves while 1.x still publishes would have two lines
# writing it, so every CLI job waits for this check. It runs no repository code.
cli-gate:
name: require the 1.x line to be frozen
needs: resolve
if: vars.STUDIO_PUBLISHES_CLI == 'true'
runs-on: ubuntu-latest
permissions: {}
steps:
- name: Require CLI_PUBLISH_FROZEN
env:
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
run: |
if [ "$CLI_PUBLISH_FROZEN" != true ]; then
echo "::error::STUDIO_PUBLISHES_CLI is on but CLI_PUBLISH_FROZEN is not: freeze the 1.x line first, or two lines would write the CLI channels"
exit 1
fi
# The v<version> tag the update pointer needs is created here, by the release
# identity the 1.x line also uses, because only that identity may create v*
# tags. This job checks out nothing and runs no repository code; the one step
# that reads RELEASE_TAG_TOKEN is written inline and only runs on the studio
# tag or branch, through the studio-release environment's ref policy.
cli-tag:
name: create the CLI tag
needs: [resolve, publish, cli-gate]
if: vars.STUDIO_PUBLISHES_CLI == 'true'
runs-on: ubuntu-latest
environment: studio-release
permissions:
contents: read
steps:
- name: Create v<version> on the studio commit
env:
TAG_TOKEN: ${{ secrets.RELEASE_TAG_TOKEN }}
RELEASE_TAG_ACTOR: ${{ vars.RELEASE_TAG_ACTOR }}
READ_TOKEN: ${{ github.token }}
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
STUDIO_TAG: ${{ needs.resolve.outputs.tag }}
CLI_VERSION: ${{ needs.resolve.outputs.version }}
APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
run: |
set -euo pipefail
repo=esengine/DeepSeek-Reasonix
stable_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
preview_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-preview\.(0|[1-9][0-9]*)$'
tag="v${CLI_VERSION#v}"
if [[ ! "$tag" =~ $stable_re && ! "$tag" =~ $preview_re ]]; then
echo "$tag publishes no CLI pointer; no tag is created"
exit 0
fi
[ "$CLI_PUBLISH_FROZEN" = true ] || { echo "::error::CLI_PUBLISH_FROZEN must be true"; exit 1; }
[ "$GITHUB_REPOSITORY" = "$repo" ] || { echo "::error::the release tag identity is only used in $repo"; exit 1; }
case "$GITHUB_REF" in
refs/tags/studio-v* | refs/heads/studio) ;;
*) echo "::error::the release tag identity is only used on a studio tag or the studio branch, not $GITHUB_REF"; exit 1 ;;
esac
[[ "$APPROVED_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::approved SHA is not a full commit SHA"; exit 1; }
[[ "$STUDIO_TAG" == "studio-$CLI_VERSION" ]] || { echo "::error::studio tag does not match the version"; exit 1; }
[ -n "$TAG_TOKEN" ] || { echo "::error::RELEASE_TAG_TOKEN is not configured; the default Actions token cannot create v* tags"; exit 1; }
[[ "$RELEASE_TAG_ACTOR" =~ ^[A-Za-z0-9][A-Za-z0-9-]*$ ]] || { echo "::error::configure RELEASE_TAG_ACTOR with the tag publisher's login"; exit 1; }
read_api() { GH_TOKEN="$READ_TOKEN" gh api "$@"; }
write_api() { GH_TOKEN="$TAG_TOKEN" gh api "$@"; }
actor="$(write_api user)"
jq -e --arg login "$RELEASE_TAG_ACTOR" '.type == "User" and (.login | ascii_downcase) == ($login | ascii_downcase)' <<<"$actor" >/dev/null ||
{ echo "::error::the release tag credential does not identify RELEASE_TAG_ACTOR"; exit 1; }
[ "$(write_api "repos/$repo" --jq .permissions.push)" = true ] ||
{ echo "::error::the release tag identity cannot push to $repo"; exit 1; }
[ "$(read_api "repos/$repo/commits/$STUDIO_TAG" --jq .sha)" = "$APPROVED_SHA" ] ||
{ echo "::error::$STUDIO_TAG does not resolve to $APPROVED_SHA"; exit 1; }
case "$(read_api "repos/$repo/compare/studio...$APPROVED_SHA" --jq .status)" in
behind | identical) ;;
*) echo "::error::$APPROVED_SHA is not on studio history"; exit 1 ;;
esac
# Prints the commit the tag points at; status 1 only when the tag does not exist.
tag_commit() {
local ref kind sha
ref="$(read_api "repos/$repo/git/ref/tags/$tag" --jq '.object.type + " " + .object.sha' 2>"$RUNNER_TEMP/tag.error")" || {
grep -Eiq '404|Not Found' "$RUNNER_TEMP/tag.error" && return 1
cat "$RUNNER_TEMP/tag.error" >&2
exit 1
}
read -r kind sha <<<"$ref"
[ "$kind" != tag ] || sha="$(read_api "repos/$repo/git/tags/$sha" --jq .object.sha)"
echo "$sha"
}
if existing="$(tag_commit)"; then
[ "$existing" = "$APPROVED_SHA" ] || { echo "::error::$tag already exists on $existing, not $APPROVED_SHA"; exit 1; }
echo "$tag already points at $APPROVED_SHA"
exit 0
fi
if ! write_api "repos/$repo/git/refs" -f "ref=refs/tags/$tag" -f "sha=$APPROVED_SHA" >/dev/null 2>"$RUNNER_TEMP/create.error"; then
# A concurrent run may have created the same tag in the meantime.
if existing="$(tag_commit)" && [ "$existing" = "$APPROVED_SHA" ]; then
echo "$tag already points at $APPROVED_SHA"
exit 0
fi
cat "$RUNNER_TEMP/create.error" >&2
exit 1
fi
echo "created $tag at $APPROVED_SHA"
# The CLI's install channels, npm's `reasonix` and the Homebrew tap. The 1.x
# line owns them until this one takes the CLI over; the repository variable
# STUDIO_PUBLISHES_CLI=true hands them here. They run once cli-pointer has
# created the CLI release, 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, cli-gate, cli-tag, cli-pointer]
if: vars.STUDIO_PUBLISHES_CLI == 'true'
runs-on: ubuntu-latest
# id-token lets npm publish --provenance attach a Sigstore attestation; the
# registry token still authenticates the publish itself.
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # 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@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
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
decision="$(node scripts/brew-cask-guard.mjs tap/Casks/reasonix.rb "${VERSION#v}")"
case "$decision" in
write) ;;
skip)
echo "the tap's cask is newer than ${VERSION#v}; leaving it"
exit 0
;;
*)
echo "::error::unexpected cask guard decision: '$decision'"
exit 1
;;
esac
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
# The 1.x updater accepts only a release tagged vMAJOR.MINOR.PATCH whose assets
# sit under that tag, so the archives go to a v<version> release. Only a
# -preview.N version moves preview; other prereleases publish nothing.
cli-pointer:
name: publish the CLI update pointer
needs: [resolve, publish, cli-gate, cli-tag]
if: vars.STUDIO_PUBLISHES_CLI == 'true'
runs-on: ubuntu-latest
concurrency:
group: studio-cli-pointer-${{ contains(needs.resolve.outputs.version, '-') && 'preview' || 'stable' }}
cancel-in-progress: false
permissions:
contents: write # create the CLI release; cli-tag has already made its tag
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
persist-credentials: false
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: cli-archives
path: cli-archives
- name: Publish the release, its record and the pointer
env:
GH_TOKEN: ${{ github.token }}
CLI_VERSION: ${{ needs.resolve.outputs.version }}
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
STUDIO_TAG: ${{ needs.resolve.outputs.tag }}
REPOSITORY: ${{ github.repository }}
ARCHIVES_DIR: cli-archives
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
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 "::error::R2 credentials are not configured; the CLI pointer was not written"
exit 1
fi
bash scripts/publish-cli-pointer.sh