From 1afc54e492d8cfd962481af2b01e2001dc7075ec Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Wed, 24 Jun 2026 15:04:43 -0700 Subject: [PATCH 1/7] Add query-contraction check against the previous release Signed-off-by: Jeremy Alvis --- .github/workflows/ci.yaml | 19 ++++++++ go/Makefile | 8 ++++ go/core/internal/database/sqlc.yaml | 9 ++++ scripts/check-query-contraction.sh | 72 +++++++++++++++++++++++++++++ scripts/upgrade-from-version.sh | 57 +++++++++++++++++++++++ 5 files changed, 165 insertions(+) create mode 100755 scripts/check-query-contraction.sh create mode 100755 scripts/upgrade-from-version.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b8e3768d9a..7b12289efa 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -160,6 +160,25 @@ jobs: echo "::error::Kubectl logs -n kagent deployment/kagent-controller" kubectl logs -n kagent deployment/kagent-controller + query-contraction-check: + name: Query Contraction Check + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + # Full history + tags so the previous release's queries can be read. + fetch-depth: 0 + fetch-tags: true + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.26" + cache: true + cache-dependency-path: go/go.sum + - name: Tier 2 - previous-release queries vs current schema + run: make -C go check-query-contraction + go-unit-tests: runs-on: ubuntu-latest steps: diff --git a/go/Makefile b/go/Makefile index b2dc7c35b9..6137a8d7d5 100644 --- a/go/Makefile +++ b/go/Makefile @@ -33,6 +33,14 @@ generate: controller-gen sqlc-generate ## Generate DeepCopy methods and sqlc que sqlc-generate: sqlc ## Generate type-safe Go code from SQL queries. cd core/internal/database && $(SQLC) generate +# Compile the previous release's sqlc queries against the current schema (the +# migration files) and fail if a migration removed/renamed/retyped a column or +# table an old query still uses. Static (no database/cluster). Needs git tags +# fetched. UPGRADE_FROM_VERSION overrides the auto-derived previous release. +.PHONY: check-query-contraction +check-query-contraction: sqlc ## Verify the previous release's queries still type-check against the current schema + SQLC=$(SQLC) ../scripts/check-query-contraction.sh + ##@ Development .PHONY: fmt diff --git a/go/core/internal/database/sqlc.yaml b/go/core/internal/database/sqlc.yaml index e9f1fe3cb8..91a4694112 100644 --- a/go/core/internal/database/sqlc.yaml +++ b/go/core/internal/database/sqlc.yaml @@ -1,3 +1,12 @@ +# `schema` points at the migration files, so `sqlc generate` validates every +# query against the schema those migrations produce. This is a load-bearing +# migration-safety gate (Tier 1): a migration that drops/renames/retypes a column +# a current query uses makes codegen fail, and the manifests-check CI job catches +# the resulting drift. Do not "fix" such a failure by deleting the query — it is +# reporting a real schema/query incompatibility. The previous-release direction +# (old queries vs the new schema = the windowed-contraction invariant) is checked +# by scripts/check-query-contraction.sh (Tier 2); see +# design/db-query-regression-testing.md. version: "2" sql: - schema: ["../../pkg/migrations/core", "../../pkg/migrations/vector"] diff --git a/scripts/check-query-contraction.sh b/scripts/check-query-contraction.sh new file mode 100755 index 0000000000..5214deaa94 --- /dev/null +++ b/scripts/check-query-contraction.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# check-query-contraction.sh — query-contraction check. +# +# Compiles the PREVIOUS release's sqlc queries against the CURRENT schema (the +# migration files under go/core/pkg/migrations). It fails if a migration on this +# branch removed, renamed, or retyped a column or table that a query shipped in +# the previous release still references — a schema change that would break the +# previous release's code against the new schema. +# +# Static: no database and no cluster. sqlc derives the schema from the migration +# files (see go/core/internal/database/sqlc.yaml), so "does every previous query +# still type-check against the new schema" is answerable offline. It catches +# column/table/type-shape contraction; semantic breaks (a new NOT NULL, a +# tightened constraint, an index/ordering change) are out of scope for a static +# check and belong to a runtime regression suite. +# +# Inputs (env): +# UPGRADE_FROM_VERSION previous version without leading 'v' (default: derived +# from git tags via upgrade-from-version.sh). +# SQLC sqlc binary to use (default: sqlc on PATH). +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(git -C "$here" rev-parse --show-toplevel)" +sqlc_bin="${SQLC:-sqlc}" + +prev="${UPGRADE_FROM_VERSION:-$("$here/upgrade-from-version.sh")}" +prev_tag="v${prev}" +queries_path="go/core/internal/database/queries" +core_migrations="$repo_root/go/core/pkg/migrations/core" +vector_migrations="$repo_root/go/core/pkg/migrations/vector" + +if ! git -C "$repo_root" rev-parse -q --verify "refs/tags/${prev_tag}" >/dev/null; then + echo "ERROR: previous release tag ${prev_tag} not found; fetch tags (git fetch --tags) or set UPGRADE_FROM_VERSION." >&2 + exit 1 +fi + +if [ -z "$(git -C "$repo_root" ls-tree "$prev_tag" -- "$queries_path" 2>/dev/null)" ]; then + echo "NOTE: ${prev_tag} has no ${queries_path}; skipping contraction check (predates the sqlc query set)." + exit 0 +fi + +workdir="$(mktemp -d)" +trap 'rm -rf "$workdir"' EXIT + +# Self-contained sqlc project: sqlc resolves schema/queries relative to the +# config file, so stage everything under workdir. Current migrations supply the +# schema; the previous release supplies the queries. +mkdir -p "$workdir/schema/core" "$workdir/schema/vector" "$workdir/queries" "$workdir/gen" "$workdir/prev" +cp "$core_migrations"/*.sql "$workdir/schema/core/" +cp "$vector_migrations"/*.sql "$workdir/schema/vector/" +git -C "$repo_root" archive "$prev_tag" "$queries_path" | tar -x -C "$workdir/prev" +cp "$workdir/prev/$queries_path"/*.sql "$workdir/queries/" + +# Minimal config: the go_type overrides in the real sqlc.yaml only affect the +# generated Go types, not whether a query type-checks against the schema, so they +# are intentionally omitted here. +cat >"$workdir/sqlc.yaml" <<'EOF' +version: "2" +sql: + - engine: "postgresql" + schema: ["schema/core", "schema/vector"] + queries: "queries" + gen: + go: + package: "dbgen" + out: "gen" +EOF + +echo "=== Contraction check: queries@${prev_tag} vs current schema ===" +( cd "$workdir" && "$sqlc_bin" compile -f sqlc.yaml ) +echo "OK: previous-release (${prev_tag}) queries still type-check against the current schema." diff --git a/scripts/upgrade-from-version.sh b/scripts/upgrade-from-version.sh new file mode 100755 index 0000000000..8058ff1464 --- /dev/null +++ b/scripts/upgrade-from-version.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# upgrade-from-version.sh — prints the version the upgrade test should upgrade +# FROM, derived from git release tags (vMAJOR.MINOR.PATCH): +# +# - the latest release's patch minus one (e.g. latest 0.9.10 -> 0.9.9), when +# the patch is >= 1 and that prior patch release actually exists; +# - otherwise the latest release of the previous minor line (e.g. latest +# 0.9.0 -> the highest 0.8.x), since patch-1 does not exist. +# +# Output has no leading 'v'. Pre-release tags (e.g. v0.9.10-rc1) are ignored. +# Kept POSIX-bash-3.2 compatible so it also works on stock macOS. +set -euo pipefail + +# Released versions, normalized and sorted ascending. Only clean X.Y.Z tags. +versions="$(git tag --list 'v[0-9]*' | sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V)" +if [ -z "${versions}" ]; then + echo "ERROR: no release tags (vMAJOR.MINOR.PATCH) found" >&2 + exit 1 +fi + +latest="$(printf '%s\n' "${versions}" | tail -1)" +major="${latest%%.*}" +rest="${latest#*.}" +minor="${rest%%.*}" +patch="${rest#*.}" + +exists() { printf '%s\n' "${versions}" | grep -qx "$1"; } + +# Same minor, patch - 1, when that release exists. +if [ "${patch}" -ge 1 ]; then + candidate="${major}.${minor}.$((patch - 1))" + if exists "${candidate}"; then + echo "${candidate}" + exit 0 + fi +fi + +# Fallback: highest release strictly below ${major}.${minor}.0 — the latest +# patch of the previous minor line. +prev="" +while IFS= read -r v; do + vmajor="${v%%.*}" + vrest="${v#*.}" + vminor="${vrest%%.*}" + if [ "${vmajor}" -lt "${major}" ] || { [ "${vmajor}" -eq "${major}" ] && [ "${vminor}" -lt "${minor}" ]; }; then + prev="${v}" + break + fi +done < <(printf '%s\n' "${versions}" | sort -rV) + +if [ -n "${prev}" ]; then + echo "${prev}" + exit 0 +fi + +echo "ERROR: could not determine an upgrade-from version before ${latest}" >&2 +exit 1 From c3c928199282cd6e2163b13e7e42a1f40630a009 Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Thu, 25 Jun 2026 07:08:49 -0700 Subject: [PATCH 2/7] Cleanup Signed-off-by: Jeremy Alvis --- .github/workflows/ci.yaml | 2 +- go/core/internal/database/sqlc.yaml | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7b12289efa..4f119a37df 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -176,7 +176,7 @@ jobs: go-version: "1.26" cache: true cache-dependency-path: go/go.sum - - name: Tier 2 - previous-release queries vs current schema + - name: Previous-release queries vs current schema run: make -C go check-query-contraction go-unit-tests: diff --git a/go/core/internal/database/sqlc.yaml b/go/core/internal/database/sqlc.yaml index 91a4694112..44c25c9eb4 100644 --- a/go/core/internal/database/sqlc.yaml +++ b/go/core/internal/database/sqlc.yaml @@ -1,12 +1,11 @@ # `schema` points at the migration files, so `sqlc generate` validates every # query against the schema those migrations produce. This is a load-bearing -# migration-safety gate (Tier 1): a migration that drops/renames/retypes a column +# migration-safety gate: a migration that drops/renames/retypes a column # a current query uses makes codegen fail, and the manifests-check CI job catches # the resulting drift. Do not "fix" such a failure by deleting the query — it is # reporting a real schema/query incompatibility. The previous-release direction # (old queries vs the new schema = the windowed-contraction invariant) is checked -# by scripts/check-query-contraction.sh (Tier 2); see -# design/db-query-regression-testing.md. +# by scripts/check-query-contraction.sh. version: "2" sql: - schema: ["../../pkg/migrations/core", "../../pkg/migrations/vector"] From b68f0402ae19ce1c6654b1b4ae67a14932c41799 Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Thu, 25 Jun 2026 07:25:36 -0700 Subject: [PATCH 3/7] Updates now that we have an lts branch Signed-off-by: Jeremy Alvis --- scripts/check-query-contraction.sh | 103 +++++++++++++++++++---------- scripts/prev-stable-version.sh | 34 ++++++++++ scripts/upgrade-from-version.sh | 57 ---------------- 3 files changed, 101 insertions(+), 93 deletions(-) create mode 100755 scripts/prev-stable-version.sh delete mode 100755 scripts/upgrade-from-version.sh diff --git a/scripts/check-query-contraction.sh b/scripts/check-query-contraction.sh index 5214deaa94..91a6751d94 100755 --- a/scripts/check-query-contraction.sh +++ b/scripts/check-query-contraction.sh @@ -1,61 +1,87 @@ #!/usr/bin/env bash # check-query-contraction.sh — query-contraction check. # -# Compiles the PREVIOUS release's sqlc queries against the CURRENT schema (the -# migration files under go/core/pkg/migrations). It fails if a migration on this -# branch removed, renamed, or retyped a column or table that a query shipped in -# the previous release still references — a schema change that would break the -# previous release's code against the new schema. +# Compiles a PREVIOUS release's sqlc queries against the CURRENT schema (the +# migration files under go/core/pkg/migrations) and fails if a migration on this +# branch removed, renamed, or retyped a column or table that an older query still +# references — a change that would break that release's code against the new +# schema (the windowed-contraction invariant). +# +# It checks two targets, deduplicated: +# A) the latest released tag reachable from HEAD — the in-line previous release; +# catches a contraction introduced during the current line's development. +# B) the previous stable line's latest patch (release/vX.Y.x tip, via +# prev-stable-version.sh) — the supported rollback-window floor. +# Today these usually resolve to the same tag (one compile); they diverge once a +# new minor releases or the stable line gets a backport patch. # # Static: no database and no cluster. sqlc derives the schema from the migration -# files (see go/core/internal/database/sqlc.yaml), so "does every previous query -# still type-check against the new schema" is answerable offline. It catches +# files (see go/core/internal/database/sqlc.yaml), so "does every old query still +# type-check against the new schema" is answerable offline. It catches # column/table/type-shape contraction; semantic breaks (a new NOT NULL, a # tightened constraint, an index/ordering change) are out of scope for a static # check and belong to a runtime regression suite. # # Inputs (env): -# UPGRADE_FROM_VERSION previous version without leading 'v' (default: derived -# from git tags via upgrade-from-version.sh). -# SQLC sqlc binary to use (default: sqlc on PATH). +# TARGET_VERSIONS space-separated versions without leading 'v' to check +# instead of the auto-derived A/B (for local runs). +# SQLC sqlc binary to use (default: sqlc on PATH). +# REMOTE git remote for target B (default: origin). set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(git -C "$here" rev-parse --show-toplevel)" sqlc_bin="${SQLC:-sqlc}" - -prev="${UPGRADE_FROM_VERSION:-$("$here/upgrade-from-version.sh")}" -prev_tag="v${prev}" queries_path="go/core/internal/database/queries" core_migrations="$repo_root/go/core/pkg/migrations/core" vector_migrations="$repo_root/go/core/pkg/migrations/vector" -if ! git -C "$repo_root" rev-parse -q --verify "refs/tags/${prev_tag}" >/dev/null; then - echo "ERROR: previous release tag ${prev_tag} not found; fetch tags (git fetch --tags) or set UPGRADE_FROM_VERSION." >&2 +# Resolve the target versions. +targets=() +if [ -n "${TARGET_VERSIONS:-}" ]; then + read -ra targets <<<"${TARGET_VERSIONS}" +else + a="$(git -C "$repo_root" describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || true)" + b="$("$here/prev-stable-version.sh" 2>/dev/null || true)" + [ -n "${a}" ] && targets+=("${a}") + [ -n "${b}" ] && targets+=("${b}") +fi +if [ "${#targets[@]}" -eq 0 ]; then + echo "ERROR: no contraction target versions resolved; ensure tags are fetched and a release branch exists, or set TARGET_VERSIONS." >&2 exit 1 fi +# Deduplicate, preserving order (version strings are space- and glob-free). +targets=($(printf '%s\n' "${targets[@]}" | awk 'NF && !seen[$0]++')) -if [ -z "$(git -C "$repo_root" ls-tree "$prev_tag" -- "$queries_path" 2>/dev/null)" ]; then - echo "NOTE: ${prev_tag} has no ${queries_path}; skipping contraction check (predates the sqlc query set)." - exit 0 -fi +workroot="$(mktemp -d)" +trap 'rm -rf "$workroot"' EXIT + +check_target() { + local prev="$1" + local prev_tag="v${prev}" -workdir="$(mktemp -d)" -trap 'rm -rf "$workdir"' EXIT + if ! git -C "$repo_root" rev-parse -q --verify "refs/tags/${prev_tag}" >/dev/null; then + echo "NOTE: tag ${prev_tag} not present locally; skipping (fetch tags to include it)." + return 0 + fi + if [ -z "$(git -C "$repo_root" ls-tree "$prev_tag" -- "$queries_path" 2>/dev/null)" ]; then + echo "NOTE: ${prev_tag} has no ${queries_path}; skipping (predates the sqlc query set)." + return 0 + fi -# Self-contained sqlc project: sqlc resolves schema/queries relative to the -# config file, so stage everything under workdir. Current migrations supply the -# schema; the previous release supplies the queries. -mkdir -p "$workdir/schema/core" "$workdir/schema/vector" "$workdir/queries" "$workdir/gen" "$workdir/prev" -cp "$core_migrations"/*.sql "$workdir/schema/core/" -cp "$vector_migrations"/*.sql "$workdir/schema/vector/" -git -C "$repo_root" archive "$prev_tag" "$queries_path" | tar -x -C "$workdir/prev" -cp "$workdir/prev/$queries_path"/*.sql "$workdir/queries/" + # Self-contained sqlc project: sqlc resolves schema/queries relative to the + # config file, so stage everything under a per-target dir. Current migrations + # supply the schema; the previous release supplies the queries. + local wd="$workroot/$prev" + mkdir -p "$wd/schema/core" "$wd/schema/vector" "$wd/queries" "$wd/gen" "$wd/prev" + cp "$core_migrations"/*.sql "$wd/schema/core/" + cp "$vector_migrations"/*.sql "$wd/schema/vector/" + git -C "$repo_root" archive "$prev_tag" "$queries_path" | tar -x -C "$wd/prev" + cp "$wd/prev/$queries_path"/*.sql "$wd/queries/" -# Minimal config: the go_type overrides in the real sqlc.yaml only affect the -# generated Go types, not whether a query type-checks against the schema, so they -# are intentionally omitted here. -cat >"$workdir/sqlc.yaml" <<'EOF' + # Minimal config: the go_type overrides in the real sqlc.yaml only affect the + # generated Go types, not whether a query type-checks against the schema. + cat >"$wd/sqlc.yaml" <<'EOF' version: "2" sql: - engine: "postgresql" @@ -67,6 +93,11 @@ sql: out: "gen" EOF -echo "=== Contraction check: queries@${prev_tag} vs current schema ===" -( cd "$workdir" && "$sqlc_bin" compile -f sqlc.yaml ) -echo "OK: previous-release (${prev_tag}) queries still type-check against the current schema." + echo "=== Contraction check: queries@${prev_tag} vs current schema ===" + ( cd "$wd" && "$sqlc_bin" compile -f sqlc.yaml ) + echo "OK: ${prev_tag} queries still type-check against the current schema." +} + +for t in "${targets[@]}"; do + check_target "$t" +done diff --git a/scripts/prev-stable-version.sh b/scripts/prev-stable-version.sh new file mode 100755 index 0000000000..030148da98 --- /dev/null +++ b/scripts/prev-stable-version.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# prev-stable-version.sh — prints the latest released patch of the previous +# stable line: the highest vMAJOR.MINOR.PATCH tag on the newest +# release/vMAJOR.MINOR.x branch (e.g. release/v0.9.x -> 0.9.10). This is the +# rollback-window floor a contraction must stay compatible with. +# +# Uses `git ls-remote`, so it needs network to the remote but not the branch +# checked out locally. Output has no leading 'v'. Override the remote with REMOTE. +set -euo pipefail + +remote="${REMOTE:-origin}" + +# Newest release branch's MAJOR.MINOR (release/v0.9.x -> 0.9). Pre-release and +# non-release branches are ignored by the pattern. +minor="$(git ls-remote --heads "$remote" 'refs/heads/release/v*' 2>/dev/null \ + | sed -nE 's#.*refs/heads/release/v([0-9]+\.[0-9]+)\.x$#\1#p' \ + | sort -V | tail -1)" +if [ -z "${minor}" ]; then + echo "ERROR: no release/vMAJOR.MINOR.x branch found on ${remote}" >&2 + exit 1 +fi + +# Highest clean vMINOR.PATCH tag on that line. The `$` anchor skips the +# annotated-tag deref entries (refs/tags/vX.Y.Z^{}). +esc="${minor//./\\.}" +latest="$(git ls-remote --tags "$remote" 2>/dev/null \ + | grep -oE "refs/tags/v${esc}\.[0-9]+$" \ + | sed 's#refs/tags/v##' | sort -V | tail -1)" +if [ -z "${latest}" ]; then + echo "ERROR: no v${minor}.PATCH release tags found on ${remote}" >&2 + exit 1 +fi + +echo "${latest}" diff --git a/scripts/upgrade-from-version.sh b/scripts/upgrade-from-version.sh deleted file mode 100755 index 8058ff1464..0000000000 --- a/scripts/upgrade-from-version.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash -# upgrade-from-version.sh — prints the version the upgrade test should upgrade -# FROM, derived from git release tags (vMAJOR.MINOR.PATCH): -# -# - the latest release's patch minus one (e.g. latest 0.9.10 -> 0.9.9), when -# the patch is >= 1 and that prior patch release actually exists; -# - otherwise the latest release of the previous minor line (e.g. latest -# 0.9.0 -> the highest 0.8.x), since patch-1 does not exist. -# -# Output has no leading 'v'. Pre-release tags (e.g. v0.9.10-rc1) are ignored. -# Kept POSIX-bash-3.2 compatible so it also works on stock macOS. -set -euo pipefail - -# Released versions, normalized and sorted ascending. Only clean X.Y.Z tags. -versions="$(git tag --list 'v[0-9]*' | sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V)" -if [ -z "${versions}" ]; then - echo "ERROR: no release tags (vMAJOR.MINOR.PATCH) found" >&2 - exit 1 -fi - -latest="$(printf '%s\n' "${versions}" | tail -1)" -major="${latest%%.*}" -rest="${latest#*.}" -minor="${rest%%.*}" -patch="${rest#*.}" - -exists() { printf '%s\n' "${versions}" | grep -qx "$1"; } - -# Same minor, patch - 1, when that release exists. -if [ "${patch}" -ge 1 ]; then - candidate="${major}.${minor}.$((patch - 1))" - if exists "${candidate}"; then - echo "${candidate}" - exit 0 - fi -fi - -# Fallback: highest release strictly below ${major}.${minor}.0 — the latest -# patch of the previous minor line. -prev="" -while IFS= read -r v; do - vmajor="${v%%.*}" - vrest="${v#*.}" - vminor="${vrest%%.*}" - if [ "${vmajor}" -lt "${major}" ] || { [ "${vmajor}" -eq "${major}" ] && [ "${vminor}" -lt "${minor}" ]; }; then - prev="${v}" - break - fi -done < <(printf '%s\n' "${versions}" | sort -rV) - -if [ -n "${prev}" ]; then - echo "${prev}" - exit 0 -fi - -echo "ERROR: could not determine an upgrade-from version before ${latest}" >&2 -exit 1 From 38ae4c989ebe6955045b55586f5904ce80c23411 Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Thu, 25 Jun 2026 09:12:13 -0700 Subject: [PATCH 4/7] Update go version in ci workflow to use go.mod Signed-off-by: Jeremy Alvis --- .github/workflows/ci.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4f119a37df..f3d66cfe65 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -173,7 +173,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.26" + go-version-file: go/go.mod cache: true cache-dependency-path: go/go.sum - name: Previous-release queries vs current schema @@ -188,7 +188,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.26" + go-version-file: go/go.mod cache: true cache-dependency-path: go/go.sum @@ -321,7 +321,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.26" + go-version-file: go/go.mod cache: true cache-dependency-path: go/go.sum - name: golangci-lint @@ -394,7 +394,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.26" + go-version-file: go/go.mod cache: true cache-dependency-path: go/go.sum From baa3c1585a0e1486b60b5904dd73ba02e6954b8c Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Thu, 25 Jun 2026 09:21:00 -0700 Subject: [PATCH 5/7] Update kagent-dev skill Signed-off-by: Jeremy Alvis --- .claude/skills/kagent-dev/references/database-migrations.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/skills/kagent-dev/references/database-migrations.md b/.claude/skills/kagent-dev/references/database-migrations.md index d63aa498bd..99853c8949 100644 --- a/.claude/skills/kagent-dev/references/database-migrations.md +++ b/.claude/skills/kagent-dev/references/database-migrations.md @@ -205,11 +205,11 @@ These tests catch policy violations at PR time without needing a running databas ## Upgrade and rollback testing -Static analysis covers file *content*; round-trip tests cover *behavior* against a real Postgres. Beyond `runner_test.go` (rollback and concurrency), two release-to-release tests make the rollback promise real. Both are *Target — not yet enforced*. +Static analysis covers file *content*; round-trip tests cover *behavior* against a real Postgres. Beyond `runner_test.go` (rollback and concurrency), release-to-release coverage makes the rollback promise real. -**Previous-minor round-trip.** Seed a database at the previous minor's latest release with representative data, apply migrations up to `HEAD`, and assert the schema matches a clean `HEAD` install and the data survives; then reverse to the previous minor and assert the schema matches a clean previous-minor install and the data survives. This exercises every changed down file rather than only reviewing it. +**Previous-minor round-trip** (*Target — not yet enforced*). Seed a database at the previous minor's latest release with representative data, apply migrations up to `HEAD`, and assert the schema matches a clean `HEAD` install and the data survives; then reverse to the previous minor and assert the schema matches a clean previous-minor install and the data survives. This exercises every changed down file rather than only reviewing it. -**Query-level backward compatibility.** Run the previous minor's database test suite against a `HEAD`-migrated schema, proving old code's queries run against the newer schema — the exact property [ahead-schema tolerance](#rollback-and-ahead-schema-tolerance) relies on. +**Query-level backward compatibility.** A static check — `scripts/check-query-contraction.sh`, run by the `query-contraction-check` CI job — compiles a previous release's sqlc queries against the `HEAD` schema and fails if a migration dropped, renamed, or retyped a column or table an older query still reads. It catches column/table/type-shape contraction with no database, against two prior versions: the latest release reachable from `HEAD` and the previous stable line's latest patch (the `release/vX.Y.x` tip, via `scripts/prev-stable-version.sh`). The fuller property — running the previous minor's whole database *test suite* against a `HEAD`-migrated schema, which also covers semantic breaks a query still compiles against — remains a *Target — not yet enforced*. ## Downstream Extension Model From ca30a646becb39bad1c4ba8514d932a2b5c6efb3 Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Thu, 25 Jun 2026 13:22:02 -0700 Subject: [PATCH 6/7] Fixes based on claude review Signed-off-by: Jeremy Alvis --- scripts/prev-stable-version.sh | 58 ++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/scripts/prev-stable-version.sh b/scripts/prev-stable-version.sh index 030148da98..07ba1e2c44 100755 --- a/scripts/prev-stable-version.sh +++ b/scripts/prev-stable-version.sh @@ -1,33 +1,65 @@ #!/usr/bin/env bash -# prev-stable-version.sh — prints the latest released patch of the previous -# stable line: the highest vMAJOR.MINOR.PATCH tag on the newest -# release/vMAJOR.MINOR.x branch (e.g. release/v0.9.x -> 0.9.10). This is the -# rollback-window floor a contraction must stay compatible with. +# prev-stable-version.sh — prints the latest released patch of the stable line +# immediately BELOW the line currently being built: the highest +# vMAJOR.MINOR.PATCH tag on the newest release/vMAJOR.MINOR.x branch whose +# MAJOR.MINOR is strictly less than the current line. This is the rollback-window +# floor a contraction must stay compatible with. # +# The current line comes from the base/target branch: +# - release/vX.Y.x -> current line is X.Y, so this resolves to the newest +# release line below X.Y (release/v0.9.x -> 0.8.x). +# - main (or any non-release branch) -> the unreleased next minor, which sorts +# above every release line, so this resolves to the newest +# release line overall (-> 0.9.x). +# Source order for the current ref: CURRENT_REF override, GITHUB_BASE_REF (PR +# target), GITHUB_REF_NAME (push), then the checked-out branch. +# +# Prints nothing and exits 0 when no stable line exists below the current one +# (e.g. building the oldest release line), so the caller can skip that target. # Uses `git ls-remote`, so it needs network to the remote but not the branch # checked out locally. Output has no leading 'v'. Override the remote with REMOTE. set -euo pipefail remote="${REMOTE:-origin}" -# Newest release branch's MAJOR.MINOR (release/v0.9.x -> 0.9). Pre-release and -# non-release branches are ignored by the pattern. -minor="$(git ls-remote --heads "$remote" 'refs/heads/release/v*' 2>/dev/null \ +# Current line MAJOR.MINOR, or empty for main / any non-release line. +current_ref="${CURRENT_REF:-${GITHUB_BASE_REF:-${GITHUB_REF_NAME:-$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)}}}" +current_minor="" +if [[ "${current_ref}" =~ ^release/v([0-9]+\.[0-9]+)\.x$ ]]; then + current_minor="${BASH_REMATCH[1]}" +fi + +# All release lines on the remote, ascending by version (MAJOR.MINOR only). +lines="$(git ls-remote --heads "$remote" 'refs/heads/release/v*' 2>/dev/null \ | sed -nE 's#.*refs/heads/release/v([0-9]+\.[0-9]+)\.x$#\1#p' \ - | sort -V | tail -1)" -if [ -z "${minor}" ]; then + | sort -V)" +if [ -z "${lines}" ]; then echo "ERROR: no release/vMAJOR.MINOR.x branch found on ${remote}" >&2 exit 1 fi -# Highest clean vMINOR.PATCH tag on that line. The `$` anchor skips the -# annotated-tag deref entries (refs/tags/vX.Y.Z^{}). -esc="${minor//./\\.}" +# ver_lt A B -> success when A < B by version sort. +ver_lt() { [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" = "$1" ]; } + +# Highest line strictly below the current line. With no current_minor (main / +# next), every line qualifies, so this lands on the newest line overall. +prev_minor="" +for l in ${lines}; do + if [ -z "${current_minor}" ] || ver_lt "$l" "${current_minor}"; then + prev_minor="$l" + fi +done +if [ -z "${prev_minor}" ]; then + # No stable line below the current one; let the caller skip that target. + exit 0 +fi + +esc="${prev_minor//./\\.}" latest="$(git ls-remote --tags "$remote" 2>/dev/null \ | grep -oE "refs/tags/v${esc}\.[0-9]+$" \ | sed 's#refs/tags/v##' | sort -V | tail -1)" if [ -z "${latest}" ]; then - echo "ERROR: no v${minor}.PATCH release tags found on ${remote}" >&2 + echo "ERROR: no v${prev_minor}.PATCH release tags found on ${remote} (fetch tags?)" >&2 exit 1 fi From ffa1f4846781a571e80ab09caf97d49e2a9f67c4 Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Thu, 25 Jun 2026 13:55:34 -0700 Subject: [PATCH 7/7] Fixes from review Signed-off-by: Jeremy Alvis --- go/Makefile | 2 +- scripts/check-query-contraction.sh | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/go/Makefile b/go/Makefile index 6137a8d7d5..d948dfabcd 100644 --- a/go/Makefile +++ b/go/Makefile @@ -36,7 +36,7 @@ sqlc-generate: sqlc ## Generate type-safe Go code from SQL queries. # Compile the previous release's sqlc queries against the current schema (the # migration files) and fail if a migration removed/renamed/retyped a column or # table an old query still uses. Static (no database/cluster). Needs git tags -# fetched. UPGRADE_FROM_VERSION overrides the auto-derived previous release. +# fetched. TARGET_VERSIONS (space-separated) overrides the auto-derived release(s). .PHONY: check-query-contraction check-query-contraction: sqlc ## Verify the previous release's queries still type-check against the current schema SQLC=$(SQLC) ../scripts/check-query-contraction.sh diff --git a/scripts/check-query-contraction.sh b/scripts/check-query-contraction.sh index 91a6751d94..599e17e255 100755 --- a/scripts/check-query-contraction.sh +++ b/scripts/check-query-contraction.sh @@ -56,12 +56,21 @@ targets=($(printf '%s\n' "${targets[@]}" | awk 'NF && !seen[$0]++')) workroot="$(mktemp -d)" trap 'rm -rf "$workroot"' EXIT +# A target resolved (non-empty version) but whose tag is absent locally means +# the checkout didn't fetch tags — a misconfiguration that would otherwise let +# the whole check pass having compiled nothing. Track the two outcomes so the +# post-loop guard can fail on that case while still allowing a legitimately +# empty run (every resolved target predates the sqlc query set). +compiled=0 +missing_tag=0 + check_target() { local prev="$1" local prev_tag="v${prev}" if ! git -C "$repo_root" rev-parse -q --verify "refs/tags/${prev_tag}" >/dev/null; then echo "NOTE: tag ${prev_tag} not present locally; skipping (fetch tags to include it)." + missing_tag=$((missing_tag + 1)) return 0 fi if [ -z "$(git -C "$repo_root" ls-tree "$prev_tag" -- "$queries_path" 2>/dev/null)" ]; then @@ -96,8 +105,21 @@ EOF echo "=== Contraction check: queries@${prev_tag} vs current schema ===" ( cd "$wd" && "$sqlc_bin" compile -f sqlc.yaml ) echo "OK: ${prev_tag} queries still type-check against the current schema." + compiled=$((compiled + 1)) } for t in "${targets[@]}"; do check_target "$t" done + +# Guard against a vacuous green: if nothing compiled because resolved targets had +# no local tag, the checkout almost certainly didn't fetch tags. Fail loudly +# rather than report success on an empty run. An all-predate run (no missing +# tags) is legitimately empty and stays green. +if [ "$compiled" -eq 0 ]; then + if [ "$missing_tag" -gt 0 ]; then + echo "ERROR: no targets compiled — ${missing_tag} resolved version(s) had no local tag; fetch tags (fetch-depth: 0, fetch-tags: true) so the contraction check actually runs." >&2 + exit 1 + fi + echo "NOTE: no targets compiled; all resolved versions predate the sqlc query set." +fi