CI/CD
Every workflow in .github/workflows, what triggers it, what it proves, and what it ships.
pull request ──► cli-test (lint · specs · tests on 3 OSes) ──► labs (the gate, touched labs × images) ──► quiz (verify)
│
push to main ──► deploy: test (PostgreSQL + Redis) ──► image (GHCR, amd64 + arm64) ──┐ │
└────► site (build) ───────────────────┴─► rollout (rsync + deploy.sh + smoke test)
tag v* ────────► release: wheels (tested outside the checkout) ──► PyPI ──► GitHub release (notes: Claude)
└──► announce (Discord, Telegram)
tag v* ────────► publish labs (OCI + cosign)
manual ────────► publish base images (KVM runner) ──► PR with the new digests
manual ────────► deploy with api_tag = an older commit (rollback)
nightly ───────► labs --all ──► report (an issue per lab that stopped solving)
issue opened ──► triage (Claude Code, Haiku, no tools)
issue labelled lab-request ──► lab-author (Claude Code subagent) ──► draft pull request
Mondays ───────► stuck-points (Claude Code, Haiku, no tools) ──► Discord, a hints issue
cli-test.yml — on every push and pull request
Ubuntu only: macOS and Windows are not run here (macOS is what development happens on, and
Windows is unsupported — see docs/faq.md).
uv sync --all-packages --frozen— the lockfile must be current.ruff checkandruff format --check.python -m norboten.labs.lint labs— every lab and journal against the spec.- Regenerates
docs/schema/*.jsonanddocs/api-reference.mdand fails if either differs from the committed copy. python -m pytest -m "not docker"— the whole suite (about 480 tests): the TUI through Textual's pilot, the API through its test client, the stores in memory.- On Ubuntu, the node checks: the terminal player against every shipped recording, and the consultant's browser fallback against the Python tokenizer.
norboten dev doctor— printed, never gating: runners have no KVM.
lab-validate.yml — the solvability gate
On every push and pull request. .github/scripts/changed_labs.py picks the labs a change touches
(all of them if it touches the runner, the engine, the registry or the baseline role) and emits a
matrix of lab × base image. Each job needs /dev/kvm, installs QEMU, and runs
norboten dev validate <lab> --image <image>: clean VM, faults, every check must fail, reference
solution, every check must pass, reboot, every check must pass again. A boot_after_break lab then
gets a second phase on a fresh VM: faults, one more boot as the learner's machine gets, and every
check must still fail — over SSH, or over a serial-port debug shell when that boot stops at a
maintenance prompt (docs/lab-spec.md §9, Q18). On failure it prints the serial console of the gate
VM. 22–77 s per job on an M-series laptop (about 100 s for the two boot_after_break labs); longer on
a runner.
Nightly at 02:00 the same workflow runs on a schedule with every lab on every image, changed or not
— upstream distributions change under labs that did not — and a last job, report, reads the run's
own jobs back and opens a broken-lab issue for each failed gate (<lab>, <image>) unless one is
open (automation/jobs/lab_health.py).
quiz-verify.yml
Validates every question bank and runs every executable question in a network-less container: the snippet's output must equal the answer key.
deploy.yml — push to main
| Job | Does | Needs |
|---|---|---|
test |
ruff, pytest with NORBOTEN_TEST_DATABASE_URL and NORBOTEN_TEST_REDIS_URL pointing at service containers (so the store and bus tests run against the real thing), node checks |
— |
image |
docker buildx of api/Dockerfile for amd64 and arm64, pushed as ghcr.io/<owner>/norboten-api:<sha> and :latest, with the GitHub Actions cache |
packages: write |
site |
seed/generate.py, then site/build.py with NORBOTEN_SITE_API=https://api.<domain>, NORBOTEN_SITE_URL=https://<domain> and the optional NORBOTEN_SITE_GA_ID variable, uploaded as an artifact |
— |
rollout |
rsync deploy/ (never .env) and site/dist to /opt/norboten, sudo deploy.sh <sha> over SSH, then curl /readyz and the site from outside |
SERVER_HOST, SERVER_HOST_KEY, NORBOTEN_DOMAIN variables; DEPLOY_SSH_KEY secret; the production environment |
rollout is skipped until SERVER_HOST exists, so the workflow is safe in a fork. It runs in the
production environment — add required reviewers there to make every deploy wait for a person.
The concurrency group never cancels a rollout halfway.
Rollback. Run the workflow by hand with api_tag set to an earlier commit: the build jobs are
skipped, the rollout checks out that commit's deploy/ and rolls out its image, which is still in
GHCR. deploy.sh also rolls back on its own when a new image does not report ready within two
minutes, and the job then fails.
release.yml — on a v* tag
Builds the norboten and norboten-runner wheels, installs them into a clean environment outside
the checkout and checks the installed package finds its labs, banks and journals, runs install.sh
end to end against them, then publishes to PyPI (trusted publishing), creates the GitHub release
with the wheels and install.sh. See
Releasing.
The release notes are drafted by Claude Code (Haiku, no tools) from the commits since the previous
tag, with the full commit list appended; without CLAUDE_CODE_OAUTH_TOKEN they are the list alone.
After the release, announce posts it to Discord and each lab it adds to Telegram — no model.
Between releases, announce-content does the other half: every push to main that adds a lab, a
journal or a question bank posts one message to the same channel, read from the push's own diff
(automation/jobs/announce_content.py). Both do nothing without DISCORD_WEBHOOK, and neither
ever names anything under rated/.
Claude Code jobs — triage.yml, stuck-points.yml, lab-author.yml
Each installs one pinned Claude Code (.github/actions/claude-code), authenticates with the
CLAUDE_CODE_OAUTH_TOKEN secret, has a timeout and a concurrency group, and skips with a notice
when the secret is not set. What each may do, and why, is in
Architecture.
| Workflow | Trigger | Secrets and variables |
|---|---|---|
triage.yml |
issues: opened, not by a bot |
CLAUDE_CODE_OAUTH_TOKEN |
stuck-points.yml |
Mondays 08:00, or by hand | CLAUDE_CODE_OAUTH_TOKEN, DISCORD_WEBHOOK; variable NORBOTEN_API |
lab-author.yml |
issues: labeled with lab-request |
CLAUDE_CODE_OAUTH_TOKEN, DISCORD_WEBHOOK |
release.yml (announce) |
after the GitHub release | DISCORD_WEBHOOK, TELEGRAM_BOT_TOKEN; variable TELEGRAM_CHAT |
lab-publish.yml — on a v* tag
Builds each lab into an OCI image with images/Dockerfile.lab, pushes <id>:<version> and
:latest to ghcr.io/<owner>/norboten-labs, and signs the digest with cosign (keyless, the
workflow's OIDC identity).
image-publish.yml — manual
Builds one golden image on a KVM runner with images/build.py, pushes it with oras as
ghcr.io/<owner>/norboten-base/<id>:<version>, and opens a pull request that writes the new digest
into images/registry.yaml — which runs the gate for every lab before it can merge.
Locally
| Command | Mirrors |
|---|---|
make check |
cli-test.yml without the OS matrix |
make test-db |
the test job's PostgreSQL and Redis tests, in throwaway containers |
make validate LAB=<id> |
one job of the gate |
make infra-validate |
both playbooks' syntax, both compose files and the Caddyfile |
make server-rehearsal |
the rollout job, against a local VM instead of the server |
make release-check |
the build job of release.yml |
make jobs-rehearsal |
triage, stuck-points, lab-author, the nightly report and the release jobs, with a real claude and a scripted model |