ZDDC/build
ZDDC e6d9966593 refactor(tables): in-dir convention + unified table+form HTML bundle
Two intertwined refactors that share too many files to split cleanly.
Both are described separately below.

PART 1 — in-dir convention for table+form spec files

Old layout had the spec at the parent and rows in a child:

    archive/<party>/
      mdl.table.yaml         spec
      mdl.form.yaml          row-edit form
      mdl/                   rows-dir
        row-001.yaml ...

URLs were /<dir>/mdl.table.html and /<dir>/mdl.form.html. Copying
mdl/ elsewhere lost the spec and form because they lived next door.

New layout collapses everything into the rows-dir:

    archive/<party>/mdl/      self-contained
      table.yaml              spec
      form.yaml               row-edit form
      row-001.yaml ...        rows

URLs become /<dir>/mdl/table.html and /<dir>/mdl/form.html. The
"copying-the-folder-takes-everything" property the user asked for
falls out by construction; the row-edit URL /<dir>/<id>.yaml.html
keeps the same shape (spec is now in the same dir, not the
grandparent).

Server changes:

- internal/handler/tablehandler.go RecognizeTableRequest fires on
  /<dir>/table.html when <dir>/table.yaml exists. The .zddc.tables
  alias map is gone — pure presence-based discovery now matches
  the form system's existing convention. Default-MDL fallback at
  archive/<party>/mdl/ stays for the virgin-archive case (the
  rows-dir need not exist on disk; the URL renders fully virtually).

- internal/handler/formhandler.go RecognizeFormRequest fires on
  /<dir>/form.html and /<dir>/<id>.yaml.html with spec at
  <dir>/form.yaml. specEligible accepts on-disk files OR the
  default-MDL virtual path so an empty mdl/ dir still surfaces the
  add-row form.

- internal/handler/tablehandler.go IsDefaultMdlSpec moves to
  serving archive/<party>/mdl/{table,form}.yaml (5 segments after
  ZDDC_ROOT). New isAtArchivePartyMdlLevel predicate; new
  isAtArchivePartyMdlDir for directory-based recognition. New
  IsDefaultMdlSpecAbs accessor for callers that hold an abs path
  rather than a URL (formhandler).

- internal/handler/formhandler.go loadFormSpec(fsRoot, path) falls
  back to embedded default-MDL bytes when os.ReadFile returns
  NotExist AND the path matches the archive-party-mdl shape. Three
  call sites updated to pass cfg.Root.

- internal/handler/formhandler.go serveFormCreate writes
  submissions to filepath.Dir(req.SpecPath) — the spec, the form,
  and rows all live in one directory. The submissionsDir creation
  is idempotent (MkdirAll); cascade falls back one level for ACL
  evaluation when the dir hasn't been materialized yet.

- internal/handler/tablehandler.go tableRowsRedirect now points at
  /<dir>/table.html (was /<dir>.table.html) when the directory
  request maps to a recognized table.

- cmd/zddc-server/main.go dispatch synth flips from
  urlPath + ".table.html" to urlPath + "/table.html" for the
  no-trailing-slash → tables-app routing.

- internal/apps/availability.go DefaultAppAt comment clarified
  that the dir at archive/<party>/mdl/ IS the table (not a child).

Client changes:

- tables/js/context.js walkServer fetches <currentdir>/table.yaml
  directly — no .zddc walk for table declarations. Rows are every
  *.yaml in current dir EXCLUDING table.yaml and form.yaml. The
  .zddc fetch-for-aliases is gated on file:// (online mode 404s
  on .zddc reads via the dispatcher's reserve guard, so skipping
  the request avoids browser console noise).

- tables/js/main.js add-row button links to relative form.html
  (same dir).

- tables/js/render.js + filters.js: every column's autofilter is
  uniformly a text-contains input, even enum columns — keeps the
  filter row visually consistent and doesn't constrain users to
  the enum vocabulary.

PART 2 — unified table+form HTML bundle

