* Replace standalone Studio wording with Unsloth Replace the single word Studio with Unsloth wherever it is used as shorthand for Unsloth Studio in docs, CLI output, UI strings, i18n locales, workflow display names, comments and docstrings. Kept unchanged: the full name Unsloth Studio, third party product names (LM Studio, Visual Studio, Mac Studio), feature names (Recipe Studio, Fine-tuning Studio and its translations), and all identifiers such as env vars, commands, paths and filenames. * Address review feedback on the Studio wording rename Use "an" before Unsloth where the rename left the article as "a". Restore the split brand where Unsloth and Studio render as two halves of the full product name: the onboarding sidebar subtitle and the IPv6 localhost warning. Scope two messages to the full name Unsloth Studio where plain Unsloth was misleading: the AMD README bullet and the CLI studio setup error.
1162 lines
51 KiB
YAML
1162 lines
51 KiB
YAML
name: Release Desktop App
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
studio_version:
|
|
description: 'Unsloth 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
|
|
|
|
env:
|
|
DESKTOP_RELEASE_NOTES: |
|
|
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.
|
|
|
|
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 an Unsloth 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 Unsloth 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 Unsloth 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:
|
|
strategy:
|
|
fail-fast: false
|
|
max-parallel: 1
|
|
matrix:
|
|
include:
|
|
- platform: macos-latest
|
|
args: '--target aarch64-apple-darwin'
|
|
label: macOS (Apple Silicon)
|
|
artifact: macos-aarch64
|
|
release_arch: aarch64
|
|
# - platform: macos-latest
|
|
# args: '--target x86_64-apple-darwin'
|
|
# label: macOS (Intel)
|
|
- platform: ubuntu-22.04
|
|
args: ''
|
|
label: Linux (x64)
|
|
artifact: linux-x64
|
|
release_arch: x64
|
|
- platform: windows-latest
|
|
args: ''
|
|
label: Windows (x64)
|
|
artifact: windows-x64
|
|
release_arch: 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 releaseBody = process.env.DESKTOP_RELEASE_NOTES;
|
|
if (!releaseBody) {
|
|
throw new Error('DESKTOP_RELEASE_NOTES must not be empty');
|
|
}
|
|
if (/\brpm\b|\.rpm/i.test(releaseBody)) {
|
|
throw new Error('Desktop release body must not advertise RPM packages');
|
|
}
|
|
if (/AppImage.*universal|universal.*AppImage/i.test(releaseBody)) {
|
|
throw new Error('Desktop release body must not advertise AppImage as universal');
|
|
}
|
|
if (!/AppImage.*experimental/i.test(releaseBody)) {
|
|
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, so a
|
|
# substituted linuxdeploy that ran here could exfiltrate signing
|
|
# material or tamper with release artifacts. Fail closed on any
|
|
# mismatch.
|
|
echo "${LINUXDEPLOY_SHA256} ${dest}" | sha256sum -c -
|
|
chmod +x "$dest"
|
|
|
|
# ── Linux: build + sign ──
|
|
- name: Build Linux app
|
|
id: build_linux
|
|
if: matrix.platform == 'ubuntu-22.04'
|
|
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
|
|
env:
|
|
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
|
|
args: -v ${{ matrix.args }}
|
|
|
|
# ── macOS: build + sign + notarize ──
|
|
- name: Build macOS app
|
|
id: build_macos
|
|
if: matrix.platform == 'macos-latest'
|
|
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
|
|
env:
|
|
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
|
|
args: -v ${{ matrix.args }}
|
|
|
|
# ── Windows: build + sign ──
|
|
- name: Build Windows app
|
|
id: build_windows
|
|
if: matrix.platform == 'windows-latest'
|
|
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
|
|
env:
|
|
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
|
|
args: -v ${{ matrix.args }}
|
|
|
|
- name: Stage release assets
|
|
shell: bash
|
|
env:
|
|
ARTIFACT_PATHS: ${{ steps.build_linux.outputs.artifactPaths || steps.build_macos.outputs.artifactPaths || steps.build_windows.outputs.artifactPaths }}
|
|
RELEASE_ARCH: ${{ matrix.release_arch }}
|
|
run: |
|
|
set -euo pipefail
|
|
if command -v python3 >/dev/null 2>&1; then
|
|
PYTHON=python3
|
|
else
|
|
PYTHON=python
|
|
fi
|
|
"$PYTHON" <<'PY'
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import unicodedata
|
|
|
|
raw_paths = os.environ.get('ARTIFACT_PATHS', '')
|
|
try:
|
|
artifact_paths = json.loads(raw_paths)
|
|
except json.JSONDecodeError as error:
|
|
sys.exit(f'Invalid tauri-action artifactPaths output: {error}')
|
|
if not isinstance(artifact_paths, list) or not artifact_paths:
|
|
sys.exit('tauri-action did not return any release artifacts')
|
|
|
|
destination = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets')
|
|
destination.mkdir(parents=True, exist_ok=True)
|
|
staged = []
|
|
for raw_path in artifact_paths:
|
|
source = pathlib.Path(raw_path)
|
|
if not source.is_file():
|
|
continue
|
|
name = source.name
|
|
for extension in ('.app.tar.gz.sig', '.app.tar.gz'):
|
|
if name.endswith(extension):
|
|
name = f'{name[:-len(extension)]}_{os.environ["RELEASE_ARCH"]}{extension}'
|
|
break
|
|
name = unicodedata.normalize('NFD', name)
|
|
name = ''.join(character for character in name if not unicodedata.combining(character))
|
|
name = re.sub(r'[ ()\[\]{}]', '.', name)
|
|
while '..' in name:
|
|
name = name.replace('..', '.')
|
|
target = destination / name
|
|
if target.exists():
|
|
sys.exit(f'Duplicate staged release asset name: {name}')
|
|
shutil.copy2(source, target)
|
|
staged.append(name)
|
|
|
|
if not staged:
|
|
sys.exit('No release files were staged')
|
|
print('Staged release assets:')
|
|
print('\n'.join(sorted(staged)))
|
|
PY
|
|
|
|
- name: Upload signed release assets
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: desktop-release-${{ matrix.artifact }}
|
|
path: ${{ runner.temp }}/desktop-release-assets/*
|
|
if-no-files-found: error
|
|
compression-level: 0
|
|
retention-days: 1
|
|
|
|
# Only this job gets write access; builds hand off signed files via artifacts.
|
|
# Draft runs do not advance the public desktop-latest channel.
|
|
publish-release:
|
|
name: Publish desktop release
|
|
needs: [prepare-version, build]
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: write # create the versioned Release and replace updater-channel metadata
|
|
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: Harden runner (audit)
|
|
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
|
with:
|
|
egress-policy: audit
|
|
|
|
- name: Download signed release assets
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
pattern: desktop-release-*
|
|
path: ${{ runner.temp }}/desktop-release-assets
|
|
merge-multiple: true
|
|
|
|
- name: Validate release asset set
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
python3 <<'PY'
|
|
import pathlib
|
|
import os
|
|
import sys
|
|
|
|
asset_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets')
|
|
files = [path for path in asset_dir.iterdir() if path.is_file()]
|
|
required_suffixes = (
|
|
'.dmg',
|
|
'.app.tar.gz',
|
|
'.app.tar.gz.sig',
|
|
'.deb',
|
|
'.AppImage',
|
|
'.AppImage.sig',
|
|
'-setup.exe',
|
|
'-setup.exe.sig',
|
|
)
|
|
for suffix in required_suffixes:
|
|
matches = [path for path in files if path.name.endswith(suffix)]
|
|
if len(matches) != 1:
|
|
sys.exit(f'Expected exactly one {suffix} release asset, found {len(matches)}')
|
|
if any(path.name == 'latest.json' for path in files):
|
|
sys.exit('Build artifacts must not supply latest.json')
|
|
print('\n'.join(sorted(path.name for path in files)))
|
|
PY
|
|
|
|
- name: Create or validate versioned release
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
RELEASE_DRAFT: ${{ inputs.draft }}
|
|
run: |
|
|
set -euo pipefail
|
|
notes_file="$RUNNER_TEMP/desktop-release-notes.md"
|
|
printf '%s\n' "$DESKTOP_RELEASE_NOTES" > "$notes_file"
|
|
|
|
release_json="$RUNNER_TEMP/versioned-release.json"
|
|
# REST tag lookup omits drafts; `gh release view` also checks pending tags.
|
|
if gh release view "$DESKTOP_RELEASE_TAG" \
|
|
--json tagName,isDraft,isPrerelease > "$release_json" 2>/dev/null; then
|
|
python3 <<'PY'
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import sys
|
|
|
|
release = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'versioned-release.json').read_text())
|
|
expected_draft = os.environ['RELEASE_DRAFT'].lower() == 'true'
|
|
expected_prerelease = os.environ['DESKTOP_PRERELEASE'].lower() == 'true'
|
|
if release.get('tagName') != os.environ['DESKTOP_RELEASE_TAG']:
|
|
sys.exit('Existing desktop release tag does not match the requested tag')
|
|
if bool(release.get('isDraft')) != expected_draft:
|
|
sys.exit('Existing desktop release draft state does not match the workflow input')
|
|
if bool(release.get('isPrerelease')) != expected_prerelease:
|
|
sys.exit('Existing desktop release prerelease state does not match the requested version')
|
|
PY
|
|
else
|
|
release_flags=(
|
|
--title "Unsloth Studio (Desktop) ${STUDIO_VERSION}"
|
|
--notes-file "$notes_file"
|
|
--target "$GITHUB_SHA"
|
|
)
|
|
if [ "$RELEASE_DRAFT" = "true" ]; then
|
|
release_flags+=(--draft)
|
|
fi
|
|
if [ "$DESKTOP_PRERELEASE" = "true" ]; then
|
|
release_flags+=(--prerelease)
|
|
fi
|
|
gh release create "$DESKTOP_RELEASE_TAG" "${release_flags[@]}"
|
|
fi
|
|
|
|
- name: Publish versioned release assets
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -euo pipefail
|
|
gh release upload "$DESKTOP_RELEASE_TAG" "$RUNNER_TEMP/desktop-release-assets"/* --clobber
|
|
|
|
- name: Generate and publish versioned updater metadata
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -euo pipefail
|
|
python3 <<'PY'
|
|
import datetime
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import sys
|
|
import urllib.parse
|
|
|
|
asset_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets')
|
|
files = [path for path in asset_dir.iterdir() if path.is_file()]
|
|
|
|
def exactly_one(suffix: str) -> pathlib.Path:
|
|
matches = [path for path in files if path.name.endswith(suffix)]
|
|
if len(matches) != 1:
|
|
sys.exit(f'Expected exactly one {suffix} updater asset, found {len(matches)}')
|
|
return matches[0]
|
|
|
|
def entry(signature_suffix: str) -> dict[str, str]:
|
|
signature_path = exactly_one(signature_suffix)
|
|
bundle_name = signature_path.name.removesuffix('.sig')
|
|
bundle_path = asset_dir / bundle_name
|
|
if not bundle_path.is_file():
|
|
sys.exit(f'Missing updater bundle for {signature_path.name}: {bundle_name}')
|
|
encoded_tag = urllib.parse.quote(os.environ['DESKTOP_RELEASE_TAG'], safe='')
|
|
encoded_name = urllib.parse.quote(bundle_name, safe='')
|
|
return {
|
|
'signature': signature_path.read_text(),
|
|
'url': (
|
|
f'https://github.com/{os.environ["GITHUB_REPOSITORY"]}/releases/download/'
|
|
f'{encoded_tag}/{encoded_name}'
|
|
),
|
|
}
|
|
|
|
darwin = entry('.app.tar.gz.sig')
|
|
linux = entry('.AppImage.sig')
|
|
windows = entry('.exe.sig')
|
|
notes = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-notes.md').read_text()
|
|
metadata = {
|
|
'version': os.environ['APP_VERSION'],
|
|
'notes': notes,
|
|
'pub_date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
|
|
'platforms': {
|
|
'darwin-aarch64': darwin,
|
|
'darwin-aarch64-app': darwin,
|
|
'linux-x86_64': linux,
|
|
'linux-x86_64-appimage': linux,
|
|
'windows-x86_64': windows,
|
|
'windows-x86_64-nsis': windows,
|
|
},
|
|
}
|
|
output = pathlib.Path(os.environ['RUNNER_TEMP'], 'latest.json')
|
|
output.write_text(json.dumps(metadata, indent=2) + '\n')
|
|
PY
|
|
gh release upload "$DESKTOP_RELEASE_TAG" "$RUNNER_TEMP/latest.json" --clobber
|
|
|
|
- name: Download versioned updater metadata
|
|
if: ${{ !inputs.draft }}
|
|
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
|
|
if: ${{ !inputs.draft }}
|
|
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
|
|
if: ${{ !inputs.draft }}
|
|
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
|
|
if: ${{ !inputs.draft }}
|
|
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
|
|
if: ${{ !inputs.draft }}
|
|
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
|