Merge pull request #28 from claude-bot-andresmgsl/build/3-version-lib

feat: lib/version.sh — one version abstraction, two backends
This commit is contained in:
Daniel Marin 2026-07-22 19:43:21 +01:00 committed by GitHub
commit 3d252d2abd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 266 additions and 1 deletions

View file

@ -28,4 +28,9 @@ jobs:
- name: Actionlint - name: Actionlint
run: bash .github/scripts/actionlint-all.sh run: bash .github/scripts/actionlint-all.sh
- name: Tests - name: Tests
env:
# The npm-backed version_write case may skip locally when npm is
# absent; in CI a skip must be a failure, or the case could
# quietly stop running (issue #3's test contract).
CEREMONY_REQUIRE_NPM: 1
run: bash test/run.sh run: bash test/run.sh

View file

@ -1 +0,0 @@

122
lib/version.sh Normal file
View file

@ -0,0 +1,122 @@
#!/usr/bin/env bash
# lib/version.sh — one version abstraction, two backends (issue #3).
#
# Sourced, never executed: no set -e/-u here — the sourcing script owns its
# own shell options. No git in this lib either: "version at the base commit"
# is the caller's job (git show / git worktree the base tree, then point
# version_read at it) — keeping the lib pure keeps its tests trivial.
#
# Backends ($1 of version_read / version_write):
# file — a VERSION file in the tree (box, rig, incubator)
# package-json — package.json's version field (cast)
# version_read <backend> [dir] — print the version; fail loudly on a
# missing/empty source. A wrong release is worse than a missing one, so
# every unreadable state is exit 1 with a message, never an empty print.
version_read() {
local backend="${1:?version_read: backend required}" dir="${2:-.}"
local path ver
case "$backend" in
file)
path="$dir/VERSION"
if [ ! -f "$path" ]; then
echo "version_read: $path: no such file" >&2
return 1
fi
# Whitespace-stripped, following box's drill-recorded.sh: a trailing
# newline or a stray space in VERSION must never make 0.7.0 look
# unlike 0.7.0 (whole-version matching everywhere).
ver="$(tr -d '[:space:]' <"$path")"
if [ -z "$ver" ]; then
echo "version_read: $path is empty" >&2
return 1
fi
printf '%s\n' "$ver"
;;
package-json)
# A clear message beats a bare command-not-found from deep inside a
# workflow log.
if ! command -v node >/dev/null 2>&1; then
echo "version_read: node is required for version-source: package-json" >&2
return 1
fi
path="$dir/package.json"
if [ ! -f "$path" ]; then
echo "version_read: $path: no such file" >&2
return 1
fi
# Read via node's own parser, never regex — cast's "pkg_version
# discipline" (cast release.yml): grepping JSON for "version" finds
# dependency versions, engine fields, anything. An absent or
# non-string field fails here rather than printing "undefined".
node -e '
const p = require(require("path").resolve(process.argv[1]));
if (typeof p.version !== "string" || p.version === "") {
console.error("version_read: " + process.argv[1] + ": no version field");
process.exit(1);
}
console.log(p.version);
' "$path"
;;
*)
echo "version_read: unknown backend: $backend" >&2
return 1
;;
esac
}
# version_is_dev <ver> — exit 0 iff ver ends in the literal -dev suffix.
# Only -dev: an rc (1.2.3-rc1) is a pre-release, not a dev tree, and
# treating it as one would let the armed guard key on the wrong state.
version_is_dev() {
case "${1:?version_is_dev: version required}" in
*-dev) return 0 ;;
*) return 1 ;;
esac
}
# version_next_dev <ver> — bare X.Y.Z -> X.Y.(Z+1)-dev (print). Refuses
# anything else, including -dev and -rc1: this is only ever called on a
# just-released version to re-arm main, and an rc's "next" is a human
# decision, not arithmetic (box's drills/ prefix-confusion lore is why
# nothing here guesses around pre-release identifiers).
version_next_dev() {
local ver="${1:?version_next_dev: version required}"
if [[ ! "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "version_next_dev: refusing '$ver' — expected bare X.Y.Z" >&2
return 1
fi
local major minor patch
IFS=. read -r major minor patch <<<"$ver"
# 10#: a zero-padded patch ("09") would otherwise be read as octal.
printf '%s.%s.%s-dev\n' "$major" "$minor" "$((10#$patch + 1))"
}
# version_write <backend> <ver> [dir] — write the version into the tree.
version_write() {
local backend="${1:?version_write: backend required}"
local ver="${2:?version_write: version required}"
local dir="${3:-.}"
case "$backend" in
file)
printf '%s\n' "$ver" >"$dir/VERSION"
;;
package-json)
if ! command -v npm >/dev/null 2>&1; then
echo "version_write: npm is required for version-source: package-json" >&2
return 1
fi
# npm pkg set + a lockfile-only install, exactly cast's incantation
# (cast release.yml L233L239): package-lock.json embeds the version
# twice, and a bump that skips the lockfile leaves every subsequent
# `npm ci` failing on the mismatch. --ignore-scripts because a
# version bump must never run anybody's install hooks.
(cd "$dir" && npm pkg set version="$ver" \
&& npm install --package-lock-only --ignore-scripts)
;;
*)
echo "version_write: unknown backend: $backend" >&2
return 1
;;
esac
}

12
test/fixtures/version/pkg/package-lock.json generated vendored Normal file
View file

@ -0,0 +1,12 @@
{
"name": "ceremony-version-fixture",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ceremony-version-fixture",
"version": "0.1.0"
}
}
}

