Skip to content

Commit 0e96d36

Browse files
authored
ci(integration): isolate live-DB stacks instead of serialising them (#731)
* ci(integration): isolate live-DB stacks instead of serialising them 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. * fix(ci): validate every port readback, not just Postgres 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.
1 parent cf2c57c commit 0e96d36

8 files changed

Lines changed: 237 additions & 98 deletions

File tree

.github/CODEOWNERS

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
# Supply-chain critical paths — changes here require explicit review
55
# (lirantal/npm-security-best-practices; see skills/stash-supply-chain-security/).
66
/.github/workflows/ @cipherstash/developers
7+
# Composite actions are workflow code by another name: they run arbitrary steps
8+
# in the same job, with the same secrets, so they need the same review gate.
9+
/.github/actions/ @cipherstash/developers
710
/.github/dependabot.yml @cipherstash/developers
811
/.github/CODEOWNERS @cipherstash/developers
912
/pnpm-workspace.yaml @cipherstash/developers
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
name: Integration database
2+
description: >-
3+
Bring up an integration docker-compose stack that is ISOLATED from every other
4+
job on the runner, and hand back the URLs to reach it.
5+
6+
Isolation is the point. These stacks used to bind fixed host ports
7+
(55430 / 55432 / 55433) under a fixed compose project name, so two jobs
8+
running the same variant on one runner collided with "address already in
9+
use". The guard was a cross-workflow job-level concurrency group
10+
(`integration-live-db-<db>`), but GitHub Actions allows only ONE in-progress
11+
plus ONE pending run per group — a third contender is CANCELLED outright,
12+
not queued. With three integration workflows sharing each key, that fired
13+
regularly and silently dropped the signal for whichever variant lost (a
14+
cancelled job never runs, so its check is absent rather than red).
15+
16+
This action removes the contention instead of serialising it: a unique
17+
compose project name per job, and EPHEMERAL host ports assigned by Docker
18+
(`CS_PG_PORTS` / `CS_PGRST_PORTS` carry a bare container port, so the compose
19+
files publish with no fixed host side) read back via `docker compose port`.
20+
Nothing is shared between jobs, so nothing needs to queue.
21+
22+
inputs:
23+
db:
24+
description: Compose variant to bring up — `postgres` or `supabase`.
25+
required: true
26+
27+
outputs:
28+
database-url:
29+
description: Direct Postgres URL for the stack this job just started.
30+
value: ${{ steps.up.outputs.database-url }}
31+
pgrest-url:
32+
description: PostgREST base URL. Empty for the `postgres` variant, which runs none.
33+
value: ${{ steps.up.outputs.pgrest-url }}
34+
35+
runs:
36+
using: composite
37+
steps:
38+
- name: Start ${{ inputs.db }}
39+
id: up
40+
shell: bash
41+
env:
42+
# A bare container port publishes to an ephemeral host port. The compose
43+
# files fall back to their fixed local-dev mappings when these are unset.
44+
CS_PG_PORTS: '5432'
45+
CS_PGRST_PORTS: '3000'
46+
# Passed through the environment rather than templated into the script
47+
# body: `${{ }}` is textual substitution, so interpolating a value
48+
# straight into shell is a script-injection seam. These are static today,
49+
# but the safe shape costs nothing.
50+
DB_VARIANT: ${{ inputs.db }}
51+
JOB_ID: ${{ github.job }}
52+
run: |
53+
set -euo pipefail
54+
55+
# Unique per job AND per attempt: a re-run must not adopt the previous
56+
# attempt's containers. `github.job` alone is not enough — matrix legs of
57+
# one job share it — so the variant goes in too.
58+
project="cs-it-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${JOB_ID}-${DB_VARIANT}"
59+
# Compose project names must be [a-z0-9][a-z0-9_-]*.
60+
project="$(printf '%s' "$project" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_-' '-')"
61+
file="local/docker-compose.${DB_VARIANT}.yml"
62+
63+
# Exported for the teardown step, which must target THIS job's stack.
64+
echo "CS_COMPOSE_PROJECT=$project" >> "$GITHUB_ENV"
65+
echo "CS_COMPOSE_FILE=$file" >> "$GITHUB_ENV"
66+
67+
docker compose -p "$project" -f "$file" up -d --wait
68+
69+
# `docker compose port` prints `0.0.0.0:49153`, and may print one line
70+
# per address family — take the first, then the port after the last
71+
# colon (IPv6 forms contain several).
72+
#
73+
# The emptiness check lives HERE, not at the call sites: `docker compose
74+
# port` exits 0 with empty stdout when a port is not bound, so "" is the
75+
# failure signal and every caller needs the same guard. Checking once, by
76+
# construction, is what stops the asymmetry this function had in review —
77+
# Postgres was validated and PostgREST was not, so an unbound PostgREST
78+
# would have silently produced `http://localhost:` and handed every
79+
# consumer a malformed URL instead of an immediate, clear error.
80+
port_of() {
81+
local svc="$1" cport="$2" hport
82+
hport="$(docker compose -p "$project" -f "$file" port "$svc" "$cport" \
83+
| head -n 1 | sed 's/.*://' | tr -d '[:space:]')"
84+
if [ -z "$hport" ]; then
85+
echo "::error::could not read the published host port for service '$svc' (container port $cport) in the '$DB_VARIANT' stack" >&2
86+
docker compose -p "$project" -f "$file" ps >&2
87+
return 1
88+
fi
89+
printf '%s' "$hport"
90+
}
91+
92+
# Each port is captured in its own bare assignment so `set -e` fails the
93+
# step on a non-zero `port_of` unambiguously.
94+
case "$DB_VARIANT" in
95+
postgres)
96+
pg_port="$(port_of postgres 5432)"
97+
database_url="postgres://cipherstash:password@localhost:${pg_port}/cipherstash"
98+
pgrest_url=''
99+
;;
100+
supabase)
101+
pg_port="$(port_of db 5432)"
102+
pgrest_port="$(port_of postgrest 3000)"
103+
database_url="postgres://postgres:password@localhost:${pg_port}/postgres"
104+
pgrest_url="http://localhost:${pgrest_port}"
105+
;;
106+
*)
107+
echo "::error::unknown db variant '$DB_VARIANT'" && exit 1
108+
;;
109+
esac
110+
111+
echo "database-url=$database_url" >> "$GITHUB_OUTPUT"
112+
echo "pgrest-url=$pgrest_url" >> "$GITHUB_OUTPUT"
113+
echo "Started '$project' — postgres on :$pg_port${pgrest_url:+, postgrest on ${pgrest_url##*:}}"

