Skip to content

Ci: Unify the linters and drive every workflow through task scripts - #1685

Open
DawidWesierski4 wants to merge 13 commits into
mainfrom
cicd_desgin
Open

Ci: Unify the linters and drive every workflow through task scripts#1685
DawidWesierski4 wants to merge 13 commits into
mainfrom
cicd_desgin

Conversation

@DawidWesierski4

Copy link
Copy Markdown
Collaborator

Rebased onto main at ed7dfaa6. 13 commits.

What this is

The CI/CD rework: workflow operations moved into task scripts, a local harness
that runs a CI job off the fleet, a CI diagnostics MCP server, the orchestrator
agent and mtl-cicd skill, the acceptance-suite hardening for the validation
fleet, the architecture/host-contract/i225 docs, and the linter unification.

Linter unification

.pre-commit-config.yaml becomes the single source of truth for which tool,
which version, which arguments and which files. checkpatch.sh chooses the file
set and reports; format-coding.sh is its write-mode wrapper; linter.yml
orchestrates only. super-linter keeps just what the hook list cannot reproduce --
notably VALIDATE_GITLEAKS, whose hook scans the staged diff and so cannot scan
a whole tree or a pull request. That residual job is now an allow-list, which is
also what keeps ansible-lint off: super-linter refuses to start when true and
false values are mixed, so the VALIDATE_ANSIBLE: false the deny-list needed
would now break the job.

./checkpatch.sh is clean and idempotent over the whole tree: 17 hooks, exit 0.

Notes for review

  • .clang-format becomes a real file at the repository root. clang-format
    searches upward from each source file, and a Windows checkout without symlink
    support materializes a symlink as text, at which point it silently falls back
    to LLVM style. destroyed-symlinks is what keeps that honest.
  • patches/dpdk/26.03/0012-net-ice-e830:-...patch is renamed. A colon is not a
    legal Windows filename character, so git checkout there refused the whole
    clone with exit 128 -- the Windows support this repository claims had been
    broken for as long as that file existed. check-illegal-windows-names keeps
    the name legal.
  • The wait-for-linter gate now names all four check runs linter.yml
    produces. It previously polled for super-linter's "Lint Code Base", which
    stopped existing; a missing check is a wait rather than a failure in that
    action, so every pull request spent ten minutes timing out and reported
    infrastructure flake instead of a configuration error.

Still open

.github/claude/ has no mtl-orchestrator.md, no skills/mtl-cicd symlink and
no mtl-ci-local entry in mcp.json. ed7dfaa6 deliberately left those unwired
because the targets did not exist on main yet; after this branch they do, so
that wiring is a sensible follow-up. The Copilot side under .github/agents/
and .github/skills/ has all three.

Five failures of the same kind: the build asserting something about its
environment instead of asking it.

- The rocky9 image reported "libxdp is absent after install" from a successful
  install. xdp-tools and libbpf default LIBDIR to ${PREFIX}/lib64, which Debian's
  pkg-config does not search and which does not exist on RHEL, so neither default
  is portable and the multiarch directory libbpf was pinned to by hand only moved
  the problem. Asking `pkg-config --variable pc_path` does not answer it either:
  on Rocky 9 that is a string from a .pc file the distribution ships, and it omits
  the compiled-in /usr/local/lib64/pkgconfig. So plant a probe .pc in each
  candidate under the prefix and install into one pkg-config actually reads, and
  register it with the dynamic linker, whose default path is just as
  distribution-specific and which fails at load time rather than at link time.
- With libxdp visible, meson enables the manager's XDP target and clang stops on
  gnu/stubs-32.h, which no package in the rocky9 image provides. The Ubuntu images
  install gcc-multilib for this; glibc-devel.i686 is the RHEL counterpart.
- -flarge-source-files is gcc's, and clang rejects unknown -f arguments outright,
  so every RxTxApp translation unit failed once the fuzz leg built with clang. It
  is now offered to the compiler and used only if taken, like -msse4.2 above it.
- The fuzz targets link against GPU direct, which their sources call: the wrappers
  #include the library sources they exercise, so those callees have to be on the
  fuzz link line and not only on libmtl's. tests/unit carries the same conditional.

The assertion that started this was worth keeping: without it the image built
green with pkg-config reporting libxdp absent, which does not fail a build -- it
configures MTL without AF_XDP.
gcc builds these; clang's -Werror does not, so the fuzz leg -- which has to
use clang for libFuzzer -- stopped in the test build:

