CI/CD pipeline (DevOps)
How Bloom is validated, built, and shipped through GitHub Actions. This is the pipeline overview; for deployment mechanics in depth (immutable images, deploy-by-tag, rollback, one-time setup) see deployment.md, and for the contributor-facing merge gate see the "Quality gates" section of AGENTS.md.
At a glance
PR to main ─┬─ CI: security ─▶ test ─▶ build (image ▶ GHCR) [must pass to merge]
└─ e2e-preview: full stack in the runner, isolated [pre-merge gate]
push to main ─ CI (same) ─▶ Deploy (dev): pull image by tag ─▶ e2e (dev): post-deploy
├─ pass ▶ promote "last green"
└─ fail ▶ auto-rollback
Two rules shape everything:
mainis the dev release branch - a push to it auto-deploys dev. So the bar to land onmainis high (full CI + e2e-preview), and them<N>->mainmilestone PR is human-gated.- Build-once, deploy-many - the API is built once as a SHA-tagged image; every deploy and rollback runs that exact image, never a rebuild.
Continuous integration
ci.yml orchestrates three reusable stage workflows in order; each
lives in its own file. It runs on every PR and on pushes to main.
| Stage | Workflow | What it does |
|---|---|---|
| security | ci-security.yml | Dependency vuln scan (OWASP: pip-audit on prod Python deps, pnpm audit --prod on prod web deps) + SAST (bandit, medium+). Prod-dep and medium+ findings block; dev/tooling vulns are reported non-blocking. |
| test | ci-test.yml | Lint (ruff, eslint), format check (ruff format, prettier), type-check (mypy strict + tsc), the pre-commit hooks, and the unit/integration suites (pytest, vitest). |
| build | ci-build.yml | Builds the API Docker image and, on a push to main, pushes it to GHCR tagged sha-<12> + latest (PRs build to validate, no push). Also builds the web bundle. |
| changelog | ci-changelog.yml | On PRs into main only: towncrier check fails a PR that adds no changelog.d/ fragment (#518). Assembling CHANGELOG.md from per-PR fragments kills the top-insert merge-conflict class; opt out of the gate with an empty +<slug>.misc.md fragment. See AGENTS.md → "Changelog entries". |
Build gating. build runs only when the work is bound for main - a push to main or a PR
whose base is main. Issue PRs into a milestone branch (m<N>) skip build and get fast
security + test feedback; nothing reaches main without a green build.
Docs site build. The docs site (apps/docs, Docusaurus) is validated by the docs-build job
in the test stage (ci-test.yml), gated on the docs path filter - it runs on every PR whose
diff can affect the site (apps/docs/, docs/ content, the TypeDoc-documented packages, shared
config) and on every push to main, where the filters are forced open. Main-bound work therefore
always carries a green docs build before the docs deploy fires; the build is deliberately not
duplicated in ci-build.yml - the deploy workflow rebuilds the site itself at publish time
(static output only, nothing to hand between stages).
Changelog gating. changelog runs only on pull_request events whose base is main - not on
the push/merge to main (which carries no fragment) and not on release PRs that touch only
CHANGELOG.md. It is orthogonal to the security -> test -> build chain and does not gate deploys.
End-to-end tests (two lanes)
The same Playwright suite (apps/web/e2e) runs in two places:
Pre-merge, ephemeral - e2e-preview.yml (E2E-6)
On every PR to main, stands up the whole stack inside the runner - the API (fake LLM
provider, in-memory store, non-prod test-login) and the Vite dev server proxying /api to it -
then runs the suite against it. It uses the PR's own API and web source, so API changes are
tested pre-merge. Fully isolated: no shared environment, no durable DB, torn down with the job.
Make it a required status check to block merges on regressions.
Post-deploy, dev + staging - e2e.yml / e2e-staging.yml (M24-9)
After a dev deploy, runs the suite against the deployed dev dashboard (E2E_BASE_URL; inert
until set); the staging twin is dispatch-only (E2E_STAGING_BASE_URL) until M25 ships a staging
deploy to hook. Both call the reusable
e2e-post-deploy.yml stage, which authenticates via
the environment's password-gated test-login secret. Non-blocking to the deploy that already
happened. It also closes the loop on bad deploys (E2E-7): a pass promotes the running image
to .last_green_tag; a failure after an API deploy auto-rolls the dev API back (compose:
the last-green image; k3s helm: the previous revision) - decision matrix in
scripts/deploy/e2e-promote-rollback.sh, unit-tested offline (see
deployment.md).
The pre-merge lane is the isolated safety net; the post-deploy lane guards the live dev
environment. See apps/web/e2e/README.md for the flake policy and how
to read a failure.
Deploy & rollback
Deploys are gated on CI success and run only from main (production is manual). Depth is in
deployment.md; in brief:
| Workflow | Trigger | Role |
|---|---|---|
deploy-dev.yml | CI success on main (+ dispatch) | Pull the SHA-tagged API image and run it on the exe.dev VM (scripts/deploy/exedev.sh, --no-build). Deprecated pending the M24-5 cutover - live until the edge flips to k3s. |
deploy-k3s-dev.yml | CI success on main (+ dispatch) | Deploy the same SHA to the dev k3s cluster via Helm (scripts/deploy/helm-dev.sh): sealed-secret sync, helm upgrade --install --set image.tag=sha-<12> --wait, then a /health endpoint gate through the ingress. Runs in parallel with the compose deploy until the cutover (runbook). |
deploy-web-dev.yml | CI success on main | Deploy the web bundle to Cloudflare Pages (dev project). |
deploy-docs-dev.yml | CI success on main (+ dispatch) | Build the docs site (apps/docs, Docusaurus) and deploy it to Cloudflare Pages (bloom-docs -> bloom-docs.pages.dev). Fails loud when Cloudflare is unconfigured - never a silent skip (M45-5). |
deploy-rollback-dev.yml | workflow_dispatch | Redeploy the previous image (.deployed_tag_prev) - one-click manual rollback (compose path). |
deploy-rollback-k3s-dev.yml | workflow_dispatch | helm rollback the dev k3s release to the previous (or a chosen) revision, health-gated like the deploy. |
deploy-production.yml | workflow_dispatch | Deploy/roll back production by tag (mode: deploy/rollback); inert until the production environment is configured. |
deploy-web-production.yml | workflow_dispatch | Deploy the web bundle to the production Pages project. |
deploy-docs-production.yml | workflow_dispatch | Deploy the docs site to the production Pages project (bloom-docs-prod); same fail-loud contract as the dev docs deploy. |
The VM keeps the current + previous (+ last-e2e-green) images so any rollback is local and never depends on the registry. App rollback is not data rollback - image rollback does not revert Supabase migrations; keep schema changes backward-compatible.
Configuration & secrets
GitHub is the source of truth: each deploy renders .env from Actions secrets + variables
(scripts/deploy/render-env.sh) - never hand-edit .env on a
server. Shared app config lives at the repository level; per-environment overrides live on the
dev / production GitHub Environments (they win), so prod uses a distinct bot/DB/domain.
Deploy-only infra (DEPLOY_*) is environment-scoped and excluded from the rendered .env. The VM
pulls the private image with the workflow's GITHUB_TOKEN (packages: read) - no long-lived
registry secret on the VM.
Merge gating summary
- Issue PR ->
m<N>: security + test (build skipped). The agent may self-merge after green CI. - PR ->
main(milestone PR or standalone): full security + test + build +e2e-preview. Human-gated - a person reviews and merges (mergingmainauto-deploys dev). Marke2e-preview(and, once stable, the deployed e2e) required status checks in branch protection. - Never merge red CI.
Running the checks locally
From apps/server (the test stage; same scripts exist in every workspace package):
pnpm run lint # eslint
pnpm run format:check # prettier
pnpm run typecheck # strict tsc
pnpm run test # vitest unit + integration tests
For apps/web: pnpm run lint, pnpm run format:check, pnpm run typecheck, pnpm run test.
Pre-commit runs ruff, ruff-format, mypy, pytest, and the commitizen check on every commit. To run
the e2e suite locally against an ephemeral stack, see
apps/web/e2e/README.md.