mirror of
https://github.com/gastownhall/beads.git
synced 2026-09-14 20:17:24 +08:00
30f30ca95b
* 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
276 lines
12 KiB
Go
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])
|
|
}
|
|
}
|
|
}
|