- st40p_handler.cpp carried a constant nothing reads;
- st40i_tests.cpp captured a compile-time constant into a lambda that does
  not need it captured;
- St30pRedundantLatency stored a latency and a starting time it never read.
  The constructor body looked like it used one of them, but it assigned to
  its own parameter, not to the inherited startingTime -- and
  initializeTiming(), which every caller runs straight after construction,
  sets that member anyway. So nothing observable changes.
Every access to these counters already goes through C11 atomics -- the
tasklet adds to them, st*p_*_get_session_stats() reads them from another
thread -- but the fields themselves were plain uint64_t. gcc's atomic
builtins accept that; clang does not, so the library did not compile with
clang at all, which is how it stayed unnoticed. Same codegen under gcc,
because gcc was already emitting atomic operations.

The USDT probes are the other half: their argument macros do arithmetic on
what they are handed, and clang refuses that on an _Atomic operand, so the
three sites that passed framebuff->stat now read it out first.
Auto pacing selection picks rl on any driver whose rl_type is TM, and
then treats the two ways that choice can fail differently: a queue whose
rate limit will not set falls back to tsc with a warning, while a tm
hierarchy that will not build fails mtl_init outright. Both mean the same
thing -- the driver in front of us has no rate limiter to offer -- and in
auto mode neither is the user's choice to defend, so both should degrade
the same way.

Found while looking at an E830 whose PF grants its VF no QoS capability.
That host crashes inside the iavf PMD before returning, so this does not
rescue it; what it fixes is the case where a driver reports the missing
capability properly, which today is a refusal to start rather than a
session paced by tsc.
The workflows implemented themselves. Host setup, cache decisions, driver builds,
test invocation and report collection were multi-line `run:` programs inside YAML,
which meant no operation could be run, read or tested anywhere except on a runner,
by pushing. Debugging a bare-metal job took a push and a forty-minute wait for a
forty-megabyte log.

Every operation is now a focused script under `.github/scripts/ci/`, verb-style
and runnable by hand, with `Taskfile.yml` as the single entry point both the
workflows and a developer call. The YAML orchestrates; it does not compute.
`check-yaml-policy.sh` keeps it that way, and also rejects a third-party action
that is not pinned to an immutable SHA.

Three contracts the scripts encode, each one a failure this fleet actually had:

**Jobs verify host state; they never install it.** apt packages, kernel modules,
DMA bindings, the media share, the analyser -- a job that repairs what it finds
hides drift in the host image and races every other job on the machine. So each
check fails in seconds with the one command that fixes it, on the host that needs
it. `configure-host.sh`, `media-assets.sh`, `ebu-list.sh` and `ice-required.sh`
are that contract; a missing analyser is degraded mode rather than failure,
because absence is not misconfiguration, and `MTL_CI_REQUIRE_COMPLIANCE=1` makes
it fatal again on a host that has one. Lab facts come from
`/etc/mtl-ci/runner.env` on the host that owns the hardware, not from GitHub
secrets, which are a second copy of lab configuration kept in sync by hand.

**A cache hit has to be usable, not merely present.** `actions/cache` saves in a
post step that runs whether the job passed or not, so a run that died half-way
through installing MTL stores the half-written tree under an unchanged key, and
every later run restores it, skips the build and fails in the first consumer that
resolves `mtl.pc`. The keys are content-addressed with an explicit schema
(`cache-keys.sh`, `cache-schema.env`) so a fixed layout bug can be rotated past,
entries are immutable, and `validate-cache.sh`, `validate-ice.sh`,
`validate-jpegxs.sh` and `validate-dependencies.sh` reject a hit that cannot be
used -- including an ICE module whose vermagic, kernel ABI fingerprint, compiler
identity or Kahawai QoS capability does not match the host it is about to load on.

**The acceptance virtualenv is a cache, not host state.** It is built from
`requirements.txt` in the checkout, lives in the runner user's cache outside
anything `git clean` touches, and is the same for every job on the host. So it is
created once, rebuilt when the requirements change or when the host's python moves
under it, built with `python3 -m venv`, `virtualenv` or `uv` -- whichever the host
has, since none of them installs anything -- and names `python3-venv` when the
host has none.

