#!/bin/sh
set -eu

# build — ZDDC source build + lockstep release driver.
#
#   ./build                  dev build: assemble tool dist/, cross-compile
#                            zddc-server binaries. Nothing else is touched
#                            — no release artifacts produced, no deploy,
#                            zddc/internal/apps/embedded/ is left alone
#                            (binary will embed whatever the last beta or
#                            stable cut committed there).
#   ./build beta             internal SHA snapshot for the BMC dev chart.
#                            Updates embedded/ with current tool HTMLs +
#                            makes a `chore(embedded): cut v<X.Y.Z>-beta`
#                            commit; the chart's appVersion pins to that
#                            SHA via Dockerfile parsing. NO public
#                            artifact in dist/release-output/.
#   ./build release          cut coordinated stable. Updates embedded/
#                            with stable-labeled bytes, makes a release
#                            commit, tags all 8 artifacts at that commit,
#                            writes <tool>_v<X.Y.Z>.html + <tool>.html
#                            symlink for every tool and the zddc-server
#                            per-platform binaries into
#                            dist/release-output/.
#   ./build release X.Y.Z    same, explicit version.
#   ./build help             this message.
#
# Lockstep: every release cut bumps all 8 tools (7 HTML + zddc-server)
# together. Coordinated next-stable = max(latest tag) + 1.
#
# Stable release cuts write a complete intended-live snapshot to
# ${ZDDC_DEPLOY_RELEASES_DIR:-$SCRIPT_DIR/dist/release-output}. The build
# does NOT touch the live site — run `./deploy --releases` to rsync the
# snapshot into /srv/zddc/. The snapshot is seeded from the current
# live state's per-version files (so older immutable artifacts are
# preserved), then this cut's <tool>.html canonical symlinks + new
# per-version file are written on top.
#
# Bake-in invariant (what zddc-server's binary embeds via //go:embed):
#   - prod image (Dockerfile.prod): always stable bytes — chart's
#                  Dockerfile.prod fetches the source at the latest
#                  zddc-server-vX.Y.Z tag.
#   - dev image  (Dockerfile):      stable OR beta-snapshot bytes — the
#                  chart's appVersion is set to either "X.Y.Z" (stable)
#                  or "X.Y.Z-beta-<sha>" (snapshot), and Dockerfile
#                  fetches that ref. Dev builds (`./build` no-arg) do
#                  NOT touch embedded/, so the binary's baked copy stays
#                  at whatever the last beta or stable cut wrote.

SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)

# Source build-lib.sh once at the top level so the helpers it provides
# (promote_zddc_server, write_zddc_server_stubs_all,
# _coordinated_next_stable) are in scope. Each tool's build.sh sources
# it again — that's a no-op on already-defined functions.
root_dir="$SCRIPT_DIR"
. "$SCRIPT_DIR/shared/build-lib.sh"

# --- Parse subcommand ------------------------------------------------------
# RELEASE_CHANNEL empty means dev mode (build only, no release output);
# "beta" means an internal SHA snapshot (regenerate embedded/ + commit,
# no public artifact); "stable" means a coordinated release cut that
# writes to dist/release-output/.
RELEASE_CHANNEL=""
RELEASE_VERSION=""

case "${1:-dev}" in
    dev|build)
        # Dev build: tool dist/ + zddc-server binaries only. Touches
        # nothing in release-output.
        ;;
    beta)
        RELEASE_CHANNEL="beta"
        ;;
    release)
        RELEASE_CHANNEL="stable"
        if [ -n "${2:-}" ]; then
            _validate_semver "$2"
            RELEASE_VERSION="$2"
            echo "=== Lockstep stable release — explicit version: v$RELEASE_VERSION ==="
        else
            RELEASE_VERSION=$(_coordinated_next_stable)
            echo "=== Lockstep stable release — coordinated version: v$RELEASE_VERSION ==="
        fi
        ;;
    help | -h | --help)
        sed -n '4,30p' "$0" | sed 's/^# \{0,1\}//'
        exit 0
        ;;
    *)
        echo "build: unknown subcommand '$1'. Try './build help'." >&2
        exit 1
        ;;
esac

# Per-tool argument list. Pass --release flag only when we're cutting
# a channel/release; dev builds invoke each tool with no args.
if [ -z "$RELEASE_CHANNEL" ]; then
    TOOL_RELEASE_ARGS=""
elif [ "$RELEASE_CHANNEL" = "stable" ]; then
    TOOL_RELEASE_ARGS="--release $RELEASE_VERSION"
else
    TOOL_RELEASE_ARGS="--release $RELEASE_CHANNEL"
fi

# Local-only build output. The release pipeline writes here; nothing
# escapes the source tree until the operator runs `./deploy`. Default
# is $SCRIPT_DIR/dist/release-output; override with
# $ZDDC_DEPLOY_RELEASES_DIR. Exported so child per-tool build.sh
# invocations see the same path.
export ZDDC_DEPLOY_RELEASES_DIR="${ZDDC_DEPLOY_RELEASES_DIR:-$SCRIPT_DIR/dist/release-output}"
RELEASES_DIR="$ZDDC_DEPLOY_RELEASES_DIR"
mkdir -p "$RELEASES_DIR"

# On a stable cut, seed RELEASES_DIR from the current live site so the
# resulting bundle is a complete intended-live snapshot, not a sparse
# diff. The seed copies the immutable per-version files
# (<tool>_v<X.Y.Z>.html, zddc-server_v<X.Y.Z>_<plat>) plus their .sig
# files. The cut then writes this version's new per-version files +
# refreshes the canonical <tool>.html / zddc-server_<plat> symlinks on
# top. `./deploy --releases` (rsync --delete-after) wipes any stale
# files in /srv/zddc/releases/ that aren't in the bundle.
#
# We skip the seed for beta cuts (no public artifacts to produce).
# Bootstrap case (no live site yet, or empty live releases dir) is
# silently skipped — the very first stable cut populates everything.
if [ "$RELEASE_CHANNEL" = "stable" ]; then
    LIVE_RELEASES="${ZDDC_LIVE_DIR:-/srv/zddc}/releases"
    if [ -d "$LIVE_RELEASES" ] && [ -n "$(ls -A "$LIVE_RELEASES" 2>/dev/null)" ]; then
        echo "=== Seeding $RELEASES_DIR from $LIVE_RELEASES (per-version artifacts only) ==="
        rm -rf "$RELEASES_DIR"
        mkdir -p "$RELEASES_DIR"
        # Copy per-version immutable files + their .sig sidecars. The
        # canonical <tool>.html / zddc-server_<plat> symlinks will be
        # rewritten by this cut and old channel/partial-pin files from
        # the previous layout (if any still live there) will be cleaned
        # up by deploy's --delete-after rsync.
        find "$LIVE_RELEASES" -maxdepth 1 -type f \( \
            -name '*_v*.html' -o \
            -name '*_v*.html.sig' -o \
            -name 'zddc-server_v*' \
        \) -exec cp -a '{}' "$RELEASES_DIR/" \;
        # Also seed the public key (it lives at the releases root).
        if [ -f "$LIVE_RELEASES/pubkey.pem" ]; then
            cp -a "$LIVE_RELEASES/pubkey.pem" "$RELEASES_DIR/"
        fi
    fi
fi

echo "=== Building ZDDC tools ==="

# Each tool's compute_build_label writes a sidecar `<tool>.label` here so
# we can assemble zddc/internal/apps/embedded/versions.txt below.
BUILD_LABELS_DIR="$SCRIPT_DIR/zddc/internal/apps/embedded/.labels"
rm -rf "$BUILD_LABELS_DIR"
mkdir -p "$BUILD_LABELS_DIR"
export BUILD_LABELS_DIR

# shellcheck disable=SC2086 # intentional word-splitting on TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/transmittal/build.sh" $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/archive/build.sh"     $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/classifier/build.sh"  $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/landing/build.sh"     $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/form/build.sh"        $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/tables/build.sh"      $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/browse/build.sh"      $TOOL_RELEASE_ARGS

echo ""
echo "=== Assembling zddc/dist/web/ ==="
# Tool HTMLs ship inside the server bundle. landing and archive call
# server APIs (GET / for the project list, directory listings for archive) and
# are useless without zddc-server. transmittal and classifier are pure
# client-side tools but are still bundled — the server uses these copies
# as the embedded fallback (//go:embed in internal/apps/embedded/) when both
# the cache is empty AND the upstream is unreachable. form is the schema-
# driven form renderer used by the form-data system; it's embedded into the
# handler package directly (not the apps cascade) since it isn't subject to
# per-folder version overrides. browse hosts the in-place markdown editor
# (no separate mdedit tool — retired in favor of browse's preview plugin).
mkdir -p "$SCRIPT_DIR/zddc/dist/web"
cp "$SCRIPT_DIR/landing/dist/index.html"             "$SCRIPT_DIR/zddc/dist/web/index.html"
cp "$SCRIPT_DIR/archive/dist/archive.html"           "$SCRIPT_DIR/zddc/dist/web/archive.html"
cp "$SCRIPT_DIR/transmittal/dist/transmittal.html"   "$SCRIPT_DIR/zddc/dist/web/transmittal.html"
cp "$SCRIPT_DIR/classifier/dist/classifier.html"     "$SCRIPT_DIR/zddc/dist/web/classifier.html"
cp "$SCRIPT_DIR/form/dist/form.html"                 "$SCRIPT_DIR/zddc/dist/web/form.html"
cp "$SCRIPT_DIR/tables/dist/tables.html"             "$SCRIPT_DIR/zddc/dist/web/tables.html"
cp "$SCRIPT_DIR/browse/dist/browse.html"             "$SCRIPT_DIR/zddc/dist/web/browse.html"
echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,form,tables,browse}.html"

# Mirror the cascade-served HTMLs into the apps embed source dir so the
# next `go build` of zddc-server picks them up via //go:embed. ONLY happens
# on a beta or stable cut — beta cuts feed the dev image (chart pins by
# SHA to the embedded-commit), stable cuts feed the prod image (chart
# pins to the tag). Plain `./build` leaves embedded files untouched —
# whatever the last beta or stable cut committed stays in place.
EMBED_DIR="$SCRIPT_DIR/zddc/internal/apps/embedded"
if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
    mkdir -p "$EMBED_DIR"
    cp "$SCRIPT_DIR/landing/dist/index.html"             "$EMBED_DIR/index.html"
    cp "$SCRIPT_DIR/archive/dist/archive.html"           "$EMBED_DIR/archive.html"
    cp "$SCRIPT_DIR/transmittal/dist/transmittal.html"   "$EMBED_DIR/transmittal.html"
    cp "$SCRIPT_DIR/classifier/dist/classifier.html"     "$EMBED_DIR/classifier.html"
    cp "$SCRIPT_DIR/browse/dist/browse.html"             "$EMBED_DIR/browse.html"
    echo "Populated $EMBED_DIR/ for //go:embed"
fi

# The unified tables renderer ships both table-mode and form-mode in
# one HTML — see tables/template.html and tables/js/mode.js. The Go
# server embeds a single tables.html (//go:embed in tablehandler.go);
# both ServeTable and ServeForm output these same bytes with their
# respective inline-context blob. Form-mode-only standalone use is
# served by form/dist/form.html (download-only, not embedded). Refresh
# on every build (including plain dev `./build`) so iteration on
# form/tables JS shows up in the binary without needing a beta cut.
cp "$SCRIPT_DIR/tables/dist/tables.html" "$SCRIPT_DIR/zddc/internal/handler/tables.html"
echo "Populated zddc/internal/handler/tables.html for //go:embed"

if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then

    # Assemble the embedded versions manifest from the per-tool .label sidecars
    # written by shared/build-lib.sh's compute_build_label. The Go side reads
    # this via //go:embed in internal/apps/versions.go and surfaces it in
    # `zddc-server --version` output and the startup log line.
    VERSIONS_FILE="$EMBED_DIR/versions.txt"
    {
        echo "# Generated by build.sh — do not edit. One <app>=<build label> per line."
        for _tool in archive transmittal classifier landing form tables browse; do
            _label_file="$BUILD_LABELS_DIR/${_tool}.label"
            if [ -f "$_label_file" ]; then
                _label=$(cat "$_label_file")
            else
                _label=""
            fi
            printf '%s=%s\n' "$_tool" "$_label"
        done
    } > "$VERSIONS_FILE"
    echo "Wrote $VERSIONS_FILE"
fi
rm -rf "$BUILD_LABELS_DIR"

# Cross-compiled zddc-server binaries for Linux/macOS/Windows. Always built
# inside docker.io/golang:1.24-alpine via podman (or docker), matching the
# helm/zddc-server-prod chart's `buildImage` so dev binaries are byte-for-byte
# what production gets. The build container is downloaded on first run.
echo ""
echo "=== Building zddc-server binaries (containerized) ==="
mkdir -p "$SCRIPT_DIR/zddc/dist"

