diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index a95f06d..a678262 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -261,7 +261,7 @@ source validation. ## Stage 3 — Guarded Tag Publication Command -**Status: Pending** +**Status: Completed** ### Goal diff --git a/internal/releasecheck/publication_test.go b/internal/releasecheck/publication_test.go new file mode 100644 index 0000000..5a1b65b --- /dev/null +++ b/internal/releasecheck/publication_test.go @@ -0,0 +1,248 @@ +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 +` diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..927b7b2 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,104 @@ +#!/bin/sh +set -eu + +script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd -P) +# shellcheck source=release-lib.sh +. "$script_dir/release-lib.sh" + +tool_name=release + +if [ "$#" -ne 1 ]; then + narratio_release_fail "$tool_name" 'usage: scripts/release.sh VERSION' +fi + +release_version=$1 +if ! narratio_release_validate_version "$release_version"; then + narratio_release_fail "$tool_name" "invalid stable version: $release_version" +fi + +repo_root=$(narratio_release_repo_root "$0") || narratio_release_fail "$tool_name" 'cannot locate repository root' +if ! cd "$repo_root"; then + narratio_release_fail "$tool_name" "cannot enter repository root: $repo_root" +fi + +narratio_release_require_main_branch() { + current_branch=$(git branch --show-current) || narratio_release_fail "$tool_name" 'cannot determine current branch' + if [ "$current_branch" != main ]; then + narratio_release_fail "$tool_name" 'release checkout must be on main' + fi +} + +narratio_release_require_clean_checkout() { + if [ -n "$(git status --porcelain)" ]; then + narratio_release_fail "$tool_name" 'release checkout must be clean' + fi +} + +narratio_release_fetch_origin() { + if ! git fetch origin main --tags; then + narratio_release_fail "$tool_name" 'failed to fetch origin main and tags' + fi +} + +narratio_release_require_no_local_tag() { + if git show-ref --verify --quiet "refs/tags/$release_version"; then + narratio_release_fail "$tool_name" "local tag already exists: $release_version" + fi +} + +narratio_release_require_no_remote_tag() { + if ! remote_tag=$(git ls-remote --tags origin "refs/tags/$release_version"); then + narratio_release_fail "$tool_name" "cannot inspect tag at origin: $release_version" + fi + if [ -n "$remote_tag" ]; then + narratio_release_fail "$tool_name" "upstream tag already exists: $release_version" + fi +} + +narratio_release_require_main_branch +narratio_release_require_clean_checkout +narratio_release_fetch_origin + +candidate_commit=$(git rev-parse --verify 'HEAD^{commit}') || narratio_release_fail "$tool_name" 'cannot resolve HEAD commit' +upstream_commit=$(git rev-parse --verify 'origin/main^{commit}') || narratio_release_fail "$tool_name" 'cannot resolve origin/main commit' +if [ "$candidate_commit" != "$upstream_commit" ]; then + narratio_release_fail "$tool_name" 'HEAD must equal origin/main before release validation' +fi +release_note="docs/releases/$release_version.md" +if ! git cat-file -e "$candidate_commit:$release_note"; then + narratio_release_fail "$tool_name" "candidate commit is missing release note: $release_note" +fi +narratio_release_require_no_local_tag +narratio_release_require_no_remote_tag + +if ! "$script_dir/check-release-candidate.sh" "$release_version"; then + narratio_release_fail "$tool_name" 'release candidate validation failed' +fi + +narratio_release_fetch_origin +narratio_release_require_main_branch +narratio_release_require_clean_checkout +current_commit=$(git rev-parse --verify 'HEAD^{commit}') || narratio_release_fail "$tool_name" 'cannot resolve HEAD commit after validation' +current_upstream_commit=$(git rev-parse --verify 'origin/main^{commit}') || narratio_release_fail "$tool_name" 'cannot resolve origin/main commit after validation' +if [ "$current_commit" != "$candidate_commit" ] || [ "$current_upstream_commit" != "$candidate_commit" ]; then + narratio_release_fail "$tool_name" 'candidate or origin/main changed during release validation' +fi +narratio_release_require_no_local_tag +narratio_release_require_no_remote_tag + +if ! git -c tag.gpgSign=false tag "$release_version" "$candidate_commit"; then + narratio_release_fail "$tool_name" "cannot create local tag: $release_version" +fi +if [ "$(git cat-file -t "refs/tags/$release_version")" != commit ]; then + narratio_release_fail "$tool_name" "local tag is not lightweight: $release_version" +fi +if [ "$(git rev-parse --verify "refs/tags/$release_version^{commit}")" != "$candidate_commit" ]; then + narratio_release_fail "$tool_name" "local tag does not resolve to candidate commit: $release_version" +fi + +if ! git push origin "refs/tags/$release_version:refs/tags/$release_version"; then + printf 'release: local tag %s was created but not published; inspect it before retrying\n' "$release_version" >&2 + exit 1 +fi +printf 'release: published %s at %s\n' "$release_version" "$candidate_commit" +exit 0