Compare commits

...
Sign in to create a new pull request.

22 commits

Author SHA1 Message Date
Hernâni Marques
4f74662202
pepsi: host-net highports + ensure mon-* users 2026-10-29 07:55:14 +01:00
Hernâni Marques
8370ad2e33
tor: CachyOS CTR CookieAuth same FP (TODO=704) 2026-10-21 03:32:38 +02:00
Hernâni Marques
eb4349085d
docs: forgejo host RO users rabbithole/amanita/firecuda-ro 2026-10-21 02:51:00 +02:00
Hernâni Marques
fe0c1e2b53
feat/dossiers-ngi: add Caddy handle docs for cascade sticky bar 2026-10-10 00:49:58 +02:00
Hernâni Marques
37ac6a2171
feat/dossiers-ngi: dated tip/at bar keeps caller path on edition switch 2026-10-10 00:49:11 +02:00
Hernâni Marques
197e0ab19a
fix/dossiers-ngi: update README and scripts for branch status 2026-10-01 12:09:29 +02:00
Hernâni Marques
8ad49de231
fix/dossiers-ngi: update README and scripts for branch status 2026-10-01 11:46:56 +02:00
Hernâni Marques
c14e215ad8
feat/dossiers-ngi: sticky bar vs-parent loc-on-roots split plus/minus 45rem 2026-09-30 23:56:51 +02:00
Hernâni Marques
8cb55e57e8
feat/dossiers-ngi: sticky bar vs-main compact dates tabular wider tree 2026-09-30 23:26:26 +02:00
Hernâni Marques
3feb694084
feat/dossiers-ngi: inject-editions-bar.sh for freeze HTML missing include 2026-09-30 22:15:38 +02:00
Hernâni Marques
b24dd17ee9
feat/dossiers-ngi: sticky bar + JSON diffstat vs parent tip 2026-09-30 22:07:06 +02:00
Hernâni Marques
c2bf7a4263
feat/dossiers-ngi: sticky bar vs-main +green/−red diffstat 2026-09-30 16:21:28 +02:00
Hernâni Marques
ce5269b745
fix/dossiers-ngi: Caddy explicit edition_dir map (cookie placeholder 404) 2026-09-30 16:13:42 +02:00
Hernâni Marques
686a9fb906
ops/dossiers-ngi: Caddy Vary Cookie + no-cache for edition-pure CSS 2026-09-30 16:07:49 +02:00
Hernâni Marques
84623b7c9a
ops/dossiers-ngi: cascade bar dates, center/mobile, soft refresh; serve /cascade 2026-09-30 15:54:46 +02:00
Hernâni Marques
737d37a76d
ops/dossiers-ngi: edition tree bar, 10min tip-skip rebuild, cleanup 2026-09-30 15:36:49 +02:00
Hernâni Marques
e35205b6cd
scripts/dossiers-ngi/README.md: update branch-status-hourly.py reference 2026-09-30 13:08:46 +02:00
Hernâni Marques
8a09314eaf
docs: update README.md with new content 2026-09-30 13:01:15 +02:00
Hernâni Marques
ff934ce5e9
ops/dossiers-ngi: hourly branch-status emit tip fields only 2026-09-30 03:48:19 +02:00
Hernâni Marques
f2a0d533b8
sites/dossiers.ngi: strip meta keep links and last-built 2026-09-30 03:44:42 +02:00
Hernâni Marques
9035b9600b
docs: daylog tech-dossier Freeze cascade tip correction 2026-09-30 01:16:45 +02:00
Hernâni Marques
6e57fceb19
ops/dossiers-ngi: hourly branch-status builder + systemd timer 2026-09-29 23:26:13 +02:00
45 changed files with 3565 additions and 406 deletions

View file