View file

@ -0,0 +1,5 @@
{
"name": "ceremony-version-fixture",
"version": "0.1.0",
"private": true
}

122
test/version.test.sh Normal file
View file

@ -0,0 +1,122 @@
#!/usr/bin/env bash
# Contract tests for lib/version.sh (issue #3). set -u, not -e: failing
# commands are behavior for the harness to inspect.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
# shellcheck source=lib/version.sh
. "$ROOT/lib/version.sh"
FIXTURES="$ROOT/test/fixtures/version"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# eq <want> <cmd...> — exit 0 iff the command succeeds AND prints exactly
# <want>; check()'s substring match alone can't prove whitespace was
# stripped (" 1.2.3" contains "1.2.3").
eq() {
local want="$1" got
shift
got="$("$@")" || return 1
[ "$got" = "$want" ]
}
# file_is <path> <ver> — the file holds exactly "<ver>\n", nothing else.
file_is() {
printf '%s\n' "$2" | cmp -s - "$1"
}
# lock_carries <dir> <ver> — package-lock.json embeds the version twice
# (top level and packages[""]); both must agree after a write.
lock_carries() {
node -e '
const l = require(require("path").resolve(process.argv[1]));
if (l.version !== process.argv[2] || l.packages[""].version !== process.argv[2]) {
console.error("lockfile version mismatch: " + JSON.stringify([l.version, l.packages[""].version]));
process.exit(1);
}
' "$1/package-lock.json" "$2"
}
# --- version_read, file backend ---------------------------------------------
mkdir -p "$TMP/plain"
printf '1.2.3\n' >"$TMP/plain/VERSION"
check "file: read happy path" 0 "" eq "1.2.3" version_read file "$TMP/plain"
mkdir -p "$TMP/padded"
printf ' 1.2.3 \n\n' >"$TMP/padded/VERSION"
check "file: surrounding whitespace and trailing newlines stripped" 0 "" \
eq "1.2.3" version_read file "$TMP/padded"
mkdir -p "$TMP/absent"
check "file: missing VERSION fails" 1 "no such file" version_read file "$TMP/absent"
mkdir -p "$TMP/blank"
printf '\n \n' >"$TMP/blank/VERSION"
check "file: whitespace-only VERSION fails" 1 "empty" version_read file "$TMP/blank"
# --- version_read, package-json backend -------------------------------------
check "package-json: read happy path from fixture" 0 "" \
eq "0.1.0" version_read package-json "$FIXTURES/pkg"
mkdir -p "$TMP/nopkg"
check "package-json: missing package.json fails" 1 "no such file" \
version_read package-json "$TMP/nopkg"
mkdir -p "$TMP/noversion"
printf '{ "name": "no-version-here" }\n' >"$TMP/noversion/package.json"
check "package-json: absent version field fails" 1 "no version field" \
version_read package-json "$TMP/noversion"
check "read: unknown backend refused" 1 "unknown backend" version_read carrier-pigeon
# --- version_is_dev ----------------------------------------------------------
check "is_dev: 1.2.3-dev yes" 0 "" version_is_dev 1.2.3-dev
check "is_dev: 1.2.3 no" 1 "" version_is_dev 1.2.3
check "is_dev: 1.2.3-rc1 no" 1 "" version_is_dev 1.2.3-rc1
# --- version_next_dev --------------------------------------------------------
check "next_dev: 0.9.0 -> 0.9.1-dev" 0 "" eq "0.9.1-dev" version_next_dev 0.9.0
check "next_dev: 0.9.9 -> 0.9.10-dev (no decimal snapping)" 0 "" \
eq "0.9.10-dev" version_next_dev 0.9.9
check "next_dev: -dev input refused" 1 "refusing" version_next_dev 1.2.3-dev
check "next_dev: -rc1 input refused" 1 "refusing" version_next_dev 1.2.3-rc1
check "next_dev: garbage refused" 1 "refusing" version_next_dev garbage
# --- version_write, file backend ---------------------------------------------
mkdir -p "$TMP/write-file"
check "write file: succeeds" 0 "" version_write file 2.0.0 "$TMP/write-file"
check "write file: file is exactly ver + newline" 0 "" \
file_is "$TMP/write-file/VERSION" 2.0.0
check "write: unknown backend refused" 1 "unknown backend" \
version_write carrier-pigeon 2.0.0
# --- version_write, package-json backend -------------------------------------
# Needs npm. Locally, skip with a notice so the suite stays runnable in
# minimal environments; in CI the skip is a failure — ci.yml sets
# CEREMONY_REQUIRE_NPM so this case can never quietly stop running there.
if command -v npm >/dev/null 2>&1; then
cp -R "$FIXTURES/pkg" "$TMP/write-pkg"
check "write package-json: succeeds" 0 "" \
version_write package-json 0.2.0 "$TMP/write-pkg"
check "write package-json: package.json carries new version" 0 "" \
eq "0.2.0" version_read package-json "$TMP/write-pkg"
check "write package-json: lockfile carries new version in both spots" 0 "" \
lock_carries "$TMP/write-pkg" 0.2.0
elif [ -n "${CEREMONY_REQUIRE_NPM:-}" ]; then
echo "FAIL: CEREMONY_REQUIRE_NPM is set but npm is missing — the package-json write case did not run"
FAIL=$((FAIL + 1))
else
echo "SKIP: npm not found — version_write package-json cases not exercised"
fi
summary