The form-render and table-render code paths share field schemas,
the cell editor for excel-mode IS a form widget, and the form
system's POST-back / validation already exists. Combining the two
HTMLs eliminates duplicating jsyaml/jsonschema/theme/source-
detection/.zddc-parsing across two single-file tools.

- tables/template.html grows two top-level mode containers:
  #table-mode (toolbar + sortable table) and #form-mode (form +
  submit button). Both hidden at parse time; the dispatcher
  unhides one. The shared #form-context placeholder was added
  here so the server's existing injectFormContext target
  resolves.

- tables/js/mode.js (new) sets window.zddcMode synchronously
  based on URL pattern: /form.html or /<id>.yaml.html → form,
  /table.html → table, else inline-context fallback for
  file:// (whichever context blob is non-empty wins). Unhides
  the matching container at DOMContentLoaded.

- tables/js/main.js init() and form/js/main.js boot() each guard
  early when mode isn't theirs. Both apps live on different
  globals (window.tablesApp vs window.formApp) so module
  registration doesn't collide.

- form/js/main.js title write falls back from #form-title to
  #table-title (the unified bundle's shared header element)
  when the dedicated id isn't present.

- tables/build.sh concatenates form modules (widgets, render,
  object, array, errors, post, serialize, util) and form CSS.
  No new external deps. Bundle grows from ~95KB to ~120KB.

- internal/handler/formhandler.go drops the //go:embed form.html
  directive; serveFormRender now writes embeddedTablesHTML via
  a small formRenderHTML() accessor (var declared in
  tablehandler.go, same package). The embedded form.html file
  is removed.

- build script: cp form/dist/form.html → internal/handler/form.html
  step is gone (file no longer exists in the source tree). cp
  tables/dist/tables.html → internal/handler/tables.html now
  runs unconditionally rather than only on beta/stable cuts —
  the renderer is a fixed binary component and dev iteration
  needs the embedded copy refreshed every build. Channel-cascaded
  apps (internal/apps/embedded/) stay channel-gated as before.

- form/dist/form.html still builds for standalone offline-only
  use (downloadable from /releases/), but no longer goes into
  the binary.

Tests:

- internal/handler/tablehandler_test.go and formhandler_test.go
  rewritten for the in-dir layout. New test
  TestRecognizeFormRequest_DefaultMdlAtArchiveParty covers
  empty-form, create POST, re-edit row, and the negative cases
  (Working/, non-mdl name) where the fallback must NOT fire.

- internal/handler/directory_test.go updated for the new
  /<dir>/table.html redirect target.

- cmd/zddc-server/main_test.go TestDispatchSlashRouting Location
  expectation updated.

- tests/form-safety.spec.js loads tables/dist/tables.html
  (named form.html in the temp dir to trigger form-mode in the
  dispatcher) so it tests the same bytes the server returns.
  Title-element selector switches to #table-title.

- tests/tables.spec.js updates the status-filter test for the
  uniform text-input filter.

Docs:

- AGENTS.md form-data system rewrites the URL conventions and
  storage layout for in-dir; gains a Tables system section
  parallel to forms describing the self-contained-directory
  property; subfolder rules ("one table per folder by
  construction; subfolders allowed and silently ignored as rows
  — legitimate uses: nested sub-tables, per-row attachments,
  drafts, future history sidecars") so we don't re-derive this.

Not included (deferred):

- ACL gating on cell-level writes — not relevant until Phase 3.
- Editable cells UI — separate commit (Phase 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:15:26 -05:00

1060 lines
54 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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,
# and zddc/internal/apps/embedded/ is left alone
# (binary will embed whatever the last beta or
# stable cut committed there).
# ./build alpha cut alpha: produce a complete release bundle
# in dist/release-output/ (cascades nothing).
# Like dev, embedded/ is NOT updated — the
# invariant is that alpha labels are never baked
# into the binary.
# ./build beta cut beta (cascades alpha → beta). Updates
# embedded/ with beta-labeled tool HTMLs and
# commits them — the dev image (which builds
# from main) ships those bytes.
# ./build release cut coordinated stable (cascades alpha + beta
# → new stable; updates embedded/ with stable
# labels, makes a release commit, tags all
# seven tools at that commit). Prod images
# (which build from the latest stable tag)
# ship those bytes.
# ./build release X.Y.Z same, explicit version.
# ./build help this message.
#
# Lockstep: every channel/release cut bumps all seven tools (6 HTML +
# zddc-server) together. Coordinated next-stable = max(latest tag) + 1.
#
# Channel/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` (or `./deploy --releases`)
# to rsync the snapshot into /srv/zddc/. The snapshot is built by seeding
# from the current live state (so cascades and the verifier see a
# complete world), then mutating the channel(s) being cut on top.
#
# Bake-in invariant (what zddc-server's binary embeds via //go:embed):
# - prod image (Dockerfile.prod, ZDDC_REF=stable): always stable bytes
# - dev image (Dockerfile, ZDDC_REF=main): stable OR beta bytes
# (whatever last beta/
# stable cut wrote)
# - alpha is NEVER baked in. Active dev iteration happens via the tool's
# local dist/<tool>.html, not via the binary's embedded copy.
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, verify_channel_links,
# _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 website worktree
# writes); set means a channel/release cut that promotes to the website
# worktree under $ZDDC_DEPLOY_RELEASES_DIR.
RELEASE_CHANNEL=""
RELEASE_VERSION=""
case "${1:-dev}" in
dev|build)
# Dev build: tool dist/ + zddc-server binaries only. Touches
# nothing in the website worktree.
;;
alpha)
RELEASE_CHANNEL="alpha"
;;
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,22p' "$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"
# When cutting a channel/release, seed RELEASES_DIR from the current live
# site so the resulting bundle is a complete intended-live snapshot, not
# a sparse one-channel diff. Two reasons:
# 1. Per-tool promote_release does cascade writes (beta cut → also
# rewrites alpha to track beta; stable cut → resets alpha + beta).
# The cascade itself is deterministic, but downstream artifacts that
# were NOT touched by this cut (e.g. older versioned files, the
# other channel mirrors, partial-version symlinks) still need to be
# present in the bundle so `./deploy --releases` (rsync
# --delete-after) doesn't wipe them off the live site.
# 2. verify_channel_links cross-checks the full release tree; it
# flags absent channels as missing. With seeding, a fresh
# `dist/release-output/` matches live state, the cut mutates on
# top, and the verifier sees a complete world.
# Bootstrap case (no live site yet, or live releases dir empty) is
# silently skipped — the very first stable cut populates everything.
if [ -n "$RELEASE_CHANNEL" ]; 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 ==="
rm -rf "$RELEASES_DIR"
mkdir -p "$RELEASES_DIR"
# cp -a preserves the symlink graph (channel mirrors +
# _v<X.Y> / _v<X> partial-version pins) so cascade decisions
# downstream see the same world the live site has.
cp -a "$LIVE_RELEASES/." "$RELEASES_DIR/"
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/mdedit/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/ ==="
# Six 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, classifier, and mdedit 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.
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/mdedit/dist/mdedit.html" "$SCRIPT_DIR/zddc/dist/web/mdedit.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,mdedit,form,tables,browse}.html"
# Mirror the five 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 — that's the project invariant: alpha labels are
# never baked into the binary, beta labels go to the dev image (which builds
# from main), and stable labels go to prod (which builds from the latest
# stable tag). Plain `./build` and `./build alpha` leave the embedded files
# untouched; whatever the last beta/stable cut committed remains 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/mdedit/dist/mdedit.html" "$EMBED_DIR/mdedit.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 mdedit 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 channel/release cut has populated $RELEASES_DIR with the actual
# bytes for this build, walk the dir and produce a detached Ed25519 .sig
# alongside every real artifact. Symlinks (channel mirrors, partial-version
# pins) skip — the .sig at the symlink's target is what counts.
#
# 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, <tool>_<channel>.html,
# and zddc-server_v*_<plat>(.exe). Excludes the index, stub pages,
# and any pre-existing .sig files.
_list=$(find "$_dir" -maxdepth 1 -type f \( \
-name '*_v*.html' -o \
-name '*_stable.html' -o -name '*_beta.html' -o -name '*_alpha.html' -o \
-name 'zddc-server_v*' -o \
-name 'zddc-server_stable_*' -o -name 'zddc-server_beta_*' -o -name 'zddc-server_alpha_*' \
\) ! -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 channel/release cut, copy the freshly cross-compiled binaries to
# the website worktree's releases/ under their canonical names +
# symlinks. promote_zddc_server also re-runs write_zddc_server_stubs_all
# internally, so the matrix-cell stub pages get regenerated in the same
# call. On a plain dev build, skip — we don't touch the worktree.
if [ -n "$RELEASE_CHANNEL" ]; then
echo ""
echo "=== Promoting zddc-server $RELEASE_CHANNEL release ==="
promote_zddc_server "$RELEASE_CHANNEL" "$RELEASE_VERSION" "$RELEASES_DIR" "$SCRIPT_DIR/zddc/dist"
fi
# Latest stable version, by following archive_stable.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_stable.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
}
# Channel "active" iff the channel mirror is real bytes rather than a
# symlink → stable. Used to surface alpha/beta in the dropdown only when
# they meaningfully differ from stable. Probes archive (HTML lockstep
# representative); zddc-server's probe is its per-platform binary.
_channel_is_active() {
_ch="$1" # alpha | beta
_f="$RELEASES_DIR/archive_${_ch}.html"
[ -L "$_f" ] && return 1 # symlink → tracks stable, not "active"
[ -f "$_f" ] && return 0
return 1
}
# 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 'mdedit_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
)
_alpha_active="0"; _channel_is_active alpha && _alpha_active="1"
_beta_active="0"; _channel_is_active beta && _beta_active="1"
{
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
# Channels — selectable directly so users can copy the channel-
# mirror URLs (e.g. archive_stable.html) for bookmarks. stable is
# the default. The label tells the truth about the channel's
# current state: when stable is set, show which version it points
# at; when alpha/beta is just a symlink to stable, mark as
# "tracks stable" so picking it isn't surprising.
printf ' <optgroup label="Channels (mutable URLs)">\n'
if [ -n "$_latest" ] && [ "$_latest" != "0.0.0" ]; then
printf ' <option value="stable" selected>stable — currently v%s</option>\n' "$_latest"
else
printf ' <option value="stable" selected>stable</option>\n'
fi
if [ "$_beta_active" = "1" ]; then
printf ' <option value="beta">beta — general testing</option>\n'
else
printf ' <option value="beta">beta — tracks stable</option>\n'
fi
if [ "$_alpha_active" = "1" ]; then
printf ' <option value="alpha">alpha — active dev</option>\n'
else
printf ' <option value="alpha">alpha — tracks stable</option>\n'
fi
printf ' </optgroup>\n'
# Pinned per-version, latest first. These are the immutable URLs
# for reproducibility. No "(current stable)" suffix because the
# stable channel above already covers that.
printf ' <optgroup label="Pinned versions (immutable URLs)">\n'
printf '%s\n' "$_all_versions" | while read -r _v; do
[ -n "$_v" ] || continue
printf ' <option value="v%s">v%s</option>\n' "$_v" "$_v"
done
printf ' </optgroup>\n'
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_stable_linux-amd64" ]; then
_zs_published="1"
fi
if [ "$_zs_published" = "1" ]; then
# Default href is the channel-mirror URL (zddc-server_stable_<plat>)
# because "stable" is the dropdown's selected option. Picking a
# pinned version from the dropdown rewrites these to the
# immutable per-version URL via the IIFE.
printf ' <a class="dl-primary"\n'
printf ' data-tool="zddc-server"\n'
printf ' data-platform="linux-amd64"\n'
printf ' href="zddc-server_stable_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_stable_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_stable_%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." \
"mdedit|Markdown Editor|Edit project markdown files in place." \
"landing|Landing|Project picker for multi-project servers."; do
_t="${_entry%%|*}"
_rest="${_entry#*|}"
_name="${_rest%%|*}"
_desc="${_rest#*|}"
# Default href is the stable-channel mirror; the dropdown
# rewires these per selection.
printf ' <a class="tool-card" data-tool="%s" href="%s_stable.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">mdedit</code> <span style="color: var(--color-text-muted); font-size: 0.85rem;">— Markdown Editor</span></span>
<select class="composer-select" data-app="mdedit" 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_stable.html</code> &rarr; <code class="inline">archive_stable.html.sig</code>, etc.). Fetch both, then:</p>
<pre>curl -O https://zddc.varasys.io/releases/archive_stable.html
curl -O https://zddc.varasys.io/releases/archive_stable.html.sig
openssl pkeyutl -verify -pubin -inkey pubkey.pem \
-rawin -in archive_stable.html \
-sigfile archive_stable.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:
allow: ["*@yourcompany.com"]
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>
<!-- ───────────── Channels explainer ───────────── -->
<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); margin-bottom: var(--spacing-xl);">
<h2 style="margin-top:0;">Channels</h2>
<p>Three channels, applied in lockstep across all tools. Pre-release channels exist to soak changes; <strong>stable</strong> is what production runs.</p>
<div class="channel-explainer">
<div>
<h4 class="alpha">alpha</h4>
<p>Active dev iteration. Rebuilds without notice. Look here for the very latest.</p>
</div>
<div>
<h4 class="beta">beta</h4>
<p>Ready for general testing. Has soaked through alpha. Still mutable — pin to a versioned URL for reproducibility.</p>
</div>
<div>
<h4 class="stable">stable</h4>
<p>Ready to ship. Every per-version file is immutable; <code>_stable</code> follows the latest cut. Channel cuts cascade: stable cut resets beta and alpha to track stable.</p>
</div>
</div>
</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 isChannel(v) {
return v === 'stable' || v === 'beta' || v === 'alpha';
}
function platBinaryName(slug, plat) {
// slug is a channel name ("stable") or a pinned version ("v0.0.8").
// The on-disk name uses the slug as-is in both cases since the
// channel-mirror filenames are zddc-server_<channel>_<plat> and
// per-version are zddc-server_v<X.Y.Z>_<plat>.
var suf = (plat.indexOf('windows') === 0) ? '.exe' : '';
return 'zddc-server_' + slug + '_' + plat + suf;
}
function htmlAssetName(tool, slug) {
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 stable-channel
// mirror (`<tool>_stable.html`, `zddc-server_stable_<plat>`) so the
// page works fully without JS — the JS just keeps things in sync
// when the user picks a different channel or pins a version.
var picker = document.getElementById('version-picker');
if (!picker) return;
function rewire(slug) {
// slug ∈ {"stable", "beta", "alpha"} | "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') {
a.href = plat ? platBinaryName(slug, plat) : ('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 = 'stable'; // default per-app
});
function rebuild() {
var lines = ['apps:'];
selects.forEach(function(sel) {
lines.push(' ' + sel.dataset.app + ': ' + sel.value);
});
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"
}
# Matrix index + verifier only run when we touched the website
# worktree. Dev builds leave the worktree alone.
if [ -n "$RELEASE_CHANNEL" ]; then
echo ""
echo "=== Signing release artifacts ==="
sign_release_artifacts "$RELEASES_DIR"
echo ""
echo "=== Building releases/index.html ==="
build_releases_index
echo ""
echo "=== Verifying channel links ==="
verify_channel_links "$RELEASES_DIR"
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 alpha-dirty embedded/*) and prod
# images compiled from `git checkout zddc-server-vX.Y.Z` would
# ship alpha bytes. (Original justification — preserved.)
#
# 2. Beta: the dev pipeline pins the chart's appVersion to a SHA
# (.forgejo/scripts/notify-chart-bump.sh reads HEAD). 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 embedded form +
# tables templates.
git -C "$SCRIPT_DIR" add "$EMBED_DIR/" \
"$SCRIPT_DIR/zddc/internal/handler/form.html" \
"$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 mdedit 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 "To cut alpha into a deployable bundle: ./build alpha"
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 mdedit 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