`watch-run.sh` is the developer's side of the same layer: it resolves a commit
from `--run/--pr/--sha/--branch`, defaulting to the pushed tip so it cannot report
on a commit that only exists locally, distinguishes "queued, no runner yet" from a
failure, and ends with the job, the failed step and the error lines. Twenty lines
instead of the log.
The YAML now only orchestrates: each step names a `task ci:*` entry point, so the
same command runs in a job and on a developer's machine. What is left in the
workflows is the part that is genuinely about GitHub -- what triggers, what runs
where, what may run at once, and what a queue is allowed to cost.

- **A shared fleet is the scheduling problem.** Every NIC label is served by one
  host, so a second job at a label is a queue and not throughput. Superseded Build,
  smoke, base and docker runs are cancelled before they queue, and `pr-gate.yml`
  states its wait as a queue budget rather than a flat twenty minutes -- a build
  that has not started yet is not a build that is failing. When the budget does
  expire, the gate says it gave up because no runner came, which is a different
  thing from a failed build and used to look identical.
- **A bare-metal job has to be bounded at every level.** Job timeout, run timeout
  and per-suite caps, so a hung test releases the host instead of holding a card
  for hours; `gtest-bare-metal.yml` carries the bounds the local harness asserts.
- **The smoke matrix says what each leg is for.** The `i225` leg runs the
  low-bandwidth subset with no capture device and its own timeouts, because that
  card has no SR-IOV and two PFs rather than VFs; it is `optional` while the label
  is new. A leg with `no_capture` skips the analyser check it cannot use.
- **`provision-runner.yml` is dispatch-only**, the single deliberate exception to
  "jobs install nothing": a human asks for a host to be prepared, by name.
- Privileged bare-metal steps no longer open with a trace-fd error, the acceptance
  report the non-smoke suites write in place is left alone, and a host running an
  ICE driver that is not the one the suite needs is told so before it tests.
A change to a bare-metal job could only be tested by pushing it and waiting for a
runner that might be busy for hours. `run-job.sh` runs the same job here --
`--runner docker` for anything that does not need a card, `--runner host` on a
machine that has one -- through the same Taskfile entry points the workflow calls,
with a local cache store that reproduces `actions/cache` semantics including its
immutability.

That is what proved the `i225` leg before CI ever dispatched it: two runs on a
host with an I225-LM, `11 passed, 3 skipped` in about twenty-two minutes, while
the leg itself was still queued behind a label nothing advertised.

`tests/` holds the assertions about all of this that do not need hardware: cache
schema rotation and poisoning, the gtest bounds, the wait-for-workflow script, the
YAML migration, the virtualenv builders, and that a missing compliance analyser is
not a gate. `task ci:test-dependencies` runs them, and so does the build workflow.
The loop an agent runs on CI is: push, find the run, find the failing job, find
the failing step, find the error in a forty-megabyte log. The first four steps are
mechanical and the last is a needle in ANSI-coded output, so all five became tools:
`ci_pr_checks` and `ci_pr_failures` for what a pull request's checks say,
`ci_last_log` for the interesting lines of one, and `ci_watch_run(pr=…)`, which
blocks until the runs of a commit finish and then names the job, the step and the
error line.

The other half drives the local harness rather than reading GitHub --
`ci_list_jobs`, `ci_run_job`, `ci_test_pr`, `ci_list_tasks`, `ci_run_task`,
`ci_cache_status`, `ci_check_ebpf`, `ci_diagnostics` -- so the same agent can
reproduce a leg here instead of queueing behind the fleet.

Failure counts and captured stderr are bounded, so a broken job cannot flood a
context window; the repository argument is validated before anything is spawned;
and the watcher's subprocess is given two minutes beyond its own poll deadline, so
it reports the timeout itself instead of being killed while writing it.
The four agents each own a phase and nothing owned the sequence: which of them
takes the next task, and which tasks may run at the same time. That decision was
made ad hoc, once per session, and it is where this repository's own facts matter
more than general judgement.

So the orchestrator is the only agent permitted to invoke the others, and its
parallel-safety test is five mutexes that are real here: a file, the build tree
and acceptance virtualenv, a physical host with its hugepages and VF layout, a NIC
label -- one host per label, so a second job at one is a queue and not throughput
-- and the gate chain of a single change. It dispatches, records evidence and never
implements: its edit tool is for the task board and its shell is for observation.
Evidence is named per dispatch, including a non-empty `runner_name` for a CI job,
which is the distinction the i225 analysis turned on.

