From 599ad81becccfddb9a92f157a32e118ebe615b70 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 2 Nov 2026 19:37:29 +0000 Subject: [PATCH] Finish publication recovery workflow --- check.go | 6 +- .../publication_recovery_workflow_test.go | 351 ++++++++++++++++++ docs/cli.md | 55 ++- docs/consumers/publish.md | 50 +++ docs/integrations/publication-records.md | 111 ++++++ docs/internal/overview.md | 21 +- docs/release.md | 81 ++-- docs/roadmap/implementation.md | 4 +- publish.go | 25 +- recovery.go | 69 ++-- 10 files changed, 697 insertions(+), 76 deletions(-) create mode 100644 cmd/releasekit/publication_recovery_workflow_test.go create mode 100644 docs/integrations/publication-records.md diff --git a/check.go b/check.go index a77abe2..454f9e0 100644 --- a/check.go +++ b/check.go @@ -46,8 +46,10 @@ type ReleaseRequest struct { // this string exactly before validation or tag creation. Check ignores this // publication-only expectation. A mismatch returns ErrUnexpectedPushURL. ExpectedPushURL string - // PublicationRecordDir selects Publish's private attempt-record directory. - // Check ignores this publication-only setting. + // PublicationRecordDir optionally overrides Publish's private attempt-record + // directory. Relative paths are resolved from the source repository root; + // Check ignores this publication-only setting. Recording failure is returned + // separately on PublicationResult and does not change publication status. PublicationRecordDir string // Validation selects optional checks run against the captured candidate. Validation ValidationSpec diff --git a/cmd/releasekit/publication_recovery_workflow_test.go b/cmd/releasekit/publication_recovery_workflow_test.go new file mode 100644 index 0000000..46a4122 --- /dev/null +++ b/cmd/releasekit/publication_recovery_workflow_test.go @@ -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()} +} diff --git a/docs/cli.md b/docs/cli.md index 50a9943..f8bde05 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,6 @@ # Command line -ReleaseKit currently provides six commands. The exit statuses below belong +ReleaseKit currently provides eight commands. The exit statuses below belong to the native executable. `go run` examples are convenient for source execution, but the Go driver maps a nonzero child exit status to 1. Use the built executable or `./scripts/release` for publication when distinct statuses are needed. @@ -75,7 +75,7 @@ Unsupported argument sequences print usage to standard error. Publish a source tag: ```sh -go run ./cmd/releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] VERSION +go run ./cmd/releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] [--record-dir DIR] VERSION ``` `publish` reads the same product, remote, validation, and additional-check @@ -138,13 +138,62 @@ mismatches fail during preflight before Go validation or tag creation. Git URL rewrites are resolved first, and the push URL is used when fetch and push URLs differ. `check` ignores this field. +The command automatically saves available publication evidence in the repository's +private attempt store. Use `--record-dir DIR` to select another directory; a +relative override is resolved from the source repository root. Standard error +reports the absolute saved record path or a recording warning separately from +the publication result. Recording failure does not change publication status. +When publication fails and a record was saved, the CLI also prints a copyable +`reconcile --record` command for that exact record. The command uses the running +executable's absolute path, so it remains usable when the executable is not on +`PATH`; if that executable is no longer available, use another supported +ReleaseKit CLI. + The command returns status 0 for confirmed publication, 1 for a known publication failure or conflict, 2 for usage, profile, or invalid-version errors, and 3 when the push outcome remains unknown. Unknown status must be -resolved against the authoritative remote before retrying. The +resolved with the explicit-record recovery operation below before retrying. The [maintainer procedure](release.md) selects the repository profile and gives the source-release workflow. +## Recover a saved publication attempt or discover local attempts + +```sh +releasekit reconcile --record FILE [--json] +releasekit publication-attempts [--repo DIR] [--record-dir DIR] [--json] +``` + +`reconcile` requires one explicitly selected record and reads a relative `FILE` +from the invoking directory. It observes the endpoint and tag stored in that +record; it does not load a profile, inspect the current candidate or local tag, +run checks, or change repository state. `publication-attempts` lists records in +the selected store without querying remotes or choosing an attempt. An omitted +`--repo` means the current directory. Its relative `--record-dir` is resolved +from the selected repository root; an absolute directory can be listed outside +a checkout. The selected directory must already exist. Discovery reports usable +records and per-record diagnostics together when some records cannot be read. + +By default, both operations print human-readable output. `--json` writes one +structured document to standard output after valid arguments, including for +reconciliation and discovery failures. Recovery does not run candidate checks +or make hosted delivery requests. It reads the record's captured endpoint, while +using current supported transport settings for its recorded remote name. + +| Recovery command result | Status | +| --- | --- | +| Reconciliation observes the intended publication | 0 | +| Reconciliation observes absence or conflict | 1 | +| Reconciliation cannot read or decode the selected record, or has an invocation error | 2 | +| Reconciliation remains unknown, including missing evidence or cancellation | 3 | +| Discovery completes without record diagnostics, including an existing empty store | 0 | +| Discovery is incomplete or fails, including cancellation | 1 | +| Discovery argument or usage error | 2 | + +The CLI JSON schema and safe evidence fields are documented in the +[publication record format](integrations/publication-records.md). Recovery +guidance for storage, older evidence, and retry decisions is in the +[maintainer procedure](release.md#resolve-publication-failures). + Build selected application targets: ```sh diff --git a/docs/consumers/publish.md b/docs/consumers/publish.md index 69bba4d..5608250 100644 --- a/docs/consumers/publish.md +++ b/docs/consumers/publish.md @@ -123,3 +123,53 @@ against the captured endpoint before retrying; see the [maintainer procedure](.. for the recovery steps. The result's endpoint is the exact URL and can contain credentials; handle it accordingly. The [CLI contract](../cli.md) documents command output and exit statuses. + +## Retain and reconcile publication evidence + +`Publish` automatically attempts to save one private record for each invocation. +The default store is under the repository's Git common directory, so linked +worktrees share it. `ReleaseRequest.PublicationRecordDir` can override the +directory; relative overrides resolve from the source repository root. The CLI +equivalent is `publish --record-dir DIR`. ReleaseKit creates its owned storage +directories privately and creates record files with owner-only permissions; +existing directory permissions are left as they are. The caller owns storage +retention and removal. + +The saved endpoint is retained exactly for later observation and may include +credentials. Treat records, `PublicationEvidence`, and `PublicationResult` as +sensitive. Store overrides in a location with suitable access controls, avoid +printing raw endpoint values, and remove records when their recovery value has +expired. Diagnostic errors and CLI output apply credential redaction; the +private record and typed evidence preserve the raw endpoint. + +Recording failure is reported in `PublicationResult.RecordError`, separately +from `Publish`'s return error and publication state. A successful push remains +successful if storage fails, but the saved handoff is unavailable. Callers can +still pass `result.Evidence` directly to `ReconcilePublication` when it contains +the required identity. For example: + +```go +func observe(ctx context.Context, result releasekit.PublicationResult) (releasekit.PublicationReconciliationResult, error) { + return releasekit.ReconcilePublication(ctx, result.Evidence) +} +``` + +The evidence is populated as publication identity becomes available, including +partial evidence on some earlier failures. An abrupt process termination before +the save point can leave no record; removed storage also removes the handoff. +Records from older attempts may lack the tag object, endpoint, or other identity +needed for a conclusive observation. + +Use `LoadPublicationEvidence(path)` to read one explicit record, +`ListPublicationAttempts(ctx, request)` to discover readable records and +per-record diagnostics in a local directory, or `ReconcilePublication(ctx, +evidence)` to observe a selected endpoint. Listing does not query remotes, and +neither listing nor reconciliation changes saved records. Recovery does not +infer the attempt from current HEAD, local tags, module files, or profiles. +`ReconcilePublication` returns a nil error for a completed `published`, +`not_published`, or `conflict` observation; unresolved `unknown` results include +a typed error that retains the underlying cause. See the +[GoDoc](../../recovery.go) for the exported fields and error kinds, the +[CLI guide](../cli.md) for command statuses, and the +[record format guide](../integrations/publication-records.md) for supported +stored and structured formats. diff --git a/docs/integrations/publication-records.md b/docs/integrations/publication-records.md new file mode 100644 index 0000000..8c1d84e --- /dev/null +++ b/docs/integrations/publication-records.md @@ -0,0 +1,111 @@ +# Publication records and recovery output + +This guide describes the supported private publication-record envelope and the +versioned JSON documents emitted by the recovery CLI. The +[publication consumer guide](../consumers/publish.md) owns storage placement, +permissions, retention, and sensitive-data handling. The +[CLI reference](../cli.md) owns command behavior, path selection, and exit +statuses; the exported Go contract is documented in [GoDoc](../../recovery.go). + +## Private attempt record + +Each saved attempt is one complete JSON document with this versioned envelope: + +```json +{ + "format_version": 1, + "evidence": { + "repository_dir": "/work/project", + "module_dir": "/work/project", + "module_path": "example.com/project", + "version": "v1.2.3", + "tag": "v1.2.3", + "candidate_commit": "", + "tag_object": "", + "endpoint": "ssh://git.example/owner/project.git", + "remote": "origin", + "recorded_at": "2026-11-02T12:00:00Z" + } +} +``` + +The format version is an integer. Evidence fields may be absent when publication +stopped before they were captured; loading preserves that partial evidence and +does not fill fields from the current checkout. Additional fields in a +supported version are ignored. A missing or unsupported version, malformed or +multiple JSON documents, or fields with invalid structural types are reported +as record-format errors. The codec supports version 1; no migration from an +unsupported version is performed. + +The `endpoint` value is stored exactly as captured and may contain credentials. +Private records are sensitive. Do not copy them into public logs or source +control. For their location, permissions, and lifecycle, see the +[publication consumer guide](../consumers/publish.md#retain-and-reconcile-publication-evidence). + +## Recovery CLI JSON + +After valid command arguments, `--json` emits one JSON document to standard +output for both successful and failed recovery operations. Each document has +`schema_version: 1` and a `command` name. These are CLI presentation documents, +not private attempt records or a Go serialization of public structs. + +`reconcile` includes `record_path`, safe `evidence`, and `state` when a record +was read. Available `observed_tag_object` and `observed_commit` fields are +included when present. A `diagnostic` is included for unresolved observation +or record loading failure. Loading failure omits evidence, state, and observed +IDs. + +`publication-attempts` includes `record_dir`, an `attempts` array with each +`record_path` and safe `evidence`, and a `diagnostics` array. A top-level +`diagnostic` describes enumeration or cancellation failure. Both arrays are +present when empty so consumers can decode the same shape for empty stores. + +For example, an unresolved reconciliation can have this shape: + +```json +{ + "schema_version": 1, + "command": "reconcile", + "record_path": "/private/attempt-abc.json", + "evidence": { + "repository_dir": "/work/project", + "module_path": "example.com/project", + "version": "v1.2.3", + "tag": "v1.2.3", + "candidate_commit": "", + "tag_object": "", + "endpoint": "ssh://git.example/owner/project.git", + "remote": "origin" + }, + "state": "unknown", + "diagnostic": { + "kind": "remote_observation", + "message": "" + } +} +``` + +A discovery document always contains both arrays, including for an empty store: + +```json +{ + "schema_version": 1, + "command": "publication-attempts", + "record_dir": "/private/publication-attempts", + "attempts": [], + "diagnostics": [] +} +``` + +CLI evidence uses the private record's snake-case field names. Endpoint strings +in CLI output are credential-redacted; the private record and typed +`PublicationEvidence` retain the exact endpoint. A diagnostic contains `kind` +and safe `message`, with `record_path` and `missing_fields` when applicable. +Raw Go errors and private record contents are not emitted as JSON fields. + +Consumers should select attempts by their returned record paths and evidence. +The listing order is deterministic by path, not a recommendation to choose the +first or newest attempt. Unknown fields may be added in later CLI schema +versions; reject unsupported `schema_version` values rather than interpreting +them as version 1. See the [CLI reference](../cli.md#recover-a-saved-publication-attempt-or-discover-local-attempts) +for invocation and status behavior. diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 8cf63df..d3976f1 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -11,12 +11,14 @@ two Go facades and a command-line interface: - `buildinfo` is the small runtime facade. `Version` reports the injected application version, the main-module version when available, or `dev`. - `cmd/releasekit` is the executable interface. Its `version`, `check`, - `publish`, `build`, `deliver`, and `ci-deliver` commands adapt profiles and - command output to the supported library operations. + `publish`, `reconcile`, `publication-attempts`, `build`, `deliver`, and + `ci-deliver` commands adapt profiles and command output to the supported + library operations. See the [CLI contract](../cli.md), [configuration reference](../config.md), [candidate check guide](../consumers/check.md), [source publication guide](../consumers/publish.md), +[publication record format](../integrations/publication-records.md), [build guide](../consumers/build.md), [delivery guide](../consumers/delivery.md), and [runtime version guide](../consumers/runtime-version.md) for task-oriented use. @@ -49,7 +51,7 @@ reports these implemented packages: | `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. | | `internal/releaseidentity` | Committed-object and working-tree module selection, declaration reading, and semantic-version-to-tag derivation. | | `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, formatting, workspace-file and filesystem-replacement checks, per-blob size limits, and selected Markdown destination and fragment checking; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. | -| `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. | +| `internal/publish` | Publication guards, one validation run, annotated tag creation, push, pre-push evidence recording, saved-attempt discovery, later observation, and recovery state. | | `internal/runtimeinfo` | Injected version selection and cached fallback metadata lookup for the runtime facade. | | `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. | @@ -75,6 +77,18 @@ it does not consult neighboring files or traverse submodule checkouts. See the [shared validation guide](validation.md) for checker, Go command, and candidate-file integration mechanics. +Publication records and recovery follow a separate path from candidate +validation. `internal/publish` captures and saves attempt evidence before the +tag push, lists local records, and observes explicitly selected evidence through +`internal/git`; the root facade owns the reusable Go evidence and result types. +The CLI's `reconcile` and `publication-attempts` commands load, present, and +select records without loading a profile or choosing an attempt automatically. +These operations do not rerun candidate checks or hosted delivery and do not +change local or remote state. The [publication consumer guide](../consumers/publish.md) +owns storage and API use; the [record format guide](../integrations/publication-records.md) +owns the private and CLI JSON formats; the [CLI contract](../cli.md) owns command +semantics and statuses. + The root facade selects the backend explicitly and passes a configured `internal/forge.Backend` into `internal/delivery`. Delivery performs cheap request and source checks, then read-only backend preflight before managed @@ -102,6 +116,7 @@ not a separate release matrix: | Piped child-process exchanges and cancellation cleanup | `internal/process` | `TestStartPipedExchangesBeforeInputEOFAndPreservesSettings`, `TestStartPipedCancellationAfterReadinessReapsChild`, `TestStartPipedPreservesNonzeroExit` | | Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishRejectsUnexpectedPushURLBeforeValidation`, `TestPublishComparesExpectedURLWithResolvedInsteadOfDestination`, `TestPublishExpectedPushURLUsesPushURLWhenFetchAndPushDiffer`, `TestCheckIgnoresExpectedPushURL`, `TestResolveUsesPushURLAndQueriesWithoutMutation` | | Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` | +| Saved attempt recording, discovery, explicit later reconciliation, and read-only recovery | `releasekit`, `internal/publish`, `internal/git`, `cmd/releasekit` | `TestLostPublicationOutputCanBeRecoveredFromExplicitRecords`, `TestBuiltPublishRecoveryCommandSelectsItsRecordAmongOtherAttempts`, `TestRecoveryFacadeLoadsPartialEvidenceAndPreservesCause`, `TestRecoveryFacadeErrorUnwrapsCancellation`, `TestListPublicationAttemptsReadsExplicitExternalStore` | | Cross-builds, version smoke checks, module paths, empty target sets, and partial outputs | `releasekit.Build`, `internal/build`, `internal/artifact`, `internal/tempdir`, `buildinfo` | `TestBuildCreatesSelectedTargetsAndInjectsVersion`, `TestBuildUsesNestedWorkingTreeModuleAndModuleRelativeOutput`, `TestBuildResolvesDirtyWorkingTreeDeclaration`, `TestBuildWithNoTargetsDoesNotInvokeCompiler`, `TestBuildReturnsPartialArtifactsAfterTargetFailure`, `TestNormalizeSettingsKeepsBuildInputsAndNormalizesTargets`, `TestOutsideFallsBackWhenTemporaryDirectoryIsInsideWorktree` | | Explicit destination selection, standalone and automatic preflight, and delivery ordering | `releasekit`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliveryForgeSelectionIsExplicit`, `TestPreflightDeliveryChecksAuthenticatedRepositoryWithoutCheckout`, `TestPreflightDeliveryPreservesPublicFailureKindsAndCauses`, `TestAutomaticPreflightFailurePrecedesPrebuiltArtifactHashing`, `TestDeliverUnavailablePreflightStopsBeforeManagedBuild`, `TestCIDeliverUnavailablePreflightStopsBeforeDependencyPreparation`, `TestCIDeliverPageOnlyReportsUnavailablePreflightWithStatusOne` | | Page-only and managed or prebuilt binary delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlySkipsBuildAndHostedAssets`, `TestDeliverUsesCommittedModuleIdentityForSeparatePages`, `TestDeliverRejectsPrebuiltArtifactsForAnotherModuleBeforeHostedRequests`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites` | diff --git a/docs/release.md b/docs/release.md index a307141..1544396 100644 --- a/docs/release.md +++ b/docs/release.md @@ -124,48 +124,65 @@ the complete checked-in release note. Report its result separately from the completed source release. For failed or uncertain delivery, follow the recovery instructions below using the same immutable version. -## Resolve failures +## Resolve publication failures -A failed push triggers a URL-resolution check against the captured endpoint, -then at most one exact authoritative tag query. If the endpoint no longer -resolves to itself, recovery reports `unknown` without querying the rewritten -destination. An exact tag-object match confirms publication even when Git -reported an error. A proven absent tag is `not_published`; a different object -is a conflict; an unreadable query is `unknown`. The command retains its local -tag and reports the state, phase, -candidate commit, tag object, and captured publication endpoint when available. -Do not retry an unknown result until you resolve the authoritative ref at that -exact endpoint. Copy the reported endpoint value into the variable below using -normal shell quoting; retain the version and tag object from the same result. -The command checks that Git URL rewriting still resolves this value to itself; -the manual check below applies the same rule before querying: +Each publication invocation attempts to save its captured identity before the +tag push. The CLI reports the saved record path separately from publication +success or failure and, after a push or immediate-reconciliation error, prints a +copyable command that selects that exact record. Run that command to make a +separate later observation: ```sh -releasekit_endpoint='COPY_CAPTURED_ENDPOINT' -releasekit_version='vX.Y.Z' -test "$(git ls-remote --get-url -- "$releasekit_endpoint")" = "$releasekit_endpoint" && - git ls-remote --refs -- "$releasekit_endpoint" "refs/tags/$releasekit_version" +'/path/to/releasekit' reconcile --record '/absolute/path/to/attempt-record.json' ``` -A failed URL-equality check or remote query leaves the outcome unresolved. -Correct transport configuration before continuing. The reported endpoint may -be different from `origin`'s fetch URL or its current push URL; querying either -of those does not establish absence at the captured endpoint. +The saved evidence fixes the original endpoint and tag identity. Later recovery +uses current supported transport settings for the recorded remote name, but it +does not substitute the current remote URL, inspect the local tag or current +HEAD, load a profile, rerun checks, or change repository state. A completed +observation reports `published`, `not_published`, or `conflict`; an incomplete +observation reports `unknown` with diagnostic context. Resolve an unknown result +by correcting the identified endpoint rewrite, transport configuration, or +missing evidence. For incomplete saved identity, select another record with the +required fields or use complete typed evidence through the Go API. -Compare the returned object ID with the tag object reported by the command. -An exact match confirms publication. If the ref is absent, the version was not -published; only then may you deliberately remove the unpublished local tag -with `git tag -d vX.Y.Z` before correcting the candidate or retrying. Do not -move, overwrite, or delete a tag that exists remotely. If a different object -uses the version, choose a new version after resolving the conflict. +If command output was lost, list local attempts and explicitly select the +matching record by its module, full tag, and saved identity: -If local tag inspection fails after creation, the reported ref may remain even -when no tag object ID was obtained. Inspect that ref before recovery. +```sh +releasekit publication-attempts --repo "$PWD" +releasekit reconcile --record '/absolute/path/from-the-list/attempt-record.json' +``` + +Discovery only reads the local store. It neither queries remotes nor chooses an +attempt automatically. Use a supported ReleaseKit CLI if the executable named +in the printed handoff command is no longer available. An older record may lack +the endpoint, tag-object ID, candidate commit, or another field required for a +conclusive result. + +A later observation that the tag is absent describes the remote at that time; +it does not prove the version was never published. Never reuse a version known +to have been published, and preserve published tags and contents as immutable. +Keep removal of an unpublished local tag, candidate correction, and retry as +explicit operator decisions after reviewing the record and authoritative +remote state. A conflicting object requires resolving the conflict and selecting +an unused version when appropriate; do not move, overwrite, or delete a +published tag. + +If record storage fails, ReleaseKit reports the recording warning separately; +the warning does not change the actual publication outcome. The saved handoff is +then unavailable. A Go caller that retained `PublicationResult.Evidence` can +still pass that typed evidence directly to `ReconcilePublication`. Process +termination before the record is written, removed storage, and older incomplete +records can leave no sufficient saved evidence for later recovery. + +Later reconciliation adds no successful-push read-back, polling, CI, consumer +installation, or hosted-delivery gate. Hosted page delivery remains separate. If validation or a guard fails before this invocation pushes, correct and commit the candidate, publish `main`, and run the command again. A retained -local tag still requires the authoritative-state check above before removal; -a concurrent publisher may already have published the version. The prepared +local tag still requires reviewing the selected record and authoritative state +before deciding whether to remove it. The prepared Woodpecker workflow builds the CLI from the tagged checkout, then uses `ci-deliver` for page-only delivery. To activate it after configuring the destination and secret, copy it to `.woodpecker/releasekit-source-delivery.yml` diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 05196d3..dbf8aad 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Publication reconciliation implementation plan -Status: ready for implementation. +Status: complete. Implement the target and resolved decisions in the [feature roadmap](publication-reconciliation.md). The original @@ -471,6 +471,8 @@ launchers and source bootstrap working without adding consumer-owned recording. ## Stage 8: Finish assembled coverage and canonical documentation +**Completed:** 2026-11-02 + **Outcome:** The full feature works across process boundaries, with supported contracts and a short consumer recovery workflow. diff --git a/publish.go b/publish.go index 36c5096..603b830 100644 --- a/publish.go +++ b/publish.go @@ -6,18 +6,23 @@ import ( "go.maximumdirect.net/releasekit/internal/publish" ) +// PublicationState describes one publication attempt or a later observation. type PublicationState string const ( // PublicationNotAttempted means no tag push was attempted. PublicationNotAttempted PublicationState = PublicationState(publish.NotAttempted) - // PublicationNotPublished means this invocation knows the tag was not published. + // PublicationNotPublished means the operation established that its tag was + // not published or was absent at observation time. It does not establish that + // the version was never published. PublicationNotPublished PublicationState = PublicationState(publish.NotPublished) - // PublicationPublished means Git reported a successful push of the intended tag object. + // PublicationPublished means the intended tag object was pushed successfully + // or observed remotely. PublicationPublished PublicationState = PublicationState(publish.Published) - // PublicationConflict means an authoritative branch or tag guard failed. + // PublicationConflict means an authoritative guard or later observation found + // conflicting state. PublicationConflict PublicationState = PublicationState(publish.Conflict) - // PublicationUnknown means a tag push failed and its remote outcome is unresolved. + // PublicationUnknown means publication could not be determined from the available evidence. PublicationUnknown PublicationState = PublicationState(publish.Unknown) ) @@ -32,7 +37,7 @@ const ( PublicationTagCreation PublicationPhase = PublicationPhase(publish.TagCreation) // PublicationPush identifies the remote tag push attempt. PublicationPush PublicationPhase = PublicationPhase(publish.Push) - // PublicationReconciliation identifies the single authoritative tag query after a push error. + // PublicationReconciliation identifies immediate observation after a push error. PublicationReconciliation PublicationPhase = PublicationPhase(publish.Reconciliation) // PublicationComplete identifies a successful tag push. PublicationComplete PublicationPhase = PublicationPhase(publish.Complete) @@ -51,8 +56,8 @@ type PublicationResult struct { TagObject string // local annotated tag object ID, when established; a local ref may remain even when empty State PublicationState // publication outcome for this invocation Evidence PublicationEvidence // typed evidence for later reconciliation; Endpoint may contain credentials - RecordPath string // absolute private record path when recording is available - RecordError error // recording failure, separate from publication outcome + RecordPath string // absolute private record path when recording succeeds + RecordError error // recording failure, separate from publication outcome and returned error } // After publication preflight succeeds, Publish runs one offline Go test suite @@ -77,7 +82,11 @@ type PublicationResult struct { // tag query: an exact object match confirms publication, absence is // not_published, a different object is conflict, and an unreadable result is // unknown. A nil error means publication is confirmed by the successful push -// or exact remote object match. +// or an exact remote object match. Publish records available attempt evidence +// before the tag push when local storage is available; recording failure is +// reported separately in PublicationResult.RecordError and does not change the +// publication outcome. PublicationResult.Evidence can be passed directly to +// ReconcilePublication even when no record was saved. func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, error) { candidateInput := candidateRequest(request) result, err := publish.Publish(ctx, publish.Request{ diff --git a/recovery.go b/recovery.go index 1cd0381..395563c 100644 --- a/recovery.go +++ b/recovery.go @@ -9,40 +9,47 @@ import ( "go.maximumdirect.net/releasekit/internal/publish" ) -// PublicationEvidence identifies one attempted publication. Endpoint may -// contain credentials; treat this value and saved records as sensitive. +// PublicationEvidence identifies one attempted publication independently of +// current repository state. Reconciliation uses RepositoryDir as Git's working +// directory, Tag and Endpoint as the observed ref and destination, and the +// captured object IDs to classify the observation. Endpoint may contain +// credentials; treat this value and saved records as sensitive. type PublicationEvidence struct { - RepositoryDir string - ModuleDir string - ModulePath string - Version string - Tag string - CandidateCommit string - TagObject string - Endpoint string - Remote string - RecordedAt string + RepositoryDir string // Git working-directory context for relative endpoints; empty uses the caller's current directory. + ModuleDir string // physical module directory captured for this attempt. + ModulePath string // declared module path captured for this attempt. + Version string // semantic version selected for this attempt. + Tag string // complete Git tag ref selected for this attempt. + CandidateCommit string // candidate commit captured before publication. + TagObject string // annotated tag object ID captured before pushing, when available. + Endpoint string // exact effective push endpoint captured for this attempt; may contain credentials. + Remote string // remote name supplying supported transport configuration during later observation. + RecordedAt string // UTC RFC3339Nano time when this attempt's evidence was initialized, when available. } -// PublicationReconciliationResult combines evidence with current remote state. +// PublicationReconciliationResult combines saved evidence with one later +// observation. It does not update local or remote state or modify the record. type PublicationReconciliationResult struct { - Evidence PublicationEvidence - State PublicationState - ObservedTagObject string - ObservedCommit string + Evidence PublicationEvidence // original selected evidence. + State PublicationState // published, not_published, conflict, or unknown. + ObservedTagObject string // observed remote tag-object ID, when available. + ObservedCommit string // observed peeled commit ID, when available. } -// PublicationRecoveryError describes an unresolved recovery operation. +// PublicationRecoveryError describes an unresolved recovery operation or a +// record/storage failure. Its Error text is credential-safe; Unwrap retains +// the underlying cause for errors.Is and errors.As. // Kind includes missing_evidence, invalid_evidence, canceled, endpoint_changed, // transport_configuration, remote_observation, inconsistent_observation, // record_format, record_read, record_write, and storage_enumeration. type PublicationRecoveryError struct { Kind string - RecordPath string - MissingFields []string + RecordPath string // selected or affected record path, when applicable. + MissingFields []string // private-record field names required for the attempted classification. cause error } +// Error returns the credential-safe recovery diagnostic. func (e *PublicationRecoveryError) Error() string { if e == nil { return "" @@ -56,6 +63,8 @@ func (e *PublicationRecoveryError) Error() string { } return message } + +// Unwrap returns the underlying recovery or storage cause. func (e *PublicationRecoveryError) Unwrap() error { if e == nil { return nil @@ -78,18 +87,24 @@ func LoadPublicationEvidence(path string) (PublicationEvidence, error) { return publicEvidence(evidence), publicRecoveryError(err, path) } +// PublicationAttemptsRequest selects local publication-attempt storage. type PublicationAttemptsRequest struct { - RepositoryDir string - RecordDir string + RepositoryDir string // repository used to resolve an empty or relative RecordDir; empty means the current directory. + RecordDir string // optional storage directory; relative paths use the repository root, and absolute paths can be listed outside a checkout. } + +// PublicationAttempt pairs one readable record path with the evidence it stores. type PublicationAttempt struct { - RecordPath string - Evidence PublicationEvidence + RecordPath string // absolute path of the selected record. + Evidence PublicationEvidence // saved identity, including any partial fields. } + +// PublicationAttemptsResult contains readable attempts and per-record +// diagnostics from one local directory enumeration. type PublicationAttemptsResult struct { - RecordDir string - Attempts []PublicationAttempt - Diagnostics []*PublicationRecoveryError + RecordDir string // resolved absolute directory selected for enumeration, when available. + Attempts []PublicationAttempt // readable records in deterministic path order. + Diagnostics []*PublicationRecoveryError // unreadable or unsupported records; other entries remain available. } // ListPublicationAttempts discovers saved local records without querying a