diff --git a/release_check_test.go b/development_check_test.go similarity index 55% rename from release_check_test.go rename to development_check_test.go index a199c22..8002c7e 100644 --- a/release_check_test.go +++ b/development_check_test.go @@ -66,7 +66,7 @@ func TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure(t *testing.T) { t.Fatal(err) } cmd := exec.Command("/bin/bash", filepath.Join(root, "scripts", scriptName)) - cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "CONFIGKIT_BASELINE_TOOLCHAIN=test-baseline", "CONFIGKIT_NEWER_TOOLCHAIN=", "CONFIGKIT_TEST_FMT_LOG="+log, "CONFIGKIT_TEST_GO_LOG="+calls) + cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "CONFIGKIT_TEST_FMT_LOG="+log, "CONFIGKIT_TEST_GO_LOG="+calls) // Log only targets selected by the script, not inherited host settings. cmd.Env = append(cmd.Env, "GOOS=", "GOARCH=") if name == "failed-go-check" { @@ -108,90 +108,3 @@ func TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure(t *testing.T) { }) } } - -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/docs/development.md b/docs/development.md index 87ce364..140830d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -64,8 +64,9 @@ git show cb5b435:docs/roadmap/remediation.md ``` Release-candidate, publication, and verification work follows the operational -[release procedure](release.md). Its single publication command validates the -committed candidate and publishes only the intended refs. +[release procedure](release.md). Its module-selected ReleaseKit `publish` +invocation validates the committed candidate and publishes only the intended +tag; those release checks remain distinct from the everyday development check. The completed string-and-integer schema-version implementation and remediation records are preserved in Git history at `244104f`. The @@ -93,9 +94,9 @@ preserved in Git history at `73ce6e9` under `docs/roadmap/`. Current overlay behavior belongs to the [configuration contract](config.md#selected-overlays), and release operations belong to the [release procedure](release.md). -Install Go 1.25.0 or a supported patched/newer Go toolchain. Acquire declared -dependencies explicitly before entering an offline development or validation -environment: +Install the Go version required by `go.mod` or a compatible newer toolchain. +Acquire declared dependencies explicitly before entering an offline +development or validation environment: ```sh go mod download @@ -125,11 +126,10 @@ Use focused checks while iterating, with validation proportionate to the change 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 [publication command](release.md#4-publish-the-committed-candidate) invokes -the release checker once. It performs one 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. +The [release procedure](release.md#4-publish-the-committed-candidate) performs +its selected validation through one `publish` invocation. The everyday checker +remains a development gate and is not a separate release prerequisite. CI never +gates a release. ## Task-to-test map @@ -154,8 +154,8 @@ code changes. Use the component overview to find the corresponding implementatio | Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` | | Inheritance scaling evidence | [Expansion benchmarks](../internal/engine/inheritance_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkInheritance' -benchmem` | | Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestLocalDocumentationLinks\|TestDependencyImportBoundary\|TestPublicFacadeBoundary'` | -| Development and release checker behavior | [Shell regression](../release_check_test.go) | `go test . -run 'TestCheckScripts\|TestDevelopmentCheck'` | -| Guarded release publication and recovery | [Publication regression](../release_publish_test.go) | `go test . -run '^TestReleasePublication'` | +| Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` | +| Release profile wiring | [Release profile test](../releasekit_profile_test.go) | `go test . -run '^TestReleaseKitProfile$'` | ## Fuzz validation diff --git a/docs/release.md b/docs/release.md index 3ea6ffc..b730815 100644 --- a/docs/release.md +++ b/docs/release.md @@ -84,9 +84,8 @@ go list -m gitea.maximumdirect.net/eric/ReleaseKit The module-selected executable is ReleaseKit v0.2.0. Do not use `go run` or `go tool` for publication: the native executable preserves its exit status and the publication-state diagnostics. ReleaseKit's child Go commands use the -`go` found on `PATH` with `GOTOOLCHAIN=local`; the existing -`CONFIGKIT_BASELINE_TOOLCHAIN` selector for ConfigKit's local release scripts -does not select their compiler. +`go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected executable +first on `PATH` through the publication command. Verify access to the authoritative remote without displaying credentials: diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 8c3e7d2..3a947db 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -139,7 +139,7 @@ second required Go/test run; all commands, version placeholders, and documented failure states match ReleaseKit v0.2.0. Do not execute its real publication command in this stage. -## Stage 5: Retire duplicated machinery and update developer guidance +## Stage 5: Retire duplicated machinery and update developer guidance — Complete 1. Remove `scripts/release.sh`, `scripts/release-check.sh`, and `release_publish_test.go` after Stages 1–4 supply the replacement. Remove diff --git a/docs/roadmap/releasekit.md b/docs/roadmap/releasekit.md index 7afb70f..c8e4edf 100644 --- a/docs/roadmap/releasekit.md +++ b/docs/roadmap/releasekit.md @@ -1,22 +1,24 @@ # Adopt ReleaseKit for source releases -Status: Proposed. This roadmap uses ReleaseKit v0.2.0 (tagged commit +Status: In progress. The ReleaseKit profile and workflow are in place; isolated +fixture verification remains in the implementation plan. This roadmap uses +ReleaseKit v0.2.0 (tagged commit `e030794969fb7787e647a90bbc4eb81bd0610f07`) as the initial adoption baseline. The [ReleaseKit release note](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.2.0/docs/releases/v0.2.0.md) summarizes its available capabilities. The staged [implementation plan](implementation.md) owns task order and acceptance checks. -## Purpose and current baseline +## Purpose and pre-adoption baseline -ConfigKit wants to use a common release process and retire duplicated local -release machinery. Today, [the release procedure](../release.md) uses +ConfigKit adopted a common release process to replace duplicated local release +machinery. Before this migration, [the release procedure](../release.md) used `scripts/release.sh` and `scripts/release-check.sh` to validate a committed -candidate, push it to `main`, and publish an annotated tag. Dedicated tests -exercise those scripts. The selected gates are one offline race-enabled Go +candidate, push it to `main`, and publish an annotated tag; dedicated tests +exercised those scripts. That workflow selected one offline race-enabled Go suite on a maintained Go 1.25 patch, one full vet pass, and CGO-disabled package -builds for Linux amd64 and arm64. The checked-in note and immutable tag are the -only declared deliverables. CI and macOS checks do not gate publication; no -hosted release page is declared. +builds for Linux amd64 and arm64. The checked-in note and immutable tag were +the only declared deliverables. CI and macOS checks did not gate publication; +no hosted release page was declared. ReleaseKit v0.2.0 offers [profile selections](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.2.0/docs/config.md) for that same test, vet, and source-only compilation set. It runs the test @@ -64,22 +66,20 @@ correctness fix requires it. This directive is the consumer minimum, not the release toolchain version. Use the latest locally available patch in the selected series to build the pinned ReleaseKit tool and run its validation, provided it meets that minimum and has native race prerequisites available. -ReleaseKit sets `GOTOOLCHAIN=local` for its child commands, so ConfigKit's -existing `CONFIGKIT_BASELINE_TOOLCHAIN` environment selection cannot select a -different validation toolchain. Prepare the tool and dependencies before -offline candidate validation; any `go.mod` change belongs in the committed -candidate. Review a minimum-Go increase as a compatibility change and explain -its consumer impact in the release note. Before v1.0.0, an intentional -increase requires a minor rather than patch release under ConfigKit's +ReleaseKit sets `GOTOOLCHAIN=local` for its child commands, so the same selected +Go executable must remain first on `PATH` for the tool build and validation. +Prepare the tool and dependencies before offline candidate validation; any +`go.mod` change belongs in the committed candidate. Review a minimum-Go increase +as a compatibility change and explain its consumer impact in the release note. +Before v1.0.0, an intentional increase requires a minor rather than patch +release under ConfigKit's [versioning policy](../policy/release.md#versioning-and-compatibility). -Use ReleaseKit's note-heading and exact effective-push-URL guards without -recreating ConfigKit's current exact-first-line-title or fetch-URL checks. -Continue writing release notes with the required `# ConfigKit ` -first line, while aligning release policy with the guards that actually run. +ReleaseKit v0.2.0 checks the release-note H1 and exact effective push URL; it +does not assert the required first-line format or the configured fetch URL. +Continue writing notes with the required `# ConfigKit ` first line. Per-test skip listings are optional diagnostic output, not a release gate; -ReleaseKit's one nonverbose standard suite still blocks publication on test -failure. +ReleaseKit's one standard suite still blocks publication on test failure. ReleaseKit owns generic candidate guards, test and compile coordination, tag creation, publication, and push recovery. ConfigKit owns version selection, @@ -89,36 +89,26 @@ release process adds no ConfigKit public API or runtime use of ReleaseKit. ## Changes from the baseline -- Add the minimal profile and declare ReleaseKit v0.2.0 as a tool dependency in - `go.mod`, updating `go.sum` and reviewing its dependency and license impact. - Document how to build the module-selected tool before offline validation. - The release runbook should record the selected series' minimum in `go.mod`, - use its latest locally available patch to build ReleaseKit and validate the - candidate, and keep the selected release command small. Do not re-create - generic Git guards or Go check sequencing in a wrapper. -- Revise the [release policy](../policy/release.md) and +- Added the minimal profile and declared ReleaseKit v0.2.0 as a tool dependency + in `go.mod`, with resolved checksums in `go.sum`. The library has no runtime + import of ReleaseKit, and tooling use alone requires no attribution entry. +- Updated the [release policy](../policy/release.md) and [maintainer procedure](../release.md) for candidate publication to `main` - before ReleaseKit's command, its effective-push-URL check, recurring Go - version selection and compatibility review, one selected validation pass, - tag-only publication, ReleaseKit's standard note and URL guards, optional - skip listings, and ReleaseKit's failure states. Preserve ConfigKit's - source-only deliverables, supported targets, and no-CI completion rule. -- Update the [development guide](../development.md) so its release guidance and - task-to-test map describe the adopted process. Keep ordinary development - checks separate from release gates; review other references to the retired - scripts. -- Retire `scripts/release.sh`, `scripts/release-check.sh`, and their duplicated - publication and gate tests once ReleaseKit takes over. Preserve the - development-check behavior and its tests from `release_check_test.go`. - Retain only a proportionate ConfigKit integration test for the selected - profile and invocation where it detects a real wiring error. -- Verify the migration without publishing a real ConfigKit version: exercise - the selected profile and release command against an isolated candidate and - temporary Git remote, including a validation failure and successful tag - publication. Confirm that the candidate's `go.mod` records the selected - series' minimum or a required higher patch, and that the same selected Go - executable builds ReleaseKit and runs the selected gates once. Do not make - a separate successful CI run a release condition. + before ReleaseKit validation, recurring Go version selection and compatibility + review, the selected validation set, tag-only publication, ReleaseKit's note + and URL guards, and its failure states. Source-only deliverables, supported + targets, and the no-CI completion rule remain unchanged. +- Updated the [development guide](../development.md) to distinguish everyday + development checks from release validation and to document the selected + profile test. +- Retired the local release scripts and duplicated publication/gate tests. + The development-check test and small profile-wiring test remain; generic + ReleaseKit publication behavior is not duplicated in ConfigKit's tests. +- The remaining migration verification exercises the selected profile and + release command against isolated candidates and a temporary Git remote, + including validation failure and successful tag publication. It confirms + the selected Go minimum and one invocation of each selected gate, without + publishing a real ConfigKit version or requiring a separate CI run. Historical tags and release notes remain unchanged. This adoption work does not itself authorize publication of a ConfigKit version. diff --git a/release_publish_test.go b/release_publish_test.go deleted file mode 100644 index 9de0e55..0000000 --- a/release_publish_test.go +++ /dev/null @@ -1,359 +0,0 @@ -package configkit - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -type releaseFixture struct { - t *testing.T - root string - remote string - base string - candidate string - validationLog string - realGit string -} - -func newReleaseFixture(t *testing.T) *releaseFixture { - t.Helper() - realGit, err := exec.LookPath("git") - if err != nil { - t.Fatal(err) - } - f := &releaseFixture{ - t: t, - root: t.TempDir(), - remote: filepath.Join(t.TempDir(), "remote.git"), - validationLog: filepath.Join(t.TempDir(), "validator.log"), - realGit: realGit, - } - f.git("init", "-q", "-b", "main") - f.git("init", "-q", "--bare", f.remote) - f.git("config", "user.name", "Test") - f.git("config", "user.email", "test@example.invalid") - f.git("config", "commit.gpgsign", "false") - f.git("config", "tag.gpgsign", "false") - f.git("config", "push.followTags", "true") - f.git("remote", "add", "origin", f.remote) - f.write(".gitignore", "ignored.log\n", 0600) - f.write("README.md", "fixture\n", 0600) - f.git("add", ".") - f.git("commit", "-qm", "Initial source") - f.base = f.git("rev-parse", "HEAD") - f.git("push", f.remote, "HEAD:refs/heads/main") - - script, err := os.ReadFile("scripts/release.sh") - if err != nil { - t.Fatal(err) - } - // Only the fixture copy substitutes the authoritative endpoint. - local := strings.ReplaceAll(string(script), "https://gitea.maximumdirect.net/eric/ConfigKit.git", f.remote) - f.write("scripts/release.sh", local, 0700) - f.write("scripts/release-check.sh", `#!/bin/sh -printf 'validate\n' >> "$CONFIGKIT_VALIDATION_LOG" -case "${CONFIGKIT_VALIDATOR_ACTION-}" in - fail) exit 37 ;; - change) git commit --allow-empty -qm 'Changed during validation' ;; - dirty) printf 'changed\n' > unexpected.txt ;; - remote-tag) git --git-dir="$CONFIGKIT_REMOTE" update-ref refs/tags/v0.9.0 "$CONFIGKIT_BASE" ;; -esac -`, 0700) - f.write("docs/releases/v0.9.0.md", "# ConfigKit v0.9.0\n\nConfiguration loading fixes.\n\nCompatibility is unchanged.\n", 0600) - f.git("add", ".") - f.git("commit", "-qm", "Prepared source") - f.candidate = f.git("rev-parse", "HEAD") - return f -} - -func (f *releaseFixture) write(path, contents string, mode os.FileMode) { - f.t.Helper() - target := filepath.Join(f.root, path) - if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil { - f.t.Fatal(err) - } - if err := os.WriteFile(target, []byte(contents), mode); err != nil { - f.t.Fatal(err) - } -} - -func (f *releaseFixture) git(args ...string) string { - f.t.Helper() - cmd := exec.Command(f.realGit, args...) - cmd.Dir = f.root - cmd.Env = append(os.Environ(), "GIT_ALLOW_PROTOCOL=file", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") - out, err := cmd.CombinedOutput() - if err != nil { - f.t.Fatalf("git %v: %v\n%s", args, err, out) - } - return strings.TrimSpace(string(out)) -} - -func (f *releaseFixture) gitResult(args ...string) (string, error) { - f.t.Helper() - cmd := exec.Command(f.realGit, args...) - cmd.Dir = f.root - cmd.Env = append(os.Environ(), "GIT_ALLOW_PROTOCOL=file", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") - out, err := cmd.CombinedOutput() - return strings.TrimSpace(string(out)), err -} - -func (f *releaseFixture) run(version, behavior, validatorAction string) (string, error) { - f.t.Helper() - bin := f.t.TempDir() - wrapper := `#!/bin/sh -if [ "${CONFIGKIT_GIT_BEHAVIOR-}" = main-unknown ] || [ "${CONFIGKIT_GIT_BEHAVIOR-}" = tag-unknown ]; then - if [ "${1-}" = ls-remote ] && [ -f "$CONFIGKIT_GIT_MARKER" ]; then exit 37; fi -fi -case "$*" in - push\ *|-c\ remote.origin.mirror=false\ push\ *) - case "$*" in - *refs/heads/main*) - case "${CONFIGKIT_GIT_BEHAVIOR-}" in - main-lost) "$CONFIGKIT_REAL_GIT" "$@" >/dev/null 2>&1; exit 37 ;; - main-reject) exit 37 ;; - main-unknown) : > "$CONFIGKIT_GIT_MARKER"; exit 37 ;; - esac ;; - *refs/tags/v0.9.0*) - case "${CONFIGKIT_GIT_BEHAVIOR-}" in - tag-lost) "$CONFIGKIT_REAL_GIT" "$@" >/dev/null 2>&1; exit 37 ;; - tag-reject) exit 37 ;; - tag-conflict) - "$CONFIGKIT_REAL_GIT" --git-dir="$CONFIGKIT_REMOTE" update-ref refs/tags/v0.9.0 "$CONFIGKIT_BASE" - exit 37 ;; - tag-unknown) : > "$CONFIGKIT_GIT_MARKER"; exit 37 ;; - esac ;; - esac - ;; -esac -exec "$CONFIGKIT_REAL_GIT" "$@" -` - if err := os.WriteFile(filepath.Join(bin, "git"), []byte(wrapper), 0700); err != nil { - f.t.Fatal(err) - } - nested := filepath.Join(f.root, "nested") - if err := os.Mkdir(nested, 0700); err != nil { - f.t.Fatal(err) - } - cmd := exec.Command("/bin/bash", filepath.Join(f.root, "scripts", "release.sh"), version) - cmd.Dir = nested - cmd.Env = append(os.Environ(), - "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), - "GIT_ALLOW_PROTOCOL=file", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", - "CONFIGKIT_VALIDATION_LOG="+f.validationLog, - "CONFIGKIT_VALIDATOR_ACTION="+validatorAction, - "CONFIGKIT_GIT_BEHAVIOR="+behavior, - "CONFIGKIT_GIT_MARKER="+filepath.Join(bin, "push-seen"), - "CONFIGKIT_REAL_GIT="+f.realGit, - "CONFIGKIT_REMOTE="+f.remote, - "CONFIGKIT_BASE="+f.base, - ) - out, err := cmd.CombinedOutput() - return string(out), err -} - -func (f *releaseFixture) validations() int { - f.t.Helper() - data, err := os.ReadFile(f.validationLog) - if os.IsNotExist(err) { - return 0 - } - if err != nil { - f.t.Fatal(err) - } - return strings.Count(string(data), "validate\n") -} - -func (f *releaseFixture) remoteRef(ref string) string { - f.t.Helper() - value, err := f.gitResult("--git-dir="+f.remote, "rev-parse", "--verify", ref) - if err != nil { - return "" - } - return value -} - -func TestReleasePublicationGuards(t *testing.T) { - for _, scenario := range []string{ - "success", "main-already-current", "ignored-file", "unrelated-tag", - "malformed-version", "wrong-branch", "wrong-destination", "multiple-destinations", - "dirty-worktree", "changed-candidate", "missing-note", "mismatched-note", - "local-tag", "remote-tag", "remote-tag-after-validation", - "dirty-after-validation", "remote-query-failure", - "failed-validation", "non-fast-forward", - } { - t.Run(scenario, func(t *testing.T) { - f := newReleaseFixture(t) - version := "v0.9.0" - validatorAction := "" - switch scenario { - case "main-already-current": - f.git("push", f.remote, "HEAD:refs/heads/main") - case "ignored-file": - f.write("ignored.log", "local only\n", 0600) - case "unrelated-tag": - f.git("tag", "-a", "-m", "Unrelated", "v0.8.0", f.base) - case "malformed-version": - version = "v00.9.0" - case "wrong-branch": - f.git("checkout", "-qb", "feature") - case "wrong-destination": - other := filepath.Join(t.TempDir(), "other.git") - f.git("init", "-q", "--bare", other) - f.git("remote", "set-url", "--push", "origin", other) - case "multiple-destinations": - f.git("remote", "set-url", "--push", "origin", f.remote) - f.git("remote", "set-url", "--add", "--push", "origin", filepath.Join(t.TempDir(), "other.git")) - case "dirty-worktree": - f.write("untracked.txt", "unexpected\n", 0600) - case "changed-candidate": - validatorAction = "change" - case "dirty-after-validation": - validatorAction = "dirty" - case "missing-note": - f.git("rm", "docs/releases/v0.9.0.md") - f.git("commit", "-qm", "Remove note") - case "mismatched-note": - f.write("docs/releases/v0.9.0.md", "# ConfigKit v0.9.1\n", 0600) - f.git("add", ".") - f.git("commit", "-qm", "Mismatch note") - case "local-tag": - f.git("tag", "v0.9.0") - case "remote-tag": - f.git("tag", "v0.9.0") - f.git("push", f.remote, "refs/tags/v0.9.0") - f.git("tag", "-d", "v0.9.0") - case "remote-tag-after-validation": - validatorAction = "remote-tag" - case "remote-query-failure": - if err := os.Rename(f.remote, f.remote+".unavailable"); err != nil { - t.Fatal(err) - } - case "failed-validation": - validatorAction = "fail" - case "non-fast-forward": - f.git("checkout", "-qb", "other", f.base) - f.git("commit", "--allow-empty", "-qm", "Divergent source") - f.git("push", f.remote, "HEAD:refs/heads/main") - f.git("checkout", "main") - } - out, err := f.run(version, "", validatorAction) - success := scenario == "success" || scenario == "main-already-current" || - scenario == "ignored-file" || scenario == "unrelated-tag" - if success != (err == nil) { - t.Fatalf("unexpected publication result: %v\n%s", err, out) - } - wantChecks := 1 - switch scenario { - case "malformed-version", "wrong-branch", "wrong-destination", - "multiple-destinations", "dirty-worktree", "missing-note", - "mismatched-note", "local-tag", "remote-tag", "remote-query-failure", - "non-fast-forward": - wantChecks = 0 - } - if got := f.validations(); got != wantChecks { - t.Fatalf("validator ran %d times, want %d", got, wantChecks) - } - if success { - if got := f.remoteRef("refs/heads/main"); got != f.candidate { - t.Fatalf("published main %s, want %s", got, f.candidate) - } - if got := f.remoteRef("refs/tags/v0.9.0^{}"); got != f.candidate { - t.Fatalf("tag points to %s, want %s", got, f.candidate) - } - if f.remoteRef("refs/tags/v0.8.0") != "" { - t.Fatal("unrelated local tag was published") - } - tagData := f.git("--git-dir="+f.remote, "cat-file", "-p", "refs/tags/v0.9.0") - _, message, ok := strings.Cut(tagData, "\n\n") - if !ok || strings.TrimSpace(message) != strings.TrimSpace(f.git("show", f.candidate+":docs/releases/v0.9.0.md")) { - t.Fatal("annotated tag does not contain the checked-in note") - } - } else if scenario != "remote-tag" && scenario != "remote-tag-after-validation" && - f.remoteRef("refs/tags/v0.9.0") != "" { - t.Fatal("failed guard published a tag") - } - }) - } -} - -func TestReleasePublicationRecovery(t *testing.T) { - for _, scenario := range []string{ - "main-lost", "main-reject", "main-unknown", - "tag-lost", "tag-reject", "tag-conflict", "tag-unknown", - } { - t.Run(scenario, func(t *testing.T) { - f := newReleaseFixture(t) - out, err := f.run("v0.9.0", scenario, "") - success := scenario == "main-lost" || scenario == "tag-lost" - if success != (err == nil) { - t.Fatalf("unexpected publication result: %v\n%s", err, out) - } - if got := f.validations(); got != 1 { - t.Fatalf("validator ran %d times", got) - } - remoteMain := f.remoteRef("refs/heads/main") - remoteTag := f.remoteRef("refs/tags/v0.9.0") - localTag, localTagErr := f.gitResult("rev-parse", "--verify", "refs/tags/v0.9.0") - if localTagErr != nil { - localTag = "" - } - switch scenario { - case "main-lost", "tag-lost": - if remoteMain != f.candidate || remoteTag == "" || remoteTag != localTag { - t.Fatal("confirmed publication did not preserve exact refs") - } - case "main-reject", "main-unknown": - if remoteMain != f.base || remoteTag != "" || localTag != "" { - t.Fatal("failed main push created publication state") - } - case "tag-reject", "tag-unknown": - if remoteMain != f.candidate || remoteTag != "" || localTag == "" { - t.Fatal("failed tag push did not retain branch-only state") - } - case "tag-conflict": - if remoteMain != f.candidate || remoteTag != f.base || localTag == "" { - t.Fatal("conflicting remote tag was overwritten") - } - } - }) - } -} - -func TestReleasePublicationUsesValidatedDestination(t *testing.T) { - for _, behavior := range []string{"success", "main-lost", "tag-lost"} { - t.Run(behavior, func(t *testing.T) { - f := newReleaseFixture(t) - other := filepath.Join(t.TempDir(), "other.git") - f.git("init", "-q", "--bare", other) - f.git("remote", "set-url", "--push", "origin", f.remote) - f.git("config", "url."+other+".pushInsteadOf", f.remote) - // These defaults must not broaden the explicit release refspecs when - // publishing through the validated named remote. - f.git("config", "remote.origin.mirror", "true") - f.git("config", "remote.origin.push", "refs/heads/*:refs/heads/*") - f.git("branch", "unrelated", f.base) - f.git("tag", "-a", "-m", "Unrelated", "v0.8.0", f.base) - f.git("--git-dir="+f.remote, "update-ref", "refs/heads/keep", f.base) - - out, err := f.run("v0.9.0", behavior, "") - if err != nil { - t.Fatalf("publish: %v\n%s", err, out) - } - if f.validations() != 1 || f.remoteRef("refs/heads/main") != f.candidate || - f.remoteRef("refs/tags/v0.9.0") != f.git("rev-parse", "refs/tags/v0.9.0") { - t.Fatal("publication did not reach the validated destination") - } - if f.remoteRef("refs/heads/keep") != f.base || - f.remoteRef("refs/heads/unrelated") != "" || f.remoteRef("refs/tags/v0.8.0") != "" { - t.Fatal("publication changed unrelated refs") - } - if refs := f.git("--git-dir="+other, "for-each-ref", "--format=%(refname)"); refs != "" { - t.Fatalf("publication reached the rewrite destination: %s", refs) - } - }) - } -} diff --git a/scripts/release-check.sh b/scripts/release-check.sh deleted file mode 100755 index 4376f53..0000000 --- a/scripts/release-check.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repository_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) -cd "$repository_dir" - -baseline_toolchain=${CONFIGKIT_BASELINE_TOOLCHAIN:?set CONFIGKIT_BASELINE_TOOLCHAIN to a cached supported Go 1.25 patch release} - -run_go() { - local toolchain=$1 - shift - GOTOOLCHAIN="$toolchain" GOPROXY=off go "$@" -} - -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 - -# Verbose output reports skipped cases without a separate evidence run. -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 -mod=readonly ./... -done diff --git a/scripts/release.sh b/scripts/release.sh deleted file mode 100755 index 740cd21..0000000 --- a/scripts/release.sh +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repository_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) -cd "$repository_dir" - -remote_url=https://gitea.maximumdirect.net/eric/ConfigKit.git - -fail() { - printf '%s\n' "$*" >&2 - exit 1 -} - -check_candidate() { - local context=$1 current status - current=$(git branch --show-current) || fail "$context: cannot read the current branch" - [[ "$current" == main ]] || fail "$context: release requires the main branch" - status=$(git status --porcelain=v1 --untracked-files=all) || fail "$context: cannot inspect the worktree" - [[ -z "$status" ]] || fail "$context: release requires a clean index and worktree" - current=$(git rev-parse --verify HEAD) || fail "$context: cannot identify the candidate" - [[ "$current" == "$candidate" ]] || fail "$context: candidate changed; commit and validate it again" -} - -check_unused_version() { - if git show-ref --verify --quiet "refs/tags/$version"; then - fail "version already has a local tag" - fi - local oid result - if oid=$(read_remote_ref "refs/tags/$version"); then - fail "version already has an authoritative tag" - else - result=$? - ((result == 2)) || fail "cannot determine authoritative tag state; publication status is unknown" - fi -} - -read_remote_ref() { - local ref=$1 response result - if response=$(git ls-remote --refs --exit-code origin "$ref" 2>/dev/null); then - [[ "$response" == *$'\t'"$ref" ]] || return 3 - printf '%s\n' "${response%%$'\t'*}" - else - result=$? - ((result == 2)) && return 2 - return 3 - fi -} - -if (($# != 1)); then - fail "usage: ./scripts/release.sh vMAJOR.MINOR.PATCH" -fi -version=$1 -[[ "$version" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || - fail "release version must be a stable vMAJOR.MINOR.PATCH without leading zeroes" - -fetch_urls=$(git remote get-url --all origin 2>/dev/null) || fail "origin is unavailable" -push_urls=$(git remote get-url --push --all origin 2>/dev/null) || fail "origin push destination is unavailable" -[[ "$fetch_urls" == "$remote_url" && "$push_urls" == "$remote_url" ]] || - fail "origin must have exactly the authoritative fetch and push destination" - -candidate=$(git rev-parse --verify HEAD) || fail "cannot identify the candidate" -check_candidate "before validation" -check_unused_version - -note_path="docs/releases/$version.md" -note_file=$(mktemp) -trap 'rm -f -- "$note_file"' EXIT -git show "$candidate:$note_path" >"$note_file" 2>/dev/null || - fail "matching release note is absent from the candidate" -IFS= read -r title <"$note_file" || [[ -n "$title" ]] || - fail "matching release note is empty" -[[ "$title" == "# ConfigKit $version" ]] || fail "release note title does not match the version" - -if remote_main=$(read_remote_ref refs/heads/main); then - : -else - result=$? - ((result == 2)) && fail "authoritative main is absent" - fail "cannot read authoritative main; publication status is unknown" -fi -git fetch --no-tags origin refs/heads/main >/dev/null || - fail "cannot fetch authoritative main" -fetched_main=$(git rev-parse --verify FETCH_HEAD) || fail "cannot inspect fetched main" -git merge-base --is-ancestor "$fetched_main" "$candidate" || - fail "candidate cannot advance authoritative main without discarding history" - -./scripts/release-check.sh || fail "release validation failed; no refs were published" -check_candidate "after validation" -check_unused_version - -# Use the named remote whose resolved destinations were checked above: a literal -# URL can apply pushInsteadOf even when origin's explicit push URL does not. -# Disable mirror defaults as well as implicit tags; publish only our refspec. -if git -c remote.origin.mirror=false push --no-follow-tags origin "$candidate:refs/heads/main"; then - : -else - if observed_main=$(read_remote_ref refs/heads/main); then - [[ "$observed_main" == "$candidate" ]] || - fail "main push did not publish candidate $candidate; authoritative main differs" - else - result=$? - ((result == 2)) && fail "main push failed for $candidate; authoritative main is absent" - fail "main push outcome is unknown for $candidate; inspect authoritative main before retrying" - fi -fi - -check_candidate "main at $candidate; tag not yet published" -git tag -a -F "$note_file" --cleanup=verbatim "$version" "$candidate" || - fail "main is at $candidate; could not create local tag $version" -tag_target=$(git rev-parse --verify "refs/tags/$version^{}") || - fail "main is at $candidate; cannot verify local tag $version" -[[ "$tag_target" == "$candidate" ]] || fail "main is at $candidate; local tag $version targets another commit" -tag_type=$(git cat-file -t "refs/tags/$version") || - fail "main is at $candidate; cannot inspect local tag $version" -[[ "$tag_type" == tag ]] || fail "main is at $candidate; local tag $version is not annotated" -tag_oid=$(git rev-parse --verify "refs/tags/$version") || - fail "main is at $candidate; cannot identify local tag $version" - -if git -c remote.origin.mirror=false push --no-follow-tags origin "refs/tags/$version:refs/tags/$version"; then - : -else - if observed_tag=$(read_remote_ref "refs/tags/$version"); then - [[ "$observed_tag" == "$tag_oid" ]] || - fail "authoritative tag $version conflicts with the local tag at $candidate; never replace it" - else - result=$? - ((result == 2)) && - fail "tag push failed; main is at $candidate, but local tag $version is unpublished" - fail "tag $version push outcome is unknown for $candidate; retain the local tag and inspect authoritative state" - fi -fi - -printf 'Published %s at %s\n' "$version" "$candidate"