Files
metabase/.github/actions/create-backport/create-backport-test.sh
Chris Truter e4471f76d3 Split kondo ratchet budgets into separate prod and test files (#83383)
* Split kondo ratchet budgets into separate prod and test files

Prod and test kondo suppressions shared one .clj-kondo/ratchets.edn budget per
linter, so a pile of test-code ignores could mask a prod-code regression (or
vice versa) inside the same number. Adds .clj-kondo/ratchets-test.edn, tracking
:ignore-counts for anything under a `test` directory, independent of the prod
file's budget. :config-counts (.clj-kondo/config.edn suppressions) stays
combined in the prod file -- config.edn's own scoping mixes test and prod
namespaces in several groups, so it can't be reliably split per suppression.

Updates every consumer: dev.kondo-ratchet's fix!/check, bin/merge-kondo-ratchets,
the backport and release-cut disable scripts, the shrink workflow, and
file-paths.yaml/.gitignore.

* Fix test-ratchets validation and exemption-suggestion bugs from roborev

fix!/check read the test ratchets file with *ratchets-file* rebound to
*test-ratchets-file*, but validated it after that binding ended, so an
unknown linter there was reported against the prod path instead. Validation
now runs inside the binding, and both now reject a nonempty :config-counts
on the test file instead of letting fix! silently drop it.

kondo-insert-ignores also only ever suggested adding a new linter's
:comment-exempt to the prod file, even when every unjustified occurrence
was test-only. It now checks prod and test occurrences against each file's
own exemptions independently and names whichever file(s) actually need it.

* Dedupe test-ratchets read+validate, simplify test-occurrence?

fix! and check each rebuilt the same read-validate-test-file sequence
inline; extracted into read-validated-test-ratchets. test-occurrence?
no longer builds a throwaway set just to check one membership.

* Note what splitting :config-counts later would actually cost

* Fix Eastwood reflection warning in new test helper

empty-test-ratchets-file! returned an untyped value, so .getPath on it
needed reflection at each call site. Hinting the return type as
java.io.File fixes it -- CI's changed-tests Eastwood run caught this.

* Move return type hint onto the arg vector

Clj-Kondo prefers the hint on the arg vector to the one on the fn name.

* Move shrink PR body file reading into dev.kondo-ratchet

The bb task only parses its arguments now; reading and combining the
ratchet files lives next to shrink-pr-body, with a test.

* Give the test ratchets file its own header and drop its :config-counts

The test file was rendered with the prod header, whose last lines point
readers at ratchets-test.edn and say :config-counts "stays here" -- both
wrong inside the test file. It also carried an empty :config-counts that
the check rejects whenever it's nonempty.

render-test writes the test file with its own header and no
:config-counts, and throws on a nonempty one rather than dropping it.
fix!, the normalization check, and the three-way merge (a new "test"
kind) all use it.
2026-10-01 19:26:37 +00:00

419 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
action_dir=$(cd "$(dirname "$0")" && pwd)
cut_release_workflow="$action_dir/../../workflows/cut-release-branch.yml"
# shellcheck source=.github/actions/create-backport/kondo-ratchets.sh
source "$action_dir/kondo-ratchets.sh"
# Any editor launch fails the run, so a continuation that waits on an editor cannot pass.
export GIT_EDITOR=false
test_root=
trap '[ -z "$test_root" ] || rm -rf -- "$test_root"' EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
test_root=$(mktemp -d)
active_ratchets='{:ignore-counts {:example 1}}'
disabled_ratchets=';; Kondo ignore ratchets apply only to master; this release branch opts out.
;; The test and fixer recognize this explicit opt-out.
{:disabled true}'
# Coverage:
#
# Target state Incoming change Other conflict Expected result
# ----------------- -------------------- --------------- ------------------------------------------
# active ratchets unrelated file none leave ratchets unchanged
# active ratchets ratchets none install the release opt-out
# disabled ratchets ratchets none keep the opt-out; finish with an empty commit
# divergent ratchets ratchets none resolve ratchets and finish the cherry-pick
# divergent ratchets ratchets (on a pty) none finish without opening an editor
# active ratchets unrelated file app.txt leave the conflict for manual resolution
# divergent ratchets ratchets + app.txt app.txt resolve only ratchets
# active ratchets test ratchets only none disable only the test ratchets file
# release cut ratchets none cut and backport commit the same opt-out
# n/a cut-release workflow n/a the workflow calls the shared writer
# any invalid commit n/a propagate the cherry-pick failure
# divergent ratchets generated backport.sh app.txt resolve only ratchets through the manual script
#
# Known gaps: an older target with no ratchets file, and an incoming commit that
# deletes the ratchets file.
test_unrelated_backport_preserves_target_ratchets() (
local repo="$test_root/unrelated"
init_repo "$repo"
local commit
commit=$(feature_commit "$repo" change_app)
git -C "$repo" checkout -q release
cd "$repo"
cherry_pick_backport "$commit"
assert_eq feature "$(cat app.txt)" "the unrelated change was not cherry-picked"
assert_ratchets "$active_ratchets"
assert_cherry_pick_completed
assert_worktree_clean
)
test_clean_ratchet_backport_installs_release_opt_out() (
local repo="$test_root/clean-ratchet"
init_repo "$repo"
local commit
commit=$(feature_commit "$repo" change_ratchets)
git -C "$repo" checkout -q release
cd "$repo"
cherry_pick_backport "$commit"
assert_ratchets_disabled
assert_cherry_pick_completed
assert_worktree_clean
)
test_disabled_release_absorbs_ratchet_only_backport() (
local repo="$test_root/disabled-release"
init_repo "$repo"
local commit
commit=$(feature_commit "$repo" change_ratchets)
git -C "$repo" checkout -q release
commit_release_change "$repo" disable_release_ratchets "Disable release ratchets"
cd "$repo"
cherry_pick_backport "$commit"
assert_ratchets_disabled
assert_cherry_pick_completed
assert_empty_commit
assert_worktree_clean
)
test_ratchet_only_conflict_is_resolved() (
local repo="$test_root/ratchet-conflict"
init_repo "$repo"
local commit
commit=$(conflicting_ratchet_commit "$repo")
cd "$repo"
cherry_pick_backport "$commit"
assert_ratchets_disabled
assert_cherry_pick_completed
assert_worktree_clean
)
test_continuation_never_opens_an_editor() (
local repo="$test_root/editor"
init_repo "$repo"
local commit
commit=$(conflicting_ratchet_commit "$repo")
cd "$repo"
# Git only opens an editor for the continuation when stdin is a terminal, so run it on a pty. The
# exported GIT_EDITOR=false turns any editor launch into a failure. The single quotes are deliberate:
# $1 and $2 are for the inner bash, not this shell.
# shellcheck disable=SC2016
python3 -c 'import os, pty, sys; sys.exit(os.waitstatus_to_exitcode(pty.spawn(sys.argv[1:])))' \
bash -c 'source "$1/kondo-ratchets.sh" && cherry_pick_backport "$2"' bash "$action_dir" "$commit" >/dev/null ||
fail "the cherry-pick continuation opened an editor"
assert_ratchets_disabled
assert_cherry_pick_completed
)
test_non_ratchet_conflict_remains_manual() (
local repo="$test_root/non-ratchet-conflict"
init_repo "$repo"
local commit
commit=$(feature_commit "$repo" change_app)
git -C "$repo" checkout -q release
commit_release_change "$repo" change_release_app "Change release app"
cd "$repo"
cherry_pick_backport "$commit"
assert_ratchets "$active_ratchets"
assert_only_conflicts app.txt
assert_cherry_pick_pending
)
test_mixed_conflict_resolves_only_ratchets() (
local repo="$test_root/mixed-conflict"
init_repo "$repo"
local commit
commit=$(feature_commit "$repo" change_app_and_ratchets)
git -C "$repo" checkout -q release
commit_release_change "$repo" change_release_app_and_ratchets "Change release app and ratchets"
cd "$repo"
cherry_pick_backport "$commit"
assert_ratchets_disabled
assert_only_conflicts app.txt
assert_cherry_pick_pending
)
test_test_ratchets_only_backport_disables_only_that_file() (
local repo="$test_root/test-ratchet-only"
init_repo "$repo"
local commit
commit=$(feature_commit "$repo" change_test_ratchets)
git -C "$repo" checkout -q release
cd "$repo"
cherry_pick_backport "$commit"
assert_ratchets "$active_ratchets"
assert_test_ratchets_disabled
assert_cherry_pick_completed
assert_worktree_clean
)
test_release_cut_and_backport_commit_the_same_opt_out() (
local cut_repo="$test_root/release-cut"
init_repo "$cut_repo"
cd "$cut_repo"
git checkout -qb release-x.99.x
write_disabled_ratchets .clj-kondo/ratchets.edn
write_disabled_ratchets .clj-kondo/ratchets-test.edn
git add --sparse .clj-kondo/ratchets.edn .clj-kondo/ratchets-test.edn
git commit -q --allow-empty --no-verify -m "Cut release-x.99.x branch"
git show HEAD:.clj-kondo/ratchets.edn > "$test_root/release-cut.edn"
git show HEAD:.clj-kondo/ratchets-test.edn > "$test_root/release-cut-test.edn"
local backport_repo="$test_root/release-cut-backport"
init_repo "$backport_repo"
local commit
commit=$(feature_commit "$backport_repo" change_both_ratchets)
git -C "$backport_repo" checkout -q release
cd "$backport_repo"
cherry_pick_backport "$commit"
git show HEAD:.clj-kondo/ratchets.edn > "$test_root/backport.edn"
git show HEAD:.clj-kondo/ratchets-test.edn > "$test_root/backport-test.edn"
cmp -s "$test_root/release-cut.edn" "$test_root/backport.edn" ||
fail "the release cut and the backport committed different prod opt-out files"
cmp -s "$test_root/release-cut-test.edn" "$test_root/backport-test.edn" ||
fail "the release cut and the backport committed different test opt-out files"
assert_eq "$disabled_ratchets" "$(cat "$test_root/release-cut.edn")" \
"the release cut did not install the expected prod opt-out"
assert_eq "$disabled_ratchets" "$(cat "$test_root/release-cut-test.edn")" \
"the release cut did not install the expected test opt-out"
)
test_cut_release_workflow_uses_the_shared_writer() (
grep -Fq 'source .github/actions/create-backport/kondo-ratchets.sh' "$cut_release_workflow" ||
fail "the cut-release workflow does not source kondo-ratchets.sh"
grep -Fq 'write_disabled_ratchets' "$cut_release_workflow" ||
fail "the cut-release workflow does not call write_disabled_ratchets"
! grep -Fq ':disabled' "$cut_release_workflow" ||
fail "the cut-release workflow carries its own copy of the opt-out"
)
test_invalid_commit_failure_is_propagated() (
local repo="$test_root/invalid"
init_repo "$repo"
cd "$repo"
if cherry_pick_backport deadbeef; then
fail "an invalid commit succeeded"
fi
)
test_generated_script_resolves_ratchets_end_to_end() (
local repo="$test_root/generated-script"
init_repo "$repo"
local commit
commit=$(feature_commit "$repo" change_app_and_ratchets)
git -C "$repo" checkout -q release
commit_release_change "$repo" change_release_app_and_ratchets "Change release app and ratchets"
cd "$repo"
cherry_pick_backport "$commit"
git cherry-pick --abort
local release_commit
release_commit=$(git rev-parse HEAD)
write_backport_script "$commit"
chmod +x backport.sh
git add backport.sh
git commit -qm "Add manual backport script"
bash -n backport.sh
grep -Fq 'write_disabled_ratchets ()' backport.sh || fail "the generated script omitted write_disabled_ratchets"
./backport.sh
assert_ratchets_disabled
assert_only_conflicts app.txt
assert_cherry_pick_pending
assert_eq "$release_commit" "$(git rev-parse HEAD)" "the generated script did not return HEAD to the release commit"
[ ! -e backport.sh ] || fail "the generated script did not remove itself"
! git ls-files --error-unmatch backport.sh >/dev/null 2>&1 || fail "the generated script is still tracked"
! git status --porcelain | grep -q backport.sh || fail "the generated script left a status entry"
)
# Test harness
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_eq() {
local expected=$1
local actual=$2
local message=$3
[ "$expected" = "$actual" ] || fail "$message (expected '$expected', got '$actual')"
}
assert_ratchets() {
assert_eq "$1" "$(cat .clj-kondo/ratchets.edn)" "unexpected ratchet state"
}
assert_ratchets_disabled() {
assert_ratchets "$disabled_ratchets"
}
assert_test_ratchets() {
assert_eq "$1" "$(cat .clj-kondo/ratchets-test.edn)" "unexpected test-ratchet state"
}
assert_test_ratchets_disabled() {
assert_test_ratchets "$disabled_ratchets"
}
assert_cherry_pick_completed() {
! git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null || fail "the cherry-pick is still pending"
}
assert_cherry_pick_pending() {
git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null || fail "the cherry-pick state was lost"
}
assert_only_conflicts() {
local expected
local actual
expected=$(printf '%s\n' "$@" | sort)
actual=$(git diff --name-only --diff-filter=U | sort)
assert_eq "$expected" "$actual" "unexpected unresolved files"
}
assert_empty_commit() {
git diff --quiet HEAD^ HEAD || fail "the backport commit was not empty"
}
assert_worktree_clean() {
[ -z "$(git status --porcelain)" ] || fail "the backport left worktree changes"
}
init_repo() {
local repo=$1
git init -q -b release "$repo"
git -C "$repo" config user.email test@example.com
git -C "$repo" config user.name "Backport test"
printf 'base\n' > "$repo/app.txt"
mkdir -p "$repo/.clj-kondo"
printf '%s\n' "$active_ratchets" > "$repo/.clj-kondo/ratchets.edn"
printf '%s\n' "$active_ratchets" > "$repo/.clj-kondo/ratchets-test.edn"
git -C "$repo" add .
git -C "$repo" commit -qm "Base"
}
feature_commit() {
local repo=$1
shift
git -C "$repo" checkout -qb feature
"$@" "$repo"
git -C "$repo" add .
git -C "$repo" commit -qm "Feature"
git -C "$repo" rev-parse HEAD
}
# Sets up a release branch whose ratchets conflict with the feature commit, and prints that commit.
conflicting_ratchet_commit() {
local repo=$1
local commit
commit=$(feature_commit "$repo" change_ratchets)
git -C "$repo" checkout -q release
commit_release_change "$repo" change_release_ratchets "Change release ratchets"
echo "$commit"
}
change_app() {
printf 'feature\n' > "$1/app.txt"
}
change_ratchets() {
printf '{:ignore-counts {:example 2}}\n' > "$1/.clj-kondo/ratchets.edn"
}
change_test_ratchets() {
printf '{:ignore-counts {:example 2}}\n' > "$1/.clj-kondo/ratchets-test.edn"
}
change_both_ratchets() {
change_ratchets "$1"
change_test_ratchets "$1"
}
change_app_and_ratchets() {
change_app "$1"
change_ratchets "$1"
}
change_release_app() {
printf 'release\n' > "$1/app.txt"
}
change_release_ratchets() {
printf '{:ignore-counts {:release 1}}\n' > "$1/.clj-kondo/ratchets.edn"
}
change_release_app_and_ratchets() {
change_release_app "$1"
change_release_ratchets "$1"
}
# Setup writes the opt-out through the shared writer; the only copies are the expected text at the top.
disable_release_ratchets() {
(cd "$1" && write_disabled_ratchets .clj-kondo/ratchets.edn && write_disabled_ratchets .clj-kondo/ratchets-test.edn)
}
commit_release_change() {
local repo=$1
local change=$2
local message=$3
"$change" "$repo"
git -C "$repo" add .
git -C "$repo" commit -qm "$message"
}
run_test() {
local description=$1
local test=${description// /_}
test=${test//-/_}
test="test_$test"
declare -F "$test" >/dev/null || fail "missing test function: $test"
printf 'TEST: %s\n' "$description"
"$test"
printf 'PASS: %s\n' "$description"
}
run_test "unrelated backport preserves target ratchets"
run_test "clean ratchet backport installs release opt-out"
run_test "disabled release absorbs ratchet-only backport"
run_test "ratchet-only conflict is resolved"
run_test "continuation never opens an editor"
run_test "non-ratchet conflict remains manual"
run_test "mixed conflict resolves only ratchets"
run_test "test ratchets only backport disables only that file"
run_test "release cut and backport commit the same opt-out"
run_test "cut-release workflow uses the shared writer"
run_test "invalid commit failure is propagated"
run_test "generated script resolves ratchets end-to-end"