diff --git a/.github/scripts/clean-machine-assert.sh b/.github/scripts/clean-machine-assert.sh new file mode 100755 index 0000000000..d96a6c8540 --- /dev/null +++ b/.github/scripts/clean-machine-assert.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. +# +# Assert the clean-machine contract after an install attempt. +# +# absent The toolchain really was absent for the whole run. Guards against a +# leg that "passed" only because masking silently failed, or because +# the installer quietly installed Xcode CLT behind our back. +# notools The trace recorded no compiler/git/brew invocation (trace mode). +# nobuild The install log shows no source build (no sdist, no cmake, no +# "Building wheel"). This is the wheels-only contract. +# +# Usage: bash .github/scripts/clean-machine-assert.sh absent notools nobuild +set -uo pipefail + +LOG="${INSTALL_LOG:-logs/install.log}" +TRACE="${UNSLOTH_TOOL_TRACE:-}" +rc=0 + +fail() { echo "::error::$*"; rc=1; } +ok() { echo "[assert] OK $*"; } + +for check in "$@"; do + case "$check" in + + absent) + # Deliberately NOT a `command -v` check. On a real virgin Mac /usr/bin/git and + # /usr/bin/cc EXIST as Xcode CLT stubs, so `command -v git` SUCCEEDS -- running + # it is what fails ("xcrun: error: invalid active developer path"). Asserting on + # `command -v` would therefore be unfaithful and would fail on a correctly masked + # runner. The honest invariant is: the tool must not WORK. + if xcode-select -p >/dev/null 2>&1; then + fail "xcode-select -p still resolves to $(xcode-select -p 2>/dev/null); not a clean Mac" + else + ok "xcode-select -p fails (the gate a virgin Mac hits)" + fi + for tool in git cc clang cmake; do + command -v "$tool" >/dev/null 2>&1 || { ok "$tool not on PATH"; continue; } + if "$tool" --version >/dev/null 2>&1; then + fail "toolchain still usable: '$tool --version' succeeded ($(command -v "$tool")); masking failed" + else + ok "$tool present but non-functional (CLT stub), as on a clean Mac" + fi + done + # brew is a plain binary with no stub, so absence from PATH is the right test. + if command -v brew >/dev/null 2>&1; then + fail "Homebrew still on PATH at $(command -v brew); masking failed" + else + ok "brew absent" + fi + ;; + + notools) + if [ -z "$TRACE" ] || [ ! -f "$TRACE" ]; then + fail "notools requested but no trace file (\$UNSLOTH_TOOL_TRACE=$TRACE)" + else + # git is legitimate under --local (it installs unsloth-zoo from a git URL); + # UNSLOTH_ALLOW_TOOLS lets that leg allow-list it explicitly. + allow="${UNSLOTH_ALLOW_TOOLS:-}" + hits="" + while IFS=$'\t' read -r tool rest; do + [ -n "$tool" ] || continue + case " $allow " in *" $tool "*) continue ;; esac + # `xcode-select -p` ASKS whether a toolchain is selected; it cannot build + # anything. The installer has to ask in order to tell the user whether a + # source build is available, and the whole point of the fix is that it then + # carries on without one. Treating the question as toolchain USE would fail + # the very leg that proves the toolchain was never used. `--install`, which + # pops the CLT installer, stays a hit. + if [ "$tool" = "xcode-select" ]; then + case "$rest" in + -p|--print-path|-v|--version|"") continue ;; + esac + fi + hits="$hits $tool" + done < "$TRACE" + if [ -n "$hits" ]; then + fail "installer invoked toolchain:$(echo "$hits" | tr ' ' '\n' | sort -u | tr '\n' ' ')" + echo "---- tool trace ----"; sort -u "$TRACE" | head -50 + else + ok "no compiler/git/brew invocation recorded" + fi + fi + ;; + + nobuild) + # "Built an sdist" is NOT the same as "needed a compiler". Four packages on the + # macOS path are sdist-only PURE PYTHON projects that build fine with no + # toolchain (verified by resolving each against cp313/macos-arm64): + # openai-whisper, argbind, randomname -- no version ever ships a wheel + # antlr4-python3-runtime==4.9.3 -- pinned below the 4.13.2 wheel + # Failing on those would be a false alarm, so the contract asserted here is + # "nothing that needs a COMPILER was built", with that allowlist subtracted. + # UNSLOTH_ALLOW_SDIST can extend it. + _allow="openai-whisper argbind randomname antlr4-python3-runtime ${UNSLOTH_ALLOW_SDIST:-}" + if [ ! -f "$LOG" ]; then + fail "nobuild requested but $LOG is missing" + else + _built="$(grep -oiE "building wheel for [a-z0-9._-]+" "$LOG" 2>/dev/null \ + | sed -E 's/.* for //' | tr 'A-Z' 'a-z' | sort -u || true)" + _bad="" + for pkg in $_built; do + case " $_allow " in *" $pkg "*) continue ;; esac + _bad="$_bad $pkg" + done + if [ -n "$_bad" ]; then + fail "built from source:$_bad -- these must resolve to wheels on a clean machine" + else + [ -n "$_built" ] && say_built="$(echo "$_built" | tr '\n' ' ')" || say_built="none" + ok "no non-allowlisted source build (built: $say_built)" + fi + # Independent of package names: a compiler error means a toolchain was needed. + if grep -qiE "error: command '(cc|gcc|clang|cl)' failed|no such file or directory: 'cc'|clang: error|cargo: not found|error: linker \`cc\` not found" "$LOG"; then + fail "compiler invocation appears in the install log" + grep -iE "error: command '(cc|gcc|clang|cl)' failed|clang: error" "$LOG" | head -10 + fi + fi + ;; + + *) + fail "unknown check '$check'" + ;; + esac +done + +exit "$rc" diff --git a/.github/scripts/clean-machine-env.sh b/.github/scripts/clean-machine-env.sh new file mode 100755 index 0000000000..c1e838d35a --- /dev/null +++ b/.github/scripts/clean-machine-env.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. +# +# Simulate a virgin developer machine on a GitHub-hosted runner, so the installer +# is exercised the way a real user's brand-new Mac / PC exercises it. +# +# Two modes, because "the tool is absent" and "the installer never called the tool" +# CANNOT be simulated by the same mechanism: +# +# mask Make the toolchain genuinely ABSENT. Scrubs PATH down to the OS +# defaults and (with --remove) moves the real toolchain aside. After +# this, `command -v git` correctly FAILS, which is what a clean Mac does. +# A failing "poison shim" on PATH would do the opposite -- `command -v` +# finds it and reports the tool as present -- so shims are NOT used here. +# +# trace Leave the toolchain working, but route it through logging wrappers that +# record the invocation and then exec the real binary. Proves whether the +# installer ever REACHES for a compiler/git, without changing behaviour. +# +# Writes shell exports to $CLEAN_ENV_FILE (default ./clean-machine.env) for the +# caller to `source`. Nothing is exported globally, so other workflow steps +# (checkout, upload-artifact) keep a normal environment. +# +# Usage: +# bash .github/scripts/clean-machine-env.sh mask [--remove] +# bash .github/scripts/clean-machine-env.sh trace +# source ./clean-machine.env +set -uo pipefail + +MODE="${1:-}" +REMOVE=0 +[ "${2:-}" = "--remove" ] && REMOVE=1 + +case "$MODE" in + mask|trace) ;; + *) echo "usage: $0 {mask|trace} [--remove]" >&2; exit 2 ;; +esac + +OS="$(uname -s)" +WORK="${CLEAN_MACHINE_DIR:-$PWD/.clean-machine}" +ENV_FILE="${CLEAN_ENV_FILE:-$PWD/clean-machine.env}" +TRACE="$WORK/tool-invocations.log" +BIN="$WORK/bin" +RESTORE="$WORK/restore.sh" +mkdir -p "$BIN" +: > "$TRACE" +: > "$ENV_FILE" +printf '#!/usr/bin/env bash\n# Undo clean-machine-env.sh --remove. Safe to run twice.\nset -uo pipefail\n' > "$RESTORE" +chmod +x "$RESTORE" + +# The toolchain we care about: a consumer install must need none of it. +TOOLS="xcode-select xcrun clang clang++ cc c++ gcc g++ git cmake make brew ninja cargo rustc" + +note() { echo "[clean-machine] $*"; } + +# ── PATH scrub ──────────────────────────────────────────────────────────────── +# Keep only OS-default system dirs. Drops Homebrew, the hosted Python toolcache, +# setup-* shims, pipx, cargo, and every other preinstalled developer dir. +scrub_path() { + local keep out="" + if [ "$OS" = "Darwin" ]; then + keep="/usr/bin:/bin:/usr/sbin:/sbin" + else + keep="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + fi + local IFS=":" + for d in $keep; do + [ -d "$d" ] && out="${out:+$out:}$d" + done + echo "$out" +} + +# ── mask ────────────────────────────────────────────────────────────────────── +if [ "$MODE" = "mask" ]; then + NEWPATH="$(scrub_path)" + { + echo "export PATH='$NEWPATH'" + # DEVELOPER_DIR must be UNSET, not pointed at a fake path: `xcode-select -p` + # honours DEVELOPER_DIR and prints it verbatim with exit 0, so setting it to a + # nonexistent dir makes the probe SUCCEED -- the exact opposite of a clean Mac, + # where DEVELOPER_DIR is unset and the missing /var/db/xcode_select_link is what + # makes `xcode-select -p` fail. + echo "unset DEVELOPER_DIR || true" + echo "unset SDKROOT CC CXX CFLAGS CXXFLAGS LDFLAGS CMAKE_GENERATOR CMAKE_PREFIX_PATH || true" + echo "export HOMEBREW_NO_AUTO_UPDATE=1" + echo "export UNSLOTH_CLEAN_MACHINE=1" + } >> "$ENV_FILE" + + if [ "$REMOVE" = "1" ] && [ "$OS" = "Darwin" ]; then + # Best-effort real removal. Each step is independent and recorded in + # restore.sh so an `if: always()` step can put the runner back. + # /var/db/xcode_select_link is exactly what `xcode-select -p` reads, so + # removing it reproduces a virgin Mac's gate precisely. `xcode-select --reset` + # is NOT enough: it can reselect a full Xcode.app. + if [ -e /var/db/xcode_select_link ]; then + if sudo rm -f /var/db/xcode_select_link 2>/dev/null; then + note "removed /var/db/xcode_select_link" + echo "sudo xcode-select --switch /Library/Developer/CommandLineTools 2>/dev/null || true" >> "$RESTORE" + else + note "WARN could not remove /var/db/xcode_select_link" + fi + fi + # Moving the CLT dir aside turns /usr/bin/{cc,clang,git} into dead shims, so + # the run also proves the install needs no compiler at all. + if [ -d /Library/Developer/CommandLineTools ]; then + if sudo mv /Library/Developer/CommandLineTools /Library/Developer/CommandLineTools.masked 2>/dev/null; then + note "moved CommandLineTools aside" + echo "sudo mv /Library/Developer/CommandLineTools.masked /Library/Developer/CommandLineTools 2>/dev/null || true" >> "$RESTORE" + else + note "WARN could not move CommandLineTools" + fi + fi + # Xcode.app must go too. With the select link removed AND CommandLineTools moved, + # `xcode-select -p` does not fail -- it falls through to whatever Xcode bundle the + # runner image ships (observed: /Applications/Xcode_16.4.app/Contents/Developer), + # which re-arms /usr/bin/git and /usr/bin/cc and silently un-cleans the machine. + # A rename is instant regardless of bundle size: same filesystem, no copy. + for app in /Applications/Xcode*.app; do + [ -d "$app" ] || continue + if sudo mv "$app" "${app}.masked" 2>/dev/null; then + note "moved $(basename "$app") aside" + echo "sudo mv '${app}.masked' '$app' 2>/dev/null || true" >> "$RESTORE" + else + note "WARN could not move $app" + fi + done + for brewdir in /opt/homebrew /usr/local/Homebrew; do + if [ -d "$brewdir" ]; then + if sudo mv "$brewdir" "${brewdir}.masked" 2>/dev/null; then + note "moved $brewdir aside" + echo "sudo mv '${brewdir}.masked' '$brewdir' 2>/dev/null || true" >> "$RESTORE" + else + note "WARN could not move $brewdir" + fi + fi + done + fi +fi + +# ── trace ───────────────────────────────────────────────────────────────────── +if [ "$MODE" = "trace" ]; then + for tool in $TOOLS; do + real="$(command -v "$tool" 2>/dev/null || true)" + [ -n "$real" ] || continue + # Wrapper logs the call then execs the REAL binary, so behaviour is unchanged + # and the trace answers "did the installer reach for this?" honestly. + cat > "$BIN/$tool" <> "$TRACE" +exec "$real" "\$@" +WRAP + chmod +x "$BIN/$tool" + done + { + echo "export PATH='$BIN:$PATH'" + echo "export UNSLOTH_TOOL_TRACE='$TRACE'" + echo "export UNSLOTH_CLEAN_MACHINE=trace" + } >> "$ENV_FILE" +fi + +note "mode=$MODE remove=$REMOVE" +note "env file: $ENV_FILE" +note "trace: $TRACE" +note "restore: $RESTORE" diff --git a/.github/workflows/clean-machine-install-ci.yml b/.github/workflows/clean-machine-install-ci.yml new file mode 100644 index 0000000000..acfe56e48f --- /dev/null +++ b/.github/workflows/clean-machine-install-ci.yml @@ -0,0 +1,528 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +# Proves Unsloth installs on a machine that has never seen a developer toolchain. +# +# Why this exists: studio-mac-install-matrix.yml runs `install.sh --local --no-torch` +# on runners that already have Xcode CLT selected AND actions/setup-python +# preinstalled, so the macOS dependency gate in install.sh never fires there -- and +# `--local` is precisely the mode that legitimately needs git. A brand-new Mac +# therefore hits a hard `exit 1` that no CI job covered. +# +# Hosted runners are developer machines, so each job simulates absence rather than +# being virgin. Two modes, because they answer different questions and cannot be +# done by the same mechanism (see .github/scripts/clean-machine-env.sh): +# mask -> the toolchain is genuinely unusable; does the install still work? +# trace -> the toolchain works but is logged; does the installer ever call it? +# Linux is the exception: containers are genuinely clean. + +name: Clean machine install + +on: + pull_request: + paths: + - 'install.sh' + - 'install.ps1' + - 'studio/setup.sh' + - 'studio/setup.ps1' + - 'studio/install_python_stack.py' + - '.github/scripts/clean-machine-*.sh' + - '.github/workflows/clean-machine-install-ci.yml' + push: + branches: [main] + paths: + - 'install.sh' + - 'install.ps1' + - '.github/workflows/clean-machine-install-ci.yml' + workflow_dispatch: + inputs: + installer_source: + description: 'published = curl unsloth.ai/install.sh, tree = the checked-out script' + type: choice + options: [tree, published] + default: tree + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # Keep every install inside the workspace so a leg cannot inherit another's state. + UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home + # No wildcard bind -> no ifconfig.me / check-host.net calls on the startup path. + UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1' + +jobs: + # ── macOS: the reported failure ──────────────────────────────────────────── + macos: + name: mac ${{ matrix.os }} / ${{ matrix.mode }} / ${{ matrix.delivery }}${{ matrix.flags && format(' {0}', matrix.flags) || '' }} + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + continue-on-error: ${{ matrix.experimental }} + # Explicit legs rather than a full cross-product: the interesting dimensions are + # (does the toolchain exist) x (how the script is delivered), not every pairing. + strategy: + fail-fast: false + matrix: + include: + # The reported failure, in the shape users run it. Default install (torch + # included) because that is what a consumer actually gets. + - {os: macos-14, mode: mask, delivery: pipe, flags: '', experimental: false} + - {os: macos-14, mode: mask, delivery: file, flags: '', experimental: false} + # What the desktop app runs: no tty, stdin closed, TAURI markers on. + - {os: macos-14, mode: mask, delivery: tauri, flags: '', experimental: false} + # Toolchain present but logged: does the installer ever reach for it? + - {os: macos-14, mode: trace, delivery: file, flags: '', experimental: false} + # --no-torch is the one macOS path that can still want a compiler + # (sentencepiece has no guaranteed cp313 arm64 wheel), so probe it apart + # from the default path instead of letting it mask the gate under test. + - {os: macos-14, mode: mask, delivery: file, flags: '--no-torch', experimental: true} + # OS-version dimension. + - {os: macos-15, mode: mask, delivery: pipe, flags: '', experimental: false} + - {os: macos-26, mode: mask, delivery: file, flags: '', experimental: true} + # Intel pins python 3.12, not 3.13 -- informational only. + - {os: macos-15-intel, mode: mask, delivery: file, flags: '', experimental: true} + + steps: + # checkout FIRST: it needs a working git, which masking then takes away. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + # Deliberately no actions/setup-python: install.sh must bring its own + # uv-managed CPython, exactly as it must on a user's machine. + + - name: Record the pre-masking toolchain + run: | + { + echo "xcode-select -p : $(xcode-select -p 2>&1 || true)" + echo "git : $(command -v git || echo none)" + echo "brew : $(command -v brew || echo none)" + echo "cmake : $(command -v cmake || echo none)" + echo "python3 : $(command -v python3 || echo none)" + } | tee runner-baseline.txt + + - name: Simulate a clean machine (${{ matrix.mode }}) + run: | + mkdir -p logs + if [ "${{ matrix.mode }}" = "mask" ]; then + bash .github/scripts/clean-machine-env.sh mask --remove + else + bash .github/scripts/clean-machine-env.sh trace + fi + + - name: Verify the simulation actually took effect + if: matrix.mode == 'mask' + run: | + set -a; . ./clean-machine.env; set +a + bash .github/scripts/clean-machine-assert.sh absent + + - name: Install + id: install + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Withheld on PR: this step runs checked-out PR code. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} + run: | + set -a; . ./clean-machine.env; set +a + set -o pipefail + rc=0 + FLAGS="${{ matrix.flags }}" + case "${{ matrix.delivery }}" in + file) + # Plain file execution: isolates "installer logic broken" from + # "curl-pipe delivery broken". + bash install.sh $FLAGS 2>&1 | tee logs/install.log || rc=$? + ;; + pipe) + # The shape users actually run. install.sh is ~150KB of top-level + # statements, so an early `exit` leaves the writer with a closed + # pipe -> `curl: (56)`. Piping a local file reproduces that + # faithfully without depending on unsloth.ai being current. + # On pull_request/push this input is empty, which correctly falls through to + # the checked-out ref -- only an explicit dispatch tests unsloth.ai. + if [ "${{ inputs.installer_source }}" = "published" ]; then + curl -fsSL https://unsloth.ai/install.sh | sh -s -- $FLAGS 2>&1 | tee logs/install.log || rc=$? + else + # `sh -s --` with no further args would pass an empty positional, + # so only add the separator when there are flags to pass. + if [ -n "$FLAGS" ]; then + cat install.sh | sh -s -- $FLAGS 2>&1 | tee logs/install.log || rc=$? + else + cat install.sh | sh 2>&1 | tee logs/install.log || rc=$? + fi + fi + ;; + tauri) + # Exactly how the desktop app invokes it: no tty, stdin closed. + # --tauri rejects a custom UNSLOTH_STUDIO_HOME outright (the desktop app + # still uses the legacy ~/.unsloth/studio root), so the workspace-scoped + # value every other leg relies on has to go here or the installer exits + # before it does any work. The runner is ephemeral, so the real home is + # as disposable as the override. + env -u UNSLOTH_STUDIO_HOME \ + bash install.sh --tauri $FLAGS < /dev/null 2>&1 | tee logs/install.log || rc=$? + ;; + esac + echo "install_rc=$rc" >> "$GITHUB_OUTPUT" + echo "installer exit code: $rc" + # The pipe legs are the ones that expose curl:(56); surface it explicitly + # rather than leaving it buried in a 4000-line log. + if grep -qE "curl: \(5[36]\)|Failure writing output to destination" logs/install.log; then + echo "::warning::curl reported a broken pipe -- an early exit killed the reader" + fi + exit "$rc" + + - name: Assert no source build and no toolchain use + if: always() && steps.install.outcome == 'success' + run: | + set -a; . ./clean-machine.env; set +a + checks="nobuild" + [ "${{ matrix.mode }}" = "trace" ] && checks="$checks notools" + bash .github/scripts/clean-machine-assert.sh $checks + + - name: Assert llama.cpp loads + if: steps.install.outcome == 'success' + run: | + set -a; . ./clean-machine.env; set +a + # The tauri leg cannot honour UNSLOTH_STUDIO_HOME (see the Install step), so + # it installed into the legacy root and that is where to look. + if [ "${{ matrix.delivery }}" = "tauri" ]; then + HOME_DIR="$HOME/.unsloth/studio" + else + HOME_DIR="$UNSLOTH_STUDIO_HOME" + fi + STUDIO_HOME="$HOME_DIR" bash .github/scripts/assert-llama-loads.sh + + - name: Restore the runner + if: always() + run: bash .clean-machine/restore.sh || true + + - name: Upload logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: clean-mac-${{ matrix.os }}-${{ matrix.mode }}-${{ matrix.delivery }} + path: | + logs/ + runner-baseline.txt + clean-machine.env + .clean-machine/tool-invocations.log + retention-days: 7 + if-no-files-found: warn + + # ── Linux: genuinely clean, via containers ──────────────────────────────── + linux: + name: linux ${{ matrix.label }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.image }} + timeout-minutes: 40 + continue-on-error: ${{ matrix.experimental }} + # Container jobs default to `sh -e`, which is dash: `set -o pipefail` is an + # "Illegal option" there and kills the step before the installer even starts. + defaults: + run: + shell: bash + strategy: + fail-fast: false + matrix: + include: + # Root + apt available: install.sh's _smart_apt_install should self-heal + # from a base image with no curl, git, gcc or cmake at all. + - label: ubuntu2404-root + image: ubuntu:24.04 + runner: ubuntu-latest + experimental: false + - label: ubuntu2404-arm-root + image: ubuntu:24.04 + runner: ubuntu-24.04-arm + experimental: false + # No elevation: today this hard-fails at install.sh:856-861. Expected + # failure -- the point is to pin the message and prove it is actionable + # rather than a bare `curl: (56)`. + - label: ubuntu2404-nonroot + image: ubuntu:24.04 + runner: ubuntu-latest + experimental: true + # Non-apt: today this hard-fails at install.sh:2034. Expected failure; + # forces the decision on whether dnf/pacman/zypper get supported. + - label: fedora41 + image: fedora:41 + runner: ubuntu-latest + experimental: true + + steps: + - name: Describe the container's starting state + run: | + for t in curl wget git gcc cc cmake make python3 sudo; do + printf '%-8s %s\n' "$t" "$(command -v $t 2>/dev/null || echo ABSENT)" + done | tee /tmp/container-baseline.txt + + # The advertised `curl | sh` cannot even start on an image without curl, so + # the bootstrap transport is provisioned separately from the installer's own + # dependencies. Everything else stays absent. + - name: Provision only the bootstrap transport + run: | + if command -v apt-get >/dev/null 2>&1; then + apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl + elif command -v dnf >/dev/null 2>&1; then + dnf install -y -q ca-certificates curl + fi + + # No actions/checkout here on purpose: it requires git, and a container with git + # preinstalled is not the clean machine under test. Fetch the two files we need + # over the transport provisioned above -- and fetch the INSTALLER from the same + # ref, so these legs can validate a fix instead of only the published script. + - name: Fetch installer + assert script for this ref + run: | + mkdir -p logs .github/scripts + raw="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${GITHUB_SHA}" + curl -fsSL "$raw/.github/scripts/clean-machine-assert.sh" -o .github/scripts/clean-machine-assert.sh + # On pull_request/push this input is empty, which correctly falls through to + # the checked-out ref -- only an explicit dispatch tests unsloth.ai. + if [ "${{ inputs.installer_source }}" = "published" ]; then + curl -fsSL https://unsloth.ai/install.sh -o install.sh + echo "installer: published (unsloth.ai)" + else + curl -fsSL "$raw/install.sh" -o install.sh + echo "installer: this ref (${GITHUB_SHA})" + fi + wc -l install.sh + + - name: Create an unprivileged user + if: matrix.label == 'ubuntu2404-nonroot' + run: | + useradd -m tester + # No sudo installed and not root -> exercises the "cannot elevate" branch. + chown -R tester logs install.sh + + - name: Install (root) + if: matrix.label != 'ubuntu2404-nonroot' + run: | + set -o pipefail + rc=0 + # Piped, because that is the advertised command and the shape that turns an + # early exit into curl:(56). + cat install.sh | sh 2>&1 | tee logs/install.log || rc=$? + echo "installer exit code: $rc" + exit "$rc" + + - name: Install (unprivileged, expected to fail cleanly) + if: matrix.label == 'ubuntu2404-nonroot' + run: | + set -o pipefail + rc=0 + su tester -c 'cat install.sh | sh' > logs/install.log 2>&1 || rc=$? + echo "installer exit code: $rc" + tail -40 logs/install.log + # It may legitimately fail; what must NOT happen is an unexplained exit or a + # bare broken-pipe error standing in for a real diagnosis. + if [ "$rc" != "0" ] && ! grep -qiE "sudo is not available|apt-get install|missing:|permission" logs/install.log; then + echo "::error::unprivileged install failed with no actionable message" + exit 1 + fi + + - name: Assert no source build + if: always() + run: | + if [ -f .github/scripts/clean-machine-assert.sh ]; then + INSTALL_LOG=logs/install.log bash .github/scripts/clean-machine-assert.sh nobuild + else + echo "::warning::assert script unavailable (fetch step did not run)" + fi + + - name: Upload logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: clean-linux-${{ matrix.label }} + path: | + logs/ + /tmp/container-baseline.txt + retention-days: 7 + if-no-files-found: warn + + # ── WSL ─────────────────────────────────────────────────────────────────── + # install.sh carries ~126 lines of WSL-specific logic (the `linux|wsl` dependency + # branch, UNSLOTH_WSL_REROUTED, the Strix Halo reroute to 24.04), and none of it + # had ever run in CI -- tests/sh/test_strixhalo_wsl_reroute.sh extracts functions + # and mocks the environment, which cannot catch anything about a real WSL. + # + # No third-party action: the official Ubuntu WSL rootfs plus `wsl --import` is + # deterministic, checksum-verifiable, and avoids adding a supply-chain dependency + # to a repo that audits its lockfiles. + wsl: + name: wsl ubuntu-24.04 + runs-on: windows-latest + timeout-minutes: 50 + continue-on-error: true + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Import a fresh Ubuntu 24.04 WSL distro + shell: pwsh + run: | + # WSL2 is present on windows-2022+ runner images; only a distro is missing. + wsl --set-default-version 2 + $url = 'https://cloud-images.ubuntu.com/wsl/releases/24.04/current/ubuntu-noble-wsl-amd64-24.04lts.rootfs.tar.gz' + $expected = '2a790896740b14d637dbdc583cce1ba081ac53b9e9cdb46dc09a2f73abbd9934' + New-Item -ItemType Directory -Force -Path wsl-dist, logs | Out-Null + Invoke-WebRequest -Uri $url -OutFile wsl-dist/rootfs.tar.gz -UseBasicParsing -TimeoutSec 900 + $actual = (Get-FileHash wsl-dist/rootfs.tar.gz -Algorithm SHA256).Hash.ToLower() + if ($actual -ne $expected) { + Write-Host "::error::rootfs checksum mismatch: got $actual" + exit 1 + } + wsl --import unsloth-ci "$PWD/wsl-dist/instance" "$PWD/wsl-dist/rootfs.tar.gz" --version 2 + wsl -d unsloth-ci -- uname -a + # A freshly imported rootfs is genuinely bare: no curl, no git, no compiler. + # That is the clean machine, not a simulation of one. + wsl -d unsloth-ci -- sh -c 'for t in curl wget git gcc cmake python3 sudo; do printf "%-8s %s\n" "$t" "$(command -v $t || echo ABSENT)"; done' + + - name: Install inside WSL, piped exactly as documented + shell: pwsh + run: | + # Only ca-certificates + curl, because the advertised one-liner cannot even + # start without a transport. Everything else must come from the installer. + wsl -d unsloth-ci -u root -- sh -c 'apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl' 2>&1 | Tee-Object -FilePath logs/wsl-bootstrap.log + # Copy the script in rather than reaching across /mnt/c: a DrvFs path brings + # Windows file permissions and CRLF risk with it, and neither is what a real + # WSL user's install looks like. + $wslPath = (wsl -d unsloth-ci -- wslpath -a "$($env:GITHUB_WORKSPACE -replace '\\','/')/install.sh").Trim() + Write-Host "installer source in WSL: $wslPath" + wsl -d unsloth-ci -u root -- cp "$wslPath" /root/install.sh + # Feed it through a pipe: same shape as `curl ... | sh`, so an early exit + # still exposes the broken-pipe problem, but the script under test is this + # ref rather than whatever production currently serves. + wsl -d unsloth-ci -u root -- sh -c 'cd /root && cat install.sh | sh' 2>&1 | Tee-Object -FilePath logs/wsl-install.log + Write-Host "installer exit: $LASTEXITCODE" + + - name: Did it detect WSL, and did it end up usable? + if: always() + shell: pwsh + run: | + # The platform line proves the wsl branch was taken rather than plain linux. + Select-String -Path logs/wsl-install.log -Pattern 'platform|\[TAURI:DIAG\]|wsl' -ErrorAction SilentlyContinue | + Select-Object -First 10 + wsl -d unsloth-ci -u root -- sh -c 'test -x "$HOME/.unsloth/studio/unsloth_studio/bin/unsloth" && "$HOME/.unsloth/studio/unsloth_studio/bin/unsloth" --version || echo "no CLI installed"' 2>&1 | + Tee-Object -FilePath logs/wsl-verify.log + + - name: Tear the distro down + if: always() + shell: pwsh + run: wsl --unregister unsloth-ci 2>&1 | Out-Null; exit 0 + + - name: Upload logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: clean-wsl-ubuntu2404 + path: logs/ + retention-days: 7 + if-no-files-found: warn + + # ── Windows ─────────────────────────────────────────────────────────────── + windows: + name: win ${{ matrix.os }} / winget=${{ matrix.winget }} + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + continue-on-error: ${{ matrix.experimental }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + winget: 'visible' + experimental: false + # The no-winget path (LTSC / Server / managed corporate machines) falls + # back to python.org + astral.sh and is completely untested today. It is + # also the path where Ensure-VCRedist silently does not run, which leaves + # torch unable to load -- hence the explicit `import torch` assert below. + - os: windows-latest + winget: 'masked' + experimental: false + - os: windows-11-arm + winget: 'visible' + experimental: true + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + # No actions/setup-python here either: install.ps1 must bootstrap Python. + + - name: Simulate a clean machine + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path logs | Out-Null + # Drop preinstalled Python, git, CMake, VS/LLVM and the WindowsApps + # aliases from PATH. A full Visual Studio uninstall is not realistic in + # CI (registry + vswhere discovery, slow, may need a reboot), so PATH and + # env scrubbing is the honest approximation -- recorded as such. + $drop = @('hostedtoolcache\windows\Python', 'WindowsApps', '\Git\', + 'CMake', 'Microsoft Visual Studio', 'BuildTools', 'LLVM', + 'MSYS', 'mingw', 'Strawberry') + $kept = ($env:PATH -split ';') | Where-Object { + $p = $_; $p -and -not ($drop | Where-Object { $p -like "*$_*" }) + } + if ('${{ matrix.winget }}' -eq 'masked') { + $kept = $kept | Where-Object { $_ -notlike '*WinGet*' -and $_ -notlike '*Microsoft\WindowsApps*' } + } + $newPath = ($kept -join ';') + "PATH=$newPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + foreach ($v in 'VSINSTALLDIR','VCINSTALLDIR','WindowsSdkDir','INCLUDE','LIB','LIBPATH') { + "$v=" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + } + Write-Host "kept PATH entries: $($kept.Count)" + + - name: Verify the simulation took effect + shell: pwsh + run: | + foreach ($t in 'python','git','cmake','cl') { + $f = Get-Command $t -ErrorAction SilentlyContinue + Write-Host ("{0,-8} {1}" -f $t, $(if ($f) { $f.Source } else { 'ABSENT' })) + } + if ('${{ matrix.winget }}' -eq 'masked' -and (Get-Command winget -ErrorAction SilentlyContinue)) { + Write-Host '::error::winget still resolvable; masking failed' + exit 1 + } + + - name: Install + id: install + shell: pwsh + run: | + $ErrorActionPreference = 'Continue' + & ./install.ps1 -SkipTorch *>&1 | Tee-Object -FilePath logs/install.log + $rc = $LASTEXITCODE + Write-Host "installer exit code: $rc" + exit $rc + + - name: Assert torch loads (the VCRedist contract) + if: steps.install.outcome == 'success' + shell: pwsh + run: | + # The prebuilt llama-server and PyTorch both link the VC++ runtime. + # Ensure-VCRedist only runs when winget exists, so on the masked leg this + # is the assertion that catches a silently broken install. + $py = Join-Path $env:UNSLOTH_STUDIO_HOME 'unsloth_studio\Scripts\python.exe' + if (-not (Test-Path $py)) { $py = (Get-Command python -ErrorAction SilentlyContinue).Source } + if (-not $py) { Write-Host '::error::no python from the install'; exit 1 } + & $py -c "import ctypes.util, sys; print('VCRUNTIME140:', ctypes.util.find_library('vcruntime140'))" + & $py -c "import torch; print('torch', torch.__version__)" + if ($LASTEXITCODE -ne 0) { Write-Host '::error::torch failed to import (VC++ runtime missing?)'; exit 1 } + + - name: Upload logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: clean-win-${{ matrix.os }}-${{ matrix.winget }} + path: logs/ + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/desktop-app-clean-machine-ci.yml b/.github/workflows/desktop-app-clean-machine-ci.yml new file mode 100644 index 0000000000..246e423dcc --- /dev/null +++ b/.github/workflows/desktop-app-clean-machine-ci.yml @@ -0,0 +1,354 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +# Installs and launches the SHIPPED desktop app on a machine stripped of developer +# tooling, on all three platforms. +# +# studio-tauri-smoke.yml compiles the Tauri crate; release-desktop.yml produces the +# bundles. Neither takes a published artifact, puts it on a clean machine, and checks +# that it starts -- which is exactly the gap the reported failures fell through: both +# came from the packaged app running its bundled +# Contents/Resources/install.sh, a path no CI job exercised. +# +# What "runs" means here, given hosted runners have no interactive desktop session: +# - the bundle installs / mounts / extracts +# - the binary is present, of the right architecture, and passes the OS gatekeeper +# checks a user would hit (macOS quarantine + codesign, Windows installer exit) +# - the process starts and STAYS UP past its preflight (it does not exit or crash), +# which is where an unhappy app dies +# - it writes tauri.log, and that log shows the preflight disposition -- the same +# field that read `ManagedReady` over an unbootable venv in the bug report +# Linux gets the strongest check: a real webview under Xvfb. + +name: Desktop app clean machine + +on: + # Also on PRs that touch this job or the machine-stripping scripts. workflow_dispatch + # alone is not enough to validate a change to the job itself: dispatch resolves the + # workflow from the DEFAULT branch, so a new or edited file on a feature branch can + # never be dispatched, and the job would first run only after merging blind. + pull_request: + paths: + - '.github/workflows/desktop-app-clean-machine-ci.yml' + - '.github/scripts/clean-machine-env.sh' + - '.github/scripts/clean-machine-assert.sh' + workflow_dispatch: + inputs: + release_tag: + description: 'Release tag in the desktop release repo' + type: string + default: 'desktop-v0.1.50-beta' + release_repo: + description: 'owner/name hosting the desktop release' + type: string + default: 'unsloth-test/unsloth-test' + strip_toolchain: + description: 'Strip developer tooling before installing' + type: boolean + default: true + schedule: + # Nightly, so a broken published bundle is caught without anyone asking. + - cron: '17 5 * * *' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + REL_REPO: ${{ inputs.release_repo || 'unsloth-test/unsloth-test' }} + REL_TAG: ${{ inputs.release_tag || 'desktop-v0.1.50-beta' }} + UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home + UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1' + +jobs: + # ── macOS: .dmg, Apple Silicon ──────────────────────────────────────────── + macos: + name: desktop macOS ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + continue-on-error: ${{ matrix.experimental }} + strategy: + fail-fast: false + matrix: + include: + - {os: macos-14, experimental: false} + - {os: macos-15, experimental: false} + - {os: macos-26, experimental: true} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download the shipped .dmg + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p dl logs + gh release download "$REL_TAG" --repo "$REL_REPO" \ + --pattern '*aarch64.dmg' --dir dl + ls -la dl + + - name: Strip the developer toolchain + if: ${{ inputs.strip_toolchain != false }} + run: | + bash .github/scripts/clean-machine-env.sh mask --remove + set -a; . ./clean-machine.env; set +a + bash .github/scripts/clean-machine-assert.sh absent + + - name: Mount and install + run: | + DMG="$(ls dl/*.dmg | head -1)" + # A real download is quarantined; Gatekeeper treats it differently from a + # locally built bundle, and that difference is a genuine failure mode. + xattr -w com.apple.quarantine \ + "0081;$(printf %x $(date +%s));Safari;" "$DMG" 2>/dev/null || true + hdiutil attach "$DMG" -nobrowse -quiet -mountpoint /Volumes/UnslothCI + APP="$(ls -d /Volumes/UnslothCI/*.app | head -1)" + echo "app bundle: $APP" + cp -R "$APP" /Applications/ + hdiutil detach /Volumes/UnslothCI -quiet + ls -la /Applications | grep -i unsloth + + - name: Inspect the bundle (arch, signature, Gatekeeper) + run: | + APP="$(ls -d /Applications/*Unsloth*.app | head -1)" + BIN="$APP/Contents/MacOS/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Contents/Info.plist")" + file "$BIN" + lipo -archs "$BIN" || true + # Report rather than gate: an unnotarised beta is expected to fail + # assessment, but a user WILL hit this, so it must be visible. + codesign -dv --verbose=2 "$APP" 2>&1 | head -20 || true + spctl -a -vvv -t install "$APP" 2>&1 | head -5 || \ + echo "::warning::Gatekeeper assessment failed -- users see 'cannot be opened' unless notarised" + # The bundled installer is what actually failed for users. + test -f "$APP/Contents/Resources/install.sh" \ + && echo "bundled install.sh present" \ + || echo "::error::no bundled install.sh in the app" + + - name: Launch and prove it stays up + run: | + set -a; [ -f ./clean-machine.env ] && . ./clean-machine.env; set +a + APP="$(ls -d /Applications/*Unsloth*.app | head -1)" + BIN="$APP/Contents/MacOS/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Contents/Info.plist")" + "$BIN" > logs/app-stdout.log 2>&1 & + APP_PID=$! + # 90s: long enough to clear preflight and start the bundled installer. + for i in $(seq 1 90); do + kill -0 "$APP_PID" 2>/dev/null || break + sleep 1 + done + if kill -0 "$APP_PID" 2>/dev/null; then + echo "app still running after 90s (pid $APP_PID)" + kill -TERM "$APP_PID" 2>/dev/null || true + else + wait "$APP_PID" 2>/dev/null; rc=$? + echo "::error::desktop app exited early with rc=$rc" + tail -50 logs/app-stdout.log || true + exit 1 + fi + + - name: What did its own log say? + if: always() + run: | + for f in "$UNSLOTH_STUDIO_HOME/tauri.log" "$HOME/.unsloth/studio/tauri.log"; do + [ -f "$f" ] || continue + echo "=== $f ===" + cp "$f" logs/ 2>/dev/null || true + tail -60 "$f" + # The two fields the bug report turned on. + grep -E "disposition=|can_auto_repair=|Xcode Command Line|ModuleNotFoundError" "$f" || true + done + + - name: Restore the runner + if: always() + run: bash .clean-machine/restore.sh || true + + - name: Upload logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: desktop-macos-${{ matrix.os }} + path: logs/ + retention-days: 7 + if-no-files-found: warn + + # ── Linux: .deb and .AppImage, with a real webview under Xvfb ──────────── + linux: + name: desktop linux ${{ matrix.kind }} + runs-on: ubuntu-22.04 + timeout-minutes: 45 + continue-on-error: ${{ matrix.experimental }} + strategy: + fail-fast: false + matrix: + include: + - {kind: deb, experimental: false} + - {kind: appimage, experimental: false} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download the shipped bundle + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p dl logs + pat='*.deb'; [ "${{ matrix.kind }}" = "appimage" ] && pat='*.AppImage' + gh release download "$REL_TAG" --repo "$REL_REPO" --pattern "$pat" --dir dl + ls -la dl + + - name: Install with NO dev tooling, only runtime libs + run: | + # Deliberately not build-essential/cmake/git: a user installing a .deb has + # none of that. WebKit + Xvfb are runtime requirements of the app itself, + # and apt pulls the .deb's declared deps -- if that list is wrong, this + # step is what catches it. + sudo apt-get update -qq + sudo apt-get install -y -qq --no-install-recommends xvfb + if [ "${{ matrix.kind }}" = "deb" ]; then + sudo apt-get install -y ./dl/*.deb || { + echo "::error::the .deb does not declare its runtime dependencies correctly" + exit 1 + } + BIN="$(dpkg -L "$(dpkg-deb -f dl/*.deb Package)" | grep -E '/usr/bin/' | head -1)" + else + sudo apt-get install -y -qq --no-install-recommends libfuse2 \ + libwebkit2gtk-4.1-0 libgtk-3-0 libayatana-appindicator3-1 || true + chmod +x dl/*.AppImage + BIN="$(ls dl/*.AppImage | head -1)" + fi + echo "BIN=$BIN" >> "$GITHUB_ENV" + echo "binary: $BIN" + + - name: Launch under Xvfb and prove it stays up + run: | + # Linux is the one platform where a hosted runner can give the app a real + # display, so this is the strongest "does the UI actually come up" check + # available without self-hosted hardware. + xvfb-run -a --server-args="-screen 0 1440x900x24" \ + "$BIN" > logs/app-stdout.log 2>&1 & + APP_PID=$! + for i in $(seq 1 90); do + kill -0 "$APP_PID" 2>/dev/null || break + sleep 1 + done + if kill -0 "$APP_PID" 2>/dev/null; then + echo "app still running after 90s" + kill -TERM "$APP_PID" 2>/dev/null || true + else + wait "$APP_PID" 2>/dev/null; rc=$? + echo "::error::desktop app exited early with rc=$rc" + tail -60 logs/app-stdout.log || true + exit 1 + fi + + - name: What did its own log say? + if: always() + run: | + for f in "$UNSLOTH_STUDIO_HOME/tauri.log" "$HOME/.unsloth/studio/tauri.log"; do + [ -f "$f" ] || continue + echo "=== $f ==="; cp "$f" logs/ 2>/dev/null || true; tail -60 "$f" + grep -E "disposition=|can_auto_repair=|ModuleNotFoundError" "$f" || true + done + + - name: Upload logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: desktop-linux-${{ matrix.kind }} + path: logs/ + retention-days: 7 + if-no-files-found: warn + + # ── Windows: NSIS setup.exe, silent install ────────────────────────────── + windows: + name: desktop windows + runs-on: windows-latest + timeout-minutes: 45 + continue-on-error: true + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download the shipped installer + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p dl logs + gh release download "$REL_TAG" --repo "$REL_REPO" --pattern '*setup.exe' --dir dl + ls -la dl + + - name: Strip developer tooling from PATH + if: ${{ inputs.strip_toolchain != false }} + shell: pwsh + run: | + $drop = @('hostedtoolcache\windows\Python', 'WindowsApps', '\Git\', 'CMake', + 'Microsoft Visual Studio', 'BuildTools', 'LLVM', 'MSYS', 'mingw') + $kept = ($env:PATH -split ';') | Where-Object { + $p = $_; $p -and -not ($drop | Where-Object { $p -like "*$_*" }) + } + "PATH=$($kept -join ';')" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Silent install + shell: pwsh + run: | + $exe = (Get-ChildItem dl/*setup.exe | Select-Object -First 1).FullName + # /S is the NSIS silent switch. A user double-clicks, but an installer that + # cannot run unattended also cannot be scripted or MDM-deployed. + $p = Start-Process -FilePath $exe -ArgumentList '/S' -Wait -PassThru + Write-Host "installer exit: $($p.ExitCode)" + if ($p.ExitCode -ne 0) { Write-Host "::error::silent install failed"; exit 1 } + $found = Get-ChildItem -Path "$env:LOCALAPPDATA","$env:ProgramFiles" -Recurse ` + -Filter '*Unsloth*.exe' -ErrorAction SilentlyContinue | + Select-Object -First 1 + if (-not $found) { Write-Host '::error::no installed executable found'; exit 1 } + Write-Host "installed: $($found.FullName)" + "APP_EXE=$($found.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Launch and prove it stays up + shell: pwsh + run: | + $p = Start-Process -FilePath $env:APP_EXE -PassThru ` + -RedirectStandardOutput logs/app-stdout.log ` + -RedirectStandardError logs/app-stderr.log + for ($i = 0; $i -lt 90; $i++) { if ($p.HasExited) { break }; Start-Sleep -Seconds 1 } + if ($p.HasExited) { + Write-Host "::error::desktop app exited early with rc=$($p.ExitCode)" + Get-Content logs/app-stdout.log, logs/app-stderr.log -Tail 40 -ErrorAction SilentlyContinue + exit 1 + } + Write-Host "app still running after 90s" + Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue + + - name: What did its own log say? + if: always() + shell: pwsh + run: | + foreach ($f in @("$env:UNSLOTH_STUDIO_HOME\tauri.log", + "$env:USERPROFILE\.unsloth\studio\tauri.log")) { + if (Test-Path $f) { + Write-Host "=== $f ===" + Copy-Item $f logs/ -ErrorAction SilentlyContinue + Get-Content $f -Tail 60 + Select-String -Path $f -Pattern 'disposition=|can_auto_repair=|ModuleNotFoundError' ` + -ErrorAction SilentlyContinue + } + } + + - name: Upload logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: desktop-windows + path: logs/ + retention-days: 7 + if-no-files-found: warn