Preserve evidence when local tag inspection fails
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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:"
|
||||
|
||||
Reference in New Issue
Block a user