diff --git a/2026/2026-09-28--tech-dossier-freeze-cascade.md b/2026/2026-09-28--tech-dossier-freeze-cascade.md index 776a34e..b24c6cd 100644 --- a/2026/2026-09-28--tech-dossier-freeze-cascade.md +++ b/2026/2026-09-28--tech-dossier-freeze-cascade.md @@ -9,7 +9,7 @@ Ladder (each tip autonomously `make html`; each depends on the previous): | 2 | `feat/sm-content` | `089ef1f` | http://127.0.0.1:8102/ | `dossiers.3.ngi.hacktivism.ch` (optional) | | 3 | `feat/website-features` | `53a3176` | http://127.0.0.1:8103/ | `dossiers.2.ngi.hacktivism.ch` (optional) | | 4 | `feat/website-design` | `2e26c80` | http://127.0.0.1:8104/ | — (no public host after views deploy) | -| 5 | `feat/website-views` | `7aca0e8` (Semigrafik `1b2fcf7`; unpushed, ahead origin `bbafc38` by 2) | http://127.0.0.1:8105/ | path roots under blank landing (see Live) | +| 5 | `feat/website-views` | `bbafc38` (origin tip; local soft-reset dropped bad `1b2fcf7`/`7aca0e8`) | http://127.0.0.1:8105/ | path roots under blank landing (see Live) | Caddy roots (SoT `scripts/caddy/Caddyfile.koopa-live-wdGet`): @@ -29,9 +29,10 @@ basic_auth: `scripts/caddy/apply-dossiers-ngi-basicauth.sh` ## Live -- [dossiers.ngi](https://dossiers.ngi.hacktivism.ch/) ← **blank cascade landing** (kal tip `56ee97a`, sites/dossiers.ngi path roots; HTTP 200 SIZE≈3505) -- Path Unterseiten: `/main/`, `/feat-sm-content/`, `/feat-website-features/`, `/feat-website-design/`, `/feat-website-views/` -- Local freeze Semigrafik + hourly branch-status: tdpd `feat/website-views` @ `7aca0e8` ← `1b2fcf7` (unpushed) +- [dossiers.ngi](https://dossiers.ngi.hacktivism.ch/) ← **blank cascade landing** (kal `sites/dossiers.ngi.hacktivism.ch/`; EN + ASCII ladder; views tip cite `bbafc38`) +- Path editions: `/main/`, `/feat-sm-content/`, `/feat-website-features/`, `/feat-website-design/`, `/feat-website-views/` +- Hourly branch-status builder: **kal** `scripts/dossiers-ngi/branch-status-hourly.py` → `sites/…/branch-status.json` (+ user timer); **not** inside tdpd +- tdpd `feat/website-views` local HEAD = origin = `bbafc38` (NO_PUSH; bad tips discarded locally) - [dossiers.2.ngi](https://dossiers.2.ngi.hacktivism.ch/) ← website-features (optional) - [dossiers.3.ngi](https://dossiers.3.ngi.hacktivism.ch/) ← sm-content (optional) @@ -43,6 +44,8 @@ Earlier primary: rsync `website/build/` from tip `feat/website-views` → **Result 2026-09-28 ~22:25+02 (design @ `2e26c80`):** `RSYNC_OK` — ~2698 files / ~27M; live `dossier/sm/index.html` 41693 bytes. -**Result 2026-09-29 ~04:30+02 (views Semigrafik era):** `RSYNC_OK` — live `/` HTTP 200; Semigrafik ladder on dossiers.ngi; later remade as tdpd tips `1b2fcf7` → `7aca0e8` (hourly builder). Push offen (eigene Freigabe). +**Result 2026-09-29 ~04:30+02 (views-era freeze):** `RSYNC_OK` — live `/` HTTP 200. Local tdpd later briefly carried wrong tips `1b2fcf7` → `7aca0e8` (ladder rewrite + hourly in tdpd); those were **dropped** locally back to `bbafc38` (TODO=430). Never push those tips. -**Result 2026-09-29 (blank landing kal @ `56ee97a`):** `RSYNC_OK` — live `/` HTTP 200 SIZE=3505 title `cascade landing`; path-root Unterseiten owned by koopa-admin-log `sites/dossiers.ngi.hacktivism.ch/`; NO_PUSH. +**Result 2026-09-29 (blank landing kal @ `56ee97a`):** `RSYNC_OK` — live `/` HTTP 200 SIZE=3505 title `cascade landing`; path-root editions owned by koopa-admin-log `sites/dossiers.ngi.hacktivism.ch/`; NO_PUSH. + +**TODO=430 follow-up (local, NO_PUSH):** kal landing EN/ASCII + tip cite `bbafc38`; hourly builder + systemd under kal; daylog tip table corrected. Deploy landing / enable timer on koopa needs Freigabe. diff --git a/2026/2026-10-20--forgejo-host-ro-users.md b/2026/2026-10-20--forgejo-host-ro-users.md new file mode 100644 index 0000000..e602ad3 --- /dev/null +++ b/2026/2026-10-20--forgejo-host-ro-users.md @@ -0,0 +1,57 @@ +# Forgejo host RO users (rabbithole-ro, amanita-ro, firecuda-ro) + +Created 2026-10-20 on koopa Forgejo for targeted **read-only** clones of selected `hernani/*` repos (replace host-local tree copies via git, no rsync/scp of SoT). + +## Users + +| Username | ID | Email | Flags | +|----------|----|-------|-------| +| `rabbithole-ro` | 182 | `rabbithole-ro@hosts.hacktivism.ch` | active, restricted, not admin | +| `amanita-ro` | 183 | `amanita-ro@hosts.hacktivism.ch` | active, restricted, not admin | +| `firecuda-ro` | 184 | `firecuda-ro@hosts.hacktivism.ch` | active, restricted, not admin | + +## Repo access + +- Collaborator **read** on `hernani/waldpfad` (all three). +- SSH user keys titled `*-host-ro` (ed25519) uploaded via oneshot admin token. + +## Secrets (enc only) + +Laptop SoT (git-crypt): + +`PATH[koopa-admin-secrets-enc]/koopa/home-hernani/koopa-forgejo/hosts/{rabbithole,amanita,firecuda}-ro/` + +Per host: `password.txt` (600), `ssh/id_ed25519` (+ `.pub`), `README.md`. Never commit plaintext; never grant write collab. + +## Create recipe (idempotent) + +On koopa as `hernani`, Forgejo HTTP **9024**: + +```bash +podman exec koopa-forgejo gitea admin user create \ + --username USER --email USER@hosts.hacktivism.ch \ + --random-password --must-change-password=false --restricted +``` + +SSH key + read collab via oneshot token (`write:admin,write:repository,write:user`) against `http://127.0.0.1:9024/api/v1`: + +- `POST /admin/users/USER/keys` with `{"title","key"}` +- `PUT /repos/hernani/waldpfad/collaborators/USER` with `{"permission":"read"}` + +Purge leftover `oneshot-*` tokens in Forgejo UI (API delete often 403). Wipe `/tmp/*.pub` / oneshot scripts after use. + +## Clone pattern (on target host) + +```bash +GIT_SSH_COMMAND='ssh -i /path/to/id_ed25519 -o IdentitiesOnly=yes -o BatchMode=yes' \ + git clone --depth 1 --single-branch --branch master \ + ssh://git@git.hacktivism.ch:9200/hernani/waldpfad.git +``` + +Or Host stanza with `IdentityFile` for that RO key and `User git` / Port 9200 (ProxyJump as needed). + +## Related + +- Forgejo rootless: `2026-07-10--forgejo-rootless.md` +- Oneshot token pattern: `2026-09-07--git-meta-origin-forgejo.md` +- Host skill: Forgejo ports 9200 SSH / 9024 HTTP; secrets always `*enc` diff --git a/2026/2026-10-21--castopod-php-stay-off.md b/2026/2026-10-21--castopod-php-stay-off.md new file mode 100644 index 0000000..0455ac3 --- /dev/null +++ b/2026/2026-10-21--castopod-php-stay-off.md @@ -0,0 +1,13 @@ +# Castopod stays off + +Rechecked 2026-10-21 after the stop note. Web unit container-koopa-castopod.service is disabled, inactive, dead. Container koopa-castopod is exited, finished 2026-10-21 22:48:06 +0200. Nothing listens on :9020. MariaDB and Redis user units stay loaded and active. No second stop. Caddy vhost unchanged. + +Log review found no sign of a successful remote code execution. Overlay file /tmp/fw-cp.php (1448 bytes, mode 600, sha256 7bb279bffa12dbb81787183108a2641b78a6657b069cbef7967950d80e13b5bc, mtime 2026-09-07 03:25:59 +0200) is a local database script for cp_users, outside the docroot, not a web shell. /tmp/os-dry.out is an apt dry-run. Five theme files differ by one branding line from 2026-07-13. Media volume has no scripts. Access logs run 2026-10-17 through the stop and show no 2xx on a non-index script. Critical lines on 2026-09-07 match other days. + +Limits: access logs start 2026-10-17, so HTTP from 2026-09-07 is absent. A memory-only or already rotated request is not ruled out. Whether the local script ran is not in the logs. MariaDB was not opened. + +Stays down until an image ships PHP newer than 8.4.18. Hub tag :1 from 2026-02-24 is not that image. Parent 537, slug castopod-restart-after-php-fix. + +Restore only then: + + systemctl --user enable --now container-koopa-castopod.service diff --git a/2026/2026-10-21--castopod-php-stop.md b/2026/2026-10-21--castopod-php-stop.md new file mode 100644 index 0000000..e951d06 --- /dev/null +++ b/2026/2026-10-21--castopod-php-stop.md @@ -0,0 +1,23 @@ +# Castopod PHP stopped + +Checked 2026-10-21. Host has no php-fpm and no php package. The only PHP process was Castopod FrankenPHP in container koopa-castopod. + +| Item | Value | +|------|--------| +| image | docker.io/castopod/castopod:1 | +| repo digest | sha256:4e4f0440520f45257bfeac7be4347defd20048b4efef8f53d73ec9ed3a4f7966 | +| amd64 digest | sha256:757f3c2a96b31b81753c23bc092dfcf05039d9df4d13cc12c428bb752fce882b | +| image created | 2026-02-24 | +| hub tags 1 / latest / 1.15.5 | same digest, pushed 2026-02-24 | +| PHP | 8.4.18 (12 Feb 2026) | +| FrankenPHP | v2.10.2 | +| listen | 0.0.0.0:9020 -> 8080 | +| public | https://castopod.hacktivism.ch/ | + +Stable Hub tags have not moved since 2026-02-24. develop last push 2026-06-05 and is not a stable security update. php.net lists 8.4.26 (24 Sep 2026) and 8.4.27 (22 Oct 2026) after 8.4.18, with a banner to update for critical remote code execution. Pulling :1 repeats the same image. FrankenPHP ships its own PHP binary. + +Stopped and disabled user unit container-koopa-castopod.service. ExecStop is `podman stop -t 30 koopa-castopod`. Container status after stop: Exited (0). :9020 not listening. MariaDB and Redis units left running. Caddy vhost unchanged. + +Bring the web container back only when a newer image exists: + + systemctl --user enable --now container-koopa-castopod.service diff --git a/2026/2026-10-24--todo704-tor-cachyos-cookieauth.md b/2026/2026-10-24--todo704-tor-cachyos-cookieauth.md new file mode 100644 index 0000000..46665b5 --- /dev/null +++ b/2026/2026-10-24--todo704-tor-cachyos-cookieauth.md @@ -0,0 +1,41 @@ +# TODO=704 Tor CachyOS CTR + CookieAuth (koopa) + +Relay was down (stale PIDFile / start-timeout on forking unit), keys intact. Recreated **same identity** on CachyOS container; CookieAuth for ControlPort; host tor disabled. + +## Stand after APPLY (2026-10-24 ~21:47–21:51 Europe/Zurich) + +| Item | Value | +|------|--------| +| CTR | `koopa-tor-relay` Up, image `localhost/koopa-tor-relay:latest` (=`:cachyos`) | +| Tor | 0.4.9.14 (CachyOS/Arch extra) | +| Unit | `container-koopa-tor-relay.service` Type=simple, active | +| Host tor | `tor.service` disabled + failed/inactive | +| FP | `02C7EECCDF7814FBA2DB1CBAED000D06E8A32EE8` (keys mtime 2024-11-14) | +| ORPort | 8080 | +| ControlPort | 127.0.0.1:9051 CookieAuthentication=1, CookieAuthFile=/var/lib/tor/control_auth_cookie | +| Bootstrap | PROGRESS=100 done; status/circuit-established=1 | +| Unauth ControlPort | 514 Authentication required | +| Cookie AUTH | 250 OK | + +## Steps + +1. Dry-run `/tmp/tor.sh` on koopa → STATUS=dry-run-ok; NEXT=`sudo APPLY=1 /tmp/tor.sh` (host tor was still enabled). +2. Human: `systemctl disable --now tor.service`, then `sudo APPLY=1 /tmp/tor.sh`. +3. APPLY: wrote CachyOS Containerfile, pulled `docker.io/cachyos/cachyos:latest`, build (cache hit), `podman create --replace --network host --user 1000:1000 --userns keep-id`, torrc bak `torrc.bak-cookie-20261024-214720`, CookieAuth on. +4. First APPLY hit dbus fail under sudo (`$DBUS_SESSION_BUS_ADDRESS` / `$XDG_RUNTIME_DIR` unset). Unit rewrite + enable finished with XDG/DBUS set; script path re-exec as SUDO_USER for later runs. +5. Verify from magikoopa SSH: unit active, CTR Up, FP match, circuit-established=1, unauth 514. + +## Live paths + +- `~/koopa-tor-relay/{torrc,data,log,build/Containerfile}` +- `~/.config/systemd/user/container-koopa-tor-relay.service` +- Driver: `/tmp/tor.sh` (TARGET=koopa); upgrade later: `~/upgrade-containers.sh --apply --rebuild-local --only koopa-tor-relay` + +## Akte mirrors + +- `configs/tor/torrc` +- `configs/tor/build/Containerfile` +- `configs/systemd/user/container-koopa-tor-relay.service` +- `host/tor/README.md` updated (was stale host `tor.service`) + +Sibling: firecuda brew tor CookieAuth same day (FP `52BB94DDC1292F950CF728708AC48523E018A718`, MyFamily both ways). Parent TODO=704. diff --git a/configs/systemd/user/container-koopa-tor-relay.service b/configs/systemd/user/container-koopa-tor-relay.service new file mode 100644 index 0000000..b65e1ce --- /dev/null +++ b/configs/systemd/user/container-koopa-tor-relay.service @@ -0,0 +1,21 @@ +# container-koopa-tor-relay.service — regenerated by tor.sh 2026-10-24T21:47:24+02:00 +# Prefer simple over podman-generate-systemd forking+PIDFile (stale CID storm). + +[Unit] +Description=Podman koopa-tor-relay +Wants=network-online.target +After=network-online.target +RequiresMountsFor=%t/containers + +[Service] +Environment=PODMAN_SYSTEMD_UNIT=%n +Restart=always +RestartSec=5 +TimeoutStartSec=120 +TimeoutStopSec=70 +ExecStart=/usr/bin/podman start --attach koopa-tor-relay +ExecStop=/usr/bin/podman stop -t 10 koopa-tor-relay +Type=simple + +[Install] +WantedBy=default.target diff --git a/configs/systemd/user/dossiers-ngi-branch-status.service b/configs/systemd/user/dossiers-ngi-branch-status.service new file mode 100644 index 0000000..971c93b --- /dev/null +++ b/configs/systemd/user/dossiers-ngi-branch-status.service @@ -0,0 +1,25 @@ +[Unit] +Description=dossiers.ngi branch-status JSON + on-demand edition rebuild +Documentation=file:%h/src/koopa/koopa-admin-log/scripts/dossiers-ngi/README.md +After=default.target + +[Service] +Type=oneshot +Nice=10 +RemainAfterExit=no +# Full freezes per changed tip can take a while; skip overlap via flock +TimeoutStartSec=1h +Environment=TZ=Europe/Zurich +Environment=ADMIN_LOG=%h/src/koopa/koopa-admin-log +Environment=TDPD_ROOT=%h/git/src/ngi/tech-dossier-project-data +Environment=OUT=/var/www/monitoring-sites/dossiers.ngi.hacktivism.ch/branch-status.json +Environment=EDITIONS_ROOT=/var/www/monitoring-sites/dossiers.ngi.hacktivism.ch/editions +# Rebuild only when current tip freeze_ok is not already true (never force from timer) +Environment=REBUILD=1 +EnvironmentFile=-%h/.config/dossiers-ngi/branch-status.env +# flock -n: second fire while busy exits 0 without stacking garbage +ExecStart=/usr/bin/flock -n %t/dossiers-ngi-branch-status.lock /usr/bin/python3 %h/src/koopa/koopa-admin-log/scripts/dossiers-ngi/branch-status-hourly.py --rebuild +SuccessExitStatus=0 1 + +[Install] +WantedBy=default.target diff --git a/configs/systemd/user/dossiers-ngi-branch-status.timer b/configs/systemd/user/dossiers-ngi-branch-status.timer new file mode 100644 index 0000000..ba283a6 --- /dev/null +++ b/configs/systemd/user/dossiers-ngi-branch-status.timer @@ -0,0 +1,14 @@ +[Unit] +Description=Timer · dossiers.ngi branch-status JSON (every 10 min) +Documentation=file:%h/src/koopa/koopa-admin-log/scripts/dossiers-ngi/README.md + +[Timer] +OnBootSec=3min +OnUnitActiveSec=10min +AccuracySec=1min +RandomizedDelaySec=30 +Persistent=true +Unit=dossiers-ngi-branch-status.service + +[Install] +WantedBy=timers.target diff --git a/configs/tor/build/Containerfile b/configs/tor/build/Containerfile new file mode 100644 index 0000000..c57a137 --- /dev/null +++ b/configs/tor/build/Containerfile @@ -0,0 +1,24 @@ +# koopa-tor-relay — Tor non-exit relay on CachyOS (Arch extra/tor) +# Same pattern as koopa-meet-web:cachyos. Rootless: tor user -> uid/gid 1000. +# Recreate: --user 1000:1000 --userns=keep-id (see README / tor.sh) +ARG CACHYOS_IMAGE=docker.io/cachyos/cachyos:latest +FROM ${CACHYOS_IMAGE} +ENV TZ=Europe/Zurich +RUN pacman -Syu --noconfirm --needed tor nyx ca-certificates \ + && pacman -Scc --noconfirm \ + && rm -rf /var/cache/pacman/pkg/* /tmp/* \ + && mkdir -p /var/lib/tor /var/log/tor \ + && if id tor >/dev/null 2>&1; then \ + groupmod -g 1000 tor 2>/dev/null || true; \ + usermod -u 1000 -g 1000 tor 2>/dev/null || true; \ + elif id debian-tor >/dev/null 2>&1; then \ + groupmod -g 1000 debian-tor; usermod -u 1000 -g 1000 debian-tor; \ + else \ + groupadd -g 1000 tor; useradd -u 1000 -g 1000 -d /var/lib/tor -s /usr/bin/nologin tor; \ + fi \ + && chown -R 1000:1000 /var/lib/tor /var/log/tor \ + && chmod 700 /var/lib/tor +EXPOSE 8080 9051 +USER 1000:1000 +ENTRYPOINT ["/usr/bin/tor"] +CMD ["-f", "/etc/tor/torrc", "--runasdaemon", "0"] diff --git a/configs/tor/torrc b/configs/tor/torrc new file mode 100644 index 0000000..9f1705f --- /dev/null +++ b/configs/tor/torrc @@ -0,0 +1,18 @@ +# KoopaRelay — same policy as live /etc/tor/torrc + admin-log configs/tor/torrc +# Identity: keys in DataDirectory (migrate from host /var/lib/tor) +Log notice file /var/log/tor/notices.log +Nickname KoopaRelay +ContactInfo info+koopa@hacktivism.ch +# dual-stack: bind all addresses on 8080 (IPv4 + IPv6), same as host tor +ORPort 8080 +ControlPort 127.0.0.1:9051 +CookieAuthentication 1 +CookieAuthFile /var/lib/tor/control_auth_cookie +ExitRelay 0 +SocksPort 0 +MyFamily 52BB94DDC1292F950CF728708AC48523E018A718 +BandwidthRate 2000 MBytes +BandwidthBurst 2000 MBytes +RelayBandwidthRate 2000 MBytes +RelayBandwidthBurst 2000 MBytes +DataDirectory /var/lib/tor diff --git a/host/tor/README.md b/host/tor/README.md index 138d11d..fa68a9c 100644 --- a/host/tor/README.md +++ b/host/tor/README.md @@ -1,13 +1,30 @@ # Tor on koopa -**Runtime:** host **`tor.service`** (not a podman container). -**Ports:** ORPort **8080** · ControlPort **9051** (loopback). -**Non-exit** relay (`ExitRelay 0`). +**Runtime:** rootless podman container **`koopa-tor-relay`** (CachyOS base, Arch `extra/tor`), managed by user unit **`container-koopa-tor-relay.service`** (`Type=simple`, `podman start --attach`). +**Host `tor.service`:** disabled / must stay off (port clash on ORPort 8080 + ControlPort 9051). +**Ports:** ORPort **8080** · ControlPort **127.0.0.1:9051** (CookieAuthentication + CookieAuthFile). +**Non-exit** relay (`ExitRelay 0`). Nickname **KoopaRelay**. +**Fingerprint (stable keys under `~/koopa-tor-relay/data/keys/`):** `02C7EECCDF7814FBA2DB1CBAED000D06E8A32EE8` +**Family:** firecuda FireCudaRelay `52BB94DDC1292F950CF728708AC48523E018A718` + +Live tree: `~/koopa-tor-relay/` (`torrc`, `data/`, `log/`, `build/Containerfile`). +Mirrors in this repo: `configs/tor/torrc`, `configs/tor/build/Containerfile`, `configs/systemd/user/container-koopa-tor-relay.service`. ```bash -systemctl status tor -ss -lntp | grep -E '8080|9051' +# status +systemctl --user status container-koopa-tor-relay.service +podman ps --filter name=koopa-tor-relay +# ControlPort cookie auth (hex) +COOKIE=$(xxd -p -c 256 ~/koopa-tor-relay/data/control_auth_cookie) +printf 'AUTHENTICATE %s\r\nGETINFO fingerprint\r\nGETINFO status/circuit-established\r\nQUIT\r\n' "$COOKIE" | nc -w 3 127.0.0.1 9051 ``` -login-health: WARN if `tor.service` not active. -Legacy `koopa-tor-relay` / `configs/tor/` removed from live inventory (git history keeps them). +Apply / recreate (after host tor off): + +```bash +sudo APPLY=1 /tmp/tor.sh +# later upgrades: +~/upgrade-containers.sh --apply --rebuild-local --only koopa-tor-relay +``` + +login-health: expect user unit active + CTR Up; host `tor.service` inactive. Parent TODO=704. diff --git a/scripts/caddy/README.md b/scripts/caddy/README.md index 4d1fc8c..ae4921d 100644 --- a/scripts/caddy/README.md +++ b/scripts/caddy/README.md @@ -41,6 +41,10 @@ Host runtime: `/etc/caddy/secrets/dossiers-ngi-basicauth.txt` (root-only). SoT: `koopa-admin-secrets-enc` (git-crypt) → `koopa/home-hernani/caddy/`. Hosts: `dossiers.ngi` / `dossiers.2.ngi` / `dossiers.3.ngi`.hacktivism.ch +HTTP→HTTPS 308 for `dossiers.ngi` (add host to the shared ACME list, leave +HTTPS body alone): `scripts/dossiers-ngi/apply-dossiers-ngi-http-308.sh` +then `sudo ~/bin/caddy-apply`. + ## silverbullet.hacktivism.ch — merge + basic_auth After podman cutover (`scripts/notes/cutover-to-silverbullet.sh --apply`) and DNS: diff --git a/scripts/dossiers-ngi/README.md b/scripts/dossiers-ngi/README.md new file mode 100644 index 0000000..6b9b65c --- /dev/null +++ b/scripts/dossiers-ngi/README.md @@ -0,0 +1,165 @@ +# dossiers-ngi — dynamic edition bar + 10‑min rebuild + +SoT for the sticky edition bar on **dossiers.ngi.hacktivism.ch**. Live freezes +sit under `editions//` on koopa; cookie `tdpd_edition` selects the build. +The bar renders a **semi-graphic parent→children tree** from +`/branch-status.json` (all `origin/*` tips, not a hard-coded ladder). + +| Path | Role | +|------|------| +| `sites/…/_cascade/` | Sticky bar shell + JS tree renderer | +| `editions/` on koopa www | Full freezes switched by the bar | +| `sites/…/cascade/` | Legacy tip stubs (optional) | +| `scripts/dossiers-ngi/branch-status-hourly.py` | Fetch remotes → tree JSON → on-demand rebuild | +| `scripts/dossiers-ngi/smoke-branch-tree.sh` | Temp-branch smoke (creates/deletes local origin only) | +| `configs/systemd/user/dossiers-ngi-branch-status.{timer,service}` | Every **10 min** oneshot on koopa (`flock` against overlap) | + +## Env + +| Var | Default | Meaning | +|-----|---------|---------| +| `TDPD_ROOT` | `$HOME/git/src/ngi/tech-dossier-project-data` | tdpd git checkout (may be git-dir only) | +| `TDPD_VENV` | `(TDPD_ROOT)/.venv` | Frozen-Flask venv; symlinked into worktrees | +| `OUT` | kal `sites/…/branch-status.json` | write path (live www on koopa) | +| `EDITIONS_ROOT` | (none) | `…/editions` — required for `--rebuild` | +| `REBUILD` / `--rebuild` | off | rebuild when `.edition-tip` ≠ tip | +| `FORCE_REBUILD` / `--force` | off | rebuild every branch (timing) | +| `FETCH` / `--no-fetch` | fetch on | `git fetch --prune origin` | +| `FREEZE_CMD` | (make/venv freeze) | stub for smoke tests; `{root}` `{branch}` `{slug}` | +| `DEFAULT_BRANCH` | `feat/website-views` | behind cookie `flask` | +| `HOST` | `https://dossiers.ngi.hacktivism.ch` | public host in JSON | + +On koopa: `%h/src/koopa/koopa-admin-log` + +`~/.config/dossiers-ngi/branch-status.env` for path overrides. +NGI clone token lives in **koopa-admin-secrets-enc** (not this tree). + +## Manual run + +```bash +# JSON only +python3 scripts/dossiers-ngi/branch-status-hourly.py + +# rebuild stale freezes + timing block in JSON +EDITIONS_ROOT=/var/www/monitoring-sites/dossiers.ngi.hacktivism.ch/editions \ + python3 scripts/dossiers-ngi/branch-status-hourly.py --rebuild + +# force all branches (wall clock → rebuild_timing) +… --rebuild --force +``` + +`rebuild_timing` fields: `wall_seconds`, `built`, `skipped`, `errors`, +`built_seconds_sum`, `built_seconds_max`. Per-branch `rebuild[name].seconds`. + +Per branch in JSON / sticky bar: + +| Field | Meaning | +|-------|---------| +| `tip` | short SHA | +| `tip_time` | committer time of tip (`%cI`) | +| `built_at` | when this edition freeze was written | +| `build_seconds` | wall time of that freeze | +| `freeze_ok` | `true` when the current origin tip freeze succeeded (sticky bar green ✓); `false` when it failed, is a placeholder, or the recorded tip is stale (red ✗) | +| `freeze_error` | last freeze error text (only when not ok) | +| `vs_parent_plus` / `vs_parent_minus` | `git diff --numstat` tip vs **parent** tip (sticky bar +grün/−rot; omitted when `parent` is empty or not in the tree, e.g. `main` and `feature/nl-net-injestion-injestor-update`) | +| `loc` | text-file line count of the tip vs the empty tree (gray `N LOC` in the plus/minus columns; only when there is no parent) | + +Rebuild runs **only when** the current origin tip is not already a successful freeze (`freeze_ok`; timer never forces). Cookie alias `editions/flask` copies from `DEFAULT_BRANCH` unless that alias is already `freeze_ok` for the same tip **and** `.edition-built-at` matches the source freeze. +Each run also: `git fetch --prune`, `worktree prune`, drop stale +`editions//` (gone remotes), purge leftover `tdpd-*` tmp dirs. + +## Smoke + +```bash +bash scripts/dossiers-ngi/smoke-branch-tree.sh +# → SMOKE_OK (temp origin + smoke/temp-sibling deleted) +``` + +## Edition freeze missing the bar (cookie trap) + +`feat/website-views` freeze can omit `{% include "_cascade/bar.html" %}`. +Cookie `tdpd_edition` then has no switcher. **No zypper / Caddy plugin.** +Way back: `https://dossiers.ngi.hacktivism.ch/_cascade/use/flask` +or `/_cascade/` (`index.html` switcher). + +On koopa (hernani, no root): + +```bash +bash scripts/dossiers-ngi/inject-editions-bar.sh +``` + +Hourly rebuild injects on skip **and** freeze-error (keep existing HTML + include). + +The bar is `position: fixed` (not sticky); `100vw` overflow is avoided. + +## Deploy landing (Freigabe) + +Do **not** `--delete` the whole www root. Sync: + +```bash +SITE="$HOME/git/admin-logs/local/koopa-admin-log/sites/dossiers.ngi.hacktivism.ch" +WWW=hernani@koopa:/var/www/monitoring-sites/dossiers.ngi.hacktivism.ch +rsync -avz --exclude .DS_Store "$SITE/_cascade/" "$WWW/_cascade/" +rsync -avz --exclude .DS_Store "$SITE/branch-status.json" "$WWW/branch-status.json" +# editions/ from a --rebuild run +``` + +## Sticky cascade bar + +`bar.js` fetches `/branch-status.json` and draws: + +``` +feature/nl-net-injestion-injestor-update … N LOC +main … N LOC +├── feat/sm-content … +N −M # vs main +│ └── feat/website-features … +N −M # vs sm-content +└── project_template … +N −M # vs main +``` + +Visible cells: freeze mark (green ✓ / red ✗) · name (tree prefix + branch) · tip `monDD HH:MM` · `Ns monDD HH:MM` (build seconds + freeze time) · plus column · minus column. `+N −M` vs the **parent** tip, in two right-aligned columns so `+898` and `+1200` share an edge. Roots with no parent show gray `N` in the plus column and `LOC` in the minus column. No `@sha`, no `default` label (`data-default` stays for flask/cookie). Inner tree `max-width: 45rem` (same as site `#navigation` / `#content` 45em at root 16px), so names line up with About and the minus column with the page edge; below 45rem the tree is full width with a small pad. Rear cells are right-aligned `tabular-nums`. Poll every 30s **updates mark/when/built/plus/minus in place** when the branch structure is unchanged (no full tree remount). + +Each name links to `/_cascade/use/` (cookie + 302 `/`). Caddy map: + +``` +~^[A-Za-z0-9_-]+$ {http.request.cookie.tdpd_edition} +``` + +Also serve `/cascade/*` from the site www root (path stubs). Live Caddy must not use a trailing `~.* flask` catch-all. + +Edition purity: responses send `Vary: Cookie` and `Cache-Control: private, no-cache` so `/static/css/style.css` (same path, different bodies per edition) cannot stick the green design CSS onto `main` after a views visit. + +so new branches need no Caddy edit — only an `editions//` tree. + +```bash +bash ~/bin/apply-dossiers-ngi-edition-switch.sh +sudo ~/bin/caddy-apply +``` + +Stanza SoT: `scripts/dossiers-ngi/caddy-dossiers-ngi.snippet`. + +## HTTP → HTTPS 308 + +Shared Caddy HTTP list already `redir https://{host}{uri} permanent` (308). +`dossiers.ngi.hacktivism.ch` was missing there → HTTP `200` empty body / +Firefox `NS_ERROR_NET_EMPTY_RESPONSE`. HTTPS site body stays untouched. + +On koopa (hernani, then root apply): + +```bash +# SoT: scripts/dossiers-ngi/apply-dossiers-ngi-http-308.sh +cp /path/to/apply-dossiers-ngi-http-308.sh ~/bin/ +chmod +x ~/bin/apply-dossiers-ngi-http-308.sh +bash ~/bin/apply-dossiers-ngi-http-308.sh +sudo ~/bin/caddy-apply +``` + +Smoke after apply: + +```bash +curl -sSI http://dossiers.ngi.hacktivism.ch/ | grep -Ei 'HTTP|location' +# 308 Location: https://dossiers.ngi.hacktivism.ch/ +``` + +`.2` / `.3` stay off this list unless `SITE=` is set. + + +Caddy edition map must list each `editions/` explicitly. A map output of `{http.request.cookie.tdpd_edition}` did not expand on Caddy v2.11.4 (only `flask` worked → 404 for `main`/views). After new tips appear, refresh the snippet map (or regenerate from `ls editions`) before `apply-dossiers-ngi-edition-switch` + `caddy-apply`. diff --git a/scripts/dossiers-ngi/apply-dossiers-ngi-edition-switch.sh b/scripts/dossiers-ngi/apply-dossiers-ngi-edition-switch.sh new file mode 100755 index 0000000..ea043c3 --- /dev/null +++ b/scripts/dossiers-ngi/apply-dossiers-ngi-edition-switch.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Host-local on koopa. Replaces dossiers.ngi.hacktivism.ch site-block body +# with cookie→editions sticky-bar switch. Leaves .2/.3 alone. +# Next: sudo ~/bin/caddy-apply +set -euo pipefail + +CADDY="${CADDY:-$HOME/koopa-caddy/Caddyfile}" +SITE="dossiers.ngi.hacktivism.ch" +SNIPPET="${SNIPPET:-$HOME/bin/caddy-dossiers-ngi.snippet}" + +if [[ ! -f "$CADDY" ]]; then + echo "FAIL: missing $CADDY" >&2 + exit 1 +fi +if [[ ! -f "$SNIPPET" ]]; then + echo "FAIL: missing snippet $SNIPPET" >&2 + exit 1 +fi + +ts="$(date +%Y%m%d-%H%M%S)" +bak="${CADDY}.bak-dossiers-edition-${ts}" +cp -a "$CADDY" "$bak" +echo "bak: $bak" + +python3 - "$CADDY" "$SITE" "$SNIPPET" <<'PY' +import sys +from pathlib import Path + +caddy_path, site, snippet_path = Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3]) +text = caddy_path.read_text() +raw_snip = snippet_path.read_text().splitlines() +body_lines = [] +for line in raw_snip: + if line.startswith("#") or not line.strip(): + continue + body_lines.append(line if line.startswith("\t") else "\t" + line.lstrip()) +body = "\n".join(body_lines).rstrip() + "\n" +if "edition_dir" not in body or "tdpd_edition" not in body: + print("FAIL: snippet body looks empty/wrong", file=sys.stderr) + sys.exit(1) + +needle = site + " {" +start = text.find(needle) +if start < 0: + # allow no space + needle = site + "{" + start = text.find(needle) +if start < 0: + print(f"FAIL: site block not found: {site}", file=sys.stderr) + sys.exit(1) + +brace_open = text.find("{", start) +if brace_open < 0: + print("FAIL: no opening brace", file=sys.stderr) + sys.exit(1) + +depth = 0 +end = None +for i, ch in enumerate(text[brace_open:], start=brace_open): + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + end = i + break +if end is None: + print("FAIL: unclosed site block", file=sys.stderr) + sys.exit(1) + +# keep optional newline after closing brace +close_end = end + 1 +if close_end < len(text) and text[close_end] == "\n": + close_end += 1 + +new_text = text[: brace_open + 1] + "\n" + body + "}" + ("\n" if text[end+1:end+2] == "\n" or True else "") +# normalize: site {\n BODY }\n +new_text = text[: brace_open + 1] + "\n" + body + "}\n" + text[close_end:] +caddy_path.write_text(new_text) +print(f"OK: replaced body of {site} ({len(body_lines)} lines)") +print("Next: sudo ~/bin/caddy-apply") +PY diff --git a/scripts/dossiers-ngi/branch-status-hourly.py b/scripts/dossiers-ngi/branch-status-hourly.py new file mode 100755 index 0000000..8cf6433 --- /dev/null +++ b/scripts/dossiers-ngi/branch-status-hourly.py @@ -0,0 +1,764 @@ +#!/usr/bin/env python3 +"""Discover all origin branches, emit branch-status.json, optional edition rebuild. + +Dynamic cascade for dossiers.ngi.hacktivism.ch: + + • every origin/* tip (not a hard-coded ladder) + • parent = nearest other tip that is a git ancestor + • sticky bar reads this JSON and draws a semi-graphic tree + • optional --rebuild / REBUILD=1: worktree + make html into editions// + only when .edition-tip differs (on demand; timer ~10min) + +Env: + + TDPD_ROOT tech-dossier-project-data checkout (may be git-dir only) + OUT branch-status.json path (live www on koopa) + EDITIONS_ROOT editions/ dir on www (required for rebuild) + REBUILD 1 → rebuild stale/missing freezes + FETCH 0 → skip git fetch (default 1) + FREEZE_CMD override build (default: make html); `{root}` `{branch}` `{slug}` + DEFAULT_BRANCH branch behind cookie flask (default feat/website-views) + HOST public host URL +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +from datetime import datetime, timezone +from pathlib import Path + +ADMIN_LOG = Path(__file__).resolve().parents[2] +_DEFAULT_OUT = ADMIN_LOG / "sites" / "dossiers.ngi.hacktivism.ch" / "branch-status.json" +_PLACEHOLDER = ( + ADMIN_LOG / "sites" / "dossiers.ngi.hacktivism.ch" / "_edition-placeholder" / "index.html" +) +HOST_DEFAULT = "https://dossiers.ngi.hacktivism.ch" +DEFAULT_TDPD = Path.home() / "git" / "src" / "ngi" / "tech-dossier-project-data" +INJECT = Path(__file__).resolve().parent / "inject-cascade-bar.py" +SLUG_RE = re.compile(r"[^A-Za-z0-9_-]+") + + +def env_path(name: str, default: Path | None = None) -> Path | None: + raw = os.environ.get(name, "").strip() + if raw: + return Path(raw).expanduser() + return default + + +def out_path() -> Path: + return env_path("OUT", _DEFAULT_OUT) # type: ignore[return-value] + + +def tdpd_root() -> Path: + return env_path("TDPD_ROOT", DEFAULT_TDPD) # type: ignore[return-value] + + +def editions_root() -> Path | None: + return env_path("EDITIONS_ROOT", None) + + +def host() -> str: + return os.environ.get("HOST", "").strip() or HOST_DEFAULT + + +def default_branch() -> str: + return os.environ.get("DEFAULT_BRANCH", "").strip() or "feat/website-views" + + +def edition_slug(branch: str) -> str: + return SLUG_RE.sub("-", branch.replace("/", "-")).strip("-") or "branch" + + +def git(repo: Path, *args: str, check: bool = True, text: bool = True) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", "-C", str(repo), *args], + check=check, + text=text, + capture_output=True, + ) + + +def fetch_origin(repo: Path) -> None: + if os.environ.get("FETCH", "1").strip() == "0": + return + subprocess.run( + ["git", "-C", str(repo), "fetch", "--prune", "origin"], + check=False, + capture_output=True, + text=True, + ) + + +def list_origin_branches(repo: Path) -> dict[str, str]: + """branch name → full SHA for refs/remotes/origin/* (no HEAD).""" + cp = git(repo, "for-each-ref", "--format=%(refname:short)|%(objectname)", "refs/remotes/origin") + out: dict[str, str] = {} + for line in cp.stdout.splitlines(): + if "|" not in line: + continue + short, sha = line.split("|", 1) + if short in ("origin", "origin/HEAD") or "->" in short: + continue + if not short.startswith("origin/"): + continue + name = short[len("origin/") :] + if name == "HEAD": + continue + out[name] = sha + return out + + +def is_ancestor(repo: Path, anc: str, desc: str) -> bool: + cp = git(repo, "merge-base", "--is-ancestor", anc, desc, check=False) + return cp.returncode == 0 + + +def commits_between(repo: Path, a: str, b: str) -> int: + cp = git(repo, "rev-list", "--count", f"{a}..{b}", check=False) + if cp.returncode != 0: + return 10**9 + try: + return int(cp.stdout.strip() or "0") + except ValueError: + return 10**9 + + +def short_sha(sha: str) -> str: + return sha[:7] if sha else "" + + +def nearest_parents(repo: Path, tips: dict[str, str]) -> dict[str, str]: + """For each branch, nearest other tip that is a strict ancestor.""" + parents: dict[str, str] = {} + names = list(tips) + for b in names: + tip_b = tips[b] + cands: list[tuple[int, str]] = [] + for a in names: + if a == b: + continue + tip_a = tips[a] + if tip_a == tip_b: + continue + if not is_ancestor(repo, tip_a, tip_b): + continue + cands.append((commits_between(repo, tip_a, tip_b), a)) + parents[b] = min(cands)[1] if cands else "" + return parents + + +def children_map(parents: dict[str, str]) -> dict[str, list[str]]: + kids: dict[str, list[str]] = {b: [] for b in parents} + orphans: list[str] = [] + for b, p in parents.items(): + if p and p in kids: + kids[p].append(b) + else: + orphans.append(b) + for p in kids: + kids[p].sort() + orphans.sort() + # roots = orphans (parent missing or empty) + kids[""] = orphans + return kids + + +def walk_tree( + parents: dict[str, str], + tips: dict[str, str], +) -> list[dict]: + """Flat rows in DFS order with semi-graphic prefix fields.""" + kids = children_map(parents) + rows: list[dict] = [] + + def walk(name: str, prefix: str, is_last: bool, is_root: bool) -> None: + tip = tips[name] + if is_root: + branch_prefix = "" + child_prefix = "" + else: + branch_prefix = prefix + ("└── " if is_last else "├── ") + child_prefix = prefix + (" " if is_last else "│ ") + rows.append( + { + "branch": name, + "edition": edition_slug(name), + "tip": short_sha(tip), + "tip_full": tip, + "parent": parents.get(name, ""), + "prefix": branch_prefix, + "url": f"/_cascade/use/{edition_slug(name)}", + } + ) + ch = kids.get(name, []) + for i, c in enumerate(ch): + walk(c, child_prefix, i == len(ch) - 1, False) + + roots = kids.get("", []) + for i, r in enumerate(roots): + walk(r, "", i == len(roots) - 1, True) + return rows + + +def ensure_placeholder(editions: Path, slug: str, tip: str) -> None: + dest = editions / slug + dest.mkdir(parents=True, exist_ok=True) + idx = dest / "index.html" + if not idx.exists(): + src = _PLACEHOLDER + if src.is_file(): + idx.write_text(src.read_text(encoding="utf-8"), encoding="utf-8") + else: + idx.write_text( + "" + '{% include "_cascade/bar.html" . %}' + "

