Repository navigation
fix(ipv6): CALIPSO make() writes a corrupt length on overflow and raises raw errors for a bad pad #2098
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: Unit Tests | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| workflow_call: | |
| inputs: | |
| gate-only: | |
| description: >- | |
| Run only the single-version gate job instead of the full matrix. | |
| Callers that merely need a pass/fail verdict before shipping set | |
| this to true: the full matrix already runs once per commit from | |
| this workflow's own push and pull_request triggers, so running it | |
| again per caller tests the same commit several times over. | |
| type: boolean | |
| required: false | |
| default: false | |
| permissions: | |
| contents: read | |
| # Only pull-request runs may be superseded. Keying every event on the ref (as | |
| # this did) coalesces all of main's pushes into one group, and GitHub cancels a | |
| # *pending* run whenever a newer one joins the group -- `cancel-in-progress: | |
| # false` protects a run that has already started, never one still queued. Merging | |
| # two PRs a minute apart therefore left the first commit with no unit tests at | |
| # all: measured on run 34970682137 for e0f0160a3, which finished `cancelled` with | |
| # zero jobs one second after the next push's run was created. Keying non-PR | |
| # events on the run id gives each commit its own group. | |
| # | |
| # `github.workflow` is the *caller's* workflow name inside a called workflow, and | |
| # `github.run_id` is the caller's run id too, so the literal `unit-tests-` prefix | |
| # is what stops a called run from sharing a group with the caller that invoked it | |
| # -- if they shared one, the callee would sit pending behind its own caller and | |
| # deadlock. | |
| concurrency: | |
| group: unit-tests-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| jobs: | |
| test: | |
| # `github.event_name` is NOT 'workflow_call' inside a called workflow -- | |
| # the github context is inherited from the caller, so it reads | |
| # 'pull_request', 'push', 'schedule' or 'workflow_run'. Guarding on it (as | |
| # this workflow used to) therefore ran the whole matrix in every caller | |
| # and skipped the gate everywhere. An input is the only reliable way for a | |
| # reusable workflow to know how it was invoked. | |
| name: Python ${{ matrix.python-version }} | |
| if: ${{ inputs.gate-only != true && needs.changes.outputs.code != 'false' }} | |
| needs: [changes] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| python-version: | |
| # 3.15 deliberately excluded: ruleset 23497679's required checks | |
| # only cover 3.10-3.14, so a 3.15 leg here cannot block a merge and | |
| # was pure queue contention -- see python-compatibility.yml for its | |
| # (non-blocking, schedule-only) replacement. | |
| - "3.10" | |
| - "3.11" | |
| - "3.12" | |
| - "3.13" | |
| - "3.14" | |
| # Exactly one leg runs under coverage, so measuring it adds no leg to | |
| # the queue. 3.14 because it had the most headroom under the 45-minute | |
| # cap on main (15.6, 15.8 and 16.8 minutes | |
| # in runs 37393532176, 37392964804 and 37391296686, against 12.8-22.0 | |
| # for the other four), and because coverage's sys.monitoring core, | |
| # which measures branches only from 3.14 on, is its cheapest tracer. | |
| include: | |
| - python-version: "3.14" | |
| coverage: true | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-python@v7 | |
| with: | |
| python-version: ${{ matrix.python-version }} | |
| cache: pip | |
| # The DPKT extra is here so the tests gated on it (test_dpkt_unit.py, | |
| # test_misc.py, test_extraction.py, test_core.py) actually execute | |
| # instead of skipping -- see #729. It is a small, pure-Python | |
| # dependency (measured clean installs on 3.10-3.14), so it costs | |
| # nothing per leg and adds no new job or leg, unlike Scapy below. | |
| # | |
| # crypto (cryptography) and NGAP (pycrate) join it for the same reason, | |
| # covering two of #738's remaining gates -- HAS_CRYPTO (test_esp_unit.py, | |
| # 14 methods) and HAS_PYCRATE (test_ngap_unit.py, 10 methods), both plain | |
| # unit-tier. `cli` (emoji) is deliberately NOT added here: its only | |
| # HAS_EMOJI-gated tests are in tests/integration/test_cli_subprocess.py, | |
| # which this job ignores wholesale, so installing it here would unlock | |
| # nothing. cryptography is the one compiled wheel of the two; measured | |
| # clean installs on 3.10-3.14 (manylinux wheels, no build step). pycrate | |
| # is pure Python but its distribution is large (~238MB, see the NGAP | |
| # extra's comment in pyproject.toml) -- still cheap in wall time, just not | |
| # "a second" cheap. | |
| # | |
| # PyPCAPFile (pypcapfile) is deliberately NOT here. It used to be kept off | |
| # every job in this file: on 3.10/3.11 (where its | |
| # `python_version < '3.12'` marker lets it install), installing it turned | |
| # 7 of the 10 HAS_PYPCAPFILE-gated methods this comment used to count | |
| # into failures, against a real bug in pcapkit/toolkit/pypcapfile.py's | |
| # handling of pypcapfile 0.12.0's `IP.src`/`IP.dst`. #743 and #746 fixed | |
| # that bug, and #751 gave the now-safe extra a home: the dedicated | |
| # `engine-tests` and `pypcap-parity` jobs below install it (15 | |
| # HAS_PYPCAPFILE-gated methods between them), deliberately apart from | |
| # this job rather than added to it -- see either job's own comment for | |
| # why. | |
| - name: Install package and test dependencies | |
| run: | | |
| python -m pip install -U pip setuptools wheel | |
| python -m pip install -e '.[test,DPKT,crypto,NGAP]' | |
| # 7.10 is the first release with `[run] patch = ["subprocess"]`, which | |
| # .github/coverage.toml relies on to measure the xdist workers. | |
| - name: Install coverage | |
| if: ${{ matrix.coverage }} | |
| run: python -m pip install 'coverage>=7.10' | |
| # `-n auto` resolves to whatever this runner reports, and nothing else in | |
| # the log says what that was -- print it so the number `-n auto` picked | |
| # is a measured fact instead of an assumption about runner size. | |
| - name: Report available parallelism | |
| run: | | |
| nproc | |
| python -c "import os; print('cpu_count', os.cpu_count())" | |
| - name: Run unit tests | |
| id: tests | |
| # `--dist load` is pinned explicitly rather than left to default: a | |
| # future change to xdist's own default, or someone "optimising" to | |
| # `loadfile`/`loadscope`, cannot silently change how this suite is | |
| # distributed. The coverage leg runs the identical selection. | |
| env: | |
| COVERAGE: ${{ matrix.coverage }} | |
| run: | | |
| set -- -q -n auto --dist load \ | |
| --ignore=tests/integration \ | |
| --ignore-glob='*_runtime.py' \ | |
| --ignore-glob='*_regression.py' | |
| if [ "$COVERAGE" = "true" ]; then | |
| # Logs every measured process and its parent, for the check below. | |
| COVERAGE_DEBUG=pid,process COVERAGE_DEBUG_FILE="$PWD/coverage-processes.log" \ | |
| python -m coverage run --rcfile=.github/coverage.toml -m pytest "$@" | |
| else | |
| python -m pytest "$@" | |
| fi | |
| # The table goes to the job summary. The numbers also go to an artifact | |
| # that coverage-comment.yml renders as a PR comment once this run | |
| # completes; that comment cannot be posted from here, because this file is | |
| # also a reusable workflow and its callers grant only `contents: read`. | |
| # `!cancelled()` so a failing test still reports what it measured, and | |
| # says the tests failed in the first line. | |
| - name: Report coverage | |
| if: ${{ !cancelled() && matrix.coverage }} | |
| shell: bash | |
| env: | |
| SHA: ${{ github.sha }} | |
| TESTS_OUTCOME: ${{ steps.tests.outcome }} | |
| run: | | |
| set -euo pipefail | |
| # #1387: a worker killed before its atexit save leaves no .coverage.* | |
| # file, and combine then reports the rest as the whole. So fail, and | |
| # post no number, unless the controller and every xdist worker (its | |
| # direct children in the log above) left one. | |
| python - <<'PY' | |
| import glob, os, re, sys | |
| parent, cmd = {}, {} | |
| log = 'coverage-processes.log' | |
| for line in open(log, errors='replace').readlines() if os.path.exists(log) else []: | |
| if m := re.match(r' *(\d+)\.\w+: New process(: cmd| parent pid): (.*)', line): | |
| (parent if m[2] == ' parent pid' else cmd)[m[1]] = m[3] | |
| root = [p for p, c in cmd.items() if "'run', '--rcfile=.github/coverage.toml'" in c] | |
| workers = sorted(p for p, q in parent.items() if root and q == root[0]) | |
| saved = set(re.findall(r'\.pid(\d+)\.', ' '.join(glob.glob('.coverage.*')))) | |
| missing = [p for p in root + workers if p not in saved] | |
| print(f'controller {root}, {len(workers)} xdist workers, missing data: {missing}') | |
| if len(root) != 1 or not workers or missing: | |
| why = f'no data file from pid {missing}' if missing else 'the process log is missing or unreadable' | |
| with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as summary: | |
| print(f'**Coverage not reported:** {why} (#1387); a total would under-count.', | |
| file=summary) | |
| print(f'::error::Coverage data is incomplete: {why}.') | |
| sys.exit(1) | |
| PY | |
| python -m coverage combine --rcfile=.github/coverage.toml | |
| python -m coverage report --rcfile=.github/coverage.toml | |
| python -m coverage html --rcfile=.github/coverage.toml -d coverage-html | |
| python -m coverage json --rcfile=.github/coverage.toml -o coverage.json | |
| mkdir -p coverage-comment | |
| # Writes the numbers as JSON for coverage-comment.yml, which renders | |
| # its own Markdown from them rather than posting text from this run. | |
| python - <<'PY' >> "$GITHUB_STEP_SUMMARY" | |
| import collections, json, os | |
| data = json.load(open('coverage.json')) | |
| keys = ('num_statements', 'missing_lines', 'num_branches', 'num_partial_branches', | |
| 'covered_lines', 'covered_branches') | |
| packages = collections.defaultdict(collections.Counter) | |
| for path, info in data['files'].items(): | |
| parts = path.split('/') | |
| name = '/'.join(parts[:2]) if len(parts) > 2 else 'pcapkit (top level)' | |
| packages[name].update({k: info['summary'][k] for k in keys}) | |
| def pct(c): | |
| total = c['num_statements'] + c['num_branches'] | |
| return 100.0 * (c['covered_lines'] + c['covered_branches']) / total if total else 100.0 | |
| rows = [[name, c['num_statements'], c['missing_lines'], c['num_branches'], | |
| c['num_partial_branches'], round(pct(c), 2)] for name, c in sorted(packages.items())] | |
| total = round(data['totals']['percent_covered'], 2) | |
| tests = os.environ['TESTS_OUTCOME'] | |
| json.dump({'total': total, 'tests': tests, 'packages': rows}, | |
| open('coverage-comment/coverage-summary.json', 'w')) | |
| status = '' if tests == 'success' else f' -- **tests {tests}**, so this is partial' | |
| print(f"**Coverage: {total:.2f}%**{status} (unit tier, Python 3.14, `{os.environ['SHA'][:9]}`)") | |
| print() | |
| print('| Package | Statements | Missed | Branches | Partial | Cover |') | |
| print('|---|--:|--:|--:|--:|--:|') | |
| for row in rows: | |
| print('| `{}` | {} | {} | {} | {} | {:.2f}% |'.format(*row)) | |
| print() | |
| print('Per-file detail: the `coverage-html` artifact of this run.') | |
| PY | |
| - name: Upload coverage HTML report | |
| if: ${{ !cancelled() && matrix.coverage }} | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: coverage-html | |
| path: coverage-html/ | |
| retention-days: 30 | |
| - name: Upload coverage summary | |
| if: ${{ !cancelled() && matrix.coverage }} | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: coverage-summary | |
| path: coverage-comment/coverage-summary.json | |
| retention-days: 7 | |
| integration: | |
| name: Integration Python ${{ matrix.python-version }} | |
| if: ${{ inputs.gate-only != true && needs.changes.outputs.code != 'false' }} | |
| needs: [changes] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| python-version: | |
| # See the `test` job above for why 3.15 is excluded here. | |
| - "3.10" | |
| - "3.11" | |
| - "3.12" | |
| - "3.13" | |
| - "3.14" | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-python@v7 | |
| with: | |
| python-version: ${{ matrix.python-version }} | |
| cache: pip | |
| # The Scapy extra is there for examples/generators/pcap.py and legacy.py, | |
| # which build their captures with scapy; examples/generators/pcapng.py needs | |
| # nothing beyond the standard library and pcapkit itself, which it uses to | |
| # parse each fixture back. DPKT joins it so the two dpkt-gated methods | |
| # living under tests/integration/ (test_engine_parity.py, | |
| # test_engine_runtime.py) actually run here instead of skipping -- see | |
| # #729; same reasoning as the `test` job above. | |
| # | |
| # `cli` (emoji) joins it for one of #738's remaining gates. This job's | |
| # selection (tests/_tiers.py's fixture_tier_paths()) always includes the | |
| # whole of tests/integration/ -- which is where every HAS_EMOJI-gated | |
| # method lives (test_cli_subprocess.py, 14 methods). `crypto` and `NGAP` | |
| # are deliberately NOT added here: test_esp_unit.py and test_ngap_unit.py | |
| # are plain unit-tier and never enter this job's selection, so installing | |
| # cryptography/pycrate here would unlock nothing -- they are already | |
| # covered, across all five Python versions, by the `test` job above. | |
| # PyPCAPFile is NOT added here either, even though this job's selection | |
| # also reaches test_new_engine_parity_runtime.py's 6 HAS_PYPCAPFILE | |
| # methods -- see the `test` job's comment above for the bug that used to | |
| # make installing it here a regression, now fixed by #743/#746. #751's | |
| # `pypcap-parity` job below covers those 6 methods instead, on the same | |
| # fixture-tier selection as this job but in a venv of its own. | |
| - name: Install package, test and generator dependencies | |
| run: | | |
| python -m pip install -U pip setuptools wheel | |
| python -m pip install -e '.[test,Scapy,DPKT,cli]' | |
| # `shell: bash` rather than the default, because it runs with -o pipefail: | |
| # without it the `tee` below would swallow the generator's exit status. | |
| - name: Regenerate sample captures | |
| shell: bash | |
| run: | | |
| # Keep a failure here legible as what it is -- the fixtures could not | |
| # be built, so no test has run yet and nothing is wrong with the code | |
| # under test. | |
| if ! python examples/generators/make_samples.py 2>&1 | tee "$RUNNER_TEMP/make-samples.log"; then | |
| echo "::error title=Sample fixture generation failed::examples/generators/make_samples.py could not rebuild examples/captures/. This is a fixture-generation failure, not a test failure -- the test suite has not run." | |
| exit 1 | |
| fi | |
| # examples/generators/pcapng.py fetches two captures from the Wireshark | |
| # repository and synthesises stand-ins when the download fails. The | |
| # stand-ins parse, and tests/protocols/test_pcapng_regression.py only | |
| # asserts that extraction succeeds, so a degraded run goes green and | |
| # is otherwise indistinguishable from a clean one. Say which happened. | |
| if grep -q '(download unavailable)' "$RUNNER_TEMP/make-samples.log"; then | |
| echo "::warning title=Sample fixtures degraded::The upstream Wireshark captures were unreachable, so synthesised stand-ins were used. The PCAP-NG tier ran, but not against the upstream bytes." | |
| grep '(download unavailable)' "$RUNNER_TEMP/make-samples.log" | |
| else | |
| echo "Upstream Wireshark captures were used, matching their pinned SHA-256 digests." | |
| fi | |
| # See the `test` job above for why this step exists. | |
| - name: Report available parallelism | |
| run: | | |
| nproc | |
| python -c "import os; print('cpu_count', os.cpu_count())" | |
| # The ``test`` job above already ran every unit-tier test for real, on this | |
| # same commit. Re-running them here (a bare ``pytest -q`` used to) bought | |
| # no signal and cost ~21.75 minutes per Python version -- ~130 minutes per | |
| # PR push across the matrix (see #715). So this selects only the | |
| # fixture-dependent tier plus the handful of unit-tier modules whose | |
| # capture read is skipped, not exercised, in the `test` job -- see | |
| # tests/_tiers.py's module docstring and fixture_tier_paths() for why a | |
| # module can be unit-tier by path and still belong here. The selection is | |
| # not spelled out as literal flags: it is asked of tests/_tiers.py | |
| # directly (FIXTURE_TIER_SELECTOR), so this step and that module cannot | |
| # drift the way two independent copies of the same list could. | |
| # | |
| # A failure to compute the selection intentionally does not fall back to | |
| # a bare `pytest -q` -- that would silently re-introduce the whole-suite | |
| # duplication this change exists to remove, with nothing in the log to | |
| # say so. `--dist load` is pinned explicitly; see the `test` job above | |
| # for why. | |
| - name: Run full test suite | |
| shell: bash | |
| run: | | |
| if ! selection=$(python -c "from tests._tiers import fixture_tier_paths; print(' '.join(fixture_tier_paths()))"); then | |
| echo "::error title=Could not compute the fixture-tier selection::tests._tiers.fixture_tier_paths() failed -- see the traceback above. Refusing to fall back to a bare 'pytest -q', which would silently re-run the entire suite instead of failing loudly." | |
| exit 1 | |
| fi | |
| if [ -z "$selection" ]; then | |
| echo "::error title=Fixture-tier selection is empty::tests._tiers.fixture_tier_paths() returned nothing, which cannot be right -- tests/integration alone should always be part of it. Refusing to run pytest with no arguments." | |
| exit 1 | |
| fi | |
| echo "Fixture-dependent selection: $selection" | |
| python -m pytest -q -n auto --dist load $selection | |
| # Per-engine coverage for the third-party capture engines (#845): every | |
| # (Python version, engine) pair is checked, and a failure -- or a correctly | |
| # asserted decline -- names the one engine and interpreter responsible. Each | |
| # pair ("cell") resolves to one of three expected outcomes, decided by the | |
| # cell script's `expect` case below (anything not listed there is | |
| # `supported`): | |
| # | |
| # * `supported` -- the engine installs via its normal pyproject.toml | |
| # extra and its dedicated unit-tier tests must | |
| # actually pass, proving this engine ran. | |
| # * `unsupported` -- the engine is installable on this interpreter but | |
| # its own PYTHON_CEILING means it declines at run | |
| # time (PyShark on 3.14+; PyPCAPFile on 3.12+, whose | |
| # pip marker would otherwise stop it ever installing | |
| # here -- so the cell installs the raw distribution | |
| # past that marker, deliberately, rather than via the | |
| # extra). The same test files run; their own | |
| # version-aware assertions (e.g. | |
| # PyPCAPFile's test_unsupported_reason_tracks_the_running_interpreter) | |
| # are what prove the decline actually fired and named | |
| # the interpreter, rather than merely skipping. | |
| # * `not-installable` -- PyPCAP on 3.12+: a C-extension build failure, not | |
| # a runtime refusal (no wheel; pcap.c does not | |
| # compile against the 3.12+ C API -- PyPCAP carries | |
| # no PYTHON_CEILING at all, so there is no decline to | |
| # assert). The cell still installs the raw | |
| # distribution past pyproject.toml's marker to prove | |
| # the failure is real rather than assumed, and treats | |
| # that failure as the expected, passing outcome -- | |
| # distinctly logged via `::notice`, never silently | |
| # green and never red for the wrong reason. If it | |
| # ever starts building, the cell fails loudly. | |
| # | |
| # One job per engine loops over the six interpreters, each in a fresh venv, | |
| # rather than one job per cell (6 Pythons x 6 engines): the queue, not the | |
| # runners, is the bottleneck, and 36 jobs would each pay checkout, setup and | |
| # apt for a minute of work. One job per *engine* rather than per Python | |
| # because the engine is what varies the environment: the apt packages | |
| # are per engine (installed once per job instead of six times), and pcap-ct | |
| # and pypcap both install a top-level `pcap` module, so a per-Python job | |
| # looping over engines would need a fresh venv per engine anyway while also | |
| # carrying every engine's system packages. It also keeps `matrix.engine` as | |
| # the one matrix axis, which is what tests/_dependency_gates.py's | |
| # _engine_matrix_variants() reads this job as. The cell script below is one | |
| # cell's install-and-test logic, run once per interpreter as its own process | |
| # (so `set -e` holds inside it), under a `::group::` named for the cell, with | |
| # every annotation titled by engine and Python version and a per-cell row in | |
| # the job summary. | |
| # | |
| # 3.15 is experimental-prerelease per #845's ruling: present so a regression | |
| # is visible, but never blocking. It runs as its own step with step-level | |
| # `continue-on-error: true`, so a 3.15 failure shows as a failed step and an | |
| # error annotation while the job -- and `required-checks`, which reads this | |
| # job's result -- stays green. The 3.10-3.14 cells share one step, and any | |
| # one of them failing fails the job. | |
| # | |
| # Scoped deliberately out (#845): per-engine integration | |
| # coverage (needs regenerated captures, so it stays in aggregate in | |
| # `integration` and `pypcap-parity`), and the multi-engine HAS_RUNTIME gate | |
| # plus the incidental crypto/NGAP/vendor coverage the pre-#845 shared venv | |
| # carried. | |
| engine-tests: | |
| name: Engines (${{ matrix.engine }}) | |
| if: ${{ inputs.gate-only != true && needs.changes.outputs.code != 'false' }} | |
| needs: [changes] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| env: | |
| ENGINE: ${{ matrix.engine }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| engine: | |
| - DPKT | |
| - Scapy | |
| - PyShark | |
| - PyPCAPFile | |
| - PyPCAP | |
| - PCAP_CT | |
| steps: | |
| - uses: actions/checkout@v7 | |
| # All six interpreters in one step; each is then reachable as | |
| # `python3.X`. setup-python does not ship 3.15 as a release build yet, so | |
| # `allow-prereleases` is needed for it to resolve at all (as in | |
| # python-compatibility.yml's compatibility-nightly job); for 3.10-3.14 a | |
| # GA release exists and wins. | |
| - uses: actions/setup-python@v7 | |
| with: | |
| python-version: | | |
| 3.10 | |
| 3.11 | |
| 3.12 | |
| 3.13 | |
| 3.14 | |
| 3.15 | |
| allow-prereleases: true | |
| # Only the engines that need a system package get one: | |
| # | |
| # tshark: PyShark is a wrapper around it and does no parsing of its own. | |
| # The debconf pre-seed stops tshark's postinst blocking on the "allow | |
| # non-superusers to capture packets" prompt. | |
| # | |
| # libpcap0.8: PCAP_CT's ctypes loader calls find_library("pcap") at run | |
| # time; without it, PCAP_CT.unsupported_reason() degrades the engine to | |
| # the default parser instead of running it. | |
| # | |
| # build-essential + libpcap-dev: PyPCAP is the one engine that compiles | |
| # (pcap.c against libpcap's headers), on every cell -- including the | |
| # `not-installable` ones, deliberately, so that failure is a genuine | |
| # compile failure against the 3.12+ C API rather than merely "no | |
| # compiler was present to try". | |
| # | |
| # `timeout-minutes` bounds this step because an apt stall in it used to | |
| # eat the whole job: per #974, a PCAP_CT cell fetched its InRelease | |
| # metadata and then went silent for 29m52s until the job's own 30-minute | |
| # cap killed it, which reports `cancelled` and so fails `Required checks | |
| # passed` -- an unrelated mirror turning a green PR red. Stalls in | |
| # `apt-get update` have had the same all-or-nothing shape. | |
| # | |
| # 10 minutes, with one retry inside it. These are whole-step wall times, | |
| # both attempts included, so they bound no single attempt. On 2026-10-07 | |
| # this step failed at 141s (job 112943258387, #1306, PyShark, | |
| # 18:17:51Z-18:20:12Z) and the parity one at 155s (job 112948578234, | |
| # #1307, 18:31:41Z-18:34:16Z, 27.9 MB of archives with | |
| # `azure.archive.ubuntu.com` logging `Ign:` every 30s). Across the runs | |
| # sampled, failures ran 91s to 194s, most at 187-194s: the band the old | |
| # 45s/90s bounds cut off. So 60s update, 180s install (~27 MB at >=0.15 | |
| # MB/s), two attempts, 480s worst case -- only 120s under the 600s step | |
| # cap, and steps past 200s are on record, so the cap rather than that | |
| # margin is the hang detector. `dpkg --configure -a` precedes the retry. | |
| # | |
| # Deliberately *not* passing `-o Acquire::Retries=3 -o | |
| # Acquire::http::Timeout=30`, which #974 proposed: both are already the | |
| # defaults in noble's apt (2.8.3: `Acquire::Retries` 3 in | |
| # apt-pkg/acquire-item.cc, `TimeOut(30)` in methods/basehttp.cc), so they | |
| # would change nothing while reading like protection. Their being in | |
| # force is also why the #974 stall ran to 29m52s: whatever hung is not | |
| # something apt's per-`select()` data timeout governs, which is why the | |
| # bound here is `timeout(1)` around the whole command. | |
| - name: Install this engine's system packages | |
| timeout-minutes: 10 | |
| run: | | |
| set -eu | |
| apt_install() { | |
| for attempt in 1 2; do | |
| if sudo timeout 60 apt-get update \ | |
| && sudo DEBIAN_FRONTEND=noninteractive timeout 180 apt-get install -y --no-install-recommends "$@"; then | |
| return 0 | |
| fi | |
| echo "::warning title=apt attempt $attempt of 2 failed (${ENGINE})::apt-get update or install failed or exceeded its per-attempt bound; retrying once." | |
| sudo dpkg --configure -a || true | |
| done | |
| return 1 | |
| } | |
| if [ "$ENGINE" = "PyShark" ]; then | |
| echo "wireshark-common wireshark-common/install-setuid boolean false" | sudo debconf-set-selections | |
| apt_install tshark | |
| elif [ "$ENGINE" = "PCAP_CT" ]; then | |
| apt_install libpcap0.8 | |
| elif [ "$ENGINE" = "PyPCAP" ]; then | |
| apt_install build-essential libpcap-dev | |
| fi | |
| # The per-cell script: one (Python version, engine) cell, start to | |
| # finish, in a venv of its own. Written to a file so each cell runs as a | |
| # separate `bash` process -- `set -e` is suspended inside a function | |
| # called from an `if`, so an inline function would let a failed install | |
| # carry on silently. | |
| # | |
| # `expect` decides not just the assertion but *how* the engine gets | |
| # installed: `supported` uses the ordinary pyproject.toml extra, whose | |
| # own marker (if any) already resolves to "install" on this Python | |
| # version; `unsupported` and `not-installable` install the raw | |
| # distribution directly, deliberately bypassing whichever marker would | |
| # otherwise have skipped it silently (#845 finding 2). | |
| # | |
| # `set +e` is scoped to the one install command whose failure is | |
| # sometimes expected. Upgrading pip and editable-installing this repo's | |
| # `test` extra failing is never expected on any cell, so those run under | |
| # the script's `set -eu` (#845's cross-review: running them unchecked | |
| # once let a broken base install on a `not-installable` cell read as | |
| # "expected"). | |
| # | |
| # Runs the SAME test files whether `expect` is `supported` or | |
| # `unsupported`: the difference is in what those files assert once they | |
| # see this interpreter's version (PyPCAPFile's | |
| # test_unsupported_reason_tracks_the_running_interpreter, PyShark's | |
| # test_the_reason_tracks_the_running_interpreter), which is what proves | |
| # the decline fired. `not-installable` cells run no tests. | |
| - name: Write the per-cell script | |
| run: | | |
| cat > "$RUNNER_TEMP/engine-cell.sh" <<'CELL' | |
| set -eu | |
| PY="$1" | |
| CELL_NAME="$ENGINE on Python $PY" | |
| case "$ENGINE:$PY" in | |
| PyShark:3.14|PyShark:3.15) EXPECT=unsupported ;; | |
| PyPCAPFile:3.12|PyPCAPFile:3.13|PyPCAPFile:3.14|PyPCAPFile:3.15) EXPECT=unsupported ;; | |
| PyPCAP:3.12|PyPCAP:3.13|PyPCAP:3.14|PyPCAP:3.15) EXPECT=not-installable ;; | |
| *) EXPECT=supported ;; | |
| esac | |
| echo "Cell: $CELL_NAME, expecting '$EXPECT'" | |
| "python$PY" -m venv "$RUNNER_TEMP/venv-$PY" | |
| # shellcheck disable=SC1090 | |
| . "$RUNNER_TEMP/venv-$PY/bin/activate" | |
| python --version | |
| python -m pip install -U pip setuptools wheel | |
| python -m pip install -e '.[test]' | |
| # RAW_PKGS is an array, not a string, so PCAP_CT's two-package case | |
| # ("pcap-ct libpcap") reaches pip as two argv entries. | |
| case "$ENGINE" in | |
| DPKT) EXTRA=DPKT; RAW_PKGS=(dpkt); IMPORT_NAME=dpkt ;; | |
| Scapy) EXTRA=Scapy; RAW_PKGS=(scapy); IMPORT_NAME=scapy ;; | |
| PyShark) EXTRA=PyShark; RAW_PKGS=(pyshark); IMPORT_NAME=pyshark ;; | |
| PyPCAPFile) EXTRA=PyPCAPFile; RAW_PKGS=(pypcapfile); IMPORT_NAME=pcapfile ;; | |
| PyPCAP) EXTRA=PyPCAP; RAW_PKGS=(pypcap); IMPORT_NAME=pcap ;; | |
| PCAP_CT) EXTRA=PCAP_CT; RAW_PKGS=(pcap-ct libpcap); IMPORT_NAME=pcap ;; | |
| *) echo "::error::unknown engine $ENGINE"; exit 1 ;; | |
| esac | |
| set +e | |
| if [ "$EXPECT" = "supported" ]; then | |
| output=$(python -m pip install -e ".[$EXTRA]" 2>&1) | |
| else | |
| output=$(python -m pip install "${RAW_PKGS[@]}" 2>&1) | |
| fi | |
| status=$? | |
| set -e | |
| echo "$output" | |
| if [ "$status" -ne 0 ] && [ "$EXPECT" != "not-installable" ]; then | |
| echo "::error title=$ENGINE failed to install on Python $PY::This cell expects '$EXPECT', which requires a successful install. pip exited $status." | |
| exit 1 | |
| fi | |
| if [ "$status" -eq 0 ] && [ "$EXPECT" = "not-installable" ]; then | |
| echo "::error title=$ENGINE now installs on Python $PY::This cell is recorded as not-installable in this job's expect case (and in pyproject.toml's marker for $ENGINE). It installed cleanly -- update both rather than leaving this green for the wrong reason." | |
| exit 1 | |
| fi | |
| if [ "$status" -ne 0 ]; then | |
| # A not-installable cell's point is a genuine C-API compile | |
| # failure, not a flaky index: grep for pip's own retry/network | |
| # vocabulary, and require the *absence* of a compile marker | |
| # alongside it. A bare "5xx or 429" number scan is not used, | |
| # because pip prints "(NNN kB)" download sizes and compiler | |
| # diagnostics quote line numbers in that range (#845's | |
| # cross-review), which would make a permanent false red. | |
| if echo "$output" | grep -qiE \ | |
| 'read timed out|connection (reset|refused|aborted)|temporary failure in name resolution|max retries exceeded|could not fetch url|newconnectionerror|readtimeouterror|httpsconnectionpool|retrying \(retry\(|http error (5[0-9][0-9]|429)|status code 429|too many 429 error responses|no matching distribution found' \ | |
| && ! echo "$output" | grep -qiE \ | |
| 'failed building wheel|error: command .* failed with exit code|subprocess-exited-with-error'; then | |
| echo "::error title=$ENGINE's install failure on Python $PY looks transient, not a compile failure::pip exited $status, but the output matches a network/index failure pattern rather than the C-API compile failure this cell records as expected. Re-run the job rather than trusting this as a genuine 'not-installable' result." | |
| exit 1 | |
| fi | |
| echo "::notice title=$ENGINE is not installable on Python $PY (expected)::pip exited $status, matching this cell's recorded expectation ('not-installable'), and the output does not match a transient-failure pattern. No tests run for this cell -- this is a deliberate skip, not a pass." | |
| echo "| $PY | $ENGINE | $EXPECT | not installable (expected) |" >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| # #845 finding 2: a `supported` cell whose extra resolves to nothing | |
| # still exits 0 and every test would then skip, so require the | |
| # module this cell exists to exercise. | |
| if ! python -c "import $IMPORT_NAME" 2>/dev/null; then | |
| echo "::error title=$ENGINE installed but $IMPORT_NAME does not import on Python $PY::pip exited 0 but the module this cell exists to exercise is not importable -- a green install that unlocked nothing." | |
| exit 1 | |
| fi | |
| case "$ENGINE" in | |
| DPKT) TEST_PATHS="tests/toolkit/test_dpkt_unit.py" ;; | |
| Scapy) TEST_PATHS="tests/toolkit/test_scapy_unit.py tests/foundation/engines/test_scapy_engine.py tests/protocols/transport/test_sctp_unit.py" ;; | |
| PyShark) TEST_PATHS="tests/toolkit/test_pyshark_unit.py tests/foundation/engines/test_pyshark_engine.py" ;; | |
| PyPCAPFile) TEST_PATHS="tests/toolkit/test_pypcapfile_unit.py tests/foundation/engines/test_pypcapfile_engine.py" ;; | |
| PyPCAP) TEST_PATHS="tests/toolkit/test_pypcap_unit.py tests/foundation/engines/test_pypcap_engine.py tests/foundation/engines/test_pcap_backend.py" ;; | |
| PCAP_CT) TEST_PATHS="tests/toolkit/test_pcap_ct_unit.py tests/foundation/engines/test_pcap_ct_engine.py tests/foundation/engines/test_pcap_backend.py" ;; | |
| esac | |
| echo "Running: $TEST_PATHS" | |
| # shellcheck disable=SC2086 | |
| if ! python -m pytest -q -n auto --dist load $TEST_PATHS; then | |
| echo "::error title=$ENGINE tests failed on Python $PY::See the 'Python $PY ($ENGINE)' group in this step's log." | |
| exit 1 | |
| fi | |
| echo "| $PY | $ENGINE | $EXPECT | passed |" >> "$GITHUB_STEP_SUMMARY" | |
| CELL | |
| { | |
| echo "### Engine cells: $ENGINE" | |
| echo | |
| echo "| Python | Engine | Expect | Result |" | |
| echo "| --- | --- | --- | --- |" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| # See the `test` job above for why this step exists. | |
| - name: Report available parallelism | |
| run: | | |
| nproc | |
| python -c "import os; print('cpu_count', os.cpu_count())" | |
| # Every cell runs even after one fails, so one red run reports all of | |
| # them; the step fails at the end if any did. | |
| - name: Run the 3.10-3.14 cells | |
| shell: bash | |
| run: | | |
| failed=() | |
| for py in 3.10 3.11 3.12 3.13 3.14; do | |
| echo "::group::Python $py ($ENGINE)" | |
| if ! bash "$RUNNER_TEMP/engine-cell.sh" "$py"; then | |
| failed+=("$py") | |
| echo "| $py | $ENGINE | | FAILED |" >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| echo "::endgroup::" | |
| done | |
| if [ "${#failed[@]}" -ne 0 ]; then | |
| echo "::error title=$ENGINE failed on Python ${failed[*]}::See each cell's group in this step's log and the job summary." | |
| exit 1 | |
| fi | |
| # Non-blocking by #845's ruling -- see this job's comment above. | |
| - name: Run the 3.15 cell (experimental, non-blocking) | |
| shell: bash | |
| continue-on-error: true | |
| run: | | |
| echo "::group::Python 3.15 ($ENGINE)" | |
| if ! bash "$RUNNER_TEMP/engine-cell.sh" 3.15; then | |
| echo "| 3.15 | $ENGINE | | FAILED (non-blocking) |" >> "$GITHUB_STEP_SUMMARY" | |
| echo "::endgroup::" | |
| echo "::error title=$ENGINE failed on Python 3.15 (non-blocking)::3.15 is experimental per #845; this does not fail the job." | |
| exit 1 | |
| fi | |
| echo "::endgroup::" | |
| # Whether upstream PyPCAP is worth building in CI at all -- #751's own | |
| # instruction was "try to build and if the CI is not a good suit, then we | |
| # ripe it". This job is that attempt: a C toolchain plus libpcap headers, on | |
| # the two Python versions its own marker allows | |
| # ("pypcap; python_version < '3.12'", pyproject.toml:157). If a clean run of | |
| # this job's install step goes red -- not a flake -- the fix is deleting this | |
| # job and returning HAS_PYPCAP to | |
| # tests/_dependency_gates.DEPENDENCY_GATE_EXCLUSIONS with that run linked as | |
| # the reason, per the ruling. | |
| # | |
| # Kept apart from `engine-tests` above for two reasons: it needs a compiler | |
| # that job has no other reason to carry, and its `pcap` module would collide | |
| # with pcap-ct's if both were installed into the one venv (see | |
| # `engine-tests`'s own comment on that). The HAS_PYPCAP-gated module this job | |
| # targets, tests/foundation/engines/test_new_engine_parity_runtime.py, reads | |
| # generated captures (arp.pcap, tcp.pcap, ipv4.pcap -- its own module | |
| # docstring), so this mirrors the `integration` job's fixture-tier selection | |
| # and Scapy/DPKT/cli baseline rather than `test`'s ignore-shape one, and pays | |
| # that job's full run cost a second time on top -- accepted here rather than | |
| # discovered, since tests/_dependency_gates.py's guard has no cheaper of its | |
| # three known selection shapes for reaching one fixture-dependent module (see | |
| # that module's own docstring). The same module also carries 6 of | |
| # HAS_PYPCAPFILE's 15 gated methods, which come along for free once | |
| # PyPCAPFile is on this job's install line too. | |
| # | |
| # The matrix is deliberately just 3.10 and 3.11, not the full five: PyPCAP's | |
| # marker and PyPCAPFile's marker are both "python_version < '3.12'", so | |
| # 3.12-3.14 would install neither extra here and pay this job's full | |
| # fixture-tier run for zero new coverage. | |
| pypcap-parity: | |
| name: PyPCAP/PyPCAPFile parity Python ${{ matrix.python-version }} | |
| if: ${{ inputs.gate-only != true && needs.changes.outputs.code != 'false' }} | |
| needs: [changes] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| python-version: | |
| - "3.10" | |
| - "3.11" | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-python@v7 | |
| with: | |
| python-version: ${{ matrix.python-version }} | |
| cache: pip | |
| # pypcap ships no wheel: it compiles pcap.c against libpcap, so both the | |
| # headers and the shared library have to be present before pip is asked | |
| # to build it. | |
| # | |
| # tshark joins them per #845's finding 1: this job installs pyshark and | |
| # its fixture-tier selection (see this job's own comment above) is the | |
| # only place tests/integration/test_engine_runtime.py's real | |
| # ``engine='pyshark'`` extraction is reachable at all -- | |
| # `engine-tests`'s own selection ignores tests/integration and | |
| # *_runtime.py wholesale. Without tshark, PyShark.unsupported_reason() | |
| # declines the engine, the extractor silently falls back to the default | |
| # parser, and the test's assertions used to pass on that fallback's | |
| # output -- a vacuous pass, invisible in skip counts. The debconf | |
| # pre-seed and DEBIAN_FRONTEND mirror `engine-tests`'s "Install this | |
| # engine's system packages" step exactly, for the same reason: tshark's | |
| # postinst otherwise blocks on the "allow non-superusers to capture | |
| # packets" prompt. | |
| # | |
| # `timeout-minutes` and the retry for the reason given on `engine-tests`'s | |
| # own install step -- see the note there, which this step shares | |
| # wholesale, including the 10-minute cap (under this job's 45) and the | |
| # 60s/180s bounds. It was not the leg that stalled in #974, but it has | |
| # since failed the same way, at 155s, on 2026-10-07: #1307's job | |
| # 112948578234, the second instance cited in that note. | |
| - name: Install libpcap headers, a C toolchain, and tshark | |
| timeout-minutes: 10 | |
| run: | | |
| set -eu | |
| echo "wireshark-common wireshark-common/install-setuid boolean false" | sudo debconf-set-selections | |
| for attempt in 1 2; do | |
| if sudo timeout 60 apt-get update \ | |
| && sudo DEBIAN_FRONTEND=noninteractive timeout 180 apt-get install -y --no-install-recommends build-essential libpcap-dev tshark; then | |
| exit 0 | |
| fi | |
| echo "::warning title=apt attempt $attempt of 2 failed::apt-get update or install failed or exceeded its per-attempt bound; retrying once." | |
| sudo dpkg --configure -a || true | |
| done | |
| exit 1 | |
| - name: Install package, test and generator dependencies | |
| run: | | |
| python -m pip install -U pip setuptools wheel | |
| python -m pip install -e '.[test,Scapy,DPKT,cli,PyShark,PyPCAP,PyPCAPFile]' | |
| # See the `integration` job above for why this step is shaped the way it | |
| # is. | |
| - name: Regenerate sample captures | |
| shell: bash | |
| run: | | |
| if ! python examples/generators/make_samples.py 2>&1 | tee "$RUNNER_TEMP/make-samples.log"; then | |
| echo "::error title=Sample fixture generation failed::examples/generators/make_samples.py could not rebuild examples/captures/. This is a fixture-generation failure, not a test failure -- the test suite has not run." | |
| exit 1 | |
| fi | |
| if grep -q '(download unavailable)' "$RUNNER_TEMP/make-samples.log"; then | |
| echo "::warning title=Sample fixtures degraded::The upstream Wireshark captures were unreachable, so synthesised stand-ins were used. The PCAP-NG tier ran, but not against the upstream bytes." | |
| grep '(download unavailable)' "$RUNNER_TEMP/make-samples.log" | |
| else | |
| echo "Upstream Wireshark captures were used, matching their pinned SHA-256 digests." | |
| fi | |
| # See the `test` job above for why this step exists. | |
| - name: Report available parallelism | |
| run: | | |
| nproc | |
| python -c "import os; print('cpu_count', os.cpu_count())" | |
| # See the `integration` job above for why this is asked of | |
| # tests/_tiers.py directly rather than spelled out as literal flags. | |
| - name: Run full test suite | |
| shell: bash | |
| run: | | |
| if ! selection=$(python -c "from tests._tiers import fixture_tier_paths; print(' '.join(fixture_tier_paths()))"); then | |
| echo "::error title=Could not compute the fixture-tier selection::tests._tiers.fixture_tier_paths() failed -- see the traceback above. Refusing to fall back to a bare 'pytest -q', which would silently re-run the entire suite instead of failing loudly." | |
| exit 1 | |
| fi | |
| if [ -z "$selection" ]; then | |
| echo "::error title=Fixture-tier selection is empty::tests._tiers.fixture_tier_paths() returned nothing, which cannot be right -- tests/integration alone should always be part of it. Refusing to run pytest with no arguments." | |
| exit 1 | |
| fi | |
| echo "Fixture-dependent selection: $selection" | |
| python -m pytest -q -n auto --dist load $selection | |
| # GitHub issue #981: two test modules | |
| # (tests/protocols/application/test_http_unit.py and | |
| # tests/test_base_class_contract.py) each pass alone, and pytest -- the | |
| # runner every job above uses -- reports the combined run green, while | |
| # plain unittest over the same two modules in one process fails four | |
| # subTest cases. pytest-subtests is not why -- it is not even installed in | |
| # this project (absent from the `test` extra in pyproject.toml), and plain | |
| # pytest already reports a failed subTest as its own top-level SUBFAILED | |
| # entry rather than folding it into a passing parent. The real cause is | |
| # tests/conftest.py's autouse restore_module_table fixture, which | |
| # reconciles the cross-module pcapkit reimport pollution #981 is about -- | |
| # but only under pytest, since plain unittest never loads conftest.py at | |
| # all. Every job above runs under pytest, so that reconciliation is why | |
| # none of them can see this shape of defect -- invisible by construction | |
| # once the fixture is doing its job, not by oversight. This job exists | |
| # solely to make that visible, by using a different test *runner* -- | |
| # unittest.TextTestRunner -- that never loads conftest.py and so never | |
| # gets the reconciliation that hides the defect from pytest. | |
| # | |
| # A cheaper, narrower alternative: `pytest --noconftest` over just those two | |
| # modules, in that order, disables that same autouse fixture directly and | |
| # reproduced the identical four subTest failures in about 70s on the pre-fix | |
| # tree, under the pytest this CI already installs. Whether that single | |
| # invocation would have been sufficient instead of this dedicated job and its | |
| # per-directory matrix was not evaluated when this job was written -- | |
| # recorded here rather than justified after the fact; see | |
| # util/run_unittest_leg.py's own docstring for the fuller version of both | |
| # this and the paragraph above. | |
| # | |
| # util/run_unittest_leg.py (its own docstring has the full reasoning) runs | |
| # one tests/ subdirectory per matrix cell (for protocols, one shard of it, | |
| # see #1029 below), paired with every root-level | |
| # tests/test_*.py module, directory first and root second -- the order | |
| # #981's own reproduction needs, since the defect is an earlier module | |
| # polluting a later one. Running the whole suite in one process is not an | |
| # option (it OOMs at 29 GB on the machine this was diagnosed on); a matrix | |
| # of one leg per top-level directory (protocols, since #1029, being two | |
| # cells) is the affordable approximation, and it | |
| # is sized for wall time rather than for memory. Peak RSS was measured for | |
| # the three cheapest legs only -- cli 152 MiB, dumpkit 237 MiB, interface | |
| # 305 MiB (resource.getrusage on RUSAGE_CHILDREN, this venv) -- leaving the | |
| # largest of the three some 90x short of 29 GB; no figure was taken for | |
| # tests/protocols, the largest leg. Measured wall times (this venv, serial): | |
| # cli 18s, const 268s, dumpkit 39s, foundation 272s, interface 38s, project | |
| # 70s, protocols 847s (before the #1029 split), toolkit 97s, utilities 42s, | |
| # vendor 104s, one leg per | |
| # matrix cell -- see `timeout-minutes` below for why the job-level budget is | |
| # no longer 20. | |
| # | |
| # GitHub issue #1029: the protocols cell measured 30.4 minutes on a CI | |
| # runner against this job's 45-minute cap (#1029 measured the next slowest | |
| # leg at 6.2 minutes on CI), so it is now two cells: `protocols/internet` and the rest of | |
| # tests/protocols (its other five subdirectories and the modules sitting | |
| # directly in it), the latter expressed as `protocols` with | |
| # `--exclude protocols/internet`. run_unittest_leg.py takes a sub-path and | |
| # repeatable --exclude for this; every other cell is untouched. | |
| # | |
| # Measured on CI (GitHub Actions wall time per cell, one unit throughout): | |
| # | |
| # leg run 37334173854 run 37341197811 | |
| # (#1031, unsharded) (#1032, sharded) | |
| # protocols 27.8m - | |
| # protocols/internet - 8.2m | |
| # protocols (rest) - 7.6m | |
| # foundation (control) 6.2m 6.4m | |
| # const (control) 6.0m 6.1m | |
| # | |
| # The baseline is 27.8m from a same-hour run, deliberately not #1029's | |
| # headline 30.4m (its quiet-runner re-run, taken after a window of | |
| # cancelled runs). The controls moved by 0.1-0.2m between the two runs, so | |
| # the runners compare. Two cells rather than three because these already | |
| # balance (8.2m against 7.6m) and the next slowest unsharded leg is | |
| # foundation at 6.4m: a third cell could save under two minutes of wall time | |
| # and would cost more co-running module pairs. No local per-module timings | |
| # are recorded here; the figure above is the one that matters, and the | |
| # pre-#1029 local serial table above is left as it was measured. | |
| # | |
| # What sharding costs: modules in different cells no longer share a process, | |
| # so pollution from one to the other is invisible again. Counts below were | |
| # derived with leg_modules() on main at 30714cd51 plus this change: 51 | |
| # modules in tests/protocols (12 under internet, 39 in the rest, of which 10 | |
| # sit directly in tests/protocols). "Pairs" are unordered, C(n,2), since a | |
| # single serial run realises each pair in one order. Before: 1275 pairs | |
| # co-ran in one process. After: 807 (internet x internet 66, rest x rest | |
| # 741); 468 do not. The 255 pairs of a protocols module followed by a | |
| # root-level module (51 x 5 roots) all survive, since every cell still runs | |
| # the root modules after its own. The 10 modules sitting directly in | |
| # tests/protocols stay in the rest cell: moving them into internet would keep | |
| # 637 pairs instead of 807 and lengthen internet, the slower cell already. | |
| # | |
| # tests/vendor was first left out of ``leg`` because running it this way | |
| # surfaced an instance of the very defect this job exists to catch (issue | |
| # #985): a stale VendorRuntimeWarning generation made assertWarnsRegex fail | |
| # in tests/vendor/test_vendor_snapshot_restore_unit.py. That was fixed by | |
| # re-resolving the class fresh per test, so the directory is now a leg (297 | |
| # tests in the leg, 118 of them tests/vendor's own, measured on this venv). | |
| # | |
| # Two directories are deliberately absent from ``leg``, for reasons that | |
| # are not this job's to fix: | |
| # | |
| # * tests/corekit -- already known and already accepted, not a new | |
| # finding. tests/_support.py's own purge_modules() docstring measures | |
| # and documents exactly this: "python -m unittest discover -s | |
| # tests/corekit fails 5 of that class's identity subtests, because | |
| # test_module sorts first and purges". pytest is "the supported | |
| # runner" for precisely this reason -- tests/conftest.py's autouse | |
| # restore_module_table fixture reconciles the module-identity drift | |
| # that plain unittest has no way to see coming. Running this leg | |
| # against tests/corekit would be permanently red for a characteristic | |
| # the suite's own documentation already treats as expected, not a | |
| # regression this job would be reporting. | |
| # * tests/integration -- needs generated captures this script does not | |
| # build, and is already run whole, under pytest, by the `integration` | |
| # job above. The exclusion lives here, in this matrix, and nowhere else: | |
| # util/run_unittest_leg.py does not refuse the directory, it collects | |
| # nothing from it. tests._tiers.is_unit_tier() rejects all 12 of its | |
| # test_*.py files, so leg_modules('integration') returns an empty tuple | |
| # and the leg would run the root-level modules alone -- green, while | |
| # covering none of tests/integration. Adding it back here would buy that | |
| # nothing, not a failure. | |
| # | |
| # Not added to ``required-checks`` below: a brand-new job earns a place in | |
| # the required set once its own track record justifies it, which is the | |
| # maintainer's call to make separately from landing it. | |
| unittest-ordering: | |
| name: Plain unittest ordering (${{ matrix.label || matrix.leg }}) | |
| # Not on pull requests: it is not a required check, and its 11 cells | |
| # would be a sixth of every PR push's jobs. It still runs on every push | |
| # to main, which is the run that tests what actually lands. | |
| if: ${{ inputs.gate-only != true && github.event_name != 'pull_request' }} | |
| runs-on: ubuntu-latest | |
| # 45, not 20: `timeout-minutes` is job-level, so it has to cover | |
| # checkout/setup/install (~39s) as well as the leg itself, and this is | |
| # the only test job in this file with no xdist underneath it, so its | |
| # protocols leg (847s serial, measured above) carries the full cost | |
| # alone. A review pass measured medium legs at 1.22-1.31x that figure on | |
| # a contended machine, putting protocols at 17-18.5 minutes, and one | |
| # contended run of it hit 1275s outright -- over the old 20-minute cap. | |
| # 45 matches the `test`/`integration`/`pypcap-parity` jobs' own cap | |
| # rather than inventing a new number (`engine-tests` is 30, but runs | |
| # under xdist, which this job deliberately does not). 45 did stop fitting | |
| # comfortably (#1029: protocols at 30.4 minutes on CI), so `protocols` is | |
| # now split into sub-legs, as foreseen -- see the matrix comment above. The | |
| # cap is left at 45: CI measured the slowest cell, protocols/internet, at | |
| # 8.2m (run 37341197811), so even a slowdown like #1029's window leaves it | |
| # far inside the cap: that window's runs were cancelled at 45.3m or more, | |
| # so it ran at least 45.3/27.8, about 1.6x (a lower bound, the runs were | |
| # cut off), which would put the 8.2m cell near 13m. Lowering the cap | |
| # would only trade headroom for a tighter timeout. Raising it to 60 | |
| # instead was the one-line alternative; it would have kept every | |
| # cross-module pair co-running but left the slowest cell at 28 minutes. | |
| timeout-minutes: 45 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| leg: | |
| - cli | |
| - const | |
| - dumpkit | |
| - foundation | |
| - interface | |
| - project | |
| - protocols/internet | |
| - toolkit | |
| - utilities | |
| - vendor | |
| include: | |
| # The remainder of tests/protocols -- see the #1029 note above. | |
| - leg: protocols | |
| exclude: --exclude protocols/internet | |
| label: protocols (rest) | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-python@v7 | |
| with: | |
| python-version: "3.14" | |
| cache: pip | |
| # DPKT/crypto/NGAP join `test` for the same reason the `test` job above | |
| # installs them: this leg's root-level modules and its own directory's | |
| # modules both reach gates that would otherwise skip silently rather | |
| # than run. | |
| - name: Install package and test dependencies | |
| run: | | |
| python -m pip install -U pip setuptools wheel | |
| python -m pip install -e '.[test,DPKT,crypto,NGAP]' | |
| - name: Report available parallelism | |
| run: | | |
| nproc | |
| python -c "import os; print('cpu_count', os.cpu_count())" | |
| # `--verbose` names every test as it runs, so a slow or hung cell | |
| # shows where it was. The step cap of 20 is about twice the slowest | |
| # cell's measured maximum (9.8 minutes; the other shard 8.9), and sits | |
| # under the job's 45 so a hang reports as this step timing out. | |
| - name: Run tests/${{ matrix.leg }} and the root-level modules under plain unittest | |
| timeout-minutes: 20 | |
| run: python util/run_unittest_leg.py ${{ matrix.leg }} ${{ matrix.exclude }} --verbose | |
| # ``CHANGELOG.md`` is generated from the newest entry under | |
| # ``docs/source/changelog/`` by ``util/changelog_md.py``, so it falls out of step | |
| # the moment an entry is edited without regenerating it. That is worth its own | |
| # job rather than leaving it to review, because ``Create Release`` feeds the file | |
| # to the GitHub Release body: a drifted copy is not merely wrong in the tree, it | |
| # is published. | |
| # | |
| # Deliberately *not* gated on ``gate-only``, unlike the four jobs above | |
| # (`test`, `integration`, `engine-tests`, `pypcap-parity`), and that is the | |
| # point of putting it here at all. ``create-release.yml`` calls this | |
| # workflow as its release gate, so an ungated job runs on the release path and | |
| # the release body cannot be built from a file that has drifted. The matrix is | |
| # skipped per caller because it is expensive and already ran for the commit; | |
| # this is neither expensive nor covered elsewhere on that path. | |
| # | |
| # ``tests/project/test_changelog_md.py`` asserts the same identity, so the | |
| # matrix covers it too -- but that test ``skipTest``s when either file is | |
| # absent, and reports a mismatch as two 17 KB strings. ``--check`` cannot skip, | |
| # exits 1 when the file is missing, and prints a unified diff. | |
| changelog: | |
| name: Changelog drift | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-python@v7 | |
| with: | |
| python-version: "3.14" | |
| # No install step: the generator imports argparse, difflib, pathlib, re, sys | |
| # and typing and nothing else, so it runs against a bare interpreter. | |
| # Verified with ``python -S``. | |
| - name: Check CHANGELOG.md is in step with its source entry | |
| shell: bash | |
| run: | | |
| if ! python util/changelog_md.py --check; then | |
| echo "::error title=CHANGELOG.md has drifted::CHANGELOG.md is generated from the newest entry under docs/source/changelog/ and no longer matches it. Regenerate it with 'python util/changelog_md.py' and commit the result. The unified diff above shows what moved." | |
| exit 1 | |
| fi | |
| # Which of a pull request's changed files are code. This decides | |
| # whether the expensive legs run at all (see `required-checks` for how their | |
| # skips are then judged), so it fails safe: anything it cannot classify is | |
| # code. | |
| # | |
| # A file is docs iff it is under `docs/` or ends in `.md`, and is not under | |
| # `.github/`, `pcapkit/`, `tests/`, `util/` or `examples/` -- so a Markdown | |
| # file in one of those (a PR template, a test fixture) is code, and so is | |
| # every other file (`pyproject.toml`, `setup.py`, ...). The diff is the PR | |
| # merge commit against its first parent, which is exactly what merging would | |
| # add; `--no-renames` lists both sides of a rename, so moving a code file | |
| # under `docs/` is still a code change. | |
| # | |
| # Only this workflow's own `pull_request` run is classified. Every other | |
| # event reports `code: true`, including a `workflow_call` from a caller | |
| # triggered by a pull request: inside a called workflow `github.event_name` | |
| # is the caller's, but `github.workflow` is the caller's name too (see the | |
| # concurrency comment at the top), and no caller is named "Unit Tests". | |
| changes: | |
| name: Classify changed files | |
| if: ${{ inputs.gate-only != true }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| outputs: | |
| code: ${{ steps.classify.outputs.code }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| if: ${{ github.event_name == 'pull_request' && github.workflow == 'Unit Tests' }} | |
| with: | |
| fetch-depth: 2 | |
| - name: Classify the diff | |
| id: classify | |
| env: | |
| OWN_PR: ${{ github.event_name == 'pull_request' && github.workflow == 'Unit Tests' }} | |
| run: | | |
| python3 - <<'PY' | |
| import os | |
| import subprocess | |
| CODE_DIRS = ('.github/', 'pcapkit/', 'tests/', 'util/', 'examples/') | |
| def is_docs(path): | |
| if path.startswith(CODE_DIRS): | |
| return False | |
| return path.startswith('docs/') or path.endswith('.md') | |
| code = 'true' | |
| if os.environ['OWN_PR'] != 'true': | |
| print("Not this workflow's own pull_request run: everything runs.") | |
| else: | |
| # -z: names are NUL-terminated, so a name holding whitespace | |
| # (or one git would otherwise quote) stays one path. | |
| diff = subprocess.run(['git', 'diff', '--name-only', '-z', '--no-renames', 'HEAD^1', 'HEAD'], | |
| capture_output=True, text=True) | |
| files = [path for path in diff.stdout.split('\0') if path] | |
| if diff.returncode != 0 or not files: | |
| print(f'::warning title=Could not list the changed files::git diff exited {diff.returncode} ' | |
| f'with {len(files)} files: {diff.stderr.strip()}. Treating the diff as code.') | |
| else: | |
| code_files = [path for path in files if not is_docs(path)] | |
| print(f'{len(files)} changed files, {len(code_files)} of them code:') | |
| for path in files: | |
| print(f" {'code' if path in code_files else 'docs'} {path}") | |
| if not code_files: | |
| code = 'false' | |
| print(f'code={code}') | |
| with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as file: | |
| print(f'code={code}', file=file) | |
| PY | |
| # The docs-only replacement for `test`, running every test that reads | |
| # docs/ or a Markdown file, since a docs-only diff can still break those: | |
| # tests/project and the root-level modules, plus the modules elsewhere under | |
| # tests/ named in the second run step. tests/project/test_unit_tests_queue.py | |
| # finds every module outside those two that names a docs/ or Markdown path | |
| # and fails until it is listed there, so a new reader cannot go unrun. Runs | |
| # only when `changes` says the diff is docs-only, so it never duplicates | |
| # `test` on a code change. It uses | |
| # util/run_unittest_leg.py's `project` leg -- tests/project plus the | |
| # root-level modules, with the extras `unittest-ordering`'s `project` cell | |
| # runs green on main -- rather than a pytest invocation of its own, which | |
| # tests/_dependency_gates.py would then have to model as a new selection | |
| # shape. | |
| project-tests: | |
| name: Project tests (docs-only) | |
| needs: [changes] | |
| if: ${{ inputs.gate-only != true && needs.changes.outputs.code == 'false' }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-python@v7 | |
| with: | |
| python-version: "3.14" | |
| cache: pip | |
| - name: Install package and test dependencies | |
| run: | | |
| python -m pip install -U pip setuptools wheel | |
| python -m pip install -e '.[test,DPKT,crypto,NGAP]' | |
| - name: Run tests/project and the root-level modules under plain unittest | |
| timeout-minutes: 15 | |
| run: python util/run_unittest_leg.py project --verbose | |
| - name: Run the other tests that read docs/ or Markdown | |
| timeout-minutes: 5 | |
| run: python -m unittest -v tests.corekit.test_sentinel_exports_unit | |
| # The verdict a shipping workflow asks for: the full suite, fixtures and | |
| # all, on one interpreter. It is deliberately not the matrix -- the matrix | |
| # has already run against this very commit, from this workflow's own push or | |
| # pull_request trigger, before anything reaches a release path. | |
| gate: | |
| name: Gate (full suite, Python 3.14) | |
| if: ${{ inputs.gate-only == true }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-python@v7 | |
| with: | |
| python-version: "3.14" | |
| cache: pip | |
| # DPKT joins Scapy here for the same reason as the `integration` job | |
| # above -- this is the full, unfiltered suite, so it is the one place | |
| # where skipping the dpkt-gated tests would be most misleading. crypto, | |
| # cli and NGAP join it for the same reason and cover the three of #738's | |
| # remaining gates this change fixes, since an unfiltered `pytest` run (no | |
| # --ignore, no tier selection) reaches every one of them regardless of | |
| # which tier they live in -- unlike the `test` and `integration` jobs | |
| # above, which each cover only the subset their own selection reaches. | |
| # PyPCAPFile is deliberately NOT added here -- see the `test` job's | |
| # comment above for the bug that used to make installing it a | |
| # regression, now fixed by #743/#746. This job runs on 3.14 only, where | |
| # PyPCAPFile's marker resolves to nothing regardless, so adding it here | |
| # would be a no-op that misleadingly suggests the extra is exercised by | |
| # `gate`; #751's `engine-tests` and `pypcap-parity` jobs cover it for | |
| # real, on the 3.10-3.14 (and 3.10-3.11) legs where it actually installs. | |
| - name: Install package, test and generator dependencies | |
| run: | | |
| python -m pip install -U pip setuptools wheel | |
| python -m pip install -e '.[test,Scapy,DPKT,crypto,cli,NGAP]' | |
| # See the integration job above for why this step is shaped the way it is. | |
| - name: Regenerate sample captures | |
| shell: bash | |
| run: | | |
| if ! python examples/generators/make_samples.py 2>&1 | tee "$RUNNER_TEMP/make-samples.log"; then | |
| echo "::error title=Sample fixture generation failed::examples/generators/make_samples.py could not rebuild examples/captures/. This is a fixture-generation failure, not a test failure -- the test suite has not run." | |
| exit 1 | |
| fi | |
| if grep -q '(download unavailable)' "$RUNNER_TEMP/make-samples.log"; then | |
| echo "::warning title=Sample fixtures degraded::The upstream Wireshark captures were unreachable, so synthesised stand-ins were used. The PCAP-NG tier ran, but not against the upstream bytes." | |
| grep '(download unavailable)' "$RUNNER_TEMP/make-samples.log" | |
| else | |
| echo "Upstream Wireshark captures were used, matching their pinned SHA-256 digests." | |
| fi | |
| # See the `test` job above for why this step exists. | |
| - name: Report available parallelism | |
| run: | | |
| nproc | |
| python -c "import os; print('cpu_count', os.cpu_count())" | |
| # Deliberately still the whole suite, unlike the `integration` job above: | |
| # this is the shipping gate's own independent re-verification (see the | |
| # job comment above), not the same accidental duplication #715 measured | |
| # for `integration`. `gate` does not run per PR push -- only via | |
| # `create-release.yml`'s `gate-only: true` -- so it is not part of the | |
| # ~130 minutes/push #715 measured, and partitioning it would trade away | |
| # the "trust nothing, re-run everything" property this job exists for. | |
| # Whether `gate` should run at all on a commit the matrix already | |
| # covered is #715's separate item 2 ("stop the gate re-running on main | |
| # pushes"), a trigger-frequency question, not a selection one -- out of | |
| # scope here. `--dist load` is pinned explicitly; see the `test` job | |
| # above for why. | |
| - name: Run full test suite | |
| run: python -m pytest -q -n auto --dist load | |
| # Ruleset 23497679's required_status_checks requires six contexts: this | |
| # job's `Required checks passed`, and five for `Compat Python 3.10`-`3.14` | |
| # from the `compatibility` job in `.github/workflows/python-compatibility.yml`. | |
| # This job aggregates the gating jobs of *this* file, the ones its `needs:` | |
| # lists, because rulesets match check names literally and support no | |
| # wildcard: requiring each matrix cell by name would mean hand-editing the | |
| # ruleset every time a matrix changes. On a docs-only pull request it | |
| # accepts `skipped` for the four code legs (see below). The `Compat` legs | |
| # are required on their own because a job cannot `needs:` a job in another | |
| # workflow file, so nothing here can stand in for | |
| # them. | |
| # | |
| # `if: ${{ always() && inputs.gate-only != true }}` and the explicit | |
| # per-job check below are both required, not decorative: | |
| # | |
| # * Without `always()`, this job would itself be *skipped* -- not | |
| # failed -- the instant any job it `needs:` failed or skipped, per | |
| # "Using jobs in a workflow" > "Defining prerequisite jobs": "If a job | |
| # fails or is skipped, all jobs that need it are skipped unless the | |
| # jobs use a conditional expression that causes the job to continue." | |
| # And a skipped required check is not automatically a blocked merge: | |
| # GitHub's own "Troubleshooting required status checks" page lists | |
| # `skipped` alongside `success` and `neutral` as a *passing* check | |
| # status, and its "Handling skipped but required checks" table says | |
| # plainly that when "A job depends on a failed job", the result is | |
| # "The dependent job is skipped and may not block merging" -- exactly | |
| # the silently-worse-than-no-gate failure this job exists to prevent. | |
| # That page's own recommended fix is this job's shape: "Use always() | |
| # with needs for required checks that depend on other jobs." | |
| # * `inputs.gate-only != true` mirrors the jobs this needs: below. | |
| # They are all skipped by their own `if:` whenever this workflow is | |
| # called with `gate-only: true` (from create-release.yml), so without | |
| # this guard every needs.*.result below would read `skipped` on that | |
| # path and this job would report a spurious failure -- on a release | |
| # call, not a PR, which is not where ruleset 23497679 applies anyway. | |
| # * The check itself inspects every dependency explicitly and prints each | |
| # one's result before failing, so a red run names which leg broke | |
| # instead of leaving that to be re-discovered from the four jobs' own | |
| # logs. | |
| # | |
| # Note for whoever adds a second caller of this reusable workflow: under | |
| # `workflow_call`, GitHub prefixes a called workflow's check names with the | |
| # calling job's own name (`<caller job> / Required checks passed`), which | |
| # would not match a ruleset entry naming just `Required checks passed`. | |
| # Harmless today -- `create-release.yml`'s only call passes | |
| # `gate-only: true`, which skips this job via the guard above before that | |
| # renaming would ever matter -- but a future caller that does NOT set | |
| # `gate-only: true` would produce a differently-named, unrequired check. | |
| # | |
| # Deliberately NOT `needs: gate`: `gate` only ever runs when this workflow | |
| # is called with `gate-only: true` and is otherwise skipped by its own | |
| # `if:`, so depending on it would leave this job permanently skipped on | |
| # every ordinary PR -- the one case this job has to actually gate. | |
| # `changelog` is deliberately left out too: it is a drift check, not a | |
| # gating leg, and folding it in would widen what merging requires -- a | |
| # change for the maintainer to decide, not one to slip in here. | |
| # `test`, `integration`, `engine-tests` and `pypcap-parity` are the gating | |
| # legs; `changes` and `project-tests` are needed only for the docs-only | |
| # path below. | |
| # | |
| # `engine-tests`'s `unsupported` and `not-installable` cells are not a gap | |
| # here: the cell script exits 0 for a cell that declined exactly as | |
| # expected, so they contribute to that job's `success` like any other cell. | |
| # | |
| # Nor is its 3.15 cell. It is a step with step-level `continue-on-error: | |
| # true` inside each engine's job, whose effect on the job's result *is* | |
| # documented (a step's `conclusion` is its result after `continue-on-error` | |
| # is applied), so the job, and this gate, read `success` while the failed | |
| # step stays visible. Not a job-level `continue-on-error`: GitHub does not | |
| # document whether `needs.<job>.result` sees such a job's failure as | |
| # `failure` or `success`, so a 3.15-only regression could block every merge. | |
| # | |
| # The docs-only path. On a pull request whose diff touches nothing but | |
| # `docs/**` and `*.md` files outside the code directories (the `changes` | |
| # job's classifier), `test`, `integration`, `engine-tests` and | |
| # `pypcap-parity` are skipped by their own `if:`, and `project-tests` runs | |
| # instead. A job-level `if:` rather than workflow-level `paths-ignore`, | |
| # because `paths-ignore` would not run this workflow at all and leave this | |
| # required context pending forever. So `skipped` is accepted for those four | |
| # legs **only** when `changes` succeeded and said `code == 'false'`, and then | |
| # `project-tests` must have succeeded. Every other combination keeps the | |
| # strict rule: `changes` failing, its output missing, or any leg skipped | |
| # while `code` is `true` fails this gate. `changes` reports `code: true` for | |
| # every event but this workflow's own `pull_request`, so `push` and | |
| # `workflow_call` runs never take this path. | |
| required-checks: | |
| name: Required checks passed | |
| needs: [changes, test, integration, engine-tests, pypcap-parity, project-tests] | |
| if: ${{ always() && inputs.gate-only != true }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Check every required job succeeded | |
| env: | |
| CHANGES: ${{ needs.changes.result }} | |
| CODE: ${{ needs.changes.outputs.code }} | |
| TEST: ${{ needs.test.result }} | |
| INTEGRATION: ${{ needs.integration.result }} | |
| ENGINE_TESTS: ${{ needs.engine-tests.result }} | |
| PYPCAP_PARITY: ${{ needs.pypcap-parity.result }} | |
| PROJECT_TESTS: ${{ needs.project-tests.result }} | |
| run: | | |
| set -eu | |
| echo "changes: $CHANGES (code: '$CODE')" | |
| echo "test: $TEST" | |
| echo "integration: $INTEGRATION" | |
| echo "engine-tests: $ENGINE_TESTS" | |
| echo "pypcap-parity: $PYPCAP_PARITY" | |
| echo "project-tests: $PROJECT_TESTS" | |
| failed=0 | |
| if [ "$CHANGES" != "success" ] || { [ "$CODE" != "true" ] && [ "$CODE" != "false" ]; }; then | |
| echo "::error title=changes did not classify the diff::result was '$CHANGES' and code was '$CODE'; without a classification nothing may be skipped." | |
| CODE=true | |
| failed=1 | |
| fi | |
| # Docs-only (code == false): a leg may be skipped, and project-tests | |
| # is what must have passed. Otherwise every leg must have succeeded. | |
| if [ "$CODE" = "false" ]; then | |
| allowed="success skipped" | |
| [ "$PROJECT_TESTS" = "success" ] || { echo "::error title=project-tests did not succeed::result was '$PROJECT_TESTS'; on a docs-only diff it is the test that has to pass."; failed=1; } | |
| else | |
| allowed="success" | |
| fi | |
| for pair in "test=$TEST" "integration=$INTEGRATION" "engine-tests=$ENGINE_TESTS" "pypcap-parity=$PYPCAP_PARITY"; do | |
| job="${pair%%=*}" | |
| result="${pair#*=}" | |
| case " $allowed " in | |
| *" $result "*) ;; | |
| *) echo "::error title=$job did not succeed::result was '$result'; accepted here: $allowed (code: '$CODE')."; failed=1 ;; | |
| esac | |
| done | |
| if [ "$failed" -ne 0 ]; then | |
| echo "::error title=Required checks did not all succeed::See the per-job lines above. A skipped or cancelled dependency is only a pass on a docs-only diff; see this job's own comment." | |
| exit 1 | |
| fi | |
| echo "All required jobs reported an accepted result." |