.github/workflows/integration-drizzle.yml

Lines changed: 37 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ name: Integration — Drizzle (EQL v3)
66
# PostgREST — but it does need to work on managed Postgres, where the `postgres`
77
# role is not a superuser, the EQL install takes its self-skipping path, and the
88
# ORE domains cannot hold data. The Supabase compose file brings up PostgREST
9-
# too; this job simply ignores it, and leaves `PGRST_URL` unset so a Supabase
10-
# suite scoped here would throw rather than silently skip.
9+
# too; the Drizzle suites simply ignore it.
1110
#
1211
# Separate from `tests.yml` on purpose: these suites need CipherStash credentials
1312
# and a database, and they THROW rather than skip when unconfigured. Keeping them
@@ -35,6 +34,7 @@ on:
3534
- 'local/supabase-init.sql'
3635
- '.github/workflows/integration-drizzle.yml'
3736
- '.github/actions/integration-setup/**'
37+
- '.github/actions/integration-db/**'
3838
pull_request:
3939
branches: ['**']
4040
# Repeated verbatim: GitHub Actions does not support YAML anchors/aliases.
@@ -57,23 +57,18 @@ on:
5757
- 'local/supabase-init.sql'
5858
- '.github/workflows/integration-drizzle.yml'
5959
- '.github/actions/integration-setup/**'
60+
- '.github/actions/integration-db/**'
6061

6162
jobs:
6263
integration:
6364
name: Drizzle v3 integration (db=${{ matrix.db }})
6465
runs-on: blacksmith-4vcpu-ubuntu-2404
65-
# Serialize every job that brings up the SAME docker-compose stack, so no two
66-
# bind the same fixed host ports on a shared runner at once (the "address
67-
# already in use" flake). Keyed by the compose variant, NOT the ref (two PRs
68-
# contend for a host port just as much as two pushes to one PR), so it
69-
# serializes across refs. The `supabase` leg uses the SAME supabase compose
70-
# (55430 / 55433) as the Supabase workflow and shares its key
71-
# (`integration-live-db-supabase`), so the two workflows queue rather than
72-
# collide; the `postgres` leg (55432) has its own key and still runs in
73-
# parallel with them.
74-
concurrency:
75-
group: integration-live-db-${{ matrix.db }}
76-
cancel-in-progress: false
66+
# No concurrency group: `integration-db` gives each job its own compose
67+
# project and ephemeral host ports, so live-DB jobs no longer contend and do
68+
# not need serialising. See that action for why the old
69+
# `integration-live-db-<db>` group had to go (it cancelled a third
70+
# contender rather than queueing it).
71+
#
7772
# Fork PRs have no secrets. Skip cleanly rather than fail on something the
7873
# contributor cannot fix — `tests.yml` still gives them a green signal.
7974
if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }}
@@ -85,17 +80,12 @@ jobs:
8580
# superuser, so the EQL install takes its self-skipping path and the ORE
8681
# domains become unusable. A suite that passes on a superuser database can
8782
# still fail there.
83+
# (The supabase compose file starts PostgREST anyway. Drizzle does not use
84+
# it, but `integration-db` supplies the URL regardless, which lets the
85+
# harness assert the `anon` path on the database it is actually running
86+
# against.)
8887
matrix:
89-
include:
90-
- db: postgres
91-
database-url: postgres://cipherstash:password@localhost:55432/cipherstash
92-
pgrest-url: ''
93-
- db: supabase
94-
database-url: postgres://postgres:password@localhost:55433/postgres
95-
# The supabase compose file starts PostgREST anyway. Drizzle does not
96-
# use it, but supplying the URL lets the harness assert the `anon`
97-
# path on the database it is actually running against.
98-
pgrest-url: http://localhost:55430
88+
db: [postgres, supabase]
9989

10090
env:
10191
CS_WORKSPACE_CRN: ${{ vars.CS_WORKSPACE_CRN }}
@@ -110,10 +100,6 @@ jobs:
110100
# instance, used only by the cross-identity test.
111101
CLERK_MACHINE_TOKEN: ${{ secrets.CLERK_MACHINE_TOKEN }}
112102
CLERK_MACHINE_TOKEN_B: ${{ secrets.CLERK_MACHINE_TOKEN_B }}
113-
# Job-level env, not a `.env` file: `dotenv/config` does not override an
114-
# already-set `process.env`, so these win and no secret is written to disk.
115-
DATABASE_URL: ${{ matrix.database-url }}
116-
PGRST_URL: ${{ matrix.pgrest-url }}
117103
# EXPLICIT, never inferred. The variant decides whether EQL is installed
118104
# with `--supabase` (and therefore whether the role grants are applied).
119105
# Inferring it from `PGRST_URL` reported `postgres` for this job's Supabase
@@ -152,30 +138,42 @@ jobs:
152138
client-key: ${{ secrets.CS_CLIENT_KEY }}
153139
client-access-key: ${{ secrets.CS_CLIENT_ACCESS_KEY }}
154140

155-
# Belt-and-braces for the concurrency guard: a run that was hard-killed
156-
# (runner crash / forced cancel) can skip its `down` and leak a container
157-
# holding the host port onto a REUSED runner. Tear any prior stack down
158-
# before starting a fresh one. `|| true` so a clean runner (nothing to
159-
# remove) is not an error.
160-
- name: Clear any leaked containers from a prior run
161-
run: docker compose -f local/docker-compose.${{ matrix.db }}.yml down -v --remove-orphans || true
162-
141+
# No pre-`up` cleanup step any more: the project name is unique per job, so
142+
# a container leaked by a hard-killed prior run cannot hold this job's
143+
# name or its (ephemeral) port. Blanket-pruning would now be actively
144+
# unsafe — without the concurrency group, another job's stack may be live
145+
# on this runner.
163146
- name: Start ${{ matrix.db }}
164-
run: docker compose -f local/docker-compose.${{ matrix.db }}.yml up -d --wait
147+
id: db
148+
uses: ./.github/actions/integration-db
149+
with:
150+
db: ${{ matrix.db }}
165151

166152
# `globalSetup` installs EQL v3 by shelling out to the real
167153
# `stash eql install --eql-version 3`, so an installer regression fails
168154
# here rather than hiding behind a test-only SQL apply.
155+
#
156+
# Step env, not a `.env` file: `dotenv/config` does not override an
157+
# already-set `process.env`, so these win and no secret is written to disk.
169158
- name: Drizzle v3 integration suites
170159
run: pnpm --filter @cipherstash/stack-drizzle run test:integration
160+
env:
161+
DATABASE_URL: ${{ steps.db.outputs.database-url }}
162+
PGRST_URL: ${{ steps.db.outputs.pgrest-url }}
171163

172164
# A second vitest invocation (stack's shared/ + identity suites live in a
173165
# different package now). Its globalSetup calls the same EQL v3 install, but
174166
# `isInstalled` short-circuits against the DB the first invocation already
175167
# provisioned — a fast no-op check, not a second schema apply.
176168
- name: Shared core + identity + wasm integration suites
177169
run: pnpm --filter @cipherstash/stack run test:integration
170+
env:
171+
DATABASE_URL: ${{ steps.db.outputs.database-url }}
172+
PGRST_URL: ${{ steps.db.outputs.pgrest-url }}
178173

174+
# Guarded on the project being set: if the stack never came up, there is
175+
# nothing to tear down and an unguarded `-p ""` would fail the job with a
176+
# confusing error that masks the real one.
179177
- name: Stop ${{ matrix.db }}
180-
if: always()
181-
run: docker compose -f local/docker-compose.${{ matrix.db }}.yml down -v
178+
if: always() && env.CS_COMPOSE_PROJECT != ''
179+
run: docker compose -p "$CS_COMPOSE_PROJECT" -f "$CS_COMPOSE_FILE" down -v

.github/workflows/integration-prisma-next.yml

Lines changed: 28 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ on:
3333
- 'local/supabase-init.sql'
3434
- '.github/workflows/integration-prisma-next.yml'
3535
- '.github/actions/integration-setup/**'
36+
- '.github/actions/integration-db/**'
3637
pull_request:
3738
branches: ['**']
3839
# Repeated verbatim: GitHub Actions does not support YAML anchors/aliases.
@@ -52,17 +53,18 @@ on:
5253
- 'local/supabase-init.sql'
5354
- '.github/workflows/integration-prisma-next.yml'
5455
- '.github/actions/integration-setup/**'
56+
- '.github/actions/integration-db/**'
5557

