Skip to main content

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:

  • main is the dev release branch - a push to it auto-deploys dev. So the bar to land on main is high (full CI + e2e-preview), and the m<N> -> main milestone 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.

StageWorkflowWhat it does
securityci-security.ymlDependency 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.
testci-test.ymlLint (ruff, eslint), format check (ruff format, prettier), type-check (mypy strict + tsc), the pre-commit hooks, and the unit/integration suites (pytest, vitest).
buildci-build.ymlBuilds 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.
changelogci-changelog.ymlOn 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:

WorkflowTriggerRole
deploy-dev.ymlCI 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.ymlCI 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.ymlCI success on mainDeploy the web bundle to Cloudflare Pages (dev project).
deploy-docs-dev.ymlCI 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.ymlworkflow_dispatchRedeploy the previous image (.deployed_tag_prev) - one-click manual rollback (compose path).
deploy-rollback-k3s-dev.ymlworkflow_dispatchhelm rollback the dev k3s release to the previous (or a chosen) revision, health-gated like the deploy.
deploy-production.ymlworkflow_dispatchDeploy/roll back production by tag (mode: deploy/rollback); inert until the production environment is configured.
deploy-web-production.ymlworkflow_dispatchDeploy the web bundle to the production Pages project.
deploy-docs-production.ymlworkflow_dispatchDeploy 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 (merging main auto-deploys dev). Mark e2e-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.