From 0bdc827f9e2a3318f51b08390d3671abc8d0313e Mon Sep 17 00:00:00 2001 From: Julien Martel Date: Sat, 15 Aug 2026 05:51:36 -0500 Subject: [PATCH 1/2] =?UTF-8?q?ci,=20notes:=20enumerate=20the=20account=20?= =?UTF-8?q?=E2=80=94=20four=20leaked=20previews,=20two=20still=20serving?= =?UTF-8?q?=20the=20=3Fref=3D=20hole?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §5.3 closed with a *look* rather than a step: the Cloudflare dashboard's Workers ▸ Routes for both zones, "since nothing in either repo enumerates what is actually deployed". Taking it turned up two things. It never needed the dashboard. The wrangler OAuth token already on the machine carries account:read and workers_scripts, so two REST calls list every Worker and every zone route — a check something can run rather than a thing to remember. And the sheet wasn't clean. Four PR-preview Workers leaked from PRs closed 2026-08-11/12, all of them workers_dev=true and publicly reachable. Two predate the 301 map, so they are copies of the old site Worker: /init.sh answers 200, and ?ref= answers 200 with the bootstrap from that ref — the fork-network hole §5.3 recorded as closed. Deleting nebelhaus-init closed the path it was on, not the code; the code lives on in every preview upload of that era, each with its own hostname. preview-web.yml's cleanup job is not at fault — both leaks are its trigger failing to fire, which no pull_request-triggered job can fix. The paths filter is evaluated against the PR's whole diff, so a PR that touched web/** early and not finally deploys a preview and never fires `closed` (nebelhaus-pr-321). And a PR closed in the same operation that deletes its head branch gets no `closed` run at all (nebelhaus-pr-341). So ask the other question, from state: preview-sweep.yml lists the account's Workers daily, asks GitHub whether each -pr-'s PR is still open, and deletes the ones that aren't. Anchored name match, so neither production Worker can match; workflow_dispatch takes a dry_run. Verified by driving the extracted job body against the live account in dry-run — it named those four and nothing else — and shellcheck-clean. One thing no merge can fix, left 👤 in §5.3: an api.hausfold.co route pointing at the nebelhaus.com 301 map, declared in no config in either repo, dormant only because that subdomain has no DNS record. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PkTBtapKcZTCeTqfhRZEJ7 --- .github/workflows/preview-sweep.yml | 154 ++++++++++++++++++++++++++++ notes/hausfold-rename.md | 115 ++++++++++++++++++++- 2 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/preview-sweep.yml diff --git a/.github/workflows/preview-sweep.yml b/.github/workflows/preview-sweep.yml new file mode 100644 index 0000000..257ee5b --- /dev/null +++ b/.github/workflows/preview-sweep.yml @@ -0,0 +1,154 @@ +# Reap preview Workers whose PR is closed. +# +# `preview-web.yml`'s own `cleanup` job is the first line and stays the fast +# one — it deletes within seconds of a close. It is not, however, reliable, and +# both of its failure modes were found live on 2026-08-15 with four leaked +# Workers on the account (rename plan §5.3): +# +# 1. The `paths:` filter is evaluated against the PR's whole diff, so a PR +# that touched `web/**` in an early push and not in its final diff +# *deploys* a preview and then never fires the `closed` event that would +# delete it. `nebelhaus-pr-321` leaked exactly this way: its preview +# uploaded 2026-08-11T19:54Z, its merged diff is one file under `notes/`. +# 2. A PR closed in the same operation that deletes its head branch gets no +# `closed` run at all — there is no ref left to run the workflow from. +# `nebelhaus-pr-341` leaked that way (closed 19:52:10Z in favour of a +# `-salvage` branch; the preview workflow has no close run that day). +# +# Neither is fixable inside a `pull_request`-triggered workflow, because both +# are the trigger failing to fire. So this one asks the other question — "which +# Workers exist, and is their PR still open?" — on a schedule, from state +# rather than from an event. That is the same lesson §5.3 drew from the +# `nebelhaus-init` orphan: a config file describes the deployment it *wants*, +# and only an enumeration says what is actually deployed. +# +# Why it matters beyond tidiness: a preview Worker is public. Every one of them +# is `workers_dev = true` by design (`wrangler.preview.toml`, and preview-web's +# route guard *requires* it), so a leaked preview keeps serving whatever that +# PR's `worker.js` did, forever. The four found on 2026-08-15 predated the 301 +# map, so two of them were still answering `/init.sh` with 200 and still +# honouring an arbitrary 40-hex `?ref=` — the fork-network hole §5.3 recorded +# as "closed by deletion", live again on a URL nobody was looking at. +# +# Scope: this repo's own preview prefix and nothing else. `nebelhaus-pr-` is +# the only name shape it will delete — the production `nebelhaus` Worker (which +# holds the nebelhaus.com zone route) cannot match, and neither can +# hausfold.co's `hausfold-pr-` previews, which that repo sweeps itself. +name: Sweep stale nebelhaus.com previews + +on: + schedule: + # Daily, well away from the hour the family's other crons run. + - cron: '37 4 * * *' + workflow_dispatch: + inputs: + dry_run: + description: 'List what would be deleted without deleting it' + type: boolean + default: false + +permissions: + contents: read + pull-requests: read + +concurrency: + group: preview-sweep + cancel-in-progress: false + +jobs: + sweep: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Reap previews whose PR is closed + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PREFIX: nebelhaus-pr- + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + api='https://api.cloudflare.com/client/v4/accounts' + scripts=$(curl -sS --retry 3 --max-time 30 \ + -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ + "${api}/${CLOUDFLARE_ACCOUNT_ID}/workers/scripts") + + if [ "$(printf '%s' "$scripts" | jq -r '.success')" != "true" ]; then + printf '%s\n' "$scripts" + echo "::error::Could not list Workers — the token needs Workers Scripts:Read." + exit 1 + fi + + # Anchor both ends. An unanchored match would put the production + # Worker one careless rename away from being swept. + names=$(printf '%s' "$scripts" \ + | jq -r --arg p "$PREFIX" '.result[].id | select(test("^" + $p + "[0-9]+$"))') + + if [ -z "$names" ]; then + echo "No ${PREFIX}* Workers on the account — nothing to sweep." + exit 0 + fi + + leaked=0 + failed=0 + for name in $names; do + pr=${name#"$PREFIX"} + # A PR number that 404s means the Worker outlived the PR record + # itself; treat that as stale rather than as a reason to stop. + state=$(gh api "repos/${REPO}/pulls/${pr}" --jq '.state' 2>/dev/null || echo 'missing') + if [ "$state" = "open" ]; then + echo " keep $name (PR #$pr is open)" + continue + fi + leaked=$((leaked + 1)) + if [ "$DRY_RUN" = "true" ]; then + echo " would delete $name (PR #$pr is $state)" + continue + fi + resp=$(curl -sS --retry 3 --max-time 30 -w '\n%{http_code}' -X DELETE \ + "${api}/${CLOUDFLARE_ACCOUNT_ID}/workers/scripts/${name}?force=true" \ + -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}") || true + code=$(printf '%s' "$resp" | tail -n1) + body=$(printf '%s' "$resp" | sed '$d') + # Same envelope-or-status-code reading as preview-web.yml's + # cleanup: Cloudflare documents this endpoint as returning no body + # on success while also showing an envelope example, so trusting + # either alone gets a green delete wrong. + # `grep -q … && ok=1` would be shorter and would exit the job under + # `set -e` on the first non-match, so each test gets an `if`. + ok=0 + if printf '%s' "$body" | grep -q '"success": *true'; then ok=1; fi + # 10007 = no such Worker. Something else won the race; that is the + # outcome this job wanted. + if printf '%s' "$body" | grep -q '"code": *10007'; then ok=1; fi + if [ -z "$(printf '%s' "$body" | tr -d '[:space:]')" ]; then + case "$code" in 2*|404) ok=1 ;; esac + fi + if [ "$ok" -eq 1 ]; then + echo " DELETED $name (PR #$pr is $state)" + else + printf '%s\n' "$body" + echo "::error::Could not delete $name (HTTP $code)." + failed=$((failed + 1)) + fi + done + + { + echo '### nebelhaus.com preview sweep' + echo + if [ "$leaked" -eq 0 ]; then + echo 'No stale previews — every one belongs to an open PR.' + elif [ "$DRY_RUN" = "true" ]; then + echo "$leaked stale preview(s) found; dry run, nothing deleted." + else + echo "$leaked stale preview(s) reaped." + fi + } >> "$GITHUB_STEP_SUMMARY" + + # A leak found here is a `closed` cleanup that did not run, which is + # worth seeing rather than silently absorbing — but it is not a + # failure, since the sweep just fixed it. Only a failed delete is. + [ "$failed" -eq 0 ] diff --git a/notes/hausfold-rename.md b/notes/hausfold-rename.md index fd6a69d..5412c71 100644 --- a/notes/hausfold-rename.md +++ b/notes/hausfold-rename.md @@ -165,7 +165,43 @@ One thing from §6 that survives and one that doesn't: - ❌ *"support stays support@nebelhaus.com, because people bought a nebelhaus product"* is now wrong: they buy a hausfold product. Support moves. -### Current handoff — 2026-08-14 (night) +### Current handoff — 2026-08-15 + +**§5.3's last item was a *look*, it has been taken, and it found a live +regression rather than a tidy sheet.** The handoff below closes with "§5.3 also +leaves one *look* rather than a step — the Cloudflare dashboard's Workers ▸ +Routes for both zones". Two things came back: + +- ⚠️ **It never needed the dashboard.** The wrangler OAuth token on this machine + reads the account: two `curl`s list every Worker and every zone route. §5.3 + carries the recipe. A thing an agent can run beats a thing a person must + remember to look at, which is the same lesson the `nebelhaus-init` orphan + taught in the other direction. +- 🚨 **Four leaked PR-preview Workers, all public, and two of them were still + serving the `?ref=<40-hex>` fork-network hole** §5.3 recorded as closed on + 2026-08-14. Closing it deleted the *script on the zone*; the same code lived + on at `nebelhaus-pr-341..workers.dev/init.sh`, which answered 200 for an + arbitrary ref. Measured, not inferred. + +The durable half is done and is in this PR: **`.github/workflows/preview-sweep.yml`** +reaps `-pr-` Workers whose PR is closed, daily and on demand, because +both leak paths are `pull_request` triggers *not firing* and no job on that +trigger can fix that. hausfold.co gets the same file. Merging it and running it +once (`gh workflow run preview-sweep.yml`) is what actually deletes the four. + +👤 **One thing no merge can do:** the enumeration also turned up an +`api.hausfold.co` route pointing at the nebelhaus.com 301 map, declared in no +config in either repo. It is dormant only because that subdomain has no DNS +record. §5.3 carries the box. + +**The substantive work left in this document is §11**, and the workshop half of +it merged as #372 while this was being written. What remains is haus (11.1's +desktop rename, 11.2's `mkNebelhaus`/`NEBELHAUS_*` shims, 11.3's state-dir +symlinks), hausfold.co's `DESKTOPS` row and routes, and 11.4's comment tail +across pounce/perch/nebelung/trill. Read §11.0's table before touching any of +it. + +### Handoff — 2026-08-14 (night) 🔄 **The name `nebelhaus` is being dropped — decision 10, taken in conversation after the audit below was written.** The desktop becomes **`hacker`**; every @@ -3498,13 +3534,88 @@ fix later. - ⚠️ **Cloudflare edge-caches 404s.** Cache-bust when verifying, or you'll chase a redirect that already works. +#### ✅ The account enumeration — run 2026-08-15, and it found four more + +**The "one look" the box below asks for has now been taken, and it was not +clean.** ⚠️ **It also did not need the dashboard.** That box concluded only +Workers ▸ Routes could answer, which was wrong in a useful direction: the +wrangler OAuth token already on this machine carries `account (read)` and +`workers_scripts`, so two REST calls enumerate the whole account — + +```sh +# the token wrangler stores; `npx wrangler whoami` refreshes it when it has expired +TOKEN=$(sed -n 's/^oauth_token *= *"\(.*\)"/\1/p' ~/Library/Preferences/.wrangler/config/default.toml) +curl -s -H "Authorization: Bearer $TOKEN" \ + https://api.cloudflare.com/client/v4/accounts/$ACC/workers/scripts # every script +curl -s -H "Authorization: Bearer $TOKEN" \ + https://api.cloudflare.com/client/v4/zones/$ZID/workers/routes # per zone +``` + +— which makes this a **check something can run**, not a thing to remember to +look at. That distinction is the whole lesson of the orphan below. + +What it found, on top of a confirmed-gone `nebelhaus-init`: + +| | | +|---|---| +| `nebelhaus.com/*` → `nebelhaus`, and nothing else on the zone | ✅ the map, as declared | +| **`api.hausfold.co` → `nebelhaus`** | 🚨 **a route declared in no config in either repo.** Dormant only because no `api` DNS record exists; add one and the *301 map* answers a hausfold.co subdomain | +| **`nebelhaus-pr-321`, `nebelhaus-pr-341`, `hausfold-pr-16`, `hausfold-pr-22`** | 🚨 **leaked PR previews, all four `workers_dev = true` and publicly reachable**, from PRs closed 2026-08-11/12 | + +🚨 **And the two `nebelhaus-pr-*` previews re-opened the hole this section +closed.** They predate the 301 map, so they are copies of the *old* site Worker: +`https://nebelhaus-pr-341..workers.dev/init.sh` answered **200 with +`bootstrap.sh`**, and `?ref=` answered **200 with the +bootstrap from that ref** — measured against haus's tip, the same way the +original was. The fork-network `?ref=` hole was not closed by deleting +`nebelhaus-init`; it was closed *on the zone*, while two public URLs kept +serving it. **Deleting a script closes the path it was on, not the code** — the +code lives on in every preview upload of the era, and those have their own +hostnames. + +**Why the previews leaked, and why the fix is not in `preview-web.yml`.** That +workflow has a `closed` cleanup job and it is well-written; both failures are +the *trigger* not firing, which a `pull_request`-triggered job cannot fix: + +1. **The `paths:` filter is evaluated against the PR's whole diff.** A PR that + touched `web/**` early and not in its final diff *deploys* a preview and + then never fires `closed`. `nebelhaus-pr-321` is exactly that: preview + uploaded 2026-08-11T19:54Z, merged diff one file under `notes/`. +2. **A PR closed in the same operation that deletes its head branch gets no + `closed` run at all** — no ref is left to run from. `nebelhaus-pr-341` + closed 19:52:10Z in favour of a `-salvage` branch, and the preview workflow + has no close run that day. + +So the fix asks the other question, from state rather than from an event: +**`.github/workflows/preview-sweep.yml`** (and hausfold.co's own copy) lists the +account's Workers on a daily cron, asks GitHub whether each `-pr-`'s +PR is still open, and deletes the ones that aren't. It matches an anchored +`^-pr-[0-9]+$`, so neither production Worker can be caught by it, and +`workflow_dispatch` takes a `dry_run` input. Verified before merge by driving +the extracted job body against the live account in dry-run: it named those four +and nothing else. + +- [ ] 👤 **Delete the `api.hausfold.co` route.** Account-side, in no config, so + no merge removes it — it is exactly the shape of the orphan below, caught + before it had anything to shadow. Ids measured 2026-08-15: + + ```sh + curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \ + https://api.cloudflare.com/client/v4/zones/f4ceec0bf169389965bf7305aab7e021/workers/routes/9bcda9b5dd184c489676bea69054ede8 + ``` +- [ ] 👤 **The account is enumerated but not *audited*** — the two calls above + cover scripts and zone routes. Custom domains, Pages projects and DNS are + three more surfaces nothing in either repo declares either. + #### ✅ One route outlived its config — found and killed, 2026-08-14 **Found by running §5's gate, and fixed the same hour** (👤 ran `npx wrangler delete --name nebelhaus-init`). The whole chain was then measured end to end — ⚠️ **the *path* is proven, the *account* is not**: nothing here enumerated the other scripts, and per this box's own lesson only the dashboard's -Workers ▸ Routes can. Worth one look while you are next in there. +Workers ▸ Routes can. Worth one look while you are next in there. *(Taken +2026-08-15 — and by API rather than by dashboard. See the box above: it found +four more, two of them still serving this box's own `?ref=` hole.)* | | | |---|---| From af1b43b966f9c3c6f6b595fed6057556fbce38fe Mon Sep 17 00:00:00 2001 From: Julien Martel Date: Sat, 15 Aug 2026 06:05:16 -0500 Subject: [PATCH 2/2] web: the desktop rows 301 onto a 404, and two docs claims that outran the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three published nebelhaus.com URLs have been redirecting onto a 404 since #372 deployed at 10:06Z: /start/what-is-nebelhaus, /start/first-run and /reference/keybindings all pointed at hausfold.co/desktops/hacker/, which is the desktop's old path with its new name substituted in. hausfold.co had retired that tree into the docs hours earlier (hausfold.co#47) — /desktops/ hacker/ is a 404, /desktops/nebelhaus/ is itself a 301 into /docs/haus/desktops/hacker/, and the #keys fragment the keybindings row carried does not exist on any page any more (the ids are what-it-turns-on, first-moves, making-it-yours, where-to-go-next). So: the two desktop rows land on /docs/haus/desktops/hacker/, keeping the #first-moves fragment which is real there. Keybindings follows the ruling hausfold.co already made for the same URL — the keys were a shortened reprint of the windows room and the port deliberately didn't copy them, so it lands on the page that has them rather than on a fragment of the desktop's sheet. The suite stayed green throughout, which is the part worth keeping: it proves the map is complete and points at hausfold.co, and nothing in it fetches, so a destination that 404s is invisible to it. Two tests now pin the renamed rows by hand, and one fails any future row pointing under hausfold.co/desktops/ — that prefix is either a 404 or the second hop this file exists to avoid. Separately, README.md and AGENTS.md picked up "new installs scaffold `haus`" in #372. They don't — haus's bootstrap.sh:567 writes inputs.nebelhaus.url for a fresh install, so a new machine spells it exactly as this one does. That claim is the §11.2 trap dressed as progress: believing the input has half-moved is what leads to finishing it by editing bench's OVERRIDABLE alone, which is the edit that makes every --override-input silently no-op while reporting success. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PkTBtapKcZTCeTqfhRZEJ7 --- .github/workflows/preview-sweep.yml | 149 ++++++++++++++++++++-------- AGENTS.md | 2 +- README.md | 8 +- notes/hausfold-rename.md | 95 ++++++++++++++---- web/test/worker.test.js | 38 ++++++- web/worker.js | 43 +++++--- 6 files changed, 251 insertions(+), 84 deletions(-) diff --git a/.github/workflows/preview-sweep.yml b/.github/workflows/preview-sweep.yml index 257ee5b..e9e1852 100644 --- a/.github/workflows/preview-sweep.yml +++ b/.github/workflows/preview-sweep.yml @@ -1,9 +1,10 @@ # Reap preview Workers whose PR is closed. # # `preview-web.yml`'s own `cleanup` job is the first line and stays the fast -# one — it deletes within seconds of a close. It is not, however, reliable, and -# both of its failure modes were found live on 2026-08-15 with four leaked -# Workers on the account (rename plan §5.3): +# one — it deletes within seconds of a close. It is not, however, reliable. Two +# of its three failure modes were found live on 2026-08-15, with four leaked +# Workers on the account across this repo and hausfold/hausfold.co (the write-up +# is `notes/hausfold-rename.md` §5.3): # # 1. The `paths:` filter is evaluated against the PR's whole diff, so a PR # that touched `web/**` in an early push and not in its final diff @@ -14,21 +15,26 @@ # `closed` run at all — there is no ref left to run the workflow from. # `nebelhaus-pr-341` leaked that way (closed 19:52:10Z in favour of a # `-salvage` branch; the preview workflow has no close run that day). +# 3. The third is the one `preview-web.yml`'s own comment already names: a +# closed PR never re-fires the event, so a transient delete failure leaks +# the Worker until someone re-runs the job by hand. Nobody re-runs a job +# on a closed PR. # -# Neither is fixable inside a `pull_request`-triggered workflow, because both -# are the trigger failing to fire. So this one asks the other question — "which -# Workers exist, and is their PR still open?" — on a schedule, from state -# rather than from an event. That is the same lesson §5.3 drew from the -# `nebelhaus-init` orphan: a config file describes the deployment it *wants*, -# and only an enumeration says what is actually deployed. +# None of the three is fixable inside a `pull_request`-triggered workflow: two +# are the trigger not firing, and the third is it firing exactly once. So this +# one asks the other question — "which Workers exist, and is their PR still +# open?" — on a schedule, from state rather than from an event. That is the +# same lesson §5.3 drew from the `nebelhaus-init` orphan: a config file +# describes the deployment it *wants*, and only an enumeration says what is +# actually deployed. # # Why it matters beyond tidiness: a preview Worker is public. Every one of them # is `workers_dev = true` by design (`wrangler.preview.toml`, and preview-web's # route guard *requires* it), so a leaked preview keeps serving whatever that -# PR's `worker.js` did, forever. The four found on 2026-08-15 predated the 301 -# map, so two of them were still answering `/init.sh` with 200 and still -# honouring an arbitrary 40-hex `?ref=` — the fork-network hole §5.3 recorded -# as "closed by deletion", live again on a URL nobody was looking at. +# PR's `worker.js` did, forever. The two found here predated the 301 map, so +# they were still answering `/init.sh` with 200 and still honouring an +# arbitrary 40-hex `?ref=` — the fork-network hole §5.3 recorded as "closed by +# deletion", live again on a URL nobody was looking at. # # Scope: this repo's own preview prefix and nothing else. `nebelhaus-pr-` is # the only name shape it will delete — the production `nebelhaus` Worker (which @@ -38,8 +44,7 @@ name: Sweep stale nebelhaus.com previews on: schedule: - # Daily, well away from the hour the family's other crons run. - - cron: '37 4 * * *' + - cron: '47 4 * * *' workflow_dispatch: inputs: dry_run: @@ -57,6 +62,9 @@ concurrency: jobs: sweep: + # A fork with Actions on would run this cron with empty secrets and go red + # daily. Both of preview-web.yml's jobs carry the same shape of guard. + if: github.repository == 'hausfold/workshop' runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -71,34 +79,80 @@ jobs: run: | set -euo pipefail + summarise() { printf '%s\n' "$1" >> "$GITHUB_STEP_SUMMARY"; } + api='https://api.cloudflare.com/client/v4/accounts' - scripts=$(curl -sS --retry 3 --max-time 30 \ + scripts=$(curl -sS --retry 3 --retry-connrefused --max-time 30 \ -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ - "${api}/${CLOUDFLARE_ACCOUNT_ID}/workers/scripts") + "${api}/${CLOUDFLARE_ACCOUNT_ID}/workers/scripts?per_page=100") + # ⚠️ The list is the one call nothing else in this repo makes. The + # shared token is provisioned for Workers Scripts:Edit and DNS:Edit; + # Read comes with Edit on Cloudflare's own scope table, but that is + # inference until the first run, which is why this fails loudly and + # names the scope rather than treating an error envelope as "clean". if [ "$(printf '%s' "$scripts" | jq -r '.success')" != "true" ]; then printf '%s\n' "$scripts" echo "::error::Could not list Workers — the token needs Workers Scripts:Read." exit 1 fi - # Anchor both ends. An unanchored match would put the production - # Worker one careless rename away from being swept. - names=$(printf '%s' "$scripts" \ - | jq -r --arg p "$PREFIX" '.result[].id | select(test("^" + $p + "[0-9]+$"))') + # A truncated list is the one failure this job could not survive: the + # leaks past the end are invisible and it still reports clean, which + # is precisely the "reports clean forever" shape it exists to end. + # The account held six scripts on 2026-08-15 and this endpoint + # answered in one page — so rather than page it speculatively, refuse + # to run the day that stops being true. + total=$(printf '%s' "$scripts" | jq -r '.result_info.total_count // (.result | length)') + got=$(printf '%s' "$scripts" | jq -r '.result | length') + if [ "$total" != "$got" ]; then + echo "::error::Workers list is paginated ($got of $total) — this job would sweep only the first page. Add a cursor." + exit 1 + fi + + # Anchored at both ends, and built without interpolating $PREFIX into + # a regex: the prefix is meant to be swapped per repo, and a `.` in a + # future one would silently become a wildcard. An unanchored match + # would put the production Worker one careless rename away from being + # swept. + names=$(printf '%s' "$scripts" | jq -r --arg p "$PREFIX" ' + .result[].id + | select(startswith($p)) + | select(.[($p | length):] | test("^[0-9]+$"))') if [ -z "$names" ]; then echo "No ${PREFIX}* Workers on the account — nothing to sweep." + # Write it even here: a job that summarises nothing reads the same + # as a job that never ran. + summarise '### nebelhaus.com preview sweep' + summarise '' + summarise 'No previews on the account at all.' exit 0 fi leaked=0 failed=0 + err=$(mktemp) for name in $names; do pr=${name#"$PREFIX"} - # A PR number that 404s means the Worker outlived the PR record - # itself; treat that as stale rather than as a reason to stop. - state=$(gh api "repos/${REPO}/pulls/${pr}" --jq '.state' 2>/dev/null || echo 'missing') + # `|| echo missing` would be the short spelling and it is the + # dangerous one: it collapses a 502, a rate limit or a token blip + # into the same answer as a real 404, and `missing` falls straight + # through to the delete. One transient GitHub error would then take + # out an *open* PR's preview, which a plain push does not rebuild + # (the paths filter may no longer match). So only a genuine 404 + # counts as gone; anything else fails this Worker and leaves it be. + if ! state=$(gh api "repos/${REPO}/pulls/${pr}" --jq '.state' 2>"$err"); then + if grep -q '(HTTP 404)' "$err"; then + # The Worker outlived the PR record itself. + state=missing + else + cat "$err" + echo "::error::Could not read PR #$pr — leaving $name alone." + failed=$((failed + 1)) + continue + fi + fi if [ "$state" = "open" ]; then echo " keep $name (PR #$pr is open)" continue @@ -108,15 +162,25 @@ jobs: echo " would delete $name (PR #$pr is $state)" continue fi - resp=$(curl -sS --retry 3 --max-time 30 -w '\n%{http_code}' -X DELETE \ + set +e + resp=$(curl -sS --retry 3 --retry-connrefused --max-time 30 \ + -w '\n%{http_code}' -X DELETE \ "${api}/${CLOUDFLARE_ACCOUNT_ID}/workers/scripts/${name}?force=true" \ - -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}") || true + -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}") + curl_status=$? + set -e + if [ "$curl_status" -ne 0 ]; then + echo "::error::curl exited $curl_status deleting $name — it may still exist." + failed=$((failed + 1)) + continue + fi code=$(printf '%s' "$resp" | tail -n1) body=$(printf '%s' "$resp" | sed '$d') - # Same envelope-or-status-code reading as preview-web.yml's - # cleanup: Cloudflare documents this endpoint as returning no body - # on success while also showing an envelope example, so trusting + # Same envelope-or-status-code reading as preview-web.yml's cleanup: + # Cloudflare documents this endpoint as returning no body on + # success while also showing an envelope example, so trusting # either alone gets a green delete wrong. + # # `grep -q … && ok=1` would be shorter and would exit the job under # `set -e` on the first non-match, so each test gets an `if`. ok=0 @@ -136,19 +200,22 @@ jobs: fi done - { - echo '### nebelhaus.com preview sweep' - echo - if [ "$leaked" -eq 0 ]; then - echo 'No stale previews — every one belongs to an open PR.' - elif [ "$DRY_RUN" = "true" ]; then - echo "$leaked stale preview(s) found; dry run, nothing deleted." - else - echo "$leaked stale preview(s) reaped." - fi - } >> "$GITHUB_STEP_SUMMARY" + summarise '### nebelhaus.com preview sweep' + summarise '' + if [ "$leaked" -eq 0 ]; then + summarise 'No stale previews — every one belongs to an open PR.' + elif [ "$DRY_RUN" = "true" ]; then + summarise "$leaked stale preview(s) found; dry run, nothing deleted." + else + summarise "$leaked stale preview(s) reaped." + fi + if [ "$failed" -ne 0 ]; then + summarise '' + summarise "$failed Worker(s) could not be judged or deleted — see the log." + fi # A leak found here is a `closed` cleanup that did not run, which is # worth seeing rather than silently absorbing — but it is not a - # failure, since the sweep just fixed it. Only a failed delete is. + # failure, since the sweep just fixed it. Only a failed delete, or a + # PR whose state could not be read, is. [ "$failed" -eq 0 ] diff --git a/AGENTS.md b/AGENTS.md index 44f83c5..9b02bcc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ git repos. > | `haus.