diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 233abf17..207ac5a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,7 +87,45 @@ jobs: RELEASE_VERSION="${GITHUB_REF_NAME#v}" gh release upload "$GITHUB_REF_NAME" "$RUNNER_TEMP/journey-costs-v${RELEASE_VERSION}.json" --clobber + # Release-time precondition: the cut may not proceed unless the live e2e + # matrix ran green for the EXACT commit being released. The job resolves the + # tagged commit SHA and asks `spacedock-release e2e-gate` whether a + # `conclusion: success` Runtime Live E2E run exists for it — a green run means + # every live lane was approved and passed (the secret-free offline-only run is + # never `success`). goreleaser `needs:` this job, so a missing/parked live run + # blocks the cut. SPACEDOCK_E2E_GATE_WAIVER (set with a reason) is the + # auditable captain-waiver escape hatch for an emergency cut; the reason is + # recorded to the step summary. + e2e-gate: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Full history + tags so `git rev-list -1 "$GITHUB_REF_NAME"` resolves + # the tagged commit SHA the gate binds the green-run match to. + fetch-depth: 0 + fetch-tags: true + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.22" + + - name: Gate the cut on a green live e2e for the release commit + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Set this to a non-empty reason to WAIVE the gate for an emergency cut. + # The reason is recorded to $GITHUB_STEP_SUMMARY so the bypass is auditable. + SPACEDOCK_E2E_GATE_WAIVER: ${{ vars.SPACEDOCK_E2E_GATE_WAIVER }} + run: | + set -euo pipefail + RELEASE_COMMIT="$(git rev-list -1 "$GITHUB_REF_NAME")" + echo "release commit for $GITHUB_REF_NAME: $RELEASE_COMMIT" + go run ./cmd/spacedock-release e2e-gate "$RELEASE_COMMIT" + goreleaser: + needs: e2e-gate runs-on: macos-latest steps: - name: Checkout diff --git a/cmd/spacedock-release/e2e_gate.go b/cmd/spacedock-release/e2e_gate.go new file mode 100644 index 00000000..9eef590a --- /dev/null +++ b/cmd/spacedock-release/e2e_gate.go @@ -0,0 +1,94 @@ +// ABOUTME: `spacedock-release e2e-gate ` — release-time precondition that +// ABOUTME: blocks the cut unless a green Runtime Live E2E run exists for the commit. +package main + +import ( + "fmt" + "os" + "os/exec" + + "github.com/spacedock-dev/spacedock/internal/release" +) + +// runListFunc fetches the `gh run list` JSON for the release commit. It is a seam +// so the gate's exit-code + step-summary behavior is testable against fixtures +// without a live gh. +type runListFunc func(commit string) ([]byte, error) + +// ghRunListForCommit queries the green Runtime Live E2E runs whose head commit is +// the release commit. `--status success` excludes parked/waiting runs (the spike +// proved a parked run is never success), and `-c ` binds the query to the +// exact tagged commit so a green run on some other line cannot satisfy the gate. +func ghRunListForCommit(commit string) ([]byte, error) { + cmd := exec.Command("gh", "run", "list", + "--workflow", "Runtime Live E2E", + "--status", "success", + "-c", commit, + "--json", "databaseId,headSha,conclusion,status", + ) + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("gh run list: %w", err) + } + return out, nil +} + +// runE2EGate evaluates the release-time e2e gate for the commit given as the sole +// argument. It reads the captain-waiver reason from SPACEDOCK_E2E_GATE_WAIVER, +// fetches the run list via list, runs the pure decision predicate, records the +// outcome to $GITHUB_STEP_SUMMARY (the audit trail), and returns the process exit +// code: 0 when the gate passes (green run matched, or explicitly waived), 1 when +// it blocks. A query or parse failure with no waiver blocks the cut. +func runE2EGate(args []string, list runListFunc) int { + if len(args) != 1 || args[0] == "" { + fmt.Fprintln(os.Stderr, "spacedock-release e2e-gate: need exactly one ") + return 2 + } + commit := args[0] + waiver := os.Getenv("SPACEDOCK_E2E_GATE_WAIVER") + + var runListJSON []byte + if waiver == "" { + // Only consult gh when there is no waiver; a waiver short-circuits the + // predicate before the run list, so an emergency cut survives a gh failure. + out, err := list(commit) + if err != nil { + fmt.Fprintf(os.Stderr, "spacedock-release e2e-gate: %v\n", err) + recordGateSummary(fmt.Sprintf("e2e gate BLOCKED for %s: could not query Runtime Live E2E runs (%v)", commit, err)) + return 1 + } + runListJSON = out + } + + dec, err := release.EvaluateE2EGate(runListJSON, commit, waiver) + if err != nil { + fmt.Fprintf(os.Stderr, "spacedock-release e2e-gate: %v\n", err) + recordGateSummary(fmt.Sprintf("e2e gate BLOCKED for %s: %v", commit, err)) + return 1 + } + + recordGateSummary(dec.Reason) + if !dec.Pass { + fmt.Fprintf(os.Stderr, "spacedock-release e2e-gate: %s\n", dec.Reason) + return 1 + } + fmt.Println(dec.Reason) + return 0 +} + +// recordGateSummary appends the gate decision to $GITHUB_STEP_SUMMARY when set +// (the GitHub Actions step-summary file) so every cut — passed, blocked, or +// waived — leaves an auditable record. Outside CI the env var is unset and this +// is a no-op. +func recordGateSummary(reason string) { + path := os.Getenv("GITHUB_STEP_SUMMARY") + if path == "" { + return + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o644) + if err != nil { + return + } + defer f.Close() + fmt.Fprintf(f, "### Release e2e gate\n\n%s\n", reason) +} diff --git a/cmd/spacedock-release/e2e_gate_test.go b/cmd/spacedock-release/e2e_gate_test.go new file mode 100644 index 00000000..733e44aa --- /dev/null +++ b/cmd/spacedock-release/e2e_gate_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// fakeRunLister returns canned `gh run list` JSON without shelling out, so the +// e2e-gate subcommand's exit-code + step-summary behavior is exercised against +// fixtures rather than a live gh. +func fakeRunLister(json string) runListFunc { + return func(commit string) ([]byte, error) { + return []byte(json), nil + } +} + +const greenForCommit = `[{"databaseId": 42, "headSha": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", "conclusion": "success", "status": "completed"}]` +const parkedForCommit = `[{"databaseId": 43, "headSha": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", "conclusion": "", "status": "waiting"}]` + +const gateCommit = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + +// TestE2EGateCommandPassesOnGreenRun — the subcommand exits 0 and records the +// matched run in $GITHUB_STEP_SUMMARY when a green run matches the commit. +func TestE2EGateCommandPassesOnGreenRun(t *testing.T) { + summary := newSummaryFile(t) + t.Setenv("GITHUB_STEP_SUMMARY", summary) + t.Setenv("SPACEDOCK_E2E_GATE_WAIVER", "") + + code := runE2EGate([]string{gateCommit}, fakeRunLister(greenForCommit)) + if code != 0 { + t.Fatalf("e2e-gate exit = %d, want 0 on a green run", code) + } + if got := readFile(t, summary); !strings.Contains(got, gateCommit) { + t.Errorf("step summary did not record the matched commit:\n%s", got) + } +} + +// TestE2EGateCommandBlocksOnParkedRun — the subcommand exits 1 on a parked run +// (the cut is blocked because the live lanes did not run green). +func TestE2EGateCommandBlocksOnParkedRun(t *testing.T) { + summary := newSummaryFile(t) + t.Setenv("GITHUB_STEP_SUMMARY", summary) + t.Setenv("SPACEDOCK_E2E_GATE_WAIVER", "") + + code := runE2EGate([]string{gateCommit}, fakeRunLister(parkedForCommit)) + if code == 0 { + t.Fatalf("e2e-gate exit = 0 on a parked run; want non-zero (cut blocked)") + } +} + +// TestE2EGateCommandPassesWhenWaived — with SPACEDOCK_E2E_GATE_WAIVER set the +// subcommand exits 0 even on a parked run, and the waiver reason is written to +// the step summary for the audit trail. +func TestE2EGateCommandPassesWhenWaived(t *testing.T) { + summary := newSummaryFile(t) + t.Setenv("GITHUB_STEP_SUMMARY", summary) + const reason = "emergency cut approved by captain clkao" + t.Setenv("SPACEDOCK_E2E_GATE_WAIVER", reason) + + code := runE2EGate([]string{gateCommit}, fakeRunLister(parkedForCommit)) + if code != 0 { + t.Fatalf("e2e-gate exit = %d on a waived gate; want 0", code) + } + got := readFile(t, summary) + if !strings.Contains(got, reason) { + t.Errorf("step summary did not record the waiver reason for the audit trail:\n%s", got) + } + if !strings.Contains(strings.ToUpper(got), "WAIV") { + t.Errorf("step summary did not mark the cut as waived:\n%s", got) + } +} + +// TestE2EGateCommandRejectsMissingCommit — the subcommand needs the release +// commit argument; with none it exits with a usage error and does not pass. +func TestE2EGateCommandRejectsMissingCommit(t *testing.T) { + t.Setenv("SPACEDOCK_E2E_GATE_WAIVER", "") + code := runE2EGate(nil, fakeRunLister(greenForCommit)) + if code == 0 { + t.Fatalf("e2e-gate exit = 0 with no release commit argument; want non-zero") + } +} + +func newSummaryFile(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "step_summary") + if err := os.WriteFile(path, nil, 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} diff --git a/cmd/spacedock-release/main.go b/cmd/spacedock-release/main.go index 3bb52579..2735f869 100644 --- a/cmd/spacedock-release/main.go +++ b/cmd/spacedock-release/main.go @@ -21,13 +21,17 @@ import ( // // spacedock-release stamp-version [ ...] // spacedock-release bump-calendar +// spacedock-release e2e-gate // // stamp-version rewrites each manifest's top-level `version` to the release // version (AC-4). bump-calendar advances the marketplace plugin entry's calendar -// key to today's `0.0.YYYYMMDDNN` (AC-2d). Both rewrite in place. notes -// summarizes the commit log since the last tag into clean release notes and, on -// confirmation, cuts the annotated tag whose body carries them (CI extracts that -// body and feeds goreleaser via --release-notes). +// key to today's `0.0.YYYYMMDDNN` (AC-2d). Both rewrite in place. e2e-gate is the +// release-time precondition: it passes (exit 0) only when a conclusion:success +// Runtime Live E2E run exists for the commit, or when SPACEDOCK_E2E_GATE_WAIVER +// is set, and blocks the cut (exit 1) otherwise. notes summarizes the commit log +// since the last tag into clean release notes and, on confirmation, cuts the +// annotated tag whose body carries them (CI extracts that body and feeds +// goreleaser via --release-notes). func main() { if len(os.Args) < 2 { usage() @@ -40,6 +44,8 @@ func main() { os.Exit(bumpCalendar(os.Args[2:])) case "journey-costs": os.Exit(journeyCosts(os.Args[2:])) + case "e2e-gate": + os.Exit(runE2EGate(os.Args[2:], ghRunListForCommit)) case "notes": os.Exit(notes(os.Args[2:])) default: @@ -314,6 +320,7 @@ Usage: spacedock-release stamp-version [ ...] spacedock-release bump-calendar spacedock-release journey-costs --metrics-dir --out + spacedock-release e2e-gate spacedock-release notes `) } diff --git a/internal/release/e2egate.go b/internal/release/e2egate.go new file mode 100644 index 00000000..0b5cf750 --- /dev/null +++ b/internal/release/e2egate.go @@ -0,0 +1,70 @@ +// ABOUTME: Release-time e2e gate predicate — decides whether a Runtime Live E2E +// ABOUTME: run proves the live matrix passed for the exact commit being released. +package release + +import ( + "encoding/json" + "fmt" +) + +// e2eRun is one entry of `gh run list --json databaseId,headSha,conclusion,status`. +// A run "proves" the live matrix passed for a commit only when its overall +// conclusion is success AND its headSha is the release commit. The spike +// established that a parked run (live lanes still awaiting per-environment +// approval) has an empty conclusion / waiting status and so never qualifies. +type e2eRun struct { + DatabaseID int64 `json:"databaseId"` + HeadSha string `json:"headSha"` + Conclusion string `json:"conclusion"` + Status string `json:"status"` +} + +// Decision is the gate outcome over a `gh run list` result for a release commit. +// Pass gates whether goreleaser may run. Waived records that the pass came from +// the captain-waiver escape hatch rather than a matched run, so the step log / +// $GITHUB_STEP_SUMMARY can mark the cut as auditable-but-unverified. MatchedRunID +// is the qualifying run when the pass is genuine (0 when waived or blocked). +type Decision struct { + Pass bool + Waived bool + MatchedRunID int64 + Reason string +} + +// EvaluateE2EGate decides whether the release commit may be cut. A non-empty +// waiverReason short-circuits to a waived pass BEFORE the run list is consulted, +// so an emergency cut survives an unusable `gh run list` result. Otherwise the +// gate passes only when runListJSON (the output of `gh run list --json +// databaseId,headSha,conclusion,status`) contains a run whose conclusion is +// "success" AND whose headSha equals releaseCommit — the exact green-for-commit +// run the spike proved distinguishes an approved full matrix from a parked, +// offline-only run. A malformed run list is an error (block), never a silent +// pass. +func EvaluateE2EGate(runListJSON []byte, releaseCommit, waiverReason string) (Decision, error) { + if waiverReason != "" { + return Decision{ + Pass: true, + Waived: true, + Reason: fmt.Sprintf("e2e gate WAIVED for %s: %s", releaseCommit, waiverReason), + }, nil + } + + var runs []e2eRun + if err := json.Unmarshal(runListJSON, &runs); err != nil { + return Decision{}, fmt.Errorf("parse gh run list output: %w", err) + } + + for _, run := range runs { + if run.Conclusion == "success" && run.HeadSha == releaseCommit { + return Decision{ + Pass: true, + MatchedRunID: run.DatabaseID, + Reason: fmt.Sprintf("green Runtime Live E2E run %d matches release commit %s", run.DatabaseID, releaseCommit), + }, nil + } + } + + return Decision{ + Reason: fmt.Sprintf("no conclusion:success Runtime Live E2E run found for release commit %s", releaseCommit), + }, nil +} diff --git a/internal/release/e2egate_test.go b/internal/release/e2egate_test.go new file mode 100644 index 00000000..d4a8fe8f --- /dev/null +++ b/internal/release/e2egate_test.go @@ -0,0 +1,159 @@ +package release + +import ( + "strings" + "testing" +) + +// runListJSON renders the shape `gh run list --json databaseId,headSha,conclusion,status` +// returns: a JSON array of run objects. Tests feed constructed fixtures so the +// expected pass/block decision comes from the fixture, never from the workflow. +const greenForCommitJSON = `[ + {"databaseId": 27050060639, "headSha": "abcdef1234567890abcdef1234567890abcdef12", "conclusion": "success", "status": "completed"} +]` + +const parkedRunJSON = `[ + {"databaseId": 27118281803, "headSha": "abcdef1234567890abcdef1234567890abcdef12", "conclusion": "", "status": "waiting"} +]` + +const greenWrongCommitJSON = `[ + {"databaseId": 27050060639, "headSha": "0000000000000000000000000000000000000000", "conclusion": "success", "status": "completed"} +]` + +const emptyRunListJSON = `[]` + +const releaseCommit = "abcdef1234567890abcdef1234567890abcdef12" + +// TestE2EGatePassesForGreenRunOnReleaseCommit — AC-2 pass case: a run with +// conclusion==success AND headSha==release commit passes the gate. +func TestE2EGatePassesForGreenRunOnReleaseCommit(t *testing.T) { + dec, err := EvaluateE2EGate([]byte(greenForCommitJSON), releaseCommit, "") + if err != nil { + t.Fatalf("EvaluateE2EGate errored on a well-formed run list: %v", err) + } + if !dec.Pass { + t.Fatalf("gate blocked a green run on the release commit: %s", dec.Reason) + } + if dec.Waived { + t.Fatalf("gate reported a waiver for a genuinely green run: %s", dec.Reason) + } + if !strings.Contains(dec.Reason, releaseCommit) { + t.Errorf("pass reason does not cite the matched commit %q: %s", releaseCommit, dec.Reason) + } +} + +// TestE2EGateBlocksParkedRun — AC-2 block case: a waiting/empty-conclusion run +// (offline job green, live lanes still awaiting environment approval) is NOT a +// pass. This is the exact false-pass the spike disproved. +func TestE2EGateBlocksParkedRun(t *testing.T) { + dec, err := EvaluateE2EGate([]byte(parkedRunJSON), releaseCommit, "") + if err != nil { + t.Fatalf("EvaluateE2EGate errored on a well-formed run list: %v", err) + } + if dec.Pass { + t.Fatalf("gate passed a parked run (conclusion empty / status waiting) for the release commit") + } +} + +// TestE2EGateBlocksGreenRunOnWrongCommit — AC-2 block case: a fully green run +// that ran for a DIFFERENT commit does not satisfy the gate; the green live run +// must be bound to the exact tagged commit. +func TestE2EGateBlocksGreenRunOnWrongCommit(t *testing.T) { + dec, err := EvaluateE2EGate([]byte(greenWrongCommitJSON), releaseCommit, "") + if err != nil { + t.Fatalf("EvaluateE2EGate errored on a well-formed run list: %v", err) + } + if dec.Pass { + t.Fatalf("gate passed a green run whose headSha did not match the release commit") + } +} + +// TestE2EGateBlocksEmptyRunList — AC-2 block case: no Runtime Live E2E run at +// all for the line being released ⇒ block the cut. +func TestE2EGateBlocksEmptyRunList(t *testing.T) { + dec, err := EvaluateE2EGate([]byte(emptyRunListJSON), releaseCommit, "") + if err != nil { + t.Fatalf("EvaluateE2EGate errored on an empty run list: %v", err) + } + if dec.Pass { + t.Fatalf("gate passed with no Runtime Live E2E run for the release commit") + } +} + +// TestE2EGateWaiverPassesWhenSet — AC-3: a non-empty waiver reason passes the +// gate even with no matching run, and records the reason for the audit log. +func TestE2EGateWaiverPassesWhenSet(t *testing.T) { + const reason = "emergency hotfix cut; live matrix unavailable (captain: clkao)" + dec, err := EvaluateE2EGate([]byte(emptyRunListJSON), releaseCommit, reason) + if err != nil { + t.Fatalf("EvaluateE2EGate errored evaluating a waived gate: %v", err) + } + if !dec.Pass { + t.Fatalf("waived gate did not pass: %s", dec.Reason) + } + if !dec.Waived { + t.Fatalf("waived gate did not flag itself as waived: %s", dec.Reason) + } + if !strings.Contains(dec.Reason, reason) { + t.Errorf("waived gate did not record the waiver reason; got: %s", dec.Reason) + } +} + +// TestE2EGateWaiverEnforcesWhenUnset — AC-3: an empty waiver reason does NOT +// bypass the gate; a parked-only run still blocks. +func TestE2EGateWaiverEnforcesWhenUnset(t *testing.T) { + dec, err := EvaluateE2EGate([]byte(parkedRunJSON), releaseCommit, "") + if err != nil { + t.Fatalf("EvaluateE2EGate errored: %v", err) + } + if dec.Pass { + t.Fatalf("an unset waiver still bypassed the gate on a parked run") + } + if dec.Waived { + t.Fatalf("an unset waiver reported itself as a waiver") + } +} + +// TestE2EGateRejectsMalformedRunList — a run list that is not valid JSON must be +// an error (block), never a silent pass. +func TestE2EGateRejectsMalformedRunList(t *testing.T) { + if _, err := EvaluateE2EGate([]byte(`not json`), releaseCommit, ""); err == nil { + t.Fatalf("EvaluateE2EGate accepted malformed run-list JSON without error") + } +} + +// TestE2EGateWaiverPassesEvenWithMalformedRunList — AC-3: an explicit captain +// waiver short-circuits BEFORE the run list is consulted, so an emergency cut is +// possible even when `gh run list` output is unusable. The waiver is the +// auditable escape hatch; it must not be defeated by a query failure. +func TestE2EGateWaiverPassesEvenWithMalformedRunList(t *testing.T) { + const reason = "registry outage; gh unavailable" + dec, err := EvaluateE2EGate([]byte(`not json`), releaseCommit, reason) + if err != nil { + t.Fatalf("waiver did not short-circuit a malformed run list: %v", err) + } + if !dec.Pass || !dec.Waived { + t.Fatalf("waiver did not pass over a malformed run list: pass=%v waived=%v reason=%s", dec.Pass, dec.Waived, dec.Reason) + } +} + +// TestE2EGatePicksMatchingRunAmongMany — when the run list carries several runs +// (the query is not limited to one), the gate passes if ANY run is green for the +// release commit, even if earlier entries are parked or on other commits. +func TestE2EGatePicksMatchingRunAmongMany(t *testing.T) { + manyJSON := `[ + {"databaseId": 3, "headSha": "0000000000000000000000000000000000000000", "conclusion": "success", "status": "completed"}, + {"databaseId": 2, "headSha": "abcdef1234567890abcdef1234567890abcdef12", "conclusion": "", "status": "waiting"}, + {"databaseId": 1, "headSha": "abcdef1234567890abcdef1234567890abcdef12", "conclusion": "success", "status": "completed"} +]` + dec, err := EvaluateE2EGate([]byte(manyJSON), releaseCommit, "") + if err != nil { + t.Fatalf("EvaluateE2EGate errored: %v", err) + } + if !dec.Pass { + t.Fatalf("gate did not find the green run for the release commit among many: %s", dec.Reason) + } + if dec.MatchedRunID != 1 { + t.Errorf("gate matched run %d; want the green-for-commit run 1", dec.MatchedRunID) + } +} diff --git a/internal/release/e2egate_workflow_test.go b/internal/release/e2egate_workflow_test.go new file mode 100644 index 00000000..42b10534 --- /dev/null +++ b/internal/release/e2egate_workflow_test.go @@ -0,0 +1,151 @@ +package release + +import ( + "fmt" + "strings" + "testing" +) + +// TestReleaseWorkflowGatesGoreleaserOnE2E locks AC-1: the real release.yml wires +// the e2e gate so a v* tag cannot reach goreleaser without a green live e2e for +// the release commit. Two halves, both parsed from the workflow YAML (not from +// any instruction-file prose): +// - the goreleaser-carrying job declares `needs:` including `e2e-gate`, and +// - the e2e-gate job resolves the tagged commit SHA and runs the +// `spacedock-release e2e-gate` step over that SHA (which itself queries +// `gh run list --workflow "Runtime Live E2E" --status success -c `). +func TestReleaseWorkflowGatesGoreleaserOnE2E(t *testing.T) { + release := readWorkflow(t, "release.yml") + if err := assertReleaseWorkflowGatesGoreleaserOnE2E(release); err != nil { + t.Fatal(err) + } +} + +// TestReleaseWorkflowE2EGateGuardRejectsDroppedNeedsEdge is the adversarial twin +// for the needs-edge half: string-substitute the `needs: e2e-gate` edge off the +// goreleaser job and the guard must RED, because goreleaser would then cut +// without the gate ever running. A guard that stays green here is a hole. +func TestReleaseWorkflowE2EGateGuardRejectsDroppedNeedsEdge(t *testing.T) { + release := readWorkflow(t, "release.yml") + if err := assertReleaseWorkflowGatesGoreleaserOnE2E(release); err != nil { + t.Fatalf("real release.yml unexpectedly fails the e2e-gate guard before mutation: %v", err) + } + + // The goreleaser job header in the real file carries the needs edge as its + // first line under the job key. Drop exactly that line. + adversarial := strings.Replace(release, + " goreleaser:\n needs: e2e-gate\n", + " goreleaser:\n", + 1) + if adversarial == release { + t.Fatal("fixture workflow missing the goreleaser `needs: e2e-gate` edge to drop") + } + + if err := assertReleaseWorkflowGatesGoreleaserOnE2E(adversarial); err == nil { + t.Fatal("e2e-gate guard accepted a goreleaser job with the needs: e2e-gate edge dropped") + } +} + +// TestReleaseWorkflowE2EGateGuardRejectsWeakenedShaMatch is the adversarial twin +// for the SHA-binding half: weaken the e2e-gate step so it no longer binds the +// query to the tagged commit SHA (drop the commit-resolving `git rev-list` and +// the `-c "$RELEASE_COMMIT"` binding, accepting "some green run somewhere"). The +// guard must RED — a gate that accepts a green run on any commit does not prove +// the live matrix passed for the commit being released. +func TestReleaseWorkflowE2EGateGuardRejectsWeakenedShaMatch(t *testing.T) { + release := readWorkflow(t, "release.yml") + + for _, tc := range []struct { + name string + from string + to string + }{ + { + "drop the commit-resolving rev-list", + `RELEASE_COMMIT="$(git rev-list -1 "$GITHUB_REF_NAME")"`, + `RELEASE_COMMIT=""`, + }, + { + "drop the SHA-bound gate invocation", + `go run ./cmd/spacedock-release e2e-gate "$RELEASE_COMMIT"`, + `echo "skipping e2e gate"`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + adversarial := strings.Replace(release, tc.from, tc.to, 1) + if adversarial == release { + t.Fatalf("fixture workflow missing the SHA-match anchor %q to weaken", tc.from) + } + if err := assertReleaseWorkflowGatesGoreleaserOnE2E(adversarial); err == nil { + t.Fatalf("e2e-gate guard accepted a workflow with %s", tc.name) + } + }) + } +} + +// assertReleaseWorkflowGatesGoreleaserOnE2E binds the AC-1 gate to the parsed +// job graph + the e2e-gate step's resolved commands. It requires: +// - the goreleaser-action carrier needs the e2e-gate job, and +// - some job resolves the tagged commit SHA (git rev-list -1 $GITHUB_REF_NAME) +// and runs `spacedock-release e2e-gate "$RELEASE_COMMIT"` over it. +// +// The predicate's own SHA/conclusion matching is unit-tested separately; this +// guard proves the WIRING gates the cut on that predicate, bound to the tag. +func assertReleaseWorkflowGatesGoreleaserOnE2E(workflow string) error { + jobs := parseWorkflowJobs(workflow) + + var goreleaserCarriers []workflowJob + gateJobNames := map[string]bool{} + for _, job := range jobs { + carriesGoreleaser := false + resolvesCommit, invokesGate := false, false + for _, step := range job.steps { + if strings.HasPrefix(step.uses, "goreleaser/goreleaser-action@") { + carriesGoreleaser = true + } + for _, command := range executableShellCommands(step.run) { + if strings.Contains(command, `git rev-list -1 "$GITHUB_REF_NAME"`) { + resolvesCommit = true + } + if isE2EGateInvocation(command) { + invokesGate = true + } + } + } + if carriesGoreleaser { + goreleaserCarriers = append(goreleaserCarriers, job) + } + if resolvesCommit && invokesGate { + gateJobNames[job.name] = true + } + } + + if len(goreleaserCarriers) == 0 { + return fmt.Errorf("release.yml has no job carrying the goreleaser action") + } + if len(gateJobNames) == 0 { + return fmt.Errorf("release.yml has no job that resolves the tagged commit SHA and runs `spacedock-release e2e-gate \"$RELEASE_COMMIT\"` over it") + } + + for _, carrier := range goreleaserCarriers { + needsAGate := false + for _, need := range carrier.needs { + if gateJobNames[need] { + needsAGate = true + } + } + if !needsAGate { + return fmt.Errorf("release.yml goreleaser job %q does not declare needs: on the e2e-gate job — a v* tag could reach goreleaser without a green live e2e for the release commit", carrier.name) + } + } + return nil +} + +// isE2EGateInvocation reports whether command runs the SHA-bound e2e-gate +// subcommand: it must invoke `spacedock-release e2e-gate` AND pass the resolved +// release commit ($RELEASE_COMMIT), so a gate that drops the SHA binding (and +// would accept a green run on any commit) is not recognized. +func isE2EGateInvocation(command string) bool { + return strings.Contains(command, `go run ./cmd/spacedock-release e2e-gate `) && + strings.Contains(command, `"$RELEASE_COMMIT"`) +} diff --git a/internal/release/journey_workflow_test.go b/internal/release/journey_workflow_test.go index ab271847..29fc933d 100644 --- a/internal/release/journey_workflow_test.go +++ b/internal/release/journey_workflow_test.go @@ -175,16 +175,16 @@ func TestReleaseWorkflowGuardRejectsGoreleaserNeedsJourneyLedger(t *testing.T) { needsForm string }{ {"scalar", " needs: journey-ledger\n"}, - {"flow sequence", " needs: [journey-ledger]\n"}, - {"block list", " needs:\n - journey-ledger\n"}, + {"flow sequence", " needs: [e2e-gate, journey-ledger]\n"}, + {"block list", " needs:\n - e2e-gate\n - journey-ledger\n"}, {"scalar with inline comment", " needs: journey-ledger # required for the upload\n"}, - {"flow sequence with inline comment", " needs: [journey-ledger] # required for the upload\n"}, - {"block list with inline comment", " needs:\n - journey-ledger # required for the upload\n"}, - {"block list split by a blank line", " needs:\n - some-other-gate\n\n - journey-ledger\n"}, + {"flow sequence with inline comment", " needs: [e2e-gate, journey-ledger] # required for the upload\n"}, + {"block list with inline comment", " needs:\n - e2e-gate\n - journey-ledger # required for the upload\n"}, + {"block list split by a blank line", " needs:\n - e2e-gate\n\n - journey-ledger\n"}, } { t.Run(tc.name, func(t *testing.T) { adversarial := strings.Replace(release, - " goreleaser:\n runs-on: macos-latest", + " goreleaser:\n needs: e2e-gate\n runs-on: macos-latest", " goreleaser:\n"+tc.needsForm+" runs-on: macos-latest", 1) if adversarial == release { @@ -223,20 +223,20 @@ func TestReleaseWorkflowGuardRejectsGoreleaserNeedsJourneyLedgerViaJobIdentitySh "anchor/alias needs", func(s string) string { // Anchor a list containing journey-ledger on the reverse edge, then // alias it onto goreleaser — GHA resolves *grx so goreleaser needs - // journey-ledger. + // journey-ledger (alongside the legitimate e2e-gate it still needs). s = strings.Replace(s, " journey-ledger:\n needs: goreleaser\n", - " journey-ledger:\n needs: &grx [goreleaser, journey-ledger]\n", 1) + " journey-ledger:\n needs: &grx [e2e-gate, journey-ledger]\n", 1) return strings.Replace(s, - " goreleaser:\n runs-on: macos-latest", + " goreleaser:\n needs: e2e-gate\n runs-on: macos-latest", " goreleaser:\n needs: *grx\n runs-on: macos-latest", 1) }, }, { "quoted job key", func(s string) string { return strings.Replace(s, - " goreleaser:\n runs-on: macos-latest", - " \"goreleaser\":\n needs: journey-ledger\n runs-on: macos-latest", 1) + " goreleaser:\n needs: e2e-gate\n runs-on: macos-latest", + " \"goreleaser\":\n needs: [e2e-gate, journey-ledger]\n runs-on: macos-latest", 1) }, }, } { @@ -271,7 +271,7 @@ const goreleaserCarrierJob = ` goreleaser-extra: // needs block) just before the real goreleaser job in the workflow text. func insertGoreleaserCarrier(t *testing.T, workflow, needsBlock string) string { t.Helper() - const anchor = " goreleaser:\n runs-on: macos-latest" + const anchor = " goreleaser:\n needs: e2e-gate\n runs-on: macos-latest" if !strings.Contains(workflow, anchor) { t.Fatal("fixture workflow missing the goreleaser job header to anchor a second carrier before") } @@ -405,11 +405,12 @@ func TestReleaseWorkflowGuardToleratesSafeReverseEdgeViaJobIdentityShapes(t *tes } // TestReleaseWorkflowJobGraphMatchesGitHubActions asserts the parsed job graph of -// the real release.yml matches what GitHub Actions resolves: goreleaser has NO -// `needs:` (it cuts regardless), and journey-ledger needs ONLY goreleaser (the -// one-way upload-ordering edge). This pins the parser to GHA semantics so a -// future parse regression that drops or invents an edge is caught directly, not -// only through the guard. +// the real release.yml matches what GitHub Actions resolves: goreleaser needs +// ONLY the e2e-gate job (the live-e2e precondition the cut depends on, never the +// journey-ledger job), and journey-ledger needs ONLY goreleaser (the one-way +// upload-ordering edge). This pins the parser to GHA semantics so a future parse +// regression that drops or invents an edge is caught directly, not only through +// the guard. func TestReleaseWorkflowJobGraphMatchesGitHubActions(t *testing.T) { release := readWorkflow(t, "release.yml") needs := map[string][]string{} @@ -420,8 +421,8 @@ func TestReleaseWorkflowJobGraphMatchesGitHubActions(t *testing.T) { if _, ok := needs["goreleaser"]; !ok { t.Fatalf("parsed graph missing the goreleaser job; got jobs %v", keysOf(needs)) } - if len(needs["goreleaser"]) != 0 { - t.Errorf("goreleaser must have no needs (cuts regardless); got %v", needs["goreleaser"]) + if got := needs["goreleaser"]; len(got) != 1 || got[0] != "e2e-gate" { + t.Errorf("goreleaser must need exactly e2e-gate (the live-e2e gate, never journey-ledger); got %v", got) } if got := needs["journey-ledger"]; len(got) != 1 || got[0] != "goreleaser" { t.Errorf("journey-ledger must need only goreleaser; got %v", got)