#!/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. # ./build alpha cut alpha: produce a complete release bundle # in dist/release-output/ (cascades nothing). # ./build beta cut beta (cascades alpha → beta). # ./build release cut coordinated stable (cascades alpha + beta # → new stable; tags all six tools). # ./build release X.Y.Z same, explicit version. # ./build help this message. # # Lockstep: every channel/release cut bumps all six tools (5 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. 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 / _v 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 `.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 echo "" echo "=== Assembling zddc/dist/web/ ===" # All five 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. 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" echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,mdedit}.html" # Mirror the same five HTMLs into the Go embed source dir so the next # `go build` of zddc-server picks them up via //go:embed. Files are checked # into git as empty placeholders; the build always overwrites them with the # fresh dist/ output. EMBED_DIR="$SCRIPT_DIR/zddc/internal/apps/embedded" 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" echo "Populated $EMBED_DIR/ for //go:embed" # 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 mdedit landing; 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" 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 ' # --- 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 < 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_stable_linux-amd64" ]; then _zs_published="1" fi if [ "$_zs_published" = "1" ]; then # Default href is the channel-mirror URL (zddc-server_stable_) # 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 ' \n' printf ' \n' printf ' Download for Linux (x86_64)\n' printf ' \n' printf ' zddc-server_stable_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." \ "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 ' \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.

Channels

Three channels, applied in lockstep across all tools. Pre-release channels exist to soak changes; stable is what production runs.

alpha

Active dev iteration. Rebuilds without notice. Look here for the very latest.

beta

Ready for general testing. Has soaked through alpha. Still mutable — pin to a versioned URL for reproducibility.

stable

Ready to ship. Every per-version file is immutable; _stable follows the latest cut. Channel cuts cascade: stable cut resets beta and alpha to track stable.

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 "=== Building releases/index.html ===" build_releases_index echo "" echo "=== Verifying channel links ===" verify_channel_links "$RELEASES_DIR" 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 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