Compare commits
30 commits
main
...
ci/interru
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31af36153b | ||
|
|
4493b14647 | ||
|
|
75e5f6a487 | ||
|
|
e5b40c11cf | ||
|
|
566eabf72e | ||
|
|
005f7a2b1b | ||
|
|
1da8e972c3 | ||
|
|
cf5d7affad | ||
|
|
1892b130ce | ||
|
|
055100bff4 | ||
|
|
60d4bdcb06 | ||
|
|
05e417d966 | ||
|
|
8035be12a3 | ||
|
|
52f6b66f52 | ||
|
|
3e4ba77436 | ||
|
|
a9ab8aead0 | ||
|
|
58471e5723 | ||
|
|
1a8ad4ae06 | ||
|
|
1dfa31ad04 | ||
|
|
18b7de14e1 | ||
|
|
d87c998a84 | ||
|
|
5c65d06b0f | ||
|
|
3e7fc344bb | ||
|
|
80fc65dfe3 | ||
|
|
3100e90453 | ||
|
|
987d999688 | ||
|
|
fe846fb43d | ||
|
|
958052e44f | ||
|
|
fd571173dc | ||
|
|
6d99729d58 |
4 changed files with 1089 additions and 0 deletions
211
.github/scripts/interrupt-install.ps1
vendored
Normal file
211
.github/scripts/interrupt-install.ps1
vendored
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||||
|
#
|
||||||
|
# Windows counterpart of interrupt-install.sh: run install.ps1 and kill it partway
|
||||||
|
# through, reproducing a user quitting the desktop app mid-install.
|
||||||
|
#
|
||||||
|
# Windows has no process groups (hence the app's windows_job.rs), so this kills the whole
|
||||||
|
# process TREE: killing only the leader leaves uv/python children to finish the dep pass.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# pwsh -File .github/scripts/interrupt-install.ps1 -Marker 'studio deps' `
|
||||||
|
# -LogPath logs/install.log -InstallArgs '--tauri --no-torch --local'
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$Marker = '',
|
||||||
|
[string]$LogPath = 'logs/install.log',
|
||||||
|
[string]$InstallArgs = '',
|
||||||
|
[int]$KillAtSeconds = 900
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LogPath) | Out-Null
|
||||||
|
Set-Content -Path $LogPath -Value '' -Encoding utf8
|
||||||
|
|
||||||
|
# Stand in for the desktop app, which writes this before spawning the installer
|
||||||
|
# (install.rs). We kill install.ps1 directly, so without it #7490's marker is absent for an
|
||||||
|
# unrelated reason -- exactly what the Windows legs reported. Both locations: Rust
|
||||||
|
# hardcodes ~/.unsloth/studio, CI overrides UNSLOTH_STUDIO_HOME. Never cleared by design.
|
||||||
|
foreach ($dir in @($env:UNSLOTH_STUDIO_HOME, (Join-Path $HOME '.unsloth\studio'))) {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($dir)) { continue }
|
||||||
|
try {
|
||||||
|
New-Item -ItemType Directory -Force -Path $dir -ErrorAction Stop | Out-Null
|
||||||
|
Set-Content -Path (Join-Path $dir '.desktop-install-in-progress') -Value '' -ErrorAction Stop
|
||||||
|
} catch { Write-Host "[interrupt] could not seed install marker in ${dir}: $_" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Its own host, so stdout can be redirected to the log while we poll. That host is WINDOWS
|
||||||
|
# PowerShell 5.1, not pwsh, with install.rs:325-339's exact flags: the only host a real
|
||||||
|
# desktop install ever uses, while every other Windows job in .github runs install.ps1
|
||||||
|
# under pwsh 7, leaving 5.1 behaviour (.NET Framework, OEM/ANSI console encoding, different
|
||||||
|
# native-command and OSArchitecture reporting) covered by nothing. The driver stays under
|
||||||
|
# pwsh; only the installer child and the repair re-run change.
|
||||||
|
$argList = @(
|
||||||
|
'-NoLogo', '-NoProfile', '-NonInteractive',
|
||||||
|
'-WindowStyle', 'Hidden',
|
||||||
|
'-ExecutionPolicy', 'Bypass',
|
||||||
|
'-File', 'install.ps1'
|
||||||
|
)
|
||||||
|
if ($InstallArgs) { $argList += $InstallArgs.Split(' ') }
|
||||||
|
$proc = Start-Process -FilePath 'powershell.exe' -ArgumentList $argList `
|
||||||
|
-RedirectStandardOutput $LogPath -RedirectStandardError "$LogPath.err" `
|
||||||
|
-PassThru -NoNewWindow
|
||||||
|
Write-Host "[interrupt] installer pid=$($proc.Id) marker='$Marker' deadline=${KillAtSeconds}s"
|
||||||
|
|
||||||
|
# Proof that the signal was DELIVERED, not merely attempted. The installer can also fail
|
||||||
|
# on its own between the last HasExited check and Stop-Tree, and a natural failure carries
|
||||||
|
# a non-zero exit code just like a kill does, so the exit status alone cannot separate the
|
||||||
|
# two on Windows. Stop-Process throws on a process that has already gone, so this flag is
|
||||||
|
# false exactly when there was nothing left to interrupt.
|
||||||
|
$script:rootKilled = $false
|
||||||
|
|
||||||
|
function Get-Descendants([int]$RootId) {
|
||||||
|
# Depth-first, deepest first. CIM gives the parent link Windows does not expose via
|
||||||
|
# process groups.
|
||||||
|
$ids = @()
|
||||||
|
foreach ($k in @(Get-CimInstance Win32_Process -Filter "ParentProcessId=$RootId" -ErrorAction SilentlyContinue)) {
|
||||||
|
$kid = [int]$k.ProcessId
|
||||||
|
$ids += Get-Descendants $kid
|
||||||
|
$ids += $kid
|
||||||
|
}
|
||||||
|
return $ids
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-Tree([int]$RootId) {
|
||||||
|
# Snapshot the whole tree BEFORE killing anything. Once a parent is gone its children are
|
||||||
|
# orphaned and there is no ParentProcessId left to walk, so the walk has to happen first.
|
||||||
|
$descendants = @(Get-Descendants $RootId)
|
||||||
|
# Then the ROOT, ahead of its children. install.ps1 watches the child it is waiting on:
|
||||||
|
# in staging run 30424366953 it had already printed "unsloth studio setup failed (exit
|
||||||
|
# code -1)" by the time Stop-Process reached it. Killing children first therefore races
|
||||||
|
# the leader's own exit, and a leader that wins that race makes Stop-Process throw over
|
||||||
|
# an interruption the driver really did deliver, failing the leg for nothing. Dead first,
|
||||||
|
# it cannot react to anything, and it cannot respawn what we are about to kill either.
|
||||||
|
try {
|
||||||
|
Stop-Process -Id $RootId -Force -ErrorAction Stop
|
||||||
|
Write-Host "[interrupt] killed installer pid=$RootId"
|
||||||
|
if ($RootId -eq $proc.Id) { $script:rootKilled = $true }
|
||||||
|
}
|
||||||
|
catch { if ($RootId -eq $proc.Id) { Write-Host "[interrupt] installer pid=$RootId was already gone: $_" } }
|
||||||
|
foreach ($id in $descendants) {
|
||||||
|
try { Stop-Process -Id $id -Force -ErrorAction Stop; Write-Host "[interrupt] killed pid=$id" }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# A leg can be aimed at either kind of phase the installer prints, and only one of them is
|
||||||
|
# a line. install.ps1 prints "[TAURI:STEP] <name>" lines, while the dependency pass rewrites
|
||||||
|
# ONE physical line with \r (install_python_stack.py:2499), so its sub-steps are
|
||||||
|
# CR-separated SEGMENTS. Splitting on \r is what makes a sub-step's END observable at all.
|
||||||
|
$SubRe = '\[[=-]+\]\s*\d+/\d+\s'
|
||||||
|
|
||||||
|
function Get-PhaseLines([string]$Path) {
|
||||||
|
$raw = Get-Content -Path $Path -Raw -ErrorAction SilentlyContinue
|
||||||
|
if (-not $raw) { return @() }
|
||||||
|
return @(($raw -replace "`r", "`n") -split "`n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-LastPhase([string]$Path) {
|
||||||
|
$p = @(Get-PhaseLines $Path | Where-Object { $_ -match '^\[TAURI:STEP\]' -or $_ -match $SubRe })
|
||||||
|
if ($p.Count) { return $p[-1] }
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
# True when the phase the marker named is no longer the running one. A sub-step marker is
|
||||||
|
# judged against the running sub-step, a step marker against the running step -- a step is
|
||||||
|
# not "over" because the sub-steps beneath it advanced.
|
||||||
|
function Test-MarkedPhaseOver {
|
||||||
|
if (-not $Marker) { return $false }
|
||||||
|
$lines = @(Get-PhaseLines $LogPath)
|
||||||
|
$subs = @($lines | Where-Object { $_ -match $SubRe })
|
||||||
|
if ($subs | Where-Object { $_ -match $Marker }) {
|
||||||
|
$last = Get-LastPhase $LogPath
|
||||||
|
return -not ($last -match $SubRe -and $last -match $Marker)
|
||||||
|
}
|
||||||
|
$steps = @($lines | Where-Object { $_ -match '^\[TAURI:STEP\]' })
|
||||||
|
if ($steps | Where-Object { $_ -match $Marker }) {
|
||||||
|
return ($steps[-1] -notmatch $Marker)
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
$killed = $false
|
||||||
|
$reason = ''
|
||||||
|
# Fifth-of-a-second slices, matching the POSIX driver: every phase label prints BEFORE its
|
||||||
|
# work starts, so this delay IS the whole distance between the label and the signal, and it
|
||||||
|
# is the only thing that can push the kill past the end of a short phase. It slept 500ms
|
||||||
|
# while the comment claimed a fifth, so it carried 2.5 slices of overshoot the POSIX side
|
||||||
|
# does not.
|
||||||
|
for ($i = 0; $i -lt ($KillAtSeconds * 5); $i++) {
|
||||||
|
if ($proc.HasExited) { $reason = 'exited-before-marker'; break }
|
||||||
|
if ($Marker) {
|
||||||
|
$hit = Select-String -Path $LogPath -Pattern $Marker -SimpleMatch:$false -ErrorAction SilentlyContinue
|
||||||
|
if ($hit) {
|
||||||
|
# Same as the POSIX driver: signal at detection, never after a delay. The label
|
||||||
|
# prints before the work, so the kill is inside the phase the moment the line
|
||||||
|
# appears, and any wait is a bet on the phase outlasting it that staging runs
|
||||||
|
# 30419729244 and 30426111484 both lost.
|
||||||
|
#
|
||||||
|
# The installer can still exit on its own between the match and the signal, which
|
||||||
|
# would record marker-hit over an install that interrupted nothing.
|
||||||
|
if ($proc.HasExited) { $reason = 'exited-before-signal'; break }
|
||||||
|
$reason = 'marker-hit'
|
||||||
|
$killed = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 200
|
||||||
|
}
|
||||||
|
if (-not $killed -and -not $proc.HasExited) { if (-not $reason) { $reason = 'deadline' }; $killed = $true }
|
||||||
|
|
||||||
|
if ($killed) {
|
||||||
|
Write-Host "[interrupt] killing process tree of $($proc.Id) ($reason)"
|
||||||
|
Stop-Tree $proc.Id
|
||||||
|
# Any straggler uv/python that reparented away from the installer. The old sweep matched
|
||||||
|
# nothing: UNSLOTH_STUDIO_HOME arrives as `D:\a\r\r/.studio-home` (github.workspace joined
|
||||||
|
# with a forward slash) while Process.Path is all backslashes, so the literal -like missed
|
||||||
|
# even the venv's own python. Hence the separator normalisation, and uv by name (it lives
|
||||||
|
# outside the studio home, and the ephemeral runner has no other uv). Under --tauri there
|
||||||
|
# is no UNSLOTH_STUDIO_HOME, so fall back to install.ps1's root or the sweep only sees uv.
|
||||||
|
$studioRoot = if ([string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) { Join-Path $HOME '.unsloth\studio' }
|
||||||
|
else { $env:UNSLOTH_STUDIO_HOME }
|
||||||
|
$homeNorm = if ([string]::IsNullOrWhiteSpace($studioRoot)) { $null }
|
||||||
|
else { ($studioRoot -replace '/', '\').TrimEnd('\') }
|
||||||
|
foreach ($p in @(Get-Process -Name 'uv', 'python', 'pythonw' -ErrorAction SilentlyContinue)) {
|
||||||
|
$path = $null
|
||||||
|
try { $path = $p.Path } catch { }
|
||||||
|
$inHome = $homeNorm -and $path -and ($path -like "$homeNorm\*")
|
||||||
|
if ($p.ProcessName -eq 'uv' -or $inHome) {
|
||||||
|
try { Stop-Process -Id $p.Id -Force; Write-Host "[interrupt] swept $($p.ProcessName) pid=$($p.Id)" } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try { $proc.WaitForExit(30000) | Out-Null } catch { }
|
||||||
|
$rc = if ($proc.HasExited) { $proc.ExitCode } else { 'running' }
|
||||||
|
Write-Host "[interrupt] installer exit=$rc reason=$reason killed=$killed root_killed=$($script:rootKilled)"
|
||||||
|
Write-Host '[interrupt] last log lines:'
|
||||||
|
Get-Content $LogPath -Tail 15 -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
if ($Marker -and -not (Select-String -Path $LogPath -Pattern $Marker -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Host "::warning::marker '$Marker' never appeared -- killed at the deadline, not the intended step"
|
||||||
|
}
|
||||||
|
# Where the signal actually landed. A phase that ended before the poll saw the marker sends
|
||||||
|
# the kill into a LATER phase, and the leg then duplicates whichever leg owns that phase
|
||||||
|
# while its own label claims otherwise.
|
||||||
|
$lastPhase = Get-LastPhase $LogPath
|
||||||
|
Write-Host "[interrupt] phase at kill: $lastPhase"
|
||||||
|
$mismatch = Test-MarkedPhaseOver
|
||||||
|
if ($mismatch) {
|
||||||
|
Write-Host "::warning::killed in '$lastPhase', not the marked phase -- that phase was already over"
|
||||||
|
}
|
||||||
|
# Lower-cased so the workflow can compare it the same way on every platform, and only
|
||||||
|
# simple values: the POSIX side sources this file.
|
||||||
|
@(
|
||||||
|
"interrupt_reason=$reason"
|
||||||
|
"interrupt_killed=$killed"
|
||||||
|
"interrupt_root_killed=$(if ($script:rootKilled) { 'true' } else { 'false' })"
|
||||||
|
"installer_exit=$rc"
|
||||||
|
"interrupt_phase_mismatch=$(if ($mismatch) { 'true' } else { 'false' })"
|
||||||
|
) | Set-Content -Path (Join-Path (Split-Path -Parent $LogPath) 'interrupt.env') -Encoding utf8
|
||||||
|
exit 0
|
||||||
164
.github/scripts/interrupt-install.sh
vendored
Executable file
164
.github/scripts/interrupt-install.sh
vendored
Executable file
|
|
@ -0,0 +1,164 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||||
|
#
|
||||||
|
# Run install.sh and SIGTERM it partway through, reproducing a user quitting the desktop
|
||||||
|
# app mid-install: main.rs cleanup_child_processes() -> install::stop_install() kills the
|
||||||
|
# installer PROCESS GROUP (install.rs:798-807). Group, not leader: `uv`/`python` children
|
||||||
|
# would otherwise finish the dep pass and the leg would prove nothing.
|
||||||
|
#
|
||||||
|
# Usage: bash .github/scripts/interrupt-install.sh "<marker>" "<logfile>" [-- install args]
|
||||||
|
# <marker> log regex to wait for before killing, e.g. "studio deps"; "" kills at deadline.
|
||||||
|
# Env: KILL_AT_SECONDS deadline (default 900), KILL_GRACE grace before SIGKILL (default 10)
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
MARKER="${1:-}"
|
||||||
|
LOG="${2:-logs/install.log}"
|
||||||
|
shift 2 || true
|
||||||
|
[ "${1:-}" = "--" ] && shift
|
||||||
|
KILL_AT_SECONDS="${KILL_AT_SECONDS:-900}"
|
||||||
|
KILL_GRACE="${KILL_GRACE:-10}"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$LOG")"
|
||||||
|
: > "$LOG"
|
||||||
|
|
||||||
|
# The desktop writes this before spawning the installer (install.rs); we kill the installer
|
||||||
|
# directly, so without it #7490's marker is absent for an unrelated reason. Both roots: Rust
|
||||||
|
# hardcodes ~/.unsloth/studio, CI overrides UNSLOTH_STUDIO_HOME. Never cleared by design.
|
||||||
|
for _marker_dir in "${UNSLOTH_STUDIO_HOME:-}" "$HOME/.unsloth/studio"; do
|
||||||
|
[ -n "$_marker_dir" ] || continue
|
||||||
|
mkdir -p "$_marker_dir" 2>/dev/null || continue
|
||||||
|
: > "$_marker_dir/.desktop-install-in-progress" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# Job control puts the child in its own group, so $! is the pgid and `kill -- -$!`
|
||||||
|
# reaches every descendant, as the Rust side does.
|
||||||
|
set -m
|
||||||
|
bash install.sh "$@" > "$LOG" 2>&1 &
|
||||||
|
PID=$!
|
||||||
|
set +m
|
||||||
|
echo "[interrupt] installer pid/pgid=$PID marker='${MARKER}' deadline=${KILL_AT_SECONDS}s"
|
||||||
|
|
||||||
|
# A leg can be aimed at either kind of phase the installer prints, and only one of them is
|
||||||
|
# a line. install.sh prints "[TAURI:STEP] <name>" lines, while the dependency pass rewrites
|
||||||
|
# ONE physical line with \r (install_python_stack.py:2499), so its ten sub-steps are
|
||||||
|
# CR-separated SEGMENTS. Splitting on \r is what makes a sub-step's END observable at all:
|
||||||
|
# without it the "studio deps" leg of staging run 30419729244 killed at "7/10 data designer
|
||||||
|
# deps" with backend_ok=true, having installed the structlog it exists to remove.
|
||||||
|
SUB_RE='\[[=-]+\][[:space:]]*[0-9]+/[0-9]+[[:space:]]'
|
||||||
|
phase_lines() { tr '\r' '\n' < "$LOG" 2>/dev/null || true; }
|
||||||
|
|
||||||
|
# True when the phase the marker named is no longer the running one. A sub-step marker is
|
||||||
|
# judged against the running sub-step, a step marker against the running step -- a step is
|
||||||
|
# not "over" because the sub-steps beneath it advanced. A marker naming neither is not
|
||||||
|
# judged. Results go through variables, never a `| grep -q`, which can report SIGPIPE
|
||||||
|
# through pipefail on a long log.
|
||||||
|
marked_phase_over() {
|
||||||
|
[ -n "$MARKER" ] || return 1
|
||||||
|
local lines steps subs last
|
||||||
|
lines="$(phase_lines)"
|
||||||
|
steps="$(printf '%s\n' "$lines" | grep -aE '^\[TAURI:STEP\]')" || true
|
||||||
|
subs="$(printf '%s\n' "$lines" | grep -aE "$SUB_RE")" || true
|
||||||
|
if [ -n "$subs" ] && [[ $subs =~ $MARKER ]]; then
|
||||||
|
last="$(printf '%s\n' "$lines" | grep -aE "^\[TAURI:STEP\]|$SUB_RE" | tail -1)" || true
|
||||||
|
[[ $last =~ $SUB_RE && $last =~ $MARKER ]] && return 1
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ -n "$steps" ] && [[ $steps =~ $MARKER ]]; then
|
||||||
|
last="$(printf '%s\n' "$steps" | tail -1)"
|
||||||
|
[[ $last =~ $MARKER ]] && return 1
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
killed=false
|
||||||
|
reason=""
|
||||||
|
# Fifth-of-a-second slices: every phase label prints BEFORE its work starts, so the poll
|
||||||
|
# delay is the whole distance between the label and the signal.
|
||||||
|
for i in $(seq 1 $(( KILL_AT_SECONDS * 5 ))); do
|
||||||
|
if ! kill -0 "$PID" 2>/dev/null; then
|
||||||
|
reason="exited-before-marker"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ -n "$MARKER" ] && grep -qE "$MARKER" "$LOG" 2>/dev/null; then
|
||||||
|
# Signal at detection, never after a delay. Every label prints BEFORE its work starts,
|
||||||
|
# so the kill is inside the phase the moment the line appears, and any wait at all is a
|
||||||
|
# bet on how long that phase runs. The bet loses: a flat 3s wait put 5 of the 12 legs
|
||||||
|
# of staging run 30419729244 into a LATER phase, and in 30426111484 it carried the
|
||||||
|
# macOS torch leg from "Installing PyTorch" into "Installing Unsloth" -- a step the
|
||||||
|
# workflow called minutes long finished in under three seconds.
|
||||||
|
#
|
||||||
|
# Between the grep and the signal the installer can still exit on its own, which would
|
||||||
|
# record marker-hit over an install that interrupted nothing.
|
||||||
|
if ! kill -0 "$PID" 2>/dev/null; then
|
||||||
|
reason="exited-before-signal"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
reason="marker-hit"
|
||||||
|
killed=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$killed" != "true" ] && kill -0 "$PID" 2>/dev/null; then
|
||||||
|
reason="${reason:-deadline}"
|
||||||
|
killed=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$killed" = "true" ]; then
|
||||||
|
echo "[interrupt] SIGTERM to process group -$PID ($reason)"
|
||||||
|
kill -TERM -- -"$PID" 2>/dev/null || kill -TERM "$PID" 2>/dev/null || true
|
||||||
|
for _ in $(seq 1 "$KILL_GRACE"); do
|
||||||
|
kill -0 "$PID" 2>/dev/null || break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
# Unconditional, and to the GROUP: the leader exits on SIGTERM while a uv or python
|
||||||
|
# descendant does not, and gating on `kill -0 "$PID"` left that child finishing the dep
|
||||||
|
# pass under the probe. Signalling an empty group is a no-op.
|
||||||
|
echo "[interrupt] SIGKILL to process group -$PID"
|
||||||
|
kill -KILL -- -"$PID" 2>/dev/null || kill -KILL "$PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
wait "$PID" 2>/dev/null
|
||||||
|
rc=$?
|
||||||
|
|
||||||
|
# Only after the reap: an unreaped leader is still a member of its own group, so this
|
||||||
|
# poll would report it alive forever. No installer may still run when the probe starts.
|
||||||
|
if [ "$killed" = "true" ]; then
|
||||||
|
for _ in $(seq 1 "$KILL_GRACE"); do
|
||||||
|
kill -0 -- -"$PID" 2>/dev/null || break
|
||||||
|
kill -KILL -- -"$PID" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
if kill -0 -- -"$PID" 2>/dev/null; then
|
||||||
|
echo "::warning::processes from installer group -$PID outlived SIGKILL"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "[interrupt] installer exit=$rc reason=$reason killed=$killed"
|
||||||
|
echo "[interrupt] last log lines:"
|
||||||
|
tail -15 "$LOG" || true
|
||||||
|
|
||||||
|
# A leg that never reached the target step must be visible, not quietly green.
|
||||||
|
if [ -n "$MARKER" ] && ! grep -qE "$MARKER" "$LOG" 2>/dev/null; then
|
||||||
|
echo "::warning::marker '$MARKER' never appeared -- this leg killed at the deadline, not at the intended step"
|
||||||
|
fi
|
||||||
|
# Where the signal actually landed. A phase that ended before the poll saw the marker sends
|
||||||
|
# the kill into a LATER phase, and the leg then duplicates whichever leg owns that phase
|
||||||
|
# while its own label claims otherwise.
|
||||||
|
_last_phase="$(phase_lines | grep -aE "^\[TAURI:STEP\]|$SUB_RE" | tail -1)" || true
|
||||||
|
echo "[interrupt] phase at kill: $_last_phase"
|
||||||
|
mismatch=false
|
||||||
|
if marked_phase_over; then
|
||||||
|
mismatch=true
|
||||||
|
echo "::warning::killed in '$_last_phase', not the marked phase -- that phase was already over"
|
||||||
|
fi
|
||||||
|
# Only simple values: the workflow sources this file, so the phase text stays out of it.
|
||||||
|
{
|
||||||
|
echo "interrupt_reason=$reason"
|
||||||
|
echo "interrupt_killed=$killed"
|
||||||
|
echo "installer_exit=$rc"
|
||||||
|
echo "interrupt_phase_mismatch=$mismatch"
|
||||||
|
} > "$(dirname "$LOG")/interrupt.env"
|
||||||
|
exit 0
|
||||||
313
.github/scripts/interrupted_install_probe.py
vendored
Normal file
313
.github/scripts/interrupted_install_probe.py
vendored
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||||
|
|
||||||
|
"""After an install is interrupted, decide whether the desktop app WOULD report the
|
||||||
|
resulting venv as healthy -- reproducing the Tauri preflight probes so the regression
|
||||||
|
is testable without building the app.
|
||||||
|
|
||||||
|
The reported bug: quitting the app during the dependency pass SIGTERMs the installer
|
||||||
|
(install.rs stop_install). Landing in the "studio deps" step drops
|
||||||
|
studio/backend/requirements/studio.txt, where structlog is declared. Preflight then
|
||||||
|
probes `unsloth -h` (managed.rs:419) and `studio desktop-capabilities` (managed.rs:318);
|
||||||
|
both SUCCEED because typer/click/rich are core, so the app reports ManagedReady with
|
||||||
|
can_auto_repair=false and the backend dies on `import structlog`.
|
||||||
|
|
||||||
|
ONE implementation for all three platforms. The Windows leg was briefly a bespoke inline
|
||||||
|
PowerShell probe that ran only `-h` and `desktop-capabilities`, so it could not observe
|
||||||
|
`studio_install_ok`, `verify-install` or `desktop-runtime-check` and would have failed
|
||||||
|
the very PRs that add them. A probe that cannot see the fix is worse than no probe.
|
||||||
|
|
||||||
|
Verdicts:
|
||||||
|
HEALTHY the backend boots AND desktop-capabilities reports the install
|
||||||
|
complete -- preflight would report ManagedReady and be right
|
||||||
|
REPAIRABLE the backend is broken AND a probe the DESKTOP consumes reports it,
|
||||||
|
so the app can offer a repair
|
||||||
|
FALSE_READY the backend is broken and every probe says ready -> THE BUG
|
||||||
|
|
||||||
|
Exit: 0 for HEALTHY/REPAIRABLE/NO_CLI, 1 for FALSE_READY, 2 for a usage error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list[str], timeout: int = 120) -> tuple[int, str, str]:
|
||||||
|
"""Returns (rc, stdout, stderr). Kept SEPARATE: preflight pipes stdout and sends
|
||||||
|
stderr to /dev/null (managed.rs:358), so anything folded into stdout here is text
|
||||||
|
the desktop never sees."""
|
||||||
|
try:
|
||||||
|
p = subprocess.run(cmd, capture_output = True, text = True, timeout = timeout)
|
||||||
|
return p.returncode, p.stdout or "", p.stderr or ""
|
||||||
|
except (subprocess.TimeoutExpired, OSError) as e:
|
||||||
|
return 127, "", f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def merged(rc_out_err: tuple[int, str, str]) -> str:
|
||||||
|
"""Both streams, for artefact logs only -- never for parsing."""
|
||||||
|
return rc_out_err[1] + rc_out_err[2]
|
||||||
|
|
||||||
|
|
||||||
|
def has_subcommand(bin_path: str, args: list[str]) -> bool:
|
||||||
|
"""Whether the CLI understands a subcommand at all: older builds lack the newer
|
||||||
|
verify commands, and 'absent' must not be read as 'reported failure'."""
|
||||||
|
rc, _, _ = run([bin_path, *args, "--help"], timeout = 60)
|
||||||
|
return rc == 0
|
||||||
|
|
||||||
|
|
||||||
|
def free_port() -> int:
|
||||||
|
with socket.socket() as s:
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
return int(s.getsockname()[1])
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
ap = argparse.ArgumentParser(description = __doc__)
|
||||||
|
ap.add_argument("bin", help = "path to the unsloth CLI")
|
||||||
|
ap.add_argument("--port", type = int, default = 0, help = "0 picks a free port")
|
||||||
|
ap.add_argument("--out", default = "probe", help = "directory for probe artefacts")
|
||||||
|
ap.add_argument("--boot-timeout", type = int, default = 120)
|
||||||
|
a = ap.parse_args(argv)
|
||||||
|
|
||||||
|
binp = a.bin
|
||||||
|
if not Path(binp).exists():
|
||||||
|
print(f"::error::unsloth bin not found: {binp}")
|
||||||
|
return 2
|
||||||
|
out = Path(a.out)
|
||||||
|
out.mkdir(parents = True, exist_ok = True)
|
||||||
|
port = a.port or free_port()
|
||||||
|
facts: dict[str, object] = {}
|
||||||
|
|
||||||
|
def say(k: str, v: object) -> None:
|
||||||
|
facts[k] = v
|
||||||
|
print(f"[probe] {k:28} = {v}")
|
||||||
|
|
||||||
|
# ── the two probes Tauri preflight actually runs ─────────────────────────
|
||||||
|
# The DESKTOP's deadline, not a generous CI one: preflight times each call out after
|
||||||
|
# 10s (managed.rs:337 for `-h`, :390 for desktop-capabilities) and reports Stale
|
||||||
|
# (managed.rs:471, :521). A longer timeout would call a slow torn venv HEALTHY and skip
|
||||||
|
# the re-run assertion; run() reports a timeout as a non-zero rc, the same REPAIRABLE arm.
|
||||||
|
PREFLIGHT_TIMEOUT = 10
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
r = run([binp, "-h"], timeout = PREFLIGHT_TIMEOUT)
|
||||||
|
(out / "cli-h.log").write_text(merged(r), encoding = "utf-8", errors = "replace")
|
||||||
|
say("cli_h_ok", r[0] == 0)
|
||||||
|
say("cli_h_seconds", round(time.time() - t0, 2))
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
caps_rc, caps_out, caps_err = run(
|
||||||
|
[binp, "studio", "desktop-capabilities", "--json"], timeout = PREFLIGHT_TIMEOUT
|
||||||
|
)
|
||||||
|
(out / "desktop-capabilities.json").write_text(caps_out, encoding = "utf-8", errors = "replace")
|
||||||
|
(out / "desktop-capabilities.stderr.log").write_text(
|
||||||
|
caps_err, encoding = "utf-8", errors = "replace"
|
||||||
|
)
|
||||||
|
say("capabilities_ok", caps_rc == 0)
|
||||||
|
say("capabilities_seconds", round(time.time() - t0, 2))
|
||||||
|
|
||||||
|
# Parse EXACTLY as the desktop does: managed.rs:414 hands the whole stdout buffer to
|
||||||
|
# serde_json, which rejects leading or trailing non-JSON, and stderr was already
|
||||||
|
# discarded at managed.rs:358. Folding stderr in made one warning line enough to fail
|
||||||
|
# the parse and report FALSE_READY over an install the real app offers to repair.
|
||||||
|
# "absent" (studio_install_ok predates the install-manifest work) and "unparseable"
|
||||||
|
# split only for a readable artefact: the desktop reports Stale for both
|
||||||
|
# ("desktop_capability_probe_failed", managed.rs:521). The field is Option<bool>
|
||||||
|
# (managed.rs:43), so serde rejects a non-boolean and the WHOLE payload fails to
|
||||||
|
# deserialize -> Stale; bool() instead read the JSON string "false" as True and
|
||||||
|
# reported HEALTHY over a torn install. Only a literal JSON true counts.
|
||||||
|
install_ok: object = "absent"
|
||||||
|
try:
|
||||||
|
parsed = json.loads(caps_out)
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
install_ok = "unparseable"
|
||||||
|
else:
|
||||||
|
v = parsed.get("studio_install_ok")
|
||||||
|
if v is None:
|
||||||
|
install_ok = "absent"
|
||||||
|
elif isinstance(v, bool):
|
||||||
|
install_ok = v
|
||||||
|
else:
|
||||||
|
install_ok = "non-boolean"
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
install_ok = "unparseable"
|
||||||
|
say("capabilities.studio_install_ok", install_ok)
|
||||||
|
|
||||||
|
# The desktop's own conclusion: Ready only on rc 0 + a parsed payload + a true
|
||||||
|
# studio_install_ok. The predicate is `!= Some(true)` (managed.rs:445), so an ABSENT
|
||||||
|
# field is Stale exactly like a false one; a CLI too old to answer is already rejected
|
||||||
|
# one check earlier on desktop_manageability_version. Leaving "absent" undecided
|
||||||
|
# reported HEALTHY on every booting leg and skipped the repair assertion -- the very
|
||||||
|
# regression `unsloth_cli/commands/studio.py` sits in the path filter to catch.
|
||||||
|
caps_ready = caps_rc == 0 and install_ok is True
|
||||||
|
say("desktop_would_call_install_ok", caps_ready)
|
||||||
|
|
||||||
|
# ── the deeper probes the fix PRs add ────────────────────────────────────
|
||||||
|
# RECORDED, but NOT repair evidence: preflight runs only `-h` and
|
||||||
|
# `studio desktop-capabilities --json` (managed.rs:357, :445), so counting these would
|
||||||
|
# let a leg pass while the real app still reports ManagedReady over a torn install.
|
||||||
|
for label, args in (
|
||||||
|
("verify_install", ["studio", "verify-install"]),
|
||||||
|
("desktop_runtime_check", ["studio", "desktop-runtime-check"]),
|
||||||
|
):
|
||||||
|
if not has_subcommand(binp, args):
|
||||||
|
say(label, "absent")
|
||||||
|
continue
|
||||||
|
r = run([binp, *args], timeout = 300)
|
||||||
|
(out / f"{label}.log").write_text(merged(r), encoding = "utf-8", errors = "replace")
|
||||||
|
say(label, "ok" if r[0] == 0 else "failed")
|
||||||
|
|
||||||
|
# The in-progress marker #7490 writes before spawning the installer. RECORDED ONLY:
|
||||||
|
# both drivers seed it and never clear it, so it is true on every leg by construction,
|
||||||
|
# and using it in the verdict would make FALSE_READY, the one outcome, unreachable.
|
||||||
|
home = Path(os.environ.get("UNSLOTH_STUDIO_HOME") or (Path.home() / ".unsloth" / "studio"))
|
||||||
|
say("install_in_progress_marker", (home / ".desktop-install-in-progress").exists())
|
||||||
|
|
||||||
|
# ── ground truth: does the backend actually boot? ────────────────────────
|
||||||
|
# Own the whole process tree: the CLI spawns uvicorn/python children that would hold
|
||||||
|
# the port and hang the next leg's probe. Same reason the driver kills the group.
|
||||||
|
popen_kw: dict = {}
|
||||||
|
if os.name == "posix":
|
||||||
|
popen_kw["start_new_session"] = True
|
||||||
|
else:
|
||||||
|
popen_kw["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||||
|
# Straight to the artefact file, never a PIPE: nothing drains a pipe until after the
|
||||||
|
# polling loop, so a backend whose imports outrun the OS buffer (64 KiB on Linux and
|
||||||
|
# macOS, one page on Windows) blocks BEFORE binding the port and backend_ok, what this
|
||||||
|
# verdict pivots on, would be false for a perfectly good install.
|
||||||
|
blog_path = out / "backend.log"
|
||||||
|
blog_fh = blog_path.open("w", encoding = "utf-8", errors = "replace")
|
||||||
|
# An interrupted install can leave the console script in place with its venv
|
||||||
|
# interpreter gone. An unguarded spawn would raise, so no verdict.json is written and
|
||||||
|
# both workflows die on json.load. An unlaunchable CLI is a broken backend `-h` flags.
|
||||||
|
proc = None
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[binp, "studio", "--api-only", "-H", "127.0.0.1", "-p", str(port)],
|
||||||
|
stdout = blog_fh,
|
||||||
|
stderr = subprocess.STDOUT,
|
||||||
|
text = True,
|
||||||
|
**popen_kw,
|
||||||
|
)
|
||||||
|
except OSError as e:
|
||||||
|
say("backend_spawn_error", f"{type(e).__name__}: {e}")
|
||||||
|
backend_ok = False
|
||||||
|
deadline = time.time() + a.boot_timeout
|
||||||
|
while proc is not None and time.time() < deadline:
|
||||||
|
if proc.poll() is not None:
|
||||||
|
break
|
||||||
|
for path in ("/api/health", "/healthz"):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}", timeout = 2) as r:
|
||||||
|
if r.status == 200:
|
||||||
|
backend_ok = True
|
||||||
|
break
|
||||||
|
except (urllib.error.URLError, OSError, TimeoutError):
|
||||||
|
pass
|
||||||
|
if backend_ok:
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
def reap() -> None:
|
||||||
|
if proc is None:
|
||||||
|
return
|
||||||
|
if os.name == "posix":
|
||||||
|
import signal
|
||||||
|
|
||||||
|
# start_new_session made this child its own group leader. Read the pgid
|
||||||
|
# BEFORE the reap: once waited on, os.getpgid() raises and escalation targets
|
||||||
|
# nothing.
|
||||||
|
try:
|
||||||
|
pgid = os.getpgid(proc.pid)
|
||||||
|
except OSError:
|
||||||
|
pgid = proc.pid
|
||||||
|
for sig in (signal.SIGTERM, signal.SIGKILL):
|
||||||
|
try:
|
||||||
|
os.killpg(pgid, sig)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
proc.wait(timeout = 10)
|
||||||
|
break
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
continue
|
||||||
|
# Unconditional, and to the GROUP -- the same escalation
|
||||||
|
# interrupt-install.sh:94 makes. The leader exits promptly on SIGTERM while a
|
||||||
|
# uvicorn worker does not, so returning once proc.wait() succeeded left that
|
||||||
|
# worker holding the port and venv while the repair reinstalled underneath.
|
||||||
|
# Signalling an empty group is a no-op.
|
||||||
|
try:
|
||||||
|
os.killpg(pgid, signal.SIGKILL)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# On win32 the CLI re-spawns the server as a CHILD and waits on it
|
||||||
|
# (unsloth_cli/commands/studio.py:1543), and CREATE_NEW_PROCESS_GROUP does not
|
||||||
|
# make terminate() reach descendants, so killing the wrapper alone leaves the
|
||||||
|
# venv locked against the repair. taskkill /T takes the tree.
|
||||||
|
run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], timeout = 30)
|
||||||
|
try:
|
||||||
|
proc.wait(timeout = 10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout = 10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
reap()
|
||||||
|
blog_fh.close()
|
||||||
|
blog = blog_path.read_text(encoding = "utf-8", errors = "replace")
|
||||||
|
say("backend_ok", backend_ok)
|
||||||
|
|
||||||
|
missing = ""
|
||||||
|
for line in blog.splitlines():
|
||||||
|
if "ModuleNotFoundError" in line:
|
||||||
|
missing = line.strip()
|
||||||
|
if missing:
|
||||||
|
say("backend_error", missing)
|
||||||
|
|
||||||
|
# ── verdict ──────────────────────────────────────────────────────────────
|
||||||
|
# A booting backend is not enough. The manifest is written LAST
|
||||||
|
# (install_python_stack.py:3255), so the data-designer leg boots while
|
||||||
|
# desktop-capabilities still says studio_install_ok=false and preflight reports Stale
|
||||||
|
# (managed.rs:445); calling that HEALTHY skipped the re-run step.
|
||||||
|
# `-h` gates it for the same reason: probe_managed_bin runs it FIRST and returns Stale
|
||||||
|
# "cli_unusable" without reaching the capability probe (managed.rs:465-478), so
|
||||||
|
# consulting cli_h_ok only in the repairable arm called a help-less CLI HEALTHY.
|
||||||
|
if backend_ok and caps_ready and facts.get("cli_h_ok"):
|
||||||
|
verdict = "HEALTHY"
|
||||||
|
elif not caps_ready or not facts.get("cli_h_ok"):
|
||||||
|
verdict = "REPAIRABLE"
|
||||||
|
else:
|
||||||
|
verdict = "FALSE_READY"
|
||||||
|
|
||||||
|
facts["verdict"] = verdict
|
||||||
|
(out / "verdict.json").write_text(json.dumps(facts, indent = 2), encoding = "utf-8")
|
||||||
|
print(f"[probe] VERDICT = {verdict}")
|
||||||
|
|
||||||
|
if verdict == "FALSE_READY":
|
||||||
|
print(
|
||||||
|
"::error::Interrupted install reports READY but the backend cannot boot"
|
||||||
|
f" ({missing or 'import failure'}). Preflight sees -h ok + desktop-capabilities"
|
||||||
|
" ok, so the app shows ManagedReady with can_auto_repair=false and the user"
|
||||||
|
" is stuck."
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
if verdict == "REPAIRABLE":
|
||||||
|
print("[probe] incomplete install is detectable -> the desktop app can auto-repair")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
401
.github/workflows/interrupted-install-ci.yml
vendored
Normal file
401
.github/workflows/interrupted-install-ci.yml
vendored
Normal file
|
|
@ -0,0 +1,401 @@
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||||
|
|
||||||
|
# Proves an INTERRUPTED install can never masquerade as a healthy one.
|
||||||
|
#
|
||||||
|
# Reported failure: quitting the app mid-install kills the installer process group
|
||||||
|
# (main.rs cleanup_child_processes -> install.rs:798-807) mid "studio deps", the step
|
||||||
|
# installing studio/backend/requirements/studio.txt where structlog is declared. On
|
||||||
|
# relaunch preflight probes `unsloth -h` and `studio desktop-capabilities --json`; both
|
||||||
|
# succeed because the CLI's own deps (typer/click/rich) are core, so the app reports
|
||||||
|
# ManagedReady with can_auto_repair=false while the backend dies on `import structlog`
|
||||||
|
# and the user is stuck on "Server stopped unexpectedly".
|
||||||
|
#
|
||||||
|
# No CI job had ever interrupted an install. This one kills the installer at each phase
|
||||||
|
# and asserts the result is genuinely healthy or explicitly repairable, never silently ready.
|
||||||
|
|
||||||
|
name: Interrupted install recovery
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'install.sh'
|
||||||
|
- 'install.ps1'
|
||||||
|
- 'studio/setup.sh'
|
||||||
|
- 'studio/setup.ps1'
|
||||||
|
- 'studio/install_python_stack.py'
|
||||||
|
- 'studio/src-tauri/src/install.rs'
|
||||||
|
- 'studio/src-tauri/src/preflight.rs'
|
||||||
|
- 'studio/src-tauri/src/preflight/**'
|
||||||
|
- 'unsloth_cli/commands/studio.py'
|
||||||
|
# Every leg installs the checkout with `--local`, so this file decides the console
|
||||||
|
# script and the core deps: `-h` and `desktop-capabilities` only survive a torn
|
||||||
|
# install because typer/click/rich are declared here, not in an extra. No other
|
||||||
|
# install workflow interrupts the installer.
|
||||||
|
- 'pyproject.toml'
|
||||||
|
# studio_install_ok and verify-install, the decisions the probe asserts on, live
|
||||||
|
# here, so an install_state() that accepts a missing manifest would merge unrun.
|
||||||
|
- 'unsloth_cli/_studio_deps.py'
|
||||||
|
- 'studio/install_manifest.py'
|
||||||
|
# The requirement files are the phases: studio.txt declares structlog, whose absence
|
||||||
|
# IS the reported false-ready bug, so moving a package between them changes what
|
||||||
|
# every interrupted state looks like.
|
||||||
|
- 'studio/backend/requirements/**'
|
||||||
|
# `interrupt*-install*` would match the .sh / .ps1 but NOT the underscored probe
|
||||||
|
# (`*` never matches `/`, and a literal `-install` follows), so list all three.
|
||||||
|
- '.github/scripts/interrupt-install.sh'
|
||||||
|
- '.github/scripts/interrupt-install.ps1'
|
||||||
|
- '.github/scripts/interrupted_install_probe.py'
|
||||||
|
- '.github/workflows/interrupted-install-ci.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── macOS + Linux: kill at each phase ─────────────────────────────────────
|
||||||
|
interrupt:
|
||||||
|
name: ${{ matrix.os }} kill@${{ matrix.label }}
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
timeout-minutes: 60
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
# Only a marker: the driver signals the moment that line appears, and no leg gets
|
||||||
|
# to wait first. Every label prints BEFORE its work starts, so the kill is inside
|
||||||
|
# the phase at detection, and a delay only bets on how long the phase runs. The
|
||||||
|
# bet lost twice, both times turning a leg into a duplicate of the next one: a
|
||||||
|
# flat 3s wait moved 5 of the 12 legs of staging run 30419729244, and in
|
||||||
|
# 30426111484 it carried the macOS torch leg into "Installing Unsloth" because
|
||||||
|
# the PyTorch step, called minutes long here, finished in under three seconds.
|
||||||
|
#
|
||||||
|
# Every leg is a hard gate. There is no continue-on-error cell: a leg allowed to
|
||||||
|
# fail is a warning wearing a red icon, and this workflow's entire claim is that
|
||||||
|
# a killed install cannot report itself healthy.
|
||||||
|
#
|
||||||
|
# The exact reported case: killed during the sub-step that installs structlog.
|
||||||
|
- {os: macos-14, label: studio-deps, marker: 'studio deps'}
|
||||||
|
# Coarse phases, earliest to latest -- each leaves a different partial venv.
|
||||||
|
# No venv cell. "Creating virtual environment" ran 0.107s in staging run
|
||||||
|
# 30419729244 (03:31:07.371 -> 07.478 to "Installing PyTorch"), shorter than any
|
||||||
|
# poll that watches the log, so the kill landed in the NEXT phase every time it
|
||||||
|
# was tried (30423181897 and 30424366953 both). It was the torch leg with a
|
||||||
|
# different label. Lost with it: a venv caught half-written. That state is not
|
||||||
|
# reachable by interruption at this resolution, and it is the only thing lost --
|
||||||
|
# the torch leg lands at the top of the PyTorch step, so what it leaves behind is
|
||||||
|
# already a complete venv with nothing installed into it.
|
||||||
|
- {os: macos-14, label: torch, marker: '\[TAURI:STEP\] Installing PyTorch'}
|
||||||
|
- {os: macos-14, label: unsloth, marker: '\[TAURI:STEP\] Installing Unsloth'}
|
||||||
|
- {os: macos-14, label: setup, marker: '\[TAURI:STEP\] Running Unsloth setup'}
|
||||||
|
# Other dependency-pass sub-steps around the named one. No pip-bootstrap cell for
|
||||||
|
# the same reason as venv: "1/10 pip bootstrap" is over before a poll can see it,
|
||||||
|
# so in both 30419729244 and 30424366953 the signal landed in "2/10 unsloth
|
||||||
|
# extras", which is the next cell down.
|
||||||
|
# No base-packages cell: --local sets skip_base, so install_python_stack returns
|
||||||
|
# before "base packages" ever prints -- that leg ran to completion, proving nothing.
|
||||||
|
- {os: macos-14, label: unsloth-extras, marker: 'unsloth extras'}
|
||||||
|
- {os: macos-14, label: data-designer, marker: 'data designer deps'}
|
||||||
|
# Linux: same teardown path, different package manager and process semantics.
|
||||||
|
- {os: ubuntu-latest, label: studio-deps, marker: 'studio deps'}
|
||||||
|
- {os: ubuntu-latest, label: torch, marker: '\[TAURI:STEP\] Installing PyTorch'}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Linux system deps
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq --no-install-recommends cmake git build-essential libcurl4-openssl-dev
|
||||||
|
|
||||||
|
- name: Install, interrupted at "${{ matrix.label }}"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
KILL_AT_SECONDS: '1500'
|
||||||
|
run: |
|
||||||
|
# --local is load-bearing. Without it install.sh:3996 resolves
|
||||||
|
# `unsloth>=2026.7.5` from PyPI, so the venv gets the PUBLISHED CLI, every
|
||||||
|
# `verify-install` / `desktop-runtime-check` probe reports "absent" whatever the
|
||||||
|
# branch does, and the lane cannot observe the fix it tests. --local overlays the
|
||||||
|
# checkout editable (install.sh:3990) before the dep pass, so a kill at "studio
|
||||||
|
# deps" leaves the branch's CLI installed.
|
||||||
|
bash .github/scripts/interrupt-install.sh \
|
||||||
|
'${{ matrix.marker }}' logs/install.log -- --tauri --local
|
||||||
|
|
||||||
|
- name: The kill must have landed where it was aimed
|
||||||
|
run: |
|
||||||
|
. logs/interrupt.env
|
||||||
|
echo "reason=$interrupt_reason killed=$interrupt_killed exit=$installer_exit"
|
||||||
|
if [ "$interrupt_reason" != "marker-hit" ]; then
|
||||||
|
echo "::error::installer never reached '${{ matrix.marker }}' (reason=$interrupt_reason)."
|
||||||
|
echo "::error::This leg proves nothing. Without this check it passes via the"
|
||||||
|
echo "::error::NO_CLI 'safe' path, which is how a --tauri/UNSLOTH_STUDIO_HOME"
|
||||||
|
echo "::error::conflict once made all 11 POSIX legs vacuously green."
|
||||||
|
tail -30 logs/install.log || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# reason alone is not proof: the installer can finish between the driver's
|
||||||
|
# post-delay liveness check and the signal, leaving reason=marker-hit over a
|
||||||
|
# COMPLETED install that probes HEALTHY and skips the re-run assertion below. The
|
||||||
|
# exit status separates them: SIGTERM takes install.sh's trap to 143
|
||||||
|
# (install.sh:716) and SIGKILL to 137, while only a completed install exits 0.
|
||||||
|
if [ "$installer_exit" = "0" ]; then
|
||||||
|
echo "::error::installer exited 0 -- it COMPLETED inside the kill window, so"
|
||||||
|
echo "::error::nothing was interrupted and this leg asserts nothing."
|
||||||
|
tail -30 logs/install.log || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# ...and it must have died from OUR signal rather than on its own. The installer
|
||||||
|
# can also fail naturally in the same window -- a dependency error exits 1 -- and
|
||||||
|
# that leg would test a broken installer while claiming to test an interrupted
|
||||||
|
# one. Recording whether `kill` returned 0 does not separate them: the leader is
|
||||||
|
# still an unreaped member of its own group, so signalling the group succeeds
|
||||||
|
# even when every process in it is already a zombie. The exit status does: every
|
||||||
|
# POSIX leg of staging run 30424366953 reported 143.
|
||||||
|
case "$installer_exit" in
|
||||||
|
143|137) ;;
|
||||||
|
*)
|
||||||
|
echo "::error::installer exited $installer_exit, which is neither SIGTERM"
|
||||||
|
echo "::error::(143, install.sh's trap at install.sh:716) nor SIGKILL (137)."
|
||||||
|
echo "::error::It died on its own, so this leg interrupted nothing."
|
||||||
|
tail -30 logs/install.log || true
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
# ...and the signal must land in the phase this leg is NAMED for. Warning-only
|
||||||
|
# left staging run 30419729244 fully green with the venv leg's install.log
|
||||||
|
# byte-identical to the torch leg's, and both "studio deps" legs killed past
|
||||||
|
# structlog (their own probe artefacts report backend_ok=true), so the flagship
|
||||||
|
# cell never reproduced the bug it is named after.
|
||||||
|
if [ "$interrupt_phase_mismatch" = "true" ]; then
|
||||||
|
echo "::error::the kill landed in a LATER phase than '${{ matrix.marker }}', so this"
|
||||||
|
echo "::error::leg duplicates whichever leg owns that phase and its label lies."
|
||||||
|
tr '\r' '\n' < logs/install.log | grep -aE '^\[TAURI:STEP\]|\[[=-]+\] *[0-9]+/[0-9]+' || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: What state is the install in?
|
||||||
|
id: probe
|
||||||
|
run: |
|
||||||
|
# --tauri refuses a custom UNSLOTH_STUDIO_HOME, so it installs here.
|
||||||
|
BIN="$HOME/.unsloth/studio/unsloth_studio/bin/unsloth"
|
||||||
|
[ -x "$BIN" ] || BIN="$HOME/.unsloth/studio/bin/unsloth"
|
||||||
|
if [ ! -x "$BIN" ]; then
|
||||||
|
# No CLI at all is SAFE: preflight reports NotInstalled, the app reinstalls.
|
||||||
|
echo "verdict=NO_CLI" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "[probe] no unsloth CLI installed -> preflight reports NotInstalled (safe)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
rc=0
|
||||||
|
python3 .github/scripts/interrupted_install_probe.py "$BIN" --out probe || rc=$?
|
||||||
|
v="$(python3 -c "import json;print(json.load(open('probe/verdict.json'))['verdict'])")"
|
||||||
|
echo "verdict=$v" >> "$GITHUB_OUTPUT"
|
||||||
|
exit "$rc"
|
||||||
|
|
||||||
|
- name: A re-run must repair, not short-circuit
|
||||||
|
# NO_CLI included: a kill at torch lands before "Installing Unsloth"
|
||||||
|
# (install.sh:2125 / :3667 / :3961), so those legs always take NO_CLI and skipping
|
||||||
|
# the re-run left three of them asserting only that a marker appeared. The bug's second half is `install.sh` seeing a "current" version and
|
||||||
|
# no-opping over a broken venv. HEALTHY also needs the install reported complete,
|
||||||
|
# so the data-designer leg (killed before the manifest is written last,
|
||||||
|
# install_python_stack.py:3255) arrives here instead of skipping that assertion.
|
||||||
|
if: always() && steps.probe.outputs.verdict != 'HEALTHY'
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
rc=0
|
||||||
|
bash install.sh --tauri --local < /dev/null 2>&1 | tee logs/repair.log || rc=$?
|
||||||
|
echo "repair exit: $rc"
|
||||||
|
BIN="$HOME/.unsloth/studio/unsloth_studio/bin/unsloth"
|
||||||
|
[ -x "$BIN" ] || BIN="$HOME/.unsloth/studio/bin/unsloth"
|
||||||
|
# No verdict.json when the bin is missing, so check here or json.load crashes.
|
||||||
|
if [ ! -x "$BIN" ]; then
|
||||||
|
echo "::error::after a full re-run there is still no unsloth CLI at $BIN"
|
||||||
|
tail -30 logs/repair.log || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
python3 .github/scripts/interrupted_install_probe.py "$BIN" --out probe-after || true
|
||||||
|
v="$(python3 -c "import json;print(json.load(open('probe-after/verdict.json'))['verdict'])")"
|
||||||
|
# A booting backend IS the repair, whatever the log narrated: judging by log
|
||||||
|
# text failed a leg whose venv was fine, matching only the frontend's "up to date".
|
||||||
|
if [ "$v" = "HEALTHY" ]; then
|
||||||
|
echo "re-run repaired the install (verdict=HEALTHY)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "::error::after a full re-run the backend still does not boot (verdict=$v)"
|
||||||
|
if grep -qiE "(venv|dependenc|python stack)[^|]*(up to date|already current)" logs/repair.log; then
|
||||||
|
echo "::error::and the re-run treated the venv as current instead of repairing it"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Upload logs
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: interrupted-${{ matrix.os }}-${{ matrix.label }}
|
||||||
|
path: |
|
||||||
|
logs/
|
||||||
|
probe/
|
||||||
|
probe-after/
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
# ── Windows: no process groups, so the kill path differs ──────────────────
|
||||||
|
interrupt-windows:
|
||||||
|
name: windows kill@${{ matrix.label }}
|
||||||
|
# No UNSLOTH_STUDIO_HOME here. The app scrubs it (install.rs:202, :356) and
|
||||||
|
# install.ps1:189-215 rejects a custom root under --tauri, so a workspace-scoped root
|
||||||
|
# forced these legs down the shell-install path: UNSLOTH_TAURI_MODE=0, frontend build
|
||||||
|
# on, different root resolution, no bundled-file overlay. Worse, "Installing PyTorch"
|
||||||
|
# is only printed by Write-TauriLog (install.ps1:2440), so the torch leg's marker could
|
||||||
|
# never appear. The runner is ephemeral, so the default root is safe to install into.
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
# install.ps1:121 parses `--no-torch`; `-SkipTorch` matches no case there and is
|
||||||
|
# silently dropped. The torch leg must NOT skip torch or its marker never appears.
|
||||||
|
- {label: studio-deps, marker: 'studio deps', installArgs: '--tauri --no-torch --local'}
|
||||||
|
- {label: torch, marker: 'Installing PyTorch', installArgs: '--tauri --local'}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install, interrupted at "${{ matrix.label }}"
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
pwsh -NoProfile -File .github/scripts/interrupt-install.ps1 `
|
||||||
|
-Marker '${{ matrix.marker }}' -LogPath logs/install.log `
|
||||||
|
-InstallArgs '${{ matrix.installArgs }}' -KillAtSeconds 1500
|
||||||
|
|
||||||
|
- name: The kill must have landed where it was aimed
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$vals = @{}
|
||||||
|
foreach ($line in (Get-Content logs/interrupt.env)) {
|
||||||
|
$kv = $line -split '=', 2
|
||||||
|
if ($kv.Count -eq 2) { $vals[$kv[0]] = $kv[1] }
|
||||||
|
}
|
||||||
|
Write-Host "reason=$($vals['interrupt_reason']) killed=$($vals['interrupt_killed']) root_killed=$($vals['interrupt_root_killed']) exit=$($vals['installer_exit'])"
|
||||||
|
if ($vals['interrupt_reason'] -ne 'marker-hit') {
|
||||||
|
Write-Host "::error::installer never reached '${{ matrix.marker }}' (reason=$($vals['interrupt_reason']))."
|
||||||
|
Write-Host '::error::This leg proves nothing: without this check it passes via the'
|
||||||
|
Write-Host '::error::probe NO_CLI safe path, exactly as the POSIX legs once did.'
|
||||||
|
Get-Content logs/install.log -Tail 30 -ErrorAction SilentlyContinue
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
# Same guard as the POSIX leg: the installer can finish between the driver's
|
||||||
|
# post-delay HasExited check and Stop-Tree, leaving reason=marker-hit over a
|
||||||
|
# COMPLETED install that probes HEALTHY and skips the re-run assertion.
|
||||||
|
# Stop-Process -Force is non-zero, so only a completed install reports 0.
|
||||||
|
# 'running' too, not just 0: the driver reports that when the installer outlived
|
||||||
|
# Stop-Tree and WaitForExit, and a live installer writing into the venv under the
|
||||||
|
# probe is not an interrupted install either. Only a real non-zero code counts.
|
||||||
|
if ($vals['installer_exit'] -eq '0' -or $vals['installer_exit'] -notmatch '^-?\d+$') {
|
||||||
|
Write-Host "::error::installer exit=$($vals['installer_exit']) -- it completed or never"
|
||||||
|
Write-Host '::error::died inside the kill window, so this leg asserts nothing.'
|
||||||
|
Get-Content logs/install.log -Tail 30 -ErrorAction SilentlyContinue
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
# ...and the signal has to have been DELIVERED. A non-zero code is weaker proof
|
||||||
|
# here than on POSIX, where only a signal produces 143/137: install.ps1 failing
|
||||||
|
# on its own also exits non-zero, so a natural failure landing between the
|
||||||
|
# driver's last HasExited check and Stop-Tree would otherwise read as a kill.
|
||||||
|
# Stop-Process throws on a process that is already gone, so the driver records
|
||||||
|
# false exactly when it found nothing left to interrupt.
|
||||||
|
if ($vals['interrupt_root_killed'] -ne 'true') {
|
||||||
|
Write-Host '::error::the driver never terminated the installer -- it was already'
|
||||||
|
Write-Host '::error::gone when Stop-Tree reached it, so it failed on its own and'
|
||||||
|
Write-Host '::error::this leg interrupted nothing.'
|
||||||
|
Get-Content logs/install.log -Tail 30 -ErrorAction SilentlyContinue
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
# Same landing check as the POSIX leg: a phase already over when the poll saw the
|
||||||
|
# marker means the kill hit a LATER phase, so the leg duplicates another one.
|
||||||
|
if ($vals['interrupt_phase_mismatch'] -eq 'true') {
|
||||||
|
Write-Host "::error::the kill landed in a LATER phase than '${{ matrix.marker }}', so this"
|
||||||
|
Write-Host '::error::leg duplicates whichever leg owns that phase and its label lies.'
|
||||||
|
((Get-Content logs/install.log -Raw) -replace "`r", "`n") -split "`n" |
|
||||||
|
Where-Object { $_ -match '^\[TAURI:STEP\]' -or $_ -match '\[[=-]+\]\s*\d+/\d+\s' }
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: What state is the install in?
|
||||||
|
id: probe
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
# --tauri refuses a custom root, so this is where install.ps1:254-262 puts it.
|
||||||
|
$bin = Join-Path $env:USERPROFILE '.unsloth\studio\unsloth_studio\Scripts\unsloth.exe'
|
||||||
|
if (-not (Test-Path $bin)) {
|
||||||
|
"verdict=NO_CLI" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
|
||||||
|
Write-Host '[probe] no unsloth CLI -> preflight reports NotInstalled (safe)'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
# The SAME probe the other platforms run. The bespoke inline version it replaced
|
||||||
|
# checked only `-h` and `desktop-capabilities`, so it could not observe
|
||||||
|
# studio_install_ok / verify-install / desktop-runtime-check and would have
|
||||||
|
# failed the very PRs that add them.
|
||||||
|
python .github/scripts/interrupted_install_probe.py $bin --out probe
|
||||||
|
$rc = $LASTEXITCODE
|
||||||
|
$v = (Get-Content probe/verdict.json -Raw | ConvertFrom-Json).verdict
|
||||||
|
"verdict=$v" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
|
||||||
|
exit $rc
|
||||||
|
|
||||||
|
- name: A re-run must repair, not short-circuit
|
||||||
|
# Same assertion the POSIX legs make, NO_CLI included: without it a Windows leg
|
||||||
|
# proves only that the break was DETECTED, never that install.ps1's version fast
|
||||||
|
# path does not short-circuit over it, the half of the bug that strands the user.
|
||||||
|
if: always() && steps.probe.outputs.verdict != 'HEALTHY'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
# powershell.exe with install.rs:325-339's flags, matching the interrupted run:
|
||||||
|
# the desktop repairs under Windows PowerShell 5.1, so a repair that only works
|
||||||
|
# under pwsh 7 would pass here and still strand the user.
|
||||||
|
powershell.exe -NoLogo -NoProfile -NonInteractive -WindowStyle Hidden `
|
||||||
|
-ExecutionPolicy Bypass -File install.ps1 ${{ matrix.installArgs }} *>&1 |
|
||||||
|
Tee-Object -FilePath logs/repair.log
|
||||||
|
$bin = Join-Path $env:USERPROFILE '.unsloth\studio\unsloth_studio\Scripts\unsloth.exe'
|
||||||
|
if (-not (Test-Path $bin)) {
|
||||||
|
Write-Host "::error::after a full re-run there is still no unsloth CLI at $bin"
|
||||||
|
Get-Content logs/repair.log -Tail 30 -ErrorAction SilentlyContinue
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
python .github/scripts/interrupted_install_probe.py $bin --out probe-after
|
||||||
|
$v = (Get-Content probe-after/verdict.json -Raw | ConvertFrom-Json).verdict
|
||||||
|
# A booting backend IS the repair, whatever the log narrated: judging by log text
|
||||||
|
# failed a POSIX leg whose venv was fine, matching only the frontend's "up to date".
|
||||||
|
if ($v -eq 'HEALTHY') {
|
||||||
|
Write-Host 're-run repaired the install (verdict=HEALTHY)'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
Write-Host "::error::after a full re-run the backend still does not boot (verdict=$v)"
|
||||||
|
$log = Get-Content logs/repair.log -Raw -ErrorAction SilentlyContinue
|
||||||
|
if ($log -match '(?i)(venv|dependenc|python stack)[^|]*(up to date|already current)') {
|
||||||
|
Write-Host '::error::and the re-run treated the venv as current instead of repairing it'
|
||||||
|
}
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Upload logs
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: interrupted-windows-${{ matrix.label }}
|
||||||
|
path: |
|
||||||
|
logs/
|
||||||
|
probe/
|
||||||
|
probe-after/
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: warn
|
||||||
Loading…
Add table
Add a link
Reference in a new issue