Skip to content

fix(verify,runs): anchor the dev baseline contract on one tree, one reader #1469

fix(verify,runs): anchor the dev baseline contract on one tree, one reader

fix(verify,runs): anchor the dev baseline contract on one tree, one reader #1469

Workflow file for this run

name: CI
on:
push:
branches: [main, release/*]
pull_request:
# Cancel superseded runs for the same ref.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
name: test (py${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python-version: ['3.11', '3.12', '3.13', '3.14']
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@v10.0.1
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
- name: Ensure tmux (for generic_tmux integration tests)
# Bounded above the fallback's worst case (~260s) so the loop always
# reaches the final `tmux -V` instead of being cancelled mid-attempt.
timeout-minutes: 6
run: |
# The ubuntu-24.04 runner image already ships tmux. Measured on this
# workflow's own run: "tmux is already the newest version
# (3.4-1ubuntu0.1) ... 0 upgraded, 0 newly installed". The apt work this
# step used to do unconditionally therefore installed nothing, while
# taking the full risk of the network to do it:
#
# `apt-get update` produced no output for 14 minutes on `test (py3.11)`
# (run 32230727567) until the 15-minute job cap killed the job, with
# `Install the project` and `Run tests` never reached — so the code
# under review never ran and the red was uninformative. It wedged that
# way four times. `|| true` cannot catch it: a command that never exits
# has no exit status to discard. apt's own Acquire timeouts did not
# fire during the stall either.
#
# So ask first, and touch the network only if the answer is no. What
# follows is a fallback for an image that stops shipping tmux, not the
# normal path: every call is bounded by `timeout` (the bound that
# actually holds here) and retried once. `tmux -V` remains the
# assertion, so a genuine failure fails the job loudly rather than
# silently skipping the tmux integration tests.
if tmux -V; then
exit 0
fi
echo "::warning::tmux absent from the runner image; falling back to apt"
for attempt in 1 2; do
if [ "$attempt" -gt 1 ]; then
sleep 5
# The one breakage `timeout` can itself cause is a kill during
# unpack, which would fail every later attempt with "dpkg was
# interrupted". Bounded too — it is recovery, not a reason to hang.
sudo timeout -k 5 20 dpkg --configure -a || true
fi
sudo timeout -k 5 45 apt-get update \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=20 \
-o Acquire::https::Timeout=20 || true
if sudo timeout -k 5 60 apt-get install -y tmux; then
break
fi
echo "::warning::tmux install attempt ${attempt} failed"
done
tmux -V
- name: Install the project
run: uv sync --locked --all-extras
- name: Run tests
run: uv run pytest -q -n logical --durations=15
test-windows:
# Real-Windows runner for the deterministic suite plus the live psmux gate,
# which this job installs psmux for and runs in its own serial step.
name: test (windows, py${{ matrix.python-version }})
runs-on: windows-latest
# 20, not the 15 the Linux test job uses: the slowest pre-lever Windows run
# took 888s against a 900s cap, so one slow runner was a bad minute away from
# a timeout that reads as a test failure. The levers below cut the measured
# mean to ~316s, but the leg still spreads 220-348s run to run; the extra
# headroom keeps that tail from failing the build first.
timeout-minutes: 20
# The suite reads/writes UTF-8 files; Windows' cp1252 default would break plain
# text I/O. UTF-8 mode (PEP 686's Python 3.15 default) makes the runner match the
# files under test; the conftest guard enforces the same for local win32 runs.
env:
PYTHONUTF8: '1'
strategy:
fail-fast: false
matrix:
# PRs run the version boundaries only (Windows failures here have been
# platform-shaped, not version-shaped); push-to-main runs the full spread.
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["3.11", "3.14"]') || fromJSON('["3.11", "3.12", "3.13", "3.14"]') }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@v10.0.1
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
- name: Install the project
run: uv sync --locked --all-extras
- name: Ensure psmux (for the live multiplexer gate)
# Bounded, for the reason the Linux `Ensure tmux` step above documents at
# length: an unbounded fetch wedged that job for 14 minutes with no exit
# status for `|| true` to discard. Here the retries are bounded by
# Invoke-WebRequest and everything else by the step timeout.
timeout-minutes: 6
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
# After `Install the project`, because the assertion below runs `uv run`.
# What this buys: the 9 tests of the serial gate step below, measured on
# this runner at 9.6s (py3.11) and 12.4s (py3.14) against the job's
# 20-minute cap. A dev box took 46-68s for the same nine — the runner is
# the faster of the two, so the cap is sized off the slower number.
run: |
$ErrorActionPreference = 'Stop'
# The release archive, not `choco install`. Chocolatey's community feed
# answered the V2 package lookup with 503 on BOTH matrix legs of this
# job's first run, while the package itself sat published and healthy.
# Chocolatey document that feed as neither supported nor guaranteed and
# rate-limited per IP behind Cloudflare, and hosted runners share egress
# addresses — other projects track the same feed-503 flake. This is one
# GET against the host the job already depends on.
#
# `releases/latest`, not a pinned tag: the floor assertion below stays
# the only version gate, so a psmux release needs no edit here.
$url = gh api repos/psmux/psmux/releases/latest --jq '.assets[] | select(.name | endswith("windows-x64.zip")) | .browser_download_url'
if (-not $url) { throw 'no windows-x64 asset on the latest psmux release' }
$zip = Join-Path $env:RUNNER_TEMP 'psmux.zip'
$dir = Join-Path $env:RUNNER_TEMP 'psmux'
Invoke-WebRequest -Uri $url -OutFile $zip -MaximumRetryCount 3 -RetryIntervalSec 5
Expand-Archive -LiteralPath $zip -DestinationPath $dir -Force
$exe = Get-ChildItem -Path $dir -Recurse -Filter psmux.exe | Select-Object -First 1
if (-not $exe) { throw "no psmux.exe in $url" }
# The archive ships tmux.exe beside psmux.exe, which looks alarming on a
# PATH but is inert: test_generic_tmux's HAVE_TMUX gates on
# `sys.platform != "win32"` before it ever looks for the binary.
# PATH for this step, GITHUB_PATH for `Run tests` and the gate step.
$env:PATH = "$($exe.Directory.FullName);$env:PATH"
Add-Content -Path $env:GITHUB_PATH -Value $exe.Directory.FullName
psmux -V
# `psmux -V` is not the assertion. test_psmux_live.py skips itself on
# PsmuxMultiplexer.available(), so an install at or below the version
# floor would leave this job green with the entire live gate never run
# — the one failure a "did it install?" check cannot see. Calling
# available() also keeps the floor in a single place (it reads
# `_LAST_UNSUPPORTED`, so a floor bump needs no edit here) and covers
# `pwsh`, which every parked window needs.
uv run python -c "import sys; from bmad_loop.adapters.psmux_backend import PsmuxMultiplexer as M; m = M(); sys.exit(0 if m.available() else f'psmux is not an admitted version: {m.version()!r}')"
- name: Run tests
# pytest roots `tmp_path` at `tempfile.gettempdir()`, which follows
# TMP/TEMP on Windows. Measured on the windows-2025 image (2026-08-14):
# the default is C:\Users\RUNNER~1\AppData\Local\Temp while the workspace
# and RUNNER_TEMP sit on D:\a — so every per-test sandbox copy crossed to
# the slower volume. Pinning both to RUNNER_TEMP keeps them on D:.
# Step-level, not job-level: the `runner` context is unavailable in a
# job `env:` block, where this would silently expand to empty.
env:
TMP: ${{ runner.temp }}
TEMP: ${{ runner.temp }}
run: uv run pytest -q -n logical --durations=15 --ignore=tests/test_psmux_live.py
- name: Run the live psmux gate
# Serial, and deliberately out of the parallel run above: these nine
# tests drive real psmux servers, which are a single-machine resource
# rather than per-worker state. Interleaved with the other ~2800 tests
# under `-n logical`, the window mint in the prune test came back empty
# on a dev box and failed the degraded-mint assertion; the registry
# isolation that lands with this step fixes that test's own asymmetry,
# and running the gate off the shared workers keeps the whole module
# from contending for the machine in the first place. A flake here
# cannot be retried away — this repo has no retry mechanism by policy.
# Cost: 9.6-12.4s measured here, 46-68s on a dev box.
env:
TMP: ${{ runner.temp }}
TEMP: ${{ runner.temp }}
run: uv run pytest -q tests/test_psmux_live.py --durations=15
version-sync:
name: version-sync
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@v10.0.1
- name: Check version fields + the CHANGELOG release contract
run: uv run --no-project python scripts/release.py check
lint:
name: lint (trunk)
runs-on: ubuntu-latest
# Headroom for a cold trunk tool-download cache; normally ~30s.
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
# Trunk needs full history for diff-aware checks...
fetch-depth: 0
# ...but not a credential helper: it only reads local history. Matches
# every other checkout in this file (zizmor/artipacked).
persist-credentials: false
- name: Trunk Check
uses: trunk-io/trunk-action@v1
typecheck:
# Pyright in basic mode over src/bmad_loop (see [tool.pyright] in pyproject.toml).
# Basic-mode adoption (#245, assessment F-4): fences annotation drift the wider
# refactor program relies on. The pinned version keeps the gate reproducible.
name: typecheck (pyright)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@v10.0.1
with:
enable-cache: true
python-version: '3.11'
- name: Install the project
# Populates .venv (venvPath/venv in the pyright config) so third-party and
# optional-extra imports (textual, pyte, httpx, …) resolve instead of being
# flagged as missing. Also installs pyright itself, which is pinned in the
# `dev` dependency group rather than named here — one pin, in the lock.
run: uv sync --locked --all-extras
- name: Pyright (basic mode)
# `uv run`, not `uvx pyright@<version>`: the same command a contributor
# runs locally, resolving the same pinned version from uv.lock.
run: uv run pyright
build:
# Packaging smoke test. Every other job runs from the source tree, so a
# packaging break is invisible to them — it has bitten before (`.trunk` had
# to be excluded from the sdist). This builds both distributions and runs the
# console script out of the installed wheel.
name: build (packaging)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@v10.0.1
with:
# A clean-room build: nothing here should come from a restored cache.
enable-cache: false
- name: Build sdist + wheel
run: uv build --clear
- name: Run the console script from the built wheel
# `--isolated --no-project` resolves `bmad-loop` from the wheel alone
# rather than from the checkout, so a broken entry point or an import the
# wheel cannot satisfy fails here instead of at a user's install.
run: uv run --isolated --no-project --with dist/*.whl -- bmad-loop --version
- name: Run `bmad-loop list` from the built wheel (core install, no extras)
# Pins #650 at the packaging level: `list` must answer from the core
# dependencies alone. --version cannot stand in — argparse answers it
# before any subcommand code imports.
run: |
mkdir -p "$RUNNER_TEMP/empty-project"
uv run --isolated --no-project --with dist/*.whl -- bmad-loop list --project "$RUNNER_TEMP/empty-project"
- name: Check the wheel carries every packaged data file
# The step above cannot stand in for this one: argparse answers --version
# from `__version__` before anything touches `bmad_loop.data`, so it exits
# 0 even on a wheel built with the whole data tree excluded (verified by
# building one). Compare against `git ls-files` rather than a hand-listed
# set, so a newly added resource is covered without anyone remembering.
run: |
uv run --isolated --no-project --with dist/*.whl -- python - <<'PY'
import pathlib
import subprocess
import sys
from importlib.resources import files
ROOT = "src/bmad_loop/data/"
tracked = subprocess.run( # fixed argv, no shell
["git", "ls-files", "-z", ROOT],
capture_output=True,
text=True,
check=True,
timeout=60,
).stdout.split("\0")
expected = [p.removeprefix(ROOT) for p in tracked if p]
if not expected:
sys.exit(f"no tracked files under {ROOT} — this check would pass vacuously")
installed = pathlib.Path(str(files("bmad_loop"))) / "data"
missing = [rel for rel in expected if not installed.joinpath(rel).is_file()]
if missing:
shown = "\n ".join(missing[:20])
more = f"\n ...and {len(missing) - 20} more" if len(missing) > 20 else ""
sys.exit(f"wheel is missing {len(missing)}/{len(expected)}:\n {shown}{more}")
print(f"ok: all {len(expected)} packaged data files are present in the wheel")
PY