Two workflow files run on every PR base, including internal stacked branches,
and on every push to develop and production:
| Workflow | File | Branches | Jobs | Target time |
|---|---|---|---|---|
| Fast checks | .github/workflows/ci.yml |
all PR bases; pushes on develop, production |
format, integrity, lint, typecheck, build, test-unit → ci-gate |
< 4 min with remote cache |
| Integration + E2E | .github/workflows/ci-integration.yml |
all PR bases; pushes on develop, production |
migrate → smoke → test-e2e-smoke → test-e2e |
~5–25 min |
Current workflow semantics:
ci.ymlis the always-on correctness gate for all PRs, including automation-created stacked PRs.compilation-planselects affected apps only for dependency/build-configuration changes on PRs todevelop; ordinary source/docs changes skip compilation. Missing diff context, production PRs/pushes, other PR bases, and explicit manual CI request full compilation.developpushes retain correctness feedback but do not repeat full compilation or trigger Vercel deployments. Superseded PR runs are canceled; production push runs are not canceled.ci-gateacceptsbuild: skippedonly after a successful plan explicitly requests no build and all correctness prerequisites pass. A failed/canceled requested build or failed plan still blocks merging.instant-navcompiles donor only for production PRs/pushes or explicit manual integration QA. Required migration, smoke, and development smoke gates continue running.- Explicit
qa:smokepreviews compile on the standard GitHub runner, then usevercel deploy --prebuilt --target=preview; Vercel does not compile the source again. Preview serving and fixed subscription costs still apply. ci-integration.ymlruns on all PR bases. Pushes still run only ondevelopandproduction.Shadscan(.github/workflows/shadscan.yml) runs on all PR bases; pushes remaindeveloponly.test-e2e-smokeproducese2e-smoke-gate;integration-gatesummarizesmigrate,smoke, and that gate. See § Branch protection for the dated live required-context inventory.- Non-production PRs run the same placeholder Supabase E2E path as
develop(example.supabase.co, zero-config bypass). They do not inheritcontinue-on-error; full E2E must pass. Production PRs keep hosted secrets ande2e-gate. test-e2eremains informational ondevelopand is summarized bye2e-gateonproduction.- The canonical repository has no
mainbranch. Legacy deny-only configuration may still mention it.
The exact live required-check sets are recorded only in § Branch protection.
- Pinned version: root
package.jsonpackageManagerand.bun-version(currentlybun@1.4.0, stable only — never canary). - Runtime vs package manager: Bun is the install/script runner. Next.js apps still execute on Node.js (Vercel project
nodeVersionis24.x). Do not passbun --bun, and do not setbunVersioninapps/*/vercel.json. - Vercel Functions Bun 1.4 is a separate runtime: Vercel's Bun 1.4 changelog documents opting Functions and Middleware onto Bun via
"bunVersion": "1.4.x". That is not how you pin the package manager."1.x"still selects Bun 1.3.14 on Functions. Next.js on the Bun runtime also requiresbun run --bun next dev|build(runtime docs). Core stays on the Node path (next dev/next build/next start) because Payload, Stripe, Supabase SSR, and eve-runtime are validated there; Vercel treats the Bun Functions runtime as an explicit breaking-change opt-in. - Vercel install vs GitHub install: App
installCommandisbunx bun@1.4.0 install --cwd ../.. --frozen-lockfile(workspace root, pinned package manager, frozen lockfile), following Vercel's documented build pin. Preview deploymentdpl_8p7c7tAFVzvdLZY5t4FB8NuTxdCxproved that the build image's Bun 1.3.14 cannot install the current lockfile without drift. Pinning installation does not opt Functions into the Bun runtime. GitHub Actions keepsbun ci --no-cache --backend=copyfilefor the portable file-copy backend. Corepack does not pin Bun (it is for pnpm/Yarn). - GitHub Actions:
ci.yml,ci-integration.yml, andqa-smoke-preview-deploy.ymlsetenv.BUN_VERSIONto that exact version; every first-partyoven-sh/setup-bun@v2step usesbun-version: ${{ env.BUN_VERSION }}. - Workflow pin verification:
verify:bun-versionparses workflow YAML with Bun's built-in parser, so it also works before dependencies are installed. It checks each setup step's ownwith.bun-versionand the workflow/job/step environment in scope; comments, run-script text, unrelated inputs, and another job's environment cannot satisfy the pin contract. Quoted scalars and YAML aliases remain supported. - Live runtime verification:
verify:vercel-build-controlsreads all three Vercel projects and requiresnodeVersion: "24.x"withbunVersionabsent ornull. Any explicit Bun runtime value fails, even if source-controlledvercel.jsonfiles still select Node. This verifier only reads project settings. - Install in CI:
bun ci --no-cache --backend=copyfile(frozen lockfile install with Bun's portable file-copy backend). Do not usebun install --frozen-lockfilein workflows unless a future Bun release documents a regression. - Lockfile format:
bun.lockremains"lockfileVersion": 1with"configVersion": 1(isolated linker). Bun 1.4 writes lockfileVersion 2 for new lockfiles, but does not bump an existing v1 file on re-save (oven-sh/bun#31602). Do not regeneratebun.lockjust to pick up v2, and do not runbun install --save-text-lockfile— that rewrite can retarget nested resolutions without a manifest change. Installed Turborepo2.10.0parses bun lockfile versions 0 and 1 only.bun run verify:bun-lock-driftfails closed on anylockfileVersionother than0or1and tells operators to keep or restore that ceiling — not to runbun install, which on Bun 1.4 can rewrite a v1 lock to v2. If a future install rewritesbun.lockto lockfileVersion 2 or 3, copy the tree (do not rewrite the committed lock in place) and require both of these to pass on the installed turbo before accepting that lock:
bunx --no-install turbo prune @asym/donor --docker
(cd out/json && bun install --frozen-lockfile)verify:bun-lock-drift still rejects versions above 1 and does not replace this parser check.
- Lockfile drift: a frozen-lockfile install does not notice when a
package.jsondependency is missing frombun.lock'sworkspacesmap — commitea9a7673added a root dependency without the regenerated lockfile and CI stayed green, while every contributor's next plainbun installsilently rewrotebun.lock.bun run verify:bun-lock-driftcompares the two files directly and is the check that catches this; it is a pure file read, so it needs no install and no network. - Turbo cache keys in
ci.ymlincludebun-${{ env.BUN_VERSION }}so cache restores do not cross Bun upgrades. - Local parity: match the pin (
bun run verify:bun-version). That command also fails if a first-party.github/workflows/*.{yml,yaml}BUN_VERSIONoroven-sh/setup-bunpin disagrees withpackageManager. Reproducible install from a clean tree isbun ci. GitHub Actions usesbun ci --no-cache --backend=copyfileso Linux runners use Bun's portable install backend for vendoredfile:tarballs.
Use the local preflight command to mirror blocking GitHub checks before pushing:
bun run ci:preflightci:preflight runs the correctness stage order below. Its build stage is conditional under the same development policy as .github/workflows/ci.yml; app-specific build inputs select build:<app>. Use bun run ci:preflight -- --full for a complete QA/release checkpoint. The release command and any authorized production-targeting push always select full mode:
format:checkskills:verifyverify:phase25-specopenspec:validateverify:openspec-deltaslintverify:data-boundaryverify:cms-public-sole-entryverify:workspace-contractverify:bun-lock-driftverify:eslintverify:shadcn-configverify:shadcn-difftypecheck- Conditional
build/build:<app>(full in--fullor production mode; CI-compatible env defaults) test:unit
For edits to the adopted roadmap and Studio packets, also run
bun run verify:program-roadmap in the canonical WSL/Linux workspace before
publication. This read-only Python 3/Node check verifies source hashes, all45
phase entries, predecessor dependency floors, recipe/requirement/scenario
coverage, declared generated views, independent checkpoint graphs and local
links. It never executes the supplied reference scripts or claims runtime
qualification. It is a scoped documentation check in addition to the unchanged
preflight stage sequence.
Regression guards: tests/unit/scripts/ci-preflight.contract.test.ts (stage order),
tests/unit/scripts/local-gates.contract.test.ts (bun run check), and
tests/unit/apps/donor-missionary-unit-smoke.contract.test.ts (app unit smoke paths).
The .husky/pre-push coordinator preserves the production push guard and runs
normal CI preflight. Commit authors, committers, names, emails, and signatures
are not development gates. GitHub access authorizes people and approved
automation; see docs/ops/github-access.md for agent command authorization.
The team workflow from PR #1428
is merged into develop. The shared parser accepts canonical GitHub HTTPS and
SSH remote forms, removes transport userinfo, and rejects malformed repository
targets before they reach pre-push or attribution queries. See
Git attribution policy for the current proof boundaries.
Direct pushes to production are blocked by .husky/pre-push unless they come from
the production release command:
bun run release:productionThe release command checks deployment discipline, full local CI preflight, and that
HEAD is already reachable from fetched develop, then summarizes deployment
impact before pushing to origin/production.
Emergency bypasses require an explicit reason:
ASYM_PRODUCTION_PUSH_BYPASS_REASON="restore previous production deploy" git push origin HEAD:productionRun this verifier after deployment-control changes:
bun run verify:deployment-discipline
bun run verify:vercel-build-controlsRun these focused checks when a change touches Sentry release wiring, release-health monitoring, Vercel deployment controls, or backup/restore proof:
bun run verify:sentry-release
bun run verify:vercel-build-controls
bun run verify:vercel-env-inventory
bun run verify:backup-restoreverify:sentry-release proves all three Next.js configs use the shared Sentry
build options, source map upload remains disabled without SENTRY_AUTH_TOKEN,
release/source map upload turns on when the build-only token is present, and
Turbo hashes the Sentry build inputs.
verify:vercel-env-inventory prints Vercel variable names and value types by
environment for admin, donor, and missionary. It does not print values.
verify:backup-restore runs pg_dump and pg_restore between disposable
Postgres containers and reports restored row counts and marker ranges. It must
not be pointed at production data.
Note (2026-07-06): this cutover will never occur — Twenty CRM has been retired (ADR-0001); the section is retained for history until the cleanup ticket removes it.
Twenty CRM is retired. Historical cutover evidence remains in
docs/guides/operations/twenty-crm-cutover.md and the archived OpenSpec change
openspec/changes/archive/2026-07-02-integrate-twenty-crm-core/. Do not re-run
that change's validation as a live production gate.
Current OpenSpec validation uses the locally pinned CLI:
bun run openspec:validateRun this maintenance check to detect known test-runner deprecation warnings (for example, Vite CJS Node API deprecations) before they become CI noise:
bun run test:unit:warningsThis check runs unit tests and fails if blocked warning patterns are present in test output.
- What it checks: Runs
bun run format:check(Prettier). - Why it exists: Reports formatting problems as formatting problems.
- Debug locally: Run
bun run format:check; if needed runbun run format.
- What it checks: Runs
bun run skills:verify(skill mirrors),bun run verify:phase25-spec,bun run openspec:validate, andbun run verify:openspec-deltas. - Why it exists: Prevents mirror and specification drift under its own check
name.
ci-gaterequires it alongside format, lint, typecheck, build, and unit tests. - Debug locally: Run the failing integrity command directly. Intentional
skill changes use
bun run skills:syncbeforebun run skills:verify.
- What it checks: Runs
bun run lint(Turborepo → ESLint flat config across all workspaces), thenbun run verify:data-boundary(architecture/data-access boundary contract over live source; gitignored Eve.eve,.nitro, and.outputgenerated trees are excluded), thenbun run verify:cms-public-sole-entry(public CMS reads confined to the published-content reader choke-point — no raw Payload reads oroverrideAccess: truein public code paths), thenbun run verify:workspace-contract(workspace dependency contract), thenbun run verify:bun-lock-drift(every workspacepackage.jsondependency key and range is recorded in the matchingbun.lockworkspacesblock), thenbun run verify:eslint(ESLint config contract — no legacy.eslintrc.*, all packages haveeslint.config.mjs, disable comments have tracking references), thenbun run verify:shadcn-config(shared shadcn config guardrails) andbun run verify:shadcn-diff(component drift guard). - Why it exists: Enforces consistent code quality and prevents architecture, workspace, and ESLint config drift.
- Debug locally: Run each command individually:
bun run lint,bun run verify:data-boundary,bun run verify:cms-public-sole-entry,bun run verify:workspace-contract,bun run verify:bun-lock-drift,bun run verify:eslint,bun run verify:shadcn-config, andbun run verify:shadcn-diff.
- What it checks: Runs
bun run typecheck(Turborepo →tsc --noEmitacross all apps and packages). - Why it exists: Catches type errors that TypeScript strict mode would surface at compile time but not at runtime.
- Debug locally: Run
bun run typecheck. Per-app:bun run typecheck:donor,bun run typecheck:admin,bun run typecheck:missionary.
- What it checks: Compiles apps selected by
scripts/verify/ci-build-policy.mjs. Routine develop PR source/docs changes and develop pushes select no build; app-specific build configuration selects that app; shared dependency/build changes, missing diff context, production, and manual CI select all apps. Explicit full local preflight runsbun run build(Turborepo →next buildfor all apps). The script applies CI-equivalent env defaults (SKIP_ENV_VALIDATION=1, stub Supabase keys, and a stubPAYLOAD_SECRET) when missing. - Why it exists: Catches bundle errors, missing imports, and Next.js build-time failures that type-checking alone cannot catch.
- Debug locally: Run
bun run buildfor CI-equivalent behavior, orbun run build:strictto validate with real local env values only.
- What it checks: Runs
bun run test:unit(Vitest with coverage enabled, targetstests/unit/**/*.test.ts(x),environment: "node"). - Artifacts: Uploads generated
coverage/asunit-test-coverage(if-no-files-found: ignore, retained for 7 days). Current development output includescoverage-summary.json,coverage-final.json,v8-raw-coverage.json, andcoverage-warnings.log. - Why it exists: Validates pure logic, utilities, and shared package behaviour without a browser or network.
- Debug locally: Run
bun run test:unitto execute unit tests and generate coverage output incoverage/. For watch mode:bunx vitest.
Optional focused CMS unit coverage (not a ci.yml job today): bun run test:unit:cms.
Run this when you want a structured, actionable unit-test triage report:
bun run test:unit:feedbackThe command runs bun run test:unit, writes ignored artifacts to test-results/unit-feedback/latest.md and test-results/unit-feedback/latest.json, and exits with the underlying unit-test status. On failure, it reruns each failing test file with bunx vitest run <test-file> and classifies failures into remediation categories: import path, server/client boundary, fallback routing, rich-text image policy, or unrelated.
To post only failing reports to a tracking issue:
UNIT_FEEDBACK_GITHUB_ISSUE=203 bun run test:unit:feedbackCurrent coverage caveat: the repo's custom raw V8 fallback provider writes coverage artifacts, but coverage-summary.json is not a line/statement/branch quality signal while it reports totalScripts: 0.
- What it does: Spins up a fresh
postgres:15-alpinecontainer, runsnode scripts/verify/supabase-migrations.mjsto bootstrap the minimal Supabaseauth/storagecompatibility schemas and apply timestamped forward migrations fromsupabase/migrations/, then runs Payload migrations viabun run cms:migrateand verifies status withbun run cms:migrate:status, then appliessupabase/seed.sql. Verifies thatpublic.profileshas exactly 1 row after seeding. - Why it matters: Catches migration ordering conflicts across both SQL + Payload migration systems, plus FK/seed incompatibilities, before they reach a hosted Supabase project.
- Debug locally: Run
bun run db:migrate:local(applies migrations without seed) orbun run seed:demo:local(migrate + seed via helper script).
- What it does: Starts
apps/donoron port 3005 withSKIP_ENV_VALIDATION=1and stub Supabase values, pollshttp://127.0.0.1:3005/api/healthfor up to 60 seconds, then asserts the response contains"status":"ok". - Why it matters: Verifies the app boots without a crash — catches missing imports, broken middleware, and startup-time errors that build alone cannot catch.
- Debug locally: Run
bun run test:e2e(default CI-equivalent env) orbun run dev:donorwith real.env.localvalues, thencurl http://localhost:3005/api/health. Expect{"status":"ok","checks":{"supabase":"ok"},"observability":{"surface":"donor",...}};observability.releasecarries the commit/ref/environment metadata when the deployment provides it.
- What it does: Re-applies SQL migrations against a fresh Postgres container through
node scripts/verify/supabase-migrations.mjs, runs Payload migrations + status checks, then applies seed data. Playwright Chromium is installed before either dev server starts, sobunxdoes not mutate the module graph while Turbopack is compiling. The job then startsapps/donoron port 3005 withE2E_AUTH_BYPASS=true, waits until/api/healthand/api/auth/demo-accountboth succeed, startsapps/adminon port 3030, waits for admin/api/health, and runs the bounded Playwright smoke suite viabun run test:e2e:smoke(demo auth preflight, usability smoke, donate, upload-crop under the donor-auth project, and Support Hub smoke). The job has a 25-minute cap, the Playwright smoke step has a 15-minute cap, and failures uploadplaywright-smoke-report/plus the dev-server logs. - Branch behavior: Produces
e2e-smoke-gate;integration-gatealso summarizes this result. See § Branch protection for which contexts GitHub currently requires. - Debug locally: Run
bun run test:e2e:smokeafterbun run test:e2e:auth-preflightwith donor on port 3005. - Coverage note: This bounded smoke gate is not the a11y, hydration, perf, or full auth signal. Run
bun run test:a11y,bun run test:perf, or the broaderbun run test:e2ewhen a change affects those contracts. - Regression guards (unit):
tests/unit/scripts/ci-integration-workflow.contract.test.tslocksintegration-gate/e2e-smoke-gate/e2e-gatewiring;tests/unit/e2e/e2e-flake-guards.test.tsforbidswaitForTimeoutintests/e2e/**/*.spec.ts;tests/unit/scripts/ci-preflight.contract.test.tslocksci:preflightstage order;tests/unit/scripts/local-gates.contract.test.tslocksbun run check;tests/unit/apps/donor-missionary-unit-smoke.contract.test.tskeeps donor/missionary unit smoke coverage and API email mock posture.
- What it does: Re-applies SQL migrations against a fresh Postgres container through
node scripts/verify/supabase-migrations.mjs, runs Payload migrations + status checks, then applies seed data, startsapps/donoron port 3005 andapps/adminon port 3030, enables deterministic test auth mode (E2E_AUTH_BYPASS=true) for Playwright web servers, and setsPLAYWRIGHT_REUSE_EXISTING_SERVER=1so Playwright reuses the already-started servers instead of trying to bind those ports again. It executes demo-auth preflight (bun run test:e2e:auth-preflight), then runs bounded production-release suites:bun run test:e2e:production-gate(donor usability, donation, About/Wallet layout and local interactions, admin Support Hub smoke and Teams controls, shared dialog-dismissal/popover-positioning/primitive-contrast and table-control accessibility coverage, and missionary summary/dashboard/chart/loading geometry)bun run test:e2e:boneyard:admin,bun run test:e2e:boneyard:missionary, andbun run test:e2e:boneyard:donor(visual regression smoke by app)bun run test:e2e:cms --project=chromium(portable CMS/admin suite tagged@cms, excluding@manualand local-seed-only@cms-local; CI reuses the same donor/admin servers) The job has a 30-minute cap, and individual Playwright suite steps have 5-10 minute caps. Uploadsplaywright-report/as an artifact on failure (retained 7 days).
- Branch behavior: On
develop, this job is informational (continue-on-error: true). Onproduction,e2e-gaterequires both this job and the deterministicinstant-navjob (--retries=0) to succeed. - Donor-only default projects: When a local or CI caller sets
PLAYWRIGHT_INCLUDE_ADMIN=0,playwright.config.tsomits the admin web server and the defaultchromium/mobile-chromeprojects ignore specs that require admin, missionary, CMS, Support Hub, or boneyard servers. Dedicated admin, missionary, CMS, and boneyard scripts keep their own configs and should be run separately when those surfaces are in scope. - Debug locally: Run
bun run test:e2e:auth-preflightfirst, thenbun run test:e2e:production-gatefor the required production gate,bun run test:e2efor the broader local suite,bun run test:e2e:cmsfor portable CMS/admin coverage,bun run test:e2e:cms:localfor the local seed-dependent CMS proof,bun run test:e2e:strict(core strict env),bun run test:e2e:cms:strict(CMS strict env),bun run test:perf(perf-only suites), orbun run test:e2e --project=chromium(Chromium only). Usebun run test:e2e:uifor interactive debugging.
Workflow YAML is the source of truth for the jobs Core emits. GitHub's branch-protection API is the source of truth for which contexts are currently required. Those two sets must not be conflated.
Verified through the GitHub branch-protection API on 2026-09-23:
developuses strict status checks and requiresci-gate,e2e-smoke-gate,migrate, andsmoke.productionuses strict status checks and requiresci-gate,e2e-gate,e2e-smoke-gate,migrate,release-source-gate, andsmoke.- Both branches enforce administrators and disable force pushes and deletion.
developrequires zero approving reviews and resolved conversations;productionrequires resolved conversations and uses the release path rather than a PR-review requirement. integration-gateremains a workflow summary job but is not currently a required branch-protection context.release-source-gateis defined inrelease-source.ymlfor production PRs. It runs from the trusted default branch with a read-only token and verifies that the PR head is already reachable fromdevelopthrough GitHub's compare API, without checking out PR code.- The canonical repository has no
mainbranch. Legacymain: falsedeployment configuration is a deny-only compatibility rule, not evidence of a live protected branch.
Commit metadata is not a CI or branch authorization gate. GitHub access and native branch protection control repository writes and merges.
- Remote cache (preferred): All
ci.ymljobs setTURBO_TOKEN(secret) andTURBO_TEAM(variable). When both are present, Turborepo uses Vercel's remote cache — unchanged tasks are skipped entirely. To verify: look for"Remote cache hit"in the CI job logs. - Local fallback: Each
ci.ymljob also caches.turbo/viaactions/cache@v4, keyed onturbo-${{ runner.os }}-${{ github.sha }}with a restore prefix ofturbo-${{ runner.os }}-. Remote cache hits still skip work whenTURBO_TOKENandTURBO_TEAMare configured. - See also:
file:.github/SECRETS.mdfor how to configureTURBO_TOKENandTURBO_TEAM.
The Vitest workspace pinning plugin resolves exported @asym/* imports through
Vite using the package in the current checkout as the self-reference anchor.
Declared export conditions, wildcard mappings and denied subpaths remain owned
by Vite/package exports. A missing declared target fails instead of falling
through to private source or another checkout. Filesystem fallback applies only
to workspace packages with no exports field. The focused resolver tests compare
actual Vite resolution with Node 24 for public, private and conditional paths.
The data-boundary scanner excludes generated .output and .nitro paths only
inside packages/eve-runtime; directories with the same names elsewhere remain
subject to the retired Twenty runtime guard.
Automatic Git deployment is enabled only for production in all three app configs. Development hosts remain on their last successful deployment until an explicit checkpoint refreshes them. This is deliberate during active development; compile-time integration errors may be discovered at the next QA checkpoint. Dependency and build-configuration PRs still request compilation.
The existing same-repository, non-draft, qa:smoke gate is retained. The preview helper pins Vercel CLI, checks the selected Core app/team, pulls preview settings from the monorepo root, builds locally, and uploads prebuilt output. It removes local .vercel state before each app and in a finalizer, and suppresses raw CLI output that could contain environment values. Build output and downloaded environment files must never become diagnostic artifacts.
Production branch protections and source ancestry checks are unchanged. release:production always uses ci:preflight -- --full, including when its source is develop.