Retire duplicated release scripts and tests
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <version>`
|
||||
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 <version>` 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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user