Files
wehub-resource-sync e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:59:56 +08:00

996 lines
46 KiB
YAML

name: Release Desktop App
on:
workflow_dispatch:
inputs:
studio_version:
description: 'Studio version tag to release (for example, v0.1.39-beta)'
type: string
required: true
pypi_version:
description: 'Exact PyPI unsloth version just published/stamped (for example, 2026.5.3); leave blank to use MIN_DESKTOP_BACKEND_VERSION'
type: string
required: false
draft:
description: 'Create as draft release; draft runs do not advance desktop-latest updater channel'
type: boolean
default: true
permissions:
contents: read
concurrency:
group: release-desktop-${{ github.repository }}
cancel-in-progress: false
jobs:
prepare-version:
name: Prepare release versions
runs-on: ubuntu-latest
outputs:
studio_version: ${{ steps.prepare.outputs.studio_version }}
app_version: ${{ steps.prepare.outputs.app_version }}
desktop_release_tag: ${{ steps.prepare.outputs.desktop_release_tag }}
prerelease: ${{ steps.prepare.outputs.prerelease }}
pypi_version: ${{ steps.prepare.outputs.pypi_version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
persist-credentials: false
- name: Validate release versions
id: prepare
shell: bash
env:
INPUT_STUDIO_VERSION: ${{ inputs.studio_version }}
INPUT_PYPI_VERSION: ${{ inputs.pypi_version }}
run: |
python3 <<'PY'
import os
import pathlib
import re
import sys
studio_version = os.environ['INPUT_STUDIO_VERSION'].strip()
if not studio_version:
sys.exit('studio_version is required, for example v0.1.39-beta')
if re.fullmatch(r'v?20\d{2}\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?', studio_version):
sys.exit(f'studio_version must be a Studio SemVer tag, not a date-style backend version: {studio_version}')
semver_tag = re.compile(
r'^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
r'(?:-[0-9A-Za-z.][0-9A-Za-z.-]*)?$'
)
if not semver_tag.fullmatch(studio_version):
sys.exit(f'studio_version must be a SemVer tag with leading v, for example v0.1.39-beta: {studio_version}')
app_version = studio_version.removeprefix('v')
desktop_release_tag = f'desktop-v{app_version}'
prerelease = 'true' if '-' in app_version.split('+', 1)[0] else 'false'
def parse_backend_version(version):
match = re.fullmatch(
r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
r'(?:([a-zA-Z]|\.dev|dev|\.rc|rc|\.post|post)(\d*))?'
r'(?:[-+]([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?',
version,
)
if not match:
return None
major, minor, patch, suffix_name, suffix_number, suffix_text = match.groups()
if suffix_name:
normalized = suffix_name.lower().lstrip('.')
order = {'dev': 0, 'a': 1, 'b': 2, 'rc': 3, 'post': 5}.get(normalized)
if order is None:
return None
number = int(suffix_number or '0')
elif suffix_text:
order = 3 if version[version.find(suffix_text) - 1] == '-' else 4
number = 0
else:
order = 4
number = 0
return (int(major), int(minor), int(patch), order, number)
preflight = pathlib.Path('studio/src-tauri/src/preflight/version.rs').read_text()
match = re.search(r'MIN_DESKTOP_BACKEND_VERSION:\s*&str\s*=\s*"([^"]+)"', preflight)
if not match:
sys.exit('Could not read MIN_DESKTOP_BACKEND_VERSION')
min_backend_version = match.group(1)
input_pypi_version = os.environ.get('INPUT_PYPI_VERSION', '').strip()
parsed_min_backend = parse_backend_version(min_backend_version)
if parsed_min_backend is None:
sys.exit(f'MIN_DESKTOP_BACKEND_VERSION is not a supported backend package version: {min_backend_version}')
pypi_version = input_pypi_version or min_backend_version
parsed_pypi = parse_backend_version(pypi_version)
if parsed_pypi is None:
sys.exit(f'pypi_version is not a supported backend package version: {pypi_version}')
if parsed_pypi < parsed_min_backend:
sys.exit(
f'pypi_version {pypi_version} is lower than desktop minimum '
f'MIN_DESKTOP_BACKEND_VERSION {min_backend_version}'
)
if input_pypi_version:
print(
'Using exact PyPI unsloth version from pypi_version input: '
f'{pypi_version} (desktop minimum: {min_backend_version})'
)
else:
print(
'Using exact PyPI unsloth version from MIN_DESKTOP_BACKEND_VERSION: '
f'{pypi_version}'
)
with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as output:
print(f'studio_version={studio_version}', file=output)
print(f'app_version={app_version}', file=output)
print(f'desktop_release_tag={desktop_release_tag}', file=output)
print(f'prerelease={prerelease}', file=output)
print(f'pypi_version={pypi_version}', file=output)
PY
- name: Verify PyPI package and Studio stamp
shell: bash
env:
STUDIO_VERSION: ${{ steps.prepare.outputs.studio_version }}
PYPI_VERSION: ${{ steps.prepare.outputs.pypi_version }}
run: |
set -euo pipefail
python3 <<'PY'
import json
import os
import pathlib
import sys
import time
import urllib.error
import urllib.request
pypi_version = os.environ['PYPI_VERSION']
dist_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'pypi-unsloth-dist')
dist_dir.mkdir(parents=True, exist_ok=True)
metadata_url = f'https://pypi.org/pypi/unsloth/{pypi_version}/json'
last_error = None
for attempt in range(1, 6):
try:
with urllib.request.urlopen(metadata_url, timeout=30) as response:
metadata = json.load(response)
break
except Exception as exc:
last_error = exc
if attempt < 5:
time.sleep(10 * attempt)
else:
sys.exit(f'Publish unsloth=={pypi_version} to PyPI before the desktop release ({last_error})')
files = metadata.get('urls') or []
if not files:
sys.exit(f'PyPI returned no distribution files for unsloth=={pypi_version}')
for file_info in files:
filename = file_info.get('filename')
url = file_info.get('url')
if not filename or '/' in filename or not url:
sys.exit(f'Unexpected PyPI file entry for unsloth=={pypi_version}: {file_info!r}')
target = dist_dir / filename
for attempt in range(1, 4):
try:
with urllib.request.urlopen(url, timeout=60) as response:
target.write_bytes(response.read())
break
except Exception as exc:
last_error = exc
if attempt < 3:
time.sleep(5 * attempt)
else:
sys.exit(f'Could not download {filename} from PyPI ({last_error})')
PY
if [ -f scripts/stamp_studio_release.py ]; then
mapfile -t dists < <(find "$RUNNER_TEMP/pypi-unsloth-dist" -type f \( -name '*.whl' -o -name '*.tar.gz' \) | sort)
if [ "${#dists[@]}" -eq 0 ]; then
echo "No PyPI wheel/sdist artifacts downloaded for unsloth==$PYPI_VERSION" >&2
exit 1
fi
python3 scripts/stamp_studio_release.py --verify-dist "$RUNNER_TEMP/pypi-unsloth-dist" --expected "$STUDIO_VERSION"
else
echo "scripts/stamp_studio_release.py not found; release-desktop requires #5308 to verify the PyPI Studio stamp." >&2
exit 1
fi
- name: Guard public updater channel version
if: ${{ !inputs.draft }}
shell: bash
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
APP_VERSION: ${{ steps.prepare.outputs.app_version }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/desktop-current"
if ! gh release download desktop-latest --pattern latest.json --dir "$RUNNER_TEMP/desktop-current" --clobber 2>/dev/null; then
echo "No existing desktop-latest latest.json found; allowing first channel publish."
exit 0
fi
python3 <<'PY'
import json
import os
import pathlib
import re
import sys
def parse(value: str):
value = value.removeprefix('v')
match = re.fullmatch(
r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
r'(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?'
r'(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?',
value,
)
if not match:
sys.exit(f'desktop-latest latest.json has invalid version: {value}')
major, minor, patch, prerelease = match.groups()
return (int(major), int(minor), int(patch), prerelease)
def numeric_tail(identifier: str) -> tuple[str, int] | None:
match = re.fullmatch(r'([A-Za-z-]+)(\d+)', identifier)
if not match:
return None
return (match.group(1).lower(), int(match.group(2)))
def compare_identifier(left: str, right: str) -> int:
left_num = left.isdigit()
right_num = right.isdigit()
if left_num and right_num:
return (int(left) > int(right)) - (int(left) < int(right))
if left_num:
return -1
if right_num:
return 1
left_tail = numeric_tail(left)
right_tail = numeric_tail(right)
if left_tail and right_tail and left_tail[0] == right_tail[0]:
return (left_tail[1] > right_tail[1]) - (left_tail[1] < right_tail[1])
return (left > right) - (left < right)
def compare_prerelease(left: str | None, right: str | None) -> int:
if left == right:
return 0
if left is None:
return 1
if right is None:
return -1
left_parts = left.split('.')
right_parts = right.split('.')
for left_part, right_part in zip(left_parts, right_parts):
order = compare_identifier(left_part, right_part)
if order:
return order
return (len(left_parts) > len(right_parts)) - (len(left_parts) < len(right_parts))
def compare(left: str, right: str) -> int:
left_major, left_minor, left_patch, left_pre = parse(left)
right_major, right_minor, right_patch, right_pre = parse(right)
left_core = (left_major, left_minor, left_patch)
right_core = (right_major, right_minor, right_patch)
if left_core != right_core:
return (left_core > right_core) - (left_core < right_core)
return compare_prerelease(left_pre, right_pre)
current_path = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-current', 'latest.json')
current = json.loads(current_path.read_text()).get('version')
next_version = os.environ['APP_VERSION']
if not isinstance(current, str):
sys.exit('desktop-latest latest.json has missing version')
if compare(next_version, current) < 0:
sys.exit(
f'Refusing to publish {next_version}; desktop-latest currently points at newer version {current}.'
)
PY
build:
# TODO: split into a "build (no secrets)" + "publish (secrets)" job pair
# with actions/upload-artifact handoff so the matrix build cannot
# publish a Release on its own. The current matrix runs across
# Linux/macOS/Windows in a single job, so the split needs artefact
# collection across the OS matrix and is out of scope for this
# hardening pass.
permissions:
contents: write # tauri-apps/tauri-action creates / uploads a GitHub Release
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- platform: macos-latest
args: '--target aarch64-apple-darwin'
label: macOS (Apple Silicon)
# - platform: macos-latest
# args: '--target x86_64-apple-darwin'
# label: macOS (Intel)
- platform: ubuntu-22.04
args: ''
label: Linux (x64)
- platform: windows-latest
args: ''
label: Windows (x64)
name: Build ${{ matrix.label }}
needs: prepare-version
runs-on: ${{ matrix.platform }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }}
DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }}
DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }}
steps:
# harden-runner in audit mode: surfaces every egress destination in
# the runner log so the allowlist for a future `egress-policy: block`
# promotion can be derived from observed traffic. Audit mode is
# cross-platform (Linux / macOS / Windows runners); blocking mode is
# currently Linux-only, so we deliberately stay in audit until the
# macOS + Windows codesign paths have been observed.
- name: Harden runner (audit)
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
persist-credentials: false
# ── Linux dependencies ──
- name: Install Linux dependencies
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
# ── Node.js ──
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: 24
- name: Install pinned Tauri CLI
# Lifecycle scripts (esbuild native-binary postinstall, etc.) are
# required for `vite build`. The pre-install lockfile structural
# audit (lockfile_supply_chain_audit.py) is the practical defence
# against the npm postinstall-dropper class -- it fires BEFORE any
# tarball runs, on the injection pattern itself rather than an
# advisory-DB lookup.
run: npm install --save-dev --prefix studio @tauri-apps/cli@2.10.1 --no-fund --no-audit
- name: Verify pinned Tauri CLI
shell: bash
run: |
out="$(npx --prefix studio tauri --version)"
echo "$out"
if [ "$out" != "tauri-cli 2.10.1" ]; then
echo "Expected tauri-cli 2.10.1, got $out" >&2
exit 1
fi
- name: Verify desktop updater and Linux package config
shell: bash
run: |
node <<'JS'
const { readFileSync } = require('node:fs');
const expected = 'https://github.com/unslothai/unsloth/releases/download/desktop-latest/latest.json';
const config = JSON.parse(readFileSync('studio/src-tauri/tauri.conf.json', 'utf8'));
const endpoints = config.plugins?.updater?.endpoints;
if (!Array.isArray(endpoints) || endpoints.length !== 1) {
throw new Error('Expected exactly one desktop updater endpoint');
}
if (endpoints[0] !== expected) {
throw new Error('Desktop updater endpoint must be ' + expected + ', got ' + endpoints[0]);
}
if (endpoints.some((endpoint) => endpoint.includes('/releases/latest/'))) {
throw new Error('Desktop updater endpoint must not use repo-wide /releases/latest/');
}
const targets = config.bundle?.targets;
if (Array.isArray(targets) && targets.some((target) => String(target).toLowerCase() === 'rpm')) {
throw new Error('Desktop release must not target RPM packages');
}
if (config.bundle?.linux?.rpm) {
throw new Error('bundle.linux.rpm must not be configured');
}
if (config.bundle?.linux?.appimage?.bundleMediaFramework !== false) {
throw new Error('Linux AppImage bundleMediaFramework must stay false');
}
const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8');
const lines = workflow.split(/\r?\n/);
const linuxInstallLines = lines.filter((line) => line.includes('sudo apt-get install'));
const ayatanaPackage = ['libayatana', 'appindicator3-dev'].join('-');
if (linuxInstallLines.some((line) => line.includes(ayatanaPackage))) {
throw new Error('Desktop Linux release must not install the Ayatana appindicator dev package');
}
if (!linuxInstallLines.some((line) => line.includes('libappindicator3-dev'))) {
throw new Error('Desktop Linux release must install libappindicator3-dev');
}
const linuxdeployLines = lines.filter((line) => line.includes('github.com/linuxdeploy/linuxdeploy/releases/download'));
if (!linuxdeployLines.some((line) => line.includes('1-alpha-20250213-2/linuxdeploy-x86_64.AppImage'))) {
throw new Error('Desktop Linux release must pin linuxdeploy 1-alpha-20250213-2');
}
// A pinned version/path is reproducibility, not integrity: the asset
// can be replaced after upload. Require the immutable SHA-256 digest
// to be pinned AND verified before chmod +x. Scope every check to the
// real "Pin linuxdeploy for AppImage" step so this guard cannot
// satisfy itself; a file-wide scan would match the guard's own code.
const expectedLinuxdeployDigest = '4648f278ab3ef31f819e67c30d50f462640e5365a77637d7e6f2ad9fd0b4522a';
const isComment = (line) => {
const trimmed = line.trim();
return trimmed.startsWith('#') || trimmed.startsWith('//');
};
const stepStart = lines.findIndex((line) => /^\s*- name: Pin linuxdeploy for AppImage\s*$/.test(line));
if (stepStart === -1) {
throw new Error('Desktop Linux release must keep the "Pin linuxdeploy for AppImage" step');
}
const stepIndent = lines[stepStart].search(/\S/);
let stepEnd = lines.length;
for (let i = stepStart + 1; i < lines.length; i += 1) {
const line = lines[i];
if (line.trim() === '') continue;
const indent = line.search(/\S/);
// The next sibling step ('- ...') at the same indent, or any dedent
// below the step, ends this step's block.
if (indent < stepIndent || (indent === stepIndent && /^\s*-\s/.test(line))) {
stepEnd = i;
break;
}
}
const stepLines = lines.slice(stepStart, stepEnd);
const digestEnvRe = /^\s*LINUXDEPLOY_SHA256:\s*["']([0-9a-f]{64})["']\s*$/;
const digestEnvLine = stepLines.find((line) => digestEnvRe.test(line));
if (!digestEnvLine || digestEnvLine.match(digestEnvRe)[1] !== expectedLinuxdeployDigest) {
throw new Error('Desktop Linux release must pin the linuxdeploy SHA-256 digest in the LINUXDEPLOY_SHA256 env');
}
const sha256Idx = stepLines.findIndex((line) => !isComment(line) && line.includes('sha256sum -c'));
if (sha256Idx === -1) {
throw new Error('Desktop Linux release must verify the linuxdeploy digest with sha256sum -c before use');
}
const chmodIdx = stepLines.findIndex((line) => !isComment(line) && /chmod\s+\+x/.test(line));
if (chmodIdx !== -1 && sha256Idx > chmodIdx) {
throw new Error('Desktop Linux release must verify the linuxdeploy digest before chmod +x');
}
const releaseBodies = [];
for (let i = 0; i < lines.length; i += 1) {
const match = lines[i].match(/^(\s*)releaseBody:\s*\|\s*$/);
if (!match) continue;
const baseIndent = match[1].length;
const bodyLines = [];
i += 1;
for (; i < lines.length; i += 1) {
const line = lines[i];
if (line.trim() === '') {
bodyLines.push('');
continue;
}
const indent = line.match(/^\s*/)[0].length;
if (indent <= baseIndent) {
i -= 1;
break;
}
bodyLines.push(line.slice(baseIndent + 2));
}
releaseBodies.push(bodyLines.join('\n'));
}
if (releaseBodies.length === 0) {
throw new Error('Expected at least one desktop release body');
}
for (const body of releaseBodies) {
if (/\brpm\b|\.rpm/i.test(body)) {
throw new Error('Desktop release body must not advertise RPM packages');
}
if (/AppImage.*universal|universal.*AppImage/i.test(body)) {
throw new Error('Desktop release body must not advertise AppImage as universal');
}
if (!/AppImage.*experimental/i.test(body)) {
throw new Error('Desktop release body must mark AppImage as experimental');
}
}
JS
- name: Install frontend dependencies
working-directory: studio/frontend
# Lifecycle scripts (esbuild native-binary postinstall, etc.) are
# required for `vite build`. The pre-install lockfile structural
# audit (lockfile_supply_chain_audit.py) is the practical defence
# against the npm postinstall-dropper class -- it fires BEFORE any
# tarball runs, on the injection pattern itself rather than an
# advisory-DB lookup.
run: npm install --no-fund --no-audit
# ── Rust ──
- name: Install Rust stable
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @ 2026-03-27
with:
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Patch desktop app version
shell: bash
working-directory: studio/src-tauri
run: |
set -euo pipefail
if command -v python3 >/dev/null 2>&1; then
PYTHON=python3
else
PYTHON=python
fi
"$PYTHON" <<'PY'
import os
import pathlib
import re
import sys
app_version = os.environ['APP_VERSION']
if not app_version:
sys.exit('APP_VERSION is required')
cargo_toml = pathlib.Path('Cargo.toml')
lines = cargo_toml.read_text().splitlines(keepends=True)
in_package = False
patched = False
for index, line in enumerate(lines):
stripped = line.strip()
if stripped == '[package]':
in_package = True
continue
if stripped.startswith('[') and stripped.endswith(']'):
in_package = False
if in_package and re.fullmatch(r'version\s*=\s*"[^"]+"\s*', stripped):
lines[index] = f'version = "{app_version}"\n'
patched = True
break
if not patched:
sys.exit('Could not patch [package] version in Cargo.toml')
cargo_toml.write_text(''.join(lines))
cargo_lock = pathlib.Path('Cargo.lock')
lock_text = cargo_lock.read_text()
lock_text, count = re.subn(
r'(?m)(^\[\[package\]\]\nname = "unsloth-studio"\nversion = ")[^"]+(")',
lambda match: f'{match.group(1)}{app_version}{match.group(2)}',
lock_text,
)
if count != 1:
sys.exit(f'Could not patch unsloth-studio version in Cargo.lock (matches={count})')
cargo_lock.write_text(lock_text)
PY
cargo metadata --locked --no-deps --format-version 1 > "$RUNNER_TEMP/cargo-metadata.json"
"$PYTHON" <<'PY'
import json
import os
import pathlib
import sys
app_version = os.environ['APP_VERSION']
metadata = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'cargo-metadata.json').read_text())
versions = [package['version'] for package in metadata.get('packages', []) if package.get('name') == 'unsloth-studio']
if versions != [app_version]:
sys.exit(f'cargo metadata unsloth-studio version mismatch: expected {app_version}, got {versions}')
PY
git diff -- Cargo.toml Cargo.lock
- name: Rust cache
uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
with:
workspaces: 'studio/src-tauri -> target'
# ── macOS: import signing certificate ──
- name: Import Apple certificate
if: matrix.platform == 'macos-latest'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
security find-identity -v -p codesigning build.keychain
rm -f certificate.p12
# ── Windows: install Azure Trusted Signing CLI ──
- name: Install trusted-signing-cli
if: matrix.platform == 'windows-latest'
run: |
cargo install trusted-signing-cli --version 0.10.0 --locked
echo "$env:USERPROFILE\.cargo\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
# ── Windows: verify signing CLI is accessible ──
- name: Verify trusted-signing-cli
if: matrix.platform == 'windows-latest'
run: |
Write-Output "PATH: $env:PATH"
Get-Command trusted-signing-cli -ErrorAction SilentlyContinue || Write-Output "trusted-signing-cli NOT in PATH"
trusted-signing-cli --version || Write-Output "trusted-signing-cli failed to run"
# ── Linux: pin AppImage packaging toolchain ──
- name: Pin linuxdeploy for AppImage
if: matrix.platform == 'ubuntu-22.04'
shell: bash
env:
# Pinning the versioned release path is reproducibility, not
# integrity: a GitHub release asset can be replaced (or its delivery
# path compromised) after upload. The SHA-256 below is the immutable
# digest of this exact asset and is the integrity gate. If linuxdeploy
# publishes a new build under this tag, this run fails closed and the
# digest must be re-pinned deliberately.
LINUXDEPLOY_URL: "https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20250213-2/linuxdeploy-x86_64.AppImage"
LINUXDEPLOY_SHA256: "4648f278ab3ef31f819e67c30d50f462640e5365a77637d7e6f2ad9fd0b4522a"
run: |
set -euo pipefail
tools_dir="$RUNNER_TEMP/tauri-tools-cache/tauri"
mkdir -p "$tools_dir"
dest="$tools_dir/linuxdeploy-x86_64.AppImage"
curl -fsSL "$LINUXDEPLOY_URL" -o "$dest"
# Verify the digest BEFORE the binary is ever marked executable. The
# next step builds the AppImage with the Tauri signing key and a
# contents:write GITHUB_TOKEN in scope, so a substituted linuxdeploy
# that ran here could exfiltrate signing material or tamper with
# published release artifacts. Fail closed on any mismatch.
echo "${LINUXDEPLOY_SHA256} ${dest}" | sha256sum -c -
chmod +x "$dest"
# ── Linux: build + sign + upload ──
- name: Build Linux app
if: matrix.platform == 'ubuntu-22.04'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
XDG_CACHE_HOME: ${{ runner.temp }}/tauri-tools-cache
with:
projectPath: studio
tauriScript: npx --prefix . tauri
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
releaseBody: |
Desktop app for Unsloth Studio.
**macOS**: Download the Apple Silicon `.dmg`.
**Windows**: Download the `-setup.exe` installer.
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
releaseDraft: ${{ inputs.draft }}
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
args: -v ${{ matrix.args }}
# ── macOS: build + sign + notarize + upload ──
- name: Build macOS app
if: matrix.platform == 'macos-latest'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
projectPath: studio
tauriScript: npx --prefix . tauri
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
releaseBody: |
Desktop app for Unsloth Studio.
**macOS**: Download the Apple Silicon `.dmg`.
**Windows**: Download the `-setup.exe` installer.
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
releaseDraft: ${{ inputs.draft }}
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
args: -v ${{ matrix.args }}
# ── Windows: build + sign + upload ──
- name: Build Windows app
if: matrix.platform == 'windows-latest'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_CERTIFICATE_PROFILE_NAME }}
with:
projectPath: studio
tauriScript: npx --prefix . tauri
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
releaseBody: |
Desktop app for Unsloth Studio.
**macOS**: Download the Apple Silicon `.dmg`.
**Windows**: Download the `-setup.exe` installer.
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
releaseDraft: ${{ inputs.draft }}
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
args: -v ${{ matrix.args }}
# Release process note: only non-draft workflow runs advance the public
# desktop-latest updater channel. Draft builds are for private review; if a
# draft is manually published later, this channel intentionally remains
# unchanged until a narrow manual channel-publish flow is added or a public
# desktop release is created by running this workflow with draft=false.
publish-updater-channel:
name: Publish desktop updater channel
needs: [prepare-version, build]
if: ${{ !inputs.draft }}
runs-on: ubuntu-latest
permissions:
contents: write
env:
GH_REPO: ${{ github.repository }}
APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }}
DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }}
DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }}
steps:
- name: Download versioned updater metadata
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/desktop-updater"
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${DESKTOP_RELEASE_TAG}" > "$RUNNER_TEMP/source-release.json"
python3 <<'PY'
import json
import os
import pathlib
import sys
source = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'source-release.json').read_text())
expected_tag = os.environ['DESKTOP_RELEASE_TAG']
if source.get('tag_name') != expected_tag:
sys.exit(f'Expected source release {expected_tag}, got {source.get("tag_name")}')
if source.get('draft'):
sys.exit(f'Source desktop release {expected_tag} is draft; refusing to publish public updater channel')
PY
gh release download "$DESKTOP_RELEASE_TAG" --pattern latest.json --dir "$RUNNER_TEMP/desktop-updater" --clobber
test -s "$RUNNER_TEMP/desktop-updater/latest.json"
- name: Validate versioned updater metadata
shell: bash
run: |
python3 <<'PY'
import json
import os
import pathlib
import re
import sys
app_version = os.environ['APP_VERSION']
release_tag = os.environ['DESKTOP_RELEASE_TAG']
latest_path = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-updater', 'latest.json')
data = json.loads(latest_path.read_text())
if not isinstance(data, dict):
sys.exit('latest.json must be a JSON object')
version = data.get('version')
if not isinstance(version, str) or not version:
sys.exit('latest.json missing version')
if not re.fullmatch(r'v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?', version):
sys.exit(f'latest.json version is not SemVer-like: {version}')
if version.removeprefix('v') != app_version:
sys.exit(f'latest.json version {version} does not match desktop app version {app_version}')
platforms = data.get('platforms')
if not isinstance(platforms, dict) or not platforms:
sys.exit('latest.json missing platforms')
required_families = {
'darwin-aarch64': False,
'linux-x86_64': False,
'windows-x86_64': False,
}
expected_prefix = f'https://github.com/unslothai/unsloth/releases/download/{release_tag}/'
forbidden_fragments = ('/releases/latest/', '/releases/download/desktop-latest/')
for platform, entry in platforms.items():
if not isinstance(entry, dict):
sys.exit(f'Platform {platform} must be an object')
url = entry.get('url')
signature = entry.get('signature')
if not isinstance(url, str) or not url.strip():
sys.exit(f'Platform {platform} missing url')
if not isinstance(signature, str) or not signature.strip():
sys.exit(f'Platform {platform} missing signature')
if any(fragment in url for fragment in forbidden_fragments):
sys.exit(f'Platform {platform} points at a moving updater channel: {url}')
if not url.startswith(expected_prefix):
sys.exit(f'Platform {platform} URL must point at {release_tag}: {url}')
for family in required_families:
if platform == family or platform.startswith(family + '-'):
required_families[family] = True
missing = [family for family, found in required_families.items() if not found]
if missing:
sys.exit('latest.json missing required platform families: ' + ', '.join(missing))
PY
- name: Ensure desktop updater channel release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
channel_json="$RUNNER_TEMP/desktop-latest-release.json"
if ! gh api "repos/${GITHUB_REPOSITORY}/releases/tags/desktop-latest" > "$channel_json" 2>/dev/null; then
gh release create desktop-latest \
--title "Unsloth Studio Desktop updater channel" \
--notes "Machine-managed desktop updater channel; latest.json is replaced by release-desktop.yml." \
--prerelease \
--latest=false \
--target "$GITHUB_SHA"
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/desktop-latest" > "$channel_json"
fi
python3 <<'PY'
import json
import os
import pathlib
import sys
channel = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-latest-release.json').read_text())
if channel.get('draft'):
sys.exit('desktop-latest release is draft; refusing to publish updater channel')
if channel.get('immutable'):
sys.exit('desktop-latest release is immutable; cannot replace latest.json')
if not channel.get('prerelease'):
sys.exit('desktop-latest release must be a prerelease so it cannot compete with repo-wide latest')
PY
- name: Prevent updater channel downgrade
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/desktop-current"
if ! gh release download desktop-latest --pattern latest.json --dir "$RUNNER_TEMP/desktop-current" --clobber 2>/dev/null; then
echo "No existing desktop-latest latest.json found; allowing first channel publish."
exit 0
fi
python3 <<'PY'
import json
import os
import pathlib
import re
import sys
def parse(value: str):
value = value.removeprefix('v')
match = re.fullmatch(
r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
r'(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?'
r'(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?',
value,
)
if not match:
sys.exit(f'desktop-latest latest.json has invalid version: {value}')
major, minor, patch, prerelease = match.groups()
return (int(major), int(minor), int(patch), prerelease)
def numeric_tail(identifier: str) -> tuple[str, int] | None:
match = re.fullmatch(r'([A-Za-z-]+)(\d+)', identifier)
if not match:
return None
return (match.group(1).lower(), int(match.group(2)))
def compare_identifier(left: str, right: str) -> int:
left_num = left.isdigit()
right_num = right.isdigit()
if left_num and right_num:
return (int(left) > int(right)) - (int(left) < int(right))
if left_num:
return -1
if right_num:
return 1
left_tail = numeric_tail(left)
right_tail = numeric_tail(right)
if left_tail and right_tail and left_tail[0] == right_tail[0]:
return (left_tail[1] > right_tail[1]) - (left_tail[1] < right_tail[1])
return (left > right) - (left < right)
def compare_prerelease(left: str | None, right: str | None) -> int:
if left == right:
return 0
if left is None:
return 1
if right is None:
return -1
left_parts = left.split('.')
right_parts = right.split('.')
for left_part, right_part in zip(left_parts, right_parts):
order = compare_identifier(left_part, right_part)
if order:
return order
return (len(left_parts) > len(right_parts)) - (len(left_parts) < len(right_parts))
def compare(left: str, right: str) -> int:
left_major, left_minor, left_patch, left_pre = parse(left)
right_major, right_minor, right_patch, right_pre = parse(right)
left_core = (left_major, left_minor, left_patch)
right_core = (right_major, right_minor, right_patch)
if left_core != right_core:
return (left_core > right_core) - (left_core < right_core)
return compare_prerelease(left_pre, right_pre)
current_path = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-current', 'latest.json')
next_path = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-updater', 'latest.json')
current = json.loads(current_path.read_text()).get('version')
next_version = json.loads(next_path.read_text()).get('version')
if not isinstance(current, str) or not isinstance(next_version, str):
sys.exit('Could not compare desktop-latest channel versions')
if compare(next_version, current) < 0:
sys.exit(
f'Refusing to move desktop-latest from {current} to older version {next_version}.'
)
PY
- name: Publish desktop updater channel metadata
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh release upload desktop-latest "$RUNNER_TEMP/desktop-updater/latest.json" --clobber
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/desktop-latest" > "$RUNNER_TEMP/desktop-latest-release.json"
python3 <<'PY'
import json
import os
import pathlib
import sys
channel = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-latest-release.json').read_text())
assets = [asset for asset in channel.get('assets', []) if asset.get('name') == 'latest.json']
if len(assets) != 1:
sys.exit(f'Expected exactly one desktop-latest latest.json asset, found {len(assets)}')
expected_url = f'https://github.com/{os.environ["GITHUB_REPOSITORY"]}/releases/download/desktop-latest/latest.json'
actual_url = assets[0].get('browser_download_url')
if actual_url != expected_url:
sys.exit(f'desktop-latest latest.json URL mismatch: expected {expected_url}, got {actual_url}')
PY