From 5a6ae70f4041024535994788c769f92d977d4e53 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sun, 25 Oct 2026 02:46:10 +0000 Subject: [PATCH 1/7] ci: add a script that selects each package's integration tests The Integration Tests job runs `go test -tags=integration ./...`, which also re-runs every untagged unit test that the Unit Tests job already runs with -race -short: 271s of test time on main, next to 536s for the tests in integration-tagged files. This script prints, per package, a -run regex naming only the tests that job skips: top-level tests in files that compile only under the integration tag, plus untagged tests that skip themselves under -short (today only TestDiscoverOrgAccounts_CancelledContext in internal/accounts). Packages with neither are not printed, so they are not compiled either. Build constraints are evaluated by `go list` with and without the tag, so `&&`, `||` and `!` expressions match what go test does. The third column counts the tests the regex runs, which can exceed the names when the in-package and external test packages share a test name, as internal/analytics does. A --shard mode partitions the selection over a shard spec with one catch-all shard and fails if a package lands in no shard or in two. Co-Authored-By: Claude Opus 5.5 --- scripts/integration-test-selection.sh | 240 ++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100755 scripts/integration-test-selection.sh diff --git a/scripts/integration-test-selection.sh b/scripts/integration-test-selection.sh new file mode 100755 index 000000000..03fc7701a --- /dev/null +++ b/scripts/integration-test-selection.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash +# integration-test-selection.sh +# +# Prints, for each Go package that has integration tests, the package import +# path, a `go test -run` regex naming exactly those tests, and how many +# top-level tests that regex runs in the package: +# +# ^(TestA|TestB|...)$ +# +# The count can exceed the number of names: the in-package and the external +# (package foo_test) test files may each define a test of the same name, and +# -run, which matches names only, runs both. internal/analytics does today. +# +# Packages with none are not printed, so the caller skips them, compile +# included. CI's Integration Tests job uses this so it runs only the tests the +# Unit Tests job (`go test -race -short`) does not; before, it re-ran every +# unit test a second time under -tags=integration. +# +# A test is selected when either: +# +# 1. It is a top-level Test function in a *_test.go file that only compiles +# with the `integration` tag. Build constraints are evaluated by the Go +# toolchain, not parsed here: the file is in `go list -tags integration`'s +# test files and not in untagged `go list`'s. That handles `&&`, `||`, `!` +# and parentheses exactly as `go test` does. A file constrained to +# `integration && foo` is excluded, as CI never sets foo; a +# `!integration` file is excluded, as CI never compiles it. +# 2. It is in a file that compiles either way, and calls testing.Short(), +# directly or through a function in the package's test files that does, +# or through testutil.SkipIfShort. The Unit Tests job skips those +# under -short, so this is the only job that runs them. +# +# The function scan relies on gofmt layout (the pre-commit go-fmt hook): a +# top-level declaration starts at column 0 and its body ends at a `}` in +# column 0. +# +# Usage: +# integration-test-selection.sh [-C dir] [package pattern ...] +# Packages default to ./... in dir (default: current directory). +# integration-test-selection.sh --shard NAME SELECTION_FILE +# Reads the shard spec from $INTEGRATION_SHARDS, checks that every +# package in SELECTION_FILE (output of the first form) lands in exactly +# one shard, then prints SELECTION_FILE's lines for shard NAME. The +# spec has one shard per line, a name then package import paths +# relative to the module path in $INTEGRATION_MODULE; `*` stands for +# every selected package no other shard names: +# +# migrations internal/database/postgres/migrations +# rest internal/auth * +# +# Sourcing the script defines its functions without running anything (used by +# scripts/test-integration-test-selection.sh). + +set -euo pipefail + +# Escape regexp metacharacters. Go identifiers cannot contain any today; this +# keeps the output a literal match if that ever changes. +regex_escape() { + printf '%s' "$1" | sed 's/[][\\.^$*+?(){}|/]/\\&/g' +} + +# Builds ^(a|b|...)$ from names on stdin, one per line, byte-sorted and +# de-duplicated, so the output does not depend on the locale. +names_to_regex() { + local names + names="$(LC_ALL=C sort -u | while IFS= read -r n; do + [[ -n "$n" ]] && regex_escape "$n" && echo + done | paste -sd'|' -)" + [[ -n "$names" ]] && printf '^(%s)$\n' "$names" +} + +# Prints "\t" for top-level Test functions defined in a +# file listed in $selectable (space-separated): +# mode 1: every such Test function. +# mode 0: only those that reach testing.Short(), see header. +# The files to scan are every test file of the package, so helpers in other +# files are seen, and counts every file defining the name. +scan_tests() { + local mode="$1" selectable="$2" + shift 2 + awk -v mode="$mode" -v selectable="$selectable" ' + BEGIN { n = split(selectable, s, " "); for (i = 1; i <= n; i++) pick[s[i]] = 1 } + function close_fn() { cur = "" } + # A new column-0 declaration or a column-0 closing brace ends the body. + /^(func|type|var|const|import)[ (]/ { close_fn() } + /^func / { + line = $0 + if (line ~ /^func \(/) { # method: track the body, never select + sub(/^func \([^)]*\) */, "", line) + cur = "method:" FNR ":" FILENAME + } else { + sub(/^func /, "", line) + cur = line; sub(/[[(].*/, "", cur) + } + # Test where X is not a lower-case letter, taking *testing.T. + if (cur ~ /^Test/ && cur !~ /^Test[a-z]/ && line ~ /^[^(]*\([A-Za-z0-9_]+ \*testing\.T\)/) { + defs[cur]++ + # Per definition: the same name may also be defined in a file that + # is not selectable (in-package vs external test package). + if (FILENAME in pick) pickable[cur] = 1 + } + } + cur != "" { + code = $0 + sub(/\/\/.*/, "", code) # drop line comments + gsub(/"([^"\\]|\\.)*"/, "\"\"", code) # and string literal contents + body[cur] = body[cur] " " code + } + /^}/ { close_fn() } + END { + for (f in body) { + if (body[f] ~ /(^|[^A-Za-z0-9_])testing\.Short\(\)/ || body[f] ~ /(^|[^A-Za-z0-9_])SkipIfShort\(/) short[f] = 1 + } + # Propagate through plain functions until nothing changes. + do { + changed = 0 + for (f in body) { + if (f in short) continue + for (g in short) { + if (g ~ /^method:/) continue + if (body[f] ~ ("(^|[^A-Za-z0-9_.])" g "\\(")) { short[f] = 1; changed = 1; break } + } + } + } while (changed) + for (f in pickable) { + if (mode == 1 || (f in short)) print f "\t" defs[f] + } + } + ' "$@" +} + +# Prints "\t\t" per package. +# Flags first, then packages: list_test_files [go list flag ...] -- pkg ... +list_test_files() { + local flags=() + while [[ $# -gt 0 && "$1" != "--" ]]; do flags+=("$1"); shift; done + shift + go list "${flags[@]}" -f '{{.ImportPath}}{{"\t"}}{{.Dir}}{{"\t"}}{{join .TestGoFiles " "}} {{join .XTestGoFiles " "}}' -- "$@" +} + +select_tests() { + local dir="." + if [[ "${1:-}" == "-C" ]]; then + dir="$2" + shift 2 + fi + [[ $# -gt 0 ]] || set -- ./... + cd "$dir" + + local tagged untagged + tagged="$(list_test_files -tags integration -- "$@")" + # -e: a package whose every file is integration-only has no untagged build; + # it is simply absent here, and the tagged listing above already failed on + # any real error. + untagged="$(list_test_files -e -- "$@" 2>/dev/null || true)" + + local pkg pdir files + while IFS=$'\t' read -r pkg pdir files; do + [[ -n "$pkg" ]] || continue + local plain=" " f integ="" common="" all=() + plain+="$(awk -F'\t' -v p="$pkg" '$1 == p { print $3 }' <<<"$untagged") " + for f in $files; do + all+=("$pdir/$f") + if [[ "$plain" == *" $f "* ]]; then + common+=" $pdir/$f" + else + integ+=" $pdir/$f" + fi + done + [[ ${#all[@]} -gt 0 ]] || continue + local found regex + found="$({ + [[ -z "$integ" ]] || scan_tests 1 "$integ" "${all[@]}" + [[ -z "$common" ]] || scan_tests 0 "$common" "${all[@]}" + } | LC_ALL=C sort -u)" + [[ -n "$found" ]] || continue + regex="$(cut -f1 <<<"$found" | names_to_regex)" + printf '%s\t%s\t%s\n' "$pkg" "$regex" "$(awk -F'\t' '{ n += $2 } END { print n }' <<<"$found")" + done <<<"$tagged" +} + +# --shard NAME SELECTION_FILE; see the header. +select_shard() { + local want="$1" selection="$2" + local spec="${INTEGRATION_SHARDS:?INTEGRATION_SHARDS must hold the shard spec}" + local module="${INTEGRATION_MODULE:?INTEGRATION_MODULE must hold the module path}" + if [[ ! "$module" =~ ^[^[:space:]]+$ ]]; then + echo "INTEGRATION_MODULE must be one module path, got: $module" >&2 + return 2 + fi + awk -F'\t' -v want="$want" -v module="$module" -v spec="$spec" ' + BEGIN { + nl = split(spec, lines, "\n") + for (i = 1; i <= nl; i++) { + nf = split(lines[i], w, " ") + if (nf == 0) continue + if (w[1] in shard_seen) { err = err "\n shard " w[1] " is defined twice"; continue } + shard_seen[w[1]] = 1 + if (nf == 1) { err = err "\n shard " w[1] " names no packages" } + for (j = 2; j <= nf; j++) { + if (w[j] == "*") { rest_count++; rest = w[1]; continue } + p = module "/" w[j] + if (p in named) { err = err "\n " p " is in shard " named[p] " and shard " w[1]; continue } + named[p] = w[1] + } + } + if (rest_count != 1) err = err "\n the spec needs exactly one * (catch-all) entry, found " rest_count + 0 + if (!(want in shard_seen)) err = err "\n shard " want " is not in the spec" + } + { selected[$1] = $0; order[++n] = $1 } + END { + for (p in named) { + if (!(p in selected)) err = err "\n " p " (shard " named[p] ") has no integration tests; remove it from the spec" + } + for (i = 1; i <= n; i++) { + p = order[i] + s = (p in named) ? named[p] : rest + if (s == "") err = err "\n " p " has integration tests but no shard" + count[s]++ + if (s == want) out[++m] = selected[p] + } + for (s in shard_seen) if (!(s in count)) err = err "\n shard " s " has no packages with integration tests" + if (err != "") { printf "integration shard spec is inconsistent:%s\n", err > "/dev/stderr"; exit 1 } + for (i = 1; i <= m; i++) print out[i] + } + ' "$selection" +} + +main() { + if [[ "${1:-}" == "--shard" ]]; then + [[ $# -eq 3 ]] || { echo "usage: $0 --shard NAME SELECTION_FILE" >&2; return 2; } + select_shard "$2" "$3" + else + select_tests "$@" + fi +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi From 576288dce31da2f7cc687427703c049f5533cf2d Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sun, 25 Oct 2026 02:46:38 +0000 Subject: [PATCH 2/7] test(ci): self-test the integration test selection on fixture packages A test the selector misses is not run by CI at all, since the Unit Tests job skips integration files, so both directions are asserted on fixture packages in a temp dir: tagged and untagged files, in-package and external test packages, `!integration`, `integration && foo`, `integration || foo` and legacy `// +build` constraints, tests gated on testing.Short() directly, through a helper and through testutil.SkipIfShort, packages with nothing to select, and anchoring and escaping of the regex. The regexes are also checked against what `go test -list` actually selects, not only as strings. Co-Authored-By: Claude Opus 5.5 --- scripts/test-integration-test-selection.sh | 363 +++++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100755 scripts/test-integration-test-selection.sh diff --git a/scripts/test-integration-test-selection.sh b/scripts/test-integration-test-selection.sh new file mode 100755 index 000000000..120b878d9 --- /dev/null +++ b/scripts/test-integration-test-selection.sh @@ -0,0 +1,363 @@ +#!/usr/bin/env bash +# test-integration-test-selection.sh +# +# Self-test for integration-test-selection.sh, against fixture packages built +# in a temp dir. Both directions matter: a test the selector misses is never +# run by CI at all (the Unit Tests job skips integration files), and a test it +# over-selects is run twice. Needs Go on PATH; the fixtures are stdlib-only. +# +# Exits 0 when all cases pass; exits 1 on any failure. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SELECT="${SCRIPT_DIR}/integration-test-selection.sh" +# shellcheck source=scripts/integration-test-selection.sh +source "$SELECT" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +export GOWORK=off GOFLAGS=-mod=mod GOTOOLCHAIN=local + +pass=0 +fail=0 +ok() { echo "PASS: $1"; (( pass++ )) || true; } +bad() { echo "FAIL: $1"; (( fail++ )) || true; } + +check_eq() { # label expected actual + if [[ "$2" == "$3" ]]; then ok "$1"; else + bad "$1" + diff <(printf '%s\n' "$2") <(printf '%s\n' "$3") | sed 's/^/ /' || true + fi +} + +M=example.com/fx +FX="$WORK/fx" +w() { mkdir -p "$(dirname "$FX/$1")"; cat >"$FX/$1"; } + +w go.mod <<<"module $M + +go 1.22" + +# --- tagged: integration-only files (in-package and external), next to an +# untagged file whose tests must not be selected. +w tagged/tagged.go <<<"package tagged" +w tagged/integration_test.go <<'EOF' +//go:build integration + +package tagged + +import ( + "os" + "testing" +) + +func TestMain(m *testing.M) { os.Exit(m.Run()) } + +func TestTaggedA(t *testing.T) {} + +func TestTaggedB(t *testing.T) { + t.Run("sub", func(t *testing.T) {}) +} + +func Test_Underscore(t *testing.T) {} + +func TestÜber(t *testing.T) {} + +func TestdataDir() string { return "testdata" } // not a test: lower-case after Test + +func tagsHelper(t *testing.T) {} // not a test: no Test prefix + +func BenchmarkTagged(b *testing.B) {} +EOF +w tagged/external_integration_test.go <<'EOF' +//go:build integration + +package tagged_test + +import "testing" + +func TestExternal(t *testing.T) {} +EOF +w tagged/unit_test.go <<'EOF' +package tagged + +import "testing" + +// Shares the TestTaggedA prefix: the anchored regex must not select it. +func TestTaggedAB(t *testing.T) {} + +func TestUnit(t *testing.T) {} +EOF + +# --- exprs: constraint expressions. Only what compiles under exactly +# -tags=integration, and not without it, is integration-only. +w exprs/exprs.go <<<"package exprs" +w exprs/andfoo_test.go <<'EOF' +//go:build integration && foo + +package exprs + +import "testing" + +func TestAndFoo(t *testing.T) {} +EOF +w exprs/orfoo_test.go <<'EOF' +//go:build integration || foo + +package exprs + +import "testing" + +func TestOrFoo(t *testing.T) {} +EOF +w exprs/andnotfoo_test.go <<'EOF' +//go:build (integration && !foo) || ignore + +package exprs + +import "testing" + +func TestAndNotFoo(t *testing.T) {} +EOF +w exprs/legacy_test.go <<'EOF' +// +build integration + +package exprs + +import "testing" + +func TestLegacyPlusBuild(t *testing.T) {} +EOF + +# --- negated: a !integration file never compiles in the integration job. +# TestSameName is defined in both halves; the integration half is selected. +w negated/negated.go <<<"package negated" +w negated/not_integration_test.go <<'EOF' +//go:build !integration + +package negated + +import "testing" + +func TestNotIntegration(t *testing.T) {} + +func TestSameName(t *testing.T) {} +EOF +w negated/integration_test.go <<'EOF' +//go:build integration + +package negated + +import "testing" + +func TestSameName(t *testing.T) {} +EOF + +# --- short: untagged tests gated on testing.Short(), directly, through a +# local helper (two levels deep), and through testutil.SkipIfShort. +w testutil/testutil.go <<'EOF' +package testutil + +import "testing" + +func SkipIfShort(t *testing.T) { + if testing.Short() { + t.Skip("short") + } +} +EOF +w short/short.go <<<"package short" +w short/short_test.go <<'EOF' +package short + +import ( + "testing" + + "example.com/fx/testutil" +) + +func TestShortDirect(t *testing.T) { + if testing.Short() { + t.Skip("short") + } +} + +func TestShortViaHelper(t *testing.T) { + requireLong(t) +} + +func TestShortViaTestutil(t *testing.T) { + testutil.SkipIfShort(t) +} + +func TestShortInClosure(t *testing.T) { + t.Run("sub", func(t *testing.T) { + if testing.Short() { + t.Skip("short") + } + }) +} + +// TestNotShort mentions testing.Short() only in this comment and a string. +func TestNotShort(t *testing.T) { + _ = "testing.Short()" // testing.Short() +} + +func TestPlain(t *testing.T) {} +EOF +w short/helpers_test.go <<'EOF' +package short + +import "testing" + +func requireLong(t *testing.T) { reallyRequireLong(t) } + +func reallyRequireLong(t *testing.T) { + if testing.Short() { + t.Skip("short") + } +} + +// A method calling testing.Short() does not make its callers Short-gated by +// name: requireLong above is a plain function, this is not. +type fixture struct{} + +func (fixture) requireLongToo(t *testing.T) bool { return testing.Short() } +EOF + +# --- dup: one name in both the in-package and the external test package, +# once with the tagged half in-package and once the other way round. -run +# matches names, so both halves run: the count says 4. +w dup/dup.go <<<"package dup" +w dup/internal_test.go <<'EOF' +package dup + +import "testing" + +func TestDupA(t *testing.T) {} +EOF +w dup/external_integration_test.go <<'EOF' +//go:build integration + +package dup_test + +import "testing" + +func TestDupA(t *testing.T) {} +EOF +w dup/internal_integration_test.go <<'EOF' +//go:build integration + +package dup + +import "testing" + +func TestDupB(t *testing.T) {} +EOF +w dup/external_test.go <<'EOF' +package dup_test + +import "testing" + +func TestDupB(t *testing.T) {} +EOF + +# --- none: unit tests only, and a package without any test file. +w none/none.go <<<"package none" +w none/none_test.go <<'EOF' +package none + +import "testing" + +func TestNone(t *testing.T) {} +EOF +w notests/notests.go <<<"package notests" + +# --------------------------------------------------------------------------- +# Selection. + +expected="$(printf '%s\n' \ + "$M/dup ^(TestDupA|TestDupB)\$ 4" \ + "$M/exprs ^(TestAndNotFoo|TestLegacyPlusBuild|TestOrFoo)\$ 3" \ + "$M/negated ^(TestSameName)\$ 1" \ + "$M/short ^(TestShortDirect|TestShortInClosure|TestShortViaHelper|TestShortViaTestutil)\$ 4" \ + "$M/tagged ^(TestExternal|TestTaggedA|TestTaggedB|Test_Underscore|TestÜber)\$ 5")" +actual="$(LC_ALL=C bash "$SELECT" -C "$FX" ./... 2>&1)" || true +check_eq "selection over the fixture module" "$expected" "$actual" + +if [[ "$actual" != *"$M/none"* && "$actual" != *"$M/notests"* && "$actual" != *"$M/testutil"* ]]; then + ok "packages with no integration tests are skipped" +else + bad "packages with no integration tests are skipped" +fi + +check_eq "explicit package list limits the output" \ + "$M/negated ^(TestSameName)\$ 1" \ + "$(LC_ALL=C bash "$SELECT" -C "$FX" ./negated ./none 2>&1 || true)" + +# What go test actually runs with the regex: the authority on anchoring and +# escaping, not a string comparison. +listed="$(cd "$FX" && go test -tags integration -list \ + "$(awk -F'\t' -v p="$M/tagged" '$1 == p { print $2 }' <<<"$actual")" ./tagged 2>&1 | + grep -v '^ok' | LC_ALL=C sort || true)" +check_eq "go test -run selects exactly the tagged tests (not TestTaggedAB)" \ + "$(printf '%s\n' TestExternal TestTaggedA TestTaggedB Test_Underscore TestÜber)" "$listed" + +listed="$(cd "$FX" && go test -list \ + "$(awk -F'\t' -v p="$M/short" '$1 == p { print $2 }' <<<"$actual")" ./short 2>&1 | + grep -v '^ok' | LC_ALL=C sort || true)" +check_eq "go test -run selects exactly the Short-gated tests" \ + "$(printf '%s\n' TestShortDirect TestShortInClosure TestShortViaHelper TestShortViaTestutil)" "$listed" + +listed="$(cd "$FX" && go test -tags integration -list \ + "$(awk -F'\t' -v p="$M/dup" '$1 == p { print $2 }' <<<"$actual")" ./dup 2>&1 | + grep -v '^ok' | LC_ALL=C sort || true)" +check_eq "the count matches what go test runs for a name in both test packages" \ + "$(printf '%s\n' TestDupA TestDupA TestDupB TestDupB)" "$listed" + +rc=0 +bash "$SELECT" -C "$FX" ./doesnotexist >/dev/null 2>&1 || rc=$? +if [[ "$rc" -ne 0 ]]; then ok "a package go list cannot load fails the script"; else bad "a package go list cannot load fails the script"; fi + +# --------------------------------------------------------------------------- +# Regex escaping. Go identifiers cannot hold metacharacters today, so this is +# tested on the function directly, then checked against Go's own regexp. + +# shellcheck disable=SC2016 # literal $ and \ on purpose +meta='a.b*c+d?e(f)g[h]i{j}k|l^m$n\o/p' +escaped="$(regex_escape "$meta")" +# shellcheck disable=SC2016 # literal $ and \ on purpose +check_eq "regex_escape escapes every metacharacter" \ + 'a\.b\*c\+d\?e\(f\)g\[h\]i\{j\}k\|l\^m\$n\\o\/p' "$escaped" +check_eq "names_to_regex anchors, sorts and de-duplicates" \ + '^(Test\.C|TestA|TestB)$' "$(printf '%s\n' TestB TestA Test.C TestA | LC_ALL=C names_to_regex)" + +cat >"$WORK/re_test.go" <<'EOF' +package re + +import ( + "os" + "regexp" + "testing" +) + +func TestEscapedIsLiteral(t *testing.T) { + re := regexp.MustCompile("^(" + os.Getenv("ESCAPED") + ")$") + if !re.MatchString(os.Getenv("META")) { + t.Fatalf("%s does not match %q", re, os.Getenv("META")) + } + if re.MatchString("aXb*c+d?e(f)g[h]i{j}k|l^m$n\\o/p") { + t.Fatal(". was not escaped") + } +} +EOF +printf 'module re\n\ngo 1.22\n' >"$WORK/go.mod" +if (cd "$WORK" && META="$meta" ESCAPED="$escaped" go test ./re_test.go >/dev/null 2>&1); then + ok "escaped text is a literal match in Go's regexp" +else + bad "escaped text is a literal match in Go's regexp" +fi + +echo "$pass passed, $fail failed" +[[ "$fail" -eq 0 ]] From f129cba4c335d657b1716310f434c0c0e14aa539 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sun, 25 Oct 2026 02:46:58 +0000 Subject: [PATCH 3/7] ci: run the integration test selection self-test in CI Adds the shard cases (a package in two shards, none, a stale package name, no or two catch-alls, an empty or unknown shard, a multi-line module path) and runs the suite as its own job next to the other script self-tests, with Go set up because the fixtures go through `go list`. CI Success needs it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 27 +++++++++++++ scripts/test-integration-test-selection.sh | 46 ++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2b682363..695da7b09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -499,6 +499,32 @@ jobs: path: ${{ runner.temp }}/coverage-integration.out retention-days: 30 + # Self-test for scripts/integration-test-selection.sh, which decides what the + # Integration Tests job runs. A selector that drops a test drops it from CI + # entirely, as the Unit Tests job skips integration files, so the suite + # asserts both directions over fixture packages. Needs Go (go list); the + # fixtures are stdlib-only, so no module cache. + integration-test-selection: + name: Integration test selection self-test + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: ${{ env.GO_VERSION }} + cache: false + + - name: Run selection self-tests + run: bash scripts/test-integration-test-selection.sh + # Docker image build test, once per architecture the image ships for. # # The AWS deploys (deploy-aws-lambda.yml, deploy-aws-fargate.yml) build @@ -1417,6 +1443,7 @@ jobs: - workflow-lint - unit-tests - integration-tests + - integration-test-selection - docker-build - terraform-validate - security-scan diff --git a/scripts/test-integration-test-selection.sh b/scripts/test-integration-test-selection.sh index 120b878d9..834db4939 100755 --- a/scripts/test-integration-test-selection.sh +++ b/scripts/test-integration-test-selection.sh @@ -359,5 +359,51 @@ else bad "escaped text is a literal match in Go's regexp" fi +# --------------------------------------------------------------------------- +# Shards: every selected package in exactly one shard. + +sel="$WORK/selection.tsv" +printf '%s\t^(A)$\t1\n' "$M/a" "$M/b" "$M/c" "$M/d" >"$sel" +export INTEGRATION_MODULE="$M" + +shard_case() { # label expected_exit spec shard [expected stdout | stderr text] + local label="$1" want_rc="$2" spec="$3" shard="$4" want="${5:-}" out rc=0 + out="$(INTEGRATION_SHARDS="$spec" bash "$SELECT" --shard "$shard" "$sel" 2>&1)" || rc=$? + if [[ "$rc" -ne "$want_rc" ]]; then + bad "$label (expected exit $want_rc, got $rc: $out)" + elif [[ "$want_rc" -eq 0 && "$out" != "$want" ]]; then + bad "$label (got: $out)" + elif [[ "$want_rc" -ne 0 && "$out" != *"$want"* ]]; then + bad "$label (output missing '$want': $out)" + else + ok "$label" + fi +} + +good="first a +second b *" +shard_case "named shard gets its packages" 0 "$good" first "$M/a ^(A)\$ 1" +shard_case "catch-all shard gets its own plus every unnamed package" 0 "$good" second \ + "$(printf '%s\t^(A)$\t1\n' "$M/b" "$M/c" "$M/d")" +shard_case "a package in two shards fails" 1 "first a +second a *" first "$M/a is in shard first and shard second" +shard_case "a package in no shard fails (no catch-all)" 1 "first a +second b" first "exactly one *" +shard_case "two catch-alls fail" 1 "first a * +second b *" first "exactly one *" +shard_case "a named package without integration tests fails" 1 "first a gone +second b *" first "$M/gone (shard first) has no integration tests" +shard_case "a shard left with no packages fails" 1 "first a +second b +third * +fourth" first "shard fourth names no packages" +shard_case "an unknown shard name fails" 1 "$good" third "shard third is not in the spec" +rc=0 +INTEGRATION_SHARDS="$good" INTEGRATION_MODULE="$M +$M/other" bash "$SELECT" --shard first "$sel" >/dev/null 2>&1 || rc=$? +if [[ "$rc" -eq 2 ]]; then ok "a multi-line module path is rejected"; else bad "a multi-line module path is rejected (exit $rc)"; fi +shard_case "a duplicated shard name fails" 1 "first a +first b *" first "shard first is defined twice" + echo "$pass passed, $fail failed" [[ "$fail" -eq 0 ]] From 54c1d90a9c51c0190170e19ef4d789ebec649019 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sun, 25 Oct 2026 03:15:10 +0000 Subject: [PATCH 4/7] ci: run only integration tests, sharded across three runners The Integration Tests job took 531s on main: about 45s of setup, 73s of compiling every package with -race and coverage, and a 480s test step holding 856s of package time on one 4-vCPU runner. 271s of that was unit tests the Unit Tests job already runs. Each leg now runs only what scripts/integration-test-selection.sh selects, so packages without integration tests are not compiled, and the legs split the rest by measured package time: migrations alone (190s on main), api + purchase + config, and auth + server + a catch-all for every other package with integration tests. A guard step fails if a package lands in no shard or in two, or if the matrix and the shard list disagree, and the test step fails if fewer top-level tests ran than were selected. Every leg keeps its own Postgres service, the migrations step, the env vars, -race, -tags=integration and -timeout=20m; fail-fast is off. The tests/e2e type-check runs on one leg. Each leg uploads its coverage profile under its own name, and a merge job rebuilds the integration-coverage artifact. The old job's profile included the unit tests it re-ran, so the merge adds the unit job's profile back: the result is 81.6% total, the same as main's. Codecov and the 80% warning read the unit profile, which is unchanged. CI Success needs the matrix job and the merge job; a failed or cancelled leg fails the matrix result, as with terraform-validate. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/README.md | 2 +- .github/workflows/ci.yml | 199 +++++++++++++++++++++++++++++------- 2 files changed, 165 insertions(+), 36 deletions(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 892ceeebe..2f1cbaf8d 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -31,7 +31,7 @@ Runs comprehensive quality checks on every pull request and push to main branch. 1. **Lint** - golangci-lint, go vet 2. **Unit Tests** - Go tests with race detection, coverage reporting -3. **Integration Tests** - Tests with real PostgreSQL +3. **Integration Tests** - Tests with real PostgreSQL: `integration`-tagged tests and tests that skip under `-short`, sharded across parallel runners (selected by `scripts/integration-test-selection.sh`) 4. **Docker Build** - Build, check and scan the Docker image natively for linux/amd64 and linux/arm64 5. **Terraform Validate** - Validate all Terraform configs (AWS, GCP, Azure) 6. **Security Scan** - gosec, trivy, tfsec diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 695da7b09..bf35e30f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -375,12 +375,47 @@ jobs: echo "::warning::Coverage is below 80% (current: ${coverage}%)" fi - # Integration tests with real PostgreSQL + # Integration tests with real PostgreSQL, sharded across parallel runners. + # + # Only the tests the Unit Tests job does not run are run here: those in + # `//go:build integration` files, plus untagged tests that skip themselves + # under -short. scripts/integration-test-selection.sh finds them and prints a + # `-run` regex per package; packages with none are not even compiled. Before + # that, this job re-ran every unit test under -tags=integration as well. + # + # Each leg runs the packages INTEGRATION_SHARDS gives its shard; `*` marks + # the catch-all shard, which takes every package with integration tests that + # no other shard names, so a new package is never silently skipped. Balance + # the shards by the per-package "ok ...