# Phase 06.5: Static client asset split — zero-cost client-only Fly deploys — Context

**Gathered:** 2026-05-16
**Status:** Ready for planning
**Source:** PRD Express Path (`docs/deploy/static-client-assets-split-plan.md`)

<domain>
## Phase Boundary

Split the staging/prod deploy pipeline so client-only changes (anything under `apps/client/**` and client-only packages) skip the full Docker image rebuild + Fly registry push and instead ship as a tarball uploaded to the existing Fly persistent volume. Atomic switch via symlink update; server still serves the assets but reads them from `/data/client-assets/current` instead of baked-in `/app/public`. Rollback is a symlink flip.

In scope:
- Server-side static-mount fallback chain (`STATIC_ASSETS_DIR` env → bundled `/app/public`).
- CI workflow split: client-only fast path (tarball upload) vs full image path (Docker rebuild) based on changed-paths.
- Release upload mechanics on Fly (`flyctl ssh sftp` or equivalent), content-addressed release dirs, symlink atomic swap.
- Post-deploy 3-check health probe (`/`, hashed JS asset, `/healthz`).
- Garbage-collect old releases (keep last N).
- Bundled-public fallback so a fresh machine still serves a page before first split upload.
- Rollback runbook documentation.

Out of scope:
- Cloudflare Pages / Tigris bucket / CDN migration (deferred — only after volume-backed split proves the shape).
- New paid infra (zero-extra-cost is hard constraint).
- Multi-machine fan-out (single Fly machine target preserved).
- Asset signing / integrity verification beyond what HTTPS gives.
- Client bundle changes themselves (mechanism only, not feature work).

</domain>

<decisions>
## Implementation Decisions

### Infrastructure
- Reuse existing Fly app and existing persistent volume — no new host, bucket, CDN, or paid service.
- Release directory layout: `/data/client-assets/releases/<git-sha>/` (content-addressed by `GITHUB_SHA`).
- Pointer: `/data/client-assets/current` is a symlink to the active release dir.
- Atomic swap via `ln -sfn` (rename semantics).
- Garbage collection: keep last 5 releases, delete older (`ls -1dt … | tail -n +6 | xargs -r rm -rf`).

### Server Behavior
- Change `apps/server/src/index.ts` static mount from hard-coded `public` to fallback chain:
  1. If `STATIC_ASSETS_DIR` env is set AND directory exists → serve that directory.
  2. Else → serve `join(__dirname, '..', 'public')` (bundled fallback).
- Bundled `/app/public` MUST remain in the server image as the cold-start fallback.
- Fly staging + prod `fly.toml` `[env]` adds `STATIC_ASSETS_DIR = "/data/client-assets/current"`.

### Build & Package
- Client built with `pnpm --filter @rebno/client build:staging`.
- Tarball: `tar -czf client-assets-${GITHUB_SHA}.tgz -C apps/server/public .` (Vite output already lands there).
- Upload via `flyctl ssh sftp` (or `flyctl ssh console` extraction equivalent).
- Extract to `/data/client-assets/releases/${GITHUB_SHA}` then symlink-switch.

### CI Workflow Split
- Client-only fast path triggers when the diff touches ONLY:
  - `apps/client/**`
  - packages used only by the client (resolved at planning time — must enumerate).
- Full image path (current behavior) triggers when the diff touches ANY of:
  - `apps/server/**`
  - `packages/protocol/**`
  - `packages/game-logic/**`
  - `packages/db/**`
  - `Dockerfile`
  - `fly.toml` / Fly config
  - `pnpm-lock.yaml`
  - GitHub Actions workflow files
- Mixed diffs (any non-client path touched) → full image path. Fast path is opt-in by exclusion.

### Health Checks (post-deploy, all three required to pass)
- `/` returns the new `index.html`.
- One hashed JS asset from the Vite manifest returns `200`.
- `/healthz` returns `200`.
- On any failure: do NOT swap symlink (or revert symlink if already swapped — TBD by planner; current doc shows swap-then-check).

