The monorepo was on npm workspaces. pnpm gives it a content-addressed store
shared between the eight packages, a lockfile that records the whole graph
rather than a flattened view of it, and — the reason this mattered in practice —
`workspace:*`, which makes an internal dependency unambiguous instead of a
version range that npm may satisfy from the registry.
Mechanics:
- `packageManager: pnpm@11.21.0` pins the version; corepack installs it in CI
and in the image, so all three environments resolve identically.
- The npm `workspaces` array is replaced by `pnpm-workspace.yaml`. pnpm
ignores the former, and keeping both would leave two sources of truth.
- All six internal dependencies moved to `workspace:*`.
- Root scripts use `pnpm -r --if-present` and `pnpm -F <pkg>`.
Two findings worth recording, both from running it rather than reading it:
`tsx` was a devDependency, but the server runs TypeScript directly in
production — the container's command is `pnpm exec tsx apps/api/src/server.ts`.
Under npm this was concealed by the runtime stage re-installing tsx by hand
after pruning dev dependencies. Under `pnpm install --prod` that sleight of
hand stops working and the image simply fails to start. tsx is now declared in
`dependencies`, which is what it has always actually been.
The first image build failed with ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY.
That is not a pnpm bug: it had decided the modules directory was stale and
wanted confirmation before deleting it, which a non-interactive build cannot
give. The trigger was the host's `node_modules` reaching the build context —
there was no `.dockerignore` at all. pnpm's tree is symlinks into a
content-addressed store, so copying it into an image produces dangling links
and a directory pnpm rightly considers corrupt. Fixed by adding
`.dockerignore` and setting `CI=true`, which is required in any non-interactive
pnpm build.
`esbuild` is denied install scripts via `allowBuilds`. Its platform binary
arrives through the optional dependency `@esbuild/linux-x64` and the postinstall
only verifies it; confirmed by running the binary directly, which reports
0.25.12.
Verified under pnpm: typecheck clean, 150 tests / 0 failures, e2e passes, web
builds. The image was built and booted against a real Postgres — health ok,
`/api/dashboard` 401 with an issuer configured, `/` and `/capacity` serve the
SPA, `/og.png` serves as image/png, and the migrator runs from the pruned
runtime stage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The runner is configured with `container.network: host`. That one setting
explains all three earlier failures, and the workflow now records them so
nobody repeats the sequence:
services: not resolvable by name from a host-networked
job — "getaddrinfo EAI_AGAIN postgres"
--network container:$HOSTNAME /etc/hostname is the HOST's name, not a
container id, so the join finds nothing
default-gateway addressing wrong idea outright: with host networking the
default route is the real router, not a bridge
Sharing the host's network namespace means a published port is just on
127.0.0.1. Readiness is now checked over TCP from the job itself rather than
with pg_isready inside the container — the latter proves the server started,
not that this job can reach it, which is the thing that actually failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second attempt failed differently: /etc/hostname inside the job reports the
HOST's name rather than the container id, so `--network container:$HOSTNAME`
found no such container.
Rather than hunt for our own container id through /proc, publish the port on
the host and connect through the job container's default gateway. That needs
no container identity at all. The port is derived from the run id so two
concurrent runs cannot collide, and DATABASE_URL is exported through GITHUB_ENV
once Postgres is actually accepting connections.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first run got through install, typecheck and all 39 tests, then failed on
`getaddrinfo EAI_AGAIN postgres`. This runner does not attach service
containers to the job's network, so the `services:` hostname never resolves.
Fixed by starting Postgres with `--network container:$HOSTNAME`, sharing the
job container's own network namespace so it appears on 127.0.0.1. That works
regardless of how the runner is configured — which matters here because the
runner is shared with other repositories and should not need reconfiguring to
suit this one.
Also queries row counts through `docker exec` rather than a local psql, since
the runner image is not guaranteed to ship postgresql-client, and removes the
container in an `if: always()` step so a failed run does not leave it behind.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`npm test` did nothing until now. CI that runs no tests is theatre, so the
tests came first — 39 of them, over the two places where an error would be
silent and expensive.
packages/core: the margin arithmetic. Every dashboard figure, idle-capacity
alert and agent answer resolves through it, and wrong numbers still look like
numbers. The cases pin decisions rather than implementation: cost is charged
against the full commitment (a naive version reports the opposite sign on a
loss-making block), aggregation sums cents rather than averaging percentages
(averaging reports +22% on a book that is losing money), break-even prices the
remaining hours and returns null rather than Infinity when there are none, and
internal research burn counts as cost with no revenue.
packages/prime: the upstream mapping. Rounding rather than truncating cents,
because 2.43 is 2.4299999 in binary and a lost cent compounds across millions
of GPU-hours. And interconnect normalisation, where an unrecognised fabric maps
to Unknown rather than Ethernet — guessing low loses a deal, guessing high
sells a training customer a cluster that cannot train.
CI runs on push and pull request: typecheck all six packages, unit tests,
migrations applied twice to a real Postgres, a seed-idempotency assertion that
fails the build if row counts move on a second run, a server boot, the front-end
build, and a Docker build.
It also asserts the inline theme script's hash still matches the CSP the proxy
allows. That script prevents a white flash for dark-mode users; if it changes
without the CSP being updated, the browser silently blocks it and nothing
anywhere reports an error.
Deployment stays a script rather than push-to-deploy. Automating it would put
an SSH key with production write access on the CI runner — a real escalation
for a project this size. The script takes a database dump before migrating and
refuses to finish if an unauthenticated request returns anything but 401.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>