Finish publication recovery workflow
This commit is contained in:
6
check.go
6
check.go
@@ -46,8 +46,10 @@ type ReleaseRequest struct {
|
|||||||
// this string exactly before validation or tag creation. Check ignores this
|
// this string exactly before validation or tag creation. Check ignores this
|
||||||
// publication-only expectation. A mismatch returns ErrUnexpectedPushURL.
|
// publication-only expectation. A mismatch returns ErrUnexpectedPushURL.
|
||||||
ExpectedPushURL string
|
ExpectedPushURL string
|
||||||
// PublicationRecordDir selects Publish's private attempt-record directory.
|
// PublicationRecordDir optionally overrides Publish's private attempt-record
|
||||||
// Check ignores this publication-only setting.
|
// 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
|
PublicationRecordDir string
|
||||||
// Validation selects optional checks run against the captured candidate.
|
// Validation selects optional checks run against the captured candidate.
|
||||||
Validation ValidationSpec
|
Validation ValidationSpec
|
||||||
|
|||||||
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()}
|
||||||
|
}
|
||||||
55
docs/cli.md
55
docs/cli.md
@@ -1,6 +1,6 @@
|
|||||||
# Command line
|
# 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,
|
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
|
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.
|
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:
|
Publish a source tag:
|
||||||
|
|
||||||
```sh
|
```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
|
`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
|
rewrites are resolved first, and the push URL is used when fetch and push URLs
|
||||||
differ. `check` ignores this field.
|
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
|
The command returns status 0 for confirmed publication, 1 for a known
|
||||||
publication failure or conflict, 2 for usage, profile, or invalid-version
|
publication failure or conflict, 2 for usage, profile, or invalid-version
|
||||||
errors, and 3 when the push outcome remains unknown. Unknown status must be
|
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
|
[maintainer procedure](release.md) selects the repository profile and gives
|
||||||
the source-release workflow.
|
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:
|
Build selected application targets:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -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
|
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
|
credentials; handle it accordingly. The [CLI contract](../cli.md) documents
|
||||||
command output and exit statuses.
|
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.
|
||||||
|
|||||||
111
docs/integrations/publication-records.md
Normal file
111
docs/integrations/publication-records.md
Normal file
@@ -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": "<full Git object ID>",
|
||||||
|
"tag_object": "<full Git object ID>",
|
||||||
|
"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": "<full Git object ID>",
|
||||||
|
"tag_object": "<full Git object ID>",
|
||||||
|
"endpoint": "ssh://git.example/owner/project.git",
|
||||||
|
"remote": "origin"
|
||||||
|
},
|
||||||
|
"state": "unknown",
|
||||||
|
"diagnostic": {
|
||||||
|
"kind": "remote_observation",
|
||||||
|
"message": "<credential-safe diagnostic>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -11,12 +11,14 @@ two Go facades and a command-line interface:
|
|||||||
- `buildinfo` is the small runtime facade. `Version` reports the injected
|
- `buildinfo` is the small runtime facade. `Version` reports the injected
|
||||||
application version, the main-module version when available, or `dev`.
|
application version, the main-module version when available, or `dev`.
|
||||||
- `cmd/releasekit` is the executable interface. Its `version`, `check`,
|
- `cmd/releasekit` is the executable interface. Its `version`, `check`,
|
||||||
`publish`, `build`, `deliver`, and `ci-deliver` commands adapt profiles and
|
`publish`, `reconcile`, `publication-attempts`, `build`, `deliver`, and
|
||||||
command output to the supported library operations.
|
`ci-deliver` commands adapt profiles and command output to the supported
|
||||||
|
library operations.
|
||||||
|
|
||||||
See the [CLI contract](../cli.md), [configuration reference](../config.md),
|
See the [CLI contract](../cli.md), [configuration reference](../config.md),
|
||||||
[candidate check guide](../consumers/check.md),
|
[candidate check guide](../consumers/check.md),
|
||||||
[source publication guide](../consumers/publish.md),
|
[source publication guide](../consumers/publish.md),
|
||||||
|
[publication record format](../integrations/publication-records.md),
|
||||||
[build guide](../consumers/build.md),
|
[build guide](../consumers/build.md),
|
||||||
[delivery guide](../consumers/delivery.md), and
|
[delivery guide](../consumers/delivery.md), and
|
||||||
[runtime version guide](../consumers/runtime-version.md) for task-oriented use.
|
[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/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/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/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/runtimeinfo` | Injected version selection and cached fallback metadata lookup for the runtime facade. |
|
||||||
| `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. |
|
| `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
|
See the [shared validation guide](validation.md) for checker, Go command, and
|
||||||
candidate-file integration mechanics.
|
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
|
The root facade selects the backend explicitly and passes a configured
|
||||||
`internal/forge.Backend` into `internal/delivery`. Delivery performs cheap
|
`internal/forge.Backend` into `internal/delivery`. Delivery performs cheap
|
||||||
request and source checks, then read-only backend preflight before managed
|
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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
||||||
|
|||||||
@@ -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
|
completed source release. For failed or uncertain delivery, follow the recovery
|
||||||
instructions below using the same immutable version.
|
instructions below using the same immutable version.
|
||||||
|
|
||||||
## Resolve failures
|
## Resolve publication failures
|
||||||
|
|
||||||
A failed push triggers a URL-resolution check against the captured endpoint,
|
Each publication invocation attempts to save its captured identity before the
|
||||||
then at most one exact authoritative tag query. If the endpoint no longer
|
tag push. The CLI reports the saved record path separately from publication
|
||||||
resolves to itself, recovery reports `unknown` without querying the rewritten
|
success or failure and, after a push or immediate-reconciliation error, prints a
|
||||||
destination. An exact tag-object match confirms publication even when Git
|
copyable command that selects that exact record. Run that command to make a
|
||||||
reported an error. A proven absent tag is `not_published`; a different object
|
separate later observation:
|
||||||
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:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
releasekit_endpoint='COPY_CAPTURED_ENDPOINT'
|
'/path/to/releasekit' reconcile --record '/absolute/path/to/attempt-record.json'
|
||||||
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"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
A failed URL-equality check or remote query leaves the outcome unresolved.
|
The saved evidence fixes the original endpoint and tag identity. Later recovery
|
||||||
Correct transport configuration before continuing. The reported endpoint may
|
uses current supported transport settings for the recorded remote name, but it
|
||||||
be different from `origin`'s fetch URL or its current push URL; querying either
|
does not substitute the current remote URL, inspect the local tag or current
|
||||||
of those does not establish absence at the captured endpoint.
|
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.
|
If command output was lost, list local attempts and explicitly select the
|
||||||
An exact match confirms publication. If the ref is absent, the version was not
|
matching record by its module, full tag, and saved identity:
|
||||||
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 local tag inspection fails after creation, the reported ref may remain even
|
```sh
|
||||||
when no tag object ID was obtained. Inspect that ref before recovery.
|
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
|
If validation or a guard fails before this invocation pushes, correct and
|
||||||
commit the candidate, publish `main`, and run the command again. A retained
|
commit the candidate, publish `main`, and run the command again. A retained
|
||||||
local tag still requires the authoritative-state check above before removal;
|
local tag still requires reviewing the selected record and authoritative state
|
||||||
a concurrent publisher may already have published the version. The prepared
|
before deciding whether to remove it. The prepared
|
||||||
Woodpecker workflow builds the CLI from the tagged checkout, then uses
|
Woodpecker workflow builds the CLI from the tagged checkout, then uses
|
||||||
`ci-deliver` for page-only delivery. To activate it after configuring the
|
`ci-deliver` for page-only delivery. To activate it after configuring the
|
||||||
destination and secret, copy it to `.woodpecker/releasekit-source-delivery.yml`
|
destination and secret, copy it to `.woodpecker/releasekit-source-delivery.yml`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Publication reconciliation implementation plan
|
# Publication reconciliation implementation plan
|
||||||
|
|
||||||
Status: ready for implementation.
|
Status: complete.
|
||||||
|
|
||||||
Implement the target and resolved decisions in the
|
Implement the target and resolved decisions in the
|
||||||
[feature roadmap](publication-reconciliation.md). The original
|
[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
|
## Stage 8: Finish assembled coverage and canonical documentation
|
||||||
|
|
||||||
|
**Completed:** 2026-11-02
|
||||||
|
|
||||||
**Outcome:** The full feature works across process boundaries, with supported
|
**Outcome:** The full feature works across process boundaries, with supported
|
||||||
contracts and a short consumer recovery workflow.
|
contracts and a short consumer recovery workflow.
|
||||||
|
|
||||||
|
|||||||
25
publish.go
25
publish.go
@@ -6,18 +6,23 @@ import (
|
|||||||
"go.maximumdirect.net/releasekit/internal/publish"
|
"go.maximumdirect.net/releasekit/internal/publish"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// PublicationState describes one publication attempt or a later observation.
|
||||||
type PublicationState string
|
type PublicationState string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// PublicationNotAttempted means no tag push was attempted.
|
// PublicationNotAttempted means no tag push was attempted.
|
||||||
PublicationNotAttempted PublicationState = PublicationState(publish.NotAttempted)
|
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)
|
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)
|
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)
|
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)
|
PublicationUnknown PublicationState = PublicationState(publish.Unknown)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,7 +37,7 @@ const (
|
|||||||
PublicationTagCreation PublicationPhase = PublicationPhase(publish.TagCreation)
|
PublicationTagCreation PublicationPhase = PublicationPhase(publish.TagCreation)
|
||||||
// PublicationPush identifies the remote tag push attempt.
|
// PublicationPush identifies the remote tag push attempt.
|
||||||
PublicationPush PublicationPhase = PublicationPhase(publish.Push)
|
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)
|
PublicationReconciliation PublicationPhase = PublicationPhase(publish.Reconciliation)
|
||||||
// PublicationComplete identifies a successful tag push.
|
// PublicationComplete identifies a successful tag push.
|
||||||
PublicationComplete PublicationPhase = PublicationPhase(publish.Complete)
|
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
|
TagObject string // local annotated tag object ID, when established; a local ref may remain even when empty
|
||||||
State PublicationState // publication outcome for this invocation
|
State PublicationState // publication outcome for this invocation
|
||||||
Evidence PublicationEvidence // typed evidence for later reconciliation; Endpoint may contain credentials
|
Evidence PublicationEvidence // typed evidence for later reconciliation; Endpoint may contain credentials
|
||||||
RecordPath string // absolute private record path when recording is available
|
RecordPath string // absolute private record path when recording succeeds
|
||||||
RecordError error // recording failure, separate from publication outcome
|
RecordError error // recording failure, separate from publication outcome and returned error
|
||||||
}
|
}
|
||||||
|
|
||||||
// After publication preflight succeeds, Publish runs one offline Go test suite
|
// 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
|
// tag query: an exact object match confirms publication, absence is
|
||||||
// not_published, a different object is conflict, and an unreadable result 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
|
// 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) {
|
func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, error) {
|
||||||
candidateInput := candidateRequest(request)
|
candidateInput := candidateRequest(request)
|
||||||
result, err := publish.Publish(ctx, publish.Request{
|
result, err := publish.Publish(ctx, publish.Request{
|
||||||
|
|||||||
69
recovery.go
69
recovery.go
@@ -9,40 +9,47 @@ import (
|
|||||||
"go.maximumdirect.net/releasekit/internal/publish"
|
"go.maximumdirect.net/releasekit/internal/publish"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PublicationEvidence identifies one attempted publication. Endpoint may
|
// PublicationEvidence identifies one attempted publication independently of
|
||||||
// contain credentials; treat this value and saved records as sensitive.
|
// 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 {
|
type PublicationEvidence struct {
|
||||||
RepositoryDir string
|
RepositoryDir string // Git working-directory context for relative endpoints; empty uses the caller's current directory.
|
||||||
ModuleDir string
|
ModuleDir string // physical module directory captured for this attempt.
|
||||||
ModulePath string
|
ModulePath string // declared module path captured for this attempt.
|
||||||
Version string
|
Version string // semantic version selected for this attempt.
|
||||||
Tag string
|
Tag string // complete Git tag ref selected for this attempt.
|
||||||
CandidateCommit string
|
CandidateCommit string // candidate commit captured before publication.
|
||||||
TagObject string
|
TagObject string // annotated tag object ID captured before pushing, when available.
|
||||||
Endpoint string
|
Endpoint string // exact effective push endpoint captured for this attempt; may contain credentials.
|
||||||
Remote string
|
Remote string // remote name supplying supported transport configuration during later observation.
|
||||||
RecordedAt string
|
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 {
|
type PublicationReconciliationResult struct {
|
||||||
Evidence PublicationEvidence
|
Evidence PublicationEvidence // original selected evidence.
|
||||||
State PublicationState
|
State PublicationState // published, not_published, conflict, or unknown.
|
||||||
ObservedTagObject string
|
ObservedTagObject string // observed remote tag-object ID, when available.
|
||||||
ObservedCommit string
|
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,
|
// Kind includes missing_evidence, invalid_evidence, canceled, endpoint_changed,
|
||||||
// transport_configuration, remote_observation, inconsistent_observation,
|
// transport_configuration, remote_observation, inconsistent_observation,
|
||||||
// record_format, record_read, record_write, and storage_enumeration.
|
// record_format, record_read, record_write, and storage_enumeration.
|
||||||
type PublicationRecoveryError struct {
|
type PublicationRecoveryError struct {
|
||||||
Kind string
|
Kind string
|
||||||
RecordPath string
|
RecordPath string // selected or affected record path, when applicable.
|
||||||
MissingFields []string
|
MissingFields []string // private-record field names required for the attempted classification.
|
||||||
cause error
|
cause error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error returns the credential-safe recovery diagnostic.
|
||||||
func (e *PublicationRecoveryError) Error() string {
|
func (e *PublicationRecoveryError) Error() string {
|
||||||
if e == nil {
|
if e == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -56,6 +63,8 @@ func (e *PublicationRecoveryError) Error() string {
|
|||||||
}
|
}
|
||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unwrap returns the underlying recovery or storage cause.
|
||||||
func (e *PublicationRecoveryError) Unwrap() error {
|
func (e *PublicationRecoveryError) Unwrap() error {
|
||||||
if e == nil {
|
if e == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -78,18 +87,24 @@ func LoadPublicationEvidence(path string) (PublicationEvidence, error) {
|
|||||||
return publicEvidence(evidence), publicRecoveryError(err, path)
|
return publicEvidence(evidence), publicRecoveryError(err, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PublicationAttemptsRequest selects local publication-attempt storage.
|
||||||
type PublicationAttemptsRequest struct {
|
type PublicationAttemptsRequest struct {
|
||||||
RepositoryDir string
|
RepositoryDir string // repository used to resolve an empty or relative RecordDir; empty means the current directory.
|
||||||
RecordDir string
|
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 {
|
type PublicationAttempt struct {
|
||||||
RecordPath string
|
RecordPath string // absolute path of the selected record.
|
||||||
Evidence PublicationEvidence
|
Evidence PublicationEvidence // saved identity, including any partial fields.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PublicationAttemptsResult contains readable attempts and per-record
|
||||||
|
// diagnostics from one local directory enumeration.
|
||||||
type PublicationAttemptsResult struct {
|
type PublicationAttemptsResult struct {
|
||||||
RecordDir string
|
RecordDir string // resolved absolute directory selected for enumeration, when available.
|
||||||
Attempts []PublicationAttempt
|
Attempts []PublicationAttempt // readable records in deterministic path order.
|
||||||
Diagnostics []*PublicationRecoveryError
|
Diagnostics []*PublicationRecoveryError // unreadable or unsupported records; other entries remain available.
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListPublicationAttempts discovers saved local records without querying a
|
// ListPublicationAttempts discovers saved local records without querying a
|
||||||
|
|||||||
Reference in New Issue
Block a user