build: simplify to stable + exact-version (drop alpha/beta as public concepts)

Releases publish only two things per tool now: a current-stable
canonical symlink and an immutable per-version file. No more channel
mirrors (_stable/_beta/_alpha) and no more partial-version pins
(_v<X.Y>, _v<X>) — those were debt from a release model that never
matched the project's actual usage.

The `./build beta` verb stays, but narrowed: it's an internal SHA
snapshot for the BMC dev chart pipeline (chart's appVersion pins to
"<X.Y.Z>-beta-<sha>" and the chart Dockerfile fetches that SHA from
git). No public artifact on /srv/zddc/releases/. The embedded/* +
chore commit produced by `./build beta` is the actual snapshot.

`./build alpha` is removed entirely.

build/build-lib.sh:
- Drop alpha verb; narrow beta verb to embedded regen + chore commit
- promote_release: stable cut writes <tool>_v<X.Y.Z>.html + <tool>.html
  symlink + <tool>.html.sig companion symlink; beta is a no-op
- promote_zddc_server: same shape — per-version binary +
  per-platform canonical symlink (zddc-server_<plat>) + .sig symlink
- write_zddc_server_stub: singular; emits per-version stubs +
  one canonical zddc-server.html for current stable
- Delete _promote_channel, verify_channel_links, _channel_is_active
- Seed-from-live now copies only per-version files + .sig + pubkey.pem
  (the canonical symlinks get rewritten by this cut; old layout files
  get cleaned by deploy's --delete-after)
- build_releases_index: dropdown simplified to "latest stable +
  pinned versions"; channels-explainer section removed; tool cards +
  CTA URLs point at canonical <tool>.html / zddc-server_<plat>;
  composer emits "stable" sentinel for `apps:` entries
- Fix the acl:{allow:[...]} footgun in the apps_pubkey example

apps.go:
- isValidChannelOrVersion: accept only "stable" + exact X.Y.Z
  (drop alpha/beta and partial pins v0.0/v0)
- normalizeChannel: same
- Resolve URL composition: stable → canonical <prefix>/<app>.html
  (no _stable_ suffix), exact-version → <prefix>/<app>_v<X.Y.Z>.html
- Tests rewritten to match (beta/alpha replaced with v0.0.4 / stable;
  a new TestParseSpec_RejectsLegacyChannelsAndPartialPins locks in
  that the removed forms now error)

browse/build.sh: gate promote_release on $is_release like every other
tool's build.sh (longstanding inconsistency that errored under the new
promote_release case-statement).

freshen-channel: deleted (no channels to freshen).

Net: -254 lines, all green on full `go test ./...`. Dev build verified
via `./build` (no-arg) — new label format "v<next>-dev · <ts> · <sha>".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-20 09:17:46 -05:00
parent 784ed21a34
commit bdd14609d1
9 changed files with 378 additions and 632 deletions

View file

@ -116,4 +116,7 @@ echo "Wrote $output_html"
# Promote AFTER the dist file exists so promote_release can copy from # Promote AFTER the dist file exists so promote_release can copy from
# $output_html. (The order matters — _promote_stable does cp $output_html ...) # $output_html. (The order matters — _promote_stable does cp $output_html ...)
promote_release "$tool" # Only fires on a release cut; plain dev builds leave release-output alone.
if [ "$is_release" = "1" ]; then
promote_release "$tool"
fi

348
build
View file

@ -6,68 +6,68 @@ set -eu
# ./build dev build: assemble tool dist/, cross-compile # ./build dev build: assemble tool dist/, cross-compile
# zddc-server binaries. Nothing else is touched # zddc-server binaries. Nothing else is touched
# — no release artifacts produced, no deploy, # — no release artifacts produced, no deploy,
# and zddc/internal/apps/embedded/ is left alone # zddc/internal/apps/embedded/ is left alone
# (binary will embed whatever the last beta or # (binary will embed whatever the last beta or
# stable cut committed there). # stable cut committed there).
# ./build alpha cut alpha: produce a complete release bundle # ./build beta internal SHA snapshot for the BMC dev chart.
# in dist/release-output/ (cascades nothing). # Updates embedded/ with current tool HTMLs +
# Like dev, embedded/ is NOT updated — the # makes a `chore(embedded): cut v<X.Y.Z>-beta`
# invariant is that alpha labels are never baked # commit; the chart's appVersion pins to that
# into the binary. # SHA via Dockerfile parsing. NO public
# ./build beta cut beta (cascades alpha → beta). Updates # artifact in dist/release-output/.
# embedded/ with beta-labeled tool HTMLs and # ./build release cut coordinated stable. Updates embedded/
# commits them — the dev image (which builds # with stable-labeled bytes, makes a release
# from main) ships those bytes. # commit, tags all 8 artifacts at that commit,
# ./build release cut coordinated stable (cascades alpha + beta # writes <tool>_v<X.Y.Z>.html + <tool>.html
# → new stable; updates embedded/ with stable # symlink for every tool and the zddc-server
# labels, makes a release commit, tags all # per-platform binaries into
# seven tools at that commit). Prod images # dist/release-output/.
# (which build from the latest stable tag)
# ship those bytes.
# ./build release X.Y.Z same, explicit version. # ./build release X.Y.Z same, explicit version.
# ./build help this message. # ./build help this message.
# #
# Lockstep: every channel/release cut bumps all seven tools (6 HTML + # Lockstep: every release cut bumps all 8 tools (7 HTML + zddc-server)
# zddc-server) together. Coordinated next-stable = max(latest tag) + 1. # together. Coordinated next-stable = max(latest tag) + 1.
# #
# Channel/release cuts write a complete intended-live snapshot to # Stable release cuts write a complete intended-live snapshot to
# ${ZDDC_DEPLOY_RELEASES_DIR:-$SCRIPT_DIR/dist/release-output}. The build # ${ZDDC_DEPLOY_RELEASES_DIR:-$SCRIPT_DIR/dist/release-output}. The build
# does NOT touch the live site — run `./deploy` (or `./deploy --releases`) # does NOT touch the live site — run `./deploy --releases` to rsync the
# to rsync the snapshot into /srv/zddc/. The snapshot is built by seeding # snapshot into /srv/zddc/. The snapshot is seeded from the current
# from the current live state (so cascades and the verifier see a # live state's per-version files (so older immutable artifacts are
# complete world), then mutating the channel(s) being cut on top. # preserved), then this cut's <tool>.html canonical symlinks + new
# per-version file are written on top.
# #
# Bake-in invariant (what zddc-server's binary embeds via //go:embed): # Bake-in invariant (what zddc-server's binary embeds via //go:embed):
# - prod image (Dockerfile.prod, ZDDC_REF=stable): always stable bytes # - prod image (Dockerfile.prod): always stable bytes — chart's
# - dev image (Dockerfile, ZDDC_REF=main): stable OR beta bytes # Dockerfile.prod fetches the source at the latest
# (whatever last beta/ # zddc-server-vX.Y.Z tag.
# stable cut wrote) # - dev image (Dockerfile): stable OR beta-snapshot bytes — the
# - alpha is NEVER baked in. Active dev iteration happens via the tool's # chart's appVersion is set to either "X.Y.Z" (stable)
# local dist/<tool>.html, not via the binary's embedded copy. # or "X.Y.Z-beta-<sha>" (snapshot), and Dockerfile
# fetches that ref. Dev builds (`./build` no-arg) do
# NOT touch embedded/, so the binary's baked copy stays
# at whatever the last beta or stable cut wrote.
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
# Source build-lib.sh once at the top level so the helpers it provides # 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, # (promote_zddc_server, write_zddc_server_stubs_all,
# _coordinated_next_stable) are in scope. Each tool's build.sh sources it # _coordinated_next_stable) are in scope. Each tool's build.sh sources
# again — that's a no-op on already-defined functions. # it again — that's a no-op on already-defined functions.
root_dir="$SCRIPT_DIR" root_dir="$SCRIPT_DIR"
. "$SCRIPT_DIR/shared/build-lib.sh" . "$SCRIPT_DIR/shared/build-lib.sh"
# --- Parse subcommand ------------------------------------------------------ # --- Parse subcommand ------------------------------------------------------
# RELEASE_CHANNEL empty means dev mode (build only, no website worktree # RELEASE_CHANNEL empty means dev mode (build only, no release output);
# writes); set means a channel/release cut that promotes to the website # "beta" means an internal SHA snapshot (regenerate embedded/ + commit,
# worktree under $ZDDC_DEPLOY_RELEASES_DIR. # no public artifact); "stable" means a coordinated release cut that
# writes to dist/release-output/.
RELEASE_CHANNEL="" RELEASE_CHANNEL=""
RELEASE_VERSION="" RELEASE_VERSION=""
case "${1:-dev}" in case "${1:-dev}" in
dev|build) dev|build)
# Dev build: tool dist/ + zddc-server binaries only. Touches # Dev build: tool dist/ + zddc-server binaries only. Touches
# nothing in the website worktree. # nothing in release-output.
;;
alpha)
RELEASE_CHANNEL="alpha"
;; ;;
beta) beta)
RELEASE_CHANNEL="beta" RELEASE_CHANNEL="beta"
@ -84,7 +84,7 @@ case "${1:-dev}" in
fi fi
;; ;;
help | -h | --help) help | -h | --help)
sed -n '4,22p' "$0" | sed 's/^# \{0,1\}//' sed -n '4,30p' "$0" | sed 's/^# \{0,1\}//'
exit 0 exit 0
;; ;;
*) *)
@ -112,32 +112,38 @@ export ZDDC_DEPLOY_RELEASES_DIR="${ZDDC_DEPLOY_RELEASES_DIR:-$SCRIPT_DIR/dist/re
RELEASES_DIR="$ZDDC_DEPLOY_RELEASES_DIR" RELEASES_DIR="$ZDDC_DEPLOY_RELEASES_DIR"
mkdir -p "$RELEASES_DIR" mkdir -p "$RELEASES_DIR"
# When cutting a channel/release, seed RELEASES_DIR from the current live # On a stable cut, seed RELEASES_DIR from the current live site so the
# site so the resulting bundle is a complete intended-live snapshot, not # resulting bundle is a complete intended-live snapshot, not a sparse
# a sparse one-channel diff. Two reasons: # diff. The seed copies the immutable per-version files
# 1. Per-tool promote_release does cascade writes (beta cut → also # (<tool>_v<X.Y.Z>.html, zddc-server_v<X.Y.Z>_<plat>) plus their .sig
# rewrites alpha to track beta; stable cut → resets alpha + beta). # files. The cut then writes this version's new per-version files +
# The cascade itself is deterministic, but downstream artifacts that # refreshes the canonical <tool>.html / zddc-server_<plat> symlinks on
# were NOT touched by this cut (e.g. older versioned files, the # top. `./deploy --releases` (rsync --delete-after) wipes any stale
# other channel mirrors, partial-version symlinks) still need to be # files in /srv/zddc/releases/ that aren't in the bundle.
# present in the bundle so `./deploy --releases` (rsync #
# --delete-after) doesn't wipe them off the live site. # We skip the seed for beta cuts (no public artifacts to produce).
# 2. verify_channel_links cross-checks the full release tree; it # Bootstrap case (no live site yet, or empty live releases dir) is
# 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. # silently skipped — the very first stable cut populates everything.
if [ -n "$RELEASE_CHANNEL" ]; then if [ "$RELEASE_CHANNEL" = "stable" ]; then
LIVE_RELEASES="${ZDDC_LIVE_DIR:-/srv/zddc}/releases" LIVE_RELEASES="${ZDDC_LIVE_DIR:-/srv/zddc}/releases"
if [ -d "$LIVE_RELEASES" ] && [ -n "$(ls -A "$LIVE_RELEASES" 2>/dev/null)" ]; then if [ -d "$LIVE_RELEASES" ] && [ -n "$(ls -A "$LIVE_RELEASES" 2>/dev/null)" ]; then
echo "=== Seeding $RELEASES_DIR from $LIVE_RELEASES ===" echo "=== Seeding $RELEASES_DIR from $LIVE_RELEASES (per-version artifacts only) ==="
rm -rf "$RELEASES_DIR" rm -rf "$RELEASES_DIR"
mkdir -p "$RELEASES_DIR" mkdir -p "$RELEASES_DIR"
# cp -a preserves the symlink graph (channel mirrors + # Copy per-version immutable files + their .sig sidecars. The
# _v<X.Y> / _v<X> partial-version pins) so cascade decisions # canonical <tool>.html / zddc-server_<plat> symlinks will be
# downstream see the same world the live site has. # rewritten by this cut and old channel/partial-pin files from
cp -a "$LIVE_RELEASES/." "$RELEASES_DIR/" # the previous layout (if any still live there) will be cleaned
# up by deploy's --delete-after rsync.
find "$LIVE_RELEASES" -maxdepth 1 -type f \( \
-name '*_v*.html' -o \
-name '*_v*.html.sig' -o \
-name 'zddc-server_v*' \
\) -exec cp -a '{}' "$RELEASES_DIR/" \;
# Also seed the public key (it lives at the releases root).
if [ -f "$LIVE_RELEASES/pubkey.pem" ]; then
cp -a "$LIVE_RELEASES/pubkey.pem" "$RELEASES_DIR/"
fi
fi fi
fi fi
@ -183,11 +189,10 @@ echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,form,tables,brow
# Mirror the cascade-served HTMLs into the apps embed source dir so the # Mirror the 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 # 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 # on a beta or stable cut — beta cuts feed the dev image (chart pins by
# never baked into the binary, beta labels go to the dev image (which builds # SHA to the embedded-commit), stable cuts feed the prod image (chart
# from main), and stable labels go to prod (which builds from the latest # pins to the tag). Plain `./build` leaves embedded files untouched —
# stable tag). Plain `./build` and `./build alpha` leave the embedded files # whatever the last beta or stable cut committed stays in place.
# untouched; whatever the last beta/stable cut committed remains in place.
EMBED_DIR="$SCRIPT_DIR/zddc/internal/apps/embedded" EMBED_DIR="$SCRIPT_DIR/zddc/internal/apps/embedded"
if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
mkdir -p "$EMBED_DIR" mkdir -p "$EMBED_DIR"
@ -304,10 +309,12 @@ echo " binary version: $ZDDC_BINARY_VERSION"
' '
# --- Sign release artifacts ----------------------------------------------- # --- Sign release artifacts -----------------------------------------------
# After a channel/release cut has populated $RELEASES_DIR with the actual # After a stable cut has populated $RELEASES_DIR with the actual bytes
# bytes for this build, walk the dir and produce a detached Ed25519 .sig # for this build, walk the dir and produce a detached Ed25519 .sig
# alongside every real artifact. Symlinks (channel mirrors, partial-version # alongside every immutable per-version artifact. Canonical symlinks
# pins) skip — the .sig at the symlink's target is what counts. # (<tool>.html, zddc-server_<plat>) skip — the .sig at the symlink's
# target is what counts, and a companion .sig symlink (also written
# by promote_release) chains the canonical .sig URL to that target.
# #
# Operators verify with stdlib openssl: # Operators verify with stdlib openssl:
# #
@ -341,14 +348,15 @@ sign_release_artifacts() {
fi fi
# Collect the artifact list first so the signing loop runs in this # Collect the artifact list first so the signing loop runs in this
# shell (no subshell counter scope issue). find: real files only # shell (no subshell counter scope issue). find: real files only
# (-P, the default), matching <tool>_v*.html, <tool>_<channel>.html, # (-P, the default), matching <tool>_v*.html and
# and zddc-server_v*_<plat>(.exe). Excludes the index, stub pages, # zddc-server_v*_<plat>(.exe). The canonical symlinks (<tool>.html /
# and any pre-existing .sig files. # zddc-server_<plat>) don't get separate .sig files — verification
# follows the symlink to the immutable per-version file whose .sig
# is signed below. Excludes the index, stub pages, and pre-existing
# .sig files.
_list=$(find "$_dir" -maxdepth 1 -type f \( \ _list=$(find "$_dir" -maxdepth 1 -type f \( \
-name '*_v*.html' -o \ -name '*_v*.html' -o \
-name '*_stable.html' -o -name '*_beta.html' -o -name '*_alpha.html' -o \ -name 'zddc-server_v*' \
-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) \) ! -name '*.sig' ! -name 'index.html' ! -name 'zddc-server_*.html' 2>/dev/null)
_signed=0 _signed=0
@ -377,22 +385,22 @@ sign_release_artifacts() {
} }
# --- Promote zddc-server release artifacts --------------------------------- # --- Promote zddc-server release artifacts ---------------------------------
# On a channel/release cut, copy the freshly cross-compiled binaries to # On a stable cut, copy the freshly cross-compiled binaries to the
# the website worktree's releases/ under their canonical names + # release-output bundle under their canonical names + symlinks.
# symlinks. promote_zddc_server also re-runs write_zddc_server_stubs_all # promote_zddc_server also re-runs write_zddc_server_stubs_all
# internally, so the matrix-cell stub pages get regenerated in the same # internally, so the per-version + canonical stub pages get regenerated
# call. On a plain dev build, skip — we don't touch the worktree. # in the same call. Beta cuts produce no public binary artifact.
if [ -n "$RELEASE_CHANNEL" ]; then if [ "$RELEASE_CHANNEL" = "stable" ]; then
echo "" echo ""
echo "=== Promoting zddc-server $RELEASE_CHANNEL release ===" echo "=== Promoting zddc-server stable release ==="
promote_zddc_server "$RELEASE_CHANNEL" "$RELEASE_VERSION" "$RELEASES_DIR" "$SCRIPT_DIR/zddc/dist" promote_zddc_server "stable" "$RELEASE_VERSION" "$RELEASES_DIR" "$SCRIPT_DIR/zddc/dist"
fi fi
# Latest stable version, by following archive_stable.html → versioned target. # Latest stable version, by following archive.html → versioned target.
# Returns "" if no stable cut exists yet (bootstrap state). All HTML tools # 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. # move in lockstep so any one of them is a valid probe; archive is canonical.
_latest_stable_version() { _latest_stable_version() {
_link="$RELEASES_DIR/archive_stable.html" _link="$RELEASES_DIR/archive.html"
[ -L "$_link" ] || return 0 [ -L "$_link" ] || return 0
_target=$(readlink "$_link") _target=$(readlink "$_link")
# archive_v0.0.8.html → 0.0.8 # archive_v0.0.8.html → 0.0.8
@ -403,18 +411,6 @@ _latest_stable_version() {
esac 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 # Regenerate website/releases/index.html as the action-first install
# guide (not a matrix). The page guides users to either self-host the # guide (not a matrix). The page guides users to either self-host the
# server or download individual tools, with one version dropdown that # server or download individual tools, with one version dropdown that
@ -450,9 +446,6 @@ build_releases_index() {
| sort -Vr | sort -Vr
) )
_alpha_active="0"; _channel_is_active alpha && _alpha_active="1"
_beta_active="0"; _channel_is_active beta && _beta_active="1"
{ {
cat <<HEAD cat <<HEAD
<!DOCTYPE html> <!DOCTYPE html>
@ -503,39 +496,21 @@ build_releases_index() {
<select id="version-picker"> <select id="version-picker">
HEAD HEAD
# Channels — selectable directly so users can copy the channel- # "latest" — the canonical URL <tool>.html, a symlink that always
# mirror URLs (e.g. archive_stable.html) for bookmarks. stable is # follows the most recently cut stable. Use this when you want
# the default. The label tells the truth about the channel's # auto-updates. Default option so the page works fully without JS.
# 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 if [ -n "$_latest" ] && [ "$_latest" != "0.0.0" ]; then
printf ' <option value="stable" selected>stable — currently v%s</option>\n' "$_latest" printf ' <option value="latest" selected>latest stable — currently v%s</option>\n' "$_latest"
else else
printf ' <option value="stable" selected>stable</option>\n' printf ' <option value="latest" selected>latest stable</option>\n'
fi 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 # Pinned per-version, latest first. Immutable URLs — pin one
# for reproducibility. No "(current stable)" suffix because the # into your archive when you depend on a specific behavior.
# stable channel above already covers that.
printf ' <optgroup label="Pinned versions (immutable URLs)">\n'
printf '%s\n' "$_all_versions" | while read -r _v; do printf '%s\n' "$_all_versions" | while read -r _v; do
[ -n "$_v" ] || continue [ -n "$_v" ] || continue
printf ' <option value="v%s">v%s</option>\n' "$_v" "$_v" printf ' <option value="v%s">v%s (pinned)</option>\n' "$_v" "$_v"
done done
printf ' </optgroup>\n'
cat <<'PICKER_END' cat <<'PICKER_END'
</select> </select>
@ -552,24 +527,24 @@ PICKER_END
# at least once. Until then, show an honest "not yet released" # at least once. Until then, show an honest "not yet released"
# placeholder rather than dangling download buttons. # placeholder rather than dangling download buttons.
_zs_published="0" _zs_published="0"
if [ -e "$RELEASES_DIR/zddc-server_stable_linux-amd64" ]; then if [ -e "$RELEASES_DIR/zddc-server_linux-amd64" ]; then
_zs_published="1" _zs_published="1"
fi fi
if [ "$_zs_published" = "1" ]; then if [ "$_zs_published" = "1" ]; then
# Default href is the channel-mirror URL (zddc-server_stable_<plat>) # Default href is the canonical per-platform URL
# because "stable" is the dropdown's selected option. Picking a # (zddc-server_<plat>), a symlink that always points at the
# pinned version from the dropdown rewrites these to the # latest stable. Picking a pinned version from the dropdown
# immutable per-version URL via the IIFE. # rewrites these to the immutable per-version URL via JS.
printf ' <a class="dl-primary"\n' printf ' <a class="dl-primary"\n'
printf ' data-tool="zddc-server"\n' printf ' data-tool="zddc-server"\n'
printf ' data-platform="linux-amd64"\n' printf ' data-platform="linux-amd64"\n'
printf ' href="zddc-server_stable_linux-amd64"\n' printf ' href="zddc-server_linux-amd64"\n'
printf ' id="dl-primary-binary">\n' printf ' id="dl-primary-binary">\n'
printf ' <span class="dl-icon">⬇</span>\n' printf ' <span class="dl-icon">⬇</span>\n'
printf ' <span>Download <span id="dl-primary-platlabel">for Linux (x86_64)</span></span>\n' printf ' <span>Download <span id="dl-primary-platlabel">for Linux (x86_64)</span></span>\n'
printf ' </a>\n' printf ' </a>\n'
printf ' <span class="dl-primary-meta" id="dl-primary-meta">zddc-server_stable_linux-amd64</span>\n' printf ' <span class="dl-primary-meta" id="dl-primary-meta">zddc-server_linux-amd64</span>\n'
printf ' <div class="dl-secondary-row" id="dl-others">\n' printf ' <div class="dl-secondary-row" id="dl-others">\n'
printf ' <span>Other platforms:</span>\n' printf ' <span>Other platforms:</span>\n'
@ -581,7 +556,7 @@ PICKER_END
_label="${_entry#*|}" _label="${_entry#*|}"
_suffix="" _suffix=""
case "$_plat" in *windows*) _suffix=".exe" ;; esac 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' \ printf ' <a data-tool="zddc-server" data-platform="%s" href="zddc-server_%s%s">%s</a>\n' \
"$_plat" "$_plat" "$_suffix" "$_label" "$_plat" "$_plat" "$_suffix" "$_label"
done done
printf ' </div>\n' printf ' </div>\n'
@ -622,9 +597,9 @@ PATH_B_OPEN
_rest="${_entry#*|}" _rest="${_entry#*|}"
_name="${_rest%%|*}" _name="${_rest%%|*}"
_desc="${_rest#*|}" _desc="${_rest#*|}"
# Default href is the stable-channel mirror; the dropdown # Default href is the canonical symlink <tool>.html; the
# rewires these per selection. # dropdown rewires these per selection.
printf ' <a class="tool-card" data-tool="%s" href="%s_stable.html">\n' "$_t" "$_t" printf ' <a class="tool-card" data-tool="%s" href="%s.html">\n' "$_t" "$_t"
printf ' <span class="tool-card__title">%s</span>\n' "$_name" 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__desc">%s</span>\n' "$_desc"
printf ' <span class="tool-card__link">Download &rarr;</span>\n' printf ' <span class="tool-card__link">Download &rarr;</span>\n'
@ -717,12 +692,12 @@ PIN_MID
</div> </div>
<div class="pin-card"> <div class="pin-card">
<h3>Verify a download</h3> <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> <p>Each artifact has a matching <code class="inline">.sig</code> file alongside it (<code class="inline">archive.html</code> &rarr; <code class="inline">archive.html.sig</code>, etc.). Fetch both, then:</p>
<pre>curl -O https://zddc.varasys.io/releases/archive_stable.html <pre>curl -O https://zddc.varasys.io/releases/archive.html
curl -O https://zddc.varasys.io/releases/archive_stable.html.sig curl -O https://zddc.varasys.io/releases/archive.html.sig
openssl pkeyutl -verify -pubin -inkey pubkey.pem \ openssl pkeyutl -verify -pubin -inkey pubkey.pem \
-rawin -in archive_stable.html \ -rawin -in archive.html \
-sigfile archive_stable.html.sig</pre> -sigfile archive.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> <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>
</div> </div>
@ -745,7 +720,8 @@ ZDDC_ROOT=/srv/zddc ./zddc-server</pre>
<pre># &lt;ZDDC_ROOT&gt;/.zddc <pre># &lt;ZDDC_ROOT&gt;/.zddc
admins: [you@yourcompany.com] admins: [you@yourcompany.com]
acl: acl:
allow: ["*@yourcompany.com"] permissions:
'*@yourcompany.com': r
apps_pubkey: | apps_pubkey: |
-----BEGIN PUBLIC KEY----- -----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAXXaxIUIyOFnhD1eZs02nEt3xZ8izOi7bURFcpJ9iWZY= MCowBQYDK2VwAyEAXXaxIUIyOFnhD1eZs02nEt3xZ8izOi7bURFcpJ9iWZY=
@ -756,25 +732,6 @@ apps_pubkey: |
<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> <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> </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> </main>
<footer class="site-footer"> <footer class="site-footer">
@ -809,18 +766,21 @@ apps_pubkey: |
var primaryMeta = document.getElementById('dl-primary-meta'); var primaryMeta = document.getElementById('dl-primary-meta');
var others = document.getElementById('dl-others'); var others = document.getElementById('dl-others');
function isChannel(v) {
return v === 'stable' || v === 'beta' || v === 'alpha';
}
function platBinaryName(slug, plat) { function platBinaryName(slug, plat) {
// slug is a channel name ("stable") or a pinned version ("v0.0.8"). // slug === "latest" → canonical symlink zddc-server_<plat>;
// The on-disk name uses the slug as-is in both cases since the // slug === "v<X.Y.Z>" → immutable per-version zddc-server_v<X.Y.Z>_<plat>.
// 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' : ''; var suf = (plat.indexOf('windows') === 0) ? '.exe' : '';
if (slug === 'latest') {
return 'zddc-server_' + plat + suf;
}
return 'zddc-server_' + slug + '_' + plat + suf; return 'zddc-server_' + slug + '_' + plat + suf;
} }
function htmlAssetName(tool, slug) { function htmlAssetName(tool, slug) {
// slug === "latest" → canonical symlink <tool>.html;
// slug === "v<X.Y.Z>" → immutable per-version <tool>_v<X.Y.Z>.html.
if (slug === 'latest') {
return tool + '.html';
}
return tool + '_' + slug + '.html'; return tool + '_' + slug + '.html';
} }
@ -838,21 +798,25 @@ apps_pubkey: |
} }
// Single source of truth: the dropdown's current value drives every // Single source of truth: the dropdown's current value drives every
// download link's href. Static markup ships with the stable-channel // download link's href. Static markup ships with the canonical
// mirror (`<tool>_stable.html`, `zddc-server_stable_<plat>`) so the // URLs (`<tool>.html`, `zddc-server_<plat>`) so the page works
// page works fully without JS — the JS just keeps things in sync // fully without JS — the JS just keeps things in sync when the
// when the user picks a different channel or pins a version. // user pins a specific version.
var picker = document.getElementById('version-picker'); var picker = document.getElementById('version-picker');
if (!picker) return; if (!picker) return;
function rewire(slug) { function rewire(slug) {
// slug ∈ {"stable", "beta", "alpha"} | "v<X.Y.Z>". Every link with // slug === "latest" | "v<X.Y.Z>". Every link with a data-tool
// a data-tool attribute is a download URL the dropdown owns. // attribute is a download URL the dropdown owns.
document.querySelectorAll('[data-tool]').forEach(function(a) { document.querySelectorAll('[data-tool]').forEach(function(a) {
var tool = a.dataset.tool; var tool = a.dataset.tool;
var plat = a.dataset.platform || ''; var plat = a.dataset.platform || '';
if (tool === 'zddc-server') { if (tool === 'zddc-server') {
a.href = plat ? platBinaryName(slug, plat) : ('zddc-server_' + slug + '.html'); if (plat) {
a.href = platBinaryName(slug, plat);
} else {
a.href = (slug === 'latest') ? 'zddc-server.html' : ('zddc-server_' + slug + '.html');
}
} else { } else {
a.href = htmlAssetName(tool, slug); a.href = htmlAssetName(tool, slug);
} }
@ -891,13 +855,17 @@ apps_pubkey: |
var optionsHTML = picker.innerHTML; var optionsHTML = picker.innerHTML;
selects.forEach(function(sel) { selects.forEach(function(sel) {
sel.innerHTML = optionsHTML; sel.innerHTML = optionsHTML;
sel.value = 'stable'; // default per-app sel.value = 'latest'; // default per-app
}); });
function rebuild() { function rebuild() {
// The picker uses "latest" as its sentinel; the .zddc apps:
// cascade resolves "stable" the same way (follow the upstream
// canonical URL), so emit "stable" for the YAML user.
var lines = ['apps:']; var lines = ['apps:'];
selects.forEach(function(sel) { selects.forEach(function(sel) {
lines.push(' ' + sel.dataset.app + ': ' + sel.value); var val = (sel.value === 'latest') ? 'stable' : sel.value;
lines.push(' ' + sel.dataset.app + ': ' + val);
}); });
textarea.value = lines.join('\n') + '\n'; textarea.value = lines.join('\n') + '\n';
} }
@ -934,9 +902,9 @@ PIN_END
echo "Wrote $_out" echo "Wrote $_out"
} }
# Matrix index + verifier only run when we touched the website # Sign artifacts + regenerate releases/index.html on stable cuts.
# worktree. Dev builds leave the worktree alone. # Beta cuts produce no public artifact, so nothing to sign or index.
if [ -n "$RELEASE_CHANNEL" ]; then if [ "$RELEASE_CHANNEL" = "stable" ]; then
echo "" echo ""
echo "=== Signing release artifacts ===" echo "=== Signing release artifacts ==="
sign_release_artifacts "$RELEASES_DIR" sign_release_artifacts "$RELEASES_DIR"
@ -944,10 +912,6 @@ if [ -n "$RELEASE_CHANNEL" ]; then
echo "" echo ""
echo "=== Building releases/index.html ===" echo "=== Building releases/index.html ==="
build_releases_index build_releases_index
echo ""
echo "=== Verifying channel links ==="
verify_channel_links "$RELEASES_DIR"
fi fi
# --- Embedded commit (stable + beta cuts) --------------------------------- # --- Embedded commit (stable + beta cuts) ---------------------------------
@ -956,12 +920,11 @@ fi
# #
# 1. Stable: the next tag block needs HEAD to point at the bytes the # 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 # stable binary will serve. Without this commit, tags would land on
# the source-side commit (with alpha-dirty embedded/*) and prod # the source-side commit (with stale embedded/*) and prod images
# images compiled from `git checkout zddc-server-vX.Y.Z` would # compiled from `git checkout zddc-server-vX.Y.Z` would ship stale
# ship alpha bytes. (Original justification — preserved.) # bytes. (Original justification — preserved.)
# #
# 2. Beta: the dev pipeline pins the chart's appVersion to a SHA # 2. Beta: the dev chart pipeline pins appVersion to a SHA. For that
# (.forgejo/scripts/notify-chart-bump.sh reads HEAD). For that
# pin to point at a SHA where embedded/* matches what the binary # pin to point at a SHA where embedded/* matches what the binary
# will serve, HEAD has to advance past the source-side commit. # will serve, HEAD has to advance past the source-side commit.
# Without this commit, the chart pin lags one commit and the dev # Without this commit, the chart pin lags one commit and the dev
@ -1037,7 +1000,8 @@ if [ -z "$RELEASE_CHANNEL" ]; then
echo " tool/dist/*.html ready" echo " tool/dist/*.html ready"
echo " zddc/dist/zddc-server-* binaries ready" echo " zddc/dist/zddc-server-* binaries ready"
echo "" echo ""
echo "To cut alpha into a deployable bundle: ./build alpha" echo "For an internal SHA snapshot (BMC dev chart): ./build beta"
echo "To cut a stable release: ./build release"
else else
echo "Cut: $RELEASE_CHANNEL" echo "Cut: $RELEASE_CHANNEL"
if [ -n "$RELEASE_VERSION" ]; then if [ -n "$RELEASE_VERSION" ]; then

View file

@ -1,99 +0,0 @@
#!/bin/sh
# =============================================================================
# freshen-channel — rebuild a tool's alpha or beta channel from its current
# stable tag, so users tracking that channel are never on code older than
# current stable.
#
# Usage:
# ./freshen-channel <tool> <channel>
# tool archive | transmittal | classifier | browse | landing | form | tables
# channel alpha | beta
#
# Why this exists:
# Stable releases do NOT automatically clobber alpha/beta files (see
# AGENTS.md "Channel discipline" rule 4). After cutting stable v0.0.5,
# users pinned to alpha may be on an older build than current stable —
# that violates the stale-channel rule. Run this to drag alpha (or
# beta) forward to whatever stable currently is.
#
# What it does:
# 1. Finds the latest <tool>-v* tag.
# 2. Creates a temporary git worktree at that tag — does NOT touch
# your current branch or working tree.
# 3. Runs <tool>/build.sh --release <channel> inside the worktree.
# 4. Copies the resulting <tool>_<channel>.html into the main repo's
# website/releases/.
# 5. Removes the worktree.
#
# The on-page label of the freshened build will be
# `<channel> · <today> · <stable-tag-sha>` — the SHA encodes which
# stable was used as the source, so anyone debugging can `git checkout`
# that exact commit.
#
# Note: the build pipeline used is the one AT THE TAG, not the latest
# main. That is intentional — pure reproducibility. If you have made
# build-system improvements since stable was cut and want the freshen
# to use them, cut a new stable that includes those changes first.
# =============================================================================
set -eu
TOOL="${1:-}"
CHANNEL="${2:-}"
case "$TOOL" in
archive | transmittal | classifier | browse | landing | form | tables) ;;
*)
echo "usage: $0 <tool> <channel>" >&2
echo " tool: archive | transmittal | classifier | browse | landing | form | tables" >&2
exit 1
;;
esac
case "$CHANNEL" in
alpha | beta) ;;
*)
echo "usage: $0 <tool> <channel>" >&2
echo " channel: alpha | beta (stable is what you are freshening FROM)" >&2
exit 1
;;
esac
REPO=$(cd "$(dirname "$0")" && pwd)
# Find the latest stable tag for the tool.
LATEST_TAG=$(git -C "$REPO" tag --list "${TOOL}-v*" --sort=-v:refname | head -1)
if [ -z "$LATEST_TAG" ]; then
echo "error: no stable tag found for ${TOOL} (looking for ${TOOL}-v*)" >&2
echo " cut a stable release first: sh ${TOOL}/build.sh --release [version]" >&2
exit 1
fi
# Temporary detached worktree at the stable tag. Cleaned up on exit.
WT=$(mktemp -d)
cleanup() {
git -C "$REPO" worktree remove --force "$WT" >/dev/null 2>&1 || true
rm -rf "$WT"
}
trap cleanup EXIT INT TERM
echo "Freshening ${TOOL} ${CHANNEL} from ${LATEST_TAG}"
git -C "$REPO" worktree add --quiet --detach "$WT" "$LATEST_TAG"
# Build in the worktree. The tool's build.sh resolves its release dir
# from $ZDDC_DEPLOY_RELEASES_DIR (default $REPO/dist/release-output);
# pass through whatever the parent process has set so freshen-channel
# honors the same target as the regular build.
DEPLOY_DIR="${ZDDC_DEPLOY_RELEASES_DIR:-$REPO/dist/release-output}"
mkdir -p "$DEPLOY_DIR"
ZDDC_DEPLOY_RELEASES_DIR="$DEPLOY_DIR" \
sh "$WT/${TOOL}/build.sh" --release "$CHANNEL"
DST="$DEPLOY_DIR/${TOOL}_${CHANNEL}.html"
if [ ! -f "$DST" ]; then
echo "error: build did not produce $DST" >&2
exit 1
fi
echo "Wrote $DST"
echo "Done. ${CHANNEL} channel for ${TOOL} now reflects ${LATEST_TAG}."
echo "Run ./deploy --releases to push it to the live site."

View file

@ -17,42 +17,39 @@
# inlined JS as containing a closing </script>. # inlined JS as containing a closing </script>.
# The JS engine treats \/ as a regular slash, # The JS engine treats \/ as a regular slash,
# so runtime behaviour is unchanged. # so runtime behaviour is unchanged.
# compute_build_label <tool> [--release [<channel-or-version>]] # compute_build_label <tool> [--release [<beta-or-version>]]
# — sets globals: build_label, build_version, # — sets globals: build_label, build_version,
# is_release, is_red, channel. # is_release, is_red, channel.
# See "Channels and release args" below. # See "Release args" below.
# promote_release <tool> — for stable / alpha / beta, copy the dist # promote_release <tool> — for stable cuts, copy the dist HTML into
# HTML into the release-output bundle # the release-output bundle (default
# (default $root_dir/../dist/release-output; # $root_dir/../dist/release-output;
# override $ZDDC_DEPLOY_RELEASES_DIR). Stable # override $ZDDC_DEPLOY_RELEASES_DIR).
# cuts write the immutable per-version file + # Writes the immutable per-version file
# refresh five symlinks (_v<X.Y>, _v<X>, # <tool>_v<X.Y.Z>.html plus the canonical
# _stable, _beta, _alpha) and tag # symlink <tool>.html pointing at it.
# <tool>-v<X.Y.Z>. Alpha/beta cuts # Tagging is centralized in the top-level
# overwrite the channel mirror in place # ./build (after the embedded commit).
# and cascade alpha → beta. No git tags # Beta cuts produce NO public artifact —
# for alpha/beta cuts. The bundle is a # they are an internal SHA snapshot for
# complete intended-live snapshot — the # the BMC dev chart pipeline; the
# top-level ./build seeds it from # embedded/* regeneration + chore commit
# /srv/zddc/releases/ before per-tool # in the top-level ./build is the actual
# promote runs, then ./deploy --releases # artifact (chart appVersion pins to that
# rsyncs it back. See ARCHITECTURE.md # SHA, Dockerfile fetches it from git).
# "Channels" for the full table.
# #
# Channels and release args: # Release args:
# <none> dev build, tool/dist/ only, label # <none> dev build, tool/dist/ only, label
# "v<next-stable>-alpha · <ts> · <sha>[-dirty]" (red). # "v<next-stable>-dev · <ts> · <sha>[-dirty]" (red).
# No release-output side-effect. To produce a deployable # No release-output side-effect.
# bundle, re-run with `--release alpha`. # --release stable cut, auto-bump patch from latest tag (or 0.0.1).
# --release stable, auto-bump patch from latest tag (or 0.0.1). # Writes <tool>_v<X.Y.Z>.html + <tool>.html symlink;
# Writes per-version file + symlinks; tags vX.Y.Z. # tagged later by ./build.
# --release X.Y.Z stable, explicit version. # --release X.Y.Z stable cut, explicit version.
# --release alpha alpha channel cut at HEAD; # --release beta internal SHA snapshot for the BMC dev chart. Build
# label "v<next-stable>-alpha · <date> · <sha>" (red). # label is "v<next-stable>-beta · <date> · <sha>";
# Overwrites <tool>_alpha.html. No tag. # no public artifact, no tag. The top-level ./build
# --release beta beta channel; label "v<next-stable>-beta · <date> · <sha>". # regenerates zddc/internal/apps/embedded/ + commits.
# Overwrites <tool>_beta.html. Cascades <tool>_alpha.html
# → <tool>_beta.html (symlink). No tag.
# --release <other> error. # --release <other> error.
# ============================================================================= # =============================================================================
@ -117,7 +114,7 @@ escape_js_close_tags() {
_validate_semver() { _validate_semver() {
_v="$1" _v="$1"
_bad() { _bad() {
echo "error: invalid release argument: '$_v' (expected: alpha, beta, or X.Y.Z stable version)" >&2 echo "error: invalid release argument: '$_v' (expected: beta, or X.Y.Z stable version)" >&2
exit 1 exit 1
} }
_v1="${_v%%.*}" _v1="${_v%%.*}"
@ -172,28 +169,25 @@ _source_commit_short_sha() {
} }
# Compute build label and channel. Reads positional args: # Compute build label and channel. Reads positional args:
# compute_build_label <tool_name> [--release [<channel-or-version>]] # compute_build_label <tool_name> [--release [<beta-or-version>]]
# Sets global variables: # Sets global variables:
# build_label — text rendered into the page's {{BUILD_LABEL}} slot # build_label — text rendered into the page's {{BUILD_LABEL}} slot
# build_version — bare semver string (stable releases only) # build_version — bare semver string (stable releases only)
# is_release — "1" for any --release invocation, else "0" # is_release — "1" for any --release invocation, else "0"
# is_red — "1" if the label should render red+bold (dev/alpha/beta), else "0" # is_red — "1" if the label should render red+bold (dev/beta), else "0"
# channel — "stable" / "alpha" / "beta" / "" (dev) # channel — "stable" / "beta" / "dev"
# #
# Versioning: pre-release semver. The next-stable target is computed from # Versioning: pre-release semver. The next-stable target is computed from
# the latest clean tool-vX.Y.Z tag (patch-bump). Plain builds and # the latest clean tool-vX.Y.Z tag (patch-bump). Plain dev builds and
# `--release alpha`/`--release beta` carry the next-stable target as a # `--release beta` carry the next-stable target as a pre-release suffix
# pre-release suffix in the on-page label so users can see which stable # in the on-page label so users can see which stable the snapshot is
# the alpha/beta is working toward. Stable releases write a clean # working toward. Stable releases write a clean vX.Y.Z label and tag.
# vX.Y.Z label and tag.
# #
# HTML tools do NOT tag alpha/beta cuts (consistent with current # HTML tools do NOT tag beta cuts — beta produces no public artifact
# behavior — alpha and beta artifacts are mutable files, not immutable # (the chart pins by SHA via appVersion). Plain dev builds and beta
# per-build snapshots). Plain dev builds and `--release alpha|beta` # cuts share the same on-page label format (full UTC timestamp + short
# cuts share the same on-page label format — full UTC timestamp + short # source SHA). A plain dev build may carry a "-dirty" SHA suffix when
# source SHA — so testers see one rendering shape regardless of how the # the working tree has uncommitted changes; release cuts don't.
# build was produced. A plain dev build may carry a "-dirty" SHA suffix
# when the working tree has uncommitted changes; release cuts don't.
compute_build_label() { compute_build_label() {
_tool="$1" _tool="$1"
_flag="${2:-}" _flag="${2:-}"
@ -208,17 +202,17 @@ compute_build_label() {
_next_stable=$(_next_stable_for_tool "$_tool") _next_stable=$(_next_stable_for_tool "$_tool")
if [ "$_flag" != "--release" ]; then if [ "$_flag" != "--release" ]; then
# Plain builds are dev builds — labeled as the alpha channel because # Plain builds are dev iteration — tool/dist/ only, no release
# that's what the next formal cut would produce, but no Codeberg upload # output. The label includes the next-stable target so a developer
# happens until `--release alpha` is invoked. Full timestamp (granular # opening the local dist file can see which version-in-progress
# than date) and -dirty marker distinguish iterative dev builds from # they're looking at. Full timestamp + dirty marker distinguish
# formal `--release alpha` cuts (which stamp date-only). # iterative dev builds from formal cuts.
_sha=$(git -C "$root_dir" rev-parse --short=7 HEAD 2>/dev/null || echo "unknown") _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 if ! git -C "$root_dir" diff --quiet HEAD 2>/dev/null; then
_sha="${_sha}-dirty" _sha="${_sha}-dirty"
fi fi
channel="alpha" channel="dev"
build_label="v${_next_stable}-alpha · ${build_timestamp} · ${_sha}" build_label="v${_next_stable}-dev · ${build_timestamp} · ${_sha}"
_emit_build_label_sidecar "$_tool" _emit_build_label_sidecar "$_tool"
return 0 return 0
fi fi
@ -226,14 +220,16 @@ compute_build_label() {
is_release=1 is_release=1
case "$_arg" in case "$_arg" in
alpha | beta) beta)
channel="$_arg" channel="beta"
# Full UTC timestamp + short source SHA — same format as # Internal SHA snapshot for the BMC dev chart. The chart's
# plain dev builds. _source_commit_short_sha walks past # appVersion gets set to "<next>-beta-<sha>" and the
# any `chore(embedded): cut …` auto-commit at HEAD so a # Dockerfile parses the suffix to fetch this SHA from git.
# re-cut on unchanged source produces the same SHA. # _source_commit_short_sha walks past any `chore(embedded):
# cut …` auto-commit at HEAD so a re-cut on unchanged
# source produces the same SHA.
_sha=$(_source_commit_short_sha) _sha=$(_source_commit_short_sha)
build_label="v${_next_stable}-${channel} · ${build_timestamp} · ${_sha}" build_label="v${_next_stable}-beta · ${build_timestamp} · ${_sha}"
_emit_build_label_sidecar "$_tool" _emit_build_label_sidecar "$_tool"
return 0 return 0
;; ;;
@ -266,14 +262,14 @@ _emit_build_label_sidecar() {
# Tools that participate in the lockstep release. Source of truth — used # Tools that participate in the lockstep release. Source of truth — used
# by helpers that enumerate "all release artifacts" (matrix render, # by helpers that enumerate "all release artifacts" (matrix render,
# coordinated next-stable, channel-link verifier). # coordinated next-stable).
ZDDC_RELEASE_TOOLS="archive transmittal classifier landing form tables browse zddc-server" ZDDC_RELEASE_TOOLS="archive transmittal classifier landing form tables browse zddc-server"
# Compute the next-stable target for a single tool — patch-bump of its own # Compute the next-stable target for a single tool — patch-bump of its own
# latest <tool>-vX.Y.Z tag. Used by compute_build_label so a tool's # latest <tool>-vX.Y.Z tag. Used by compute_build_label so a tool's
# alpha/beta on-page label still reads against its own history (e.g. an # on-page label reads against its own history (e.g. a beta cut for a
# alpha cut for a tool that's been quiet still labels itself targeting that # tool that's been quiet still labels itself targeting that tool's next
# tool's next stable, even when the lockstep convention is in force). # stable, even when the lockstep convention is in force).
_next_stable_for_tool() { _next_stable_for_tool() {
_t="$1" _t="$1"
_latest=$(git -C "$root_dir" tag --list "${_t}-v*" 2>/dev/null \ _latest=$(git -C "$root_dir" tag --list "${_t}-v*" 2>/dev/null \
@ -315,30 +311,22 @@ _coordinated_next_stable() {
} }
# Promote a built dist file to the release-output bundle. Reads from caller # Promote a built dist file to the release-output bundle. Reads from caller
# scope: $channel ("stable" / "alpha" / "beta"), $build_version (stable only), # scope: $channel ("stable" / "beta"), $build_version (stable only),
# $output_html, $root_dir. Bundle path resolves from $ZDDC_DEPLOY_RELEASES_DIR # $output_html, $root_dir. Bundle path resolves from $ZDDC_DEPLOY_RELEASES_DIR
# (default $root_dir/../dist/release-output). # (default $root_dir/../dist/release-output).
# #
# Stable cuts: # Stable cuts:
# 1. Skip if source unchanged since latest stable tag. # 1. Copy dist HTML → <bundle>/<tool>_v<X.Y.Z>.html (immutable).
# 2. Copy dist HTML → <bundle>/<tool>_v<X.Y.Z>.html (immutable). # 2. Refresh canonical symlink: <bundle>/<tool>.html → the new versioned file.
# 3. Refresh symlinks: _v<X.Y>, _v<X>, _stable, _beta, _alpha all → the # 3. Tag the commit <tool>-v<X.Y.Z> (centralized in the top-level ./build).
# 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: # Beta cuts:
# 1. Overwrite <bundle>/<tool>_<channel>.html with dist HTML # No public artifact. The chart's Dockerfile fetches the source at the
# (replaces a symlink with real bytes if one was there). # SHA pinned in chart appVersion and compiles its own binary; the
# 2. For beta: cascade <tool>_alpha.html → <tool>_beta.html (symlink), # embedded/* regeneration + chore commit in the top-level ./build is
# since alpha defaults to beta when no active alpha. # the actual snapshot.
# 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. # 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() { promote_release() {
_tool="$1" _tool="$1"
# The top-level `./build` exports $ZDDC_DEPLOY_RELEASES_DIR pointing # The top-level `./build` exports $ZDDC_DEPLOY_RELEASES_DIR pointing
@ -363,18 +351,16 @@ promote_release() {
# every tool, even when a tool's source hasn't changed since # every tool, even when a tool's source hasn't changed since
# its last tag. The bytes are identical (build is deterministic # its last tag. The bytes are identical (build is deterministic
# at the same source), so the overwrite is a no-op on disk; # at the same source), so the overwrite is a no-op on disk;
# but the symlink chain (_v<X.Y>, _v<X>, _stable, _beta, _alpha) # but the canonical symlink <tool>.html advances to the new
# gets advanced to the new version, which is the actual goal. # version, which is the actual goal.
#
# The previous "skip if no source changes since $_latest" check
# was a relic of per-tool independent versioning. It broke
# CI re-cuts at a tag commit (HEAD == latest tag → diff empty
# → skip → dist/release-output/ stays seeded at the previous
# version → deploy publishes the previous version).
_promote_stable "$_tool" "$build_version" "$_releases_dir" _promote_stable "$_tool" "$build_version" "$_releases_dir"
;; ;;
alpha | beta) beta)
_promote_channel "$_tool" "$channel" "$_releases_dir" # Internal SHA snapshot for the BMC dev chart. No public
# artifact: the chart fetches the source at the SHA via git,
# the embedded/* regeneration + chore commit (in the top-
# level ./build) IS the artifact.
echo " ${_tool}: beta is internal (no public artifact)"
;; ;;
*) *)
echo "promote_release: unknown channel '$channel'" >&2 echo "promote_release: unknown channel '$channel'" >&2
@ -383,35 +369,31 @@ promote_release() {
esac esac
} }
# Stable cut: per-version file + 5 symlinks. Tagging is centralized in # Stable cut: per-version immutable file + canonical symlink. Tagging is
# the top-level ./build (it commits embedded artifacts FIRST, then tags # centralized in the top-level ./build (it commits embedded artifacts
# at the new commit — see "Release commit + tag" block at the bottom of # FIRST, then tags at the new commit — see "Release commit + tag" block
# the script). _promote_stable historically created tags itself, but # at the bottom of the script). _promote_stable historically created
# that placed them on the source-side commit before embedded files were # tags itself, but that placed them on the source-side commit before
# folded in, leaving prod binaries with alpha-dirty bytes baked in. # embedded files were folded in, leaving prod binaries with stale bytes
# baked in.
_promote_stable() { _promote_stable() {
_t="$1" _t="$1"
_ver="$2" _ver="$2"
_rdir="$3" _rdir="$3"
_major="${_ver%%.*}"
_rest="${_ver#*.}"
_minor="${_rest%%.*}"
_versioned="${_t}_v${_ver}.html" _versioned="${_t}_v${_ver}.html"
_canonical="${_t}.html"
cp "$output_html" "$_rdir/$_versioned" cp "$output_html" "$_rdir/$_versioned"
echo "Wrote $_rdir/$_versioned" echo "Wrote $_rdir/$_versioned"
# Refresh the 5 symlinks. Cascade: stable cut → beta + alpha both ln -sfn "$_versioned" "$_rdir/$_canonical"
# reset to stable (no active dev on either downstream channel). echo " $_canonical$_versioned"
for _sym in "${_t}_v${_major}.${_minor}.html" \
"${_t}_v${_major}.html" \ # Companion .sig symlink so `curl <canonical>.sig` resolves. The
"${_t}_stable.html" \ # actual .sig file is written by sign_release_artifacts; this
"${_t}_beta.html" \ # symlink points there.
"${_t}_alpha.html"; do ln -sfn "${_versioned}.sig" "$_rdir/${_canonical}.sig"
ln -sfn "$_versioned" "$_rdir/$_sym"
echo " $_sym$_versioned"
done
# Pre-flight check only: if the tag already exists pointing at a # Pre-flight check only: if the tag already exists pointing at a
# commit that is NOT an ancestor of HEAD, the operator needs to # commit that is NOT an ancestor of HEAD, the operator needs to
@ -431,28 +413,6 @@ _promote_stable() {
echo "Released ${_t} v${_ver} (stable; tagging deferred to top-level build)" echo "Released ${_t} v${_ver} (stable; tagging deferred to top-level build)"
} }
# 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}"
}
# Platforms zddc-server is cross-compiled for. The first three are # Platforms zddc-server is cross-compiled for. The first three are
# extension-less (Linux/macOS); Windows gets .exe. The build always emits # extension-less (Linux/macOS); Windows gets .exe. The build always emits
# all four; the matrix cell's stub page links each by its <platform> tag. # all four; the matrix cell's stub page links each by its <platform> tag.
@ -470,32 +430,36 @@ _zddc_server_platform_label() {
esac esac
} }
# Resolve a zddc-server binary's filename for one (version, platform). # Resolve a zddc-server binary's filename for one (slug, platform).
# Returns the bare name (no path); ".exe" suffix on windows. # Returns the bare name (no path); ".exe" suffix on windows. Empty slug
# means the canonical "current stable" symlink (zddc-server_<plat>);
# non-empty slug is a per-version asset (zddc-server_v<X.Y.Z>_<plat>).
_zddc_server_binary_name() { _zddc_server_binary_name() {
_ver_or_chan="$1" _slug="$1"
_plat="$2" _plat="$2"
_suffix="" _suffix=""
case "$_plat" in *windows*) _suffix=".exe" ;; esac case "$_plat" in *windows*) _suffix=".exe" ;; esac
if echo "$_ver_or_chan" | grep -qE '^v[0-9]'; then if [ -z "$_slug" ]; then
# Per-version asset, e.g. zddc-server_v0.0.8_linux-amd64 printf 'zddc-server_%s%s' "$_plat" "$_suffix"
printf 'zddc-server_%s_%s%s' "$_ver_or_chan" "$_plat" "$_suffix"
else else
# Channel mirror, e.g. zddc-server_stable_linux-amd64 printf 'zddc-server_%s_%s%s' "$_slug" "$_plat" "$_suffix"
printf 'zddc-server_%s_%s%s' "$_ver_or_chan" "$_plat" "$_suffix"
fi fi
} }
# Write the small HTML index page that becomes the matrix cell's link for # Write the small HTML index page that becomes the entry point for a
# a zddc-server release. Lists each platform binary with a download link. # zddc-server release. Lists each platform binary with a download link.
# $1 — release directory (absolute) # $1 — release directory (absolute)
# $2 — slug (e.g. v0.0.8, v0.0, stable, beta, alpha) # $2 — slug ("" for canonical "current stable", or "v0.0.8" per-version)
# $3 — display label (e.g. "v0.0.8", "stable channel") # $3 — display label (e.g. "current stable", "v0.0.8")
write_zddc_server_stub() { write_zddc_server_stub() {
_rdir="$1" _rdir="$1"
_slug="$2" _slug="$2"
_label="$3" _label="$3"
if [ -z "$_slug" ]; then
_out="$_rdir/zddc-server.html"
else
_out="$_rdir/zddc-server_${_slug}.html" _out="$_rdir/zddc-server_${_slug}.html"
fi
{ {
cat <<HEAD cat <<HEAD
@ -541,68 +505,40 @@ TAIL
} }
# Refresh every zddc-server stub page based on what's currently in the # Refresh every zddc-server stub page based on what's currently in the
# release-output bundle. Driven by the existing per-version binary files + # release-output bundle: one per-version stub per zddc-server_v*_*
# symlinks that the release flow already maintains; just emits the HTML # binary set, plus a canonical zddc-server.html if the latest-stable
# wrappers for them. Safe to run on every cut (idempotent). # symlinks are in place. Indexed off linux-amd64 since all four
# platforms ship in lockstep.
# #
# $1 — releases dir (absolute) # $1 — releases dir (absolute)
write_zddc_server_stubs_all() { write_zddc_server_stubs_all() {
_rdir="$1" _rdir="$1"
# Every per-version stable binary that exists. We index off # Per-version stubs (immutable).
# linux-amd64 specifically since all four platforms ship in lockstep
# — if the linux build is missing the version is incomplete anyway.
for _bin in "$_rdir"/zddc-server_v*_linux-amd64; do for _bin in "$_rdir"/zddc-server_v*_linux-amd64; do
[ -e "$_bin" ] || continue [ -e "$_bin" ] || continue
_name=$(basename "$_bin") _name=$(basename "$_bin")
# zddc-server_vX.Y.Z_linux-amd64 → vX.Y.Z
_slug=$(echo "$_name" | sed -E 's/^zddc-server_(v[^_]+)_linux-amd64$/\1/') _slug=$(echo "$_name" | sed -E 's/^zddc-server_(v[^_]+)_linux-amd64$/\1/')
# Skip partial-version pins (vX.Y, vX) — these are written
# separately below from symlink resolution.
case "$_slug" in case "$_slug" in
v*.*.*) write_zddc_server_stub "$_rdir" "$_slug" "$_slug" ;; v*.*.*) write_zddc_server_stub "$_rdir" "$_slug" "$_slug" ;;
esac esac
done done
# Partial-version + channel stubs follow the symlink chain. If the # Canonical stub (follows the latest-stable symlink). Probes the
# symlink resolves to a real binary, write the stub; otherwise skip. # linux-amd64 canonical name; if it exists, the platform symlinks
for _slug in stable beta alpha; do # are in place and we can write the entry page.
_probe="$_rdir/zddc-server_${_slug}_linux-amd64" if [ -e "$_rdir/zddc-server_linux-amd64" ]; then
if [ -e "$_probe" ]; then write_zddc_server_stub "$_rdir" "" "current stable"
write_zddc_server_stub "$_rdir" "$_slug" "${_slug} channel"
fi
done
# vX.Y and vX partial pins — derive the slug list from the per-version
# binaries so we only emit pages we actually have artifacts for.
_all_versions=$(find "$_rdir" -maxdepth 1 -name 'zddc-server_v*_linux-amd64' \
| sed -E 's|^.*/zddc-server_(v[0-9]+\.[0-9]+\.[0-9]+)_linux-amd64$|\1|' \
| sort -Vu)
if [ -n "$_all_versions" ]; then
# vX.Y pins — pick the highest patch within each X.Y, then make
# sure the symlink and stub exist.
echo "$_all_versions" | sed -E 's|^v([0-9]+\.[0-9]+)\.[0-9]+$|\1|' | sort -Vu | while read -r _xy; do
_probe="$_rdir/zddc-server_v${_xy}_linux-amd64"
if [ -e "$_probe" ]; then
write_zddc_server_stub "$_rdir" "v${_xy}" "v${_xy}"
fi
done
# vX pins.
echo "$_all_versions" | sed -E 's|^v([0-9]+)\..*$|\1|' | sort -Vu | while read -r _x; do
_probe="$_rdir/zddc-server_v${_x}_linux-amd64"
if [ -e "$_probe" ]; then
write_zddc_server_stub "$_rdir" "v${_x}" "v${_x}"
fi
done
fi fi
} }
# Promote a freshly-cross-compiled set of zddc-server binaries to the # Promote a freshly-cross-compiled set of zddc-server binaries to the
# release-output bundle. Called by the top-level ./build on a release cut. # release-output bundle. Called by the top-level ./build on a stable
# release cut. Beta cuts produce no public artifact (the chart's
# Dockerfile compiles from source at the SHA pinned in appVersion).
# #
# $1 — channel ("stable" | "alpha" | "beta") # $1 — channel ("stable" | "beta")
# $2 — version (X.Y.Z; required for stable; ignored for alpha/beta but # $2 — version (X.Y.Z; required for stable; ignored for beta)
# passed through so labels can include the next-stable target)
# $3 — releases dir (absolute) # $3 — releases dir (absolute)
# $4 — dist dir holding cross-compiled binaries (absolute) # $4 — dist dir holding cross-compiled binaries (absolute)
promote_zddc_server() { promote_zddc_server() {
@ -628,27 +564,21 @@ promote_zddc_server() {
echo "promote_zddc_server: stable cut requires version" >&2 echo "promote_zddc_server: stable cut requires version" >&2
return 1 return 1
fi fi
_major="${_ver%%.*}"
_rest="${_ver#*.}"
_minor="${_rest%%.*}"
# Per-version: copy each binary to its immutable name + refresh # Per-version immutable + canonical per-platform symlink.
# the partial-version + channel symlinks. Mirrors the HTML-tool
# cascade: stable cut → beta + alpha both reset to stable.
for _plat in $ZDDC_SERVER_PLATFORMS; do for _plat in $ZDDC_SERVER_PLATFORMS; do
_suffix="" _suffix=""
case "$_plat" in *windows*) _suffix=".exe" ;; esac case "$_plat" in *windows*) _suffix=".exe" ;; esac
_src="$_dist/zddc-server-${_plat}${_suffix}" _src="$_dist/zddc-server-${_plat}${_suffix}"
_versioned="zddc-server_v${_ver}_${_plat}${_suffix}" _versioned="zddc-server_v${_ver}_${_plat}${_suffix}"
_canonical="zddc-server_${_plat}${_suffix}"
cp "$_src" "$_rdir/$_versioned" cp "$_src" "$_rdir/$_versioned"
echo "Wrote $_rdir/$_versioned" echo "Wrote $_rdir/$_versioned"
for _sym in "zddc-server_v${_major}.${_minor}_${_plat}${_suffix}" \ ln -sfn "$_versioned" "$_rdir/$_canonical"
"zddc-server_v${_major}_${_plat}${_suffix}" \ echo " $_canonical$_versioned"
"zddc-server_stable_${_plat}${_suffix}" \ # Companion .sig symlink — see _promote_stable for the
"zddc-server_beta_${_plat}${_suffix}" \ # same pattern.
"zddc-server_alpha_${_plat}${_suffix}"; do ln -sfn "${_versioned}.sig" "$_rdir/${_canonical}.sig"
ln -sfn "$_versioned" "$_rdir/$_sym"
done
done done
# Pre-flight tag check only — actual tagging happens in the # Pre-flight tag check only — actual tagging happens in the
@ -667,22 +597,11 @@ promote_zddc_server() {
fi fi
echo "Released zddc-server v${_ver} (stable; tagging deferred to top-level build)" echo "Released zddc-server v${_ver} (stable; tagging deferred to top-level build)"
;; ;;
alpha | beta) beta)
# Mutable channel mirror per platform; cascade alpha → beta on # Internal SHA snapshot — the chart's Dockerfile fetches the
# a beta cut. # source at that SHA and compiles its own binary. No public
for _plat in $ZDDC_SERVER_PLATFORMS; do # binary is published.
_suffix="" echo " zddc-server: beta is internal (no public artifact)"
case "$_plat" in *windows*) _suffix=".exe" ;; esac
_src="$_dist/zddc-server-${_plat}${_suffix}"
_file="zddc-server_${_ch}_${_plat}${_suffix}"
rm -f "$_rdir/$_file"
cp "$_src" "$_rdir/$_file"
echo "Wrote $_rdir/$_file"
if [ "$_ch" = "beta" ]; then
ln -sfn "$_file" "$_rdir/zddc-server_alpha_${_plat}${_suffix}"
fi
done
echo "Released zddc-server ${_ch}"
;; ;;
*) *)
echo "promote_zddc_server: unknown channel '$_ch'" >&2 echo "promote_zddc_server: unknown channel '$_ch'" >&2
@ -690,70 +609,6 @@ promote_zddc_server() {
;; ;;
esac esac
# Refresh every stub page (covers the new release plus any pre-existing). # Refresh stub pages (per-version + canonical).
write_zddc_server_stubs_all "$_rdir" write_zddc_server_stubs_all "$_rdir"
} }
# Verify every channel link for every release tool exists and resolves.
# Runs at the end of every build. Fails the build if anything is dangling.
# Channel verification covers both HTML tools (one .html per channel) and
# zddc-server (one stub HTML + four binaries per channel).
#
# Bootstrap-friendly: if zddc-server has no per-version artifacts at all
# (i.e. no release has been cut yet under the new lockstep model), the
# zddc-server entries are skipped with a heads-up rather than failing. The
# first stable cut materializes them.
verify_channel_links() {
_rdir="$1"
_missing=0
_verified=0
for _t in archive transmittal classifier landing form tables browse; do
for _ch in stable beta alpha; do
_f="$_rdir/${_t}_${_ch}.html"
if [ -e "$_f" ]; then
_verified=$((_verified + 1))
else
echo " MISSING: ${_t}_${_ch}.html" >&2
_missing=$((_missing + 1))
fi
done
done
# zddc-server's stable cut anchors the channel chain (cascade rule:
# stable cut → alpha + beta both reset to stable). Until stable
# exists, the verifier runs in bootstrap mode and skips — alpha/beta
# cuts in isolation are valid bootstrap state but have no cascade
# fallback target yet.
_zs_stable_exists=$(find "$_rdir" -maxdepth 1 -name 'zddc-server_stable_linux-amd64' -print -quit 2>/dev/null)
if [ -z "$_zs_stable_exists" ]; then
echo " (zddc-server stable not yet cut — run 'sh build.sh --release' to anchor the channel chain)"
else
for _ch in stable beta alpha; do
_f="$_rdir/zddc-server_${_ch}.html"
if [ -e "$_f" ]; then
_verified=$((_verified + 1))
else
echo " MISSING: zddc-server_${_ch}.html" >&2
_missing=$((_missing + 1))
fi
for _plat in $ZDDC_SERVER_PLATFORMS; do
_suffix=""
case "$_plat" in *windows*) _suffix=".exe" ;; esac
_f="$_rdir/zddc-server_${_ch}_${_plat}${_suffix}"
if [ -e "$_f" ]; then
_verified=$((_verified + 1))
else
echo " MISSING: zddc-server_${_ch}_${_plat}${_suffix}" >&2
_missing=$((_missing + 1))
fi
done
done
fi
if [ "$_missing" -gt 0 ]; then
echo "channel-link verification: $_missing missing artifact(s)" >&2
return 1
fi
echo "channel-link verification: $_verified link(s) ok"
}

