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>
This commit is contained in:
+27
-10
@@ -59,6 +59,19 @@ jobs:
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
# Corepack ships with Node and installs the exact pnpm pinned by
|
||||
# `packageManager` in package.json, so CI, the image and a laptop all run
|
||||
# the same version. `--activate` puts it on PATH; the download prompt is
|
||||
# disabled because a non-interactive runner cannot answer it and would
|
||||
# otherwise hang until the job times out.
|
||||
- name: Enable pnpm
|
||||
env:
|
||||
COREPACK_ENABLE_DOWNLOAD_PROMPT: '0'
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare --activate
|
||||
pnpm --version
|
||||
|
||||
- name: Start Postgres
|
||||
run: |
|
||||
PG_PORT=$(( 45000 + (${{ github.run_id }} % 15000) ))
|
||||
@@ -91,38 +104,42 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Install
|
||||
run: npm install --no-audit --no-fund
|
||||
# --frozen-lockfile fails rather than quietly resolving a different
|
||||
# tree when the lockfile and manifests disagree. That is the whole
|
||||
# point of committing a lockfile, and it is the default in CI anyway —
|
||||
# stated here so it survives someone running this locally.
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck every package
|
||||
run: npm run typecheck
|
||||
run: pnpm run typecheck
|
||||
|
||||
- name: Unit tests
|
||||
run: npm test --workspaces --if-present
|
||||
run: pnpm run test
|
||||
|
||||
- name: Migrations apply to a real Postgres
|
||||
run: npx tsx packages/db/src/migrate.ts
|
||||
run: pnpm exec tsx packages/db/src/migrate.ts
|
||||
|
||||
- name: Migrations are re-runnable
|
||||
run: npx tsx packages/db/src/migrate.ts
|
||||
run: pnpm exec tsx packages/db/src/migrate.ts
|
||||
|
||||
- name: Seed is idempotent
|
||||
# A seed that duplicates on a second run corrupts any database it is
|
||||
# pointed at twice, and nobody notices until the counts look odd.
|
||||
run: |
|
||||
npx tsx packages/db/src/seed/index.ts > /dev/null
|
||||
pnpm exec tsx packages/db/src/seed/index.ts > /dev/null
|
||||
count() { docker exec "$PG_CONTAINER" psql -U pig -d pig -tAc "select count(*) from contacts"; }
|
||||
BEFORE=$(count)
|
||||
npx tsx packages/db/src/seed/index.ts > /dev/null
|
||||
pnpm exec tsx packages/db/src/seed/index.ts > /dev/null
|
||||
AFTER=$(count)
|
||||
echo "contacts: $BEFORE -> $AFTER"
|
||||
test "$BEFORE" = "$AFTER" || { echo "SEED IS NOT IDEMPOTENT"; exit 1; }
|
||||
|
||||
- name: Critical path E2E against Postgres and Hono
|
||||
run: npm run test:e2e
|
||||
run: pnpm run test:e2e
|
||||
|
||||
- name: Server boots and answers
|
||||
run: |
|
||||
NODE_ENV=development PIG_PORT=8930 npx tsx apps/api/src/server.ts &
|
||||
NODE_ENV=development PIG_PORT=8930 pnpm exec tsx apps/api/src/server.ts &
|
||||
for i in $(seq 1 30); do
|
||||
curl -sf http://127.0.0.1:8930/api/health && break
|
||||
sleep 1
|
||||
@@ -130,7 +147,7 @@ jobs:
|
||||
curl -sf http://127.0.0.1:8930/api/health | grep -q '"ok":true'
|
||||
|
||||
- name: Front end builds
|
||||
run: npm run build -w @pig/web
|
||||
run: pnpm -F @pig/web run build
|
||||
|
||||
- name: Inline theme script still matches the deployed CSP hash
|
||||
# The proxy allows exactly one inline script by hash. If the script
|
||||
|
||||
Reference in New Issue
Block a user