Centralized CI/CD build automation for the ISU Redux repos —
Redux (.NET), Redux_GUI (Next.js), quantumsolver (Python), and anything added later.
rbs is a Python CLI that is the pipeline. GitHub Actions does not contain the build logic; it
installs this CLI and calls it. The same binary runs in a terminal, in a devcontainer, and in CI —
so "it passed locally" and "it passed in the PR" mean the same thing.
Three problems in the Redux repos this is built to solve:
- Pipeline logic was trapped in YAML. It could only run on GitHub. You could not reproduce a failing gate locally without pushing a commit and waiting.
- Every repo drifted.
quantumsolvergates onpip-audit → black → ruff → pytest --cov-fail-under=85;Reduxruns build+test only;Redux_GUIhas no tests and a non-blocking linter. There was no shared floor and no single place to raise it. - The tested artifact was not the shipped artifact. CI built and pushed an image, but tests ran
against the source tree, never the image.
rbsbuilds the artifact, tests that artifact, and only then pushes it.
Adding a gate to all repos should be one change here — not three PRs in three languages.
Every repo, in every language, exposes the same verbs:
| Operation | Meaning |
|---|---|
audit |
Dependency vulnerability scan |
format-check |
Verify formatting, write nothing |
lint |
Static analysis |
unit-test |
Unit tests + coverage gate |
build |
Build the deployable artifact locally (never pushes) |
integration-test |
Run tests against the locally-built artifact over HTTP |
push |
Tag and push the already-tested artifact to the registry |
report |
Aggregate all results into one report |
mise run test / rbs unit-test means the same thing whether the repo is C#, Python, or JS.
An engine implements those operations for one toolchain. The engine owns the whole set; there is
no lint module that branches on language.
| Engine | Toolchain | Repos | Status |
|---|---|---|---|
uv |
Python + uv (ruff, black, pytest, pip-audit) | quantumsolver, this repo |
implemented |
npm |
Node (npm audit, biome, eslint) | Redux_GUI |
implemented |
dotnet |
.NET SDK (dotnet test, analyzers) |
Redux |
planned |
Two things the npm engine does deliberately:
auditrunsnpm audit --omit=dev, so it scans only what ships — the same surface theuvengine gets fromuv export --no-dev. Including devDependencies would gate a release on vulnerabilities in the linter.unit-testreportsskippedwhenpackage.jsonhas notestscript, rather than passing vacuously. It activates on its own the moment a suite is added.
Tools run via npx --no-install — the node analogue of uv run — so a missing devDependency fails
loudly instead of being silently fetched from the registry mid-gate.
Adding a stack = one new class in src/redux_build/engines/ subclassing Engine. Container
operations (build / integration-test / push) are identical everywhere, so they live on the base
class and every engine inherits them; a toolchain can still override any of them.
A repo picks its engine in rbs.toml:
engine = "uv"Every operation returns a Fragment, the unit the reporter aggregates:
{
"engine": "npm",
"operation": "lint",
"status": "failure",
"summary": "26 errors, 43 warnings",
"variant": "",
"metrics": {},
"duration_s": 4.4,
"findings": [
{
"severity": "error",
"location": "components/hooks/ProblemProvider/Problem.js:51",
"rule": "no-undef",
"message": "'requestInfo' is not defined."
}
]
}findings is what makes the report actionable rather than merely informative — a reviewer sees
which file and which rule, not just a count. The shape is deliberately tool-agnostic, so location
is file:line for a linter, pkg@range for an audit, and a test id for a test run. Engines get it
by asking their tools for JSON (eslint -f json, biome --reporter=json, npm audit --json) and
translating; a document that fails to parse yields no findings rather than crashing the run, and the
exit code still gates.
status is one of success / failure / skipped / warning / blocked. Schema:
schemas/report-fragment.schema.json. Operations are pure —
they return a Fragment and the CLI performs the side effects — which is why they are unit-testable.
rbs <operation>
│
├─ read ./rbs.toml → which engine, which parameters
├─ RunContext.detect() → local or GitHub? (GITHUB_ACTIONS)
├─ engine.<operation>(ctx) → shells out to uv / docker / …
└─ report.emit(ctx, fragment)
├─ write $RBS_REPORT_DIR/<operation>[-<variant>].json (always)
├─ print a one-line result (always)
├─ append $GITHUB_STEP_SUMMARY (CI only)
└─ append $GITHUB_OUTPUT status=… (CI only)
rbs report → load every fragment → render markdown → (--post) upsert one sticky PR comment
Exit code is non-zero when the operation fails.
A PR gate's job is to tell you everything that is wrong in one pass, not just the first thing.
Quality gates (audit, format-check, lint, unit-test) have no prerequisites. A lint
failure never stops the audit from running. Every one of them reports, every run.
The artifact chain is a real data dependency, so it is enforced: you cannot integration-test an
image that failed to build, or push one that failed its tests. Those operations declare
prerequisites (Engine.requires), and when one is unmet the operation reports blocked —
"a required upstream operation failed, so this never ran" — instead of running and failing for the
wrong reason, or reporting a misleading skipped.
audit ─┐
lint ─┼─ all always run, independently
… ─┘
build ──✅/❌──▶ integration-test ──✅/❌──▶ push ❌ upstream ⇒ ⛔ blocked downstream
Crucially that policy lives here, not in workflow YAML. rbs integration-test reads the
fragments already on disk and short-circuits itself, so it behaves identically on your laptop and in
CI. Workflow steps therefore carry a uniform, policy-free continue-on-error: true, and
rbs report is the single gate that fails the build:
- run: rbs audit
continue-on-error: true # identical on every operation step — no policy in YAML
- run: rbs lint
continue-on-error: true
…
- run: rbs report --post # the only step that can fail the jobFor branch protection / rulesets this means you require one status check — the report job — rather than one per operation. It cannot go stale as operations are added.
The artifact hand-off — the point of the whole design:
build ──→ local/<name>:ci ──→ integration-test (runs THAT image) ──→ push (retags THAT image)
(never pushed until it has passed)
Nothing is uploaded to a registry to be pulled back down. The bytes that were tested are the bytes that ship.
The CLI needs uv. Docker is needed only for build /
integration-test / push.
# ephemeral — no install, always current
uvx --from ~/projects/ISU/Redux_Build_System rbs --help
# on PATH, tracks your edits live
uv tool install --editable ~/projects/ISU/Redux_Build_System
# or from inside this repo
uv sync --all-groups && uv run rbs --helprbs acts on the current working directory and reads ./rbs.toml from it.
cd ~/projects/ISU/quantumsolver
rbs lint # one gate
rbs unit-test --variant py3.12
rbs build # → local/quantumsolver:ci
rbs report # aggregate everything run so farResults accumulate in .rbs/report/ until you clear it, so you can run gates one at a time and then
render a combined report. Add .rbs/ and report.md to the consumer repo's .gitignore.
Useful flags and variables:
--variant <label> |
Tags the fragment (e.g. py3.12) so matrix runs don't overwrite each other |
report --post |
Upsert the report as a sticky PR comment (CI only; no-op without a token) |
report --soft |
Render the report but exit 0 even if an operation failed |
RBS_REPORT_DIR |
Where fragments are written (default ./.rbs/report) |
Lives at the root of the consumer repo.
engine = "uv" # required — which toolchain engine
package = "quantumsolver" # coverage target; fallback artifact name
[unit-test]
coverage-min = 85 # --cov-fail-under
[artifact]
image = "ghcr.io/reduxisu/quantumsolver" # registry name; local tag derives from it
dockerfile = "Dockerfile" # optional, default "Dockerfile"
context = "." # optional, default "."Consumed today: engine, package, unit-test.coverage-min, artifact.{image,name,dockerfile,context}.
Keys for unimplemented operations ([integration], [push], matrix python-versions) are defined in
docs/onboarding.md and are inert until those operations land.
build derives local/<name>:ci from artifact.name, else the last path segment of
artifact.image, else package.
rbs ships as a devcontainer Feature, so a consumer repo does not vendor any install logic — it
lists the feature and gets the CLI on PATH:
Open that container and you have quantumsolver's toolchain plus rbs lint, rbs unit-test,
rbs build. Same feature line works on the .NET base image for Redux and the Node one for
Redux_GUI — the feature installs a managed Python for itself, so the host image needs no Python.
| Option | Default | Meaning |
|---|---|---|
version |
latest |
Git ref of this repo to install; pin it (e.g. "version": "v1") for reproducibility |
repo |
this repo's URL | Source repository, for forks |
Pair it with docker-outside-of-docker. The feature deliberately does not install Docker;
rbs build and rbs integration-test need a Docker socket, so the consumer adds that feature
alongside it. The rbs feature installs after common-utils, python, and the docker feature.
Source lives in features/src/rbs/ and publishes to
ghcr.io/reduxisu/features/rbs via .github/workflows/publish-features.yml on any change to it.
The package is public, so no docker login is needed.
Bump version in devcontainer-feature.json whenever you change the feature — republishing an
existing version is a no-op. That version is the feature's own contract and is independent of the
rbs CLI version. Test changes before publishing with:
devcontainer features test --project-folder features --features rbs \
--base-image mcr.microsoft.com/devcontainers/base:ubuntuThis repo ships a devcontainer so rbs can be exercised in the same kind of sandbox the consumer
repos use. It includes docker-outside-of-docker, so rbs build inside the container drives the
host Docker daemon and the images it builds are visible from your host.
It works headless from a terminal — no VS Code needed, which is the point if you want it sitting in its own tmux pane.
npm install -g @devcontainers/cli # once (or use `npx @devcontainers/cli` below)
cd ~/projects/ISU/Redux_Build_System
devcontainer up --workspace-folder . # build + start; postCreate runs uv sync
# run gates inside the container
devcontainer exec --workspace-folder . rbs lint
devcontainer exec --workspace-folder . rbs unit-test
devcontainer exec --workspace-folder . mise run test
# or get a shell and stay there (good for a dedicated pane)
devcontainer exec --workspace-folder . bashTo drive another repo from that pane, mount it and run rbs against it:
cd ~/projects/ISU/quantumsolver
devcontainer up --workspace-folder . # once that repo has its own devcontainerOr, without any devcontainer, the same thing in plain Docker:
docker run --rm -it \
-v ~/projects/ISU:/work -w /work/quantumsolver \
-v /var/run/docker.sock:/var/run/docker.sock \
mcr.microsoft.com/devcontainers/python:3.12 bashNote: with docker-outside-of-docker, containers started by rbs are siblings on the host, not
children. Bind mounts inside rbs resolve against host paths, and a service on port 27100 is
reachable at localhost:27100 on the host.
Gotcha — "moby": false is required. The python:3.12 devcontainer base is now Debian
trixie, which has no moby-cli package, so the docker feature's default (moby: true) fails the
build outright. This config pins moby: false to use the upstream Docker CE CLI instead. Any other
repo adding a docker feature on a trixie-based image needs the same setting.
Status: not yet implemented. The reusable workflows described here are the next milestone. Everything above this section works today.
A consumer repo will carry two files and no pipeline logic:
rbs.toml (above) and .github/workflows/ci.yml:
name: CI
on:
pull_request: { branches: [main] }
push: { branches: [main] }
permissions:
contents: read # checkout
packages: write # push → GHCR
pull-requests: write # sticky report comment
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
uses: ReduxISU/Redux_Build_System/.github/workflows/ci-uv.yml@v1
secrets: inheritThe hub's reusable workflow installs rbs and calls one operation per step:
jobs:
quality: # matrix: 3.12 / 3.13
steps:
- uses: ./.github/actions/setup-rbs
- { run: rbs audit, continue-on-error: true }
- { run: rbs format-check, continue-on-error: true }
- { run: rbs lint, continue-on-error: true }
- { run: "rbs unit-test --variant py${{ matrix.python }}", continue-on-error: true }
- uses: actions/upload-artifact@v4 # report-quality-<ver>
build-test: # needs: quality
steps: # rbs itself blocks the chain — see "Failure policy"
- { run: rbs build, continue-on-error: true } # local image, not pushed
- { run: rbs integration-test, continue-on-error: true } # ⛔ if build failed
- { run: rbs push, continue-on-error: true } # ⛔ unless both passed
- uses: actions/upload-artifact@v4
report: # needs: [quality, build-test], if: always()
steps:
- uses: actions/download-artifact@v4 # pattern: report-*, merge-multiple
- run: rbs report --post # sticky PR comment + job summary; THE gateif: always() on the report job is required — without it a failed upstream job would skip the
report and you would lose the very summary explaining what broke.
Why permissions are declared in the caller: a reusable workflow can only narrow GITHUB_TOKEN
scope, never widen it. Fork PRs get a read-only token and no secrets — push and comment are skipped
by design, and the report still renders in the job summary.
Each operation drops a JSON fragment; each job uploads its fragments as an artifact; a final job
merges them and renders the table twice — into $GITHUB_STEP_SUMMARY (the bottom of the Actions
run) and as one sticky PR comment, keyed by an HTML marker so re-runs edit it instead of spamming
the thread.
Rows appear in the engine's pipeline order, not alphabetically, so the table reads in the order the work actually happened:
## Redux Build System — CI Report
`uv` · commit `a1b2c3d`
| Operation | Status | Summary | Time |
|---|:--:|---|--:|
| audit | ✅ | no known vulnerabilities | 3.1s |
| format-check | ✅ | all files formatted | 0.9s |
| lint | ✅ | 0 issues | 2.4s |
| unit-test · py3.12 | ✅ | 142 passed · coverage 91% | 37.2s |
| unit-test · py3.13 | ✅ | 142 passed · coverage 91% | 36.8s |
| build | ✅ | built `local/quantumsolver:ci` · 610MB | 48.0s |
| integration-test | ✅ | /health 200 · 6/6 checks | 12.7s |
| push | ⏭️ | only on push to `main` | — |
**Overall: ✅ 7 passed · 0 failed · 1 skipped**A run where the artifact chain breaks reports the failure once and marks the rest blocked, so it is
obvious what was never attempted versus what was legitimately not applicable:
| build | ❌ | docker build failed | 0.9s |
| integration-test | ⛔ | not run — `build` failed | — |
| push | ⛔ | not run — `build` failed | — |
**Overall: ❌ 0 passed · 1 failed · 0 skipped · 2 blocked**Below the table, any operation with findings gets a collapsed block naming what to actually fix:
<details><summary>❌ lint — 26 errors, 43 warnings</summary>
| Severity | Location | Rule | Message |
|---|---|---|---|
| error | components/hooks/ProblemProvider/Problem.js:51 | no-undef | 'requestInfo' is not defined. |
| error | components/pageblocks/VerifyRowReact.js:43 | react-hooks/set-state-in-effect | Calling setState synchronously within an effect… |
| | | | _… and 49 more_ |
</details>At most MAX_FINDINGS (20) are listed per operation, most severe first, and the remainder is always
stated — a truncated report must never read as a complete one. Messages are trimmed to their first
line (react-hooks rules embed several paragraphs and a code frame); the full text stays in the
fragment JSON.
| Capability | State |
|---|---|
audit, format-check, lint, unit-test (uv engine) |
✅ implemented |
audit, format-check, lint, unit-test (npm engine) |
✅ implemented |
build — local image via docker buildx --load |
✅ implemented |
report — fragments, markdown, findings, sticky comment + job summary |
✅ implemented |
integration-test |
⬜ next |
push |
⬜ planned |
rbs ci — run the engine's full ordered pipeline |
⬜ planned |
Reusable workflows + setup-rbs action |
⬜ planned |
dotnet engine |
⬜ planned |
deploy — pull-based compose deploy |
⬜ planned |
Unimplemented operations report skipped, never a false pass.
Maintainer tool. Runs this working tree against another repo, so you can iterate on an engine and see the effect immediately. Consumers never need it — they use the published feature.
cd ~/projects/ISU/quantumsolver
~/projects/ISU/Redux_Build_System/rbs_dev.sh lint # host: dev rbs against this repo
~/projects/ISU/Redux_Build_System/rbs_dev.sh -c unit-test # same, inside the repo's devcontainer
~/projects/ISU/Redux_Build_System/rbs_dev.sh -c shell # shell in that container
~/projects/ISU/Redux_Build_System/rbs_dev.sh --down # tear it downIn container mode it rewrites the target's devcontainer into a scratch
.devcontainer/.rbs-dev/ config that (a) resolves the rbs feature from this repo's local
features/src/rbs instead of the registry, and (b) bind-mounts this working tree to /opt/rbs-src
and installs it editable. Point (b) matters: the feature normally installs rbs from git, so
without the override the container would run committed main, not your edits. Because the install
is editable, saving a .py file changes behavior in the running container with no rebuild.
The scratch dir is added to the target's .git/info/exclude, so it never dirties git status or a
tracked .gitignore.
This repo runs its own pipeline against itself:
uv sync --all-groups
uv run pytest # the build system's own tests
uv run rbs lint # dogfooding — rbs linting rbs
mise run test # same thing through the shared task vocabularyLayout:
src/redux_build/
├─ cli.py Typer app — one subcommand per operation
├─ config.py rbs.toml loading
├─ context.py RunContext — cwd, local-vs-CI detection, paths
├─ runner.py shell-out helper (rc, output, duration)
├─ models.py Fragment + Status
├─ report.py fragments → markdown → sticky PR comment
├─ docker.py image tag derivation, size
├─ registry.py engine name → class
└─ engines/
├─ base.py Engine ABC + shared container operations
└─ uv.py UvEngine
See docs/engine-contract.md to add an engine and
docs/onboarding.md to onboard a repo.