Files
pad/docker-compose.test.yml
xarmian 5aa4bbe319 fix(build): give each worktree its own Postgres test port, and refuse to run when it is unreachable (TASK-2708) (#1253)
* fix(build): give each worktree its own Postgres test port, and refuse to run when it is unreachable (TASK-2708)

docker-compose.test.yml bound the host port to 5445, so exactly one worktree
could run make test-pg at a time. With concurrent worktrees the normal
operating mode that produced three incidents in an afternoon: a
port-already-allocated collision, a container dying mid-run under concurrent
suites, and a stack orphaned by a removed worktree blocking the port for
everyone.

The worst of the three forged a gate leg: go test exited 2 having executed NO
TESTS because the database was unreachable, and exit 2 with zero FAIL lines
reads like a pass at a glance.

Docker now assigns the host port and the Makefile reads it back with
docker compose port. Before running anything the target probes the HOST path
the tests will use, from a throwaway container, and refuses with a banner
saying no tests executed rather than letting an unreachable database look like
a result. If the suite fails and the database is gone afterwards, it says the
failures are infrastructure.

The compose project name was already per-directory, so teardown never could
reach a sibling; the orphan recovery command is now documented where someone
looking for it will be.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* docs: record that make test-pg is now safe from concurrent worktrees (TASK-2708)

The worktree section is where a reader learns what is safe to run alongside a
sibling, so it is where this belongs — including that a privately-started
container is no longer needed, and the recovery command for a stack orphaned by
a deleted worktree.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* docs(store): the mutation-harness recipe reads the port back instead of hardcoding it (TASK-2708)

A paste-ready recipe in a comment is a consumed artifact: it said 5445, and
after the ephemeral-port change pasting it would connect to whatever else is on
that port, or to nothing. Found by re-running the prose sweep with a
path-scoped exclusion — the first pass piped through 'grep -v node_modules',
which filters by LINE CONTENT and had silently eaten the hits in files whose
matching line mentions node_modules.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* docs(build): say that the banner discriminates, not the exit code (TASK-2708)

Measured while building the counterfactual matrix: make collapses every failed
recipe to exit 2, so the infrastructure refusals and an ordinary test failure
are indistinguishable by status. The banners are the only discriminator, and a
reader who assumed otherwise would build automation on a difference that does
not exist.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* fix(build): unique compose project, guarded startup, loud teardown failure (TASK-2708)

All four from codex round 1, each verified in the recipe before accepting.

Compose defaults the project name to the directory BASENAME, so two checkouts
sharing a basename share a stack and one down -v tears down the other's
database mid-run — the cross-worktree teardown this task exists to prevent,
reached through a second door. The project name is now explicit and keyed to
the absolute path. My compose comment had claimed the default was already
sufficient, in the place the next reader would believe it.

up --wait now runs inside the guarded block: a health-check timeout used to
abort the recipe before teardown, leaving the stack behind and creating exactly
the orphan this task was filed about.

A failed teardown is announced with the command to reap the stack instead of
being swallowed. It does NOT fail the build: the tests genuinely ran and their
status is honest; the leak is a separate fact and is now a loud one.

make test-pg-project prints the name so an orphan can be reaped without
re-deriving it.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* fix(build): portable readiness probe, safe project derivation, honest recovery docs (TASK-2708)

Five from codex round 2, each verified before accepting.

The readiness probe used `docker run --network host`, which is Linux-only by
default. On Docker Desktop a perfectly healthy database would have been
reported unreachable and the target would have REFUSED TO RUN where it used to
work — a guard against false greens turned into a false red. It now uses the
host's pg_isready when present and falls back to an in-container check, which
is weaker but never lies about the platform.

The project name interpolated CURDIR into shell command text, so a checkout
path containing a quote would have broken the quoting. The shell now reads its
own working directory instead.

Teardown failures on the three guard exits were silenced by >/dev/null,
contradicting the loud-teardown promise those same guards make.

Two docs were falsified by my own earlier commit in this branch: CLAUDE.md
still told the reader to reap a stack by directory name, and the mutation
recipe in the store test omitted -p entirely, which is exactly the
same-basename collision the change exists to prevent.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* fix(build): tear the stack down on interrupt; record why a post-run outage is not reported (TASK-2708)

Round 3, one finding accepted and one refused.

ACCEPTED: Ctrl-C during go test killed the recipe shell before down -v, leaving
an orphaned stack — the exact failure this task was filed about. An INT/TERM
trap set before up covers startup as well.

REFUSED, with the premise checked rather than argued: the reviewer asked for
the post-run banner's EXIT_CODE gate to be dropped so a database dying after a
passing run is reported. storetest.NewPostgres skips only when the env var is
EMPTY; a database that is gone produces t.Fatalf, not a skip. So exit 0 means
every Postgres-backed test completed against a live database, and failing the
leg because the container stopped afterwards would convert honest greens into
reds. Written into the Makefile so the next reviewer does not re-raise it.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* fix(build): probe the host-published port on every platform; interrupt reports teardown honestly (TASK-2708)

Round 4, both findings real.

The readiness fallback ran 'compose exec pg_isready', which answers whether the
server is alive INSIDE the container — a broken host port mapping passes it and
the guard is bypassed. Not a rarely-exercised path either: this box has no host
pg_isready, so the fallback is the branch that has been running all along. It
now reaches back through host.docker.internal from a throwaway container, which
is native on Docker Desktop and resolves on Linux via
--add-host=...:host-gateway. Verified against a live stack, with a negative
control on a port nothing listens on.

That is the third version of this probe. --network host was Linux-only and
would have falsely refused on Desktop; compose exec was portable but asked a
narrower question than the claim it carried.

The interrupt trap announced 'stack torn down' unconditionally, so an
interrupted run whose teardown failed reported successful cleanup. It now
reports what happened and names the command to reap the stack.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-05 10:17:47 -04:00

43 lines
1.9 KiB
YAML

# PostgreSQL for integration tests.
# Usage: make test-pg (starts, runs, tears down)
# make test-pg-down (tear down a stack left behind)
#
# THE HOST PORT IS EPHEMERAL AND THAT IS THE POINT (TASK-2708). It used to be
# hardcoded to 5445, which let exactly ONE worktree run the Postgres leg at a
# time: a second `make test-pg` failed with "port is already allocated", and a
# stack orphaned by a removed worktree blocked the port for everyone until
# somebody found and reaped it. With concurrent worktrees the normal operating
# mode, that produced three incidents in one afternoon — including a run that
# exited 2 having executed NO TESTS, which reads a lot like green at a glance.
#
# Docker assigns the host port; the Makefile reads it back with
# `docker compose port postgres 5432` and builds PAD_TEST_POSTGRES_URL from it.
# Do not reintroduce a fixed host port here, and do not hardcode one in a
# script — derive it, or you have rebuilt the collision.
#
# The Makefile also sets an EXPLICIT compose project name, keyed to the
# checkout's absolute path. Compose's default is the directory BASENAME, which
# is per-worktree in the usual layout but NOT unique — two checkouts sharing a
# basename would share a stack, and one `down -v` would tear down the other's
# database mid-run. An earlier version of this comment claimed the default was
# already sufficient; it is not, and the Makefile no longer relies on it.
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: pad
POSTGRES_PASSWORD: pad
POSTGRES_DB: pad
ports:
# No host side: Docker picks a free port. Read it back rather than
# assuming one (see the header).
- "5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pad"]
interval: 2s
timeout: 2s
retries: 10
tmpfs:
- /var/lib/postgresql/data # RAM-backed for speed