Compare commits

...

16 commits

Author SHA1 Message Date
Hernâni Marques
261c25c87f
fix(dossiers-ngi): update README and scripts for branch status 2026-10-01 11:39:45 +02:00
Hernâni Marques
7397cddb83
feat(dossiers-ngi): sticky bar vs-parent loc-on-roots split plus/minus 45rem 2026-09-30 23:45:02 +02:00
Hernâni Marques
da6f384399
feat(dossiers-ngi): sticky bar vs-main compact dates tabular wider tree 2026-09-30 23:23:14 +02:00
Hernâni Marques
52e5c1d328
feat(dossiers-ngi): inject-editions-bar.sh for freeze HTML missing include 2026-09-30 22:25:38 +02:00
Hernâni Marques
27c7f4e37e
feat(dossiers-ngi): sticky bar + JSON diffstat vs parent tip 2026-09-30 22:17:03 +02:00
Hernâni Marques
31af42538a
feat: dossiers-ngi sticky bar vs-main +green/−red diffstat 2026-09-30 16:07:28 +02:00
Hernâni Marques
a4ec0305bc
fix: dossiers-ngi Caddy explicit edition_dir map (cookie placeholder 404) 2026-09-30 16:00:55 +02:00
Hernâni Marques
10d445c712
ops: dossiers-ngi Caddy Vary Cookie + no-cache for edition-pure CSS 2026-09-30 15:56:50 +02:00
Hernâni Marques
5677318ae2
ops: dossiers-ngi cascade bar dates, center/mobile, soft refresh; serve /cascade 2026-09-30 15:52:25 +02:00
Hernâni Marques
ee82ef9b6e
ops: dossiers-ngi edition tree bar, 10min tip-skip rebuild, cleanup 2026-09-30 15:35:35 +02:00
Hernâni Marques
ebecb26c7c
scripts/dossiers-ngi/README.md: update branch-status-hourly.py reference 2026-09-30 12:52:38 +02:00
Hernâni Marques
467092db1f
docs: update README.md with new content 2026-09-30 12:50:54 +02:00
Hernâni Marques
c1fefcc766
ops: dossiers-ngi hourly branch-status emit tip fields only 2026-09-30 03:41:53 +02:00
Hernâni Marques
c406352e1a
sites/dossiers.ngi: strip meta keep links and last-built 2026-09-30 03:41:53 +02:00
Hernâni Marques
ef0ff7f60d
docs: daylog tech-dossier Freeze cascade tip correction 2026-09-30 03:41:53 +02:00
Hernâni Marques
2b9f6c6b83
ops: dossiers-ngi hourly branch-status builder + systemd timer 2026-09-30 03:41:53 +02:00
32 changed files with 2663 additions and 399 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,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,140 @@
# 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).
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`.
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,753 @@
#!/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 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 = editions / edition_slug(src_branch)
dst = editions / "flask"
tip = tips[src_branch]
if tip_recorded(editions, "flask") == tip and (dst / "index.html").is_file():
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,65 @@
# 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"
redir * / 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,120 @@
#!/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"
# 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,120 @@
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;
}

View file

@ -0,0 +1,5 @@
<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>
</nav>
<script src="/_cascade/bar.js" defer></script>

View file

@ -0,0 +1,351 @@
(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 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="/_cascade/use/' +
esc(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="/_cascade/use/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();
}
});
}
loadStatus(false);
setInterval(function () {
loadStatus(true);
}, POLL_MS);
window.addEventListener("resize", function () {
pinBar();
});
})();

View file

@ -0,0 +1,16 @@
<!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>
</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>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,120 @@
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;
}

View file

@ -0,0 +1,5 @@
<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>
</nav>
<script src="/_cascade/bar.js" defer></script>

View file

@ -0,0 +1,351 @@
(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 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="/_cascade/use/' +
esc(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="/_cascade/use/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();
}
});
}
loadStatus(false);
setInterval(function () {
loadStatus(true);
}, POLL_MS);
window.addEventListener("resize", function () {
pinBar();
});
})();

View file

@ -0,0 +1,16 @@
<!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>
</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>