View file

@ -18,8 +18,8 @@
// //
// Spec forms (each is a string value in `.zddc apps:`): // Spec forms (each is a string value in `.zddc apps:`):
// //
// :stable / :beta / :alpha / :v0.0.4 / :v0.0 / :v0 — channel-only // :stable / :v0.0.4 — channel-only
// stable / beta / alpha / v0.0.4 / v0.0 / v0 — channel-only (no leading colon) // stable / v0.0.4 / 0.0.4 — channel-only (no leading colon)
// https://host/path — URL-prefix only (combines with cascade channel) // https://host/path — URL-prefix only (combines with cascade channel)
// https://host/path:stable — URL-prefix + channel (composes) // https://host/path:stable — URL-prefix + channel (composes)
// https://host/path/file.html — terminal full URL (used as-is) // https://host/path/file.html — terminal full URL (used as-is)
@ -84,7 +84,7 @@ type SpecComponents struct {
// Composable forms — either or both may be set, both may be empty // Composable forms — either or both may be set, both may be empty
// (caller should treat empty-everything as a no-op). // (caller should treat empty-everything as a no-op).
URLPrefix string // "https://host/path" (no trailing /) URLPrefix string // "https://host/path" (no trailing /)
Channel string // "stable" / "beta" / "alpha" / "v0.0.4" / "v0.0" / "v0" Channel string // "stable" (latest), "v0.0.4" (exact version pin)
} }
// IsTerminal reports whether this spec terminates composition. // IsTerminal reports whether this spec terminates composition.
@ -144,7 +144,7 @@ func ParseSpec(spec, zddcDir, root string) (SpecComponents, error) {
// https://host:8080/path:stable → URLPrefix=https://host:8080/path, Channel=stable // https://host:8080/path:stable → URLPrefix=https://host:8080/path, Channel=stable
// https://host:8080/path → URLPrefix=https://host:8080/path // https://host:8080/path → URLPrefix=https://host:8080/path
// https://host/path/file.html → FullURL=https://host/path/file.html (terminal) // https://host/path/file.html → FullURL=https://host/path/file.html (terminal)
// https://host/path/file.html:beta → error (terminal URL with extra suffix) // https://host/path/file.html:stable → error (terminal URL with extra suffix)
func parseURLSpec(spec string) (SpecComponents, error) { func parseURLSpec(spec string) (SpecComponents, error) {
// Locate the channel separator: last `:` that comes after the last `/`. // Locate the channel separator: last `:` that comes after the last `/`.
lastSlash := strings.LastIndex(spec, "/") lastSlash := strings.LastIndex(spec, "/")
@ -191,10 +191,12 @@ func parseURLSpec(spec string) (SpecComponents, error) {
return out, nil return out, nil
} }
// isValidChannelOrVersion reports whether s is `stable`/`beta`/`alpha` or a // isValidChannelOrVersion reports whether s is `stable` (the canonical
// version like `v0.0.4`/`0.0.4`/`v0.0`/`v0`. // "current stable" alias) or an exact-version pin like `v0.0.4` / `0.0.4`.
// Partial pins (`v0.0`, `v0`) and the legacy `beta`/`alpha` channels
// are no longer accepted — the upstream publishes only stable + exact.
func isValidChannelOrVersion(s string) bool { func isValidChannelOrVersion(s string) bool {
if s == "stable" || s == "beta" || s == "alpha" { if s == "stable" {
return true return true
} }
rest := strings.TrimPrefix(s, "v") rest := strings.TrimPrefix(s, "v")
@ -202,7 +204,7 @@ func isValidChannelOrVersion(s string) bool {
return false return false
} }
parts := strings.Split(rest, ".") parts := strings.Split(rest, ".")
if len(parts) > 3 { if len(parts) != 3 {
return false return false
} }
for _, p := range parts { for _, p := range parts {
@ -221,7 +223,7 @@ func isValidChannelOrVersion(s string) bool {
// normalizeChannel ensures versions carry the `v` prefix (so the resulting // normalizeChannel ensures versions carry the `v` prefix (so the resulting
// filename is `<app>_v<X.Y.Z>.html` per upstream convention). // filename is `<app>_v<X.Y.Z>.html` per upstream convention).
func normalizeChannel(s string) string { func normalizeChannel(s string) string {
if s == "stable" || s == "beta" || s == "alpha" { if s == "stable" {
return s return s
} }
if !strings.HasPrefix(s, "v") { if !strings.HasPrefix(s, "v") {
@ -359,9 +361,18 @@ func (s *appsState) finalize() (Source, bool, error) {
if channel == "" { if channel == "" {
channel = DefaultChannel channel = DefaultChannel
} }
// channel == "stable" → canonical URL <prefix>/<app>.html (a
// symlink that always follows the latest stable cut).
// channel == "v<X.Y.Z>" → immutable per-version URL.
var name string
if channel == "stable" {
name = s.app + ".html"
} else {
name = s.app + "_" + channel + ".html"
}
return Source{ return Source{
App: s.app, App: s.app,
URL: urlPrefix + "/" + s.app + "_" + channel + ".html", URL: urlPrefix + "/" + name,
}, true, nil }, true, nil
} }

View file

@ -11,15 +11,13 @@ import (
// ── ParseSpec ──────────────────────────────────────────────────────────── // ── ParseSpec ────────────────────────────────────────────────────────────
func TestParseSpec_Channels(t *testing.T) { func TestParseSpec_Channels(t *testing.T) {
// "stable" is the only channel alias (latest stable). beta and alpha
// channels no longer exist as public concepts.
cases := []struct { cases := []struct {
spec, wantChan string spec, wantChan string
}{ }{
{"stable", "stable"}, {"stable", "stable"},
{"beta", "beta"},
{"alpha", "alpha"},
{":stable", "stable"}, {":stable", "stable"},
{":beta", "beta"},
{":alpha", "alpha"},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.spec, func(t *testing.T) { t.Run(tc.spec, func(t *testing.T) {
@ -38,18 +36,17 @@ func TestParseSpec_Channels(t *testing.T) {
} }
func TestParseSpec_Versions(t *testing.T) { func TestParseSpec_Versions(t *testing.T) {
// Exact-version pins only. Partial pins (v0.0, v0) no longer exist
// — the upstream publishes <tool>.html (current stable) and
// <tool>_v<X.Y.Z>.html (exact-version immutable). Bare "0.0.4"
// (no v prefix) is normalized to "v0.0.4".
cases := []struct { cases := []struct {
spec, wantChan string spec, wantChan string
}{ }{
{"v0.0.4", "v0.0.4"}, {"v0.0.4", "v0.0.4"},
{"0.0.4", "v0.0.4"}, {"0.0.4", "v0.0.4"},
{"v0.0", "v0.0"},
{"0.0", "v0.0"},
{"v0", "v0"},
{"0", "v0"},
{":v0.0.4", "v0.0.4"}, {":v0.0.4", "v0.0.4"},
{":0.0.4", "v0.0.4"}, {":0.0.4", "v0.0.4"},
{":v0", "v0"},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.spec, func(t *testing.T) { t.Run(tc.spec, func(t *testing.T) {
@ -64,6 +61,19 @@ func TestParseSpec_Versions(t *testing.T) {
} }
} }
func TestParseSpec_RejectsLegacyChannelsAndPartialPins(t *testing.T) {
// alpha/beta channels and partial-version pins are no longer valid.
rejected := []string{"alpha", "beta", ":alpha", ":beta", "v0.0", "v0", "0.0", "0", ":v0.0"}
for _, spec := range rejected {
t.Run(spec, func(t *testing.T) {
_, err := ParseSpec(spec, "/root", "/root")
if err == nil {
t.Errorf("expected error for %q, got none", spec)
}
})
}
}
func TestParseSpec_URLPrefix(t *testing.T) { func TestParseSpec_URLPrefix(t *testing.T) {
cases := []struct { cases := []struct {
spec, wantPrefix, wantChan string spec, wantPrefix, wantChan string
@ -71,14 +81,13 @@ func TestParseSpec_URLPrefix(t *testing.T) {
{"https://my-mirror.example/releases", "https://my-mirror.example/releases", ""}, {"https://my-mirror.example/releases", "https://my-mirror.example/releases", ""},
{"https://my-mirror.example/releases/", "https://my-mirror.example/releases", ""}, // trailing slash stripped {"https://my-mirror.example/releases/", "https://my-mirror.example/releases", ""}, // trailing slash stripped
{"https://my-mirror.example/releases:stable", "https://my-mirror.example/releases", "stable"}, {"https://my-mirror.example/releases:stable", "https://my-mirror.example/releases", "stable"},
{"https://my-mirror.example/releases:beta", "https://my-mirror.example/releases", "beta"},
{"https://my-mirror.example/releases:v0.0.4", "https://my-mirror.example/releases", "v0.0.4"}, {"https://my-mirror.example/releases:v0.0.4", "https://my-mirror.example/releases", "v0.0.4"},
// Port colon must NOT be confused with channel separator. // Port colon must NOT be confused with channel separator.
{"https://my-mirror.example:8080/releases", "https://my-mirror.example:8080/releases", ""}, {"https://my-mirror.example:8080/releases", "https://my-mirror.example:8080/releases", ""},
{"https://my-mirror.example:8080/releases:stable", "https://my-mirror.example:8080/releases", "stable"}, {"https://my-mirror.example:8080/releases:stable", "https://my-mirror.example:8080/releases", "stable"},
// Colon embedded in path before final slash — treated as part of path. // Colon embedded in path before final slash — treated as part of path.
{"https://host/some:thing/releases", "https://host/some:thing/releases", ""}, {"https://host/some:thing/releases", "https://host/some:thing/releases", ""},
{"https://host/some:thing/releases:beta", "https://host/some:thing/releases", "beta"}, {"https://host/some:thing/releases:v0.0.4", "https://host/some:thing/releases", "v0.0.4"},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.spec, func(t *testing.T) { t.Run(tc.spec, func(t *testing.T) {
@ -192,13 +201,15 @@ func TestResolve_NoEntries(t *testing.T) {
func TestResolve_PerAppChannelOnly(t *testing.T) { func TestResolve_PerAppChannelOnly(t *testing.T) {
root := t.TempDir() root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{ chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{"archive": "beta"}, Apps: map[string]string{"archive": "stable"},
}}} }}}
src, has, err := Resolve(chain, "archive", root, root) src, has, err := Resolve(chain, "archive", root, root)
if err != nil || !has { if err != nil || !has {
t.Fatalf("has=%v err=%v", has, err) t.Fatalf("has=%v err=%v", has, err)
} }
want := DefaultUpstreamReleases + "/archive_beta.html" // stable channel → canonical URL (no _stable_ suffix); the upstream
// publishes a symlink at this URL pointing at the latest version.
want := DefaultUpstreamReleases + "/archive.html"
if src.URL != want { if src.URL != want {
t.Errorf("got URL=%q, want %q", src.URL, want) t.Errorf("got URL=%q, want %q", src.URL, want)
} }
@ -223,40 +234,40 @@ func TestResolve_DefaultProvidesURLAndChannel(t *testing.T) {
root := t.TempDir() root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{ chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{ Apps: map[string]string{
"default": "https://mirror.example/releases:beta", "default": "https://mirror.example/releases:v0.0.4",
}, },
}}} }}}
src, has, err := Resolve(chain, "archive", root, root) src, has, err := Resolve(chain, "archive", root, root)
if err != nil || !has { if err != nil || !has {
t.Fatalf("has=%v err=%v", has, err) t.Fatalf("has=%v err=%v", has, err)
} }
if src.URL != "https://mirror.example/releases/archive_beta.html" { if src.URL != "https://mirror.example/releases/archive_v0.0.4.html" {
t.Errorf("got URL=%q", src.URL) t.Errorf("got URL=%q", src.URL)
} }
} }
func TestResolve_DefaultPlusPerAppChannelOverride(t *testing.T) { func TestResolve_DefaultPlusPerAppChannelOverride(t *testing.T) {
// User's example: default=https://zddc.varasys.io/releases:stable, // default=https://zddc.varasys.io/releases:stable, classifier=:v0.0.4
// classifier=:beta → mirror URL with classifier_beta.html. // → classifier pinned to v0.0.4 on the same mirror.
root := t.TempDir() root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{ chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{ Apps: map[string]string{
"default": "https://zddc.varasys.io/releases:stable", "default": "https://zddc.varasys.io/releases:stable",
"classifier": ":beta", "classifier": ":v0.0.4",
}, },
}}} }}}
src, _, err := Resolve(chain, "classifier", root, root) src, _, err := Resolve(chain, "classifier", root, root)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if src.URL != "https://zddc.varasys.io/releases/classifier_beta.html" { if src.URL != "https://zddc.varasys.io/releases/classifier_v0.0.4.html" {
t.Errorf("got URL=%q", src.URL) t.Errorf("got URL=%q", src.URL)
} }
} }
func TestResolve_DefaultPlusPerAppURLPrefixOverride(t *testing.T) { func TestResolve_DefaultPlusPerAppURLPrefixOverride(t *testing.T) {
// User's example: default=...:stable, archive=https://my.local.stuff/releases // default=...:stable, archive=https://my.local.stuff/releases
// → custom URL + default channel (stable). // → custom URL + default channel (stable, canonical filename).
root := t.TempDir() root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{ chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{ Apps: map[string]string{
@ -268,7 +279,7 @@ func TestResolve_DefaultPlusPerAppURLPrefixOverride(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if src.URL != "https://my.local.stuff/releases/archive_stable.html" { if src.URL != "https://my.local.stuff/releases/archive.html" {
t.Errorf("got URL=%q", src.URL) t.Errorf("got URL=%q", src.URL)
} }
} }
@ -278,13 +289,13 @@ func TestResolve_DeeperLevelOverridesParentChannel(t *testing.T) {
requestDir := filepath.Join(root, "Project-A") requestDir := filepath.Join(root, "Project-A")
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{ chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
{Apps: map[string]string{"default": ":stable"}}, {Apps: map[string]string{"default": ":stable"}},
{Apps: map[string]string{"default": ":beta"}}, {Apps: map[string]string{"default": ":v0.0.4"}},
}} }}
src, _, err := Resolve(chain, "archive", root, requestDir) src, _, err := Resolve(chain, "archive", root, requestDir)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
want := DefaultUpstreamReleases + "/archive_beta.html" want := DefaultUpstreamReleases + "/archive_v0.0.4.html"
if src.URL != want { if src.URL != want {
t.Errorf("got URL=%q, want %q", src.URL, want) t.Errorf("got URL=%q, want %q", src.URL, want)
} }
@ -301,8 +312,9 @@ func TestResolve_DeeperLevelOverridesParentURL(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// b.example URL prefix wins; channel inherited (stable). // b.example URL prefix wins; channel inherited (stable → canonical
want := "https://b.example/releases/archive_stable.html" // filename, no _stable_ suffix).
want := "https://b.example/releases/archive.html"
if src.URL != want { if src.URL != want {
t.Errorf("got URL=%q, want %q", src.URL, want) t.Errorf("got URL=%q, want %q", src.URL, want)
} }
@ -329,13 +341,13 @@ func TestResolve_DeeperNonTerminalOverridesParentTerminal(t *testing.T) {
requestDir := filepath.Join(root, "Project-A") requestDir := filepath.Join(root, "Project-A")
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{ chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
{Apps: map[string]string{"archive": "https://a.example/archive.html"}}, // terminal {Apps: map[string]string{"archive": "https://a.example/archive.html"}}, // terminal
{Apps: map[string]string{"archive": "alpha"}}, // non-terminal {Apps: map[string]string{"archive": "v0.0.4"}}, // non-terminal
}} }}
src, _, err := Resolve(chain, "archive", root, requestDir) src, _, err := Resolve(chain, "archive", root, requestDir)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
want := DefaultUpstreamReleases + "/archive_alpha.html" want := DefaultUpstreamReleases + "/archive_v0.0.4.html"
if src.URL != want { if src.URL != want {
t.Errorf("got URL=%q, want %q", src.URL, want) t.Errorf("got URL=%q, want %q", src.URL, want)
} }
@ -382,7 +394,7 @@ func TestResolve_PerAppOverridesDefaultAtSameLevel(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if src2.URL != "https://a.example/releases/classifier_stable.html" { if src2.URL != "https://a.example/releases/classifier.html" {
t.Errorf("got classifier URL=%q (want a.example default)", src2.URL) t.Errorf("got classifier URL=%q (want a.example default)", src2.URL)
} }
} }
@ -417,9 +429,9 @@ func TestPreviewLine(t *testing.T) {
} }
}) })
t.Run("default channel → URL", func(t *testing.T) { t.Run("default channel → URL", func(t *testing.T) {
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{Apps: map[string]string{"default": ":beta"}}}} chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{Apps: map[string]string{"default": ":v0.0.4"}}}}
got := PreviewLine(chain, "archive", root, root) got := PreviewLine(chain, "archive", root, root)
if !strings.Contains(got, "archive_beta.html") { if !strings.Contains(got, "archive_v0.0.4.html") {
t.Errorf("got %q", got) t.Errorf("got %q", got)
} }
}) })

View file

@ -221,15 +221,15 @@ func TestServer_FetchFailFallsBackToEmbedded(t *testing.T) {
func TestServer_VParam_CacheHitServesFromCache(t *testing.T) { func TestServer_VParam_CacheHitServesFromCache(t *testing.T) {
srv, _, root := newTestServer(t, []byte("ignored")) srv, _, root := newTestServer(t, []byte("ignored"))
// Pre-populate the cache with a known URL. // Pre-populate the cache with a known URL.
cachedURL := "https://zddc.varasys.io/releases/archive_beta.html" cachedURL := "https://zddc.varasys.io/releases/archive_v0.0.4.html"
cachedBody := []byte("CACHED beta archive") cachedBody := []byte("CACHED v0.0.4 archive")
if err := srv.Cache.Write(cachedURL, cachedBody); err != nil { if err := srv.Cache.Write(cachedURL, cachedBody); err != nil {
t.Fatal(err) t.Fatal(err)
} }
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}} chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=beta", nil), "archive", chain, root) srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=v0.0.4", nil), "archive", chain, root)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
} }
@ -245,7 +245,7 @@ func TestServer_VParam_CacheMissReturns404(t *testing.T) {
srv, _, root := newTestServer(t, []byte("ignored")) srv, _, root := newTestServer(t, []byte("ignored"))
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}} chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=beta", nil), "archive", chain, root) srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=v0.0.4", nil), "archive", chain, root)
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Fatalf("status=%d (want 404)", rec.Code) t.Fatalf("status=%d (want 404)", rec.Code)
} }
@ -277,19 +277,19 @@ func TestServer_VParam_BadSpecReturns400(t *testing.T) {
func TestServer_VParam_CombinesWithCascadeURLPrefix(t *testing.T) { func TestServer_VParam_CombinesWithCascadeURLPrefix(t *testing.T) {
// Cascade has a default URL prefix; ?v=:beta should resolve against it. // Cascade has a default URL prefix; ?v=:beta should resolve against it.
srv, _, root := newTestServer(t, []byte("ignored")) srv, _, root := newTestServer(t, []byte("ignored"))
cachedURL := "https://my-mirror.example/releases/archive_beta.html" cachedURL := "https://my-mirror.example/releases/archive_v0.0.4.html"
if err := srv.Cache.Write(cachedURL, []byte("MIRROR beta")); err != nil { if err := srv.Cache.Write(cachedURL, []byte("MIRROR v0.0.4")); err != nil {
t.Fatal(err) t.Fatal(err)
} }
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{ chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{"default": "https://my-mirror.example/releases:stable"}, Apps: map[string]string{"default": "https://my-mirror.example/releases:stable"},
}}} }}}
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=:beta", nil), "archive", chain, root) srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=:v0.0.4", nil), "archive", chain, root)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
} }
if rec.Body.String() != "MIRROR beta" { if rec.Body.String() != "MIRROR v0.0.4" {
t.Errorf("body=%q", rec.Body.String()) t.Errorf("body=%q", rec.Body.String())
} }
if got := rec.Header().Get("X-ZDDC-Source"); got != "cache:"+cachedURL { if got := rec.Header().Get("X-ZDDC-Source"); got != "cache:"+cachedURL {
@ -299,9 +299,9 @@ func TestServer_VParam_CombinesWithCascadeURLPrefix(t *testing.T) {
func TestServer_VParam_OverridesPathTerminalFromCascade(t *testing.T) { func TestServer_VParam_OverridesPathTerminalFromCascade(t *testing.T) {
// Operator's cascade specifies a path source. User passes ?v=stable. // Operator's cascade specifies a path source. User passes ?v=stable.
// ?v= overrides → resolves to canonical/archive_stable.html, then cache check. // ?v= overrides → resolves to canonical/archive.html, then cache check.
srv, _, root := newTestServer(t, []byte("ignored")) srv, _, root := newTestServer(t, []byte("ignored"))
cachedURL := "https://zddc.varasys.io/releases/archive_stable.html" cachedURL := "https://zddc.varasys.io/releases/archive.html"
if err := srv.Cache.Write(cachedURL, []byte("CACHED stable")); err != nil { if err := srv.Cache.Write(cachedURL, []byte("CACHED stable")); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View file

@ -8,7 +8,7 @@ import (
// embeddedVersionsRaw is the manifest written by the top-level build.sh // embeddedVersionsRaw is the manifest written by the top-level build.sh
// at compile time. Format is one `<app>=<build label>` line per app — // at compile time. Format is one `<app>=<build label>` line per app —
// e.g. `archive=v0.0.5-alpha · 2026-05-01 14:00:00 · abc1234`. An empty // e.g. `archive=v0.0.5-beta · 2026-05-01 14:00:00 · abc1234`. An empty
// or missing value indicates the embedded slot was not populated (a fresh // or missing value indicates the embedded slot was not populated (a fresh
// clone where build.sh hasn't run yet). // clone where build.sh hasn't run yet).
// //

View file

@ -1515,7 +1515,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp">v0.0.17</span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.18-dev · 2026-05-20 14:16:52 · 784ed21-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">