#!/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-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 _v.html + .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 .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-" (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 # (_v.html, zddc-server_v_) plus their .sig # files. The cut then writes this version's new per-version files + # refreshes the canonical .html / zddc-server_ 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 only. # Strict X.Y.Z match avoids picking up legacy partial-version # pins (_v, _v) that may still be lying around as # leftover .sig files in /srv/zddc/releases/ from the pre- # simplification layout. The canonical .html / # zddc-server_ symlinks will be rewritten by this cut; # anything else (channel mirrors, partial pins, retired tools) # gets cleaned by deploy's --delete-after rsync. find "$LIVE_RELEASES" -maxdepth 1 -type f -regextype posix-extended \( \ -regex '.*/[a-z-]+_v[0-9]+\.[0-9]+\.[0-9]+\.html' -o \ -regex '.*/[a-z-]+_v[0-9]+\.[0-9]+\.[0-9]+\.html\.sig' -o \ -regex '.*/zddc-server_v[0-9]+\.[0-9]+\.[0-9]+_.*' \ \) -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 `.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 = 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 # (.html, zddc-server_) 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 _v*.html and # zddc-server_v*_(.exe). The canonical symlinks (.html / # zddc-server_) 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 < Download ZDDC

Download ZDDC

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.

Changes every download link below.

Path A — Self-host the server

One small Go binary. All five tools are baked in via //go:embed; the server picks the right one for each folder of your archive. Adds ACL via .zddc files, the virtual .archive 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.

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_), 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 ' \n' printf ' \n' printf ' Download for Linux (x86_64)\n' printf ' \n' printf ' zddc-server_linux-amd64\n' printf '
\n' printf ' Other platforms:\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 ' %s\n' \ "$_plat" "$_plat" "$_suffix" "$_label" done printf '
\n' cat <<'PATH_A_END'

After download: chmod +x the file, set ZDDC_ROOT=/path/to/archive, run. Need a different platform? Build from source at the matching tag.

PATH_A_END else # Bootstrap state: no zddc-server stable cut yet. cat <<'PATH_A_BOOTSTRAP'

Not yet published. The first lockstep release publishes binaries here. Until then, build from source: git clone and (cd zddc && go build ./cmd/zddc-server). Once sh build.sh --release runs, this card auto-populates with download buttons for every platform.

PATH_A_BOOTSTRAP fi cat <<'PATH_B_OPEN'

Path B — Standalone tools

Every tool is a single self-contained HTML file. Open it locally and point it at a folder on your disk — 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.

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 .html; the # dropdown rewires these per selection. printf ' \n' "$_t" "$_t" printf ' %s\n' "$_name" printf ' %s\n' "$_desc" printf ' Download →\n' printf ' \n' done cat <<'PATH_B_END'

Your version, forever

Your server may run v0.0.8 next month and v0.1.0 the month after. Your project doesn't have to follow. If you depend on a specific behavior in archive 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:

Drop a copy into your archive

Save the tool's HTML at the path the server would serve it from. The server's resolution order picks up real files first — before any cascade or embedded fallback.

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

Now MyProject/archive.html is yours. The server serves your bytes; nothing about a future --release can change them.

Pin via .zddc

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.

PIN_MID printf '
# MyProject/.zddc\napps:\n  archive: v%s
\n' "$_latest" cat <<'PIN_END'

Server fetches once on first hit, caches under _app/, falls through to the embedded copy if the fetch fails.

Your archive's tools are yours. 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.

Build your .zddc apps: block

Pick a channel or pinned version for each tool. The YAML on the right updates as you go — copy it into a .zddc file at the level of your archive where you want the config to apply. Closer-to-leaf wins, so a project subdirectory can override what its parent says.

Tip: leave a tool on stable 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 → .zddc apps: entry (closer-to-leaf wins) → embedded fallback.

Verify your downloads

Every artifact published here is signed with a long-lived Ed25519 key. You can verify any download against the public key using stock openssl — no special tooling. zddc-server verifies automatically when fetching tools via the apps: cascade once the operator configures ZDDC_APPS_PUBKEY — same posture as TLS certificates: the binary bakes nothing in, the operator points it at the public key they trust.

Public key

Download pubkey.pem

SHA-256 fingerprint of the DER-encoded SubjectPublicKeyInfo:

7766dc8cf963f32156ddcc96825c52ba0333ffe4c243ad54f9eaf26195b065ab

Verify the fingerprint after downloading:

openssl pkey -pubin -in pubkey.pem -outform DER | sha256sum

Verify a download

Each artifact has a matching .sig file alongside it (archive.htmlarchive.html.sig, etc.). Fetch both, then:

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

Output is Signature Verified Successfully 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.

Configure zddc-server to auto-verify

For server deployments, configure the public key once and the apps fetcher verifies every apps:-pinned URL automatically. Two equivalent ways — pick whichever matches how you already manage config:

Env var (k8s, systemd, Docker)

Save pubkey.pem on your server, then point zddc-server at it:

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

Fits the deployment shape where env vars are already the config plumbing — Helm chart values, systemd unit-file Environment=, Docker -e.

Inline in root .zddc

Paste the PEM contents under apps_pubkey: in the root .zddc:

# <ZDDC_ROOT>/.zddc
admins: [you@yourcompany.com]
acl:
  permissions:
    '*@yourcompany.com': r
apps_pubkey: |
  -----BEGIN PUBLIC KEY-----
  MCowBQYDK2VwAyEAXXaxIUIyOFnhD1eZs02nEt3xZ8izOi7bURFcpJ9iWZY=
  -----END PUBLIC KEY-----

Honored only at the root .zddc (root-only, like admins:). When both forms are set, the env var wins. Subtree .zddc files with apps_pubkey: are silently ignored — the trust anchor doesn't cascade.

When configured, the resolver fetches the .sig automatically on every URL-pinned apps: 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 openssl verify command above as part of their save workflow.

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