From cdfe2e851a76e5acb8bc0e984f9d6e9038493d26 Mon Sep 17 00:00:00 2001 From: Hector Alvarez Date: Mon, 14 Sep 2026 08:17:55 -0500 Subject: [PATCH 1/3] fix(deploy): retain previous release assets to close the stale-HTML window --- .gitignore | 3 +++ docker-compose.prod.yml | 6 ++++++ scripts/deploy.sh | 22 ++++++++++++++++++++++ web/nginx.conf | 13 +++++++++++++ 4 files changed, 44 insertions(+) diff --git a/.gitignore b/.gitignore index 762fa97..d1f12d5 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,8 @@ coverage/ docker-compose.override.yml +# Previous-release assets extracted by scripts/deploy.sh (HK-6) +/.prev-assets/ + .DS_Store *.log diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index affb950..986779f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -28,6 +28,12 @@ services: cap_drop: - ALL logging: *default-logging + volumes: + # Assets from the previous release, extracted by scripts/deploy.sh + # (step 3b) before the container swap. Served read-only as a + # fallback for content hashes the new build no longer has, so the + # edge-cached HTML (2 h TTL, ADR 0006) never hits dead 404s. + - ./.prev-assets:/usr/share/nginx/html-prev:ro nginx: image: ghcr.io/${REGISTRY_OWNER:-hectormalvarez}/clock_website-nginx:${IMAGE_TAG:-latest} diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 3e5b71a..c726582 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -50,6 +50,28 @@ info "Pulling images for tag: $IMAGE_TAG" IMAGE_TAG="$IMAGE_TAG" $COMPOSE pull web nginx || err "Could not pull images for tag $IMAGE_TAG" ok "Images pulled" +# ── Step 3b: Preserve the previous build's assets ───────────────── +# Cloudflare edge-caches the HTML for up to 2 h (ADR 0006), so after a +# deploy the cached page can reference asset hashes that no longer exist +# in the NEW web image. Copy the running (old) container's /assets into +# .prev-assets/ on the checkout; the web service bind-mounts that +# directory read-only (see docker-compose.prod.yml) and web/nginx.conf +# falls back to it for hashes missing from the new build. Accumulative: +# hashes from earlier releases are kept, ~20 KB per deploy. Best-effort — +# a failed copy (e.g. first deploy, no web container yet) must not abort. +info "Preserving previous build assets..." +mkdir -p .prev-assets/assets +# Stream the running container's asset directory contents into the +# fallback root. The `.` form (contents, not the directory) can never +# nest a duplicate assets/ level, and re-running it accumulates hashes +# from earlier releases (~20 KB per deploy). Best-effort: a failed copy +# (e.g. first deploy, no web container yet) must not abort the deploy. +if $COMPOSE exec -T web tar -C /usr/share/nginx/html/assets -cf - . | tar -C .prev-assets/assets -xf -; then + ok "Previous assets preserved ($(ls .prev-assets/assets | wc -l) files)" +else + info "No previous web container found — starting with an empty fallback" +fi + # ── Step 4: Swap containers ─────────────────────────────────────── # Reconcile ONLY the services whose images were just pulled. A bare # `up -d --remove-orphans` would also recreate the webhook container — which is diff --git a/web/nginx.conf b/web/nginx.conf index 8cadd1d..34fa936 100644 --- a/web/nginx.conf +++ b/web/nginx.conf @@ -25,7 +25,20 @@ server { } # Vite emits content-hashed filenames, so these are safe to cache forever. + # A hash missing from THIS build may still be referenced by the HTML that + # Cloudflare's edge cached before the last deploy (Edge TTL 2 h, ADR 0006) + # — those resolve from the previous release's assets (bind-mounted by + # docker-compose.prod.yml, populated by scripts/deploy.sh). Without the + # fallback, every deploy serves dead-asset 404s for up to 2 h. See ADR-0007. location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri @prev_assets; + } + + # Fallback root: /assets/ under the previous-release mount. + location @prev_assets { + root /usr/share/nginx/html-prev; expires 1y; add_header Cache-Control "public, immutable"; try_files $uri =404; From d18a84f26466a3d7145a95ef10790140385f6e7f Mon Sep 17 00:00:00 2001 From: Hector Alvarez Date: Mon, 14 Sep 2026 08:18:15 -0500 Subject: [PATCH 2/3] docs(adr): add ADR-0007 previous-release asset retention, amend ADR-0006 --- docs/adr/0006-cloudflare-edge-caching.md | 9 ++- .../0007-previous-release-asset-retention.md | 70 +++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0007-previous-release-asset-retention.md diff --git a/docs/adr/0006-cloudflare-edge-caching.md b/docs/adr/0006-cloudflare-edge-caching.md index 7500499..349fc17 100644 --- a/docs/adr/0006-cloudflare-edge-caching.md +++ b/docs/adr/0006-cloudflare-edge-caching.md @@ -59,10 +59,15 @@ instructions, and `Cache-Control` alone gives both caches the same one. ## Consequences - After a deploy, visitors can receive the previous HTML for at most **2 - hours** (the Free-plan Edge TTL floor). Because nothing is purged, old + hours** (the Free-plan Edge TTL floor). ~~Because nothing is purged, old hashed assets remain edge-resident, so even a stale HTML document still resolves its assets; the residual risk is limited to an old asset being - evicted from a data center's cache. + evicted from a data center's cache.~~ + **Amended 2026-09-14 (ADR-0007):** this assumption failed in practice — + old hashes were evicted on origin at every container swap and Cloudflare + cached the resulting 404s, breaking the homepage for up to 2 h per + deploy. ADR-0007 keeps the previous release's assets servable on origin, + closing the window. - Origin load drops to roughly one HTML fetch per TTL expiry per data center, with Cloudflare's cache lock collapsing concurrent misses. - **No Cloudflare credentials exist in this repository, on the server, or in diff --git a/docs/adr/0007-previous-release-asset-retention.md b/docs/adr/0007-previous-release-asset-retention.md new file mode 100644 index 0000000..d1ab52e --- /dev/null +++ b/docs/adr/0007-previous-release-asset-retention.md @@ -0,0 +1,70 @@ +# 7. Retain the previous release's assets on origin as a fallback + +- **Status:** Accepted +- **Date:** 2026-09-14 +- **Amends:** ADR-0006 (invalidates one of its assumptions) + +## Context + +ADR-0006 accepted a bounded staleness window after each deploy on the +assumption that *"old hashed assets remain edge-resident, so even a stale +HTML document still resolves its assets"*. That assumption was **observed to +fail in practice on 2026-09-14**: after a deploy, Cloudflare served the +still-cached homepage (Edge TTL 2 h), but requests for the asset hashes the +old HTML referenced returned **404 — and Cloudflare cached the 404 itself** +(`cf-cache-status: HIT` on the 404 response). For up to 2 h after every +deploy, the homepage was broken for edge-cached visitors. This happened on +two consecutive deploys the same day. + +The assets are baked into the `web` image (ADR-0001/0002), so when the +container is swapped, the old hashes stop existing **on origin** — the edge +cannot re-fetch them, and any miss becomes a permanent 404 for that hash. + +## Decision + +Keep the previous release's assets servable on origin: + +1. **`scripts/deploy.sh` (step 3b, before the container swap):** stream the + running web container's `/usr/share/nginx/html/assets` into + `.prev-assets/assets/` on the checkout via + `docker compose exec -T web tar … -cf - . | tar -xf -`. The `.` (contents) + form can never nest a duplicate `assets/` level; re-runs accumulate + hashes from earlier releases (~20 KB per deploy — negligible). The step + is best-effort: with no previous web container (first deploy), it logs + and continues. +2. **`docker-compose.prod.yml`:** the web service bind-mounts + `./.prev-assets` read-only at `/usr/share/nginx/html-prev`. The + relative-path source resolves identically on the host and inside the + webhook container (same mechanism as the existing `nginx.conf` mount). +3. **`web/nginx.conf`:** `location /assets/` uses + `try_files $uri @prev_assets`; the named location serves from + `/usr/share/nginx/html-prev` with the same immutable cache headers. + Hashes present in the new build always win; only hashes missing from it + fall back. + +The first deploy after this ADR merges still runs the *old* in-memory +deploy.sh (the webhook executes the script before it re-pulls), so the +fallback becomes fully populated from the **second** deploy onward. From +then on, a deploy no longer requires a Cloudflare purge to keep the site +whole. + +## Alternatives considered + +- **Deploy-time purge via the Cloudflare API** — rejected, consistent with + ADR-0006: requires `CLOUDFLARE_ZONE_ID` + `CLOUDFLARE_API_TOKEN` on the + server or in repo secrets. +- **Shorter Edge TTL in the Cache Rule** — the 2 h TTL is already the + Free-plan floor, and shortening it only shrinks (never closes) the window + while raising origin load. +- **Deterministic content hashing** — helps only when the code didn't + change; any real change still rewrites the hashes. + +## Consequences + +- A stale edge-cached homepage always resolves its assets; the HK-6 + breakage window is closed without Cloudflare credentials. +- The host accumulates `.prev-assets/` (~20 KB per deploy, gitignored). +- Untrusted/unknown paths under `/assets/` still 404 (through the fallback + chain), keeping the honest-404 behaviour of ADR-0002's error page design. +- Rollbacks are unaffected: the restored old image serves its own assets + natively; the fallback is simply dormant. \ No newline at end of file From b590e20e0b223498e999f9786dd57d5e7d72d682 Mon Sep 17 00:00:00 2001 From: Hector Alvarez Date: Mon, 14 Sep 2026 08:21:25 -0500 Subject: [PATCH 3/3] docs(tasks): close HK-6 in backlog and sprint, update memory bank --- docs/adr/0006-cloudflare-edge-caching.md | 4 +- .../0007-previous-release-asset-retention.md | 8 +- memory-bank/activeContext.md | 55 ++++++------ tasks/backlog.md | 19 ----- tasks/sprint.md | 85 +++++++++++-------- 5 files changed, 82 insertions(+), 89 deletions(-) diff --git a/docs/adr/0006-cloudflare-edge-caching.md b/docs/adr/0006-cloudflare-edge-caching.md index 349fc17..45dcb8f 100644 --- a/docs/adr/0006-cloudflare-edge-caching.md +++ b/docs/adr/0006-cloudflare-edge-caching.md @@ -17,7 +17,7 @@ two files the HTML references are served from the edge. The most expensive hop The `no-cache` policy itself is correct for browsers: the HTML references `index-.js`, and a stale HTML file would point at hashes that no longer -exist. The tension is that browsers and the Cloudflare edge need *different* +exist. The tension is that browsers and the Cloudflare edge need _different_ instructions, and `Cache-Control` alone gives both caches the same one. ## Decision @@ -27,7 +27,7 @@ instructions, and `Cache-Control` alone gives both caches the same one. the edge with a 304, via the existing `ETag`/`Last-Modified`). - The **edge** caches the document via a zone **Cache Rule**: hostname `clock.taylormadetech.net`, URI path in `{/, /index.html}`, cache - eligibility *Eligible for cache*, **Edge TTL 2 hours** (the Free-plan + eligibility _Eligible for cache_, **Edge TTL 2 hours** (the Free-plan floor). The rule lives in the dashboard because origin headers cannot express it on this plan (see Alternatives); `/healthz` is deliberately not matched, so health checks can never be served from cache. diff --git a/docs/adr/0007-previous-release-asset-retention.md b/docs/adr/0007-previous-release-asset-retention.md index d1ab52e..4623146 100644 --- a/docs/adr/0007-previous-release-asset-retention.md +++ b/docs/adr/0007-previous-release-asset-retention.md @@ -7,8 +7,8 @@ ## Context ADR-0006 accepted a bounded staleness window after each deploy on the -assumption that *"old hashed assets remain edge-resident, so even a stale -HTML document still resolves its assets"*. That assumption was **observed to +assumption that _"old hashed assets remain edge-resident, so even a stale +HTML document still resolves its assets"_. That assumption was **observed to fail in practice on 2026-09-14**: after a deploy, Cloudflare served the still-cached homepage (Edge TTL 2 h), but requests for the asset hashes the old HTML referenced returned **404 — and Cloudflare cached the 404 itself** @@ -42,7 +42,7 @@ Keep the previous release's assets servable on origin: Hashes present in the new build always win; only hashes missing from it fall back. -The first deploy after this ADR merges still runs the *old* in-memory +The first deploy after this ADR merges still runs the _old_ in-memory deploy.sh (the webhook executes the script before it re-pulls), so the fallback becomes fully populated from the **second** deploy onward. From then on, a deploy no longer requires a Cloudflare purge to keep the site @@ -67,4 +67,4 @@ whole. - Untrusted/unknown paths under `/assets/` still 404 (through the fallback chain), keeping the honest-404 behaviour of ADR-0002's error page design. - Rollbacks are unaffected: the restored old image serves its own assets - natively; the fallback is simply dormant. \ No newline at end of file + natively; the fallback is simply dormant. diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index e98dddb..3fe4e5d 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -1,38 +1,39 @@ # Active Context -Updated: 2026-09-13 (evening) +Updated: 2026-09-14 ## Current focus -**US-005 — UX polish & a11y hardening: implemented on `feat/us-005-ux-polish`** -(off `dev`), awaiting owner QA/review → PR into `dev`. Commits: story + -baseline shots → token layer → alarm redesign → timer normalization → a11y -wiring/FLIP → AA placeholder fix → focus-restore fix → verification record. - -- Verify with the story's §6 record - (`docs/stories/US-005-ux-polish-accessibility.md`): contrast table (all - AA), 20/20 Playwright a11y assertions, 140 unit tests, before/after - screenshots. -- Design decisions worth knowing: green/dark aesthetic kept; emoji glyphs - replaced by inline SVG (`currentColor`); text-tier tokens replace the - opacity soup (never use raw opacity for text); `panelHadFocus` pattern in - both UI files because mousedown blurs before outside-click close runs; - timer toggle renders icon + text span (never replace button content). -- Screenshot tooling lives in `/tmp/ux-shots/` (Playwright 1.63 — NOT a repo - dependency): shoot.mjs (14-shot matrix), a11y.mjs (20 assertions), - contrast.mjs (WCAG math). Vite dev server on :5173. +**HK-6 — implemented on `fix/hk6-asset-retention`** (off `main`): the +stale-HTML/dead-asset window after deploys is closed via previous-release +asset retention (ADR-0007). deploy.sh step 3b streams the old web +container's assets into `.prev-assets/` (tar contents-form — the `docker +cp src/. dst/` form NESTS, do not use); compose bind-mounts it ro at +`/usr/share/nginx/html-prev`; `web/nginx.conf` `try_files $uri +@prev_assets` fallback serves old hashes with immutable headers. + +- Verified locally: `sh -n`, both `compose config -q`, `nginx -t` on both + configs, functional docker test (old hash 200 via fallback, unknown 404). +- Sandbox gotcha: this environment's docker bind mounts can show STALE + container views of host dirs (caused a long nesting-wild-goose-chase — + the data was polluted by an earlier root-owned `docker cp`, and the + container saw content the host didn't have). Trust host-side `find` over + container `ls` here. +- First deploy after merge runs the old in-memory deploy.sh; fallback is + fully populated from the second deploy onward (in ADR-0007). ## Environment state -- Branch `feat/us-005-ux-polish` = dev + 8 commits; `dev`/`main` synced. -- `make check` green (140 tests). Vite dev server may still be running on - :5173 (started for screenshots). +- Branch `fix/hk6-asset-retention` = main + 3 commits (code, ADRs, tasks). + `dev` has sprint-tracking commit `54edefa` that main lacks — this branch's + sprint.md rewrite reconciles them on merge. +- `make check` green (140 tests); pure cores untouched by HK-6. ## Next steps -- Owner: review US-005 (screenshots + behavior), then PR into `dev` → - `main` (rebase-merge; the ruleset requires PRs for `main`). -- Owner: audible alarm-tone check → US-001 fully Done. -- Pick next epic: US-002 (deprioritized) vs US-004 (blocker cleared by - US-005's shared FLIP helper). -- Schedule HK-5 (nginx re-pin) / HK-6 (cache purge or asset retention). +- Owner: review + merge HK-6 PR → `main`; next deploy needs NO manual CF + purge from the second deploy on. +- Owner: audible alarm-tone check (US-001) and visual pass on prod (US-005) + — both still open. +- Pick next epic: US-004 (needs PO story + likely ADR) vs US-002 (needs PO + story). HK-5 (nginx re-pin) is the remaining housekeeping item. diff --git a/tasks/backlog.md b/tasks/backlog.md index 2006d8e..b6e315a 100644 --- a/tasks/backlog.md +++ b/tasks/backlog.md @@ -40,22 +40,3 @@ sized/sequenced by the SDM. the image tag in `.github/workflows/ci.yml` (nginx-config-check), and run `nginx -t` locally on both configs. - Priority: Medium — security updates are accruing on the EOL branch. - -### HK-6 — Eliminate the stale-HTML / dead-asset window after deploys - -- Status: Candidate (observed live 2026-09-13 ~22:00Z). -- Finding: after a deploy that changes asset hashes, the edge-cached homepage - (2 h Cache Rule TTL, per ADR 0006) references assets that no longer exist - on origin; requests 404, and **Cloudflare caches the 404 itself** - (`cf-cache-status: HIT` on a 404). Until the HTML entry expires, the - homepage is broken. ADR 0006's assumption that old hashed assets remain - edge-resident does not hold in practice. -- Fix candidates (pick one, or combine): - 1. Purge the CF cache after a successful deploy (needs a CF API token — - scope decision + secret management). - 2. Retain the previous build's `assets/` on origin in `scripts/deploy.sh` - so old hashes keep resolving (simplest, no CF dependency). - 3. Shorten the HTML Edge TTL (e.g. 5-10 min) to shrink the window. - - Also consider making builds hash-deterministic for unchanged code. -- Priority: Medium-High — every deploy currently risks a broken homepage for - up to 2 h. diff --git a/tasks/sprint.md b/tasks/sprint.md index e9c0194..b9c1623 100644 --- a/tasks/sprint.md +++ b/tasks/sprint.md @@ -2,54 +2,65 @@ Sprint window: active (no fixed calendar sprint; pipeline-driven). -## Completed (pending final QA) +## Completed ### US-001 — Multiple Alarms (Epic: Alarms) -- Status: **MERGED + DEPLOYED.** PR #13 merged 2026-09-13 21:17Z (linear - history, 9 commits). -- Release run #5 succeeded 21:18Z; webhook deploy fired; `/healthz` verified - HTTP 200 post-deploy. +- Status: **MERGED + DEPLOYED** (PR #13, 2026-09-13 21:17Z). Release run #5 → + webhook deploy → `/healthz` 200. - Remaining DoD: manual browser check that the alarm tone plays (audio cannot - be verified in jsdom) — owner task. Then story status → Done. -- Note: the homepage may serve up to 2 h of edge cache (ADR 0006); judge - deploy freshness via `/healthz` or hashed assets, not the cached HTML. + be verified in jsdom) — owner task. Then this entry is deleted. + +### US-005 — UX Polish & Accessibility Hardening + +- Status: **MERGED + DEPLOYED** (PR #15 rebase-merged into `main`, + 2026-09-14; Release run succeeded → deploy verified on origin via fresh + asset hashes + US-005 markers in the served bundle/CSS; `/healthz` 200). +- All 8 ACs verified pre-merge (140 unit tests, 20/20 Playwright a11y + assertions, computed contrast table, 14 before/after screenshots in + `docs/stories/US-005/screenshots/`). +- Absorbed US-003 items 1–3 (shared FLIP helper, ring-overlay outside-click + guard, panel focus management) — **US-003 is closed**. +- Remaining DoD: owner visual pass on the live site — a human eyeball on + prod is the last gate; rollback via revert PR if anything regressed. + +### HK-6 — Eliminate the stale-HTML/dead-asset window after deploys + +- Status: **IMPLEMENTED on `fix/hk6-asset-retention`** (off `main`) — + previous-release assets retained on origin per **ADR-0007**: deploy.sh + step 3b streams the old container's `/assets` into `.prev-assets/` + (accumulative), the web service bind-mounts it read-only, and + `web/nginx.conf` falls back to it for hashes missing from the new build. +- Verified locally: `sh -n`, both `docker compose config -q` runs, `nginx +-t` on both configs, and a functional docker test — old-hash asset serves + **200 with immutable headers** from the fallback, new assets 200, unknown + 404, `/` and `/healthz` unaffected. +- Note: the first deploy after merge still runs the old in-memory deploy.sh; + the fallback is fully populated from the second deploy onward (recorded in + ADR-0007). ## In flight -### US-005 — UX Polish & Accessibility Hardening +Nothing — pipeline is empty pending owner decisions. + +## Ready (not started) — needs owner pick -- Status: **Implementation complete on `feat/us-005-ux-polish`** — all 8 ACs - verified; awaiting owner QA + code review, then PR into `dev`. -- Delivered: visible labeled fields (alarm create redesign), semantic - WCAG-AA design tokens (all pairs computed ≥4.7:1), monochrome SVG icons - (red ⏰ / blue 💤 emojis removed), shared control scale across alarm + - timer panels, always-visible timer steppers (were hover-only), focus - management (into panels on open, restored to toggles on close), ring - overlay `aria-modal` + focus trap + outside-click guard (US-003 items 2–3 - absorbed), shared FLIP helper with unit tests (US-003 item 1 absorbed), - `prefers-reduced-motion` support. 140 tests passing; 20/20 Playwright - a11y assertions; 14 before/after screenshots in the story folder. -- Note: alarm/timer/clock **pure cores untouched** — behavior tests pass - unchanged. - -## Ready (not started) - -After US-005 closes: owner picks the next epic — US-002 (recurring alarms, -deprioritized) vs US-004 (multi-clock, blocker cleared). HK-5/HK-6 are -standalone housekeeping candidates. +| # | Item | Type | Dependencies | Notes | +| --- | --------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| 1 | **US-004** — wire up the multi-clock registry | Epic | FLIP blocker cleared by US-005; needs PO story defining "a clock" + likely ADR (layout/composition-root change) | Was deprioritized behind US-005; now unblocked. | +| 2 | **US-002** — recurring (daily-repeat) alarms | Story | none (builds on alarm core) | Needs a PO user story before work. | +| 3 | **HK-5** — re-pin nginx off EOL 1.27 branch | Housekeeping (security) | none | Medium; security updates accruing on the unmaintained branch. | ## Dependencies & sequencing -- US-002 can start any time — builds directly on the alarm core. -- US-004's sequencing blocker (shared FLIP helper) is resolved by US-005; - still needs a PO story + possibly an ADR. -- HK-5/HK-6 are independent of the epics. +- US-002 and US-004 are independent of each other; either can be next. +- HK-5 is fully standalone; can slot into any gap. ## Risks -| Risk | Likelihood | Impact | Mitigation | -| ----------------------------------------------------------------------------------------- | ---------- | --------------------------------------------- | -------------------------------------------- | -| Production alarm audio unverified | Medium | Medium (US-001 DoD gap) | Owner manual check; tracked above | -| Stale edge HTML references dead assets after each deploy; cached 404s extend the breakage | High | Medium (broken homepage up to 2 h per deploy) | HK-6: purge, asset retention, or shorter TTL | -| nginx 1.27 base gets no security updates (EOL mainline) | High | Medium | HK-5: re-pin to current stable | +| Risk | Likelihood | Impact | Mitigation | +| -------------------------------------------------------------------------- | ---------- | -------------------------------------------- | -------------------------------------------- | +| Production alarm audio unverified | Medium | Medium (US-001 DoD gap) | Owner manual check; tracked above | +| nginx 1.27 base gets no security updates (EOL mainline) | High | Medium | HK-5: re-pin to current stable | +| US-005 sign-off without a human look at prod | Low | Low (aesthetic regressions screenshots miss) | Owner visual pass; rollback via revert PR | +| HK-6 fallback unproven on the real host until the second post-merge deploy | Low | Low (old behaviour = broken window recurs) | Owner: verify next deploy via old-hash asset |