1104 lines
43 KiB
Go
1104 lines
43 KiB
Go
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 TestCandidateOperationsDiscoverGoOnlyWhenLaterWorkNeedsIt(t *testing.T) {
|
|
gitPath := mustLookPath(t, "git")
|
|
gitOnly := t.TempDir()
|
|
if err := os.Symlink(gitPath, filepath.Join(gitOnly, "git")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, operation := range []string{"check", "publish"} {
|
|
for _, withBuild := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("%s/build=%t", operation, withBuild), func(t *testing.T) {
|
|
repo := newPublishFixture(t)
|
|
request := releasekit.ReleaseRequest{
|
|
RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3",
|
|
AdditionalChecks: func(context.Context, releasekit.Candidate) error {
|
|
t.Setenv("PATH", gitOnly)
|
|
return nil
|
|
},
|
|
}
|
|
if withBuild {
|
|
request.Build = &releasekit.BuildSpec{MainPackage: ".", BinaryName: "fixture", Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}}}
|
|
}
|
|
var err error
|
|
if operation == "check" {
|
|
_, err = releasekit.Check(context.Background(), request)
|
|
} else {
|
|
_, err = releasekit.Publish(context.Background(), request)
|
|
}
|
|
if withBuild {
|
|
if !errors.Is(err, exec.ErrNotFound) {
|
|
t.Fatalf("later build did not rediscover changed Go inputs: %v", err)
|
|
}
|
|
} else if err != nil {
|
|
t.Fatalf("finished Go work added a discovery gate: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
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.ModuleDir != repo.work || candidate.ModulePath != "example.com/releasekit-publish-fixture" || candidate.Tag != "v1.2.3" || 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.RepositoryDir != repo.work || result.ModuleDir != repo.work || result.ModulePath != "example.com/releasekit-publish-fixture" || result.Tag != "v1.2.3" || 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 TestPublishUsesSeparateFullTagsForModulesAtOneCommit(t *testing.T) {
|
|
repo := newPublishFixture(t)
|
|
firstDir, secondDir := "integrations/first", "integrations/second"
|
|
addNestedReleaseModule(t, repo.work, firstDir, "example.com/first", "v1.2.3")
|
|
addNestedReleaseModule(t, repo.work, secondDir, "example.com/second", "v1.2.3")
|
|
gitRun(t, repo.work, "add", firstDir, secondDir)
|
|
gitRun(t, repo.work, "commit", "-m", "add two release modules")
|
|
gitRun(t, repo.work, "push", "origin")
|
|
commit := gitText(t, repo.work, "rev-parse", "HEAD")
|
|
|
|
for _, moduleDir := range []string{firstDir, secondDir} {
|
|
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
|
|
RepositoryDir: repo.work,
|
|
ModuleDir: moduleDir,
|
|
Product: "Sample App",
|
|
Version: "v1.2.3",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Publish(%q): %v", moduleDir, err)
|
|
}
|
|
wantTag := moduleDir + "/v1.2.3"
|
|
if result.CandidateCommit != commit || result.Tag != wantTag || result.Version != "v1.2.3" {
|
|
t.Fatalf("publication for %q = %#v", moduleDir, result)
|
|
}
|
|
if got := gitText(t, repo.bare, "rev-parse", "refs/tags/"+wantTag); got != result.TagObject {
|
|
t.Fatalf("remote tag %s object = %s, want %s", wantTag, got, result.TagObject)
|
|
}
|
|
}
|
|
refs := gitText(t, repo.bare, "for-each-ref", "--format=%(refname)", "refs/tags")
|
|
if strings.Contains(refs, "refs/tags/v1.2.3\n") || !strings.Contains(refs, "refs/tags/"+firstDir+"/v1.2.3") || !strings.Contains(refs, "refs/tags/"+secondDir+"/v1.2.3") {
|
|
t.Fatalf("published tag refs = %q", refs)
|
|
}
|
|
|
|
firstTag := firstDir + "/v1.2.3"
|
|
gitRun(t, repo.work, "tag", "-d", firstTag)
|
|
conflict, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
|
|
RepositoryDir: repo.work,
|
|
ModuleDir: firstDir,
|
|
Product: "Sample App",
|
|
Version: "v1.2.3",
|
|
})
|
|
if !errors.Is(err, git.ErrRemoteTagConflict) || conflict.State != releasekit.PublicationConflict || conflict.Tag != firstTag {
|
|
t.Fatalf("reuse of remote tag result=%#v error=%v", conflict, err)
|
|
}
|
|
}
|
|
|
|
func TestPublishNestedRejectedPushReportsFullTag(t *testing.T) {
|
|
repo := newPublishFixture(t)
|
|
moduleDir := "integrations/ckconfig"
|
|
addNestedReleaseModule(t, repo.work, moduleDir, "example.com/ckconfig", "v1.2.3")
|
|
gitRun(t, repo.work, "add", moduleDir)
|
|
gitRun(t, repo.work, "commit", "-m", "add nested module")
|
|
gitRun(t, repo.work, "push", "origin")
|
|
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)
|
|
}
|
|
wantTag := moduleDir + "/v1.2.3"
|
|
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
|
|
RepositoryDir: repo.work,
|
|
ModuleDir: moduleDir,
|
|
Product: "Sample App",
|
|
Version: "v1.2.3",
|
|
})
|
|
if err == nil || result.State != releasekit.PublicationNotPublished || result.Tag != wantTag {
|
|
t.Fatalf("nested rejected push result=%#v error=%v", result, err)
|
|
}
|
|
if !strings.Contains(err.Error(), wantTag) || !strings.Contains(err.Error(), repo.bare) || !strings.Contains(err.Error(), "authoritative tag is absent") {
|
|
t.Fatalf("nested rejected-push error = %v", err)
|
|
}
|
|
if _, err := exec.Command("git", "--git-dir", repo.bare, "show-ref", "--verify", "--quiet", "refs/tags/"+wantTag).CombinedOutput(); err == nil {
|
|
t.Fatal("rejected nested tag unexpectedly appeared on the remote")
|
|
}
|
|
}
|
|
|
|
func TestPublishNestedTagKeepsUnknownReconciliationEvidence(t *testing.T) {
|
|
repo := newPublishFixture(t)
|
|
moduleDir := "integrations/ckconfig"
|
|
addNestedReleaseModule(t, repo.work, moduleDir, "example.com/ckconfig", "v1.2.3")
|
|
gitRun(t, repo.work, "add", moduleDir)
|
|
gitRun(t, repo.work, "commit", "-m", "add nested module")
|
|
gitRun(t, repo.work, "push", "origin")
|
|
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)
|
|
}
|
|
wantTag := moduleDir + "/v1.2.3"
|
|
wrapper := installTagQueryFailureWrapper(t, wantTag)
|
|
t.Setenv("PATH", wrapper+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
|
|
RepositoryDir: repo.work,
|
|
ModuleDir: moduleDir,
|
|
Product: "Sample App",
|
|
Version: "v1.2.3",
|
|
})
|
|
if err == nil || result.State != releasekit.PublicationUnknown || result.Tag != wantTag || result.TagObject == "" {
|
|
t.Fatalf("nested unknown result=%#v error=%v", result, err)
|
|
}
|
|
if !strings.Contains(err.Error(), wantTag) || !strings.Contains(err.Error(), repo.bare) || !strings.Contains(err.Error(), "reconciliation unavailable") || !strings.Contains(err.Error(), "failed to push some refs") {
|
|
t.Fatalf("nested reconciliation error = %v", err)
|
|
}
|
|
}
|
|
|
|
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")
|
|
captured, 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")
|
|
}
|
|
if captured.RepositoryDir != repo.work || captured.ModuleDir != repo.work || captured.ModulePath != "example.com/releasekit-publish-fixture" || captured.Tag != "v1.2.3" || captured.Commit != repo.commit || captured.Version != "v1.2.3" {
|
|
t.Fatalf("Check() failure candidate = %#v, want captured identity alongside validation error", captured)
|
|
}
|
|
}
|
|
|
|
func TestMarkdownValidationRunsThroughCheckAndPublish(t *testing.T) {
|
|
addBrokenMarkdownDestination := func(t *testing.T, repo publishFixture) {
|
|
t.Helper()
|
|
writeFile(t, repo.work, "guide.md", "[missing](missing.md#top)\n")
|
|
gitRun(t, repo.work, "add", "guide.md")
|
|
gitRun(t, repo.work, "commit", "-m", "add broken Markdown destination")
|
|
}
|
|
assertMarkdownFailure := func(t *testing.T, err error) {
|
|
t.Helper()
|
|
var failure *releasekit.ValidationError
|
|
if !errors.As(err, &failure) || failure.Check != "markdown-links" {
|
|
t.Fatalf("operation error = %v, want public markdown-links failure", err)
|
|
}
|
|
if len(failure.Findings) != 1 || failure.Findings[0].Path != "guide.md" || failure.Findings[0].Target != "missing.md#top" || failure.Findings[0].Line != 1 {
|
|
t.Fatalf("Markdown findings = %#v, want source location and target", failure.Findings)
|
|
}
|
|
}
|
|
|
|
t.Run("check", func(t *testing.T) {
|
|
repo := newPublishFixture(t)
|
|
addBrokenMarkdownDestination(t, repo)
|
|
_, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{
|
|
RepositoryDir: repo.work,
|
|
Product: "Sample App",
|
|
Version: "v1.2.3",
|
|
Validation: releasekit.ValidationSpec{Markdown: &releasekit.MarkdownValidationSpec{}},
|
|
})
|
|
assertMarkdownFailure(t, err)
|
|
})
|
|
|
|
t.Run("publish", func(t *testing.T) {
|
|
repo := newPublishFixture(t)
|
|
addBrokenMarkdownDestination(t, repo)
|
|
gitRun(t, repo.work, "config", "--unset", "remote.origin.mirror")
|
|
gitRun(t, repo.work, "push", "origin", "HEAD:refs/heads/main")
|
|
callbackCalls := 0
|
|
_, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
|
|
RepositoryDir: repo.work,
|
|
Product: "Sample App",
|
|
Version: "v1.2.3",
|
|
Validation: releasekit.ValidationSpec{Markdown: &releasekit.MarkdownValidationSpec{}},
|
|
AdditionalChecks: func(context.Context, releasekit.Candidate) error {
|
|
callbackCalls++
|
|
return nil
|
|
},
|
|
})
|
|
assertMarkdownFailure(t, err)
|
|
if callbackCalls != 0 {
|
|
t.Fatalf("additional checks called %d times after Markdown failure", callbackCalls)
|
|
}
|
|
if tag := gitText(t, repo.work, "tag", "--list", "v1.2.3"); tag != "" {
|
|
t.Fatalf("failed Markdown validation created tag %q", tag)
|
|
}
|
|
})
|
|
}
|
|
|
|
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 addNestedReleaseModule(t *testing.T, repository, moduleDir, modulePath, version string) {
|
|
t.Helper()
|
|
writeFile(t, repository, moduleDir+"/go.mod", "module "+modulePath+"\n\ngo 1.25.0\n")
|
|
writeFile(t, repository, moduleDir+"/fixture_test.go", "package fixture\nimport \"testing\"\nfunc TestFixture(t *testing.T) {}\n")
|
|
writeFile(t, repository, moduleDir+"/docs/releases/"+version+".md", "# Sample App "+version+"\n\nNested release.\n")
|
|
}
|
|
|
|
func installTagQueryFailureWrapper(t *testing.T, tag string) string {
|
|
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)
|
|
}
|
|
marker := filepath.Join(t.TempDir(), "push-attempted")
|
|
contents := fmt.Sprintf(`#!/bin/sh
|
|
REAL_GIT=%s
|
|
MARKER=%s
|
|
TAG_REF=%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" ] && [ -f "$MARKER" ]; then
|
|
case "$ARGS" in *"$TAG_REF"*) echo "reconciliation unavailable" >&2; exit 77;; esac
|
|
fi
|
|
if [ "$IS_PUSH" = "1" ]; then
|
|
"$REAL_GIT" "$@"
|
|
STATUS=$?
|
|
: > "$MARKER"
|
|
exit "$STATUS"
|
|
fi
|
|
exec "$REAL_GIT" "$@"
|
|
`, shellQuote(realGit), shellQuote(marker), shellQuote("refs/tags/"+tag))
|
|
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(contents), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return bin
|
|
}
|
|
|
|
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.TB) 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.TB, 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.TB, 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.TB, 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)
|
|
}
|
|
}
|