541 lines
21 KiB
Go
541 lines
21 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
|
"go.maximumdirect.net/releasekit/internal/gotool"
|
|
"go.maximumdirect.net/releasekit/internal/publish"
|
|
)
|
|
|
|
func TestVendoredAPIOnlyPublicationRecovery(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("fixture executes POSIX recovery commands")
|
|
}
|
|
repo, remote := newSimplePublishRepository(t, false)
|
|
source := repositoryRoot(t)
|
|
relative, err := filepath.Rel(repo, source)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeRepositoryFile(t, repo, "go.mod", "module example.com/cli-candidate\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.0.0\n\nreplace go.maximumdirect.net/releasekit => "+strconv.Quote(relative)+"\n")
|
|
writeRepositoryFile(t, repo, "uses_releasekit.go", "package candidate\nimport _ \"go.maximumdirect.net/releasekit\"\n")
|
|
sums, err := os.ReadFile(filepath.Join(source, "go.sum"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeRepositoryFile(t, repo, "go.sum", string(sums))
|
|
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
|
|
for _, args := range [][]string{{"mod", "tidy"}, {"mod", "vendor"}} {
|
|
command := exec.Command(goBinary, args...)
|
|
command.Dir = repo
|
|
if out, err := command.CombinedOutput(); err != nil {
|
|
t.Fatalf("prepare vendored API: %v: %s", err, out)
|
|
}
|
|
}
|
|
cliGitRun(t, repo, "add", ".")
|
|
cliGitRun(t, repo, "commit", "-m", "Vendor API dependency")
|
|
cliGitRun(t, repo, "push", "origin", "main")
|
|
before := cliGitOutput(t, repo, "rev-parse", "HEAD^{tree}")
|
|
if err := os.WriteFile(filepath.Join(remote, "hooks", "pre-receive"), []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
launcherStore := t.TempDir()
|
|
launcherArgs, err := gotool.RecoveryTool(t.Context(), repo, launcherStore)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
launcher := exec.Command(goBinary, append(launcherArgs.Args, "publish", "--repo", repo, "v1.2.3")...)
|
|
launcher.Dir = launcherArgs.Directory
|
|
publicationOutput, err := launcher.CombinedOutput()
|
|
var exitError *exec.ExitError
|
|
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 {
|
|
t.Fatalf("vendored publication: %v: %s", err, publicationOutput)
|
|
}
|
|
// The later handoff owns different launch metadata from the original driver.
|
|
if err := os.RemoveAll(launcherStore); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
command := outputLineValue(string(publicationOutput), "recovery command: ")
|
|
if command == "" {
|
|
t.Fatalf("no handoff: %s", publicationOutput)
|
|
}
|
|
recovery := exec.Command("sh", "-c", command)
|
|
recovery.Dir = t.TempDir()
|
|
recovered, err := recovery.CombinedOutput()
|
|
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 || !strings.Contains(string(recovered), "module=example.com/cli-candidate tag=v1.2.3 state=not_published") {
|
|
t.Fatalf("vendored recovery: %v: %s", err, recovered)
|
|
}
|
|
if status := cliGitOutput(t, repo, "status", "--porcelain"); status != "" {
|
|
t.Fatalf("handoff changed consumer files: %s", status)
|
|
}
|
|
if after := cliGitOutput(t, repo, "write-tree"); after != before {
|
|
t.Fatalf("handoff changed consumer index: %s != %s", after, before)
|
|
}
|
|
}
|
|
|
|
func TestRecoveryToolPreservesVersionedReplacement(t *testing.T) {
|
|
proxy := writeReleaseKitModuleProxy(t)
|
|
environment := make(map[string]string)
|
|
useSyntheticModuleProxy(t, environment, "file://"+filepath.ToSlash(proxy))
|
|
for key, value := range environment {
|
|
t.Setenv(key, value)
|
|
}
|
|
module := t.TempDir()
|
|
data := "module example.com/recovery-consumer\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.2.197\n\nreplace go.maximumdirect.net/releasekit v0.2.197 => go.maximumdirect.net/releasekit v0.2.198\n"
|
|
if err := os.WriteFile(filepath.Join(module, "go.mod"), []byte(data), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
shimDir := t.TempDir()
|
|
logPath := filepath.Join(t.TempDir(), "target")
|
|
realGo := filepath.Join(runtime.GOROOT(), "bin", "go")
|
|
shim := "#!/bin/sh\ncase \" $* \" in *\" -deps \"*) printf '%s/%s\\n' \"$GOOS\" \"$GOARCH\" > \"$RECOVERY_TARGET_LOG\" ;; esac\nexec \"$RECOVERY_REAL_GO\" \"$@\"\n"
|
|
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("RECOVERY_TARGET_LOG", logPath)
|
|
t.Setenv("RECOVERY_REAL_GO", realGo)
|
|
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
foreignOS, foreignArch := foreignTarget()
|
|
t.Setenv("GOOS", foreignOS)
|
|
t.Setenv("GOARCH", foreignArch)
|
|
args, err := gotool.RecoveryTool(t.Context(), module, t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if target, err := os.ReadFile(logPath); err != nil || strings.TrimSpace(string(target)) != runtime.GOOS+"/"+runtime.GOARCH {
|
|
t.Fatalf("recovery preparation target = %q, %v", target, err)
|
|
}
|
|
command := exec.Command(realGo, args.Args...)
|
|
command.Dir = args.Directory
|
|
command.Env = setEnvironment(setEnvironment(os.Environ(), "GOOS", runtime.GOOS), "GOARCH", runtime.GOARCH)
|
|
output, err := command.CombinedOutput()
|
|
if err != nil || !strings.Contains(string(output), "tool-version=v0.2.198 helper=tool-only dependency") {
|
|
t.Fatalf("versioned recovery selection: %v: %s", err, output)
|
|
}
|
|
after, err := os.ReadFile(filepath.Join(module, "go.mod"))
|
|
if err != nil || string(after) != data {
|
|
t.Fatalf("consumer metadata changed: %q, %v", after, err)
|
|
}
|
|
}
|
|
|
|
func TestRecoveryToolSupportsReleaseKitSourceModule(t *testing.T) {
|
|
args, err := gotool.RecoveryTool(t.Context(), repositoryRoot(t), t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
command := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), append(args.Args, "version")...)
|
|
command.Dir = args.Directory
|
|
output, err := command.CombinedOutput()
|
|
if err != nil || !strings.HasPrefix(string(output), "releasekit ") {
|
|
t.Fatalf("source module recovery entry point: %v: %s", err, output)
|
|
}
|
|
}
|
|
|
|
func TestColdGoToolRecoverySurvivesLauncherExit(t *testing.T) {
|
|
if runtime.GOOS != "linux" {
|
|
t.Skip("fixture observes the cold executable through /proc")
|
|
}
|
|
for _, mode := range []struct{ registered, canceled bool }{{true, false}, {false, false}, {true, true}, {false, true}} {
|
|
registered := mode.registered
|
|
t.Run(fmt.Sprintf("registered=%t/canceled=%t", registered, mode.canceled), func(t *testing.T) {
|
|
repo, remote := newSimplePublishRepository(t, false)
|
|
source := repositoryRoot(t)
|
|
// A distinct replacement identity gives this tool a fresh link action
|
|
// while retaining the shared compilation cache for its dependencies.
|
|
selectedSource := filepath.Join(t.TempDir(), "ReleaseKit source")
|
|
if err := os.Symlink(source, selectedSource); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !registered {
|
|
relative, err := filepath.Rel(repo, selectedSource)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
selectedSource = relative
|
|
}
|
|
module := "module example.com/cli-candidate\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.0.0\n\nreplace go.maximumdirect.net/releasekit => " + strconv.Quote(selectedSource) + "\n"
|
|
if registered {
|
|
module += "\ntool go.maximumdirect.net/releasekit/cmd/releasekit\n"
|
|
}
|
|
writeRepositoryFile(t, repo, "go.mod", module)
|
|
sums, err := os.ReadFile(filepath.Join(source, "go.sum"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeRepositoryFile(t, repo, "go.sum", string(sums))
|
|
if !registered {
|
|
for _, name := range []string{"release.sh", "select-module.sh"} {
|
|
data, err := os.ReadFile(filepath.Join(source, "examples", "multi-module", name))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(repo, name), data, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
writeRepositoryFile(t, repo, "release-modules.tsv", "root\tv*\t.\treleasekit.json\n")
|
|
}
|
|
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
|
|
if registered {
|
|
prepare := exec.Command(goBinary, "list", "-mod=mod", "-deps", "go.maximumdirect.net/releasekit/cmd/releasekit")
|
|
prepare.Dir = repo
|
|
if out, err := prepare.CombinedOutput(); err != nil {
|
|
t.Fatalf("prepare tool: %v: %s", err, out)
|
|
}
|
|
}
|
|
cliGitRun(t, repo, "add", ".")
|
|
cliGitRun(t, repo, "commit", "-m", "Select release tool")
|
|
cliGitRun(t, repo, "push", "origin", "main")
|
|
if err := os.WriteFile(filepath.Join(remote, "hooks", "pre-receive"), []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
realGit, err := exec.LookPath("git")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bin := t.TempDir()
|
|
executed := filepath.Join(t.TempDir(), "executed-tool")
|
|
wrapper := "#!/bin/sh\nparent=$(readlink \"/proc/$PPID/exe\")\ncase \"$parent\" in */exe/releasekit) printf '%s' \"$parent\" > " + shellQuote(executed) + ";; esac\n" + cancelPushScript + "exec " + shellQuote(realGit) + " \"$@\"\n"
|
|
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(wrapper), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
var launch *exec.Cmd
|
|
if registered {
|
|
launch = exec.Command(goBinary, "tool", "releasekit", "publish", "--repo", repo, "v1.2.3")
|
|
} else {
|
|
launch = exec.Command(filepath.Join(repo, "release.sh"), "root", "v1.2.3")
|
|
}
|
|
launch.Dir = repo
|
|
var out []byte
|
|
if mode.canceled {
|
|
out, err = canceledPublicationOutput(t, launch, repo, "")
|
|
} else {
|
|
out, err = launch.CombinedOutput()
|
|
}
|
|
var exitError *exec.ExitError
|
|
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 {
|
|
t.Fatalf("publication: %v: %s", err, out)
|
|
}
|
|
text := string(out)
|
|
launchedPath, err := os.ReadFile(executed)
|
|
if err != nil {
|
|
t.Fatalf("did not exercise a cold temporary executable: %v: %s", err, text)
|
|
}
|
|
if _, err := os.Stat(string(launchedPath)); !os.IsNotExist(err) {
|
|
t.Fatalf("cold executable still exists: %s: %v", launchedPath, err)
|
|
}
|
|
entries, err := os.ReadDir(filepath.Dir(repo))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, entry := range entries {
|
|
if strings.HasPrefix(entry.Name(), ".releasekit-bootstrap.") {
|
|
t.Fatalf("launcher bootstrap survived: %s", entry.Name())
|
|
}
|
|
}
|
|
command := outputLineValue(text, "recovery command: ")
|
|
if !strings.Contains(command, "go ") || strings.Contains(command, "/exe/releasekit") {
|
|
t.Fatalf("unstable handoff: %s", command)
|
|
}
|
|
if !registered && !strings.Contains(command, "publication-recovery") {
|
|
t.Fatalf("API-only handoff lacks durable metadata: %s", command)
|
|
}
|
|
// Execute from another directory after the original driver and the
|
|
// API-only launcher's temporary files are gone.
|
|
originalRecord := outputLineValue(text, "publication record saved: ")
|
|
evidence, err := publish.LoadEvidence(originalRecord)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
evidence.Endpoint = filepath.Join(t.TempDir(), "unavailable.git")
|
|
unknownRecord, err := publish.SaveEvidence(t.Context(), repo, t.TempDir(), evidence)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, scenario := range []struct {
|
|
record string
|
|
status int
|
|
}{{"", 1}, {filepath.Join(t.TempDir(), "missing.json"), 2}, {unknownRecord, 3}, {"", 0}} {
|
|
if scenario.status == 0 {
|
|
if err := os.Remove(filepath.Join(remote, "hooks", "pre-receive")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cliGitRun(t, repo, "push", "origin", "refs/tags/v1.2.3")
|
|
}
|
|
selected := command
|
|
if scenario.record != "" {
|
|
selected = strings.Replace(selected, shellQuote(originalRecord), shellQuote(scenario.record), 1)
|
|
}
|
|
recovery := exec.Command("sh", "-c", selected)
|
|
recovery.Dir = t.TempDir()
|
|
foreignOS, foreignArch := foreignTarget()
|
|
recovery.Env = setEnvironment(setEnvironment(os.Environ(), "GOOS", foreignOS), "GOARCH", foreignArch)
|
|
recovered, err := recovery.CombinedOutput()
|
|
if scenario.status == 0 && err != nil || scenario.status != 0 && (!errors.As(err, &exitError) || exitError.ExitCode() != scenario.status) {
|
|
t.Fatalf("recovery status want %d: %v: %s", scenario.status, err, recovered)
|
|
}
|
|
if scenario.status == 1 && !strings.Contains(string(recovered), "state=not_published") {
|
|
t.Fatalf("wrong observation: %s", recovered)
|
|
}
|
|
}
|
|
if status := cliGitOutput(t, repo, "status", "--porcelain"); status != "" {
|
|
t.Fatalf("handoff modified candidate: %s", status)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPublishRecordOverrideWarningDoesNotChangeSuccess(t *testing.T) {
|
|
repository, _ := newSimplePublishRepository(t, true)
|
|
var stdout, stderr bytes.Buffer
|
|
status := run(t.Context(), []string{"publish", "--repo", repository, "--record-dir", "record-blocker", "v1.2.3"}, &stdout, &stderr)
|
|
if status != 0 || !strings.Contains(stdout.String(), "published v1.2.3") {
|
|
t.Fatalf("status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "publication record warning:") || strings.Contains(stderr.String(), "publication record saved:") || strings.Contains(stderr.String(), "recovery command:") {
|
|
t.Fatalf("recording warning or success handoff output = %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestBuiltPublishRecoveryCommandSelectsItsRecordAmongOtherAttempts(t *testing.T) {
|
|
for _, mode := range []string{"rejected", "canceled", "preparation failure"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
|
|
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
|
|
t.Setenv("GIT_TERMINAL_PROMPT", "0")
|
|
repository, remote := newSimplePublishRepository(t, false)
|
|
store := filepath.Join(t.TempDir(), "attempt records with ' apostrophe")
|
|
hook := filepath.Join(remote, "hooks", "pre-receive")
|
|
if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
|
|
executable := filepath.Join(t.TempDir(), "releasekit's CLI")
|
|
build := exec.Command(goBinary, "build", "-o", executable, "./cmd/releasekit")
|
|
build.Dir = repositoryRoot(t)
|
|
if output, err := build.CombinedOutput(); err != nil {
|
|
t.Fatalf("build CLI: %v\n%s", err, output)
|
|
}
|
|
|
|
if mode == "canceled" {
|
|
realGit, err := exec.LookPath("git")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bin := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(bin, "git"), []byte("#!/bin/sh\n"+cancelPushScript+"exec "+shellQuote(realGit)+" \"$@\"\n"), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
}
|
|
if mode == "preparation failure" {
|
|
realGo, err := exec.LookPath("go")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bin := t.TempDir()
|
|
script := "#!/bin/sh\nif [ \"$1\" = list ] && [ \"$2\" = -e ]; then exit 73; fi\nexec " + shellQuote(realGo) + " \"$@\"\n"
|
|
if err := os.WriteFile(filepath.Join(bin, "go"), []byte(script), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
}
|
|
command := exec.Command(executable, "publish", "--repo", repository, "--record-dir", store, "v1.2.3")
|
|
command.Dir = repositoryRoot(t)
|
|
var output []byte
|
|
var err error
|
|
if mode == "canceled" {
|
|
output, err = canceledPublicationOutput(t, command, repository, store)
|
|
} else {
|
|
output, err = command.CombinedOutput()
|
|
}
|
|
var exitError *exec.ExitError
|
|
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 {
|
|
t.Fatalf("publication exit = %v, output = %q; want status 1", err, output)
|
|
}
|
|
text := string(output)
|
|
if mode == "preparation failure" && !strings.Contains(text, "recovery tool preparation warning:") {
|
|
t.Fatalf("preparation warning missing: %s", text)
|
|
}
|
|
if !strings.Contains(text, "publication state=not_published phase=reconciliation") {
|
|
t.Fatalf("publication failure classification changed: %q", text)
|
|
}
|
|
recordPath := outputLineValue(text, "publication record saved: ")
|
|
if !filepath.IsAbs(recordPath) || !strings.Contains(recordPath, "attempt records with ' apostrophe") {
|
|
t.Fatalf("saved record path = %q, output = %q", recordPath, text)
|
|
}
|
|
recoveryCommand := outputLineValue(text, "recovery command: ")
|
|
if recoveryCommand == "" || !strings.Contains(recoveryCommand, shellQuote(executable)) || !strings.Contains(recoveryCommand, shellQuote(recordPath)) {
|
|
t.Fatalf("copyable recovery command = %q, output = %q", recoveryCommand, text)
|
|
}
|
|
if !strings.Contains(text, "selected supported ReleaseKit CLI") {
|
|
t.Fatalf("output omits guidance for an unavailable original executable: %q", text)
|
|
}
|
|
|
|
for _, decoy := range []publish.Evidence{
|
|
{RepositoryDir: repository, ModulePath: "example.com/other-module", Version: "v8.8.8", Tag: "v8.8.8", Endpoint: remote, Remote: "origin"},
|
|
{RepositoryDir: repository, ModulePath: "example.com/retry", Version: "v1.2.4", Tag: "v1.2.4", Endpoint: remote, Remote: "origin"},
|
|
} {
|
|
if _, err := publish.SaveEvidence(t.Context(), repository, store, decoy); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
recovery := exec.Command("/bin/sh", "-c", recoveryCommand)
|
|
recovery.Dir = t.TempDir()
|
|
recoveryOutput, recoveryErr := recovery.CombinedOutput()
|
|
if !errors.As(recoveryErr, &exitError) || exitError.ExitCode() != 1 {
|
|
t.Fatalf("recovery command exit = %v, output = %q; want known absence status 1", recoveryErr, recoveryOutput)
|
|
}
|
|
recoveryText := string(recoveryOutput)
|
|
if !strings.Contains(recoveryText, "module=example.com/cli-candidate tag=v1.2.3 state=not_published") || strings.Contains(recoveryText, "example.com/other-module") || strings.Contains(recoveryText, "example.com/retry") {
|
|
t.Fatalf("printed command selected the wrong attempt: %q", recoveryText)
|
|
}
|
|
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestShellQuoteHandlesApostrophesAndSpaces(t *testing.T) {
|
|
got := shellQuote("/tmp/ReleaseKit's recovery record.json")
|
|
if got != "'/tmp/ReleaseKit'\"'\"'s recovery record.json'" {
|
|
t.Fatalf("shellQuote() = %q", got)
|
|
}
|
|
}
|
|
|
|
func newSimplePublishRepository(t *testing.T, recordBlocker bool) (string, string) {
|
|
t.Helper()
|
|
repository := newCLIRepository(t)
|
|
settings, err := json.Marshal(cli.Settings{Product: "Example Product", Remote: "origin"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeRepositoryFile(t, repository, "releasekit.json", string(settings))
|
|
files := []string{"releasekit.json"}
|
|
if recordBlocker {
|
|
writeRepositoryFile(t, repository, "record-blocker", "tracked file\n")
|
|
files = append(files, "record-blocker")
|
|
}
|
|
cliGitRun(t, repository, append([]string{"add"}, files...)...)
|
|
cliGitRun(t, repository, "commit", "-m", "configure source release")
|
|
remote := filepath.Join(t.TempDir(), "origin.git")
|
|
cliGitRun(t, repository, "init", "--bare", "--initial-branch=main", remote)
|
|
cliGitRun(t, repository, "remote", "add", "origin", remote)
|
|
cliGitRun(t, repository, "push", "--set-upstream", "origin", "main")
|
|
return repository, remote
|
|
}
|
|
|
|
func outputLineValue(output, prefix string) string {
|
|
for _, line := range strings.Split(output, "\n") {
|
|
if strings.HasPrefix(line, prefix) {
|
|
return strings.TrimPrefix(line, prefix)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func repositoryRoot(t *testing.T) string {
|
|
t.Helper()
|
|
directory, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return filepath.Clean(filepath.Join(directory, "..", ".."))
|
|
}
|
|
|
|
// The shim blocks exactly when Git would start the recorded tag push. The CLI
|
|
// PID lets the test cancel the actual operation even behind a Go driver/launcher.
|
|
const cancelPushScript = `if [ -n "$RELEASEKIT_CANCEL_PUSH_READY" ]; then
|
|
for arg in "$@"; do
|
|
if [ "$arg" = push ]; then
|
|
printf '%s' "$PPID" > "$RELEASEKIT_CANCEL_PUSH_READY"
|
|
exec sleep 600
|
|
fi
|
|
done
|
|
fi
|
|
`
|
|
|
|
func canceledPublicationOutput(t *testing.T, command *exec.Cmd, repo, store string) ([]byte, error) {
|
|
t.Helper()
|
|
ready := filepath.Join(t.TempDir(), "push-ready")
|
|
command.Env = append(os.Environ(), "RELEASEKIT_CANCEL_PUSH_READY="+ready)
|
|
var output bytes.Buffer
|
|
command.Stdout = &output
|
|
command.Stderr = &output
|
|
if err := command.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer command.Process.Kill()
|
|
done := make(chan error, 1)
|
|
go func() { done <- command.Wait() }()
|
|
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
|
|
defer cancel()
|
|
ticker := time.NewTicker(5 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
var operation *os.Process
|
|
waiting:
|
|
for {
|
|
select {
|
|
case err := <-done:
|
|
t.Fatalf("publication finished before push interception: %v: %s", err, &output)
|
|
case <-ctx.Done():
|
|
_ = command.Process.Kill()
|
|
<-done
|
|
t.Fatal("publication never reached push interception")
|
|
case <-ticker.C:
|
|
data, err := os.ReadFile(ready)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
pid, err := strconv.Atoi(string(data))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
operation, err = os.FindProcess(pid)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer operation.Release()
|
|
break waiting
|
|
}
|
|
}
|
|
// Check the handoff identity before allowing cancellation to reach publication.
|
|
listed, err := publish.ListAttempts(t.Context(), repo, store)
|
|
if err != nil || len(listed.Attempts) != 1 || listed.Attempts[0].Evidence.TagObject == "" {
|
|
_ = operation.Signal(os.Interrupt)
|
|
<-done
|
|
t.Fatalf("pre-push record is incomplete: %#v, %v", listed, err)
|
|
}
|
|
if err := operation.Signal(os.Interrupt); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case err := <-done:
|
|
if !strings.Contains(output.String(), "context canceled") {
|
|
t.Fatalf("publication lost its cancellation diagnostic: %s", &output)
|
|
}
|
|
return output.Bytes(), err
|
|
case <-ctx.Done():
|
|
_ = operation.Kill()
|
|
_ = command.Process.Kill()
|
|
<-done
|
|
t.Fatal("canceled publication did not return")
|
|
}
|
|
return nil, nil
|
|
}
|