Reconcile uncertain tag pushes
This commit is contained in:
@@ -3,8 +3,8 @@
|
|||||||
ReleaseKit is an opinionated Go library and executable for a shared release
|
ReleaseKit is an opinionated Go library and executable for a shared release
|
||||||
process. The Go module, root `releasekit` facade, `buildinfo` runtime package,
|
process. The Go module, root `releasekit` facade, `buildinfo` runtime package,
|
||||||
local candidate check, source-tag publication API, and `version` and `check`
|
local candidate check, source-tag publication API, and `version` and `check`
|
||||||
commands are implemented. Push reconciliation, a publication command, builds,
|
commands are implemented. A publication command, builds, delivery, and release
|
||||||
delivery, and release automation remain to be implemented. Their selected
|
automation remain to be implemented. Their selected
|
||||||
behavior and sequence are described in the
|
behavior and sequence are described in the
|
||||||
[product roadmap](docs/roadmap/releasekit.md) and
|
[product roadmap](docs/roadmap/releasekit.md) and
|
||||||
[implementation plan](docs/roadmap/implementation.md).
|
[implementation plan](docs/roadmap/implementation.md).
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ tag object ID. Publication states distinguish an unattempted push, a tag that
|
|||||||
was created locally but not pushed, a successful push, a guard conflict, and an
|
was created locally but not pushed, a successful push, a guard conflict, and an
|
||||||
unresolved push error.
|
unresolved push error.
|
||||||
|
|
||||||
When a push reports an error, the local annotated tag is retained and the
|
When a push reports an error, ReleaseKit makes one bounded query for the
|
||||||
result is `unknown` until a later implementation adds authoritative
|
authoritative tag. An exact tag object match confirms success. An absent tag is
|
||||||
reconciliation. Do not treat that state as success or proof that the remote
|
reported as `not_published`, a different object as `conflict`, and an unreadable
|
||||||
lacks the tag.
|
query as `unknown`. The local annotated tag is retained in every case; an
|
||||||
|
`unknown` result is neither success nor proof that the remote lacks the tag.
|
||||||
|
|||||||
@@ -614,11 +614,12 @@ validates once between exact remote guards, creates an unsigned annotated tag,
|
|||||||
rechecks local and remote state, and pushes only the captured tag object. Bare
|
rechecks local and remote state, and pushes only the captured tag object. Bare
|
||||||
remote tests verify the note bytes, peeled commit, tag object, unchanged branch
|
remote tests verify the note bytes, peeled commit, tag object, unchanged branch
|
||||||
and unrelated refs, disabled follow-tags and mirror expansion, validation count,
|
and unrelated refs, disabled follow-tags and mirror expansion, validation count,
|
||||||
remote conflicts, and retained local tags with `unknown` status after push error.
|
remote conflicts, and retained local tags after push error; Stage 8 adds outcome
|
||||||
|
classification through remote reconciliation.
|
||||||
|
|
||||||
## Stage 8: Resolve rejected and ambiguous publication
|
## Stage 8: Resolve rejected and ambiguous publication
|
||||||
|
|
||||||
Status: Pending. Depends on Stage 7.
|
Status: Complete. Depends on Stage 7.
|
||||||
|
|
||||||
1. Add the one-query reconciliation path and public error/result classification.
|
1. Add the one-query reconciliation path and public error/result classification.
|
||||||
Preserve the original push cause when resolution does not confirm success.
|
Preserve the original push cause when resolution does not confirm success.
|
||||||
@@ -635,6 +636,17 @@ missing, conflicting, and unavailable remote tags have distinct outcomes. Failed
|
|||||||
pushes issue one reconciliation query, successful pushes issue none, and no
|
pushes issue one reconciliation query, successful pushes issue none, and no
|
||||||
recovery force-update or automatic tag deletion occurs.
|
recovery force-update or automatic tag deletion occurs.
|
||||||
|
|
||||||
|
Evidence: failed tag pushes receive one bounded exact-tag query. Matching object
|
||||||
|
identity returns published; absence returns not_published with the original push
|
||||||
|
error; a different object returns conflict; query failure returns unknown while
|
||||||
|
preserving the push cause. Tests cover rejection by a bare remote's receive
|
||||||
|
hook, an installed conflicting tag, unavailable reconciliation, and a lost
|
||||||
|
acknowledgment after the bare remote accepted the tag while the caller context
|
||||||
|
was canceled. Query counts confirm one recovery read after errors and none after
|
||||||
|
a successful push.
|
||||||
|
Tests also verify that a remote branch change during validation and a changed
|
||||||
|
local tag before push prevent publication.
|
||||||
|
|
||||||
## Stage 9: Expose publication and document ReleaseKit bootstrap
|
## Stage 9: Expose publication and document ReleaseKit bootstrap
|
||||||
|
|
||||||
Status: Pending. Depends on Stage 8.
|
Status: Pending. Depends on Stage 8.
|
||||||
|
|||||||
@@ -92,6 +92,34 @@ func InspectLocalTag(ctx context.Context, repositoryDir, version, expectedObject
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QueryTag reads one exact authoritative version tag and its peeled commit.
|
||||||
|
// Failed reads are unreadable and never interpreted as absence.
|
||||||
|
func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, version string) (RemoteRef, error) {
|
||||||
|
if !validVersionRef(version) {
|
||||||
|
return RemoteRef{}, fmt.Errorf("invalid version tag name %q", version)
|
||||||
|
}
|
||||||
|
ref := "refs/tags/" + version
|
||||||
|
peeledRef := ref + "^{}"
|
||||||
|
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, ref, peeledRef)
|
||||||
|
if err != nil {
|
||||||
|
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: query authoritative tag %s at %s: %w", ErrRemoteStateUnreadable, version, endpoint.PushURL, err)
|
||||||
|
}
|
||||||
|
remoteTag := RemoteRef{State: RefAbsent}
|
||||||
|
for _, line := range outputLines(output) {
|
||||||
|
object, name, ok := strings.Cut(line, "\t")
|
||||||
|
if !ok || !validObjectID(object) {
|
||||||
|
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, line)
|
||||||
|
}
|
||||||
|
switch name {
|
||||||
|
case ref:
|
||||||
|
remoteTag = mergeRef(remoteTag, object, false)
|
||||||
|
case peeledRef:
|
||||||
|
remoteTag = mergeRef(remoteTag, object, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return remoteTag, nil
|
||||||
|
}
|
||||||
|
|
||||||
// PushTag publishes only the captured tag object to its exact tag ref.
|
// PushTag publishes only the captured tag object to its exact tag ref.
|
||||||
func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, version, tagObject string) error {
|
func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, version, tagObject string) error {
|
||||||
if !validVersionRef(version) || !validObjectID(tagObject) {
|
if !validVersionRef(version) || !validObjectID(tagObject) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||||
@@ -24,13 +25,16 @@ const (
|
|||||||
type Phase string
|
type Phase string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Preflight Phase = "preflight"
|
Preflight Phase = "preflight"
|
||||||
Validation Phase = "validation"
|
Validation Phase = "validation"
|
||||||
TagCreation Phase = "tag_creation"
|
TagCreation Phase = "tag_creation"
|
||||||
Push Phase = "push"
|
Push Phase = "push"
|
||||||
Complete Phase = "complete"
|
Reconciliation Phase = "reconciliation"
|
||||||
|
Complete Phase = "complete"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const recoveryTimeout = 30 * time.Second
|
||||||
|
|
||||||
type Request struct {
|
type Request struct {
|
||||||
RepositoryDir string
|
RepositoryDir string
|
||||||
Product string
|
Product string
|
||||||
@@ -50,8 +54,9 @@ type Result struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Publish validates one captured candidate and pushes only its annotated tag
|
// Publish validates one captured candidate and pushes only its annotated tag
|
||||||
// object. A failed push remains Unknown until authoritative reconciliation is
|
// object. A failed push receives one bounded authoritative tag query. Exact
|
||||||
// added by the following implementation stage.
|
// object equality confirms publication; absence and conflict are classified,
|
||||||
|
// while an unreadable result remains Unknown and preserves the push error.
|
||||||
func Publish(ctx context.Context, request Request) (Result, error) {
|
func Publish(ctx context.Context, request Request) (Result, error) {
|
||||||
result := Result{Version: request.Version, Phase: Preflight, State: NotAttempted}
|
result := Result{Version: request.Version, Phase: Preflight, State: NotAttempted}
|
||||||
remoteName := request.Remote
|
remoteName := request.Remote
|
||||||
@@ -129,13 +134,42 @@ func Publish(ctx context.Context, request Request) (Result, error) {
|
|||||||
result.Phase = Push
|
result.Phase = Push
|
||||||
result.State = Unknown
|
result.State = Unknown
|
||||||
if err := endpoint.PushTag(ctx, captured.RepositoryDir, captured.Version, tagObject); err != nil {
|
if err := endpoint.PushTag(ctx, captured.RepositoryDir, captured.Version, tagObject); err != nil {
|
||||||
return result, err
|
return reconcilePush(endpoint, captured, tagObject, result, err)
|
||||||
}
|
}
|
||||||
result.Phase = Complete
|
result.Phase = Complete
|
||||||
result.State = Published
|
result.State = Published
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObject string, result Result, pushErr error) (Result, error) {
|
||||||
|
result.Phase = Reconciliation
|
||||||
|
recoveryCtx, cancel := context.WithTimeout(context.Background(), recoveryTimeout)
|
||||||
|
defer cancel()
|
||||||
|
remoteTag, queryErr := endpoint.QueryTag(recoveryCtx, captured.RepositoryDir, captured.Version)
|
||||||
|
if queryErr != nil || remoteTag.State == git.RefUnreadable || remoteTag.State == git.RefConflict {
|
||||||
|
result.State = Unknown
|
||||||
|
if queryErr == nil {
|
||||||
|
if remoteTag.State == git.RefConflict {
|
||||||
|
queryErr = errors.New("authoritative tag query returned inconsistent exact ref records")
|
||||||
|
} else {
|
||||||
|
queryErr = git.ErrRemoteStateUnreadable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, fmt.Errorf("push annotated tag %s failed; authoritative reconciliation failed (%v): %w", captured.Version, queryErr, pushErr)
|
||||||
|
}
|
||||||
|
if remoteTag.State == git.RefAbsent {
|
||||||
|
result.State = NotPublished
|
||||||
|
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag is absent: %w", captured.Version, pushErr)
|
||||||
|
}
|
||||||
|
if remoteTag.State == git.RefPresent && remoteTag.Object == tagObject && (remoteTag.Peeled == "" || remoteTag.Peeled == captured.Commit) {
|
||||||
|
result.Phase = Complete
|
||||||
|
result.State = Published
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
result.State = Conflict
|
||||||
|
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag identifies object %s, not %s: %w", captured.Version, remoteTag.Object, tagObject, pushErr)
|
||||||
|
}
|
||||||
|
|
||||||
func isRemoteConflict(err error) bool {
|
func isRemoteConflict(err error) bool {
|
||||||
return errors.Is(err, git.ErrRemoteMainMissing) ||
|
return errors.Is(err, git.ErrRemoteMainMissing) ||
|
||||||
errors.Is(err, git.ErrRemoteMainConflict) ||
|
errors.Is(err, git.ErrRemoteMainConflict) ||
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ const (
|
|||||||
PublicationTagCreation PublicationPhase = PublicationPhase(publish.TagCreation)
|
PublicationTagCreation PublicationPhase = PublicationPhase(publish.TagCreation)
|
||||||
// PublicationPush identifies the remote tag push attempt.
|
// PublicationPush identifies the remote tag push attempt.
|
||||||
PublicationPush PublicationPhase = PublicationPhase(publish.Push)
|
PublicationPush PublicationPhase = PublicationPhase(publish.Push)
|
||||||
|
// PublicationReconciliation identifies the single authoritative tag query after a push error.
|
||||||
|
PublicationReconciliation PublicationPhase = PublicationPhase(publish.Reconciliation)
|
||||||
// PublicationComplete identifies a successful tag push.
|
// PublicationComplete identifies a successful tag push.
|
||||||
PublicationComplete PublicationPhase = PublicationPhase(publish.Complete)
|
PublicationComplete PublicationPhase = PublicationPhase(publish.Complete)
|
||||||
)
|
)
|
||||||
@@ -51,8 +53,10 @@ type PublicationResult struct {
|
|||||||
// object to the selected remote. It returns a result with errors: preflight
|
// object to the selected remote. It returns a result with errors: preflight
|
||||||
// failures are not_attempted, failed authoritative guards are conflict, and
|
// failures are not_attempted, failed authoritative guards are conflict, and
|
||||||
// errors after local tag creation but before push are not_published. A failed
|
// errors after local tag creation but before push are not_published. A failed
|
||||||
// push is unknown until remote reconciliation is available. A nil error means
|
// push gets one bounded authoritative tag query: an exact object match confirms
|
||||||
// the push succeeded and the result state is published.
|
// publication, absence is not_published, a different object is conflict, and
|
||||||
|
// an unreadable result is unknown. A nil error means publication is confirmed
|
||||||
|
// by the successful push or exact remote object match.
|
||||||
func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, error) {
|
func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, error) {
|
||||||
var validate func(context.Context, candidate.Candidate) error
|
var validate func(context.Context, candidate.Candidate) error
|
||||||
if request.Validate != nil {
|
if request.Validate != nil {
|
||||||
|
|||||||
295
publish_test.go
295
publish_test.go
@@ -2,11 +2,14 @@ package releasekit_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||||
)
|
)
|
||||||
@@ -15,6 +18,8 @@ const releaseNote = "# Sample App v1.2.3\n\nA source release.\n\nUpgrade notes s
|
|||||||
|
|
||||||
func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
|
func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
|
||||||
repo := newPublishFixture(t)
|
repo := newPublishFixture(t)
|
||||||
|
wrapper := installGitWrapper(t, repo, "pass")
|
||||||
|
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
refsBefore := gitText(t, repo.bare, "show-ref", "--heads", "--tags")
|
refsBefore := gitText(t, repo.bare, "show-ref", "--heads", "--tags")
|
||||||
commit := repo.commit
|
commit := repo.commit
|
||||||
var validations int
|
var validations int
|
||||||
@@ -36,6 +41,9 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
|
|||||||
if validations != 1 {
|
if validations != 1 {
|
||||||
t.Fatalf("validation calls = %d, want 1", validations)
|
t.Fatalf("validation calls = %d, want 1", validations)
|
||||||
}
|
}
|
||||||
|
if queries := countLines(t, wrapper.queryLog); queries != 3 {
|
||||||
|
t.Fatalf("authoritative tag query count = %d, want 3 guards and no success read-back", queries)
|
||||||
|
}
|
||||||
if result.State != releasekit.PublicationPublished || result.Phase != releasekit.PublicationComplete {
|
if result.State != releasekit.PublicationPublished || result.Phase != releasekit.PublicationComplete {
|
||||||
t.Fatalf("publication result = %#v", result)
|
t.Fatalf("publication result = %#v", result)
|
||||||
}
|
}
|
||||||
@@ -75,10 +83,12 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPublishFailureKeepsTheLocalTagAndReportsUnknown(t *testing.T) {
|
func TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished(t *testing.T) {
|
||||||
repo := newPublishFixture(t)
|
repo := newPublishFixture(t)
|
||||||
hook := filepath.Join(repo.work, ".git", "hooks", "pre-push")
|
wrapper := installGitWrapper(t, repo, "pass")
|
||||||
if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
|
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
hook := filepath.Join(repo.bare, "hooks", "pre-receive")
|
||||||
|
if err := os.WriteFile(hook, []byte("#!/bin/sh\ncat >/dev/null\nexit 1\n"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
|
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
|
||||||
@@ -90,9 +100,15 @@ func TestPublishFailureKeepsTheLocalTagAndReportsUnknown(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("Publish() succeeded despite rejected push hook")
|
t.Fatal("Publish() succeeded despite rejected push hook")
|
||||||
}
|
}
|
||||||
if result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationPush || result.TagObject == "" {
|
if result.State != releasekit.PublicationNotPublished || result.Phase != releasekit.PublicationReconciliation || result.TagObject == "" {
|
||||||
t.Fatalf("publication result = %#v", result)
|
t.Fatalf("publication result = %#v", result)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(err.Error(), "failed to push some refs") || !strings.Contains(err.Error(), "authoritative tag is absent") {
|
||||||
|
t.Fatalf("push error was not preserved with absence evidence: %v", err)
|
||||||
|
}
|
||||||
|
if queries := countLines(t, wrapper.queryLog); queries != 4 {
|
||||||
|
t.Fatalf("authoritative tag query count = %d, want one reconciliation query", queries)
|
||||||
|
}
|
||||||
if got := gitText(t, repo.work, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject {
|
if got := gitText(t, repo.work, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject {
|
||||||
t.Fatalf("local tag object = %s, want retained object %s", got, result.TagObject)
|
t.Fatalf("local tag object = %s, want retained object %s", got, result.TagObject)
|
||||||
}
|
}
|
||||||
@@ -101,6 +117,169 @@ func TestPublishFailureKeepsTheLocalTagAndReportsUnknown(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPublishLostAcknowledgmentConfirmsExactRemoteObject(t *testing.T) {
|
||||||
|
repo := newPublishFixture(t)
|
||||||
|
wrapper := installGitWrapper(t, repo, "lost-ack-cancel")
|
||||||
|
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
finished := make(chan struct{})
|
||||||
|
watcherDone := make(chan struct{})
|
||||||
|
result, err := releasekit.Publish(ctx, releasekit.ReleaseRequest{
|
||||||
|
RepositoryDir: repo.work,
|
||||||
|
Product: "Sample App",
|
||||||
|
Version: "v1.2.3",
|
||||||
|
Validate: func(context.Context, releasekit.Candidate) error {
|
||||||
|
go func() {
|
||||||
|
defer close(watcherDone)
|
||||||
|
for {
|
||||||
|
if _, statErr := os.Stat(wrapper.pushDone); statErr == nil {
|
||||||
|
cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-finished:
|
||||||
|
return
|
||||||
|
case <-time.After(time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
close(finished)
|
||||||
|
<-watcherDone
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.State != releasekit.PublicationPublished || result.Phase != releasekit.PublicationComplete {
|
||||||
|
t.Fatalf("publication result = %#v", result)
|
||||||
|
}
|
||||||
|
if queries := countLines(t, wrapper.queryLog); queries != 4 {
|
||||||
|
t.Fatalf("authoritative tag query count = %d, want one cancellation recovery query", queries)
|
||||||
|
}
|
||||||
|
if got := gitText(t, repo.bare, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject {
|
||||||
|
t.Fatalf("remote tag object = %s, want %s", got, result.TagObject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishConflictingRemoteTagIsNotReportedAsAbsent(t *testing.T) {
|
||||||
|
repo := newPublishFixture(t)
|
||||||
|
wrapper := installGitWrapper(t, repo, "pass")
|
||||||
|
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
hook := filepath.Join(repo.work, ".git", "hooks", "pre-push")
|
||||||
|
conflictingObject := gitText(t, repo.work, "rev-parse", "refs/tags/v0.9.0")
|
||||||
|
script := fmt.Sprintf("#!/bin/sh\n%s --git-dir=%s update-ref refs/tags/v1.2.3 %s\nexit 1\n", shellQuote(mustLookPath(t, "git")), shellQuote(repo.bare), conflictingObject)
|
||||||
|
if err := os.WriteFile(hook, []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
|
||||||
|
RepositoryDir: repo.work,
|
||||||
|
Product: "Sample App",
|
||||||
|
Version: "v1.2.3",
|
||||||
|
Validate: func(context.Context, releasekit.Candidate) error { return nil },
|
||||||
|
})
|
||||||
|
if err == nil || result.State != releasekit.PublicationConflict || result.Phase != releasekit.PublicationReconciliation {
|
||||||
|
t.Fatalf("result = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "authoritative tag identifies object") || !strings.Contains(err.Error(), "failed to push some refs") {
|
||||||
|
t.Fatalf("error does not distinguish conflict and preserve push failure: %v", err)
|
||||||
|
}
|
||||||
|
if queries := countLines(t, wrapper.queryLog); queries != 4 {
|
||||||
|
t.Fatalf("authoritative tag query count = %d, want one reconciliation query", queries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishStopsWhenLocalTagChangesBeforePush(t *testing.T) {
|
||||||
|
repo := newPublishFixture(t)
|
||||||
|
gitRun(t, repo.work, "-c", "tag.gpgSign=false", "tag", "--annotate", "--message", "different tag", "v0.8.0", repo.commit)
|
||||||
|
conflictingObject := gitText(t, repo.work, "rev-parse", "refs/tags/v0.8.0")
|
||||||
|
wrapper := installGitWrapperWithMutation(t, repo, conflictingObject)
|
||||||
|
t.Setenv("PATH", wrapper.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",
|
||||||
|
Validate: func(context.Context, releasekit.Candidate) error { return nil },
|
||||||
|
})
|
||||||
|
if err == nil || result.State != releasekit.PublicationNotPublished || result.Phase != releasekit.PublicationTagCreation {
|
||||||
|
t.Fatalf("result = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
if got := gitText(t, repo.work, "rev-parse", "refs/tags/v1.2.3"); got != conflictingObject {
|
||||||
|
t.Fatalf("changed local tag object = %s, want %s", got, conflictingObject)
|
||||||
|
}
|
||||||
|
if result.TagObject == "" || result.TagObject == conflictingObject {
|
||||||
|
t.Fatalf("captured tag object = %q, want the prior object; publish error: %v", result.TagObject, err)
|
||||||
|
}
|
||||||
|
if queries := countLines(t, wrapper.queryLog); queries != 2 {
|
||||||
|
t.Fatalf("remote query count = %d, want only the pre-validation and prepublication guards", queries)
|
||||||
|
}
|
||||||
|
if _, err := exec.Command("git", "--git-dir", repo.bare, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3").CombinedOutput(); err == nil {
|
||||||
|
t.Fatal("changed local tag was pushed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError(t *testing.T) {
|
||||||
|
repo := newPublishFixture(t)
|
||||||
|
wrapper := installGitWrapper(t, repo, "query-failure")
|
||||||
|
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
hook := filepath.Join(repo.bare, "hooks", "pre-receive")
|
||||||
|
if err := os.WriteFile(hook, []byte("#!/bin/sh\ncat >/dev/null\nexit 1\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
|
||||||
|
RepositoryDir: repo.work,
|
||||||
|
Product: "Sample App",
|
||||||
|
Version: "v1.2.3",
|
||||||
|
Validate: func(context.Context, releasekit.Candidate) error { return nil },
|
||||||
|
})
|
||||||
|
if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation {
|
||||||
|
t.Fatalf("result = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "reconciliation unavailable") || !strings.Contains(err.Error(), "failed to push some refs") {
|
||||||
|
t.Fatalf("error does not preserve both query and push failure: %v", err)
|
||||||
|
}
|
||||||
|
if queries := countLines(t, wrapper.queryLog); queries != 4 {
|
||||||
|
t.Fatalf("authoritative tag query count = %d, want one failed reconciliation query", queries)
|
||||||
|
}
|
||||||
|
if got := gitText(t, repo.work, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject {
|
||||||
|
t.Fatalf("local tag object = %s, want retained object %s", got, result.TagObject)
|
||||||
|
}
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if !errors.As(err, &exitErr) || exitErr.ExitCode() == 0 {
|
||||||
|
t.Fatalf("push cause was not preserved in error chain: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishUninterpretableReconciliationRemainsUnknown(t *testing.T) {
|
||||||
|
repo := newPublishFixture(t)
|
||||||
|
other := gitText(t, repo.work, "rev-parse", "refs/tags/v0.9.0")
|
||||||
|
wrapper := installGitWrapperWithMutationMode(t, repo, "query-conflict", other)
|
||||||
|
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
hook := filepath.Join(repo.work, ".git", "hooks", "pre-push")
|
||||||
|
if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
|
||||||
|
RepositoryDir: repo.work,
|
||||||
|
Product: "Sample App",
|
||||||
|
Version: "v1.2.3",
|
||||||
|
Validate: func(context.Context, releasekit.Candidate) error { return nil },
|
||||||
|
})
|
||||||
|
if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation {
|
||||||
|
t.Fatalf("result = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "inconsistent exact ref records") || !strings.Contains(err.Error(), "failed to push some refs") {
|
||||||
|
t.Fatalf("error does not preserve uninterpretable remote state and push failure: %v", err)
|
||||||
|
}
|
||||||
|
if queries := countLines(t, wrapper.queryLog); queries != 4 {
|
||||||
|
t.Fatalf("authoritative tag query count = %d, want one uninterpretable reconciliation query", queries)
|
||||||
|
}
|
||||||
|
if got := gitText(t, repo.work, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject {
|
||||||
|
t.Fatalf("local tag object = %s, want retained object %s", got, result.TagObject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPublishRemoteConflictStopsBeforeValidation(t *testing.T) {
|
func TestPublishRemoteConflictStopsBeforeValidation(t *testing.T) {
|
||||||
repo := newPublishFixture(t)
|
repo := newPublishFixture(t)
|
||||||
gitRun(t, repo.bare, "update-ref", "refs/heads/main", repo.unrelatedCommit)
|
gitRun(t, repo.bare, "update-ref", "refs/heads/main", repo.unrelatedCommit)
|
||||||
@@ -151,6 +330,114 @@ type publishFixture struct {
|
|||||||
unrelatedCommit string
|
unrelatedCommit string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type gitWrapper struct {
|
||||||
|
bin string
|
||||||
|
queryLog string
|
||||||
|
pushDone string
|
||||||
|
realGit string
|
||||||
|
}
|
||||||
|
|
||||||
|
func installGitWrapper(t *testing.T, repo publishFixture, mode string) gitWrapper {
|
||||||
|
t.Helper()
|
||||||
|
return installGitWrapperWithMutationMode(t, repo, mode, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func installGitWrapperWithMutation(t *testing.T, repo publishFixture, object string) gitWrapper {
|
||||||
|
t.Helper()
|
||||||
|
return installGitWrapperWithMutationMode(t, repo, "mutate-tag", object)
|
||||||
|
}
|
||||||
|
|
||||||
|
func installGitWrapperWithMutationMode(t *testing.T, repo publishFixture, mode, mutationObject string) gitWrapper {
|
||||||
|
t.Helper()
|
||||||
|
realGit, err := exec.LookPath("git")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := filepath.Join(t.TempDir(), "bin")
|
||||||
|
if err := os.MkdirAll(bin, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
queryLog := filepath.Join(t.TempDir(), "tag-queries.log")
|
||||||
|
pushDone := filepath.Join(t.TempDir(), "push-attempted")
|
||||||
|
contents := fmt.Sprintf(`#!/bin/sh
|
||||||
|
REAL_GIT=%s
|
||||||
|
QUERY_LOG=%s
|
||||||
|
PUSH_DONE=%s
|
||||||
|
MODE=%s
|
||||||
|
MUTATE_OBJECT=%s
|
||||||
|
ARGS=" $* "
|
||||||
|
IS_PUSH=0
|
||||||
|
IS_LS_REMOTE=0
|
||||||
|
for ARG in "$@"; do
|
||||||
|
[ "$ARG" = "push" ] && IS_PUSH=1
|
||||||
|
[ "$ARG" = "ls-remote" ] && IS_LS_REMOTE=1
|
||||||
|
done
|
||||||
|
if [ "$IS_LS_REMOTE" = "1" ]; then
|
||||||
|
case "$ARGS" in *refs/tags/v1.2.3*) echo query >> "$QUERY_LOG";; esac
|
||||||
|
if [ "$MODE" = "query-failure" ] && [ -f "$PUSH_DONE" ]; then
|
||||||
|
case "$ARGS" in *refs/tags/v1.2.3*) echo "reconciliation unavailable" >&2; exit 77;; esac
|
||||||
|
fi
|
||||||
|
if [ "$MODE" = "query-conflict" ] && [ -f "$PUSH_DONE" ]; then
|
||||||
|
case "$ARGS" in
|
||||||
|
*refs/tags/v1.2.3*)
|
||||||
|
OBJECT=$("$REAL_GIT" rev-parse refs/tags/v1.2.3)
|
||||||
|
printf '%%s\trefs/tags/v1.2.3\n' "$OBJECT"
|
||||||
|
printf '%%s\trefs/tags/v1.2.3\n' "$MUTATE_OBJECT"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ "$IS_PUSH" = "1" ]; then
|
||||||
|
"$REAL_GIT" "$@"
|
||||||
|
STATUS=$?
|
||||||
|
: > "$PUSH_DONE"
|
||||||
|
if [ "$MODE" = "lost-ack-cancel" ] && [ "$STATUS" = "0" ]; then kill -STOP $$; fi
|
||||||
|
exit "$STATUS"
|
||||||
|
fi
|
||||||
|
if [ "$MODE" = "mutate-tag" ]; then
|
||||||
|
case "$ARGS" in
|
||||||
|
*"cat-file tag refs/tags/v1.2.3"*)
|
||||||
|
"$REAL_GIT" "$@"
|
||||||
|
STATUS=$?
|
||||||
|
[ "$STATUS" = "0" ] && "$REAL_GIT" update-ref refs/tags/v1.2.3 "$MUTATE_OBJECT"
|
||||||
|
exit "$STATUS"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
exec "$REAL_GIT" "$@"
|
||||||
|
`, shellQuote(realGit), shellQuote(queryLog), shellQuote(pushDone), shellQuote(mode), shellQuote(mutationObject))
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(contents), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return gitWrapper{bin: bin, queryLog: queryLog, pushDone: pushDone, realGit: realGit}
|
||||||
|
}
|
||||||
|
|
||||||
|
func countLines(t *testing.T, path string) int {
|
||||||
|
t.Helper()
|
||||||
|
contents, err := os.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return len(strings.Fields(string(contents)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustLookPath(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
path, err := exec.LookPath(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellQuote(value string) string {
|
||||||
|
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
func newPublishFixture(t *testing.T) publishFixture {
|
func newPublishFixture(t *testing.T) publishFixture {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
base := t.TempDir()
|
base := t.TempDir()
|
||||||
|
|||||||
Reference in New Issue
Block a user