ops: dossiers-ngi edition tree bar, 10min tip-skip rebuild, cleanup
This commit is contained in:
parent
ebecb26c7c
commit
ee82ef9b6e
22 changed files with 1458 additions and 173 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Write dossiers.ngi branch-status.json from tdpd tips
|
Description=dossiers.ngi branch-status JSON + on-demand edition rebuild
|
||||||
Documentation=file:%h/src/koopa/koopa-admin-log/scripts/dossiers-ngi/README.md
|
Documentation=file:%h/src/koopa/koopa-admin-log/scripts/dossiers-ngi/README.md
|
||||||
After=default.target
|
After=default.target
|
||||||
|
|
||||||
|
|
@ -7,14 +7,18 @@ After=default.target
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
Nice=10
|
Nice=10
|
||||||
RemainAfterExit=no
|
RemainAfterExit=no
|
||||||
|
# Full freezes per changed tip can take a while; skip overlap via flock
|
||||||
|
TimeoutStartSec=1h
|
||||||
Environment=TZ=Europe/Zurich
|
Environment=TZ=Europe/Zurich
|
||||||
Environment=ADMIN_LOG=%h/src/koopa/koopa-admin-log
|
Environment=ADMIN_LOG=%h/src/koopa/koopa-admin-log
|
||||||
# Override on koopa if the tdpd checkout path differs
|
|
||||||
Environment=TDPD_ROOT=%h/git/src/ngi/tech-dossier-project-data
|
Environment=TDPD_ROOT=%h/git/src/ngi/tech-dossier-project-data
|
||||||
# Live site root (timer must refresh public JSON, not only the kal tree)
|
|
||||||
Environment=OUT=/var/www/monitoring-sites/dossiers.ngi.hacktivism.ch/branch-status.json
|
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 .edition-tip differs / missing (never force from timer)
|
||||||
|
Environment=REBUILD=1
|
||||||
EnvironmentFile=-%h/.config/dossiers-ngi/branch-status.env
|
EnvironmentFile=-%h/.config/dossiers-ngi/branch-status.env
|
||||||
ExecStart=/usr/bin/python3 %h/src/koopa/koopa-admin-log/scripts/dossiers-ngi/branch-status-hourly.py
|
# 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
|
SuccessExitStatus=0 1
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Timer · dossiers.ngi branch-status JSON (hourly)
|
Description=Timer · dossiers.ngi branch-status JSON (every 10 min)
|
||||||
Documentation=file:%h/src/koopa/koopa-admin-log/scripts/dossiers-ngi/README.md
|
Documentation=file:%h/src/koopa/koopa-admin-log/scripts/dossiers-ngi/README.md
|
||||||
|
|
||||||
[Timer]
|
[Timer]
|
||||||
OnBootSec=3min
|
OnBootSec=3min
|
||||||
OnUnitActiveSec=1h
|
OnUnitActiveSec=10min
|
||||||
AccuracySec=2min
|
AccuracySec=1min
|
||||||
|
RandomizedDelaySec=30
|
||||||
Persistent=true
|
Persistent=true
|
||||||
Unit=dossiers-ngi-branch-status.service
|
Unit=dossiers-ngi-branch-status.service
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,88 +1,109 @@
|
||||||
# dossiers-ngi — cascade landing + hourly branch-status
|
# dossiers-ngi — dynamic edition bar + 10‑min rebuild
|
||||||
|
|
||||||
SoT for the blank cascade landing on **dossiers.ngi.hacktivism.ch**
|
SoT for the sticky edition bar on **dossiers.ngi.hacktivism.ch**. Live freezes
|
||||||
(path roots only; no dossiers.2 / dossiers.3 ownership here).
|
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 |
|
| Path | Role |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `sites/dossiers.ngi.hacktivism.ch/` | Landing HTML (EN, ASCII ladder) |
|
| `sites/…/_cascade/` | Sticky bar shell + JS tree renderer |
|
||||||
| `scripts/dossiers-ngi/branch-status-hourly.py` | Read tips from tdpd checkout → `branch-status.json` |
|
| `editions/` on koopa www | Full freezes switched by the bar |
|
||||||
| `configs/systemd/user/dossiers-ngi-branch-status.{timer,service}` | Hourly oneshot on koopa |
|
| `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
|
## Env
|
||||||
|
|
||||||
| Var | Default | Meaning |
|
| Var | Default | Meaning |
|
||||||
|-----|---------|---------|
|
|-----|---------|---------|
|
||||||
| `TDPD_ROOT` | `$HOME/git/src/ngi/tech-dossier-project-data` | tech-dossier-project-data checkout for tip SHAs |
|
| `TDPD_ROOT` | `$HOME/git/src/ngi/tech-dossier-project-data` | tdpd git checkout (may be git-dir only) |
|
||||||
| `OUT` | `sites/dossiers.ngi.hacktivism.ch/branch-status.json` under kal | write path for JSON (live www on koopa) |
|
| `TDPD_VENV` | `(TDPD_ROOT)/.venv` | Frozen-Flask venv; symlinked into worktrees |
|
||||||
| `ADMIN_LOG` | (script derives from `__file__`) | koopa-admin-log root |
|
| `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 the clone often lives at `%h/src/koopa/koopa-admin-log`; set
|
On koopa: `%h/src/koopa/koopa-admin-log` +
|
||||||
`TDPD_ROOT` and `OUT` in `~/.config/dossiers-ngi/branch-status.env` when the
|
`~/.config/dossiers-ngi/branch-status.env` for path overrides.
|
||||||
tdpd path or live www path differs from the laptop defaults.
|
NGI clone token lives in **koopa-admin-secrets-enc** (not this tree).
|
||||||
|
|
||||||
## Manual run
|
## Manual run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# JSON only
|
||||||
python3 scripts/dossiers-ngi/branch-status-hourly.py
|
python3 scripts/dossiers-ngi/branch-status-hourly.py
|
||||||
# → sites/dossiers.ngi.hacktivism.ch/branch-status.json
|
|
||||||
|
# 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 |
|
||||||
|
|
||||||
|
Rebuild runs **only when** `.edition-tip` ≠ current tip (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)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deploy landing (Freigabe)
|
## Deploy landing (Freigabe)
|
||||||
|
|
||||||
```text
|
Do **not** `--delete` the whole www root. Sync:
|
||||||
hernani@koopa:/var/www/monitoring-sites/dossiers.ngi.hacktivism.ch/
|
|
||||||
|
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
rsync from `sites/dossiers.ngi.hacktivism.ch/` — Freigabe this session required.
|
|
||||||
Hourly JSON can sit next to the HTML once the timer is installed on koopa
|
|
||||||
(Freigabe for host mutation).
|
|
||||||
|
|
||||||
## Ownership
|
|
||||||
|
|
||||||
Hourly builder belongs in **kal** (this tree). Do **not** put
|
|
||||||
`scripts/branch-status-hourly.py` back into tdpd.
|
|
||||||
|
|
||||||
## Sticky cascade bar
|
## Sticky cascade bar
|
||||||
|
|
||||||
Stock Caddy `templates` renders `{% include "_cascade/bar.html" . %}` on each
|
`bar.js` fetches `/branch-status.json` and draws:
|
||||||
HTML page. The include path has no leading slash. The dot passes the request,
|
|
||||||
so `.Req.URL.Path` marks the active link with `class="is-active"` and
|
|
||||||
`aria-current="page"`. Delimiters are `{%` `%}`, so a later `{{ }}` in real
|
|
||||||
dossier HTML stays literal.
|
|
||||||
|
|
||||||
The bar lists cascade `/` plus the five branch paths. Seeded tips match
|
```
|
||||||
`branch-status.json`. `/_cascade/bar.js` refreshes `[data-tip]` from that JSON
|
main @ d2fbd79
|
||||||
(`cache: no-store`). CSS is `/_cascade/bar.css` (`position: sticky`).
|
├── feat/sm-content @ …
|
||||||
|
│ └── …
|
||||||
|
└── project_template @ …
|
||||||
|
```
|
||||||
|
|
||||||
Enable templates before rsync. A page without the include does not grow the
|
Each name links to `/_cascade/use/<edition>` (cookie + 302 `/`). Caddy map:
|
||||||
bar. `{%` without the templates directive is shown as raw text.
|
|
||||||
|
|
||||||
Templates apply is **host-local on koopa** (`~/bin/apply-dossiers-ngi-templates.sh`),
|
```
|
||||||
not tracked in this repo. It edits `~/koopa-caddy/Caddyfile` only (this host;
|
~^[A-Za-z0-9_-]+$ {http.request.cookie.tdpd_edition}
|
||||||
keeps `header Alt-Svc "clear"` and any `basic_auth`; leaves `.2` / `.3`
|
```
|
||||||
untouched). Then reload with the usual home→`/etc` helper:
|
|
||||||
|
so new branches need no Caddy edit — only an `editions/<slug>/` tree.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash ~/bin/apply-dossiers-ngi-templates.sh
|
bash ~/bin/apply-dossiers-ngi-edition-switch.sh
|
||||||
sudo ~/bin/caddy-apply
|
sudo ~/bin/caddy-apply
|
||||||
```
|
```
|
||||||
|
|
||||||
The stanza body for hand-check / re-seed lives in
|
Stanza SoT: `scripts/dossiers-ngi/caddy-dossiers-ngi.snippet`.
|
||||||
`scripts/dossiers-ngi/caddy-dossiers-ngi.snippet`.
|
|
||||||
`scripts/caddy/caddy-apply.sh --snippet` leaves an existing block unchanged.
|
|
||||||
Leave `scripts/caddy/apply-goa-wdGet-full-replace.sh` and
|
|
||||||
`scripts/caddy/apply-dossiers-ngi-basicauth.sh` unused. No response-body
|
|
||||||
replace plugin.
|
|
||||||
|
|
||||||
After validate and reload succeed, rsync from the laptop (Freigabe):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rsync -avz --delete --exclude .DS_Store \
|
|
||||||
"$HOME/git/admin-logs/local/koopa-admin-log/sites/dossiers.ngi.hacktivism.ch/" \
|
|
||||||
hernani@koopa:/var/www/monitoring-sites/dossiers.ngi.hacktivism.ch/
|
|
||||||
```
|
|
||||||
|
|
||||||
`--delete` removes `_cascade/` on the server when the source tree is an older
|
|
||||||
copy. The bar does not finish the dossier site. This section does not install
|
|
||||||
the hourly timer.
|
|
||||||
|
|
|
||||||
82
scripts/dossiers-ngi/apply-dossiers-ngi-edition-switch.sh
Executable file
82
scripts/dossiers-ngi/apply-dossiers-ngi-edition-switch.sh
Executable 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
|
||||||
|
|
@ -1,90 +1,637 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Emit branch-status.json for the dossiers.ngi path-root ladder.
|
"""Discover all origin branches, emit branch-status.json, optional edition rebuild.
|
||||||
|
|
||||||
Reads tip SHAs from the tech-dossier-project-data git checkout
|
Dynamic cascade for dossiers.ngi.hacktivism.ch:
|
||||||
(TDPD_ROOT / default). Writes under the kal landing SoT:
|
|
||||||
|
|
||||||
sites/dossiers.ngi.hacktivism.ch/branch-status.json
|
• 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)
|
||||||
|
|
||||||
Path roots only under https://dossiers.ngi.hacktivism.ch/
|
Env:
|
||||||
(/main/, /feat-sm-content/, /feat-website-features/,
|
|
||||||
/feat-website-design/, /feat-website-views/).
|
TDPD_ROOT tech-dossier-project-data checkout (may be git-dir only)
|
||||||
Single public host only. Hourly builder lives in kal, not tdpd.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ADMIN_LOG = Path(__file__).resolve().parents[2]
|
ADMIN_LOG = Path(__file__).resolve().parents[2]
|
||||||
_DEFAULT_OUT = ADMIN_LOG / "sites" / "dossiers.ngi.hacktivism.ch" / "branch-status.json"
|
_DEFAULT_OUT = ADMIN_LOG / "sites" / "dossiers.ngi.hacktivism.ch" / "branch-status.json"
|
||||||
HOST = "https://dossiers.ngi.hacktivism.ch"
|
_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:
|
def out_path() -> Path:
|
||||||
raw = os.environ.get("OUT", "").strip()
|
return env_path("OUT", _DEFAULT_OUT) # type: ignore[return-value]
|
||||||
return Path(raw).expanduser() if raw else _DEFAULT_OUT
|
|
||||||
|
|
||||||
# Default local clone; override with TDPD_ROOT (koopa may use a different path).
|
|
||||||
DEFAULT_TDPD = Path.home() / "git" / "src" / "ngi" / "tech-dossier-project-data"
|
|
||||||
|
|
||||||
LADDER = [
|
|
||||||
("main", "", f"{HOST}/main/"),
|
|
||||||
("feat/sm-content", "main", f"{HOST}/feat-sm-content/"),
|
|
||||||
("feat/website-features", "feat/sm-content", f"{HOST}/feat-website-features/"),
|
|
||||||
("feat/website-design", "feat/website-features", f"{HOST}/feat-website-design/"),
|
|
||||||
("feat/website-views", "feat/website-design", f"{HOST}/feat-website-views/"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def tdpd_root() -> Path:
|
def tdpd_root() -> Path:
|
||||||
raw = os.environ.get("TDPD_ROOT", "").strip()
|
return env_path("TDPD_ROOT", DEFAULT_TDPD) # type: ignore[return-value]
|
||||||
return Path(raw).expanduser() if raw else DEFAULT_TDPD
|
|
||||||
|
|
||||||
|
|
||||||
def tip_of(repo: Path, branch: str) -> str:
|
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:
|
try:
|
||||||
out = subprocess.check_output(
|
return int(cp.stdout.strip() or "0")
|
||||||
["git", "-C", str(repo), "rev-parse", "--short", branch],
|
except ValueError:
|
||||||
stderr=subprocess.DEVNULL,
|
return 10**9
|
||||||
text=True,
|
|
||||||
).strip()
|
|
||||||
return out
|
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def short_sha(sha: str) -> str:
|
||||||
repo = tdpd_root()
|
return sha[:7] if sha else ""
|
||||||
if not (repo / ".git").exists() and not repo.is_dir():
|
|
||||||
print(f"TDPD_ROOT missing or not a dir: {repo}", file=sys.stderr)
|
|
||||||
return 1
|
def nearest_parents(repo: Path, tips: dict[str, str]) -> dict[str, str]:
|
||||||
branches = []
|
"""For each branch, nearest other tip that is a strict ancestor."""
|
||||||
for name, parent, url in LADDER:
|
parents: dict[str, str] = {}
|
||||||
branches.append(
|
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,
|
"branch": name,
|
||||||
"tip": tip_of(repo, name),
|
"edition": edition_slug(name),
|
||||||
"parent": parent,
|
"tip": short_sha(tip),
|
||||||
"url": url,
|
"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 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 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():
|
||||||
|
return
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, str(INJECT), str(edition_dir)],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 tip_recorded(editions, slug) == tip
|
||||||
|
and (dest / "index.html").is_file()
|
||||||
|
):
|
||||||
|
inject_bar(dest)
|
||||||
|
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:
|
||||||
|
ensure_placeholder(editions, slug, tip)
|
||||||
|
return {
|
||||||
|
"status": f"error:worktree:{cp.stderr.strip()[:200]}",
|
||||||
|
"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)
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
inject_bar(dest)
|
||||||
|
return {"status": "built", "seconds": secs}
|
||||||
|
except Exception as exc: # noqa: BLE001 — hourly must continue
|
||||||
|
ensure_placeholder(editions, slug, tip)
|
||||||
|
return {
|
||||||
|
"status": f"error:{type(exc).__name__}:{exc}",
|
||||||
|
"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"):
|
||||||
|
s = src / name
|
||||||
|
if s.is_file():
|
||||||
|
shutil.copy2(s, dst / name)
|
||||||
|
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)
|
||||||
|
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()}
|
||||||
|
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"]
|
||||||
|
branches_out.append(entry)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"generated": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
|
"generated": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
|
||||||
"host": HOST,
|
"host": host(),
|
||||||
"branches": [
|
"default_branch": default_branch(),
|
||||||
{"branch": b["branch"], "tip": b["tip"], "url": b["url"]}
|
"default_edition": "flask",
|
||||||
for b in branches
|
"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 = out_path()
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
dest.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
dest.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||||
print(f"wrote {dest}", file=sys.stderr)
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,44 @@
|
||||||
# Stanza body for dossiers.ngi.hacktivism.ch only (hand-check / re-seed).
|
# Stanza body for dossiers.ngi.hacktivism.ch only (hand-check / re-seed).
|
||||||
# Templates apply is host-local on koopa (not in this repo):
|
# Apply on koopa (host-local):
|
||||||
# bash ~/bin/apply-dossiers-ngi-templates.sh
|
# bash ~/bin/apply-dossiers-ngi-edition-switch.sh
|
||||||
# sudo ~/bin/caddy-apply
|
# sudo ~/bin/caddy-apply
|
||||||
#
|
#
|
||||||
# Keep the existing line: header Alt-Svc "clear"
|
# Sticky bar switches build via cookie tdpd_edition → editions/<name>/.
|
||||||
# Keep any existing basic_auth in that same block.
|
# Stock Caddy only (templates + map + handle). No replace-response plugin.
|
||||||
# scripts/caddy/caddy-apply.sh --snippet reports an existing block and
|
# Keep .2 / .3 blocks untouched.
|
||||||
# does not merge these lines. Leave that script unused for this change.
|
|
||||||
# Leave scripts/caddy/apply-goa-wdGet-full-replace.sh unused.
|
|
||||||
# Leave scripts/caddy/apply-dossiers-ngi-basicauth.sh unused.
|
|
||||||
# Stock Caddy templates only. No replace-response plugin.
|
|
||||||
#
|
#
|
||||||
# Tab-indented lines inserted after header Alt-Svc:
|
# Site-block body (no outer braces):
|
||||||
|
|
||||||
|
header Alt-Svc "clear"
|
||||||
|
|
||||||
|
@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"
|
||||||
|
redir * / 302
|
||||||
|
}
|
||||||
|
|
||||||
|
handle /_cascade/* {
|
||||||
|
root * /var/www/monitoring-sites/dossiers.ngi.hacktivism.ch
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
handle /branch-status.json {
|
||||||
|
root * /var/www/monitoring-sites/dossiers.ngi.hacktivism.ch
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dynamic editions: cookie value is the dir name under editions/
|
||||||
|
# (hourly builder stages every origin/* tip as slug with / → -).
|
||||||
|
map {http.request.cookie.tdpd_edition} {edition_dir} {
|
||||||
|
default flask
|
||||||
|
flask flask
|
||||||
|
~^[A-Za-z0-9_-]+$ {http.request.cookie.tdpd_edition}
|
||||||
|
}
|
||||||
|
|
||||||
|
root * /var/www/monitoring-sites/dossiers.ngi.hacktivism.ch/editions/{edition_dir}
|
||||||
templates {
|
templates {
|
||||||
mime text/html
|
mime text/html
|
||||||
between "{%" "%}"
|
between "{%" "%}"
|
||||||
|
root /var/www/monitoring-sites/dossiers.ngi.hacktivism.ch
|
||||||
}
|
}
|
||||||
|
file_server
|
||||||
|
try_files {path} {path}/ {path}/index.html
|
||||||
|
|
|
||||||
56
scripts/dossiers-ngi/inject-cascade-bar.py
Executable file
56
scripts/dossiers-ngi/inject-cascade-bar.py
Executable 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())
|
||||||
98
scripts/dossiers-ngi/smoke-branch-tree.sh
Executable file
98
scripts/dossiers-ngi/smoke-branch-tree.sh
Executable file
|
|
@ -0,0 +1,98 @@
|
||||||
|
#!/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"]
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
@ -2,28 +2,50 @@
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.35rem 0.85rem;
|
|
||||||
align-items: baseline;
|
|
||||||
margin: -1.5rem -1.5rem 1rem;
|
margin: -1.5rem -1.5rem 1rem;
|
||||||
padding: 0.55rem 1.5rem;
|
padding: 0.55rem 1.5rem 0.65rem;
|
||||||
background: #10141a;
|
background: #10141a;
|
||||||
border-bottom: 1px solid #243040;
|
border-bottom: 1px solid #243040;
|
||||||
|
color: #c8d0d8;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
.cascade-bar a {
|
.cascade-tree-lines {
|
||||||
|
white-space: pre;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
a.cascade-tree-row {
|
||||||
|
display: block;
|
||||||
color: #7ec8ff;
|
color: #7ec8ff;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
padding: 0.05rem 0.15rem;
|
||||||
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
.cascade-bar a:hover { text-decoration: underline; }
|
a.cascade-tree-row:hover {
|
||||||
.cascade-bar a.is-active {
|
background: #1a2430;
|
||||||
color: #e8eef4;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
box-shadow: inset 0 -2px 0 #e8eef4;
|
|
||||||
}
|
}
|
||||||
.cascade-bar a.is-active:hover { text-decoration: none; }
|
a.cascade-tree-row.is-active {
|
||||||
.cascade-bar code { color: inherit; }
|
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-bar .tip {
|
.cascade-bar .tip {
|
||||||
color: #8a96a3;
|
color: #8a96a3;
|
||||||
font-size: 12px;
|
}
|
||||||
|
.cascade-bar .cascade-default {
|
||||||
|
color: #6a9080;
|
||||||
|
}
|
||||||
|
.cascade-timing {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
color: #6a7888;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.cascade-bar .tip {
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
{% $p := .Req.URL.Path %}
|
|
||||||
<link rel="stylesheet" href="/_cascade/bar.css">
|
<link rel="stylesheet" href="/_cascade/bar.css">
|
||||||
<nav class="cascade-bar" aria-label="cascade">
|
<nav class="cascade-bar" aria-label="cascade builds" id="cascade-bar">
|
||||||
<a href="/" {% if or (eq $p "/") (eq $p "/index.html") %}class="is-active" aria-current="page"{% end %}>cascade</a>
|
<div class="cascade-tree" id="cascade-tree" role="tree">Loading builds…</div>
|
||||||
<a href="/main/" {% if or (eq $p "/main") (or (eq $p "/main/") (eq $p "/main/index.html")) %}class="is-active" aria-current="page"{% end %}><code>/main/</code> <span class="tip" data-tip="main">main @ d2fbd79</span></a>
|
|
||||||
<a href="/feat-sm-content/" {% if or (eq $p "/feat-sm-content") (or (eq $p "/feat-sm-content/") (eq $p "/feat-sm-content/index.html")) %}class="is-active" aria-current="page"{% end %}><code>/feat-sm-content/</code> <span class="tip" data-tip="feat/sm-content">feat/sm-content @ 089ef1f</span></a>
|
|
||||||
<a href="/feat-website-features/" {% if or (eq $p "/feat-website-features") (or (eq $p "/feat-website-features/") (eq $p "/feat-website-features/index.html")) %}class="is-active" aria-current="page"{% end %}><code>/feat-website-features/</code> <span class="tip" data-tip="feat/website-features">feat/website-features @ 53a3176</span></a>
|
|
||||||
<a href="/feat-website-design/" {% if or (eq $p "/feat-website-design") (or (eq $p "/feat-website-design/") (eq $p "/feat-website-design/index.html")) %}class="is-active" aria-current="page"{% end %}><code>/feat-website-design/</code> <span class="tip" data-tip="feat/website-design">feat/website-design @ 2e26c80</span></a>
|
|
||||||
<a href="/feat-website-views/" {% if or (eq $p "/feat-website-views") (or (eq $p "/feat-website-views/") (eq $p "/feat-website-views/index.html")) %}class="is-active" aria-current="page"{% end %}><code>/feat-website-views/</code> <span class="tip" data-tip="feat/website-views">feat/website-views @ bbafc38</span></a>
|
|
||||||
</nav>
|
</nav>
|
||||||
<script src="/_cascade/bar.js" defer></script>
|
<script src="/_cascade/bar.js" defer></script>
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,146 @@
|
||||||
(function () {
|
(function () {
|
||||||
var nodes = document.querySelectorAll(".cascade-bar [data-tip]");
|
function cookieEdition() {
|
||||||
if (!nodes.length) return;
|
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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortTime(iso) {
|
||||||
|
if (!iso) return "";
|
||||||
|
// keep local-looking wall clock from ISO when possible
|
||||||
|
var m = String(iso).match(/T(\d{2}:\d{2})/);
|
||||||
|
if (m) return m[1];
|
||||||
|
return String(iso).slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 renderTree(data) {
|
||||||
|
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";
|
||||||
|
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 tip = row.tip || "";
|
||||||
|
var prefix = row.prefix || "";
|
||||||
|
var parent = row.parent || "";
|
||||||
|
var tipTime = row.tip_time || "";
|
||||||
|
var builtAt = row.built_at || "";
|
||||||
|
var buildSec = row.build_seconds;
|
||||||
|
var isDefault = branch === defaultBranch;
|
||||||
|
var metaBits = [];
|
||||||
|
if (tip) metaBits.push("@" + tip);
|
||||||
|
if (tipTime) metaBits.push("tip " + shortTime(tipTime));
|
||||||
|
if (buildSec != null && buildSec !== "")
|
||||||
|
metaBits.push("build " + fmtBuildSec(buildSec));
|
||||||
|
if (builtAt) metaBits.push("at " + shortTime(builtAt));
|
||||||
|
var label =
|
||||||
|
esc(prefix) +
|
||||||
|
"<code>" +
|
||||||
|
esc(branch) +
|
||||||
|
"</code>" +
|
||||||
|
(metaBits.length
|
||||||
|
? '<span class="tip"> ' + esc(metaBits.join(" · ")) + "</span>"
|
||||||
|
: "") +
|
||||||
|
(isDefault ? '<span class="cascade-default"> · default</span>' : "");
|
||||||
|
html.push(
|
||||||
|
'<a class="cascade-tree-row" role="treeitem" href="/_cascade/use/' +
|
||||||
|
esc(edition) +
|
||||||
|
'" data-edition="' +
|
||||||
|
esc(edition) +
|
||||||
|
'"' +
|
||||||
|
(parent ? ' data-parent="' + esc(parent) + '"' : "") +
|
||||||
|
(isDefault ? ' data-default="1"' : "") +
|
||||||
|
' title="Switch build to ' +
|
||||||
|
esc(branch) +
|
||||||
|
(tip ? " @ " + esc(tip) : "") +
|
||||||
|
(tipTime ? " tip=" + esc(tipTime) : "") +
|
||||||
|
(buildSec != null ? " build=" + esc(fmtBuildSec(buildSec)) : "") +
|
||||||
|
'">' +
|
||||||
|
label +
|
||||||
|
"</a>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
html.push("</div>");
|
||||||
|
if (timing && timing.wall_seconds != null) {
|
||||||
|
html.push(
|
||||||
|
'<div class="cascade-timing">last full pass ~' +
|
||||||
|
esc(String(timing.wall_seconds)) +
|
||||||
|
"s · built " +
|
||||||
|
esc(String(timing.built || 0)) +
|
||||||
|
" / skipped " +
|
||||||
|
esc(String(timing.skipped || 0)) +
|
||||||
|
" / " +
|
||||||
|
esc(String(timing.branches || list.length)) +
|
||||||
|
"</div>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
mount.innerHTML = html.join("");
|
||||||
|
markActive(mount, cookieEdition());
|
||||||
|
}
|
||||||
|
|
||||||
|
var edition = cookieEdition();
|
||||||
|
var mount = document.getElementById("cascade-tree");
|
||||||
fetch("/branch-status.json", { cache: "no-store" })
|
fetch("/branch-status.json", { cache: "no-store" })
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
if (!res.ok) throw new Error("status");
|
if (!res.ok) throw new Error("status");
|
||||||
return res.json();
|
return res.json();
|
||||||
})
|
})
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
var tips = {};
|
renderTree(data);
|
||||||
var list = (data && data.branches) || [];
|
|
||||||
for (var i = 0; i < list.length; i++) {
|
|
||||||
var row = list[i];
|
|
||||||
if (row && row.branch && row.tip) tips[row.branch] = row.tip;
|
|
||||||
}
|
|
||||||
for (var j = 0; j < nodes.length; j++) {
|
|
||||||
var el = nodes[j];
|
|
||||||
var name = el.getAttribute("data-tip");
|
|
||||||
var tip = tips[name];
|
|
||||||
if (tip) el.textContent = name + " @ " + tip;
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(function () {});
|
.catch(function () {
|
||||||
|
if (mount) {
|
||||||
|
mount.innerHTML =
|
||||||
|
'<a class="cascade-tree-row" href="/_cascade/use/flask" data-edition="flask"><code>flask</code></a>' +
|
||||||
|
'<span class="tip"> (branch-status unavailable)</span>';
|
||||||
|
markActive(mount, edition);
|
||||||
|
}
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -1,31 +1,124 @@
|
||||||
{
|
{
|
||||||
"generated": "2026-09-30T02:30:50+02:00",
|
"generated": "2026-09-30T15:34:26+02:00",
|
||||||
"host": "https://dossiers.ngi.hacktivism.ch",
|
"host": "koopa",
|
||||||
|
"default_branch": "feat/website-views",
|
||||||
|
"default_edition": "flask",
|
||||||
"branches": [
|
"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",
|
"branch": "main",
|
||||||
|
"edition": "main",
|
||||||
"tip": "d2fbd79",
|
"tip": "d2fbd79",
|
||||||
"url": "https://dossiers.ngi.hacktivism.ch/main/"
|
"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",
|
"branch": "feat/sm-content",
|
||||||
|
"edition": "feat-sm-content",
|
||||||
"tip": "089ef1f",
|
"tip": "089ef1f",
|
||||||
"url": "https://dossiers.ngi.hacktivism.ch/feat-sm-content/"
|
"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",
|
"branch": "feat/website-features",
|
||||||
|
"edition": "feat-website-features",
|
||||||
"tip": "53a3176",
|
"tip": "53a3176",
|
||||||
"url": "https://dossiers.ngi.hacktivism.ch/feat-website-features/"
|
"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",
|
"branch": "feat/website-design",
|
||||||
|
"edition": "feat-website-design",
|
||||||
"tip": "2e26c80",
|
"tip": "2e26c80",
|
||||||
"url": "https://dossiers.ngi.hacktivism.ch/feat-website-design/"
|
"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",
|
"branch": "feat/website-views",
|
||||||
|
"edition": "feat-website-views",
|
||||||
"tip": "bbafc38",
|
"tip": "bbafc38",
|
||||||
"url": "https://dossiers.ngi.hacktivism.ch/feat-website-views/"
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
51
sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.css
Normal file
51
sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.css
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
.cascade-bar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
margin: -1.5rem -1.5rem 1rem;
|
||||||
|
padding: 0.55rem 1.5rem 0.65rem;
|
||||||
|
background: #10141a;
|
||||||
|
border-bottom: 1px solid #243040;
|
||||||
|
color: #c8d0d8;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.cascade-tree-lines {
|
||||||
|
white-space: pre;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
a.cascade-tree-row {
|
||||||
|
display: block;
|
||||||
|
color: #7ec8ff;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.05rem 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-bar .tip {
|
||||||
|
color: #8a96a3;
|
||||||
|
}
|
||||||
|
.cascade-bar .cascade-default {
|
||||||
|
color: #6a9080;
|
||||||
|
}
|
||||||
|
.cascade-timing {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
color: #6a7888;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.cascade-bar .tip {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
146
sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.js
Normal file
146
sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.js
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
(function () {
|
||||||
|
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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortTime(iso) {
|
||||||
|
if (!iso) return "";
|
||||||
|
// keep local-looking wall clock from ISO when possible
|
||||||
|
var m = String(iso).match(/T(\d{2}:\d{2})/);
|
||||||
|
if (m) return m[1];
|
||||||
|
return String(iso).slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 renderTree(data) {
|
||||||
|
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";
|
||||||
|
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 tip = row.tip || "";
|
||||||
|
var prefix = row.prefix || "";
|
||||||
|
var parent = row.parent || "";
|
||||||
|
var tipTime = row.tip_time || "";
|
||||||
|
var builtAt = row.built_at || "";
|
||||||
|
var buildSec = row.build_seconds;
|
||||||
|
var isDefault = branch === defaultBranch;
|
||||||
|
var metaBits = [];
|
||||||
|
if (tip) metaBits.push("@" + tip);
|
||||||
|
if (tipTime) metaBits.push("tip " + shortTime(tipTime));
|
||||||
|
if (buildSec != null && buildSec !== "")
|
||||||
|
metaBits.push("build " + fmtBuildSec(buildSec));
|
||||||
|
if (builtAt) metaBits.push("at " + shortTime(builtAt));
|
||||||
|
var label =
|
||||||
|
esc(prefix) +
|
||||||
|
"<code>" +
|
||||||
|
esc(branch) +
|
||||||
|
"</code>" +
|
||||||
|
(metaBits.length
|
||||||
|
? '<span class="tip"> ' + esc(metaBits.join(" · ")) + "</span>"
|
||||||
|
: "") +
|
||||||
|
(isDefault ? '<span class="cascade-default"> · default</span>' : "");
|
||||||
|
html.push(
|
||||||
|
'<a class="cascade-tree-row" role="treeitem" href="/_cascade/use/' +
|
||||||
|
esc(edition) +
|
||||||
|
'" data-edition="' +
|
||||||
|
esc(edition) +
|
||||||
|
'"' +
|
||||||
|
(parent ? ' data-parent="' + esc(parent) + '"' : "") +
|
||||||
|
(isDefault ? ' data-default="1"' : "") +
|
||||||
|
' title="Switch build to ' +
|
||||||
|
esc(branch) +
|
||||||
|
(tip ? " @ " + esc(tip) : "") +
|
||||||
|
(tipTime ? " tip=" + esc(tipTime) : "") +
|
||||||
|
(buildSec != null ? " build=" + esc(fmtBuildSec(buildSec)) : "") +
|
||||||
|
'">' +
|
||||||
|
label +
|
||||||
|
"</a>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
html.push("</div>");
|
||||||
|
if (timing && timing.wall_seconds != null) {
|
||||||
|
html.push(
|
||||||
|
'<div class="cascade-timing">last full pass ~' +
|
||||||
|
esc(String(timing.wall_seconds)) +
|
||||||
|
"s · built " +
|
||||||
|
esc(String(timing.built || 0)) +
|
||||||
|
" / skipped " +
|
||||||
|
esc(String(timing.skipped || 0)) +
|
||||||
|
" / " +
|
||||||
|
esc(String(timing.branches || list.length)) +
|
||||||
|
"</div>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
mount.innerHTML = html.join("");
|
||||||
|
markActive(mount, cookieEdition());
|
||||||
|
}
|
||||||
|
|
||||||
|
var edition = cookieEdition();
|
||||||
|
var mount = document.getElementById("cascade-tree");
|
||||||
|
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);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (mount) {
|
||||||
|
mount.innerHTML =
|
||||||
|
'<a class="cascade-tree-row" href="/_cascade/use/flask" data-edition="flask"><code>flask</code></a>' +
|
||||||
|
'<span class="tip"> (branch-status unavailable)</span>';
|
||||||
|
markActive(mount, edition);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -26,13 +26,14 @@ li { margin: 0.25rem 0; }
|
||||||
<body>
|
<body>
|
||||||
{% include "_cascade/bar.html" . %}
|
{% include "_cascade/bar.html" . %}
|
||||||
<h1>cascade</h1>
|
<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>
|
<p class="meta">last-built: 2026-09-30T02:30:50+02:00</p>
|
||||||
<ul class="plain">
|
<ul class="plain">
|
||||||
<li><a href="/main/"><code>/main/</code></a> <span class="dim">·</span> <code>main</code> @ <code>d2fbd79</code></li>
|
<li><a href="/cascade/main/"><code>/main/</code></a> <span class="dim">·</span> <code>main</code> @ <code>d2fbd79</code></li>
|
||||||
<li><a href="/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-sm-content/"><code>/feat-sm-content/</code></a> <span class="dim">·</span> <code>feat/sm-content</code> @ <code>089ef1f</code></li>
|
||||||
<li><a href="/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-features/"><code>/feat-website-features/</code></a> <span class="dim">·</span> <code>feat/website-features</code> @ <code>53a3176</code></li>
|
||||||
<li><a href="/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-design/"><code>/feat-website-design/</code></a> <span class="dim">·</span> <code>feat/website-design</code> @ <code>2e26c80</code></li>
|
||||||
<li><a href="/feat-website-views/"><code>/feat-website-views/</code></a> <span class="dim">·</span> <code>feat/website-views</code> @ <code>bbafc38</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>
|
</ul>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue