Preserve evidence when local tag inspection fails

This commit is contained in:
2026-11-02 03:20:13 +00:00
parent 92c95ef031
commit aa57fee4de
7 changed files with 81 additions and 16 deletions

View File

@@ -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.

View File

@@ -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;

View File

@@ -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 |

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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:"