ZDDC/shared/build-lib.sh
ZDDC 408a1a0571 refactor: HTML tools live in website/releases/ as static files + symlink hierarchy
Rolls back the HTML-tool side of the Codeberg-as-canonical refactor
(commits 2dc9ad2, b28c4ae, bdac8dc) in favor of a simpler model:
per-version HTML files committed under website/releases/ as immutable
real files; partial-version pins (<tool>_v<X.Y>.html, <tool>_v<X>.html)
and channel mirrors (<tool>_<channel>.html) are checked-in symlinks.
Docker-tag pattern: :1.2.3 is pinned, :1.2 floats, :1 floats further,
:stable floats furthest.

URL scheme — every URL resolves to actual HTML via the symlink chain;
no JS indirection, no manifest.json, no Caddy regex-rewrite:

  /releases/<tool>_v<X.Y.Z>.html    exact version (real file)
  /releases/<tool>_v<X.Y>.html      latest patch within X.Y.* (symlink)
  /releases/<tool>_v<X>.html        latest within X.*.* (symlink)
  /releases/<tool>_stable.html      current stable (symlink)
  /releases/<tool>_beta.html        current beta (symlink to stable when no
                                    active beta; real file when beta is in flight)
  /releases/<tool>_alpha.html       current alpha (similar — symlink to beta
                                    or stable when no active alpha)

Cascade rule (in shared/build-lib.sh promote_release):
  --release [version] (stable cut) → write per-version file; refresh 5
                                     symlinks (_v<X.Y>, _v<X>, _stable,
                                     _beta, _alpha) → new versioned file;
                                     tag <tool>-v<X.Y.Z>.
  --release beta                   → overwrite <tool>_beta.html with real
                                     bytes; cascade _alpha.html → _beta.html
                                     (symlink). No tag — channel URLs are
                                     stable URLs by design; counters defeat
                                     that.
  --release alpha                  → overwrite <tool>_alpha.html with real
                                     bytes. No tag, no other side-effects.
  Plain `sh tool/build.sh`         → dist/ only. No website/releases/
                                     side-effect, no commit.

Code changes:

