feat: lib/version.sh — one version abstraction, two backends
The seam that lets box/rig/incubator (a VERSION file) and cast (package.json + lockfile sync) share every other ceremony component unchanged. Sourced, pure (no git), fail-loud on every unreadable state. Carries cast's pkg_version discipline (node's parser, never regex) and its lockfile-only bump incantation; refuses pre-release arithmetic (-dev/-rc1) per box's prefix-confusion lore. The npm-backed write test skips locally without npm but CI sets CEREMONY_REQUIRE_NPM so the skip is a failure there — the case can never quietly stop running. Closes #3 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
79cdbb81aa
commit
36fc98bf40
6 changed files with 266 additions and 1 deletions
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
|
|
@ -28,4 +28,9 @@ jobs:
|
|||
- name: Actionlint
|
||||
run: bash .github/scripts/actionlint-all.sh
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
|
||||
122
lib/version.sh
Normal file
122
lib/version.sh
Normal 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 L233–L239): 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
12
test/fixtures/version/pkg/package-lock.json
generated
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
5
test/fixtures/version/pkg/package.json
vendored
Normal file
5
test/fixtures/version/pkg/package.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "ceremony-version-fixture",
|
||||
"version": "0.1.0",
|
||||
"private": true
|
||||
}
|
||||
122
test/version.test.sh
Normal file
122
test/version.test.sh
Normal 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
|
||||
Loading…
Reference in a new issue