ZDDC/shared/build-lib.sh
ZDDC 94591397cf build: pre-release semver for alpha/beta channels
Replace the build-counter version scheme (every alpha push monotonically
bumps the patch number, producing immutable :0.0.X tags that look
indistinguishable from stable releases) with proper semver pre-release
suffixes. Stable owns clean vX.Y.Z; alpha and beta carry
vX.Y.Z-{alpha,beta}[.N] indicating the next-stable target.

The next-stable target is the patch-bump of the latest clean
<prefix>-vX.Y.Z tag. Counter N is per-channel (alpha and beta count
separately) and resets when a new stable advances next-patch. Used
only for zddc-server image tags, where every release is git-tagged;
HTML tools omit the counter since alpha/beta cuts there don't tag.

release-image.sh:
- New CLI: sh release-image.sh [alpha|beta|stable] [<version>].
- Default channel alpha. Version arg only valid (and only optional)
  for stable.
- Auto-derives the version via next_prerelease for alpha/beta, and
  patch-bump for unspecified stable.
- Now creates the git tag itself (the auto-derived version is no
  longer something the operator can predict in advance), but does
  not push — operator finishes with `git push --tags`.

shared/build-lib.sh:
- Add next_prerelease(channel, tag_prefix) helper.
- compute_build_label embeds v<next-stable>-{alpha,beta} in the
  on-page label for plain and --release alpha|beta builds.
- Plain builds: v<next-stable>-alpha · <ts> · <sha>[-dirty]
  --release alpha: v<next-stable>-alpha · <date> · <sha>
  --release beta:  v<next-stable>-beta · <date> · <sha>
  --release [<version>]: v<X.Y.Z> (clean stable, unchanged shape).

Pre-release semver ordering (vX.Y.Z-alpha.1 < vX.Y.Z-alpha.2 <
vX.Y.Z-beta.1 < vX.Y.Z) is honored by registry tag sorting,
git tag --sort=-v:refname, sort -V, npm, cargo — so consumers can
pin or compare versions without surprises.

Existing zddc-server-v0.0.{3..7} git tags and registry tags are
audit history; not rewritten. Going forward, alpha/beta cuts produce
v0.0.8-{alpha,beta}.N format, and clean v0.0.8 is reserved for a
deliberate stable promotion.

freshen-channel needs no code change. It runs --release <channel>
inside a worktree at the latest stable tag, where the build-lib.sh
at that tag is still the old version producing old-format labels;
the first stable cut after this commit will propagate the new format
to subsequent freshens (per the existing "build pipeline at the tag"
reproducibility policy).

AGENTS.md and CLAUDE.md updated.
2026-04-29 17:32:35 -05:00

