GitHub Actions Workflows¶
Important
The workflows under .github/workflows/ trigger each other, and
the chain that results is not visible from any single file – reading one
on: block never shows what a different workflow’s completion goes on
to start. This page is the repository-wide graph. The release pipeline
itself, job by job from a version bump to a published package, is on
Release Process.
At a Glance¶
Workflow |
File |
|
|
|
|
other |
|---|---|---|---|---|---|---|
CodeQL |
|
|
|
Sat 02:00 |
– |
– |
Unit Tests |
|
|
|
– |
– |
|
Experimental Tests |
|
|
|
– |
– |
– |
Python Compatibility |
|
|
|
Sat 04:00 |
– |
– |
Lint |
|
– |
|
Sat 06:00 |
– |
|
Vendor Update |
|
– |
– |
Sat 10:00 |
Unit Tests |
– |
Conda Update |
|
– |
– |
Sat 18:00 |
Unit Tests |
– |
GitHub Pages |
|
– |
|
Sat 02:00 |
Unit Tests |
– |
Cron Head |
|
– |
– |
– |
– |
|
Create Release |
|
tags |
– |
– |
Vendor Update |
– |
Project Status |
|
– |
– |
daily 00:37 |
– |
|
Coverage Comment |
|
– |
– |
– |
Unit Tests |
– |
CI Timing |
|
– |
– |
Mon 07:23 |
– |
|
Python Versions |
|
– |
– |
Mon 05:41 |
– |
|
Note
Only Coverage Comment fires exclusively on workflow_run: it has
nothing to do without a completed Unit Tests run. Each of the others
that carry one pairs it with a direct trigger of its own – a Saturday
schedule for Vendor Update, Conda Update and GitHub Pages, a v* tag
push for Create Release – for the path where no upstream run exists yet to
chain from.
The Graph¶
Solid arrows are direct triggers (push, pull_request, schedule,
workflow_dispatch, issues, pull_request_target), drawn in the first
graph with a node for each schedule day. The second graph is the edges from one
workflow to another. Thick arrows are workflow_run edges – a
separate run, started only after the upstream workflow’s run has fully
completed. Dotted arrows are uses: edges – a reusable-workflow call that
runs synchronously inside the caller’s own run, sharing its run id rather
than starting a new one.
flowchart LR
PUSH["push: main"]
PR["pull_request: main"]
SAT["schedule: Saturday"]
MON["schedule: Monday"]
DISPATCH["workflow_dispatch"]
DAILY["schedule: daily"]
ISSUES["issues"]
PRT["pull_request_target"]
TAG["push: tags v*"]
UT["Unit Tests<br/>unit-tests.yml"]
EX["Experimental Tests<br/>experimental.yml"]
PC["Python Compatibility<br/>python-compatibility.yml"]
CQ["CodeQL<br/>codeql-analysis.yml"]
GP["GitHub Pages<br/>deploy-pages.yml"]
CU["Conda Update<br/>cron-conda.yml"]
LT["Lint<br/>lint.yml"]
VU["Vendor Update<br/>cron-vendor.yml"]
PV["Python Versions<br/>cron-python-versions.yml"]
CT["CI Timing<br/>ci-timing.yml"]
PS["Project Status<br/>project-status.yml"]
CR["Create Release<br/>create-release.yml"]
PUSH --> UT
PR --> UT
PUSH --> EX
PR --> EX
PUSH --> PC
PR --> PC
SAT -->|04:00| PC
PUSH --> CQ
PR --> CQ
SAT -->|02:00| CQ
PR --> GP
SAT -->|02:00| GP
SAT -->|18:00| CU
PR --> LT
SAT -->|06:00| LT
DISPATCH --> LT
SAT -->|10:00| VU
MON -->|05:41| PV
DISPATCH --> PV
MON -->|07:23| CT
DISPATCH --> CT
DISPATCH --> PS
DAILY -->|00:37| PS
ISSUES --> PS
PRT --> PS
TAG --> CR
classDef trig fill:none,stroke-dasharray:2 2
class PUSH,PR,SAT,MON,DISPATCH,DAILY,ISSUES,PRT,TAG trig
flowchart LR
UT["Unit Tests<br/>unit-tests.yml"]
VU["Vendor Update<br/>cron-vendor.yml"]
CU["Conda Update<br/>cron-conda.yml"]
GP["GitHub Pages<br/>deploy-pages.yml"]
CH["Cron Head<br/>cron-head.yml"]
CC["Coverage Comment<br/>coverage-comment.yml"]
CR["Create Release<br/>create-release.yml"]
UT ==>|workflow_run: completed| VU
UT ==>|workflow_run: completed| CU
UT ==>|workflow_run: completed| GP
VU ==>|workflow_run: completed| CR
UT ==>|workflow_run: completed| CC
VU -.-> CH
CU -.-> CH
GP -.-> CH
CH -.-> UT
CR -.-> UT
The double relationship between Unit Tests and the dependants that also
call it is the part that reading a single file cannot show, and it is
deliberate rather than redundant. Vendor Update, Conda Update and GitHub Pages
each call Cron Head as their head job (dotted, above), and its test gate
calls unit-tests.yml with gate-only: true only when the caller’s
trigger is schedule – the gate’s if: skips workflow_run and
pull_request – so only on the Saturday path, where no completed Unit Tests
run exists yet to read a verdict from. On the workflow_run path (thick
arrows), each reads the verdict Unit Tests already reached instead of re-running
the gate a second time, and acts on it only once the head’s verdict job,
described under the edge list below, says it is a verdict on the commit it
builds (#1967). Create
Release has no schedule, and its gate job’s if: reads version_check’s evidence
rather than the event, so its gate can run whichever way the run was raised, once
version_check itself has passed: on a v* tag push, and on a
workflow_run from Vendor Update when some target is not yet published
(Release Process gives the conditions).
workflow_run Edges¶
Each edge cites the on: block it comes from, so grepping those blocks
re-derives the list:
.github/workflows/cron-vendor.yml, itsworkflow_runtrigger – Vendor Update fires on completion of Unit Tests..github/workflows/cron-conda.yml, itsworkflow_runtrigger – Conda Update fires on completion of Unit Tests..github/workflows/deploy-pages.yml, itsworkflow_runtrigger – GitHub Pages fires on completion of Unit Tests..github/workflows/create-release.yml, itsworkflow_runtrigger – Create Release fires on completion of Vendor Update..github/workflows/coverage-comment.yml, itsworkflow_runtrigger – Coverage Comment fires on completion of Unit Tests.
workflow_run fires for every completion of the named workflow –
success, failure, or skipped – and regardless of what triggered it,
including a pull request’s own run of Unit Tests. Every
downstream workflow therefore re-checks github.event.workflow_run.event
and .head_branch (or, for Create Release and Coverage Comment,
.conclusion) itself before doing anything with an outward effect; none of
the filtering happens in the on: block.
Those checks say nothing about which commit was verified. A workflow_run
checks out github.sha, main’s tip when the event is raised, not
github.event.workflow_run.head_sha, the commit the completed run tested, and
in a merge wave the two differ: Conda Update 38325136710 bumped 2e941bbb7 under
the verdict for 9083163f5 (#1643).
So Vendor Update, Conda Update and GitHub Pages each run Cron Head’s verdict
job first, in their head job, and act only when its verified output says
the tip is the verified commit, or that commit plus bot commits
to conda/build and conda/requirements.txt alone; otherwise they skip, and
the tip’s own Unit Tests run raises them again. Vendor Update’s run still
concludes success then, since verdict ran, so Create Release reads its
vendor-update job to tell it from a real one
(#1748). The rule and its evidence are in
util/verdict_covers_checkout.py. Conda Update also writes after the
checkout, so it applies the same rule a second time: its push rebases onto
main’s tip only when the commits that brings in pass it, and otherwise
pushes nothing, and Conda Tag tags the commit that push landed rather than the
tip (#1632).
Vendor Update’s own commit raises it again: the push gets a Unit Tests run, whose
completion starts another crawl. The commit bumps the version only when the crawl
changed code. If every change is to comments or docstrings, the commit is “Updated
vendor documentation without a version bump”, and the Create Release run that
follows sees the same version, so it has nothing new to publish
(#1674). A crawl raised by
such a commit’s own Unit Tests run does not commit comment changes on top of it,
so documentation commits cannot chain. The rule is in
util/vendor_code_changed.py. A bump commit carries every module the crawl
wrote, a new one included, so the crawl it raises does not find one untracked
and bump again (#1685).
Reusable-Workflow Calls (uses:)¶
A uses: call is a different relationship from triggering: it runs inside
the caller’s own workflow run, as one of the caller’s own jobs, rather than
starting an independent run after the caller finishes. The calls target
unit-tests.yml, always with gate-only: true, and cron-head.yml:
.github/workflows/create-release.yml, jobunit-tests– calls./.github/workflows/unit-tests.ymlwithgate-only: true..github/workflows/cron-head.yml, jobunit-tests– calls./.github/workflows/unit-tests.ymlwithgate-only: true..github/workflows/cron-conda.yml,cron-vendor.ymlanddeploy-pages.yml, jobheadin each – calls./.github/workflows/cron-head.yml, with no inputs. The call’sverifiedoutput is itsverdictjob’s, and the acting job reads it besideneeds.head.result.
Cron Head (#1967) is
one copy of the test gate and the verdict job, which Conda Update, Vendor
Update and GitHub Pages used to carry each. It holds contents: read and no
more, which is what each caller grants its head job and what
unit-tests.yml is called with. Every expression in it reads the caller’s github context, so its jobs decide on
the event that started the caller and check out the caller’s github.sha. A
job of the call skips on every path – the gate everywhere but the schedule,
verdict everywhere but a workflow_run – so each acting job keeps
!cancelled(), for the reason the skip-cascade section below gives, and
treats a head result other than failure as passing.
Their schedules are eight hours apart on Saturdays: GitHub Pages at 02:00, Vendor Update at 10:00 and Conda Update at 18:00 UTC (#1967). The owner’s ruling kept a schedule on each and allowed them aligned or spread, and nothing orders them:
None reads another’s output. A Vendor Update push reaches Conda Update and GitHub Pages anyway, as a Unit Tests run whose completion raises both on the
workflow_runpath. Create Release reads only the Vendor Update run that triggered it, through itsvendor_updatejob, so the time of the others does not matter to it.At one time they cost more. Vendor Update and Conda Update both ran at 10:00 until this change, and each scheduled pair read back from 2026-09-26 on checked out the same commit – on 2026-10-17, Conda Update 39023770904 and Vendor Update 39023891165 both gated 44ed96fc3, their
Gate (full suite, …)jobs taking twelve and ten minutes. Their writing jobs share therepository-maintenancegroup, soconda-updatethen waited forvendor-updateto finish, and a third job joining that group cancels the one pending there.
Spread, each head gates alone, and Conda Update runs once Vendor Update’s
Create Release has had the day to publish, so its Check the GitHub release
exists step finds the release rather than skipping the GitHub upload
(#1634).
gate-only: true selects not only unit-tests.yml’s gate (the full
suite, on latest) but also changelog (checks CHANGELOG.md against its
source entry) and python-versions, which reads gate’s version (see
Python Versions below). It skips the rest – the matrix jobs, test,
test-ktlo, integration, integration-ktlo, engine-tests (a job per
engine, each looping over its Python versions), pypcap-parity,
pypcap-parity-ktlo and unittest-ordering, each of which has already run
once for this commit from Unit Tests’ own push trigger (and, all but
unittest-ordering, which runs on main pushes only, from its
pull_request trigger), so running any of them again per caller would test
the same commit several times over. It also skips changes,
project-tests, whose project pins gate’s full suite runs too (see
Project Pins below), and required-checks (see Required Status
Checks below), which are gated out by their own if: rather than by having
already run. changelog carries no
if: at all and runs on every path regardless – deliberately, per its own
comment (unit-tests.yml:1629-1635): create-release.yml feeds
CHANGELOG.md to the GitHub Release body, so the release path is exactly
where a drifted file must not go unchecked. Confirmed on run 36210743295 (a
schedule-triggered GitHub Pages run): both Changelog drift and Gate
(full suite, Python 3.14) succeeded, with the matrix jobs that
existed then – Python ${{ matrix.python-version }}, Integration
Python …, Engines Python … and PyPCAP/PyPCAPFile parity Python
… – all reporting skipped.
The Skip Cascade (#888)¶
The relationships above compose into a failure mode worth seeing on its
own graph. Create Release’s version_check – ahead of the uses: call
above – carries:
if: >-
${{ !cancelled() &&
(github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') &&
!(github.event_name == 'push' && github.actor_id == vars.RELEASE_APP_BOT_ID) &&
needs.vendor_update.outputs.updated != 'false' }}
Its third clause skips the tag-push run that
the release App’s own tag starts (Release Process, “The Release App”). On the
workflow_run path, this evaluates false, and the job is skipped
outright, whenever the upstream Vendor Update run’s own conclusion was
anything other than success – including
skipped, which is exactly what Vendor Update’s own jobs report when
their workflow_run-path guards decline (see cron-vendor.yml’s
vendor-update job). Since
#1748 it is also false
for a Vendor Update that concluded success with vendor-update skipped –
its verdict job found the Unit Tests verdict was for another commit – which
the vendor_update job reads from that run’s jobs first (observed on run
38650191283). !cancelled() keeps a skipped vendor_update, on a push, from
skipping it. unit-tests then depends on that job with
needs: [ version_check ] and an if: that
calls no status-check function, so a skipped dependency skips it too – and
every job past it is gated the same way: github needs [ unit-tests,
version_check ], tag and pypi need [ github, unit-tests,
version_check ], and conda needs [ tag, github, unit-tests,
version_check ].
flowchart TD
WR["Vendor Update completed<br/>conclusion != success,<br/>or vendor-update skipped"]
VC["version_check<br/>SKIPPED"]
UTX["unit-tests (uses: unit-tests.yml)<br/>SKIPPED"]
GH["github (GitHub Release)<br/>SKIPPED"]
TAGJ["tag (Conda Tag)<br/>SKIPPED"]
PY["pypi (PyPI distribution)<br/>SKIPPED"]
CD["conda<br/>SKIPPED"]
WR -->|if: false| VC
VC -->|needs| UTX
UTX -->|needs| GH
GH -->|needs| TAGJ
GH -->|needs| PY
GH -->|needs| CD
TAGJ -->|needs| CD
classDef skipped fill:#fdecea,stroke:#c0392b,stroke-dasharray:4 2
class UTX,VC,GH,TAGJ,PY,CD skipped
This is a transitive reduction, as in Release Process: version_check and
unit-tests are also direct needs: of tag, pypi and conda,
but github
already implies those edges, since it depends on both itself. Drawing them
would add lines without changing which job can start before which, or which
job a skip in version_check reaches.
The whole run then reports skipped, not failed – there is no red X to
notice. Observed in production on runs 36511610205 and
36510774776, both
workflow_run-triggered Create Release runs on main that concluded
skipped with every one of their jobs – Release test gate,
Check Version, GitHub Release, Conda Tag (release), PyPI
distribution for Python … and Conda deployment (release) … – reporting
skipped in turn.
This is a distinct mechanism from the PCAPKIT_TAG_EXISTS skip
Release Process already documents under “Precautions” (a later run
skipping because the tag a previous run already created still exists): this
one can skip the very first attempt, before any tag is ever created, purely
because the upstream Vendor Update run did not itself conclude success.
Such a run is also kept out of the release’s concurrency group
(its concurrency block,
#1705). It is keyed on
its own github.run_id rather than on the ref, so it cannot cancel a run
waiting there with release work. Keyed on the ref, run 38499295798 joined at
02:06:32Z on 2026-10-14, and the pending 38499150441, whose Vendor Update had
succeeded, was cancelled a second later. A run whose Vendor Update concluded
success with vendor-update skipped (#1748) is still keyed on the ref:
the group reads only the event, which does not say which jobs ran. It can still
displace a run pending there, and releases nothing in its place, so that
release waits for the next Vendor Update on main.
pull_request_target¶
Project Status is the only workflow on pull_request_target, and the only one
that runs with a secret on an event a fork can cause: it writes the project board’s
Status field with PROJECT_TOKEN, which a fork’s pull_request run would not
receive. It therefore never checks out the repository and has no uses: step. It
fetches util/project_status.py through the API at github.sha – the base
branch’s tip under that event – and takes only the item number from the payload.
tests/project/test_project_status.py asserts this over the file. Without the
secret it skips with a ::notice::. The Status mapping it applies is documented in
Running the Repository.
Coverage (#1063)¶
Coverage is measured inside an existing leg rather than by a workflow of its
own: the Python 3.15 leg of test (matrix key coverage: true) runs
its unchanged pytest selection under coverage run
--rcfile=.github/coverage.toml. That rcfile repeats
pyproject.toml’s [tool.coverage.*] settings
(tests/project/test_coverage_rcfile.py asserts it) and adds parallel
and patch = ["subprocess"], without which only the xdist controller is
measured, not its workers, plus core = "ctrace": with the suite purging
and re-importing pcapkit, one module’s peak RSS was 4.17 GiB under the default
sys.monitoring core against 0.41 GiB under ctrace, and that growth
killed the runner. sigterm = true and a pytest_sessionfinish hook in
tests/conftest.py that saves each xdist worker’s data early keep a
worker that dies during shutdown from losing its file
(#1423). The leg’s
Report coverage step then runs util/coverage_report.py, which fails,
posting no total, unless the controller and every xdist worker saved a data file
(#1387), and otherwise
writes the total and a per-package table to the job summary. The leg uploads the HTML
report as the coverage-html artifact (30 days), with the bare numbers as the
coverage-summary artifact and the per-process debug log as
coverage-processes-log.
The PR comment comes from Coverage Comment (coverage-comment.yml),
not from unit-tests.yml. That file is also a reusable workflow, a called
workflow can only narrow the token its caller passes, and every caller grants
contents: read, so a pull-requests: write job there would break them.
Coverage Comment runs no tests. On workflow_run it uses the default
branch’s file and its own token, so fork pull requests get a comment too. It
treats the artifact as untrusted: it never checks out pull-request code. It
takes the PR number from the API, as the open PR whose head is the run’s head
sha, and renders its own Markdown from validated numbers. It edits one comment,
found by a hidden marker, on each push. The first line says when the tests
failed. Neither workflow’s coverage step is a required check.
CI Timing Review (#1538)¶
CI Timing (ci-timing.yml) runs util/ci_timing.py every Monday,
or on dispatch with a days window and an optional concurrency_hours runner-pool
measurement. Through the API it lists every completed run in the window of Unit Tests,
Python Compatibility, Lint, CodeQL, GitHub Pages and Experimental Tests (#1732, so
its two-hour legs stay measured), a day per query because a filtered listing stops
at 1,000 runs (Pages completed 1,154 in the week to 2026-10-09). It then reads the
jobs of an evenly spaced sample – 150 push and 150 pull-request runs of Unit Tests,
60 runs of each other workflow – and the summary says how many runs each sample
holds and which span they cover. For the week to 2026-10-19 that was 677 calls
(#1930: 49 listing pages,
one per workflow per day and one more per further 100 runs; 601 job reads; 15 for
the JUnit artifacts; 12 workflow-file reads) of GITHUB_TOKEN’s 1,000 an hour, on
a read-only token (actions: read, contents: read). It took 16.6 minutes,
908 seconds of it on the job reads, which the API answers at up to 2.4 s each and
which are made one at a time. So they are read last, in an order every prefix of
which spans each sample, and stop at --budget-minutes (30): the report is then
written on the runs read, with a note saying so. The step’s cap (35 minutes) and the
job’s (40) sit above that budget, and the summary and upload steps run on
always(), so a report written before a cap stopped the job is still published.
Progress goes to the log as the script runs. A job is measured by its own outcome: one
cancelled at 95% of its cap or later timed out and counts, even in a run that reads
cancelled for that reason; one cancelled earlier was superseded and does not. A
step cap written as an expression differs by leg and reads as none.
The report goes to the job summary and to the ci-timing-report artifact (90
days), and the newest earlier report is the next run’s baseline. It is not a
workflow_run on Unit Tests, which would add a job per run to a runner pool
measured saturated at 20 concurrent jobs.
Per-test timings come from the test and integration legs, and from
experimental.yml’s, which run pytest with --durations=30
--durations-min=1.0 and --junitxml and upload the file as
junit-unit-<version>, junit-integration-<version> or
junit-experimental-<version> (14 days). Each upload step is continue-on-error,
so an artifact-service outage cannot fail a leg. pytest writes one <testcase> per test however
many subtests it runs – about 0.6 MB per unit leg (estimate) – but the suite’s
tests= count includes subtests, so the review reads each testcase’s time. The
ordering, gate, engine-tests and pypcap-parity legs write no file, and nor
does a leg killed at its cap, since pytest writes it at session end.
A HARD threshold fails the run with status 1, and GitHub emails a scheduled workflow’s failure to whoever last edited its cron line; a WARN is an annotation. A run that cannot read the API ends with 2 and one that crashes with 3, each with an annotation saying no threshold was evaluated. Each threshold is anchored to a figure measured on the issue:
ID |
Fires when |
Measured |
|---|---|---|
|
a job’s p90 reaches 50% of its |
|
|
a step with a cap of its own reaches 50% of it at p90 (the ordering cells’ 20) |
|
|
|
– |
|
a job’s or capped step’s linear fit reaches its cap within 14 days |
– |
|
a job’s median rises 20% on the baseline, with 20 samples each side |
– |
|
pull-request time to mergeable passes 18 min at the median |
14.4 min |
|
the coverage leg’s tests step passes 1.8 times its paired legs’ (median) |
1.57 |
|
ms per test on 3.13 rises 15% on the baseline |
110.8 ms |
|
run queue p90 passes 5 min on pull requests or 15 min on pushes |
3.1 and 9.6 min |
|
more than 35% of pull-request runs are cancelled, i.e. superseded |
29% |
|
a module passes 30 s summed or a test 10 s on 3.13, a module enters the top 10, or a class’s every test costs about what its first does |
– |
|
a different job finishes last before |
|
|
pull-request time to |
19.6 min; 36.7 in the two days to 2026-10-10 |
The cap line sits at 50% because at the fastest growth measured, +1.27 min/day on
Python 3.14, crossing it leaves 12-18 days before the cap: one review and one
fixing pull request. Time to Required checks passed is HARD only as a regression
because its p90 measures runner-queue load as much as test time: it was 19.6 min when
the line was set and 36.7 min in the two days to 2026-10-10 with the pool saturated,
so an absolute HARD line would email about the queue every week. The 25% rise is a
starting value, to be tuned against the weekly reports. W9 is therefore expected to
fire while the pool stays saturated, and the JUnit timings are what show whether
W7’s 10-second test line needs raising. What to do when one fires is on
Running the Repository.
H1, H2 and H4 on a non-blocking job are warnings, not HARD, so they do
not fail the run (#1732): on every job of experimental.yml, which no
required context names, on every KTLO leg, a job whose name ends in (KTLO)
(#1633), and on a job whose name ends in (experimental) in any workflow. That
last covers the experimental legs that ran in unit-tests.yml under
test’s 45-minute cap before the split. The finding keeps its H id and says it is non-blocking. A
KTLO leg past its job’s timeout-minutes is still cancelled, and its check run
reads red, though since #1738 nothing gating waits on it, so that warning is worth
acting on before the cap.
Work Kept Off the Pull-Request Queue (#1538)¶
Cuts in unit-tests.yml keep work that cannot gate a merge from competing
with pull-request jobs for the 20-runner pool. None touches a required check or adds a
permission to the reusable workflow.
unittest-orderingruns onmainpushes only, per #1052 item H, and hasneeds: [test], so its cells join the queue oncetestfinishes rather than when the push run is created, ahead of that run’s ownPythonlegs and of pull-request jobs created later in a merge wave. Itsif:opens with!cancelled(), so the cells still run whentestfails, and a red cell still fails the push run that GitHub Pages, Conda Update and Vendor Update (and so Create Release) wait on. The cost is wall time: the push run, and those workflows, finish about one slowest cell later, about 6 min before any queue wait (estimate).engine-testshas noDPKTcell. Its only path wastests/toolkit/test_dpkt_unit.py, whichtestruns withDPKTinstalled on every leg. Dropped with it: that file in a venv holding onlytestandDPKT.
Another cut, running the then-prerelease 3.15 engine cell on main pushes only,
ended with #1633: 3.15 is
in the supported tier, so its cells block on every run.
Required Status Checks¶
Ruleset 23497679 on main requires the contexts below, with
strict_required_status_checks_policy: true (a branch must be up to date
with main before it can merge, not merely green). Each was found by
grepping every workflow file for its name:
Required context |
Emitted by |
Where |
|---|---|---|
|
job |
|
|
job |
|
|
job |
|
|
job |
|
|
job |
|
|
job |
|
That is the list after #1633,
which added Compat Python 3.15 and removed Compat Python 3.10. Moving a version
between the tiers below means editing the ruleset in the same change.
Required checks passed is defined by exactly one job
(unit-tests.yml:1964). Compat Python is defined by one, the
name: of the compatibility job in python-compatibility.yml, whose
matrix comes from .github/python-versions.toml: the required legs
are its supported tier (python-versions.toml:20-24), and its KTLO legs
(python-versions.toml:16-19) have names ending in (KTLO), so no required
context matches one. A comment at
unit-tests.yml:1876 mentions the string without defining it.
Required checks passed is an aggregate, not a single check run. The ruleset
requires it and the Compat Python 3.11-3.15 legs, nothing else, as
unit-tests.yml’s own comment (unit-tests.yml:1875-1885) records. It
needs: the gating jobs of unit-tests.yml – test, integration,
engine-tests and pypcap-parity, plus project-tests, the one run of
the project pins, and changes for the docs-only path – and checks each
result explicitly
(unit-tests.yml:1963-2019). Rulesets match check names literally, with no
wildcard, so requiring the matrix cells one by one would mean editing the
ruleset whenever a matrix changes; the aggregate keeps the required list fixed.
It accepts the gating jobs as skipped only on a docs-only pull request, where
project-tests runs every test that reads docs/ or Markdown as well;
project-tests must pass on every diff. The Compat legs stay required separately because they come from
python-compatibility.yml, and a job cannot needs: a job in another
workflow file. This job runs on Unit Tests’ own push/pull_request
triggers; the gate-only: true reusable calls documented above skip it via
if: ${{ always() && inputs.gate-only != true }} (unit-tests.yml:1966),
so it is never produced – and never expected – on those paths.
Python versions are tiered – supported and KTLO
(#1633), and experimental
(#1660, below) – in
.github/python-versions.toml (see Python Versions).
The supported tier, 3.11-3.15, blocks: a failed leg fails Required checks passed or its own
required Compat context. The KTLO (keep the lights on) tier – end-of-life 3.7-3.10, still built and
tested – never blocks, is never a required context, and never shows a commit or a
pull request red (#1672). In unit-tests.yml the KTLO legs are jobs of their own
since #1738: test-ktlo,
integration-ktlo and pypcap-parity-ktlo, which neither required-checks
nor unittest-ordering needs:, so not even a failure no step can contain, such
as a lost runner or the job cap, reaches a gate. Each runs its gating twin’s steps,
by YAML alias (Reusing workflow configurations),
and keeps its legs’ check names, Python 3.10 (KTLO) and so on. In
python-compatibility.yml and create-release.yml a KTLO leg shares its job
with supported legs. Every job holding KTLO legs works the same way:
Every step that can fail on a KTLO leg has an
idandcontinue-on-error: ${{ matrix.ktlo == true }}(unit-tests.yml:394-411), unless it is masked on every leg, as the timings uploads are. Itsoutcomeis thenfailurebut itsconclusionsuccess, so the job, its check run andrequired-checksreadsuccess. A step that needs an earlier one’s work waits on that step’soutcome: a leg that cannot install stops there, and apypileg stops at its first failure.The last step,
KTLO summary, runs on KTLO legs only and reports each failedoutcomeas a warning and a job-summary line, throughutil/leg_summary.py(see Workflow Scripts (#1967)). A step’s own annotations takeANNOTATION_LEVEL:warningon a KTLO leg,erroron a supported one.engine-tests’ KTLO cells, a step of their own withcontinue-on-error: true, report the same way.Never job-level
continue-on-erroralone: it passes the run, but the leg’s check run still concludesfailure, which showed every commit red whileCompat Python 3.7 (KTLO)carried it.unit-tests.yml’s KTLO jobs carry it on top of the step-level guards, as a backstop for the failures no step contains, asexperimental.yml’s job does; it “prevents a workflow run from failing when a job fails” (Workflow syntax), so the Unit Tests run itsworkflow_runconsumers read stays green.
Errors remain on a failed KTLO step, though none changes a conclusion: the
runner’s Process completed with exit code N. for a run: step, and an
action’s own. And #1634’s tag check still blocks the pypi KTLO legs: it fails
every leg alike, and masked, the leg would go on to publish.
PyPy is tiered the same way. test and integration run its supported
versions after CPython’s, and test-ktlo and integration-ktlo its KTLO ones,
each leg running the ALL test tier (#1958, see Test Tiers). A
supported PyPy leg blocks through Required checks passed, which needs its job,
so it adds no context to the ruleset. No Compat, engine-tests or
pypcap-parity leg runs PyPy. On PyPy 8.0, which setup-python’s pypy3.11
is, PyPI has no Linux wheel of cryptography, so the install step builds it
with the image’s Rust and libssl-dev, and none of lxml, so the test
extra carries lxml on CPython only.
On PyPy an xdist worker’s RSS grows with every test it runs, rather than in a few
heavy modules, so a PyPy leg’s Run unit tests hands its pytest command to
util/pytest_shards.py, as GraalPy’s leg does (see below), which runs it once per
directory its SHARDS names, tests/protocols and then tests/corekit, and once
more over the rest, ignoring each (#2005).
Each run starts its workers afresh. Measured on PyPy 8.0.1 at -n 4, one run
over the whole ALL tier took each worker to 5.4-5.9 GB and the workers together
past 20 GB, beyond the runner’s 16 GB; split, the largest run peaked at 2.6 GB a
worker and 8.5 GB in all, and the tier took 383 s rather than 493 s. A smaller
-n would only hand each worker more tests, and a PYPY_GC_MAX heap cap turns
the kills into MemoryErrors. Each run but the last writes a JUnit file of its
own beside junit.xml, which Upload test timings takes too, and the step
fails if any run did.
3.7 can neither install the test extra nor import the package, so each 3.7 leg
fails one step, with a warning saying why – test and integration at the
install step, the Compat leg at Verify package – and concludes
success. They run on ubuntu-22.04, which retires on 2027-04-17 after
brownouts from 2027-03-23 (actions/runner-images#14254). A brownout fails a
test-ktlo or integration-ktlo leg before any step runs, where no step-level
continue-on-error reaches; since #1738 that fails nothing gating, but the leg’s
check run reads red, so they must still go before then (#1660). 3.6 is
in no matrix: setup-python builds it only for retired Ubuntu images. Nor are
3.7-3.9 in engine-tests, which installs every interpreter on
ubuntu-latest, where setup-python has no Ubuntu 26.04 build of any of them. Nor
are they in pypcap-parity, which also runs on ubuntu-latest; none of them
was measured on that job.
The experimental tier is free-threaded CPython and GraalPy, spelt as
setup-python reads them (3.14t, graalpy-25.4). Each runs a leg of
experimental, the matrix job of .github/workflows/experimental.yml
(“Experimental Tests”), named Python <version> (experimental), and nothing
else: no integration, Compat, wheel or conda leg, so never a required
context. They were legs of test until #1732, which moved them to a
workflow of their own, by the owner’s ruling that nothing waits on them. That
workflow reports separately and holds up nothing: a job cannot needs: one in
another workflow, nothing calls it, and no workflow_run names it, so the Unit
Tests run completes without it, and with it Required checks passed,
unittest-ordering and the workflows that wait on Unit Tests completing
(coverage-comment.yml, deploy-pages.yml, cron-vendor.yml and
cron-conda.yml). It runs on Unit Tests’ push and pull_request events,
skips a docs-only pull request by the same changes script, and lets a newer
commit supersede a run, a pull request’s or, unlike Unit Tests, a push to main’s
(#1903): since nothing
waits on the legs, main’s newest commit is the one worth testing, whereas Unit
Tests keeps a run per push for unittest-ordering, which runs only there.
A superseded run is skipped or stopped, never cancelled (#1938): GitHub counts a
cancelled check against its commit, so a cancelled run marks the commit red.
The workflow has no concurrency group either, since a group cancels its pending
run once a newer one queues. Its first job, current (“Check the commit is still
current”), sets up latest and installs requests, which the script fetches with,
then runs util/superseded.py’s check. A push’s commit is current
while it is the head of main; a pull request’s while the pull request is open and
its head is still the event’s pull_request.head.sha; any other event’s always.
Every other job needs current and skips where its output is false, so a run
superseded before it starts ends with skipped checks and a Superseded notice.
Each of those if: conditions opens with !cancelled(), which drops the implicit
success(), so a failed current step, which leaves the output empty, or a failed
current job runs the rest; the legs still need changes and python-versions
to succeed. Run unit tests runs its whole command – the scope’s sudo where
there is one, then prlimit, timeout and pytest – under the script’s watch,
outside the scope, which asks again every 60 seconds. The watcher runs on a CPython of
its own, latest from a second setup-python step, watcher-python, with
update-environment: false, so the leg’s PATH, LD_LIBRARY_PATH and pip
cache are as its own setup-python left them, and requests goes into that
interpreter, never the leg’s GraalPy or free-threaded build. The two steps are capped
at 1 minute each, and where either fails the tests run unwatched. Once the commit is superseded,
or the pull request merged or closed, it sends SIGTERM, then SIGKILL 30 seconds on:
to pcapkit-tests.scope by name (sudo -n systemctl kill) where the tests run
in it, since they run as the runner’s user under a root-owned sudo and in
timeout’s own process group, and to the command’s session otherwise. Ubuntu’s
/etc/sudoers sets use_pty, which sudoers(5) says has no effect where sudo
has no terminal, as in a step; the scope is reached by name either way. Once the
command exits, the watcher writes a Superseded notice and a job-summary line and
exits 0, so the leg ends green and frees its runner. Every wait is bounded: where the
command outlives SIGKILL by 15 seconds, it writes a Superseded run not stopped
warning and exits 70, not 0, since the tests may run on, holding the step’s output,
until timeout or the step’s cap ends them, and the failed step is then a warning
like any other on these legs. Otherwise the step ends with the tests’ own status. An
API the script cannot read counts as current, so it never stops the tests, and it
asks with If-None-Match, as a 304 answer costs no rate limit.
The legs never block, by the KTLO mechanics above: every step that can fail has
continue-on-error: true, their annotations are warnings, and Experimental
summary, the job’s last step, reports each failed step. A failure no step
contains, such as a lost runner, fails the job, and the job’s own
continue-on-error: true keeps it from failing the run: it “prevents a workflow
run from failing when a job fails” (Workflow syntax),
though the leg’s check run still reads failure. A job past its
timeout-minutes is cancelled, and that page does not say whether the job-level
guard covers a cancellation. So setup-python, the install, the watcher’s setup-python
and requests (#1938,
below) and the tests have caps of their own, of 5, 10, 1, 1 and 120 minutes, under
the job’s 140, which leaves 3 for the rest: a step at its cap
fails rather than cancels, and the guard holds (actions/runner
src/Runner.Worker/StepsRunner.cs:322-353). The runner’s has timed out error
stays, changing no conclusion, as Process completed with exit code N. does.
Everything but the install and the tests took under 20 seconds on every uncovered
leg of runs 38569321543-38569522442. test’s caps are main’s: 45 minutes for the
job, and none on a step. Promoting one whose leg stays green is a hand edit, which
util/python_versions.py refuses until the build has a wheel, a conda build
and a Compat leg.
The tests’ 120 minutes are sized for GraalPy, the slowest of the legs that share
them: its leg reached 26% of the unit tier in 25 minutes (job 115915976024), where
3.14t’s ran the whole tier in 5.8 (run 38581834697). The experimental legs run the
ALL test tier alone, as every leg but latest does (see Test Tiers below):
List the LATEST tier asks latest_tier_paths() on the runner’s own python3,
before setup-python, so GraalPy does not parse the tree, and Run unit tests
passes each module it printed as an --ignore. The
round-trip sweeps of #1732
took 8% of the unit tier’s time in a serial 3.14 run (86 of 1,031 s), and 10-11% on
the 3.14, 3.15 and 3.14t legs of run 38581834697, so the rest should take GraalPy
88-113 minutes (estimate).
GraalPy’s leg still loses its runner 18-30 minutes into its tests, with The runner
has received a shutdown signal (#1740),
so each experimental leg samples its runner while the tests run. Start the resource
monitor writes free -m, df -h of / and the workspace’s volume,
/proc/loadavg and the five processes with the most RSS every 30 seconds, each
line led by [monitor], to $RUNNER_TEMP/resource-monitor.log; Run unit
tests copies the file into its own log with tail -F; Stop the resource
monitor ends the sampler, if: always(). The file is uploaded as
resource-monitor-<version> (14 days, if: always(), continue-on-error)
only where the runner survives the tests. always()
runs a step “even when canceled”, and docs.github.com says nothing of a runner’s
shutdown, after which the runner evaluates no later step’s condition (actions/runner
src/Runner.Worker/StepsRunner.cs:205-208, at 796cc18ff). Job 116057255255
skipped every step after its tests, Post Run actions/checkout@v7 too, whose
post-if
“defaults to always()”. On a lost runner, then, the [monitor] lines in the
tests’ log are the record. The monitor’s steps – start, stop and upload – are each
continue-on-error with no cap of their own, so the caps above and the check names
are as they were.
A lost runner fails the job, and so its check, whatever continue-on-error says,
so Run unit tests holds the tests to what leaves the runner alive, and a breach
fails the step, which Containment summary and Experimental summary report as
warnings:
GraalPy alone gets
GRAAL_PYTHON_VM_ARGS=--vm.Xmx2g --vm.XX:+ExitOnOutOfMemoryError, a 2 GiB heap for every GraalPy process, xdist workers included, and-n 3, a CPU of the runner’s 4 left to the JIT and the runner; 3.14t keeps-n auto. A worker’s live heap grows with the tests it runs (#1753): in job 116172356218 all three reached 2.2-2.6 GB RSS in 16 minutes, and aMemoryErrorin pytest’s own failure report ended the session, exit 3, with 7.9 GB free. So a worker whose heap is spent exits, xdist fails the test it ran as crashed and starts another, and--max-worker-restart=30allows 30 restarts in all, where three 16-minute heaps need about 21 in 110 minutes; xdist’s default is 4 a worker, 12 at-n 3.GraalPy’s tier runs in shards (#1963), so no worker outlives its heap. In job 118912680486, the first full run of the curated
ALLtier, nine workers went down at 2.5-3.0 GB RSS, 9-17 minutes into their lives, each failing a test that passes alone. So on GraalPy’s legRun unit testshands its pytest command toutil/pytest_shards.py, which runs it once per shardSHARDSnames – a directory, or a glob of one directory’s entries such astests/corekit/test_i*– and once more over the rest, and every run starts its own workers. Each run also ignores what every earlier shard took, so a glob may precede its own directory, which then takes the remainder. The shards are sized by each one’s largest live heap after a full GC, measured locally on GraalPy 25.4.4 at-n 3: run whole, the tier lost 8 workers, its heap reached the 2 GiB cap, and it took 3559 s; in these shards none went down, no heap passed 1303 MB, and it took 1879 s. The runs share the step’stimeout, scope and watcher, so the caps hold over the whole tier; ontimeout’s SIGINT the run in progress writes its JUnit file and no later one starts. Each shard’s run writes a JUnit file of its own besidejunit.xml, such asjunit-tests-protocols.xml, andUpload test timingstakes them all. 3.14t’s leg runs pytest once, as before.timeout --signal=INT --kill-after=3m 110mends pytest under the step’s 120-minute cap, so it still writes the junit XML, and exits 124;prlimit --fsize=2147483648caps any file at 2 GiB. Thetimeoutis GNU’s,gnutimeoutwhere it exists (#2020): on Ubuntu 26.04timeoutis uutils’, whose--kill-aftersends SIGKILL with the SIGINT, and to its child alone.Where the scope starts
trueunder a throwaway unit,pcapkit-probe-<pid>.scope, with every flag the tests get (sudo -n --preserve-env,MemoryMax=12G,MemorySwapMax=0,CPUWeight=20,--uid,--gid), the tests run in the transient scopepcapkit-tests.scopeas the runner’s own user; otherwise they run the same pytest command line uncapped, under aNo memory capwarning. sudo resetsPATHand dropsLD_*, where setup-python puts itslib/, soenvsets them, and the heap, again. The sampler started a step earlier, outside the scope, so an OOM kill there leaves it running; each sample adds the kernel’soom_killcount and the scope’smemory.currentandmemory.events.
The step writes $RUNNER_TEMP/tests-status: the exit status, the scope and the
probe’s error, the seconds the tests took, the OOM kills while they ran
(/proc/vmstat), the lines in their output that report a raised MemoryError (#1962),
not those that mention one, such as a docstring a traceback prints, and xdist’s node
down lines. From it and the monitor’s last sample, Containment summary warns
No memory cap, tests lost a process to the OOM killer, tests timed out at 110
minutes, tests raised MemoryError or tests lost an xdist worker, each a
warning of its own. A timeout is exit 124, or 137 with no OOM kill
after 110 minutes: --kill-after’s SIGKILL to a teardown SIGINT did not end, where an
OOM kill of pytest exits 137 too. Experimental summary, the job’s last step and the
KTLO summary’s script, still warns of every failed step, tests included. The 2 GiB,
-n 3 and 12G are estimates for a 16 GB, 4-CPU runner until a run shows them. At 3
workers rather than 4, GraalPy’s tests should take about 117-151 minutes (an inference
from the 88-113 above, not measured), so its leg may end on the 110-minute timeout as a
warning: that is expected, blocks nothing, and leaves the caps as they are.
Test Tiers¶
Every test module runs in one of two test tiers (#1958): ALL, which runs on every
leg, and LATEST, which runs on latest alone. They are orthogonal to the unit
and fixture-dependent tiers, which decide what a module may read and so which job
collects it; a test tier decides on which of that job’s legs it runs.
module_tier() in tests/_tiers.py decides, in one place, for every
tests/**/test_*.py. ALL is a curated, opt-in list (#1958, #1963): short enough
to save time on every leg, the same on every leg so QA stays consistent, and enough
on its own to validate a change on each interpreter, while latest runs the whole
suite as the gate.
The marker. A module joins
ALLwith a module-level__tier__ = 'ALL', read by AST, under a#: Test tier (#1958): <reason>comment. It is a module constant rather than a pytest marker, sounittest-ordering’s plainunittestruns read the same module. A value that is not'ALL'or'LATEST', or a marker assigned twice or anywhere but at the module’s top level, fails the tier guard.The default. A module that states no tier is
LATEST, so a new module runs onlatestuntil it says why every leg needs it.__tier__ = 'LATEST'is optional, and records why a module someone might expect on every leg is not; a module that comparessys.version_info,sys.implementationorsys.maxsize, readssys.flags, callssysconfig.get_config_var()or probessyswithhasattr/getattrhas to state its tier either way, so a version gate never lands onlatestalone by omission. The check is syntactic and best-effort: an alias, a flag imported frompcapkit.utilities.compator a gate in a helper is not seen, and such a module states its tier by hand.
A module opts in when it exercises behaviour that can differ between interpreters or versions:
compat shims and version or implementation gates;
C-accelerated versus pure paths, and code sensitive to garbage collection, weak references or finalisation;
int,bytesandstructedge cases, andsys.maxsizeor recursion limits;pickling and copying, metaclass and ABC machinery, and enum mechanics;
stream and buffer semantics, threading and locking, and the warning and exception hooks;
a third-party engine whose installable releases vary by interpreter, such as
pypcapandpypcapfile, which install below 3.12 only, and Scapy, which theintegrationandpypcap-paritylegs install on every interpreter and atestleg offlatestskips;the hand-picked round trips, whatever their names, and the #1202 edge-case tables, whose cases are each written out by hand, as the owner ruled on #1958; and
the smoke slice: a representative module per protocol family and per package, so each leg still drives the main parse and build paths.
Everything else is LATEST: the per-protocol field-by-field tests, whose
behaviour is interpreter-independent pure Python, each real sweep – a round trip
over every class or registry entry a table enumerates, every offset of a packet or
every capture, as the owner ruled on #1732 and #1958 – and the docs, project and
structure tests. The vendor extra is installed on latest alone and the crawlers
are pure Python, so the vendor modules are LATEST, each saying so, but for the
file-restore and environment-variable tests, which stay on every leg. The tier guard itself is ALL, since tests/conftest.py
runs it at collection on every leg.
Which legs run which tier:
Leg |
Tiers |
|---|---|
|
|
|
the project pins |
|
|
|
|
|
|
|
|
Each leg asks tests/_tiers.py at run time, so no workflow lists a tier: a
test leg off latest passes each module latest_tier_paths() prints as an
--ignore, and an integration or pypcap-parity leg off latest runs
fixture_tier_paths('ALL'), which drops each LATEST path and node ID and
replaces a directory holding a LATEST module by its ALL ones, since pytest
applies no ignore to a path it was handed. tests/test_tier_guard.py checks
that every module resolves to exactly one tier and runs the steps to check what each
does with the answer, and tests/project/test_sweeps_on_latest_only_1732.py
does the same for every leg the tier file gives.
The project pins stay in a job of their own rather than joining the latest leg:
project-tests runs in parallel with the coverage leg, the slowest of test’s,
rather than lengthening it under coverage, and it is also the job that runs the docs
readers on a docs-only diff.
Latest CPython runs the whole suite, as the sanity check and the gate (the owner’s
ruling on #1958), so it also installs what the whole suite gates on. In test and
integration the step Install the whole suite's dependencies on latest runs on
the latest leg alone, after the install every leg runs, and adds the engines pip
can install there (Scapy, PyShark), html5lib through vendor, Sphinx and
the pinned sphinx-autodoc-typehints through docs, and emoji through cli.
project-tests and gate, which run on latest only, install the same set in
their Install package and test dependencies and Install package, test and
generator dependencies steps. The other legs keep their install lines and run the
ALL tier with them, so a test gated on one of these extras skips there and runs on
latest. The other engines stay out: PCAP_CT needs a system libpcap, which
engine-tests installs for its own cell, and the PyPCAP and PyPCAPFile
markers resolve to nothing on latest. tests/_dependency_gates.py reads the
latest step as that leg’s extras, and
tests/project/test_whole_suite_on_latest_1958.py pins the set and the gates it
closes.
Project Pins¶
The project pins run once per push (#1954): the modules under
tests/project whose run does not vary by interpreter, as they read the
repository’s text or drive a util/ script. is_project_pin() in
tests/_tiers.py decides, by the AST of each module and of the repository
code it runs: a module is a pin unless it, a module it takes names from, or a file
it runs by path imports pcapkit, or compares sys.version_info or
sys.implementation. A computed import, or a .py name computed for a
loader or a child process, counts as importing it, and so does a file that does
not parse. Every pin is in the LATEST test tier (see Test Tiers above),
and a tests/project module marked __tier__ = 'ALL' is no pin. The modules
that import pcapkit are no pins either; those the tiers send to latest run
on test’s latest leg, under coverage.
project-tests runs the pins under pytest, with test’s flags, on latest,
on every pull request and every push to main, and required-checks needs it.
Every test, test-ktlo and experimental leg passes each pin as an
--ignore, latest’s coverage leg included, which measures only pcapkit.
latest asks project_pin_paths() at run time, and every other leg
latest_tier_paths(), which holds each pin, as integration asks for its
selection, so no workflow lists the pins, and tests/test_tier_guard.py runs
the steps to check what each does with the answer. Under gate-only, gate’s
full suite runs the pins with everything else. unittest-ordering’s project
cell keeps them, as it asks how a module runs in one interpreter, in file order and
without xdist, which is a question about the module rather than the Python version.
A pin’s skip where tomllib is missing, before 3.11, runs on no leg: the scripts
those pins drive read the tier file on the runner’s own python3, and
tests/project/test_python_versions_1660.py checks those skips in a child
interpreter that cannot import tomllib.
File Size Caps (#1969)¶
A test that writes without bound fills the runner’s disk rather than failing: #1372’s out.json grew to
several gigabytes on every run. So every pytest and unittest command in
.github/workflows/unit-tests.yml runs as python util/cap_file_size.py
python -m ...: test’s Run unit tests; integration’s,
pypcap-parity’s and gate’s Run full test suite; the KTLO twins of
test, integration and pypcap-parity, which share their steps; the
engine cells that engine-tests’ Write the per-cell script writes;
unittest-ordering’s cells; and each run step of project-tests.
util/cap_file_size.py sets RLIMIT_FSIZE, soft and hard, to 2 GiB, or keeps
a lower hard limit, and execs the command, so the step’s exit status and
continue-on-error behave as before and every process the command starts
inherits the cap. A write past it raises OSError: [Errno 27] File too large in
Python, since CPython ignores SIGXFSZ, so the test errors; any other program is
killed by the signal, whose default action the script restores before it execs.
experimental.yml’s legs get the same 2 GiB through prlimit --fsize (see
above).
tests/project/test_file_size_caps_1969.py fails when a command in
unit-tests.yml runs pytest, unittest or util/run_unittest_leg.py
without the script, and runs the script against a small limit.
Workflow Scripts (#1967)¶
Shell that several steps repeat lives in a util/ script, which the steps
run, rather than in a composite action: a composite action’s inner steps cannot
take timeout-minutes, so each calling step keeps its own, with its shell:,
if:, env: and continue-on-error. Each script’s docstring says what it
does; each has tests under tests/project/ that run it.
Script |
Run by |
|---|---|
|
|
|
|
|
|
|
|
The summaries run on the image’s /usr/bin/python3 rather than the leg’s
interpreter, which may have failed to set up or be too broken to run anything, so
they need only the standard library, as classify_changes.py does on the
runner’s python3. util/pytest_shards.py runs on /usr/bin/python3
too, standard library only, so on GraalPy’s leg the leg’s interpreter runs pytest
alone; on PyPy’s legs the leg’s interpreter runs the script as well, under the
file-size cap every test command of unit-tests.yml takes. Both changes jobs check out on every event, since the script is in the
tree. create-release.yml’s pypi job keeps its own copy of
the KTLO summary’s shell until its shell moves to scripts too; a project pin runs
that copy and the script on the same steps and requires the same output.
Action Pins (#1968)¶
A tag or a branch is a reference its owner can move, so every uses: of an
action outside this repository names a full commit, with the release that commit
is as a trailing comment:
- uses: actions/checkout@<40-hex commit> # v7.0.1
A step’s uses is therefore the commit alone, as the YAML reads it; the comment
is what Dependabot rewrites with the commit on a bump. A local ./ workflow call
stays as it is. .github/dependabot.yml watches the github-actions
ecosystem from the repository root beside pip, on the same weekly schedule, so a
new release reaches the workflows as a Dependabot pull request rather than by a tag
moving under a run.
tests/project/test_actions_pinned_1968.py fails on a uses: under
.github that names a tag, a branch or a commit without its version comment,
and when .github/dependabot.yml drops the github-actions ecosystem.
pinned() and versions() in tests/_workflows.py read a pin, for a test
that names a particular action.
Workflow Lint (#1967)¶
Lint’s lint job runs actionlint over every workflow in its actionlint step,
first, as it needs no Python. The step downloads the release that
ACTIONLINT_VERSION names and checks it against ACTIONLINT_SHA256, from that
release’s checksums file, before unpacking it. Dependabot does not read this pin,
so a bump changes both. The workflows use YAML anchors, which actionlint reads from
1.7.10 on, and the pin is 1.7.12 or newer.
actionlint runs the image’s shellcheck over each run: block, and shellcheck
still reports style and info findings, so the step is advisory like the others in
Lint. Its run summary counts the findings by actionlint rule and by shellcheck code.
The step’s -ignore pattern, which matches a message, not a file, hides a false
positive: concurrency.queue, which project-status.yml sets and GitHub
documents, is a key no actionlint release knows yet (rhysd/actionlint#657). The pattern goes once a
release that knows it is pinned.
tests/project/test_actionlint_in_lint_1967.py fails when the step is gone,
pins an older release or no checksum, unpacks before it verifies, or passes an
-ignore pattern other than that, and when no workflow sets
concurrency.queue any more.
Dependency Updates (#1968)¶
Dependabot opens every dependency bump, weekly, from .github/dependabot.yml
(the owner’s ruling on #1968):
pipover/,/docsand/examples/benchmark. The docs build pins Sphinx and sphinx-autodoc-typehints exactly indocs/requirements.txt, and Dependabot proposes bumps to both pins; the owner accepted that, so each bump is reviewed against the docs build rather than held back. The benchmark’srequirements-*.txtfiles are watched because nothing else moves them.github-actionsover/, per Action Pins above.
Neither conda/requirements.txt nor the benchmark’s image digests are
watched: Conda Update’s Refresh the platform pins and Setup Conda
Distribution steps rewrite the former, and Dependabot reads only a FROM line
that names its image, while the benchmark’s Dockerfile takes its base image
through ARG PYTHON_IMAGE from examples/benchmark/python-images.txt.
PyUp, which opened the same kind of bump before, opens none: .pyup.yml sets
update: False and lists no requirements:. A file listed there takes its own
update, which defaults to all, so the list would override the global
setting. Deleting .pyup.yml would not stop the bot either, as without it
PyUp runs on its defaults. The PyUp App’s installation, and the
pyup.io/safety-ci commit status it posts, are repository settings.
Python Versions¶
Since #1660,
.github/python-versions.toml declares every Python version CI runs: one
line per version, with its tier – supported, ktlo, experimental, or
pre-release, which no version carries – and, where ubuntu-latest cannot
provide it, the runner it needs. latest names the newest supported CPython, which every
job on one interpreter runs. util/python_versions.py reads the file with
the runner’s own python3 – its tomllib needs 3.11, and every image that
runs it has 3.12 or newer – and writes each matrix as JSON: a python-versions
job in unit-tests.yml and python-compatibility.yml, version_check in
create-release.yml and conda-update in cron-conda.yml hand theirs on
through fromJSON, and each single-interpreter job reads latest.
A tier change is a one-line edit to that file, plus the matching edit to ruleset
23497679’s Compat contexts. The file’s exemptions list each pin kept
off latest, with its reason in the comment above it, such as the conda
jobs’ != '3.15'. tests/project/test_python_versions_1660.py fails on a
pin the file does not list, on an exemption with no comment, and on a
classifier, doc or comment that no longer matches the file – a tier range,
the coverage leg’s version. Its tests, and the other tests that
read the file, skip on the KTLO interpreters, which have no tomllib. Some
edits it names rather than makes: a new pypi KTLO leg needs its own
ktlo_* output for release_status, and a pre-release leg needs
non-blocking mechanics no workflow has yet, so the script refuses one.
Python Versions (cron-python-versions.yml) proposes those edits.
Every Monday, or on dispatch, its detect job runs
util/update_python_versions.py with no secret: it reads python.org’s
release cycle, PyPy’s releases, setup-python’s versions-manifest.json and
the runner-images README, recomputes the tiers by the rules in that script’s
docstring, rewrites the file and the prose these tests pin to it, and runs the
audit modules on the result. Only when that changes something does pr
enter github-release, so the owner approves only those runs. It mints a
release App token (Release Process, “The Release App”) with contents and pull
requests write, force-pushes bot/python-versions and opens or updates its
one pull request, whose body lists each move and why, the Compat contexts to
change in the ruleset by hand, and every renamed check run. It never pushes
main, edits the ruleset or writes a workflow file, which that token cannot
push, so workflow comments name the tiers rather than restate them. While the
branch’s pull request is open, it does not re-push tiers a commit on the branch
already proposes, hand edits or merges of main on top or not, nor overwrite
commits it did not make: when the branch holds some and proposes other tiers,
detect fails before asking for the approval. Once that pull request is
merged or closed, the next run with something to propose recreates the branch
from main, a closed proposal’s tiers included. It reports a pre-release
instead of writing it. Nor does it retire a job (#1747): when the tiers leave
a matrix job with no leg, as 3.11 reaching end of life leaves
pypcap-parity, it writes them as computed, warns, and puts the job first in
the body as a manual step: retire or remove it, and the needs: that list it,
such as the one in required-checks, before merging. A proposal is still
identified by its tiers alone, so retiring the job on main does not propose
them again. The
experimental entries are hand-maintained: no source it reads rates them, so
every run keeps their lines as written, and a hand edit to one changes no
proposal.
Deployment environment:¶
These jobs declare a deployment environment: and can therefore pause for an
approval, in create-release.yml and cron-python-versions.yml:
github–environment: github-releasetag–environment: conda-tagpypi–environment: pypiconda–environment: anacondapr–environment: github-release, for the release App’s key (see Python Versions)
ANACONDA_TOKEN is a secret of the anaconda environment, and the
PYPCAPKIT token and PYPCAPKIT_ACTIONS_DEPLOY_KEY are secrets of
conda-tag. GitHub gives an environment’s secrets only to a job that names
it, so the scheduled jobs reading them name it too
(#1968):
Conda Update’s
conda-updateandconda-tag–environment: conda-tagConda Update’s
conda-dist–environment: anacondaVendor Update’s
vendor-update–environment: conda-tag
A required reviewer on conda-tag or anaconda would therefore hold every
scheduled bump for an approval, besides the release-graph reason
Release Process gives for keeping them without one. Each run of these jobs
also records a deployment to its environment.
tests/project/test_secret_environments_1968.py checks that every job
reading one of these secrets names the environment holding it.
No other workflow in scope here declares one – including
deploy-pages.yml, whose publish-pages job pushes straight to the
gh-pages branch via JamesIves/github-pages-deploy-action rather than
through an environment:-gated deployment. Its deploy-pages job builds
the site, a pull request’s included, with contents: read, and uploads it
for publish-pages, which alone holds contents: write and never runs on
a pull request. Its Check gh-pages holds no newer build step skips the
deploy when gh-pages already holds the build of a commit this run’s commit
is behind, so a build that finishes late cannot replace newer documentation
(#1968). Of these environments, only
github-release carries a required reviewer; Release Process
covers why one approval on that single environment is enough to gate the
whole release graph, and is the page to read for the reasoning rather than
repeating it here.