# Pick a container runtime. Both work; podman is preferred (rootless default).
GO_RUNNER=""
if command -v podman >/dev/null 2>&1; then
    GO_RUNNER=podman
elif command -v docker >/dev/null 2>&1; then
    GO_RUNNER=docker
else
    echo "error: neither podman nor docker is available — cannot build zddc-server binaries." >&2
    echo "       Install podman (preferred) or docker. zddc-server build is containerized as policy." >&2
    exit 1
fi

GO_BUILD_IMAGE="${ZDDC_GO_BUILD_IMAGE:-docker.io/golang:1.24-alpine}"

# Cache the Go module + build cache across runs via named volumes that
# persist between container invocations. Second build is fast.
GO_MOD_VOL="${ZDDC_GO_MOD_VOL:-zddc-go-mod}"
GO_BUILD_VOL="${ZDDC_GO_BUILD_VOL:-zddc-go-cache}"

# Compute the binary's own version. On a stable cut, hard-code the
# coordinated version so the binary embeds the same string the rest of the
# release cycle has agreed on. Otherwise fall back to git describe (clean
# tag, or tag-N-gSHA[-dirty] for in-flight commits).
if [ -n "$RELEASE_VERSION" ]; then
    ZDDC_BINARY_VERSION="$RELEASE_VERSION"
else
    ZDDC_BINARY_VERSION=$(git -C "$SCRIPT_DIR" describe --tags --dirty --match 'zddc-server-v*' 2>/dev/null || true)
    if [ -z "$ZDDC_BINARY_VERSION" ]; then
        _sha=$(git -C "$SCRIPT_DIR" rev-parse --short=7 HEAD 2>/dev/null || echo unknown)
        if ! git -C "$SCRIPT_DIR" diff --quiet HEAD 2>/dev/null; then
            _sha="${_sha}-dirty"
        fi
        ZDDC_BINARY_VERSION="dev-${_sha}"
    fi
fi
echo "  binary version: $ZDDC_BINARY_VERSION"

# Single container invocation, multiple cross-compile targets inside a
# `for` loop — avoids paying image-startup overhead 4×.
"$GO_RUNNER" run --rm \
    -v "$SCRIPT_DIR:/src:Z" \
    -v "${GO_MOD_VOL}:/go/pkg/mod" \
    -v "${GO_BUILD_VOL}:/root/.cache/go-build" \
    -w /src/zddc \
    -e GOFLAGS=-mod=mod \
    -e CGO_ENABLED=0 \
    -e ZDDC_BINARY_VERSION="$ZDDC_BINARY_VERSION" \
    "$GO_BUILD_IMAGE" \
    sh -c '
        set -e
        for target in linux/amd64 darwin/amd64 darwin/arm64 windows/amd64; do
            os="${target%/*}"; arch="${target#*/}"
            out="zddc-server-${os}-${arch}"
            case "$os" in windows) out="${out}.exe" ;; esac
            echo "  building $out"
            GOOS="$os" GOARCH="$arch" \
                go build -trimpath \
                    -ldflags="-s -w -X main.version=${ZDDC_BINARY_VERSION}" \
                    -o "dist/$out" ./cmd/zddc-server
        done
    '

# --- Sign release artifacts -----------------------------------------------
# After a stable cut has populated $RELEASES_DIR with the actual bytes
# for this build, walk the dir and produce a detached Ed25519 .sig
# alongside every immutable per-version artifact. Canonical symlinks
# (<tool>.html, zddc-server_<plat>) skip — the .sig at the symlink's
# target is what counts, and a companion .sig symlink (also written
# by promote_release) chains the canonical .sig URL to that target.
#
# Operators verify with stdlib openssl:
#
#   openssl pkeyutl -verify -pubin -inkey pubkey.pem \
#       -rawin -in archive_v0.0.16.html \
#       -sigfile archive_v0.0.16.html.sig
#
# zddc-server's apps/fetch.go fetches the .sig alongside the artifact and
# verifies against the public key embedded at compile time
# (zddc/internal/apps/embedded/pubkey.pem).
#
# Key location: ZDDC_SIGNING_KEY (path to PEM-encoded Ed25519 private key).
# Unset → fail; signing is mandatory for release/channel cuts because the
# verifier rejects unsigned bytes outright.
sign_release_artifacts() {
    _dir="$1"
    if [ -z "${ZDDC_SIGNING_KEY:-}" ]; then
        echo "ERROR: ZDDC_SIGNING_KEY is unset; cannot sign release artifacts." >&2
        echo "  Generate a key once with:" >&2
        echo "    mkdir -p ~/.config/zddc-signing && chmod 700 ~/.config/zddc-signing" >&2
        echo "    openssl genpkey -algorithm Ed25519 -out ~/.config/zddc-signing/key.pem" >&2
        echo "    chmod 600 ~/.config/zddc-signing/key.pem" >&2
        echo "    export ZDDC_SIGNING_KEY=~/.config/zddc-signing/key.pem" >&2
        echo "  Then publish the matching public key:" >&2
        echo "    openssl pkey -in \$ZDDC_SIGNING_KEY -pubout > /path/to/pubkey.pem" >&2
        return 1
    fi
    if [ ! -f "$ZDDC_SIGNING_KEY" ]; then
        echo "ERROR: ZDDC_SIGNING_KEY=$ZDDC_SIGNING_KEY does not exist." >&2
        return 1
    fi
    # Collect the artifact list first so the signing loop runs in this
    # shell (no subshell counter scope issue). find: real files only
    # (-P, the default), matching <tool>_v*.html and
    # zddc-server_v*_<plat>(.exe). The canonical symlinks (<tool>.html /
    # zddc-server_<plat>) don't get separate .sig files — verification
    # follows the symlink to the immutable per-version file whose .sig
    # is signed below. Excludes the index, stub pages, and pre-existing
    # .sig files.
    _list=$(find "$_dir" -maxdepth 1 -type f \( \
        -name '*_v*.html' -o \
        -name 'zddc-server_v*' \
    \) ! -name '*.sig' ! -name 'index.html' ! -name 'zddc-server_*.html' 2>/dev/null)

    _signed=0
    _skipped=0
    OLDIFS="$IFS"
    IFS='
'
    for _f in $_list; do
        _sig="$_f.sig"
        if openssl pkeyutl -sign \
                -inkey "$ZDDC_SIGNING_KEY" \
                -rawin -in "$_f" \
                -out "$_sig" 2>/dev/null; then
            _signed=$((_signed + 1))
        else
            echo "  WARN: failed to sign $_f" >&2
            _skipped=$((_skipped + 1))
        fi
    done
    IFS="$OLDIFS"

    echo "  Signed $_signed artifacts, $_skipped skipped"
    if [ "$_skipped" -gt 0 ]; then
        return 1
    fi
}

