From a472ccc446ede2d071723dfa67e76c6d1917b266 Mon Sep 17 00:00:00 2001 From: CL Kao Date: Sat, 13 Jun 2026 00:21:22 -0700 Subject: [PATCH 1/5] ci: bump node-20 actions to node24 majors + node24 pin guard (AC-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub forces actions/checkout@v4, setup-go@v5, goreleaser-action@v6 (and sibling node-20 actions) onto node24 from 2026-06-16. Bump every node24-tracked uses: pin across the five workflows to its node24 major: checkout@v5, setup-go@v6, goreleaser-action@v7, setup-node@v6, setup-python@v6, upload-artifact@v5. deploy-pages@v4 / upload-pages-artifact@v3 have no node24 release yet and sit off the flip-cut path (docs.yml) — left pinned with a why-comment. Add internal/release/node24_actions_guard_test.go: parses every .github/workflows/*.yml uses: pin and reds if any node24-tracked action is pinned below its recorded node24-minimum major. The minimum map is the in-test oracle (from the deprecation facts), so a regression to @v4 fails the guard. Companion adversarial sub-test reverts checkout to @v4 and asserts the guard trips. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/docs.yml | 7 +- .github/workflows/install-e2e.yml | 6 +- .github/workflows/next-publish.yml | 4 +- .github/workflows/release.yml | 18 +-- .github/workflows/runtime-live-e2e.yml | 28 ++-- internal/release/node24_actions_guard_test.go | 152 ++++++++++++++++++ 6 files changed, 185 insertions(+), 30 deletions(-) create mode 100644 internal/release/node24_actions_guard_test.go diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0b0e6ca97..cbfe1cdcd 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -25,10 +25,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" @@ -40,6 +40,8 @@ jobs: - name: Upload Pages artifact if: github.ref == 'refs/heads/main' + # Pinned: no node24 major published yet (upload-pages-artifact internally + # pins node20 upload-artifact). Not on the flip-cut path. Bump when released. uses: actions/upload-pages-artifact@v3 with: path: site @@ -59,4 +61,5 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment + # Pinned: no node24 major published yet. Not on the flip-cut path. Bump when released. uses: actions/deploy-pages@v4 diff --git a/.github/workflows/install-e2e.yml b/.github/workflows/install-e2e.yml index b229d33d8..60472dcc4 100644 --- a/.github/workflows/install-e2e.yml +++ b/.github/workflows/install-e2e.yml @@ -25,18 +25,18 @@ jobs: os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version: "1.22" # Build the release tarballs + checksums.txt locally without publishing. # The runner's native-OS tarball is what install.sh below consumes. - name: Snapshot release tarballs - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@v7 with: version: "~> v2" args: release --snapshot --clean --skip=publish,homebrew diff --git a/.github/workflows/next-publish.yml b/.github/workflows/next-publish.yml index 234671c50..531f314d6 100644 --- a/.github/workflows/next-publish.yml +++ b/.github/workflows/next-publish.yml @@ -16,10 +16,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: "1.22" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0161653d1..11122ca7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,12 +26,12 @@ jobs: runs-on: macos-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: "1.22" @@ -100,7 +100,7 @@ jobs: runs-on: macos-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 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. @@ -108,7 +108,7 @@ jobs: fetch-tags: true - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: "1.22" @@ -129,19 +129,19 @@ jobs: runs-on: macos-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: # Full history + tags so `git describe --tags` resolves the version # goreleaser stamps into internal/cli.Version. fetch-tags: true is # required so annotated-tag bodies (the release notes consumed by the # next step's `git tag -l --format='%(contents:body)'`) reach the - # runner — actions/checkout@v4's default is false, which leaves the + # runner — actions/checkout@v5's default is false, which leaves the # tag ref but drops the tag object. fetch-depth: 0 fetch-tags: true - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: "1.22" @@ -155,7 +155,7 @@ jobs: - name: Extract release notes from the tag body run: | set -euo pipefail - # Re-fetch the tag annotation explicitly — actions/checkout@v4 sometimes + # Re-fetch the tag annotation explicitly — actions/checkout@v5 sometimes # drops the tag object even with fetch-tags: true, leaving %(contents:body) # empty. A bare git fetch of the ref forces the annotation through. git fetch origin "+refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}" --force @@ -172,7 +172,7 @@ jobs: fi - name: Run goreleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@v7 with: version: "~> v2" args: release --clean --release-notes ${{ runner.temp }}/release-notes.txt diff --git a/.github/workflows/runtime-live-e2e.yml b/.github/workflows/runtime-live-e2e.yml index 9e2021500..414d546f8 100644 --- a/.github/workflows/runtime-live-e2e.yml +++ b/.github/workflows/runtime-live-e2e.yml @@ -55,9 +55,9 @@ jobs: offline: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version: "1.22" @@ -108,13 +108,13 @@ jobs: exit 1 fi - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version: "1.22" - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: "20" @@ -194,7 +194,7 @@ jobs: - name: Upload live artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: runtime-live-e2e-claude-live-${{ matrix.model }} path: | @@ -225,13 +225,13 @@ jobs: exit 1 fi - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version: "1.22" - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: "20" @@ -315,7 +315,7 @@ jobs: - name: Upload live artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: runtime-live-e2e-codex-live path: | @@ -343,13 +343,13 @@ jobs: exit 1 fi - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version: "1.22" - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: "20" @@ -422,7 +422,7 @@ jobs: - name: Upload live artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: runtime-live-e2e-pi-live path: | diff --git a/internal/release/node24_actions_guard_test.go b/internal/release/node24_actions_guard_test.go new file mode 100644 index 000000000..6de908854 --- /dev/null +++ b/internal/release/node24_actions_guard_test.go @@ -0,0 +1,152 @@ +// ABOUTME: Guards that every GitHub Actions `uses:` pin sits at or above the +// ABOUTME: node24 major GitHub forces from 2026-06-16, so no workflow regresses. +package release + +import ( + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" +) + +// node24MinMajor is the independent oracle: the lowest major release of each +// action that runs on node24. Sourced from GitHub's runner-deprecation changelog +// (revised deadline 2026-06-16) and each action's release notes, NOT from the +// workflow files this test checks — so a pin that drops below its minimum reds. +// Actions absent from this map (deploy-pages, upload-pages-artifact) have no +// node24 release published yet and are left at their current node20 pins; they +// sit off the release-cut path (docs.yml only) and are not enforced here. +var node24MinMajor = map[string]int{ + "actions/checkout": 5, + "actions/setup-go": 6, + "actions/setup-node": 6, + "actions/setup-python": 6, + "actions/upload-artifact": 5, + "goreleaser/goreleaser-action": 7, +} + +// usesPin matches a workflow `uses: owner/action@vN` step ref and captures the +// `owner/action` slug and the integer major from a `@vN` tag. A `@` or +// `@` pin (no `vN`) does not match — those carry no major to compare. +var usesPin = regexp.MustCompile(`uses:\s*([\w.-]+/[\w.-]+)@v(\d+)`) + +// actionPin is one parsed `uses:` occurrence with its source location, so a +// failure names the exact workflow file and line that regressed. +type actionPin struct { + file string + line int + slug string + major int +} + +// parseWorkflowActionPins extracts every `uses: owner/action@vN` pin from a +// workflow file's text, with the line number of each, so the guard can report +// precisely where a sub-minimum pin lives. +func parseWorkflowActionPins(file, content string) []actionPin { + var pins []actionPin + for i, line := range strings.Split(content, "\n") { + if m := usesPin.FindStringSubmatch(line); m != nil { + major, _ := strconv.Atoi(m[2]) + pins = append(pins, actionPin{file: file, line: i + 1, slug: m[1], major: major}) + } + } + return pins +} + +// readWorkflowFiles returns the path + text of every `.github/workflows/*.yml` +// file, the full set the deprecation guard must cover. +func readWorkflowFiles(t *testing.T) map[string]string { + t.Helper() + dir := filepath.Join("..", "..", ".github", "workflows") + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + files := map[string]string{} + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".yml") { + continue + } + path := filepath.Join(dir, e.Name()) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + files[e.Name()] = string(data) + } + if len(files) == 0 { + t.Fatalf("no .github/workflows/*.yml files found under %s", dir) + } + return files +} + +// belowNode24Minimum returns the pins in the given set that are pinned below +// their recorded node24-minimum major. Pins for actions absent from the oracle +// map (the exempt pages actions) are ignored. +func belowNode24Minimum(pins []actionPin) []actionPin { + var below []actionPin + for _, p := range pins { + min, tracked := node24MinMajor[p.slug] + if tracked && p.major < min { + below = append(below, p) + } + } + return below +} + +// TestNode24ActionsPinnedAtMinimum locks AC-5: every workflow's `uses:` pin for +// a node24-tracked action sits at or above the major GitHub forces to node24 on +// 2026-06-16. A re-pin below the minimum (e.g. checkout back to @v4) reds here. +func TestNode24ActionsPinnedAtMinimum(t *testing.T) { + var all []actionPin + for name, content := range readWorkflowFiles(t) { + all = append(all, parseWorkflowActionPins(name, content)...) + } + + // Every tracked action must actually appear somewhere, so the guard fails if + // a workflow stops using an action the oracle still expects to find pinned. + seen := map[string]bool{} + for _, p := range all { + seen[p.slug] = true + } + for slug := range node24MinMajor { + if !seen[slug] { + t.Errorf("no `uses: %s@vN` pin found in any workflow; the node24 guard is not covering it", slug) + } + } + + for _, p := range belowNode24Minimum(all) { + t.Errorf("%s:%d pins %s@v%d, below the node24 minimum @v%d (node-20 forced off 2026-06-16)", + p.file, p.line, p.slug, p.major, node24MinMajor[p.slug]) + } +} + +// TestNode24GuardRejectsRevertedPin proves the guard is load-bearing: rewriting +// one node24-pinned `uses:` line back to @v4 must make belowNode24Minimum flag +// it. The check compares parsed majors against the in-test oracle, so it tracks +// the actual pinned version, not whatever the file happens to mention. +func TestNode24GuardRejectsRevertedPin(t *testing.T) { + const file = "release.yml" + content := readWorkflowFiles(t)[file] + + reverted := strings.Replace(content, "uses: actions/checkout@v5", "uses: actions/checkout@v4", 1) + if reverted == content { + t.Fatalf("fixture %s has no `uses: actions/checkout@v5` line to revert", file) + } + + below := belowNode24Minimum(parseWorkflowActionPins(file, reverted)) + if len(below) == 0 { + t.Fatal("reverting checkout to @v4 did not trip belowNode24Minimum; the guard is not load-bearing") + } + found := false + for _, p := range below { + if p.slug == "actions/checkout" && p.major == 4 { + found = true + } + } + if !found { + t.Errorf("expected the reverted actions/checkout@v4 pin to be flagged; got %v", below) + } +} From 9be6abddd93036c8fd523d46bdbfdbc33b6b12f2 Mon Sep 17 00:00:00 2001 From: CL Kao Date: Sat, 13 Jun 2026 00:23:41 -0700 Subject: [PATCH 2/5] release: checksum-gate tamper test + hasGitEntry guard comment (AC-1, AC-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AC-1: internal/release/install_checksum_gate_test.go builds a local dist/ fixture (a tar.gz holding a bare runnable spacedock + a matching checksums.txt) and drives `sh install.sh` via SPACEDOCK_INSTALL_FROM, no goreleaser. Asserts the happy path installs a runnable binary and that a byte-appended (tampered) tarball makes install.sh exit non-zero installing nothing. Companion load-bearing sub-test strips the gate lines (install.sh:164-169) to a temp copy, runs the SAME tamper case against it, and asserts the gateless installer wrongly exits 0 — proving the live tamper assertion actually binds the gate. Verified: deleting install.sh's gate reds the tamper assertion; restoring it greens all four. AC-4: cross-reference comment at hasGitEntry (handlers.go) naming TestDiscoverWorkflowsSkipsNestedCheckout as its sole guard, so a future edit knows what protects it. Comment-only; internal/status stays green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../release/install_checksum_gate_test.go | 238 ++++++++++++++++++ internal/status/handlers.go | 4 + 2 files changed, 242 insertions(+) create mode 100644 internal/release/install_checksum_gate_test.go diff --git a/internal/release/install_checksum_gate_test.go b/internal/release/install_checksum_gate_test.go new file mode 100644 index 000000000..d9b085404 --- /dev/null +++ b/internal/release/install_checksum_gate_test.go @@ -0,0 +1,238 @@ +// ABOUTME: Drives install.sh's checksum gate over a local dist fixture, proving +// ABOUTME: a tampered tarball aborts and that the gate lines are load-bearing. +package release + +import ( + "archive/tar" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// installFixture is a local goreleaser-shaped `dist/` directory: one +// `spacedock___.tar.gz` holding a bare runnable `spacedock` at the +// archive root, plus a `checksums.txt` line matching that tarball — the same +// layout install.sh's SPACEDOCK_INSTALL_FROM= path consumes. +type installFixture struct { + dir string // the dist dir to point SPACEDOCK_INSTALL_FROM at + tarballPath string // absolute path to the single os/arch tarball + asset string // the tarball's basename + marker string // the string the installed binary prints when run +} + +// buildInstallFixture writes a dist/ fixture under a fresh temp dir. The bare +// `spacedock` payload is a tiny shell script that echoes a unique marker, so the +// happy-path test can exec the installed file and confirm a RUNNABLE binary +// landed (not just any file). checksums.txt is computed from the real tarball +// bytes, so the gate's expected hash is correct until a test mutates the tarball. +func buildInstallFixture(t *testing.T) installFixture { + t.Helper() + os, arch := goosArch(t) + dist := t.TempDir() + + const marker = "spacedock-fixture-ran-ok" + // A bare executable `spacedock` at the archive root. A shell script is enough + // for install.sh (it `install`s the file 0755) and lets the test exec it. + binary := "#!/bin/sh\necho " + marker + "\n" + + asset := "spacedock_0.0.0_" + os + "_" + arch + ".tar.gz" + tarballPath := filepath.Join(dist, asset) + writeTarGz(t, tarballPath, "spacedock", []byte(binary)) + + sum := sha256OfFile(t, tarballPath) + // goreleaser's checksums.txt format is `␣␣`; install.sh + // parses it with `awk '$2 == filename {print $1}'`, so two space-separated + // fields suffice. + checksums := sum + " " + asset + "\n" + if err := osWriteFile(filepath.Join(dist, "checksums.txt"), checksums); err != nil { + t.Fatal(err) + } + + return installFixture{dir: dist, tarballPath: tarballPath, asset: asset, marker: marker} +} + +// runInstall runs the given install.sh script against a dist fixture via the +// SPACEDOCK_INSTALL_FROM local-dist override, installing into a fresh dir. It +// returns the install dir and the script's exit code (0 on success). The env is +// set explicitly (not scrubbed) because this path REQUIRES the override. +func runInstall(t *testing.T, script, distDir string) (installDir string, exitCode int) { + t.Helper() + installDir = filepath.Join(t.TempDir(), "bin") + cmd := exec.Command("sh", script) + cmd.Env = append(scrubInstallEnv(), + "SPACEDOCK_INSTALL_FROM="+distDir, + "SPACEDOCK_INSTALL_DIR="+installDir, + ) + out, err := cmd.CombinedOutput() + t.Logf("install.sh (%s) output:\n%s", filepath.Base(script), out) + if err == nil { + return installDir, 0 + } + if ee, ok := err.(*exec.ExitError); ok { + return installDir, ee.ExitCode() + } + t.Fatalf("install.sh failed to launch: %v", err) + return installDir, -1 +} + +// TestChecksumGateInstallsAndRejectsTamper locks AC-1: install.sh's checksum gate +// installs a runnable binary on the happy path and aborts (installing nothing) on +// a byte-tampered tarball. Driven over a local dist fixture, no goreleaser. +func TestChecksumGateInstallsAndRejectsTamper(t *testing.T) { + script := filepath.Join("..", "..", "install.sh") + + // Happy path: the fixture's checksums.txt matches the tarball, so the gate + // passes and a runnable `spacedock` lands and prints its marker. + t.Run("happy path installs a runnable binary", func(t *testing.T) { + fx := buildInstallFixture(t) + installDir, code := runInstall(t, script, fx.dir) + if code != 0 { + t.Fatalf("happy-path install exited %d, want 0", code) + } + out, err := exec.Command(filepath.Join(installDir, "spacedock")).CombinedOutput() + if err != nil { + t.Fatalf("installed spacedock did not run: %v\n%s", err, out) + } + if !strings.Contains(string(out), fx.marker) { + t.Errorf("installed binary printed %q, want it to contain %q", out, fx.marker) + } + }) + + // Tamper case: append bytes to the tarball so its sha256 no longer matches the + // (unchanged) checksums.txt line. The gate MUST abort non-zero, installing + // nothing. This is the assertion that reds if install.sh:164-169 are deleted. + t.Run("tampered tarball aborts installing nothing", func(t *testing.T) { + fx := buildInstallFixture(t) + appendBytes(t, fx.tarballPath, []byte{0xde, 0xad, 0xbe, 0xef}) + + installDir, code := runInstall(t, script, fx.dir) + if code == 0 { + t.Fatal("install.sh accepted a tampered tarball (exit 0); the checksum gate is not fail-closed") + } + if _, err := os.Stat(filepath.Join(installDir, "spacedock")); err == nil { + t.Error("install.sh installed a binary despite the checksum mismatch") + } + }) +} + +// TestChecksumGateGuardIsLoadBearing proves the tamper assertion above actually +// exercises the gate: it strips the checksum-gate lines (install.sh:164-169) to a +// temp copy of the script, runs the SAME tamper case against THAT copy, and +// asserts the gateless installer now WRONGLY exits 0 and installs the tampered +// binary. If stripping the gate did NOT change behavior, the live tamper test +// wasn't binding the gate — that is the hole this load-bearing check closes. +func TestChecksumGateGuardIsLoadBearing(t *testing.T) { + original := readInstallScript(t) + gateless, removed := stripChecksumGate(original) + if !removed { + t.Fatalf("could not locate the checksum-gate block in install.sh to strip; the load-bearing check cannot bind") + } + + gatelessScript := filepath.Join(t.TempDir(), "install-gateless.sh") + if err := osWriteFile(gatelessScript, gateless); err != nil { + t.Fatal(err) + } + + fx := buildInstallFixture(t) + appendBytes(t, fx.tarballPath, []byte{0xde, 0xad, 0xbe, 0xef}) + + installDir, code := runInstall(t, gatelessScript, fx.dir) + if code != 0 { + t.Fatalf("gateless install.sh exited %d on a tampered tarball; expected 0 (the strip should let the tamper through), so the live tamper test is NOT exercising the gate", code) + } + if _, err := os.Stat(filepath.Join(installDir, "spacedock")); err != nil { + t.Errorf("gateless install.sh did not install the tampered binary (%v); the strip removed more than the gate", err) + } +} + +// stripChecksumGate removes install.sh's checksum-verification block — the +// `expected=…` extraction, its empty-check `die`, the `actual=…` hash, and the +// mismatch `if … die … fi` — yielding a script that extracts and installs WITHOUT +// verifying. It returns the rewritten text and whether the block was found, so a +// drift in install.sh's gate shape fails the load-bearing test loudly rather than +// silently checking nothing. +func stripChecksumGate(script string) (string, bool) { + lines := strings.Split(script, "\n") + start, end := -1, -1 + for i, line := range lines { + if start == -1 && strings.HasPrefix(strings.TrimSpace(line), "expected=") { + start = i + } + // The gate ends at the closing `fi` of the mismatch check. + if start != -1 && strings.TrimSpace(line) == "fi" { + end = i + break + } + } + if start == -1 || end == -1 { + return script, false + } + kept := append(append([]string{}, lines[:start]...), lines[end+1:]...) + return strings.Join(kept, "\n"), true +} + +func readInstallScript(t *testing.T) string { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "..", "install.sh")) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +// writeTarGz writes a gzip-compressed tar at path containing a single file with +// the given name (at the archive root) and contents, mode 0755. +func writeTarGz(t *testing.T, path, name string, content []byte) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + hdr := &tar.Header{Name: name, Mode: 0o755, Size: int64(len(content)), Typeflag: tar.TypeReg} + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(content); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } +} + +func sha256OfFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func appendBytes(t *testing.T, path string, extra []byte) { + t.Helper() + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if _, err := f.Write(extra); err != nil { + t.Fatal(err) + } +} + +func osWriteFile(path, content string) error { + return os.WriteFile(path, []byte(content), 0o644) +} diff --git a/internal/status/handlers.go b/internal/status/handlers.go index a122391ba..8827f7469 100644 --- a/internal/status/handlers.go +++ b/internal/status/handlers.go @@ -558,6 +558,10 @@ func discoverWorkflows(root string) []string { // repository checkout) or a regular file (a linked/agent worktree's gitlink). A // dir with one is a self-contained checkout, so the discovery walk does not // descend into it (its workflows are copies of the outer repo's). +// +// Guarded by TestDiscoverWorkflowsSkipsNestedCheckout (discover_worktree_noise_test.go): +// that test is the sole coverage proving this prune fires, so changing this +// gitlink/checkout detection should be checked against it. func hasGitEntry(dir string) bool { st, err := os.Stat(filepath.Join(dir, ".git")) return err == nil && (st.IsDir() || st.Mode().IsRegular()) From 7c88c1939a47f6dcef23a0911eba1012d392a5ba Mon Sep 17 00:00:00 2001 From: CL Kao Date: Sat, 13 Jun 2026 00:24:35 -0700 Subject: [PATCH 3/5] test: clear gofmt drift on survey_sync_codex_test.go (AC-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gofmt -l flagged this file. Under go1.26.1 a naive gofmt -w would rewrite the line-23 bare '' in the cwd comment into a curly U+201D (the go/doc comment reformatter treating '' as a typographic quote) — a content change, not whitespace. Reword the comment to drop the bare '' first, so the only formatting change gofmt applies is the benign comment-column realignment on lines 62-65. Verified: gofmt -l clean, zero U+201D bytes. Co-Authored-By: Claude Opus 4.8 (1M context) --- skills/integration/survey_sync_codex_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/skills/integration/survey_sync_codex_test.go b/skills/integration/survey_sync_codex_test.go index d7004247a..742785083 100644 --- a/skills/integration/survey_sync_codex_test.go +++ b/skills/integration/survey_sync_codex_test.go @@ -20,7 +20,7 @@ import ( // writeCodexSession writes a minimal agentsview-ingestible Codex rollout jsonl under // codexDir for a session whose source cwd is repoCwd. agentsview derives `project` from // the git-root basename of that cwd and blanks the stored cwd, so the session lands with -// project = basename(repoCwd-normalized) and cwd = ''. +// project = basename(repoCwd-normalized) and a blanked cwd. func writeCodexSession(t *testing.T, codexDir, id, repoCwd string) { t.Helper() day := filepath.Join(codexDir, "2026", "06", "02") @@ -62,10 +62,10 @@ func TestSurveyCodexPresenceThroughSync(t *testing.T) { } root := t.TempDir() - home := filepath.Join(root, "home") // empty HOME isolates ALL default sources - dataDir := filepath.Join(root, "data") // AGENTSVIEW_DATA_DIR - codexDir := filepath.Join(root, "codex") // CODEX_SESSIONS_DIR - claudeDir := filepath.Join(root, "claude") // CLAUDE_PROJECTS_DIR (empty — Codex-only test) + home := filepath.Join(root, "home") // empty HOME isolates ALL default sources + dataDir := filepath.Join(root, "data") // AGENTSVIEW_DATA_DIR + codexDir := filepath.Join(root, "codex") // CODEX_SESSIONS_DIR + claudeDir := filepath.Join(root, "claude") // CLAUDE_PROJECTS_DIR (empty — Codex-only test) for _, d := range []string{home, dataDir, codexDir, claudeDir} { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatalf("mkdir %s: %v", d, err) From 83613efb2b4d1b24c8ecd6cd0252814dff0497f6 Mon Sep 17 00:00:00 2001 From: CL Kao Date: Sat, 13 Jun 2026 00:26:06 -0700 Subject: [PATCH 4/5] release: header names darwin+linux cross-build, guarded (AC-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.19.9 added linux binaries but release.yml's file-header still described a darwin-only build ("cross-builds the darwin … tarballs", "darwin binaries are built natively"). Reword the header to name the darwin+linux × arm64+amd64 matrix .goreleaser.yaml actually builds, keeping the accurate "runs on macOS" note (darwin native; linux cross-compiles, CGO_ENABLED=0). docs/releasing.md was already reconciled and is left unchanged. Guard it in goreleaser_guard_test.go: TestReleaseHeaderNamesEveryBuildOS parses .goreleaser.yaml's goos set (the independent oracle) and asserts release.yml's leading comment header names every build OS — so a header that drops `linux` reds even while it still mentions `darwin`. Companion adversarial sub-test strips `linux` from a header copy and asserts the guard trips. Verified: reverting to the darwin-only header reds the check. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 8 +-- internal/release/goreleaser_guard_test.go | 83 +++++++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11122ca7c..1f0cec055 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,7 @@ -# Cuts a GitHub Release on every v* tag: goreleaser cross-builds the darwin -# arm64+amd64 tarballs, writes checksums.txt, publishes the release, and bumps -# the homebrew-tap formula. Runs on macOS so the released darwin binaries are -# built natively. +# Cuts a GitHub Release on every v* tag: goreleaser cross-builds the darwin and +# linux arm64+amd64 tarballs, writes checksums.txt, publishes the release, and +# bumps the homebrew-tap formula. Runs on macOS so the darwin binaries are built +# natively (CGO_ENABLED=0, so the linux tarballs cross-compile on the same host). name: release on: diff --git a/internal/release/goreleaser_guard_test.go b/internal/release/goreleaser_guard_test.go index 094d54cdc..34a70dc3d 100644 --- a/internal/release/goreleaser_guard_test.go +++ b/internal/release/goreleaser_guard_test.go @@ -108,3 +108,86 @@ func readGoreleaserConfig(t *testing.T) string { } return string(data) } + +// goosOf returns the distinct goos tokens .goreleaser.yaml builds — the +// independent oracle the release.yml header must not contradict. +func goosOf(targets map[buildTarget]bool) []string { + seen := map[string]bool{} + for tgt := range targets { + seen[tgt.os] = true + } + var oses []string + for o := range seen { + oses = append(oses, o) + } + sort.Strings(oses) + return oses +} + +// leadingCommentBlock returns the file's leading `#` comment lines (up to the +// first non-comment, non-blank line) joined into one string — the workflow +// header the doc-accuracy check inspects. +func leadingCommentBlock(content string) string { + var header []string + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") { + header = append(header, line) + continue + } + if trimmed == "" { + continue + } + break + } + return strings.Join(header, "\n") +} + +// goosMissingFromHeader returns the build OSes that the header text fails to +// name — the doc-vs-config drift the guard catches. +func goosMissingFromHeader(header string, oses []string) []string { + var missing []string + for _, o := range oses { + if !strings.Contains(header, o) { + missing = append(missing, o) + } + } + return missing +} + +// TestReleaseHeaderNamesEveryBuildOS locks AC-2: release.yml's file-header comment +// names every goos .goreleaser.yaml actually builds, so the header cannot claim a +// darwin-only build while the config cross-builds linux too. The oracle is the +// parsed build set (not header prose), so a header that drops `linux` reds even +// though the file still mentions `darwin`. +func TestReleaseHeaderNamesEveryBuildOS(t *testing.T) { + oses := goosOf(parseGoreleaserBuildTargets(readGoreleaserConfig(t))) + if len(oses) == 0 { + t.Fatal("parsed no goos from .goreleaser.yaml; the header check has no oracle to bind") + } + header := leadingCommentBlock(readReleaseWorkflow(t)) + if header == "" { + t.Fatal("release.yml has no leading comment header to check") + } + if missing := goosMissingFromHeader(header, oses); len(missing) > 0 { + t.Errorf("release.yml header omits build OS %s while .goreleaser.yaml builds %s; header:\n%s", + strings.Join(missing, ", "), strings.Join(oses, ", "), header) + } +} + +// TestReleaseHeaderGuardRejectsDarwinOnly proves the guard is load-bearing: a +// header with `linux` removed (the pre-task darwin-only shape) must fail the +// name-every-OS check against a config that still builds linux. +func TestReleaseHeaderGuardRejectsDarwinOnly(t *testing.T) { + oses := goosOf(parseGoreleaserBuildTargets(readGoreleaserConfig(t))) + header := leadingCommentBlock(readReleaseWorkflow(t)) + + darwinOnly := strings.ReplaceAll(header, "linux", "") + if darwinOnly == header { + t.Fatal("release.yml header has no `linux` token to strip; the load-bearing check cannot bind") + } + if missing := goosMissingFromHeader(darwinOnly, oses); len(missing) == 0 { + t.Fatalf("stripping `linux` from the header did not trip the guard; it is not load-bearing (oses=%s)", + strings.Join(oses, ", ")) + } +} From 97dad74b99970e48d57649ded178473f668f017a Mon Sep 17 00:00:00 2001 From: CL Kao Date: Sat, 13 Jun 2026 14:12:02 -0700 Subject: [PATCH 5/5] =?UTF-8?q?test:=20portable=20checksum=20tamper=20?= =?UTF-8?q?=E2=80=94=20valid=20tarball,=20mismatched=20hash=20(AC-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The load-bearing sub-test (and the main tamper case) tampered by byte-appending to the tarball. On macOS `tar` tolerates the trailing bytes, but on Linux CI `tar` REJECTS the corrupted archive independent of the checksum gate, so the gateless install exited 1 instead of the expected 0 — TestChecksumGateGuardIsLoadBearing failed on Linux and the byte-append did not isolate the gate. Swap the tamper for a structurally-VALID tar.gz whose `spacedock` payload differs, leaving checksums.txt unchanged: the archive extracts cleanly on every platform, so the ONLY post-strip rejection is sha256 != checksums.txt — the checksum gate, isolated. Applied to both the main tamper case and the load-bearing sub-test. install.sh and the gate are untouched. Verified: full extract with `tar -xzf … spacedock` succeeds (rc=0); deleting install.sh's gate still reds the tamper assertion; release pkg green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../release/install_checksum_gate_test.go | 49 ++++++++++++------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/internal/release/install_checksum_gate_test.go b/internal/release/install_checksum_gate_test.go index d9b085404..c8eda8468 100644 --- a/internal/release/install_checksum_gate_test.go +++ b/internal/release/install_checksum_gate_test.go @@ -23,6 +23,7 @@ type installFixture struct { tarballPath string // absolute path to the single os/arch tarball asset string // the tarball's basename marker string // the string the installed binary prints when run + checksum string // the sha256 recorded in checksums.txt for the original tarball } // buildInstallFixture writes a dist/ fixture under a fresh temp dir. The bare @@ -53,7 +54,7 @@ func buildInstallFixture(t *testing.T) installFixture { t.Fatal(err) } - return installFixture{dir: dist, tarballPath: tarballPath, asset: asset, marker: marker} + return installFixture{dir: dist, tarballPath: tarballPath, asset: asset, marker: marker, checksum: sum} } // runInstall runs the given install.sh script against a dist fixture via the @@ -103,12 +104,16 @@ func TestChecksumGateInstallsAndRejectsTamper(t *testing.T) { } }) - // Tamper case: append bytes to the tarball so its sha256 no longer matches the - // (unchanged) checksums.txt line. The gate MUST abort non-zero, installing - // nothing. This is the assertion that reds if install.sh:164-169 are deleted. + // Tamper case: swap the tarball for a structurally-valid one whose `spacedock` + // payload differs, so its sha256 no longer matches the (unchanged) checksums.txt + // line. The gate MUST abort non-zero, installing nothing. The swap keeps the + // archive extractable on every platform (a byte-appended tarball is rejected by + // Linux `tar` itself, which would mask the gate), so ONLY the sha256 mismatch + // triggers rejection — this is the assertion that reds if install.sh:164-169 + // are deleted. t.Run("tampered tarball aborts installing nothing", func(t *testing.T) { fx := buildInstallFixture(t) - appendBytes(t, fx.tarballPath, []byte{0xde, 0xad, 0xbe, 0xef}) + tamperFixtureTarball(t, fx) installDir, code := runInstall(t, script, fx.dir) if code == 0 { @@ -125,7 +130,10 @@ func TestChecksumGateInstallsAndRejectsTamper(t *testing.T) { // temp copy of the script, runs the SAME tamper case against THAT copy, and // asserts the gateless installer now WRONGLY exits 0 and installs the tampered // binary. If stripping the gate did NOT change behavior, the live tamper test -// wasn't binding the gate — that is the hole this load-bearing check closes. +// wasn't binding the gate — that is the hole this load-bearing check closes. The +// tamper is a structurally-valid wrong-hash tarball (not a byte-corrupted one), +// so the gateless extract succeeds on every platform and the ONLY thing the +// stripped gate stops rejecting is the hash mismatch. func TestChecksumGateGuardIsLoadBearing(t *testing.T) { original := readInstallScript(t) gateless, removed := stripChecksumGate(original) @@ -139,7 +147,7 @@ func TestChecksumGateGuardIsLoadBearing(t *testing.T) { } fx := buildInstallFixture(t) - appendBytes(t, fx.tarballPath, []byte{0xde, 0xad, 0xbe, 0xef}) + tamperFixtureTarball(t, fx) installDir, code := runInstall(t, gatelessScript, fx.dir) if code != 0 { @@ -150,6 +158,21 @@ func TestChecksumGateGuardIsLoadBearing(t *testing.T) { } } +// tamperFixtureTarball overwrites the fixture's tarball with a fresh, fully-valid +// tar.gz whose `spacedock` payload differs from the original, WITHOUT touching the +// fixture's checksums.txt. The result extracts cleanly on every platform (so a +// platform's `tar` corruption-rejection never masks the gate), yet its sha256 no +// longer matches the recorded line — isolating the checksum gate as the sole +// reason install.sh rejects it. +func tamperFixtureTarball(t *testing.T, fx installFixture) { + t.Helper() + tamperedBinary := "#!/bin/sh\necho " + fx.marker + "-tampered\n" + writeTarGz(t, fx.tarballPath, "spacedock", []byte(tamperedBinary)) + if sha256OfFile(t, fx.tarballPath) == fx.checksum { + t.Fatalf("tampered tarball hash unexpectedly equals the recorded checksum; the swap did not change the bytes") + } +} + // stripChecksumGate removes install.sh's checksum-verification block — the // `expected=…` extraction, its empty-check `die`, the `actual=…` hash, and the // mismatch `if … die … fi` — yielding a script that extracts and installs WITHOUT @@ -221,18 +244,6 @@ func sha256OfFile(t *testing.T, path string) string { return hex.EncodeToString(sum[:]) } -func appendBytes(t *testing.T, path string, extra []byte) { - t.Helper() - f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) - if err != nil { - t.Fatal(err) - } - defer f.Close() - if _, err := f.Write(extra); err != nil { - t.Fatal(err) - } -} - func osWriteFile(path, content string) error { return os.WriteFile(path, []byte(content), 0o644) }