fix(ci): isolate concurrent runs on a shared Docker daemon and explain readiness failures
Cause of PR #51's migration-smoke failure.scripts/test-migrations-integration.sh gave PostgreSQL an ephemeral host port (POSTGRES_PORT=0) but inherited S3_PORT=8333 from .env.example. Several runs were active on one runner's Docker daemon (Gitea 1.24 ignores concurrency), so they contended for host port 8333. Compose's own error was sent to /dev/null, so the job said only "did not become ready".
Isolation: audit of every script CI runs
Script / job
Shared resource
Change
test-migrations-integration.sh
host port 8333
S3_PORT=0; the bound port was already discovered with compose port
Compose project name (run id + PID; job containers often share low PIDs)
random suffix
smoke-deploy.sh
edge subnet 10.249.<random>.0/24, about a 1-in-200 collision for each pair of concurrent runs
skip subnets already on the daemon, and choose again on Docker's overlap error (up to 5 tries)
CI image-build
image tag ekklesia-ci:$GITHUB_SHA (a PR and a push of one commit share it; one run's cleanup removes the other's image)
tag adds ${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}
Already isolated
every other published host port (ephemeral), the TLS suite's container and network (pid + random), smoke staging dirs and built image tags (random)
—
Not changed: the Dockerfile's BuildKit pnpm-store cache mount (id=ekklesia-pnpm-store) is shared between concurrent builds by design. The pnpm store is content-addressed and tolerates concurrent writers.
Everything passes through compose_diagnostics_redact, which masks:
literal values the script supplies (its generated passwords and keys);
credential-looking NAME=value / NAME: value pairs;
URL user information;
the rest of any Authorization header.
It also strips terminal control sequences. All three scripts use it at every readiness check. The infrastructure smoke's previous unredacted full-log dump on failure is replaced by it.
Example: a reproduced port collision now reports its cause.
Disposable PostgreSQL and object storage did not become ready.
--- docker compose up (last 20 lines)
...
Error response from daemon: driver failed programming external connectivity on endpoint ...-object-storage-1 (...): Bind for 127.0.0.1:8333 failed: port is already allocated
--- docker compose ps
...object-storage-1 ... Created
...postgres-1 ... Up Less than a second (health: starting) 127.0.0.1:32832->5432/tcp
--- docker compose logs postgres (last 40 lines)
...
Tests
tests/ci-scripts.test.ts covers:
ephemeral ports, and no fixed -p/PORT= anywhere in the CI scripts;
invocation-unique project names;
the subnet choice and retry;
the per-run CI image tag;
diagnostics wired into every readiness check, with no raw log printing;
the redaction itself, run through bash.
Verification (local)
pnpm ci:verify: passes.
pnpm test:migrations:integration: 306/306, with noS3_PORT override while a dev stack held 127.0.0.1:8333, which is exactly the collision that failed #51.
scripts/smoke-infrastructure.sh: passes.
scripts/smoke-deploy.sh: passes, run concurrently with the two above on one daemon.
Forced race: a probe network covering 10.249.0.0/16 made every subnet overlap. The smoke chose four times, then reported Docker's "networks have overlapping IPv4" with ps and logs, and left no containers or networks behind.
## fix(ci): isolate concurrent runs on a shared Docker daemon and explain readiness failures
**Cause of PR #51's `migration-smoke` failure.** `scripts/test-migrations-integration.sh` gave PostgreSQL an ephemeral host port (`POSTGRES_PORT=0`) but inherited `S3_PORT=8333` from `.env.example`. Several runs were active on one runner's Docker daemon (Gitea 1.24 ignores `concurrency`), so they contended for host port 8333. Compose's own error was sent to `/dev/null`, so the job said only "did not become ready".
### Isolation: audit of every script CI runs
| Script / job | Shared resource | Change |
|---|---|---|
| `test-migrations-integration.sh` | host port 8333 | `S3_PORT=0`; the bound port was already discovered with `compose port` |
| `test-migrations-integration.sh`, `smoke-infrastructure.sh` | Compose project name (run id + PID; job containers often share low PIDs) | random suffix |
| `smoke-deploy.sh` | edge subnet `10.249.<random>.0/24`, about a 1-in-200 collision for each pair of concurrent runs | skip subnets already on the daemon, and choose again on Docker's overlap error (up to 5 tries) |
| CI `image-build` | image tag `ekklesia-ci:$GITHUB_SHA` (a PR and a push of one commit share it; one run's cleanup removes the other's image) | tag adds `${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}` |
| Already isolated | every other published host port (ephemeral), the TLS suite's container and network (`pid` + random), smoke staging dirs and built image tags (random) | — |
Not changed: the Dockerfile's BuildKit pnpm-store cache mount (`id=ekklesia-pnpm-store`) is shared between concurrent builds by design. The pnpm store is content-addressed and tolerates concurrent writers.
### Readiness diagnostics
`scripts/lib/compose-diagnostics.sh` (`compose_report_not_ready`) prints:
- the last 20 lines of the failed `up`;
- `docker compose ps --all`;
- the last 40 log lines of every service.
Everything passes through `compose_diagnostics_redact`, which masks:
- literal values the script supplies (its generated passwords and keys);
- credential-looking `NAME=value` / `NAME: value` pairs;
- URL user information;
- the rest of any Authorization header.
It also strips terminal control sequences. All three scripts use it at every readiness check. The infrastructure smoke's previous unredacted full-log dump on failure is replaced by it.
**Example: a reproduced port collision now reports its cause.**
```
Disposable PostgreSQL and object storage did not become ready.
--- docker compose up (last 20 lines)
...
Error response from daemon: driver failed programming external connectivity on endpoint ...-object-storage-1 (...): Bind for 127.0.0.1:8333 failed: port is already allocated
--- docker compose ps
...object-storage-1 ... Created
...postgres-1 ... Up Less than a second (health: starting) 127.0.0.1:32832->5432/tcp
--- docker compose logs postgres (last 40 lines)
...
```
### Tests
`tests/ci-scripts.test.ts` covers:
- ephemeral ports, and no fixed `-p`/`PORT=` anywhere in the CI scripts;
- invocation-unique project names;
- the subnet choice and retry;
- the per-run CI image tag;
- diagnostics wired into every readiness check, with no raw log printing;
- the redaction itself, run through bash.
### Verification (local)
- **`pnpm ci:verify`**: passes.
- **`pnpm test:migrations:integration`**: 306/306, with **no** `S3_PORT` override while a dev stack held 127.0.0.1:8333, which is exactly the collision that failed #51.
- **`scripts/smoke-infrastructure.sh`**: passes.
- **`scripts/smoke-deploy.sh`**: passes, run concurrently with the two above on one daemon.
- **Forced race:** a probe network covering `10.249.0.0/16` made every subnet overlap. The smoke chose four times, then reported Docker's "networks have overlapping IPv4" with ps and logs, and left no containers or networks behind.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
PR #51's migration-smoke failed 0.7 s after the build: the suite pinned
PostgreSQL to an ephemeral port but inherited S3_PORT=8333 from
.env.example, so concurrent runs on one runner (Gitea ignores workflow
concurrency) contended for one host port. Compose's own error was
discarded, so the failure said only that the services "did not become
ready".
Isolation, from an audit of every script CI runs:
- test-migrations-integration.sh: S3_PORT=0, like POSTGRES_PORT=0.
- test-migrations-integration.sh and smoke-infrastructure.sh: project names
gain a random suffix; run id plus PID alone is not unique when job
containers share low PIDs.
- smoke-deploy.sh: the edge /24 is chosen from those no network on the
daemon uses, and the first `up` chooses again when a concurrent run wins
a race for the same subnet (Docker's address-pool overlap), up to five
times.
- CI image-build: the image tag includes the run id and attempt, so a pull
request and a push of the same commit cannot remove each other's image.
- Already isolated: every other published port (ephemeral), the TLS
suite's container and network (random), smoke staging directories and
image tags (random).
Diagnostics: scripts/lib/compose-diagnostics.sh prints the failed `up`
command's output, `docker compose ps`, and the last 40 log lines of each
service, all redacted (supplied literals, credential-looking NAME=value
pairs, URL user information, Authorization headers) with terminal control
sequences stripped. All three scripts use it at every readiness check; the
infrastructure smoke's unredacted full-log dump is replaced by it.
tests/ci-scripts.test.ts pins ephemeral ports, unique project names, the
subnet choice, the CI image tag, the diagnostics, and the redaction itself
(run through bash).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
fix(ci): isolate concurrent runs on a shared Docker daemon and explain readiness failures
Cause of PR #51's
migration-smokefailure.scripts/test-migrations-integration.shgave PostgreSQL an ephemeral host port (POSTGRES_PORT=0) but inheritedS3_PORT=8333from.env.example. Several runs were active on one runner's Docker daemon (Gitea 1.24 ignoresconcurrency), so they contended for host port 8333. Compose's own error was sent to/dev/null, so the job said only "did not become ready".Isolation: audit of every script CI runs
test-migrations-integration.shS3_PORT=0; the bound port was already discovered withcompose porttest-migrations-integration.sh,smoke-infrastructure.shsmoke-deploy.sh10.249.<random>.0/24, about a 1-in-200 collision for each pair of concurrent runsimage-buildekklesia-ci:$GITHUB_SHA(a PR and a push of one commit share it; one run's cleanup removes the other's image)${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}pid+ random), smoke staging dirs and built image tags (random)Not changed: the Dockerfile's BuildKit pnpm-store cache mount (
id=ekklesia-pnpm-store) is shared between concurrent builds by design. The pnpm store is content-addressed and tolerates concurrent writers.Readiness diagnostics
scripts/lib/compose-diagnostics.sh(compose_report_not_ready) prints:up;docker compose ps --all;Everything passes through
compose_diagnostics_redact, which masks:NAME=value/NAME: valuepairs;It also strips terminal control sequences. All three scripts use it at every readiness check. The infrastructure smoke's previous unredacted full-log dump on failure is replaced by it.
Example: a reproduced port collision now reports its cause.
Tests
tests/ci-scripts.test.tscovers:-p/PORT=anywhere in the CI scripts;Verification (local)
pnpm ci:verify: passes.pnpm test:migrations:integration: 306/306, with noS3_PORToverride while a dev stack held 127.0.0.1:8333, which is exactly the collision that failed #51.scripts/smoke-infrastructure.sh: passes.scripts/smoke-deploy.sh: passes, run concurrently with the two above on one daemon.10.249.0.0/16made every subnet overlap. The smoke chose four times, then reported Docker's "networks have overlapping IPv4" with ps and logs, and left no containers or networks behind.🤖 Generated with Claude Code