diff --git a/CHANGELOG.md b/CHANGELOG.md index 66a4a03..91037ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +## The new-app checklist: in the catalog, a gate, piloted on wger (2026-10-01, evening) + +- **`NEW-APP-CHECKLIST.md`** — the reviewer's draft (`6f18f74`) reviewed: 60 rows in 10 groups (fit · images, start + command, database · storage and backup · accounts and strangers · health · resources · updates · mail · text · + sign-off), each with how to test it, why it exists, and a `since` date. 7 rows added (0.9 self-call at the public name, + 1.7 every entrypoint switch read and decided, 1.8 debug off, 1.9 `data_key`, 2.8 an upload opens again, 3.9 sign in as + each client does, 8.5 the website count); 16 sharpened; 9 wrong claims fixed (citations, a "how" that could not show + R-737, rows that duplicate a gate now name it). +- **`onboarding/_TEMPLATE.md`** — the record a new app starts from: one line per id, `done | evidence`, `n/a | reason`, + `open | what is missing`. **`onboarding/wger.md`** — the pilot record (wger is exempt; 11 rows open, each a register row). +- **Gate `onboarding`** (`scripts/check-onboarding.py`, in `catalog_gates.py --fast`, so the hook and CI): a template + directory not among the 53 published before 2026-10-01 (listed by name) needs a record answering every id whose `since` + ≤ its `opened:`, none `open`, every `done` naming a non-empty file or a directory holding one, every `n/a` with a + 4-word reason; `_TEMPLATE.md` must carry every id; rows inside an HTML comment do not count. Evidence in a sibling repo + (`felhom.eu/…`) is checked where it sits beside the catalog and listed NOT CHECKED where it does not (the CI runner). + Decoys in `test_gate_decoys.py` (16 cases; 5 gate mutants each seen turning the suite red); `test_catalog_gates.py` + counts 11 gates. +- **`onboarding/EXISTING-APPS-GAPS.md`** + `scripts/onboarding_gaps.py` — read only: per checklist group, what the + committed files show for each of the 53 (headline: fit 52, images/DB 53, storage 38, accounts 39, health 49, + resources 24, updates 26, text 52). It found 8 templates whose `mem_limit` is under the sum of their limits (R-758). +- CLAUDE.md, REUSE.md §5 and README "Adding a New App": step 0 is `cp onboarding/_TEMPLATE.md onboarding/.md`. +- No template changed. Found by the pilot on the live wger template: R-762 (no CSS/JS, no photos served), R-763 + (strangers sign up, guest accounts), R-764 (no mail). Evidence `felhom.eu/documentation/audits/new-app-checklist-2026-10-01/`. + ## calibre-web: the admin login name is generated at install (2026-10-01, late afternoon) - **`09` §3 decision 61** (operator ruling, R-752 option A): a new deploy field `ADMIN_USER` (`type: secret`, diff --git a/CLAUDE.md b/CLAUDE.md index a3023e2..624588f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,13 @@ deployed `app.yaml` (customer secrets) is never overwritten. Full deploy details ## Conventions +- **Adding a NEW app starts with `cp onboarding/_TEMPLATE.md onboarding/.md`** (operator request + 2026-10-01). `NEW-APP-CHECKLIST.md` is the list — 60 checks in 10 groups, each with how to test it and + why it exists; the record answers every id `done` (with evidence that exists), `n/a` (with a reason) or + `open`. The template and its complete record are published in ONE commit: `scripts/check-onboarding.py` + (gate `onboarding`, in `--fast`, so the hook and CI) refuses a new template directory without one. The 53 + apps published before 2026-10-01 are exempt by name; what the catalog shows for them is + `onboarding/EXISTING-APPS-GAPS.md` (regenerate with `python3 scripts/onboarding_gaps.py`). - **See `REUSE.md` before adding or editing an app** — canonical example app (paperless-ngx), required `.felhom.yml` fields, healthcheck family per image type, memory-limit rules, traps. - Update `REUSE.md` in the same commit that changes a catalog-wide convention. diff --git a/CONTEXT.md b/CONTEXT.md index ae57c75..20ee7c2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -2,6 +2,10 @@ > Created with the REUSE.md rollout (2026-07-03). History: `CHANGELOG.md`; format spec: `README.md`. +- **2026-10-01 (evening) — THE NEW-APP CHECKLIST.** `NEW-APP-CHECKLIST.md` (60 rows, `since` per id) + `onboarding/` + (`_TEMPLATE.md`, `wger.md` pilot, `EXISTING-APPS-GAPS.md`) + gate `onboarding` (`check-onboarding.py`, `--fast`, decoys). + The 53 published apps exempt by name (operator default, may reverse). A new app = template + complete record in one + commit. Open from the pilot: R-758..R-764 (R-762/R-763 P2 on wger). - **2026-09-24 (morning) — STEP DEFINITIONS (`5ed599c`).** Every ladder entry but the newest has its own compose at `templates//steps/.yml` (sha256 of `to`, canonical JSON, 16 hex = controller `stacks.StepKey`); gate rule 4 + decoys; the writer keeps the superseded step; 8 backfilled diff --git a/NEW-APP-CHECKLIST.md b/NEW-APP-CHECKLIST.md index 9909dea..74a1fd0 100644 --- a/NEW-APP-CHECKLIST.md +++ b/NEW-APP-CHECKLIST.md @@ -1,119 +1,158 @@ -# NEW-APP CHECKLIST — draft (reviewer, 2026-10-01) +# NEW-APP CHECKLIST — what a new catalog app must have measured before it reaches the live catalog -**What this is.** The list of things every new catalog app must have measured and recorded BEFORE it reaches the live -catalog. Each item exists because something went wrong on a real app — the "why" column names it. The filled-in copy -per app is the app's **onboarding record**; a gate refuses a new app without a complete one. +> Operator request 2026-10-01: before new apps are added, a checklist every new app passes. Reviewer's draft the same +> day, reviewed and piloted on wger by CC (`felhom.eu/documentation/audits/new-app-checklist-2026-10-01/`). +> **The gate:** `scripts/check-onboarding.py` (in `catalog_gates.py --fast`, so the pre-push hook and CI run it). -**How to read an item.** Each answer is **done** (with an evidence path), **n/a** (with a one-line reason), or **open** -(blocks publishing). "Measured" means on the bench or on scratch guest 9202 through the product — never read from -upstream alone. Upstream reading is allowed to PLAN a test, never to CLOSE an item. +**What this is.** Every check below exists because something went wrong on a real app — the *why* column names it. +The filled-in copy per app is the app's **onboarding record**, `onboarding/.md`, started by copying +`onboarding/_TEMPLATE.md`. A new template directory without a complete record is refused by the gate. -**Where it lives (proposed — CC confirms):** the checklist in `app-catalog-felhom.eu/NEW-APP-CHECKLIST.md`; one record per -app in `app-catalog-felhom.eu/onboarding/.md` (outside `templates/`, so the box never syncs it — the controller copies -only `docker-compose.yml` and `.felhom.yml`, `sync.go:364`). The new app is tested from the drill catalog on 9202 and -reaches the live catalog only with a complete record. +**How an item is answered.** One line per id in the record: ` | done | `, ` | n/a | ` +or ` | open | `. +- **done** names evidence that exists: a non-empty file, or a directory holding one. Paths are written from the + workspace root — `app-catalog-felhom.eu/…` or `felhom.eu/documentation/audits/…`. Several paths: separate them with + ` ; `. A note may follow the path(s) after ` — `. +- **n/a** carries a reason of at least four words. +- **open** blocks publishing. + +**Measured, not read.** "Measured" means on the bench (LXC 9401 on demo-hp) or on scratch guest 9202 (drill catalog +only, `09` §6.5) through the product. Upstream reading may PLAN a test; it never CLOSES an item. Where the *how* says +"read", reading is the test (a licence, a tag list). + +**The `since` column.** The date an id joined the list. A record carries every id whose `since` is on or before the +record's `opened:` date; an id added later binds only apps opened after it. A record may answer a newer id anyway. + +**The 53 apps in the catalog on 2026-10-01 are exempt** (operator default, may be reversed); what the catalog +already shows for them is `onboarding/EXISTING-APPS-GAPS.md`. An exempt app's record, if one exists, must still be +well-formed, and may say `open`. --- ## 0. Fit — should we offer it at all? -| # | Check | How | Why (what went wrong before) | -|---|---|---|---| -| 0.1 | Open-source licence; we pull the upstream image, never redistribute | read the repo | the business sells installation, not software | -| 0.2 | Upstream is alive: a release in the last 6 months, issues answered | read the repo | plant-it went `abandoned`, its image is gone | -| 0.3 | An official image with **version tags** (not `latest`-only), amd64 (+arm64 if Pi) | registry | pins and digests need real tags | -| 0.4 | **Telemetry / phone-home** — on by default? a switch to turn it off? | docs + one packet capture or log read on 9202 | data sovereignty is the pitch | -| 0.5 | Needs the internet at runtime (claim tokens, first-start downloads)? | docs + a first start | plex (plex.tv claim), adventurelog (world data), immich (geodata) | -| 0.6 | Needs ports other than HTTP(S)? The tunnel carries HTTP only | compose + docs | gitea SSH, media DLNA, game servers — say what the household loses | -| 0.7 | Phone / desktop apps: do they work through the tunnel and the setup gate? | docs + one real client if one exists | immich's phone app vs the gate (decision 46) | -| 0.8 | What a household gets from it, in one sentence (Hungarian) | — | the catalog card and `use_cases` | +| id | since | Check | How | Why (what went wrong before) | +|---|---|---|---|---| +| 0.1 | 2026-10-01 | Open-source licence; we pull the upstream image, never redistribute it | read the repo's licence file | the business sells installation, not software | +| 0.2 | 2026-10-01 | Upstream is alive: a release in the last 6 months, issues answered | read the repo | plant-it is `lifecycle: abandoned` (`templates/plant-it/.felhom.yml`) | +| 0.3 | 2026-10-01 | An official image with **version tags** (not `latest` only), amd64 (+ arm64 if `pi_compatible`) | `docker manifest inspect :` — the tag list can be stale (REUSE.md §2 "Image pinning") | pins, digests and the ladder need real tags | +| 0.4 | 2026-10-01 | **Telemetry / phone-home** — on by default? a switch to turn it off? Start-time downloads (exercise sync, model fetch)? | read the entrypoint (1.7) for start-time network jobs; on 9202, the app's log of its first start | data sovereignty is the pitch | +| 0.5 | 2026-10-01 | Needs the internet at runtime (claim tokens, first-start downloads)? | docs + the first start on 9202 | plex (`PLEX_CLAIM`), adventurelog's world data (`09` decision 41), immich's geodata import (R-732) | +| 0.6 | 2026-10-01 | Needs ports other than HTTP(S)? The tunnel carries HTTP only — say what the household loses | compose + docs | gitea SSH, DLNA, game servers | +| 0.7 | 2026-10-01 | Phone / desktop apps: which login route do they call, and does it work through the tunnel and the setup gate? | docs for the route; then that route with `curl` through traefik on 9202 (3.9) — a real client only if one is at hand | wger's phone-app login route answered 500 while the web login worked (R-737); a gated app is unreachable for a phone app (`09` decision 46) | +| 0.8 | 2026-10-01 | What a household gets from it, in one sentence (Hungarian) | — | the catalog card and `use_cases` | +| 0.9 | 2026-10-01 | The app does not call ITSELF at its public name (server-side), or the bench override is recorded | the env/config the server reads for its own URL; then the bench start | wanderer cannot run on the bench at all (R-739) — no bench, no update proof | -## 1. Images and the database +## 1. Images, the start command and the database -| # | Check | How | Why | -|---|---|---|---| -| 1.1 | Every image pinned to a concrete tag; resolvable | `catalog_gates.py ` (gates 1, 2) | `:latest` breaks restore fidelity | -| 1.2 | Database engine and major = **what the app's own upstream compose runs** | upstream compose at the pinned tag | decision 42's rule; avoids an early conversion | -| 1.3 | MariaDB sidecar: `MARIADB_AUTO_UPGRADE=1`; PostgreSQL 18: mount at `/var/lib/postgresql` | compose | R-459; PG 18 refuses `/data` | -| 1.4 | The app migrates its own database at start, **or** the switch that makes it do so is set | an update on 9202, login read back | wger: no migration without `DJANGO_*` switch, update said "done", login 500 (R-738) | -| 1.5 | The app runs its production server, not a dev server | process list in the container | wger `runserver` (R-755) | -| 1.6 | Every secret the app needs is generated (no image default, no empty key) | compose + a login on 9202 | wger JWT key missing → login 500 (R-737) | +| id | since | Check | How | Why | +|---|---|---|---|---| +| 1.1 | 2026-10-01 | Every image pinned to a concrete tag that resolves | **gates `image-pins` + `image-resolvable`** (`catalog_gates.py `) | `:latest` breaks restore fidelity | +| 1.2 | 2026-10-01 | Database engine and major = what the app's own upstream compose runs | upstream compose at the pinned tag | `09` decisions 37/42 (one conversion, not two) | +| 1.3 | 2026-10-01 | MariaDB sidecar: `MARIADB_AUTO_UPGRADE=1`; PostgreSQL 18: mount at `/var/lib/postgresql` | compose (no gate checks either today) | R-459; PG 18 refuses `/var/lib/postgresql/data` (CLAUDE.md engine rule) | +| 1.4 | 2026-10-01 | The app migrates its own database at start, **or** the switch that makes it do so is set | 1.7's read names the switch; then **prove it**: install the PREVIOUS upstream release on the bench, seed, move to the pin (`upgrade-test.py --move`), read back through the login | wger ran no migration without `DJANGO_PERFORM_MIGRATIONS`; the update said `done`, the login answered 500 (R-738). A new app at its newest tag has no "next" update to try — the step INTO the pin is the test | +| 1.5 | 2026-10-01 | The app runs its production server, not a development server | process list in the running container (`ps` / `/proc/*/cmdline`) | wger ran `manage.py runserver` (R-755) | +| 1.6 | 2026-10-01 | Every key and secret the app READS is set or generated — none left at an image default or empty | list the env names the app's settings read (grep its settings source for env reads of `*KEY*`, `*SECRET*`, `*TOKEN*`, `*PEM*`); each one set in the compose; then 3.9's logins on every route | wger read `JWT_PRIVATE_KEY`, the template set none: the API login answered 500 on the right password while the web login worked (R-737); grafana falls back to `admin` on an empty field (R-708) | +| 1.7 | 2026-10-01 | **Every start-time switch in the image's entrypoint is read and decided** (migrations, production server, debug, start-time downloads, static files) | read the entrypoint inside the image (`docker run --rm --entrypoint cat `); list each `if $VAR` and the value the template sets | one read of wger's `entrypoint.sh` shows `DJANGO_PERFORM_MIGRATIONS` AND `WGER_USE_GUNICORN` — R-738 and R-755 in one file (`felhom.eu/documentation/audits/more-night-apps-2026-09-30/box/wger/entrypoint-read.txt`) | +| 1.8 | 2026-10-01 | Debug / development mode is OFF on the public origin | 1.7's read + the running container's env; an error page through traefik shows no stack trace | adventurelog ran Django with `DEBUG=True` on the public origin (R-482) | +| 1.9 | 2026-10-01 | A secret whose loss destroys data or locks people out (it encrypts stored data, or signs 2FA secrets / long-lived tokens) carries `data_key: true` and a comment saying why it must never be regenerated; a key that only signs sessions does not | what each generated secret is used for (the app's settings source) | a restore must RECOVER that key; regenerating it destroys data or locks out 2FA (felhom-app-catalog skill §4) | ## 2. Storage and backup -| # | Check | How | Why | -|---|---|---|---| -| 2.1 | Every path the app writes is mounted — **measured** | gate 3, `check-volume-persistence.py ` | papra backed up an empty folder (R-156) | -| 2.2 | Where each path lives: named volume (NVMe) / `${HDD_PATH}/appdata` / `${USERDATA_PATH}` | compose, REUSE.md skeleton | the household can browse userdata, not appdata | -| 2.3 | Backup class for every HDD path (`mandatory` / `excluded` …) and `data_paths` labels | `.felhom.yml backup:` | DB rows that point at files need those files (07) | -| 2.4 | File owner / uid fits the box (PUID/PGID where the image wants them) | first start on 9202 | linuxserver images chown their tree | -| 2.5 | **A backup and a restore through the product, data read back** | 9202: seed → backup → remove → restore → read back | the promise is the restore, not the backup | -| 2.6 | "Remove with data" and "remove keep data" both work | 9202 | R-756 (refused with a folder present) | -| 2.7 | Off-site size estimate for a typical household | measured size after seed + a sentence | decision 50's size warning | +| id | since | Check | How | Why | +|---|---|---|---|---| +| 2.1 | 2026-10-01 | Every path the app writes is mounted — **measured** | **gate `volume-persistence`** (`check-volume-persistence.py `, scratch host) | papra backed up an empty folder (R-156) | +| 2.2 | 2026-10-01 | Where each path lives: named volume (NVMe) / `${HDD_PATH}/appdata` / `${USERDATA_PATH}` | compose; REUSE.md §2 "Compose file skeleton" | the household can browse userdata, not appdata | +| 2.3 | 2026-10-01 | Backup class for every HDD path (`backup:` in `.felhom.yml`) and what the tier-1 unit holds | `.felhom.yml backup:`; the app's backup page on 9202 | DB rows point at files (07); the tier-1 unit holds NO drive-side data (R-537, R-538) | +| 2.4 | 2026-10-01 | File owner / uid fits the box (PUID/PGID where the image wants them) | first start on 9202 | linuxserver images chown their tree | +| 2.5 | 2026-10-01 | **A backup and a restore through the product, data read back** | 9202: seed → backup → remove → restore → read back | the promise is the restore, not the backup | +| 2.6 | 2026-10-01 | "Remove with data" and "remove, keep data" both do what they say | 9202, then list what is left on the drive | "remove with data" was inert (R-442); refused with the folder present (R-756, cause not yet known) | +| 2.7 | 2026-10-01 | Off-site size for a typical household | measured size after the seed + one sentence | `09` decision 50 (the page names the largest apps) | +| 2.8 | 2026-10-01 | A file the household uploads opens again **through the front door** | 9202: upload through traefik, open it the way the page does | adventurelog's photos uploaded and rendered broken (R-483) | ## 3. Accounts and strangers -| # | Check | How | Why | -|---|---|---|---| -| 3.1 | First-admin class (FIRST-ADMIN.md 1–6), **measured** | fresh install on 9202 | decision 45 | -| 3.2 | Known default login → `after_install` with a generated password (and name, if the name is public and lockable) | 9202: default fails, generated works, wrong fails | bookstack, calibre-web (decisions 45, 61) | -| 3.3 | Open first-run screen → `setup_gate` (+ probe that **flips**, measured before and after) | 9202 as a stranger | 32 apps, decision 46 | -| 3.4 | Open sign-up after the setup → `signup_block` / `after_setup` switch; case-insensitive | 9202 as a stranger | decisions 47–49 | -| 3.5 | The install window: a stranger reaches nothing before the password is replaced | poll once a second during install | R-741 | -| 3.6 | **Lock-out**: N wrong passwords by a stranger — who is locked (name / address / everyone), for how long | 9202 through traefik | mealie 24 h, wger everyone (R-747, R-752, R-753) | -| 3.7 | The household can change its password and add family members; the page says how | 9202 | `add_people` copy | -| 3.8 | Secrets pass to commands as arguments, never inside program code | review `after_install` | security review 2026-09-29 | +| id | since | Check | How | Why | +|---|---|---|---|---| +| 3.1 | 2026-10-01 | First-admin class (FIRST-ADMIN.md 1–6), **measured**, and the household can make its first account on a fresh install | fresh install on 9202, through traefik | `09` decision 45; wishlist could not be signed up to while the deploy said success (R-612) | +| 3.2 | 2026-10-01 | Known default login → `after_install` with a generated password (and a generated name, if the name is public and a lock targets it) | 9202: default fails, generated works, wrong fails | bookstack, claper (R-702), calibre-web (`09` decisions 45, 61) | +| 3.3 | 2026-10-01 | Open first-run screen → `setup_gate` (+ a probe that **flips**, measured before and after) | 9202 as a stranger; **gate `probe-measured`** refuses a probe with no measurement above it | 33 apps gated today (`09` decision 46); a probe that never flips blocks the household (R-715) | +| 3.4 | 2026-10-01 | Open sign-up after the setup → `signup_block` / `after_setup`; the block case-insensitive, the API sign-up blocked too | 9202 as a stranger, after the setup | R-711, R-512; `09` decisions 47–49 | +| 3.5 | 2026-10-01 | The install window: a stranger reaches nothing before the password is replaced | poll the default login once a second from the install press | R-741 (fixed box-wide in controller v0.284.x — a new `after_install` app still proves it) | +| 3.6 | 2026-10-01 | **Lock-out**: N wrong passwords by a stranger for the public name — who is locked (name / address / everyone), for how long | 9202 through traefik; then the household's right password, and a SECOND member's | mealie 24 h (R-747); behind the tunnel every visitor has ONE address, so a per-address lock locks everyone (R-753) — wger (R-752) | +| 3.7 | 2026-10-01 | The household can change its password and add family members; the page says how | 9202 | `add_people` copy | +| 3.8 | 2026-10-01 | Secrets pass to commands as arguments, never inside program code | review `after_install` | claper pasted the password into Elixir code (R-713); security review 2026-09-29 | +| 3.9 | 2026-10-01 | **Sign in the way each client does, through traefik over https**: the browser form (with its https `Origin` and CSRF cookie) AND every API login route a phone/desktop app uses — right password works, wrong refused | 9202, `curl` with the headers a browser sends; the API route from 0.7 | wger refused every browser sign-in behind traefik (CSRF, R-712); wger's API login 500 (R-737) | ## 4. Health -| # | Check | How | Why | -|---|---|---|---| -| 4.1 | Compose healthcheck of the right family, `127.0.0.1` not `localhost` | REUSE.md §2 | vaultwarden IPv6 trap | -| 4.2 | The controller probe dials what the compose healthcheck dials; a real health path where one exists | gate 7 | a wrong probe stops a working app after an update (R-618) | -| 4.3 | Healthy within `start_period` on a **cold first start** (incl. one-time imports) | 9202, timed | immich geodata import | -| 4.4 | Negative control: a broken app reads unhealthy | stop the DB, read the status | a probe that is always green proves nothing | -| 4.5 | The probe-named container is the stack name; sidecars `-db` … | compose | `findProbeContainer` fallback picks the DB | +| id | since | Check | How | Why | +|---|---|---|---|---| +| 4.1 | 2026-10-01 | Compose healthcheck of the family the IMAGE has (inspected, one tool per run), dialling `127.0.0.1` not `localhost` | the inspection loop in the felhom-app-catalog skill §2; REUSE.md §2 (no gate checks `localhost`; 0 templates use it today) | rallly's guessed `wget` (ENOENT); vaultwarden's IPv6 trap | +| 4.2 | 2026-10-01 | The controller probe dials what the compose healthcheck dials; a real health path where one exists | **gate `probe-matches-compose`** | a wrong probe stops a working app after an update (R-618) | +| 4.3 | 2026-10-01 | Healthy within `start_period` on a **cold first start** (incl. one-time imports), with no restarts | 9202, timed, `RestartCount` read | glance crash-looped on every fresh install (R-473); immich's first start restarted 12× (R-676) — `09` decision 28 stops ≥ 6 in 10 min | +| 4.4 | 2026-10-01 | Negative control: a broken app reads unhealthy | stop the DB (or break the app's data dir), read the status | a probe that is always green proves nothing; uptime-kuma parked on its wizard read healthy (R-613) | +| 4.5 | 2026-10-01 | The probe-named container is the stack name; sidecars `-db` … | compose (REUSE.md §2 "Probe-container naming") | `findProbeContainer` falls back to the DB; paperless's probe never ran (R-630) | ## 5. Resources -| # | Check | How | Why | -|---|---|---|---| -| 5.1 | **First start from birth, swap OFF**: peak `anon`, `oom_kill` = 0 | bench, sampled every 2 s | immich DB killed at 512 MiB, hidden by swap on 9202 (R-732, R-733) | -| 5.2 | 10-minute light-load soak: peak < 80 % of the limit, 0 kills, 0 restarts | harness memory watch | romm OOM at +76 s (decision 22) | -| 5.3 | `mem_limit` in `.felhom.yml` = the sum of the compose limits | gate / review | immich's header was 128 MB off | -| 5.4 | Node/Java apps: does the heap size itself from the limit? | two watches at two limits | R-693 (docmost) | -| 5.5 | Pi-compatible (yes/no), disk it pulls (image size) | registry | old images filled 9202 (R-736) | +| id | since | Check | How | Why | +|---|---|---|---|---| +| 5.1 | 2026-10-01 | **First start from birth, swap OFF**: peak `anon`, `oom_kill` = 0 | bench, the container's own cgroup sampled every 2 s from creation (`memory.stat` anon, `memory.events` oom_kill — never Docker's `OOMKilled`, R-528) | immich's DB killed at 512 MiB, hidden by swap on 9202 (R-732, R-733); calcom could not start at its limit (R-703) | +| 5.2 | 2026-10-01 | 10-minute soak under the household's heaviest ordinary act: peak `anon` < 80 % of the limit, 0 kills, 0 restarts | harness memory watch (`upgrade-test.py`) + one burst of that act | romm OOM-looped for six hours after an update called success (R-635, `09` decision 22); paperless lost 11 of 20 uploads at once (R-514) | +| 5.3 | 2026-10-01 | `mem_limit` in `.felhom.yml` = the sum of the compose limits, and the header comment says the same | arithmetic by hand — **no gate checks it; 8 templates differ today (R-758)** | immich's `mem_limit` was 128 MB under its sum, its header named a 256M database running at 512M (fixed `56c4888`) | +| 5.4 | 2026-10-01 | Node/Java apps: does the heap size itself from the limit? | two watches at two limits | R-693 (docmost) | +| 5.5 | 2026-10-01 | Pi-compatible (yes/no) and the disk the images pull | registry (image size) | `pi_compatible` is a card field; a box's Docker disk refuses installs when full (R-736) | ## 6. Updates -| # | Check | How | Why | -|---|---|---|---| -| 6.1 | An upgrade fixture: seed + read-back **through the app's own front door**, negative control | `upgrade_fixtures*.py` | without it the app never updates itself | -| 6.2 | A first ladder step proven on bench + 9202 (`--write-ladder`), **or** "manual only" with the reason | harness | 35 of 53 update at night; the rest need a person | -| 6.3 | The undo works on a real failure (one forced-fail case) | 9202 | zipline's real failed jump, undone in 20 s | -| 6.4 | `files_may_change` mark understood (which files change at start) | bench | immich's six marker files (R-734) | -| 6.5 | Tag shape is stable upstream (no `v` dropped, no flavour prefix) | tag list | gramps-web, jellyfin, kimai (R-731) | +| id | since | Check | How | Why | +|---|---|---|---|---| +| 6.1 | 2026-10-01 | An upgrade fixture: seed + read-back **through the app's own front door**, negative control — or the reason none can exist | `upgrade_fixtures*.py` | the box's health check sees only the front page; only the read-back saw wger broken (R-738); three apps cannot be seeded headless (R-624) | +| 6.2 | 2026-10-01 | A first ladder step proven on bench + 9202 (`--write-ladder`), **or** "manual only" with the reason | harness; **gates `test-record` + `test-record-move`** check the entry once written | 38 of 53 apps carry a ladder today; zipline needed two steps where one failed (R-742) | +| 6.3 | 2026-10-01 | The undo works on a real failure (one forced-fail case) | 9202, the drill catalog's image store (`09` §6.5) | zipline's real failed jump, undone in 20 s (R-742) | +| 6.4 | 2026-10-01 | `files_may_change` understood (which files change at start) | bench (`files_changed_detail`) | immich's six marker files (R-734) | +| 6.5 | 2026-10-01 | Tag shape is stable upstream (no `v` dropped, no flavour prefix) and whether the publisher re-pushes tags | tag list over a year; linuxserver rebuilds weekly | gramps-web, jellyfin, kimai (R-731); same-tag re-pushes (R-743, `09` decisions 52/55) | ## 7. Mail -| # | Check | How | Why | -|---|---|---|---| -| 7.1 | Sends mail? → `smtp_mapping` + `${VAR:-}` compose lines; a fresh install with mail OFF boots | 9202 | vaultwarden empty-vars trap | +| id | since | Check | How | Why | +|---|---|---|---|---| +| 7.1 | 2026-10-01 | Sends mail? → `smtp_mapping` + `${VAR:-}` compose lines; a fresh install with mail OFF boots | 9202 | vaultwarden's empty-vars trap (REUSE.md §2 "App-email") | ## 8. Household-facing text and listing -| # | Check | How | Why | -|---|---|---|---| -| 8.1 | Hungarian + English, informal „te", no „kérjük"; parity green; freeze updated | `check-copy-i18n.py` | operator rule | -| 8.2 | `app_info`: tagline, use_cases, first_steps, default_creds (if any), add_people | read on 9202's page | the page is the manual | -| 8.3 | Logo + screenshots on felhom.eu by `slug` | the asset URL answers 200 | the card is blank otherwise | -| 8.4 | README tables, FIRST-ADMIN row, `category`, `catalog_since` | review | REUSE.md §5 | +| id | since | Check | How | Why | +|---|---|---|---|---| +| 8.1 | 2026-10-01 | Hungarian + English, informal „te", no „kérjük"; parity green; freeze updated | **gate `copy-i18n`** (`check-copy-i18n.py --capture-freeze` after a copy change) | operator rule; R-560 | +| 8.2 | 2026-10-01 | `app_info`: tagline, use_cases, first_steps, default_creds (if any), add_people — and each one TRUE on 9202 | read on 9202's app page and follow the first steps | paperless's page named a login that did not exist (R-515); first steps named a literal `wiki.DOMAIN` (R-498) | +| 8.3 | 2026-10-01 | Logo + screenshots on felhom.eu by `slug` | `https://felhom.eu/assets/-logo.svg` (or `.png`, the controller's fallback) and `-screenshot-.webp` answer 200 — NOT `-logo.webp`, which the canonical template's comment still names (R-761) | the card is blank otherwise | +| 8.4 | 2026-10-01 | README tables, FIRST-ADMIN row, `category`, `catalog_since` | review | REUSE.md §5 | +| 8.5 | 2026-10-01 | The website's app count still holds | `ls templates | wc -l` against `felhom.eu/website` claims; record a drift | felhom-app-catalog skill §6 | ## 9. Sign-off -| # | Check | How | -|---|---|---| -| 9.1 | `python3 scripts/catalog_gates.py ` — all green (exit 0) | bench / scratch | -| 9.2 | A fresh install on 9202 from the drill catalog, as a household and as a stranger, start to finish | 9202 | -| 9.3 | Every item above done or n/a-with-reason; every finding that is not fixed is a register row | the record | -| 9.4 | Published to the live catalog in ONE commit with its record | the gate checks | \ No newline at end of file +| id | since | Check | How | Why | +|---|---|---|---|---| +| 9.1 | 2026-10-01 | `python3 scripts/catalog_gates.py ` — all green (exit 0) | bench / scratch host (it runs the runtime gate) | the one entry point (R-161) | +| 9.2 | 2026-10-01 | A fresh install on 9202 from the drill catalog, as a household and as a stranger, start to finish | 9202 | the walk finds what single checks miss (R-482..R-488 in one evening) | +| 9.3 | 2026-10-01 | Every item above done or n/a-with-reason; every finding not fixed is a register row | the record | prose is not a record | +| 9.4 | 2026-10-01 | Published to the live catalog in ONE commit with its record | **gate `onboarding`** | a template and its proof travel together | + +--- + +## Row count + +| group | rows | +|---|---| +| 0 Fit | 9 | +| 1 Images, start command, database | 9 | +| 2 Storage and backup | 8 | +| 3 Accounts and strangers | 9 | +| 4 Health | 5 | +| 5 Resources | 5 | +| 6 Updates | 5 | +| 7 Mail | 1 | +| 8 Text and listing | 5 | +| 9 Sign-off | 4 | +| **total** | **60** | diff --git a/README.md b/README.md index 809614f..23cbc42 100644 --- a/README.md +++ b/README.md @@ -427,6 +427,9 @@ where the felhom-controller is not used. For controller-based deployments, these ## Adding a New App +0. Copy `onboarding/_TEMPLATE.md` to `onboarding/.md` and answer every check in `NEW-APP-CHECKLIST.md` + (done with evidence / n/a with a reason). The `onboarding` gate (`scripts/check-onboarding.py`, run by + `catalog_gates.py --fast`) refuses a new template directory without a complete record; publish both in one commit. 1. Create `templates//docker-compose.yml` following the template standards above 2. Create `templates//.felhom.yml` following the metadata format 3. Commit and push — the controller will pick it up on next sync diff --git a/REPORT.md b/REPORT.md index b466502..d6b506f 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,12 +1,15 @@ -# REPORT — calibre-web's admin login name is generated at install (2026-10-01, late afternoon) +# REPORT — the new-app checklist: in the catalog, a gate, piloted on wger (2026-10-01, evening) -Full session report: `felhom.eu/REPORT-calibre-name-and-prune-2026-10-01.md`. +Full session report: `felhom.eu/REPORT-new-app-checklist-2026-10-01.md`; evidence +`felhom.eu/documentation/audits/new-app-checklist-2026-10-01/`. -- **`09` §3 decision 61** (operator) — catalog `e9f50b5`: `ADMIN_USER` (`type: secret`, `generate: "hex:5"`); `after_install` - renames `admin` in `app.db` (Calibre-Web has no rename command), sets the password with its own `cps.py -s`, proves both. - hu + en copy; the Hungarian freeze re-captured for the five changed calibre-web strings only; FIRST-ADMIN updated. -- **9202:** a stranger got in 0 of 31 times during the install hold; 40 wrong tries on `admin` → the household in at once - with its own name (form and OPDS); `admin` refused. -- **demo-hp:** renamed by hand; the box then injected its own `ADMIN_USER` for the installed app (R-757) — renamed again to - that value; the name is in the operator's credentials file only. -- Gates: `catalog_gates.py --fast calibre-web` OK; pushed through the pre-push gates (no `--no-verify`). +- **`NEW-APP-CHECKLIST.md`** reviewed: 60 rows in 10 groups (draft 53); 7 added, 16 sharpened, 9 wrong claims fixed. +- **`onboarding/_TEMPLATE.md`** (one line per id) and **`onboarding/wger.md`** (the pilot; 11 open rows, each a register row). +- **Gate `onboarding`** (`scripts/check-onboarding.py`, `--fast`: hook + CI). The 53 published apps are exempt by name. + 16 decoys judged right; 5 deliberately broken versions of the gate each turned the decoy suite red. +- **`onboarding/EXISTING-APPS-GAPS.md`** from `scripts/onboarding_gaps.py` (read only). +- **The pilot:** the draft caught R-752 and R-755 as written; it missed R-737 (its "how" logged in on the web form only) + and R-738 for a NEW app (nothing to update at the newest tag). Rows 1.4/1.6 sharpened, 1.7/3.9 added — now all four. + The new rows then found three live wger defects: R-762, R-763, R-764. No template was changed. +- Gates: `catalog_gates.py --fast` OK (11); `test_gate_decoys.py` 121 OK; `test_catalog_gates.py` OK; + `decoy_coverage_gate.py` 0 unaccounted. diff --git a/REUSE.md b/REUSE.md index 25cce94..142f91b 100644 --- a/REUSE.md +++ b/REUSE.md @@ -62,6 +62,10 @@ Templates are config; the few script helpers other scripts must REUSE, never re- ## 5. Extension points (adding a new app) +0. **First: `cp onboarding/_TEMPLATE.md onboarding/.md`** and work `NEW-APP-CHECKLIST.md` top to bottom — the + record is what the `onboarding` gate (`scripts/check-onboarding.py`) reads; a new template directory without a + complete record is refused at push. The app is tested from the drill catalog on 9202 (`09` §6.5) and reaches the + live catalog in ONE commit with its record. 1. `templates//docker-compose.yml` — copy `templates/paperless-ngx/docker-compose.yml` skeleton; every service needs `container_name`, `restart: unless-stopped`, `TZ=Europe/Budapest`, `deploy.resources.limits.memory`, a healthcheck (family per §2), Traefik labels on the web service, `traefik-public` external + `-internal` network if it has a DB. 2. `templates//.felhom.yml` — copy `templates/paperless-ngx/.felhom.yml`; required keys per §2; DOMAIN + SUBDOMAIN fields always; `mem_limit` = sum of compose limits; Hungarian user-facing text; `healthcheck.checks` probe. 3. Update `README.md` App Catalog + Variable-types tables (convention — every existing app is listed). diff --git a/onboarding/EXISTING-APPS-GAPS.md b/onboarding/EXISTING-APPS-GAPS.md new file mode 100644 index 0000000..e0abaa4 --- /dev/null +++ b/onboarding/EXISTING-APPS-GAPS.md @@ -0,0 +1,87 @@ +# EXISTING APPS — what the catalog already shows, per checklist group + +> Generated by `scripts/onboarding_gaps.py` from committed files (catalog `6d72c09`). **Do not edit by hand.** +> Read only: nothing was re-tested. A cell is what a FILE says, not a measurement made today. The 53 apps +> published before the checklist (2026-10-01) are exempt from the onboarding gate; this page is information, +> not work (operator default 2026-10-01, may be reversed). + +## Headline — apps whose files show the group covered (of 53) + +| group | covered | what "covered" means here (the signal read) | +|---|---|---| +| 0 Fit | 52 / 53 | `lifecycle` available, `use_cases` and `pi_compatible` present — licence, telemetry, internet need and phone apps are recorded nowhere | +| 1 Images, start command, DB | 53 / 53 | pins clean and the engine rules hold (MariaDB auto-upgrade, PG 18 mount) — entrypoint switches, the production server, migrations and secrets read (1.4–1.9) are recorded for NO app | +| 2 Storage and backup | 38 / 53 | the 2026-08-02 persistence sweep read the app CLEAN, and an HDD app carries `backup:` classes — no restore round trip is recorded per app | +| 3 Accounts and strangers | 39 / 53 | a FIRST-ADMIN.md row whose source is MEASURED on a box — lock-out (3.6) is recorded only for the R-752 apps | +| 4 Health | 49 / 53 | every service has a compose healthcheck, a controller probe exists, the exposed container is named like the stack — no negative control is recorded | +| 5 Resources | 24 / 53 | every service limited, `mem_limit` = the sum, and a ladder entry carries a measured memory watch — no first-start-from-birth watch is recorded except immich's | +| 6 Updates | 26 / 53 | a proven (not backfilled) ladder step AND an upgrade fixture | +| 7 Mail | — | not countable from files: whether an app WANTS mail is not recorded; the mapped count is below | +| 8 Text and listing | 52 / 53 | English block, tagline + use_cases + first_steps, listed in README, a FIRST-ADMIN row | + +Mail: 6 app(s) carry `smtp_mapping`. + +## Found while computing this page + +- `mem_limit` differs from the sum of the compose limits (REUSE.md §2 says equal): 8 — adventurelog (384M vs 896), bookstack (512M vs 768), calcom (768M vs 1792), claper (384M vs 640), kimai (384M vs 640), nextcloud (1024M vs 1664), outline (768M vs 1152), zipline (512M vs 768). +- A service with no memory limit: none. +- A MariaDB sidecar without `MARIADB_AUTO_UPGRADE=1`: none. +- A PostgreSQL 18 data mount at the old path: none. + +## Per app + +| app | 0 fit | 1 images/DB | 2 storage | 3 accounts | 4 health | 5 resources | 6 updates | 7 mail | 8 text | +|---|---|---|---|---|---|---|---|---|---| +| actualbudget | yes | no DB sidecar | sweep clean | class 4, measured + setup_gate | yes | no watch | 0 proven step(s), fixture | — | yes | +| adventurelog | yes | engine rules hold | sweep clean | class 4, measured + setup_gate/signup_block/after_setup | hc missing | watched 73%, mem_limit≠sum | 1 proven step(s), fixture | — | yes | +| audiobookshelf | yes | no DB sidecar | sweep clean, backup classes | class 4, measured + setup_gate | yes | watched 10% | 1 proven step(s), fixture | — | yes | +| bentopdf | yes | no DB sidecar | sweep undetermined | class 5, read only | yes | no watch | 0 proven step(s), no fixture | — | yes | +| bookstack | yes | engine rules hold | sweep clean | class 3, measured + after_install | yes | watched 44%, mem_limit≠sum | 1 proven step(s), fixture | — | yes | +| calcom | yes | engine rules hold | sweep clean | class 4, measured + setup_gate/signup_block/after_setup | yes | watched 62%, mem_limit≠sum | 1 proven step(s), fixture | smtp mapped | yes | +| calibre-web | yes | no DB sidecar | sweep clean, backup classes | class 3, measured + after_install | yes | watched 19% | 1 proven step(s), fixture | — | yes | +| claper | yes | engine rules hold | sweep undetermined | class 3 (+ open sign-up), measured + after_install | yes | watched 48%, mem_limit≠sum | 1 proven step(s), fixture | — | yes | +| code-server | yes | no DB sidecar | sweep clean | class 1, read only | yes | no watch | 0 proven step(s), fixture | — | yes | +| crafty-controller | yes | no DB sidecar | sweep clean | class 1, read only | yes | watched 3% | 1 proven step(s), fixture | — | yes | +| docmost | yes | engine rules hold | sweep undetermined | class 4, measured + setup_gate | yes | watched 80% | 1 proven step(s), fixture | — | yes | +| emby | yes | no DB sidecar | sweep clean, backup classes | class 4, measured + setup_gate | yes | watched 5% | 2 proven step(s), no fixture | — | yes | +| ghost | yes | no DB sidecar | sweep clean | class 4, measured + setup_gate | yes | watched 27% | 2 proven step(s), no fixture | — | yes | +| gitea | yes | no DB sidecar | sweep clean | class 4, measured + setup_gate/signup_block/after_setup | yes | watched 27% | 1 proven step(s), fixture | smtp mapped | yes | +| glance | yes | no DB sidecar | sweep undetermined | class 5, read only | yes | no watch | 0 proven step(s), no fixture | — | yes | +| gokapi | yes | no DB sidecar | sweep clean | class 1, read only | yes | no watch | 0 proven step(s), no fixture | — | yes | +| grafana | yes | no DB sidecar | sweep clean | class 1, read only | yes | watched 47% | 1 proven step(s), fixture | — | yes | +| gramps-web | yes | no DB sidecar | sweep broken | class 4, measured + setup_gate/signup_block/after_setup | yes | no watch | 0 proven step(s), fixture | — | yes | +| home-assistant | yes | no DB sidecar | sweep clean | class 4, measured + setup_gate | yes | watched 32% | 1 proven step(s), fixture | — | yes | +| homebox | yes | no DB sidecar | sweep clean | class 4, measured + setup_gate/signup_block/after_setup | yes | no watch | 0 proven step(s), no fixture | — | yes | +| homepage | yes | no DB sidecar | sweep clean | class 5, read only | yes | no watch | 0 proven step(s), fixture | — | yes | +| immich | yes | engine rules hold | sweep undetermined, backup classes | class 4, measured + setup_gate | probe container not in compose | watched 51% | 2 proven step(s), no fixture | — | yes | +| jellyfin | yes | no DB sidecar | sweep clean, backup classes | class 4, measured + setup_gate | yes | no watch | 0 proven step(s), fixture | — | yes | +| kimai | yes | engine rules hold | sweep clean | class 1, read only | yes | watched 45%, mem_limit≠sum | 2 proven step(s), no fixture | — | yes | +| komga | yes | no DB sidecar | sweep clean, backup classes | class 4, measured + setup_gate | yes | watched 64% | 2 proven step(s), no fixture | — | yes | +| mealie | yes | no DB sidecar | sweep clean | class 3, measured + after_install | yes | watched 23% | 1 proven step(s), fixture | smtp mapped | yes | +| n8n | yes | no DB sidecar | sweep clean | class 4, measured + setup_gate | yes | watched 23% | 3 proven step(s), fixture | — | yes | +| navidrome | yes | no DB sidecar | sweep clean, backup classes | class 4, measured + setup_gate | yes | watched 7% | 2 proven step(s), fixture | — | yes | +| nextcloud | yes | engine rules hold | sweep clean, backup classes | class 1, measured | yes | watched 24%, mem_limit≠sum | 2 proven step(s), fixture | smtp mapped | yes | +| onlyoffice | yes | no DB sidecar | sweep clean | class 5, read only | yes | no watch | 0 proven step(s), fixture | — | yes | +| opengist | yes | no DB sidecar | sweep clean | class 4, measured + setup_gate/signup_block | yes | watched 78% | 1 proven step(s), fixture | — | yes | +| outline | yes | engine rules hold | sweep clean | class 4, measured + setup_gate | yes | watched 34%, mem_limit≠sum | 2 proven step(s), fixture | — | yes | +| paperless-ngx | yes | engine rules hold | sweep clean, backup classes | class 1, read only | probe container not in compose | watched 40% | 1 proven step(s), fixture | — | yes | +| papra | yes | no DB sidecar | sweep broken | class 4, measured + setup_gate/signup_block/after_setup | yes | no watch | 0 proven step(s), fixture | — | yes | +| plant-it | — (abandoned) | no DB sidecar | sweep undetermined | class 4, read only + setup_gate | yes | no watch | 0 proven step(s), no fixture | — | yes | +| plex | yes | no DB sidecar | sweep clean, backup classes | class 2, read only | yes | no watch | 0 proven step(s), fixture | — | yes | +| privatebin | yes | no DB sidecar | sweep broken | class 5, read only | yes | no watch | 0 proven step(s), fixture | — | yes | +| radarr | yes | no DB sidecar | sweep clean, backup classes | class 4, measured + setup_gate | yes | no watch | 0 proven step(s), fixture | — | yes | +| rallly | yes | engine rules hold | sweep clean | class 4, measured + setup_gate | yes | watched 61% | 2 proven step(s), fixture | smtp mapped | yes | +| recipe-importer | yes | no DB sidecar | sweep undetermined | class 4, measured + setup_gate | yes | no watch | 0 proven step(s), no fixture | — | not in README | +| romm | yes | engine rules hold | sweep clean, backup classes | class 4, measured + setup_gate | yes | watched 78% | 2 proven step(s), fixture | — | yes | +| seerr | yes | no DB sidecar | sweep clean | class 4, measured + setup_gate | yes | no watch | 0 proven step(s), no fixture | — | yes | +| sonarr | yes | no DB sidecar | sweep clean, backup classes | class 4, measured + setup_gate | yes | watched 14% | 1 proven step(s), no fixture | — | yes | +| sparkyfitness | yes | engine rules hold | sweep undetermined | class 4, measured + setup_gate/signup_block/after_setup | yes | watched 31% | 1 proven step(s), fixture | — | yes | +| tandoor | yes | engine rules hold | sweep clean | class 4, measured + setup_gate | yes | watched 79% | 1 proven step(s), fixture | — | yes | +| termix | yes | no DB sidecar | sweep clean | class 4, measured + setup_gate/signup_block/after_setup | yes | no watch | 0 proven step(s), fixture | — | yes | +| uptime-kuma | yes | no DB sidecar | sweep undetermined | class 4, measured + setup_gate | yes | watched 43% | 1 proven step(s), fixture | — | yes | +| vaultwarden | yes | no DB sidecar | sweep clean | class 1, read only | yes | no watch | 0 proven step(s), fixture | smtp mapped | yes | +| vikunja | yes | no DB sidecar | sweep clean | class 4, measured + setup_gate/signup_block/after_setup | hc missing | no watch | 0 proven step(s), fixture | — | yes | +| wanderer | yes | no DB sidecar | sweep undetermined | class 4, measured + signup_block/after_setup | yes | no watch | 0 proven step(s), no fixture | — | yes | +| wger | yes | no DB sidecar | sweep clean | class 3, measured + after_install | yes | watched 50% | 1 proven step(s), fixture | — | yes | +| wishlist | yes | no DB sidecar | sweep broken | class 4, measured + setup_gate/signup_block | yes | watched 36% | 1 proven step(s), fixture | — | yes | +| zipline | yes | engine rules hold | sweep undetermined | class 4, measured + setup_gate | yes | watched 34%, mem_limit≠sum | 2 proven step(s), fixture | — | yes | diff --git a/onboarding/_TEMPLATE.md b/onboarding/_TEMPLATE.md new file mode 100644 index 0000000..b8bc993 --- /dev/null +++ b/onboarding/_TEMPLATE.md @@ -0,0 +1,75 @@ +# Onboarding record — + +app: +opened: YYYY-MM-DD +template_at: + + + +0.1 | open | not started: Open-source licence +0.2 | open | not started: Upstream is alive: a release in the last 6 months, issues answered +0.3 | open | not started: An official image with version tags (not latest only), amd64 (+ arm64 if pi_compatible) +0.4 | open | not started: Telemetry / phone-home +0.5 | open | not started: Needs the internet at runtime (claim tokens, first-start downloads)? +0.6 | open | not started: Needs ports other than HTTP(S)? The tunnel carries HTTP only +0.7 | open | not started: Phone / desktop apps: which login route do they call, and does it work through the … +0.8 | open | not started: What a household gets from it, in one sentence (Hungarian) +0.9 | open | not started: The app does not call ITSELF at its public name (server-side), or the bench override … +1.1 | open | not started: Every image pinned to a concrete tag that resolves +1.2 | open | not started: Database engine and major = what the app's own upstream compose runs +1.3 | open | not started: MariaDB sidecar: MARIADB_AUTO_UPGRADE=1 +1.4 | open | not started: The app migrates its own database at start, or the switch that makes it do so is set +1.5 | open | not started: The app runs its production server, not a development server +1.6 | open | not started: Every key and secret the app READS is set or generated +1.7 | open | not started: Every start-time switch in the image's entrypoint is read and decided (migrations, … +1.8 | open | not started: Debug / development mode is OFF on the public origin +1.9 | open | not started: A secret whose loss destroys data or locks people out (it encrypts stored data, or … +2.1 | open | not started: Every path the app writes is mounted +2.2 | open | not started: Where each path lives: named volume (NVMe) / ${HDD_PATH}/appdata / ${USERDATA_PATH} +2.3 | open | not started: Backup class for every HDD path (backup: in .felhom.yml) and what the tier-1 unit holds +2.4 | open | not started: File owner / uid fits the box (PUID/PGID where the image wants them) +2.5 | open | not started: A backup and a restore through the product, data read back +2.6 | open | not started: "Remove with data" and "remove, keep data" both do what they say +2.7 | open | not started: Off-site size for a typical household +2.8 | open | not started: A file the household uploads opens again through the front door +3.1 | open | not started: First-admin class (FIRST-ADMIN.md 1–6), measured, and the household can make its first … +3.2 | open | not started: Known default login → after_install with a generated password (and a generated name, … +3.3 | open | not started: Open first-run screen → setup_gate (+ a probe that flips, measured before and after) +3.4 | open | not started: Open sign-up after the setup → signup_block / after_setup +3.5 | open | not started: The install window: a stranger reaches nothing before the password is replaced +3.6 | open | not started: Lock-out: N wrong passwords by a stranger for the public name +3.7 | open | not started: The household can change its password and add family members +3.8 | open | not started: Secrets pass to commands as arguments, never inside program code +3.9 | open | not started: Sign in the way each client does, through traefik over https: the browser form (with … +4.1 | open | not started: Compose healthcheck of the family the IMAGE has (inspected, one tool per run), … +4.2 | open | not started: The controller probe dials what the compose healthcheck dials +4.3 | open | not started: Healthy within start_period on a cold first start (incl. one-time imports), with no … +4.4 | open | not started: Negative control: a broken app reads unhealthy +4.5 | open | not started: The probe-named container is the stack name +5.1 | open | not started: First start from birth, swap OFF: peak anon, oom_kill = 0 +5.2 | open | not started: 10-minute soak under the household's heaviest ordinary act: peak anon < 80 % of the … +5.3 | open | not started: mem_limit in .felhom.yml = the sum of the compose limits, and the header comment says … +5.4 | open | not started: Node/Java apps: does the heap size itself from the limit? +5.5 | open | not started: Pi-compatible (yes/no) and the disk the images pull +6.1 | open | not started: An upgrade fixture: seed + read-back through the app's own front door, negative control +6.2 | open | not started: A first ladder step proven on bench + 9202 (--write-ladder), or "manual only" with the … +6.3 | open | not started: The undo works on a real failure (one forced-fail case) +6.4 | open | not started: files_may_change understood (which files change at start) +6.5 | open | not started: Tag shape is stable upstream (no v dropped, no flavour prefix) and whether the … +7.1 | open | not started: Sends mail? → smtp_mapping + ${VAR:-} compose lines +8.1 | open | not started: Hungarian + English, informal „te", no „kérjük" +8.2 | open | not started: app_info: tagline, use_cases, first_steps, default_creds (if any), add_people +8.3 | open | not started: Logo + screenshots on felhom.eu by slug +8.4 | open | not started: README tables, FIRST-ADMIN row, category, catalog_since +8.5 | open | not started: The website's app count still holds +9.1 | open | not started: python3 scripts/catalog_gates.py +9.2 | open | not started: A fresh install on 9202 from the drill catalog, as a household and as a stranger, … +9.3 | open | not started: Every item above done or n/a-with-reason +9.4 | open | not started: Published to the live catalog in ONE commit with its record diff --git a/onboarding/wger.md b/onboarding/wger.md new file mode 100644 index 0000000..4848bc7 --- /dev/null +++ b/onboarding/wger.md @@ -0,0 +1,75 @@ +# Onboarding record — wger + +app: wger +opened: 2026-10-01 +template_at: 82fff32 (wger/server:2.7; catalog main 6d72c09 when measured) + + + +0.1 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/B/B1-upstream-reads.txt — AGPL-3.0; image pulled from Docker Hub +0.2 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/B/B1-upstream-reads.txt — 2.7 on 2026-09-03; repo pushed 2026-10-01; 251 open issues +0.3 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/B/B1-upstream-reads.txt — plain `x.y` tags, amd64 + arm64 +0.4 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C2-static-reads.txt — no start-time sync or download is switched on; no outbound connection at rest; ingredient search can download from wger.de on demand (`DOWNLOAD_INGREDIENTS_FROM=WGER`, not measured) +0.5 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C1-install.txt ; felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C2-static-reads.txt — first start runs local migrations only +0.6 | n/a | wger serves only HTTP on port 8000; nothing else is published +0.7 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C3-logins.txt — the phone app's route: right 200 + a token that reads the API, wrong 400; wger is not gated (class 3) +0.8 | done | app-catalog-felhom.eu/templates/wger/.felhom.yml — tagline + five use_cases +0.9 | done | felhom.eu/documentation/audits/more-night-apps-2026-09-30/bench/apps/wger/bench/evidence/MV-wger/verdict.json — runs on the bench; SITE_URL builds links only +1.1 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/B/B3-gates-now.txt — image-pins and image-resolvable exit 0 +1.2 | n/a | wger keeps SQLite on its own volume, no database sidecar +1.3 | n/a | no MariaDB or PostgreSQL service in this template +1.4 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C2-static-reads.txt ; felhom.eu/documentation/audits/more-night-apps-2026-09-30/box/wger/step.txt — `DJANGO_PERFORM_MIGRATIONS=True`; the 2.6 -> 2.7 step migrated and read back on the box +1.5 | open | the container runs `manage.py runserver` — `WGER_USE_GUNICORN` is not set (R-755; C2) +1.6 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C2-static-reads.txt ; felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C3-logins.txt — of 15 key-like env reads: SECRET_KEY generated, JWT pair made at start (phone route 200), DB password unused by SQLite; the rest belong to features off here (S3, mail, reCAPTCHA, OIDC, PowerSync) +1.7 | open | two entrypoint switches are still undecided: `WGER_USE_GUNICORN` (R-755) and `DJANGO_DEBUG` — unset, so `collectstatic` never runs (R-762; C2, C8) +1.8 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C2-static-reads.txt ; felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C3-logins.txt — DEBUG False; an unknown page is a plain 404, no debug page +1.9 | n/a | SECRET_KEY signs sessions and reset links only; the JWT pair lives on the data volume and is backed up with it +2.1 | done | app-catalog-felhom.eu/audits/persistence-sweep-2026-08-02/state/gate.log — wger CLEAN (the two volumes are unchanged since) +2.2 | done | app-catalog-felhom.eu/templates/wger/docker-compose.yml — two named volumes (NVMe), no drive path +2.3 | n/a | needs_hdd false: no drive path to classify; the tier-1 unit holds both named volumes +2.4 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C8-signup-guest-media-static.txt — the app wrote its DB and a photo as user `wger` +2.5 | open | no backup -> remove -> restore -> read back of wger: the box has no per-app backup press outside an Update (R-648) and wger has no newer step yet (R-759) +2.6 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C7-remove.txt ; felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C8b-remove-with-data.txt — both choices delete both volumes; for a no-drive app "keep data" keeps only the backups (the page says the app stored no drive data) +2.7 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C4-seed-photo-size.txt — 4.2 MB + 16 KB after the seed +2.8 | open | an uploaded photo is saved (201, file on the volume) but answers 404 through the front door — nothing serves /media/ (R-762; C4, C8) +3.1 | done | felhom.eu/documentation/audits/login-gate-2026-09-29/D/D2-live.txt ; felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C3-logins.txt — class 3, measured +3.2 | done | felhom.eu/documentation/audits/login-gate-2026-09-29/D/D2-live.txt ; felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C1-install.txt — default refused, generated signs in, wrong refused +3.3 | n/a | class 3: a known default login, not an open first-run screen +3.4 | open | after the setup a stranger signed up and signed in, and each anonymous visit to the dashboard made a guest account — `ALLOW_REGISTRATION` and `ALLOW_GUEST_USERS` default True (R-763; C8) +3.5 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C1-install.txt — 92 stranger tries from the press: 90 no route, 1 the gate, then refused; never let in +3.6 | done | felhom.eu/documentation/audits/lockouts-2026-10-01/B/B5-wger-fix-5min.txt — only the name tried is locked, 5 min; the second member unaffected (`09` decision 58) +3.7 | open | not measured: changing the password and adding a family member; the template has no `add_people` text (R-759) +3.8 | done | app-catalog-felhom.eu/templates/wger/.felhom.yml — the password is `sys.argv[1]` +3.9 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C3-logins.txt — browser form with https Origin: right 302, wrong refused; phone route right 200, wrong 400 +4.1 | done | app-catalog-felhom.eu/templates/wger/docker-compose.yml ; felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C1-install.txt — wget to 127.0.0.1:8000, docker healthy +4.2 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/B/B3-gates-now.txt — probe-matches-compose exit 0 +4.3 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C1-install.txt — healthy 107 s after the press (image pull included), 0 restarts +4.4 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C5-negative-control.txt — paused: the controller read `stopped` within 4 s, `running` 40 s after; a probe-only failure (running but wrong) was not built +4.5 | done | app-catalog-felhom.eu/templates/wger/docker-compose.yml — `container_name: wger`, the only service +5.1 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C6-first-start-memory.txt ; felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C6-wger-mem.csv — from birth, 183 s: peak anon 267 MiB = 69.5 % of 384M, swap 0, oom_kill 0 +5.2 | done | felhom.eu/documentation/audits/more-night-apps-2026-09-30/bench/apps/wger/bench/evidence/MV-wger/memory-samples.json — 10 min: peak anon 49.8 %, 0 kills, 0 restarts +5.3 | done | app-catalog-felhom.eu/templates/wger/.felhom.yml — mem_limit 384M = the one service's 384M +5.4 | n/a | wger is a Python (Django) app, no self-sizing heap +5.5 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/B/B1-upstream-reads.txt — pi_compatible true, arm64 published, ~375 MB +6.1 | done | app-catalog-felhom.eu/scripts/upgrade_fixtures_box.py — `Wger`: a weight entry through the login + API, with two negative controls +6.2 | done | felhom.eu/documentation/audits/more-night-apps-2026-09-30/bench/apps/wger/bench/evidence/MV-wger/verdict.json ; felhom.eu/documentation/audits/more-night-apps-2026-09-30/box/wger/step.txt — 2.6 -> 2.7 proven on both venues +6.3 | open | not measured for wger: no forced-fail undo (R-759) +6.4 | done | felhom.eu/documentation/audits/more-night-apps-2026-09-30/bench/apps/wger/bench/evidence/MV-wger/verdict.json — no mark: no file changed at start +6.5 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/B/B1-upstream-reads.txt — `x.y` + `x.y.0`, stable 2.1 -> 2.7; pre-releases carry `-dev`/`-alpha` suffixes +7.1 | open | wger has a mail switch (`ENABLE_EMAIL`) and no `smtp_mapping`; its mail goes to the console, so a password-reset mail never leaves the box (R-764; C2) +8.1 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/B/B3-gates-now.txt — copy-i18n exit 0 +8.2 | open | not read on 9202's app page this session; `add_people` is absent (R-759) +8.3 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/B/B1-upstream-reads.txt — wger-logo.png and screenshot-1 answer 200 +8.4 | done | app-catalog-felhom.eu/README.md — row `fitness.*`; FIRST-ADMIN row; category home; catalog_since set +8.5 | n/a | wger is an existing app; the count does not change +9.1 | open | the runtime volume-persistence gate was not re-run today (last CLEAN 2026-08-02); the other gates exit 0 (B3) (R-759) +9.2 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C1-install.txt ; felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C8-signup-guest-media-static.txt — fresh installs from the drill catalog, as household and stranger +9.3 | open | 10 other rows stay open; each is a register row (R-755, R-759, R-762, R-763, R-764) +9.4 | n/a | wger is exempt: published 2026-02-15, before the checklist diff --git a/scripts/catalog_gates.py b/scripts/catalog_gates.py index da3fd78..138a18e 100644 --- a/scripts/catalog_gates.py +++ b/scripts/catalog_gates.py @@ -23,6 +23,8 @@ Gates, in order (all must pass; **non-zero exit on any failure**): and its newest step IS the compose's images (runs in CI too) 9. test-record-move git history + the registry for MOVED refs only — an image move adds a PROVEN ladder entry whose digests the registry still serves (hook; skipped on CI) + 10. onboarding static, instant, whole repo — a NEW template directory carries a complete onboarding + record (NEW-APP-CHECKLIST.md; the 53 apps published before 2026-10-01 are exempt by name) 4. engine-major static, needs GIT HISTORY — no database engine pin crosses a MAJOR version (operator ruling 2026-09-13; expires when Slice 4 / R-448 ships). Runs in the pre-push hook, which has the full clone; on a SHALLOW clone (CI fetches at @@ -107,6 +109,11 @@ GATES = [ # move must add a PROVEN entry whose digests the registry still serves. ("test-record", "check-test-record.py", True, True, False), ("test-record-move", "check-test-record-move.py", False, True, True), + # 2026-10-01 (operator request): a NEW template directory carries a complete onboarding record — + # onboarding/.md answering every NEW-APP-CHECKLIST.md id, none open, every `done` naming evidence that + # exists. Static, files only, so it is --fast and bites in the hook AND in CI. The 53 apps published before + # the checklist are exempt by name inside the script. + ("onboarding", "check-onboarding.py", False, True, False), ] VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"} diff --git a/scripts/check-onboarding.py b/scripts/check-onboarding.py new file mode 100644 index 0000000..e15a60c --- /dev/null +++ b/scripts/check-onboarding.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""check-onboarding.py — a NEW catalog app carries a complete onboarding record (NEW-APP-CHECKLIST.md). + +WHY. Operator request 2026-10-01: before a new app is offered, it is mapped and tested against one checklist — +storage, database, the first admin, health checks, memory, updates, mail, the household's text. A checklist that +nothing enforces is a wish; this gate is the enforcement. It reads files only (no network, no containers, no git +history), so it runs in `catalog_gates.py --fast`: the pre-push hook and CI. + +THE RULE, for every directory under templates/ that is NOT in EXEMPT: + 1. `onboarding/.md` exists, its `app:` line names the app, and its `opened: YYYY-MM-DD` is a real date + on or after CUTOFF and not in the future. + 2. It answers every checklist id whose `since` date is on or before `opened:` (an id added later binds only + apps opened after it — the checklist stores the date per id). + 3. No answer is `open`. + 4. Every `done` names evidence that EXISTS: a non-empty file, or a directory holding at least one non-empty + file. An empty directory is a label, not evidence (R-410: a `mkdir` once turned a release gate green). + 5. Every `n/a` carries a reason of at least MIN_REASON_WORDS words. +For an EXEMPT app that has a record anyway (wger, the pilot): the record must be well-formed (known ids, no +duplicate, a valid status, `done` evidence that exists, `n/a` with a reason) — `open` and missing ids are allowed. +And `onboarding/_TEMPLATE.md` must carry every checklist id, so a row added to the checklist cannot be forgotten +in the template a new app copies. + +EVIDENCE PATHS are written from the workspace root. `app-catalog-felhom.eu/…` resolves against THIS checkout +(whatever its directory is called — the CI runner checks out into another name). Any other first component +(`felhom.eu/…`) resolves against the checkout's parent directory; if that sibling repository is not there (the CI +runner fetches this repo alone) the path is NOT CHECKED and the count is printed — the pre-push hook on DooPlex has +the sibling and checks it. Same shape as engine-major's shallow-clone skip: said out loud, never silent. + +WHY THE 53 ARE LISTED BY NAME and not "new since commit X": the CI runner fetches at --depth 1 and has no history +to diff (R-452), and a list is a fact a reader can check. A directory not on the list is new, whatever its age. + +Run from the repo root: python3 scripts/check-onboarding.py [--root=DIR] [--today=YYYY-MM-DD] +Exit 0 all records complete · 1 a record is missing or incomplete · 2 the checklist itself cannot be read. +""" +import datetime +import os +import re +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CATALOG_PREFIX = "app-catalog-felhom.eu/" +CUTOFF = "2026-10-01" +MIN_REASON_WORDS = 4 +STATUSES = ("done", "n/a", "open") + +# The 53 apps in the catalog on 2026-10-01 (catalog main 9c5eae9 + the checklist commit). They were published +# before the checklist existed; re-testing them is not owed (operator default 2026-10-01, may be reversed). What the +# catalog shows for each is onboarding/EXISTING-APPS-GAPS.md. NEVER add a name here to let a new app through. +EXEMPT = frozenset(""" +actualbudget adventurelog audiobookshelf bentopdf bookstack calcom calibre-web claper code-server +crafty-controller docmost emby ghost gitea glance gokapi grafana gramps-web home-assistant homebox homepage +immich jellyfin kimai komga mealie n8n navidrome nextcloud onlyoffice opengist outline paperless-ngx papra +plant-it plex privatebin radarr rallly recipe-importer romm seerr sonarr sparkyfitness tandoor termix +uptime-kuma vaultwarden vikunja wanderer wger wishlist zipline +""".split()) + +ROW_CHECKLIST = re.compile(r"^\|\s*(\d+\.\d+)\s*\|\s*(\d{4}-\d{2}-\d{2})\s*\|") +ROW_RECORD = re.compile(r"^(\d+\.\d+)\s*\|\s*([^|]*?)\s*\|\s*(.*?)\s*$") +DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def read_checklist(root): + path = os.path.join(root, "NEW-APP-CHECKLIST.md") + if not os.path.isfile(path): + return None, "NEW-APP-CHECKLIST.md is missing" + ids = {} + for line in open(path, encoding="utf-8"): + m = ROW_CHECKLIST.match(line) + if m: + cid, since = m.group(1), m.group(2) + if cid in ids: + return None, "checklist id %s appears twice" % cid + try: + datetime.date.fromisoformat(since) + except ValueError: + return None, "checklist id %s has a bad since date %r" % (cid, since) + ids[cid] = since + if not ids: + return None, "no `| | |` rows found in NEW-APP-CHECKLIST.md" + return ids, None + + +def evidence_ok(p): + if os.path.isfile(p): + return os.path.getsize(p) > 0 + if os.path.isdir(p): + for dp, _dn, fn in os.walk(p): + for f in fn: + if os.path.getsize(os.path.join(dp, f)) > 0: + return True + return False + + +def resolve(root, rel, unchecked): + """Return (abs path or None, problem or None). None, None = not checkable here (counted).""" + rel = rel.strip().strip("`") + if not rel or rel.startswith("/") or ".." in rel.split("/"): + return None, "evidence %r is not a workspace-relative path" % rel + if rel.startswith(CATALOG_PREFIX): + return os.path.join(root, rel[len(CATALOG_PREFIX):]), None + first = rel.split("/", 1)[0] + sib = os.path.join(os.path.dirname(root), first) + if not os.path.isdir(sib): + unchecked.append(rel) + return None, None + return os.path.join(os.path.dirname(root), rel), None + + +def check_record(root, app, path, checklist, strict, today, unchecked): + probs = [] + # A row inside an HTML comment is not an answer: `` is how a row is set aside, and a commented-out + # `1.4 | done | …` must not count as done (the label without the fact, R-421). Line numbers are kept. + text = re.sub(r"", lambda m: "\n" * m.group(0).count("\n"), + open(path, encoding="utf-8").read(), flags=re.S) + head = {} + for line in text.splitlines(): + m = re.match(r"^(app|opened):\s*(\S+)\s*$", line) + if m and m.group(1) not in head: + head[m.group(1)] = m.group(2) + if head.get("app") != app: + probs.append("its `app:` line reads %r, not %r" % (head.get("app"), app)) + opened = head.get("opened", "") + if not DATE.match(opened): + probs.append("no `opened: YYYY-MM-DD` line") + opened = None + else: + try: + datetime.date.fromisoformat(opened) + except ValueError: + probs.append("`opened: %s` is not a real date" % opened) + opened = None + if strict and opened: + if opened < CUTOFF: + probs.append("`opened: %s` is before the checklist existed (%s) — a new app cannot be opened earlier" + % (opened, CUTOFF)) + if opened > today: + probs.append("`opened: %s` is in the future (today %s)" % (opened, today)) + + seen = {} + for n, line in enumerate(text.splitlines(), 1): + m = ROW_RECORD.match(line) + if not m: + continue + cid, status, rest = m.group(1), m.group(2).lower(), m.group(3) + if cid not in checklist: + probs.append("line %d: id %s is not in the checklist" % (n, cid)) + continue + if cid in seen: + probs.append("line %d: id %s answered twice (first on line %d)" % (n, cid, seen[cid])) + continue + seen[cid] = n + if status not in STATUSES: + probs.append("line %d: id %s has status %r — one of done / n/a / open" % (n, cid, status)) + elif status == "open": + if strict: + probs.append("id %s is OPEN: %s" % (cid, rest or "(no note)")) + elif status == "n/a": + if len(re.findall(r"[^\W\d_]{2,}", rest)) < MIN_REASON_WORDS: + probs.append("id %s is n/a with no reason of %d+ words: %r" % (cid, MIN_REASON_WORDS, rest)) + else: # done + paths = rest.split(" — ", 1)[0] + parts = [p for p in (x.strip() for x in paths.split(" ; ")) if p] + if not parts: + probs.append("id %s is done with no evidence path" % cid) + for p in parts: + ap, why = resolve(root, p, unchecked) + if why: + probs.append("id %s: %s" % (cid, why)) + elif ap and not evidence_ok(ap): + probs.append("id %s is done but its evidence %s does not exist (or is empty)" % (cid, p)) + if strict and opened: + missing = [c for c, s in sorted(checklist.items(), key=lambda kv: [int(x) for x in kv[0].split(".")]) + if s <= opened and c not in seen] + if missing: + probs.append("missing id(s): %s" % ", ".join(missing)) + return probs + + +def main(argv): + root = ROOT + today = datetime.date.today().isoformat() + for a in argv: + if a.startswith("--root="): + root = os.path.abspath(a.split("=", 1)[1]) + elif a.startswith("--today="): + today = a.split("=", 1)[1] + elif a in ("--all",): + pass # the runner passes --all through; every template is judged anyway + elif not a.startswith("-"): + pass # app scope is not used: the rule is about the whole templates/ tree + else: + print("unknown option %s" % a) + return 2 + checklist, err = read_checklist(root) + if err: + print("ONBOARDING GATE INCONCLUSIVE — %s" % err) + return 2 + + tdir = os.path.join(root, "templates") + odir = os.path.join(root, "onboarding") + apps = sorted(d for d in os.listdir(tdir) if os.path.isdir(os.path.join(tdir, d))) + new = [a for a in apps if a not in EXEMPT] + problems, unchecked = [], [] + + tpl = os.path.join(odir, "_TEMPLATE.md") + if not os.path.isfile(tpl): + problems.append(("_TEMPLATE", ["onboarding/_TEMPLATE.md is missing"])) + else: + body = re.sub(r"", "", open(tpl, encoding="utf-8").read(), flags=re.S) + have = {m.group(1) for m in (ROW_RECORD.match(l) for l in body.splitlines()) if m} + lack = sorted(set(checklist) - have, key=lambda c: [int(x) for x in c.split(".")]) + if lack: + problems.append(("_TEMPLATE", ["onboarding/_TEMPLATE.md lacks checklist id(s): %s" % ", ".join(lack)])) + + for app in new: + rec = os.path.join(odir, app + ".md") + if not os.path.isfile(rec): + problems.append((app, ["NEW app with no onboarding record — copy onboarding/_TEMPLATE.md to " + "onboarding/%s.md and answer every id (NEW-APP-CHECKLIST.md)" % app])) + continue + p = check_record(root, app, rec, checklist, True, today, unchecked) + if p: + problems.append((app, p)) + + exempt_records = [] + if os.path.isdir(odir): + for f in sorted(os.listdir(odir)): + name = f[:-3] if f.endswith(".md") else None + if not name or name.startswith("_") or name.isupper() or "-GAPS" in name.upper(): + continue + if name not in apps: + problems.append((name, ["onboarding/%s.md names no template directory" % f])) + elif name in EXEMPT: + exempt_records.append(name) + p = check_record(root, name, os.path.join(odir, f), checklist, False, today, unchecked) + if p: + problems.append((name, p)) + + print("onboarding gate — %d checklist ids; %d template dirs: %d exempt (published before %s), %d new; " + "%d exempt app(s) with a record (shape-checked): %s" + % (len(checklist), len(apps), len(apps) - len(new), CUTOFF, len(new), len(exempt_records), + ", ".join(exempt_records) or "none")) + if unchecked: + print(" NOT CHECKED here (sibling repository absent — the pre-push hook on DooPlex checks them): %d path(s)" + % len(unchecked)) + for u in unchecked[:10]: + print(" %s" % u) + if problems: + print("ONBOARDING GATE FAILED:") + for app, ps in problems: + for p in ps: + print(" %s: %s" % (app, p)) + return 1 + print("onboarding gate OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/onboarding_gaps.py b/scripts/onboarding_gaps.py new file mode 100644 index 0000000..ac59138 --- /dev/null +++ b/scripts/onboarding_gaps.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""onboarding_gaps.py — what the catalog's FILES already show, per checklist group, for the 53 exempt apps. + +Writes onboarding/EXISTING-APPS-GAPS.md. READ ONLY: no network, no containers, no re-testing. It answers "which of +the NEW-APP-CHECKLIST.md groups does the catalog already hold a record for, per old app" from what is committed: +the templates, the ladder entries, the fixture tables, FIRST-ADMIN.md, README.md and the 2026-08-02 persistence +sweep. A cell is a signal the files carry, not a measurement made today — "shown" means a file says it, never +that it is still true. This is information, not work (operator default 2026-10-01). + +Run from the repo root (PyYAML needed — this is a local report, not a gate): + python3 scripts/onboarding_gaps.py # rewrite onboarding/EXISTING-APPS-GAPS.md + python3 scripts/onboarding_gaps.py --check # exit 1 if the committed page differs from a fresh run +""" +import datetime +import io +import json +import os +import re +import subprocess +import sys + +import yaml + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +OUT = os.path.join(ROOT, "onboarding", "EXISTING-APPS-GAPS.md") +SWEEP = os.path.join(ROOT, "audits", "persistence-sweep-2026-08-02", "state", "gate.log") +sys.path.insert(0, os.path.join(ROOT, "scripts")) + + +def exempt(): + src = io.open(os.path.join(ROOT, "scripts", "check-onboarding.py"), encoding="utf-8").read() + return sorted(re.search(r'EXEMPT = frozenset\("""(.*?)"""', src, re.S).group(1).split()) + + +def mb(v): + m = re.match(r"^\s*(\d+(?:\.\d+)?)\s*([MG])", str(v or "")) + if not m: + return None + return int(float(m.group(1)) * (1024 if m.group(2) == "G" else 1)) + + +def fixture_apps(): + names = set() + for f in ("upgrade_fixtures.py", "upgrade_fixtures_box.py", "upgrade_fixtures_box28.py"): + src = io.open(os.path.join(ROOT, "scripts", f), encoding="utf-8").read() + for block in re.findall(r"FIXTURES(?:28)?(?: = |\.update\()\{(.*?)\n\}", src, re.S): + names |= set(re.findall(r'^\s+"([a-z0-9-]+)":', block, re.M)) + return names + + +def sweep_verdicts(): + """The FIRST run's lists in the 2026-08-02 sweep (53 in scope). BROKEN and UNDETERMINED are named; the rest + of the 53 were CLEAN.""" + out = {} + if not os.path.isfile(SWEEP): + return out + text = io.open(SWEEP, encoding="utf-8").read() + first = text.split("of 53 in scope")[0] + sec = None + for line in first.splitlines(): + if line.startswith("BROKEN"): + sec = "broken" + elif line.startswith("UNDETERMINED"): + sec = "undetermined" + elif sec and re.match(r"^ ([a-z0-9-]+)(:|$)", line): + out[re.match(r"^ ([a-z0-9-]+)", line).group(1)] = sec + return out + + +def first_admin_rows(): + rows = {} + for line in io.open(os.path.join(ROOT, "FIRST-ADMIN.md"), encoding="utf-8"): + cells = [c.strip() for c in line.strip().strip("|").split("|")] + if len(cells) >= 6 and re.match(r"^\**[a-z0-9-]+\**$", cells[0]) and cells[1][:1].isdigit(): + rows[cells[0].strip("*")] = {"class": cells[1], "measured": "**M" in cells[5] or cells[5].startswith("M")} + return rows + + +def main(argv): + apps = exempt() + fx = fixture_apps() + sweep = sweep_verdicts() + fa = first_admin_rows() + readme = io.open(os.path.join(ROOT, "README.md"), encoding="utf-8").read() + rows, tally = [], {g: 0 for g in range(9)} + notes = {"mem_mismatch": [], "no_limit": [], "maria": [], "pg18": []} + for app in apps: + d = os.path.join(ROOT, "templates", app) + fy_text = io.open(os.path.join(d, ".felhom.yml"), encoding="utf-8").read() + fy = yaml.safe_load(fy_text) or {} + dc = yaml.safe_load(io.open(os.path.join(d, "docker-compose.yml"), encoding="utf-8")) or {} + svcs = dc.get("services") or {} + ai = fy.get("app_info") or {} + res = fy.get("resources") or {} + cell = {} + + # 0 Fit + life = (fy.get("lifecycle") or "available") + g0 = life == "available" and bool(ai.get("use_cases")) and "pi_compatible" in res + cell[0] = ("yes" if g0 else "—") + ("" if life == "available" else " (%s)" % life) + + # 1 Images, start command, database — the files show the ENGINE rules only + engines, ok1 = [], True + for sn, s in svcs.items(): + img = str(s.get("image", "")) + env = s.get("environment") or [] + env = env if isinstance(env, list) else ["%s=%s" % kv for kv in env.items()] + if img.startswith("mariadb:"): + engines.append("mariadb") + if not any(str(e).replace(" ", "") in ("MARIADB_AUTO_UPGRADE=1", "MARIADB_AUTO_UPGRADE=\"1\"") for e in env): + ok1 = False + notes["maria"].append(app) + if re.match(r"^(postgres|postgis/postgis|ghcr\.io/immich-app/postgres):", img): + engines.append("pg") + if re.match(r"^postgres:18", img) and any(":/var/lib/postgresql/data" in str(v) for v in s.get("volumes") or []): + ok1 = False + notes["pg18"].append(app) + if ":latest" in img or ":" not in img.split("/")[-1]: + ok1 = False + cell[1] = ("engine rules hold" if engines else "no DB sidecar") if ok1 else "ENGINE RULE BROKEN" + g1 = ok1 + + # 2 Storage and backup + sv = sweep.get(app, "clean" if sweep else None) + hdd = bool(res.get("needs_hdd")) + g2 = sv == "clean" and (not hdd or "backup" in fy) + cell[2] = "%s%s" % ("sweep %s" % sv if sv else "no sweep", + (", backup classes" if "backup" in fy else ", HDD w/o classes") if hdd else "") + + # 3 Accounts and strangers + r = fa.get(app) + mech = [k for k in ("after_install", "setup_gate", "signup_block", "after_setup") if fy.get(k)] + g3 = bool(r and r["measured"]) + cell[3] = ("class %s, %s" % (r["class"], "measured" if r["measured"] else "read only") if r else "no row") + \ + ((" + " + "/".join(mech)) if mech else "") + + # 4 Health + all_hc = all(s.get("healthcheck") for s in svcs.values()) + probe = bool((fy.get("healthcheck") or {}).get("checks")) + # the probe dials the container named like the stack, or the one its `container:` names (R-630) + targets = {c.get("container") for c in (fy.get("healthcheck") or {}).get("checks") or [] if c.get("container")} + names = {s.get("container_name") for s in svcs.values()} + named = (app in names) if not targets else targets <= names + g4 = all_hc and probe and named + cell[4] = "yes" if g4 else ", ".join(x for x, ok in (("hc missing", all_hc), ("no probe", probe), + ("probe container not in compose", named)) if not ok) + + # 5 Resources + lims = [mb(((s.get("deploy") or {}).get("resources") or {}).get("limits", {}).get("memory")) for s in svcs.values()] + every = all(lims) + total = sum(x for x in lims if x) + ml = mb(res.get("mem_limit")) + if ml != total: + notes["mem_mismatch"].append("%s (%s vs %d)" % (app, res.get("mem_limit"), total)) + if not every: + notes["no_limit"].append(app) + ladder = [json.loads(l.strip()[2:]) for l in fy_text.splitlines() if l.strip().startswith('- {"from"')] + watched = [e for e in ladder if e.get("memory_peak_pct") is not None] + g5 = every and ml == total and bool(watched) + cell[5] = ("watched %.0f%%" % watched[-1]["memory_peak_pct"] if watched else "no watch") + \ + ("" if ml == total else ", mem_limit≠sum") + ("" if every else ", a service w/o limit") + + # 6 Updates + proven = [e for e in ladder if e.get("verdict") == "proven" and not e.get("backfilled")] + g6 = bool(proven) and app in fx + cell[6] = "%d proven step(s)%s" % (len(proven), ", fixture" if app in fx else ", no fixture") + + # 7 Mail — the files cannot say whether an app WANTS mail; only whether it is mapped + cell[7] = "smtp mapped" if fy.get("smtp_mapping") else "—" + + # 8 Text and listing + en = bool((fy.get("i18n") or {}).get("en")) + txt = all(ai.get(k) for k in ("tagline", "use_cases", "first_steps")) + # README's App Catalog table names an app by display name, so the row is found by its subdomain cell + listed = re.search(r"\|\s*%s\.\*\s*\|" % re.escape(str(fy.get("subdomain", "\0"))), readme) is not None + g8 = en and txt and listed and bool(r) + cell[8] = "yes" if g8 else ", ".join(x for x, ok in (("no en", en), ("app_info gap", txt), + ("not in README", listed), ("no FIRST-ADMIN row", bool(r))) if not ok) + for g, v in enumerate((g0, g1, g2, g3, g4, g5, g6, None, g8)): + if v: + tally[g] += 1 + rows.append((app, cell)) + + n = len(apps) + sha = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT, capture_output=True, text=True).stdout.strip() + L = [] + L.append("# EXISTING APPS — what the catalog already shows, per checklist group") + L.append("") + L.append("> Generated by `scripts/onboarding_gaps.py` from committed files (catalog `%s`). **Do not edit by hand.**" % sha) + L.append("> Read only: nothing was re-tested. A cell is what a FILE says, not a measurement made today. The 53 apps") + L.append("> published before the checklist (2026-10-01) are exempt from the onboarding gate; this page is information,") + L.append("> not work (operator default 2026-10-01, may be reversed).") + L.append("") + L.append("## Headline — apps whose files show the group covered (of %d)" % n) + L.append("") + L.append("| group | covered | what \"covered\" means here (the signal read) |") + L.append("|---|---|---|") + defs = [ + ("0 Fit", "`lifecycle` available, `use_cases` and `pi_compatible` present — licence, telemetry, internet need and phone apps are recorded nowhere"), + ("1 Images, start command, DB", "pins clean and the engine rules hold (MariaDB auto-upgrade, PG 18 mount) — entrypoint switches, the production server, migrations and secrets read (1.4–1.9) are recorded for NO app"), + ("2 Storage and backup", "the 2026-08-02 persistence sweep read the app CLEAN, and an HDD app carries `backup:` classes — no restore round trip is recorded per app"), + ("3 Accounts and strangers", "a FIRST-ADMIN.md row whose source is MEASURED on a box — lock-out (3.6) is recorded only for the R-752 apps"), + ("4 Health", "every service has a compose healthcheck, a controller probe exists, the exposed container is named like the stack — no negative control is recorded"), + ("5 Resources", "every service limited, `mem_limit` = the sum, and a ladder entry carries a measured memory watch — no first-start-from-birth watch is recorded except immich's"), + ("6 Updates", "a proven (not backfilled) ladder step AND an upgrade fixture"), + ("7 Mail", "not countable from files: whether an app WANTS mail is not recorded; the mapped count is below"), + ("8 Text and listing", "English block, tagline + use_cases + first_steps, listed in README, a FIRST-ADMIN row"), + ] + for g, (name, d) in enumerate(defs): + L.append("| %s | %s | %s |" % (name, "—" if g == 7 else "%d / %d" % (tally[g], n), d)) + L.append("") + L.append("Mail: %d app(s) carry `smtp_mapping`." % sum(1 for _a, c in rows if c[7] == "smtp mapped")) + L.append("") + L.append("## Found while computing this page") + L.append("") + L.append("- `mem_limit` differs from the sum of the compose limits (REUSE.md §2 says equal): %d — %s." + % (len(notes["mem_mismatch"]), ", ".join(notes["mem_mismatch"]) or "none")) + L.append("- A service with no memory limit: %s." % (", ".join(notes["no_limit"]) or "none")) + L.append("- A MariaDB sidecar without `MARIADB_AUTO_UPGRADE=1`: %s." % (", ".join(notes["maria"]) or "none")) + L.append("- A PostgreSQL 18 data mount at the old path: %s." % (", ".join(notes["pg18"]) or "none")) + L.append("") + L.append("## Per app") + L.append("") + L.append("| app | 0 fit | 1 images/DB | 2 storage | 3 accounts | 4 health | 5 resources | 6 updates | 7 mail | 8 text |") + L.append("|---|---|---|---|---|---|---|---|---|---|") + for app, c in rows: + L.append("| %s | %s |" % (app, " | ".join(c[g] for g in range(9)))) + L.append("") + body = "\n".join(L) + + if "--check" in argv: + cur = io.open(OUT, encoding="utf-8").read() if os.path.isfile(OUT) else "" + strip = lambda t: re.sub(r"catalog `[0-9a-f]+`", "catalog `X`", t) + if strip(cur) != strip(body): + print("EXISTING-APPS-GAPS.md is stale — run python3 scripts/onboarding_gaps.py") + return 1 + print("EXISTING-APPS-GAPS.md is current") + return 0 + io.open(OUT, "w", encoding="utf-8").write(body) + print("wrote %s — %d apps; covered per group: %s" % (os.path.relpath(OUT, ROOT), n, + ", ".join("%d:%s" % (g, "-" if g == 7 else tally[g]) for g in range(9)))) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/test_catalog_gates.py b/scripts/test_catalog_gates.py index ed5567e..82ad10b 100644 --- a/scripts/test_catalog_gates.py +++ b/scripts/test_catalog_gates.py @@ -62,7 +62,9 @@ class CatalogGatesFastTest(unittest.TestCase): # (copy-i18n, probe-matches-compose) — the test was red and nobody ran it. Now 9 with the test # record's two halves; the slow pair stays out of --fast. STALE AGAIN until 2026-09-30: 10 since # probe-measured joined; the test had been red on main (found by the more-night-apps session). - self.assertEqual(len(mod.GATES), 10) + # 11 since 2026-10-01: onboarding (NEW-APP-CHECKLIST.md), fast and history-free, so it runs in CI too. + self.assertEqual(len(mod.GATES), 11) + self.assertIn("onboarding", [g[0] for g in mod.GATES if g[3] and not g[4]]) self.assertEqual([g[0] for g in mod.GATES if not g[3]], ["image-resolvable", "volume-persistence"]) self.assertIn("test-record", [g[0] for g in mod.GATES if g[3] and not g[4]]) # runs in CI too # the gates that need git history are the ones the CI half cannot run (R-452's shallow gap) diff --git a/scripts/test_gate_decoys.py b/scripts/test_gate_decoys.py index a47b328..ecb5849 100644 --- a/scripts/test_gate_decoys.py +++ b/scripts/test_gate_decoys.py @@ -55,6 +55,7 @@ COVERS = { "test-record": "a ladder whose newest step is not the compose's images (a move without a record), a gap, a line that is not one JSON entry, a failed verdict - vs a clean ladder (09 decision 13)", "test-record-move": "an image move with NO entry, with the entry only in a COMMENT or in README, with a failed/backfilled entry, with a digest the registry no longer serves, memory_tight without a raised limit - vs a proven entry that matches; a ref moving in a compose COMMENT is not a move (09 decision 13)", "probe-measured": "the measurement written in the TAGLINE or another comment block, not directly above setup_done_probe:; a date with no before/after; before/after with no date; 'read upstream' instead of 'measured' - vs a genuine measured comment (R-715)", + "onboarding": "a NEW template with no record; a record missing an id, or carrying it only inside an HTML comment; a `done` whose path does not exist, is an EMPTY directory (the mkdir shape, R-410) or names an absent sibling-repo file; an `n/a` with an empty or two-word reason; an `open` row; `opened:` backdated before the checklist; the template a new app copies lacking a new id - vs a complete record, an id added after `opened:`, and an exempt app's record with open rows (NEW-APP-CHECKLIST.md)", "copy-i18n": "Hungarian edited in a COMMENT/README/display_name (label, not copy) vs a real frozen string changed; an English block that is not English, is not matched to a Hungarian twin, or rewrites a credential (R-560). Also the DEGRADED mode CI actually runs — PyYAML shadowed out, freeze only (R-595)", } @@ -485,6 +486,138 @@ def test_record_cases(clone): case_tr_move("FACT: a re-test with no new digest reaches the move gate too", clone, [(NF, tr_append(rt(TR_D1, TR_D1, box_evidence="x")))] + with_steps, 1, ("no new digest",), {old: TR_D1}) + +def onboarding_cases(): + """The onboarding gate reads FILES — the checklist, the template, the records, and evidence paths that may live + in a SIBLING repository. So each case runs in its own scratch WORKSPACE: /app-catalog-felhom.eu (a clone, + with this working tree's checklist + template copied in — the files under test are the ones being edited) and + /felhom.eu (a stand-in sibling holding one evidence file). The real tree is never touched.""" + global ran + ws = tempfile.mkdtemp(prefix="catalog-onboarding-") + cat = os.path.join(ws, "app-catalog-felhom.eu") + sh(["git", "clone", "-q", "file://" + ROOT, cat], cwd=ROOT) + for rel in ("NEW-APP-CHECKLIST.md", os.path.join("onboarding", "_TEMPLATE.md")): + os.makedirs(os.path.dirname(os.path.join(cat, rel)) or cat, exist_ok=True) + shutil.copy(os.path.join(ROOT, rel), os.path.join(cat, rel)) + sib = os.path.join(ws, "felhom.eu", "documentation", "audits", "onb") + os.makedirs(sib) + with io.open(os.path.join(sib, "proof.txt"), "w", encoding="utf-8") as fh: + fh.write("measured\n") + shutil.copytree(os.path.join(cat, "templates", "vaultwarden"), os.path.join(cat, "templates", "newapp")) + ev = os.path.join(cat, "onboarding", "evidence", "newapp") + os.makedirs(ev) + with io.open(os.path.join(ev, "e.txt"), "w", encoding="utf-8") as fh: + fh.write("bench output\n") + ids = [m.group(1) for m in (re.match(r"^(\d+\.\d+) \|", l) for l in + io.open(os.path.join(cat, "onboarding", "_TEMPLATE.md"), encoding="utf-8")) if m] + if len(ids) < 50: + raise SystemExit("the template carries %d ids — the fixture drifted, not the gate" % len(ids)) + + def record(rows=None, opened="2026-10-01", app="newapp"): + rows = rows if rows is not None else ["%s | done | app-catalog-felhom.eu/onboarding/evidence/newapp/e.txt" % i + for i in ids] + return "# Onboarding record\n\napp: %s\nopened: %s\n\n%s\n" % (app, opened, "\n".join(rows)) + + def full(**over): + out = [] + for i in ids: + out.append(over.get(i, "%s | done | app-catalog-felhom.eu/onboarding/evidence/newapp/e.txt" % i)) + return [r for r in out if r is not None] + + REC = os.path.join(cat, "onboarding", "newapp.md") + + def case_onb(name, setup, expect_rc, must=()): + global ran + ran += 1 + try: + setup() + r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-onboarding.py"), "--root=" + cat, + "--today=2026-10-02"], cwd=cat) + out = r.stdout + r.stderr + if r.returncode == expect_rc and all(m in out for m in must): + print(" ok %-52s rc=%d (expected %d)" % (name, r.returncode, expect_rc)) + else: + fails.append("%s: rc=%d expected %d; missing %s\n%s" % ( + name, r.returncode, expect_rc, [m for m in must if m not in out], out[-900:])) + finally: + for f in (REC, os.path.join(cat, "onboarding", "wger.md")): + if os.path.exists(f): + os.remove(f) + shutil.copy(os.path.join(ROOT, "NEW-APP-CHECKLIST.md"), os.path.join(cat, "NEW-APP-CHECKLIST.md")) + shutil.copy(os.path.join(ROOT, "onboarding", "_TEMPLATE.md"), os.path.join(cat, "onboarding", "_TEMPLATE.md")) + empty = os.path.join(cat, "onboarding", "evidence", "hollow") + if os.path.isdir(empty): + shutil.rmtree(empty) + + def put(text, path=REC): + def f(): + with io.open(path, "w", encoding="utf-8") as fh: + fh.write(text) + return f + + try: + print("\n-- onboarding: the facts (each MUST be refused)") + case_onb("FACT: a new template with NO record", lambda: None, 1, ("newapp: NEW app with no onboarding record",)) + case_onb("FACT: a record missing id 1.4", put(record(full(**{"1.4": None}))), 1, ("missing id(s): 1.4",)) + case_onb("FACT: 1.4 answered only inside an HTML comment", + put(record(full(**{"1.4": ""}))), + 1, ("missing id(s): 1.4",)) + case_onb("FACT: done with a path that does not exist", + put(record(full(**{"2.5": "2.5 | done | app-catalog-felhom.eu/onboarding/evidence/newapp/restore.txt"}))), + 1, ("id 2.5 is done but its evidence", "restore.txt")) + def hollow(): + os.makedirs(os.path.join(cat, "onboarding", "evidence", "hollow")) + put(record(full(**{"5.1": "5.1 | done | app-catalog-felhom.eu/onboarding/evidence/hollow"})))() + case_onb("FACT: done with an EMPTY directory (the mkdir shape)", hollow, 1, ("id 5.1 is done but its evidence",)) + case_onb("FACT: done naming an absent file in the sibling repo", + put(record(full(**{"3.6": "3.6 | done | felhom.eu/documentation/audits/onb/lockout.txt"}))), + 1, ("id 3.6 is done but its evidence",)) + case_onb("FACT: n/a with an EMPTY reason", put(record(full(**{"7.1": "7.1 | n/a | "}))), 1, ("id 7.1 is n/a",)) + case_onb("FACT: n/a with a two-word reason", put(record(full(**{"7.1": "7.1 | n/a | not needed"}))), 1, ("id 7.1 is n/a",)) + case_onb("FACT: an OPEN row", put(record(full(**{"6.3": "6.3 | open | the forced-fail case is not run yet"}))), + 1, ("id 6.3 is OPEN",)) + case_onb("FACT: opened: backdated before the checklist", put(record(full(), opened="2026-09-01")), + 1, ("before the checklist existed",)) + def new_id_template_lacks(): + t = io.open(os.path.join(cat, "NEW-APP-CHECKLIST.md"), encoding="utf-8").read() + t = t.replace("\n## 7. Mail", "\n| 6.9 | 2026-10-01 | a new check | how | why |\n\n## 7. Mail", 1) + io.open(os.path.join(cat, "NEW-APP-CHECKLIST.md"), "w", encoding="utf-8").write(t) + put(record(full() + ["6.9 | done | app-catalog-felhom.eu/onboarding/evidence/newapp/e.txt"]))() + case_onb("FACT: a checklist id the template a new app copies lacks", new_id_template_lacks, 1, + ("_TEMPLATE.md lacks checklist id(s): 6.9",)) + case_onb("FACT: an exempt app's record with a done that points nowhere", + lambda: (put(record(full()))(), put(record(["1.5 | done | app-catalog-felhom.eu/nowhere.txt"], + app="wger"), os.path.join(cat, "onboarding", "wger.md"))()), + 1, ("wger: id 1.5 is done but its evidence",)) + + print("-- onboarding: the genuine articles (each MUST pass)") + case_onb("GENUINE: a complete record (catalog + sibling evidence)", + put(record(full(**{"3.6": "3.6 | done | felhom.eu/documentation/audits/onb/proof.txt — measured on 9202", + "7.1": "7.1 | n/a | the app sends no mail at all"}))), 0, ("onboarding gate OK",)) + def later_id(): + t = io.open(os.path.join(cat, "NEW-APP-CHECKLIST.md"), encoding="utf-8").read() + t = t.replace("\n## 7. Mail", "\n| 6.9 | 2026-11-01 | a later check | how | why |\n\n## 7. Mail", 1) + io.open(os.path.join(cat, "NEW-APP-CHECKLIST.md"), "w", encoding="utf-8").write(t) + tp = os.path.join(cat, "onboarding", "_TEMPLATE.md") + io.open(tp, "a", encoding="utf-8").write("6.9 | open | not started: a later check\n") + put(record(full()))() + case_onb("GENUINE: an id added AFTER opened: does not bind", later_id, 0, ("onboarding gate OK",)) + case_onb("GENUINE: an exempt app's record may say open", + lambda: (put(record(full()))(), put(record(["1.5 | open | the dev server runs (R-755)"], app="wger"), + os.path.join(cat, "onboarding", "wger.md"))()), + 0, ("exempt app(s) with a record (shape-checked): wger",)) + def no_sibling(): + shutil.move(os.path.join(ws, "felhom.eu"), os.path.join(ws, "felhom.eu.away")) + put(record(full(**{"3.6": "3.6 | done | felhom.eu/documentation/audits/onb/lockout.txt"})))() + try: + case_onb("STATED SKIP: sibling repo absent (the CI shape) - printed, not checked", no_sibling, 0, + ("NOT CHECKED here", "felhom.eu/documentation/audits/onb/lockout.txt")) + finally: + if os.path.isdir(os.path.join(ws, "felhom.eu.away")): + shutil.move(os.path.join(ws, "felhom.eu.away"), os.path.join(ws, "felhom.eu")) + finally: + shutil.rmtree(ws, ignore_errors=True) + def main(): gate = os.path.join(ROOT, "scripts", "check-engine-major.py") if not os.path.isfile(gate): @@ -961,6 +1094,8 @@ i18n: finally: shutil.rmtree(clone, ignore_errors=True) + onboarding_cases() + if fails: print() for f in fails: