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
|
||||
// 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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
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` |
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
25
publish.go
25
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{
|
||||
|
||||
69
recovery.go
69
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
|
||||
|
||||
Reference in New Issue
Block a user