package releasecheck import ( "os" "os/exec" "path/filepath" "strings" "testing" ) func TestReleaseCommandRejectsWrongBranchAndDirtyCheckout(t *testing.T) { t.Run("wrong branch", func(t *testing.T) { fixture := newReleaseFixture(t) fixture.git("checkout", "-qb", "feature") fixture.mustFail(t, "release checkout must be on main") fixture.requireNoTag(t) }) t.Run("dirty checkout", func(t *testing.T) { fixture := newReleaseFixture(t) writeReleaseFixtureFile(t, filepath.Join(fixture.worktree, "dirty.txt"), "dirty\n", 0o644) fixture.mustFail(t, "release checkout must be clean") fixture.requireNoTag(t) }) } func TestReleaseCommandRejectsInvalidInvocation(t *testing.T) { fixture := newReleaseFixture(t) if output, err := fixture.runWith(); err == nil || !strings.Contains(output, "usage: scripts/release.sh VERSION") { t.Fatalf("missing argument error = %v\n%s", err, output) } if output, err := fixture.runWith("v1.2"); err == nil || !strings.Contains(output, "invalid stable version") { t.Fatalf("invalid version error = %v\n%s", err, output) } fixture.requireNoTag(t) } func TestReleaseCommandRejectsUnpublishedOrExistingCandidates(t *testing.T) { t.Run("head differs from origin main", func(t *testing.T) { fixture := newReleaseFixture(t) writeReleaseFixtureFile(t, filepath.Join(fixture.worktree, "local.txt"), "local\n", 0o644) fixture.git("add", "local.txt") fixture.git("commit", "-qm", "local only") fixture.mustFail(t, "HEAD must equal origin/main") fixture.requireNoTag(t) }) t.Run("candidate checker fails", func(t *testing.T) { fixture := newReleaseFixture(t) fixture.extraEnv = append(fixture.extraEnv, "NARRATIO_RELEASE_CHECKER_MODE=fail") fixture.mustFail(t, "release candidate validation failed") fixture.requireNoTag(t) }) t.Run("local tag exists", func(t *testing.T) { fixture := newReleaseFixture(t) fixture.git("tag", "v1.2.3") fixture.mustFail(t, "local tag already exists") }) t.Run("upstream tag exists", func(t *testing.T) { fixture := newReleaseFixture(t) fixture.git("tag", "v1.2.3") fixture.git("push", "origin", "refs/tags/v1.2.3:refs/tags/v1.2.3") fixture.git("tag", "-d", "v1.2.3") fixture.mustFail(t, "local tag already exists") }) } func TestReleaseCommandRejectsChangedUpstreamDuringValidation(t *testing.T) { fixture := newReleaseFixture(t) advance := filepath.Join(t.TempDir(), "advance-origin.sh") writeReleaseFixtureFile(t, advance, `#!/bin/sh set -eu clone_dir=$(mktemp -d) trap 'rm -rf "$clone_dir"' 0 HUP INT TERM git clone -q --branch main "$NARRATIO_RELEASE_TEST_ORIGIN" "$clone_dir" cd "$clone_dir" git config user.email releasecheck@example.test git config user.name 'Release Check' printf 'advance\n' > upstream.txt git add upstream.txt git commit -qm advance git push -q origin main `, 0o755) fixture.extraEnv = append(fixture.extraEnv, "NARRATIO_RELEASE_CHECKER_MODE=advance-origin", "NARRATIO_RELEASE_ADVANCE_ORIGIN="+advance, "NARRATIO_RELEASE_TEST_ORIGIN="+fixture.origin, ) fixture.mustFail(t, "candidate or origin/main changed during release validation") fixture.requireNoTag(t) } func TestReleaseCommandPushesOnlyTheRecordedLightweightTag(t *testing.T) { fixture := newReleaseFixture(t) candidate := fixture.gitOutput("rev-parse", "HEAD^{commit}") output, err := fixture.run() if err != nil { t.Fatalf("release command error = %v\n%s", err, output) } if !strings.Contains(output, "release: published v1.2.3 at "+candidate) { t.Fatalf("release output = %q", output) } if got := fixture.gitOutput("cat-file", "-t", "refs/tags/v1.2.3"); got != "commit" { t.Fatalf("local tag type = %q, want commit", got) } if got := fixture.gitOutput("rev-parse", "refs/tags/v1.2.3^{commit}"); got != candidate { t.Fatalf("local tag commit = %q, want %q", got, candidate) } if got := fixture.gitOutput("--git-dir", fixture.origin, "show-ref", "--verify", "--hash", "refs/tags/v1.2.3"); got != candidate { t.Fatalf("remote tag commit = %q, want %q", got, candidate) } if got := fixture.gitOutput("--git-dir", fixture.origin, "show-ref", "--verify", "--hash", "refs/heads/main"); got != candidate { t.Fatalf("remote main = %q, want %q", got, candidate) } if got := strings.Fields(fixture.gitOutput("--git-dir", fixture.origin, "for-each-ref", "--format=%(refname)", "refs/heads", "refs/tags")); !slicesEqual(got, []string{"refs/heads/main", "refs/tags/v1.2.3"}) { t.Fatalf("remote refs = %#v", got) } } func TestReleaseCommandKeepsUnpublishedLocalTagAfterPushFailure(t *testing.T) { fixture := newReleaseFixture(t) writeReleaseFixtureFile(t, filepath.Join(fixture.origin, "hooks", "pre-receive"), "#!/bin/sh\nexit 1\n", 0o755) output, err := fixture.run() if err == nil || !strings.Contains(output, "local tag v1.2.3 was created but not published") { t.Fatalf("push failure = %v\n%s", err, output) } if got := fixture.gitOutput("cat-file", "-t", "refs/tags/v1.2.3"); got != "commit" { t.Fatalf("local tag type after failed push = %q", got) } if output, err := runReleaseGit(fixture.worktree, "--git-dir", fixture.origin, "show-ref", "--verify", "refs/tags/v1.2.3"); err == nil { t.Fatalf("remote tag exists after failed push: %s", output) } } type releaseFixture struct { worktree string origin string extraEnv []string } func newReleaseFixture(t *testing.T) *releaseFixture { t.Helper() root := t.TempDir() worktree := filepath.Join(root, "worktree") origin := filepath.Join(root, "origin.git") if err := os.MkdirAll(filepath.Join(worktree, "scripts"), 0o755); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(worktree, "docs", "releases"), 0o755); err != nil { t.Fatal(err) } repoRoot := releaseCheckRepoRoot(t) copyReleaseFixtureFile(t, filepath.Join(repoRoot, "scripts", "release-lib.sh"), filepath.Join(worktree, "scripts", "release-lib.sh"), 0o755) copyReleaseFixtureFile(t, filepath.Join(repoRoot, "scripts", "release.sh"), filepath.Join(worktree, "scripts", "release.sh"), 0o755) writeReleaseFixtureFile(t, filepath.Join(worktree, "scripts", "check-release-candidate.sh"), releaseCandidateStub, 0o755) writeReleaseFixtureFile(t, filepath.Join(worktree, "docs", "releases", "v1.2.3.md"), "# Narratio v1.2.3\n", 0o644) writeReleaseFixtureFile(t, filepath.Join(worktree, "README.md"), "fixture\n", 0o644) fixture := &releaseFixture{worktree: worktree, origin: origin} fixture.git("init", "-qb", "main") fixture.git("config", "user.email", "releasecheck@example.test") fixture.git("config", "user.name", "Release Check") fixture.git("add", ".") fixture.git("commit", "-qm", "fixture") if output, err := runReleaseGit(worktree, "init", "--bare", "-q", origin); err != nil { t.Fatalf("init bare origin: %v\n%s", err, output) } fixture.git("remote", "add", "origin", origin) fixture.git("push", "-qu", "origin", "main") return fixture } func (fixture *releaseFixture) run() (string, error) { return fixture.runWith("v1.2.3") } func (fixture *releaseFixture) runWith(args ...string) (string, error) { commandArgs := append([]string{filepath.Join(fixture.worktree, "scripts", "release.sh")}, args...) command := exec.Command("sh", commandArgs...) command.Dir = fixture.worktree command.Env = append(os.Environ(), fixture.extraEnv...) output, err := command.CombinedOutput() return string(output), err } func (fixture *releaseFixture) mustFail(t *testing.T, want string) { t.Helper() output, err := fixture.run() if err == nil || !strings.Contains(output, want) { t.Fatalf("release failure = %v\n%s", err, output) } } func (fixture *releaseFixture) requireNoTag(t *testing.T) { t.Helper() if fixture.gitOutput("show-ref", "--verify", "refs/tags/v1.2.3") != "" { t.Fatal("unexpected local tag") } if output, err := runReleaseGit(fixture.worktree, "--git-dir", fixture.origin, "show-ref", "--verify", "refs/tags/v1.2.3"); err == nil { t.Fatalf("unexpected remote tag: %s", output) } } func (fixture *releaseFixture) git(args ...string) { if output, err := runReleaseGit(fixture.worktree, args...); err != nil { panic("fixture git command failed: " + output) } } func (fixture *releaseFixture) gitOutput(args ...string) string { output, err := runReleaseGit(fixture.worktree, args...) if err != nil { return "" } return strings.TrimSpace(output) } func runReleaseGit(dir string, args ...string) (string, error) { command := exec.Command("git", args...) command.Dir = dir output, err := command.CombinedOutput() return string(output), err } func copyReleaseFixtureFile(t *testing.T, source, destination string, mode os.FileMode) { t.Helper() data, err := os.ReadFile(source) if err != nil { t.Fatal(err) } writeReleaseFixtureFile(t, destination, string(data), mode) } func writeReleaseFixtureFile(t *testing.T, path, contents string, mode os.FileMode) { t.Helper() if err := os.WriteFile(path, []byte(contents), mode); err != nil { t.Fatal(err) } } const releaseCandidateStub = `#!/bin/sh set -eu case "${NARRATIO_RELEASE_CHECKER_MODE:-success}" in success) ;; fail) exit 1 ;; advance-origin) "$NARRATIO_RELEASE_ADVANCE_ORIGIN" ;; *) exit 1 ;; esac `