edition pending

\n", + encoding="utf-8", + ) + tip_path = dest / ".edition-tip" + if not tip_path.exists(): + tip_path.write_text(tip + "\n", encoding="utf-8") + + +def tip_recorded(editions: Path, slug: str) -> str: + p = editions / slug / ".edition-tip" + if not p.is_file(): + return "" + return p.read_text(encoding="utf-8").strip() + + + +def diff_numstat(repo: Path, a: str, b: str) -> tuple[int, int]: + """Insertions/deletions of b vs a (git diff --numstat).""" + if not a or not b or a == b: + return 0, 0 + cp = git(repo, "diff", "--numstat", a, b, check=False) + if cp.returncode != 0: + return 0, 0 + plus = minus = 0 + for line in (cp.stdout or "").splitlines(): + parts = line.split(" ") + if len(parts) < 3: + continue + a, d = parts[0], parts[1] + if a == "-" or d == "-": + continue + try: + plus += int(a) + minus += int(d) + except ValueError: + continue + return plus, minus + + +def empty_tree_oid(repo: Path) -> str: + cp = git(repo, "hash-object", "-t", "tree", "/dev/null") + return (cp.stdout or "").strip() + + +def loc_at_tip(repo: Path, tip: str, empty: str) -> int: + """Text-file line count of tip vs the empty tree.""" + plus, _ = diff_numstat(repo, empty, tip) + return plus + + +def tip_commit_time(repo: Path, tip: str) -> str: + """Committer ISO time of tip (empty on failure).""" + cp = git(repo, "log", "-1", "--format=%cI", tip, check=False) + if cp.returncode != 0: + return "" + return (cp.stdout or "").strip() + + +def read_build_meta(editions: Path, slug: str) -> dict: + dest = editions / slug + out: dict = {} + at = dest / ".edition-built-at" + sec = dest / ".edition-build-seconds" + if at.is_file(): + out["built_at"] = at.read_text(encoding="utf-8").strip() + if sec.is_file(): + raw = sec.read_text(encoding="utf-8").strip() + try: + out["build_seconds"] = float(raw) + except ValueError: + pass + return out + + +def write_build_meta(editions: Path, slug: str, seconds: float) -> None: + dest = editions / slug + dest.mkdir(parents=True, exist_ok=True) + now = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") + (dest / ".edition-built-at").write_text(now + "\n", encoding="utf-8") + (dest / ".edition-build-seconds").write_text(f"{seconds:.3f}\n", encoding="utf-8") + + +def write_freeze_status(editions: Path, slug: str, ok: bool, error: str = "") -> None: + dest = editions / slug + dest.mkdir(parents=True, exist_ok=True) + (dest / ".edition-freeze-ok").write_text("1\n" if ok else "0\n", encoding="utf-8") + errp = dest / ".edition-freeze-error" + if ok or not error: + if errp.exists(): + errp.unlink() + return + errp.write_text(error[:500] + "\n", encoding="utf-8") + + +def freeze_ok_for(editions: Path | None, slug: str, tip_full: str) -> tuple[bool, str]: + """Current origin tip freeze succeeded (green ✓) or not (red ✗).""" + if editions is None or not tip_full: + return False, "" + dest = editions / slug + err = "" + errp = dest / ".edition-freeze-error" + if errp.is_file(): + err = errp.read_text(encoding="utf-8").strip() + recorded = tip_recorded(editions, slug) + okp = dest / ".edition-freeze-ok" + if okp.is_file(): + file_ok = okp.read_text(encoding="utf-8").strip().lower() in ( + "1", + "true", + "ok", + "yes", + ) + else: + file_ok = (dest / ".edition-built-at").is_file() + ok = bool(file_ok and recorded == tip_full and (dest / "index.html").is_file()) + return ok, err + + +def cleanup_stale_editions(editions: Path, tips: dict[str, str]) -> list[str]: + """Drop edition dirs whose branch tip is gone (keep flask alias).""" + keep = {edition_slug(b) for b in tips} | {"flask"} + removed: list[str] = [] + if not editions.is_dir(): + return removed + for child in list(editions.iterdir()): + if not child.is_dir(): + continue + if child.name in keep: + continue + shutil.rmtree(child, ignore_errors=True) + removed.append(child.name) + return removed + + +def cleanup_orphan_tmps(max_age_sec: int = 3600) -> int: + """Remove leftover tdpd-* workdirs older than max_age_sec.""" + bases: list[Path] = [] + home_tmp = Path.home() / "tmp" + if home_tmp.is_dir(): + bases.append(home_tmp) + bases.append(Path(tempfile.gettempdir())) + cutoff = time.time() - max_age_sec + n = 0 + for base in bases: + try: + kids = list(base.glob("tdpd-*")) + except OSError: + continue + for path in kids: + if not path.is_dir(): + continue + try: + if path.stat().st_mtime >= cutoff: + continue + except OSError: + continue + shutil.rmtree(path, ignore_errors=True) + n += 1 + return n + + +def cleanup_repo(repo: Path) -> None: + git(repo, "worktree", "prune", check=False) + + +def _attach_venv(work: Path) -> None: + """Worktrees lack .venv; reuse TDPD_VENV or sibling checkout .venv via symlink.""" + if (work / ".venv" / "bin" / "python").exists(): + return + candidates: list[Path] = [] + raw = os.environ.get("TDPD_VENV", "").strip() + if raw: + candidates.append(Path(raw).expanduser()) + root = tdpd_root() + candidates.append(root / ".venv") + # laptop SoT checkout even when TDPD_ROOT is git-dir-only on koopa + candidates.append(DEFAULT_TDPD / ".venv") + for venv in candidates: + py = venv / "bin" / "python" + if py.is_file(): + dest = work / ".venv" + if dest.exists() or dest.is_symlink(): + return + dest.symlink_to(venv.resolve()) + return + + +def run_freeze(work: Path, branch: str, slug: str) -> None: + tmpl = os.environ.get("FREEZE_CMD", "").strip() + env = os.environ.copy() + env["TDPD_BRANCH"] = branch + env["TDPD_SLUG"] = slug + if tmpl: + cmd = tmpl.format(root=str(work), branch=branch, slug=slug) + subprocess.run(cmd, shell=True, check=True, cwd=str(work), env=env) + return + _attach_venv(work) + # Older branches hardcode `python3 -m website.freeze` without .venv; + # prefer the attached venv interpreter directly. + py = work / ".venv" / "bin" / "python" + freeze_mod = work / "website" / "freeze.py" + if py.is_file() and freeze_mod.is_file(): + build = work / "website" / "build" + if build.exists(): + shutil.rmtree(build) + subprocess.run( + [str(py), "-m", "website.freeze"], + check=True, + cwd=str(work), + env=env, + ) + return + makefile = work / "Makefile" + if not makefile.is_file(): + raise FileNotFoundError(f"no Makefile in {work}") + subprocess.run(["make", "html"], check=True, cwd=str(work), env=env) + + +def inject_bar(edition_dir: Path) -> None: + if not INJECT.is_file(): + print(f"WARN: inject script missing: {INJECT}", file=sys.stderr) + return + cp = subprocess.run( + [sys.executable, str(INJECT), str(edition_dir)], + check=False, + capture_output=True, + text=True, + ) + if cp.stdout: + print(cp.stdout, end="" if str(cp.stdout).endswith("\n") else "\n", file=sys.stderr) + if cp.returncode != 0: + err = (cp.stderr or "").strip()[:300] + print(f"WARN: inject_bar {edition_dir}: {err}", file=sys.stderr) + + +def rebuild_edition(repo: Path, branch: str, tip: str, editions: Path) -> dict: + """Return {status, seconds} — status skipped|built|placeholder|error:…""" + slug = edition_slug(branch) + dest = editions / slug + t0 = time.perf_counter() + force = os.environ.get("FORCE_REBUILD", "").strip() in ("1", "true", "yes") + if not force and freeze_ok_for(editions, slug, tip)[0]: + inject_bar(dest) + if not (dest / ".edition-freeze-ok").is_file(): + write_freeze_status(editions, slug, True) + return {"status": "skipped", "seconds": round(time.perf_counter() - t0, 3)} + + wt: Path | None = None + try: + tmp_base = Path.home() / "tmp" if (Path.home() / "tmp").is_dir() else Path(tempfile.gettempdir()) + wt = Path(tempfile.mkdtemp(prefix=f"tdpd-{slug}-", dir=str(tmp_base))) + cp = git(repo, "worktree", "add", "--detach", str(wt), tip, check=False) + if cp.returncode != 0: + git(repo, "worktree", "prune", check=False) + cp = git(repo, "worktree", "add", "--detach", str(wt), tip, check=False) + if cp.returncode != 0: + st = f"error:worktree:{cp.stderr.strip()[:200]}" + ensure_placeholder(editions, slug, tip) + write_freeze_status(editions, slug, False, st) + inject_bar(dest) + return { + "status": st, + "seconds": round(time.perf_counter() - t0, 3), + } + + run_freeze(wt, branch, slug) + build = wt / "website" / "build" + if not build.is_dir(): + ensure_placeholder(editions, slug, tip) + write_freeze_status(editions, slug, False, "error:no-build") + inject_bar(dest) + return {"status": "error:no-build", "seconds": round(time.perf_counter() - t0, 3)} + + dest.mkdir(parents=True, exist_ok=True) + keep_meta = { + ".edition-tip", + ".edition-built-at", + ".edition-build-seconds", + ".edition-freeze-ok", + ".edition-freeze-error", + } + for child in list(dest.iterdir()): + if child.name in keep_meta: + continue + if child.is_dir(): + shutil.rmtree(child) + else: + child.unlink() + for child in build.iterdir(): + target = dest / child.name + if child.is_dir(): + shutil.copytree(child, target) + else: + shutil.copy2(child, target) + secs = round(time.perf_counter() - t0, 3) + (dest / ".edition-tip").write_text(tip + "\n", encoding="utf-8") + write_build_meta(editions, slug, secs) + write_freeze_status(editions, slug, True) + inject_bar(dest) + return {"status": "built", "seconds": secs} + except Exception as exc: # noqa: BLE001 — hourly must continue + st = f"error:{type(exc).__name__}:{exc}" + ensure_placeholder(editions, slug, tip) + write_freeze_status(editions, slug, False, st) + inject_bar(dest) + return { + "status": st, + "seconds": round(time.perf_counter() - t0, 3), + } + finally: + if wt is not None: + git(repo, "worktree", "remove", "--force", str(wt), check=False) + shutil.rmtree(wt, ignore_errors=True) + + +def _edition_built_at(editions: Path, slug: str) -> str: + p = editions / slug / ".edition-built-at" + if not p.is_file(): + return "" + return p.read_text(encoding="utf-8").strip() + + +def sync_flask_alias(editions: Path, tips: dict[str, str]) -> None: + """Keep editions/flask in sync with DEFAULT_BRANCH freeze when present.""" + src_branch = default_branch() + if src_branch not in tips: + return + src_slug = edition_slug(src_branch) + src = editions / src_slug + dst = editions / "flask" + tip = tips[src_branch] + flask_ok, _ = freeze_ok_for(editions, "flask", tip) + src_built = _edition_built_at(editions, src_slug) + flask_built = _edition_built_at(editions, "flask") + if flask_ok and src_built and src_built == flask_built: + return + if not (src / "index.html").is_file(): + ensure_placeholder(editions, "flask", tip) + return + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(src, dst) + (dst / ".edition-tip").write_text(tip + "\n", encoding="utf-8") + # reuse src build meta if present + for name in ( + ".edition-built-at", + ".edition-build-seconds", + ".edition-freeze-ok", + ".edition-freeze-error", + ): + s = src / name + if s.is_file(): + shutil.copy2(s, dst / name) + else: + p = dst / name + if p.exists(): + p.unlink() + inject_bar(dst) + + +def build_payload(repo: Path, tips: dict[str, str], rebuild: bool) -> dict: + parents = nearest_parents(repo, tips) + rows = walk_tree(parents, tips) + rebuild_status: dict[str, dict] = {} + rebuild_timing: dict | None = None + ed_root = editions_root() + cleanup_repo(repo) + n_tmp = cleanup_orphan_tmps() + if n_tmp: + print(f"cleanup_tmp removed={n_tmp}", file=sys.stderr) + if ed_root is not None: + ed_root.mkdir(parents=True, exist_ok=True) + removed = cleanup_stale_editions(ed_root, tips) + if removed: + print(f"cleanup_editions removed={removed}", file=sys.stderr) + if rebuild and ed_root is not None: + wall0 = time.perf_counter() + for name, tip in sorted(tips.items()): + # rebuild_edition itself skips when .edition-tip matches tip + rebuild_status[name] = rebuild_edition(repo, name, tip, ed_root) + sync_flask_alias(ed_root, tips) + cleanup_repo(repo) + cleanup_orphan_tmps(max_age_sec=600) + wall = round(time.perf_counter() - wall0, 3) + built_secs = [ + v["seconds"] + for v in rebuild_status.values() + if v.get("status") == "built" + ] + rebuild_timing = { + "wall_seconds": wall, + "branches": len(tips), + "built": sum(1 for v in rebuild_status.values() if v.get("status") == "built"), + "skipped": sum(1 for v in rebuild_status.values() if v.get("status") == "skipped"), + "errors": sum( + 1 for v in rebuild_status.values() if str(v.get("status", "")).startswith("error") + ), + "built_seconds_sum": round(sum(built_secs), 3) if built_secs else 0.0, + "built_seconds_max": round(max(built_secs), 3) if built_secs else 0.0, + } + print( + f"REBUILD_TIMING wall_seconds={wall} built={rebuild_timing['built']} " + f"skipped={rebuild_timing['skipped']} errors={rebuild_timing['errors']} " + f"built_seconds_sum={rebuild_timing['built_seconds_sum']} " + f"built_seconds_max={rebuild_timing['built_seconds_max']}", + file=sys.stderr, + ) + elif ed_root is not None: + ed_root.mkdir(parents=True, exist_ok=True) + for name, tip in tips.items(): + slug = edition_slug(name) + if not (ed_root / slug / "index.html").is_file(): + ensure_placeholder(ed_root, slug, tip) + write_freeze_status(ed_root, slug, False, "placeholder") + rebuild_status[name] = {"status": "placeholder", "seconds": 0.0} + if not (ed_root / "flask" / "index.html").is_file(): + sync_flask_alias(ed_root, tips) + + tip_times = {name: tip_commit_time(repo, tip) for name, tip in tips.items()} + empty = empty_tree_oid(repo) + branches_out = [] + for row in rows: + name = row["branch"] + slug = row["edition"] + meta = read_build_meta(ed_root, slug) if ed_root is not None else {} + # prefer this-run built seconds when present + rb = rebuild_status.get(name) or {} + if rb.get("status") == "built" and "seconds" in rb: + meta = { + **meta, + "build_seconds": rb["seconds"], + } + entry = { + "branch": name, + "edition": slug, + "tip": row["tip"], + "tip_time": tip_times.get(name, ""), + "parent": row["parent"], + "prefix": row["prefix"], + "url": row["url"], + } + if meta.get("built_at"): + entry["built_at"] = meta["built_at"] + if "build_seconds" in meta: + entry["build_seconds"] = meta["build_seconds"] + freeze_ok, freeze_err = freeze_ok_for(ed_root, slug, tips[name]) + st = str(rb.get("status") or "") + if st == "built": + freeze_ok, freeze_err = True, "" + elif st.startswith("error") or st == "placeholder": + freeze_ok, freeze_err = False, st + entry["freeze_ok"] = freeze_ok + if freeze_err: + entry["freeze_error"] = freeze_err + # sticky bar: +grün/−rot vs parent tip; gray loc when no parent in tree + parent = row["parent"] + if parent and parent in tips: + plus, minus = diff_numstat(repo, tips[parent], row["tip"]) + entry["vs_parent_plus"] = plus + entry["vs_parent_minus"] = minus + else: + entry["loc"] = loc_at_tip(repo, row["tip"], empty) + branches_out.append(entry) + + payload = { + "generated": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"), + "host": host(), + "default_branch": default_branch(), + "default_edition": "flask", + "branches": branches_out, + } + if rebuild_status: + payload["rebuild"] = rebuild_status + if rebuild_timing is not None: + payload["rebuild_timing"] = rebuild_timing + return payload + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument( + "--rebuild", + action="store_true", + help="rebuild stale editions into EDITIONS_ROOT", + ) + p.add_argument( + "--force", + action="store_true", + help="rebuild every edition even when .edition-tip matches (timing)", + ) + p.add_argument( + "--no-fetch", + action="store_true", + help="skip git fetch --prune origin", + ) + return p.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + repo = tdpd_root() + if not (repo / ".git").exists() and not (repo / "HEAD").exists(): + # allow bare / gitfile + gitfile = repo / ".git" + if not repo.is_dir(): + print(f"TDPD_ROOT missing or not a dir: {repo}", file=sys.stderr) + return 1 + if not gitfile.exists() and not (repo / "objects").exists(): + print(f"TDPD_ROOT has no git metadata: {repo}", file=sys.stderr) + return 1 + + if args.no_fetch: + os.environ["FETCH"] = "0" + if args.force: + os.environ["FORCE_REBUILD"] = "1" + fetch_origin(repo) + + tips = list_origin_branches(repo) + if not tips: + print("no origin/* branches found", file=sys.stderr) + return 1 + + rebuild = ( + args.rebuild + or args.force + or os.environ.get("REBUILD", "").strip() in ("1", "true", "yes") + ) + if rebuild and editions_root() is None: + print("REBUILD set but EDITIONS_ROOT missing", file=sys.stderr) + return 1 + + payload = build_payload(repo, tips, rebuild) + dest = out_path() + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + print(f"wrote {dest} branches={len(payload['branches'])}", file=sys.stderr) + if "rebuild" in payload: + print(f"rebuild={payload['rebuild']}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/dossiers-ngi/caddy-dossiers-ngi.snippet b/scripts/dossiers-ngi/caddy-dossiers-ngi.snippet new file mode 100644 index 0000000..7e02a41 --- /dev/null +++ b/scripts/dossiers-ngi/caddy-dossiers-ngi.snippet @@ -0,0 +1,68 @@ +# Stanza body for dossiers.ngi.hacktivism.ch only (hand-check / re-seed). +# Apply on koopa (host-local): +# bash ~/bin/apply-dossiers-ngi-edition-switch.sh +# sudo ~/bin/caddy-apply +# +# Sticky bar switches build via cookie tdpd_edition → editions//. +# Stock Caddy only (templates + map + handle). No replace-response plugin. +# Keep .2 / .3 blocks untouched. +# +# Site-block body (no outer braces): + + header Alt-Svc "clear" + # Cookie selects editions//; same paths (/static/css/style.css) must not + # share one browser/CDN cache entry across editions (main has no green CSS). + header Vary "Cookie" + header Cache-Control "private, no-cache" + + @use path_regexp use ^/_cascade/use/([A-Za-z0-9_-]+)/?$ + handle @use { + header Set-Cookie "tdpd_edition={re.use.1}; Path=/; Max-Age=31536000; SameSite=Lax" + header Cache-Control "private, no-store" + # Keep the caller's path (?next=). Never bounce to /. No next → bar page. + @next query_regexp next ^(/|/[^/].*)$ + redir @next {query.next} 302 + redir * /_cascade/ 302 + } + + handle /_cascade/* { + root * /var/www/monitoring-sites/dossiers.ngi.hacktivism.ch + header Cache-Control "private, no-cache" + file_server + } + # Path-root cascade stubs live under sites/…/cascade/ + handle /cascade/* { + root * /var/www/monitoring-sites/dossiers.ngi.hacktivism.ch + header Cache-Control "private, no-cache" + file_server + try_files {path} {path}/ {path}/index.html + } + handle /branch-status.json { + root * /var/www/monitoring-sites/dossiers.ngi.hacktivism.ch + header Cache-Control "private, no-cache" + file_server + } + + # Explicit edition_dir map (placeholder map output broke on Caddy v2.11.4 — + # non-flask cookies 404'd). Refresh this list when new origin tips appear + # (branch-status rebuild creates editions//). + map {http.request.cookie.tdpd_edition} {edition_dir} { + default flask + flask flask + feat-sm-content feat-sm-content + feat-website-design feat-website-design + feat-website-features feat-website-features + feat-website-views feat-website-views + feature-nl-net-injestion-injestor-update feature-nl-net-injestion-injestor-update + main main + project_template project_template + } + + root * /var/www/monitoring-sites/dossiers.ngi.hacktivism.ch/editions/{edition_dir} + templates { + mime text/html + between "{%" "%}" + root /var/www/monitoring-sites/dossiers.ngi.hacktivism.ch + } + file_server + try_files {path} {path}/ {path}/index.html diff --git a/scripts/dossiers-ngi/inject-cascade-bar.py b/scripts/dossiers-ngi/inject-cascade-bar.py new file mode 100755 index 0000000..9659dde --- /dev/null +++ b/scripts/dossiers-ngi/inject-cascade-bar.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Insert {% include "_cascade/bar.html" . %} after in freeze HTML. + +Idempotent. Skips files that already contain the include or live under +_cascade/. Usage: + + python3 inject-cascade-bar.py /path/to/edition/root +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +INCLUDE = '{% include "_cascade/bar.html" . %}' +BODY_RE = re.compile(r"(]*>)", re.IGNORECASE) + + +def inject(text: str) -> str | None: + if INCLUDE in text: + return None + if "_cascade/bar" in text: + return None + m = BODY_RE.search(text) + if not m: + return None + i = m.end() + return text[:i] + "\n" + INCLUDE + "\n" + text[i:] + + +def main() -> int: + if len(sys.argv) != 2: + print(f"usage: {sys.argv[0]} EDITION_ROOT", file=sys.stderr) + return 2 + root = Path(sys.argv[1]).resolve() + if not root.is_dir(): + print(f"not a dir: {root}", file=sys.stderr) + return 2 + changed = 0 + scanned = 0 + for path in root.rglob("*.html"): + if "/_cascade/" in path.as_posix() + "/": + continue + scanned += 1 + raw = path.read_text(encoding="utf-8", errors="ignore") + out = inject(raw) + if out is None: + continue + path.write_text(out, encoding="utf-8") + changed += 1 + print(f"OK: scanned={scanned} injected={changed} root={root}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/dossiers-ngi/inject-editions-bar.sh b/scripts/dossiers-ngi/inject-editions-bar.sh new file mode 100755 index 0000000..f30790c --- /dev/null +++ b/scripts/dossiers-ngi/inject-editions-bar.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Inject cascade-bar include into every editions/ freeze on koopa. +# No zypper, no Caddy plugin, no root — hernani must own $WWW. +# +# bash scripts/dossiers-ngi/inject-editions-bar.sh +# WWW=/var/www/monitoring-sites/dossiers.ngi.hacktivism.ch bash … +# +# Way back without the bar: https://dossiers.ngi.hacktivism.ch/_cascade/use/flask +set -euo pipefail + +WWW="${WWW:-/var/www/monitoring-sites/dossiers.ngi.hacktivism.ch}" +ED="${ED:-$WWW/editions}" + +if [[ ! -d "$ED" ]]; then + echo "FAIL: not a dir: $ED" >&2 + exit 2 +fi + +python3 - "$ED" <<'PY' +from __future__ import annotations + +import re +import sys +from pathlib import Path + +INCLUDE = '{% include "_cascade/bar.html" . %}' +BODY_RE = re.compile(r"(]*>)", re.IGNORECASE) + + +def inject(text: str) -> str | None: + if INCLUDE in text or "_cascade/bar" in text: + return None + m = BODY_RE.search(text) + if not m: + return None + i = m.end() + return text[:i] + "\n" + INCLUDE + "\n" + text[i:] + + +root = Path(sys.argv[1]).resolve() +scanned = changed = skipped = nobody = 0 +print(f"ED={root}") +for slug_dir in sorted(p for p in root.iterdir() if p.is_dir()): + for path in slug_dir.rglob("*.html"): + posix = path.as_posix() + if "/_cascade/" in posix + "/": + continue + scanned += 1 + raw = path.read_text(encoding="utf-8", errors="ignore") + if INCLUDE in raw or "_cascade/bar" in raw: + skipped += 1 + continue + out = inject(raw) + if out is None: + nobody += 1 + print(f"NOBODY {path.relative_to(root)}") + continue + path.write_text(out, encoding="utf-8") + changed += 1 + print(f"INJECT {path.relative_to(root)}") + +print(f"OK scanned={scanned} injected={changed} already={skipped} nobody={nobody}") +if nobody: + sys.exit(1) +PY + +echo "----- index.html bar? -----" +python3 - "$ED" <<'PY' +from pathlib import Path +import sys +root = Path(sys.argv[1]) +for d in sorted(p for p in root.iterdir() if p.is_dir()): + idx = d / "index.html" + if not idx.is_file(): + print(f"MISSING {d.name}/index.html") + continue + t = idx.read_text(encoding="utf-8", errors="ignore") + flag = "BAR" if ("cascade-bar" in t or "_cascade/bar" in t) else "NOBAR" + print(f"{flag} {d.name}") +PY diff --git a/scripts/dossiers-ngi/smoke-branch-tree.sh b/scripts/dossiers-ngi/smoke-branch-tree.sh new file mode 100755 index 0000000..e180854 --- /dev/null +++ b/scripts/dossiers-ngi/smoke-branch-tree.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# Local smoke: temp origin with extra branches → tree JSON + stub rebuild → cleanup. +# Does not touch origin on git.ngi-0.eu. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +SCRIPT="$ROOT/scripts/dossiers-ngi/branch-status-hourly.py" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/tdpd-smoke-XXXXXX")" +cleanup() { rm -rf "$TMP"; } +trap cleanup EXIT + +echo "TMP=$TMP" + +BARE="$TMP/origin.git" +CLONE="$TMP/tdpd" +EDITIONS="$TMP/editions" +OUT="$TMP/branch-status.json" + +git init --bare "$BARE" >/dev/null +git clone "$BARE" "$CLONE" >/dev/null 2>&1 +cd "$CLONE" +git config user.email "smoke@example.test" +git config user.name "smoke" +echo "root" > README +git add README +git commit -m "main root" >/dev/null +git branch -M main +git push -u origin main >/dev/null 2>&1 + +# ladder: main → feat/a → feat/b +git checkout -b feat/a >/dev/null 2>&1 +echo a > a.txt && git add a.txt && git commit -m "feat a" >/dev/null +git push -u origin feat/a >/dev/null 2>&1 +git checkout -b feat/b >/dev/null 2>&1 +echo b > b.txt && git add b.txt && git commit -m "feat b" >/dev/null +git push -u origin feat/b >/dev/null 2>&1 + +# sibling off main +git checkout main >/dev/null 2>&1 +git checkout -b smoke/temp-sibling >/dev/null 2>&1 +echo s > s.txt && git add s.txt && git commit -m "smoke sibling" >/dev/null +git push -u origin smoke/temp-sibling >/dev/null 2>&1 + +# stub freeze: write website/build/index.html +FREEZE_CMD='mkdir -p "{root}/website/build" && printf "%s\n" "

{branch}

" > "{root}/website/build/index.html"' + +mkdir -p "$EDITIONS" +# seed a Makefile so non-stub path would find one; stub overrides via FREEZE_CMD +FETCH=0 TDPD_ROOT="$CLONE" OUT="$OUT" EDITIONS_ROOT="$EDITIONS" \ + FREEZE_CMD="$FREEZE_CMD" REBUILD=1 \ + python3 "$SCRIPT" --rebuild --no-fetch + +python3 - "$OUT" <<'PY' +import json, sys +from pathlib import Path +data = json.loads(Path(sys.argv[1]).read_text()) +branches = {b["branch"]: b for b in data["branches"]} +assert "main" in branches +assert "feat/a" in branches +assert "feat/b" in branches +assert "smoke/temp-sibling" in branches +assert branches["feat/a"]["parent"] == "main", branches["feat/a"] +assert branches["feat/b"]["parent"] == "feat/a", branches["feat/b"] +assert branches["smoke/temp-sibling"]["parent"] == "main", branches["smoke/temp-sibling"] +for b in data["branches"]: + assert b.get("freeze_ok") is True, b +# prefixes semi-graphic +prefs = {b["branch"]: b["prefix"] for b in data["branches"]} +assert any("├──" in p or "└──" in p for p in prefs.values()), prefs +# editions built +print("OK tree:") +for b in data["branches"]: + print(f" {b['prefix']}{b['branch']} @ {b['tip']} -> {b['edition']} parent={b['parent']!r}") +print("rebuild", data.get("rebuild")) +PY + +# editions exist with stub content +test -f "$EDITIONS/feat-a/index.html" +test -f "$EDITIONS/smoke-temp-sibling/index.html" +grep -q "feat/a" "$EDITIONS/feat-a/index.html" +grep -q "smoke/temp-sibling" "$EDITIONS/smoke-temp-sibling/index.html" +test "$(cat "$EDITIONS/feat-a/.edition-freeze-ok")" = "1" + +# red ✗ when last freeze of this tip failed +printf '0\n' > "$EDITIONS/feat-a/.edition-freeze-ok" +printf 'error:smoke\n' > "$EDITIONS/feat-a/.edition-freeze-error" +FETCH=0 TDPD_ROOT="$CLONE" OUT="$OUT" EDITIONS_ROOT="$EDITIONS" \ + FREEZE_CMD="$FREEZE_CMD" \ + python3 "$SCRIPT" --no-fetch +python3 - "$OUT" <<'PY' +import json, sys +from pathlib import Path +data = json.loads(Path(sys.argv[1]).read_text()) +rows = {b["branch"]: b for b in data["branches"]} +assert rows["feat/a"]["freeze_ok"] is False, rows["feat/a"] +assert rows["feat/a"].get("freeze_error") == "error:smoke", rows["feat/a"] +assert rows["main"]["freeze_ok"] is True, rows["main"] +print("OK freeze_ok false after failed mark") +PY +printf '1\n' > "$EDITIONS/feat-a/.edition-freeze-ok" +rm -f "$EDITIONS/feat-a/.edition-freeze-error" + +# flask alias: matching tip + stale HTML must copy from DEFAULT_BRANCH +MAIN_TIP="$(tr -d '\n' < "$EDITIONS/main/.edition-tip")" +mkdir -p "$EDITIONS/flask" +printf '%s\n' "$MAIN_TIP" > "$EDITIONS/flask/.edition-tip" +printf '%s\n' 'stale-flask' > "$EDITIONS/flask/index.html" +printf '%s\n' '2026-09-30T17:35:26+02:00' > "$EDITIONS/flask/.edition-built-at" +FETCH=0 TDPD_ROOT="$CLONE" OUT="$OUT" EDITIONS_ROOT="$EDITIONS" \ + FREEZE_CMD="$FREEZE_CMD" REBUILD=1 DEFAULT_BRANCH=main \ + python3 "$SCRIPT" --rebuild --no-fetch +grep -q "main" "$EDITIONS/flask/index.html" +if grep -q "stale-flask" "$EDITIONS/flask/index.html"; then + echo "FAIL flask alias kept stale HTML" + exit 1 +fi +test "$(tr -d '\n' < "$EDITIONS/flask/.edition-freeze-ok")" = "1" +test "$(tr -d '\n' < "$EDITIONS/flask/.edition-built-at")" = "$(tr -d '\n' < "$EDITIONS/main/.edition-built-at")" +echo "OK flask alias copied after stale skip" + +# delete smoke branch on origin; fetch --prune drops remote-tracking ref +cd "$CLONE" +git push origin --delete smoke/temp-sibling >/dev/null 2>&1 +git branch -D smoke/temp-sibling >/dev/null 2>&1 || true +TDPD_ROOT="$CLONE" OUT="$OUT" EDITIONS_ROOT="$EDITIONS" \ + FREEZE_CMD="$FREEZE_CMD" \ + python3 "$SCRIPT" +python3 - "$OUT" <<'PY' +import json, sys +from pathlib import Path +data = json.loads(Path(sys.argv[1]).read_text()) +names = {b["branch"] for b in data["branches"]} +assert "smoke/temp-sibling" not in names, names +assert names == {"main", "feat/a", "feat/b"}, names +print("OK smoke branch removed from tree") +PY + +echo "SMOKE_OK" diff --git a/scripts/pepsi/ensure-mon-users.sh b/scripts/pepsi/ensure-mon-users.sh new file mode 100755 index 0000000..661edaa --- /dev/null +++ b/scripts/pepsi/ensure-mon-users.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Idempotent: ensure mon-* OS users + Maildir inside koopa-pepsi. +# Needed after every container recreate: /home is not in the image and is +# not a volume by default → useradd state vanishes while DB login_assignment +# survives. +# +# UIDs fixed 1000–1004 (match login_assignment / prior live). +# STATUS= only; no secrets. Run on koopa as hernani (rootless podman exec). +set -euo pipefail + +CTR="${PEPSI_CTR:-koopa-pepsi}" +USERS=( + "mon-smtp:1000" + "mon-maildir:1001" + "mon-dkim:1002" + "mon-bounce:1003" + "mon-local:1004" +) + +echo "STATUS=start CTR=$CTR HOST=$(hostname -s)" + +if ! podman container exists "$CTR" 2>/dev/null; then + echo "STATUS=missing_ctr CTR=$CTR" + exit 2 +fi +if [[ "$(podman inspect -f '{{.State.Running}}' "$CTR" 2>/dev/null || echo false)" != "true" ]]; then + echo "STATUS=ctr_not_running CTR=$CTR" + exit 3 +fi + +created=0 +present=0 +for entry in "${USERS[@]}"; do + u="${entry%%:*}" + uid="${entry##*:}" + if podman exec "$CTR" getent passwd "$u" >/dev/null 2>&1; then + echo "USER=$u STATE=present" + present=$((present + 1)) + else + podman exec -u root "$CTR" useradd -u "$uid" -U -m -d "/home/$u" -s /usr/sbin/nologin "$u" + echo "USER=$u STATE=created UID=$uid" + created=$((created + 1)) + fi + # Maildir skeleton (cur/new/tmp) — safe if already present + podman exec -u root "$CTR" bash -c " + set -e + home=/home/$u + mkdir -p \"\$home/Maildir\"/{cur,new,tmp} + chown -R $u:$u \"\$home\" + chmod 700 \"\$home\" + " + echo "MAILDIR=$u OK=1" +done + +echo "STATUS=done CREATED=$created PRESENT_BEFORE=$present EXPECT=${#USERS[@]}" +podman exec "$CTR" getent passwd | grep '^mon-' || true +exit 0 diff --git a/scripts/pepsi/firewalld-pepsi-highports.sh b/scripts/pepsi/firewalld-pepsi-highports.sh new file mode 100755 index 0000000..1387fa1 --- /dev/null +++ b/scripts/pepsi/firewalld-pepsi-highports.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Open rootless pepsi high-ports on koopa (firewalld). +# Compose map (LAN bind 192.168.100.95): +# 1025→25 1465→465 1587→587 1993→993 18443→443 +# Freigabe: human this session. Run on koopa as root / sudo. +set -euo pipefail + +PORTS=(1025/tcp 1465/tcp 1587/tcp 1993/tcp 18443/tcp) +ZONE="${FIREWALLD_ZONE:-}" + +if [[ ${EUID:-$(id -u)} -ne 0 ]]; then + echo "STATUS=need_root HINT='sudo $0'" + exit 2 +fi + +if ! command -v firewall-cmd >/dev/null 2>&1; then + echo "STATUS=no_firewalld" + exit 3 +fi + +if ! firewall-cmd --state >/dev/null 2>&1; then + echo "STATUS=firewalld_down" + exit 4 +fi + +if [[ -z "$ZONE" ]]; then + # Prefer the zone that owns the LAN NIC; else default. + ZONE=$(firewall-cmd --get-zone-of-interface=eno1 2>/dev/null || true) + if [[ -z "$ZONE" ]]; then + ZONE=$(firewall-cmd --get-default-zone) + fi +fi + +echo "STATUS=start ZONE=$ZONE HOST=$(hostname -s)" + +added=0 +present=0 +for p in "${PORTS[@]}"; do + if firewall-cmd --zone="$ZONE" --query-port="$p" >/dev/null 2>&1; then + echo "PORT=$p STATE=present" + present=$((present + 1)) + else + firewall-cmd --zone="$ZONE" --add-port="$p" --permanent + echo "PORT=$p STATE=added_permanent" + added=$((added + 1)) + fi +done + +firewall-cmd --reload +echo "STATUS=reloaded" + +ok=0 +for p in "${PORTS[@]}"; do + if firewall-cmd --zone="$ZONE" --query-port="$p" >/dev/null 2>&1; then + echo "VERIFY=$p OK=1" + ok=$((ok + 1)) + else + echo "VERIFY=$p OK=0" + fi +done + +echo "STATUS=done ZONE=$ZONE ADDED=$added PRESENT_BEFORE=$present VERIFY_OK=$ok EXPECT=${#PORTS[@]}" +if [[ "$ok" -eq "${#PORTS[@]}" ]]; then + exit 0 +fi +exit 1 diff --git a/scripts/pepsi/hostnet-pepsi-highports.sh b/scripts/pepsi/hostnet-pepsi-highports.sh new file mode 100755 index 0000000..aeacc55 --- /dev/null +++ b/scripts/pepsi/hostnet-pepsi-highports.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# Switch koopa-pepsi to network_mode:host on high ports so SMTP client IP +# is preserved (rootlessport SNAT otherwise forces 10.89.16.4 → SPF fail). +# +# Vecigate stays pure DNAT (25/465/587 → 1025/1465/1587). No mail on router. +# DB stays bridged; publish 127.0.0.1:15432→5432 for host-net pepsi. +# +# Freigabe: human this session (Script hinlegen + apply+restart). +# Run on koopa as hernani (rootless podman). Never echoes DB secrets. +set -euo pipefail + +PEPSI_DIR="${PEPSI_DIR:-$HOME/koopa-pepsi}" +TS="$(date +%Y%m%d-%H%M%S)" +BAK="${PEPSI_DIR}/bak-hostnet-${TS}" +COMPOSE_BIN="" + +echo "STATUS=start HOST=$(hostname -s) DIR=$PEPSI_DIR" + +if [[ ! -d "$PEPSI_DIR" ]]; then + echo "STATUS=missing_dir PATH=$PEPSI_DIR" + exit 2 +fi +cd "$PEPSI_DIR" + +if command -v podman-compose >/dev/null 2>&1; then + COMPOSE_BIN="podman-compose" +elif podman compose version >/dev/null 2>&1; then + COMPOSE_BIN="podman compose" +else + echo "STATUS=no_compose" + exit 3 +fi + +if [[ ! -f compose.yml || ! -f conf/pepsi.conf ]]; then + echo "STATUS=missing_files" + exit 4 +fi + +already=0 +if grep -q 'network_mode:[[:space:]]*host' compose.yml \ + && grep -qE '^PORT = 1025$' conf/pepsi.conf \ + && grep -qE '@127\.0\.0\.1:15432/' conf/pepsi.conf; then + already=1 + echo "STATUS=already_hostnet HINT=skip_patch" +fi + +if [[ "$already" -eq 0 ]]; then + mkdir -p "$BAK" + cp -a compose.yml conf/pepsi.conf "$BAK/" + echo "STATUS=bak PATH=$BAK" + + python3 - <<'PY' +from pathlib import Path +import re +import sys + +root = Path(".") +compose_path = root / "compose.yml" +conf_path = root / "conf" / "pepsi.conf" +compose = compose_path.read_text() +conf = conf_path.read_text() + +# Split services crudely on "\n pepsi:" / "\n db:" +def patch_compose(text: str) -> str: + # Retarget env DB URLs first (password stays ${VAR}) + text = text.replace("@db:5432/", "@127.0.0.1:15432/") + + # Ensure db publishes 127.0.0.1:15432:5432 + if "15432:5432" not in text: + text = re.sub( + r"(container_name: koopa-pepsi-db\n)", + r'\1 ports:\n - "127.0.0.1:15432:5432"\n', + text, + count=1, + ) + + # pepsi service: add network_mode host; remove its ports: block + # Match from " pepsi:" until next top-level service or "volumes:" + m = re.search(r"(?ms)^ pepsi:\n(.*?)(?=^volumes:|^ [a-z])", text) + if not m: + print("STATUS=pepsi_service_not_found") + sys.exit(5) + body = m.group(1) + # drop ports block inside pepsi + body2 = re.sub(r"(?m)^ ports:\n(?: [^\n]*\n)+", "", body) + if "network_mode:" not in body2: + body2 = re.sub( + r"(container_name: koopa-pepsi\n)", + r"\1 network_mode: host\n", + body2, + count=1, + ) + text = text[: m.start(1)] + body2 + text[m.end(1) :] + return text + + +def set_port(text: str, section: str, port: int) -> str: + pat = rf"(\[{re.escape(section)}\]\n(?:.*\n)*?PORT = )\d+" + new, n = re.subn(pat, rf"\g<1>{port}", text, count=1) + if n != 1: + print(f"STATUS=port_patch_fail SECTION={section} N={n}") + sys.exit(6) + return new + + +compose = patch_compose(compose) +conf = conf.replace("@db:5432/", "@127.0.0.1:15432/") +conf = set_port(conf, "pepsi-ingress-listener-mx", 1025) +conf = set_port(conf, "pepsi-ingress-listener-submissions", 1465) +conf = set_port(conf, "pepsi-ingress-listener-submission", 1587) +conf = set_port(conf, "pepsi-httpd-listener-https", 18443) + +compose_path.write_text(compose) +conf_path.write_text(conf) + +# sanity STATUS (no secrets) +checks = [ + ("network_mode_host", bool(re.search(r"(?m)^ network_mode:\s*host\s*$", compose))), + ("db_pub_15432", "15432:5432" in compose), + ("no_db_hostname", "@db:5432/" not in compose and "@db:5432/" not in conf), + ("port_1025", bool(re.search(r"(?m)^PORT = 1025\s*$", conf))), + ("port_1465", bool(re.search(r"(?m)^PORT = 1465\s*$", conf))), + ("port_1587", bool(re.search(r"(?m)^PORT = 1587\s*$", conf))), + ("port_18443", bool(re.search(r"(?m)^PORT = 18443\s*$", conf))), + ("pepsi_ports_gone", "192.168.100.95:1025" not in compose), +] +for k, v in checks: + print(f"CHECK={k} OK={int(v)}") +if not all(v for _, v in checks): + print("STATUS=patch_sanity_fail") + sys.exit(7) +print("STATUS=patched") +PY +fi + +echo "STATUS=compose_up BIN=$COMPOSE_BIN" +# shellcheck disable=SC2086 +$COMPOSE_BIN up -d --force-recreate +sleep 4 + +echo "STATUS=verify" +ss_out="$(ss -lntp 2>/dev/null | grep -E ':1025|:1465|:1587|:18443|:15432' || true)" +# redact pid/user clutter lightly +echo "$ss_out" | sed -E 's/users:\(\("([^"]+)".*/users=(("\1"…)/' || true + +rootless_hit=0 +if echo "$ss_out" | grep -q rootlessport; then + rootless_hit=1 +fi +db_pub=0 +if echo "$ss_out" | grep -qE '127\.0\.0\.1:15432'; then + db_pub=1 +fi +mx_ok=0 +if echo "$ss_out" | grep -qE ':1025\b'; then + mx_ok=1 +fi + +podman ps --format '{{.Names}} {{.Status}}' | grep pepsi || true + +if command -v pg_isready >/dev/null 2>&1; then + if pg_isready -h 127.0.0.1 -p 15432 -d pepsi >/dev/null 2>&1; then + echo "STATUS=db_ready HOST=127.0.0.1 PORT=15432" + else + echo "STATUS=db_not_ready HOST=127.0.0.1 PORT=15432" + fi +else + if timeout 2 bash -c 'echo >/dev/tcp/127.0.0.1/15432' 2>/dev/null; then + echo "STATUS=db_tcp_open HOST=127.0.0.1 PORT=15432" + else + echo "STATUS=db_tcp_closed HOST=127.0.0.1 PORT=15432" + fi +fi + +# recent log lines (strip any postgres URLs) +podman logs --tail 40 koopa-pepsi 2>/dev/null \ + | grep -Ei 'listen|error|fatal|spf|client|1025|postgres|ready' \ + | sed -E 's#postgres://[^[:space:]]+#postgres://*#g' \ + | tail -12 || true + +echo "STATUS=done MX_1025=$mx_ok DB_PUB=$db_pub ROOTLESSPORT_ON_HIGH=$rootless_hit BAK=${BAK:-none}" +echo "HINT=next ensure-mon-users.sh after recreate; then taler-mail send --expect-pong" +echo "HINT=Vecigate DNAT unchanged (no mail on router); /home ephemeral unless volume+ensure" +# Optional: run ensure if sibling script present (same dir or PEPSI_DIR/scripts) +ENSURE="" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +for cand in "$here/ensure-mon-users.sh" "$PEPSI_DIR/scripts/ensure-mon-users.sh" "$PEPSI_DIR/ensure-mon-users.sh"; do + if [[ -x "$cand" ]]; then + ENSURE="$cand" + break + fi +done +if [[ -n "$ENSURE" ]]; then + echo "STATUS=ensure_mon_users SCRIPT=$ENSURE" + PEPSI_CTR=koopa-pepsi "$ENSURE" || echo "STATUS=ensure_warn" +fi +if [[ "$mx_ok" -eq 1 && "$rootless_hit" -eq 0 && "$db_pub" -eq 1 ]]; then + exit 0 +fi +if [[ "$mx_ok" -eq 1 && "$db_pub" -eq 1 ]]; then + echo "STATUS=warn_rootlessport_still_present" + exit 0 +fi +exit 1 diff --git a/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.css b/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.css new file mode 100644 index 0000000..feefe10 --- /dev/null +++ b/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.css @@ -0,0 +1,141 @@ +html { + padding-top: var(--cascade-pad, 0px); +} +.cascade-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + display: flex; + justify-content: center; + box-sizing: border-box; + width: 100%; + max-width: none; + margin: 0; + padding: 0.2rem 0 0.25rem; + background: #10141a; + border-bottom: 1px solid #243040; + color: #c8d0d8; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; + line-height: 1.25; +} +.cascade-tree { + /* 45rem = site #navigation/#content 45em at root 16px (bar font is 11px) */ + width: 100%; + max-width: 45rem; + margin: 0 auto; + box-sizing: border-box; +} +.cascade-tree-lines { + display: flex; + flex-direction: column; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} +a.cascade-tree-row { + display: grid; + grid-template-columns: 1.05rem minmax(6rem, 1fr) 7.2rem 10.5rem 4.4rem 3.6rem; + column-gap: 0.55rem; + align-items: baseline; + color: #7ec8ff; + text-decoration: none; + padding: 0 0.15rem; + border-radius: 2px; +} +a.cascade-tree-row:hover { + background: #1a2430; + text-decoration: none; +} +a.cascade-tree-row.is-active { + color: #e8eef4; + background: #1e2a38; + box-shadow: inset 3px 0 0 #e8eef4; +} +a.cascade-tree-row.is-active:hover { + text-decoration: none; +} +.cascade-bar code { + color: inherit; + font-size: inherit; +} +.cascade-name { + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + color: inherit; +} +.cascade-when, +.cascade-built, +.cascade-plus, +.cascade-minus { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; + color: #8a96a3; +} +.cascade-timing { + display: none; +} + +@media (max-width: 45rem) { + .cascade-bar { + padding: 0.15rem 0.4rem 0.2rem; + font-size: 10px; + } + .cascade-tree { + max-width: 100%; + } + a.cascade-tree-row { + grid-template-columns: 0.9rem minmax(5rem, 1fr) 5.6rem 8.4rem 3.6rem 3rem; + column-gap: 0.35rem; + } +} + +.cascade-mark { + text-align: center; + font-weight: 700; + line-height: 1; +} +.cascade-bar .cascade-ok { + color: #3fb950; +} +.cascade-bar .cascade-fail { + color: #f85149; +} +.cascade-bar .diff-plus { + color: #3fb950; + font-weight: 600; +} +.cascade-bar .diff-minus { + color: #f85149; + font-weight: 600; +} +.cascade-bar .diff-loc, +.cascade-bar .diff-loc-unit { + color: #8a96a3; + font-weight: 600; +} +.cascade-hint { + width: 100%; + max-width: 45rem; + margin: 0.15rem auto 0; + box-sizing: border-box; + padding: 0.2rem 0.35rem; + color: #f0c674; + text-align: center; +} +.cascade-hint[hidden] { + display: none; +} +.cascade-missing-msg { + max-width: 45rem; + margin: 1.5rem auto; + padding: 0 0.75rem; + color: #c8d0d8; + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 1rem; + line-height: 1.4; +} diff --git a/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.html b/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.html new file mode 100644 index 0000000..d81564d --- /dev/null +++ b/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.html @@ -0,0 +1,6 @@ + + + diff --git a/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.js b/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.js new file mode 100644 index 0000000..8db6e4a --- /dev/null +++ b/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.js @@ -0,0 +1,466 @@ +(function () { + var POLL_MS = 30000; + var MONTHS = [ + "jan", + "feb", + "mar", + "apr", + "may", + "jun", + "jul", + "aug", + "sep", + "oct", + "nov", + "dec", + ]; + + function cookieEdition() { + var m = document.cookie.match(/(?:^|; )tdpd_edition=([^;]*)/); + if (!m) return "flask"; + try { + return decodeURIComponent(m[1]) || "flask"; + } catch (e) { + return "flask"; + } + } + + function setEditionCookie(edition) { + document.cookie = + "tdpd_edition=" + + encodeURIComponent(edition) + + "; Path=/; Max-Age=31536000; SameSite=Lax"; + } + + function keepPath() { + return location.pathname + location.search; + } + + function useHref(edition) { + var path = keepPath(); + var href = "/_cascade/use/" + encodeURIComponent(edition); + if (!path || path.indexOf("/_cascade/use/") === 0) return href; + return href + "?next=" + encodeURIComponent(path); + } + + function hintEl() { + return document.getElementById("cascade-hint"); + } + + function showHint(msg) { + var el = hintEl(); + if (!el) { + var bar = document.getElementById("cascade-bar"); + if (!bar) return; + el = document.createElement("div"); + el.id = "cascade-hint"; + el.className = "cascade-hint"; + el.setAttribute("role", "status"); + bar.appendChild(el); + } + el.hidden = false; + el.textContent = msg; + pinBar(); + } + + function clearHint() { + var el = hintEl(); + if (!el) return; + el.hidden = true; + el.textContent = ""; + pinBar(); + } + + function responseKeepsBar(res, text) { + if (res && res.ok) return true; + var body = text || ""; + return ( + body.indexOf("cascade-bar") !== -1 || + body.indexOf("cascade-missing") !== -1 + ); + } + + function switchEdition(edition, branchLabel) { + var prev = cookieEdition(); + var here = keepPath() || "/"; + var label = branchLabel || edition; + if (!edition) return; + if (edition === prev) { + clearHint(); + return; + } + setEditionCookie(edition); + fetch(here, { cache: "no-store", credentials: "same-origin" }) + .then(function (res) { + return res.text().then(function (text) { + return { res: res, text: text }; + }); + }) + .then(function (got) { + if (responseKeepsBar(got.res, got.text)) { + location.replace(here + location.hash); + return; + } + setEditionCookie(prev); + showHint( + "This path is not in " + + label + + ". Choose another branch above." + ); + }) + .catch(function () { + setEditionCookie(prev); + showHint( + "This path is not in " + + label + + ". Choose another branch above." + ); + }); + } + + function esc(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + /** ISO → "sep30 16:12" (3-letter English month + DD + HH:MM). */ + function shortTime(iso) { + if (!iso) return ""; + var m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2})/); + if (m) { + var mon = MONTHS[Number(m[2]) - 1] || m[2]; + return mon + m[3] + " " + m[4]; + } + return String(iso).slice(0, 16).replace("T", " "); + } + + function fmtBuildSec(sec) { + if (sec == null || sec === "") return ""; + var n = Number(sec); + if (!isFinite(n)) return ""; + if (n < 10) return n.toFixed(1) + "s"; + return Math.round(n) + "s"; + } + + function builtText(row) { + var sec = fmtBuildSec(row.build_seconds); + var at = shortTime(row.built_at || ""); + if (sec && at) return sec + " " + at; + return sec || at; + } + + function freezeOk(row) { + return row && row.freeze_ok === true; + } + + function freezeLabel(row) { + if (freezeOk(row)) return "freeze ok"; + var err = row && row.freeze_error ? String(row.freeze_error) : ""; + return err ? "freeze failed: " + err : "freeze failed"; + } + + function freezeMarkHtml(row) { + var ok = freezeOk(row); + return ( + '' + + (ok ? "✓" : "✗") + + "" + ); + } + + function plusHtml(row) { + if (row.vs_parent_plus != null) { + var p = Number(row.vs_parent_plus); + if (!isFinite(p)) return ""; + return '+' + esc(String(p)) + ""; + } + if (row.loc != null) { + var n = Number(row.loc); + if (!isFinite(n)) return ""; + return '' + esc(String(n)) + ""; + } + return ""; + } + + function minusHtml(row) { + if (row.vs_parent_minus != null) { + var m = Number(row.vs_parent_minus); + if (!isFinite(m)) return ""; + return '−' + esc(String(m)) + ""; + } + if (row.loc != null) { + return 'LOC'; + } + return ""; + } + + function setText(el, text) { + if (!el) return; + var t = text || ""; + if (el.textContent !== t) el.textContent = t; + } + + function rowTitle(row) { + var tip = row.tip || ""; + var tipTime = row.tip_time || ""; + var vs = ""; + if (row.vs_parent_plus != null || row.vs_parent_minus != null) { + vs = + " vs parent +" + + String(row.vs_parent_plus == null ? 0 : row.vs_parent_plus) + + "/−" + + String(row.vs_parent_minus == null ? 0 : row.vs_parent_minus); + } else if (row.loc != null) { + vs = " loc=" + String(row.loc); + } + var built = builtText(row); + return ( + "Switch build to " + + (row.branch || "") + + (tip ? " @ " + tip : "") + + (tipTime ? " tip=" + shortTime(tipTime) : "") + + (built ? " build=" + built : "") + + " " + + freezeLabel(row) + + vs + ); + } + + function structureKey(list) { + var parts = []; + for (var i = 0; i < list.length; i++) { + var row = list[i]; + parts.push( + (row.edition || "") + + "\t" + + (row.branch || "") + + "\t" + + (row.prefix || "") + + "\t" + + (row.parent || "") + ); + } + return parts.join("\n"); + } + + function pinBar(timingMsg) { + var bar = document.getElementById("cascade-bar"); + if (!bar) return; + if (timingMsg) { + bar.setAttribute("data-timing", timingMsg); + bar.setAttribute("title", timingMsg); + } + var h = bar.offsetHeight || 0; + document.documentElement.style.setProperty("--cascade-pad", h + "px"); + } + + function markActive(root, edition) { + var links = root.querySelectorAll("a[data-edition]"); + for (var i = 0; i < links.length; i++) { + var a = links[i]; + var ed = a.getAttribute("data-edition"); + var on = + ed === edition || + (edition === "flask" && a.getAttribute("data-default") === "1"); + if (on) { + a.classList.add("is-active"); + a.setAttribute("aria-current", "page"); + } else { + a.classList.remove("is-active"); + a.removeAttribute("aria-current"); + } + } + } + + function timingText(timing, listLen) { + if (!timing || timing.wall_seconds == null) return ""; + return ( + "last full pass ~" + + String(timing.wall_seconds) + + "s · built " + + String(timing.built || 0) + + " / skipped " + + String(timing.skipped || 0) + + " / " + + String(timing.branches || listLen) + ); + } + + function updateValues(mount, data) { + var list = (data && data.branches) || []; + var defaultBranch = (data && data.default_branch) || "feat/website-views"; + var timing = (data && data.rebuild_timing) || null; + var rows = mount.querySelectorAll("a.cascade-tree-row[data-edition]"); + if (rows.length !== list.length) return false; + for (var i = 0; i < list.length; i++) { + var row = list[i]; + var a = rows[i]; + if (a.getAttribute("data-edition") !== (row.edition || "")) return false; + if (a.getAttribute("data-branch") !== (row.branch || "")) return false; + var isDefault = (row.branch || "") === defaultBranch; + if (isDefault) a.setAttribute("data-default", "1"); + else a.removeAttribute("data-default"); + setText(a.querySelector(".cascade-when"), shortTime(row.tip_time || "")); + setText(a.querySelector(".cascade-built"), builtText(row)); + var markEl = a.querySelector(".cascade-mark"); + if (markEl) { + var markHtml = freezeMarkHtml(row); + if (markEl.outerHTML !== markHtml) markEl.outerHTML = markHtml; + } + var plusEl = a.querySelector(".cascade-plus"); + if (plusEl) { + var ph = plusHtml(row); + if (plusEl.innerHTML !== ph) plusEl.innerHTML = ph; + } + var minusEl = a.querySelector(".cascade-minus"); + if (minusEl) { + var mh = minusHtml(row); + if (minusEl.innerHTML !== mh) minusEl.innerHTML = mh; + } + a.setAttribute("title", rowTitle(row)); + } + var timingEl = mount.querySelector(".cascade-timing"); + var t = timingText(timing, list.length); + if (t) { + if (!timingEl) { + timingEl = document.createElement("div"); + timingEl.className = "cascade-timing"; + mount.appendChild(timingEl); + } + if (timingEl.textContent !== t) timingEl.textContent = t; + } else if (timingEl) { + timingEl.parentNode.removeChild(timingEl); + } + markActive(mount, cookieEdition()); + pinBar(t); + return true; + } + + function renderTree(data, soft) { + var mount = document.getElementById("cascade-tree"); + if (!mount) return; + var list = (data && data.branches) || []; + if (!list.length) { + mount.textContent = "No branches in branch-status.json"; + pinBar(); + return; + } + if (soft && mount.getAttribute("data-structure") === structureKey(list)) { + if (updateValues(mount, data)) return; + } + var defaultBranch = (data && data.default_branch) || "feat/website-views"; + var timing = (data && data.rebuild_timing) || null; + var html = []; + html.push('
'); + for (var i = 0; i < list.length; i++) { + var row = list[i]; + var edition = row.edition || ""; + var branch = row.branch || ""; + var prefix = row.prefix || ""; + var parent = row.parent || ""; + var isDefault = branch === defaultBranch; + html.push( + '' + + freezeMarkHtml(row) + + '' + + esc(prefix) + + "" + + esc(branch) + + "" + + '' + + esc(shortTime(row.tip_time || "")) + + "" + + '' + + esc(builtText(row)) + + "" + + '' + + plusHtml(row) + + "" + + '' + + minusHtml(row) + + "" + + "" + ); + } + html.push("
"); + var t = timingText(timing, list.length); + if (t) { + html.push('
' + esc(t) + "
"); + } + mount.innerHTML = html.join(""); + mount.setAttribute("data-structure", structureKey(list)); + markActive(mount, cookieEdition()); + pinBar(t); + } + + function loadStatus(soft) { + var mount = document.getElementById("cascade-tree"); + return fetch("/branch-status.json", { cache: "no-store" }) + .then(function (res) { + if (!res.ok) throw new Error("status"); + return res.json(); + }) + .then(function (data) { + renderTree(data, !!soft); + }) + .catch(function () { + if (!soft && mount && !mount.querySelector("a[data-edition]")) { + mount.innerHTML = + 'flask' + + ' (branch-status unavailable)'; + markActive(mount, cookieEdition()); + pinBar(); + } + }); + } + + document.addEventListener("click", function (ev) { + var a = ev.target.closest + ? ev.target.closest("a.cascade-tree-row[data-edition]") + : null; + if (!a || ev.defaultPrevented) return; + if (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) + return; + ev.preventDefault(); + switchEdition( + a.getAttribute("data-edition"), + a.getAttribute("data-branch") || a.getAttribute("data-edition") + ); + }); + + if (document.body && document.body.classList.contains("cascade-missing")) { + showHint( + "This path is not in the current branch. Choose another branch above." + ); + } + + loadStatus(false); + setInterval(function () { + loadStatus(true); + }, POLL_MS); + window.addEventListener("resize", function () { + pinBar(); + }); +})(); diff --git a/sites/dossiers.ngi.hacktivism.ch/_cascade/index.html b/sites/dossiers.ngi.hacktivism.ch/_cascade/index.html new file mode 100644 index 0000000..db0bc96 --- /dev/null +++ b/sites/dossiers.ngi.hacktivism.ch/_cascade/index.html @@ -0,0 +1,17 @@ + + + + + +cascade editions + + + + +

Stuck on an edition without a bar? Pick a build above, or reset to flask.

+ + + diff --git a/sites/dossiers.ngi.hacktivism.ch/_cascade/missing.html b/sites/dossiers.ngi.hacktivism.ch/_cascade/missing.html new file mode 100644 index 0000000..449d470 --- /dev/null +++ b/sites/dossiers.ngi.hacktivism.ch/_cascade/missing.html @@ -0,0 +1,17 @@ + + + + + +path not in this branch + + + + +

This address is not in the current branch. Choose another branch above.

+ + + diff --git a/sites/dossiers.ngi.hacktivism.ch/_edition-placeholder/index.html b/sites/dossiers.ngi.hacktivism.ch/_edition-placeholder/index.html new file mode 100644 index 0000000..369b971 --- /dev/null +++ b/sites/dossiers.ngi.hacktivism.ch/_edition-placeholder/index.html @@ -0,0 +1,17 @@ + + + + + +edition pending + + + +{% include "_cascade/bar.html" . %} +

edition pending

+

Full freeze for this tip is not staged under editions/ yet. Sticky bar still switches other builds.

+ + diff --git a/sites/dossiers.ngi.hacktivism.ch/branch-status.json b/sites/dossiers.ngi.hacktivism.ch/branch-status.json new file mode 100644 index 0000000..8870c12 --- /dev/null +++ b/sites/dossiers.ngi.hacktivism.ch/branch-status.json @@ -0,0 +1,124 @@ +{ + "generated": "2026-09-30T15:34:26+02:00", + "host": "koopa", + "default_branch": "feat/website-views", + "default_edition": "flask", + "branches": [ + { + "branch": "feature/nl-net-injestion-injestor-update", + "edition": "feature-nl-net-injestion-injestor-update", + "tip": "ba68fe9", + "tip_time": "2026-08-27T10:42:02+02:00", + "parent": "", + "prefix": "", + "url": "/_cascade/use/feature-nl-net-injestion-injestor-update", + "built_at": "2026-09-30T15:23:10+02:00", + "build_seconds": 3.841 + }, + { + "branch": "main", + "edition": "main", + "tip": "d2fbd79", + "tip_time": "2026-09-01T16:52:35+02:00", + "parent": "", + "prefix": "", + "url": "/_cascade/use/main", + "built_at": "2026-09-30T15:23:10+02:00", + "build_seconds": 8.447 + }, + { + "branch": "feat/sm-content", + "edition": "feat-sm-content", + "tip": "089ef1f", + "tip_time": "2026-09-28T15:52:12+02:00", + "parent": "main", + "prefix": "\u251c\u2500\u2500 ", + "url": "/_cascade/use/feat-sm-content", + "built_at": "2026-09-30T15:23:10+02:00", + "build_seconds": 12.845 + }, + { + "branch": "feat/website-features", + "edition": "feat-website-features", + "tip": "53a3176", + "tip_time": "2026-09-28T19:38:22+02:00", + "parent": "feat/sm-content", + "prefix": "\u2502 \u2514\u2500\u2500 ", + "url": "/_cascade/use/feat-website-features", + "built_at": "2026-09-30T15:23:10+02:00", + "build_seconds": 8.34 + }, + { + "branch": "feat/website-design", + "edition": "feat-website-design", + "tip": "2e26c80", + "tip_time": "2026-09-28T22:42:08+02:00", + "parent": "feat/website-features", + "prefix": "\u2502 \u2514\u2500\u2500 ", + "url": "/_cascade/use/feat-website-design", + "built_at": "2026-09-30T15:23:10+02:00", + "build_seconds": 10.358 + }, + { + "branch": "feat/website-views", + "edition": "feat-website-views", + "tip": "bbafc38", + "tip_time": "2026-09-28T23:17:42+02:00", + "parent": "feat/website-design", + "prefix": "\u2502 \u2514\u2500\u2500 ", + "url": "/_cascade/use/feat-website-views", + "built_at": "2026-09-30T15:23:10+02:00", + "build_seconds": 5.576 + }, + { + "branch": "project_template", + "edition": "project_template", + "tip": "a97c263", + "tip_time": "2026-09-28T15:03:35+02:00", + "parent": "main", + "prefix": "\u2514\u2500\u2500 ", + "url": "/_cascade/use/project_template", + "built_at": "2026-09-30T15:23:10+02:00", + "build_seconds": 2.245 + } + ], + "rebuild": { + "feat/sm-content": { + "status": "skipped", + "seconds": 0.077 + }, + "feat/website-design": { + "status": "skipped", + "seconds": 0.083 + }, + "feat/website-features": { + "status": "skipped", + "seconds": 0.091 + }, + "feat/website-views": { + "status": "skipped", + "seconds": 0.09 + }, + "feature/nl-net-injestion-injestor-update": { + "status": "skipped", + "seconds": 0.098 + }, + "main": { + "status": "skipped", + "seconds": 0.102 + }, + "project_template": { + "status": "skipped", + "seconds": 0.083 + } + }, + "rebuild_timing": { + "wall_seconds": 0.627, + "branches": 7, + "built": 0, + "skipped": 7, + "errors": 0, + "built_seconds_sum": 0.0, + "built_seconds_max": 0.0 + } +} diff --git a/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.css b/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.css new file mode 100644 index 0000000..feefe10 --- /dev/null +++ b/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.css @@ -0,0 +1,141 @@ +html { + padding-top: var(--cascade-pad, 0px); +} +.cascade-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + display: flex; + justify-content: center; + box-sizing: border-box; + width: 100%; + max-width: none; + margin: 0; + padding: 0.2rem 0 0.25rem; + background: #10141a; + border-bottom: 1px solid #243040; + color: #c8d0d8; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; + line-height: 1.25; +} +.cascade-tree { + /* 45rem = site #navigation/#content 45em at root 16px (bar font is 11px) */ + width: 100%; + max-width: 45rem; + margin: 0 auto; + box-sizing: border-box; +} +.cascade-tree-lines { + display: flex; + flex-direction: column; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} +a.cascade-tree-row { + display: grid; + grid-template-columns: 1.05rem minmax(6rem, 1fr) 7.2rem 10.5rem 4.4rem 3.6rem; + column-gap: 0.55rem; + align-items: baseline; + color: #7ec8ff; + text-decoration: none; + padding: 0 0.15rem; + border-radius: 2px; +} +a.cascade-tree-row:hover { + background: #1a2430; + text-decoration: none; +} +a.cascade-tree-row.is-active { + color: #e8eef4; + background: #1e2a38; + box-shadow: inset 3px 0 0 #e8eef4; +} +a.cascade-tree-row.is-active:hover { + text-decoration: none; +} +.cascade-bar code { + color: inherit; + font-size: inherit; +} +.cascade-name { + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + color: inherit; +} +.cascade-when, +.cascade-built, +.cascade-plus, +.cascade-minus { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; + color: #8a96a3; +} +.cascade-timing { + display: none; +} + +@media (max-width: 45rem) { + .cascade-bar { + padding: 0.15rem 0.4rem 0.2rem; + font-size: 10px; + } + .cascade-tree { + max-width: 100%; + } + a.cascade-tree-row { + grid-template-columns: 0.9rem minmax(5rem, 1fr) 5.6rem 8.4rem 3.6rem 3rem; + column-gap: 0.35rem; + } +} + +.cascade-mark { + text-align: center; + font-weight: 700; + line-height: 1; +} +.cascade-bar .cascade-ok { + color: #3fb950; +} +.cascade-bar .cascade-fail { + color: #f85149; +} +.cascade-bar .diff-plus { + color: #3fb950; + font-weight: 600; +} +.cascade-bar .diff-minus { + color: #f85149; + font-weight: 600; +} +.cascade-bar .diff-loc, +.cascade-bar .diff-loc-unit { + color: #8a96a3; + font-weight: 600; +} +.cascade-hint { + width: 100%; + max-width: 45rem; + margin: 0.15rem auto 0; + box-sizing: border-box; + padding: 0.2rem 0.35rem; + color: #f0c674; + text-align: center; +} +.cascade-hint[hidden] { + display: none; +} +.cascade-missing-msg { + max-width: 45rem; + margin: 1.5rem auto; + padding: 0 0.75rem; + color: #c8d0d8; + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 1rem; + line-height: 1.4; +} diff --git a/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.html b/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.html new file mode 100644 index 0000000..d81564d --- /dev/null +++ b/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.html @@ -0,0 +1,6 @@ + + + diff --git a/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.js b/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.js new file mode 100644 index 0000000..8db6e4a --- /dev/null +++ b/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.js @@ -0,0 +1,466 @@ +(function () { + var POLL_MS = 30000; + var MONTHS = [ + "jan", + "feb", + "mar", + "apr", + "may", + "jun", + "jul", + "aug", + "sep", + "oct", + "nov", + "dec", + ]; + + function cookieEdition() { + var m = document.cookie.match(/(?:^|; )tdpd_edition=([^;]*)/); + if (!m) return "flask"; + try { + return decodeURIComponent(m[1]) || "flask"; + } catch (e) { + return "flask"; + } + } + + function setEditionCookie(edition) { + document.cookie = + "tdpd_edition=" + + encodeURIComponent(edition) + + "; Path=/; Max-Age=31536000; SameSite=Lax"; + } + + function keepPath() { + return location.pathname + location.search; + } + + function useHref(edition) { + var path = keepPath(); + var href = "/_cascade/use/" + encodeURIComponent(edition); + if (!path || path.indexOf("/_cascade/use/") === 0) return href; + return href + "?next=" + encodeURIComponent(path); + } + + function hintEl() { + return document.getElementById("cascade-hint"); + } + + function showHint(msg) { + var el = hintEl(); + if (!el) { + var bar = document.getElementById("cascade-bar"); + if (!bar) return; + el = document.createElement("div"); + el.id = "cascade-hint"; + el.className = "cascade-hint"; + el.setAttribute("role", "status"); + bar.appendChild(el); + } + el.hidden = false; + el.textContent = msg; + pinBar(); + } + + function clearHint() { + var el = hintEl(); + if (!el) return; + el.hidden = true; + el.textContent = ""; + pinBar(); + } + + function responseKeepsBar(res, text) { + if (res && res.ok) return true; + var body = text || ""; + return ( + body.indexOf("cascade-bar") !== -1 || + body.indexOf("cascade-missing") !== -1 + ); + } + + function switchEdition(edition, branchLabel) { + var prev = cookieEdition(); + var here = keepPath() || "/"; + var label = branchLabel || edition; + if (!edition) return; + if (edition === prev) { + clearHint(); + return; + } + setEditionCookie(edition); + fetch(here, { cache: "no-store", credentials: "same-origin" }) + .then(function (res) { + return res.text().then(function (text) { + return { res: res, text: text }; + }); + }) + .then(function (got) { + if (responseKeepsBar(got.res, got.text)) { + location.replace(here + location.hash); + return; + } + setEditionCookie(prev); + showHint( + "This path is not in " + + label + + ". Choose another branch above." + ); + }) + .catch(function () { + setEditionCookie(prev); + showHint( + "This path is not in " + + label + + ". Choose another branch above." + ); + }); + } + + function esc(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + /** ISO → "sep30 16:12" (3-letter English month + DD + HH:MM). */ + function shortTime(iso) { + if (!iso) return ""; + var m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2})/); + if (m) { + var mon = MONTHS[Number(m[2]) - 1] || m[2]; + return mon + m[3] + " " + m[4]; + } + return String(iso).slice(0, 16).replace("T", " "); + } + + function fmtBuildSec(sec) { + if (sec == null || sec === "") return ""; + var n = Number(sec); + if (!isFinite(n)) return ""; + if (n < 10) return n.toFixed(1) + "s"; + return Math.round(n) + "s"; + } + + function builtText(row) { + var sec = fmtBuildSec(row.build_seconds); + var at = shortTime(row.built_at || ""); + if (sec && at) return sec + " " + at; + return sec || at; + } + + function freezeOk(row) { + return row && row.freeze_ok === true; + } + + function freezeLabel(row) { + if (freezeOk(row)) return "freeze ok"; + var err = row && row.freeze_error ? String(row.freeze_error) : ""; + return err ? "freeze failed: " + err : "freeze failed"; + } + + function freezeMarkHtml(row) { + var ok = freezeOk(row); + return ( + '' + + (ok ? "✓" : "✗") + + "" + ); + } + + function plusHtml(row) { + if (row.vs_parent_plus != null) { + var p = Number(row.vs_parent_plus); + if (!isFinite(p)) return ""; + return '+' + esc(String(p)) + ""; + } + if (row.loc != null) { + var n = Number(row.loc); + if (!isFinite(n)) return ""; + return '' + esc(String(n)) + ""; + } + return ""; + } + + function minusHtml(row) { + if (row.vs_parent_minus != null) { + var m = Number(row.vs_parent_minus); + if (!isFinite(m)) return ""; + return '−' + esc(String(m)) + ""; + } + if (row.loc != null) { + return 'LOC'; + } + return ""; + } + + function setText(el, text) { + if (!el) return; + var t = text || ""; + if (el.textContent !== t) el.textContent = t; + } + + function rowTitle(row) { + var tip = row.tip || ""; + var tipTime = row.tip_time || ""; + var vs = ""; + if (row.vs_parent_plus != null || row.vs_parent_minus != null) { + vs = + " vs parent +" + + String(row.vs_parent_plus == null ? 0 : row.vs_parent_plus) + + "/−" + + String(row.vs_parent_minus == null ? 0 : row.vs_parent_minus); + } else if (row.loc != null) { + vs = " loc=" + String(row.loc); + } + var built = builtText(row); + return ( + "Switch build to " + + (row.branch || "") + + (tip ? " @ " + tip : "") + + (tipTime ? " tip=" + shortTime(tipTime) : "") + + (built ? " build=" + built : "") + + " " + + freezeLabel(row) + + vs + ); + } + + function structureKey(list) { + var parts = []; + for (var i = 0; i < list.length; i++) { + var row = list[i]; + parts.push( + (row.edition || "") + + "\t" + + (row.branch || "") + + "\t" + + (row.prefix || "") + + "\t" + + (row.parent || "") + ); + } + return parts.join("\n"); + } + + function pinBar(timingMsg) { + var bar = document.getElementById("cascade-bar"); + if (!bar) return; + if (timingMsg) { + bar.setAttribute("data-timing", timingMsg); + bar.setAttribute("title", timingMsg); + } + var h = bar.offsetHeight || 0; + document.documentElement.style.setProperty("--cascade-pad", h + "px"); + } + + function markActive(root, edition) { + var links = root.querySelectorAll("a[data-edition]"); + for (var i = 0; i < links.length; i++) { + var a = links[i]; + var ed = a.getAttribute("data-edition"); + var on = + ed === edition || + (edition === "flask" && a.getAttribute("data-default") === "1"); + if (on) { + a.classList.add("is-active"); + a.setAttribute("aria-current", "page"); + } else { + a.classList.remove("is-active"); + a.removeAttribute("aria-current"); + } + } + } + + function timingText(timing, listLen) { + if (!timing || timing.wall_seconds == null) return ""; + return ( + "last full pass ~" + + String(timing.wall_seconds) + + "s · built " + + String(timing.built || 0) + + " / skipped " + + String(timing.skipped || 0) + + " / " + + String(timing.branches || listLen) + ); + } + + function updateValues(mount, data) { + var list = (data && data.branches) || []; + var defaultBranch = (data && data.default_branch) || "feat/website-views"; + var timing = (data && data.rebuild_timing) || null; + var rows = mount.querySelectorAll("a.cascade-tree-row[data-edition]"); + if (rows.length !== list.length) return false; + for (var i = 0; i < list.length; i++) { + var row = list[i]; + var a = rows[i]; + if (a.getAttribute("data-edition") !== (row.edition || "")) return false; + if (a.getAttribute("data-branch") !== (row.branch || "")) return false; + var isDefault = (row.branch || "") === defaultBranch; + if (isDefault) a.setAttribute("data-default", "1"); + else a.removeAttribute("data-default"); + setText(a.querySelector(".cascade-when"), shortTime(row.tip_time || "")); + setText(a.querySelector(".cascade-built"), builtText(row)); + var markEl = a.querySelector(".cascade-mark"); + if (markEl) { + var markHtml = freezeMarkHtml(row); + if (markEl.outerHTML !== markHtml) markEl.outerHTML = markHtml; + } + var plusEl = a.querySelector(".cascade-plus"); + if (plusEl) { + var ph = plusHtml(row); + if (plusEl.innerHTML !== ph) plusEl.innerHTML = ph; + } + var minusEl = a.querySelector(".cascade-minus"); + if (minusEl) { + var mh = minusHtml(row); + if (minusEl.innerHTML !== mh) minusEl.innerHTML = mh; + } + a.setAttribute("title", rowTitle(row)); + } + var timingEl = mount.querySelector(".cascade-timing"); + var t = timingText(timing, list.length); + if (t) { + if (!timingEl) { + timingEl = document.createElement("div"); + timingEl.className = "cascade-timing"; + mount.appendChild(timingEl); + } + if (timingEl.textContent !== t) timingEl.textContent = t; + } else if (timingEl) { + timingEl.parentNode.removeChild(timingEl); + } + markActive(mount, cookieEdition()); + pinBar(t); + return true; + } + + function renderTree(data, soft) { + var mount = document.getElementById("cascade-tree"); + if (!mount) return; + var list = (data && data.branches) || []; + if (!list.length) { + mount.textContent = "No branches in branch-status.json"; + pinBar(); + return; + } + if (soft && mount.getAttribute("data-structure") === structureKey(list)) { + if (updateValues(mount, data)) return; + } + var defaultBranch = (data && data.default_branch) || "feat/website-views"; + var timing = (data && data.rebuild_timing) || null; + var html = []; + html.push('
'); + for (var i = 0; i < list.length; i++) { + var row = list[i]; + var edition = row.edition || ""; + var branch = row.branch || ""; + var prefix = row.prefix || ""; + var parent = row.parent || ""; + var isDefault = branch === defaultBranch; + html.push( + '' + + freezeMarkHtml(row) + + '' + + esc(prefix) + + "" + + esc(branch) + + "" + + '' + + esc(shortTime(row.tip_time || "")) + + "" + + '' + + esc(builtText(row)) + + "" + + '' + + plusHtml(row) + + "" + + '' + + minusHtml(row) + + "" + + "" + ); + } + html.push("
"); + var t = timingText(timing, list.length); + if (t) { + html.push('
' + esc(t) + "
"); + } + mount.innerHTML = html.join(""); + mount.setAttribute("data-structure", structureKey(list)); + markActive(mount, cookieEdition()); + pinBar(t); + } + + function loadStatus(soft) { + var mount = document.getElementById("cascade-tree"); + return fetch("/branch-status.json", { cache: "no-store" }) + .then(function (res) { + if (!res.ok) throw new Error("status"); + return res.json(); + }) + .then(function (data) { + renderTree(data, !!soft); + }) + .catch(function () { + if (!soft && mount && !mount.querySelector("a[data-edition]")) { + mount.innerHTML = + 'flask' + + ' (branch-status unavailable)'; + markActive(mount, cookieEdition()); + pinBar(); + } + }); + } + + document.addEventListener("click", function (ev) { + var a = ev.target.closest + ? ev.target.closest("a.cascade-tree-row[data-edition]") + : null; + if (!a || ev.defaultPrevented) return; + if (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) + return; + ev.preventDefault(); + switchEdition( + a.getAttribute("data-edition"), + a.getAttribute("data-branch") || a.getAttribute("data-edition") + ); + }); + + if (document.body && document.body.classList.contains("cascade-missing")) { + showHint( + "This path is not in the current branch. Choose another branch above." + ); + } + + loadStatus(false); + setInterval(function () { + loadStatus(true); + }, POLL_MS); + window.addEventListener("resize", function () { + pinBar(); + }); +})(); diff --git a/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/index.html b/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/index.html new file mode 100644 index 0000000..db0bc96 --- /dev/null +++ b/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/index.html @@ -0,0 +1,17 @@ + + + + + +cascade editions + + + + +

Stuck on an edition without a bar? Pick a build above, or reset to flask.

+ + + diff --git a/sites/dossiers.ngi.hacktivism.ch/cascade/feat-sm-content/index.html b/sites/dossiers.ngi.hacktivism.ch/cascade/feat-sm-content/index.html new file mode 100644 index 0000000..13f4285 --- /dev/null +++ b/sites/dossiers.ngi.hacktivism.ch/cascade/feat-sm-content/index.html @@ -0,0 +1,31 @@ + + + + + +feat/sm-content + + + +{% include "_cascade/bar.html" . %} +

/feat-sm-content/

+

last-built: 2026-09-30T02:30:50+02:00 · feat/sm-content @ 089ef1f

+ + diff --git a/sites/dossiers.ngi.hacktivism.ch/cascade/feat-website-design/index.html b/sites/dossiers.ngi.hacktivism.ch/cascade/feat-website-design/index.html new file mode 100644 index 0000000..142b80e --- /dev/null +++ b/sites/dossiers.ngi.hacktivism.ch/cascade/feat-website-design/index.html @@ -0,0 +1,31 @@ + + + + + +feat/website-design + + + +{% include "_cascade/bar.html" . %} +

/feat-website-design/

+

last-built: 2026-09-30T02:30:50+02:00 · feat/website-design @ 2e26c80

+ + diff --git a/sites/dossiers.ngi.hacktivism.ch/cascade/feat-website-features/index.html b/sites/dossiers.ngi.hacktivism.ch/cascade/feat-website-features/index.html new file mode 100644 index 0000000..3271f8f --- /dev/null +++ b/sites/dossiers.ngi.hacktivism.ch/cascade/feat-website-features/index.html @@ -0,0 +1,31 @@ + + + + + +feat/website-features + + + +{% include "_cascade/bar.html" . %} +

/feat-website-features/

+

last-built: 2026-09-30T02:30:50+02:00 · feat/website-features @ 53a3176

+ + diff --git a/sites/dossiers.ngi.hacktivism.ch/cascade/feat-website-views/index.html b/sites/dossiers.ngi.hacktivism.ch/cascade/feat-website-views/index.html new file mode 100644 index 0000000..5896890 --- /dev/null +++ b/sites/dossiers.ngi.hacktivism.ch/cascade/feat-website-views/index.html @@ -0,0 +1,31 @@ + + + + + +feat/website-views + + + +{% include "_cascade/bar.html" . %} +

/feat-website-views/

+

last-built: 2026-09-30T02:30:50+02:00 · feat/website-views @ bbafc38

+ + diff --git a/sites/dossiers.ngi.hacktivism.ch/cascade/index.html b/sites/dossiers.ngi.hacktivism.ch/cascade/index.html new file mode 100644 index 0000000..71a5d33 --- /dev/null +++ b/sites/dossiers.ngi.hacktivism.ch/cascade/index.html @@ -0,0 +1,39 @@ + + + + + +cascade landing + + + +{% include "_cascade/bar.html" . %} +

cascade

+

Flask freeze lives at / (views). This ladder is tip stubs only.

+

last-built: 2026-09-30T02:30:50+02:00

+ + + diff --git a/sites/dossiers.ngi.hacktivism.ch/cascade/main/index.html b/sites/dossiers.ngi.hacktivism.ch/cascade/main/index.html new file mode 100644 index 0000000..941036c --- /dev/null +++ b/sites/dossiers.ngi.hacktivism.ch/cascade/main/index.html @@ -0,0 +1,31 @@ + + + + + +main + + + +{% include "_cascade/bar.html" . %} +

/main/

+

last-built: 2026-09-30T02:30:50+02:00 · main @ d2fbd79

+ + diff --git a/sites/dossiers.ngi.hacktivism.ch/feat-sm-content/index.html b/sites/dossiers.ngi.hacktivism.ch/feat-sm-content/index.html deleted file mode 100644 index a3100e1..0000000 --- a/sites/dossiers.ngi.hacktivism.ch/feat-sm-content/index.html +++ /dev/null @@ -1,64 +0,0 @@ - - - - - -feat/sm-content · cascade - - - - -

edition: feat/sm-content

-

path /feat-sm-content/ · tip 089ef1f · depends on main

- -

ladder position

-
cascade ladder (path roots on dossiers.ngi only) - - main @ d2fbd79 - | -* +-- feat/sm-content @ 089ef1f <-- this edition - | - +-- feat/website-features @ 53a3176 - | - +-- feat/website-design @ 2e26c80 - | - +-- feat/website-views @ bbafc38
-

what this looks like

-

SM dossier prose content on top of main.

-

Blank relational stub. Frozen Flask build for this tip is separate; this page only shows cascade relation and path root.

- - - - diff --git a/sites/dossiers.ngi.hacktivism.ch/feat-website-design/index.html b/sites/dossiers.ngi.hacktivism.ch/feat-website-design/index.html deleted file mode 100644 index 12c451c..0000000 --- a/sites/dossiers.ngi.hacktivism.ch/feat-website-design/index.html +++ /dev/null @@ -1,64 +0,0 @@ - - - - - -feat/website-design · cascade - - - - -

edition: feat/website-design

-

path /feat-website-design/ · tip 2e26c80 · depends on feat/website-features

- -

ladder position

-
cascade ladder (path roots on dossiers.ngi only) - - main @ d2fbd79 - | - +-- feat/sm-content @ 089ef1f - | - +-- feat/website-features @ 53a3176 - | -* +-- feat/website-design @ 2e26c80 <-- this edition - | - +-- feat/website-views @ bbafc38
-

what this looks like

-

Design/CSS layer on top of features.

-

Blank relational stub. Frozen Flask build for this tip is separate; this page only shows cascade relation and path root.

- - - - diff --git a/sites/dossiers.ngi.hacktivism.ch/feat-website-features/index.html b/sites/dossiers.ngi.hacktivism.ch/feat-website-features/index.html deleted file mode 100644 index 257847a..0000000 --- a/sites/dossiers.ngi.hacktivism.ch/feat-website-features/index.html +++ /dev/null @@ -1,64 +0,0 @@ - - - - - -feat/website-features · cascade - - - - -

edition: feat/website-features

-

path /feat-website-features/ · tip 53a3176 · depends on feat/sm-content

- -

ladder position

-
cascade ladder (path roots on dossiers.ngi only) - - main @ d2fbd79 - | - +-- feat/sm-content @ 089ef1f - | -* +-- feat/website-features @ 53a3176 <-- this edition - | - +-- feat/website-design @ 2e26c80 - | - +-- feat/website-views @ bbafc38
-

what this looks like

-

Website feature routes on top of sm-content.

-

Blank relational stub. Frozen Flask build for this tip is separate; this page only shows cascade relation and path root.

- - - - diff --git a/sites/dossiers.ngi.hacktivism.ch/feat-website-views/index.html b/sites/dossiers.ngi.hacktivism.ch/feat-website-views/index.html deleted file mode 100644 index 1b622fd..0000000 --- a/sites/dossiers.ngi.hacktivism.ch/feat-website-views/index.html +++ /dev/null @@ -1,68 +0,0 @@ - - - - - -feat/website-views · cascade - - - - -

edition: feat/website-views

-

path /feat-website-views/ · tip bbafc38 · depends on feat/website-design

- -

ladder position

-
cascade ladder (path roots on dossiers.ngi only) - - main @ d2fbd79 - | - +-- feat/sm-content @ 089ef1f - | - +-- feat/website-features @ 53a3176 - | - +-- feat/website-design @ 2e26c80 - | -* +-- feat/website-views @ bbafc38 <-- this edition
-

what this looks like

-

Views/list layer on top of design (origin tip).

-

Blank relational stub. Frozen Flask build for this tip is separate; this page only shows cascade relation and path root.

- -

views gap vs dossiers.ngi-0.eu: -Frozen Flask on this tip exposes project_list / project_detail / dossier_list / blank. -It does not yet mirror the Next.js category-style views on dossiers.ngi-0.eu. -This path documents that gap; category-parity views remain open work.

- - - diff --git a/sites/dossiers.ngi.hacktivism.ch/index.html b/sites/dossiers.ngi.hacktivism.ch/index.html deleted file mode 100644 index a7b111e..0000000 --- a/sites/dossiers.ngi.hacktivism.ch/index.html +++ /dev/null @@ -1,69 +0,0 @@ - - - - - -cascade landing - - - - -

tech-dossier cascade landing

-

host: dossiers.ngi.hacktivism.ch · blank relational index · owned by koopa-admin-log

- -

relation to main

-
cascade ladder (path roots on dossiers.ngi only) - - main @ d2fbd79 - | - +-- feat/sm-content @ 089ef1f - | - +-- feat/website-features @ 53a3176 - | - +-- feat/website-design @ 2e26c80 - | - +-- feat/website-views @ bbafc38
-

editions

-
    -
  • /main/ — main @ d2fbd79 · base
    Base freeze tip.
  • -
  • /feat-sm-content/ — feat/sm-content @ 089ef1f · depends on main
    SM dossier prose content on top of main.
  • -
  • /feat-website-features/ — feat/website-features @ 53a3176 · depends on feat/sm-content
    Website feature routes on top of sm-content.
  • -
  • /feat-website-design/ — feat/website-design @ 2e26c80 · depends on feat/website-features
    Design/CSS layer on top of features.
  • -
  • /feat-website-views/ — feat/website-views @ bbafc38 · depends on feat/website-design
    Views/list layer on top of design (origin tip).
  • -
-

Each path is its own root for that branch edition. Single public host dossiers.ngi only.

- - - diff --git a/sites/dossiers.ngi.hacktivism.ch/main/index.html b/sites/dossiers.ngi.hacktivism.ch/main/index.html deleted file mode 100644 index 9f9490c..0000000 --- a/sites/dossiers.ngi.hacktivism.ch/main/index.html +++ /dev/null @@ -1,64 +0,0 @@ - - - - - -main · cascade - - - - -

edition: main

-

path /main/ · tip d2fbd79 · depends on none (base)

- -

ladder position

-
cascade ladder (path roots on dossiers.ngi only) - -* main @ d2fbd79 <-- this edition - | - +-- feat/sm-content @ 089ef1f - | - +-- feat/website-features @ 53a3176 - | - +-- feat/website-design @ 2e26c80 - | - +-- feat/website-views @ bbafc38
-

what this looks like

-

Base freeze tip.

-

Blank relational stub. Frozen Flask build for this tip is separate; this page only shows cascade relation and path root.

- - - -