Simplify release validation gates

This commit is contained in:
2026-09-28 00:57:25 +00:00
parent d6fe498652
commit ad2f07db5f
6 changed files with 126 additions and 90 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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)
}
})
}
}

View File

@@ -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
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
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