Add guarded single-command release publication
This commit is contained in:
322
release_publish_test.go
Normal file
322
release_publish_test.go
Normal file
@@ -0,0 +1,322 @@
|
||||
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
|
||||
if [ "${1-}" = push ]; then
|
||||
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
|
||||
fi
|
||||
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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user