Sixteen CI quality gates for Python web apps, in one uses: line.
colormath is a reusable GitHub Actions workflow (plus the scripts and composite actions behind it) that gives a Poetry-managed Python project a complete, parallel CI gate suite: formatting, lint (Python and JS), types, docstrings, alembic migration sync, import boundaries, tests, template lint, security, secrets, accessibility, dependency CVEs (Python and JS), Dockerfile lint, and per-change test coverage.
It grew out of a family of FastAPI + Poetry + Jinja/Tailwind apps deployed on Cloud Run, but the gates apply to most Poetry projects — the JS-based gates (frontend tests, JS lint, CSS lint, template accessibility) are driven by npm scripts you define, and any gate can be switched off.
Create .github/workflows/gates.yml in your repo:
name: Gates
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
gates:
uses: ColorMath/ci/.github/workflows/gates.yml@vX.Y.Z # latest: /releases/latest
with:
python-version: "3.12"
default-branch: mainOpen a pull request and you'll get sixteen parallel checks, each posting a compact stats block to the run summary. Project-specific jobs (deploys, previews, seeding) stay in your repo as siblings that gate on the suite:
deploy-staging:
needs: gates
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# ... your deploy steps, unchangedThe example/ directory is a minimal compliant consumer — start there to see every contract file in place. colormath's own CI runs the full suite against it on every PR.
| Gate | Tool | Enforces |
|---|---|---|
ruff |
ruff | formatting + lint |
tests |
vitest + pytest | poetry.lock in sync with pyproject, then JS and Python suites green |
typecheck |
mypy | type checks pass |
docstrings |
interrogate | docstring coverage ≥ your [tool.interrogate] fail-under |
migrations |
git (no deps) | branch not missing alembic migrations that landed on the base branch (set enable-migrations: false without alembic) |
import-linter |
import-linter | your [tool.importlinter] import contracts hold (layers / forbidden / independence); set enable-import-linter: false until you write contracts |
jslint |
eslint | hand-written JS passes lint |
styles |
stylelint | design tokens only — no hardcoded colors/font-sizes |
templates |
djlint | Jinja templates are well-formed (per [tool.djlint]) |
sast |
bandit | no Medium+ security findings in app source |
secrets |
gitleaks | no secrets anywhere in git history |
a11y |
html-validate | templates pass the a11y preset |
deps |
pip-audit | locked, shipped deps have no actionable CVEs |
js-deps |
npm audit | locked, shipped JS deps have no high+ CVEs |
dockerfile |
hadolint | Dockerfile best practices |
diff-coverage |
diff-cover | changed lines ≥90% covered |
Two design choices worth calling out:
diff-coverage, not total coverage. The gate requires the lines you changed to be covered. It never fails on pre-existing untested code, so you can adopt it on day one of a legacy codebase and ratchet quality up one PR at a time.depsandjs-depsaudit what ships. Locked versions are exported frompoetry.lockfor the production dependency groups only, and npm audit runs with--omit=dev— dev tooling never triggers a CVE failure, and local runs audit exactly what CI audits.depsskips PRs that don't move dependencies. The scan is ~200 sequential PyPI lookups (minutes, against a suite where everything else finishes in ~2), and its verdict can only change when the locked set, the allowlist, or the upstream advisory databases move. So on apull_requestthat touches none ofpoetry.lock,pyproject.toml, or.colormath/audit.conf, it reports green without scanning. Every other event — including each push to the default branch — scans in full, so newly-published advisories still surface on merge; add aschedule:trigger to your caller if you also want a nightly re-check. Opt out withdeps-skip-unchanged: false.make auditalways scans in full.migrationscatches alembic divergence before the merge. A PR that branched before newer migrations landed on the base branch merges into multiple alembic heads. The gate diffs the base branch against the PR's merge-base, scoped tomigrations-path, and fails with "pull in the latest<branch>" when the base moved. The base branch is yourdefault-branchinput when set, else discovered from the repo — main and master both just work.import-linterenforces the architecture you write down. The gate runslint-importsagainst the contracts in your[tool.importlinter]— a layered dependency order, a module that must stay independent of another, a boundary a subsystem may not cross. Like the rest of the suite it needs no project deps (grimp builds the import graph by static analysis), and the rules are entirely yours: colormath ships the runner, your pyproject ships the contracts. Handy for invariants a reviewer can't reliably catch by eye — e.g. a worker entrypoint that must never import a FastAPI-coupled module.
Every gate has an enable-<gate> input (default true). On a codebase with
existing findings, land the caller with the failing gates disabled, burn the
findings down, and enable them one by one:
uses: ColorMath/ci/.github/workflows/gates.yml@vX.Y.Z # latest: /releases/latest
with:
python-version: "3.12"
default-branch: main
enable-a11y: false # TODO: burn down template findings
enable-diff-coverage: false # TODO: enable once the suite measures coverageDisabled gates show as skipped in the run — visible, never silently absent.
All inputs are optional.
| Input | Default | Purpose |
|---|---|---|
python-version |
"3.12" |
Python for all Python gates |
node-version |
"22" |
Node for all JS gates (html-validate 11.x needs ≥22) |
default-branch |
repo default | Base branch for diff-coverage |
workdir |
"." |
Directory containing the app, for monorepos |
poetry-install-args |
"" |
Extra poetry install args, e.g. "--with webapp,worker" |
ruff-spec |
"ruff>=0.14,<0.15" |
pip spec for ruff — match your pyproject pin |
ruff-select |
"" (full lint) |
Restrict ruff check to specific rules, e.g. "I" |
bandit-spec |
"bandit>=1.9,<2" |
pip spec for bandit — match your pyproject pin |
interrogate-spec |
"interrogate>=1.7,<2" |
pip spec for interrogate — match your pyproject pin |
interrogate-paths |
"." |
space-separated paths for interrogate; scoped by [tool.interrogate] excludes |
migrations-path |
"alembic/versions" |
migrations directory the migrations gate watches |
gitleaks-version |
"8.30.1" |
gitleaks release to install |
djlint-spec |
"djlint>=1.36,<2" |
pip spec for djlint — match your pyproject pin |
djlint-paths |
"templates/" |
space-separated template paths for djlint; profile/ignores from [tool.djlint] |
import-linter-spec |
"import-linter>=2,<3" |
pip spec for import-linter — match your pyproject pin |
hadolint-version |
"2.14.0" |
hadolint release to install |
hadolint-dockerfiles |
"Dockerfile" |
space-separated Dockerfile paths to lint |
npm-audit-level |
"high" |
severity at which npm audit fails the js-deps gate |
diff-cover-fail-under |
"90" |
Minimum % coverage on changed lines |
free-disk-space |
false |
Reclaim runner disk first (heavy ML dependency trees) |
enable-<gate> |
true |
Per-gate opt-out (see above); new gates ship opt-in in a MINOR, then default-on in the next MAJOR |
| File | Needed for | Purpose |
|---|---|---|
pyproject.toml with [tool.bandit] |
sast |
scan scope/excludes (plus your mypy/ruff config as usual) |
pyproject.toml with [tool.interrogate] |
docstrings |
coverage threshold (fail-under) + excludes |
pyproject.toml with [tool.djlint] |
templates |
djlint profile (e.g. jinja) + rule ignores |
pyproject.toml with [tool.importlinter] |
import-linter |
your import contracts (root package(s) + layers/forbidden/independence) (reference) |
.colormath/audit.conf |
deps |
poetry groups that ship + CVE allowlist (reference) |
npm scripts test, jslint, styles, a11y |
the JS gates | see example/package.json |
eslint.config.js + vendored eslint.config.colormath.mjs |
jslint |
thin caller of the shared eslint base (reference); devDeps eslint, @eslint/js, globals |
Dockerfile |
dockerfile |
linted by hadolint (reference) |
AGENTS.md + vendored AGENTS.colormath.md |
agents | app-specific facts locally, shared colormath conventions imported (reference) |
.colormath/ci.env |
optional | non-secret env sourced before pytest in CI |
.colormath/ci-extra-install.sh |
optional | extra install steps after poetry install (must be executable) |
.gitleaks.toml |
optional | gitleaks false-positive allowlist, used when present |
Once the checks are green, register each gate's job name as a required status
check in your branch protection (they appear as gates / Ruff (format + lint)
and so on) — and remove the required contexts left over from your
pre-colormath workflow. Old names never report under the suite, so each one
pins every PR at "Expected — waiting for status" and blocks merging.
review.yml is a second reusable workflow —
entirely opt-in, adopted per project by adding a caller (skip the caller and
nothing changes). It runs two Claude agents in parallel on every non-draft PR
(re-runnable by commenting @claude):
- review — the "Thermonuclear Review": a deliberately adversarial audit of
the diff across correctness, security, maintainability, and DevEx, posted as
a tracking comment (marker line
## Thermonuclear Review) with inline comments on the relevant lines. - test-plan — classifies the diff (UI vs API surface) and posts a
## Test Plancomment with concrete QA checklists, exposing a machine-readable verdict (qa_depth,requires_ui_qa,requires_api_qa) as workflow outputs for downstream QA jobs to gate on.
Both agents run via anthropics/claude-code-action, which needs more than
the caller file — do these once per repo, in order:
- Install the Claude GitHub App on the
repo (or org). The action exchanges OIDC (
id-token: write) for an app token and posts its comments asclaude[bot]— without the app installed, the agents cannot authenticate to GitHub and the jobs fail at startup. - Add the API key secret:
gh secret set ANTHROPIC_API_KEY --repo <owner>/<repo>(an Anthropic API key with access to the model you configure; both agents share it). - Add the caller workflow below, pinned to an exact tag.
- Optionally set
review-focusto point the reviewer at your project's sensitive surfaces — without it the review is generic.
name: claude-review
on:
pull_request:
types: [opened, reopened, ready_for_review]
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
jobs:
review:
uses: ColorMath/ci/.github/workflows/review.yml@vX.Y.Z # latest: /releases/latest
permissions:
contents: read
pull-requests: write
issues: write
id-token: write
secrets:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
with:
review-focus: "Pay attention to <project-specific hot spots>."Inputs: review-focus (extra project-specific emphasis for the reviewer),
enable-review / enable-test-plan toggles, and per-agent model and effort
controls. Requires an ANTHROPIC_API_KEY repo secret.
| Input | Default | Purpose |
|---|---|---|
model |
"claude-sonnet-4-6" |
Fallback for whichever agent has no explicit model |
review-model |
"" → model |
Model for the review agent |
test-plan-model |
"claude-haiku-4-5" |
Model for the test-plan agent |
review-effort |
"medium" |
--effort for the review agent |
test-plan-effort |
"" (no flag) |
--effort for the test-plan agent |
The two agents are priced very differently in practice — on a representative
PR the reviewer cost $1.00 over 18 turns and the test-plan agent $0.53 over
14. So the defaults put the test-plan agent on Haiku (read-only analysis
producing a checklist — well inside its range, at a third of Sonnet's price)
and drop the reviewer to medium effort, while leaving the reviewer itself on
Sonnet, where adversarial depth actually buys findings.
Two sharp edges. effort is rejected by Haiku 4.5 and Sonnet 4.5, so
setting test-plan-effort while test-plan-model is on its Haiku default
will fail the request — move that model to a 4.6+ model first. And because
test-plan-model has a non-empty default, setting model: alone no longer
reaches both agents; set test-plan-model: too if you want one model
everywhere.
The issue_comment / pull_request_review_comment triggers are what enable
@claude re-runs, but they come with noise: GitHub can't filter comment
events by body at the trigger level, so every PR comment — including the
review's own two bot comments — creates a run that immediately skips. If you
prefer a quiet Actions tab, keep only the pull_request trigger and re-run
reviews from the Actions UI (or flip the PR draft → ready).
This repo is also a Claude Code plugin marketplace.
The colormath plugin ships skills for working in consumer repos:
/colormath:ship takes the current branch through the whole PR pipeline
(open the PR, watch the gates, wait for the Thermonuclear Review, execute the
generated test plan against the running stack and post its results, fix
every finding it can — blockers included) and ends at a gated final review that
auto-merges when the PR is genuinely clean or holds and explains why;
/colormath:qa QAs a focus area against the running stack and hands the
fixes to ship; /colormath:bugfix turns a specific bug report into a
merged fix — establishing the facts the report omitted, reproducing the defect
before touching code, fixing at the layer the invariant belongs to, remediating
data the bug already corrupted, then handing off to ship; and
/colormath:refine-ticket grooms a ticket until it can be worked —
investigating the code before it asks anything, so its questions are few and
concrete, then writing back a standalone description, an implementation plan
whose every step names a real file, and a QA plan someone could execute; and
/colormath:refine-initiative does the layer above for an initiative —
reading its feature definitions, investigating the architecture and decision
records they land in, interviewing until the picture is complete, then rewriting
the initiative and every feature with the background implementation needs, while
stopping short of code-level plans and never starting the build; and
/colormath:plan-initiative closes the loop between the two — running
refine-ticket over every ticket in an initiative, in build order, injecting
each ticket's place in the sequence and what the earlier plans decided, so the
seams line up instead of seven independent groomings contradicting each other;
and /colormath:implement-ticket takes a groomed ticket the rest of the way
— checking its plan still matches the code before touching anything, building at
the layer the plan names, executing the ticket's QA plan against the running
stack, then handing off to ship. Each skill's behavior, prerequisites, and
contract dependencies are documented in plugin/README.md.
refine-ticket, refine-initiative, plan-initiative and
implement-ticket need the Abacus MCP
server connected — the plugin's one tracker dependency.
Install manually:
/plugin marketplace add ColorMath/ci
/plugin install colormath@colormath
or have a consumer repo offer it to everyone who opens it, via
.claude/settings.json:
{
"extraKnownMarketplaces": {
"colormath": {
"source": { "source": "github", "repo": "ColorMath/ci" }
}
},
"enabledPlugins": {
"colormath@colormath": true
}
}Three files are vendored into every consumer at the pinned tag:
Makefile.colormath (a local mirror of every gate, so
all consumers share the same make endpoints),
eslint.config.colormath.mjs (the shared eslint
base — your eslint.config.js stays a thin caller; see its header for the
factory options and escape hatches), and
AGENTS.colormath.md (the shared agent conventions — see
Agent docs below). Vendor them once:
# REF is the release you are pinning to — see github.com/ColorMath/ci/releases/latest
REF=vX.Y.Z
curl -fsSLO "https://raw.githubusercontent.com/ColorMath/ci/$REF/Makefile.colormath"
curl -fsSLO "https://raw.githubusercontent.com/ColorMath/ci/$REF/eslint.config.colormath.mjs"
curl -fsSLO "https://raw.githubusercontent.com/ColorMath/ci/$REF/AGENTS.colormath.md"Thereafter make colormath-update REF=vX.Y.Z refreshes all three in one step.
then include the Makefile from yours, providing the one target it expects
from you (test) and setting knobs before the include if your project
differs:
# COLORMATH_DIFF_COVER_BASE = origin/master # example override
COLORMATH_PREFLIGHT_SKIP = templates # mirror your enable-*: false flags
include Makefile.colormath
test: ## your mirror of the tests gate
poetry run pytest tests/ && npm testNow every gate has a same-named make mirror (make jslint, make audit,
make dockerfile, …), and make preflight runs the full suite minus
COLORMATH_PREFLIGHT_SKIP — keep that list in lockstep with the gates your
caller disables. The audit and coverage-diff targets fetch their gate
scripts from this repo at the file's own stamped tag, so local runs and CI
share one implementation. On upgrades, make colormath-update REF=vX.Y.Z
refreshes all three vendored files — keep them in lockstep with your
gates.yml pin, and review the diff like any other dependency bump.
Coding agents need the same facts the gate table above gives a human: which gates exist, when to run preflight, how a branch ships, what never to touch. Written per-repo, those facts drift — four apps end up with four descriptions of one gate suite, and the stale ones are indistinguishable from the current ones.
AGENTS.colormath.md is that content, vendored like the
other two files and refreshed by the same make colormath-update. Your own
AGENTS.md keeps only what is true of your app — domain, commands, ports,
architecture, local conventions — and imports the shared half:
# YourApp
One paragraph on what this app is and what it's built from.
Shared colormath conventions (CI gates, shipping, guardrails): @AGENTS.colormath.md
## Commands
…The import line is written as a sentence on purpose: @path is Claude Code
syntax, and an agent that doesn't resolve it still reads a usable pointer.
Two things deliberately stay in your AGENTS.md, because the shared copy
cannot know them: which gates your caller disables (that lives in gates.yml
and COLORMATH_PREFLIGHT_SKIP, which are the authority), and where your design
tokens live. example/AGENTS.md shows the wiring.
One SemVer tag stream, and consumers pin exact tags only (a specific
@vX.Y.Z, never a floating major tag): an upgrade should arrive as a
reviewable PR whose diff and changelog explain themselves — not as a surprise
inside an unrelated one. Every tag has a
GitHub Release carrying that
version's changelog section, so
releases/latest is the
authoritative answer to "what should I pin to?"
The snippets above deliberately say vX.Y.Z rather than a real version. Pins
written into documentation rot silently — these had been sitting three
releases stale — so there is nothing here to keep up to date.
The rule for MAJOR: if a consumer's CI can go from green to red without the
consumer editing anything, it's MAJOR. New gates ship disabled-by-default in
a MINOR and are promoted to default-on in the next MAJOR. Details in
LIFECYCLE.md; release history in CHANGELOG.md.
While on 0.x, breaking changes may land in any release.
.github/workflows/gates.yml # the reusable gate suite (workflow_call)
.github/workflows/review.yml # optional reusable AI review + test-plan suite
.github/workflows/ci.yml # self-test: runs the suite against example/
.github/actions/ # setup-python-poetry, setup-node, gate-summary
.claude-plugin/ # plugin marketplace manifest
plugin/ # the colormath Claude Code plugin (skills)
Makefile.colormath # shared local gate targets — vendored by consumers
eslint.config.colormath.mjs # shared eslint base — vendored by consumers
AGENTS.colormath.md # shared agent conventions — vendored by consumers
scripts/ # gate scripts, fetched by the workflow at its own ref
release/ # release tooling — stamp, verify, cut (not shipped)
example/ # minimal compliant consumer + contract reference
docs/ # adoption notes for the maintainer's own products
Planned next, on the same tag stream and exact-pin rule: a Copier template for the in-repo files this stack shares (Dockerfile, Makefile, compose), and Terraform modules for the Cloud Run deployment shape.
colormath ships no runtime application code — infrastructure only. The 10-second rule for what belongs here: would a sibling project copy this file unmodified except for names, ports, and IDs? Then it belongs in colormath. Does it mention a specific domain, or need more than ~3 template variables to fit every project? Then it's a product decision wearing a costume; keep it in your repo.
This repo is public so consumers under any GitHub owner can use every channel without auth friction. Never commit real project IDs or secrets — the gitleaks gate runs on colormath itself, and example values must stay obviously fake.