Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions scripts/check-migration-order.sh
Original file line number Diff line number Diff line change
@@ -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 <migrations-dir>...
#
# Environment:
# MIGRATION_BASE base ref or sha to compare with (default origin/main). On a pull request CI passes the fetched
# origin/<base branch>; 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 <migrations-dir>..."; 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=() # "pr<TAB>path" 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
66 changes: 66 additions & 0 deletions scripts/check-migration-order.test.sh
Original file line number Diff line number Diff line change
@@ -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; }