Skip to content

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.ts importing a not-yet-existing helper, one it per behaviour named for R5:

    ```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 — import resolvePort and replace the hardcoded await app.listen(3000, '0.0.0.0') with await app.listen(resolvePort(process.env), '0.0.0.0'). No other change to main.ts.

  • [ ] Confirm the test has teeth — temporarily make resolvePort always return 3000, watch the PORT: '10000' case fail, restore.

  • [ ] pnpm typecheck exits 0 (no any, no !; NodeJS.ProcessEnv comes from the api's types: ["node"]); pnpm lint exits 0 (pnpm format first if needed); pnpm test green.

  • [ ] 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 .dockerignore excluding 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 exact node:22.x tag and the @swc/core build 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, then PORT=8080 node apps/api/dist/main.js in the background, then curl -sf localhost:8080/health → a 200 JSON body. This proves the built api boots and the @gw2priory/* runtime imports (netSellPrice, recipe-graph values) resolve — the hard part of R2. 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 /health check (SC3), noting it in deploy.md's log (T4).

  • [ ] pnpm typecheck/lint/test still 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.ts that reads and parses render.yaml and asserts the shape from plan.md § Data & contracts, one it per 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.yaml exactly as plan.md § Approach specifies — two services (static web building apps/web → apps/web/dist with the two ordered routes; docker api with dockerfilePath: apps/api/Dockerfile, dockerContext: ., plan: free, healthCheckPath: /health), both region: frankfurt, both autoDeployTrigger: checksPass, the per-service buildFilter.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 typecheck exits 0 (the test typechecks — the any in the parse cast is the one isolated boundary cast, commented); pnpm lint exits 0; pnpm test green.

  • [ ] 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.ts a describe('the deploy is documented') with a case asserting docs/architecture/deploy.md exists, contains a heading mentioning the one-time Render↔GitHub connection, and that CLAUDE.md references docs/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.md capturing, in prose a future reader can act on: - The deploy shape — Render, one render.yaml: free Static Site (CDN, Frankfurt) for apps/web with the ordered /api/* :splat proxy + /*→/index.html fallback; docker apps/api web service on the free tier, Frankfurt; deploy-on-main gated by CI via autoDeployTrigger: 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), mirroring ci.md's branch-protection note. - F2 — first-sync URL check — after the first sync, confirm the api service's real URL is https://gw2priory-api.onrender.com; if Render suffixed the name, update render.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. Flipping plan: 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 to CLAUDE.md's Reference section linking docs/architecture/deploy.md. Rerun — PASS.

  • [ ] Confirm teeth — remove the CLAUDE.md link, watch the case fail, restore.

  • [ ] pnpm test green (confirm the CLAUDE.md edit did not break constitution.test.ts or any tests/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.ts a case SC10: the spec 014 traceability table is complete and its named tests exist that parses specs/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 under tests/ or apps/ (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 from plan.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 in docs/architecture/deploy.md. Rerun — PASS.
  • [ ] Confirm teeth — blank one table cell, watch the case fail, restore.
  • [ ] Full in-repo verification: pnpm typecheck exits 0; pnpm lint exits 0; pnpm test fully green including tests/workflow/* and tests/ci/* (prior specs intact — SC9); find docs/superpowers -type f | wc -l prints 0 (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/pnpm are not on the default PATH; they live at /opt/homebrew/bin. Prefix export PATH="/opt/homebrew/bin:$PATH" when a command reports command not found.
  • No Docker in this sandbox. docker is not installed here, so T2's container-level build can't be run locally; the runtime .ts-resolution is instead proven with node apps/api/dist/main.js + curl /health, and the container proof is deferred to the first Render deploy's /health check (SC3). If a later run environment has Docker, do the docker build+run smoke then.
  • After each task, run pnpm format before committing so pnpm lint stays 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 the F2 URL check and fixes render.yaml if 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 → 200 same-origin (not a 3xx), prefix stripped: the proxy-not-redirect proof (research.md V1 residual).
    • SC3/P1 #4 — the api /health returns 200 at its own URL (also proves the Docker build).
    • SC5/P2 #1–#2 — a green main commit 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.
  • Graduation (step 6): research.md § Graduation lists the deploy facts to consolidate into docs/architecture/deploy.md — most land in T4; confirm nothing is left only in research.md before closing the branch (superpowers:finishing-a-development-branch).
  • Isolation: already implementing in the 014-render-deploy worktree (constitution step 0), so a long run cannot disturb the shared tree.