From eead184b7f62dbe53b2b671c66a38dc5085a27ea Mon Sep 17 00:00:00 2001 From: Kat Suricata Date: Thu, 13 Aug 2026 23:33:03 -0400 Subject: [PATCH] Wire katsuricata.tngl.io release pipeline Spindle release workflow for annotated v* tags: builds the WASM solver, vets/tests all packages (host + js/wasm), cross-compiles static linux amd64/arm64 server binaries, packages deploy-ready tarballs (binary + web/static + license), source tarball and SHA256SUMS, publishes them to the pages repo at /hecapte/v/, and records the amd64 tarball as an sh.tangled.repo.artifact on the PDS. Ships the Cloudron-less server deployment only; Cloudron distribution stays on the Cloudron App Store. - scripts/: pages index generator + secrets-UI SSH key normalizer (verbatim copies, shared across projects) - README: prebuilt-release install path + Cloudron note pointer --- .tangled/workflows/release.yml | 231 ++++++++++++++++++++ README.md | 28 ++- scripts/make-indexes.py | 373 +++++++++++++++++++++++++++++++++ scripts/write-ssh-key.sh | 62 ++++++ 4 files changed, 693 insertions(+), 1 deletion(-) create mode 100644 .tangled/workflows/release.yml create mode 100644 scripts/make-indexes.py create mode 100644 scripts/write-ssh-key.sh diff --git a/.tangled/workflows/release.yml b/.tangled/workflows/release.yml new file mode 100644 index 0000000..e973ddc --- /dev/null +++ b/.tangled/workflows/release.yml @@ -0,0 +1,231 @@ +# .tangled/workflows/release.yml +# +# Release pipeline for HeCAPTe, run by spindle.tangled.sh on every annotated +# v* tag push. It: +# +# 1. Builds the WASM solver, vets and tests every package (the js/wasm +# variant included), and builds static linux/amd64 + linux/arm64 server +# binaries. Pure Go with CGO off, so the cross-compiles are cheap and +# fit the default spindle microvm timeout (5m). +# 2. Publishes the tarballs (server binary + web/static assets, everything +# a Cloudron-less deploy needs), checksums, and a source tarball to the +# `pages` repository under hecapte//, regenerating the directory +# indexes as it goes. Pushing pages#main auto-deploys to +# https://katsuricata.tngl.io/hecapte//. (Cloudron distribution is +# separate and lives on the Cloudron App Store; this pipeline ships only +# the self-hosted server deployment.) +# 3. Publishes the amd64 tarball to the PDS as an sh.tangled.repo.artifact +# record, so each release also lives in the ATmosphere. +# +# One-time setup (NOT in this file): +# * `pages` repo on Tangled, configured as the *index site* for +# katsuricata.tngl.io (branch main, deploy directory /). +# * A passphrase-less deploy keypair; the public half added to the Tangled +# account, the private half stored as the repo secret SHEAF_PAGES_SSH_KEY +# (Repo -> Settings -> Secrets; the SHEAF_ prefix is historical — one +# shared keypair serves every project). +# * A bsky.social app password stored as the repo secret ATP_APP_PASSWORD +# (used only for the artifact record step). +# * Tag with annotated tags only: `git tag -a v4.0.1 -m "v4.0.1"` — the +# artifact record needs a tag object (`git rev-parse vX^{tag}`). + +when: + # Every publish is exactly one annotated tag push, so TANGLED_REF_NAME is + # always the version. A `manual` trigger here would get TANGLED_REF_NAME + # = the commit sha instead, and release it under that bogus name — don't. + - event: ["push"] + tag: ["v*"] + +engine: microvm +image: nixos + +dependencies: + # The microvm devshell contains ONLY what's listed here. Anything a step + # runs must be declared. Nixpkgs attrs; search: https://search.nixos.org + - go # server + wasm solver builds, tests, vet + - python3 # make-indexes.py and write-ssh-key.sh are stdlib-only python + - jq # the PDS artifact step + - openssh + - git + - curl + - xxd # artifact step: hex -> base64 for the tag bytes + +clone: + skip: false + depth: 1 # the tag is detached; the tag object itself is ref'd by name + submodules: false + +environment: + VERSION: "0.0.0" # overridden by the first step from the pushed tag name + BASE_URL: "https://katsuricata.tngl.io" + PAGES_REPO_SSH: "ssh://git@tangled.org/did:plc:b2oydp42a7jhgj72nbykc4ej/pages" + PDS: "https://bsky.social" + ATP_IDENTIFIER: "katsuricata.com" + CGO_ENABLED: "0" # modernc.org/sqlite is pure Go; binaries stay static + +steps: + # microvm steps run in one shared shell but DON'T inherit `cd` cleanly and + # env vars don't always survive — each step re-reads .release/VERSION and + # re-cds to /workspace/repo. Never assume either persists. + + - name: "Resolve version & workspace" + command: | + set -x + VERSION="${TANGLED_REF_NAME#v}" + echo "release version: $VERSION (ref $TANGLED_REF)" + mkdir -p /workspace/repo/.release/dist + echo "$VERSION" | tee /workspace/repo/.release/VERSION + + - name: "Build WASM solver" + command: | + # solver.wasm is a gitignored build artifact, so a fresh CI checkout + # has none — but the packaged tarballs must contain the exact file the + # server will serve (its startup hash is the deploy-drift surface). + cd /workspace/repo + GOOS=js GOARCH=wasm go build -o web/static/solver.wasm ./cmd/wasm-solver + sha256sum web/static/solver.wasm + + - name: "Vet & test" + command: | + # `go vet ./...` / `go test ./...` fail on non-wasm hosts: cmd/wasm-solver + # imports syscall/js and has no buildable files outside js/wasm. Run the + # host packages by name, then vet the solver under the wasm GOOS/GOARCH. + set -e + cd /workspace/repo + go vet ./internal/... ./cmd/server ./templates ./locales + go test ./internal/... ./cmd/server ./templates ./locales + GOOS=js GOARCH=wasm go vet ./cmd/wasm-solver + + - name: "Build server binaries (linux amd64+arm64)" + command: | + # each binary lands directly in its staging dir; the tarballs' internal + # layout is the runtime layout: `hecapte` next to `web/static`. + set -e + cd /workspace/repo + VERSION=$(cat .release/VERSION) + GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" \ + -o ".release/build/hecapte-${VERSION}-linux-amd64/hecapte" ./cmd/server + GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w" \ + -o ".release/build/hecapte-${VERSION}-linux-arm64/hecapte" ./cmd/server + + - name: "Package archives, checksums, source tarball" + command: | + set -e + cd /workspace/repo + VERSION=$(cat .release/VERSION) + DIST=.release/dist + for arch in amd64 arm64; do + stage=".release/build/hecapte-${VERSION}-linux-${arch}" + mkdir -p "$stage/web" + cp -r web/static "$stage/web/static" + cp LICENSE.md README.md "$stage/" + tar -C .release/build -czf "$DIST/hecapte-linux-${arch}.tar.gz" \ + "hecapte-${VERSION}-linux-${arch}" + done + # git archive by tag NAME: the checkout is a detached HEAD at the tag + # (depth 1) and only the tag ref exists. + git archive --format=tar.gz --prefix="hecapte-${VERSION}/" \ + -o "$DIST/hecapte-${VERSION}-source.tar.gz" "v${VERSION}" + (cd "$DIST" && sha256sum *.tar.gz > SHA256SUMS) + ls -la "$DIST" + + - name: "Publish to pages repo (auto-deploys katsuricata.tngl.io)" + command: | + set -euo pipefail + cd /workspace/repo + VERSION=$(cat .release/VERSION) + DEPLOY=/workspace/repo/.release/pages-deploy + # dedicated passphrase-less deploy key, stored as a repo secret. + # the public half must be on the tangled account SSH keys. + mkdir -p .release/ssh + # write the secret to a file first so the shell never word-splits or + # otherwise mangles the multiline key on expansion; write-ssh-key.sh + # then rebuilds canonical PEM from whatever the secrets UI stored. + printf '%s' "$SHEAF_PAGES_SSH_KEY" > .release/ssh/id.raw + python3 scripts/write-ssh-key.sh "$(cat .release/ssh/id.raw)" .release/ssh/id + shred -u .release/ssh/id.raw 2>/dev/null || rm -f .release/ssh/id.raw + chmod 700 .release/ssh && chmod 600 .release/ssh/id + echo "deploy key: $(wc -l <.release/ssh/id) lines; fingerprint:" + ssh-keygen -y -P "" -f .release/ssh/id | ssh-keygen -lf - \ + || { echo "!! SHEAF_PAGES_SSH_KEY is not a valid openssh private key"; exit 1; } + + # GIT_SSH_COMMAND must be set on the same shell that runs clone AND + # push, with an absolute key path (later cd's break relative ones). + # Do NOT probe ssh before the clone: BatchMode disables host-key + # accept-new, and known_hosts starts empty. + export GIT_SSH_COMMAND="ssh -i /workspace/repo/.release/ssh/id -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" + rm -rf "$DEPLOY" + git clone --depth 1 "$PAGES_REPO_SSH" "$DEPLOY" + # verify auth against the host the clone just accepted a key for + ssh -T git@tangled.org 2>&1 | head -3 || true + + # copy the freshly built release into place + REL_DIR="$DEPLOY/hecapte/v${VERSION}" + mkdir -p "$REL_DIR" + cp .release/dist/* "$REL_DIR/" + + # regenerate every index.html from the on-disk tree + meta + python3 scripts/make-indexes.py \ + --repo-root "$DEPLOY" \ + --project hecapte \ + --version "v${VERSION}" \ + --tag-sha "$TANGLED_SHA" \ + --source-url "https://tangled.org/katsuricata.com/HeCAPTe" + + cd "$DEPLOY" + git config user.email "spindle@katsuricata.com" + git config user.name "hecapte release pipeline" + git add -A + if git diff --cached --quiet; then + echo "nothing to publish (re-run of ${VERSION}?)" + else + git commit -m "hecapte: release v${VERSION}" + git push origin main + fi + echo "=> ${BASE_URL}/hecapte/v${VERSION}/" + + - name: "Publish sh.tangled.repo.artifact record to PDS" + command: | + # uploads the amd64 tarball as a blob and creates an + # sh.tangled.repo.artifact record keyed by the annotated tag object. + set -euo pipefail + cd /workspace/repo + ARTIFACT_PATH=".release/dist/hecapte-linux-amd64.tar.gz" + ARTIFACT_NAME="hecapte-linux-amd64.tar.gz" + + session=$(curl -fsS -X POST "$PDS/xrpc/com.atproto.server.createSession" \ + -H "Content-Type: application/json" \ + -d "{\"identifier\":\"$ATP_IDENTIFIER\",\"password\":\"$ATP_APP_PASSWORD\"}") + jwt=$(echo "$session" | jq -r .accessJwt) + did=$(echo "$session" | jq -r .did) + + blob=$(curl -fsS -X POST "$PDS/xrpc/com.atproto.repo.uploadBlob" \ + -H "Authorization: Bearer $jwt" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"$ARTIFACT_PATH") + + # requires an ANNOTATED tag (`git tag -a vX -m ...`) + tag_hash=$(git rev-parse "${TANGLED_REF_NAME}^{tag}") + tag_bytes=$(printf '%s' "$tag_hash" | xxd -r -p | base64 | tr -d '=') + + record=$(jq -n \ + --arg did "$did" \ + --arg tag "$tag_bytes" \ + --arg name "$ARTIFACT_NAME" \ + --arg repo "$TANGLED_REPO_URL" \ + --arg created "$(date -Iseconds)" \ + --argjson blob "$(echo "$blob" | jq .blob)" '{ + repo: $did, collection: "sh.tangled.repo.artifact", validate: false, + record: { + "$type": "sh.tangled.repo.artifact", + tag: {"$bytes": $tag}, name: $name, repo: $repo, + artifact: $blob, createdAt: $created + } + }') + + curl -fsS -X POST "$PDS/xrpc/com.atproto.repo.createRecord" \ + -H "Authorization: Bearer $jwt" \ + -H "Content-Type: application/json" \ + -d "$record" + echo + echo "artifact record created for $ARTIFACT_NAME" diff --git a/README.md b/README.md index 72355a2..054c1d7 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,32 @@ The browser sends the public `site_key` to `/challenge`. Your backend sends the ## Installation +Run a prebuilt release binary, or build from source. Both layouts put the `hecapte` binary next to the `web/static` assets it serves, the WASM solver included. + +### Option 1: Prebuilt Release + +Every release publishes at [katsuricata.tngl.io/hecapte](https://katsuricata.tngl.io/hecapte/): one directory per version with a `hecapte-linux-amd64.tar.gz`, a `hecapte-linux-arm64.tar.gz`, a source tarball, and a `SHA256SUMS` checksum file. A binary tarball contains the `hecapte` server binary, the `web/static` assets, and the license. + +```bash +# substitute the version and the architecture you want +curl -LO https://katsuricata.tngl.io/hecapte/v4.0.1/hecapte-linux-amd64.tar.gz +curl -LO https://katsuricata.tngl.io/hecapte/v4.0.1/SHA256SUMS +sha256sum --check --ignore-missing SHA256SUMS +tar -xzf hecapte-linux-amd64.tar.gz +cd hecapte-4.0.1-linux-amd64 +``` + +Start the server from that directory: + +```bash +export ADMIN_LOGIN_POW_SECRET=$(openssl rand -hex 32) +./hecapte --host example.com --port 8080 --db hecapte.db +``` + +The `hecapte--source.tar.gz` archive is a snapshot of the tagged commit, for builders who do not want to clone the repository. + +### Option 2: Build from Source + 1. **Clone the repository:** ```bash @@ -1343,7 +1369,7 @@ The Cloudron build: - Uses `start.sh` to auto-generate `ADMIN_LOGIN_POW_SECRET` on the first run - Serves `/healthz` for the platform health check (the manifest sets `healthCheckPath`) instead of the demo page -> **Note:** HeCAPTe is **not** exclusively a Cloudron package. The Cloudron-related files at the repo root (`Dockerfile`, `CloudronManifest.json`, `CloudronVersions.json`, `start.sh`, `CHANGELOG`, `icon.png`) exist only for Cloudron distribution. If you self-host HeCAPTe directly, you can ignore or remove them. Build the Go binary and run it as described in the [Running in Production](#running-in-production) section above. +> **Note:** HeCAPTe is **not** exclusively a Cloudron package. The Cloudron-related files at the repo root (`Dockerfile`, `CloudronManifest.json`, `CloudronVersions.json`, `start.sh`, `CHANGELOG`, `icon.png`) exist only for Cloudron distribution. If you self-host HeCAPTe directly, you can ignore or remove them. Build the Go binary and run it as described in the [Running in Production](#running-in-production) section above, or download a prebuilt archive from [katsuricata.tngl.io/hecapte](https://katsuricata.tngl.io/hecapte/) (see [Option 1: Prebuilt Release](#option-1-prebuilt-release)). **Cloudron packaging files** at the repo root: diff --git a/scripts/make-indexes.py b/scripts/make-indexes.py new file mode 100644 index 0000000..bb51b90 --- /dev/null +++ b/scripts/make-indexes.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +"""make-indexes.py — regenerate Digital-Rust-styled directory indexes for the +katsuricata.tngl.io pages repo after a release lands. + +Three tiers, all self-contained (no JS, inline CSS, no webfonts): + + /index.html root: every project + its versions + //index.html project: its releases, newest first + ///index.html version: the downloadable files + +A tiny ``.meta/.json`` in the pages repo records each published +version (semver + publish date) so the indexes can sort releases by real +semver and show a human "released" date even after later releases land. The +``--project``/``--version`` flags only stamp *this* release into the meta and +guarantee its directory exists; everything else is derived by scanning the +tree, so re-running on an existing checkout is idempotent. + +Usage (from .tangled/workflows/release.yml): + + python3 scripts/make-indexes.py \ + --repo-root /tangled/workspace/pages-deploy \ + --project sheaf --version v1.0.0 \ + --tag-sha \ + --source-url https://tangled.org/katsuricata.com/Sheaf + +Python 3.9+, stdlib only. +""" +from __future__ import annotations + +import argparse +import html +import json +import re +import subprocess +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterable + +# -------------------------------------------------------------------------- +# Digital Rust tokens (trimmed excerpt of frontend/src/dr-tokens.css; the +# pages repo has no build step, so these are baked in here). +CSS = r""" +:root{ + --event-horizon:#110402; --null-pointer:#230905; --core-meltdown:#340d07; + --bad-sector:#46120a; --dark-packet:#691b0f; --digital-rust:#af2e1a; + --firewall-breach:#b74230; --fatal-error:#cf8175; --amber-alert:#bf5747; + --phantom-current:#e7c0ba; --phosphor-smoke:#efd5d1; --ghost-pixel:#f7eae8; + --memory-leek:#8fa667; --stack-overglow:#d4a759; + --surface:var(--null-pointer); --text:var(--ghost-pixel); + --text-dim:rgba(239,213,209,.55); --border:var(--dark-packet); + --accent:var(--fatal-error); --well:rgba(70,18,10,.45); +} +*{box-sizing:border-box} +html{color-scheme:dark} +body{margin:0;min-height:100vh;background:var(--surface);color:var(--text); + font-family:ui-serif,'Iowan Old Style','Source Serif 4',Charter,Cambria,Georgia,serif; + font-size:17px;line-height:1.6; + background-image:repeating-linear-gradient(0deg,rgba(175,46,26,.025) 0 1px,transparent 1px 3px)} +.wrap{max-width:58rem;margin:0 auto;padding:3rem 1.25rem 6rem} +header.crumb{border-bottom:2px solid var(--bad-sector);padding-bottom:.6rem; + margin-bottom:1.75rem;display:flex;flex-wrap:wrap;gap:.35rem;align-items:baseline} +.crumb a,.ver a,.file a{color:var(--phantom-current);text-decoration:none} +.crumb a:hover,.ver a:hover,.file a:hover{color:var(--fatal-error)} +.crumb .sep{color:var(--text-dim);user-select:none} +.crumb .cap{margin-left:auto;font-family:'IBM Plex Mono','Space Mono',ui-monospace,monospace; + font-size:.72rem;letter-spacing:.18em;text-transform:uppercase;color:var(--text-dim)} +h1{font-size:1.9rem;line-height:1.2;margin:.2em 0 .1em;color:var(--accent); + font-weight:700;letter-spacing:-.01em} +h2{font-size:1.05rem;margin:2.2rem 0 .4rem;color:var(--phosphor-smoke); + font-family:'IBM Plex Mono','Space Mono',ui-monospace,monospace;font-weight:600; + letter-spacing:.14em;text-transform:uppercase} +p.lede{margin:.35rem 0 0;color:var(--text-dim);max-width:46rem} +.rule{border:0;border-top:2px solid var(--bad-sector);margin:2rem 0 1.4rem} +a{color:var(--phantom-current)} +a:hover{color:var(--fatal-error)} +code{font-family:'IBM Plex Mono','Space Mono',ui-monospace,monospace;font-size:.92em; + background:var(--well);border:1px solid var(--border);border-radius:3px;padding:.08em .35em} +/* index lists: a plain directory listing, not marketing cards */ +table.dir{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums} +.dir th{font-family:'IBM Plex Mono','Space Mono',ui-monospace,monospace;font-weight:600; + font-size:.68rem;letter-spacing:.16em;text-transform:uppercase;color:var(--text-dim); + text-align:left;padding:.4rem .75rem;border-bottom:2px solid var(--bad-sector)} +.dir td{padding:.5rem .75rem;border-bottom:1px solid rgba(105,27,15,.5);vertical-align:baseline} +.dir td.size,.dir td.date{color:var(--text-dim);white-space:nowrap;text-align:right; + font-family:'IBM Plex Mono','Space Mono',ui-monospace,monospace;font-size:.8rem} +.dir tr:hover td{background:rgba(175,46,26,.05)} +.dir .name a{font-weight:600} +.dir .note{color:var(--text-dim);font-size:.85rem} +.pane{border:2px solid var(--border);border-radius:4px;background:rgba(35,9,5,.6); + box-shadow:3px 3px 0 0 var(--bad-sector),inset 0 1px 0 rgba(255,255,255,.04); + overflow:hidden} +.pane table.dir td:first-child{padding-left:1.1rem} +.pane table.dir th:first-child{padding-left:1.1rem} +.badge{display:inline-block;font-family:'IBM Plex Mono','Space Mono',ui-monospace,monospace; + font-size:.68rem;letter-spacing:.12em;text-transform:uppercase;color:var(--memory-leek); + border:1px solid rgba(143,166,103,.4);border-radius:3px;padding:.1rem .45rem;margin-left:.5rem} +.empty{color:var(--text-dim);font-style:italic} +footer{margin-top:3.5rem;padding-top:1rem;border-top:1px solid rgba(70,18,10,.6); + font-family:'IBM Plex Mono','Space Mono',ui-monospace,monospace;font-size:.72rem; + color:var(--text-dim);letter-spacing:.06em} +footer a{color:var(--text-dim);text-decoration:underline;text-underline-offset:2px} +footer a:hover{color:var(--fatal-error)} +""" + +# -------------------------------------------------------------------------- +SEMVER_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.+-]*)?$") + + +def semver_key(name: str) -> tuple: + m = SEMVER_RE.match(name) + if not m: + return (-1, name) + return (tuple(int(g) for g in m.groups()), name) + + +def human_size(n: int) -> str: + units = ["B", "KiB", "MiB", "GiB"] + v = float(n) + for u in units: + if v < 1024 or u == units[-1]: + return f"{v:.1f} {u}" if u != "B" else f"{int(v)} B" + v /= 1024 + return f"{v:.1f} GiB" + + +def esc(s: str) -> str: + return html.escape(s, quote=True) + + +# -------------------------------------------------------------------------- +@dataclass +class Project: + name: str + source_url: str = "" + # version-dir-name -> {"version": "v1.0.0", "date": "2026-08-12T.."} + versions: dict = field(default_factory=dict) + + +def load_meta(root: Path) -> dict[str, Project]: + meta_dir = root / ".meta" + out: dict[str, Project] = {} + if not meta_dir.is_dir(): + return out + for f in sorted(meta_dir.glob("*.json")): + try: + d = json.loads(f.read_text()) + except Exception: + continue + p = Project(name=d.get("name", f.stem), + source_url=d.get("source_url", ""), + versions=d.get("versions", {})) + out[p.name] = p + return out + + +def save_meta(root: Path, projects: dict[str, Project]) -> None: + meta_dir = root / ".meta" + meta_dir.mkdir(exist_ok=True) + for p in projects.values(): + (meta_dir / f"{p.name}.json").write_text(json.dumps( + {"name": p.name, "source_url": p.source_url, "versions": p.versions}, + indent=2, sort_keys=True) + "\n") + + +def scan_projects(root: Path, meta: dict[str, Project]) -> dict[str, Project]: + """Fold on-disk project dirs into the loaded meta (disk wins for version + list; meta supplies dates/source urls for previously published dirs). + + A child directory only counts as a project if it holds at least one + semver-looking version dir — that keeps helper dirs like ``scripts/`` + from being promoted as projects. + """ + for child in sorted(root.iterdir()): + if not child.is_dir() or child.name.startswith("."): + continue + vdirs = [d for d in child.iterdir() + if d.is_dir() and SEMVER_RE.match(d.name)] + if not vdirs and child.name not in meta: + continue + proj = meta.setdefault(child.name, Project(name=child.name)) + for vdir in sorted(vdirs): + proj.versions.setdefault(vdir.name, {"version": vdir.name, "date": ""}) + return meta + + +def git_last_commit_iso(repo: Path, path: Path) -> str: + try: + out = subprocess.check_output( + ["git", "log", "-1", "--format=%cI", "--", str(path.relative_to(repo))], + cwd=repo, stderr=subprocess.DEVNULL) + return out.decode().strip() + except Exception: + return "" + + +def fmt_date(iso: str) -> str: + if not iso: + return "—" + try: + dt = datetime.fromisoformat(iso.replace("Z", "+00:00")) + except ValueError: + return iso + return dt.strftime("%Y-%m-%d") + + +# -------------------------------------------------------------------------- +def page(*, title: str, crumb: Iterable[tuple[str, str]], lede: str, + body: str, base_url: str) -> str: + parts = [] + first = True + sep = ' / ' + for label, href in crumb: + if not first: + parts.append(sep) + parts.append(f'{esc(label)}' if href else esc(label)) + first = False + crumbs = "".join(parts) + # `lede` is trusted HTML (we build the source-url fragment ourselves); + # only its dynamic interpolations are already esc()'d at the call site. + return f""" + + + + + +{esc(title)} — katsuricata.tngl.io + + + +
+
{crumbs}tangled pages
+

