flowchart LR
trig["v* tag, or workflow_dispatch"] --> build["build-images<br/>nix build .#image-<svc> (matrix x4)"]
build --> smoke{"smoke-stack<br/>compose up --wait from fresh images<br/>INSTREAM probes + decision-log assertions"}
smoke -- "pass, tag ref" --> pub["publish-images<br/>GHCR :latest + :version + SPDX SBOM"]
smoke -- "pass, dispatch" --> rehearse["stop: rehearsal only,<br/>nothing published"]
smoke -- "fail" --> dump["failure dump:<br/>compose logs + docker inspect states"]
pub --> notes["draft release:<br/>digests, changelog, SBOM assets"]
CI & Release
Every artifact is built by Nix from one lockfile: CI and a local machine produce byte-identical images from the same derivations. No docker build and no Dockerfile exists in the build path.
Workflows
| Workflow | Triggers | Function |
|---|---|---|
flake-check.yml |
PRs, pushes to master |
nix flake check --all-systems --no-build + image-closure dry-run builds |
release-images.yml |
tags v*, workflow_dispatch |
build → smoke gate → publish → draft release (publish and release require a tag ref) |
tests.yml |
tags v*, workflow_dispatch |
addon unit suite (pytest tests/, mitmproxy stubbed) |
Merge-path CI is deliberately light: flake-check, pre-commit.ci, and dependency-review only. The heavy jobs (image builds, compose smoke, unit suite) run on the release path — tags and manual dispatch — so merges stay fast.
The smoke gate
smoke-stack is the release-quality gate: a full compose deployment from the freshly built images (never from GHCR), exercising the verdict path end-to-end before anything can be published.
- Images are loaded into the runner’s daemon from the build job’s artifacts.
docker compose -f docker-compose.yml -f tests/smoke-override.yml up -d --waitbrings up the stack: local image refs, published probe ports, dnsmasq upstream servers, and a stubbed openvpn (the smoke verifies images and the verdict path; tunnel verification needs real PKI and runs in the verdict test suite).tests/instream_probe.pywaits for clamd (--wait, polling a clean probe), then asserts both verdicts over the INSTREAM protocol: benign bytes →CLEAN, EICAR →INFECTED.- Decision-log assertions: requests through the explicit proxy (
example.compasslisted →splice,wikipedia.orgunlisted →bump) must appear with those verdicts indecisions.jsonl.
A failure at any step blocks publish-images — nothing broken reaches GHCR. The failure dump captures compose logs plus docker inspect runtime state (exit code, error, OOM) for every container.
Rehearsals vs releases
workflow_dispatch runs the identical build + smoke pipeline without a tag; publish-images and release-notes require refs/tags/v* and are skipped otherwise. Dispatches are therefore full rehearsals with zero registry side effects.
The gate earned its place in six consecutive rehearsals — each caught a real image/addon runtime defect that build-time checks cannot see:
| Rehearsal | Defect caught | Issue | Fix |
|---|---|---|---|
| #1 | dnsmasq: no /etc/passwd in image → privilege-drop fails (exit 1) |
#43 | #36 |
| #2 | dnsmasq: no /etc/group, /var/run → pidfile failure (exit 3) |
#44 | #37 |
| #3 | clamav entry: install not in image PATH; no shell binary (silent exit 1) |
#45 | #40 |
| #4 | freshclam: no DatabaseMirror → no DB → clamd fails dead |
#46 | #42 |
| #4+ | addon: CLAMD_ADDR hardcoded loopback vs compose CLAMD_HOST=clamav → scan fail-open |
#47 | #32 |
| #5 | /var/lib/clamav not writable by uid 900 → DB bootstrap fails |
#48 | #49 |
| #6 | — | — | PASS (run) |
Rehearsal #6 cleared all assertions: build ×4, CLEAN/INFECTED INSTREAM probes, and the splice/bump decision-log lines through real proxy traffic. The defect ledger (#43–#48) documents each finding with symptom, root cause, and the verifying run.
Images
Built with pkgs.dockerTools.buildLayeredImage from nix/images.nix; image entrypoints reference every binary by absolute store path (no PATH resolution inside images) and bake the minimal /etc/passwd, /etc/group, and runtime directories each service requires. Outputs are Linux-only; darwin systems skip image outputs.
nix run .#load-images # build all four locally + docker load (linux host)Publishing a release
Releases are GPG-signed tags; the release job verifies the signature (git tag -v) and aborts on an unsigned or badly signed tag:
VERSION=v0.1.1 # example
git tag -s $VERSION -m "OpenSASE $VERSION: release summary"
git push origin $VERSIONThe pipeline then builds, smoke-tests, and publishes all four images to GHCR (:latest + the version), and opens a draft release generated on the runner:
- digest table for all four images (with a note that the images passed the smoke gate)
- commit changelog since the previous tag
- SPDX SBOMs attached as release assets
- 60-second compose deploy snippet
Publish the draft to ship; consumers need only an OCI runtime. Kubernetes consumers apply k8s/manifests.yaml, rendered from k8s/manifests.cue (nix run .#k8s-manifests).
Docs site
pages.yml renders this site with Quarto and deploys to GitHub Pages on pushes to master that touch _quarto.yml, docs/**, index.qmd, or the README.