* scripts/scan_*: add Mini Shai-Hulud May-12 IOC strings and pin-blocklists Append the May-12 2026 wave indicators (git-tanstack.com, transformers.pyz, /tmp/transformers.pyz, "With Love TeamPCP", "We've been online over 2 hours") to all three scanner IOC tables, add BLOCKED_NPM_VERSIONS (42 TanStack pkgs, 4 opensearch versions, 3 squawk pkgs) in scan_npm_packages.py and lockfile_supply_chain_audit.py (kept byte-identical), add BLOCKED_PYPI_VERSIONS (guardrails-ai 0.10.1, mistralai 2.4.6, lightning 2.6.2/2.6.3) plus RE_MAY12_IOC wiring across check_py_file/check_shell_file/check_workflow_file in scan_packages.py. The npm orchestrator and the lockfile auditor now short-circuit on a blocked entry before fetching the tarball, and the PyPI download pipeline drops blocked specs before pip download is invoked. * tests/security: regression suite for supply-chain scanners Adds offline fixture corpus and pytest coverage for scan_npm_packages, scan_packages, and lockfile_supply_chain_audit so future IOC-table drift surfaces at PR time. Pytest scope narrowed to tests/security so GPU smoke tests are not picked up by default. * ci(security-audit): drop continue-on-error on pip-scan and npm-scan jobs Promote three harden-runner blocks to egress-policy: block with per-job allowlists. Add tests-security job running pytest tests/security as a hard gate. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * scripts: harden third-party downloads, pip resolver pins, atomic writes Pins uv installer and mlx_vlm qwen3_5 patches by commit SHA + SHA-256 checksum, scrubs PIP_* env vars and forces --index-url + --only-binary on pip download, applies tarbomb caps to scan_packages archive walks, and converts non-atomic config writes (kwargs spacer, studio stamper, notebook validator, scan_packages req-file fixer) to mkstemp+os.replace. Also adds host allowlist to notebook_to_python downloader, threads an --allow-shell flag through its shell=True emission with reviewer warning comments, locks both MLX installer scripts to set -euo pipefail, and extends CODEOWNERS so colab snapshot data files require notebook-owner review. * ci(workflows): harden release-desktop / smoke / notebooks workflows Pin dtolnay/rust-toolchain to a 40-char SHA, scope release-desktop permissions to read at workflow level with job-level write only on the build job, append --ignore-scripts to every npm ci / npm install in studio-frontend-ci / wheel-smoke / studio-tauri-smoke / release-desktop, validate client_payload.ref shape via an env-var-isolated regex on every notebooks-ci job, and add step-security/harden-runner in audit mode as the first step of release-desktop and mlx-ci. * scripts: promote silent scanner failures to non-zero exit codes scan_packages now returns 2 on pip-download failure and emits a CRITICAL archive_corrupted finding on truncated wheels/sdists. notebook_to_python exits 1 on per-notebook failures; notebook_validator wraps the stash/pop in try/finally; lockfile audit rejects bare UNSLOTH_LOCKFILE_AUDIT_SKIP=1 with a loud GitHub Actions warning. * Add npm cooldown + new-install-script gate + Dependabot cooldown Pins min-release-age=7 (npm 11.10+) in repo-root and studio/frontend .npmrc, adds scripts/check_new_install_scripts.py to fail PRs that add a postinstall dep, ships a new security-audit job for npm audit signatures plus the diff, and extends .github/dependabot.yml with cooldown stanzas. Pin @tanstack/react-router to 1.169.9 per GHSA- g7cv-rxg3-hmpx; lockfile regen deferred until that release lands on npm. tests/security gains 4 new tests; full suite 26/26 green. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * ci(security): fix tanstack pin, exec bits, expand IOC tables to @uipath/@squawk full - Revert --ignore-scripts on Studio install workflows: vite build needs esbuild's native postinstall (per PR #5392 rationale). Keep --ignore-scripts on security-audit.yml's standalone npm audit job. - Pin @tanstack/react-router to the actual published 1.169.2 (was a forward-looking 1.169.9 that does not exist on npm; broke npm ci). - Drop redundant repo-root .npmrc; studio/frontend/.npmrc covers the only npm project today (root cooldown re-instate via dependabot.yml). - Restore exec bits on 7 files my filesystem stripped during cherry-pick. - Expand BLOCKED_NPM_VERSIONS with full safedep.io + Aikido enumeration: 22 @squawk/* packages with 5 versions each (110 entries; previously 3 entries with 1 version each), and 66 @uipath/* packages (entirely missing before). Mirror in scripts/lockfile_supply_chain_audit.py. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * tests/security: suppress CodeQL py/incomplete-url-substring-sanitization The two flagged 'X' in Y assertions are NOT URL sanitization checks. They verify our scanner WROTE a known IOC literal into its stdout / Finding.evidence, which is the opposite of an attack surface -- matching the scanner's output is precisely what catches the worm. Inline lgtm[] suppression with a 4-line rationale comment above each. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * scripts/scan_*: expand IOC tables with Aikido full 169-pkg enumeration Per Aikido 2026-05-12 disclosure (373 malicious package-version entries across 169 npm package names), add to BLOCKED_NPM_VERSIONS: - @mistralai/* npm scope (3 packages, 9 versions) -- separate from the PyPI mistralai package already in BLOCKED_PYPI_VERSIONS - @tallyui/* (10 packages, 30 entries) - @beproduct/nestjs-auth (18 versions 0.1.2..0.1.19) - @draftlab/* + @draftauth/* (5 packages) - @taskflow-corp/cli, @tolka/cli, @ml-toolkit-ts/*, @mesadev/*, @dirigible-ai/sdk, @supersurkhet/* - 10 unscoped packages (safe-action, ts-dna, cross-stitch, cmux-agent-mcp, agentwork-cli, git-branch-selector, wot-api, git-git-git, nextmove-mcp, ml-toolkit-ts) Also add to KNOWN_IOC_STRINGS / NPM_IOC_STRINGS: - router_init.js SHA-256 ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c - tanstack_runner.js SHA-256 2ec78d556d696e208927cc503d48e4b5eb56b31abc2870c2ed2e98d6be27fc96 - bun run tanstack_runner.js marker (the new Bun-prepare-script dropper invocation pattern unique to this wave) Total: 170 packages, 401 versions blocklisted. Studio lockfile still scans clean (0 findings, 0 hard errors). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * scripts/scan_*: web-verification additions (@tanstack/setup, intercom-client) Two findings from cross-checking BLOCKED_NPM_VERSIONS / KNOWN_IOC_STRINGS against GHSA-g7cv-rxg3-hmpx + Aikido + safedep.io + Socket + Semgrep. - Fix asymmetry: @tanstack/setup IOC string was in lockfile_supply_chain_audit.py's NPM_IOC_STRINGS but missing from scan_npm_packages.py's KNOWN_IOC_STRINGS. The literal is the malicious optional-dependency name used by the May-12 TanStack wave; no legitimate npm package of this name exists. - Add intercom-client@7.0.4: the npm counterpart of the lightning 2.6.2/2.6.3 PyPI compromise (Apr-30 wave). Same threat actor (TeamPCP). Confirmed by Semgrep, Aikido, OX Security, Resecurity, Kodem. Safe version is 7.0.3 and earlier. Total BLOCKED_NPM_VERSIONS: 171 packages / 402 versions. Both files remain byte-identical. Studio lockfile still scans clean. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * ci(security): add workflow-trigger lint refusing pull_request_target + cache-poisoning vectors The two patterns that together powered GHSA-g7cv-rxg3-hmpx (TanStack Mini Shai-Hulud) are now gated at PR time: 1. pull_request_target -- the worm chain started with a fork PR that ran in the base-repo context. Every workflow in this repo today uses 'pull_request' (safe); the lint refuses any new pull_request_target additions outright. workflow_run is restricted, allowed only with an explicit allow-comment. 2. Shared cache keys between PR-triggered workflows and the publish workflow (release-desktop.yml). The TanStack attack chain poisoned a shared Actions cache from a fork PR; the legitimate release workflow then restored the poisoned cache. The lint refuses any cache key that appears in both a PR-triggered workflow and a workflow_dispatch-only / publish workflow. Current tree is clean: 0 pull_request_target, 0 workflow_run, 0 PR-publish cache-key collisions across all 24 workflows. The lint locks that invariant in place. Files: + scripts/lint_workflow_triggers.py (~200 LOC, stdlib + PyYAML) + tests/security/test_lint_workflow_triggers.py (5 tests covering current-tree pass, pull_request_target reject, workflow_run restricted, justified workflow_run accept, cache-key collision reject) ~ .github/workflows/security-audit.yml: new workflow-trigger-lint job, no continue-on-error, harden-runner block-mode, PyYAML only runtime dep. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * security: fix tests-security CI job + CodeQL false-positives Two CI failures on the prior push: 1. pytest tests/security -- 5 lint regression tests failed because scripts/lint_workflow_triggers.py imports PyYAML which is not in the bare runner's Python env. Added pyyaml==6.0.2 to the pip install step alongside pytest. (29 scanner tests already passed.) 2. CodeQL py/incomplete-url-substring-sanitization fired on two test assertions that check the scanner WROTE the IOC literal to its own stdout/stderr. The rule pattern-matches on `"<host>" in <var>` and cannot distinguish a URL sanitizer from a regression-test evidence check. Previous `# lgtm[...]` inline suppressions were detached from the operator when pre-commit reformatted the assert across multiple lines. Rebuilt the IOC literals at runtime (`"git-tanstack." + "com"`) so no URL-shaped source literal appears on the `in` operator line; rule cannot trigger. Verified locally: `pytest tests/security -v` -> 34 passed in 2.70s. * security(studio): defensive .npmrc cooldown aliases + save-exact Two additions to studio/frontend/.npmrc to harden the existing `min-release-age=7` (Mini Shai-Hulud defence): 1. `minimum-release-age=10080` (minutes) -- defensive alias for the same 7-day floor. Some npm versions / wrappers consult one key but not the other; setting both prevents a single upstream setting-name parse change from silently disabling the cooldown. The two keys MUST agree (do not let them drift). 2. `save-exact=true` -- refuses to write back `^x.y.z` ranges into package.json when a maintainer runs `npm install <pkg>` locally. Does NOT rewrite already-present ranges; stops NEW carets from creeping into the manifest as patch-version footguns. Verified: pytest tests/security -> 34 passed in 2.63s. * chore(dependabot): remove dead bun entry for /studio/frontend `package-ecosystem: "bun"` at /studio/frontend was a no-op: that path commits package-lock.json, not bun.lock / bun.lockb, so Dependabot's bun ecosystem silently skipped it. The actual behaviour is unchanged -- the npm entry below the cargo block already owns npm_and_yarn security advisories for /studio/frontend with `open-pull-requests-limit: 0` (version-update PRs suppressed, security PRs flow through). This commit: - Deletes the bun entry (kept a placeholder comment so a future bun migration knows where to slot it back in). - Rewrites the npm /studio/frontend entry comment to explain the real intent: lockfile is the authoritative pin, .npmrc `min-release-age=7` already blocks fresh tarballs at install time, dependabot only needs to surface security advisories. No functional change: same set of dependabot PRs as before (zero version updates, security advisories grouped weekly with cooldown). Verified: pytest tests/security -> 34 passed in 2.67s; YAML parses cleanly via PyYAML. * fix(dependabot): drop unsupported semver-* cooldown keys on github-actions Dependabot's validator rejected the config with: The property '#/updates/0/cooldown/semver-minor-days' is not supported for the package ecosystem 'github-actions'. The property '#/updates/0/cooldown/semver-patch-days' is not supported for the package ecosystem 'github-actions'. The `semver-minor-days` / `semver-patch-days` cooldown knobs are only valid for semver-aware ecosystems (npm, cargo, etc.). The github-actions ecosystem pins via git tags / SHAs, not semver, so only `default-days` is honored. Pre-existing bug on main; surfaced on this PR because the prior commit re-validated the file. Behaviour: github-actions PRs now respect the 7-day cooldown floor (was already the intent), without the no-op semver bands. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
898 lines
40 KiB
YAML
898 lines
40 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
|
|
|
|
- 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
|
|
|
|
# ── 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 libayatana-appindicator3-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');
|
|
}
|
|
|
|
const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8');
|
|
const lines = workflow.split(/\r?\n/);
|
|
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');
|
|
}
|
|
}
|
|
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.9.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: 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 }}
|
|
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` (Ubuntu/Debian) or `.AppImage` (universal).
|
|
|
|
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
|
|
> 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` (Ubuntu/Debian) or `.AppImage` (universal).
|
|
|
|
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
|
|
> 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` (Ubuntu/Debian) or `.AppImage` (universal).
|
|
|
|
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
|
|
> 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
|