diff --git a/.github/scripts/assert-nobuild.ps1 b/.github/scripts/assert-nobuild.ps1 index 64a1ba9813..c9937bfd95 100644 --- a/.github/scripts/assert-nobuild.ps1 +++ b/.github/scripts/assert-nobuild.ps1 @@ -3,11 +3,10 @@ # The `nobuild` contract from clean-machine-assert.sh, for Windows. # -# Why a port and not `shell: bash`: the clean-machine scrub drops every `*\Git\*` -# entry from PATH and from the Machine/User registry copies, and the bash version -# needs sed/grep/tr/sort out of C:\Program Files\Git\usr\bin. This also runs inside -# the servercore container, which has no bash at all. Both Windows lanes call this -# one file so the sdist allowlist cannot drift between them. +# A port and not `shell: bash`: the clean-machine scrub drops every `*\Git\*` PATH +# entry, and the bash version needs sed/grep/tr/sort out of Git's usr/bin. It also runs +# inside the servercore container, which has no bash at all. Both Windows lanes call +# this one file so the sdist allowlist cannot drift. # # Usage: assert-nobuild.ps1 -LogPath logs/install.log (exit 1 = a source build) [CmdletBinding()] @@ -18,10 +17,9 @@ if (-not (Test-Path -LiteralPath $LogPath)) { exit 1 } -# "Built an sdist" is NOT "needed a compiler". Every name here was checked against -# its own sdist: setuptools.build_meta backend, no ext_modules, no .c/.cpp/.pyx/.rs -# file, so its PEP 517 build is a pure-Python copy step. UNSLOTH_ALLOW_SDIST extends -# the list. Kept identical to clean-machine-assert.sh's `_allow`. +# "Built an sdist" is NOT "needed a compiler": every name here has a +# setuptools.build_meta backend, no ext_modules and no .c/.cpp/.pyx/.rs file, so its +# PEP 517 build is a pure-Python copy step. Identical to clean-machine-assert.sh. $allow = @('openai-whisper', 'argbind', 'randomname', 'antlr4-python3-runtime', 'triton-kernels') if ($env:UNSLOTH_ALLOW_SDIST) { $allow += ($env:UNSLOTH_ALLOW_SDIST -split '\s+' | Where-Object { $_ }) @@ -30,20 +28,18 @@ if ($env:UNSLOTH_ALLOW_SDIST) { # prints can disagree on the separator (triton_kernels vs triton-kernels). $allow = @($allow | ForEach-Object { $_.ToLowerInvariant() -replace '_', '-' }) -# [char]27, not "`e": the `e escape is PowerShell 6+, and this runs under Windows -# PowerShell 5.1 too, where "`e" degrades to a literal "e" and the strip would eat -# real text instead of ANSI codes. +# [char]27, not "`e": that escape is PowerShell 6+, and under Windows PowerShell 5.1 +# it degrades to a literal "e" and the strip eats real text instead of ANSI codes. $esc = [char]27 $text = (Get-Content -LiteralPath $LogPath -Raw) -replace "$esc\[[0-9;]*[A-Za-z]", '' $built = @() foreach ($line in ($text -split "`r?`n")) { # A local-path build is something the caller pointed at (the CI source overlay), - # never something dependency resolution chose. Index dependencies always print - # `==`, so no signal is lost. + # never something resolution chose; index dependencies always print `==`. if ($line -imatch 'building [a-z0-9._-]+ @ file://') { continue } # pip prints `Building wheel for `, uv prints `Building ==` - # (astral-sh/uv#11165). Requiring `==` or ` @ ` after the name keeps this off the - # installer's own lowercase "building frontend..." progress text. + # (astral-sh/uv#11165); the `==` or ` @ ` requirement keeps this off the + # installer's own lowercase "building frontend..." text. foreach ($m in [regex]::Matches($line, '(?i)building wheel for ([a-z0-9._-]+)|building ([a-z0-9._-]+)(==| @ )')) { $name = if ($m.Groups[1].Success) { $m.Groups[1].Value } else { $m.Groups[2].Value } $built += ($name.ToLowerInvariant() -replace '_', '-') diff --git a/.github/scripts/clean-machine-assert.sh b/.github/scripts/clean-machine-assert.sh index 4bf1f5957f..37b9604f7c 100755 --- a/.github/scripts/clean-machine-assert.sh +++ b/.github/scripts/clean-machine-assert.sh @@ -30,9 +30,8 @@ for check in "$@"; do case "$check" in absent) - # Deliberately NOT `command -v`: on a virgin Mac /usr/bin/{git,cc} EXIST as CLT - # stubs, so `command -v` succeeds and only RUNNING them fails ("invalid active - # developer path"). The honest invariant is: must not WORK. + # NOT `command -v`: on a virgin Mac /usr/bin/{git,cc} EXIST as CLT stubs, so it + # succeeds and only RUNNING them fails. The invariant is: 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 @@ -74,10 +73,9 @@ for check in "$@"; do while IFS=$'\t' read -r tool rest; do [ -n "$tool" ] || continue case " $allow " in *" $tool "*) continue ;; esac - # `xcode-select -p` only ASKS whether a toolchain is selected; the installer - # has to ask, and the fix is that it carries on without one. Counting the - # question as USE would fail the very leg proving the toolchain went - # untouched. `--install`, which pops the CLT installer, stays a hit. + # `xcode-select -p` only ASKS whether a toolchain is selected, and the fix + # is that the installer carries on without one, so the question is not USE. + # `--install`, which pops the CLT installer, stays a hit. if [ "$tool" = "xcode-select" ]; then case "$rest" in -p|--print-path|-v|--version|"") continue ;; @@ -95,9 +93,8 @@ for check in "$@"; do ;; nobuild) - # "Built an sdist" is NOT "needed a compiler", so the contract is "nothing - # needing a COMPILER was built". Every name below was checked against its - # actual sdist: setuptools.build_meta backend, no ext_modules, not one + # "Built an sdist" is NOT "needed a compiler". Every name below was checked + # against its own sdist: setuptools.build_meta backend, no ext_modules, no # .c/.cpp/.pyx/.rs file, so its PEP 517 build is a pure-Python copy step. # openai-whisper, argbind, randomname -- no version ever ships a wheel # antlr4-python3-runtime==4.9.3 -- pinned below the 4.13.2 wheel @@ -110,23 +107,19 @@ for check in "$@"; do # UNSLOTH_ALLOW_SDIST extends the allowlist. # # Lowercased and underscore-folded on both sides: a distribution name and the - # name uv prints can disagree on the separator (requirement triton_kernels vs - # build line triton-kernels), and a one-spelling allowlist silently misses. + # name uv prints can disagree on the separator (triton_kernels vs triton-kernels). _allow="$(printf '%s' "openai-whisper argbind randomname antlr4-python3-runtime triton-kernels ${UNSLOTH_ALLOW_SDIST:-}" | tr 'A-Z_' 'a-z-')" if [ ! -f "$LOG" ]; then fail "nobuild requested but $LOG is missing" else - # uv does NOT use pip's phrasing: it prints `Building ==` to - # stderr (astral-sh/uv#11165), so the pip-only pattern left _built empty on - # every uv source build. Match both. Requiring `==` or ` @ ` after the name - # keeps this off the installer's own lowercase "building frontend..." - # progress text. Strip ANSI first so a coloured run (FORCE_COLOR) parses. - # - # `Building @ file://...` is dropped first: a local-path build is - # something the caller pointed at (install.sh --local, or the - # UNSLOTH_CI_SOURCE_OVERLAY editable overlay), never a dependency resolution - # chose. Index dependencies always print `==`, so no signal is - # lost: a genuine sdist from PyPI is still caught, including one named unsloth. + # uv prints `Building ==`, pip prints `Building wheel for + # ` (astral-sh/uv#11165), so match both; the `==` or ` @ ` requirement + # keeps this off the installer's own lowercase "building frontend..." text, and + # ANSI is stripped first so a coloured run (FORCE_COLOR) parses. + # `Building @ file://...` is dropped: a local-path build is something the + # caller pointed at (--local, or the editable overlay), never something + # resolution chose. Index dependencies always print `==`, so a + # genuine PyPI sdist is still caught, including one named unsloth. _esc=$(printf '\033') _built="$(sed -E "s/${_esc}\[[0-9;]*[A-Za-z]//g" "$LOG" 2>/dev/null \ | grep -viE "building [a-z0-9._-]+ @ file://" \ @@ -157,10 +150,9 @@ for check in "$@"; do macho) # The one thing masking cannot reproduce: Rosetta 2 is preinstalled on hosted # runners and absent from a factory-fresh Mac, so an x86_64-only payload runs - # green here and dies with "bad CPU type in executable" for the user. Assert the - # architecture rather than hope the runner lacks Rosetta. - # `lipo` is an xcrun shim and is gone after masking, so read `file -b`, exactly - # as the desktop lane does. Keyed off `uname -m`, since macos-15-intel is x86_64. + # green here and dies with "bad CPU type in executable" for the user. `lipo` is an + # xcrun shim and gone after masking, so read `file -b`, keyed off `uname -m` + # (macos-15-intel is x86_64). # # SCOPE: all of $MACHO_ROOT, including the .venv_t5_510/_530/_550 sidecars. # Those are payload, not scratch: setup.sh:579-581 creates them during a @@ -203,20 +195,17 @@ for check in "$@"; do *) bad_arch="$bad_arch $f [$desc]" ;; esac - # Signature: MAIN EXECUTABLES ONLY. Asserting it for every Mach-O failed - # the mask/pipe leg on 29 ordinary PyPI extension modules (lxml, - # charset_normalizer, cygrpc, fontTools, ...) plus libportaudio.dylib. - # The premise was wrong: those are MH_BUNDLE/MH_DYLIB images dlopen'd - # into a process without library validation and ship unsigned, and the - # run that flagged them had already imported them with the installer - # exiting 0. Enforcement lands on main executables and gatekept .app - # bundles, so that is all this asserts. + # Signature: MAIN EXECUTABLES ONLY. Asserting it for every Mach-O failed the + # mask/pipe leg on 29 ordinary PyPI extension modules plus libportaudio.dylib: + # those are MH_BUNDLE/MH_DYLIB images dlopen'd without library validation and + # ship unsigned, and that run had already imported them with the installer + # exiting 0. macOS enforces on main executables and gatekept .app bundles. # - # Key off the filetype `file` reports, not the path or extension: a .so - # may be a bundle or a dylib, and an executable may have no extension. - # The library veto is second so a mixed-type fat file counts as a - # library. Substring tests are order-independent: Apple's `file` prints - # `Mach-O 64-bit executable arm64`, GNU's `Mach-O 64-bit arm64 executable`. + # Key off the filetype `file` reports, not the path: a .so may be a bundle or a + # dylib, and an executable may have no extension. The library veto is second so + # a mixed-type fat file counts as a library. Substring tests are + # order-independent (Apple prints `... executable arm64`, GNU `... arm64 + # executable`). _is_exe=0 case "$desc" in *executable*) _is_exe=1 ;; esac case "$desc" in *"shared library"*|*bundle*) _is_exe=0 ;; esac @@ -229,16 +218,14 @@ for check in "$@"; do # ("Killed: 9"), while x86_64 execs it happily, so an unsigned x86_64 # payload is not the same defect. if [ "$want" = "arm64" ] && [ "$_is_exe" = 1 ]; then - # Ad-hoc counts as signed: arm64 linkers apply an ad-hoc seal by - # default, so the test is "has a seal that verifies", not "has an - # identity". `spctl`/`--strict` would demand an authority and reject - # ad-hoc, so neither is used. + # Ad-hoc counts as signed: arm64 linkers seal ad-hoc by default, so the + # test is "has a seal that verifies", not "has an identity". `spctl` and + # `--strict` would demand an authority and reject ad-hoc. if ! codesign -v "$f" >/dev/null 2>&1; then - # Nothing to verify and a seal that does not match mean different - # things. Captured, not piped into grep: `codesign -dvv` exits - # non-zero on an unsigned file, and under the `pipefail` above that - # status is what `codesign ... | grep -q` returns even on a match, - # reporting every unsigned binary as a broken signature. + # Nothing to verify and a seal that does not match mean different things. + # Captured, not piped into grep: `codesign -dvv` exits non-zero on an + # unsigned file, and under `pipefail` that status is what the pipeline + # returns even on a match. _sig="$(codesign -dvv "$f" 2>&1 || true)" case "$_sig" in *"not signed at all"*) unsigned="$unsigned $f" ;; diff --git a/.github/workflows/clean-machine-install-ci.yml b/.github/workflows/clean-machine-install-ci.yml index 448b19e244..5476e1dacc 100644 --- a/.github/workflows/clean-machine-install-ci.yml +++ b/.github/workflows/clean-machine-install-ci.yml @@ -15,24 +15,18 @@ # Linux is the exception: containers are genuinely clean. # # ── What each leg actually puts under test ──────────────────────────────────── -# install.sh / install.ps1 come from this ref, but they install unsloth FROM PyPI, -# the consumer path, which has to stay that way. Everything Python-side is therefore -# read out of the RELEASED wheel: studio/setup.sh, setup.ps1, -# install_python_stack.py, and every requirements and constraints file those reach -# through Path(__file__). Left alone this workflow validates only the two shell -# installers, and a branch changing constraints.txt or setup.ps1 gets a green run -# that proves nothing about the change; the `Assert the Fedora outcome is a known -# one` step below was already working around exactly that. +# install.sh / install.ps1 come from this ref, but they install unsloth FROM PyPI -- +# the consumer path, which has to stay that way -- so everything Python-side would +# come out of the RELEASED wheel (setup.sh, setup.ps1, install_python_stack.py and +# every requirements/constraints file they reach via Path(__file__)), and a branch +# changing any of them would get a green run proving nothing about the change. # -# So `overlay: true` legs re-point the venv at this ref before studio setup runs, via -# UNSLOTH_CI_SOURCE_OVERLAY (install.sh / install.ps1, just above their "Run studio -# setup" section): a `--no-deps` editable install of the checkout. `import studio` -# then resolves to the working tree, so the existing setup-script lookup finds this -# ref's setup.sh / setup.ps1 and install_python_stack reads this ref's constraints. -# Deliberately NOT `install.sh --local`: that also installs -# `unsloth-zoo @ git+https://...`, which genuinely needs git, and git absence is the -# whole point of the masked legs. The overlay resolves nothing and clones nothing, so -# it survives git, cmake and the compilers all being gone. +# `overlay: true` legs therefore re-point the venv at this ref before studio setup +# runs, via UNSLOTH_CI_SOURCE_OVERLAY: a `--no-deps` editable install of the +# checkout, so `import studio` resolves to the working tree and the setup-script +# lookup finds this ref's setup.sh / setup.ps1. Deliberately NOT `install.sh +# --local`, which also pulls `unsloth-zoo @ git+https://...` and so needs the git +# these legs remove; an editable overlay resolves and clones nothing. # # Legs left on `overlay: false`, and why: # mac */mask/pipe the `curl | sh` shape a user runs. Kept end-to-end on the @@ -56,17 +50,14 @@ on: - 'studio/setup.sh' - 'studio/setup.ps1' - 'studio/install_python_stack.py' - # setup.sh (727) and setup.ps1 (2343, 3630, 3916) call these directly, and the + # setup.sh (727) and setup.ps1 (2343, 3630, 3916) call these directly and the # overlay makes them THIS ref's code, so they decide whether a clean machine gets - # a native prebuilt or falls back to a toolchain-dependent path. Left off the - # list, a change to one of them skipped the only workflow that can see it. + # a native prebuilt or a toolchain-dependent fallback. - 'studio/install_*_prebuilt.py' - 'studio/prebuilt_core.py' - 'studio/node_prebuilt_pins.json' - # The overlay exists so a constraints or requirements change is actually - # exercised here (see the header). Without these paths the one workflow that - # resolves them with no compiler and no cached wheels never runs for the PR that - # changes them, and the update-smoke jobs cannot stand in: they start from a + # The overlay exists so a constraints or requirements change is exercised here + # (see the header). The update-smoke jobs cannot stand in: they start from a # preinstalled Python and full developer tooling. - 'studio/backend/requirements/**' - '.github/scripts/clean-machine-*.sh' @@ -78,10 +69,8 @@ on: - '.github/workflows/clean-machine-install-ci.yml' push: branches: [main] - # Same list as the PR filter. A direct push to main touching setup.sh, setup.ps1, - # install_python_stack.py, a requirements file or a clean-machine helper skipped - # this workflow entirely, so the post-merge backstop for exactly those files never - # happened. + # Same list as the PR filter: without it a direct push to main touching any of + # these skipped the workflow, so the post-merge backstop never happened. paths: - 'install.sh' - 'install.ps1' @@ -237,19 +226,19 @@ jobs: set -o pipefail rc=0 FLAGS="${{ matrix.flags }}" - # A consumer has no CI=true, no GITHUB_*, no RUNNER_*: an installer branching - # on any of them is a hidden dependency nobody outside CI exercises. Scoped to - # the installer's own process, so $GITHUB_OUTPUT below still resolves. `case` + # A consumer has no CI=true, no GITHUB_*, no RUNNER_*: branching on any of + # them is a hidden dependency nobody outside CI exercises. Scoped to the + # installer's own process, so $GITHUB_OUTPUT below still resolves. `case` # rather than `sed`, whose BRE has no \| alternation on macOS. CLEAN_ENV="" for v in $(env | cut -d= -f1); do case "$v" in CI|GITHUB_*|RUNNER_*) CLEAN_ENV="$CLEAN_ENV -u $v" ;; esac done echo "unset for the installer:$CLEAN_ENV" - # A `published` dispatch asks whether unsloth.ai's script works. Only `pipe` - # honoured it, so six of the eight macOS rows ran the checked-out script and - # were still reported as published coverage. Resolve it once, here, for every - # delivery. Empty on pull_request/push, so automatic runs stay on this ref. + # A `published` dispatch asks whether unsloth.ai's script works, and only + # `pipe` honoured it, so six of eight macOS rows ran the checked-out script + # under the published label. Resolved once here for every delivery. Empty on + # pull_request/push, so automatic runs stay on this ref. SCRIPT=install.sh if [ "${{ inputs.installer_source }}" = "published" ]; then curl -fsSL https://unsloth.ai/install.sh -o published-install.sh @@ -303,9 +292,8 @@ jobs: fi exit "$rc" - # Without this the gap returns silently: install.sh ignores an unset - # UNSLOTH_CI_SOURCE_OVERLAY, so a typo in the matrix or the expression puts every - # leg back on the released wheel with nothing in the run saying so. + # install.sh ignores an unset UNSLOTH_CI_SOURCE_OVERLAY, so a typo in the matrix + # or the expression silently puts every leg back on the released wheel. - name: Assert this ref's Python was really put under test if: matrix.overlay && inputs.installer_source != 'published' && steps.install.outcome == 'success' run: | @@ -321,9 +309,7 @@ jobs: set -a; . ./clean-machine.env; set +a checks="nobuild" # `absent` ran only BEFORE the install, so an installer that quietly selected - # the CLT or installed a compiler left the leg green while every later source - # build could succeed, the exact behaviour `absent` claims to guard the whole - # run against. Re-run it afterwards. + # the CLT or installed a compiler left the leg green. Re-run it afterwards. [ "${{ matrix.mode }}" = "mask" ] && checks="$checks absent" [ "${{ matrix.mode }}" = "trace" ] && checks="$checks notools" UNSLOTH_CLEAN_ALLOW_WORKING='${{ matrix.allow_working }}' \ @@ -785,21 +771,14 @@ jobs: # ── 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) that had never run - # in CI: tests/sh/test_strixhalo_wsl_reroute.sh extracts functions and mocks the - # environment, which cannot catch anything about a real WSL. + # in CI: tests/sh/test_strixhalo_wsl_reroute.sh mocks the environment, which cannot + # catch anything about a real WSL. No third-party action either -- the official + # Ubuntu rootfs plus `wsl --import` is deterministic and checksum-verifiable. # - # No third-party action: the official Ubuntu rootfs plus `wsl --import` is - # deterministic and checksum-verifiable, adding no supply-chain dependency to a repo - # that audits its lockfiles. - # - # Gating, deliberately: this is the only job that runs the real WSL branch, so a - # job-level continue-on-error made the distro import, the installer exit code, the - # `platform wsl` assertion and the CLI check all unable to fail anything. There is no - # flake to absorb; if the pinned rootfs ever moves, a red job is the correct signal. - # - # It is also the only job that can catch a piped install being truncated: WSL is the - # one platform whose install shells out to Windows interop mid-script, and interop - # relays the stdin it inherited. #7548 is in main now, so this gates unconditionally. + # Gating, deliberately: it is the only job that runs the real WSL branch and the only + # one that can catch a piped install being truncated (WSL shells out to Windows + # interop mid-script, and interop relays the stdin it inherited). There is no flake to + # absorb, and #7548 is in main, so this gates unconditionally. wsl: name: wsl ubuntu-24.04 runs-on: windows-latest @@ -864,12 +843,11 @@ jobs: Write-Host '::error::the WSL install produced no log' exit 1 } - # This job is the one that proves the pipe stays intact. WSL is the only - # platform whose install shells out to Windows interop mid-script - # (_maybe_reroute_strixhalo_to_2404 -> powershell.exe, wsl.exe), and interop - # relays the stdin it inherited, so before #7548 it drank the rest of the - # script and sh died on a half-read line. #7548's _unsloth_main wrapper makes - # sh parse the whole file first; a truncation here means that regressed. + # The pipe-integrity check. Interop (_maybe_reroute_strixhalo_to_2404 -> + # powershell.exe, wsl.exe) relays the stdin it inherited, so before #7548 it + # drank the rest of the script and sh died on a half-read line. #7548's + # _unsloth_main wrapper makes sh parse the file first; a truncation here means + # that regressed. if (Select-String -Path logs/wsl-install.log ` -Pattern 'Syntax error: Unterminated quoted string' -Quiet) { Write-Host '::error::the piped install was truncated again; install.sh is no longer parsed in full before it runs' @@ -878,7 +856,7 @@ jobs: # Printing the code discarded it, and the next step's CLI check does not # compensate: install.sh links the `unsloth` shim (4174-4182) BEFORE it reports # a failing studio/setup.sh (4219-4230), so a late setup failure leaves a shim - # whose --version succeeds and the job looked green. + # whose --version succeeds. if ($installRc -ne 0) { Write-Host "::error::WSL installer exited $installRc" exit $installRc @@ -891,9 +869,8 @@ jobs: # 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 - # Printing could not fail, and that alternation also matches `platform linux`: - # if detection regresses, every WSL branch is skipped and this job still passes - # as a plain-Linux install, the one thing no other job covers. `step` writes + # Printing could not fail, and that alternation also matches `platform linux`, + # so a detection regression would pass as a plain-Linux install. `step` writes # the label in reverse video, so strip ANSI or an anchored match never hits. $esc = [char]27 $platformLines = @( @@ -907,8 +884,7 @@ jobs: exit 1 } # No `|| echo`: substituting a message for the missing CLI made the inner - # shell, this step and the job all succeed even when the install produced - # nothing usable, which is half of what this step asks. + # shell, this step and the job all succeed on an install that produced nothing. $verify = wsl -d unsloth-ci -u root -- sh -c 'set -e; test -x "$HOME/.unsloth/studio/unsloth_studio/bin/unsloth"; "$HOME/.unsloth/studio/unsloth_studio/bin/unsloth" --version' 2>&1 $verifyRc = $LASTEXITCODE $verify | Tee-Object -FilePath logs/wsl-verify.log @@ -995,11 +971,11 @@ jobs: $drop = @('hostedtoolcache\windows\Python', 'WindowsApps', '\Git\', 'CMake', 'Microsoft Visual Studio', 'BuildTools', 'LLVM', 'MSYS', 'mingw', 'Strawberry') - # winget is an app-execution alias in ...\Local\Microsoft\WindowsApps, which - # the blanket drop removes on EVERY leg, so winget=visible was silently running - # the same no-winget fallback as winget=masked. Resolve it before the scrub and - # hand it back through a shim: the visible leg gets winget without the Store's - # python.exe alias returning. windows-11-arm has no winget on the hosted image + # winget is an app-execution alias in ...\Local\Microsoft\WindowsApps, so the + # blanket drop removed it on EVERY leg and winget=visible silently ran the same + # fallback as winget=masked. Resolve it before the scrub and hand it back + # through a shim, so the visible leg gets winget without the Store's python.exe + # alias. windows-11-arm has no winget on the hosted image # (actions/runner-images#14083), so only windows-latest can carry it. $wantWinget = ('${{ matrix.winget }}' -ne 'masked') -and ('${{ matrix.os }}' -eq 'windows-latest') $wingetCmd = Get-Command winget -ErrorAction SilentlyContinue @@ -1074,14 +1050,13 @@ jobs: if ($f -and $t -ne 'py') { $leaked += "$t -> $($f.Source)" } } # The launcher binary may stay, but an interpreter it can still START is a - # leak: Find-CompatiblePython (install.ps1:1130-1153) probes `py` first, so any - # version registered outside the two renamed toolcache directories gets reused - # and Python bootstrap never runs. Exempting `py` left that unchecked. + # leak: Find-CompatiblePython (install.ps1:1130-1153) probes `py` first, so a + # version registered outside the renamed toolcache dirs gets reused and the + # Python bootstrap never runs. if (Get-Command py -ErrorAction SilentlyContinue) { - # -0p prints the launcher's REGISTRY view. The mask renames the toolcache - # directory on disk but cannot rewrite those entries, so -0p keeps naming - # paths that no longer exist: context for a failure, never evidence of one. - # Only a probe that actually STARTS counts. + # -0p is the launcher's REGISTRY view, and the mask renames directories + # without rewriting it, so -0p keeps naming paths that no longer exist: + # context for a failure, never evidence of one. Only a probe that STARTS counts. Write-Host "py -0p (stale registry entries; masked paths no longer exist on disk):" & py -0p 2>&1 | ForEach-Object { Write-Host " $_" } foreach ($v in '-3.11', '-3.12', '-3.13') { @@ -1092,16 +1067,13 @@ jobs: if ($rc -eq 0) { $leaked += "py $v -> $out" } } # A FAILING probe is the outcome we want, but it leaves $LASTEXITCODE - # non-zero and cmdlets never reset it. The runner appends - # if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE } - # to every pwsh step (actions/runner#351), so all three Windows legs exited 1 - # with no ::error:: printed, on machines that were in fact clean, and never - # reached the Install step. + # non-zero and cmdlets never reset it, and the runner appends + # `exit $LASTEXITCODE` to every pwsh step (actions/runner#351) -- so all + # three legs exited 1, silently, on machines that were in fact clean. $global:LASTEXITCODE = 0 } - # Printing alone could not fail, and the leg was green while not clean: run - # 30365014702 logged `python ABSENT` then `Python 3.13 already installed` / - # `Using CPython ... C:\hostedtoolcache\windows\Python\...`. + # Printing alone could not fail: run 30365014702 logged `python ABSENT` then + # `Python 3.13 already installed` / `Using CPython ... C:\hostedtoolcache\...`. if ($leaked) { Write-Host "::error::developer tooling survived the scrub: $($leaked -join '; ')" exit 1 @@ -1114,8 +1086,7 @@ jobs: exit 1 } } elseif ('${{ matrix.os }}' -eq 'windows-latest' -and -not $winget) { - # Without this the visible leg quietly degrades into a second masked leg and - # nothing in this workflow exercises the normal winget bootstrap. + # Otherwise the visible leg quietly degrades into a second masked leg. Write-Host '::error::winget is not resolvable on the visible leg; the winget bootstrap is not under test' exit 1 } @@ -1150,13 +1121,11 @@ jobs: } else { Write-Host "installer: this ref ($env:GITHUB_SHA)" } - # No -SkipTorch: install.ps1 has no param block and its parser matches - # `--no-torch` only (112-142), so the token was silently dropped and every - # Windows leg installed torch anyway. The assert below needs torch, so get it - # on purpose rather than by accident. - # Under powershell.exe, not this pwsh 7 step: a clean Windows box ships - # Windows PowerShell 5.1 only, and the desktop launches it the same way - # (install.rs:325-339). pwsh 7 is a runner-image extra no user is promised. + # No -SkipTorch: install.ps1's parser matches `--no-torch` only (112-142), so + # the token was silently dropped and every leg installed torch anyway. The + # assert below needs torch, so get it on purpose. Run under powershell.exe, not + # this pwsh 7 step: a clean Windows box ships Windows PowerShell 5.1 only, and + # the desktop launches it the same way (install.rs:325-339). & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass ` -File $script *>&1 | Tee-Object -FilePath logs/install.log $rc = $LASTEXITCODE @@ -1171,12 +1140,11 @@ jobs: if: always() && matrix.os == 'windows-11-arm' && steps.install.outcome != 'skipped' shell: pwsh run: | - # The flip condition. #7549 makes install.ps1 prefer an x64 CPython on an ARM64 - # host, and torchaudio does publish win_amd64, so an AMD64 venv interpreter - # means the fix has landed and this pin is stale. Asked of the interpreter - # (sysconfig), not inferred from PROCESSOR_ARCHITECTURE, which describes the - # shell. The failing PyTorch step runs after the venv exists, so the absence of - # that interpreter is itself a different failure. + # The flip condition. #7549 makes install.ps1 prefer an x64 CPython on ARM64 + # hosts and torchaudio does publish win_amd64, so an AMD64 venv interpreter + # means the fix landed. Asked of the interpreter (sysconfig), not inferred from + # PROCESSOR_ARCHITECTURE, which describes the shell. The pinned PyTorch failure + # comes after the venv exists, so a missing interpreter is a different failure. $venvPy = Join-Path $env:UNSLOTH_STUDIO_HOME 'unsloth_studio\Scripts\python.exe' if (-not (Test-Path -LiteralPath $venvPy)) { Write-Host "::error::no venv interpreter at $venvPy; the run did not even reach the pinned PyTorch failure" @@ -1221,12 +1189,11 @@ jobs: run: | # The flip condition. studio/setup.ps1:1655-1669 gates on git unconditionally # and can only get it from winget, so masking winget leaves no way to satisfy - # it; #7549 relaxes the gate to the --local and llama.cpp source-build paths - # that actually use git, so that wording being present means the fix landed. - # Read from the checkout because this row is overlay: true, which is what makes - # the branch's setup.ps1 the one that runs (install.ps1:2626-2642). A published - # dispatch takes setup.ps1 from the released wheel instead, so there the tree - # says nothing and only the outcome checks below can retire the pin. + # it; #7549 relaxes the gate to the --local and source-build paths that really + # use git, so that wording appearing means the fix landed. Read from the + # checkout because overlay: true makes the branch's setup.ps1 the one that runs + # (install.ps1:2626-2642); a published dispatch takes it from the released + # wheel instead, where only the outcome checks below can retire the pin. if ('${{ inputs.installer_source }}' -ne 'published') { $relaxed = Select-String -Path studio/setup.ps1 -SimpleMatch -Quiet ` -Pattern 'Git is required for --local and llama.cpp source-build installs' @@ -1245,10 +1212,10 @@ jobs: } $log = Get-Content logs/install.log -Raw # All three, so a failure anywhere else is still red: winget really was absent, - # the git gate is what fired, and it is what stopped studio setup rather than - # a warning the install walked past. The winget check is not redundant with the - # matrix -- without it this pin would also absorb a leg whose PATH scrub failed - # and which then died on the same gate for an entirely different reason. + # the git gate fired, and it is what stopped studio setup rather than a warning + # the install walked past. The winget check is not redundant with the matrix -- + # without it the pin would absorb a leg whose PATH scrub failed and which then + # died on the same gate for a different reason. $noWinget = $log -match 'will require Python \+ uv to be already installed' $gitGate = $log -match 'Git is required but could not be installed automatically' $setupRc = $log -match 'unsloth studio setup failed \(exit code 1\)' @@ -1274,21 +1241,20 @@ jobs: if: always() && steps.install.outcome != 'skipped' shell: pwsh run: | - # macOS and Linux re-run `absent`/`nobuild` after the install; Windows checked - # nothing afterwards, so setup.ps1 committing to a llama.cpp SOURCE build would - # winget-install CMake (setup.ps1:816-822) and VS Build Tools (845-857) and the - # leg still went green. Git is out of scope on purpose: bootstrapping it through - # winget (setup.ps1:1658-1661) is the consumer path the visible leg exists to - # exercise. The VC++ runtime is a runtime, not a toolchain, and is likewise fine. + # Windows checked nothing after the install, so setup.ps1 committing to a + # llama.cpp SOURCE build would winget-install CMake (setup.ps1:816-822) and VS + # Build Tools (845-857) and the leg still went green. Git is out of scope on + # purpose: bootstrapping it through winget (setup.ps1:1658-1661) is the consumer + # path the visible leg exercises. The VC++ runtime is a runtime, not a toolchain. $bad = @() if (-not (Test-Path logs/install.log)) { Write-Host '::error::no install log, so nothing proves the install stayed toolchain-free' exit 1 } # The announcements inside Ensure-BuildToolsForLlamaSourceBuild, which runs only - # when a source build is committed. Matched instead of the package ids: setup.ps1 - # PRINTS `winget install Microsoft.VisualStudio.2022.BuildTools` as manual advice - # when winget is missing, and advice is not an install. + # for a committed source build. Matched instead of the package ids, because + # setup.ps1 PRINTS `winget install ...BuildTools` as manual advice when winget is + # missing, and advice is not an install. foreach ($m in 'CMake not found -- installing via winget', 'Visual Studio Build Tools not found -- installing via winget') { if (Select-String -Path logs/install.log -Pattern $m -SimpleMatch -Quiet) { @@ -1332,13 +1298,11 @@ jobs: if: steps.install.outcome == 'success' shell: pwsh run: | - # HONESTY NOTE: the hosted image ships the VC++ 2015-2022 runtime in System32 - # and it cannot be removed without breaking the runner, so `import torch` - # succeeding here does NOT prove a genuinely clean no-winget machine has the - # runtime: Test-VCRedistInstalled (setup.ps1:875) finds the preinstalled DLL - # and Ensure-VCRedist (891) short-circuits before it needs winget. Record that, - # then assert what CAN fail: torch imports, and the masked leg really did take - # the no-winget path. + # HONESTY NOTE: the image ships the VC++ 2015-2022 runtime in System32 and it + # cannot be removed without breaking the runner, so `import torch` succeeding + # does NOT prove a clean no-winget machine has it -- Test-VCRedistInstalled + # (setup.ps1:875) finds the preinstalled DLL and Ensure-VCRedist (891) + # short-circuits. Record that, then assert what CAN fail. $sys32 = Join-Path $env:WINDIR 'System32\vcruntime140_1.dll' Write-Host "preinstalled System32 vcruntime140_1.dll: $(Test-Path $sys32)" $py = Join-Path $env:UNSLOTH_STUDIO_HOME 'unsloth_studio\Scripts\python.exe' @@ -1367,12 +1331,10 @@ jobs: # ── Windows, genuinely virgin: the same install inside a Windows container ──── # The `win` legs above only SIMULATE absence, and two things they structurally - # cannot test are the VC++ 2015-2022 runtime (it ships in the runner image's - # System32 and cannot be removed without breaking the runner, hence the HONESTY - # NOTE on the torch assert) and a Windows with no Microsoft Store at all rather - # than a winget hidden from PATH. A servercore container answers both, so this - # lane lives here rather than in a sibling file: same premise, same path filters, - # and the reader compares masked against real in one place. + # cannot test are the VC++ 2015-2022 runtime (it ships in the runner image's System32 + # and cannot be removed without breaking the runner) and a Windows with no Microsoft + # Store at all rather than a winget hidden from PATH. A servercore container answers + # both, so this lane lives here: same premise, same path filters, masked next to real. # # Constraints, all load-bearing: # * `container:` is Linux-only on the Actions runner (actions/runner#1402), so @@ -1395,9 +1357,9 @@ jobs: fetch-depth: 1 persist-credentials: false - # Docker is installed on every windows-2022 image but is not always already - # running: one spike leg died in 21s on npipe:////./pipe/docker_engine, and that - # flake misreads as "Windows containers are unavailable". + # Docker is on every windows-2022 image but is not always already running: one + # spike leg died in 21s on npipe:////./pipe/docker_engine, which misreads as + # "Windows containers are unavailable". - name: Ensure the Docker daemon is running shell: pwsh run: ./.github/scripts/ensure-docker-daemon.ps1 @@ -1418,11 +1380,9 @@ jobs: - name: Start the container shell: pwsh run: | - # Deliberately never refresh a cached image: process isolation needs the - # container build <= the host build, and MCR has shipped a patched image - # ahead of the runner host before (actions/runner-images#11582 broke every - # Windows container job for ~2 weeks). The cached one is the one that - # matched at runner-image build time. + # Never refresh a cached image: process isolation needs the container build + # <= the host build, and MCR has shipped a patched image ahead of the host + # before (actions/runner-images#11582 broke Windows containers for ~2 weeks). if ((docker images --format '{{.Repository}}:{{.Tag}}') -contains $env:IMAGE) { Write-Host "using the runner's pre-cached $env:IMAGE (no pull)" } else { @@ -1515,13 +1475,12 @@ jobs: *>&1 | Tee-Object -FilePath logs/virginity.log exit $LASTEXITCODE - # AFTER the virginity assertion, so that assertion still proves what it says. - # A fresh container ships an almost empty trusted-root store; a real Windows - # desktop fills it via automatic root update, so seeding it makes the container - # MORE representative, not less. Needed because studio/install_node_prebuilt.py - # downloads Node with bare urllib.request.urlopen and so reads the empty Windows - # ROOT store and gets CERTIFICATE_VERIFY_FAILED; uv and pip bundle certifi and - # are unaffected. That product fragility is reported separately, not fixed here. + # AFTER the virginity assertion, so that assertion still proves what it says. A + # fresh container ships an almost empty trusted-root store while a real desktop + # fills it via automatic root update, so seeding makes this MORE representative. + # Needed because studio/install_node_prebuilt.py downloads Node with bare + # urllib.request.urlopen, reads the empty Windows ROOT store and gets + # CERTIFICATE_VERIFY_FAILED; uv and pip bundle certifi. Reported separately. - name: Seed the container's trusted root CA store shell: pwsh run: | diff --git a/.github/workflows/desktop-app-clean-machine-ci.yml b/.github/workflows/desktop-app-clean-machine-ci.yml index acb695f9bf..88dd51303a 100644 --- a/.github/workflows/desktop-app-clean-machine-ci.yml +++ b/.github/workflows/desktop-app-clean-machine-ci.yml @@ -10,12 +10,11 @@ # running its bundled Contents/Resources/install.sh, which no CI job exercised. # # Hosted runners have no interactive desktop session, so "runs" means: the bundle -# installs / mounts / extracts, the binary is present, of the right architecture, and -# clears the gatekeeper checks a user would hit (macOS quarantine + codesign, Windows -# installer exit); the process STAYS UP past its preflight (where an unhappy app dies); -# and it writes tauri.log with a preflight disposition -- the field that read -# `ManagedReady` over an unbootable venv in the bug report. Linux gets the strongest -# check: a real webview under Xvfb. +# installs, the binary is present, of the right architecture, and clears the gatekeeper +# checks a user hits (macOS quarantine + codesign, Windows installer exit); the process +# STAYS UP past its preflight, where an unhappy app dies; and it writes tauri.log with a +# preflight disposition, the 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 @@ -61,12 +60,11 @@ env: # one frozen release, so the schedule was re-testing the same fixture forever. REL_REPO: ${{ inputs.release_repo || github.repository }} # Empty unless dispatched: a pinned tag is an immutable fixture, so a nightly against - # it could never catch a newly published broken bundle. Each download step resolves - # the newest desktop-v* release when this is empty -- drafts included, because that is - # how every desktop-v* release here is cut (desktop-v0.1.50-beta, desktop-v0.1.471-beta - # are both drafts), so --exclude-drafts matched nothing and every leg died resolving. - # A draft has no tag ref and releases/tags/ 404s for one, but gh looks drafts up - # over GraphQL, so `gh release download ` still fetches their assets. + # it could never catch a newly published broken bundle. Each download step then + # resolves the newest desktop-v* release, drafts included -- every desktop-v* release + # here is cut as a draft, so --exclude-drafts matched nothing and every leg died + # resolving. releases/tags/ 404s for a draft, but gh looks drafts up over GraphQL, + # so `gh release download ` still fetches their assets. REL_TAG: ${{ inputs.release_tag || '' }} UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1' @@ -180,11 +178,11 @@ jobs: set -o pipefail APP="$(ls -d /Applications/*Unsloth*.app | head -1)" # A headless runner never clicks Install: preflight sets `not_installed` and - # returns (use-tauri-backend.ts:252-254) while startup-screen.tsx:388-389 - # waits for the button, so launching alone sits on that screen for 90s and - # passes without ever running the bundled installer. Invoke it as - # src-tauri/src/install.rs does: --tauri, stdin closed, no tty. --tauri - # rejects a custom studio home (install.sh:102-114), so drop the override. + # returns (use-tauri-backend.ts:252-254) while startup-screen.tsx:388-389 waits + # for the button, so launching alone sits there for 90s without ever running + # the bundled installer. Invoke it as src-tauri/src/install.rs does: --tauri, + # stdin closed, no tty. --tauri rejects a custom studio home + # (install.sh:102-114), so drop the override. # KNOWN OUTCOME PIN, retire when the desktop release catches up to #7547. # REL_TAG predates #7547, so the bundle's own install.sh still hard-exits on # the Xcode CLT gate that #7547 replaced with a warning. No change to this PR @@ -328,18 +326,14 @@ jobs: ls -la dl - name: Strip the developer toolchain - # Same gate as macOS. Without this the Linux rows ignored strip_toolchain - # entirely and ran the bundled installer with the runner's git, gcc, cmake and - # make in /usr/bin, so a bundle that needs a toolchain passed the one workflow - # whose premise is that it must not. + # Same gate as macOS: without it the Linux rows ignored strip_toolchain and ran + # the bundled installer with the runner's git, gcc, cmake and make in /usr/bin. # # BEFORE the bundle install, as macOS and Windows already do: dpkg runs the - # package's own maintainer scripts, and installing first meant they ran with the - # hosted image's git, compilers and cmake in /usr/bin, so a release whose scripts - # reached for one would pass here and fail on a clean machine. Nothing in that - # install needs a masked tool -- clean-machine-env.sh moves aside only $TOOLS - # (compilers, git, cmake, make, brew, cargo), leaving apt, dpkg and sudo -- and - # the current bundle ships a postrm and no install-time script at all. + # package's own maintainer scripts, so installing first let them see the hosted + # image's toolchain. Nothing in that install needs a masked tool -- + # clean-machine-env.sh moves aside only $TOOLS, leaving the package manager + # itself -- and the current bundle ships a postrm and no install-time script. if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }} run: | bash .github/scripts/clean-machine-env.sh mask --remove @@ -660,13 +654,11 @@ jobs: - name: Run the bundled installer, the path first launch takes shell: pwsh run: | - # The launch step below only proves the process stayed alive. On a fresh - # profile preflight reports not_installed and the app waits for a click on - # Install (use-tauri-backend.ts:252-254, startup-screen.tsx:388-389), so this - # job passed on a bundle whose embedded install.ps1 was missing or broken -- - # the packaged-app failure the workflow exists to catch. - # tauri.conf.json:56-59 ships it as a bundle resource, so find it where NSIS - # put it and invoke it as install.rs:326-341 does. + # The launch step below only proves the process stayed alive: on a fresh + # profile the app waits for a click on Install (use-tauri-backend.ts:252-254, + # startup-screen.tsx:388-389), so this job passed on a bundle whose embedded + # install.ps1 was missing or broken. tauri.conf.json:56-59 ships it as a bundle + # resource, so find it where NSIS put it and invoke it as install.rs:326-341. $root = Split-Path -Parent $env:APP_EXE $ps1 = Get-ChildItem -Path $root -Recurse -Filter 'install.ps1' -ErrorAction SilentlyContinue | Select-Object -First 1 @@ -734,12 +726,11 @@ jobs: -SimpleMatch -Quiet) { $disposition = $true } } } - # Same acceptance criterion macOS and Linux already enforce. Test-Path, - # Get-Content and Select-String cannot fail, so without these two lines the - # step was decoration and the 90s liveness check was the whole bar. - # setup_logging (src-tauri/src/main.rs:50-67) opens tauri.log unconditionally - # at process start, so no log means the binary never got that far, and an app - # that hangs before preflight completes would otherwise pass. + # Same acceptance criterion macOS and Linux enforce. Test-Path, Get-Content and + # Select-String cannot fail, so without these two lines the step was decoration. + # setup_logging (src-tauri/src/main.rs:50-67) opens tauri.log unconditionally at + # process start, so no log means the binary never got that far, and an app that + # hangs before preflight would otherwise pass. if (-not $found) { Write-Host '::error::the app wrote no tauri.log; it never reached setup_logging' exit 1