Staging run 30426111484 failed the macOS torch leg on the landing check: the 3s beat carried the signal from "Installing PyTorch" into "Installing Unsloth", because the PyTorch step, which this workflow called minutes long, finished in under three seconds. The beat only ever existed to land mid-work, and it cannot do that safely: every label prints before its work starts, so detection is already inside the phase, and any wait is a bet on how long that phase runs. It lost in 30419729244 and again here. So the beat is gone rather than retuned, and with it the matrix knob and the driver parameter on both platforms. The landing check stays and can still fail, since a phase shorter than one poll is seen only after it ends. The Windows driver also polled every 500ms while its own comment claimed a fifth of a second. That is 2.5 slices of overshoot the POSIX side does not carry, and it is now 200ms like the POSIX loop.
401 lines
22 KiB
YAML
401 lines
22 KiB
YAML
# 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
|