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.
344 lines
14 KiB
Bash
Executable file
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"
|
|
}
|