# --- Promote zddc-server release artifacts ---------------------------------
# On a stable cut, copy the freshly cross-compiled binaries to the
# release-output bundle under their canonical names + symlinks.
# promote_zddc_server also re-runs write_zddc_server_stubs_all
# internally, so the per-version + canonical stub pages get regenerated
# in the same call. Beta cuts produce no public binary artifact.
if [ "$RELEASE_CHANNEL" = "stable" ]; then
    echo ""
    echo "=== Promoting zddc-server stable release ==="
    promote_zddc_server "stable" "$RELEASE_VERSION" "$RELEASES_DIR" "$SCRIPT_DIR/zddc/dist"
fi

# Latest stable version, by following archive.html → versioned target.
# Returns "" if no stable cut exists yet (bootstrap state). All HTML tools
# move in lockstep so any one of them is a valid probe; archive is canonical.
_latest_stable_version() {
    _link="$RELEASES_DIR/archive.html"
    [ -L "$_link" ] || return 0
    _target=$(readlink "$_link")
    # archive_v0.0.8.html → 0.0.8
    _v="${_target#archive_v}"
    _v="${_v%.html}"
    case "$_v" in
        [0-9]*.[0-9]*.[0-9]*) echo "$_v" ;;
    esac
}

# Regenerate website/releases/index.html as the action-first install
# guide (not a matrix). The page guides users to either self-host the
# server or download individual tools, with one version dropdown that
# rewires every download link via JS. The default static state always
# uses latest-stable URLs so the page works fully without JS.
build_releases_index() {
    _out="$RELEASES_DIR/index.html"
    mkdir -p "$RELEASES_DIR"

    _latest=$(_latest_stable_version)
    if [ -z "$_latest" ]; then
        _latest="0.0.0"
    fi

    # All distinct stable versions across every tool, descending. Same
    # awk that the prior matrix used — proven across the tool naming.
    _all_versions=$(
        find "$RELEASES_DIR" -maxdepth 1 -type f \( \
            -name 'archive_v*.html' -o -name 'transmittal_v*.html' \
            -o -name 'classifier_v*.html' -o -name 'browse_v*.html' \
            -o -name 'landing_v*.html' \
            -o -name 'zddc-server_v*_linux-amd64' \
        \) 2>/dev/null \
        | awk -F/ '{
            n = split($NF, parts, "_v");
            if (n < 2) next;
            v = parts[2];
            sub(/\.html$/, "", v);
            sub(/_linux-amd64$/, "", v);
            if (v ~ /^[0-9]+\.[0-9]+\.[0-9]+$/) print v;
        }' \
        | sort -Vu \
        | sort -Vr
    )

    {
        cat <<HEAD
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Download ZDDC</title>
  <meta name="description" content="Self-host the ZDDC server, or download individual tools. Pin a version your project trusts; your archive's tools are yours.">
  <meta name="theme-color" content="#2a5a8a">
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
  <link rel="stylesheet" href="../css/style.css">
</head>
<body>
  <header class="site-header">
    <div class="container header-content">
      <a href="/" class="brand">
        <svg class="brand-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
          <rect width="64" height="64" rx="12" fill="#1e3a5f"/>
          <g fill="#fff">
            <rect x="14" y="18" width="36" height="7"/>
            <polygon points="43,25 50,25 21,43 14,43"/>
            <rect x="14" y="43" width="36" height="7"/>
          </g>
        </svg>
        <span class="brand-name">ZDDC</span>
      </a>
      <nav class="header-nav">
        <a href="/" class="nav-link">Home</a>
        <a href="../reference.html" class="nav-link">Docs</a>
        <a href="index.html" class="nav-link active">Download</a>
      </nav>
    </div>
  </header>

  <section class="hero">
    <div class="container">
      <h1>Download ZDDC</h1>
      <p class="hero-subtitle">Pick how you want to use it. Pick the version you want. Every link below points at a real, immutable file you can save into your archive — your tools, your version, forever.</p>
    </div>
  </section>

  <main class="container" style="margin-bottom: var(--spacing-2xl);">
    <div class="version-picker-bar">
      <label for="version-picker">Showing</label>
      <select id="version-picker">
HEAD

        # "latest" — the canonical URL <tool>.html, a symlink that always
        # follows the most recently cut stable. Use this when you want
        # auto-updates. Default option so the page works fully without JS.
        if [ -n "$_latest" ] && [ "$_latest" != "0.0.0" ]; then
            printf '        <option value="latest" selected>latest stable — currently v%s</option>\n' "$_latest"
        else
            printf '        <option value="latest" selected>latest stable</option>\n'
        fi

        # Pinned per-version, latest first. Immutable URLs — pin one
        # into your archive when you depend on a specific behavior.
        printf '%s\n' "$_all_versions" | while read -r _v; do
            [ -n "$_v" ] || continue
            printf '        <option value="v%s">v%s (pinned)</option>\n' "$_v" "$_v"
        done

        cat <<'PICKER_END'
      </select>
      <span class="picker-hint">Changes every download link below.</span>
    </div>

    <!-- ───────────── Path A — Self-host the server ───────────── -->
    <section class="card" style="background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--spacing-lg) var(--spacing-xl); margin-top: var(--spacing-lg);">
      <h2 style="margin-top:0;">Path A — Self-host the server</h2>
      <p>One small Go binary. <strong>All five tools are baked in</strong> via <code>//go:embed</code>; the server picks the right one for each folder of your archive. Adds ACL via <code>.zddc</code> files, the virtual <code>.archive</code> document index, and SSO header passthrough. Stop the server and the directory is still a perfectly valid ZDDC archive — the server is convenience, not lock-in.</p>
PICKER_END

        # Render the download UI only when zddc-server has been published
        # at least once. Until then, show an honest "not yet released"
        # placeholder rather than dangling download buttons.
        _zs_published="0"
        if [ -e "$RELEASES_DIR/zddc-server_linux-amd64" ]; then
            _zs_published="1"
        fi

        if [ "$_zs_published" = "1" ]; then
            # Default href is the canonical per-platform URL
            # (zddc-server_<plat>), a symlink that always points at the
            # latest stable. Picking a pinned version from the dropdown
            # rewrites these to the immutable per-version URL via JS.
            printf '      <a class="dl-primary"\n'
            printf '         data-tool="zddc-server"\n'
            printf '         data-platform="linux-amd64"\n'
            printf '         href="zddc-server_linux-amd64"\n'
            printf '         id="dl-primary-binary">\n'
            printf '        <span class="dl-icon">⬇</span>\n'
            printf '        <span>Download <span id="dl-primary-platlabel">for Linux (x86_64)</span></span>\n'
            printf '      </a>\n'
            printf '      <span class="dl-primary-meta" id="dl-primary-meta">zddc-server_linux-amd64</span>\n'

            printf '      <div class="dl-secondary-row" id="dl-others">\n'
            printf '        <span>Other platforms:</span>\n'
            for _entry in "linux-amd64|Linux (x86_64)" \
                          "darwin-amd64|macOS (Intel)" \
                          "darwin-arm64|macOS (Apple Silicon)" \
                          "windows-amd64|Windows (x86_64)"; do
                _plat="${_entry%%|*}"
                _label="${_entry#*|}"
                _suffix=""
                case "$_plat" in *windows*) _suffix=".exe" ;; esac
                printf '        <a data-tool="zddc-server" data-platform="%s" href="zddc-server_%s%s">%s</a>\n' \
                    "$_plat" "$_plat" "$_suffix" "$_label"
            done
            printf '      </div>\n'

            cat <<'PATH_A_END'
      <p style="margin-top: var(--spacing-md); font-size: 0.92rem; color: var(--color-text-muted);">
        After download: <code>chmod +x</code> the file, set <code>ZDDC_ROOT=/path/to/archive</code>, run.
        Need a different platform? <a href="https://codeberg.org/VARASYS/ZDDC">Build from source</a> at the matching tag.
      </p>
    </section>
