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.