From c28ce7ce2f8c22231cc0832449a61dd36d9a1f6e Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 7 Nov 2026 23:10:21 +0100 Subject: [PATCH] R-1228: retire DooPlex's copy of the website (operator yes); the installer pin moves to INSTALLER-TAG - INSTALLER-TAG (one line) is the only installer pin; publish-site-cloudflare.sh reads it. - hostinstall gate 6c: pin file holds one tag, no manifest pins one, publish script takes the site from origin/main and scripts/ from the tag. Red seen on five mutants. - manifests: webpage.yaml keeps only FileBrowser; contact-mailer.yaml loses the old felhom.eu/api Ingress (an orphan: glued to the next doc by a `80---` typo). - R-110 fence text, manifests/website rules, build skill, runbooks, poster facts updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- .claude/rules/manifests.md | 14 +- .claude/rules/website.md | 4 +- CLAUDE.md | 15 +- INSTALLER-TAG | 4 + STATUS.md | 2 +- .../felhom-system-poster.facts.md | 2 +- documentation/backlog/OPEN-ITEMS.md | 3 +- documentation/runbooks/day0-install.md | 19 +- documentation/runbooks/publish-train-rules.md | 8 +- documentation/runbooks/secrets.md | 2 +- manifests/contact-mailer.yaml | 31 -- manifests/webpage.yaml | 314 ------------------ scripts/CHANGELOG.md | 17 + scripts/hostinstall_gates.py | 70 ++-- scripts/publish-site-cloudflare.sh | 18 +- skills/felhom-build-deploy/SKILL.md | 2 +- 16 files changed, 107 insertions(+), 418 deletions(-) create mode 100644 INSTALLER-TAG diff --git a/.claude/rules/manifests.md b/.claude/rules/manifests.md index beb7db31..8c7e988b 100644 --- a/.claude/rules/manifests.md +++ b/.claude/rules/manifests.md @@ -23,13 +23,9 @@ ArgoCD's source of truth is the **manifest in git**: - `manifest_bearer_gate.py` fails on any bearer-shaped literal anywhere under `manifests/`. It runs from `python3 scripts/repo_gates.py`. -## The two git-syncs in `webpage.yaml` +## The installer is no longer served from a manifest -`manifests/webpage.yaml` runs **two** git-syncs and they track different refs: the website from -`main`, and `/scripts/` from the tag `installer-v`. Changing either `--ref` changes -what real machines download — the init container **and** the sidecar both carry it, and both must -move together. `hostinstall_gates.py` gate 6 fails if the manifest stops naming an `installer-v…` -tag or if the website stops tracking `main`. All four git-sync containers (two sidecars, two init containers) read -Gitea **in-cluster** (`http://gitea.gitea-system.svc.cluster.local:3000/…`, since 2026-10-19) and must name the same -repo — gate 6 fails if they differ; the sidecars carry `--max-failures=-1` (retry, never exit). The full publish/rollback procedure is the R-110 fence -in the repo core `CLAUDE.md`. +Until 2026-11-07 `manifests/webpage.yaml` ran DooPlex's copy of the website and pinned the installer tag in two +`--ref` lines. That copy is retired (R-1228); `webpage.yaml` now holds only FileBrowser. The installer tag lives in +`INSTALLER-TAG` at the repo root, and `hostinstall_gates.py` gate 6 fails if any manifest pins one again. The full +publish/rollback procedure is the R-110 fence in the repo core `CLAUDE.md`. diff --git a/.claude/rules/website.md b/.claude/rules/website.md index a53ee5a3..9d8829e3 100644 --- a/.claude/rules/website.md +++ b/.claude/rules/website.md @@ -8,8 +8,8 @@ paths: ["website/**"] **Since 2026-10-30 (D35 / R-1228) the public site is served by Cloudflare Pages** (project `felhom-site`): a push to `main` reaches the public site only after `sec run CF_PAGES_TOKEN -- scripts/publish-site-cloudflare.sh` (`--dry-run` -first; it uploads `website/` from `main` and `/scripts/` from the installer tag). DooPlex's git-sync copy keeps tracking -`main` as the way back (`documentation/audits/release-2026-10-30/p3-cloudflare/PLAN.md` §5). No image build. +first; it uploads `website/` from `main` and `/scripts/` from the installer tag). DooPlex's git-sync copy (the way back) +was retired on 2026-11-07 (R-1228); the installer tag is pinned in `INSTALLER-TAG`. No image build. **Do NOT pin the website to the installer tag.** The sparse-checkout used to cover `/website/` and `/scripts/` in one sync; pinning that would turn every copy edit into a release. The installer's diff --git a/CLAUDE.md b/CLAUDE.md index d083ab62..b1e45fa5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,18 +58,17 @@ This fence is in the core deliberately: its trigger is editing `scripts/felhom-h no path-scoped rule covers that file. It governs the one artifact that runs **as root on a virgin box**. -- **Pushing `scripts/felhom-host-install.sh` to `main` publishes NOTHING.** `manifests/webpage.yaml` - runs two git-syncs: the website from `main`, and `/scripts/` from the tag - `installer-v`. -- **To publish:** cut `installer-v`, bump the `--ref` in `webpage.yaml` (**both** - the sidecar and the init container), commit, sync — **and** (since 2026-10-30, D35: felhom.eu is served by Cloudflare - Pages) run `sec run CF_PAGES_TOKEN -- scripts/publish-site-cloudflare.sh`, which uploads the tag the manifest names. +- **Pushing `scripts/felhom-host-install.sh` to `main` publishes NOTHING.** felhom.eu is Cloudflare Pages (D35); its + `/scripts/` is uploaded from the tag `installer-v` named in the one-line file `INSTALLER-TAG` (since + 2026-11-07, R-1228 — DooPlex's own copy and its two git-syncs are retired), the website from `main`. +- **To publish:** cut `installer-v`, put it in `INSTALLER-TAG`, commit, push — **and** run + `sec run CF_PAGES_TOKEN -- scripts/publish-site-cloudflare.sh`, which uploads the tag that file names. - **To roll back:** move the tag back, then run the same script (~1 min; it re-reads the tag). That is the emergency lever; fix forward afterwards. - **Do NOT pin the website to the tag**, and the URL never carries a ref — `felhom-bootstrap.sh` and the hub's day-0 command follow the tag with no edit. -- `hostinstall_gates.py` gate 6 fails if the manifest stops naming an `installer-v…` tag or if the - website stops tracking `main`. +- `hostinstall_gates.py` gate 6 fails if `INSTALLER-TAG` does not name exactly one `installer-v…` tag, if a manifest + pins a second one, or if the publish script stops taking the site from `main` and `/scripts/` from the tag. ## Workflow — what is specific to this repo diff --git a/INSTALLER-TAG b/INSTALLER-TAG new file mode 100644 index 00000000..191b2d3e --- /dev/null +++ b/INSTALLER-TAG @@ -0,0 +1,4 @@ +# The installer version felhom.eu/scripts/ serves (R-110, R-1228). ONE line: installer-v. +# scripts/publish-site-cloudflare.sh uploads scripts/ from THIS tag; hostinstall_gates.py gate 6 checks this file. +# Until 2026-11-07 the pin was the two `--ref=installer-v…` lines in manifests/webpage.yaml (DooPlex's copy, retired). +installer-v1.36.1 diff --git a/STATUS.md b/STATUS.md index 0197c75d..21d8f4a9 100644 --- a/STATUS.md +++ b/STATUS.md @@ -25,7 +25,7 @@ ## What we work on - HTTP2Shell waiters: cal.com, Calibre-Web, RomM have no fix; SparkyFitness has one on a new main version (R-785, behind R-784). -- Dated checks, run by CC. 2026-11-06: ask you to retire DooPlex's old copy of the website (R-1228). +- Retire DooPlex's old copy of the website (your yes, 2026-11-07): the installer's version pin moves out of it first (R-1228). ## Waiting on you — decide diff --git a/documentation/architecture/felhom-system-poster.facts.md b/documentation/architecture/felhom-system-poster.facts.md index 2b8f2d75..965e8f5a 100644 --- a/documentation/architecture/felhom-system-poster.facts.md +++ b/documentation/architecture/felhom-system-poster.facts.md @@ -116,7 +116,7 @@ Footnote: "W = the household's backup window start, 02:30 by default. Tiers 1– - **Vaultwarden** — the operator's password manager. - **Prometheus + Alertmanager** — the operator's alarms. - **The signing keys** — they sign every agent update and config bundle a box accepts. -- **The contact form's mailer** (`api.felhom.eu`) — and, until 2026-11-06, the old copy of the website (the way back after the 2026-10-30 move to Cloudflare). +- **The contact form's mailer** (`api.felhom.eu`). The old copy of the website (the way back after the 2026-10-30 move to Cloudflare) was retired on 2026-11-07. - **Local backups** — several restic sets on DooPlex's own disks (red outline: "all on the same machine"). - **ep0-copy** — a nightly pull of the whole ep0 datastore back to DooPlex (ciphertext only; DooPlex cannot read households' copies). diff --git a/documentation/backlog/OPEN-ITEMS.md b/documentation/backlog/OPEN-ITEMS.md index aa61f447..e80aa283 100644 --- a/documentation/backlog/OPEN-ITEMS.md +++ b/documentation/backlog/OPEN-ITEMS.md @@ -210,7 +210,7 @@ stopping line that lies. | **R-1206** | Backup & restore | P3 | **Backups written while the box's clock ran ahead stay „the latest" after the clock is fixed.** The rehearsal's scenario #10 (clock set to 2026-10-28) left off-site snapshots dated 2026-10-28; on 2026-10-21 the rebuilt box's recovery page listed every app's „Legutóbbi mentés: 2026. 10. 28. 07:39", and the full restore said „mentés: 2026. 10. 28. 07:37" — a date in the future (`audits/fixes-2026-10-21/P1-live/07-unlock-right.txt`, `11-nextcloud-full-restore.txt`). restic orders snapshots by their own time, so the future ones are „newest" to every retention rule for a week. NOT measured: whether the box's keep-daily prune then drops a real snapshot. Related R-1202, R-1204 (clock). | NARROWED — the NAS tier only. **2026-10-22 night (read, `backup/offbox_window.go:22-24, 130-137`):** on the Felhom off-site tier no real snapshot is lost — the guard refuses a prune while any snapshot is future-dated (R-822, by design). The NAS tier prunes without that guard: open. **2026-10-25:** the kept-data choice no longer takes such a future-dated copy as the newest (R-1271, controller `bd9f4f7`); retention is unchanged — this row. | — | A pure unit test with the policy simulator on the NAS tier and one future-dated snapshot; if a real one is dropped, give the NAS tier the same guard | CC | | **R-1212** | Backup & restore | P4 | **Two household texts on the backup path do not follow the box's language:** the share-restore summary (`SharesRestoreResult.FlashMessage`, `controller/internal/backup/shares_restore.go`) is built from Hungarian Go literals, and the off-site `LastError` for an interrupted run is the Hungarian literal „megszakadt futás (a vezérlő újraindult futás közben)" (`controller/internal/backup/backup.go`). An English household reads Hungarian there. Found by the night helper 2026-10-21. | OPEN | — | Bundle keys for both (rendered in the box language when written, R-557), with parity fixtures | CC | | **R-1221** | Backup & restore | P4 | **The nightly whole-box backup starts ~1.5–5 min later each night, until one night is skipped (after ~70–80 nights with no press between).** REAL, low harm (night 2026-10-22; split from R-1189 (b)): agent `localapi/server.go:1184` is `age >= Cadence` with the age counted from the archive START; the controller polls every 5 min (`quiesce.go:150`) and the archive starts after the apps stop. Measured on demo-hp: 04:31:07 → 04:33:18 → 04:36:47 → 04:38:14, and 04:33:37 → 04:38:44 → 04:42:40. At W+6h one night is skipped (44 h is below the 48 h valve, `quiesce.go:807-818`); the hub's 26 h line (`deadline.go:29`) mails the operator. Any manual or off-cycle run resets the drift. Evidence `audits/night-2026-10-22/P3-candidates/demohp-whole-box-completions.txt`. | OPEN | — | Fix: inside the night window a tier is due at `age >= cadence - 4h`; the agent answers due-ness, so it is agent code (a unit test with an injected clock) and rides an agent release | CC | -| **R-1228** | Backup & restore | P3 | **The website, the installer script and the hub are all served from the home line — a DooPlex loss stops new installs and the public site.** DooPlex survey 2026-10-23 night (`audits/dooplex-survival-2026-10-22/PLAN.md`, G8): `felhom.eu`, `hub.felhom.eu` and `gitea.dooplex.hu` resolve to the home address; the installer fetches `felhom.eu/scripts/felhom-host-install.sh`; only the ISO (Cloudflare R2) survives. | OPEN — **2026-10-23 — D35 ruled YES, after the release week (operator):** serve the website and the installer from Cloudflare like the ISO. Dated: start on or after 2026-10-30 (DUE-CHECKS). **2026-10-30: SWITCHED** (operator yes, 10:02:35 UTC): `felhom.eu` and `www` are Cloudflare Pages (`felhom-site`, uploaded by `scripts/publish-site-cloudflare.sh`); 30 of 30 pages and the installer byte-identical to `installer-v1.36.1` from outside; a fresh install from the public ISO 1.30.0 (throwaway VM 3461 on demo-hp) fetched the installer from the test address and from `felhom.eu` and passed the preflight both times; the contact form posts to `api.felhom.eu` (DooPlex mailer, a test mail arrived). `audits/release-2026-10-30/p3-cloudflare/`. **What is left:** a new install still needs DooPlex for the hub (claim, config, first report) and Gitea (the agent package, the golden) — by design (D35); DooPlex's webpage copy runs until 2026-11-06 as the way back (DUE-CHECKS), then the operator decides to retire it. NARROWED **2026-11-07: asked** (demo-check session, in chat); the due date moved to 2026-11-14 to wait for the answer. | the operator's yes | Operator decides (STATUS): A) accept — Gitea and the hub first, the site after; B) the static site + the installer tag on Cloudflare like the ISO — CC's pick, after the release week | CC (ruled yes 2026-10-23, D35) | +| **R-1228** | Backup & restore | P3 | **The website, the installer script and the hub are all served from the home line — a DooPlex loss stops new installs and the public site.** DooPlex survey 2026-10-23 night (`audits/dooplex-survival-2026-10-22/PLAN.md`, G8): `felhom.eu`, `hub.felhom.eu` and `gitea.dooplex.hu` resolve to the home address; the installer fetches `felhom.eu/scripts/felhom-host-install.sh`; only the ISO (Cloudflare R2) survives. | OPEN — **2026-10-23 — D35 ruled YES, after the release week (operator):** serve the website and the installer from Cloudflare like the ISO. Dated: start on or after 2026-10-30 (DUE-CHECKS). **2026-10-30: SWITCHED** (operator yes, 10:02:35 UTC): `felhom.eu` and `www` are Cloudflare Pages (`felhom-site`, uploaded by `scripts/publish-site-cloudflare.sh`); 30 of 30 pages and the installer byte-identical to `installer-v1.36.1` from outside; a fresh install from the public ISO 1.30.0 (throwaway VM 3461 on demo-hp) fetched the installer from the test address and from `felhom.eu` and passed the preflight both times; the contact form posts to `api.felhom.eu` (DooPlex mailer, a test mail arrived). `audits/release-2026-10-30/p3-cloudflare/`. **What is left:** a new install still needs DooPlex for the hub (claim, config, first report) and Gitea (the agent package, the golden) — by design (D35); DooPlex's webpage copy runs until 2026-11-06 as the way back (DUE-CHECKS), then the operator decides to retire it. NARROWED **2026-11-07: asked, and the operator said RETIRE IT (in chat, 23:0x CET).** The copy has no visitors (`felhom.eu` resolves to Cloudflare, also from DooPlex). It is not just a Deployment to delete: `manifests/webpage.yaml` is also where the installer TAG is pinned — `publish-site-cloudflare.sh` reads it and `hostinstall_gates.py` gate 6 checks it, and the R-110 fence in `CLAUDE.md` + `.claude/rules/manifests.md` describes it. So the retirement moves the tag pin to its own home first (script + gate 6 + its decoys + the fence text), then removes the webpage Deployment, Service, Ingress, its two ConfigMaps and TLS Secret by a deliberate ArgoCD sync (FileBrowser in the same file stays). Not started at night during an agent rollout. | the operator's yes | Operator decides (STATUS): A) accept — Gitea and the hub first, the site after; B) the static site + the installer tag on Cloudflare like the ISO — CC's pick, after the release week | CC (ruled yes 2026-10-23, D35) | | **R-1229** | Backup & restore | P4 | **ArgoCD's per-app auto-sync settings live only on the cluster, not in git.** DooPlex survey 2026-10-23 night (`audits/dooplex-survival-2026-10-22/PLAN.md`, G9): the Applications differ (`enabled` true/false, prune, selfHeal); a rebuilt cluster from `homelab-manifests` would not have them. | OPEN | — | Write each Application's sync policy into `homelab-manifests` (a homelab change, the operator's yes for the commit that changes live behaviour) | operator | | **R-1231** | Backup & restore | P4 | **The recovery page says the box's host service is „older” when the controller only cannot tell.** Test night 2026-10-23 (`P4-testnight/07a-unlock-refused-fixture-version.txt`): with the agent's capability `unknown` (a version string it could not compare), the page read „…a hozzá tartozó házon belüli szolgáltatás régebbi… A gép magától frissül”. The same page is reached by a merely-down agent, for which „older” and „updates itself” are not true. | OPEN | — | A separate sentence for `unknown` („a gép most nem éri el a házon belüli szolgáltatást — próbáld újra pár perc múlva”); a render test per state; rides a controller release | CC | | **R-1235** | Backup & restore | P3 | **The release copy on ep0 (D33) is proven to load, not yet to restore a registry — and it follows the newest versions, not the ones the fleet needs.** Built 2026-10-23 (`scripts/release-copy/`, `audits/day-2026-10-23/08-release-copy-D33.txt`). (a) `total-loss-of-dooplex.md` „Restore the release copy” steps 2 (skopeo push back, same digest) and 3 (agent packages back) were never walked into a real new registry — only a `docker load` on 9202. (b) The copy keeps the newest 3 image tags and 5 agent versions; it does not ask the hub for the floor and the vouched agent and always include them — a vouch more than 5 releases behind would fall out. (c) The golden (`felhom-golden`, 637 MB for 0.331.0) is not copied: a new install after a DooPlex loss needs it (the brief named images + agent packages only; one golden fits under the 3 GB cap). | OPEN | — | (a) a throwaway registry on demo-hp, push one tag back, compare digests; (b) read `/configuration`'s floor + vouched agent; (c) the operator says whether the golden goes in | CC | @@ -406,6 +406,5 @@ stopping line that lies. | R-1035 | 2026-11-11 | „felhő tárhely” + „saját felhő tárhely” impressions and average position, and the new page's own impressions/clicks/position, 4 weeks after publishing; operator supplies the read; detail in the R-1035 row | | R-528 | 2026-11-12 | no failed oom_check / unexplained app_oom since 2026-10-12; detail in the R-528 row | | R-1035 | 2026-12-09 | the same three numbers 8 weeks after publishing, compared with the 4-week read; a null at 4 weeks is not a verdict, both reads are needed; detail in the R-1035 row | -| R-1228 | 2026-11-14 | D35 way back ends: ask the operator to retire DooPlex's felhom.eu/www webpage copy (ingress + git-sync), or keep it longer; detail in the R-1228 row | | **R-1297** | Process & tooling | P3 | **CI waits dominate a working day: felhom.eu's CI run takes ~13 min and the ONE runner makes every repo's job wait behind it (up to 10–17 min queued).** Measured 2026-10-26 (operator: „these CI waiting times are getting out of hand"): since 10-25 felhom.eu runs median 793 s, the controller/agent/catalog 57–102 s; queue median 5 s, max 548–1023 s. In run 2366, ~10 of 13 minutes are four suites — `test_gate_decoys.py` 232 s, `test_due_checks_gate.py` 133 s, `test_repo_gates.py` 132 s (it runs the whole gate set a SECOND time), `dooplex-offsite/test_dooplex_offsite.py` 69 s — plus `hub-gofmt` ~110 s (a cold Go cache per run). **Ruled 2026-10-26 by the operator: BOTH** — (A) trim felhom.eu CI: the heavy suites only when their own files change, no duplicate full run, a Go cache for hub-gofmt; (B) a second CI runner on DooPlex. | **OPEN — (A) BUILT 2026-10-26 evening, closes after the first nightly run (2026-10-27 03:20 UTC) is read.** Both runners proven at once (jobs 2374 + 2375 on `felhom-gates-runner` / `-2`, 17:11:41Z). felhom.eu CI, one push each: **(a) docs only** 810 s (old runner) → 351 s (2 CPUs) → see the close-out; **(b) a gate script** 351 → 203 s (`ecd82aba`, job 2376); **(c) a hub change** 351 → 118 s (`7e57e824`, job 2378). Heavy suites by path + tag + nightly; `test_repo_gates.py` and due-checks group G test the wiring (130 s each → under 3 s). Decoys through CI's entry point with the filter ON: a docs-only register break → red; an inert gate → GREEN at first (a decoy hole, fixed in `e33ec821`) → red. No Go cache: CI has no Go (the 110 s was security-scan, now 27 s). `audits/r1297-ci-trim-2026-10-26/`. (B) DONE by the operator (`09` §3 decision 267). | — | (A) CC, a run of its own (a CI scope change, ruled); (B) a DooPlex change — a brief or the homelab session; and CC waits on CI only before a release or at the end of a run | CC | diff --git a/documentation/runbooks/day0-install.md b/documentation/runbooks/day0-install.md index 153bd7cc..87e0aa8c 100644 --- a/documentation/runbooks/day0-install.md +++ b/documentation/runbooks/day0-install.md @@ -233,8 +233,9 @@ directions: it makes an operator think a pushed fix is live when it is not, and is live when it is not. (Measured 2026-08-12: this URL served `1.25.0` while `main` held `1.27.0`, three and a half hours after the push.) -`manifests/webpage.yaml` runs **two** git-syncs against **different refs**: the website tracks `main`, -and `/scripts/` is checked out from the tag **`installer-v`**. So this URL serves the +felhom.eu is Cloudflare Pages (D35). `scripts/publish-site-cloudflare.sh` uploads the website from `main` and +`/scripts/` from the tag **`installer-v`** named in `INSTALLER-TAG` (one line; until 2026-11-07 the +pin was two `--ref` lines in `manifests/webpage.yaml`, DooPlex's retired copy — R-1228). So this URL serves the **tagged** installer, not `main`. **What actually publishes it — three acts, and the middle one is two lines, not one:** @@ -242,15 +243,13 @@ and `/scripts/` is checked out from the tag **`installer-v`**. S 1. Bump `SCRIPT_VERSION` in `scripts/felhom-host-install.sh` (the single version source) and push to `main`. *Nothing is published yet.* 2. Cut and push the tag `installer-v`. -3. Move **BOTH** `--ref=installer-v…` pins in `manifests/webpage.yaml` — the git-sync **sidecar** and - the **init container** (today lines 327 and 372) — commit, and sync ArgoCD. **Moving one pin is the - trap**: the running pod keeps serving until it restarts, and then a fresh pod seeded by the stale - init container serves the OLD script with no error anywhere. +3. Put the new tag in `INSTALLER-TAG`, commit, push, then upload: + `sec run CF_PAGES_TOKEN -- scripts/publish-site-cloudflare.sh` (`--dry-run` first). -**To roll back:** move the tag back and wait ~30 s. No ArgoCD sync, no deploy — that is the emergency -lever; fix forward afterwards. **Do not pin the website to the tag**, or every copy edit becomes a -release. `hostinstall_gates.py` gate 6 fails if the manifest stops naming an `installer-v…` tag or if -the website stops tracking `main`. +**To roll back:** move the tag back, then run the same script (~1 min; it re-reads the tag). That is the +emergency lever; fix forward afterwards. **Do not pin the website to the tag**, or every copy edit becomes a +release. `hostinstall_gates.py` gate 6 fails if `INSTALLER-TAG` does not name exactly one tag, if a manifest +pins a second one, or if the publish script stops taking the site from `main` and `/scripts/` from the tag. **How to verify from OUTSIDE that the published version actually changed** — a push, a green sync and a correct-looking manifest are each consistent with nothing having been published, so ask the public URL diff --git a/documentation/runbooks/publish-train-rules.md b/documentation/runbooks/publish-train-rules.md index 9db5cd94..8fbc82dd 100644 --- a/documentation/runbooks/publish-train-rules.md +++ b/documentation/runbooks/publish-train-rules.md @@ -114,9 +114,9 @@ one stops an ISO from carrying a golden the floor has already outrun. ## 6. The website and the installer reach the public through Cloudflare (D35 / R-1228, 2026-10-30) -`felhom.eu` (site + `/scripts/felhom-host-install.sh`) is Cloudflare Pages since 2026-10-30. A push to `main` and a -new `installer-v…` tag change only DooPlex's copy (the way back); the public copy changes with -`sec run CF_PAGES_TOKEN -- scripts/publish-site-cloudflare.sh` (`--dry-run` first). The script reads the installer tag -from `manifests/webpage.yaml`, so bump both `--ref`s there FIRST, then upload — and it refuses an installer that is not +`felhom.eu` (site + `/scripts/felhom-host-install.sh`) is Cloudflare Pages since 2026-10-30; DooPlex's copy (the way +back) was retired on 2026-11-07. A push to `main` or a new `installer-v…` tag changes nothing public; the public copy +changes with `sec run CF_PAGES_TOKEN -- scripts/publish-site-cloudflare.sh` (`--dry-run` first). The script reads the +installer tag from `INSTALLER-TAG`, so put the new tag there FIRST, then upload — and it refuses an installer that is not the tag's byte for byte. Check after: `felhom.eu/scripts/felhom-host-install.sh`'s sha256 equals `git show installer-v:scripts/felhom-host-install.sh | sha256sum`. diff --git a/documentation/runbooks/secrets.md b/documentation/runbooks/secrets.md index eb6b6ea6..f1952bc6 100644 --- a/documentation/runbooks/secrets.md +++ b/documentation/runbooks/secrets.md @@ -280,7 +280,7 @@ attempted; reachability is the point. The operator ruled that `felhom.eu` stays anonymously readable for the moment. Making it private breaks two live paths that clone it with **no credentials**: the website git-sync in -`manifests/webpage.yaml`, and the installer fetched from the `installer-v…` tag by every new box +`manifests/webpage.yaml` (retired 2026-11-07, R-1228 — no longer a reason), and the installer fetched from the `installer-v…` tag by every new box (R-110). Both would have to be given credentials first. The standing consequence of that ruling: **no secret may ever enter this repo again** — which `manifest_bearer_gate.py` now enforces with no exemption. If the decision is ever reversed, the 32 `` comments served on diff --git a/manifests/contact-mailer.yaml b/manifests/contact-mailer.yaml index f2657e1b..470ae1b1 100644 --- a/manifests/contact-mailer.yaml +++ b/manifests/contact-mailer.yaml @@ -112,37 +112,6 @@ spec: - port: 80 targetPort: 8080 --- -# Ingress: routes felhom.eu/api/* to the contact mailer -# This is a SEPARATE ingress from the website - nginx-ingress merges them -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: contact-mailer - namespace: felhom-system - annotations: - cert-manager.io/cluster-issuer: letsencrypt-prod - # Allow larger uploads for attachments - nginx.ingress.kubernetes.io/proxy-body-size: "25m" - # Timeout for large file uploads - nginx.ingress.kubernetes.io/proxy-read-timeout: "60" - nginx.ingress.kubernetes.io/proxy-send-timeout: "60" -spec: - ingressClassName: nginx-internal - tls: - - hosts: - - felhom.eu - secretName: felhom-webpage-tls - rules: - - host: felhom.eu - http: - paths: - - path: /api - pathType: Prefix - backend: - service: - name: contact-mailer - port: - number: 80--- # D35 / R-1228 (operator yes 2026-10-30): the website moves to Cloudflare Pages, which cannot run this mailer. The form # posts here instead (website `formEndpoint` = https://api.felhom.eu/api/contact); the mailer's CORS already answers # for https://felhom.eu and https://www.felhom.eu. DNS: api.felhom.eu CNAME → the home line, NOT proxied (HTTP-01). diff --git a/manifests/webpage.yaml b/manifests/webpage.yaml index a13e557a..a583b698 100644 --- a/manifests/webpage.yaml +++ b/manifests/webpage.yaml @@ -43,68 +43,6 @@ data: } --- # =================== -# NGINX CONFIG FOR CLEAN URLs -# =================== -apiVersion: v1 -kind: ConfigMap -metadata: - name: nginx-config - namespace: felhom-system -data: - default.conf: | - server { - listen 80; - server_name _; - root /usr/share/nginx/html/current/website; - index index.html; - - # R-931 (2026-10-12): compress text. Measured before: site.css went out as 114 784 raw bytes, no - # Content-Encoding. text/html is always compressed once gzip is on; the list adds the rest. - gzip on; - gzip_comp_level 5; - gzip_min_length 1024; - gzip_vary on; - gzip_proxied any; - gzip_types text/css application/javascript text/javascript application/json image/svg+xml text/plain application/xml; - - # Enable clean URLs - serve .html files without extension - location / { - try_files $uri $uri.html $uri/ =404; - } - - location = /sitemap.xml { - types { application/xml xml; } - default_type application/xml; - } - - # Host-install script. It lives at the repo's /scripts (outside the website doc-root), - # synced into .../current/scripts by git-sync (see the sparse-checkout ConfigMap). Served - # as text/plain so operators can inspect it in a browser before download-then-run. - # R-110: served from the INSTALLER TAG's tree, not the website's. The URL is unchanged - # (https://felhom.eu/scripts/felhom-host-install.sh) — it never carried a ref, so every - # producer of it (the bootstrap script, the hub's day-0 command) follows the tag with no - # edit. What changed is which tree this root points at. - location /scripts/ { - root /usr/share/nginx/scripts/current; - default_type text/plain; - } - - # Cache static assets - location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { - expires 7d; - add_header Cache-Control "public, immutable"; - } - - # Security headers - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - - # Error pages - error_page 404 /404.html; - error_page 500 502 503 504 /50x.html; - } ---- -# =================== # FILEBROWSER # =================== apiVersion: apps/v1 @@ -219,255 +157,3 @@ spec: name: filebrowser port: number: 80 ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: git-sync-sparse-checkout - namespace: felhom-system -data: - # R-110: TWO sparse-checkouts, because there are now two syncs with two different refs. - # The website tracks `main` (a copy edit must never need a release); /scripts/ tracks the - # installer TAG (pushing the installer must never publish it). - sparse-checkout: | - /website/ - sparse-checkout-scripts: | - /scripts/ ---- -# =================== -# WEBPAGE (nginx) -# =================== -apiVersion: apps/v1 -kind: Deployment -metadata: - name: felhom-webpage - namespace: felhom-system - labels: - app: felhom-webpage -spec: - replicas: 1 - selector: - matchLabels: - app: felhom-webpage - template: - metadata: - labels: - app: felhom-webpage - annotations: - # The nginx config is mounted with subPath, which never refreshes in a running pod: bump this when - # the nginx-config ConfigMap changes, so the sync restarts the pod onto it. - felhom.eu/nginx-config-rev: "2026-10-12-gzip" - spec: - containers: - - name: nginx - image: nginx:alpine - ports: - - containerPort: 80 - volumeMounts: - - name: git-data - mountPath: /usr/share/nginx/html - readOnly: true - - name: git-data-scripts - mountPath: /usr/share/nginx/scripts - readOnly: true - - name: nginx-config - mountPath: /etc/nginx/conf.d/default.conf - subPath: default.conf - resources: - requests: - memory: "32Mi" - cpu: "10m" - limits: - memory: "128Mi" - cpu: "200m" - livenessProbe: - httpGet: - path: / - port: 80 - initialDelaySeconds: 5 - periodSeconds: 30 - readinessProbe: - httpGet: - path: / - port: 80 - initialDelaySeconds: 3 - periodSeconds: 10 - - # ── The WEBSITE sync — tracks `main`, unchanged cadence ────────────────────────────── - # Deliberately still a branch: the site is content, and a typo fix must reach felhom.eu in - # thirty seconds without cutting a release. Only /scripts/ moved to a tag (R-110). - # - # All four git-syncs read Gitea IN-CLUSTER (plain HTTP, never leaves the cluster — the hub's own Gitea - # client does the same): no internet hop, so the pod can start during an internet outage (2026-10-19 09:24 - # the public path failed once and the sidecar restarted). If the website ever leaves the DooPlex cluster, - # this must go back to https://gitea.dooplex.hu/admin/felhom.eu.git. `hostinstall_gates.py` gate 6 fails - # if the four `--repo` values differ. - - name: git-sync - image: registry.k8s.io/git-sync/git-sync:v4.4.0 - args: - - --repo=http://gitea.gitea-system.svc.cluster.local:3000/admin/felhom.eu.git - - --ref=main - - --root=/git - - --link=current - - --period=30s - - --max-failures=-1 # an outage must not restart the sidecar (git-sync v4.4.0 main.go L922: <0 = retry forever) - # Only sync the website subdirectory - - --sparse-checkout-file=/etc/git-sync/sparse-checkout - volumeMounts: - - name: git-data - mountPath: /git - - name: sparse-checkout - mountPath: /etc/git-sync - resources: - requests: - memory: "32Mi" - cpu: "10m" - limits: - memory: "128Mi" - cpu: "100m" - securityContext: - runAsUser: 65534 # nobody - - # ── The INSTALLER sync — tracks a TAG (R-110, operator ruling 2026-08-03) ───────────── - # felhom-host-install.sh runs as root on a virgin machine. Before this it was served - # straight from `main`, so pushing it WAS publishing it: within thirty seconds it was what - # every new machine downloaded and ran, with no staging and no rollback but another push. - # - # Publishing is now moving this tag; rolling back is moving it back. PROVEN, not assumed: - # git-sync v4.4.0 follows a tag AND notices a moved one — measured 2026-08-03 on a - # throwaway sync against this very repo (`update required … local: remote:` → - # `updated successfully`, one period, ~20 s). - # - # Bump this ref when the installer's published version changes. `hostinstall_gates.py` - # gate 6 fails if this sync stops naming an `installer-v…` tag. - - name: git-sync-scripts - image: registry.k8s.io/git-sync/git-sync:v4.4.0 - args: - - --repo=http://gitea.gitea-system.svc.cluster.local:3000/admin/felhom.eu.git - - --ref=installer-v1.36.1 - - --root=/git-scripts - - --link=current - - --period=30s - - --max-failures=-1 # an outage must not restart the sidecar (git-sync v4.4.0 main.go L922: <0 = retry forever) - - --sparse-checkout-file=/etc/git-sync-scripts/sparse-checkout - volumeMounts: - - name: git-data-scripts - mountPath: /git-scripts - - name: sparse-checkout-scripts - mountPath: /etc/git-sync-scripts - resources: - requests: - memory: "32Mi" - cpu: "10m" - limits: - memory: "128Mi" - cpu: "100m" - securityContext: - runAsUser: 65534 # nobody - - # Init container: wait for first sync before nginx starts - initContainers: - # BOTH trees are seeded before nginx accepts traffic. The second one is why /scripts/ has - # no 404 window across this change: a fresh pod does not become ready until the installer - # tag has been checked out, exactly as the website already worked. - - name: git-sync-init - image: registry.k8s.io/git-sync/git-sync:v4.4.0 - args: - - --repo=http://gitea.gitea-system.svc.cluster.local:3000/admin/felhom.eu.git - - --ref=main - - --root=/git - - --link=current - - --one-time - - --sparse-checkout-file=/etc/git-sync/sparse-checkout - volumeMounts: - - name: git-data - mountPath: /git - - name: sparse-checkout - mountPath: /etc/git-sync - securityContext: - runAsUser: 65534 - - name: git-sync-scripts-init - image: registry.k8s.io/git-sync/git-sync:v4.4.0 - args: - - --repo=http://gitea.gitea-system.svc.cluster.local:3000/admin/felhom.eu.git - - --ref=installer-v1.36.1 - - --root=/git-scripts - - --link=current - - --one-time - - --sparse-checkout-file=/etc/git-sync-scripts/sparse-checkout - volumeMounts: - - name: git-data-scripts - mountPath: /git-scripts - - name: sparse-checkout-scripts - mountPath: /etc/git-sync-scripts - securityContext: - runAsUser: 65534 - - volumes: - - name: git-data - emptyDir: {} - - name: git-data-scripts - emptyDir: {} - - name: nginx-config - configMap: - name: nginx-config - - name: sparse-checkout - configMap: - name: git-sync-sparse-checkout - items: - - key: sparse-checkout - path: sparse-checkout - - name: sparse-checkout-scripts - configMap: - name: git-sync-sparse-checkout - items: - - key: sparse-checkout-scripts - path: sparse-checkout ---- -apiVersion: v1 -kind: Service -metadata: - name: felhom-webpage - namespace: felhom-system -spec: - selector: - app: felhom-webpage - ports: - - port: 80 - targetPort: 80 ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: felhom-webpage - namespace: felhom-system - annotations: - cert-manager.io/cluster-issuer: letsencrypt-prod -spec: - ingressClassName: nginx-internal - tls: - - hosts: - - felhom.eu - - www.felhom.eu - secretName: felhom-webpage-tls - rules: - - host: felhom.eu - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: felhom-webpage - port: - number: 80 - - host: www.felhom.eu - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: felhom-webpage - port: - number: 80 \ No newline at end of file diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index e922b6a2..420a15bc 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -1,3 +1,20 @@ +## 2026-11-07 — the installer pin moves to `INSTALLER-TAG`; DooPlex's copy of the website retired (R-1228, operator yes) + +- **Why.** Since 2026-10-30 Cloudflare Pages serves felhom.eu; DooPlex's git-sync + nginx copy was the way back until + 2026-11-06, and the operator said retire it. That copy's manifest was ALSO the installer pin (two `--ref` lines), read by + `publish-site-cloudflare.sh` and checked by gate 6 — so the pin moved first. +- **`INSTALLER-TAG`** (repo root, one line `installer-v1.36.1`) is now the only pin. `publish-site-cloudflare.sh` reads it + (refuses anything but exactly one `installer-vX.Y.Z`). Dry run: installer sha256 `e18cdd17…` = the public file's. +- **`hostinstall_gates.py` gate 6 (6c)** now checks: the pin file holds one tag; no manifest pins an installer tag; the + publish script reads the pin file and takes the site from `origin/main`, `/scripts/` from the tag. Red seen on five + mutants (the old manifest pin, two tags, a bad tag shape, site from the tag, the script reading the manifest). +- **Manifests:** `webpage.yaml` keeps only FileBrowser (removed: the webpage Deployment, Service, Ingress, `nginx-config`, + `git-sync-sparse-checkout`). `contact-mailer.yaml`: the old `felhom.eu/api` Ingress block removed — it had been glued + to the next document by a `number: 80---` typo, so git already parsed only `contact-mailer-api`; the live object + was an orphan. The form's route (`api.felhom.eu`, its own certificate) is unchanged. +- **Docs:** the R-110 fence in `CLAUDE.md`, `.claude/rules/manifests.md`, `.claude/rules/website.md`, the build skill, + `day0-install.md`, `publish-train-rules.md`, `secrets.md`, the poster facts. + ## 2026-11-02 — the watch: stated unreadable pins are GAP, timeouts retried, every failed source printed (R-1334); R-1310's rules - **Measured first** (`documentation/audits/security-2026-11-02/part1/DIAGNOSIS.md`): five real nights, both runners, 0 Docker Hub 429. A diff --git a/scripts/hostinstall_gates.py b/scripts/hostinstall_gates.py index 7118817e..d0d19522 100644 --- a/scripts/hostinstall_gates.py +++ b/scripts/hostinstall_gates.py @@ -182,8 +182,8 @@ else: # invisible in a diff that touches one line. # 6b. `fetch_raw` still pins to the resolved agent version — the positive form, so the mechanism # cannot be quietly deleted rather than regressed. -# 6c. the website manifest still syncs `/scripts/` from a TAG ref and the website from `main` — -# the split is the deploy-side half of the same channel, and reverting it is one word. +# 6c. INSTALLER-TAG is the one pin, no manifest pins a second one, and the publish script takes the site +# from `main` and `/scripts/` from the tag — the deploy-side half of the same channel (R-1228). branch_refs = [l for l in lines if "raw/branch/" in l and not l.lstrip().startswith("#")] if branch_refs: fail("installer still fetches from a BRANCH ref — the run-time channel is floating again " @@ -197,34 +197,54 @@ else: fail("fetch_raw no longer pins to $ART_AGENT_VER — the agent's configs and its binary can " "again come from different refs in one install (R-183)") -WEBPAGE = os.path.join(ROOT, "manifests", "webpage.yaml") +# Since 2026-11-07 (R-1228, DooPlex's copy retired) the installer tag is pinned in ONE place, INSTALLER-TAG, and +# Cloudflare Pages is the only publish path (scripts/publish-site-cloudflare.sh). What must hold: +# - INSTALLER-TAG names exactly one `installer-vX.Y.Z` (the script refuses anything else; the gate says so first); +# - no manifest names an installer tag any more — a second pin is how two copies serve different installers; +# - the publish script reads the pin file, takes the site from origin/main and scripts/ from the tag. +PIN = os.path.join(ROOT, "INSTALLER-TAG") try: - with io.open(WEBPAGE, "r", encoding="utf-8") as f: - wp = f.read() + with io.open(PIN, "r", encoding="utf-8") as f: + pin_lines = [l.strip() for l in f if l.strip() and not l.lstrip().startswith("#")] except IOError as e: - fail("cannot read manifests/webpage.yaml to check the publish channel: %s" % e) - wp = None -if wp is not None: - # The scripts sync must name a tag ref; the website sync must still track main. - if re.search(r'--ref=installer-v', wp): - ok("manifest: /scripts/ syncs from an installer tag") + fail("cannot read INSTALLER-TAG (the installer pin, R-110/R-1228): %s" % e) + pin_lines = None +if pin_lines is not None: + if len(pin_lines) == 1 and re.match(r'^installer-v\d+\.\d+\.\d+$', pin_lines[0]): + ok("INSTALLER-TAG pins one installer tag (%s)" % pin_lines[0]) else: - fail("manifests/webpage.yaml has no `--ref=installer-v…` sync — /scripts/ is not served " - "from a tag, so pushing the installer publishes it again (R-110)") - if re.search(r'--(branch|ref)=main', wp): - ok("manifest: the website still tracks main (a copy edit must not need a release)") + fail("INSTALLER-TAG must hold exactly ONE line `installer-vX.Y.Z` (found: %s) — the publish script " + "refuses it, and the public installer cannot be moved" % (pin_lines or "nothing")) +stale = [] +for dirpath, _dirs, files in os.walk(os.path.join(ROOT, "manifests")): + for fn in files: + if fn.endswith((".yaml", ".yml")): + with io.open(os.path.join(dirpath, fn), "r", encoding="utf-8") as f: + if re.search(r'--ref=installer-v', f.read()): + stale.append(os.path.relpath(os.path.join(dirpath, fn), ROOT)) +if stale: + fail("a manifest still pins an installer tag (%s) — the pin lives ONLY in INSTALLER-TAG since R-1228; " + "two pins can serve two different installers" % ", ".join(stale)) +else: + ok("no manifest pins an installer tag (INSTALLER-TAG is the only pin)") +PUB = os.path.join(ROOT, "scripts", "publish-site-cloudflare.sh") +try: + with io.open(PUB, "r", encoding="utf-8") as f: + pub = f.read() +except IOError as e: + fail("cannot read scripts/publish-site-cloudflare.sh (the only publish path): %s" % e) + pub = None +if pub is not None: + code = "\n".join(l for l in pub.splitlines() if not l.lstrip().startswith("#")) + if '"$REPO/INSTALLER-TAG"' in code: + ok("publish script reads INSTALLER-TAG") else: - fail("manifests/webpage.yaml no longer tracks main for the website — pinning the SITE to " - "the installer tag turns every copy edit into a release") - # All four git-sync containers (two sidecars, two init containers) must read the SAME repo: they moved to - # the in-cluster Gitea address together (2026-10-19), and an init container left on another URL seeds the - # pod from a different source than the sidecar that keeps it fresh. - repos = re.findall(r'--repo=(\S+)', wp) - if len(repos) == 4 and len(set(repos)) == 1: - ok("manifest: all four git-syncs read the same repo (%s)" % repos[0]) + fail("publish-site-cloudflare.sh no longer reads INSTALLER-TAG — it can upload an installer nobody pinned") + if re.search(r'archive origin/main website', code) and re.search(r'archive "\$TAG" scripts', code): + ok("publish script: the site from main, scripts/ from the tag (a copy edit must not need a release)") else: - fail("manifests/webpage.yaml: the git-sync `--repo` values differ or are not four (%s) — the " - "sidecars and init containers must read one repo" % ", ".join(repos)) + fail("publish-site-cloudflare.sh: the site must come from origin/main and scripts/ from the tag — " + "pinning the SITE to the tag turns every copy edit into a release; scripts/ from main publishes on push") # ── R-185: every path that RESOLVES the backup target must also grant on it ────────────────── # diff --git a/scripts/publish-site-cloudflare.sh b/scripts/publish-site-cloudflare.sh index c9664e70..b3e7d3a7 100755 --- a/scripts/publish-site-cloudflare.sh +++ b/scripts/publish-site-cloudflare.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash # publish-site-cloudflare.sh [--dry-run] [--project NAME] — D35 / R-1228 (2026-10-30): the public website and the -# installer, uploaded to Cloudflare Pages. It replaces „push to main and DooPlex's git-sync serves it" for the copy on -# Cloudflare; DooPlex keeps serving its own copy until the DNS switch, and for 7 days after it as the way back. +# installer, uploaded to Cloudflare Pages. It is the ONLY publish path: DooPlex's own copy (git-sync + nginx) was the +# way back until 2026-11-06 and was retired on 2026-11-07 (operator yes). # -# WHAT IT UPLOADS — the same files DooPlex serves today, from the same refs: -# / website/ from origin/main (DooPlex: git-sync --ref=main, nginx root website/) -# /scripts/ scripts/ from the installer TAG (DooPlex: git-sync --ref=installer-v, nginx location /scripts/) -# The tag is read from manifests/webpage.yaml, so the two copies can never name different installers. Clean URLs +# WHAT IT UPLOADS: +# / website/ from origin/main (a copy edit never needs a release) +# /scripts/ scripts/ from the installer TAG (pushing the installer publishes nothing — R-110) +# The tag is read from INSTALLER-TAG at the repo root (one line; hostinstall_gates.py gate 6 checks it). Clean URLs # (/kapcsolat → kapcsolat.html), 404.html and compression are Pages' own behaviour — nothing to configure. # # Run: sec run CF_PAGES_TOKEN -- scripts/publish-site-cloudflare.sh [--dry-run] @@ -30,9 +30,9 @@ NODE_IMAGE="node:20.17.0-alpine" REPO=$(git -C "$(dirname "$0")" rev-parse --show-toplevel) git -C "$REPO" fetch -q origin main --tags -TAG=$(grep -oE -- '--ref=installer-v[0-9]+\.[0-9]+\.[0-9]+' "$REPO/manifests/webpage.yaml" | sort -u | sed 's/--ref=//') -if [ "$(printf '%s\n' "$TAG" | wc -l)" != 1 ] || [ -z "$TAG" ]; then - echo "REFUSED: manifests/webpage.yaml must name exactly ONE installer tag (found: ${TAG:-none})" >&2; exit 1 +TAG=$(grep -v '^[[:space:]]*#' "$REPO/INSTALLER-TAG" | grep -oE '^installer-v[0-9]+\.[0-9]+\.[0-9]+$' || true) +if [ "$(grep -v '^[[:space:]]*#' "$REPO/INSTALLER-TAG" | grep -c .)" != 1 ] || [ -z "$TAG" ]; then + echo "REFUSED: INSTALLER-TAG must hold exactly ONE line installer-vX.Y.Z (found: ${TAG:-none})" >&2; exit 1 fi git -C "$REPO" rev-parse -q --verify "refs/tags/$TAG" >/dev/null || { echo "REFUSED: tag $TAG does not exist" >&2; exit 1; } SITE_REV=$(git -C "$REPO" rev-parse --short origin/main) diff --git a/skills/felhom-build-deploy/SKILL.md b/skills/felhom-build-deploy/SKILL.md index e9a96876..7982d5a8 100644 --- a/skills/felhom-build-deploy/SKILL.md +++ b/skills/felhom-build-deploy/SKILL.md @@ -227,7 +227,7 @@ Green gate before any hub commit: `go build ./... && go vet ./... && go test ./. ## Website (felhom.eu/website) Since 2026-10-30 (D35) the public site is Cloudflare Pages: push to `main`, then `sec run CF_PAGES_TOKEN -- -scripts/publish-site-cloudflare.sh` (`--dry-run` first; DooPlex's git-sync copy is only the way back). **Run `python3 scripts/site_gates.py` +scripts/publish-site-cloudflare.sh` (`--dry-run` first; DooPlex's git-sync copy was retired 2026-11-07, R-1228 — the installer tag is in `INSTALLER-TAG`). **Run `python3 scripts/site_gates.py` first, after ANY website change** (BOM, emoji, nav parity, cache-bust `?v=N` — bump it when css/svg change). New pages must be added to the script's `PAGES` list. Emergency edits: https://files.felhom.eu.