From ad2f07db5fc13bd2e83ecfe2138ef75b4f02fb6c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 28 Sep 2026 00:57:25 +0000 Subject: [PATCH] Simplify release validation gates --- docs/development.md | 3 +- docs/policy/release.md | 18 ++++-- docs/release.md | 19 +++--- docs/roadmap/implementation.md | 6 ++ release_check_test.go | 108 +++++++++++++++++++++++++++------ scripts/release-check.sh | 62 ++----------------- 6 files changed, 126 insertions(+), 90 deletions(-) diff --git a/docs/development.md b/docs/development.md index 9492a46..1bac297 100644 --- a/docs/development.md +++ b/docs/development.md @@ -117,7 +117,8 @@ under the testing policy. Documentation-only work normally needs the local-link check below and whitespace review; behavior-bearing examples and commands also need execution. Use `go test -race ./...` for ownership or concurrency changes. The [release checker](release.md#4-validate-the-source-candidate) performs one -minimum-toolchain race-suite run, vet, module hygiene, and Linux cross-builds. +minimum-toolchain offline race-suite run, one full vet pass, and Linux +cross-builds using readonly module mode. It already covers examples, links, and package boundaries; do not additionally run the everyday checker just to repeat those checks. CI never gates a release. diff --git a/docs/policy/release.md b/docs/policy/release.md index f7dd8c7..d7eae83 100644 --- a/docs/policy/release.md +++ b/docs/policy/release.md @@ -26,7 +26,7 @@ policy and link to it rather than introduce additional release requirements. ConfigKit adopts a source-only Go module release model. The source tag and version-matched release note are the declared deliverables. Standalone binaries, container images, and separate binary/package-registry publication are not -required. This does not waive source compilation, module hygiene, testing, or +required. This does not waive source compilation, dependency maintenance, testing, or local target-build validation. Post-publication consumer retrieval is optional, as described under Release Model. @@ -148,11 +148,17 @@ gate; historical release notes are unchanged. ConfigKit provides one local release checker. Run it once against the final committed candidate using one maintained patch of the minimum supported Go line -on Linux. It checks module hygiene, formatting and whitespace, the full offline -suite with the race detector, static analysis, and CGO-disabled Linux amd64/arm64 -builds. Examples, documentation links, consumer fixtures, and package boundaries -are covered by that suite, not separate repeated runs. Native package compilation -is covered by the test run. A second toolchain is optional and non-gating. +on Linux. It runs one full offline race suite, one full vet pass, and CGO-disabled +Linux amd64/arm64 builds. The race suite protects library behavior, ownership, +and concurrent use; it includes examples, documentation links, consumer fixtures, +and package boundaries without separate runs. Full vet checks more than the +subset normally run by tests, so test-time vet is disabled. The two cross-builds +cover supported configurations that native race compilation does not. A second +toolchain is optional and non-gating. + +All Go checks use readonly module mode and an offline dependency cache. +Formatting and whitespace remain ordinary development checks. Review module +tidiness when dependencies change, rather than auditing it on every release. Commit the release note and associated changes before release validation. Record the exact validated commit; revalidate if the candidate changes. Do not require diff --git a/docs/release.md b/docs/release.md index 95656aa..84f9a5d 100644 --- a/docs/release.md +++ b/docs/release.md @@ -18,7 +18,8 @@ binary, container image, generated package, or separate registry publication. - Best-effort targets: macOS on amd64 and arm64, under the [platform support policy](policy/release.md#platform-support) -The local Linux checker includes native race tests and CGO-disabled cross-builds. +The local Linux checker includes one offline race suite, one full vet pass, and +CGO-disabled amd64/arm64 cross-builds. Its output reports skipped filesystem cases; no separate evidence record or human sign-off is required. Native macOS checks and Darwin cross-builds are optional and do not gate publication. @@ -136,13 +137,15 @@ printf '%s\n' "$candidate_commit" > "$(git rev-parse --git-path configkit-releas ``` If the final candidate is already committed, omit the staging and commit commands; -do not create an empty commit. The checker validates module manifests in a -disposable copy, formatting, whitespace, the full offline suite once with the race -detector and verbose skip reporting, vet once, and CGO-disabled Linux amd64/arm64 -builds. The suite includes examples, consumer fixtures, documentation links, and -package boundaries; no separate reruns are required. No additional linter is -claimed. Sibling-consumer links are checked when those checkouts exist and are -otherwise inapplicable in an isolated checkout. +do not create an empty commit. The checker runs one full offline race suite with +verbose skip reporting and test-time vet disabled, one full vet pass, and +CGO-disabled Linux amd64/arm64 builds. Go commands use readonly module mode. +The suite includes examples, consumer fixtures, documentation links, and package +boundaries; no separate reruns are required. Formatting and whitespace remain +ordinary development checks in `scripts/check.sh`; module tidiness is reviewed +when dependencies change. No additional linter is claimed. Sibling-consumer links +are checked when those checkouts exist and are otherwise inapplicable in an +isolated checkout. A changed candidate requires validation again. Do not reuse a recorded candidate from an older revision. The record is written only after successful local checks. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index ef495a0..6695983 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -33,6 +33,12 @@ Bash 3.2 and existing Git/Go prerequisites; no new release framework is needed. ## Stage 1 — Simplify the release checker and select its gates +**Completed 2026-09-28.** The checker now runs one readonly offline race suite +with test-time vet disabled, one full vet pass, and CGO-disabled Linux amd64/arm64 +builds. The disposable module audit and release formatting/whitespace scans were +removed. Focused checker tests, Bash syntax checks, and the real Go 1.25.14 +release checker passed on Linux. + Change `scripts/release-check.sh`, checker tests in `release_check_test.go`, and the affected descriptions in `docs/policy/release.md`, `docs/release.md`, and `docs/development.md` together. The existing guarded publication blocks remain diff --git a/release_check_test.go b/release_check_test.go index a08122b..aa80b88 100644 --- a/release_check_test.go +++ b/release_check_test.go @@ -123,8 +123,8 @@ func TestReleaseRunbookGuards(t *testing.T) { } } -func TestCheckScriptsHandleFilenamesAndStopOnFailure(t *testing.T) { - for _, scriptName := range []string{"release-check.sh", "check.sh"} { +func TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure(t *testing.T) { + for _, scriptName := range []string{"check.sh"} { t.Run(scriptName, func(t *testing.T) { script, err := os.ReadFile("scripts/" + scriptName) if err != nil { @@ -217,25 +217,95 @@ func TestCheckScriptsHandleFilenamesAndStopOnFailure(t *testing.T) { if fail && strings.Contains(string(invoked), "vet ./...") { t.Fatal("checker continued after failed test") } - if scriptName == "release-check.sh" && !fail { - if strings.Count(string(invoked), " test ") != 1 || !strings.Contains(string(invoked), " test -race -v ./...\n") { - t.Fatalf("want one complete race-test run, got:\n%s", invoked) - } - if strings.Count(string(invoked), " vet ./...") != 1 || strings.Count(string(invoked), " build ./...") != 2 { - t.Fatalf("want one vet run and only two cross-builds, got:\n%s", invoked) - } - // These are selected release gates, not incidental loop details. - for _, target := range []string{"linux/amd64", "linux/arm64"} { - if !strings.Contains(string(invoked), target+" build ./...") { - t.Errorf("checker omitted required target %s", target) - } - } - if strings.Contains(string(invoked), "darwin/") { - t.Fatal("checker requires best-effort macOS validation") - } - } }) } }) } } + +func TestCheckScriptsReleaseGates(t *testing.T) { + script, err := os.ReadFile("scripts/release-check.sh") + if err != nil { + t.Fatal(err) + } + for _, scenario := range []string{"success", "failed-test", "missing-toolchain"} { + t.Run(scenario, func(t *testing.T) { + root := t.TempDir() + bin := t.TempDir() + for _, directory := range []string{"scripts", "nested"} { + if err := os.Mkdir(filepath.Join(root, directory), 0700); err != nil { + t.Fatal(err) + } + } + checker := filepath.Join(root, "scripts", "release-check.sh") + if err := os.WriteFile(checker, script, 0700); err != nil { + t.Fatal(err) + } + calls := filepath.Join(bin, "go.calls") + goStub := `#!/bin/sh +printf '%s|%s|%s|%s|%s|%s\n' "$GOTOOLCHAIN" "$GOPROXY" "${CGO_ENABLED-}" "${GOOS-}" "${GOARCH-}" "$*" >> "$CONFIGKIT_TEST_GO_LOG" +if [ "${CONFIGKIT_TEST_FAIL-}" = toolchain ] && [ "${1-}" = version ]; then exit 37; fi +if [ "${CONFIGKIT_TEST_FAIL-}" = test ] && [ "${1-}" = test ]; then exit 37; fi +` + if err := os.WriteFile(filepath.Join(bin, "go"), []byte(goStub), 0700); err != nil { + t.Fatal(err) + } + cmd := exec.Command("/bin/bash", checker) + cmd.Dir = filepath.Join(root, "nested") + cmd.Env = append(os.Environ(), + "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), + "CONFIGKIT_BASELINE_TOOLCHAIN=test-baseline", + "CONFIGKIT_TEST_GO_LOG="+calls, + "GOOS=", "GOARCH=", "CGO_ENABLED=", + ) + switch scenario { + case "failed-test": + cmd.Env = append(cmd.Env, "CONFIGKIT_TEST_FAIL=test") + case "missing-toolchain": + cmd.Env = append(cmd.Env, "CONFIGKIT_TEST_FAIL=toolchain") + } + output, err := cmd.CombinedOutput() + if scenario == "success" && err != nil { + t.Fatalf("checker: %v\n%s", err, output) + } + if scenario != "success" && err == nil { + t.Fatalf("checker accepted %s", scenario) + } + data, err := os.ReadFile(calls) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + for _, line := range lines { + if !strings.HasPrefix(line, "test-baseline|off|") { + t.Fatalf("toolchain or offline setting lost: %q", line) + } + } + if scenario == "missing-toolchain" { + if len(lines) != 1 || !strings.HasSuffix(lines[0], "|version") { + t.Fatalf("checker ran after unavailable toolchain: %q", lines) + } + return + } + if len(lines) < 2 || lines[1] != "test-baseline|off||||test -mod=readonly -vet=off -race -v ./..." { + t.Fatalf("release suite command changed: %q", lines) + } + if scenario == "failed-test" { + if len(lines) != 2 { + t.Fatalf("checker continued after failure: %q", lines) + } + return + } + want := []string{ + "test-baseline|off||||version", + "test-baseline|off||||test -mod=readonly -vet=off -race -v ./...", + "test-baseline|off||||vet -mod=readonly ./...", + "test-baseline|off|0|linux|amd64|build -mod=readonly ./...", + "test-baseline|off|0|linux|arm64|build -mod=readonly ./...", + } + if strings.Join(lines, "\n") != strings.Join(want, "\n") { + t.Fatalf("unexpected release gates:\n%s", data) + } + }) + } +} diff --git a/scripts/release-check.sh b/scripts/release-check.sh index 2722993..4376f53 100755 --- a/scripts/release-check.sh +++ b/scripts/release-check.sh @@ -12,68 +12,18 @@ run_go() { GOTOOLCHAIN="$toolchain" GOPROXY=off go "$@" } -check_toolchain() { - local toolchain=$1 - GOTOOLCHAIN="$toolchain" GOPROXY=off go version >/dev/null - run_go "$toolchain" mod download -} - -check_toolchain "$baseline_toolchain" -bash -n scripts/release-check.sh - -temporary_dir=$(mktemp -d) -trap 'rm -rf -- "$temporary_dir"' EXIT -candidate_manifest=$temporary_dir/candidate-files -while IFS= read -r -d '' candidate_file; do - if [[ -e "$candidate_file" || -L "$candidate_file" ]]; then - printf '%s\0' "$candidate_file" >>"$candidate_manifest" - fi -done < <(git ls-files --cached --others --exclude-standard -z) -tar --null --files-from="$candidate_manifest" --create --file=- | - tar --extract --file=- --directory="$temporary_dir" -( - cd "$temporary_dir" - GOTOOLCHAIN="$baseline_toolchain" GOPROXY=off go mod tidy -) -diff -u go.mod "$temporary_dir/go.mod" -diff -u go.sum "$temporary_dir/go.sum" - -go_files=() -while IFS= read -r -d '' go_file; do - if [[ -f "$go_file" ]]; then - go_files+=("$go_file") - fi -done < <(git ls-files --cached --others --exclude-standard -z -- '*.go') -if ((${#go_files[@]})); then - unformatted=$(gofmt -l -- "${go_files[@]}") - if [[ -n "$unformatted" ]]; then - printf 'gofmt required for:\n%s\n' "$unformatted" >&2 - exit 1 - fi +if ! GOTOOLCHAIN="$baseline_toolchain" GOPROXY=off go version >/dev/null; then + printf 'release toolchain %s is unavailable; acquire it before validation\n' "$baseline_toolchain" >&2 + exit 1 fi -git diff --check HEAD -- -while IFS= read -r -d '' candidate_file; do - if [[ ! -f "$candidate_file" ]]; then - continue - fi - case "$candidate_file" in - *.go|*.md|*.sh|go.mod|go.sum|LICENSE|.gitignore|AGENTS.md) - if LC_ALL=C grep -nE '[[:blank:]]+$' -- "$candidate_file"; then - printf 'trailing whitespace found in %s\n' "$candidate_file" >&2 - exit 1 - fi - ;; - esac -done < <(git ls-files --cached --others --exclude-standard -z) - # Verbose output reports skipped cases without a separate evidence run. -run_go "$baseline_toolchain" test -race -v ./... -run_go "$baseline_toolchain" vet ./... +run_go "$baseline_toolchain" test -mod=readonly -vet=off -race -v ./... +run_go "$baseline_toolchain" vet -mod=readonly ./... # Only release-validated targets gate publication; macOS is best-effort. for target in linux/amd64 linux/arm64; do target_os=${target%/*} target_arch=${target#*/} - GOTOOLCHAIN="$baseline_toolchain" GOPROXY=off CGO_ENABLED=0 GOOS="$target_os" GOARCH="$target_arch" go build ./... + GOTOOLCHAIN="$baseline_toolchain" GOPROXY=off CGO_ENABLED=0 GOOS="$target_os" GOARCH="$target_arch" go build -mod=readonly ./... done