package scripts import ( "os" "os/exec" "path/filepath" "strings" "testing" ) // Keep publication tests offline: real Git repositories exercise ref safety, // while a fake Go command represents the expensive candidate validation boundary. type releaseFixture struct { t *testing.T root string remote string bin string log string initial string } func writeFile(t *testing.T, path, content string, mode os.FileMode) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), mode); err != nil { t.Fatal(err) } } func command(t *testing.T, dir string, args ...string) string { t.Helper() cmd := exec.Command(args[0], args[1:]...) cmd.Dir = dir output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("%v: %v\n%s", args, err, output) } return strings.TrimSpace(string(output)) } func newReleaseFixture(t *testing.T) *releaseFixture { t.Helper() dir := t.TempDir() f := &releaseFixture{t: t, root: filepath.Join(dir, "work"), remote: filepath.Join(dir, "origin.git"), bin: filepath.Join(dir, "bin"), log: filepath.Join(dir, "validation.log")} command(t, dir, "git", "init", "--bare", f.remote) command(t, dir, "git", "init", "-b", "main", f.root) f.git("config", "user.name", "Release Test") f.git("config", "user.email", "release@example.invalid") f.git("config", "commit.gpgSign", "false") f.git("config", "tag.gpgSign", "false") for _, name := range []string{"release.sh", "check-release-source.sh"} { content, err := os.ReadFile(name) if err != nil { t.Fatal(err) } writeFile(t, filepath.Join(f.root, "scripts", name), string(content), 0755) } writeFile(t, filepath.Join(f.root, ".gitignore"), "vendor/\n", 0644) f.git("add", ".") f.git("commit", "-m", "Initial source") f.git("remote", "add", "origin", f.remote) f.git("push", "origin", "main") f.initial = f.git("rev-parse", "HEAD") writeFile(t, filepath.Join(f.root, "docs/releases/v0.8.0.md"), "# Notarius v0.8.0\n\nA concise note without fixed sections.\n", 0644) f.git("add", ".") f.git("commit", "-m", "Prepare release") writeFile(t, filepath.Join(f.bin, "go"), `#!/bin/sh set -eu [ "$GOWORK" = off ] printf '%s\n' "$1" >> "$RELEASE_TEST_LOG" case "$1" in test) [ "${RELEASE_TEST_FAIL:-}" != test ] || exit 1 if [ "${RELEASE_TEST_CHANGE:-}" = yes ]; then printf 'changed\n' >> docs/releases/v0.8.0.md fi ;; build) while [ "$1" != -o ]; do shift; done shift printf '#!/bin/sh\nprintf "notarius v0.8.0\\n"\n' > "$1" chmod +x "$1" ;; *) exit 1 ;; esac `, 0755) return f } func (f *releaseFixture) git(args ...string) string { f.t.Helper() return command(f.t, f.root, append([]string{"git"}, args...)...) } func (f *releaseFixture) run(version string, env ...string) (string, error) { f.t.Helper() cmd := exec.Command("sh", "scripts/release.sh", version) cmd.Dir = f.root cmd.Env = append(os.Environ(), "PATH="+f.bin+string(os.PathListSeparator)+os.Getenv("PATH"), "RELEASE_TEST_LOG="+f.log) cmd.Env = append(cmd.Env, env...) output, err := cmd.CombinedOutput() return string(output), err } func TestReleasePublishesExactCandidateOnce(t *testing.T) { f := newReleaseFixture(t) // Neither ignored local files nor unrelated local tags are release inputs. writeFile(t, filepath.Join(f.root, "vendor/local.txt"), "ignored", 0644) f.git("tag", "unrelated") f.git("tag", "-a", "unrelated-annotated", "-m", "Do not publish") f.git("config", "push.followTags", "true") candidate := f.git("rev-parse", "HEAD") output, err := f.run("v0.8.0") if err != nil { t.Fatalf("release: %v\n%s", err, output) } for _, ref := range []string{"refs/heads/main", "refs/tags/v0.8.0"} { if got := command(t, f.remote, "git", "rev-parse", ref); got != candidate { t.Fatalf("%s = %s, want %s", ref, got, candidate) } } if got := command(t, f.remote, "git", "cat-file", "-t", "v0.8.0"); got != "commit" { t.Fatalf("tag type = %s", got) } if got := f.git("ls-remote", "--tags", "origin", "refs/tags/unrelated*"); got != "" { t.Fatal("published unrelated tag") } log, err := os.ReadFile(f.log) if err != nil || string(log) != "test\nbuild\n" { t.Fatalf("validation was not performed once: %q, %v", log, err) } } func TestReleaseGuardsPreventPublication(t *testing.T) { for _, name := range []string{"invalid_version", "missing_note", "wrong_title", "dirty", "staged", "wrong_branch", "local_tag", "remote_tag", "remote_ahead", "remote_unavailable", "validation_failure", "candidate_changed"} { t.Run(name, func(t *testing.T) { f := newReleaseFixture(t) version := "v0.8.0" var env []string switch name { case "invalid_version": version = "v00.8.0" case "missing_note": version = "v0.8.1" case "wrong_title": writeFile(t, filepath.Join(f.root, "docs/releases/v0.8.0.md"), "# Wrong version\n", 0644) f.git("add", ".") f.git("commit", "-m", "Wrong title") case "dirty", "staged": writeFile(t, filepath.Join(f.root, "dirty.txt"), "dirty", 0644) if name == "staged" { f.git("add", ".") } case "wrong_branch": f.git("checkout", "-b", "other") case "local_tag": f.git("tag", version) case "remote_tag": f.git("push", "origin", f.initial+":refs/tags/"+version) case "remote_ahead": // Publish an independent descendant of the old remote tip. f.git("checkout", "-b", "other", f.initial) f.git("commit", "--allow-empty", "-m", "Other work") f.git("push", "origin", "HEAD:main") f.initial = f.git("rev-parse", "HEAD") f.git("checkout", "main") case "remote_unavailable": f.git("remote", "set-url", "origin", filepath.Join(t.TempDir(), "missing.git")) case "validation_failure": env = []string{"RELEASE_TEST_FAIL=test"} case "candidate_changed": env = []string{"RELEASE_TEST_CHANGE=yes"} } if output, err := f.run(version, env...); err == nil { t.Fatalf("guard allowed publication: %s", output) } if got := command(t, f.remote, "git", "rev-parse", "main"); got != f.initial { t.Fatal("guard advanced remote branch") } if name != "remote_tag" { if got := command(t, f.remote, "git", "tag", "--list"); got != "" { t.Fatalf("guard published tag: %s", got) } } }) } } func TestReleasePushFailureRecovery(t *testing.T) { for _, mode := range []string{"branch_rejected", "tag_rejected", "tag_accepted_disconnect", "tag_unknown"} { t.Run(mode, func(t *testing.T) { f := newReleaseFixture(t) if mode == "branch_rejected" || mode == "tag_rejected" { pattern := "refs/heads/main" if mode == "tag_rejected" { pattern = "refs/tags/*" } writeFile(t, filepath.Join(f.remote, "hooks/update"), "#!/bin/sh\ncase \"$1\" in "+pattern+") exit 1;; esac\n", 0755) } else { realGit, err := exec.LookPath("git") if err != nil { t.Fatal(err) } // Simulate a lost acknowledgement, optionally followed by an outage. writeFile(t, filepath.Join(f.bin, "git"), `#!/bin/sh set -eu if [ "$1" = -c ] && [ "$3" = push ] && [ "$5" = refs/tags/v0.8.0:refs/tags/v0.8.0 ]; then "$RELEASE_TEST_GIT" "$@" touch "$RELEASE_TEST_MARKER" exit 1 fi if [ "$1" = ls-remote ] && [ -e "$RELEASE_TEST_MARKER" ] && [ "$RELEASE_TEST_MODE" = tag_unknown ]; then exit 1 fi exec "$RELEASE_TEST_GIT" "$@" `, 0755) output, err := f.run("v0.8.0", "RELEASE_TEST_GIT="+realGit, "RELEASE_TEST_MARKER="+filepath.Join(f.bin, "marker"), "RELEASE_TEST_MODE="+mode) if (err == nil) != (mode == "tag_accepted_disconnect") { t.Fatalf("unexpected outcome: %v\n%s", err, output) } if got := command(t, f.remote, "git", "rev-parse", "v0.8.0"); got != f.git("rev-parse", "HEAD") { t.Fatal("wrong remote tag") } return } if output, err := f.run("v0.8.0"); err == nil { t.Fatalf("rejected push reported success: %s", output) } if got := command(t, f.remote, "git", "tag", "--list"); got != "" { t.Fatal("rejected push published tag") } if mode == "tag_rejected" { if got := f.git("rev-parse", "v0.8.0"); got != f.git("rev-parse", "HEAD") { t.Fatal("unpublished local tag not preserved") } } else if got := f.git("tag", "--list"); got != "" { t.Fatal("created tag after failed branch push") } }) } }