diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0b0e6ca9..cbfe1cdc 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 b229d33d..60472dcc 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 234671c5..531f314d 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 0161653d..1f0cec05 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: @@ -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 9e202150..414d546f 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/goreleaser_guard_test.go b/internal/release/goreleaser_guard_test.go index 094d54cd..34a70dc3 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, ", ")) + } +} diff --git a/internal/release/install_checksum_gate_test.go b/internal/release/install_checksum_gate_test.go new file mode 100644 index 00000000..c8eda846 --- /dev/null +++ b/internal/release/install_checksum_gate_test.go @@ -0,0 +1,249 @@ +// 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 + checksum string // the sha256 recorded in checksums.txt for the original tarball +} + +// 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, checksum: sum} +} + +// 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: 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) + tamperFixtureTarball(t, fx) + + 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. 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) + 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) + tamperFixtureTarball(t, fx) + + 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) + } +} + +// 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 +// 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 osWriteFile(path, content string) error { + return os.WriteFile(path, []byte(content), 0o644) +} diff --git a/internal/release/node24_actions_guard_test.go b/internal/release/node24_actions_guard_test.go new file mode 100644 index 00000000..6de90885 --- /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) + } +} diff --git a/internal/status/handlers.go b/internal/status/handlers.go index a122391b..8827f746 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()) diff --git a/skills/integration/survey_sync_codex_test.go b/skills/integration/survey_sync_codex_test.go index d7004247..74278508 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)