From 1d06ba019c0763cac7a93dbd3cfcd60c8cfc73bf Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 6 Oct 2026 01:05:31 +0000 Subject: [PATCH] Recover publication against the captured endpoint --- cmd/releasekit/main.go | 2 +- cmd/releasekit/release_wrapper_test.go | 34 +++++++++++++++++++++++++- docs/cli.md | 3 ++- docs/release.md | 24 +++++++++++++----- docs/roadmap/remediation.md | 9 +++++++ 5 files changed, 63 insertions(+), 9 deletions(-) diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index f2f1d7c..d669f28 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -61,7 +61,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "validating and publishing %s\n", invocation.Version) result, err := releasekit.Publish(ctx, request) if err != nil { - fmt.Fprintf(stderr, "publication state=%s phase=%s version=%s commit=%s tag-object=%s: %v\n", result.State, result.Phase, result.Version, result.CandidateCommit, result.TagObject, err) + fmt.Fprintf(stderr, "publication state=%s phase=%s version=%s commit=%s tag-object=%s endpoint=%q: %v\n", result.State, result.Phase, result.Version, result.CandidateCommit, result.TagObject, result.Endpoint, err) if errors.Is(err, releasekit.ErrInvalidVersion) { return 2 } diff --git a/cmd/releasekit/release_wrapper_test.go b/cmd/releasekit/release_wrapper_test.go index f4276c7..470e716 100644 --- a/cmd/releasekit/release_wrapper_test.go +++ b/cmd/releasekit/release_wrapper_test.go @@ -2,10 +2,12 @@ package main import ( "encoding/json" + "fmt" "os" "os/exec" "path/filepath" "runtime" + "strings" "testing" ) @@ -57,6 +59,10 @@ func TestReleaseWrapperPreservesNativeExitStatuses(t *testing.T) { cliGitRun(t, repository, "init", "--bare", remote) cliGitRun(t, repository, "remote", "set-url", "origin", remote) cliGitRun(t, repository, "push", "origin", "HEAD:refs/heads/main") + fetch := filepath.Join(t.TempDir(), "fetch.git") + cliGitRun(t, repository, "init", "--bare", fetch) + cliGitRun(t, repository, "remote", "set-url", "origin", fetch) + cliGitRun(t, repository, "remote", "set-url", "--push", "origin", remote) if mode.name == "rejected" { if err := os.WriteFile(filepath.Join(remote, "hooks/pre-receive"), []byte("#!/bin/sh\nexit 1\n"), 0755); err != nil { t.Fatal(err) @@ -67,7 +73,12 @@ func TestReleaseWrapperPreservesNativeExitStatuses(t *testing.T) { marker := filepath.Join(bin, "push-started") shim := `#!/bin/sh for arg do - if [ "$arg" = push ]; then : > "$RELEASEKIT_TEST_PUSH_MARKER"; exit 1; fi + if [ "$arg" = push ]; then + "$RELEASEKIT_TEST_REAL_GIT" "$@" || exit 1 + "$RELEASEKIT_TEST_REAL_GIT" remote set-url --push origin "$RELEASEKIT_TEST_MOVED_ENDPOINT" + : > "$RELEASEKIT_TEST_PUSH_MARKER" + exit 1 + fi done if [ -f "$RELEASEKIT_TEST_PUSH_MARKER" ]; then for arg do if [ "$arg" = ls-remote ]; then exit 128; fi; done @@ -80,6 +91,7 @@ exec "$RELEASEKIT_TEST_REAL_GIT" "$@" t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("RELEASEKIT_TEST_REAL_GIT", realGit) t.Setenv("RELEASEKIT_TEST_PUSH_MARKER", marker) + t.Setenv("RELEASEKIT_TEST_MOVED_ENDPOINT", fetch) } command := exec.Command("./scripts/release", mode.version) command.Dir = repository @@ -95,6 +107,26 @@ exec "$RELEASEKIT_TEST_REAL_GIT" "$@" if status != mode.status { t.Fatalf("status=%d want=%d output=%s", status, mode.status, output) } + if mode.name == "unknown" { + if !strings.Contains(string(output), fmt.Sprintf("endpoint=%q", remote)) { + t.Fatalf("captured endpoint missing: %s", output) + } + query := exec.Command(realGit, "ls-remote", "--refs", "--", remote, "refs/tags/v1.2.3") + query.Dir = repository + found, err := query.Output() + if err != nil || len(found) == 0 { + t.Fatalf("captured endpoint lost published tag: %q %v", found, err) + } + if !strings.Contains(string(found), cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3")) { + t.Fatalf("wrong recovered tag: %s", found) + } + fetchQuery := exec.Command(realGit, "ls-remote", "--refs", "origin", "refs/tags/v1.2.3") + fetchQuery.Dir = repository + stale, err := fetchQuery.Output() + if err != nil || len(stale) != 0 { + t.Fatalf("fetch fixture unexpectedly found tag: %q %v", stale, err) + } + } }) } } diff --git a/docs/cli.md b/docs/cli.md index 1263ec1..1df377c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -42,7 +42,8 @@ go run ./cmd/releasekit publish [--repo DIR] [--config FILE] VERSION It validates the candidate once, checks the authoritative remote, creates an annotated local tag, and pushes only its exact tag object. It prints the confirmed version, candidate commit, and tag object on success. Validation -progress and failed publication state are written to standard error. A signal +progress and failed publication state, including the captured publication +endpoint for recovery, are written to standard error. A signal cancels the active operation; if it arrives during a push, the library's bounded reconciliation determines whether the exact tag was accepted. diff --git a/docs/release.md b/docs/release.md index 28138d1..727c27d 100644 --- a/docs/release.md +++ b/docs/release.md @@ -77,13 +77,24 @@ A failed push triggers one exact authoritative tag query. 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, and tag object when available. Do not retry an unknown result -until you resolve the authoritative ref: +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. +Check that Git URL rewriting still resolves this value to itself before querying: ```sh -git ls-remote --refs origin "refs/tags/vX.Y.Z" +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" ``` +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. + 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 @@ -91,9 +102,10 @@ 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 validation or a guard fails before the push, the local tag has not been -published. Correct and commit the candidate, publish `main`, and run the -command again. The prepared Woodpecker workflow builds the CLI from the tagged +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 Woodpecker workflow builds the CLI from the tagged checkout and runs page-only delivery. To activate it after configuring the destination and secret, copy it to `.woodpecker/releasekit-source-delivery.yml` and add the Woodpecker repository secret `releasekit_gitea_token`. If page diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index c6df3ec..c57c5e4 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -323,6 +323,8 @@ and unresolved publication 3. The focused wrapper test passes. ### R9 — High: Manual recovery inspects the fetch endpoint +Status: Resolved. + Category: Release recovery correctness and documentation gap. **Evidence:** [The runbook](../release.md), around line 82, prescribes @@ -347,6 +349,13 @@ local tag. fetch/push repositories and confirm that a published tag is recognized. Include endpoint changes after an uncertain push when verifying retained recovery facts. +**Resolution:** Failed publication output includes the captured endpoint. The +runbook checks URL resolution and queries that exact endpoint, retaining tags +on failed or unresolved checks. Local wrapper fixtures use separate fetch and +push repositories; a lost acknowledgment changes the configured push URL, and +recovery still finds the intended object at the captured endpoint while the +fetch endpoint reports absence. Focused publication/wrapper tests pass. + ### R10 — Medium: The boundary checker bans valid command-internal composition Category: Inadvertently strengthened architectural prohibition.