From fb2e2c538626615ea3eda2fcd5f524879ec728c4 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 6 Sep 2026 19:16:00 -0500 Subject: [PATCH] Run release checks with the stock macOS Bash --- docs/release.md | 5 +- docs/roadmap/remediation.md | 7 +++ release_check_test.go | 100 ++++++++++++++++++++++++++++++++++++ scripts/release-check.sh | 5 +- 4 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 release_check_test.go diff --git a/docs/release.md b/docs/release.md index 706d260..7e3e380 100644 --- a/docs/release.md +++ b/docs/release.md @@ -22,8 +22,9 @@ and macOS; cross-compilation is not native execution evidence. ## 1. Prepare access and tools -The release maintainer owns this procedure. Work in a dedicated clone with Git, Bash, -and two supported Go toolchains available. Configure private-host Git and Go module +The release maintainer owns this procedure. Work in a dedicated clone with Git, Bash 3.2 or newer, +and two supported Go toolchains available. The checker supports the stock macOS +Bash; its filename enumeration is null-delimited, including names with whitespace. Configure private-host Git and Go module access through the normal credential manager before starting. Keep tokens, passwords, SSH material, and environment dumps outside the repository and command transcript. Verify access without displaying credentials: diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 9105e77..96fd230 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -239,6 +239,13 @@ default temporary directory and with an intentionally symlinked temporary direct ## 9. P2 — Make the release checker's Bash prerequisite executable on macOS +Status: complete. Replaced mapfile with a Bash-3.2-compatible null-delimited array +read loop. Shell orchestration tests cover spaces/newlines and fail-fast behavior. +The complete checker passes under macOS system Bash 3.2 with cached Go 1.25.5 and +1.26.0, including both baselines, race tests, hygiene/docs/examples and all four +production cross-builds. New native Linux execution remains a release gate, not a +claim made by this local check. + Evidence: [scripts/release-check.sh](../../scripts/release-check.sh) uses `mapfile -d ''`, but [the release procedure](../release.md) requires only Bash. The macOS system Bash is 3.2 and has no `mapfile`; the null-delimiter option also diff --git a/release_check_test.go b/release_check_test.go new file mode 100644 index 0000000..b5b8be8 --- /dev/null +++ b/release_check_test.go @@ -0,0 +1,100 @@ +package configkit + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestReleaseCheckerShellHandlesFilenamesAndStopsOnFailure(t *testing.T) { + script, err := os.ReadFile("scripts/release-check.sh") + if err != nil { + t.Fatal(err) + } + for _, fail := range []bool{false, true} { + name := "success" + if fail { + name = "failed-go-check" + } + t.Run(name, func(t *testing.T) { + root := t.TempDir() + bin := t.TempDir() + log := filepath.Join(bin, "gofmt.args") + calls := filepath.Join(bin, "go.calls") + for _, directory := range []string{"scripts", "nested"} { + if err := os.Mkdir(filepath.Join(root, directory), 0700); err != nil { + t.Fatal(err) + } + } + write := func(path string, data []byte, mode os.FileMode) { + t.Helper() + if err := os.WriteFile(path, data, mode); err != nil { + t.Fatal(err) + } + } + write(filepath.Join(root, "scripts", "release-check.sh"), script, 0700) + write(filepath.Join(root, "go.mod"), []byte("module example.invalid/check\n"), 0600) + write(filepath.Join(root, "go.sum"), nil, 0600) + files := []string{"nested/a space.go", "nested/a\nnewline.go"} + for _, file := range files { + write(filepath.Join(root, file), []byte("package example\n"), 0600) + } + // Isolate expensive toolchain commands: this test owns shell orchestration, + // not Go's compiler or the production tests exercised by the real checker. + write(filepath.Join(bin, "go"), []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$CONFIGKIT_TEST_GO_LOG\"\nif [ \"${CONFIGKIT_TEST_FAIL-}\" = yes ] && [ \"${1-}\" = test ]; then exit 37; fi\n"), 0700) + write(filepath.Join(bin, "gofmt"), []byte("#!/bin/sh\nprintf '%s\\000' \"$@\" > \"$CONFIGKIT_TEST_FMT_LOG\"\n"), 0700) + init := exec.Command("git", "init", "-q") + init.Dir = root + if out, err := init.CombinedOutput(); err != nil { + t.Fatalf("git init: %v %s", err, out) + } + // HEAD is required by the whitespace gate. Commit only the module baseline. + for _, args := range [][]string{{"add", "go.mod", "go.sum"}, {"-c", "user.name=Test", "-c", "user.email=test@example.invalid", "-c", "commit.gpgsign=false", "commit", "-qm", "Test fixture"}} { + cmd := exec.Command("git", args...) + cmd.Dir = root + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git fixture: %v %s", err, out) + } + } + cmd := exec.Command("/bin/bash", filepath.Join(root, "scripts", "release-check.sh")) + cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "CONFIGKIT_BASELINE_TOOLCHAIN=test-baseline", "CONFIGKIT_NEWER_TOOLCHAIN=test-newer", "CONFIGKIT_TEST_FMT_LOG="+log, "CONFIGKIT_TEST_GO_LOG="+calls) + if fail { + cmd.Env = append(cmd.Env, "CONFIGKIT_TEST_FAIL=yes") + } + out, err := cmd.CombinedOutput() + if fail { + if err == nil { + t.Fatal("checker ignored failed Go test") + } + } else if err != nil { + t.Fatalf("checker: %v %s", err, out) + } + arguments, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + parts := bytes.Split(arguments, []byte{0}) + for _, file := range files { + found := false + for _, arg := range parts { + if string(arg) == file { + found = true + } + } + if !found { + t.Fatalf("filename was split: %q", file) + } + } + invoked, err := os.ReadFile(calls) + if err != nil { + t.Fatal(err) + } + if fail && strings.Contains(string(invoked), "vet ./...") { + t.Fatal("checker continued after failed test") + } + }) + } +} diff --git a/scripts/release-check.sh b/scripts/release-check.sh index a4f56bb..b779a48 100755 --- a/scripts/release-check.sh +++ b/scripts/release-check.sh @@ -35,7 +35,10 @@ git ls-files --cached --others --exclude-standard -z | diff -u go.mod "$temporary_dir/go.mod" diff -u go.sum "$temporary_dir/go.sum" -mapfile -d '' go_files < <(git ls-files --cached --others --exclude-standard -z -- '*.go') +go_files=() +while IFS= read -r -d '' go_file; do + go_files+=("$go_file") +done < <(git ls-files --cached --others --exclude-standard -z -- '*.go') if ((${#go_files[@]})); then unformatted=$(gofmt -l -- "${go_files[@]}") if [[ -n "$unformatted" ]]; then