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.
|
and pushes only that tag object.
|
||||||
|
|
||||||
A successful result means Git confirmed the push or an exact authoritative tag
|
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
|
query confirmed it after a push error. A failed inspection after local tag
|
||||||
push. `PublicationState` distinguishes a known failure, conflict, confirmed
|
creation can leave the tag ref even when its object ID is unavailable; when
|
||||||
publication, and unresolved outcome. Resolve an unknown result against the
|
the ID was established it remains in `TagObject`. The local tag is retained
|
||||||
captured endpoint before retrying; see the [maintainer procedure](../release.md)
|
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
|
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
|
credentials; handle it accordingly. The [CLI contract](../cli.md) documents
|
||||||
command output and exit statuses.
|
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
|
move, overwrite, or delete a tag that exists remotely. If a different object
|
||||||
uses the version, choose a new version after resolving the conflict.
|
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
|
If validation or a guard fails before this invocation pushes, correct and
|
||||||
commit the candidate, publish `main`, and run the command again. A retained
|
commit the candidate, publish `main`, and run the command again. A retained
|
||||||
local tag still requires the authoritative-state check above before removal;
|
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. |
|
| 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. |
|
| 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. |
|
| 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 |
|
| 8 | A06, A14 | Pending |
|
||||||
| 9 | A16 | Pending |
|
| 9 | A16 | Pending |
|
||||||
| 10 | A07: allocation and temporary metadata | Pending |
|
| 10 | A07: allocation and temporary metadata | Pending |
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// CreateAnnotatedTag records the captured note as an unsigned annotated tag
|
// 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) {
|
func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string, note []byte) (string, error) {
|
||||||
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
|
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -35,35 +36,43 @@ func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
ref := "refs/tags/" + tag
|
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)
|
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
|
||||||
if err != nil {
|
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" {
|
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)
|
tagObject, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
|
||||||
if err != nil {
|
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)
|
tagObject = strings.TrimSpace(tagObject)
|
||||||
if !validObjectID(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+"^{}")
|
peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{}")
|
||||||
if err != nil {
|
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 {
|
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)
|
tagContents, err := run(ctx, repositoryDir, nil, "cat-file", "tag", ref)
|
||||||
if err != nil {
|
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"))
|
separator := bytes.Index([]byte(tagContents), []byte("\n\n"))
|
||||||
if separator < 0 || !bytes.Equal([]byte(tagContents[separator+2:]), note) {
|
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
|
return tagObject, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,10 +147,10 @@ func Publish(ctx context.Context, request Request) (Result, error) {
|
|||||||
result.Phase = TagCreation
|
result.Phase = TagCreation
|
||||||
result.State = NotPublished
|
result.State = NotPublished
|
||||||
tagObject, err := git.CreateAnnotatedTag(ctx, captured.RepositoryDir, captured.Tag, captured.Commit, []byte(captured.Note))
|
tagObject, err := git.CreateAnnotatedTag(ctx, captured.RepositoryDir, captured.Tag, captured.Commit, []byte(captured.Note))
|
||||||
|
result.TagObject = tagObject
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
result.TagObject = tagObject
|
|
||||||
if err := candidate.Recheck(ctx, captured, tagObject); err != nil {
|
if err := candidate.Recheck(ctx, captured, tagObject); err != nil {
|
||||||
return result, fmt.Errorf("pre-push local guard: %w", err)
|
return result, fmt.Errorf("pre-push local guard: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ type PublicationResult struct {
|
|||||||
CandidateCommit string // captured main commit
|
CandidateCommit string // captured main commit
|
||||||
Endpoint string // captured effective push URL, which may contain credentials
|
Endpoint string // captured effective push URL, which may contain credentials
|
||||||
Phase PublicationPhase // last operation phase reached
|
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
|
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) {
|
func TestPublishComparesExpectedURLWithResolvedInsteadOfDestination(t *testing.T) {
|
||||||
repo := newPublishFixture(t)
|
repo := newPublishFixture(t)
|
||||||
alias := "releasekit-expected-push:"
|
alias := "releasekit-expected-push:"
|
||||||
|
|||||||
Reference in New Issue
Block a user