Reconcile uncertain tag pushes

This commit is contained in:
2026-10-05 18:53:44 +00:00
parent 3757d40ac4
commit 7481dd0fd2
7 changed files with 388 additions and 22 deletions

View File

@@ -2,11 +2,14 @@ package releasekit_test
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
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) {
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")
commit := repo.commit
var validations int
@@ -36,6 +41,9 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
if validations != 1 {
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 {
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)
hook := filepath.Join(repo.work, ".git", "hooks", "pre-push")
if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
wrapper := installGitWrapper(t, repo, "pass")
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{
@@ -90,9 +100,15 @@ func TestPublishFailureKeepsTheLocalTagAndReportsUnknown(t *testing.T) {
if err == nil {
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)
}
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 {
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) {
repo := newPublishFixture(t)
gitRun(t, repo.bare, "update-ref", "refs/heads/main", repo.unrelatedCommit)
@@ -151,6 +330,114 @@ type publishFixture struct {
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 {
t.Helper()
base := t.TempDir()