PATH_A_END
        else
            # Bootstrap state: no zddc-server stable cut yet.
            cat <<'PATH_A_BOOTSTRAP'
      <p style="margin-top: var(--spacing-md); padding: var(--spacing-md); background: var(--color-bg); border-left: 3px solid var(--color-accent); border-radius: var(--radius-sm); color: var(--color-text);">
        <strong>Not yet published.</strong> The first lockstep release publishes binaries here. Until then, build from source: <code>git clone</code> and <code>(cd zddc && go build ./cmd/zddc-server)</code>. Once <code>sh build.sh --release</code> runs, this card auto-populates with download buttons for every platform.
      </p>
    </section>
PATH_A_BOOTSTRAP
        fi

        cat <<'PATH_B_OPEN'

    <!-- ───────────── Path B — Standalone tool HTMLs ───────────── -->
    <section class="card" style="border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--spacing-lg) var(--spacing-xl); margin-top: var(--spacing-xl);">
      <h2 style="margin-top:0;">Path B — Standalone tools</h2>
      <p>Every tool is a single self-contained HTML file. <strong>Open it locally and point it at a folder on your disk</strong> — no install, no server, no account. Same on-disk layout the server uses. Use one tool, use all five, mix and match — there is no orchestration to set up.</p>
      <div class="grid-4" style="margin-top: var(--spacing-md);">
PATH_B_OPEN

        # Tool cards — reuse home page's .tool-card vocabulary
        for _entry in "archive|Archive Browser|Browse and download from a ZDDC archive." \
                      "transmittal|Transmittal Creator|Build, sign, and verify transmittal packages." \
                      "classifier|Classifier|Rename loose files to ZDDC convention." \
                      "browse|File Browser|Browse the project tree; includes the markdown editor." \
                      "landing|Landing|Project picker for multi-project servers."; do
            _t="${_entry%%|*}"
            _rest="${_entry#*|}"
            _name="${_rest%%|*}"
            _desc="${_rest#*|}"
            # Default href is the canonical symlink <tool>.html; the
            # dropdown rewires these per selection.
            printf '        <a class="tool-card" data-tool="%s" href="%s.html">\n' "$_t" "$_t"
            printf '          <span class="tool-card__title">%s</span>\n' "$_name"
            printf '          <span class="tool-card__desc">%s</span>\n' "$_desc"
            printf '          <span class="tool-card__link">Download &rarr;</span>\n'
            printf '        </a>\n'
        done

        cat <<'PATH_B_END'
      </div>
    </section>

    <!-- ───────────── Pinning empowerment narrative ───────────── -->
    <section class="card" style="border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--spacing-lg) var(--spacing-xl); margin-top: var(--spacing-xl);">
      <h2 style="margin-top:0;">Your version, forever</h2>
      <p>Your server may run v0.0.8 next month and v0.1.0 the month after. <strong>Your project doesn't have to follow.</strong> If you depend on a specific behavior in <code>archive</code> v0.0.5, save that version into your archive — the next server upgrade can't take it away from you. Two ways to do it:</p>
      <div class="grid-2" style="margin-top: var(--spacing-md);">
        <div class="pin-card">
          <h3>Drop a copy into your archive</h3>
          <p>Save the tool's HTML at the path the server would serve it from. The server's resolution order picks up real files <em>first</em> — before any cascade or embedded fallback.</p>
PATH_B_END

        printf '          <pre>curl -o MyProject/archive.html \\\n  https://zddc.varasys.io/releases/archive_v%s.html</pre>\n' "$_latest"

        cat <<'PIN_MID'
          <p>Now <code>MyProject/archive.html</code> is yours. The server serves your bytes; nothing about a future <code>--release</code> can change them.</p>
        </div>
        <div class="pin-card">
          <h3>Pin via <code>.zddc</code></h3>
          <p>Less invasive — no copies in your archive, just a small config entry telling the server which version to fetch and cache. Closer-to-leaf wins, so subprojects can pin further.</p>
