Skip to content

ci(integration): isolate live-DB stacks instead of serialising them#731

Merged
coderdan merged 2 commits into
mainfrom
ci/integration-port-isolation
Jul 21, 2026
Merged

ci(integration): isolate live-DB stacks instead of serialising them#731
coderdan merged 2 commits into
mainfrom
ci/integration-port-isolation

Conversation

@coderdan

@coderdan coderdan commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Integration jobs have been getting cancelled rather than run — four times in ~80 minutes on 2026-07-20, most recently the db=supabase leg of #712, which is the exact variant that PR exists to fix. That job had never actually run; re-running it manually passed.

Root cause

The three integration workflows share one job-level concurrency group, integration-live-db-<db>, deliberately not scoped by ref (d02bf63) because two PRs contend for a fixed host port as much as two pushes to one PR.

But GitHub Actions allows only one in-progress plus one pending run per concurrency group. A third contender is cancelled outright, not queued. With three workflows (integration-drizzle, integration-supabase, integration-prisma-next) funnelling into two keys, that fires under any load:

time (2026-07-20) workflow result
10:40 Drizzle cancelled
11:48 Drizzle cancelled
11:51 prisma-next cancelled
11:59 prisma-next (#712) cancelled

The failure mode is worse than a flake. A cancelled job never runs, so its check goes absent rather than red — the signal is silently lost, and main isn't branch-protected, so nothing blocks the merge.

Fix — remove the contention instead of serialising it

A new integration-db composite action gives every job:

  • its own compose project name (run id + attempt + job + variant), so container names can't collide; and
  • ephemeral host portsCS_PG_PORTS / CS_PGRST_PORTS carry a bare container port, so compose publishes with no fixed host side, and the assigned port is read back with docker compose port.

Nothing is shared between jobs, so nothing needs to queue. The concurrency groups are gone from all three workflows, and the three duplicated up/down/URL blocks collapse into one action.

Local dev is unchanged. The compose files keep their fixed 55432 / 55433 / 55430 mappings when the vars are unset, so the help text in test-kit/src/env.ts and live-gate.ts stays accurate.

Dropped the pre-up docker compose down as well: with unique project names it's a no-op, and blanket-pruning is now actively unsafe — without the concurrency group, another job's stack may be live on the same runner.

Two things found along the way

  • .github/actions/ had no CODEOWNERS rule, though composite actions run arbitrary steps in the same job with the same secrets as the workflow calling them — same blast radius as .github/workflows/, which is covered. Added the rule plus the matching assertion in supply-chain.e2e.test.ts, and verified the assertion fails without it (not a vacuous gate).
  • The Drizzle workflow header claimed PGRST_URL is left unset; the matrix had been setting it. Corrected.

Validation

  • docker compose config resolves to the fixed mapping by default and to an ephemeral publish under the CI vars — both variants, all three ports.
  • All six YAML files parse; the action's shell body is syntax-clean and has no GitHub templating interpolated into it (values pass through env, avoiding the script-injection seam).
  • 18/18 supply-chain e2e tests pass.

The live docker compose port readback can only be exercised on a runner, so this PR's own integration jobs are the real test — they exercise every path the action adds.

No changeset: repo tooling only, no published package surface affected.

Summary by CodeRabbit

  • New Features

    • Added automated integration database setup supporting both PostgreSQL and Supabase environments.
    • Integration tests now run with isolated database instances and dynamically assigned connection endpoints, enabling parallel jobs without port conflicts.
  • Bug Fixes

    • Improved cleanup of temporary integration environments after test completion.
    • Added validation and diagnostics when database setup cannot determine connection details.
  • Chores

    • Extended supply-chain ownership checks to cover shared automation actions.

Integration jobs were being CANCELLED, not run — four times in ~80 minutes
on 2026-07-20, most recently the `db=supabase` leg of #712, which is the
exact variant that PR exists to fix.

Cause: the three integration workflows share one job-level concurrency
group, `integration-live-db-<db>`, deliberately not scoped by ref
(d02bf63) because two PRs contend for a fixed host port as much as two
pushes to one PR. But GitHub Actions allows only ONE in-progress plus ONE
pending run per group — a third contender is cancelled outright. With
three workflows funnelling into two keys, that fires under any load.

The failure mode is worse than a flake: a cancelled job never runs, so
its check goes ABSENT rather than red. The signal is silently lost, and
`main` is not branch-protected, so nothing blocks the merge.

Remove the contention rather than serialising it. A new `integration-db`
composite action gives every job:

  - its own compose project name (run id + attempt + job + variant), so
    container names cannot collide; and
  - EPHEMERAL host ports — `CS_PG_PORTS` / `CS_PGRST_PORTS` carry a bare
    container port, so compose publishes with no fixed host side and the
    assigned port is read back with `docker compose port`.

Nothing is shared between jobs, so nothing needs to queue, and the
concurrency groups are gone from all three workflows. The compose files
keep their fixed 55432 / 55433 / 55430 mappings when the vars are unset,
so local dev and the help text in `test-kit/src/env.ts` are unchanged.

Dropped the pre-`up` `docker compose down` too. With unique project names
it is a no-op, and blanket-pruning is now actively unsafe: without the
concurrency group, another job's stack may be live on the same runner.

Also, since this adds a composite action: `.github/actions/` had no
CODEOWNERS rule, though such actions run arbitrary steps in the same job
with the same secrets as the workflow calling them. Added the rule and
the matching assertion in `supply-chain.e2e.test.ts` (verified it fails
without it). Fixed a stale claim in the Drizzle workflow header that
`PGRST_URL` is left unset — the matrix had been setting it.

Verified: compose interpolation resolves to the fixed mapping by default
and to an ephemeral publish under the CI vars (`docker compose config`);
YAML parses; the action's shell body is syntax-clean with no GitHub
templating interpolated into it; 18/18 supply-chain e2e tests pass.
@coderdan
coderdan requested a review from a team as a code owner July 21, 2026 03:12
@changeset-bot

changeset-bot Bot commented Jul 21, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: c28b825

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@coderdan, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2c6c55c5-991e-4449-882c-b04e0db393c6

📥 Commits

Reviewing files that changed from the base of the PR and between c28b825 and dccc322.

📒 Files selected for processing (1)
  • .github/actions/integration-db/action.yml
📝 Walkthrough

Walkthrough

Integration CI now uses a shared composite action to launch isolated Postgres or Supabase Compose stacks with ephemeral ports, exposes runtime connection URLs to tests, and performs project-scoped teardown. Workflow triggers and CODEOWNERS coverage were updated accordingly.

Changes

Integration database CI

Layer / File(s) Summary
Database stack action and port allocation
.github/actions/integration-db/action.yml, local/docker-compose.*.yml
The new action starts variant-specific Compose projects with dynamically assigned ports and outputs Postgres and optional PostgREST URLs. Compose files support environment-based port overrides.
Workflow integration and lifecycle
.github/workflows/integration-drizzle.yml, .github/workflows/integration-prisma-next.yml, .github/workflows/integration-supabase.yml
Integration workflows use the action, simplify database matrices, remove serialization and leaked-container cleanup, pass action outputs to tests, and conditionally tear down started projects.
Action ownership coverage
.github/CODEOWNERS, e2e/tests/supply-chain.e2e.test.ts
The actions directory receives CODEOWNERS coverage, and the supply-chain test verifies it.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • cipherstash/stack#382 — Both changes update CODEOWNERS governance rules and their supply-chain coverage test.

Suggested reviewers: tobyhede

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main CI change: isolating integration database stacks instead of serializing jobs.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ci/integration-port-isolation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderdan

Copy link
Copy Markdown
Contributor Author

Verified on the runner

The PR body flagged that the live docker compose port readback could only be exercised in CI. It now has been — this PR's own integration jobs are that test, and all five pass.

Ephemeral ports were genuinely assigned (not a silent fallback to the fixed mappings):

cs-it-29797935468-1-integration-postgres  — postgres on :32768
cs-it-29797935468-1-integration-supabase  — postgres on :32768, postgrest on 32769

55432 / 55433 / 55430 appear nowhere. Docker assigned the ports, the action read them back, and the suites connected against them.

The contention is gone. Five integration jobs ran concurrently — three of them the supabase variant, which under the shared integration-live-db-supabase group could only ever run one at a time with a second pending and any third cancelled:

job result duration
Drizzle (db=postgres) pass 1m8s
Drizzle (db=supabase) pass 1m30s
prisma-next (db=postgres) pass 47s
prisma-next (db=supabase) pass 1m3s
Supabase (db=supabase) pass 1m15s

Cancelled checks: 0. Note the two legs that both landed on :32768 — separate runners, so harmless, but that is exactly the collision the fixed-port scheme existed to serialise around, and it no longer needs to.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/actions/integration-db/action.yml:
- Around line 83-99: Validate the PostgREST port in the supabase branch before
constructing or exporting pgrest_url. Capture the result of port_of postgrest
3000, fail with a clear error and docker compose status output when it is empty,
and preserve the existing pg_port validation and URL behavior for valid ports.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 01f103ca-e389-48b8-bd71-2398acb17312

📥 Commits

Reviewing files that changed from the base of the PR and between f0b20d3 and c28b825.

📒 Files selected for processing (8)
  • .github/CODEOWNERS
  • .github/actions/integration-db/action.yml
  • .github/workflows/integration-drizzle.yml
  • .github/workflows/integration-prisma-next.yml
  • .github/workflows/integration-supabase.yml
  • e2e/tests/supply-chain.e2e.test.ts
  • local/docker-compose.postgres.yml
  • local/docker-compose.supabase.yml

Comment thread .github/actions/integration-db/action.yml Outdated
CodeRabbit caught a real asymmetry in the new action: `pg_port` was checked
for emptiness but the supabase branch built `pgrest_url` straight from
`$(port_of postgrest 3000)`. `docker compose port` exits 0 with EMPTY
stdout when a port is not bound, so an unbound PostgREST would have
silently yielded `http://localhost:` and handed every `PGRST_URL` consumer
(the Drizzle-supabase leg, both Supabase-workflow steps) a malformed URL
instead of the immediate error the Postgres path already gave.

Move the check into `port_of` itself so it applies by construction and the
asymmetry cannot come back, rather than adding a second call-site guard.
Each port is now captured in its own bare assignment so `set -e` fails the
step unambiguously on a non-zero `port_of`.

Verified with a stubbed `docker` that returns a port for `db` and nothing
for `postgrest`: the Postgres read succeeds, the guard fires for postgrest,
and the script exits 1 before exporting anything.
@coderdan

Copy link
Copy Markdown
Contributor Author

Good catch @coderabbitai — fixed in dccc322. Real asymmetry, and my bug.

Rather than add a second call-site guard, I moved the emptiness check into port_of itself, so it applies by construction and the asymmetry can't come back the next time a service is added. Each port is now captured in its own bare assignment so set -e fails the step unambiguously on a non-zero port_of.

Verified with a stubbed docker that returns a port for db and nothing for postgrest:

pg_port=32768 (postgres port read fine)
GUARD FIRED for 'postgrest'
exit=1

The script stops before exporting anything, instead of emitting http://localhost:.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@coderdan Thanks—this is a solid fix. Centralizing the non-empty result check in port_of makes the invariant apply uniformly to every service, and the bare assignments ensure set -e reliably halts before any invalid URL is exported.

🐇 ✅


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@coderdan
coderdan requested a review from auxesis July 21, 2026 03:29

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

Fixes a genuinely nasty CI failure mode: the three integration workflows funnelled into two ref-unscoped concurrency groups (integration-live-db-<db>), and GitHub Actions allows only one in-progress + one pending per group — so a third contender is cancelled, not queued. A cancelled job's check goes absent rather than red, and since main isn't branch-protected, the lost signal blocks nothing (it bit the db=supabase leg of #712 — the exact variant that PR exists to fix). This removes the contention instead of serialising it: a new integration-db composite action gives each job a unique compose project name + ephemeral host ports, so nothing is shared and nothing needs to queue.

Well-reasoned and cleanly executed. LGTM / approvable — details below; no blocking issues.

What I verified

  • Isolation mechanism is correct. The compose files gain ports: "${CS_PG_PORTS:-55432:5432}" — unset keeps the fixed local-dev mapping; set to a bare container port (CS_PG_PORTS=5432) makes Compose publish to an ephemeral host port. The action reads the assigned port back via docker compose port, and the project name (cs-it-<run>-<attempt>-<job>-<variant>, lowercased/sanitised to [a-z0-9_-], always starting with c) is unique per job and attempt, so a re-run can't adopt a prior attempt's containers.
  • Fail-closed port readback. port_of treats empty stdout (what docker compose port prints, at exit 0, for an unbound port) as the failure signal — errors, dumps ps, returns 1 — and each port is a bare x="$(port_of …)" assignment so set -euo pipefail fails the step unambiguously (correctly avoiding the local x=$(…) exit-status mask). The comment notes this closes a real review-found asymmetry where an unbound PostgREST would have silently produced http://localhost:.
  • Teardown is correctly guarded. CS_COMPOSE_PROJECT/CS_COMPOSE_FILE are written to $GITHUB_ENV before docker compose up, so even a failed up (e.g. --wait timeout) still tears down the right project; and if: always() && env.CS_COMPOSE_PROJECT != '' skips teardown cleanly when the stack never came up (no confusing -p "" error masking the real failure). Removing the pre-up blanket down -v is right — with no concurrency group, another job's stack may be live on the runner, so a blanket prune would now be actively unsafe.
  • Script-injection-safe. inputs.db and github.job reach the shell via env: (DB_VARIANT/JOB_ID), never templated into the run body; matrix.db is a static [postgres, supabase]. No user-controlled value interpolates into a shell script.
  • Governance gate is real. /.github/actions/ added to CODEOWNERS (composite actions run arbitrary steps with the workflow's secrets — same blast radius as /.github/workflows/) and asserted in supply-chain.e2e.test.ts; the PR states the assertion was verified to fail without the rule (non-vacuous). No changeset is correct — repo tooling only, no published surface.
  • The runner-only path is proven. The docker compose port readback can only run on a real runner, and all five of this PR's own integration jobs pass (Drizzle postgres+supabase, Supabase, prisma-next postgres+supabase) — so the added action executed end-to-end and worked.

Notes — non-blocking

  1. Increased concurrent load on the shared ZeroKMS workspace. The old Supabase workflow comment cited "a live-DB job that shares one ZeroKMS workspace should finish, not be interrupted" as part of the queueing rationale. With the groups gone, all three workflows × both variants can now run fully concurrently against the same CS_WORKSPACE_CRN. This looks safe — the per-job DB is where suite state lives and it's now isolated, and I grepped the integration suites for workspace-global assertions (listClients/listKeys/key counts) and found none — but worth a moment's confirmation that no suite depends on workspace-exclusive ZeroKMS state, since that's the one shared resource the isolation doesn't cover.
  2. No reaper for hard-killed leaks. A SIGKILL'd job skips even the always() teardown, and the pre-up prune is (correctly) gone, so on a reused runner a leaked stack has nothing to clean it up. Harmless if the Blacksmith runners are ephemeral (fresh VM per job) — worth a one-line confirmation, else a periodic prune of cs-it-* projects older than N hours would backstop it.

Verdict

A real, well-diagnosed fix for a silent-signal-loss failure mode, implemented with genuinely careful shell (fail-closed readback, guarded teardown, injection-safe env passing) and validated by its own green integration run. Both notes are non-blocking confirmations. I'd approve this — say the word and I'll flip it to a formal approval.

@auxesis auxesis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changes look good, thanks for the fix @coderdan.

@coderdan
coderdan merged commit 0e96d36 into main Jul 21, 2026
15 of 16 checks passed
@coderdan
coderdan deleted the ci/integration-port-isolation branch July 21, 2026 04:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants