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
221 changes: 221 additions & 0 deletions .github/workflows/preview-sweep.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
# 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. 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
# *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).
# 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.
#
# 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 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-<n>` 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-<n>` previews, which that repo sweeps itself.
name: Sweep stale nebelhaus.com previews

on:
schedule:
- cron: '47 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:
# 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:
- 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

summarise() { printf '%s\n' "$1" >> "$GITHUB_STEP_SUMMARY"; }

api='https://api.cloudflare.com/client/v4/accounts'
scripts=$(curl -sS --retry 3 --retry-connrefused --max-time 30 \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
"${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

# 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"}
# `|| 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
fi
leaked=$((leaked + 1))
if [ "$DRY_RUN" = "true" ]; then
echo " would delete $name (PR #$pr is $state)"
continue
fi
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}")
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
# 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

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, or a
# PR whose state could not be read, is.
[ "$failed" -eq 0 ]
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ git repos.
> | `haus.<option>` | the option namespace | ✅ **already renamed** (nebelhaus#261). `nebelhaus.*` still evaluates via `modules/renamed.nix`, with a warning — never write it. Options that later moved *within* `haus.*` (the `claude` room → `agents`, 2026-08-11) are aliased in `modules/moved.nix` instead; same warning, different file, and that one has no deletion condition. |
> | **nebelhaus** bare | ~~one **desktop** built on `haus`~~ | 🔄 **renamed to `hacker`, 2026-08-14** (§11, decision 10) — this row said "**stays**, forever (§6)" and §11 is the reversal. A bare `nebelhaus` in prose now means the desktop *under its old name*, and belongs in a sentence about the past or nowhere. |
> | `github.com/nebelhaus/*`, `GH_ORG` | the org and its repos | ✅ **already renamed** — every *family* repo is `github.com/hausfold/*` (§3, 2026-08-09). The archived Messages client stayed behind (§3.4), and the dead org is kept alive forever regardless: shipped copies of pounce and perch hit `api.github.com/repos/nebelhaus/<app>` for their update check and only a live org redirects them. |
> | `--override-input nebelhaus/…` in `bench`, `nebelhaus.url` | the consumer's flake **input name** | **still not renamed here**, and the reason got sharper rather than weaker. §11.2 moves it to `haus` — new installs scaffold that already — but the input name and `bench`'s `OVERRIDABLE` are ONE edit: Nix doesn't hard-fail an override for an unknown input, so changing either alone makes `bench try` build the pinned desktop while reporting your branch. It is a 👤 call on a 👤 file (§3.3's flake-input-paths box), and `~/.config/nix` still says `nebelhaus`. |
> | `--override-input nebelhaus/…` in `bench`, `nebelhaus.url` | the consumer's flake **input name** | **still not renamed here**, and the reason got sharper rather than weaker. §11.2 moves it to `haus` — ⚠️ **not yet, and nothing scaffolds `haus` today**: `haus`'s `bootstrap.sh` writes `inputs.nebelhaus.url = "github:hausfold/haus"` for a fresh install (measured 2026-08-15), so a new machine spells it exactly as this one does. The input name and `bench`'s `OVERRIDABLE` are ONE edit: Nix doesn't hard-fail an override for an unknown input, so changing either alone makes `bench try` build the pinned desktop while reporting your branch. It is a 👤 call on a 👤 file (§3.3's flake-input-paths box), and `~/.config/nix` still says `nebelhaus`. |
> | `nebelhaus.com` | the domain | **§5**, with the 301s |
>
> **And since 2026-08-10, `haus` carries five senses of its own** (decision 8):
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ after three lock files move behind it.

Never walk that by hand. `./bench ship` does it in order; `./bench status`
names every pin that's fallen behind. (Those are repo names. The consumer's flake INPUT is its own name for the
same thing — `~/.config/nix` still spells it `nebelhaus`, and new installs
scaffold `haus`; both work, and renaming it means renaming `bench`'s
`--override-input` in the same edit or every override silently stops applying.)
same thing — it is spelled `nebelhaus` everywhere, in `~/.config/nix` and in
what `bootstrap.sh` scaffolds for a new install alike. Moving it to `haus` is
§11.2 of the rename note, and it means renaming `bench`'s `--override-input` in
the same edit or every override silently stops applying while still reporting
success.)

## start

Expand Down
Loading
Loading