2026-08-30 19:20:12 +00:00
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" , 0 o644 )
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" , 0 o644 )
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 )
2026-09-28 01:50:26 +00:00
assertCheckerCalls ( t , fixture , 1 )
2026-08-30 19:20:12 +00:00
} )
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
` , 0 o755 )
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 )
2026-09-28 01:50:26 +00:00
writeReleaseFixtureFile ( t , filepath . Join ( fixture . worktree , ".git" , "info" , "exclude" ) , "ignored.tmp\n" , 0 o644 )
writeReleaseFixtureFile ( t , filepath . Join ( fixture . worktree , "ignored.tmp" ) , "local-only\n" , 0 o644 )
2026-08-30 19:20:12 +00:00
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 )
}
2026-09-28 01:50:26 +00:00
assertCheckerCalls ( t , fixture , 1 )
2026-08-30 19:20:12 +00:00
}
2026-09-28 01:50:26 +00:00
func TestReleaseCommandRetainsLocalTagAfterUnconfirmedPush ( t * testing . T ) {
2026-08-30 19:20:12 +00:00
fixture := newReleaseFixture ( t )
writeReleaseFixtureFile ( t , filepath . Join ( fixture . origin , "hooks" , "pre-receive" ) , "#!/bin/sh\nexit 1\n" , 0 o755 )
output , err := fixture . run ( )
2026-09-28 01:50:26 +00:00
if err == nil || ! strings . Contains ( output , "tag push for v1.2.3 failed or is unconfirmed" ) || strings . Contains ( output , "release: published" ) || strings . Contains ( output , "not published" ) {
2026-08-30 19:20:12 +00:00
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 )
}
2026-09-28 01:50:26 +00:00
assertCheckerCalls ( t , fixture , 1 )
}
func assertCheckerCalls ( t * testing . T , fixture * releaseFixture , want int ) {
t . Helper ( )
data , err := os . ReadFile ( fixture . checkerLog )
if err != nil {
t . Fatal ( err )
}
if got := len ( strings . Fields ( string ( data ) ) ) ; got != want {
t . Fatalf ( "candidate checker called %d times, want %d: %q" , got , want , data )
}
2026-08-30 19:20:12 +00:00
}
type releaseFixture struct {
2026-09-28 01:50:26 +00:00
worktree string
origin string
checkerLog string
extraEnv [ ] string
2026-08-30 19:20:12 +00:00
}
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" ) , 0 o755 ) ; err != nil {
t . Fatal ( err )
}
if err := os . MkdirAll ( filepath . Join ( worktree , "docs" , "releases" ) , 0 o755 ) ; err != nil {
t . Fatal ( err )
}
repoRoot := releaseCheckRepoRoot ( t )
copyReleaseFixtureFile ( t , filepath . Join ( repoRoot , "scripts" , "release-lib.sh" ) , filepath . Join ( worktree , "scripts" , "release-lib.sh" ) , 0 o755 )
copyReleaseFixtureFile ( t , filepath . Join ( repoRoot , "scripts" , "release.sh" ) , filepath . Join ( worktree , "scripts" , "release.sh" ) , 0 o755 )
writeReleaseFixtureFile ( t , filepath . Join ( worktree , "scripts" , "check-release-candidate.sh" ) , releaseCandidateStub , 0 o755 )
writeReleaseFixtureFile ( t , filepath . Join ( worktree , "docs" , "releases" , "v1.2.3.md" ) , "# Narratio v1.2.3\n" , 0 o644 )
writeReleaseFixtureFile ( t , filepath . Join ( worktree , "README.md" ) , "fixture\n" , 0 o644 )
2026-09-28 01:50:26 +00:00
fixture := & releaseFixture { worktree : worktree , origin : origin , checkerLog : filepath . Join ( root , "checker.log" ) }
2026-08-30 19:20:12 +00:00
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
2026-09-28 01:50:26 +00:00
command . Env = append ( os . Environ ( ) , "NARRATIO_RELEASE_CHECKER_LOG=" + fixture . checkerLog )
command . Env = append ( command . Env , fixture . extraEnv ... )
2026-08-30 19:20:12 +00:00
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
2026-09-28 01:50:26 +00:00
printf ' called \ n ' >> "$NARRATIO_RELEASE_CHECKER_LOG"
2026-08-30 19:20:12 +00:00
case "${NARRATIO_RELEASE_CHECKER_MODE:-success}" in
success ) ; ;
fail ) exit 1 ; ;
advance - origin ) "$NARRATIO_RELEASE_ADVANCE_ORIGIN" ; ;
* ) exit 1 ; ;
esac
`