From aa57fee4dea405b62a15204c30eff875d74c85f9 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 2 Nov 2026 03:20:13 +0000 Subject: [PATCH] Preserve evidence when local tag inspection fails --- docs/consumers/publish.md | 10 ++++--- docs/release.md | 3 ++ docs/roadmap/implementation.md | 2 +- internal/git/tag.go | 27 ++++++++++++------ internal/publish/publish.go | 2 +- publish.go | 2 +- publish_test.go | 51 ++++++++++++++++++++++++++++++++++ 7 files changed, 81 insertions(+), 16 deletions(-) diff --git a/docs/consumers/publish.md b/docs/consumers/publish.md index 1c55247..69bba4d 100644 --- a/docs/consumers/publish.md +++ b/docs/consumers/publish.md @@ -114,10 +114,12 @@ candidate and remote guards, creates an annotated tag for the captured commit, and pushes only that tag object. A successful result means Git confirmed the push or an exact authoritative tag -query confirmed it after a push error. The local tag is retained after a failed -push. `PublicationState` distinguishes a known failure, conflict, confirmed -publication, and unresolved outcome. Resolve an unknown result against the -captured endpoint before retrying; see the [maintainer procedure](../release.md) +query confirmed it after a push error. A failed inspection after local tag +creation can leave the tag ref even when its object ID is unavailable; when +the ID was established it remains in `TagObject`. The local tag is retained +after a failed push. `PublicationState` distinguishes a known failure, conflict, +confirmed publication, and unresolved outcome. Resolve an unknown result +against the captured endpoint before retrying; see the [maintainer procedure](../release.md) 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. diff --git a/docs/release.md b/docs/release.md index f76223f..a307141 100644 --- a/docs/release.md +++ b/docs/release.md @@ -159,6 +159,9 @@ 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 +when no tag object ID was obtained. Inspect that ref before recovery. + 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; diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 743876e..05b97be 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -42,7 +42,7 @@ the agent policy; an intermediate compilation dependency is recorded accurately. | 4 | A03: buffered command teardown | Complete — inherited-pipe, cancellation, healthy-command, native build, and publication recovery checks passed. | | 5 | A03, A18: piped lifetime and queued cancellation | Complete — process and Git lifecycle tests passed under the race detector; public preparation cancellation passed. | | 6 | A04 | Complete — Git display, endpoint change, public mismatch, CLI, publication, and Git failure regressions passed. | -| 7 | A05 | Pending | +| 7 | A05 | Complete — real-repository inspection failures preserve the local ref, available object ID, and Git cause without a push. | | 8 | A06, A14 | Pending | | 9 | A16 | Pending | | 10 | A07: allocation and temporary metadata | Pending | diff --git a/internal/git/tag.go b/internal/git/tag.go index fef24ac..f87faa7 100644 --- a/internal/git/tag.go +++ b/internal/git/tag.go @@ -9,7 +9,8 @@ import ( ) // CreateAnnotatedTag records the captured note as an unsigned annotated tag -// on the captured commit and returns the tag object's ID. +// on the captured commit. Once its type and ID are established, the ID is +// returned even if a later verification fails. func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string, note []byte) (string, error) { if err := CheckTagRef(ctx, repositoryDir, tag); err != nil { return "", err @@ -35,35 +36,43 @@ func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string, } ref := "refs/tags/" + tag + object, err := inspectCreatedTag(ctx, repositoryDir, ref, commit, note) + if err != nil { + return object, fmt.Errorf("verify created tag %s; local ref may remain: %w", ref, err) + } + return object, nil +} + +func inspectCreatedTag(ctx context.Context, repositoryDir, ref, commit string, note []byte) (string, error) { tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref) if err != nil { - return "", fmt.Errorf("inspect created tag %s: %w", tag, err) + return "", fmt.Errorf("inspect created tag %s: %w", ref, err) } if strings.TrimSpace(tagType) != "tag" { - return "", fmt.Errorf("created tag %s has object type %q, want tag", tag, strings.TrimSpace(tagType)) + return "", fmt.Errorf("created tag %s has object type %q, want tag", ref, strings.TrimSpace(tagType)) } tagObject, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref) if err != nil { - return "", fmt.Errorf("read created tag object %s: %w", tag, err) + return "", fmt.Errorf("read created tag object %s: %w", ref, err) } tagObject = strings.TrimSpace(tagObject) if !validObjectID(tagObject) { - return "", fmt.Errorf("created tag %s has invalid object ID %q", tag, tagObject) + return "", fmt.Errorf("created tag %s has invalid object ID %q", ref, tagObject) } peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{}") if err != nil { - return "", fmt.Errorf("read peeled commit for tag %s: %w", tag, err) + return tagObject, fmt.Errorf("read peeled commit for tag %s: %w", ref, err) } if strings.TrimSpace(peeled) != commit { - return "", fmt.Errorf("created tag %s peels to %s, want candidate %s", tag, strings.TrimSpace(peeled), commit) + return tagObject, fmt.Errorf("created tag %s peels to %s, want candidate %s", ref, strings.TrimSpace(peeled), commit) } tagContents, err := run(ctx, repositoryDir, nil, "cat-file", "tag", ref) if err != nil { - return "", fmt.Errorf("read annotated tag %s: %w", tag, err) + return tagObject, fmt.Errorf("read annotated tag %s: %w", ref, err) } separator := bytes.Index([]byte(tagContents), []byte("\n\n")) if separator < 0 || !bytes.Equal([]byte(tagContents[separator+2:]), note) { - return "", fmt.Errorf("annotated tag %s message does not match the captured release note", tag) + return tagObject, fmt.Errorf("annotated tag %s message does not match the captured release note", ref) } return tagObject, nil } diff --git a/internal/publish/publish.go b/internal/publish/publish.go index 53d990c..9c342cf 100644 --- a/internal/publish/publish.go +++ b/internal/publish/publish.go @@ -147,10 +147,10 @@ func Publish(ctx context.Context, request Request) (Result, error) { result.Phase = TagCreation result.State = NotPublished tagObject, err := git.CreateAnnotatedTag(ctx, captured.RepositoryDir, captured.Tag, captured.Commit, []byte(captured.Note)) + result.TagObject = tagObject if err != nil { return result, err } - result.TagObject = tagObject if err := candidate.Recheck(ctx, captured, tagObject); err != nil { return result, fmt.Errorf("pre-push local guard: %w", err) } diff --git a/publish.go b/publish.go index 7eaf1ee..c5762d9 100644 --- a/publish.go +++ b/publish.go @@ -48,7 +48,7 @@ type PublicationResult struct { CandidateCommit string // captured main commit Endpoint string // captured effective push URL, which may contain credentials Phase PublicationPhase // last operation phase reached - TagObject string // local annotated tag object ID, when created + TagObject string // local annotated tag object ID, when established; a local ref may remain even when empty State PublicationState // publication outcome for this invocation } diff --git a/publish_test.go b/publish_test.go index 897a77b..fb28a34 100644 --- a/publish_test.go +++ b/publish_test.go @@ -353,6 +353,57 @@ func TestPublishRedactsCredentialsInEndpointMismatch(t *testing.T) { } } +func TestPublishRetainsEvidenceWhenCreatedTagInspectionFails(t *testing.T) { + for _, test := range []struct { + name, failingCommand string + wantObject bool + }{ + {"type inspection", "cat-file -t refs/tags/v1.2.3", false}, + {"peeled commit inspection", "rev-parse --verify refs/tags/v1.2.3^{}", true}, + } { + t.Run(test.name, func(t *testing.T) { + repo := newPublishFixture(t) + realGit := mustLookPath(t, "git") + bin := t.TempDir() + pushMarker := filepath.Join(t.TempDir(), "push") + script := fmt.Sprintf(`#!/bin/sh +case " $* " in + *" %s "*) echo "inspection failed" >&2; exit 77 ;; + *" push "*) : > %s ;; +esac +exec %s "$@" +`, test.failingCommand, shellQuote(pushMarker), shellQuote(realGit)) + if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ + RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", + }) + var exit *exec.ExitError + if !errors.As(err, &exit) || exit.ExitCode() != 77 || result.State != releasekit.PublicationNotPublished || result.Phase != releasekit.PublicationTagCreation { + t.Fatalf("publication = %#v, error = %v", result, err) + } + if !strings.Contains(err.Error(), "refs/tags/v1.2.3") || !strings.Contains(err.Error(), "may remain") { + t.Fatalf("missing local ref evidence: %v", err) + } + actual, readErr := exec.Command(realGit, "-C", repo.work, "rev-parse", "--verify", "refs/tags/v1.2.3").Output() + if readErr != nil { + t.Fatal(readErr) + } + if test.wantObject && result.TagObject != strings.TrimSpace(string(actual)) || !test.wantObject && result.TagObject != "" { + t.Fatalf("tag object = %q, local ref = %q", result.TagObject, actual) + } + if _, err := os.Stat(pushMarker); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("unexpected push marker: %v", err) + } + if err := exec.Command(realGit, "-C", repo.bare, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3").Run(); err == nil { + t.Fatal("remote tag was published") + } + }) + } +} + func TestPublishComparesExpectedURLWithResolvedInsteadOfDestination(t *testing.T) { repo := newPublishFixture(t) alias := "releasekit-expected-push:"