{esc(title)}

+

{lede}

+
+{body} +
served by tangled pages · +built
+
+ + +""" + + +def render_root(projects: dict[str, Project], base_url: str) -> str: + if not projects: + body = '

Nothing published yet.

' + else: + rows = [] + for name in sorted(projects): + p = projects[name] + versions = sorted(p.versions, key=semver_key, reverse=True) + latest = versions[0] if versions else "" + src = f' · source' if p.source_url else "" + ver_count = f"{len(versions)} release{'s' if len(versions) != 1 else ''}" + latest_td = (f'{esc(latest)}' + if latest else '—') + rows.append( + f'{esc(name)}{src}' + f'{latest_td}' + f'{ver_count}' + f"") + body = ('
' + "" + f"{''.join(rows)}
projectlatestreleases
") + return page( + title="katsuricata.tngl.io", + crumb=[("katsuricata.tngl.io", "")], + lede="Release files for software published from tangled.org/katsuricata.com.", + body=body, base_url=base_url) + + +def render_project(p: Project, root: Path, base_url: str) -> str: + versions = sorted(p.versions, key=semver_key, reverse=True) + if not versions: + body = '

No releases yet.

' + else: + rows = [] + for i, vname in enumerate(versions): + info = p.versions[vname] + date = info.get("date") or git_last_commit_iso(root, root / p.name / vname) + badge = 'latest' if i == 0 else "" + rows.append( + f'{esc(vname)}{badge}' + f'{esc(fmt_date(date))}') + body = ('
' + "" + f"{''.join(rows)}
releasepublished
") + src = f' · source & history' if p.source_url else "" + return page( + title=p.name, + crumb=[("katsuricata.tngl.io", "/"), (p.name, "")], + lede=f"All published releases of {esc(p.name)}.{src}", + body=body, base_url=base_url) + + +def render_version(p: Project, vname: str, root: Path, base_url: str) -> str: + vdir = root / p.name / vname + files = [f for f in sorted(vdir.iterdir()) if f.is_file() and f.name != "index.html"] + if not files: + body = '

No downloadable artifacts recorded here yet.

' + else: + sums_link = '

SHA256SUMS · verify with sha256sum -c SHA256SUMS

' \ + if any(f.name == "SHA256SUMS" for f in files) else "" + rows = [] + for f in files: + st = f.stat() + note = "" + if f.name.endswith(".pkg.tar.zst"): + note = " · pacman package" + elif f.name.endswith("source.tar.gz"): + note = " · source" + elif f.name.endswith(".tar.gz"): + note = " · tarball" + elif f.name.endswith(".zip"): + note = " · zip" + elif f.name.startswith("sheaf-linux-") and "." not in f.name: + note = " · raw binary" + rows.append( + f'{esc(f.name)}' + f'{note}' + f'{human_size(st.st_size)}') + body = ('
' + "" + f"{''.join(rows)}
filesize
{sums_link}") + src = f' · source' if p.source_url else "" + return page( + title=f"{p.name} {vname}", + crumb=[("katsuricata.tngl.io", "/"), (p.name, f"/{p.name}/"), (vname, "")], + lede=f"Download files for {esc(p.name)} {esc(vname)}.{src}", + body=body, base_url=base_url) + + +# -------------------------------------------------------------------------- +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--repo-root", required=True, type=Path, help="pages repo checkout") + ap.add_argument("--project", required=True, help="project dir just published (e.g. sheaf)") + ap.add_argument("--version", required=True, help="version dir just published (e.g. v1.0.0)") + ap.add_argument("--tag-sha", default="", help="sha of the tagged commit (for provenance)") + ap.add_argument("--source-url", default="", help="tangled repo url, recorded in meta") + ap.add_argument("--base-url", default="https://katsuricata.tngl.io") + args = ap.parse_args() + root: Path = args.repo_root + + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + meta = scan_projects(root, load_meta(root)) + proj = meta.setdefault(args.project, Project(name=args.project)) + # guarantee the just-published version dir exists (empty releases still + # get an index page; CI has already copied artifacts into it) + (root / proj.name / args.version).mkdir(parents=True, exist_ok=True) + if args.source_url: + proj.source_url = args.source_url + proj.versions[args.version] = {"version": args.version, "date": now, + "tag_sha": args.tag_sha} + save_meta(root, meta) + + # write indexes; abs hrefs keep every page working at any depth + (root / "index.html").write_text(render_root(meta, args.base_url)) + for p in meta.values(): + pdir = root / p.name + pdir.mkdir(exist_ok=True) + (pdir / "index.html").write_text(render_project(p, root, args.base_url)) + for vname in p.versions: + vdir = pdir / vname + vdir.mkdir(parents=True, exist_ok=True) + (vdir / "index.html").write_text(render_version(p, vname, root, args.base_url)) + print(f"indexes written for {len(meta)} project(s); {args.project}/{args.version} stamped {now}") + + +if __name__ == "__main__": + main() diff --git a/scripts/write-ssh-key.sh b/scripts/write-ssh-key.sh new file mode 100644 index 0000000..c13a825 --- /dev/null +++ b/scripts/write-ssh-key.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""write-ssh-key.sh — normalize a pasted OpenSSH private key back to canonical +PEM so ssh(1) accepts it, regardless of how a secrets UI stored it. + +Handles the realistic ways a web "secret" field can mangle a multiline key: + +* CRLF line endings (``\\r`` stripped) +* missing trailing newline +* the base64 body collapsed onto one line with spaces instead of newlines +* leading/trailing whitespace + +Usage: write-ssh-key.sh + +Prints the number of bytes written; exits non-zero with a diagnostic if the +result doesn't look like an OpenSSH private key. +""" +import base64 +import re +import sys + +KEY_RE = re.compile( + r"-----BEGIN (?POPENSSH|PRIVATE|RSA|EC|DSA)[^-]*PRIVATE KEY-----" + r"(?P.*?)" + r"-----END (?P=kind)[^-]*PRIVATE KEY-----", + re.DOTALL, +) + + +def main() -> int: + if len(sys.argv) != 3: + print(__doc__) + return 2 + raw, out = sys.argv[1], sys.argv[2] + raw = raw.strip().replace("\r", "") + + m = KEY_RE.search(raw) + if not m: + print("write-ssh-key: no PEM block found in the secret", file=sys.stderr) + return 1 + header = m.group(0).split("-----", 4)[1] + kind = m.group("kind") + # re-wrap the base64 body at 70 chars/line (OpenSSH's native width) + body = re.sub(r"[^A-Za-z0-9+/=]", "", m.group("body")) + try: + base64.b64decode(body, validate=True) + except Exception as e: # noqa: BLE001 + print(f"write-ssh-key: base64 body is invalid: {e}", file=sys.stderr) + return 1 + lines = [body[i : i + 70] for i in range(0, len(body), 70)] + block = ( + f"-----BEGIN {kind} PRIVATE KEY-----\n" + + "\n".join(lines) + + f"\n-----END {kind} PRIVATE KEY-----\n" + ) + with open(out, "w") as fh: + fh.write(block) + print(f"normalized {kind} private key -> {out} ({len(block)} bytes)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) -- 2.51.2