Finish publication recovery workflow
This commit is contained in:
351
cmd/releasekit/publication_recovery_workflow_test.go
Normal file
351
cmd/releasekit/publication_recovery_workflow_test.go
Normal file
@@ -0,0 +1,351 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.maximumdirect.net/releasekit"
|
||||
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||
)
|
||||
|
||||
func TestPublicationPushGateHelper(t *testing.T) {
|
||||
address := os.Getenv("RELEASEKIT_PUSH_GATE_ADDR")
|
||||
if address == "" {
|
||||
return
|
||||
}
|
||||
connection, err := net.DialTimeout("tcp", address, 10*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("connect to pre-receive gate: %v", err)
|
||||
}
|
||||
defer connection.Close()
|
||||
if _, err := fmt.Fprintln(connection, "ready"); err != nil {
|
||||
t.Fatalf("announce pre-receive readiness: %v", err)
|
||||
}
|
||||
response, err := bufio.NewReader(connection).ReadString('\n')
|
||||
if err != nil || strings.TrimSpace(response) != "reject" {
|
||||
t.Fatalf("pre-receive gate response = %q, %v", response, err)
|
||||
}
|
||||
// The test process is the pre-receive hook's gate. Reject only after the
|
||||
// parent has observed the pre-push record and explicitly released it.
|
||||
t.Fatal("controlled rejection after pre-push record observation")
|
||||
}
|
||||
|
||||
func TestLostPublicationOutputCanBeRecoveredFromExplicitRecords(t *testing.T) {
|
||||
setIsolatedGitEnvironment(t)
|
||||
repository := newCLIRepository(t)
|
||||
store := filepath.Join(t.TempDir(), "publication records")
|
||||
checkLog := filepath.Join(t.TempDir(), "candidate checks.log")
|
||||
var hostedRequests atomic.Int64
|
||||
hostedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hostedRequests.Add(1)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer hostedServer.Close()
|
||||
|
||||
settings := cli.Settings{
|
||||
Product: "Example Product",
|
||||
Remote: "origin",
|
||||
Checks: []cli.CheckCommand{{
|
||||
Args: []string{"/bin/sh", "-c", "printf 'candidate\\n' >> \"$RELEASEKIT_CANDIDATE_LOG\""},
|
||||
Env: map[string]string{"RELEASEKIT_CANDIDATE_LOG": checkLog},
|
||||
}},
|
||||
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: hostedServer.URL, Owner: "example", Repository: "candidate"},
|
||||
}
|
||||
writeSettings := func() {
|
||||
t.Helper()
|
||||
contents, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeRepositoryFile(t, repository, "releasekit.json", string(contents))
|
||||
cliGitRun(t, repository, "add", "releasekit.json")
|
||||
cliGitRun(t, repository, "commit", "-m", "update publication profile")
|
||||
}
|
||||
writeSettings()
|
||||
remote := filepath.Join(t.TempDir(), "original.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")
|
||||
|
||||
nestedDir := "integrations/recovery"
|
||||
writeRepositoryFile(t, repository, filepath.ToSlash(filepath.Join(nestedDir, "go.mod")), "module example.com/cli-candidate/recovery\n\ngo 1.25.0\n")
|
||||
writeRepositoryFile(t, repository, filepath.ToSlash(filepath.Join(nestedDir, "candidate_test.go")), "package recovery\nimport \"testing\"\nfunc TestFixture(t *testing.T) {}\n")
|
||||
writeRepositoryFile(t, repository, filepath.ToSlash(filepath.Join(nestedDir, "docs/releases/v1.2.3.md")), "# Example Product v1.2.3\n\nRecovery fixture.\n")
|
||||
cliGitRun(t, repository, "add", nestedDir)
|
||||
cliGitRun(t, repository, "commit", "-m", "add nested module")
|
||||
cliGitRun(t, repository, "push", "origin", "main")
|
||||
installPushGate(t, remote)
|
||||
|
||||
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
|
||||
executable := filepath.Join(t.TempDir(), "releasekit recovery workflow")
|
||||
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)
|
||||
}
|
||||
testBinary, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rootTag := "v1.2.3"
|
||||
rootRecord := rejectPublicationAfterRecording(t, executable, testBinary, repository, store, rootTag, "")
|
||||
if rootRecord.Evidence.ModuleDir != repository || rootRecord.Evidence.Tag != rootTag {
|
||||
t.Fatalf("root attempt evidence = %#v", rootRecord.Evidence)
|
||||
}
|
||||
|
||||
settings.ModuleDir = nestedDir
|
||||
writeSettings()
|
||||
cliGitRun(t, repository, "push", "origin", "main")
|
||||
nestedTag := nestedDir + "/v1.2.3"
|
||||
nestedRecord := rejectPublicationAfterRecording(t, executable, testBinary, repository, store, nestedTag, nestedDir)
|
||||
if nestedRecord.Evidence.ModuleDir != filepath.Join(repository, nestedDir) || nestedRecord.Evidence.Tag != nestedTag {
|
||||
t.Fatalf("nested attempt evidence = %#v", nestedRecord.Evidence)
|
||||
}
|
||||
if rootRecord.RecordPath == nestedRecord.RecordPath {
|
||||
t.Fatalf("publication attempts reused record path %q", rootRecord.RecordPath)
|
||||
}
|
||||
|
||||
// Replace the rejected root tag on the original endpoint with a different
|
||||
// annotated object, then make the current checkout point somewhere else.
|
||||
hook := filepath.Join(remote, "hooks", "pre-receive")
|
||||
if err := os.Remove(hook); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cliGitRun(t, repository, "tag", "-f", "-a", rootTag, "-m", "remote replacement after failed attempt", "HEAD")
|
||||
cliGitRun(t, repository, "push", "origin", "refs/tags/"+rootTag)
|
||||
decoyRemote := filepath.Join(t.TempDir(), "replacement-origin.git")
|
||||
cliGitRun(t, repository, "init", "--bare", "--initial-branch=main", decoyRemote)
|
||||
cliGitRun(t, repository, "remote", "set-url", "origin", decoyRemote)
|
||||
cliGitRun(t, repository, "switch", "-c", "recovery-inspection")
|
||||
cliGitRun(t, repository, "commit", "--allow-empty", "-m", "move recovery checkout")
|
||||
cliGitRun(t, repository, "tag", "-d", rootTag, nestedTag)
|
||||
writeRepositoryFile(t, repository, "untracked recovery marker", "preserve this file\n")
|
||||
|
||||
before := snapshotRecoveryRepository(t, repository, remote, rootRecord.RecordPath, nestedRecord.RecordPath)
|
||||
listing := runCLIJSON(t, executable, t.TempDir(), "publication-attempts", "--repo", repository, "--record-dir", store, "--json")
|
||||
var listed struct {
|
||||
Attempts []struct {
|
||||
RecordPath string `json:"record_path"`
|
||||
Evidence struct {
|
||||
Tag string `json:"tag"`
|
||||
ModuleDir string `json:"module_dir"`
|
||||
} `json:"evidence"`
|
||||
} `json:"attempts"`
|
||||
}
|
||||
if err := json.Unmarshal(listing, &listed); err != nil {
|
||||
t.Fatalf("decode publication-attempts output: %v\n%s", err, listing)
|
||||
}
|
||||
selectedRoot := ""
|
||||
selectedNested := ""
|
||||
for _, attempt := range listed.Attempts {
|
||||
switch attempt.Evidence.Tag {
|
||||
case rootTag:
|
||||
selectedRoot = attempt.RecordPath
|
||||
case nestedTag:
|
||||
selectedNested = attempt.RecordPath
|
||||
}
|
||||
}
|
||||
if selectedRoot != rootRecord.RecordPath || selectedNested != nestedRecord.RecordPath {
|
||||
t.Fatalf("discovery selected root=%q nested=%q; want %q and %q; listing=%s", selectedRoot, selectedNested, rootRecord.RecordPath, nestedRecord.RecordPath, listing)
|
||||
}
|
||||
|
||||
rootResult := runCLIStatusJSON(t, executable, t.TempDir(), "reconcile", "--record", selectedRoot, "--json")
|
||||
if rootResult.status != 1 || rootResult.document["state"] != "conflict" {
|
||||
t.Fatalf("explicit root reconciliation status=%d document=%s; want conflict on the recorded original endpoint", rootResult.status, rootResult.output)
|
||||
}
|
||||
nestedResult := runCLIStatusJSON(t, executable, t.TempDir(), "reconcile", "--record", selectedNested, "--json")
|
||||
if nestedResult.status != 1 || nestedResult.document["state"] != "not_published" {
|
||||
t.Fatalf("explicit nested reconciliation status=%d document=%s; want not_published", nestedResult.status, nestedResult.output)
|
||||
}
|
||||
if got := hostedRequests.Load(); got != 0 {
|
||||
t.Fatalf("later recovery made %d hosted delivery requests", got)
|
||||
}
|
||||
after := snapshotRecoveryRepository(t, repository, remote, rootRecord.RecordPath, nestedRecord.RecordPath)
|
||||
if before != after {
|
||||
t.Fatalf("reconciliation changed local or saved state:\nbefore: %#v\nafter: %#v", before, after)
|
||||
}
|
||||
checkBytes, err := os.ReadFile(checkLog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Count(string(checkBytes), "candidate\n"); got != 2 {
|
||||
t.Fatalf("candidate checks ran %d times; want once per publication and none during recovery: %q", got, checkBytes)
|
||||
}
|
||||
}
|
||||
|
||||
type listedAttempt struct {
|
||||
RecordPath string
|
||||
Evidence releasekit.PublicationEvidence
|
||||
}
|
||||
|
||||
func rejectPublicationAfterRecording(t *testing.T, executable, testBinary, repository, store, tag, expectedModule string) listedAttempt {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
commandArgs := []string{"publish", "--repo", repository, "--record-dir", store}
|
||||
if expectedModule != "" {
|
||||
commandArgs = append(commandArgs, "--expected-module", expectedModule)
|
||||
}
|
||||
commandArgs = append(commandArgs, "v1.2.3")
|
||||
command := exec.Command(executable, commandArgs...)
|
||||
command.Dir = repositoryRoot(t)
|
||||
command.Env = append(os.Environ(), "RELEASEKIT_PUSH_GATE_ADDR="+listener.Addr().String(), "RELEASEKIT_PUSH_GATE_TEST_BINARY="+testBinary)
|
||||
command.Stdout = io.Discard
|
||||
command.Stderr = io.Discard
|
||||
if err := command.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if command.Process != nil {
|
||||
_ = command.Process.Kill()
|
||||
_ = command.Wait()
|
||||
}
|
||||
}()
|
||||
if tcpListener, ok := listener.(*net.TCPListener); ok {
|
||||
if err := tcpListener.SetDeadline(time.Now().Add(90 * time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
connection, err := listener.Accept()
|
||||
if err != nil {
|
||||
t.Fatalf("wait for pre-receive gate for %s: %v", tag, err)
|
||||
}
|
||||
defer connection.Close()
|
||||
if tcpConnection, ok := connection.(*net.TCPConn); ok {
|
||||
if err := tcpConnection.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
line, err := bufio.NewReader(connection).ReadString('\n')
|
||||
if err != nil || strings.TrimSpace(line) != "ready" {
|
||||
t.Fatalf("pre-receive gate readiness for %s = %q, %v", tag, line, err)
|
||||
}
|
||||
listed, err := releasekit.ListPublicationAttempts(context.Background(), releasekit.PublicationAttemptsRequest{RepositoryDir: repository, RecordDir: store})
|
||||
if err != nil {
|
||||
t.Fatalf("list attempts while tag push is held: %v", err)
|
||||
}
|
||||
var selected *releasekit.PublicationAttempt
|
||||
for i := range listed.Attempts {
|
||||
if listed.Attempts[i].Evidence.Tag == tag {
|
||||
selected = &listed.Attempts[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if selected == nil {
|
||||
t.Fatalf("pre-push record for %s was not visible before the hook continued: %#v", tag, listed.Attempts)
|
||||
}
|
||||
if selected.Evidence.CandidateCommit == "" || selected.Evidence.TagObject == "" || selected.Evidence.Endpoint == "" {
|
||||
t.Fatalf("pre-push evidence for %s is incomplete: %#v", tag, selected.Evidence)
|
||||
}
|
||||
if _, err := fmt.Fprintln(connection, "reject"); err != nil {
|
||||
t.Fatalf("release pre-receive gate for %s: %v", tag, err)
|
||||
}
|
||||
waitErr := command.Wait()
|
||||
command.Process = nil
|
||||
if waitErr == nil {
|
||||
t.Fatalf("publish %s succeeded despite the controlled rejection", tag)
|
||||
} else {
|
||||
var exitError *exec.ExitError
|
||||
if !errors.As(waitErr, &exitError) || exitError.ExitCode() != 1 {
|
||||
t.Fatalf("publish %s exit = %v; want publication failure status 1", tag, waitErr)
|
||||
}
|
||||
}
|
||||
return listedAttempt{RecordPath: selected.RecordPath, Evidence: selected.Evidence}
|
||||
}
|
||||
|
||||
func installPushGate(t *testing.T, remote string) {
|
||||
t.Helper()
|
||||
hook := filepath.Join(remote, "hooks", "pre-receive")
|
||||
script := "#!/bin/sh\nwhile read old new ref; do\n case \"$ref\" in\n refs/tags/*) \"$RELEASEKIT_PUSH_GATE_TEST_BINARY\" -test.run='^TestPublicationPushGateHelper$'; exit $? ;;\n esac\ndone\nexit 0\n"
|
||||
if err := os.WriteFile(hook, []byte(script), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func setIsolatedGitEnvironment(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
|
||||
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
|
||||
t.Setenv("GIT_TERMINAL_PROMPT", "0")
|
||||
}
|
||||
|
||||
func runCLIJSON(t *testing.T, executable, directory string, args ...string) []byte {
|
||||
t.Helper()
|
||||
result := runCLIStatusJSON(t, executable, directory, args...)
|
||||
if result.status != 0 {
|
||||
t.Fatalf("CLI %v exited with status %d: %s", args, result.status, result.output)
|
||||
}
|
||||
return result.output
|
||||
}
|
||||
|
||||
type cliJSONResult struct {
|
||||
status int
|
||||
output []byte
|
||||
document map[string]any
|
||||
}
|
||||
|
||||
func runCLIStatusJSON(t *testing.T, executable, directory string, args ...string) cliJSONResult {
|
||||
t.Helper()
|
||||
command := exec.Command(executable, args...)
|
||||
command.Dir = directory
|
||||
output, err := command.CombinedOutput()
|
||||
status := 0
|
||||
if err != nil {
|
||||
var exitError *exec.ExitError
|
||||
if !errors.As(err, &exitError) {
|
||||
t.Fatalf("run CLI %v: %v\n%s", args, err, output)
|
||||
}
|
||||
status = exitError.ExitCode()
|
||||
}
|
||||
var document map[string]any
|
||||
if err := json.Unmarshal(output, &document); err != nil {
|
||||
t.Fatalf("decode CLI JSON for %v: %v\n%s", args, err, output)
|
||||
}
|
||||
return cliJSONResult{status: status, output: output, document: document}
|
||||
}
|
||||
|
||||
type recoverySnapshot struct {
|
||||
Branch string
|
||||
Head string
|
||||
Worktree string
|
||||
LocalRefs string
|
||||
RemoteRefs string
|
||||
Records string
|
||||
}
|
||||
|
||||
func snapshotRecoveryRepository(t *testing.T, repository, remote string, recordPaths ...string) recoverySnapshot {
|
||||
t.Helper()
|
||||
branch := cliGitOutput(t, repository, "branch", "--show-current")
|
||||
head := cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||
worktree := cliGitOutput(t, repository, "status", "--porcelain=v1", "--untracked-files=all")
|
||||
localRefs := cliGitOutput(t, repository, "show-ref")
|
||||
remoteRefs := cliGitOutput(t, remote, "show-ref")
|
||||
var records strings.Builder
|
||||
for _, path := range recordPaths {
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read saved record %s: %v", path, err)
|
||||
}
|
||||
fmt.Fprintf(&records, "%s\x00%s\n", path, contents)
|
||||
}
|
||||
return recoverySnapshot{Branch: branch, Head: head, Worktree: worktree, LocalRefs: localRefs, RemoteRefs: remoteRefs, Records: records.String()}
|
||||
}
|
||||
Reference in New Issue
Block a user