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