Continuous integration: workflows, gates & runner minutes¶
This document describes what runs in CI, which jobs can block a change and which only advise, and how the suite is kept affordable on a private repository's GitHub Actions allowance.
- The workflows
- Required vs advisory gates
- Runner minutes
- Diagnosing a red run
- Reproducing every gate locally
The workflows¶
| Workflow | File | Triggers | What it gates |
|---|---|---|---|
| Enterprise CI (SoftHSM) | enterprise-ci.yaml |
push/PR on main, nightly, dispatch |
The main suite: the HSM-backed integration flow plus the static-analysis, coverage, OpenAPI, docs-structure, FIPS, fuzz and Postgres/DR gates |
| Test | test.yaml |
push/PR on main, dispatch |
The upstream project's original job. Its unique coverage is the root-package integration_test.go, which drives a live server over HTTP behind a real OIDC provider (KeyCloak); everything else it runs is a subset of Enterprise CI |
| Supply chain | supply-chain.yaml |
push/PR on main, dispatch |
govulncheck (gating) and the CycloneDX Go-module SBOM — see supply-chain security |
| Kubernetes smoke | k8s-smoke.yaml |
push/PR on main touching the image/chart/server |
Builds the image and deploys the Helm chart on an ephemeral kind cluster against SoftHSM |
| Documentation site | docs.yaml |
push/PR on main touching docs sources |
The --strict Material for MkDocs build, and publishing to GitHub Pages — see documentation site |
| Container | container.yaml |
push on any branch, PR on main, weekly, dispatch, and workflow_call from the release |
Both architectures of the published image, smoke-tested and — outside pull requests — pushed, signed and pulled back anonymously. The only workflow that publishes the image; see the container image |
| Release | release.yaml |
v* tags, dispatch (dry run) |
A tag becoming a signed image, release archives and a GitHub release. Calls Enterprise CI and Container rather than repeating them — see releasing |
Required vs advisory gates¶
Nine jobs in enterprise-ci.yaml are required: a failure is a real defect to
fix. Four are advisory (continue-on-error: true) because they exercise
timing-sensitive faults, third-party clients or machine-dependent numbers, and
so can go red for reasons that are not a regression in this repository:
| Advisory job | Why it cannot block | Detail |
|---|---|---|
| Chaos / fault-injection | Injects timing-sensitive faults | resilience |
| Data-race detector | Long, dependency-heavy run under -race |
make test-race |
| External-client interop | Depends on pinned third-party clients fetched at run time | scripts/interop-test.sh |
| Benchmark regression | ns/op is noisy on shared hosted runners |
benchmarks |
Because they cannot block anything, they run on the nightly schedule and on manual dispatch only — not on every push. That is the single largest lever on the suite's cost (see below). To run one against a specific commit, dispatch Enterprise CI (SoftHSM) from the Actions tab.
Runner minutes¶
GitHub Actions is free for public repositories but metered for private ones.
secsy-pki is private, so every job on every push draws on the account's monthly
allowance, and the suite is large: a full run is 12 jobs in enterprise-ci.yaml
alone, plus five more workflows on a push — of which container.yaml is the
most expensive, because it compiles the whole Go tree with cgo once per
architecture.
The allowance was exhausted on 2026-07-04. The symptom is distinctive and worth recognising, because it looks nothing like a test failure:
Every job in every workflow completes in ~2 seconds, with no runner assigned and zero steps executed. The GitHub UI reports the run as failed without any log output.
That is GitHub declining to schedule the jobs, not the jobs failing. It persisted across a monthly billing reset, which rules out simple minute exhaustion and points at an account-level block (a spending limit that is still at its cap, or a billing problem). Only the repository owner can clear it, by one of:
- making the repository public — Actions is unmetered for public repositories;
- raising the spending limit / attaching a payment method for private minutes;
- running the suite on self-hosted runners, which are not metered.
Until then no workflow can run, regardless of the state of the code.
Three measures keep the suite within a realistic allowance once it is restored:
- Advisory suites are nightly, not per-push (above). They are the most
expensive half of
enterprise-ci.yamland gate nothing. - Every workflow has a
concurrencygroup withcancel-in-progress, so a burst of pushes does not run superseded jobs to completion.test.yamlpreviously had neither this nor a branch filter — it matchedbranches: ['**'], and on 2026-07-02 alone that produced 52 runs in a day. - Path filters on the image/chart and documentation workflows, so a docs-only change does not start a kind cluster.
Diagnosing a red run¶
Work down this list — the first two cost nothing and explain most red runs:
- Zero steps, no runner, ~2s duration? Billing, not code. See runner minutes.
startup_failureconclusion? The workflow YAML is invalid; the job list will be empty.- A single job red? Reproduce it locally — every gate has a one-command equivalent (next section).
The job-level detail the UI hides is available from the API:
Reproducing every gate locally¶
Each required gate is a Makefile target or script that CI invokes verbatim, so a local run and a CI run cannot drift:
| Job | Command |
|---|---|
| SoftHSM integration suite | ./scripts/integration-test.sh |
| Static analysis | make vet && make lint |
| Test-coverage ratchet | make cover-check |
| OpenAPI spec & client SDK | server/scripts/openapi-check.sh |
| Documentation structure | ./scripts/check-docs.sh |
| Documentation site (strict) | make docs-site |
| Fuzz smoke | FUZZTIME=20s server/scripts/fuzz.sh |
| FIPS 140-3 | make build-fips |
| DR store integrity | SECSY_TEST_PG_DSN=… go test -tags sqlite -p 1 ./internal/database/... ./internal/leader/... |
| govulncheck | make govulncheck |
| Chaos (advisory) | ./scripts/chaos-test.sh -v |
| Data race (advisory) | make test-race |
| Interop (advisory) | ./scripts/interop-test.sh |
| Benchmarks (advisory) | make bench-compare |
Two environment notes that cause local-only failures:
- The Postgres-backed packages must be serialized with
-p 1. Running the whole tree in parallel against one database exhausts connections and reportsdriver: bad connection, which is a harness artifact, not a regression. - The coverage gate must run without an HSM or Postgres in the environment,
matching the job that generated the baseline. Unset
SECSY_PKCS11_MODULE,SOFTHSM2_CONFandSECSY_TEST_PG_DSNbeforemake cover-check, or the numbers will not be comparable. See coverage.
Never commit a coverage baseline generated on a developer machine. Env vars
are not the only thing that moves the numbers — attached hardware does too. A
host with a YubiHSM 2 on USB covers device paths in internal/yubihsm that a
hosted runner cannot reach, which recorded that package 18.6pp too high and
failed the ratchet on the next push. Refresh the baseline on the runner class
that enforces it: dispatch Enterprise CI (SoftHSM) with
refresh_coverage_baseline=true, then download the coverage-baseline
artifact and commit it verbatim. The same applies to bench/baseline.txt
(refresh_baseline=true).
↩ Back to the development index · documentation map