package releasekit_test import ( "context" "errors" "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "time" releasekit "gitea.maximumdirect.net/eric/ReleaseKit" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git" ) const releaseNote = "# Sample App v1.2.3\n\nA source release.\n\nUpgrade notes stay intact.\n" func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) { repo := newPublishFixture(t) wrapper := installGitWrapper(t, repo, "pass") t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH")) suiteRuns := filepath.Join(t.TempDir(), "suite-runs") t.Setenv("RELEASEKIT_FIXTURE_SUITE_RUNS", suiteRuns) refsBefore := gitText(t, repo.bare, "show-ref", "--heads", "--tags") commit := repo.commit var validations int result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", AdditionalChecks: func(_ context.Context, candidate releasekit.Candidate) error { validations++ runs, readErr := os.ReadFile(suiteRuns) if readErr != nil || string(runs) != "ran\n" { return fmt.Errorf("standard suite runs before additional checks: contents=%q error=%v", runs, readErr) } if candidate.RepositoryDir != repo.work || candidate.Commit != commit || candidate.Version != "v1.2.3" { t.Fatalf("callback candidate = %#v", candidate) } return nil }, }) if err != nil { t.Fatal(err) } if validations != 1 { t.Fatalf("validation calls = %d, want 1", validations) } if runs, err := os.ReadFile(suiteRuns); err != nil || string(runs) != "ran\n" { t.Fatalf("standard suite runs = %q, error = %v; want one run", runs, err) } if queries := countLines(t, wrapper.queryLog); queries != 3 { t.Fatalf("authoritative tag query count = %d, want 3 guards and no success read-back", queries) } if result.State != releasekit.PublicationPublished || result.Phase != releasekit.PublicationComplete { t.Fatalf("publication result = %#v", result) } if result.CandidateCommit != commit || result.Endpoint != repo.bare || result.TagObject == "" { t.Fatalf("publication identity = %#v", result) } refsAfter := gitText(t, repo.bare, "show-ref", "--heads", "--tags") for _, ref := range strings.Split(refsBefore, "\n") { if !strings.Contains(refsAfter, ref) { t.Fatalf("preexisting remote ref %q changed or disappeared; refs now:\n%s", ref, refsAfter) } } remoteTag := gitText(t, repo.bare, "rev-parse", "refs/tags/v1.2.3") if remoteTag != result.TagObject { t.Fatalf("remote tag object = %s, want %s", remoteTag, result.TagObject) } if peeled := gitText(t, repo.bare, "rev-parse", "refs/tags/v1.2.3^{}"); peeled != commit { t.Fatalf("remote tag peels to %s, want %s", peeled, commit) } if tagType := gitText(t, repo.bare, "cat-file", "-t", "refs/tags/v1.2.3"); tagType != "tag" { t.Fatalf("remote tag object type = %q, want tag", tagType) } tagContents := gitBytes(t, repo.bare, "cat-file", "tag", "refs/tags/v1.2.3") separator := strings.Index(tagContents, "\n\n") if separator < 0 || tagContents[separator+2:] != releaseNote { t.Fatalf("annotated tag message = %q, want %q", tagContents[separator+2:], releaseNote) } if strings.Contains(tagContents, "-----BEGIN PGP SIGNATURE-----") { t.Fatal("publication tag unexpectedly contains a signature") } if !strings.Contains(refsAfter, "refs/heads/unrelated") || !strings.Contains(refsAfter, "refs/tags/v0.9.0") { t.Fatalf("publication changed unrelated refs:\n%s", refsAfter) } if !strings.Contains(refsAfter, "refs/tags/v1.2.3") || strings.Count(refsAfter, "refs/tags/") != 2 { t.Fatalf("publication pushed unexpected tag refs:\n%s", refsAfter) } } func TestPublishRejectsUnexpectedPushURLBeforeValidation(t *testing.T) { repo := newPublishFixture(t) suiteRuns := filepath.Join(t.TempDir(), "suite-runs") t.Setenv("RELEASEKIT_FIXTURE_SUITE_RUNS", suiteRuns) expected := repo.bare + string(os.PathSeparator) var callbacks int result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", ExpectedPushURL: expected, AdditionalChecks: func(context.Context, releasekit.Candidate) error { callbacks++ return nil }, }) if !errors.Is(err, releasekit.ErrUnexpectedPushURL) { t.Fatalf("Publish() error = %v, want ErrUnexpectedPushURL", err) } if !strings.Contains(err.Error(), fmt.Sprintf("expected %q", expected)) || !strings.Contains(err.Error(), fmt.Sprintf("resolved %q", repo.bare)) { t.Fatalf("Publish() error = %v, want expected and resolved URLs", err) } if result.State != releasekit.PublicationNotAttempted || result.Phase != releasekit.PublicationPreflight || result.Endpoint != repo.bare || result.TagObject != "" { t.Fatalf("publication result = %#v, want preflight failure at resolved endpoint", result) } if callbacks != 0 { t.Fatalf("additional checks ran %d times, want none", callbacks) } if _, statErr := os.Stat(suiteRuns); !errors.Is(statErr, os.ErrNotExist) { t.Fatalf("standard-suite marker stat error = %v, want no suite run", statErr) } if got := gitText(t, repo.work, "tag", "--list", "v1.2.3"); got != "" { t.Fatalf("local tag exists after URL mismatch: %q", got) } } func TestPublishComparesExpectedURLWithResolvedInsteadOfDestination(t *testing.T) { repo := newPublishFixture(t) alias := "releasekit-expected-push:" gitRun(t, repo.work, "config", "url."+repo.bare+".insteadOf", alias) gitRun(t, repo.work, "remote", "set-url", "origin", alias) result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", ExpectedPushURL: repo.bare, }) if err != nil { t.Fatalf("Publish() with the resolved insteadOf destination failed: %v", err) } if result.Endpoint != repo.bare || result.State != releasekit.PublicationPublished { t.Fatalf("publication result = %#v, want resolved push URL and successful publication", result) } } func TestPublishExpectedPushURLUsesPushURLWhenFetchAndPushDiffer(t *testing.T) { repo := newPublishFixture(t) fetchURL := filepath.Join(t.TempDir(), "fetch-only.git") gitRun(t, repo.work, "remote", "set-url", "origin", fetchURL) gitRun(t, repo.work, "config", "remote.origin.pushurl", repo.bare) result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", ExpectedPushURL: fetchURL, }) if !errors.Is(err, releasekit.ErrUnexpectedPushURL) { t.Fatalf("Publish() error = %v, want ErrUnexpectedPushURL", err) } if result.Endpoint != repo.bare || !strings.Contains(err.Error(), fmt.Sprintf("resolved %q", repo.bare)) { t.Fatalf("result = %#v, error = %v; want the effective push URL %q", result, err, repo.bare) } } func TestCheckIgnoresExpectedPushURL(t *testing.T) { repo := newPublishFixture(t) var callbacks int _, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{ RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", ExpectedPushURL: "unrelated destination", AdditionalChecks: func(context.Context, releasekit.Candidate) error { callbacks++ return nil }, }) if err != nil { t.Fatalf("Check() considered publication URL expectation: %v", err) } if callbacks != 1 { t.Fatalf("additional checks ran %d times, want one", callbacks) } } func TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks(t *testing.T) { requireRacePrerequisites(t) repo := newPublishFixture(t) raceMarker := filepath.Join(t.TempDir(), "race-enabled") t.Setenv("RELEASEKIT_RACE_MARKER", raceMarker) checks := 0 _, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{ RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", Validation: releasekit.ValidationSpec{ Go: releasekit.GoValidationSpec{Race: true}, }, AdditionalChecks: func(context.Context, releasekit.Candidate) error { checks++ return nil }, }) if err != nil { t.Fatalf("Check() error = %v", err) } if checks != 1 { t.Fatalf("additional checks ran %d times, want once", checks) } if _, err := os.Stat(raceMarker); err != nil { t.Fatalf("race-enabled fixture did not run: %v", err) } } func TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation(t *testing.T) { repo := newPublishFixture(t) t.Setenv("RELEASEKIT_FAIL_FIXTURE_SUITE", "1") var checks int result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", AdditionalChecks: func(context.Context, releasekit.Candidate) error { checks++ return nil }, }) if err == nil || !strings.Contains(err.Error(), "fixture standard suite failure") { t.Fatalf("Publish() error = %v, want fixture standard suite failure", err) } var validationError *releasekit.ValidationError if !errors.As(err, &validationError) || validationError.Check != "go-test" { t.Fatalf("Publish() error = %v, want a public go-test validation error", err) } var exitErr *exec.ExitError if !errors.As(err, &exitErr) { t.Fatalf("Publish() error = %v, want the Go process error preserved", err) } if !errors.Is(err, exitErr) { t.Fatal("Publish() error does not preserve the Go process error identity") } if checks != 0 || result.TagObject != "" || result.State != releasekit.PublicationNotAttempted { t.Fatalf("failed standard suite result=%#v additional checks=%d", result, checks) } for _, command := range [][]string{ {"git", "-C", repo.work, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3"}, {"git", "--git-dir", repo.bare, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3"}, } { if _, err := exec.Command(command[0], command[1:]...).CombinedOutput(); err == nil { t.Fatalf("failed standard suite created release tag with %v", command) } } } func requireRacePrerequisites(t *testing.T) { t.Helper() supported := runtime.GOOS == "darwin" && (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64") || (runtime.GOOS == "freebsd" || runtime.GOOS == "linux") && (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64" || runtime.GOARCH == "ppc64le") || runtime.GOOS == "windows" && runtime.GOARCH == "amd64" if !supported { t.Skipf("Go race detector is not supported on %s/%s", runtime.GOOS, runtime.GOARCH) } cgo, err := exec.Command("go", "env", "CGO_ENABLED").Output() if err != nil || strings.TrimSpace(string(cgo)) != "1" { t.Skip("Go race detector requires CGO_ENABLED=1") } cc, err := exec.Command("go", "env", "CC").Output() if err != nil || len(strings.Fields(string(cc))) == 0 { t.Skip("Go race detector requires a C compiler") } if _, err := exec.LookPath(strings.Fields(string(cc))[0]); err != nil { t.Skip("Go race detector C compiler is unavailable") } } func TestCheckReturnsPublicValidationError(t *testing.T) { repo := newPublishFixture(t) t.Setenv("RELEASEKIT_FAIL_FIXTURE_SUITE", "1") _, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{ RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", }) var validationError *releasekit.ValidationError if !errors.As(err, &validationError) || validationError.Check != "go-test" { t.Fatalf("Check() error = %v, want a public go-test validation error", err) } var exitErr *exec.ExitError if !errors.As(err, &exitErr) { t.Fatalf("Check() error = %v, want the Go process error preserved", err) } if !errors.Is(err, exitErr) { t.Fatal("Check() error does not preserve the Go process error identity") } } func TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation(t *testing.T) { repo := newPublishFixture(t) suiteRuns := filepath.Join(t.TempDir(), "suite-runs") t.Setenv("RELEASEKIT_FIXTURE_SUITE_RUNS", suiteRuns) wantErr := errors.New("additional product check failed") var checks int result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", AdditionalChecks: func(context.Context, releasekit.Candidate) error { checks++ return wantErr }, }) if !errors.Is(err, wantErr) { t.Fatalf("Publish() error = %v, want wrapped additional-check error", err) } var validationError *releasekit.ValidationError if errors.As(err, &validationError) { t.Fatalf("Publish() callback error was reclassified as validation failure: %v", err) } if runs, readErr := os.ReadFile(suiteRuns); readErr != nil || string(runs) != "ran\n" { t.Fatalf("standard suite runs = %q, error = %v; want one successful run", runs, readErr) } if checks != 1 || result.TagObject != "" || result.State != releasekit.PublicationNotAttempted { t.Fatalf("additional-check failure result=%#v checks=%d", result, checks) } for _, command := range [][]string{ {"git", "-C", repo.work, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3"}, {"git", "--git-dir", repo.bare, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3"}, } { if _, err := exec.Command(command[0], command[1:]...).CombinedOutput(); err == nil { t.Fatalf("additional-check failure created release tag with %v", command) } } } func TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished(t *testing.T) { repo := newPublishFixture(t) wrapper := installGitWrapper(t, repo, "pass") t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH")) hook := filepath.Join(repo.bare, "hooks", "pre-receive") if err := os.WriteFile(hook, []byte("#!/bin/sh\ncat >/dev/null\nexit 1\n"), 0o755); err != nil { t.Fatal(err) } result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil }, }) if err == nil { t.Fatal("Publish() succeeded despite rejected push hook") } if result.State != releasekit.PublicationNotPublished || result.Phase != releasekit.PublicationReconciliation || result.TagObject == "" { t.Fatalf("publication result = %#v", result) } if !strings.Contains(err.Error(), "failed to push some refs") || !strings.Contains(err.Error(), "authoritative tag is absent") { t.Fatalf("push error was not preserved with absence evidence: %v", err) } if queries := countLines(t, wrapper.queryLog); queries != 4 { t.Fatalf("authoritative tag query count = %d, want one reconciliation query", queries) } if got := gitText(t, repo.work, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject { t.Fatalf("local tag object = %s, want retained object %s", got, result.TagObject) } if _, err := exec.Command("git", "--git-dir", repo.bare, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3").CombinedOutput(); err == nil { t.Fatal("rejected push unexpectedly published the tag") } } func TestPublishLostAcknowledgmentConfirmsExactRemoteObject(t *testing.T) { repo := newPublishFixture(t) wrapper := installGitWrapper(t, repo, "lost-ack-cancel") t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH")) ctx, cancel := context.WithCancel(context.Background()) defer cancel() finished := make(chan struct{}) watcherDone := make(chan struct{}) result, err := releasekit.Publish(ctx, releasekit.ReleaseRequest{ RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", AdditionalChecks: func(context.Context, releasekit.Candidate) error { go func() { defer close(watcherDone) for { if _, statErr := os.Stat(wrapper.pushDone); statErr == nil { cancel() return } select { case <-finished: return case <-time.After(time.Millisecond): } } }() return nil }, }) close(finished) <-watcherDone if err != nil { t.Fatal(err) } if result.State != releasekit.PublicationPublished || result.Phase != releasekit.PublicationComplete { t.Fatalf("publication result = %#v", result) } if queries := countLines(t, wrapper.queryLog); queries != 4 { t.Fatalf("authoritative tag query count = %d, want one cancellation recovery query", queries) } if got := gitText(t, repo.bare, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject { t.Fatalf("remote tag object = %s, want %s", got, result.TagObject) } } func TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown(t *testing.T) { repo := newPublishFixture(t) base := t.TempDir() emptyRemote := filepath.Join(base, "empty.git") gitRun(t, base, "init", "--bare", emptyRemote) wrapper := installGitWrapperWithMutationMode(t, repo, "lost-ack-rewrite", emptyRemote) t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH")) result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil }, }) if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation { t.Fatalf("result = %#v, error = %v", result, err) } if !strings.Contains(err.Error(), "scoped URL resolves") || !strings.Contains(err.Error(), "lost acknowledgment") { t.Fatalf("error does not preserve endpoint verification and push failures: %v", err) } if !errors.Is(err, git.ErrEndpointChanged) || !errors.Is(err, git.ErrRemoteStateUnreadable) { t.Fatalf("endpoint verification error identity was not preserved: %v", err) } var exitErr *exec.ExitError if !errors.As(err, &exitErr) || exitErr.ExitCode() != 77 { t.Fatalf("push cause identity was not preserved: %v", err) } if queries := countLines(t, wrapper.queryLog); queries != 3 { t.Fatalf("authoritative tag query count = %d, want no tag query after endpoint verification failed", queries) } if got := gitText(t, repo.work, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject { t.Fatalf("local tag object = %s, want retained object %s", got, result.TagObject) } if got := gitText(t, repo.bare, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject { t.Fatalf("original remote tag object = %s, want published object %s", got, result.TagObject) } if got := gitText(t, repo.bare, "rev-parse", "refs/tags/v1.2.3^{}"); got != repo.commit { t.Fatalf("original remote tag peels to %s, want candidate commit %s", got, repo.commit) } } func TestPublishConflictingRemoteTagIsNotReportedAsAbsent(t *testing.T) { repo := newPublishFixture(t) wrapper := installGitWrapper(t, repo, "pass") t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH")) hook := filepath.Join(repo.work, ".git", "hooks", "pre-push") conflictingObject := gitText(t, repo.work, "rev-parse", "refs/tags/v0.9.0") script := fmt.Sprintf("#!/bin/sh\n%s --git-dir=%s update-ref refs/tags/v1.2.3 %s\nexit 1\n", shellQuote(mustLookPath(t, "git")), shellQuote(repo.bare), conflictingObject) if err := os.WriteFile(hook, []byte(script), 0o755); err != nil { t.Fatal(err) } result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil }, }) if err == nil || result.State != releasekit.PublicationConflict || result.Phase != releasekit.PublicationReconciliation { t.Fatalf("result = %#v, error = %v", result, err) } if !strings.Contains(err.Error(), "authoritative tag identifies object") || !strings.Contains(err.Error(), "failed to push some refs") { t.Fatalf("error does not distinguish conflict and preserve push failure: %v", err) } if queries := countLines(t, wrapper.queryLog); queries != 4 { t.Fatalf("authoritative tag query count = %d, want one reconciliation query", queries) } } func TestPublishStopsWhenLocalTagChangesBeforePush(t *testing.T) { repo := newPublishFixture(t) gitRun(t, repo.work, "-c", "tag.gpgSign=false", "tag", "--annotate", "--message", "different tag", "v0.8.0", repo.commit) conflictingObject := gitText(t, repo.work, "rev-parse", "refs/tags/v0.8.0") wrapper := installGitWrapperWithMutation(t, repo, conflictingObject) t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH")) result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil }, }) if err == nil || result.State != releasekit.PublicationNotPublished || result.Phase != releasekit.PublicationTagCreation { t.Fatalf("result = %#v, error = %v", result, err) } if got := gitText(t, repo.work, "rev-parse", "refs/tags/v1.2.3"); got != conflictingObject { t.Fatalf("changed local tag object = %s, want %s", got, conflictingObject) } if result.TagObject == "" || result.TagObject == conflictingObject { t.Fatalf("captured tag object = %q, want the prior object; publish error: %v", result.TagObject, err) } if queries := countLines(t, wrapper.queryLog); queries != 2 { t.Fatalf("remote query count = %d, want only the pre-validation and prepublication guards", queries) } if _, err := exec.Command("git", "--git-dir", repo.bare, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3").CombinedOutput(); err == nil { t.Fatal("changed local tag was pushed") } } func TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError(t *testing.T) { repo := newPublishFixture(t) wrapper := installGitWrapper(t, repo, "query-failure") t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH")) hook := filepath.Join(repo.bare, "hooks", "pre-receive") if err := os.WriteFile(hook, []byte("#!/bin/sh\ncat >/dev/null\nexit 1\n"), 0o755); err != nil { t.Fatal(err) } result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil }, }) if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation { t.Fatalf("result = %#v, error = %v", result, err) } if !strings.Contains(err.Error(), "reconciliation unavailable") || !strings.Contains(err.Error(), "failed to push some refs") { t.Fatalf("error does not preserve both query and push failure: %v", err) } if queries := countLines(t, wrapper.queryLog); queries != 4 { t.Fatalf("authoritative tag query count = %d, want one failed reconciliation query", queries) } if got := gitText(t, repo.work, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject { t.Fatalf("local tag object = %s, want retained object %s", got, result.TagObject) } var exitErr *exec.ExitError if !errors.As(err, &exitErr) || exitErr.ExitCode() == 0 { t.Fatalf("push cause was not preserved in error chain: %v", err) } } func TestPublishUninterpretableReconciliationRemainsUnknown(t *testing.T) { repo := newPublishFixture(t) other := gitText(t, repo.work, "rev-parse", "refs/tags/v0.9.0") wrapper := installGitWrapperWithMutationMode(t, repo, "query-conflict", other) t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH")) hook := filepath.Join(repo.work, ".git", "hooks", "pre-push") if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { t.Fatal(err) } result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil }, }) if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation { t.Fatalf("result = %#v, error = %v", result, err) } if !strings.Contains(err.Error(), "inconsistent exact ref records") || !strings.Contains(err.Error(), "failed to push some refs") { t.Fatalf("error does not preserve uninterpretable remote state and push failure: %v", err) } if queries := countLines(t, wrapper.queryLog); queries != 4 { t.Fatalf("authoritative tag query count = %d, want one uninterpretable reconciliation query", queries) } if got := gitText(t, repo.work, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject { t.Fatalf("local tag object = %s, want retained object %s", got, result.TagObject) } } func TestPublishRemoteConflictStopsBeforeValidation(t *testing.T) { repo := newPublishFixture(t) gitRun(t, repo.bare, "update-ref", "refs/heads/main", repo.unrelatedCommit) var validations int result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", AdditionalChecks: func(context.Context, releasekit.Candidate) error { validations++ return nil }, }) if err == nil { t.Fatal("Publish() succeeded with a conflicting authoritative main") } if validations != 0 || result.State != releasekit.PublicationConflict || result.TagObject != "" { t.Fatalf("validation calls = %d, result = %#v, error = %v", validations, result, err) } } func TestPublishRemoteChangeDuringValidationPreventsLocalTag(t *testing.T) { repo := newPublishFixture(t) result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", AdditionalChecks: func(context.Context, releasekit.Candidate) error { gitRun(t, repo.bare, "update-ref", "refs/heads/main", repo.unrelatedCommit) return nil }, }) if err == nil { t.Fatal("Publish() succeeded after authoritative main changed during validation") } if result.State != releasekit.PublicationConflict || result.Phase != releasekit.PublicationValidation || result.TagObject != "" { t.Fatalf("publication result = %#v, error = %v", result, err) } if _, err := exec.Command("git", "-C", repo.work, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3").CombinedOutput(); err == nil { t.Fatal("tag was created after the remote guard failed") } } type publishFixture struct { work string bare string commit string unrelatedCommit string } type gitWrapper struct { bin string queryLog string pushDone string realGit string } func installGitWrapper(t *testing.T, repo publishFixture, mode string) gitWrapper { t.Helper() return installGitWrapperWithMutationMode(t, repo, mode, "") } func installGitWrapperWithMutation(t *testing.T, repo publishFixture, object string) gitWrapper { t.Helper() return installGitWrapperWithMutationMode(t, repo, "mutate-tag", object) } func installGitWrapperWithMutationMode(t *testing.T, repo publishFixture, mode, mutationObject string) gitWrapper { t.Helper() realGit, err := exec.LookPath("git") if err != nil { t.Fatal(err) } bin := filepath.Join(t.TempDir(), "bin") if err := os.MkdirAll(bin, 0o755); err != nil { t.Fatal(err) } queryLog := filepath.Join(t.TempDir(), "tag-queries.log") pushDone := filepath.Join(t.TempDir(), "push-attempted") contents := fmt.Sprintf(`#!/bin/sh REAL_GIT=%s QUERY_LOG=%s PUSH_DONE=%s MODE=%s MUTATE_OBJECT=%s ARGS=" $* " IS_PUSH=0 IS_LS_REMOTE=0 for ARG in "$@"; do [ "$ARG" = "push" ] && IS_PUSH=1 [ "$ARG" = "ls-remote" ] && IS_LS_REMOTE=1 done if [ "$IS_LS_REMOTE" = "1" ]; then case "$ARGS" in *refs/tags/v1.2.3*) echo query >> "$QUERY_LOG";; esac if [ "$MODE" = "query-failure" ] && [ -f "$PUSH_DONE" ]; then case "$ARGS" in *refs/tags/v1.2.3*) echo "reconciliation unavailable" >&2; exit 77;; esac fi if [ "$MODE" = "query-conflict" ] && [ -f "$PUSH_DONE" ]; then case "$ARGS" in *refs/tags/v1.2.3*) OBJECT=$("$REAL_GIT" rev-parse refs/tags/v1.2.3) printf '%%s\trefs/tags/v1.2.3\n' "$OBJECT" printf '%%s\trefs/tags/v1.2.3\n' "$MUTATE_OBJECT" exit 0 ;; esac fi fi if [ "$IS_PUSH" = "1" ]; then "$REAL_GIT" "$@" STATUS=$? : > "$PUSH_DONE" if [ "$MODE" = "lost-ack-cancel" ] && [ "$STATUS" = "0" ]; then kill -STOP $$; fi if [ "$MODE" = "lost-ack-rewrite" ] && [ "$STATUS" = "0" ]; then "$REAL_GIT" config --local "url.\"$MUTATE_OBJECT\".insteadOf" %s echo "lost acknowledgment after tag was accepted" >&2 exit 77 fi exit "$STATUS" fi if [ "$MODE" = "mutate-tag" ]; then case "$ARGS" in *"cat-file tag refs/tags/v1.2.3"*) "$REAL_GIT" "$@" STATUS=$? [ "$STATUS" = "0" ] && "$REAL_GIT" update-ref refs/tags/v1.2.3 "$MUTATE_OBJECT" exit "$STATUS" ;; esac fi exec "$REAL_GIT" "$@" `, shellQuote(realGit), shellQuote(queryLog), shellQuote(pushDone), shellQuote(mode), shellQuote(mutationObject), shellQuote(repo.bare)) if err := os.WriteFile(filepath.Join(bin, "git"), []byte(contents), 0o755); err != nil { t.Fatal(err) } return gitWrapper{bin: bin, queryLog: queryLog, pushDone: pushDone, realGit: realGit} } func countLines(t *testing.T, path string) int { t.Helper() contents, err := os.ReadFile(path) if os.IsNotExist(err) { return 0 } if err != nil { t.Fatal(err) } return len(strings.Fields(string(contents))) } func mustLookPath(t *testing.T, name string) string { t.Helper() path, err := exec.LookPath(name) if err != nil { t.Fatal(err) } return path } func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } func newPublishFixture(t *testing.T) publishFixture { t.Helper() base := t.TempDir() work := filepath.Join(base, "work") bare := filepath.Join(base, "remote.git") gitRun(t, base, "init", "-b", "main", work) gitRun(t, work, "config", "user.name", "ReleaseKit Test") gitRun(t, work, "config", "user.email", "releasekit@example.invalid") writeFile(t, work, "go.mod", "module example.com/releasekit-publish-fixture\n\ngo 1.25.0\n") writeFile(t, work, "fixture_test.go", `package fixture import ( "os" "testing" ) func TestFixture(t *testing.T) { if path := os.Getenv("RELEASEKIT_FIXTURE_SUITE_RUNS"); path != "" { file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { t.Fatal(err) } if _, err := file.WriteString("ran\n"); err != nil { _ = file.Close() t.Fatal(err) } if err := file.Close(); err != nil { t.Fatal(err) } } if os.Getenv("RELEASEKIT_FAIL_FIXTURE_SUITE") == "1" { t.Fatal("fixture standard suite failure") } } `) writeFile(t, work, "race_fixture_test.go", `//go:build race package fixture import ( "os" "testing" ) func TestRaceBuildSelection(t *testing.T) { marker := os.Getenv("RELEASEKIT_RACE_MARKER") if marker == "" { return } if err := os.WriteFile(marker, []byte("race enabled"), 0o600); err != nil { t.Fatal(err) } } `) writeFile(t, work, "docs/releases/v1.2.3.md", releaseNote) writeFile(t, work, "source.txt", "candidate") gitRun(t, work, "add", ".") gitRun(t, work, "commit", "-m", "Candidate") commit := gitText(t, work, "rev-parse", "HEAD") gitRun(t, work, "init", "--bare", bare) gitRun(t, work, "remote", "add", "origin", bare) gitRun(t, work, "push", "origin", "HEAD:refs/heads/main") gitRun(t, work, "push", "origin", "HEAD:refs/heads/unrelated") gitRun(t, work, "tag", "v0.9.0", commit) gitRun(t, work, "push", "origin", "refs/tags/v0.9.0:refs/tags/v0.9.0") other := gitText(t, work, "commit-tree", gitText(t, work, "rev-parse", "HEAD^{tree}"), "-p", commit, "-m", "Other remote commit") gitRun(t, work, "push", "origin", other+":refs/heads/other") gitRun(t, work, "config", "remote.origin.mirror", "true") gitRun(t, work, "config", "push.followTags", "true") gitRun(t, work, "config", "tag.gpgSign", "true") return publishFixture{work: work, bare: bare, commit: commit, unrelatedCommit: other} } func gitRun(t *testing.T, dir string, args ...string) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output) } } func gitText(t *testing.T, dir string, args ...string) string { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output) } return strings.TrimSuffix(string(output), "\n") } func gitBytes(t *testing.T, dir string, args ...string) string { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output) } return string(output) } func writeFile(t *testing.T, root, name, contents string) { t.Helper() path := filepath.Join(root, filepath.FromSlash(name)) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { t.Fatal(err) } }