diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 580f6e93c..ce858638d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,7 +68,7 @@ jobs: web: apps/* packages/* proto/* brand/* package.json bun.lock bunfig.toml turbo.json biome.json buf.yaml buf.gen.yaml scripts/* rust: crates/* proto/* Cargo.toml Cargo.lock build: apps/* packages/* proto/* crates/* package.json bun.lock Cargo.toml Cargo.lock - migrations: apps/puzzled/atlas/* apps/puzzled/atlas.hcl crates/puzzled-server/src/*_tests.rs crates/puzzled-server/src/capabilities/preferences/* + migrations: apps/puzzled/atlas/* apps/puzzled/atlas.hcl crates/puzzled-server/src/*_tests.rs crates/puzzled-server/src/capabilities/preferences/* scripts/check-migration-order* # ========================================== # Repository Standards @@ -384,6 +384,29 @@ jobs: PUZZLED_REQUIRE_DB_TESTS: "1" steps: - uses: actions/checkout@v4.2.2 + with: + fetch-depth: 0 + + - name: Migration versions are free and in order + if: github.event_name == 'pull_request' || github.event_name == 'merge_group' + env: + EVENT: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + PR_BASE: ${{ github.event.pull_request.base.sha }} + QUEUE_BASE: ${{ github.event.merge_group.base_sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + env -u PR_NUMBER -u GH_REPO -u GH_TOKEN bash scripts/check-migration-order.test.sh + if [ "$EVENT" = pull_request ]; then + export MIGRATION_BASE="$PR_BASE" + else + export MIGRATION_BASE="$QUEUE_BASE" + unset PR_NUMBER + fi + scripts/check-migration-order.sh apps/puzzled/atlas/migrations - name: No job selects a billed runner run: bash .github/scripts/check-ci-runner-profile.sh diff --git a/scripts/check-migration-order.sh b/scripts/check-migration-order.sh new file mode 100755 index 000000000..4c86309ef --- /dev/null +++ b/scripts/check-migration-order.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Migration version guard: a migration this change ADDS must sort above every version already on the base branch +# in the same directory (Atlas applies linearly and refuses an older file at deploy), must not share a version +# with another file in the directory, and must not take a version another open pull request already adds. +# Every error names the next free version, so the fix is one rename. +# +# scripts/check-migration-order.sh ... +# +# Environment: +# MIGRATION_BASE base ref or sha to compare with (default origin/main). On a pull request CI passes the fetched +# origin/; in a merge group, the queue's base sha. +# PR_NUMBER, GH_REPO, GH_TOKEN when all are set (and gh is installed) a version added by another open, non-draft +# pull request into the same base is a collision. An unreachable API warns and skips only this check. +# Exit: 0 clean, 1 a migration breaks a rule, 2 usage / unknown base. +set -uo pipefail + +[ $# -ge 1 ] || { echo "usage: check-migration-order.sh ..."; exit 2; } +base=${MIGRATION_BASE:-origin/main} +git rev-parse --verify -q "${base}^{commit}" >/dev/null || { echo "check-migration-order: no ref $base"; exit 2; } +mb=$(git merge-base "$base" HEAD) || { echo "check-migration-order: no merge base with $base (shallow clone?)"; exit 2; } + +ver() { basename "$1" | grep -oE '^[0-9]+' || true; } +vers_at() { git ls-tree --name-only "$1" -- "$2/" 2>/dev/null | while read -r f; do case "$f" in *.sql) ver "$f" ;; esac; done; } +max_of() { local m=0 v; for v in "$@"; do [ -n "$v" ] && [ $((10#$v)) -gt $((10#$m)) ] && m=$v; done; echo "$m"; } +# next free version above $1; a timestamp store takes the clock when it is ahead, a counter store takes +1 (width kept) +next_free() { + local top=$1 w=${#1} now + if [ "$w" -ge 12 ]; then now=$(date -u +%Y%m%d%H%M%S); [ $((10#$now)) -gt $((10#$top)) ] && { echo "$now"; return; }; fi + printf "%0${w}d\n" $((10#$top + 1)) +} + +others=() # "prpath" lines of files other open pull requests add +if [ -n "${PR_NUMBER:-}" ] && [ -n "${GH_REPO:-}" ] && command -v gh >/dev/null 2>&1; then + if prs=$(gh api "repos/$GH_REPO/pulls?state=open&per_page=100" --jq ".[] | select(.draft|not) | select(.number != $PR_NUMBER) | select(.base.ref == \"${BASE_REF:-main}\") | .number" 2>/dev/null); then + for n in $prs; do + if files=$(gh api "repos/$GH_REPO/pulls/$n/files?per_page=100" --jq '.[] | select(.status == "added") | .filename' 2>/dev/null); then + while IFS= read -r f; do [ -n "$f" ] && others+=("$n $f"); done <<<"$files" + else echo "warning: could not list files of PR #$n; its migrations are not compared"; fi + done + else echo "warning: GitHub API unreachable; the open pull request collision check is skipped"; fi +fi + +rc=0 +for dir in "$@"; do + dir=${dir%/} + added=$(git diff --name-only --diff-filter=A "$mb" HEAD -- "$dir/" | grep -E '\.sql$' | grep -E "^$dir/[^/]+$" || true) + [ -n "$added" ] || { echo "$dir: no added migrations"; continue; } + base_vers=$(vers_at "$base" "$dir"); newest=$(max_of $base_vers) + claimed=$(printf '%s\n' "${others[@]:-}" | awk -F'\t' -v d="$dir/" 'index($2, d) == 1 {n=$2; sub(".*/","",n); if (match(n,/^[0-9]+/)) print substr(n,RSTART,RLENGTH)}') + top=$(max_of $newest $claimed $(for f in $added; do ver "$f"; done)) + while IFS= read -r f; do + [ -n "$f" ] || continue + v=$(ver "$f"); slug=$(basename "$f" | sed -E 's/^[0-9]+_?//') + if [ -z "$v" ]; then echo "BAD NAME: $f has no leading version digits"; rc=1; continue; fi + nf=$(next_free "$top") + if [ $((10#$v)) -le $((10#$newest)) ]; then + echo "OUT OF ORDER: $f (version $v) is not above $base's newest $newest in $dir: rename to $dir/${nf}_${slug}"; rc=1 + fi + twins=$(git ls-tree --name-only HEAD -- "$dir/" | while read -r t; do [ "$t" != "$f" ] && [ "$(ver "$t")" = "$v" ] && echo "$t"; done) + if [ -n "$twins" ]; then echo "DUPLICATE VERSION: $f shares version $v with $(echo $twins): rename to $dir/${nf}_${slug}"; rc=1; fi + hit=$(printf '%s\n' "${others[@]:-}" | awk -F'\t' -v d="$dir/" -v v="$v" 'index($2, d) == 1 {n=$2; sub(".*/","",n); if (match(n,/^[0-9]+/) && substr(n,RSTART,RLENGTH)+0 == v+0) print "#" $1}' | sort -u | tr '\n' ' ') + if [ -n "$hit" ]; then echo "COLLISION: $f (version $v) is also added by open pull request $hit: the second to merge fails at deploy; rename to $dir/${nf}_${slug}"; rc=1; fi + done <<<"$added" +done +[ $rc -eq 0 ] && echo "migration order ok vs $base" +exit $rc diff --git a/scripts/check-migration-order.test.sh b/scripts/check-migration-order.test.sh new file mode 100755 index 000000000..b8013f23c --- /dev/null +++ b/scripts/check-migration-order.test.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Tests for check-migration-order.sh, run in throwaway git repositories: bash scripts/check-migration-order.test.sh +set -uo pipefail +S=$(cd "$(dirname "$0")" && pwd)/check-migration-order.sh +T=$(mktemp -d); trap 'rm -rf "$T"' EXIT +fails=0 +ok() { echo "ok $1"; } +bad() { echo "FAIL $1"; fails=$((fails + 1)); } +expect() { # name want-rc grep-pattern(optional) -- command output captured in $out + local name=$1 want=$2 pat=${3:-} + [ "$rc" = "$want" ] || { bad "$name (rc $rc, want $want): $out"; return; } + if [ -n "$pat" ] && ! grep -q -- "$pat" <<<"$out"; then bad "$name (missing '$pat'): $out"; return; fi + ok "$name" +} +g() { git -C "$R" -c user.email=t@t -c user.name=t "$@"; } +mk() { R=$T/$1; mkdir -p "$R/m"; git -C "$R" init -q -b main; touch "$R/m/20260101000000_a.sql" "$R/m/20260102000000_b.sql"; g add -A; g commit -qm base; g branch -q origin/main 2>/dev/null; g checkout -q -b topic; } +run() { out=$(cd "$R" && MIGRATION_BASE=main "$@" 2>&1); rc=$?; } + +# Guard the workflow binding as well as the script: no mutable remote ref or authenticated fetch. +workflow="$(dirname "$S")/../.github/workflows/ci.yml" +grep -Fq 'PR_BASE: ${{ github.event.pull_request.base.sha }}' "$workflow" && ok "workflow binds event base" || bad "workflow binds event base" +grep -Fq 'export MIGRATION_BASE="$PR_BASE"' "$workflow" && ok "workflow uses immutable base" || bad "workflow uses immutable base" + +mk newer; touch "$R/m/20260103000000_c.sql"; g add -A; g commit -qm c +run "$S" m; expect "newer version passes" 0 "migration order ok" + +mk older; touch "$R/m/20260101120000_c.sql"; g add -A; g commit -qm c +run "$S" m; expect "version below main's newest fails" 1 "OUT OF ORDER" +grep -q "rename to m/" <<<"$out" && ok "names the next free version" || bad "names the next free version: $out" + +mk dup; touch "$R/m/20260105000000_c.sql" "$R/m/20260105000000_d.sql"; g add -A; g commit -qm c +run "$S" m; expect "two files with one version fail" 1 "DUPLICATE VERSION" + +mk none; touch "$R/other.txt"; g add -A; g commit -qm c +run "$S" m; expect "no added migrations passes" 0 "no added migrations" + +# main moves on after the branch was cut: the branch's version is now below main's newest +mk moved; touch "$R/m/20260103000000_c.sql"; g add -A; g commit -qm c +g checkout -q main; touch "$R/m/20260104000000_x.sql"; g add -A; g commit -qm x; g checkout -q topic +run "$S" m; expect "main moving past the branch fails" 1 "OUT OF ORDER" + +# CI uses immutable event SHAs, even without origin/main or persisted credentials. +mk immutable; base_sha=$(g rev-parse main); g branch -D origin/main >/dev/null + touch "$R/m/20260103000000_c.sql"; g add -A; g commit -qm c +run env MIGRATION_BASE="$base_sha" "$S" m; expect "immutable event base without remote ref passes" 0 "migration order ok" +run env MIGRATION_BASE=missing-event-base "$S" m; expect "missing event base fails closed" 2 "no ref" + +# open pull request collision, with a stub gh +mk coll; touch "$R/m/20260106000000_c.sql"; g add -A; g commit -qm c +mkdir -p "$T/bin"; cat > "$T/bin/gh" <<'G' +#!/usr/bin/env bash +case "$2" in + repos/o/r/pulls\?*) echo 7 ;; + repos/o/r/pulls/7/files*) echo "m/20260106000000_other.sql" ;; + *) exit 1 ;; +esac +G +chmod +x "$T/bin/gh" +run env PATH="$T/bin:$PATH" PR_NUMBER=5 GH_REPO=o/r BASE_REF=main "$S" m; expect "version another open PR adds fails" 1 "COLLISION" +cat > "$T/bin/gh" <<'G' +#!/usr/bin/env bash +exit 1 +G +run env PATH="$T/bin:$PATH" PR_NUMBER=5 GH_REPO=o/r BASE_REF=main "$S" m; expect "unreachable API warns and passes" 0 "warning" + +[ $fails -eq 0 ] && echo "all passed" || { echo "$fails failed"; exit 1; }