The `mtl-cicd` skill is the CI half of the routing matrix, which had no agent: the
design contract that workflows orchestrate and scripts implement, and the
inventory to read before editing either.
Resolve the datapath from the ports under test instead of hardcoding
it, register the JPEG XS plugin tree, capture ST 2110 pcaps under
sudo so compliance checks get complete captures, make room before
recording raw video and delete the RX recording once it has been
checked. Generated configs and the low-bandwidth cases are adjusted
for the i225 smoke leg.
… leg

Three things a person needs and could previously only get by reading the
workflows: what the pipeline is (`cicd_setup_proposition.md`, with the prebuilt
dependency problem it solves in `.github/github_actions_issue.md`), what a runner
has to have before it can serve a label (`ci_runner_setup.md` -- the packages the
jobs check but cannot fix, the media share, the EBU LIST analyser and when its
absence is degraded mode rather than failure, and which host carries which card),
and why the `i225` leg was red for two days without ever running
(`i225_leg_analysis.md`).

That last one is here because the evidence is not visible in the Actions UI. A job
queued because the fleet is busy and a job queued because no host advertises its
label are the same grey dot; they differ only in `runner_name`, and every i225 job
record had an empty one until mtl-runner-12 came online. The one real failure after
that took twelve seconds and named its own fix.
…ners

The local harness, its cache store and the reports the jobs collect all land in
the checkout, and `git status` has to stay readable for the suites that assert on
it. CODEOWNERS gains the CI directories, which had no owner.
Three lint systems disagreed. format-coding.sh invoked clang-format, shfmt
and the Python tools directly and required each on PATH, so the version a
developer happened to have installed decided the result; linter.yml ran
super-linter against a second set of rule files; neither pinned the same
versions as the other.

.pre-commit-config.yaml is now the only place a tool, a version, an
argument or a file filter is declared. checkpatch.sh chooses which files
to feed it and how to report, format-coding.sh is its write-mode wrapper,
and the git hooks and linter.yml run that same list on Linux, macOS and
Windows. super-linter keeps only what the list cannot reproduce, including
GITLEAKS: its hook scans the staged diff and so cannot scan a whole tree
or a pull request.

.clang-format becomes a real file at the repository root because
clang-format searches upward from each source file, and a Windows checkout
without symlink support materializes a symlink as a text file, at which
point it silently falls back to LLVM style.

Every pin is at its latest release, and each was measured on its own with
the rest of the config held fixed:

  clang-format  14.0.6 -> 22.1.8      isort      5.13.2 -> 8.0.1
  black         24.4.0 -> 26.5.1      flake8     7.0.0  -> 7.3.0
  ruff          0.4.1  -> 0.16.3      shfmt      3.7.0  -> 3.13.1
  shellcheck    0.10.0 -> 0.11.0      markdownlint 0.43.0 -> 0.49.1
  yamllint      1.35.1 -> 1.38.0      actionlint 1.7.7  -> 1.7.12
  gitleaks      8.16.3 -> 8.30.0      textlint   14.0.4 -> 15.8.0
  htmlhint      1.1.4  -> 1.9.2       pre-commit-hooks 6.0.0 (new)

Nine of those are byte-identical over the whole tree. Four are not, and
the interesting content of this commit is what was done about them.

**A version bump may not smuggle in a rule change.** Three tried:

ruff 0.16 reported 592 findings on a tree ruff 0.4 passed -- blind
excepts, datetime timezones, pyupgrade rewrites. None of it was new code.
ruff's *implicit* default rule set grew from about 40 rules to 413, and
.ruff.toml named no rules, so it pinned the version rather than the check.
It now selects E, W and F explicitly.

markdownlint 0.49 ships MD059 and MD060, which did not exist when this
config was vendored; MD060 alone reports 309 findings and rewrites 20
files. Both are off, with the reason recorded at the key.

textlint-rule-terminology 5.x rewrote prose in 15 files, including 18
lines of published CHANGELOG.md history, and replaced "blank line" with
"empty line" -- in a document describing git commit format, where "blank
line" is git's own wording. The *engine* is bumped to textlint 15.8.0; the
*word list* stays at terminology 4.0.1, because a word list is rule
content and rule content is versioned separately for exactly this reason.

clang-format 22 changed two things. It no longer reads `(type)-1` as a
cast, so `((mtl_iova_t)-1)` becomes `((mtl_iova_t) - 1)`; that is
whitespace, the tokens are identical, and for `((align)-1)` -- a macro
parameter, not a type -- the new spacing is simply correct. Accepted. But
from 18 on it also breaks a braced initializer whose elements carry
trailing comments to one element per line, which added 480 lines to
st_avx512_vbmi.c and destroyed the layout of six permute tables that are
written one pixel group per row so the pattern can be read against the
422le10 packing they implement. Those six now sit in a
`/* clang-format off */` region: a pin on the layout, not an exemption
from review.

