Files
gastownhall__beads/scripts/ci_capability_selector_test.go
Jim Wordelman 30f30ca95b ci: PR-CI lane runs the full internal/storage/dolt suite against a live Dolt server (#5836)
* test(ci): red — gastownhall/beads: no PR-CI lane runs TestBenchDBPurgeDoesNotLeak against a live Dolt server (checkDolt SKIP) (refs be-aiy5)

* feat(ci): green — gastownhall/beads: no PR-CI lane runs TestBenchDBPurgeDoesNotLeak against a live Dolt server (checkDolt SKIP) (refs be-aiy5)

Add test-server-storage-full as a sibling job to test-server-storage in
pr-risk.yml. build-embedded already compiles every top-level test in
internal/storage/dolt into one binary (embedded-test-binaries); the
existing job only ever runs -test.run '^TestConformance$' against it.
The new job reuses that same artifact and live-server setup with no new
build step, and runs everything else via -test.skip '^TestConformance$'
so previously-stranded tests (TestCreateGuard_*, federation/concurrent
suites, and diff-owned tests such as TestBenchDBPurgeDoesNotLeak on PR
#5792) get a real PASS/FAIL instead of a silent SKIP. Sets
BEADS_TEST_ENV_RUN_DOLT=1 so federation_test.go's own live-server check
fails loudly rather than skipping if this job's setup ever regresses.
Wired into ci-gate: added to needs, CI_GATE_REQUIRED, and the
tier-gated skipped_ok fallback (mirrors test-server-storage's existing
treatment in each).

Also updates two pre-existing meta-tests over pr-risk.yml that pinned
exact snapshots of the file this job necessarily changes:
scripts/ci_capability_selector_test.go's ci-gate authority snapshot,
and scripts/pull_dolt_image_test.go's retry-helper call count
(pr-risk.yml: 2 -> 3, the new job's own Dolt-image-pull step).

* chore: release gate PASS for be-pp7e (PR-CI lane for TestBenchDBPurgeDoesNotLeak)

Deploy gate record for be-pp7e, gating be-aiy5's PR-CI lane addition
(runs TestBenchDBPurgeDoesNotLeak against a live Dolt server instead of
letting checkDolt SKIP it). Review: be-w90n, verdict pass. All 7 gate
criteria PASS; two pre-existing/unrelated failures encountered during
evaluation (be-jy56, be-vf95) were attributed and excluded from the
verdict.

* test(ci): red — shard test-server-storage-full across 16 matrix jobs (refs be-yy25)

TestPRRiskGateReachesFullServerDoltStorageSuite now asserts
pr-risk.yml's test-server-storage-full job is a fail-fast:false matrix
over shard 1..16, with the Test step invoking
server-storage-test-shard.sh, instead of pinning the old single-job
unsharded command. Fails against the current workflow, which still
runs all 1126 tests unsharded in one 15m job (be-extn).

* feat(ci): green — shard test-server-storage-full across 16 matrix jobs (refs be-yy25)

Adds .github/scripts/server-storage-test-shard.sh (mirrors
embedded-storage-test-shard.sh: discovers internal/storage/dolt/*_test.go,
excludes TestConformance, manifest-plus-hash-fallback shard assignment) and
its shard manifest .github/scripts/server-storage-test-shards.txt, pinning
TestCloudAuthCLIRouting (~9.5m) alone on shard 1 and spreading the other
known 10-20s-heavy tests across the rest.

pr-risk.yml's test-server-storage-full job becomes a fail-fast:false matrix
over shard 1..16, Test step now runs server-storage-test-shard.sh instead of
the old unsharded single-job command that died at 256/1126 tests after
15m28s with zero failures (job 95616282508).

Documents the new lane in engdocs/CI_REQUIRED_CHECK_TOPOLOGY.md alongside the
existing embedded matrix description.

TestPRRiskGateReachesFullServerDoltStorageSuite now passes. Offline shard
partition verified disjoint and complete across all 1125 discovered tests
(BEADS_TEST_SHARD_LIST_ONLY=1 for i=1..16). (be-extn)

* test(feat): red — Fix: sharded lane runs prebuilt test binary from repo root, breaking ../schema/migrations and ../issueops (6 tests) (refs be-gepv)

* feat: green — Fix: sharded lane runs prebuilt test binary from repo root, breaking ../schema/migrations and ../issueops (6 tests) (refs be-gepv)

* chore: release gate PASS for be-23f3

Extends deploy/be-pp7e-gate (PR gastownhall/beads#5836) with be-gepv's
fix rather than cutting a fresh isolated branch: be-gepv's own bead
text and be-extn's precedent both establish this fix belongs on the
already-open PR. All 7 gate criteria PASS; two repo-wide lint/policy
failures attributed to already-tracked pre-existing issues (be-vf95,
be-jy56), confirmed untouched by this PR lineage.

be-gepv, be-6yo1, be-23f3

* fix(dolt): federation test isolation + panic (be-3c78s)

TestFederationDatabaseIsolation and TestFederationVersionControlAPIs are
federation-prototype tests that predate the suite's shared-server,
branch-per-test isolation architecture (setupTestStore/StartTestBranch)
and were never updated to account for it.

- setupFederationStore hardcoded Database: "beads" for both towns. New()
  always connects in server mode against one shared Dolt server for the
  whole test binary (TestMain), so Path alone doesn't isolate two stores
  — Database does. Both towns silently shared one database, so Alpha
  could read Beta's issues and vice versa. Give each town its own
  database (beads_test_federation_<prefix>, matching the beads_test AD-01
  cleanup-firewall prefix).

- TestFederationDatabaseIsolation's not-found assertions assumed GetIssue
  returns (nil, nil) on a miss. It never does — issueops.GetIssueInTx
  always returns a wrapped storage.ErrNotFound. Switched to
  errors.Is(err, storage.ErrNotFound).

- TestFederationVersionControlAPIs hardcoded Checkout(ctx, "main") to
  return to its "starting" branch, but setupTestStore already checks out
  a private branch (test-<name>-<hex>) — "main" is a branch shared by
  every parallel test in the binary and never has vc-001. That mismatch
  is what produced the nil-pointer panic (GetIssue's error was discarded
  via `_`, and the resulting nil issue was dereferenced). Capture the
  real starting branch via CurrentBranch() right after setupTestStore()
  and check both previously-discarded GetIssue errors before
  dereferencing.

Neither bug is a production security issue: New(), Checkout(), and
GetIssue() all behave exactly as specified. Both bugs are confined to
this prototype test file's own setup/assertions, which hadn't caught up
to the isolation model the rest of the suite already uses correctly.

Verified via `go build`/`go vet`/`go test -c` (all clean) and running the
compiled binary, which now reaches and cleanly SKIPs both tests (no
Docker locally) instead of the previous isolation failure / panic
surfacing at compile-adjacent runtime. Full GREEN confirmation is via
CI, which has a working Dolt/Docker environment.

Refs be-3c78s

* chore: release gate PASS for be-42tlj
2026-08-19 20:39:38 -07:00

276 lines
12 KiB
Go

package scripts_test
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
var selectorKeys = []string{
"embedded_cli", "proxied_cli", "embedded_storage", "embedded_conformance", "server_conformance", "full", "reason",
}
func TestCICapabilitySelectorClassifiesOwnedPaths(t *testing.T) {
for _, tt := range []struct {
name string
records []string
want map[string]string
}{
{"docs", []string{"A", "docs/guide.md"}, decisions(false, false, false, false, false, "docs_only")},
{"command", []string{"M", "cmd/bd/create.go"}, decisions(true, true, false, false, false, "cmd_bd")},
{"embedded", []string{"M", "internal/storage/embeddeddolt/store.go"}, decisions(true, false, true, true, false, "embedded_storage")},
{"server", []string{"M", "internal/storage/dolt/store.go"}, decisions(false, true, false, false, true, "server_storage")},
{"shared conformance", []string{"M", "backend/conformance/portable.go"}, decisions(false, false, false, true, true, "shared_conformance")},
{"mixed union", []string{"M", "cmd/bd/create.go", "M", "internal/storage/embeddeddolt/store.go"}, decisions(true, true, true, true, false, "mixed")},
{"all leaves normalize full", []string{"M", "cmd/bd/create.go", "M", "internal/storage/embeddeddolt/store.go", "M", "internal/storage/dolt/store.go"}, decisions(true, true, true, true, true, "full")},
} {
t.Run(tt.name, func(t *testing.T) {
got, _ := runCICapabilitySelector(t, tt.records, "pull_request", true, false)
assertSelectorOutputs(t, got, tt.want)
})
}
}
func TestCICapabilitySelectorFailsClosed(t *testing.T) {
for _, tt := range []struct {
name string
event string
records []string
bounds bool
gitFail bool
reason string
}{
{"push", "push", nil, true, false, "push"},
{"merge group", "merge_group", nil, true, false, "merge_group"},
{"unsupported event", "schedule", nil, true, false, "unsupported_event"},
{"missing bounds", "pull_request", nil, false, false, "missing_bounds"},
{"diff failure", "pull_request", nil, true, true, "diff_failed"},
{"delete", "pull_request", []string{"D", "docs/guide.md"}, true, false, "unsafe_status"},
{"rename", "pull_request", []string{"R100", "docs/old.md", "docs/new.md"}, true, false, "unsafe_status"},
{"copy", "pull_request", []string{"C100", "docs/old.md", "docs/new.md"}, true, false, "unsafe_status"},
{"type change", "pull_request", []string{"T", "docs/guide.md"}, true, false, "unsafe_status"},
{"unmerged", "pull_request", []string{"U", "docs/guide.md"}, true, false, "unsafe_status"},
{"unknown status", "pull_request", []string{"X", "docs/guide.md"}, true, false, "unsafe_status"},
{"broken pair", "pull_request", []string{"B", "docs/guide.md"}, true, false, "unsafe_status"},
{"malformed", "pull_request", []string{"A"}, true, false, "malformed_record"},
{"absolute", "pull_request", []string{"A", "/tmp/unsafe"}, true, false, "unsafe_path"},
{"traversal", "pull_request", []string{"A", "docs/../unsafe.md"}, true, false, "unsafe_path"},
{"workflow", "pull_request", []string{"M", ".github/workflows/pr-risk.yml"}, true, false, "unsafe_control"},
{"selector", "pull_request", []string{"M", ".github/scripts/ci-capability-selector.sh"}, true, false, "unsafe_control"},
{"build input", "pull_request", []string{"M", "go.mod"}, true, false, "build_input"},
{"cross cutting storage", "pull_request", []string{"M", "internal/storage/schema/migrations/1.sql"}, true, false, "cross_cutting_storage"},
{"unowned", "pull_request", []string{"M", "internal/other/change.go"}, true, false, "unowned_path"},
} {
t.Run(tt.name, func(t *testing.T) {
got, _ := runCICapabilitySelector(t, tt.records, tt.event, tt.bounds, tt.gitFail)
assertSelectorOutputs(t, got, decisions(true, true, true, true, true, tt.reason))
})
}
}
func TestCICapabilitySelectorNeverInjectsFilenamesIntoOutputs(t *testing.T) {
got, raw := runCICapabilitySelector(t, []string{"A", "docs/new\nname\t%=.md"}, "pull_request", true, false)
assertSelectorOutputs(t, got, decisions(false, false, false, false, false, "docs_only"))
if strings.Contains(raw, "new\nname") || strings.Contains(raw, "\t%=") {
t.Fatalf("filename leaked into selector output: %q", raw)
}
got, raw = runCICapabilitySelector(t, []string{"A", "unowned/new\nname\t%=.txt"}, "pull_request", true, false)
assertSelectorOutputs(t, got, decisions(true, true, true, true, true, "unowned_path"))
if strings.Contains(raw, "new\nname") || strings.Contains(raw, "\t%=") {
t.Fatalf("untrusted filename leaked into selector output: %q", raw)
}
}
func TestCICapabilitySelectorWorkflowPreservesExistingAuthority(t *testing.T) {
data, err := os.ReadFile(filepath.Join(sourceRepoRoot(t), ".github", "workflows", "pr-risk.yml"))
if err != nil {
t.Fatalf("read workflow: %v", err)
}
var workflow capabilitySelectorWorkflow
if err := yaml.Unmarshal(data, &workflow); err != nil {
t.Fatalf("parse workflow: %v", err)
}
wantJobs := map[string]bool{"detect-ci-tier": true, "build-embedded": true, "test-embedded-storage": true, "test-embedded-conformance": true, "test-server-storage": true, "test-server-storage-full": true, "test-embedded-cmd": true, "test-proxied-cmd": true, "test-nix": true, "ci-gate": true}
if len(workflow.Jobs) != len(wantJobs) {
t.Errorf("job count = %d, want %d", len(workflow.Jobs), len(wantJobs))
}
for name := range workflow.Jobs {
if !wantJobs[name] {
t.Errorf("unexpected job %q", name)
}
}
for name := range wantJobs {
if _, ok := workflow.Jobs[name]; !ok {
t.Errorf("missing job %q", name)
}
}
detector := workflow.Jobs["detect-ci-tier"]
if detector.Outputs["full_embedded"] != "${{ steps.tier.outputs.full_embedded }}" {
t.Errorf("full_embedded output = %q", detector.Outputs["full_embedded"])
}
tier, shadow := detector.step(t, "Decide embedded Dolt coverage"), detector.step(t, "Shadow CI capability selector")
if tier.ID != "tier" || shadow.ID != "shadow-selector" || !shadow.ContinueOnError || detector.stepIndex(t, shadow.Name) != detector.stepIndex(t, tier.Name)+1 {
t.Errorf("shadow step is not immediately after tier: tier=%+v shadow=%+v", tier, shadow)
}
for _, name := range []string{"build-embedded", "test-embedded-storage", "test-embedded-conformance", "test-server-storage", "test-server-storage-full", "test-embedded-cmd", "test-proxied-cmd"} {
if got := workflow.Jobs[name].If; got != "needs.detect-ci-tier.outputs.full_embedded == 'true'" {
t.Errorf("%s if = %q", name, got)
}
}
if workflow.Jobs["test-nix"].If != "" {
t.Errorf("test-nix if = %q, want ungated", workflow.Jobs["test-nix"].If)
}
gate := workflow.Jobs["ci-gate"]
wantNeeds := []string{"detect-ci-tier", "build-embedded", "test-embedded-storage", "test-embedded-conformance", "test-server-storage", "test-server-storage-full", "test-embedded-cmd", "test-proxied-cmd", "test-nix"}
gateStep := gate.step(t, "Evaluate CI gate")
if gate.If != "${{ always() }}" || strings.Join(gate.Needs, ",") != strings.Join(wantNeeds, ",") || gateStep.Env["FULL_EMBEDDED"] != "${{ needs.detect-ci-tier.outputs.full_embedded }}" || gateStep.Env["CI_GATE_REQUIRED"] != "DETECT_CI_TIER BUILD_EMBEDDED TEST_EMBEDDED_STORAGE TEST_EMBEDDED_CONFORMANCE TEST_SERVER_STORAGE TEST_SERVER_STORAGE_FULL TEST_EMBEDDED_CMD TEST_PROXIED_CMD TEST_NIX" || !strings.Contains(gateStep.Run, "skipped_ok=\"BUILD_EMBEDDED TEST_EMBEDDED_STORAGE TEST_EMBEDDED_CONFORMANCE TEST_SERVER_STORAGE TEST_SERVER_STORAGE_FULL TEST_EMBEDDED_CMD TEST_PROXIED_CMD\"") {
t.Errorf("ci-gate authority changed: needs=%v env=%v", gate.Needs, gateStep.Env)
}
for name, job := range workflow.Jobs {
if strings.Contains(job.If, "shadow_") {
t.Errorf("job %q shadow condition = %q", name, job.If)
}
for _, step := range job.Steps {
if strings.Contains(step.If, "shadow_") {
t.Errorf("step %q shadow condition = %q", step.Name, step.If)
}
for key, value := range step.Env {
if strings.Contains(value, "shadow_") {
t.Errorf("step %q env %s consumes shadow output", step.Name, key)
}
}
}
}
}
type capabilitySelectorWorkflow struct {
Jobs map[string]capabilitySelectorJob `yaml:"jobs"`
}
type capabilitySelectorJob struct {
Needs ciWorkflowStringList `yaml:"needs"`
If string `yaml:"if"`
Outputs map[string]string `yaml:"outputs"`
Steps []capabilitySelectorWorkflowStep `yaml:"steps"`
}
type capabilitySelectorWorkflowStep struct {
Name string `yaml:"name"`
ID string `yaml:"id"`
If string `yaml:"if"`
ContinueOnError bool `yaml:"continue-on-error"`
Run string `yaml:"run"`
Env map[string]string `yaml:"env"`
}
func (job capabilitySelectorJob) step(t *testing.T, name string) capabilitySelectorWorkflowStep {
t.Helper()
for _, step := range job.Steps {
if step.Name == name {
return step
}
}
t.Fatalf("job has no %q step", name)
return capabilitySelectorWorkflowStep{}
}
func (job capabilitySelectorJob) stepIndex(t *testing.T, name string) int {
t.Helper()
for index, step := range job.Steps {
if step.Name == name {
return index
}
}
t.Fatalf("job has no %q step", name)
return -1
}
func decisions(embeddedCLI, proxiedCLI, embeddedStorage, embeddedConformance, serverConformance bool, reason string) map[string]string {
full := embeddedCLI && proxiedCLI && embeddedStorage && embeddedConformance && serverConformance
return map[string]string{
"embedded_cli": boolString(embeddedCLI),
"proxied_cli": boolString(proxiedCLI),
"embedded_storage": boolString(embeddedStorage),
"embedded_conformance": boolString(embeddedConformance),
"server_conformance": boolString(serverConformance),
"full": boolString(full),
"reason": reason,
}
}
func boolString(value bool) string {
if value {
return "true"
}
return "false"
}
func runCICapabilitySelector(t *testing.T, records []string, event string, bounds, gitFail bool) (map[string]string, string) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("selector is a Bash boundary")
}
dir := t.TempDir()
fixture := filepath.Join(dir, "diff")
if err := os.WriteFile(fixture, append([]byte(strings.Join(records, "\x00")), 0), 0600); err != nil {
t.Fatalf("write NUL fixture: %v", err)
}
fakeGit := filepath.Join(dir, "git")
if err := os.WriteFile(fakeGit, []byte("#!/bin/sh\n[ \"${FAKE_GIT_FAIL:-}\" = 1 ] && exit 1\ncat \"$FAKE_GIT_FIXTURE\"\n"), 0700); err != nil {
t.Fatalf("write fake git: %v", err)
}
output := filepath.Join(dir, "github-output")
cmd := exec.Command("bash", filepath.Join("..", ".github", "scripts", "ci-capability-selector.sh"))
cmd.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "GITHUB_EVENT_NAME="+event, "FAKE_GIT_FIXTURE="+fixture, "GITHUB_OUTPUT="+output)
if bounds {
cmd.Env = append(cmd.Env, "PR_BASE_SHA=base", "PR_HEAD_SHA=head")
}
if gitFail {
cmd.Env = append(cmd.Env, "FAKE_GIT_FAIL=1")
}
raw, err := cmd.Output()
if err != nil {
t.Fatalf("selector failed: %v", err)
}
outputBytes, err := os.ReadFile(output)
if err != nil {
t.Fatalf("read GITHUB_OUTPUT: %v", err)
}
if string(raw) != string(outputBytes) {
t.Fatalf("stdout and GITHUB_OUTPUT differ:\nstdout=%q\noutput=%q", raw, outputBytes)
}
return parseSelectorOutputs(t, string(raw)), string(raw)
}
func parseSelectorOutputs(t *testing.T, raw string) map[string]string {
t.Helper()
lines := strings.Split(strings.TrimSuffix(raw, "\n"), "\n")
if len(lines) != len(selectorKeys) {
t.Fatalf("selector output has %d lines, want %d: %q", len(lines), len(selectorKeys), raw)
}
got := make(map[string]string, len(lines))
for index, line := range lines {
key, value, ok := strings.Cut(line, "=")
if !ok || key != selectorKeys[index] {
t.Fatalf("selector output line %d = %q, want %q=...", index, line, selectorKeys[index])
}
got[key] = value
}
return got
}
func assertSelectorOutputs(t *testing.T, got, want map[string]string) {
t.Helper()
for _, key := range selectorKeys {
if got[key] != want[key] {
t.Errorf("%s = %q, want %q", key, got[key], want[key])
}
}
}