@ -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) | | 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) | | 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) | | 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`): Caddy roots (SoT `scripts/caddy/Caddyfile.koopa-live-wdGet`):
@ -29,9 +29,10 @@ basic_auth: `scripts/caddy/apply-dossiers-ngi-basicauth.sh`
## Live ## Live
- [dossiers.ngi](https://dossiers.ngi.hacktivism.ch/) ← **blank cascade landing** (kal tip `56ee97a`, sites/dossiers.ngi path roots; HTTP 200 SIZE≈3505) - [dossiers.ngi](https://dossiers.ngi.hacktivism.ch/) ← **blank cascade landing** (kal `sites/dossiers.ngi.hacktivism.ch/`; EN + ASCII ladder; views tip cite `bbafc38`)
- Path Unterseiten: `/main/`, `/feat-sm-content/`, `/feat-website-features/`, `/feat-website-design/`, `/feat-website-views/` - Path editions: `/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) - 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.2.ngi](https://dossiers.2.ngi.hacktivism.ch/) ← website-features (optional)
- [dossiers.3.ngi](https://dossiers.3.ngi.hacktivism.ch/) ← sm-content (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-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.

View file

@ -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`

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"]

18
configs/tor/torrc Normal file
View file

@ -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

View file

@ -1,13 +1,30 @@
# Tor on koopa # Tor on koopa
**Runtime:** host **`tor.service`** (not a podman container). **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`).
**Ports:** ORPort **8080** · ControlPort **9051** (loopback). **Host `tor.service`:** disabled / must stay off (port clash on ORPort 8080 + ControlPort 9051).
**Non-exit** relay (`ExitRelay 0`). **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 ```bash
systemctl status tor # status
ss -lntp | grep -E '8080|9051' 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. Apply / recreate (after host tor off):
Legacy `koopa-tor-relay` / `configs/tor/` removed from live inventory (git history keeps them).
```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.

View file

@ -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/`. SoT: `koopa-admin-secrets-enc` (git-crypt) → `koopa/home-hernani/caddy/`.
Hosts: `dossiers.ngi` / `dossiers.2.ngi` / `dossiers.3.ngi`.hacktivism.ch 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 ## silverbullet.hacktivism.ch — merge + basic_auth
After podman cutover (`scripts/notes/cutover-to-silverbullet.sh --apply`) and DNS: After podman cutover (`scripts/notes/cutover-to-silverbullet.sh --apply`) and DNS:

View file

@ -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/<slug>/` 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/<slug>/` (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/<edition>` (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/<slug>/` 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/<slug>` 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`.

View file

@ -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

View file

@ -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/<slug>/
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(
"<!DOCTYPE html><html><body>"
'{% include "_cascade/bar.html" . %}'
"<h1>edition pending</h1></body></html>\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())

View file

@ -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/<name>/.
# 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/<slug>/; 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/<slug>/).
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

View file

@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Insert {% include "_cascade/bar.html" . %} after <body> 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"(<body[^>]*>)", 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())

View file

@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Inject cascade-bar include into every editions/<slug> 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"(<body[^>]*>)", 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

View file

@ -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" "<!DOCTYPE html><html><body><h1>{branch}</h1></body></html>" > "{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' '<html>stale-flask</html>' > "$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"

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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;
}

View file

@ -0,0 +1,6 @@
<link rel="stylesheet" href="/_cascade/bar.css">
<nav class="cascade-bar" aria-label="cascade builds" id="cascade-bar">
<div class="cascade-tree" id="cascade-tree" role="tree">Loading builds…</div>
<div class="cascade-hint" id="cascade-hint" role="status" hidden></div>
</nav>
<script src="/_cascade/bar.js" defer></script>

View file

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** 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 (
'<span class="cascade-mark ' +
(ok ? "cascade-ok" : "cascade-fail") +
'" title="' +
esc(freezeLabel(row)) +
'" aria-label="' +
esc(freezeLabel(row)) +
'">' +
(ok ? "✓" : "✗") +
"</span>"
);
}
function plusHtml(row) {
if (row.vs_parent_plus != null) {
var p = Number(row.vs_parent_plus);
if (!isFinite(p)) return "";
return '<span class="diff-plus">+' + esc(String(p)) + "</span>";
}
if (row.loc != null) {
var n = Number(row.loc);
if (!isFinite(n)) return "";
return '<span class="diff-loc">' + esc(String(n)) + "</span>";
}
return "";
}
function minusHtml(row) {
if (row.vs_parent_minus != null) {
var m = Number(row.vs_parent_minus);
if (!isFinite(m)) return "";
return '<span class="diff-minus">−' + esc(String(m)) + "</span>";
}
if (row.loc != null) {
return '<span class="diff-loc-unit">LOC</span>';
}
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('<div class="cascade-tree-lines" role="group">');
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(
'<a class="cascade-tree-row" role="treeitem" href="' +
esc(useHref(edition)) +
'" data-edition="' +
esc(edition) +
'" data-branch="' +
esc(branch) +
'"' +
(parent ? ' data-parent="' + esc(parent) + '"' : "") +
(isDefault ? ' data-default="1"' : "") +
' title="' +
esc(rowTitle(row)) +
'">' +
freezeMarkHtml(row) +
'<span class="cascade-name">' +
esc(prefix) +
"<code>" +
esc(branch) +
"</code></span>" +
'<span class="cascade-when">' +
esc(shortTime(row.tip_time || "")) +
"</span>" +
'<span class="cascade-built">' +
esc(builtText(row)) +
"</span>" +
'<span class="cascade-plus">' +
plusHtml(row) +
"</span>" +
'<span class="cascade-minus">' +
minusHtml(row) +
"</span>" +
"</a>"
);
}
html.push("</div>");
var t = timingText(timing, list.length);
if (t) {
html.push('<div class="cascade-timing">' + esc(t) + "</div>");
}
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 =
'<a class="cascade-tree-row" href="' +
esc(useHref("flask")) +
'" data-edition="flask" data-branch="flask"><span class="cascade-name"><code>flask</code></span></a>' +
'<span class="tip"> (branch-status unavailable)</span>';
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();
});
})();

View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>cascade editions</title>
<link rel="stylesheet" href="/_cascade/bar.css">
</head>
<body>
<nav class="cascade-bar" aria-label="cascade builds" id="cascade-bar">
<div class="cascade-tree" id="cascade-tree" role="tree">Loading builds…</div>
<div class="cascade-hint" id="cascade-hint" role="status" hidden></div>
</nav>
<p>Stuck on an edition without a bar? Pick a build above, or <a href="/_cascade/use/flask">reset to flask</a>.</p>
<script src="/_cascade/bar.js" defer></script>
</body>
</html>

View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>path not in this branch</title>
<link rel="stylesheet" href="/_cascade/bar.css">
</head>
<body class="cascade-missing">
<nav class="cascade-bar" aria-label="cascade builds" id="cascade-bar">
<div class="cascade-tree" id="cascade-tree" role="tree">Loading builds…</div>
<div class="cascade-hint" id="cascade-hint" role="status" hidden></div>
</nav>
<p class="cascade-missing-msg">This address is not in the current branch. Choose another branch above.</p>
<script src="/_cascade/bar.js" defer></script>
</body>
</html>

View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>edition pending</title>
<style>
:root { color-scheme: dark; }
body { margin:0; padding:1.5rem; font-family: ui-monospace, monospace; background:#0b0d10; color:#c8d0d8; }
</style>
</head>
<body>
{% include "_cascade/bar.html" . %}
<h1>edition pending</h1>
<p>Full freeze for this tip is not staged under <code>editions/</code> yet. Sticky bar still switches other builds.</p>
</body>
</html>

View file

@ -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
}
}

View file

@ -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;
}

View file

@ -0,0 +1,6 @@
<link rel="stylesheet" href="/_cascade/bar.css">
<nav class="cascade-bar" aria-label="cascade builds" id="cascade-bar">
<div class="cascade-tree" id="cascade-tree" role="tree">Loading builds…</div>
<div class="cascade-hint" id="cascade-hint" role="status" hidden></div>
</nav>
<script src="/_cascade/bar.js" defer></script>

View file

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** 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 (
'<span class="cascade-mark ' +
(ok ? "cascade-ok" : "cascade-fail") +
'" title="' +
esc(freezeLabel(row)) +
'" aria-label="' +
esc(freezeLabel(row)) +
'">' +
(ok ? "✓" : "✗") +
"</span>"
);
}
function plusHtml(row) {
if (row.vs_parent_plus != null) {
var p = Number(row.vs_parent_plus);
if (!isFinite(p)) return "";
return '<span class="diff-plus">+' + esc(String(p)) + "</span>";
}
if (row.loc != null) {
var n = Number(row.loc);
if (!isFinite(n)) return "";
return '<span class="diff-loc">' + esc(String(n)) + "</span>";
}
return "";
}
function minusHtml(row) {
if (row.vs_parent_minus != null) {
var m = Number(row.vs_parent_minus);
if (!isFinite(m)) return "";
return '<span class="diff-minus">−' + esc(String(m)) + "</span>";
}
if (row.loc != null) {
return '<span class="diff-loc-unit">LOC</span>';
}
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('<div class="cascade-tree-lines" role="group">');
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(
'<a class="cascade-tree-row" role="treeitem" href="' +
esc(useHref(edition)) +
'" data-edition="' +
esc(edition) +
'" data-branch="' +
esc(branch) +
'"' +
(parent ? ' data-parent="' + esc(parent) + '"' : "") +
(isDefault ? ' data-default="1"' : "") +
' title="' +
esc(rowTitle(row)) +
'">' +
freezeMarkHtml(row) +
'<span class="cascade-name">' +
esc(prefix) +
"<code>" +
esc(branch) +
"</code></span>" +
'<span class="cascade-when">' +
esc(shortTime(row.tip_time || "")) +
"</span>" +
'<span class="cascade-built">' +
esc(builtText(row)) +
"</span>" +
'<span class="cascade-plus">' +
plusHtml(row) +
"</span>" +
'<span class="cascade-minus">' +
minusHtml(row) +
"</span>" +
"</a>"
);
}
html.push("</div>");
var t = timingText(timing, list.length);
if (t) {
html.push('<div class="cascade-timing">' + esc(t) + "</div>");
}
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 =
'<a class="cascade-tree-row" href="' +
esc(useHref("flask")) +
'" data-edition="flask" data-branch="flask"><span class="cascade-name"><code>flask</code></span></a>' +
'<span class="tip"> (branch-status unavailable)</span>';
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();
});
})();

View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>cascade editions</title>
<link rel="stylesheet" href="/_cascade/bar.css">
</head>
<body>
<nav class="cascade-bar" aria-label="cascade builds" id="cascade-bar">
<div class="cascade-tree" id="cascade-tree" role="tree">Loading builds…</div>
<div class="cascade-hint" id="cascade-hint" role="status" hidden></div>
</nav>
<p>Stuck on an edition without a bar? Pick a build above, or <a href="/_cascade/use/flask">reset to flask</a>.</p>
<script src="/_cascade/bar.js" defer></script>
</body>
</html>

View file

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>feat/sm-content</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 1.5rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 14px; line-height: 1.45;
background: #0b0d10; color: #c8d0d8;
}
a { color: #7ec8ff; text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.5rem; color: #e8eef4; }
.meta { color: #8a96a3; margin: 0 0 1rem; }
ul.plain { padding-left: 1.2rem; margin: 0; }
li { margin: 0.25rem 0; }
.dim { color: #6a7682; }
</style>
</head>
<body>
{% include "_cascade/bar.html" . %}
<h1><code>/feat-sm-content/</code></h1>
<p class="meta">last-built: 2026-09-30T02:30:50+02:00 <span class="dim">·</span> <code>feat/sm-content</code> @ <code>089ef1f</code></p>
</body>
</html>

View file

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>feat/website-design</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 1.5rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 14px; line-height: 1.45;
background: #0b0d10; color: #c8d0d8;
}
a { color: #7ec8ff; text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.5rem; color: #e8eef4; }
.meta { color: #8a96a3; margin: 0 0 1rem; }
ul.plain { padding-left: 1.2rem; margin: 0; }
li { margin: 0.25rem 0; }
.dim { color: #6a7682; }
</style>
</head>
<body>
{% include "_cascade/bar.html" . %}
<h1><code>/feat-website-design/</code></h1>
<p class="meta">last-built: 2026-09-30T02:30:50+02:00 <span class="dim">·</span> <code>feat/website-design</code> @ <code>2e26c80</code></p>
</body>
</html>

View file

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>feat/website-features</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 1.5rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 14px; line-height: 1.45;
background: #0b0d10; color: #c8d0d8;
}
a { color: #7ec8ff; text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.5rem; color: #e8eef4; }
.meta { color: #8a96a3; margin: 0 0 1rem; }
ul.plain { padding-left: 1.2rem; margin: 0; }
li { margin: 0.25rem 0; }
.dim { color: #6a7682; }
</style>
</head>
<body>
{% include "_cascade/bar.html" . %}
<h1><code>/feat-website-features/</code></h1>
<p class="meta">last-built: 2026-09-30T02:30:50+02:00 <span class="dim">·</span> <code>feat/website-features</code> @ <code>53a3176</code></p>
</body>
</html>

View file

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>feat/website-views</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 1.5rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 14px; line-height: 1.45;
background: #0b0d10; color: #c8d0d8;
}
a { color: #7ec8ff; text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.5rem; color: #e8eef4; }
.meta { color: #8a96a3; margin: 0 0 1rem; }
ul.plain { padding-left: 1.2rem; margin: 0; }
li { margin: 0.25rem 0; }
.dim { color: #6a7682; }
</style>
</head>
<body>
{% include "_cascade/bar.html" . %}
<h1><code>/feat-website-views/</code></h1>
<p class="meta">last-built: 2026-09-30T02:30:50+02:00 <span class="dim">·</span> <code>feat/website-views</code> @ <code>bbafc38</code></p>
</body>
</html>

View file

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>cascade landing</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 1.5rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 14px; line-height: 1.45;
background: #0b0d10; color: #c8d0d8;
}
a { color: #7ec8ff; text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.5rem; color: #e8eef4; }
.meta { color: #8a96a3; margin: 0 0 1rem; }
ul.plain { padding-left: 1.2rem; margin: 0; }
li { margin: 0.25rem 0; }
.dim { color: #6a7682; }
</style>
</head>
<body>
{% include "_cascade/bar.html" . %}
<h1>cascade</h1>
<p class="meta">Flask freeze lives at <a href="/"><code>/</code></a> (views). This ladder is tip stubs only.</p>
<p class="meta">last-built: 2026-09-30T02:30:50+02:00</p>
<ul class="plain">
<li><a href="/cascade/main/"><code>/main/</code></a> <span class="dim">·</span> <code>main</code> @ <code>d2fbd79</code></li>
<li><a href="/cascade/feat-sm-content/"><code>/feat-sm-content/</code></a> <span class="dim">·</span> <code>feat/sm-content</code> @ <code>089ef1f</code></li>
<li><a href="/cascade/feat-website-features/"><code>/feat-website-features/</code></a> <span class="dim">·</span> <code>feat/website-features</code> @ <code>53a3176</code></li>
<li><a href="/cascade/feat-website-design/"><code>/feat-website-design/</code></a> <span class="dim">·</span> <code>feat/website-design</code> @ <code>2e26c80</code></li>
<li><a href="/cascade/feat-website-views/"><code>/feat-website-views/</code></a> <span class="dim">·</span> <code>feat/website-views</code> @ <code>bbafc38</code></li>
</ul>
</body>
</html>

View file

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>main</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 1.5rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 14px; line-height: 1.45;
background: #0b0d10; color: #c8d0d8;
}
a { color: #7ec8ff; text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.5rem; color: #e8eef4; }
.meta { color: #8a96a3; margin: 0 0 1rem; }
ul.plain { padding-left: 1.2rem; margin: 0; }
li { margin: 0.25rem 0; }
.dim { color: #6a7682; }
</style>
</head>
<body>
{% include "_cascade/bar.html" . %}
<h1><code>/main/</code></h1>
<p class="meta">last-built: 2026-09-30T02:30:50+02:00 <span class="dim">·</span> <code>main</code> @ <code>d2fbd79</code></p>
</body>
</html>

View file

@ -1,64 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>feat/sm-content · cascade</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 1.5rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 14px; line-height: 1.45;
background: #0b0d10; color: #c8d0d8;
}
a { color: #7ec8ff; text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.75rem; color: #e8eef4; }
h2 { font-size: 1rem; font-weight: 600; margin: 1.25rem 0 0.5rem; color: #e8eef4; }
.meta { color: #8a96a3; margin-bottom: 1rem; }
.box {
border: 1px solid #2a3340; background: #12161c;
padding: 0.85rem 1rem; margin: 0.75rem 0; white-space: pre; overflow-x: auto;
}
.nav { margin: 0.5rem 0 1rem; }
.nav a { margin-right: 0.85rem; }
.note { color: #b8a46a; margin-top: 1rem; max-width: 72ch; white-space: normal; }
.ok { color: #7dcea0; }
.dim { color: #6a7682; }
ul.plain { padding-left: 1.2rem; }
li { margin: 0.25rem 0; }
</style>
</head>
<body>
<h1>edition: feat/sm-content</h1>
<p class="meta">path <code>/feat-sm-content/</code> &middot; tip <code>089ef1f</code> &middot; depends on <a href="/main/"><code>main</code></a></p>
<div class="nav">
<a href="/">home</a>
<a href="/main/">/main/</a>
<a href="/feat-sm-content/">/feat-sm-content/</a>
<a href="/feat-website-features/">/feat-website-features/</a>
<a href="/feat-website-design/">/feat-website-design/</a>
<a href="/feat-website-views/">/feat-website-views/</a>
</div>
<h2>ladder position</h2>
<div class="box">cascade ladder (path roots on dossiers.ngi only)
<a href='/main/'>main</a> @ d2fbd79
<span class='dim'> |</span>
<span class='ok'>* +-- feat/sm-content @ 089ef1f &lt;-- this edition</span>
<span class='dim'> |</span>
<span class='dim'> +--</span> <a href='/feat-website-features/'>feat/website-features</a> @ 53a3176
<span class='dim'> |</span>
<span class='dim'> +--</span> <a href='/feat-website-design/'>feat/website-design</a> @ 2e26c80
<span class='dim'> |</span>
<span class='dim'> +--</span> <a href='/feat-website-views/'>feat/website-views</a> @ bbafc38</div>
<h2>what this looks like</h2>
<p>SM dossier prose content on top of main.</p>
<p class="dim">Blank relational stub. Frozen Flask build for this tip is separate; this page only shows cascade relation and path root.</p>
</body>
</html>

View file

@ -1,64 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>feat/website-design · cascade</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 1.5rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 14px; line-height: 1.45;
background: #0b0d10; color: #c8d0d8;
}
a { color: #7ec8ff; text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.75rem; color: #e8eef4; }
h2 { font-size: 1rem; font-weight: 600; margin: 1.25rem 0 0.5rem; color: #e8eef4; }
.meta { color: #8a96a3; margin-bottom: 1rem; }
.box {
border: 1px solid #2a3340; background: #12161c;
padding: 0.85rem 1rem; margin: 0.75rem 0; white-space: pre; overflow-x: auto;
}
.nav { margin: 0.5rem 0 1rem; }
.nav a { margin-right: 0.85rem; }
.note { color: #b8a46a; margin-top: 1rem; max-width: 72ch; white-space: normal; }
.ok { color: #7dcea0; }
.dim { color: #6a7682; }
ul.plain { padding-left: 1.2rem; }
li { margin: 0.25rem 0; }
</style>
</head>
<body>
<h1>edition: feat/website-design</h1>
<p class="meta">path <code>/feat-website-design/</code> &middot; tip <code>2e26c80</code> &middot; depends on <a href="/feat-website-features/"><code>feat/website-features</code></a></p>
<div class="nav">
<a href="/">home</a>
<a href="/main/">/main/</a>
<a href="/feat-sm-content/">/feat-sm-content/</a>
<a href="/feat-website-features/">/feat-website-features/</a>
<a href="/feat-website-design/">/feat-website-design/</a>
<a href="/feat-website-views/">/feat-website-views/</a>
</div>
<h2>ladder position</h2>
<div class="box">cascade ladder (path roots on dossiers.ngi only)
<a href='/main/'>main</a> @ d2fbd79
<span class='dim'> |</span>
<span class='dim'> +--</span> <a href='/feat-sm-content/'>feat/sm-content</a> @ 089ef1f
<span class='dim'> |</span>
<span class='dim'> +--</span> <a href='/feat-website-features/'>feat/website-features</a> @ 53a3176
<span class='dim'> |</span>
<span class='ok'>* +-- feat/website-design @ 2e26c80 &lt;-- this edition</span>
<span class='dim'> |</span>
<span class='dim'> +--</span> <a href='/feat-website-views/'>feat/website-views</a> @ bbafc38</div>
<h2>what this looks like</h2>
<p>Design/CSS layer on top of features.</p>
<p class="dim">Blank relational stub. Frozen Flask build for this tip is separate; this page only shows cascade relation and path root.</p>
</body>
</html>

View file

@ -1,64 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>feat/website-features · cascade</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 1.5rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 14px; line-height: 1.45;
background: #0b0d10; color: #c8d0d8;
}
a { color: #7ec8ff; text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.75rem; color: #e8eef4; }
h2 { font-size: 1rem; font-weight: 600; margin: 1.25rem 0 0.5rem; color: #e8eef4; }
.meta { color: #8a96a3; margin-bottom: 1rem; }
.box {
border: 1px solid #2a3340; background: #12161c;
padding: 0.85rem 1rem; margin: 0.75rem 0; white-space: pre; overflow-x: auto;
}
.nav { margin: 0.5rem 0 1rem; }
.nav a { margin-right: 0.85rem; }
.note { color: #b8a46a; margin-top: 1rem; max-width: 72ch; white-space: normal; }
.ok { color: #7dcea0; }
.dim { color: #6a7682; }
ul.plain { padding-left: 1.2rem; }
li { margin: 0.25rem 0; }
</style>
</head>
<body>
<h1>edition: feat/website-features</h1>
<p class="meta">path <code>/feat-website-features/</code> &middot; tip <code>53a3176</code> &middot; depends on <a href="/feat-sm-content/"><code>feat/sm-content</code></a></p>
<div class="nav">
<a href="/">home</a>
<a href="/main/">/main/</a>
<a href="/feat-sm-content/">/feat-sm-content/</a>
<a href="/feat-website-features/">/feat-website-features/</a>
<a href="/feat-website-design/">/feat-website-design/</a>
<a href="/feat-website-views/">/feat-website-views/</a>
</div>
<h2>ladder position</h2>
<div class="box">cascade ladder (path roots on dossiers.ngi only)
<a href='/main/'>main</a> @ d2fbd79
<span class='dim'> |</span>
<span class='dim'> +--</span> <a href='/feat-sm-content/'>feat/sm-content</a> @ 089ef1f
<span class='dim'> |</span>
<span class='ok'>* +-- feat/website-features @ 53a3176 &lt;-- this edition</span>
<span class='dim'> |</span>
<span class='dim'> +--</span> <a href='/feat-website-design/'>feat/website-design</a> @ 2e26c80
<span class='dim'> |</span>
<span class='dim'> +--</span> <a href='/feat-website-views/'>feat/website-views</a> @ bbafc38</div>
<h2>what this looks like</h2>
<p>Website feature routes on top of sm-content.</p>
<p class="dim">Blank relational stub. Frozen Flask build for this tip is separate; this page only shows cascade relation and path root.</p>
</body>
</html>

View file

@ -1,68 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>feat/website-views · cascade</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 1.5rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 14px; line-height: 1.45;
background: #0b0d10; color: #c8d0d8;
}
a { color: #7ec8ff; text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.75rem; color: #e8eef4; }
h2 { font-size: 1rem; font-weight: 600; margin: 1.25rem 0 0.5rem; color: #e8eef4; }
.meta { color: #8a96a3; margin-bottom: 1rem; }
.box {
border: 1px solid #2a3340; background: #12161c;
padding: 0.85rem 1rem; margin: 0.75rem 0; white-space: pre; overflow-x: auto;
}
.nav { margin: 0.5rem 0 1rem; }
.nav a { margin-right: 0.85rem; }
.note { color: #b8a46a; margin-top: 1rem; max-width: 72ch; white-space: normal; }
.ok { color: #7dcea0; }
.dim { color: #6a7682; }
ul.plain { padding-left: 1.2rem; }
li { margin: 0.25rem 0; }
</style>
</head>
<body>
<h1>edition: feat/website-views</h1>
<p class="meta">path <code>/feat-website-views/</code> &middot; tip <code>bbafc38</code> &middot; depends on <a href="/feat-website-design/"><code>feat/website-design</code></a></p>
<div class="nav">
<a href="/">home</a>
<a href="/main/">/main/</a>
<a href="/feat-sm-content/">/feat-sm-content/</a>
<a href="/feat-website-features/">/feat-website-features/</a>
<a href="/feat-website-design/">/feat-website-design/</a>
<a href="/feat-website-views/">/feat-website-views/</a>
</div>
<h2>ladder position</h2>
<div class="box">cascade ladder (path roots on dossiers.ngi only)
<a href='/main/'>main</a> @ d2fbd79
<span class='dim'> |</span>
<span class='dim'> +--</span> <a href='/feat-sm-content/'>feat/sm-content</a> @ 089ef1f
<span class='dim'> |</span>
<span class='dim'> +--</span> <a href='/feat-website-features/'>feat/website-features</a> @ 53a3176
<span class='dim'> |</span>
<span class='dim'> +--</span> <a href='/feat-website-design/'>feat/website-design</a> @ 2e26c80
<span class='dim'> |</span>
<span class='ok'>* +-- feat/website-views @ bbafc38 &lt;-- this edition</span></div>
<h2>what this looks like</h2>
<p>Views/list layer on top of design (origin tip).</p>
<p class="dim">Blank relational stub. Frozen Flask build for this tip is separate; this page only shows cascade relation and path root.</p>
<p class="note"><strong>views gap vs dossiers.ngi-0.eu:</strong>
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.</p>
</body>
</html>

View file

@ -1,69 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>cascade landing</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 1.5rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 14px; line-height: 1.45;
background: #0b0d10; color: #c8d0d8;
}
a { color: #7ec8ff; text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.75rem; color: #e8eef4; }
h2 { font-size: 1rem; font-weight: 600; margin: 1.25rem 0 0.5rem; color: #e8eef4; }
.meta { color: #8a96a3; margin-bottom: 1rem; }
.box {
border: 1px solid #2a3340; background: #12161c;
padding: 0.85rem 1rem; margin: 0.75rem 0; white-space: pre; overflow-x: auto;
}
.nav { margin: 0.5rem 0 1rem; }
.nav a { margin-right: 0.85rem; }
.note { color: #b8a46a; margin-top: 1rem; max-width: 72ch; white-space: normal; }
.ok { color: #7dcea0; }
.dim { color: #6a7682; }
ul.plain { padding-left: 1.2rem; }
li { margin: 0.25rem 0; }
</style>
</head>
<body>
<h1>tech-dossier cascade landing</h1>
<p class="meta">host: dossiers.ngi.hacktivism.ch &middot; blank relational index &middot; owned by koopa-admin-log</p>
<div class="nav">
<a href="/">/</a>
<a href="/main/">/main/</a>
<a href="/feat-sm-content/">/feat-sm-content/</a>
<a href="/feat-website-features/">/feat-website-features/</a>
<a href="/feat-website-design/">/feat-website-design/</a>
<a href="/feat-website-views/">/feat-website-views/</a>
</div>
<h2>relation to main</h2>
<div class="box">cascade ladder (path roots on dossiers.ngi only)
<a href='/main/'>main</a> @ d2fbd79
<span class='dim'> |</span>
<span class='dim'> +--</span> <a href='/feat-sm-content/'>feat/sm-content</a> @ 089ef1f
<span class='dim'> |</span>
<span class='dim'> +--</span> <a href='/feat-website-features/'>feat/website-features</a> @ 53a3176
<span class='dim'> |</span>
<span class='dim'> +--</span> <a href='/feat-website-design/'>feat/website-design</a> @ 2e26c80
<span class='dim'> |</span>
<span class='dim'> +--</span> <a href='/feat-website-views/'>feat/website-views</a> @ bbafc38</div>
<h2>editions</h2>
<ul class="plain">
<li><a href="/main/"><code>/main/</code></a> &mdash; <code>main</code> @ <code>d2fbd79</code> &middot; base<br><span class="dim">Base freeze tip.</span></li>
<li><a href="/feat-sm-content/"><code>/feat-sm-content/</code></a> &mdash; <code>feat/sm-content</code> @ <code>089ef1f</code> &middot; depends on <code>main</code><br><span class="dim">SM dossier prose content on top of main.</span></li>
<li><a href="/feat-website-features/"><code>/feat-website-features/</code></a> &mdash; <code>feat/website-features</code> @ <code>53a3176</code> &middot; depends on <code>feat/sm-content</code><br><span class="dim">Website feature routes on top of sm-content.</span></li>
<li><a href="/feat-website-design/"><code>/feat-website-design/</code></a> &mdash; <code>feat/website-design</code> @ <code>2e26c80</code> &middot; depends on <code>feat/website-features</code><br><span class="dim">Design/CSS layer on top of features.</span></li>
<li><a href="/feat-website-views/"><code>/feat-website-views/</code></a> &mdash; <code>feat/website-views</code> @ <code>bbafc38</code> &middot; depends on <code>feat/website-design</code><br><span class="dim">Views/list layer on top of design (origin tip).</span></li>
</ul>
<p class="note">Each path is its own root for that branch edition. Single public host dossiers.ngi only.</p>
</body>
</html>

View file

@ -1,64 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>main · cascade</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 1.5rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 14px; line-height: 1.45;
background: #0b0d10; color: #c8d0d8;
}
a { color: #7ec8ff; text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.75rem; color: #e8eef4; }
h2 { font-size: 1rem; font-weight: 600; margin: 1.25rem 0 0.5rem; color: #e8eef4; }
.meta { color: #8a96a3; margin-bottom: 1rem; }
.box {
border: 1px solid #2a3340; background: #12161c;
padding: 0.85rem 1rem; margin: 0.75rem 0; white-space: pre; overflow-x: auto;
}
.nav { margin: 0.5rem 0 1rem; }
.nav a { margin-right: 0.85rem; }
.note { color: #b8a46a; margin-top: 1rem; max-width: 72ch; white-space: normal; }
.ok { color: #7dcea0; }
.dim { color: #6a7682; }
ul.plain { padding-left: 1.2rem; }
li { margin: 0.25rem 0; }
</style>
</head>
<body>
<h1>edition: main</h1>
<p class="meta">path <code>/main/</code> &middot; tip <code>d2fbd79</code> &middot; depends on <span class='dim'>none (base)</span></p>
<div class="nav">
<a href="/">home</a>
<a href="/main/">/main/</a>
<a href="/feat-sm-content/">/feat-sm-content/</a>
<a href="/feat-website-features/">/feat-website-features/</a>
<a href="/feat-website-design/">/feat-website-design/</a>
<a href="/feat-website-views/">/feat-website-views/</a>
</div>
<h2>ladder position</h2>
<div class="box">cascade ladder (path roots on dossiers.ngi only)
<span class='ok'>* main @ d2fbd79 &lt;-- this edition</span>
<span class='dim'> |</span>
<span class='dim'> +--</span> <a href='/feat-sm-content/'>feat/sm-content</a> @ 089ef1f
<span class='dim'> |</span>
<span class='dim'> +--</span> <a href='/feat-website-features/'>feat/website-features</a> @ 53a3176
<span class='dim'> |</span>
<span class='dim'> +--</span> <a href='/feat-website-design/'>feat/website-design</a> @ 2e26c80
<span class='dim'> |</span>
<span class='dim'> +--</span> <a href='/feat-website-views/'>feat/website-views</a> @ bbafc38</div>
<h2>what this looks like</h2>
<p>Base freeze tip.</p>
<p class="dim">Blank relational stub. Frozen Flask build for this tip is separate; this page only shows cascade relation and path root.</p>
</body>
</html>