Blog ·
PCB CI gate: a signed, reproducible report on every pull request
Sergey Alekseev
Software teams stopped merging code that fails its tests a long time ago. Circuit boards mostly still go to the fab after a design review by eye and a DRC run on someone’s laptop. We built a PCB CI gate for that: a GitHub Action, pcbkit-io/verify, that runs PCBKit’s check on your KiCad projects on every push and pull request, fails the change when a board does not reach the level you require and writes a report anyone can re-run to the same digest. It is public and MIT licensed. We tried it on three public KiCad boards and on our own library of 44 and some of what it does not decide yet came out of those runs.
The workflow
This is the whole setup for one board, from the action’s README:
# .github/workflows/board.yml
on: [push, pull_request]
permissions: { contents: read, pull-requests: write }
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pcbkit-io/verify@v0
with:
projects: hardware/main-board # the directory with the .kicad_pro and .kicad_pcb
require: layout # language | wiring | ratings | intent | layout | ready
The action installs the PCBKit CLI release it was tested with
(2026.10.31 today), caches it between runs and checks each board on a copy of its files. Your files
are never changed. A repository with many boards names them with a glob (projects: "*/*") and a
library that wants the summary without blocking anyone sets policy: report.
The input is the KiCad project as it is: the .kicad_pro, the .kicad_pcb, the schematic and the
rules. A board written in the PCBKit language gives the check more to judge but
nothing has to be converted first.
A verdict in levels
The check does not give one pass or fail. It gives a verdict in six levels, each built on the one before it and a board is verified to the last level that passes with every level before it:
| Level | What it judges | On a plain KiCad board |
|---|---|---|
| language | the design file parses and its statements agree | nothing to judge |
| wiring | pins driven, supplies and grounds, polarity, mating connectors | from the schematic’s pin types |
| ratings | each part against its voltage, current, power and temperature ratings | only where a rating is known |
| intent | the board against the requirements its design states | nothing stated, nothing judged |
| layout | clearances, shorts, courtyards, holes, thermals, silkscreen | fully |
| ready | the fab’s capability, holes, vias in pads, exports, parts | the fab half fully |
A new gate on an existing KiCad board usually starts at require: layout. It moves to ready once
the parts carry order codes.
Each level also counts the cells of the design its rules looked at: checked (decided on facts
the board states), unknown (a rule needs a fact the board does not carry) and unchecked. A
level that passes with no checked cell passed because it had nothing to judge and the summary
says “passes judging nothing”. With policy: strict the gate refuses such a pass. We added this
after the first real runs, when a KiCad board’s ratings level “passed” with zero findings because
almost no part on it stated a rating.
What you see on a pull request
The job’s summary has one row per board, worst first: its state, the level it reached, errors, warnings and unconfirmed findings, what each level judged and the codes that fail it. Under it each board gets a card with its picture, a checklist and its findings grouped by code, each with a link that opens the board in PCBKit’s viewer with those items selected.
A pull request gets one comment, updated in place on every push: for each board the change touched, its level before and after and its new and fixed findings. Only boards whose files changed are checked at the base commit. Findings are also annotated on the files, at the design’s line where the check gives one; on a pull request only the new ones. SARIF for code scanning is opt-in, since a board’s findings would otherwise become the repository’s security alerts.
A finding from a check still on probation is marked unconfirmed. It is shown and counted and fails
the gate only under strict. Why checks start on probation is in the post on real respin
faults.
The report and how to check it
Each board gets one JSON file, pcbkit-report@2: the gate’s result, the verdict per level, the
coverage, the waived findings, every finding with its file and line, the unrouted nets, every input
file with its SHA-256 and a digest over them, the pcbkit version with the binary’s SHA-256 and a
digest over all of it. The report holds no time, no host name and no address, so the same files and
the same release give the same report.
python3 report.py verify pcbkit-reports/hardware--main-board.json
verify recomputes the report’s digest (was it edited?), the inputs’ digest from the files you have
(is it the same board?) and, with the same release installed, runs the check again and compares
(does it reproduce?).
Signing is optional. With attest: true the action signs each report with a GitHub artifact
attestation (Sigstore, keyless) and gh attestation verify shows which repository, commit and
workflow made it. It is off by default because attestations on private repositories depend on the
owner’s GitHub plan. The digest and verify work everywhere, signed or not.
The same report runs outside GitHub. A GitLab CI template uses the same report.py and adds a JUnit
file whose test cases are the levels, so the merge request’s test widget shows which board fails at
which level. Any other CI runs report.py with Python 3 and pcbkit on the path.
Three public KiCad boards
We ran the gate as a real workflow (nektos/act on dev2, 96 cores, shared) on three public projects as their authors ship them: the Corne keyboard (178 parts, KiCad 7), Antmicro’s Jetson Nano baseboard (369 parts, KiCad 9) and HackRF One (437 parts, 4 layers, KiCad 6). Nothing was pushed to them and their authors were not contacted.
| Board | Gate | Why | Action step |
|---|---|---|---|
| Corne (hotswap) | fails, verified to intent | 4 layout errors (solder-mask-bridge) |
16 s |
| Jetson Nano baseboard | fails, verified to intent | 12 layout errors, 1 unrouted net | 32 s |
| HackRF One | fails, verified to intent | 13 layout errors (thermal-spokes) |
10 s |
All three fail and they fail for reasons KiCad 10.0.7’s own DRC with the projects’ rules also reports. On HackRF all 13 errors are KiCad’s 13 starved thermal reliefs. On the Jetson board all 12 match KiCad (7 overlapping courtyards, a short, 4 starved thermals); KiCad’s 36 hole clearance errors there are 64 unconfirmed findings in PCBKit. The authors shipped these boards with those violations and accepted them.
On the first run Corne had 17 errors and 13 of them were not in KiCad: tracks 0.190 mm from USB-C pads whose mask opening reaches 0.2 mm. People switch off a gate that reports errors their own board tool does not, so we fixed the check: the next build no longer reports them and the 4 left are the 4 KiCad reports too.
The reports came back with the same digest from the GitHub runner and from the GitLab job script
and report.py verify with the release reproduced each of them.
The runs also showed how little a plain KiCad file lets the upper levels judge: wiring 30 to 55 checked cells of 174 to 266, ratings 0 of 334 on HackRF, ready 0 of 19 on Corne. That is the summary’s “passes judging nothing”, printed instead of a green tick.
Waivers
Some violations are accepted on purpose and the gate has to know which. Two places record that
decision. What you excluded in
KiCad’s DRC dialog (drc_exclusions in the .kicad_pro) is waived when PCBKit’s finding is the same
violation on the same items. In a PCBKit design, @allow(...) on the component, net or block,
with its reason:
@allow(.intentCurrent, "the strip runs at most 6 A: WLED's current limit, agreed with the person")
J1 = header(2)
Waived findings stay in the report under “Waived by the project” with their reasons. A waiver that waives nothing is itself a warning.
Our own board library on every push
The 44 boards of pcbkit-io/boards run through the gate on every push and pull request in report mode. On GitHub’s ubuntu-latest (4 vCPU) the checks of the whole library take 40.1 s and the job 52 s with the CLI from cache. A pull request that changes one board takes 70 s, including checking its base and drawing its picture.
It found problems in our own boards. 38 are verified to ready and 6 stop at intent with layout errors: 1,213 and 919 hole clearance errors on the two ESP32-P4 boards and duplicated vias 0.000 mm apart on four Doom boards. No board is green: on all 44 the ready level passes having judged none of its cells, boards already ordered at JLCPCB included. The gate says so on each one. That run used the 2026.10.31 release, so these are a release’s verdicts; we filed the hollow ready level as a verifier defect.
The slowest board needs 12.3 s for one check and its 1,424 findings come in three pages of at most 500, each a whole check. A CI should not run a check three times, so that is a CLI fix too.
What a pass does not say
A pass at require: layout means: on these exact files, this exact binary found no error in any
level up to layout and no unrouted net. That is all it says. The report does not decide a
land that is the wrong size for the part you buy or a pin table that differs from the datasheet,
unless the design carries part certificates. It does not decide
emissions, immunity or safety marks, which are a lab’s work. It does not run the board’s firmware.
Version 0 prints no measured error rates. The rates we measure (precision in blind rounds, recall on real faults) belong to a given binary and the report will carry them once each release ships the rows measured on it. When a board is ordered from PCBKit, the order record keeps the archive the fab got by its SHA-256, so the files that passed are the files that were made.
The gate’s levels and the current numbers are on the verification page; the install is in the quick start.