Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,8 @@ coverage/

docker-compose.override.yml

# Previous-release assets extracted by scripts/deploy.sh (HK-6)
/.prev-assets/

.DS_Store
*.log
6 changes: 6 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
13 changes: 9 additions & 4 deletions docs/adr/0006-cloudflare-edge-caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<hash>.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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions docs/adr/0007-previous-release-asset-retention.md
Original file line number Diff line number Diff line change
@@ -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.
55 changes: 28 additions & 27 deletions memory-bank/activeContext.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 0 additions & 19 deletions tasks/backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading