Skip to content

chore(deps): bump librt from 0.15.0 to 0.16.0 #640

chore(deps): bump librt from 0.15.0 to 0.16.0

chore(deps): bump librt from 0.15.0 to 0.16.0 #640

Workflow file for this run

name: CI
on:
# Only master runs on push. Feature branches are covered by their pull_request run,
# so a branch with an open PR no longer runs the whole matrix twice (push + PR events;
# the concurrency group below only dedupes within a single event).
push:
branches: [master]
pull_request:
# Weekly run (Mon 06:00 UTC). The reason MOVED on 2026-08-11 and is worth keeping
# straight: `requirements.txt` is now pinned, so pydivert and psutil no longer
# drift. Three things still do, and a scheduled build is the only thing watching
# them between pushes:
# * `requirements-dev.txt` is deliberately unpinned - pytest, hypothesis and
# pytest-cov track latest, and a new pytest can redden a green suite;
# * the runner image and the 3.14.x patch `setup-python` resolves both move
# under us - and that is not theoretical: a 3.14 patch brought Tcl/Tk 9.0,
# whose DLL-embedded library archive broke the frozen exe on an older
# PyInstaller and added `libtommath` to the bundle (2026-08-17).
# `pyinstaller` USED to be unpinned here and in release.yml, which is how the
# same commit could build a working exe on CI and a crashing one on a developer
# machine. It is pinned in requirements-build.txt now, so what this cron watches
# on the build side is the runner and the interpreter, not the freezer.
#
# 🔴 A red cron is only worth the minutes if somebody reads it - and the way
# this repository made sure of that CHANGED on 2026-08-21.
#
# The owner decided on 2026-08-17 that a red weekly run would be read by hand
# (`gh run list --workflow CI --branch master`) rather than announced, and
# reversed that on 2026-08-21 for one reason: **a cron is very easy to miss.**
# Nothing arrives when it goes red, the run hangs off no pull request anybody
# is looking at, and a week later it is off the first page. The narrow
# exception carved out for advisories (the `audit` job below) had already made
# the same argument for a smaller case.
#
# So the `cron-issue` job at the end of this file opens one, deduplicated by
# the set of jobs that failed. It never closes anything: a later green run
# means those jobs passed once, not that the cause is gone.
schedule:
- cron: "0 6 * * 1"
# Allow running the pipeline by hand from the Actions tab.
workflow_dispatch:
# Least-privilege token: CI only reads the repository. If a step ever needs to write
# (releases, pages), raise the permission on THAT job, not here.
permissions:
contents: read
# One in-flight run per ref: a push to a branch with an open PR no longer runs CI twice,
# and a newer push cancels the superseded run (saves minutes, above all the Windows build).
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
# The suite scans FILES. A commit message and a pull-request description are just
# as public and just as permanent - a message cannot be edited away, because the
# commit that carried it stays - and nothing in tests/ can see either of them. So
# the conventions that govern them (English only, plain hyphens, nothing private
# to a machine) were held up by remembering, every time. This is the guard.
public-text:
name: commit messages and PR description
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# the whole range being merged, not just its tip
fetch-depth: 0
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
check-latest: true # newest 3.14.x, not the runner image's cached one
- name: check the commit messages
# Through the environment, for the same reason as the step below: a
# `${{ }}` is pasted into the script BEFORE any shell sees it, so a
# branch name is not data there - it is source code. `base_ref` has to
# name a branch that already exists here, which makes this narrow rather
# than harmless, and the fix is three lines. Quote the variables: an
# unquoted expansion is the same fault one layer down.
env:
BASE_REF: ${{ github.base_ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
python tools/check_public_text.py --commits "origin/$BASE_REF..$HEAD_SHA"
- name: check the pull-request description
# Passed through the environment rather than interpolated into the shell:
# a description is untrusted text and must never become part of a command.
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: |
printf '%s' "$PR_BODY" > pr-body.txt
python tools/check_public_text.py --text-file pr-body.txt
# Static analysis, one platform: ruff reads source, and source does not differ
# between the runners. Kept out of the `tests` matrix for exactly that reason -
# in there it would run twice and say the same thing.
lint:
name: ruff (F, B, C90 and PLR0913 block, S and ASYNC report)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
check-latest: true # newest 3.14.x, not the runner image's cached one
- name: Install the linter
run: pip install --require-hashes -r requirements-lint.txt
# The gate. `--select` on the command line replaces the families named in
# pyproject.toml and nothing else: the per-file ignores and the target
# version still come from there, so the two runs below cannot drift apart
# from what a developer sees locally.
# 🔴 Every blocking rule has to be named HERE as well. `--select` on the
# command line REPLACES the list in pyproject.toml, so a rule added there
# and forgotten here is configured, visible to a developer running plain
# `ruff check`, and absent from the only place that can block a merge.
# tests/test_code_shape.py::test_the_selected_rules_only_ever_grow holds the
# two lists to each other so this cannot drift in silence.
- name: Bugs, dead code, complexity and argument count (blocking)
run: ruff check --select F,B,C90,PLR0913 --output-format github
# The report. S is 306 findings on this repository and 287 of them are
# things a test suite for a packet mangler does on purpose - so it
# annotates the diff and never fails the run. `ruff check` exits 1 on any
# finding, hence --exit-zero rather than a `continue-on-error` on the step:
# a step that "succeeded but failed" reads as a broken job to everybody.
- name: Security patterns and async misuse (report only)
run: ruff check --select S,ASYNC --exit-zero --output-format github
# Its own job rather than a step in `lint`: two gates, two check names, so a
# branch-protection rule can require either one on its own and a red one says
# which of the two it was without opening the log.
types:
name: mypy
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
check-latest: true # newest 3.14.x, not the runner image's cached one
- name: Install the type checker
run: pip install --require-hashes -r requirements-lint.txt
# No project dependencies installed on purpose: `pydivert` is Windows-only
# and `psutil` is not needed to READ this package, and both are ignored by
# name in [tool.mypy]. Verified in that exact shape (Linux, neither
# installed) before this job was written - a missing module and an untyped
# one are different code paths in mypy, and only one of them was measured
# on the developer machine.
- name: Type check the package
run: mypy
# Semgrep, with no account and no token: `p/default` is fetched anonymously
# from the registry (measured - 343 rules, 184 files, 35 s). The rules are used
# here and never carried in the repository, because the Semgrep Rules License
# allows the first and forbids the second.
semgrep:
name: semgrep (ERROR, HIGH and CRITICAL block)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
check-latest: true # newest 3.14.x, not the runner image's cached one
- name: Install the scanner
run: pip install -r requirements-scan.txt
# No --error and no --severity here: the scan reports, and tools/semgrep_gate.py
# decides. `--severity` knows INFO/WARNING/ERROR only, while registry rules also
# carry HIGH and CRITICAL - measured with a probe rule, `--severity ERROR`
# returned zero findings for a rule marked HIGH. Metrics are off: this repository
# does not send telemetry about its own source anywhere.
- name: Scan
run: |
semgrep scan --config p/default --metrics=off --oss-only \
--json --output semgrep.json --quiet
- name: Decide
run: python tools/semgrep_gate.py semgrep.json
# On Windows, and on two schedules of its own - see the triggers below. The
# weekly half asks whether the world changed under versions that did not; the
# pull-request half asks whether the versions changed. It USED to be weekly and
# nothing else, which left a tag that moved a pin unchecked until the following
# Monday, with a merge and possibly a release in between (2026-08-21).
#
# windows-latest because that is where the shipped set actually exists:
# `pydivert` carries a `sys_platform == "win32"` marker, so a Linux runner would
# skip the one dependency that puts a kernel driver on a user's machine.
audit:
# 🔴 A CONTRACT with the branch ruleset, like every other job name here: the
# rule requires a status check by this exact string. Do not edit it without
# editing the ruleset first.
name: pip-audit (advisories against the pinned set)
# Three triggers, two different questions.
#
# schedule / workflow_dispatch - "did the WORLD move under versions that
# did not?" The set is hash-pinned, so only a newly published advisory
# can change the answer, and that is a clock event.
# pull_request - "did the SET move?" A pull request that edits a
# requirements file changes what is installed, and waiting until Monday
# to find out means a merge and possibly a release in between.
#
# A pull request that touches no requirements file cannot change the answer,
# so the job starts, asks that question cheaply, and stops. It runs rather
# than being skipped by a job-level `if:` on purpose: a job that reports
# "skipped" is an awkward thing for a required check to be, while one that
# reports success after finding nothing to do is not.
if: >-
github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|| github.event_name == 'pull_request'
runs-on: windows-latest
timeout-minutes: 15
permissions:
contents: read
# Raised on THIS job only, and only because it opens an issue when it finds
# something on the SCHEDULED path. Nothing else in this workflow may write
# anything, and the pull-request path never uses it - it fails the run
# instead, which is what a pull request can act on.
issues: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# The pull-request path diffs against the base branch, and a shallow
# clone has no base branch to diff against.
fetch-depth: 0
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
check-latest: true # newest 3.14.x, not the runner image's cached one
# 🔴 The cheap question, and the one place this job can go quietly wrong.
# If the base cannot be fetched, `git diff` fails and so does this step -
# deliberately. The alternative shape ("could not tell, so assume nothing
# changed") is the worst failure mode a gate has: green because it could
# not look.
- name: Is there anything to audit
id: scope
if: github.event_name == 'pull_request'
shell: bash
env:
BASE_REF: ${{ github.base_ref }}
run: |
git fetch --no-tags origin "$BASE_REF"
changed="$(git diff --name-only "origin/$BASE_REF...HEAD" -- 'requirements*.txt')"
if [ -n "$changed" ]; then
echo "the pinned set moved:"; echo "$changed"
echo "audit=yes" >> "$GITHUB_OUTPUT"
else
echo "no requirements file changed - the answer cannot have moved"
echo "audit=no" >> "$GITHUB_OUTPUT"
fi
# Every step below is skipped on a pull request that moved no requirements
# file. The condition is repeated rather than shared because GitHub Actions
# has no way to name one - there are no YAML anchors here.
- name: Install the auditor
if: github.event_name != 'pull_request' || steps.scope.outputs.audit == 'yes'
run: pip install -r requirements-scan.txt
# The pinned set, installed exactly as the build installs it, into its own
# environment - then audited BY PATH. Auditing the requirement files instead
# silently skips `packaging` and `setuptools` (measured), and a scanner that
# covers less than it was asked to is worse than no scanner at all.
- name: Install the pinned set into a clean environment
if: github.event_name != 'pull_request' || steps.scope.outputs.audit == 'yes'
shell: bash
run: |
python -m venv audit-env
audit-env/Scripts/python -m pip install --quiet \
--require-hashes -r requirements.txt -r requirements-build.txt
- name: Audit it
if: github.event_name != 'pull_request' || steps.scope.outputs.audit == 'yes'
shell: bash
# pip-audit exits non-zero when it finds something, and finding something
# is what the next step is for - so the exit code is captured, not obeyed.
run: |
set +e
pip-audit --path audit-env/Lib/site-packages -f json -o audit.json
echo "pip-audit exit: $?"
set -e
test -s audit.json || { echo "no report was written"; exit 1; }
# 🔴 The pull-request path ENDS HERE, and it ends by failing. An issue is
# the right answer to "the world moved under a set nobody is touching this
# week"; it is the wrong answer to "this change adds a vulnerable pin",
# which has an author, a diff and a review open right now. The same JSON is
# read by the same tool, so the two paths cannot disagree about what counts
# as a finding.
- name: Refuse a pull request that pins something vulnerable
if: github.event_name == 'pull_request' && steps.scope.outputs.audit == 'yes'
shell: bash
run: |
verdict="$(python tools/audit_issue.py --audit audit.json)"
echo "verdict: $verdict"
if [ "$verdict" != "none" ]; then
echo "::error::This pull request changes a requirements file, and the set"
echo "::error::it pins carries a published advisory. Raise the pin, or drop"
echo "::error::the dependency - see the report above."
exit 1
fi
echo "nothing published against the set this pull request pins."
- name: Open an issue if anything was found
if: github.event_name != 'pull_request'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh issue list --label security-scan --state open --json number,title > open.json
action=$(python tools/audit_issue.py --audit audit.json --existing open.json \
--title-out issue-title.txt --body-out issue-body.md \
--run-url "$RUN_URL")
echo "verdict: $action"
case "$action" in
create)
gh label create security-scan --color B60205 \
--description "opened by the weekly dependency audit" --force
gh issue create --label security-scan \
--title "$(cat issue-title.txt)" --body-file issue-body.md
;;
skip) echo "an open issue already names exactly these advisories" ;;
none) echo "nothing published against the pinned set" ;;
*) echo "unexpected verdict from tools/audit_issue.py"; exit 1 ;;
esac
# The registry that ships is checked by the suite for POINTING at code that
# exists. Nothing in the repository ever RAN one, so "116 caught, 0 survived"
# was true on the day somebody typed it and unverifiable afterwards. This is
# that sentence, executed.
#
# windows-latest, and not to be tidy: `pydivert` carries a win32 marker, and an
# entry whose test needs the driver would report SURVIVED on a Linux runner -
# the test passes there by returning early, which is indistinguishable from a
# guard that does not guard.
mutations:
name: mutation registry
if: github.event_name != 'push'
runs-on: windows-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# `--changed` diffs against the base branch, which has to be here.
fetch-depth: 0
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
check-latest: true # newest 3.14.x, not the runner image's cached one
- name: Install dependencies
run: |
pip install --require-hashes -r requirements.txt
pip install -r requirements-dev.txt
# 🔴 The linter too, and it is not decoration here. Three guards in
# tests/test_code_shape.py read a threshold out of pyproject.toml and
# ask ruff whether anything crosses it. Without ruff on PATH they skip,
# a skip is not a failure, and the runner reports SURVIVED for a
# mutation the guard would have caught anywhere else. Measured on this
# very branch (2026-08-21): two entries came back SURVIVED with no
# defect behind either - the job simply could not run the checks it was
# asked to break. The whole file, never a single package picked out of
# it by grep: a hash cannot travel through `$(grep ...)`.
pip install --require-hashes -r requirements-lint.txt
# A pull request pays for what it touched; the weekly run pays for
# everything. The full pass is minutes (~13 for 117 entries on a developer
# machine), which is fine once a week and absurd on every push.
- name: Break each guarded behaviour and prove its test reddens
shell: bash
env:
BASE_REF: ${{ github.base_ref }}
# Through the environment like every other context value here - the
# repository's own guard refuses a `${{ }}` inside a run block, and it
# refused this one while it was being written.
EVENT_NAME: ${{ github.event_name }}
run: |
if [ "$EVENT_NAME" = "pull_request" ]; then
# No --depth here: `git fetch --depth=0` is an error ("depth 0 is
# not a positive number"), and the checkout above already took the
# full history with fetch-depth: 0.
git fetch --no-tags origin "$BASE_REF"
python tools/mutate.py --changed "origin/$BASE_REF"
else
python tools/mutate.py
fi
tests:
name: tests (${{ matrix.os }}, py${{ matrix.python-version }})
runs-on: ${{ matrix.os }}
timeout-minutes: 20
# The matrix varies the OS, not the interpreter. `requires-python` stays at
# >=3.10 on purpose - installing from source on an older Python is supported -
# but CI proves the version the project is actually developed, built and
# released on. Testing 3.10 was exercising a configuration nobody ships.
# If that trade stops being worth it, add a version back HERE; do not "fix"
# the gap by raising requires-python to match.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.14"]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# The diff-coverage step below compares against the base branch, and a
# shallow clone has no base branch to compare against.
fetch-depth: 0
- name: Verify required release files are in the checkout
# These are git-ignored by pattern and re-included by exception, so a
# missing force-add or a broken .gitignore makes them vanish on a fresh
# clone. Fail here, loudly, instead of deep inside a test or PyInstaller.
shell: bash
run: |
missing=0
for f in BeanNetworkTester.spec VERSION.txt LICENSE \
THIRD-PARTY-NOTICES.md pyproject.toml CHANGELOG.md; do
if [ ! -f "$f" ]; then echo "MISSING from the repo: $f"; missing=1; fi
done
for d in beantester tests lang licenses scenarios; do
if [ ! -d "$d" ]; then echo "MISSING from the repo: $d/"; missing=1; fi
done
if [ "$missing" != "0" ]; then
echo "A required file is not tracked by git. If it is BeanNetworkTester.spec,"
echo "it is git-ignored by *.spec - force-add it: git add -f BeanNetworkTester.spec"
exit 1
fi
echo "All required release files are present."
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true # newest 3.14.x, not the runner image's cached one
cache: pip
- name: Install dependencies
# 🔴 No `pip install --upgrade pip`, here or anywhere else in this repository.
# That line fetched an UNPINNED pip from the index and then used it to check
# the hashes on the next line - the one unverified link in a hash-checked
# chain, and the last place anybody would look. The pip that checks them is
# the one `setup-python` shipped with the interpreter, and that action is
# pinned by SHA. Guarded by tests/test_version_and_release.py.
run: |
pip install --require-hashes -r requirements.txt
pip install -r requirements-dev.txt
# The whole lint file rather than diff-cover picked out of it by grep. A
# hash cannot travel through `$(grep ...)`: it hands pip a bare pin, and
# pip stays out of hash-checking mode. semgrep and pip-audit moved to
# requirements-scan.txt, so this no longer drags an engine onto both legs.
pip install --require-hashes -r requirements-lint.txt
# ONE run of the whole suite, under coverage. testpaths=["tests"] (pyproject)
# means this already includes the suites that used to own separate steps:
# * property-based matchers + CLI fuzzing - the filter mini-language runs in
# the packet hot path (it must never raise), and the CLI is the CI contract
# (every outcome has an exit code, never a traceback);
# * concurrency chaos - threads are only dangerous together: a "running"
# engine with a dead capture thread means WinDivert is queueing the user's
# packets into a void.
# Those steps re-ran tests the full run had just executed, on every matrix cell.
# pytest names the failing file, so the extra checkmarks bought no diagnosis.
#
# COVERAGE_PROCESS_START is not optional: the GUI tests run in subprocesses,
# and without it every line they exercise is reported as uncovered, so the same
# suite reads far lower and a sane gate would fail on healthy code. The measured
# split (with/without it) and the gate itself live in ONE place - pyproject
# (fail_under, and the comment above it) - so they are not restated here, where a
# copied number only drifts out of date.
- name: Test suite + coverage gate (engine, i18n, CLI, fail-safe, tables, chaos)
env:
COVERAGE_PROCESS_START: ${{ github.workspace }}/pyproject.toml
run: python -m pytest tests --cov=beantester --cov-report=term --cov-report=xml
# The whole-repository gate (pyproject: fail_under) answers "is this project
# tested". It cannot answer "is this CHANGE tested": at ~1200 tests the
# average barely moves, so a pull request can add a hundred untested lines
# and still pass it - and those are the lines nobody has run twice yet.
#
# 80 rather than 100, deliberately. Parts of the GUI are only reachable
# through a real Tk event loop that the fake tkinter cannot drive, so
# demanding every changed line would push people towards writing the test
# that is easy instead of the one that matters. Ubuntu only, because the two
# matrix legs produce the same answer, and pull requests only, because there
# is nothing to diff against on a push to master.
- name: Coverage of the lines this pull request changed
if: github.event_name == 'pull_request' && matrix.os == 'ubuntu-latest'
shell: bash
env:
BASE_REF: ${{ github.base_ref }}
run: |
git fetch --no-tags origin "$BASE_REF"
diff-cover coverage.xml --compare-branch "origin/$BASE_REF" --fail-under 80
- name: Coverage report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-${{ matrix.os }}-py${{ matrix.python-version }}
path: coverage.xml
if-no-files-found: ignore
- name: GUI smoke (fake tkinter)
run: python smoke_gui.py
# Headed render on real Tk under a virtual display (Linux only). The fake-tk
# smoke cannot see truncation or DPI/layout breakage; this builds the real App
# at the minimum supported resolution in EVERY shipped language (the script
# discovers them, so a new one is covered the day it lands - Polish strings
# are the longest, and that is where layouts crack) and fails on any clipped
# key widget.
# 🔴 Everything here except the last line is armour against the runner's
# package mirror, and it was paid for: on 2026-08-19 this step burned the
# job's whole 20-minute budget three times in a row without ever starting
# the render. The log said `Ign: http://azure.archive.ubuntu.com/...` four
# times per index list, then went silent for 15 minutes 39 seconds. The
# same step took 17 seconds on master the day before. Nothing in this
# repository changed - GitHub's image points apt at a mirror that stopped
# answering, and `apt-get update` has no timeout of its own.
#
# * touch apt at all only when a package is actually missing;
# * rewrite that mirror to the one that answered (the fallback apt was
# already limping towards);
# * bound every apt call, so a dead mirror costs a minute and a clear
# error rather than the job's whole budget;
# * bound the render too, because a GUI that hangs must look different
# from a mirror that hangs;
# * and run Python UNBUFFERED, because when this hung its output was
# still in a pipe buffer and the archived log showed nothing at all -
# which is why the first diagnosis blamed the wrong command.
- name: GUI render check (real Tk, Xvfb, 1366x768, every language)
if: runner.os == 'Linux'
env:
BEAN_NO_ELEVATE: "1"
run: |
missing=""
for package in python3-tk xvfb; do
dpkg -s "$package" >/dev/null 2>&1 || missing="$missing $package"
done
if [ -n "$missing" ]; then
echo "installing:$missing"
# 🔴 The mirror is NOT in sources.list on these images - it is in a
# MIRRORLIST that sources.list points at, and the first rewrite here
# missed it. The log said so plainly and it took a second failure to
# read it: `Get:1 file:/etc/apt/apt-mirrors.txt Mirrorlist [144 B]`,
# then `Ign: http://azure.archive.ubuntu.com/...` for every index.
for source in /etc/apt/apt-mirrors.txt /etc/apt/sources.list \
/etc/apt/sources.list.d/*.sources \
/etc/apt/sources.list.d/*.list; do
if [ -f "$source" ]; then
sudo sed -i 's|azure.archive.ubuntu.com|archive.ubuntu.com|g' "$source"
fi
done
# The image ships package lists, so the cheap path is to install
# without refreshing them at all. `apt-get update` is the expensive,
# network-bound half, and it is only worth paying for when the lists
# really are too old for the package we need.
if ! sudo timeout 180 apt-get install -y --no-install-recommends $missing; then
echo "install from the shipped lists failed - refreshing them"
sudo timeout 240 apt-get update \
-o Acquire::Retries=2 \
-o Acquire::http::Timeout=15 -o Acquire::https::Timeout=15
sudo timeout 240 apt-get install -y --no-install-recommends $missing
fi
else
echo "python3-tk and xvfb are already on the image"
fi
timeout 300 xvfb-run -a --server-args="-screen 0 1366x768x24" \
python -u tools/ci_gui_render.py
# The CLI contract itself: --simulate needs neither Windows, WinDivert nor
# admin, so every runner can prove it end to end.
- name: CLI - simulation run
run: python bean_network_tester.py --simulate --loss 10 --duration 2 --interval 1 --seed 42
- name: CLI - config validation without touching the driver
run: python bean_network_tester.py --dry-run --loss 10 --dst-port "80,443,!8080"
- name: CLI - environment report
run: python bean_network_tester.py --doctor
- name: CLI - licence and third-party notices
run: python bean_network_tester.py --license
# Exit codes are the contract CI depends on - assert them, do not assume.
- name: CLI - exit codes are honoured
shell: bash
run: |
set +e
python bean_network_tester.py --simulate --duration 1 ; ok=$?
python bean_network_tester.py --simulate --loss 250 ; bad_cfg=$?
python bean_network_tester.py --simulate --duration 1 --scenario nope.json ; bad_scen=$?
python bean_network_tester.py --simulate --duration 1 --min-packets 999999999 ; assertion=$?
set -e
echo "ok=$ok config=$bad_cfg scenario=$bad_scen assertion=$assertion"
test "$ok" = "0" || { echo "a clean run must exit 0"; exit 1; }
test "$bad_cfg" = "3" || { echo "a bad value must exit 3"; exit 1; }
test "$bad_scen" = "4" || { echo "a bad scenario must exit 4"; exit 1; }
test "$assertion" = "6" || { echo "a failed check must exit 6"; exit 1; }
# Machine-readable output must stay machine-readable (stdout = NDJSON only).
- name: CLI - NDJSON output parses
shell: bash
run: |
python bean_network_tester.py --simulate --duration 2 --interval 1 --format json --seed 7 > run.ndjson
python -c "
import json
records = [json.loads(l) for l in open('run.ndjson') if l.strip()]
summary = records[-1]
assert summary['event'] == 'summary', summary
assert summary['exit_code'] == 0 and summary['seed'] == 7, summary
print('NDJSON ok:', len(records), 'records')
"
build:
name: build the executable (Windows)
runs-on: windows-latest
timeout-minutes: 30
needs: tests
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Must match the interpreter release.yml builds with: PyInstaller freezes
# THIS Python into the bundle, so a CI build on a different one is not
# smoke-testing the artefact users get.
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
check-latest: true # newest 3.14.x, not the runner image's cached one
cache: pip
- name: Install dependencies
run: |
# One hash-checked resolution over both files: the runtime pins and
# the freezer's whole closure. Nothing here may resolve to bytes
# that are not written down.
pip install --require-hashes -r requirements.txt -r requirements-build.txt
# onedir + console subsystem + asInvoker - see BeanNetworkTester.spec
- name: Build
run: pyinstaller --noconfirm BeanNetworkTester.spec
# The whole point of the console-subsystem build: the CLI works from the
# EXECUTABLE, the shell waits for it, and the exit code comes back.
- name: Smoke the built exe (CLI mode)
shell: bash
run: |
set +e
EXE="dist/BeanNetworkTester/BeanNetworkTester.exe"
"$EXE" --version ; version=$?
"$EXE" --simulate --duration 2 --interval 1 --seed 42 ; run=$?
"$EXE" --simulate --loss 250 ; bad=$?
set -e
echo "version=$version run=$run bad=$bad"
test "$version" = "0" || { echo "--version must work from the exe"; exit 1; }
test "$run" = "0" || { echo "the exe must run a simulated session"; exit 1; }
test "$bad" = "3" || { echo "the exe must report a config error as 3"; exit 1; }
- name: The exe ships the WinDivert driver next to itself (never in %TEMP%)
shell: bash
run: |
find dist/BeanNetworkTester -iname "WinDivert*" -print | tee found.txt
test -s found.txt || { echo "WinDivert.dll/.sys is missing from the bundle"; exit 1; }
# 🔴 The scanner guards the REGISTRY, not the other way round. `legal.py` is
# the reviewed list of what we ship and the SBOM is generated from it, so the
# question worth asking on every build is "does the bundle contain something
# that list does not know?". It already has an answer: on 2026-08-11 this
# comparison found zlib, libffi and 42 Microsoft runtime files shipping in
# every release while every list claimed to be complete.
#
# Here rather than in release.yml deliberately. This job has `contents: read`
# and cuts no tag, so a third-party action costs far less here - and a hole
# found at PR time is cheaper than one found while publishing. Syft is pinned
# by SHA; the tag it stood at is in the comment.
- name: Scan the bundle and hold the licence registry to it
uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
with:
path: dist/BeanNetworkTester
format: syft-json
output-file: bundle-scan.json
upload-artifact: false
upload-release-assets: false
- name: The registry must account for everything the scan found
shell: bash
run: python tools/sbom.py --audit-bundle bundle-scan.json
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: BeanNetworkTester-windows
path: dist/BeanNetworkTester
# 🔴 The weekly run tells nobody it went red. This job is the telling, and it
# reverses the 2026-08-17 decision - see the note beside `schedule` at the top.
#
# `needs` NAMES EVERY OTHER JOB, and that is load-bearing rather than tidy: a
# job left out of this list can fail every Monday for a year in perfect silence.
# tests/test_version_and_release.py holds the two lists to each other so adding
# a job to this workflow cannot quietly leave it unwatched.
#
# `always()` because `needs` on a job that was SKIPPED (public-text is
# pull-request-only, so it is skipped here) would otherwise skip this one too.
# `contains(needs.*.result, 'failure')` and not `failure()`: a cancelled run -
# superseded, or out of time - says nothing about the code, and an issue for it
# is the noise that teaches people to close these unread. The tool refuses that
# case a second time, in tests/test_cron_issue.py.
cron-issue:
name: open an issue when the weekly run is red
needs: [public-text, lint, types, semgrep, audit, mutations, tests, build]
# 🔴 `workflow_dispatch` is here so this path can be PROVEN. Until it was
# added, the only way to find out whether `gh issue create` works with these
# permissions was a red Monday - which is to say, the notice that exists
# because a cron is easy to miss was itself unexercised, and would have been
# discovered broken at exactly the moment it was needed.
#
# A hand-fired run can sit on any branch, so `tools/cron_issue.py` puts the
# ref in the title whenever it is not the default one. Without that, an issue
# from a test run would carry the same title a genuine Monday failure of the
# same job produces, and the real one would come back as `skip` - swallowed,
# silently, by the test issue.
#
# `pull_request` is deliberately absent and must stay absent: a notice that
# fired there would open an issue per pull request.
if: >-
always()
&& (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
&& contains(needs.*.result, 'failure')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
# Raised on THIS job only, for the same reason as on `audit`: it opens an
# issue and does nothing else.
issues: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
check-latest: true # newest 3.14.x, not the runner image's cached one
- name: Say which jobs went red, once
shell: bash
# Every context value travels through the environment, never into the
# script text: the repository's own guard refuses a `${{ }}` inside a
# `run:` block, and `toJSON(needs)` is exactly the kind of value that
# must not become source code.
env:
GH_TOKEN: ${{ github.token }}
NEEDS: ${{ toJSON(needs) }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
HEAD_SHA: ${{ github.sha }}
HEAD_REF: ${{ github.ref }}
run: |
printf '%s' "$NEEDS" > needs.json
gh issue list --label ci-cron --state open --json number,title > open.json
action=$(python tools/cron_issue.py --needs needs.json --existing open.json \
--title-out issue-title.txt --body-out issue-body.md \
--run-url "$RUN_URL" --commit "$HEAD_SHA" --ref "$HEAD_REF")
echo "verdict: $action"
case "$action" in
create)
gh label create ci-cron --color D93F0B \
--description "opened by the weekly scheduled run" --force
gh issue create --label ci-cron \
--title "$(cat issue-title.txt)" --body-file issue-body.md
;;
skip) echo "an open issue already names exactly these jobs" ;;
none) echo "nothing in this run failed" ;;
*) echo "unexpected verdict from tools/cron_issue.py"; exit 1 ;;
esac