PIN_MID

        printf '          <pre># MyProject/.zddc\napps:\n  archive: v%s</pre>\n' "$_latest"

        cat <<'PIN_END'
          <p>Server fetches once on first hit, caches under <code>_app/</code>, falls through to the embedded copy if the fetch fails.</p>
        </div>
      </div>
      <p class="pin-note">Your archive's tools are <strong>yours</strong>. The server is convenience; deletion of the server doesn't break your archive — every per-version download above is a real, immutable static file. Save what you trust.</p>
    </section>

    <!-- ───────────── Apps: composer ───────────── -->
    <section class="card" style="border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--spacing-lg) var(--spacing-xl); margin-top: var(--spacing-xl);">
      <h2 style="margin-top:0;">Build your <code>.zddc apps:</code> block</h2>
      <p>Pick a channel or pinned version for each tool. The YAML on the right updates as you go — copy it into a <code class="inline">.zddc</code> file at the level of your archive where you want the config to apply. <em>Closer-to-leaf wins</em>, so a project subdirectory can override what its parent says.</p>
      <div class="composer-grid" style="display: grid; grid-template-columns: minmax(260px, 1fr) 2fr; gap: var(--spacing-lg); margin-top: var(--spacing-md);">
        <div class="composer-rows" style="display: flex; flex-direction: column; gap: var(--spacing-sm);">
          <label class="composer-row" style="display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm);">
            <span style="flex: 1;"><code class="inline">archive</code> <span style="color: var(--color-text-muted); font-size: 0.85rem;">— Archive Browser</span></span>
            <select class="composer-select" data-app="archive" style="min-width: 140px;"></select>
          </label>
          <label class="composer-row" style="display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm);">
            <span style="flex: 1;"><code class="inline">transmittal</code> <span style="color: var(--color-text-muted); font-size: 0.85rem;">— Transmittal Creator</span></span>
            <select class="composer-select" data-app="transmittal" style="min-width: 140px;"></select>
          </label>
          <label class="composer-row" style="display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm);">
            <span style="flex: 1;"><code class="inline">classifier</code> <span style="color: var(--color-text-muted); font-size: 0.85rem;">— Classifier</span></span>
            <select class="composer-select" data-app="classifier" style="min-width: 140px;"></select>
          </label>
          <label class="composer-row" style="display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm);">
            <span style="flex: 1;"><code class="inline">browse</code> <span style="color: var(--color-text-muted); font-size: 0.85rem;">— File Browser (with markdown editor)</span></span>
            <select class="composer-select" data-app="browse" style="min-width: 140px;"></select>
          </label>
          <label class="composer-row" style="display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm);">
            <span style="flex: 1;"><code class="inline">landing</code> <span style="color: var(--color-text-muted); font-size: 0.85rem;">— Landing</span></span>
            <select class="composer-select" data-app="landing" style="min-width: 140px;"></select>
          </label>
        </div>
        <div class="composer-output" style="display: flex; flex-direction: column; gap: var(--spacing-sm);">
          <textarea id="composer-yaml" readonly rows="10" style="width: 100%; font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.875rem; padding: var(--spacing-sm); border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-bg); color: var(--color-text); resize: vertical;"></textarea>
          <div style="display: flex; justify-content: space-between; align-items: center;">
            <span id="composer-status" style="font-size: 0.85rem; color: var(--color-text-muted);"></span>
            <button id="composer-copy" type="button" class="dl-primary" style="padding: 0.4rem 0.9rem; font-size: 0.875rem;">Copy</button>
          </div>
        </div>
      </div>
      <p style="margin-top: var(--spacing-md); font-size: 0.9rem; color: var(--color-text-muted);">Tip: leave a tool on <strong>stable</strong> if you don't have a reason to pin it. Pin specific versions when you depend on a behavior in that release. The server's resolution order is: a real file in your archive &rarr; <code class="inline">.zddc apps:</code> entry (closer-to-leaf wins) &rarr; embedded fallback.</p>
    </section>

    <!-- ───────────── Verify your downloads ───────────── -->
    <section class="card" style="border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--spacing-lg) var(--spacing-xl); margin-top: var(--spacing-xl);">
      <h2 style="margin-top:0;">Verify your downloads</h2>
      <p>Every artifact published here is signed with a long-lived Ed25519 key. <strong>You can verify any download against the public key</strong> using stock <code class="inline">openssl</code> — no special tooling. <code class="inline">zddc-server</code> verifies automatically when fetching tools via the <code class="inline">apps:</code> cascade once the operator configures <code class="inline">ZDDC_APPS_PUBKEY</code> — same posture as TLS certificates: the binary bakes nothing in, the operator points it at the public key they trust.</p>
      <div class="grid-2" style="margin-top: var(--spacing-md);">
        <div class="pin-card">
          <h3>Public key</h3>
          <p><a href="../pubkey.pem"><strong>Download <code class="inline">pubkey.pem</code></strong></a></p>
          <p style="font-size: 0.85rem; color: var(--color-text-muted);">SHA-256 fingerprint of the DER-encoded SubjectPublicKeyInfo:</p>
          <pre style="word-break: break-all; white-space: pre-wrap;">7766dc8cf963f32156ddcc96825c52ba0333ffe4c243ad54f9eaf26195b065ab</pre>
          <p style="font-size: 0.85rem; color: var(--color-text-muted);">Verify the fingerprint after downloading:</p>
          <pre>openssl pkey -pubin -in pubkey.pem -outform DER | sha256sum</pre>
        </div>
        <div class="pin-card">
          <h3>Verify a download</h3>
          <p>Each artifact has a matching <code class="inline">.sig</code> file alongside it (<code class="inline">archive.html</code> &rarr; <code class="inline">archive.html.sig</code>, etc.). Fetch both, then:</p>
<pre>curl -O https://zddc.varasys.io/releases/archive.html
curl -O https://zddc.varasys.io/releases/archive.html.sig
openssl pkeyutl -verify -pubin -inkey pubkey.pem \
    -rawin -in archive.html \
    -sigfile archive.html.sig</pre>
          <p style="font-size: 0.85rem;">Output is <code class="inline">Signature Verified Successfully</code> on a clean download. Any other output (or no output and a non-zero exit) means the bytes do not match the published signature — do not trust them.</p>
        </div>
      </div>
      <h3 style="margin-top: var(--spacing-lg);">Configure <code class="inline">zddc-server</code> to auto-verify</h3>
      <p>For server deployments, configure the public key once and the apps fetcher verifies every <code class="inline">apps:</code>-pinned URL automatically. Two equivalent ways — pick whichever matches how you already manage config:</p>
      <div class="grid-2" style="margin-top: var(--spacing-md);">
        <div class="pin-card">
          <h3>Env var (k8s, systemd, Docker)</h3>
          <p>Save <code class="inline">pubkey.pem</code> on your server, then point <code class="inline">zddc-server</code> at it:</p>
<pre>curl -o /etc/zddc/pubkey.pem \
  https://zddc.varasys.io/pubkey.pem

ZDDC_APPS_PUBKEY=/etc/zddc/pubkey.pem \
ZDDC_ROOT=/srv/zddc ./zddc-server</pre>
          <p style="font-size: 0.85rem;">Fits the deployment shape where env vars are already the config plumbing — Helm chart values, systemd unit-file <code class="inline">Environment=</code>, Docker <code class="inline">-e</code>.</p>
        </div>
        <div class="pin-card">
          <h3>Inline in root <code class="inline">.zddc</code></h3>
          <p>Paste the PEM contents under <code class="inline">apps_pubkey:</code> in the root <code class="inline">.zddc</code>:</p>