### Rollback
- Single command: `ln -sfn /data/client-assets/releases/<previous-sha> /data/client-assets/current`.
- No image rollback required for client-only failures.
- Document the runbook in `docs/deploy/` alongside the split-plan doc.

### Traceability
- This phase touches `REQ-DEP-01`, `REQ-DEP-04`, `REQ-CLI-08` per source doc tags.

### Claude's Discretion
- Exact GitHub Actions YAML structure (matrix vs separate workflows vs job-level conditionals).
- Path-filter implementation (`dorny/paths-filter` action vs custom `git diff` script).
- Where to centralize the static-asset-path resolver (helper module in `apps/server/src/static-assets.ts` vs inline in `index.ts`).
- Tarball staging location on the Fly machine (`/tmp/` vs a dedicated upload dir).
- Health-check command shape (curl-from-machine vs flyctl-driven external probe).
- GC threshold tuning (5 is doc default; planner may revisit).
- Symlink swap ordering relative to health checks (swap-then-check vs stage-then-check-then-swap).
- Concurrency / lock handling if two client-only deploys race (advisory file lock on the volume vs CI-side mutex).
- Logging / telemetry on which path the release came through (image vs volume).

</decisions>

<canonical_refs>
## Canonical References

**Downstream agents MUST read these before planning or implementing.**

### Source of truth
- `docs/deploy/static-client-assets-split-plan.md` — authoritative split-plan doc; all decisions above derive from it.

### Server entrypoint (mount change target)
- `apps/server/src/index.ts` — current hard-coded static mount; primary edit site.

### Fly config
- `fly.toml` (staging) — `[env]` insertion site for `STATIC_ASSETS_DIR`.
- Prod Fly config (if separate file — planner to confirm layout).

### CI workflow
- `.github/workflows/` — GitHub Actions workflow files; planner enumerates which to split.

### Build entry
- `apps/client/package.json` — `build:staging` script verification.
- `apps/server/public/` — current Vite output landing dir, source for tarball.

### Phase dependency
- Phase 06.4 PLAN/SUMMARY artifacts (in `.planning/phases/06.4-*/`) — preserves any deploy-script changes shipped during 06.4 that this phase must not regress.

### Requirements traceability
- `traceable-reqs.toml` — `REQ-DEP-01`, `REQ-DEP-04`, `REQ-CLI-08` definitions.

### Project deploy lessons (existing intel)
- `CLAUDE.md` "Key Gotchas" — vite-wipes-public, CI-rebuilds-dist, push-frequently lessons apply to CI changes.

</canonical_refs>

<specifics>
## Specific Ideas

- Hard constraint: zero new paid infra. Volume-backed path is preferred *because* it changes deploy mechanics without adding a new prod dependency.
- Hard constraint: bundled `/app/public` fallback must remain — a freshly provisioned machine MUST serve a page before any split-asset upload completes.
- Atomic symlink swap (`ln -sfn`) is the rollback primitive — rollback runbook is a single command.
- Health-check probes are intentionally cheap: `/`, one hashed JS asset, `/healthz`. No deep smoke required for client-only path.
- Garbage collection keeps last 5 releases by default.
- Later upgrade path (deferred, not this phase): Cloudflare Pages free tier, Tigris bucket, or CDN front. Volume split is the cheapest migration step toward those.

</specifics>

<deferred>
## Deferred Ideas

- Cloudflare Pages / Tigris bucket / CDN migration — only after volume-backed split proves the workflow shape (source doc "Later Upgrade" section).
- Asset integrity / signing (HTTPS-only baseline assumed for this phase).
- Multi-region or multi-machine fan-out of `/data/client-assets/current` (single-machine Fly target preserved per ARCHITECTURE).

</deferred>

---

*Phase: 06.5-static-client-asset-split-zero-cost-client-only-fly-deploys*
*Context gathered: 2026-05-16 via PRD Express Path*
