diff --git a/docs/versioning.md b/docs/versioning.md index 72b3c4d..e0a17a9 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -93,12 +93,14 @@ A versioning scheme without enforcement decays in months. Five cheap mechanical 2. **Runtime self-report.** `GET /version` returns every surface version. Dashboards, monitoring, inter-service handshakes, and humans all read from one source. `/healthz` stays a plain `"ok"` string for k8s probes — version negotiation is a separate concern. 3. **Golden SDK contract tests.** `tests/sdk_contract/` Rhai scripts exercise every SDK surface and must pass on every commit. The contract is the test. 4. **Migration replay test.** An integration test that boots a fresh Postgres, applies every migration in order, and asserts the resulting schema. Catches the most common mistake (edited-not-added migration). -5. **CI guardrail script.** A small diff-aware check that: - - Fails if `SDK_VERSION`'s major changed without a `CHANGELOG.md` breaking-change entry - - Fails if a new file appeared in `migrations/` that isn't the next sequential number - - Fails if a route handler removed or retyped a public field without a `BREAKING:` line in the commit message +5. **CI guardrail script.** [`scripts/check-versioning.sh`](../scripts/check-versioning.sh) — runs the structural checks that don't need git history: + - Migration files are numbered sequentially from `0001_*.sql` with no gaps. + - `SDK_VERSION` parses as `MAJOR.MINOR` (numeric, no extra components). + - `[workspace.package].version` parses as `MAJOR.MINOR.PATCH`. -(3) through (5) are wired in over the next few PRs; (1) and (2) land in the same commit as this document. + Run manually as `bash scripts/check-versioning.sh`. Wires into CI when CI exists. Deferred to the same future PR that introduces CI: SDK-major-bump-needs-CHANGELOG and `BREAKING:` commit-message annotation (both need git history + a CHANGELOG file that doesn't exist yet). + +(3) and (4) are now in place: [`crates/executor-core/tests/sdk_contract.rs`](../crates/executor-core/tests/sdk_contract.rs) holds the SDK contract suite; [`crates/manager-core/tests/schema_snapshot.rs`](../crates/manager-core/tests/schema_snapshot.rs) holds the schema snapshot guard. --- diff --git a/scripts/check-versioning.sh b/scripts/check-versioning.sh new file mode 100755 index 0000000..81573a9 --- /dev/null +++ b/scripts/check-versioning.sh @@ -0,0 +1,126 @@ +#!/bin/sh +# Versioning guardrail — runs the structural checks from +# docs/versioning.md that don't need git history. Designed to be +# called from a CI job (once we have one) and/or as a pre-commit +# step. Exits 0 if everything is in shape, non-zero on the first +# failure with a precise message. +# +# What this DOES check: +# * Migration filenames are sequential `0001_*.sql`, `0002_*.sql`, +# ... starting from 0001 with no gaps and no duplicates. +# * SDK_VERSION in shared::version parses as MAJOR.MINOR (numeric). +# * Workspace product version in Cargo.toml parses as +# MAJOR.MINOR.PATCH (numeric). +# +# What this does NOT check (deferred until we have CI + a CHANGELOG +# file): +# * Whether an SDK major bump was paired with a CHANGELOG entry. +# * Whether commits that retype public fields carry a `BREAKING:` +# annotation in the commit message. +# +# Usage: bash scripts/check-versioning.sh + +set -eu + +# Resolve repo root from this script's location so the checks run no +# matter what working directory the caller is in. +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +fail() { + printf 'check-versioning: FAIL — %s\n' "$1" >&2 + exit 1 +} + +# ---------------------------------------------------------------------- +# 1. Migration filenames sequential +# ---------------------------------------------------------------------- +MIGRATIONS_DIR="$REPO_ROOT/crates/manager-core/migrations" +[ -d "$MIGRATIONS_DIR" ] || fail "migrations dir not found at $MIGRATIONS_DIR" + +i=1 +for file in "$MIGRATIONS_DIR"/*.sql; do + [ -e "$file" ] || fail "no migration files found in $MIGRATIONS_DIR" + base="$(basename "$file")" + expected_prefix="$(printf '%04d_' "$i")" + case "$base" in + "$expected_prefix"*) + ;; + *) + fail "migration $base is not next-in-sequence (expected ${expected_prefix}.sql); migrations must be added with strictly increasing 4-digit numbers" + ;; + esac + i=$((i + 1)) +done +printf 'check-versioning: OK — %d migration(s) numbered sequentially\n' "$((i - 1))" + +# ---------------------------------------------------------------------- +# 2. SDK_VERSION format +# ---------------------------------------------------------------------- +SDK_FILE="$REPO_ROOT/crates/shared/src/version.rs" +[ -f "$SDK_FILE" ] || fail "shared::version not found at $SDK_FILE" + +SDK_VERSION="$( + awk '/^pub const SDK_VERSION/ { match($0, /"[^"]+"/); print substr($0, RSTART+1, RLENGTH-2); exit }' "$SDK_FILE" +)" +[ -n "$SDK_VERSION" ] || fail "could not parse SDK_VERSION from $SDK_FILE" + +case "$SDK_VERSION" in + [0-9]*"."[0-9]*) + # Reject things like "1.2.3" or "v1.2" or empty parts. + major="${SDK_VERSION%%.*}" + minor="${SDK_VERSION#*.}" + case "$major" in + ''|*[!0-9]*) fail "SDK_VERSION '$SDK_VERSION' major is not numeric" ;; + esac + case "$minor" in + ''|*[!0-9]*) fail "SDK_VERSION '$SDK_VERSION' minor is not numeric (extra components?)" ;; + esac + ;; + *) + fail "SDK_VERSION '$SDK_VERSION' is not MAJOR.MINOR (expected e.g. '1.1')" + ;; +esac +printf 'check-versioning: OK — SDK_VERSION = %s\n' "$SDK_VERSION" + +# ---------------------------------------------------------------------- +# 3. Workspace product version (semver MAJOR.MINOR.PATCH) +# ---------------------------------------------------------------------- +ROOT_CARGO="$REPO_ROOT/Cargo.toml" +[ -f "$ROOT_CARGO" ] || fail "workspace Cargo.toml not found" + +PRODUCT_VERSION="$( + awk ' + /^\[workspace\.package\]/ { in_section = 1; next } + /^\[/ { in_section = 0 } + in_section && /^version *= */ { + match($0, /"[^"]+"/) + print substr($0, RSTART+1, RLENGTH-2) + exit + } + ' "$ROOT_CARGO" +)" +[ -n "$PRODUCT_VERSION" ] || fail "could not parse [workspace.package].version from $ROOT_CARGO" + +case "$PRODUCT_VERSION" in + [0-9]*"."[0-9]*"."[0-9]*) + major="${PRODUCT_VERSION%%.*}" + rest="${PRODUCT_VERSION#*.}" + minor="${rest%%.*}" + patch="${rest#*.}" + for part_name in major minor patch; do + eval "part=\$$part_name" + case "$part" in + ''|*[!0-9]*) + fail "product version '$PRODUCT_VERSION' has non-numeric $part_name component" + ;; + esac + done + ;; + *) + fail "product version '$PRODUCT_VERSION' is not MAJOR.MINOR.PATCH" + ;; +esac +printf 'check-versioning: OK — product version = %s\n' "$PRODUCT_VERSION" + +printf '\ncheck-versioning: all checks passed.\n'