From 36fc98bf409b19df36bb495c236d5462291a0603 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Wed, 22 Jul 2026 18:19:50 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20lib/version.sh=20=E2=80=94=20one=20vers?= =?UTF-8?q?ion=20abstraction,=20two=20backends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ci.yml | 5 + lib/.gitkeep | 1 - lib/version.sh | 122 ++++++++++++++++++++ test/fixtures/version/pkg/package-lock.json | 12 ++ test/fixtures/version/pkg/package.json | 5 + test/version.test.sh | 122 ++++++++++++++++++++ 6 files changed, 266 insertions(+), 1 deletion(-) delete mode 100644 lib/.gitkeep create mode 100644 lib/version.sh create mode 100644 test/fixtures/version/pkg/package-lock.json create mode 100644 test/fixtures/version/pkg/package.json create mode 100644 test/version.test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebcc372..44e0c18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/lib/.gitkeep b/lib/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/lib/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/lib/version.sh b/lib/version.sh new file mode 100644 index 0000000..8bcd5d6 --- /dev/null +++ b/lib/version.sh @@ -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 [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 — 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 — 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 [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 +} diff --git a/test/fixtures/version/pkg/package-lock.json b/test/fixtures/version/pkg/package-lock.json new file mode 100644 index 0000000..08ed5e7 --- /dev/null +++ b/test/fixtures/version/pkg/package-lock.json @@ -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" + } + } +} diff --git a/test/fixtures/version/pkg/package.json b/test/fixtures/version/pkg/package.json new file mode 100644 index 0000000..35ebff1 --- /dev/null +++ b/test/fixtures/version/pkg/package.json @@ -0,0 +1,5 @@ +{ + "name": "ceremony-version-fixture", + "version": "0.1.0", + "private": true +} diff --git a/test/version.test.sh b/test/version.test.sh new file mode 100644 index 0000000..d02d56d --- /dev/null +++ b/test/version.test.sh @@ -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 — exit 0 iff the command succeeds AND prints exactly +# ; 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 — the file holds exactly "\n", nothing else. +file_is() { + printf '%s\n' "$2" | cmp -s - "$1" +} + +# lock_carries — 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