5658
jobs:
5759
integration:
5860
name: prisma-next v3 integration (db=${{ matrix.db }})
5961
runs-on: blacksmith-4vcpu-ubuntu-2404
60-
# Serialize every job that brings up the SAME docker-compose stack — same
61-
# rationale and keys as the Drizzle/Supabase workflows, so the jobs queue
62-
# on a shared runner rather than collide on the fixed host ports.
63-
concurrency:
64-
group: integration-live-db-${{ matrix.db }}
65-
cancel-in-progress: false
62+
# No concurrency group: `integration-db` gives each job its own compose
63+
# project and ephemeral host ports, so live-DB jobs no longer contend and do
64+
# not need serialising. See that action for why the old
65+
# `integration-live-db-<db>` group had to go (it cancelled a third
66+
# contender rather than queueing it).
67+
#
6668
# Fork PRs have no secrets. Skip cleanly rather than fail on something the
6769
# contributor cannot fix — `tests.yml` still gives them a green signal.
6870
if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }}
@@ -74,23 +76,13 @@ jobs:
7476
# is not a superuser, so the EQL install takes its self-skipping path. A
7577
# suite that passes on a superuser database can still fail there.
7678
matrix:
77-
include:
78-
- db: postgres
79-
database-url: postgres://cipherstash:password@localhost:55432/cipherstash
80-
pgrest-url: ''
81-
- db: supabase
82-
database-url: postgres://postgres:password@localhost:55433/postgres
83-
pgrest-url: http://localhost:55430
79+
db: [postgres, supabase]
8480

8581
env:
8682
CS_WORKSPACE_CRN: ${{ vars.CS_WORKSPACE_CRN }}
8783
CS_CLIENT_ID: ${{ vars.CS_CLIENT_ID }}
8884
CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }}
8985
CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }}
90-
# Job-level env, not a `.env` file: `dotenv/config` does not override an
91-
# already-set `process.env`, so these win and no secret is written to disk.
92-
DATABASE_URL: ${{ matrix.database-url }}
93-
PGRST_URL: ${{ matrix.pgrest-url }}
9486
# EXPLICIT, never inferred — same rationale as the Drizzle workflow.
9587
CS_IT_DB_VARIANT: ${{ matrix.db }}
9688

@@ -109,21 +101,31 @@ jobs:
109101
client-key: ${{ secrets.CS_CLIENT_KEY }}
110102
client-access-key: ${{ secrets.CS_CLIENT_ACCESS_KEY }}
111103

112-
# Belt-and-braces for the concurrency guard: a run that was hard-killed
113-
# can skip its `down` and leak a container holding the host port onto a
114-
# REUSED runner. `|| true` so a clean runner is not an error.
115-
- name: Clear any leaked containers from a prior run
116-
run: docker compose -f local/docker-compose.${{ matrix.db }}.yml down -v --remove-orphans || true
117-
104+
# No pre-`up` cleanup step any more: the project name is unique per job, so
105+
# a container leaked by a hard-killed prior run cannot hold this job's
106+
# name or its (ephemeral) port. Blanket-pruning would now be actively
107+
# unsafe — without the concurrency group, another job's stack may be live
108+
# on this runner.
118109
- name: Start ${{ matrix.db }}
119-
run: docker compose -f local/docker-compose.${{ matrix.db }}.yml up -d --wait
110+
id: db
111+
uses: ./.github/actions/integration-db
112+
with:
113+
db: ${{ matrix.db }}
120114

121115
# `globalSetup` installs EQL v3 by shelling out to the real
122116
# `stash eql install --eql-version 3`, so an installer regression fails
123117
# here rather than hiding behind a test-only SQL apply.
124118
- name: prisma-next v3 family suites
125119
run: pnpm --filter @cipherstash/prisma-next run test:integration
120+
env:
121+
# Step env, not a `.env` file: `dotenv/config` does not override an
122+
# already-set `process.env`, so these win and no secret hits disk.
123+
DATABASE_URL: ${{ steps.db.outputs.database-url }}
124+
PGRST_URL: ${{ steps.db.outputs.pgrest-url }}
126125

126+
# Guarded on the project being set: if the stack never came up, there is
127+
# nothing to tear down and an unguarded `-p ""` would fail the job with a
128+
# confusing error that masks the real one.
127129
- name: Stop ${{ matrix.db }}
128-
if: always()
129-
run: docker compose -f local/docker-compose.${{ matrix.db }}.yml down -v
130+
if: always() && env.CS_COMPOSE_PROJECT != ''
131+
run: docker compose -p "$CS_COMPOSE_PROJECT" -f "$CS_COMPOSE_FILE" down -v

0 commit comments

Comments
 (0)