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