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