# Sovereign CI — calls reusable workflow from paiml/.github
# Change once in paiml/.github → applies to all repos
#
# Jobs provided by sovereign-ci.yml:
# test: cargo test --lib (self-hosted clean-room)
# lint: cargo clippy --all-targets -- -D warnings + cargo fmt --check
# coverage: cargo llvm-cov + codecov upload
# security: cargo audit (ubuntu-latest, continue-on-error)
# provenance: SLSA attest-build-provenance
# gate: aggregates test+lint results
#
# NOTE: sovereign-ci compiles NO doctests. Its test job is hard-scoped to
# `cargo test --lib` and, with use_nextest: true, is run by cargo-nextest,
# which cannot execute doctests at all. See the `doctests` job below (#318).
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
workflow_dispatch:
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# PMAT-237: what can this change break? One job, one decision, and every
# heavy job below gates on it. The release gate (`make dogfood-release`) is
# untouched and still runs everything.
classify:
runs-on: ubuntu-latest
outputs:
code: ${{ steps.class.outputs.code }}
gate_c: ${{ steps.class.outputs.gate_c }}
gate_d: ${{ steps.class.outputs.gate_d }}
gates: ${{ steps.class.outputs.gates }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- id: class
uses: ./.github/actions/changed-class
ci:
if: needs.classify.outputs.code == 'true'
needs: classify
uses: paiml/.github/.github/workflows/sovereign-ci.yml@main
with:
repo: ${{ github.event.repository.name }}
# PMAT-155 Phase 2 nextest pilot (build-performance.md §4.3 + §7 Phase 2).
# forjar = medium workload, healthy --lib suite. cargo-nextest is baked into
# sovereign-ci:stable (infra Dockerfile). Pilot only — keep until F11 test-job
# p95 ≤ 300s is verified over 7 days, then promote fleet-wide.
use_nextest: true
secrets: inherit
# Release preflight: a stale Cargo.lock must fail PR CI, not the tag.
# Every v1.4.x release failed because Cargo.toml was bumped without
# committing the refreshed Cargo.lock — release.yml's `cargo package`
# then dirties the tree and skips release creation entirely.
lockfile:
name: lockfile-preflight
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Verify Cargo.lock is committed and current
# #423: the contract crates are workspace members, so the root crate's
# path dependencies are unpublished until the release publishes them in
# order. `--workspace` packages the members first and resolves the root
# against that overlay (cargo >= 1.90), which is exactly what
# `cargo publish --workspace` does at release time.
run: cargo package --locked --no-verify --workspace
# Regression guard (#179): every shipped standalone example config must pass
# `forjar validate`. The sovereign-ci `test` job only runs `cargo test --lib`,
# so this integration test (tests/examples_validate.rs) needs an explicit step.
# Deterministic and offline — validate never opens a network/SSH connection.
#
# THAT `--lib` IS THE POINT OF THIS JOB, and it generalises: NOTHING in this
# repo's CI runs `tests/*.rs` except the targets named here by hand. A
# falsification test under tests/ that is not in this list is written, is
# green on a developer's machine, and is never executed by CI again — which
# is the same "guard nobody runs" shape as #242 and #298. Add new structural
# guards to this list, or accept that they are documentation.
examples-validate:
if: needs.classify.outputs.code == 'true'
needs: classify
name: examples-validate
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Validate all standalone example configs
run: cargo test --locked --test examples_validate
# #292: a .rs file under src/ that no `mod`, `#[path]` or `include!`
# names is compiled by nothing — not linted, not type-checked, and its
# tests do not run. Three were in that state, one of which did not parse
# as Rust at all. Cheap: a directory walk, no build of the crate needed.
- name: No source file may be orphaned or duplicated
run: cargo test --locked --test falsification_no_orphaned_source_files
# #298: every citation in contracts/ must resolve to the exact item it
# names, in the file it names, and every binding must name an equation
# its contract defines. This replaces the Python resolver that used to
# live in proofs.yml, whose regex read 77 of 211 citations and reported
# "every resolvable falsifier citation resolves" over seven dangling
# ones. One resolver, in one dialect, in a place that runs it.
- name: Every contract citation must resolve
run: cargo test --locked --test falsification_contract_citations_resolve
# forjar#549: a drift query the target never answered is UNMEASURED, not
# drift. Asks 203.0.113.9 (TEST-NET-3) over ssh, so it waits out the
# connect timeout once per test.
- name: An unanswered drift query is not drift
run: cargo test --locked --test falsification_drift_unmeasured_is_not_drift
# FALSIFY-FVS-005. Spawns the built binary, so it is the slow one here.
- name: The binary advertises no undeclared transport
run: cargo test --locked --test e2e_transport_absence_t
# #376: `undo` re-converged to the CURRENT config instead of the target
# generation, so it exited 0 printing "1 converged" while the managed
# bytes never moved. These assert the BYTES AT THE PATH, never the
# summary line — the summary is what the defect printed. Includes the
# control that fails an "always rewrite" implementation, and the jidoka
# cases: an undo that cannot faithfully replay must exit non-zero and
# leave the host untouched.
- name: undo must actually undo, or refuse
run: cargo test --locked --test falsification_undo_actually_undoes
# #377: `undo` paired the cwd forjar.yaml with an unrelated --state-dir
# and applied it. Carries the over-correction guards too: one operator
# editing and undoing their own stack must NEVER be refused.
- name: undo must refuse a foreign state dir, and only a foreign one
run: cargo test --locked --test falsification_undo_state_dir_interlock
# #386: the Coverage lane cached `target`, and `cargo llvm-cov` over this
# crate's 242 integration test binaries is a 70.70 GiB tree — already the
# size of a hosted runner's disk. The cache asked tar+zstd for a second
# copy of it on the same filesystem: the SAVE ENOSPC'd on every run (a
# `##[warning]`, so the job stayed green and the cache was never written),
# and when the margin was tighter the ENOSPC landed mid-build and killed
# the runner's Worker process, taking the job's logs with it. Parses the
# workflows and prints its denominator; self-hosted jobs are exempt.
- name: No hosted job may cache a Rust build directory
run: cargo test --locked --test falsification_hosted_jobs_do_not_cache_target
# #401: fourteen paths were BOTH tracked and matched by .gitignore
# (3,759,246 bytes). Nothing visibly broke — gitignore does not un-track a
# file — which is exactly why it survived, while the post-commit hook
# re-staged a 1.2 MB `.pmat/baseline.json` after every commit and any two
# branches conflicted on its `created_at` line. Asserts the index against
# the rules, with `--no-index` (without it git answers about the index and
# reports every tracked path as not-ignored, hiding the defect) and
# WITHOUT `-v` (which prints negation matches and exits 0, making the
# assertion unsatisfiable while any file is deliberately re-included).
- name: No tracked file may be gitignored
run: cargo test --locked --test falsification_no_tracked_file_is_gitignored
# #400: the quorum gate took the branch NAME from the pushed ref and
# everything else — diff, receipt, falsification test — from the local
# checkout. Pushing a branch you were not standing on was refused with
# "no quorum receipt" for a receipt that was in the pushed commit, and an
# UNTRACKED receipt carrying a `waived.reason` bypassed the whole gate
# silently, for exactly the enforced authors QUORUM_SKIP is refused to.
# Drives the real scripts/quorum-gate.sh against synthetic repos.
- name: The quorum gate must judge the pushed commit
run: cargo test --locked --test falsification_quorum_gate_reads_the_pushed_ref
# PMAT-159: the sudo transport moved from `sudo bash /dev/fd/3` (sudo closes
# fd 3 -> exit 127 for every sudo: true resource) to a private temp file. This
# is the ALWAYS-ON falsifier: a fake sudo that closes every fd >= 3 and a fake
# id that forces the non-root branch, then the real emitted wrapper. It went
# RED on the old emitter and needs no privilege. The reusable test job is
# `cargo test --lib`, so it runs here or nowhere.
- name: The sudo transport survives closefrom
run: cargo test --locked --test falsification_sudo_transport_closefrom_emulated
# And the LIVE half of the same falsifier. The emulated test fakes `sudo`,
# so it can show the transport surviving an emulated closefrom but never
# the host's real one with a real uid 0 on the other side.
# tests/falsification_sudo_transport_survives_closefrom.rs does exactly
# that, but every test in it is gated on `sudo -n true` and SKIPS where
# that is missing — and a skip is an absence of evidence: on a host
# without passwordless sudo that file was green against the fd-3 emitter
# too. FORJAR_REQUIRE_SUDO_TESTS=1 turns each of those skips into a panic
# naming the missing capability, so a green step here means the gated
# tests RAN, under real sudo, not that they declined to. ubuntu-latest
# satisfies the gate by GitHub's documented hosted-runner environment
# (the `runner` user is non-root and has passwordless sudo) — that is
# cited, not measured here, which is why the switch is set: if it ever
# stops holding, this step fails loudly instead of skipping silently.
- name: The sudo transport survives closefrom under real sudo (required, not skippable)
env:
FORJAR_REQUIRE_SUDO_TESTS: "1"
run: cargo test --locked --test falsification_sudo_transport_survives_closefrom
# PMAT-159: a release ships ONE version and every artefact that states it
# must state the same one — manifest, lockfile, changelog, and the binary's
# own `--version`. The target existed and ran in NO CI job, which is the
# same absence as not having it. `--locked` is the load-bearing flag, not
# house style: under a bare `cargo test` cargo repairs a stale `Cargo.lock`
# before the test body runs, so the #131 lockfile invariant cannot go red
# here; `--locked` makes cargo refuse a lagging lock at resolution and the
# test is the message-bearing witness for it. Drop the flag and this step
# measures three of its four legs.
- name: The release version is coherent (manifest, lock, changelog, binary)
run: cargo test --locked --test falsification_version_matches_manifest
# Regression guard (#237): `default-features = false` must yield a LIBRARY that
# compiles. tests/falsification_default_features_trim.rs pins the manifest's
# shape, but only a real build proves the module gates are consistent — trait
# impls, macro expansions and glob re-exports are invisible to a manifest test.
# The gates are load-bearing in one direction only: dropping `cli` must not
# break the library, and nothing in the default lane would ever notice if it did.
no-default-features:
if: needs.classify.outputs.code == 'true'
needs: classify
name: no-default-features
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Library must build with every default feature off
run: cargo check --locked --no-default-features --lib
# Regression guard (#318): no PR job compiled a single one of this crate's 87
# doctests. sovereign-ci's `test` is hard-scoped to `cargo test --lib` and, with
# use_nextest: true, is run by cargo-nextest, which cannot execute doctests at
# all; `lockfile` is --no-verify and `examples-validate` selects one integration
# target. Doctests were therefore first compiled at the clean-room release gate
# (GATE B3), where one wrong ``` fence costs a full release cycle — which is how
# paiml/forjar#315 passed every PR check and then failed the release.
# sovereign-ci has no doctest input and `--doc` cannot ride in on `test_args`,
# because it conflicts with that job's `--lib` target selection. So the PR lane
# needs its own job. Deterministic and offline; ~8s of doctests once built.
doctests:
if: needs.classify.outputs.code == 'true'
needs: classify
name: doctests
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Compile and run every doctest
run: cargo test --locked --doc
# Top-level gate: satisfies org ruleset which requires check named "gate".
# The reusable workflow produces "ci / gate" but rulesets need exact match on "gate".
# PMAT-163: the tool-free dogfood gates run here on every PR — surface
# derived from the built artifact (C) and every documented invocation
# executed against fixtures (D), plus the guard tests. Gates B (pmat comply),
# F (coverage + mutants), G (pv contracts) and H (crux reconcile) need pmat
# and pv, which are provisioned on the clean-room hosts by forjar.yaml, so
# they run there through `make dogfood-release`.
# PMAT-542: the gates that MEASURE THE BUILT BINARY, and the release build
# they need, run only when the change can move them. Measured on the 25 PRs
# merged as of cddf78cd: 7 were code=true and could reach neither gate, and
# each paid 21.3 minutes of a 25.2-minute critical path for a surface its diff
# could not touch. The guard tests below are a separate job because they are
# cheap and every code change can move them.
#
# THIS IS A LATENCY CHANGE, NOT A COVERAGE CHANGE. `make dogfood-release` runs
# A-H and T over the whole window before any tag, and the `gate` job below
# refuses this job's skip whenever `gates` says it should have run.
dogfood-surface:
if: needs.classify.outputs.code == 'true' && needs.classify.outputs.gates != 'none'
needs: classify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build the artifact the surface gates measure
run: cargo build --release --locked
- name: Gate C — surface from the built artifact
if: needs.classify.outputs.gate_c == 'true'
run: bash scripts/dogfood/surface.sh
- name: Gate C — NOT-SELECTED
if: needs.classify.outputs.gate_c != 'true'
run: echo "GATE C NOT-SELECTED this change touches nothing gate C reads (the built binary, docs/audits/surface_audit.csv, scripts/dogfood/surface.sh) — measured in full by make dogfood-release before the tag"
- name: Cookbook — clone paiml/forjar-cookbook (public) beside the workspace for gate D
if: needs.classify.outputs.gate_d == 'true'
run: git clone --quiet --depth 1 --branch master https://github.com/paiml/forjar-cookbook "$RUNNER_TEMP/forjar-cookbook"
- name: Gate D — documented invocations run against fixtures
if: needs.classify.outputs.gate_d == 'true'
run: bash scripts/dogfood/docs.sh
env:
COOKBOOK: ${{ runner.temp }}/forjar-cookbook
- name: Gate D — NOT-SELECTED
if: needs.classify.outputs.gate_d != 'true'
run: echo "GATE D NOT-SELECTED this change touches nothing gate D reads (the built binary, README.md, docs/audits/surface_audit.csv, scripts/dogfood/docs.sh) — measured in full by make dogfood-release before the tag"
dogfood-guards:
if: needs.classify.outputs.code == 'true'
needs: classify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Guard tests
run: cargo test --locked --test falsification_dogfood_skill_is_named --test falsification_dogfood_scripts_declare_mutations --test falsification_dogfood_release_check_pr_window --test falsification_dogfood_harness_and_quorum --test falsification_crux_gate_reads_the_release_section --test falsification_coverage_gate_mutation_scope --test falsification_cb200_ratchet_measures_this_tree --test falsification_pr_lane_runs_what_the_change_can_break --test falsification_pr_lane_selects_the_gate_the_change_can_move --test falsification_gate_a_the_pr_and_its_commits_name_one_ticket
gate:
runs-on: ubuntu-latest
needs: [classify, ci, lockfile, examples-validate, no-default-features, doctests, dogfood-surface, dogfood-guards]
if: always()
steps:
- name: Check required jobs
env:
CODE: ${{ needs.classify.outputs.code }}
GATES: ${{ needs.classify.outputs.gates }}
run: |
# PMAT-237: a job may be SKIPPED only because the change cannot reach
# it. The gate re-reads the class rather than trusting the skip: if
# the change IS code and a heavy job did not run, that is a hole, not
# a saving, and this gate refuses it by name.
# Written with explicit `if`, not `[ … ] && return 0`: an AND-list
# that fails at statement level exits the step under `bash -e` before
# the message is printed, and a gate that fails silently is half a
# gate.
# THE CLASS ITSELF MUST HAVE BEEN MEASURED (PMAT-237).
#
# If `classify` fails or is cancelled its output is EMPTY, every heavy
# job's `if` is false so they all skip, and a gate that read an empty
# class as "not code" would pass over a change nothing tested. A
# review lane found exactly that. An unmeasured class is code.
if [ "${{ needs.classify.result }}" != "success" ]; then
echo "::error::classify did not run (${{ needs.classify.result }}), so nothing knows what this change can break"
exit 1
fi
if [ -z "$CODE" ]; then
echo "::error::the class is empty — treating it as code and refusing"
exit 1
fi
# PMAT-542: the SELECTION must have been measured too. The classifier
# prints `none` and never the empty string, exactly so that "nothing
# was selected" and "nothing computed a selection" cannot look the
# same here. An empty value is the second one.
if [ -z "$GATES" ]; then
echo "::error::the gate selection is empty — nothing measured which gates this change can move, and an unmeasured selection is not a selection"
exit 1
fi
ok() {
case "$2" in
success) return 0 ;;
skipped)
if [ "$CODE" != "true" ]; then
echo "$1: skipped — the change touches nothing it reads"
return 0
fi
echo "::error::$1 was skipped on a change the classifier called code"
return 1
;;
*)
echo "::error::$1: $2"
return 1
;;
esac
}
ok "ci" "${{ needs.ci.result }}" || exit 1
if [ "${{ needs.lockfile.result }}" != "success" ]; then
echo "lockfile preflight failed: ${{ needs.lockfile.result }}"
exit 1
fi
ok "examples-validate" "${{ needs.examples-validate.result }}" || exit 1
ok "no-default-features" "${{ needs.no-default-features.result }}" || exit 1
ok "doctests" "${{ needs.doctests.result }}" || exit 1
ok "dogfood-guards" "${{ needs.dogfood-guards.result }}" || exit 1
# PMAT-542: dogfood-surface skips on its OWN condition, which is
# `gates != none` rather than the class. Re-read the selection here
# for the same reason `ok` re-reads the class: a skip is a claim until
# the thing that decided it says so.
case "${{ needs.dogfood-surface.result }}" in
success) echo "dogfood-surface: ran gates $GATES" ;;
skipped)
if [ "$CODE" != "true" ]; then
echo "dogfood-surface: skipped — the change touches nothing it reads"
elif [ "$GATES" = "none" ]; then
echo "dogfood-surface: NOT-SELECTED — this change can move neither gate C nor gate D; both are measured in full by make dogfood-release before the tag"
else
echo "::error::dogfood-surface was skipped while the selection says $GATES"
exit 1
fi
;;
*)
echo "::error::dogfood-surface: ${{ needs.dogfood-surface.result }}"
exit 1
;;
esac
echo "All required jobs passed"