344 lines
14 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> — write to website/releases/ in the layout
# driven by $channel and $build_version. For
# stable, also update the _stable.html
# symlink and create the git tag.
# update_alpha <tool> — copy the just-built dist file to
# website/releases/<tool>_alpha.html so the
# site's alpha hyperlinks always serve the
# latest dev build. Called automatically by
# each tool's build.sh on plain (non-release)
# builds. Plain copy (not symlink) so it
# survives deployments whose web server only
# mounts website/. `--release alpha` writes
# the same file with the formal
# "alpha · <date> · <sha>" label; the next
# plain build clobbers it.
#
# Channels and release args:
# <none> dev build, dist/ + alpha mirror, label
# "v<next-stable>-alpha · <ts> · <sha>[-dirty]" (red).
# Plain builds ARE alpha builds — every dev build cascades
# to the alpha channel via update_alpha. The version embeds
# the next-stable target (patch-bump of the latest clean
# <tool>-vX.Y.Z tag).
# --release stable, auto-bump patch from latest tag (or 0.0.1).
# Label "vX.Y.Z" (black).
# --release X.Y.Z stable, explicit version.
# --release alpha alpha channel cut at HEAD;
# label "v<next-stable>-alpha · <date> · <sha>" (red).
# --release beta beta channel; label "v<next-stable>-beta · <date> · <sha>".
# Opt-in soak channel before stable promotion.
# --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
# 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"
}
# Echo the next pre-release version for a given channel + tag prefix.
# next_prerelease <channel> <tag_prefix>
#
# Channel must be alpha or beta. Tag prefix is the leading text on this
# tool's stable git tags — e.g. "zddc-server-v" or "archive-v" — so the
# function can be called from either the server release script or any
# HTML tool's build.sh against the same monorepo tag namespace.
#
# Algorithm:
# 1. Walk tags matching <prefix>X.Y.Z (clean stable, no suffix); pick the
# semver-highest. Default 0.0.0 if no stable tag exists yet.
# 2. Bump the patch component → next_patch.
# 3. Count existing tags of the form <prefix><next_patch>-<channel>.*
# and emit <next_patch>-<channel>.<count+1>.
#
# The patch-bump assumption: every active pre-release window targets the
# next patch of the latest stable. Cutting a real stable resets the
# counter naturally because next_patch advances. Operators wanting a
# minor or major bump cut stable explicitly with a version arg, then the
# subsequent alphas auto-derive against the new stable.
next_prerelease() {
_channel="$1"
_prefix="$2"
case "$_channel" in
alpha | beta) ;;
*) echo "next_prerelease: channel must be alpha or beta (got '$_channel')" >&2; return 1 ;;
esac
if [ -z "$_prefix" ]; then
echo "next_prerelease: tag prefix is required" >&2
return 1
fi
_latest=$(git -C "$root_dir" tag --list "${_prefix}*" 2>/dev/null \
| grep -E "^${_prefix}[0-9]+\.[0-9]+\.[0-9]+\$" \
| sed "s|^${_prefix}||" \
| sort -V \
| tail -1)
[ -n "$_latest" ] || _latest="0.0.0"
_major="${_latest%%.*}"
_rest="${_latest#*.}"
_minor="${_rest%%.*}"
_patch="${_rest#*.}"
_patch=$((_patch + 1))
_next_patch="${_major}.${_minor}.${_patch}"
_count=$(git -C "$root_dir" tag --list "${_prefix}${_next_patch}-${_channel}.*" 2>/dev/null | wc -l | tr -d ' ')
_count=$((_count + 1))
echo "${_next_patch}-${_channel}.${_count}"
}
# 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 mirror to website/releases/<tool>_alpha.html, so they ARE
# alpha builds. Full timestamp (granular than date) and -dirty marker
# distinguish iterative dev builds from formal `--release alpha` cuts.
_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 the appropriate slot under website/releases/.
# Reads from caller scope: $channel, $build_version, $output_html, $root_dir.
#
# Stable releases write website/releases/<tool>_v<version>.html, refresh the
# website/releases/<tool>_stable.html symlink, and tag <tool>-v<version> in
# git. Skips silently when the source has not changed since the latest tag.
#
# Alpha and beta channel releases overwrite website/releases/<tool>_<channel>.html
# in place with no tag (the embedded label carries date + commit SHA, so the
# source is recoverable from git directly).
promote_release() {
_tool="$1"
_releases_dir="$root_dir/../website/releases"
mkdir -p "$_releases_dir"
if [ "$channel" = "alpha" ] || [ "$channel" = "beta" ]; then
_dest="${_releases_dir}/${_tool}_${channel}.html"
cp "$output_html" "$_dest"
echo "Released $channel to $_dest"
return 0
fi
if [ "$channel" != "stable" ] || [ -z "$build_version" ]; then
echo "promote_release: refusing to promote — channel=$channel build_version=$build_version" >&2
exit 1
fi
_latest=$(git -C "$root_dir" tag --list "${_tool}-v*" --sort=-v:refname 2>/dev/null | head -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
_versioned="${_releases_dir}/${_tool}_v${build_version}.html"
cp "$output_html" "$_versioned"
echo "Released $_versioned"
# Symlink target is relative to its own directory so the link survives
# path moves and works regardless of where the website is mounted.
(cd "$_releases_dir" && ln -sfn "${_tool}_v${build_version}.html" "${_tool}_stable.html")
echo "Updated ${_tool}_stable.html -> ${_tool}_v${build_version}.html"
git -C "$root_dir" tag "${_tool}-v${build_version}"
echo "Tagged ${_tool}-v${build_version} — run: git push --tags"
}
# Mirror the just-built dist file to website/releases/<tool>_alpha.html so
# the website's alpha hyperlinks always serve whatever dist currently holds.
# Plain copy (not symlink): symlinks pointing outside website/ break under
# deployments whose web server only mounts website/ (notably the canonical
# Caddy setup at /etc/containers/systemd/caddy.container, which mounts
# /home/user/src/zddc/website read-only and cannot follow ../ paths to
# landing/dist or archive/dist on the host filesystem).
#
# Trade-off: every dev build that touches a tool's source dirties the
# corresponding _alpha.html file in git. Commit those alongside the source
# change (or `git checkout` them before pushing) since the alpha channel is
# explicitly mutable.
#
# `--release alpha` overwrites the same file with a "alpha · date · sha"
# labeled build; the next plain build clobbers it again. That's the alpha-
# is-mutable contract.
#
# Reads $output_html and $root_dir from caller scope.
update_alpha() {
_tool="$1"
_releases_dir="$root_dir/../website/releases"
_dest="${_releases_dir}/${_tool}_alpha.html"
mkdir -p "$_releases_dir"
# rm first: if the dest is currently a symlink to dist (legacy from the
# earlier symlink approach), `cp` would follow the symlink and try to
# write to the same file it's reading from. Removing first replaces the
# symlink with a plain regular file copy.
rm -f "$_dest"
cp "$output_html" "$_dest"
echo "Mirrored to $_dest"
}