- .gitignore — drop website/releases/*.html and website/releases/zddc-server-*
  exclusions; HTML tool files are tracked again. Replace the comment with
  the new model description.
- shared/build-lib.sh — drop next_prerelease (no -alpha.N / -beta.N counter
  tags). Drop the Codeberg-upload path for HTML tools (no longer sourcing
  publish-codeberg-release.sh from build-lib). promote_release rewritten
  with two helpers: _promote_stable (per-version file + 5 symlinks + tag)
  and _promote_channel (overwrite mirror + cascade alpha→beta on beta cut).
- zddc/release.sh — drop alpha/beta channel path entirely; binaries publish
  only on stable cuts. zddc-server's beta/alpha builds-from-source via the
  helm charts (next phase) — no binary distribution needed for those channels.
- bootstrap/level2.html.tmpl — drop manifest.json fetch; resolve ?v= to a
  static URL via the symlink chain. New suffixFor() handles channel names,
  exact versions, and partial-version pins (?v=0.0, ?v=0). Same logic in
  level1.html.tmpl already works because the local-staging files (e.g.
  ../<tool>_v0.0.html) exist via the same symlink scheme.
- build.sh build_releases_index — revert to filesystem scan of
  website/releases/ instead of Codeberg API call. Drop manifest.json
  generation. Per-tool sections list channel chips + per-version pin links;
  zddc-server section links to Codeberg release pages directly.
- tests/build-label.spec.js — fix the channel-label regex to match the
  pre-release-semver format introduced in commit 9459139 ("v0.0.3-alpha · ...").
  Pre-existing test failure that wasn't caught at the time.

Storage:

- 30 new committed files under website/releases/ — 10 real (per-version) +
  20 symlinks (5 tools × 4 partial/channel variants, plus alpha as a real
  file by default).
- Initial state: stable v0.0.2 across all 5 tools; alpha/beta/v0.0/v0
  symlinks all point at <tool>_v0.0.2.html.
- manifest.json deleted (no longer needed).

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

339 lines
13 KiB
Bash
Executable file

#!/bin/sh
# =============================================================================
# ZDDC shared build helpers — sourced by each tool's build.sh
#
# Usage in a tool build.sh:
# root_dir=$(cd "$(dirname "$0")" && pwd)
# . "$root_dir/../shared/build-lib.sh"
#
# Provides:
# ensure_exists <path> — abort with error if file missing
# concat_files <file ...> — cat each relative path under $root_dir
# build_timestamp — ISO UTC timestamp string, set at source time;
# used as build_label for dev builds
# escape_js_close_tags <in> <out>
# — copy <in> to <out> with all '</' rewritten as
# '<\/' so the HTML parser cannot misread the
# inlined JS as containing a closing </script>.
# The JS engine treats \/ as a regular slash,
# so runtime behaviour is unchanged.
# compute_build_label <tool> [--release [<channel-or-version>]]
# — sets globals: build_label, build_version,
# is_release, is_red, channel.
# See "Channels and release args" below.
# promote_release <tool> — for stable / alpha / beta, copy the dist
# HTML into website/releases/. Stable cuts
# write the immutable per-version file +
# refresh five symlinks (_v<X.Y>, _v<X>,
# _stable, _beta, _alpha) and tag
# <tool>-v<X.Y.Z>. Alpha/beta cuts
# overwrite the channel mirror in place
# and cascade alpha → beta. No git tags
# for alpha/beta cuts; no Codeberg upload
# for HTML tools. See ARCHITECTURE.md
# "Channels" for the full table.
#
# Channels and release args:
# <none> dev build, dist/ only, label
# "v<next-stable>-alpha · <ts> · <sha>[-dirty]" (red).
# No website/releases/ side-effect. To publish, re-run
# with `--release alpha`.
# --release stable, auto-bump patch from latest tag (or 0.0.1).
# Writes per-version file + symlinks; tags vX.Y.Z.
# --release X.Y.Z stable, explicit version.
# --release alpha alpha channel cut at HEAD;
# label "v<next-stable>-alpha · <date> · <sha>" (red).
# Overwrites <tool>_alpha.html. No tag.
# --release beta beta channel; label "v<next-stable>-beta · <date> · <sha>".
# Overwrites <tool>_beta.html. Cascades <tool>_alpha.html
# → <tool>_beta.html (symlink). No tag.
# --release <other> error.
# =============================================================================
# Abort if root_dir is not set by the caller
if [ -z "${root_dir:-}" ]; then
echo "build-lib.sh: root_dir must be set before sourcing this file" >&2
exit 1
fi
# NOTE: shared/publish-codeberg-release.sh is no longer sourced here.
# HTML tools publish to website/releases/ as committed static files; only
# zddc-server/release.sh uploads to Codeberg (it sources the helper directly).
# Fail hard on any missing source file
ensure_exists() {
_path="$1"
if [ ! -f "$_path" ]; then
echo "error: missing file: $_path" >&2
exit 1
fi
}
# Concatenate files listed as positional args, each relative to root_dir
concat_files() {
for _rel do
ensure_exists "$root_dir/$_rel"
cat "$root_dir/$_rel"
printf '\n'
done
}
# ISO UTC build timestamp — set once when this file is sourced
build_timestamp=$(date -u +"%Y-%m-%d %H:%M:%S")
# Read shared/favicon.svg, base64-encode it, and assemble a data: URI suitable
# for inlining into a <link rel="icon" type="image/svg+xml" href="..."> tag.
# Set once at source time so every tool's build.sh can pick it up via the
# $favicon_data_uri global. Editing shared/favicon.svg auto-propagates to all
# tools on the next build.
_favicon_path="$root_dir/../shared/favicon.svg"
if [ -f "$_favicon_path" ]; then
favicon_data_uri="data:image/svg+xml;base64,$(base64 -w 0 "$_favicon_path")"
else
favicon_data_uri=""
fi
# Rewrite '</script' (case-insensitive) in JS as '<\/script' so the HTML parser
# cannot mistake string contents for a closing </script> tag. Per the HTML5 spec
# only </script terminates a <script> block — other tags like </div> are safe
# inside a script's text content. Narrowly targeting </script avoids corrupting
# regex literals like /</g whose trailing letter is a flag, not a tag name.
# The JS engine treats '\/' the same as '/' inside a string, so behaviour is
# unchanged. See ARCHITECTURE.md "HTML Embedding Safety".
escape_js_close_tags() {
sed 's#</\([sS][cC][rR][iI][pP][tT]\)#<\\/\1#g' "$1" > "$2"
}
# Validate that $1 is a strict X.Y.Z numeric version, where each component
# is a non-empty numeric string. Exits with an error if not.
_validate_semver() {
_v="$1"
_bad() {
echo "error: invalid release argument: '$_v' (expected: alpha, beta, or X.Y.Z stable version)" >&2
exit 1
}
_v1="${_v%%.*}"
_rest="${_v#*.}"
[ "$_rest" = "$_v" ] && _bad
_v2="${_rest%%.*}"
_v3="${_rest#*.}"
{ [ "$_v3" = "$_rest" ] || [ "$_v3" != "${_v3%.*}" ]; } && _bad
case "$_v1" in '' | *[!0-9]*) _bad ;; esac
case "$_v2" in '' | *[!0-9]*) _bad ;; esac
case "$_v3" in '' | *[!0-9]*) _bad ;; esac
}
# Compute build label and channel. Reads positional args:
# compute_build_label <tool_name> [--release [<channel-or-version>]]
# Sets global variables:
# build_label — text rendered into the page's {{BUILD_LABEL}} slot
# build_version — bare semver string (stable releases only)
# is_release — "1" for any --release invocation, else "0"
# is_red — "1" if the label should render red+bold (dev/alpha/beta), else "0"
# channel — "stable" / "alpha" / "beta" / "" (dev)
#
# Versioning: pre-release semver. The next-stable target is computed from
# the latest clean tool-vX.Y.Z tag (patch-bump). Plain builds and
# `--release alpha`/`--release beta` carry the next-stable target as a
# pre-release suffix in the on-page label so users can see which stable
# the alpha/beta is working toward. Stable releases write a clean
# vX.Y.Z label and tag.
#
# HTML tools do NOT tag alpha/beta cuts (consistent with current
# behavior — alpha and beta artifacts are mutable files, not immutable
# per-build snapshots). The label distinguishes plain dev builds from
# explicit channel cuts via the timestamp granularity (full ts + dirty
# marker for plain builds vs. date-only for `--release alpha|beta`).
compute_build_label() {
_tool="$1"
_flag="${2:-}"
_arg="${3:-}"
is_release=0
is_red=1
channel=""
build_version=""
# Compute the next-stable target once for label inclusion.
_next_stable=$(_next_stable_for_tool "$_tool")
if [ "$_flag" != "--release" ]; then
# Plain builds are dev builds — labeled as the alpha channel because
# that's what the next formal cut would produce, but no Codeberg upload
# happens until `--release alpha` is invoked. Full timestamp (granular
# than date) and -dirty marker distinguish iterative dev builds from
# formal `--release alpha` cuts (which stamp date-only).
_sha=$(git -C "$root_dir" rev-parse --short=7 HEAD 2>/dev/null || echo "unknown")
if ! git -C "$root_dir" diff --quiet HEAD 2>/dev/null; then
_sha="${_sha}-dirty"
fi
channel="alpha"
build_label="v${_next_stable}-alpha · ${build_timestamp} · ${_sha}"
return 0
fi
is_release=1
case "$_arg" in
alpha | beta)
channel="$_arg"
_date=$(date -u +"%Y-%m-%d")
_sha=$(git -C "$root_dir" rev-parse --short=7 HEAD 2>/dev/null || echo "unknown")
build_label="v${_next_stable}-${channel} · ${_date} · ${_sha}"
return 0
;;
'')
# Stable cut, auto-bump patch.
build_version="$_next_stable"
;;
*)
_validate_semver "$_arg"
build_version="$_arg"
;;
esac
channel="stable"
is_red=0
build_label="v${build_version}"
}
# Compute the next-stable target version for a tool — i.e., the patch-bump
# of the latest clean <tool>-vX.Y.Z tag. Used by compute_build_label to
# embed the target version in alpha/beta labels.
_next_stable_for_tool() {
_t="$1"
_latest=$(git -C "$root_dir" tag --list "${_t}-v*" 2>/dev/null \
| grep -E "^${_t}-v[0-9]+\.[0-9]+\.[0-9]+\$" \
| sed "s|^${_t}-v||" \
| sort -V \
| tail -1)
[ -n "$_latest" ] || _latest="0.0.0"
_major="${_latest%%.*}"
_rest="${_latest#*.}"
_minor="${_rest%%.*}"
_patch="${_rest#*.}"
echo "${_major}.${_minor}.$((_patch + 1))"
}
# Promote a built dist file to website/releases/. Reads from caller scope:
# $channel ("stable" / "alpha" / "beta"), $build_version (stable only),
# $output_html, $root_dir.
#
# Stable cuts:
# 1. Skip if source unchanged since latest stable tag.
# 2. Copy dist HTML → website/releases/<tool>_v<X.Y.Z>.html (immutable).
# 3. Refresh symlinks: _v<X.Y>, _v<X>, _stable, _beta, _alpha all → the
# new versioned file. Cascade rule: stable cut means beta and alpha
# reset to stable (no active dev on either downstream channel).
# 4. Tag the commit <tool>-v<X.Y.Z>.
#
# Alpha/beta cuts:
# 1. Overwrite website/releases/<tool>_<channel>.html with dist HTML
# (replaces a symlink with real bytes if one was there).
# 2. For beta: cascade <tool>_alpha.html → <tool>_beta.html (symlink),
# since alpha defaults to beta when no active alpha.
# 3. No tag — channel URLs are stable URLs by design; counters defeat
# that. The on-page label encodes <date> · <sha> for traceability.
#
# Plain dev builds (no --release): never call promote_release.
#
# No Codeberg upload — HTML tools live in git. zddc-server's release.sh
# handles binary uploads to Codeberg directly (different distribution model).
promote_release() {
_tool="$1"
_releases_dir="$root_dir/../website/releases"
if [ ! -d "$_releases_dir" ]; then
echo "promote_release: $_releases_dir not found" >&2
return 1
fi
case "$channel" in
stable)
if [ -z "$build_version" ]; then
echo "promote_release: stable channel but no build_version" >&2
return 1
fi
_latest=$(git -C "$root_dir" tag --list "${_tool}-v*" 2>/dev/null \
| grep -E "^${_tool}-v[0-9]+\.[0-9]+\.[0-9]+\$" \
| sort -V | tail -1)
if [ -n "$_latest" ] && git -C "$root_dir" diff --quiet "$_latest" HEAD -- . ../shared 2>/dev/null; then
echo "${_tool}: no source changes since $_latest — skipping"
return 0
fi
_promote_stable "$_tool" "$build_version" "$_releases_dir"
;;
alpha | beta)
_promote_channel "$_tool" "$channel" "$_releases_dir"
;;
*)
echo "promote_release: unknown channel '$channel'" >&2
return 1
;;
esac
}
# Stable cut: per-version file + 5 symlinks + git tag.
_promote_stable() {
_t="$1"
_ver="$2"
_rdir="$3"
_major="${_ver%%.*}"
_rest="${_ver#*.}"
_minor="${_rest%%.*}"
_versioned="${_t}_v${_ver}.html"
cp "$output_html" "$_rdir/$_versioned"
echo "Wrote $_rdir/$_versioned"
# Refresh the 5 symlinks. Cascade: stable cut → beta + alpha both
# reset to stable (no active dev on either downstream channel).
for _sym in "${_t}_v${_major}.${_minor}.html" \
"${_t}_v${_major}.html" \
"${_t}_stable.html" \
"${_t}_beta.html" \
"${_t}_alpha.html"; do
ln -sfn "$_versioned" "$_rdir/$_sym"
echo " $_sym$_versioned"
done
_tag="${_t}-v${_ver}"
if git -C "$root_dir" rev-parse -q --verify "refs/tags/$_tag" >/dev/null; then
_existing=$(git -C "$root_dir" rev-list -n 1 "$_tag")
_head=$(git -C "$root_dir" rev-parse HEAD)
if [ "$_existing" != "$_head" ]; then
echo "promote_release: tag $_tag exists at $_existing but HEAD is $_head" >&2
return 1
fi
echo "(tag $_tag already at HEAD)"
else
git -C "$root_dir" tag "$_tag"
echo "tagged $_tag"
fi
echo "Released ${_t} v${_ver} (stable)"
echo " publish git tag with: git push origin $_tag"
}
# Alpha/beta cut: overwrite mutable channel mirror; cascade alpha → beta
# on a beta cut (alpha defaults to beta when no active alpha).
_promote_channel() {
_t="$1"
_ch="$2"
_rdir="$3"
_file="${_t}_${_ch}.html"
# Replace symlink (if present) with real bytes by removing first;
# cp -f follows symlinks and would overwrite the symlink target.
rm -f "$_rdir/$_file"
cp "$output_html" "$_rdir/$_file"
echo "Wrote $_rdir/$_file"
if [ "$_ch" = "beta" ]; then
ln -sfn "$_file" "$_rdir/${_t}_alpha.html"
echo " ${_t}_alpha.html → $_file"
fi
echo "Released ${_t} ${_ch}"
}