api-events-deploy.yml. The Go api/ tree and api-deploy.yml workflow are removed from this repository.
All workflows
Consolidation rules
Keep the check list short and the runner bill honest:- A check that is one script against an already-installed tree belongs as a step in the job that owns that install (
checks,schema-gates,openapi-parity), not as a new workflow or job. - Repo-wide one-second linters go in
code-quality.ymlstatic-suite. That job runs besidegatesinstead of after it; the thinstatic-checksjob holds the requiredStatic Checksname and fails if either one fails. - New jobs are for genuinely different infrastructure (services, Docker builds, browsers) or for wall-time parallelism on the critical path.
- Size the runner by measured duration. Path filters, echo/compat jobs, coverage comments, lint-only, node script gates and the docs link check use
blacksmith-2vcpu-ubuntu-2404. Typecheck and lint jobs, node gates that need an install, and validation-only builds useblacksmith-4vcpu-ubuntu-2404. Keepblacksmith-8vcpu-ubuntu-2404for long vitest coverage suites, integration, e2e,next build, Docker service stacks, browsers and Rust. GitHub/Blacksmith bill per started job, rounded up to a minute; 8vCPU costs 4× 2vCPU-minutes and 2× 4vCPU-minutes, so a job under two minutes on 8vCPU is usually cheaper one size down. Every 8vCPU job is listed in.github/runner-allowlist.json, which Lint Workflows checks withscripts/check-runner-sizes.mjs. 2vCPU jobs writeruns-on: ${{ vars.RUNNER_LIGHT || 'blacksmith-2vcpu-ubuntu-2404' }}.runner-routing.ymlruns daily at 00:17 UTC (and on demand) and setsRUNNER_LIGHTtoubuntu-24.04while under 98% of the org’s 50,000 included GitHub minutes are used, then back to the Blacksmith label (routes in.github/runner-routes.json, scriptscripts/runner-routing/route.mjs). An unset variable means Blacksmith, and a failed usage read sets it back to Blacksmith. A missing or expiredRUNNER_ROUTING_TOKENfails the daily run and leaves the last label in place; delete the variable to force Blacksmith. The same checker rejects a plain label that has a route, unless the job is listed underpinnedin.github/runner-allowlist.json. Gates,static-suite,pr-buildandschema-gatesare pinned to Blacksmith for the whole month: measured on 2026-11-03 they ran 1.2 to 1.7 times as fast there, and the shorter runs at Blacksmith’s lower per-minute rate cost about the same as using included GitHub minutes first. - Conditional workflows keep
paths:on push only. On pull_request,api-events-deployalways runs and itschangesjob (dorny/paths-filter, API call, no checkout) decides relevance; every other job isif:-gated on it. The small components (checkai-deploy,mcp-deploy,gcp-platform-signals,gcp-terraform-validate) have nopull_requesttrigger:components-ci.ymlfilters paths for all of them in onecomponents / changesjob and calls each one (workflow_call) only when the PR touches it. A PR that touches none of them starts one light job instead of five. Their PR checks are named<caller> / <job>(for examplecheckai / checks), so a job that runs on both events names itself${{ github.event_name == 'pull_request' && 'checks' || 'checkai / checks' }}: short when called, prefixed on push, so names stay unique across workflows. Do not put several cheap scans in separate jobs: each job is billed as at least a minute, sosecurity.ymlruns gitleaks, the npm audit and Semgrep (throughdocker run) in onesecurityjob. Dorny filters that use!exclusions must setpredicate-quantifier: some-with-excludes— defaultsometreats each!glob as its own matcher and marks every non-excluded file relevant. Give jobs unique display names (<component> / <job>) because check runs are matched by name across all workflows.
Scheduled failure alerts
GitHub tells nobody when a scheduled run fails, andRunner Routing failed daily for 26 days before anyone saw it. scheduled-failure-alert.yml keeps one open issue per failing scheduled workflow, with the label scheduled-failure. A later failure updates the issue in place, and the first scheduled run to pass comments and closes it. It uses the workflow token, so it needs no secret. To add a scheduled workflow, add its name to the workflows list in that file; Lint Workflows fails until you do.
Promotion PR references
The existing Enforce Branching Rules check also rejects GitHub closing-keyword references to promotion PRs. A target is a promotion only when GitHub confirms that its head and base belong to the same repository and the branch pair isdev → qa or qa → main. Titles and labels do not determine this classification; ordinary issue-closing references remain allowed.
Use neutral wording for promotion review work: Follow-up for #123 or Addresses review feedback on #123. Closing keywords (close/closes/closed, fix/fixes/fixed, and resolve/resolves/resolved, case-insensitive and with an optional colon) belong with issues that should actually close.
The check inspects the current PR title, description, and full messages of commits newly introduced by the PR. It reads complete Git history, avoiding the pull-request commits API’s 250-commit limit. Commits already reachable from the repository’s default branch are excluded: replaying historical messages during dev → qa → main must not permanently block promotions. New feature-branch commit messages are still checked. The range starts at the original event base SHA, while the default-branch exclusion uses the fetched tip, so unrelated base-branch advances do not invalidate reruns. Metadata edits, new commits, reopen, and ready-for-review events rerun the check. A run checks the live title and description rather than the event payload, and checks again when they change during the run (up to three times). When the head commit or base branch moved, the run passes with a “Skipped” line and leaves the check to the run for that change. A source or target repository other than this one still fails.
Supported references are #123, soundchecklive/repository#123, and https://github.com/soundchecklive/repository/issues/123 or /pull/123, with an optional trailing slash on URL references. Repeat the closing keyword for each reference. Lookups stay within this GitHub organization. References to other organizations are outside this guard’s scope and are never fetched. Inaccessible or unverifiable same-organization targets fail closed; the job token may not be able to read another private repository. Check access or use neutral wording when no closure is intended.
The workflow uses only read permissions and does not use pull_request_target, repository secrets, or write credentials. Tests run with node --test scripts/promotion-reference-guard/*.test.mjs and need no npm dependencies. The rule lives in scripts/promotion-reference-guard/check.mjs.
To block merging, Enforce Branching Rules must remain a required check in the applicable branch rules. This change does not alter branch protection or bypass permissions. This human-error guard is not a security boundary: a PR can change its pull_request workflow or the checked-out guard script and intentionally bypass the check. Use independent review/protection for those files if resistance to deliberate bypass is required. The check also cannot inspect a merge message edited in GitHub’s final merge dialog or direct pushes that bypass PRs; keep merge-time titles/messages neutral too. Existing merged commits are not rewritten.
Promotion PR gate reuse
A dev to qa or qa to main PR re-runs on every push to its head branch, and the same push already ran the full gates on the same tree. In the three days before this change, those re-runs were 17% of modeled CI spend, about $20 a day.api-events-deploy.yml, web-ci-cd.yml and three of the components in components-ci.yml (mcp-ts, openapi-operation-ids and Terraform) skip the duplicate gates when a push run already passed them. checkai and gcp-platform-signals always run on promotions, because their PRs run gates a push never does: eve build, and the Docker build, smoke test and Trivy scan. code-quality.yml and security.yml only run on PRs, so there is no push run to reuse; they run on every promotion push.
In api-events-deploy.yml the api-events / changes job decides. In components-ci.yml the components / changes job runs one decision per relevant reusable component in parallel and skips that component when it reuses. In web-ci-cd.yml a promotion-reuse job decides, and checks, unit-tests and unit-tests-merge depend on it. Both run scripts/promotion-reuse/decide.mjs, which reuses a push run only when all of these hold:
- The PR comes from a branch in this repository and is dev to qa or qa to main. The workflow jobs also check the base branch before they poll, so other PRs from
devorqapay nothing. - The base brought no change to a path the workflow watches since the merge base, so the merge result has the same watched tree as the head.
- A push run on the head branch is at or before the PR head, and nothing the workflow watches changed between that run’s commit and the PR head. The watched paths are the
on.push.pathslist of the workflow, plusEXTRA_WATCHEDfor inputs the gates read that the filter omits. Today that isscripts/api-resources/,checkai/andmcp-ts/for web, which its client generators and SDK build consume. - Every gate listed for the workflow in
WORKFLOWSfinished withsuccessin that run. Forapi-events-deploy.ymlthey arechecks,integration-tests (suite),encore-e2e (suite)andschema-gates. Forweb-ci-cd.ymlthey arechecks, bothunit-testsshards andunit-tests-merge. For the components they aremcp-ts / checksandTerraform CI. A deploy or an e2e job that is still running, or that failed, does not matter, because promotion PRs never ran it.
qa changed watched paths, or when the script errors.
Reused gates are skipped. In api-events-deploy.yml, integration-tests and encore-e2e report through the required api-events / suites (scripts/required-job-result.mjs), which accepts a skip only when changes reports reuse=true. components / result applies the same rule to each component. openapi-parity and pr-build still run. openapi-parity compares the spec with the PR’s base, so on a promotion PR it checks the breaking changes against what qa or main already serves, which the push run never saw. pr-build builds with the target environment’s config and only runs on PRs. The web jobs are not required checks, so a skip needs no result job there, but unit-tests-merge carries the same condition because it would otherwise run without the unit-test artifacts. The script is checked out from the base commit, so a PR cannot edit its own check, and the first promotion after this lands runs every gate because the base does not have the script yet.
Run node scripts/promotion-reuse/decide.mjs with the PR_* variables from the workflow to see a decision locally. Tests: node --test scripts/promotion-reuse/decide.test.mjs.
Ready PR gate reuse
Drafts skip integration and e2e, so marking a PR ready has to start the workflows again. That run used to repeat jobs that had already passed on the same commit, usually a few minutes earlier, at about $32 a week.web-ci-cd.yml and api-events-deploy.yml now skip those repeats when the inputs are identical. scripts/promotion-reuse/ready.mjs makes the decision in the promotion-reuse job on web and the api-events / changes job on api-events, and it reuses an earlier run only when all of these hold:
- The event is the first attempt of a
ready_for_reviewrun, on a PR from a branch in this repository intodev. Promotion PRs keep the rules above. “Re-run all jobs” always runs the gates. - An earlier run on the same head commit passed every gate in
READY_GATES. For web they arechecks, bothunit-testsshards andunit-tests-merge. For api-events they arechecks,openapi-parity,pr-buildandschema-gates. - That run targeted the same branch, and its merge commit has the same two parents as this run’s: the same
devcommit and the same head commit. Any move ofdevruns the gates, because gates read files outside each workflow’s path filter, such asintegrations/cleo-pay/. Eachchecksjob records its merge and target in a step namedTested merge <sha> into <branch>, because the Actions API leaves the PR details empty on most PR runs and never reports the merge.
changes use continue-on-error, so their failure cannot block the gates that depend on that job. A PR moved back to draft and marked ready again can reuse the same earlier run; push a commit or use “Re-run all jobs” to force the gates.
Integration and e2e still run, because a draft never ran them. On api-events they read the reuse output, which only the promotion check sets, and the reused gates read ready_reuse.
Tests: node --test scripts/promotion-reuse/ready.test.mjs.
Semantic convention lint
code-quality.yml Conventions (advisory) and the husky pre-commit run .github/semantic-lint.py. The script sends the added diff hunks to the third-party classifier at https://classifier.dev/v1/classify, one call per convention (logging PII, swallowed errors, money as float, bare Date). Findings at confidence 0.9 or higher become one sticky PR issue comment (<!-- semantic-lint -->, posted by conventions-comment). The cited line is the first added line of the hunk. Lower-confidence findings go to the job summary only. The check is advisory: it is not required, and an unreachable classifier exits 0. Conventions live in the script’s CONVENTIONS list. Review loops (/x, /release-loop) must read that comment; it is not a review thread, so a green check and an empty thread list do not mean it is clear. To skip it locally, set SOUNDCHECK_SKIP_SEMANTIC_LINT=1.
Affected checks and Jev change triage
scripts/affected/ chooses which checks a change needs. graph.mjs maps paths to packages and their downstream consumers: the OpenAPI spec feeds the web, mcp-ts, and mobile clients; mcp-ts feeds api-events and CheckAI; the web generated client, web/src/lib/crew, and web/src/lib/api-core feed mobile. profiles.mjs turns that into a plan of <package>:<check> ids. The drift test in scripts/affected/affected.test.mjs fails when a workflow changes filter lists a path the map does not cover.
In CI the planner always runs from a trusted checkout of the base commit (.trusted-base) and reads the PR only as git data (--repo). The key goes only to jobs that run no PR code: Affected checks, e2e-plan (web) and api-events / encore-e2e-select. Their npm upgrade reads the base commit’s .npmrc, not the PR’s. PR code therefore never sees AI_GATEWAY_API_KEY and cannot change its own plan. Changes to the planner, scripts/lib/, .github/actions/, or code-quality.yml always run every check.
When JEV_CHANGE_TRIAGE is shadow or on, the planner sends the PR title and description (or commit messages), the file list, and the redacted diff with three lines of context to Jev (checkai/scripts/jev/change-triage.ts). The change is split by package and each package gets its own call, run in parallel: up to six calls (smaller packages are folded into the sixth) with up to 20,000 diff characters each, inside a 24,000-character state. The answers merge strictly: the most cautious change type (at its lowest confidence), the highest risk, and the highest full-suite and e2e probabilities. If any call fails, the whole answer counts as missing. Jev answers a change type (docs, tests-only, refactor, bugfix, feature, schema-or-migration, api-contract, …), a risk level, and whether the full suite or e2e should run. In on mode the change type picks the checks from a fixed profile table:
- Jev can add checks at any confidence, for example the full unit suite for a refactor or integration and e2e runs for a schema change.
- Jev can remove checks only when the change type has probability ≥ 0.8 and the planner’s own facts agree (a comment-only diff, test-only files), or when a trivial-risk fix (≥ 0.9) lets it drop downstream packages.
- Floor checks always stay: Prisma and migrations, SQL, lockfiles and
package.json, auth, money, env and config, and deleted files. Any.github/change disables trimming. - A timeout, missing key, or unreadable answer falls back to the map plan.
scripts/affected/areas.mjs: sign-in, gigs, staffing, money, clients, messaging, imports, org settings, and catalog. Each area lists its web Playwright specs and its e2e/tests/ folders, and a test fails when a spec or folder belongs to no area. With JEV_CHANGE_TRIAGE=on, the api-events / encore-e2e (suite) job behind the required api-events / suites shrinks rather than skipping. On PRs it runs the folders for every area at probability 0.3 or higher, plus sign-in and any changed test folders, with route-coverage enforcement off. It runs the full suite when:
- Jev gave no area answers, or its change type is below 0.6 confidence
- the PR touches the schema, SQL, a lockfile, the e2e harness,
tools/, or a workflow - the planner step fails
dev always run the full suite. In shadow, the Affected checks summary shows the selection without applying it.
Web e2e uses the same areas. With JEV_CHANGE_TRIAGE=on, the e2e-tests job in web-ci-cd.yml passes --area-specs to run-local-e2e.mjs, which keeps only the critical-tier rows tagged in the selected areas’ spec files (sign-in always included). Changed specs still run in full. An empty or failed selection keeps the whole critical tier. node web/scripts/e2e-critical-tier.mjs --list --area-specs=a.spec.ts,b.spec.ts shows the rows a selection keeps.
A workflow change forces the full e2e run only when its changed lines fall in an e2e job (e2e-tests, encore-e2e, changes) or outside jobs: in web-ci-cd.yml or api-events-deploy.yml, or when an e2e setup action changes. Deploy-only edits no longer do.
The e2e-plan job in web-ci-cd.yml lists the PR’s changed spec files and sets the e2e shard count. A PR that changes no spec runs one shard, and PRs that change specs and pushes to dev run two. Each shard pays about 80 seconds of setup (dependencies, containers, migrations, Encore boot) for a critical tier of about 13 tests, and 76% of web-e2e PRs change no spec, so this removes about 15% of PR web e2e job-minutes. The cost is about a minute of extra wall-clock time on those PRs.
The web e2e job also carries the Turbopack next dev compile cache (web/.next/dev/cache) between runs. Measured locally, a warm cache makes the dev server ready in 2.7 seconds instead of 9.1, and first page loads take 2.1 seconds instead of 11.8 across ten pages. The cache is large (1 GB cold) and grows with every run, so pushes to dev start clean and save a fresh cache per shard, and PRs only restore it. Turbopack writes the cache after a few idle seconds, so dev pushes set E2E_TURBOPACK_FLUSH_MS and global teardown pauses before Playwright stops the server. The job summary reports which cache was restored and its size.
Jev also answers nine signal questions, merged across package calls in the strict direction. Each drives one rule (thresholds in SIGNAL_RULES, scripts/affected/profiles.mjs):
To measure whether Jev can safely run fewer checks,
node scripts/affected/replay.mjs --prs 200 replays merged PRs. For each PR it fetches the CI jobs that ran (a job counts as a real failure only when every attempt on a commit failed), reads the failed e2e logs for the failing folders and specs, and runs live Jev triage on the PR diff. It reports failures in checks Jev’s plan would skip, e2e failures outside the area selection, job-minutes saved by candidate skip policies, and failure rates by Jev’s answers. Results are cached per PR in .git/affected-replay.jsonl; --report rebuilds the report from the cache.
To measure the automated review loop, node scripts/review-loops/review-loops.mjs collects every merged PR into dev, qa, and main. For each PR it records Greptile and Codex review rounds, their inline findings (severity, file, round), manual @greptileai triggers, and fix commits after the first review. It links promotion PRs to the fix (“sweep”) PRs that reference them, and asks Jev to classify each finding: its category, whether a local check could have caught it, whether it depends on other files, and whether it is valid. The report covers rounds per PR over time and by size, package, and author; findings raised only in later rounds; category and package hotspots; and the promotion loop. Caches live in .git/review-loops.jsonl and .git/review-findings.jsonl; --report re-analyses them.
To stress-test the planner with live Jev calls, run node scripts/affected/stress.mjs. It runs synthetic scenarios with expected change types, failure injection (timeouts, a bad key, a 40,000-line diff, eight packages), a replay of recent merged PRs on origin/dev, and repeated runs of the same change to check stability. It exits non-zero only on a safety violation: a floor check missing, or a map check dropped without a trim decision.
Locally, the planner reads AI_GATEWAY_API_KEY and JEV_CHANGE_TRIAGE from the shell, or failing that from web/.env (the worktree’s, then the main checkout’s). CI reads only the repository variable and secret. shadow records what Jev would have run without applying it. Local decisions append to .git/jev-triage.log; CI writes them to the Affected checks job summary and the affected-plan artifact.
Where it runs:
- Pre-commit stays fast: lint and format on staged files, related tests (no coverage), the comment gate, and scanners. No Jev call.
- Pre-push (
.husky/pre-push) plans the pushed range and runs the type checks, lint, and Knip for affected packages.JEV_CHANGE_TRIAGEdefaults toshadowthere. When the plan asks for a full unit suite (from the lockfile, auth, or money floors, or from Jev), pre-push leaves that suite to CI, which runs each affected package’s full suite on the PR.SOUNDCHECK_PREPUSH_FULL=1ignores Jev and runs those suites locally. E2E, integration, and OpenAPI parity are left to CI. See Git hooks for timings and workarounds. - Coverage is not measured in CI. Unit suites run without coverage and no hook or check reads a coverage percentage. Run
npm run test:coveragein a package for a local report. - CI (
code-quality.yml): theAffected checksjob plans the PR (repository variableJEV_CHANGE_TRIAGE, defaultshadow; secretAI_GATEWAY_API_KEY).Static Checksskips PRs that touch none of its inputs.Mobile - Qualitystarts when the map says mobile is affected, or when a changed path still needs the comment gate, the test-date gate, or the root format check (e2e/,scripts/,tools/, and the root files that check covers, including.editorconfig). It skips its type check, lint, unit tests, and Knip when the map says mobile is unaffected. It follows the map only, never Jev, and runs everything if the plan job fails or the plan value is missing.
CI rerun triage
ci-rerun-triage.yml runs when Web CI/CD, api-events CI/CD, Code Quality, checkai CI, or the MCP workflow fails on its first attempt. scripts/ci-rerun/triage.mjs fetches each failed job’s log and extracts the failing step: its last lines, plus earlier lines that look like errors. Classification comes from two sources:
- Known signatures (
scripts/ci-rerun/signatures.mjs):- Code failures are never rerun: type errors, format, lint, coverage,
KNOWN_GAPS, OpenAPI parity, taxonomy, comments, schema drift, test assertions. - Infrastructure failures are rerun: Encore boot, network, upstream 5xx, registry or rate limits, runner loss.
- Code failures are never rerun: type errors, format, lint, coverage,
- Jev (
checkai/scripts/jev/ci-failure.ts) classifies the rest: the cause, whether a rerun would pass, and whether a person should look.
infrastructure or flaky-test at 0.75 or higher and “rerun passes” at 0.7 or higher. The run is rerun (failed jobs only, once) only when every failed job is rerun-safe and vars.CI_RERUN_TRIAGE is on. The default is shadow, which only reports.
The verdicts (rerun-safe, needs a fix with a fix hint, or unclear) go to the job summary and a sticky PR comment. Across 80 real failures from the replay cache, signatures classified 56 as code and 4 as infrastructure. Jev labelled most of the remaining 24 code, config, or test problems and approved no reruns on its own. Run node scripts/ci-rerun/triage.mjs --run <id> --mode shadow to triage any run locally.
Review triage
review-triage.yml runs when Greptile or Codex submits a review; it can also be run by hand with a PR number. scripts/review-triage/triage.mjs reads the PR’s review threads through one GraphQL query and triages the unresolved bot findings:
- Duplicates: a finding that another open finding already covers, either from a different reviewer within 3 lines of it or with similar wording in the same file, goes in “same as another finding”. A finding similar to one already resolved stays in “fix now” and is marked as raised again.
- Jev (
checkai/scripts/jev/review-finding.ts --triage) answers the category, validity, the action (fix now, batch, decline, discuss), and whether to sweep the diff for the same bug. - Severity: P0 and P1 stay in “fix now” unless Jev says decline at 0.9 or higher. Other findings are declined at 0.75.
<!-- greptile_comment --> and <!-- /greptile_comment -->. Notes it does not file as separate comments stay in that block, including when the Greptile check says 0 comments added. Review loops (/x, /release-loop) read that block on the head SHA. A confidence score of 5 is not a merge requirement.
Engineer setup for affected checks
-
Pull
devand runnpm ciat the repo root. Husky’spreparescript installs both hooks (pre-commit and pre-push). -
Optional, to get Jev locally:
cd checkai && npm ci- add
AI_GATEWAY_API_KEY=toweb/.env(gitignored; seeweb/.env.example).
-
Expect fast commits and slower pushes: pre-push runs the type checks and lint for the packages your push affects. Add the full unit suites with
SOUNDCHECK_PREPUSH_FULL=1 git push. Never use--no-verify. -
The pre-push hook runs Jev in
shadowunless your shell setsJEV_CHANGE_TRIAGE. Decisions are appended to.git/jev-triage.login each checkout. -
In worktrees,
npm run worktree:setup -- . checkaiinstalls both packages.
Required checks and path filters
GitHub required status checks must be reported on every PR. A workflow withpull_request.paths: that does not match the PR never runs, so a required check from it sits at “Expected — waiting for status to be reported” and blocks the merge forever. The same thing happened when bot commits carried [skip ci] (fixed in f583f8ea2). A poller workflow used to sit on a runner until path-filtered jobs finished; that is retired. Do not recreate it.
The supported pattern is the changes job above: the workflow always runs, irrelevant jobs are skipped, and GitHub treats a skipped required check as passing. Path logic then lives once, in the workflow.
Required status checks
Ruleset Protect default,dev,qa,main requires (GitHub Actions):Enforce Branching RulesMobile - Quality,Static Checksapi-events / changes,api-events / checks,api-events / suites,api-events / openapi-parity,api-events / schema-gates,api-events / pr-buildcomponents / result(checkai, mcp-ts, gcp-platform-signals, GCP Terraform)security
qa and main only, so components / result runs only there. A component caller job skipped by its if reports only the caller name (checkai), never checkai / checks, so the per-component names cannot be required; the result job applies scripts/required-job-result.mjs to each component and fails on any relevant component that failed, was cancelled, or was skipped without a reuse.
Draft PRs skip integration and e2e. api-events / integration-tests, api-events / encore-e2e, api-events / encore-e2e-select and web e2e-plan / e2e-tests skip while a PR is a draft. Everything else still runs on a relevant draft push, including api-events / checks, openapi-parity, schema-gates, pr-build, web checks and unit-tests, Mobile - Quality, and the mcp-ts, checkai and gcp-platform-signals jobs. api-events-deploy.yml and web-ci-cd.yml also trigger on ready_for_review, so marking the PR ready runs them on the same head. A skipped check counts as passing, and a job skipped by its own if never evaluates a computed name:, so the required names come from separate result jobs. The suites report as api-events / integration-tests (suite) and api-events / encore-e2e (suite). One suites-result job always runs and applies scripts/required-job-result.mjs to each suite.
On a draft it is named api-events / suites (deferred) and passes with a note to mark the PR ready. The required name api-events / suites is then not reported at all, so the PR shows them as expected rather than failed, and merge and auto-merge stay blocked until the ready_for_review run reports them. A skipped job would not do this: a skipped required check counts as passing.
Once the PR is ready, it reports the required name and:
- Pass when both suites passed, or when the
changesjob found nothing relevant to api-events. - Fail on any other skip, failure or cancel, including a failed
changesjob.
dev → qa, qa → main) get no budget comment, because their pushes are merges, not iteration.
Why api-events / changes is itself required: a job that is skipped because its needs failed also reports skipped, so only a red changes job blocks that case. components / result covers the same case for the components, because it fails when components / changes did not succeed. Do not require checkai / evals; it is toggled by vars.CHECKAI_EVAL_URL and skipped-as-pass would hide it. Do not require path-filtered Web CI names or api-events / set-env. Mobile - Quality is a required check that skips when the plan says it has nothing to scan; a skipped required check still passes. When it starts, it includes Knip only if the map says mobile is affected. checkai / checks and checkai / evals restore checkai/node_modules via setup-checkai-deps. Mobile - Quality and the mobile jobs in ci-cd.yml restore root node_modules via setup-mobile-deps (same restore/save split as setup-e2e-deps).
Wire enum changes
The web and the API deploy separately, in no fixed order, so a web build can read an API that has not changed yet. When a value of a wire enum changes, deploy the API first, or first ship a web build that accepts both values. Renamingclient to recipient in awaitingParties (PR 4070) left the portal with no agreements for nine minutes on DEV because the web deployed first. Web services parse lists with parseWireRows (web/src/lib/services/wireDrift.ts): a row that does not match is dropped, the rest render, and Sentry gets the route, the field path and the received enum value. Search Sentry for the wire_route tag.
Mobile app CI/CD
Workflow overview
Smart builds
The CI pipeline uses fingerprint-based detection:- QA pushes always build and submit iOS, including JS-only changes; Android and manual smart builds skip compatible native builds
- Main pushes publish OTA only when both platforms have compatible production binaries; native changes block OTA until new production binaries are built separately.
Native label on PRs
native-fingerprint.yml labels a PR native when it changes the Expo fingerprint, and removes the label when a later push no longer does. It compares the PR merge commit with its base parent (scripts/native-fingerprint/check.mjs) and lists the changed sources in the job summary. It is advisory and never fails the PR. A native PR on dev holds back the OTA for every JS change promoted with it until new production binaries exist, so schedule the build or batch those PRs on purpose.
The workflow runs only when a PR touches one of its paths. scripts/native-fingerprint/paths.integration.test.mjs (Code Quality, mobile-affected) fails when the fingerprint gains a repo file that the list does not cover. A PR that reverts its only native change stops matching the paths, so remove a stale label by hand.
Version management
- Version string: Managed by EAS remotely (
appVersionSource: "remote") - Build numbers: Auto-incremented by EAS (
autoIncrement) - Auto-update: CI updates patch version if iOS submission would fail
Workflow modes
The unifiedci-cd.yml workflow supports multiple modes via workflow_dispatch:
Required secrets
GitHub secrets
EAS credentials
Managed in EAS Dashboard or viaeas credentials:
- iOS certificates and provisioning profiles
- Android keystore
iOS submission
Configuration
TestFlight groups
IfTESTFLIGHT_GROUPS is set, CI auto-assigns builds to those groups:
Android submission
First-time setup
Before CI can publish to Internal:1
Create app in Play Console
Package:
com.soundchecklive.app2
Complete store listing
Name, description, category, contact info, privacy policy
3
Complete app content
Data Safety, Target audience, Content rating questionnaire
4
Add graphics
512x512 icon, 1024x500 feature graphic, screenshots
5
Configure Play App Signing
EAS manages keystore, but Play needs first upload
Submission options
- Preferred: Configure Google Play service account in EAS once (non-interactive CI)
- Alternative: Add
GOOGLE_SERVICE_ACCOUNT_JSONto GitHub secrets
Typical flows
Branch and channel routing
QA retains production backend/auth. Its separate OTA channel keeps tester updates away from store binaries. The
qa profile extends production with store distribution, no development client, and no simulator build. Other Git branches and tags are rejected by the release route guard.
Native builds from qa
Promote code through a PR fromdev to qa. Mobile changes on qa always build a fresh iOS binary with the qa profile and automatically submit its exact validated ID to TestFlight, including for JS-only changes. Android builds remain fingerprint-based and Android submission remains manual. QA pushes do not publish OTAs. The version-check action may still update the version and push its generated version commit to qa.
Release to testers
QA iOS submissions happen automatically after a successful build. To retry a submission or submit Android, dispatchpromote from qa with the platform and exact build ID. The build’s project, platform, status, distribution, profile, and channel are checked before submission. QA Android submissions use only the internal track; iOS uploads go to App Store Connect/TestFlight. Alternatively, dispatch build_submit with submit=true to build and submit together. Build modes default to submit=false.
Production release from main
After promoting approved code tomain, manually dispatch smart_build or build_submit there when a new production native binary is needed. The production profile embeds the production channel. Submit that exact build after approval. Never promote the QA-channel binary to the public store; its embedded QA channel would persist. iOS uploads require a separate App Review step before public release.
OTA updates
Merging mobile changes tomain automatically publishes production OTAs for both platforms, provided compatible production binaries exist. For a manual retry or tester OTA, dispatch ota_update from qa for tester updates or main for production updates. Choose the platform and leave update_branch=auto to select the source’s channel. This historical input selects an EAS channel, not a Git branch. Explicit cross-channel requests fail before publication.
The workflow compares each selected platform against finished builds with the matching profile, channel, and runtime. QA fingerprints and OTAs both use the production EAS environment. Fingerprint/API failures block publication. Only main pushes and manual ota_update publish OTAs. QA pushes, other branches, tags, and smart_build do not. A missing production binary blocks the entire automatic OTA; create and distribute that binary separately, then rerun the OTA.
Before the first QA build/release, create EAS branch qa and channel qa linked to it; keep channel production linked to branch production. Both lanes need a native build containing expo-updates before device verification.
Project configuration
eas.json
Key settings:app.config.js
Runtime version and EAS Update endpoint:eas.json set channel (development / preview / production) so binaries pull updates from the matching EAS Update channel.
Troubleshooting
Missing EXPO_TOKEN
Missing EXPO_TOKEN
Workflows fail early if
EXPO_TOKEN is not set. Add it to GitHub Secrets.iOS submission fails - version exists
iOS submission fails - version exists
CI auto-updates the patch version. If you still see this, the auto-update ran but something else failed.
Android submission fails
Android submission fails
Ensure Google Play service account is configured or
GOOGLE_SERVICE_ACCOUNT_JSON is set.Build number issues
Build number issues
EAS manages build numbers remotely. Never manually set
buildNumber or versionCode.Version commands
Web app CI/CD
Workflow:web-ci-cd.yml
Triggers
Jobs
- Checks: Lint, typecheck, build Next.js app
- E2E Tests: Playwright with Encore api-events on :4000 (not the retired Go API on
:8080) - Deploy (push to
dev,qa,main): one image build, pushed to the Azure registry and to the environment’s Artifact Registry<env>-webrepository. It deploys to Azure App Service, then to Cloud Run<env>-webby digest, then invalidates Cloud CDN on<env>-web-url-map. The Cloud Run steps needGCP_WEB_WIF_PROVIDER_<ENV>andGCP_WEB_DEPLOYER_SA_<ENV>; while both are unset the job deploys to Azure only. Seeinfra-gcp/modules/web/README.md(CI deploys, Cutover runbook).
Additive nightly coverage
web-e2e-nightly.yml is additive — it does not replace web-ci-cd.yml e2e.
It runs the complete Chromium suite nightly at 03:00 UTC (and via
workflow_dispatch), with isolated ephemeral API stacks per shard, Playwright
artifacts/JSON reports, and web/scripts/e2e-coverage-gate.mjs.
--shard=N/M is rewritten by web/scripts/run-local-e2e.mjs into a
duration-weighted file list (web/scripts/playwright-duration-shards.mjs) so
long specs do not pile onto one shard. Use it for
coverage-matrix enforcement and the alternate feature-state job; keep PR smoke
and push-to-dev full suite on the existing CI path.
Alerting MVP: GitHub Actions marks a failed nightly run red and retains the
Playwright artifacts for investigation. This workflow does not yet have a
dedicated Slack/webhook alert; add one when an owned on-call destination is
available rather than reusing the changelog or docs-only webhooks.
Required secrets
API Events CI/CD
Workflow:api-events-deploy.yml
The Encore api-events service is the public API and system of record.
Triggers
Pipeline (summary)
- Set environment: Resolve dev / qa / prod from branch or dispatch input
- Checks: Lint, format, SQL scanner steps, typecheck, unit tests
- Parallel gates:
schema-gates(Prisma validate, layout, drift, ephemeral migrate, status, replay),openapi-parity(emitted spec, path templates, breaking changes, MCP coverage),integration-tests,encore-e2e,pr-build(Docker validation, PR only) - Deploy: Roll out new revision (shared env DB migrations apply on boot)
encore-e2e runs the suite twice on one runner: first against the provider stubs (Jev-selected folders when JEV_CHANGE_TRIAGE=on, with route-coverage enforcement on a full run), then the full suite against the provider emulators in tools/emulators after Encore restarts with dev/e2e-emulators.env. Every stateful service is reset between the passes: the Twilio and Clerk emulators restart, Azurite is recreated, and the database is reset and re-seeded. The emulator pass used to be a separate encore-e2e-emulators job that repeated the same setup on its own 8vCPU runner; folding it in saves about one billed minute per run, and an emulator-mode failure now blocks merge through the required encore-e2e check. It loads a Blacksmith-cached clerk-emulator image (setup-docker-builder@v2 with cache-key: tools/clerk-emulator/Dockerfile, then build-push-action) and restores e2e/node_modules via setup-e2e-deps, the same node_modules-cache pattern as setup-api-events-deps. Changes under tools/clerk-emulator/** (except markdown) make the job relevant.
Concurrency
api-events deploy sharesbackend-deploy-{env} with terraform-apply.yml:
Deploy chain
When Terraform changes affect migration-related infrastructure,terraform-apply.yml dispatches api-events-deploy.yml after apply so new instances boot-migrate pending schema changes.
The retired Go API Linux Web App (
infra/modules/backend) is still provisioned in production. Dev and QA set enable_app_service = false, so Terraform destroys that App Service while leaving Postgres, blob storage, and the rest of the Azure stack in place. This repository no longer builds or deploys Go API images via api-deploy.yml.MCP CI/CD
Workflow:mcp-deploy.yml
Library CI for @soundcheck/mcp: one checks job (lint, format, typecheck, tsc build, unit tests). Hosted MCP deploys with api-events. The workflow no longer pushes a soundcheck-mcp image or deploys Azure App Service.
Database schema CI
Job:schema-gates in api-events-deploy.yml (no shared env DB credentials)
Runs whenever api-events/** changes, in parallel with checks. The former standalone api-migrations.yml duplicated this job and was retired.
Steps
prisma validateand migrations layout checkmigrate diffdrift check (schema.prisma vs migrations) against a shadow Postgresnpm run db:migrateagainst fresh GHA Postgres on:5434(soundcheck_test), thendb:migrate:statusnpm run test:migration-idempotencereplays required migrations
schema:parity gate and no api/db/** path trigger.
Shared-env apply (not in this job)
Pending migrations for dev/qa/prod apply when api-events starts (migrateDeployOrThrow in encore.service.ts). Explicit local apply:
cd api-events && npm run db:migrate.
Linear Releases
Workflow:linear-release.yml
Linear Releases answer “is ENG-123 live, and where?” by grouping issues into releases per pipeline. The workflow listens with workflow_run for successful runs of Web CI/CD, api-events CI/CD, and CI / CD on qa and main, then runs linear/linear-release-action to create one completed release per deploy. dev deploys are not tracked; a merge into dev leaves the issue in ENG Review until the ticket-states job below moves it.
Pipelines
Six continuous pipelines in Linear (Settings → Releases). Names must match the workflow exactly because the secret name is derived from<component>_<stage>.
The commit path filter is not configured by hand.
scripts/linear-release-paths.mjs reads the source workflow’s on.push.paths list at the deployed revision and passes it as include_paths, so the release filter is the deploy trigger by construction. Leave the path filter empty on the Linear pipelines; a narrower filter there would only hide commits the deploy shipped.
Secrets are pipeline access keys generated per pipeline in Linear; personal API keys do not work. They live on the GitHub environments: the *_QA keys on qa, the *_PRODUCTION keys on prod. The sync job selects the environment from the branch. Add them as environment secrets, not variables.
Linear’s team workflow settings keep one status automation: PR opened → ENG Review. The ticket-states job below owns QA and Done, so “PR merged into dev → QA” and “release completed in a Production pipeline → Done” must stay off: the first undoes the ENG Review that follows a merge, and the second would skip the device-label hold. Done therefore means verified and live for customers, not merged.
Ticket states
Thelinear / ticket states job runs scripts/linear-ticket-states/run.mjs after the same resolve gate as sync, once per real qa or main deploy. It is stateless and idempotent: web, api-events and mobile each trigger it for the same head, and job-level concurrency (linear-ticket-states-<branch>) runs one pass per branch at a time.
- Fully promoted means every merged GitHub PR attached to the issue shipped in every component it touches. A PR’s files are matched against each deploy workflow’s
on.push.pathsat the deployed revision (web, api-events, mobile, the same listssyncuses). A renamed file counts for both its old and new path. For each touched component, the newest qualifying run on the branch must contain the PR’smerge_commit_sha(git merge-base --is-ancestor, full-history checkout); a commit missing from the checkout counts as not shipped. A qualifying run follows the same rules asresolve: its deploy job succeeded. Web and api-events runs, push orworkflow_dispatch(such as the Terraform-dispatched API deploy), must also own a successful Deployment toqaorprod. Mobile counts push runs only, and also needs the publishing step:Submit to App Store Connectonqa,Publish OTA updateonmain. An OTA skipped for a native change therefore does not count, and mobile tickets wait for the next published update. A PR that touches no deploy path only needs to be in the deployed head. Closed, unmerged PRs and linked promotion PRs (dev→qa,qa→main) are ignored. - Fails closed. The issue is left alone, so a partial ship never moves, when it has any of these: a linked PR that is still open, a merged PR not yet shipped, a PR from another repository or one that cannot be read, no PR attachment, or more than one page of labels, attachments or hold comments. Only the newest 30 successful runs per component are searched.
- No stale writes. Each move or comment re-reads the issue first and is dropped if the issue left the source state or the hold comment already exists. A
qapass planned before amainpass therefore never moves a Done ticket back to QA. - Hold comments are deduped per branch by their text, so repeated deploys of
qacomment once and the firstmaindeploy comments once more. - The decision rules live in
scripts/linear-ticket-states/decide.mjs, covered bydecide.test.mjs(run in Lint Workflows).
- No
LINEAR_API_KEYenvironment secret: the job prints a notice and exits 0. The key is a Linear personal API key with write access, stored on theqaandprodenvironments (pipeline access keys cannot change issue state). - Automatic runs are dry until the repository variable
LINEAR_TICKET_STATESislive. A dry run writes the planned moves to the job summary and changes nothing. workflow_dispatchuses the samebranchanddry_runinputs. Dispatch also runssyncwith the samedry_run, so keepdry_run=trueunless both should go live.
What counts as a deploy
- The source run must conclude
success, and the component’s deploy job (deploy,api-events / build-and-deploy,OTA Update, orSmart build - iOS) must itself have succeeded. Runs where every job was skipped still reportsuccessand are ignored. - The mobile version-bump commit (
chore: update version to X.Y.Z [skip mobile build]) is rejected before a runner starts. - Web and api-events deploy jobs declare
environment:, so the workflow also requires a GitHub Deployment forhead_shainqaorprod. An api-events dispatch that targets a different environment than its branch is skipped. - Web and api-events additionally require that the Deployment’s success status was logged by this run, so an older deployment of the same sha does not count.
- Manual
CI / CDdispatches (smart_build,build_submit,promote) are not recorded automatically; their submit behaviour depends on inputs the run payload does not expose. Record those with a dispatch of this workflow. - Release version is the short SHA, so re-running a deploy re-syncs the same release instead of creating a second one.
- Any GitHub API failure in the gate fails the run; nothing is synced on uncertainty.
- Every checkout pins
workflow_run.head_sha.github.shaunderworkflow_runis the default-branch tip and must not be used.
v<expo.version> (TestFlight|OTA) · <sha>, using the version that check-version pushed for that run when it bumped one.
Dry run and backfill
workflow_dispatch takes component, branch, optional base_ref, and dry_run (default true). Dispatch works from any branch that contains the workflow; workflow_run only fires from the default branch’s copy.
base_ref, the action’s first sync for a pipeline scans only HEAD. Run the backfill once per pipeline before the first automatic sync.
Infrastructure (Terraform)
Azure
Workflow:terraform-apply.yml
Azure apply stays on GitHub-hosted runners until that stack is retired. It is not the GCP apply path.
Triggers
Pipeline steps
- Init: Initialize Terraform with backend
- Security Scan: Run tfsec
- Plan: Generate execution plan
- Apply: Apply changes (merge only)
Required approvals
- Dev/QA: Automatic on merge
- Prod: Requires manual approval from infrastructure leads
GCP
gcp-terraform-validate.yml checks formatting and runs terraform validate plus unit tests. It has no GCP credentials.
Plan and apply run in three Cloud Build deployments: dev in dev-soundcheck-live, QA in qa-soundcheck-live, and prod in soundcheck-live. Each project owns its connection, image repository, App-key secret, and plan/apply/builder identities. All three use one GitHub App. A pull request plans the target environment and posts one sticky comment per root. Once automatic applies are enabled, a merge to dev, qa, or main applies that environment. The DNS root (infra-gcp/dns) applies only after merge to main, and that apply also runs the prod environment so the new zone is visible. api-events deploy does not wait.
Operators bootstrap infra-gcp/terraform-runner/environments/{dev,qa,prod} independently, using the shared module in infra-gcp/terraform-runner/terraform. Each runner state lives at terraform-runner/default.tfstate in its project’s dedicated <project>-terraform-runner-tfstate bucket. Existing environment and DNS state locations do not change. The apply trigger starts disabled; enable it in that environment’s root only after the image, App key, and plan path are verified and the runner script has reached the target branch. Bootstrap and the destroy guard are in the GCP infra skill.