Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions cmd/spacedock-release/e2e_gate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// ABOUTME: `spacedock-release e2e-gate <commit>` — 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 <commit>` 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 <release-commit-sha>")
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)
}
101 changes: 101 additions & 0 deletions cmd/spacedock-release/e2e_gate_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
15 changes: 11 additions & 4 deletions cmd/spacedock-release/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,17 @@ import (
//
// spacedock-release stamp-version <release-version> <plugin.json> [<plugin.json> ...]
// spacedock-release bump-calendar <marketplace.json>
// spacedock-release e2e-gate <release-commit-sha>
//
// 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()
Expand All @@ -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:
Expand Down Expand Up @@ -314,6 +320,7 @@ Usage:
spacedock-release stamp-version <release-version> <plugin.json> [<plugin.json> ...]
spacedock-release bump-calendar <marketplace.json>
spacedock-release journey-costs <release-version> --metrics-dir <dir> --out <path>
spacedock-release e2e-gate <release-commit-sha>
spacedock-release notes <release-version>
`)
}
70 changes: 70 additions & 0 deletions internal/release/e2egate.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading