Finish publication recovery workflow

This commit is contained in:
2026-11-02 19:37:29 +00:00
parent 6915a86132
commit 599ad81bec
10 changed files with 697 additions and 76 deletions

View 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()}
}