<pre># &lt;ZDDC_ROOT&gt;/.zddc
admins: [you@yourcompany.com]
acl:
  permissions:
    '*@yourcompany.com': r
apps_pubkey: |
  -----BEGIN PUBLIC KEY-----
  MCowBQYDK2VwAyEAXXaxIUIyOFnhD1eZs02nEt3xZ8izOi7bURFcpJ9iWZY=
  -----END PUBLIC KEY-----</pre>
          <p style="font-size: 0.85rem;">Honored only at the root <code class="inline">.zddc</code> (root-only, like <code class="inline">admins:</code>). When both forms are set, the env var wins. Subtree <code class="inline">.zddc</code> files with <code class="inline">apps_pubkey:</code> are silently ignored — the trust anchor doesn't cascade.</p>
        </div>
      </div>
      <p style="margin-top: var(--spacing-md); font-size: 0.9rem; color: var(--color-text-muted);">When configured, the resolver fetches the <code class="inline">.sig</code> automatically on every URL-pinned <code class="inline">apps:</code> entry and rejects any unsigned or invalid-signature artifact, falling back to the embedded copy. Operators enforcing signature verification on locally-saved artifacts (Path A, "drop a copy into your archive") run the <code class="inline">openssl</code> verify command above as part of their save workflow.</p>
    </section>

  </main>

  <footer class="site-footer">
    <div class="container footer-content">
      <span>ZDDC is open source — <a href="https://codeberg.org/VARASYS/ZDDC">codeberg.org/VARASYS/ZDDC</a></span>
    </div>
  </footer>

  <script>
  (function() {
    // Platform auto-detect: choose the most likely binary for this user's
    // OS on first paint. Promotes that platform to the primary CTA; the
    // other three render in the secondary row. UA-sniffing is good
    // enough — wrong guesses fall through to the always-visible
    // "Other platforms" row below.
    var ua = navigator.userAgent || '';
    var detected = 'linux-amd64';   // sensible default
    var platLabel = 'Linux (x86_64)';
    if (/Macintosh|Mac OS X/.test(ua)) {
      // Apple Silicon vs Intel — UA hints aren't reliable, prefer arm64
      // since modern Macs are predominantly arm64. Users on Intel can
      // pick from "Other platforms".
      detected = 'darwin-arm64';
      platLabel = 'macOS (Apple Silicon)';
    } else if (/Windows/.test(ua)) {
      detected = 'windows-amd64';
      platLabel = 'Windows (x86_64)';
    }

    var primary = document.getElementById('dl-primary-binary');
    var primaryLabel = document.getElementById('dl-primary-platlabel');
    var primaryMeta = document.getElementById('dl-primary-meta');
    var others = document.getElementById('dl-others');

    function platBinaryName(slug, plat) {
      // slug === "latest" → canonical symlink zddc-server_<plat>;
      // slug === "v<X.Y.Z>" → immutable per-version zddc-server_v<X.Y.Z>_<plat>.
      var suf = (plat.indexOf('windows') === 0) ? '.exe' : '';
      if (slug === 'latest') {
        return 'zddc-server_' + plat + suf;
      }
      return 'zddc-server_' + slug + '_' + plat + suf;
    }
    function htmlAssetName(tool, slug) {
      // slug === "latest" → canonical symlink <tool>.html;
      // slug === "v<X.Y.Z>" → immutable per-version <tool>_v<X.Y.Z>.html.
      if (slug === 'latest') {
        return tool + '.html';
      }
      return tool + '_' + slug + '.html';
    }

    // Promote the detected platform to the primary CTA. The secondary
    // row keeps all four; the matching one is hidden to avoid showing
    // the same download twice.
    if (primary) {
      primary.dataset.platform = detected;
      if (primaryLabel) primaryLabel.textContent = 'for ' + platLabel;
    }
    if (others) {
      others.querySelectorAll('a[data-platform="' + detected + '"]').forEach(function(a) {
        a.style.display = 'none';
      });
    }

    // Single source of truth: the dropdown's current value drives every
    // download link's href. Static markup ships with the canonical
    // URLs (`<tool>.html`, `zddc-server_<plat>`) so the page works
    // fully without JS — the JS just keeps things in sync when the
    // user pins a specific version.
    var picker = document.getElementById('version-picker');
    if (!picker) return;

    function rewire(slug) {
      // slug === "latest" | "v<X.Y.Z>". Every link with a data-tool
      // attribute is a download URL the dropdown owns.
      document.querySelectorAll('[data-tool]').forEach(function(a) {
        var tool = a.dataset.tool;
        var plat = a.dataset.platform || '';
        if (tool === 'zddc-server') {
          if (plat) {
            a.href = platBinaryName(slug, plat);
          } else {
            a.href = (slug === 'latest') ? 'zddc-server.html' : ('zddc-server_' + slug + '.html');
          }
        } else {
          a.href = htmlAssetName(tool, slug);
        }
      });
      if (primary && primaryMeta) {
        primaryMeta.textContent = primary.getAttribute('href');
      }
    }

    picker.addEventListener('change', function() { rewire(picker.value); });

    // Run rewire once on load to apply the platform-detection result
    // (the static href for the primary button is for linux-amd64; on a
    // non-linux client, that needs to flip to the detected platform).
    rewire(picker.value);
  })();

  // ── Apps: composer ─────────────────────────────────────────────
  // Picks per-app channels/versions from dropdowns, emits an equivalent
  // .zddc apps: YAML block in a textarea, with a Copy button. Reuses
  // the version-picker's option list as the source of truth for what
  // values are valid (avoids duplicating the version data into JS).
  (function() {
    var picker = document.getElementById('version-picker');
    var textarea = document.getElementById('composer-yaml');
    var copyBtn = document.getElementById('composer-copy');
    var status = document.getElementById('composer-status');
    if (!picker || !textarea) return;
    var selects = document.querySelectorAll('.composer-select');
    if (selects.length === 0) return;

    // Clone version-picker's option list (channels + pinned versions)
    // into each per-app composer-select. innerHTML round-trips fine
    // because every option is a static <option value="...">label</option>
    // produced by the build script — no inline JS, no event handlers.
    var optionsHTML = picker.innerHTML;
    selects.forEach(function(sel) {
      sel.innerHTML = optionsHTML;
      sel.value = 'latest';   // default per-app
    });

    function rebuild() {
      // The picker uses "latest" as its sentinel; the .zddc apps:
      // cascade resolves "stable" the same way (follow the upstream
      // canonical URL), so emit "stable" for the YAML user.
      var lines = ['apps:'];
      selects.forEach(function(sel) {
        var val = (sel.value === 'latest') ? 'stable' : sel.value;
        lines.push('  ' + sel.dataset.app + ': ' + val);
      });
      textarea.value = lines.join('\n') + '\n';
    }

    selects.forEach(function(sel) {
      sel.addEventListener('change', rebuild);
    });
    rebuild();

    if (copyBtn) {
      copyBtn.addEventListener('click', function() {
        textarea.select();
        try {
          if (navigator.clipboard && navigator.clipboard.writeText) {
            navigator.clipboard.writeText(textarea.value);
          } else {
            document.execCommand('copy');
          }
          if (status) {
            status.textContent = 'Copied to clipboard';
            setTimeout(function() { status.textContent = ''; }, 2000);
          }
        } catch (e) {
          if (status) status.textContent = 'Copy failed — select the text and copy manually';
        }
      });
    }
  })();
  </script>
