Plan 013 — Docs previews
Status: approved Approved by the human on 2026-08-13. The agent transcribed this status on the human's explicit instruction; the decision is the human's.
Written in plan mode from spec.md and research.md. Approved by the human before any code is written. Status is set by the human, never by the agent: proposed → approved.
Produced alone — tasks.md stays untouched until this plan is approved in turn.
Goal
Reading a rendered artifact stops being a local errand. After this lands, pushing to a branch is the only action a reviewer needs from an author: CI publishes that branch's built docs and GitHub surfaces the link on the pull request. Every gate in this project asks a human to approve a document, and until now the only convenient way to read one was as raw Markdown in a diff.
The platform half of this already exists and is proven — the Cloudflare project, the secrets, the verified deploy path (research.md V6). What is missing is the automation: today a human runs wrangler pages deploy by hand, so the preview is only as fresh as the last time someone remembered.
Approach
One workflow file gains one step and one job.
The existing check job keeps every check it has, unchanged and in order, and gains an upload-artifact step immediately after pnpm docs:build. A second job, docs, declares needs: check and does no install, no checkout, and no build — it downloads that artifact and hands the directory to wrangler-action. The published bytes are therefore provably the checked bytes, and the job costs a runner start rather than a second toolchain.
A single deploy step covers both preview and production. Cloudflare decides which is which by comparing the --branch value against the project's production branch (main, set at creation), so the workflow passes github.head_ref on a pull request and github.ref_name on a push, and needs no if: gate.
The link reaches the pull request through GitHub's own deployment mechanism rather than a bot comment. The docs job declares an environment whose name is an expression over github.event_name and whose url is the deploy step's deployment-url output. research.md V1 established that this split is not a style choice: environment.name may use the github context but not steps, while environment.url may use steps. Each key gets the only context it is allowed.
Target state of the changed portion of .github/workflows/ci.yml:
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
steps:
# ... unchanged through `pnpm docs:build` ...
- uses: actions/upload-artifact@v7
with:
name: docs-site
path: .vitepress/dist
retention-days: 1
docs:
needs: check
runs-on: ubuntu-latest
environment:
name: ${{ github.event_name == 'pull_request' && 'Docs preview' || 'Docs' }}
url: ${{ steps.deploy.outputs.deployment-url }}
steps:
- uses: actions/download-artifact@v8
with:
name: docs-site
path: dist
- id: deploy
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist --project-name=gw2-priory-docs --branch=${{ github.head_ref || github.ref_name }}environment.url carries deployment-url — the immutable per-deployment hash — and deliberately notpages-deployment-alias-url. research.md F2: the alias is documented as present "if it exists", and production deploys have none, so wiring the alias would yield an empty URL on exactly the main runs P2 cares about. It would have looked correct on this very pull request and broken on merge.
Architecture
The boundary that matters is the artifact. check knows nothing about publishing; docs knows nothing about building. Neither can be made to publish something the other did not verify, because the only channel between them is a named artifact produced after the last check.
check remains the sole required status check on main. docs failing — expired token, provider outage — leaves the pull request mergeable by construction, since nothing requires it (R8).
Tech stack
- GitHub Actions — already the CI platform (spec 003). This spec adds no second workflow file.
actions/upload-artifact@v7andactions/download-artifact@v8— the artifact hand-off between jobs. Majors read from each repository'sreleases/lateston 2026-08-13 (research.mdV5). These are not a matched pair; writingv7for both pins a download major that does not exist.cloudflare/wrangler-action@v4— runswrangler pages deployand exposesdeployment-url. Verified from its committedaction.yml(research.mdV5). The brainstorming sketch saidv3; that was wrong.- Cloudflare Pages, project
gw2-priory-docs, direct-upload mode, production branchmain. Created during discovery and already serving this branch.
No new repository dependency. Nothing is added to package.json: wrangler ships inside the action and runs only on the runner. The manual deploys during discovery used npx wrangler, which installs nothing into the repo either.
Global Constraints
Copied verbatim from the architecture docs. Every task inherits these; do not summarise or reword them — a test asserts they appear here unchanged.
From docs/architecture/typescript.md:
- No
any. Not in app code, not in tests. Useunknownplus narrowing, or model the type properly. If a third-party type forces it, isolate it behind one typed adapter and comment why. - No non-null assertions (
!) to silence the compiler. - No
@ts-expect-errorwithout a comment explaining what is expected and when it can be removed. - Validate everything crossing a boundary (GW2 API responses, HTTP input) at runtime, not just at the type level.
- Prefer pure functions for domain logic. The optimizer must be testable without a network or a database.
- Match the style of surrounding code. No new dependency without justification in the spec or plan.
moduleResolution: "node"andbaseUrlare removed in TS 7. Use"nodenext"(or"bundler"for Vite/bundler-resolved code) pluspaths: { "*": ["./*"] }in place ofbaseUrl.- TypeScript's default
libincludes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), ordocument/windowresolve everywhere, silently defeating profile isolation. A profile that needs DOM adds"DOM","DOM.Iterable"on top of that floor. experimentalDecoratorsandemitDecoratorMetadataare supported for typecheck only —tsc --noEmitaccepts decorator syntax and resolves metadata types, but--noEmitnever emitsdesign:paramtypesat runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; seestack.md's api build model.
From docs/architecture/stack.md:
- Monorepo, pnpm workspaces.
apps/api— NestJS (TypeScript).apps/web— React (TypeScript).packages/*— shared code (domain types, the curated Mystic Forge dataset) when sharing is real, not speculative.- Postgres for persistence. In-memory cache for the MVP — no Redis until the caching story earns it.
- Vitest everywhere, both apps.
- Deploy: managed PaaS (Fly.io / Railway). CI: GitHub Actions — lint + typecheck + test + build.
- Static data (items, station recipes) is immutable: cache hard.
- Prices are volatile: short TTL, recomputed live.
- GW2 API rate limit: per-IP token bucket, 300 burst, refill 5/sec,
429on overflow. Batch up to 200 ids per?ids=call. - All GW2 API access goes through the client that budgets this. Never call the GW2 API directly from a service.
- API keys are user secrets: encrypted at rest, never logged, never returned to the client.
From CLAUDE.md: typecheck clean, tests pass, every acceptance scenario and success criterion covered by a test whose name traces to it, no unexplained escape hatches, the human reviews the diff.
File Structure
Exact paths, and what each file is responsible for. A path here is a commitment; a task that touches a file not listed is a signal the plan missed something.
| Path | Change | Responsibility |
|---|---|---|
.github/workflows/ci.yml | modified | The upload step on check, and the whole docs job. The only file that changes behaviour. |
tests/ci/workflow.test.ts | modified | Structural guard. Rebind existing assertions to jobs.check by name, replace the single-job assertion, add the deploy-job assertions (P3) and spec 013 traceability (SC12). |
docs/architecture/ci.md | modified | The uncommittable half (R12): the Cloudflare project's identity and mode, the two secret names and the token's scope, the v-pre authoring rule, and the dated verification log for SC1–SC7. |
docs/gaps/docs-build-render-failures.md | new | Record of research.md F4 — docs:build exits 0 on a failed server render. A finding about spec 003's check, parked as a record, not fixed here. |
specs/013-docs-previews/spec.md | modified | Traceability table filled in; status moved to implemented inside this branch, by the human's decision, before merge. |
No file under apps/ or packages/ is touched (R14), so pnpm verify:contract is unaffected and no OpenAPI document changes.
Data & contracts
No runtime data, no HTTP endpoint, no schema. The only contract introduced is between the two jobs, and it is a single string that must match in three places:
| Name | Where it appears |
|---|---|
docs-site | upload-artifact's name, download-artifact's name, and the test that asserts the two agree |
That agreement is the one thing a rename can silently break — an upload under one name and a download under another produces a red job with an unhelpful message, which is why P3 #2 tests it rather than trusting it.
Test strategy
The spec's criteria split cleanly, and the split is the same one spec 003 made: workflow shape is asserted from the committed file; workflow behaviour on GitHub is observed once and dated.
Automated — extending tests/ci/workflow.test.ts, which already parses the workflow as YAML:
- Two jobs exist, named
checkanddocs, anddocsdeclaresneeds: check(P3 #1, SC8). - The artifact name uploaded by
checkequals the one downloaded bydocs, and the upload step comes after thepnpm docs:buildstep (P3 #2, SC8). - Credentials appear only as
secrets.*references; no literal that looks like a token (SC10). - Every assertion the suite makes today still holds, rebound to
jobs.checkby name (SC9). - Spec 013's traceability table has no empty cell and names only test files that exist (SC12), mirroring the existing
T3block for spec 003.
The one deliberate breakage. The suite currently reads Object.values(jobs)[0] at module level (tests/ci/workflow.test.ts:40) and asserts Object.keys(jobs) has length 1 (line 60). Both must change — not as incidental fallout but as the point: reading "the first job" would keep passing while silently asserting nothing about the job it meant. Spec 004 set this precedent when it changed the ordered-checks assertion, and left a comment saying why. This does the same.
Observational — SC1–SC7, recorded with dates in docs/architecture/ci.md. These are the same class as spec 003's SC1–SC3/SC6: a link on a pull request page, a diagram that renders, a merge that stays possible. They also resolve research.md's three open verdicts, which is why the first real run is a planned verification event rather than a hope:
| Open verdict | Settled by observing |
|---|---|
| V2 — does the deployment render on the PR page? | SC1 |
| V4 — do repeat deploys collapse to one entry? | SC2 |
V3 — is deployments: write required? | the first docs job either runs or fails with a permissions error |
Not tested, deliberately. That Cloudflare serves the right bytes — that is Cloudflare's job, proven by hand during discovery (V6) and not re-proven per run. And no test asserts the content of a published page; a build that renders is spec 003's concern, and F4 already records that its check is weaker than it looks.
Alternatives considered
- Cloudflare's git integration — rejected in brainstorming. Zero repo code, but the build configuration lives only in a dashboard, and Cloudflare's build image becomes a second toolchain to keep in step with CI. This project already carries one uncommittable setting (branch protection) and did not want a second.
- A bot comment carrying the link — rejected by the human as unprofessional-looking. The native deployment control costs one extra job and an artifact hand-off; that is the price of the nicer surface, paid knowingly.
- Hanging the
environmentoff the existingcheckjob — one job, no artifact, ~4 lines. Rejected: it would labelpush: mainruns with a preview environment, and any protection rule later added to that environment would gate the required status check itself. - Publishing the alias URL instead of the deployment URL — rejected on
research.mdF2. Prettier link, empty on production runs. - Making the repository public and using GitHub Pages — considered and set aside in brainstorming. It would also close spec 003's SC6 gap, but going public is a decision of its own and not this spec's to make.
Risks
- The two artifact actions are on different majors (v7 upload, v8 download). Their interoperation is asserted by neither changelog. Mitigation: it fails loudly on the first run, in the download step, with a clear message; the fix is a version bump, not a redesign.
environment.namemight reject the expression despite the documented context table. Mitigation: the failure is a workflow that will not parse — immediate and unmissable. Fallback is already decided (R5): one environment name for both cases, not two jobs.deployments: writemay turn out to be required (V3 open). Mitigation: add it at job level, never workflow level, so the existing least-privilege assertion onpermissions.contents: read(tests/ci/workflow.test.ts:113) keeps holding. Not added pre-emptively: granting a scope on suspicion is the wrong default.- The first CI-published preview link may fail TLS for about a minute (
research.mdF1) — an SSL handshake error that reads like a broken workflow. Mitigation: already documented in this branch; goes intodocs/architecture/ci.mdso the next reader recognises it instead of debugging it. Note the project has already had its first deployment, so this is likely spent. - The secrets have never been exercised by Actions. They were set by hand and verified only from a local shell. Mitigation: this is the single most likely first-run failure, and the first
docsjob is the test. - Every pull request now publishes, including code-only ones that change no Markdown. Mitigation: accepted — the deploy is an upload of already-built files, costing seconds, well inside the free ceilings measured in V7. Gating on changed paths is speculative optimisation.
Open questions
None blocking. Three items are open by nature and resolve on the first real run rather than before it — V2, V3 and V4, all answered by reality, not by the human. The human accepted them as open when approving the spec on 2026-08-13.
One question is genuinely for the human, and it is not a prerequisite: whether to make the repository public. It would close spec 003's SC6 gap (branch protection is unavailable on a free private plan) and is entirely independent of this work. Recorded here so it is not lost, not to be decided now.