flake8 is kept rather than folded into ruff, for one measured reason:
ruff 0.16 does not implement F824 ("dead `global` declaration") at all --
`ruff rule F824` answers "unknown rule". flake8 7.3 found two, both
genuinely dead (the names are only mutated, never rebound), both removed
here. .ruff.toml mirrors flake8's rule set rather than extending it so
that retiring flake8 later is a delete and not a re-measurement.

black's `--line-length 88` is black's own default, written down because
the documented Python line length was 120 while the formatter had been
wrapping at 88 the whole time. Not a conflict -- black wraps at 88, ruff
only rejects past 120 -- but only one of the two was stated, and a default
is not a pin.

Five pre-commit-hooks guards are added. None is a style check; each
mechanically enforces a claim this repository already makes and nothing
checked: destroyed-symlinks (the .clang-format-as-text failure above),
check-illegal-windows-names and mixed-line-ending (the platform support
claim), check-merge-conflict, and detect-private-key -- the only secret
scan that runs in a bare whole-tree checkpatch.sh, since gitleaks sees
only the staged diff.

Four more from that repo were probed and left off, two because they fail
on pre-existing defects that are not this commit's to fix, both now
recorded in doc/coding_standard.md §3.1:

  check-json           20+ tests/tools/RxTxApp/script/**/*.json use
                       trailing commas; json-c accepts them, strict JSON
                       does not.
  check-case-conflict  tests/acceptance/mtl_engine/RxTxApp.py and
                       rxtxapp.py are both tracked, so this tree cannot be
                       checked out on a case-insensitive filesystem --
                       which contradicts the macOS and Windows support
                       claimed above. Renaming a module mtl_engine imports
                       is not a lint change.

The remaining source churn is clang-format 22 and black 26 improving what
they touch: `struct st40_meta m {}` becomes `m{}`, single-expression
lambdas collapse, and black hugs a sole `textwrap.dedent` argument.

check-illegal-windows-names earned its place immediately. patches/ was
excluded globally -- a vendored patch series must not be reformatted -- and
a global exclude turned out to be wrong in both directions. It protected
nothing, because every formatter here is selected by language type and a
*.patch file is none of those types; deleting it changed no hook's result
over the whole tree. And a hook-level exclude cannot un-exclude a global
one, so it silently disabled the one hook that reads paths instead of
content. Blinded, that hook reported "no files to check" while

  patches/dpdk/26.03/0012-net-ice-e830:-use-direct-MMIO-for-PHC-update.patch

sat in the tree. A colon is not a legal filename character on Windows, so
`git checkout` there refuses the whole clone with "error: invalid path"
and exit 128 -- meaning the Windows support claimed in §6 had been broken
for as long as that file existed, and the new Windows CI job could never
have gone green no matter what the linters said. The file is renamed
(nothing references it by name; script/build_dpdk.sh globs *.patch and the
0012- ordering prefix is preserved), the exclusion now sits only on
mixed-line-ending, the one hook that does read every file regardless of
type, and the guard is what keeps the name legal from here on.

Renaming the CI job broke build.yml's linter gate, so that is fixed here too.
wait-for-linter polled for super-linter's check run, "Lint Code Base",
which stopped existing the moment checkpatch replaced it. A missing check
is not a failure in that action, it is a wait, so every pull request spent
ten minutes timing out and reported infrastructure flake rather than a
configuration error. The gate now names all four check runs that linter.yml
actually produces -- the three checkpatch OSes and the residual job -- and
build still declares needs: [wait-for-linter, checksums], so a lint failure
skips the DPDK build instead of paying for it. wait-for-workflow takes a
newline-separated list and requires every entry to reach success; a single
name is unchanged, which is what the other two callers pass.

The coupling is by check-run name and nothing validates the two lists
against each other, so all three files now say so at the point where the
mistake would be made: both linter.yml job names, the gate itself, and
doc/coding_standard.md §4.1. The action also reads its inputs from env
instead of interpolating them into the script body, which a multi-line
value would have broken outright.

18 hooks, ./checkpatch.sh clean and idempotent, ColumnLimit stays 90.

Signed-off-by: Wesierski, Dawid <dawid.wesierski@intel.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant