Repository navigation
chore(deps): bump librt from 0.15.0 to 0.16.0 #640
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |