Tasks 014 — Render deploy on merge
Execution skill: superpowers:subagent-driven-development — one implementer per task, then a two-stage review (spec compliance, then code quality). superpowers:test-driven-development applies inside every task: no production code before a failing test that demands it. Reach for superpowers:systematic-debugging on any surprise rather than guessing.
Derived from plan.md (approved). Each task is small, independently verifiable, and reviewed as its own diff. Split where a reviewer could reject one task while approving its neighbour — not where the work merely changes subject. A task is done only when it satisfies the definition of done in CLAUDE.md.
Global Constraints in plan.md apply to every task and are not repeated per task.
T1 — The API binds the platform-provided port
Satisfies: R5; enables P1 #4 / SC3 (the API answers on the port Render assigns).
The port change is trivial to write but must be TDD'd through a pure, testable helper rather than a raw edit to bootstrap() (which binds a socket and can't be unit-tested). Extract the resolution, test it, then wire it into main.ts.
[ ] RED: create
apps/api/src/config/port.test.tsimporting a not-yet-existing helper, oneitper behaviour named forR5:```ts import { describe, expect, it } from 'vitest'; import { resolvePort } from './port'; describe('resolvePort (R5)', () => { it('uses PORT when set to a positive integer', () => { expect(resolvePort({ PORT: '10000' })).toBe(10000); }); it('falls back to 3000 when PORT is unset (local dev)', () => { expect(resolvePort({})).toBe(3000); }); it('falls back to 3000 when PORT is not a valid number', () => { expect(resolvePort({ PORT: 'nope' })).toBe(3000); }); }); ``` Run `pnpm exec vitest run apps/api/src/config/port.test.ts` — watch it FAIL (module missing).[ ] GREEN: create
apps/api/src/config/port.ts:```ts export function resolvePort(env: NodeJS.ProcessEnv): number { const parsed = Number(env.PORT); return Number.isInteger(parsed) && parsed > 0 ? parsed : 3000; } ``` Rerun — PASS.[ ] GREEN: wire it into
apps/api/src/main.ts— importresolvePortand replace the hardcodedawait app.listen(3000, '0.0.0.0')withawait app.listen(resolvePort(process.env), '0.0.0.0'). No other change tomain.ts.[ ] Confirm the test has teeth — temporarily make
resolvePortalwaysreturn 3000, watch thePORT: '10000'case fail, restore.[ ]
pnpm typecheckexits 0 (noany, no!;NodeJS.ProcessEnvcomes from the api'stypes: ["node"]);pnpm lintexits 0 (pnpm formatfirst if needed);pnpm testgreen.[ ] Commit (
api: bind the platform-provided PORT for deploy).
Verified by: apps/api/src/config/port.test.ts (all three cases); pnpm typecheck/lint/test green.
T2 — The API Docker image (build context = monorepo root)
Satisfies: R2; the packaging that makes P1 #4/SC3 true on Render.
Docker was chosen at plan approval (2026-08-13). The image must preserve the pnpm workspace layout so the source-.ts @gw2priory/* packages resolve at runtime (plan.md § Approach; monorepo.md): base node:22 (a concrete ≥ 22.18 tag), install with a frozen lockfile, build the api, and run node apps/api/dist/main.js from a filesystem where the workspace symlinks are intact. Do not use pnpm deploy or otherwise flatten packages into node_modules — that moves their realpath insidenode_modules and reintroduces the type-strip failure (monorepo.md F12).
[ ] Create root
.dockerignoreexcluding everything the in-container install must not see, so the build does a clean install and the context stays small:``` **/node_modules **/dist .git .github .claude specs docs **/.vitepress/dist **/*.test.ts ```[ ] Create
apps/api/Dockerfile(context is the repo root). Shape — pin the exactnode:22.xtag and the@swc/corebuild allowance (pnpm-workspace.yaml) at implementation:```dockerfile FROM node:22-slim RUN corepack enable WORKDIR /repo # Manifests first for layer caching, then a frozen install (keeps the pnpm # workspace symlink layout that the .ts packages resolve through at runtime). COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ COPY apps/api/package.json apps/api/ COPY packages/domain/package.json packages/domain/ COPY packages/recipe-graph/package.json packages/recipe-graph/ COPY packages/legendary-recipes/package.json packages/legendary-recipes/ RUN pnpm install --frozen-lockfile # Source, then build only the api (packages stay as source .ts). COPY . . RUN pnpm --filter @gw2priory/api build # Render injects PORT; the app binds 0.0.0.0:$PORT via resolvePort (T1). CMD ["node", "apps/api/dist/main.js"] ```[ ] Prove the runtime
.ts-resolution locally without Docker (this sandbox has no Docker — see Notes). From the repo root:pnpm --filter @gw2priory/api build, thenPORT=8080 node apps/api/dist/main.jsin the background, thencurl -sf localhost:8080/health→ a200JSON body. This proves the built api boots and the@gw2priory/*runtime imports (netSellPrice, recipe-graph values) resolve — the hard part ofR2. Stop the process.[ ] If Docker is available in the run environment:
docker build -f apps/api/Dockerfile -t gw2priory-api .,docker run --rm -e PORT=8080 -p 8080:8080 gw2priory-api,curl -sf localhost:8080/health→200. If Docker is not available, record that the container-level proof is deferred to the first Render deploy's/healthcheck (SC3), noting it indeploy.md's log (T4).[ ]
pnpm typecheck/lint/teststill green (no app code changed here beyond T1).[ ] Commit (
api: add Dockerfile and .dockerignore for the Render image).
Verified by: local node apps/api/dist/main.js + curl /health → 200; docker build+run where available, else the first Render deploy's health check (recorded in deploy.md).
T3 — The blueprint render.yaml, guarded by a structural test
Satisfies: R1, R3, R6, R7, R8, R9, region; SC7, SC8, P2 #3, P3 #1, P3 #2.
The test is written first and drives the blueprint, mirroring tests/ci/workflow.test.ts over ci.yml. yaml is already a devDependency (spec 003) — no pnpm add; just ensure pnpm install has run. Pin the exact Render static-site keys (type: static vs type: web+runtime: static; staticPublishPath) against Render's current blueprint spec at implementation, and assert whichever the schema uses in both the test and the file.
[ ] RED: create
tests/deploy/render-blueprint.test.tsthat reads and parsesrender.yamland asserts the shape fromplan.md§ Data & contracts, oneitper criterion:```ts import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parse } from 'yaml'; import { describe, expect, it } from 'vitest'; const repoRoot = fileURLToPath(new URL('../..', import.meta.url)); const blueprint = parse( readFileSync(join(repoRoot, 'render.yaml'), 'utf8'), ) as { services: Array<Record<string, any>> }; const services = blueprint.services; const api = services.find((s) => s.runtime === 'docker'); const web = services.find((s) => s !== api); const paths = (s: Record<string, any>): string[] => s.buildFilter?.paths ?? []; describe('render.yaml (R1)', () => { it('declares exactly two services', () => { expect(services).toHaveLength(2); }); it('R2/R9/R6: the api is a free docker service in frankfurt with a /health check', () => { expect(api?.dockerfilePath).toBe('apps/api/Dockerfile'); expect(api?.plan).toBe('free'); expect(api?.region).toBe('frankfurt'); expect(api?.healthCheckPath).toBe('/health'); }); it('R7: both services deploy only after CI passes', () => { expect(api?.autoDeployTrigger).toBe('checksPass'); expect(web?.autoDeployTrigger).toBe('checksPass'); }); it('R3: the web proxies /api/* (prefix-stripped) then falls back to index.html, in order', () => { const routes = web?.routes as Array<Record<string, string>>; expect(routes[0].type).toBe('rewrite'); expect(routes[0].source).toBe('/api/*'); expect(routes[0].destination).toMatch(/gw2priory-api/); expect(routes[0].destination.endsWith('/:splat')).toBe(true); expect(routes[1]).toEqual({ type: 'rewrite', source: '/*', destination: '/index.html', }); }); it('R8/P3: build filters scope each service and share packages/lockfile', () => { expect(paths(api!)).toEqual( expect.arrayContaining(['apps/api/**', 'packages/**', 'pnpm-lock.yaml']), ); expect(paths(web!)).toEqual( expect.arrayContaining(['apps/web/**', 'pnpm-lock.yaml']), ); expect(paths(web!)).not.toContain('apps/api/**'); }); }); ``` Run `pnpm exec vitest run tests/deploy/render-blueprint.test.ts` — watch it FAIL (file missing).[ ] GREEN: create
render.yamlexactly asplan.md§ Approach specifies — two services (staticwebbuildingapps/web→apps/web/distwith the two ordered routes; dockerapiwithdockerfilePath: apps/api/Dockerfile,dockerContext: .,plan: free,healthCheckPath: /health), bothregion: frankfurt, bothautoDeployTrigger: checksPass, the per-servicebuildFilter.paths. Rerun — PASS.[ ] Confirm the test has teeth — swap the two web routes, watch the ordering case fail; add
apps/api/**to the web filter, watch the isolation case fail; restore both.[ ]
pnpm typecheckexits 0 (the test typechecks — theanyin the parse cast is the one isolated boundary cast, commented);pnpm lintexits 0;pnpm testgreen.[ ] Commit (
deploy: add render.yaml blueprint and its structural guard).
Verified by: tests/deploy/render-blueprint.test.ts (all cases); pnpm typecheck/lint/test green.
T4 — Document the deploy shape, the one-time human step, and the verification log
Satisfies: R10, R11; creates the dated-verification home the observational criteria (SC1–SC6, P1 #1–#5, P2 #1–#2) are recorded in at step 5, and captures F1/F2.
Docs, not code — so the RED step guards existence and linkage rather than behaviour, matching 003's T2.
[ ] RED: add to
tests/deploy/render-blueprint.test.tsadescribe('the deploy is documented')with a case assertingdocs/architecture/deploy.mdexists, contains a heading mentioning the one-time Render↔GitHub connection, and thatCLAUDE.mdreferencesdocs/architecture/deploy.md:```ts const read = (p: string) => readFileSync(join(repoRoot, p), 'utf8'); describe('the deploy is documented (R10/R11)', () => { it('deploy.md exists and covers the one-time connection', () => { const doc = read('docs/architecture/deploy.md'); expect(doc).toMatch(/connect|Blueprint|GitHub/i); }); it('CLAUDE.md references deploy.md', () => { expect(read('CLAUDE.md')).toMatch(/docs\/architecture\/deploy\.md/); }); }); ``` Run — watch it FAIL (doc missing).[ ] GREEN: create
docs/architecture/deploy.mdcapturing, in prose a future reader can act on: - The deploy shape — Render, onerender.yaml: free Static Site (CDN, Frankfurt) forapps/webwith the ordered/api/*:splatproxy +/*→/index.htmlfallback; dockerapps/apiweb service on the free tier, Frankfurt; deploy-on-maingated by CI viaautoDeployTrigger: checksPass(no new workflow). - The one-time human step (R10) — create the Render account, connect the GitHub repo via Render's integration, run the first Blueprint sync. State plainly why it is not a committed artifact (a Render-side connection), mirroringci.md's branch-protection note. -F2— first-sync URL check — after the first sync, confirm the api service's real URL ishttps://gw2priory-api.onrender.com; if Render suffixed the name, updaterender.yaml's route destination to match and re-sync. -F1— Swagger (/api-docs) is not proxied by/api/*; it lives on the api's own URL. - The free-tier trade-off (R9) — the api spins down when idle and cold-starts on the next request; the Static Site (CDN) does not. Flippingplan: free→ a paid tier is one line. - A "Verification log" section with placeholder rows for the dated observations filled at step 5 (SC1–SC6). Add a line toCLAUDE.md's Reference section linkingdocs/architecture/deploy.md. Rerun — PASS.[ ] Confirm teeth — remove the
CLAUDE.mdlink, watch the case fail, restore.[ ]
pnpm testgreen (confirm theCLAUDE.mdedit did not breakconstitution.test.tsor anytests/workflow/*invariant).[ ] Commit (
docs: capture the Render deploy shape and one-time setup).
Verified by: tests/deploy/render-blueprint.test.ts › the deploy is documented; pnpm test green.
T5 — Fill traceability and lock SC10 / SC9
Satisfies: SC10, SC9; final in-repo verification.
- [ ] RED: add to
tests/deploy/render-blueprint.test.tsa caseSC10: the spec 014 traceability table is complete and its named tests existthat parsesspecs/014-render-deploy/spec.md's traceability table (rows| criterion | … |), asserts no cell is empty, and that every backtick-quoted test file name in a cell is found as a real path undertests/orapps/(manual-record cells without backticks are allowed as notes). Run — watch it FAIL until the table is filled. - [ ] GREEN: fill the Test/verification column of
spec.md's traceability table fromplan.md§ Test strategy — automated rows cite`tests/deploy/render-blueprint.test.ts`(with the case name) or`apps/api/src/config/port.test.ts`; observational rows cite the dated record indocs/architecture/deploy.md. Rerun — PASS. - [ ] Confirm teeth — blank one table cell, watch the case fail, restore.
- [ ] Full in-repo verification:
pnpm typecheckexits 0;pnpm lintexits 0;pnpm testfully green includingtests/workflow/*andtests/ci/*(prior specs intact —SC9);find docs/superpowers -type f | wc -lprints0(SC9). - [ ] Commit (
specs: fill spec 014 traceability and lock SC10).
Verified by: tests/deploy/render-blueprint.test.ts › SC10: …; clean pnpm typecheck/lint/test; zero files under docs/superpowers/.
Notes
Staging area for decisions and surprises found during implementation — including anything that turned out differently from what plan.md assumed. Move each one into spec.md, research.md, or docs/ before closing the feature; this section is not a home.
- Environment quirk (this sandbox):
node/pnpmare not on the defaultPATH; they live at/opt/homebrew/bin. Prefixexport PATH="/opt/homebrew/bin:$PATH"when a command reportscommand not found. - No Docker in this sandbox.
dockeris not installed here, so T2's container-level build can't be run locally; the runtime.ts-resolution is instead proven withnode apps/api/dist/main.js+curl /health, and the container proof is deferred to the first Render deploy's/healthcheck (SC3). If a later run environment has Docker, do thedocker build+runsmoke then. - After each task, run
pnpm formatbefore committing sopnpm lintstays green throughout. - Verification at step 5 (not a coding task — the observational criteria). Proven only on the real Render deploy and recorded with dates in
docs/architecture/deploy.md:- The human creates the Render account, connects the repo, and runs the first Blueprint sync (
R10), then does theF2URL check and fixesrender.yamlif the api name was suffixed. SC1/P1 #1— the web URL renders over HTTPS.SC4/P1 #5— a deep-link reload serves the app.SC2/P1 #3—curl -I https://<web>/api/health→200same-origin (not a3xx), prefix stripped: the proxy-not-redirect proof (research.mdV1 residual).SC3/P1 #4— the api/healthreturns200at its own URL (also proves the Docker build).SC5/P2 #1–#2— a greenmaincommit deploys; a red/pending one does not.SC6/P3— a web-only commit redeploys only the web service (from the deploy logs).- Record each with its date; the traceability rows for these cite that log.
- The human creates the Render account, connects the repo, and runs the first Blueprint sync (
- Graduation (step 6):
research.md§ Graduation lists the deploy facts to consolidate intodocs/architecture/deploy.md— most land in T4; confirm nothing is left only inresearch.mdbefore closing the branch (superpowers:finishing-a-development-branch). - Isolation: already implementing in the
014-render-deployworktree (constitution step 0), so a long run cannot disturb the shared tree.