Add guarded release tag command
This commit is contained in:
@@ -261,7 +261,7 @@ source validation.
|
||||
|
||||
## Stage 3 — Guarded Tag Publication Command
|
||||
|
||||
**Status: Pending**
|
||||
**Status: Completed**
|
||||
|
||||
### Goal
|
||||
|
||||
|
||||
248
internal/releasecheck/publication_test.go
Normal file
248
internal/releasecheck/publication_test.go
Normal file
@@ -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
|
||||
`
|
||||
104
scripts/release.sh
Executable file
104
scripts/release.sh
Executable file
@@ -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
|
||||
Reference in New Issue
Block a user