</body>
</html>
PIN_END
    } > "$_out"
    echo "Wrote $_out"
}

# Sign artifacts + regenerate releases/index.html on stable cuts.
# Beta cuts produce no public artifact, so nothing to sign or index.
if [ "$RELEASE_CHANNEL" = "stable" ]; then
    echo ""
    echo "=== Signing release artifacts ==="
    sign_release_artifacts "$RELEASES_DIR"

    echo ""
    echo "=== Building releases/index.html ==="
    build_releases_index
fi

# --- Embedded commit (stable + beta cuts) ---------------------------------
# On both stable and beta cuts, fold the regenerated embedded artifacts
# into a single commit on main. Two reasons:
#
#   1. Stable: the next tag block needs HEAD to point at the bytes the
#      stable binary will serve. Without this commit, tags would land on
#      the source-side commit (with stale embedded/*) and prod images
#      compiled from `git checkout zddc-server-vX.Y.Z` would ship stale
#      bytes. (Original justification — preserved.)
#
#   2. Beta: the dev chart pipeline pins appVersion to a SHA. For that
#      pin to point at a SHA where embedded/* matches what the binary
#      will serve, HEAD has to advance past the source-side commit.
#      Without this commit, the chart pin lags one commit and the dev
#      image bakes the previous beta cut's bytes — exactly the failure
#      mode that required manual chart-rebases on the v0.0.16-beta cuts.
#
# Idempotent: if there are no embedded changes, no commit is made.
if [ "$RELEASE_CHANNEL" = "stable" ] || [ "$RELEASE_CHANNEL" = "beta" ]; then
    echo ""
    echo "=== Embedded commit ==="

    # Stage the artifacts that are part of the release. dist/ is
    # gitignored everywhere — none of the tools' dist/<tool>.html files
    # are tracked. The release commit only carries the bake-in artifacts
    # that the binary needs at //go:embed time + the unified form/tables
    # template (form-mode is hosted by tables.html via the zddcMode
    # dispatcher; there is no separate form.html //go:embed target).
    git -C "$SCRIPT_DIR" add "$EMBED_DIR/" \
                              "$SCRIPT_DIR/zddc/internal/handler/tables.html"

    if ! git -C "$SCRIPT_DIR" diff --cached --quiet; then
        if [ "$RELEASE_CHANNEL" = "stable" ]; then
            git -C "$SCRIPT_DIR" commit -m "release: v${RELEASE_VERSION} lockstep"
        else
            # Beta cuts don't carry an explicit version — derive the
            # next-stable target from the lockstep helper so the commit
            # message reflects "cut v<X.Y.Z>-beta" the same way
            # versions.txt and the release-output filenames do.
            _embed_ver=$(_coordinated_next_stable)
            git -C "$SCRIPT_DIR" commit -m "chore(embedded): cut v${_embed_ver}-beta"
        fi
        echo "  embedded commit created at HEAD: $(git -C "$SCRIPT_DIR" rev-parse --short HEAD)"
    else
        echo "  no embedded changes to commit (re-run on same source state)"
    fi
fi

# --- Release tag (stable cut only) ----------------------------------------
# Beta channels never get tags — channel mirrors are by-design moving
# targets. Tags only exist for stable, where they pin a specific
# X.Y.Z to an immutable commit (the release commit produced above).
if [ "$RELEASE_CHANNEL" = "stable" ]; then
    echo ""
    echo "=== Release tag ==="

    # Tag the nine artifacts at HEAD. Pre-flight already validated that
    # any pre-existing tag is in HEAD's history, so this is safe.
    _head=$(git -C "$SCRIPT_DIR" rev-parse HEAD)
    for _t in archive transmittal classifier landing form tables browse zddc-server; do
        _tag="${_t}-v${RELEASE_VERSION}"
        if git -C "$SCRIPT_DIR" rev-parse -q --verify "refs/tags/$_tag" >/dev/null; then
            _existing=$(git -C "$SCRIPT_DIR" rev-list -n 1 "$_tag")
            if [ "$_existing" = "$_head" ]; then
                echo "  $_tag already at HEAD"
                continue
            fi
            # Pre-flight in promote_release confirmed _existing is an
            # ancestor of HEAD. Move it forward to HEAD (the release commit).
            git -C "$SCRIPT_DIR" tag -f "$_tag" "$_head" >/dev/null
            echo "  $_tag advanced to release commit"
        else
            git -C "$SCRIPT_DIR" tag "$_tag" "$_head"
            echo "  $_tag created at release commit"
        fi
    done
fi

echo ""
echo "=== Build done ==="
echo ""
if [ -z "$RELEASE_CHANNEL" ]; then
    echo "Mode: dev (source-only build; live site untouched)"
    echo "  tool/dist/*.html ready"
    echo "  zddc/dist/zddc-server-* binaries ready"
    echo ""
    echo "For an internal SHA snapshot (BMC dev chart):  ./build beta"
    echo "To cut a stable release:                       ./build release"
else
    echo "Cut: $RELEASE_CHANNEL"
    if [ -n "$RELEASE_VERSION" ]; then
        echo "Version: v$RELEASE_VERSION"
        echo ""
        echo "Tags created locally on main (push when ready):"
        for _t in archive transmittal classifier landing form tables browse zddc-server; do
            echo "  ${_t}-v${RELEASE_VERSION}"
        done
        echo "  git push origin main && git push origin --tags"
    fi
    echo ""
    echo "Snapshot ready at $RELEASES_DIR/"
    echo ""
    echo "To publish to the live site:"
    echo "  ./deploy --releases    # rsync the snapshot to /srv/zddc/releases/"
    echo "  ./deploy               # full sync (content + releases)"
fi
