Treat incomplete remote tag observations as uncertain
This commit is contained in:
@@ -36,6 +36,64 @@ func TestVersionCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPublishCommandReportsUnknownForPeeledOnlyRecovery(t *testing.T) {
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
writeRepositoryFile(t, repository, "docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nRelease note.\n")
|
||||||
|
profile, err := json.Marshal(cli.Settings{Product: "Example Product", Remote: "origin"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeRepositoryFile(t, repository, "releasekit.json", string(profile))
|
||||||
|
cliGitRun(t, repository, "add", "docs/releases/v1.2.3.md", "releasekit.json")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "configure release")
|
||||||
|
remote := filepath.Join(t.TempDir(), "origin.git")
|
||||||
|
cliGitRun(t, repository, "init", "--bare", remote)
|
||||||
|
cliGitRun(t, repository, "remote", "add", "origin", remote)
|
||||||
|
cliGitRun(t, repository, "push", "--set-upstream", "origin", "main")
|
||||||
|
realGit, err := exec.LookPath("git")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := t.TempDir()
|
||||||
|
marker := filepath.Join(t.TempDir(), "pushed")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
is_push=0
|
||||||
|
is_ls=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
[ "$arg" = push ] && is_push=1
|
||||||
|
[ "$arg" = ls-remote ] && is_ls=1
|
||||||
|
done
|
||||||
|
if [ "$is_ls" = 1 ] && [ -f "$PUSH_MARKER" ]; then
|
||||||
|
case " $* " 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"
|
||||||
|
exit 0;; esac
|
||||||
|
fi
|
||||||
|
if [ "$is_push" = 1 ]; then
|
||||||
|
"$REAL_GIT" "$@" || exit $?
|
||||||
|
: > "$PUSH_MARKER"
|
||||||
|
echo 'lost acknowledgment' >&2
|
||||||
|
exit 77
|
||||||
|
fi
|
||||||
|
exec "$REAL_GIT" "$@"
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
t.Setenv("REAL_GIT", realGit)
|
||||||
|
t.Setenv("PUSH_MARKER", marker)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
status := run(context.Background(), []string{"publish", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
|
||||||
|
if status != 3 || !strings.Contains(stderr.String(), "unknown") || strings.Contains(stdout.String(), "published") {
|
||||||
|
t.Fatalf("status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
local := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3")
|
||||||
|
if got := cliGitOutput(t, remote, "rev-parse", "refs/tags/v1.2.3"); got != local {
|
||||||
|
t.Fatalf("remote tag = %s, want %s", got, local)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUnknownCommand(t *testing.T) {
|
func TestUnknownCommand(t *testing.T) {
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
if status := run(context.Background(), []string{"unknown"}, &stdout, &stderr); status != 2 {
|
if status := run(context.Background(), []string{"unknown"}, &stdout, &stderr); status != 2 {
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ reproducibility guarantees are part of this work.
|
|||||||
|
|
||||||
## Stage 1: Finalize remote ref observations and verify recovery
|
## Stage 1: Finalize remote ref observations and verify recovery
|
||||||
|
|
||||||
**Status:** pending.
|
**Status:** complete. The Git query, public publication, and CLI publication
|
||||||
|
regressions pass with the offline environment; the root `TestPublish` tests and
|
||||||
|
the full `internal/git` package also pass.
|
||||||
|
|
||||||
**Files:** `internal/git/remote.go`, `internal/git/tag.go`, their tests,
|
**Files:** `internal/git/remote.go`, `internal/git/tag.go`, their tests,
|
||||||
`publish_test.go`, and a focused CLI publication test under `cmd/releasekit/`.
|
`publish_test.go`, and a focused CLI publication test under `cmd/releasekit/`.
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// RemoteRef holds an exact ref's object and optional peeled commit. State is
|
// RemoteRef holds an exact ref's object and optional peeled commit. State is
|
||||||
// RefConflict only when more than one exact result or malformed data is found.
|
// RefConflict when records are inconsistent or incomplete.
|
||||||
type RemoteRef struct {
|
type RemoteRef struct {
|
||||||
State RefState
|
State RefState
|
||||||
Object string
|
Object string
|
||||||
@@ -130,7 +130,7 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (
|
|||||||
remoteTag = mergeRef(remoteTag, object, true)
|
remoteTag = mergeRef(remoteTag, object, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Snapshot{Main: main, Tag: remoteTag}, nil
|
return Snapshot{Main: main, Tag: finalizeRef(remoteTag)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequireCandidate requires exact main equality and an absent remote
|
// RequireCandidate requires exact main equality and an absent remote
|
||||||
@@ -294,6 +294,13 @@ func mergeRef(ref RemoteRef, object string, peeled bool) RemoteRef {
|
|||||||
return ref
|
return ref
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func finalizeRef(ref RemoteRef) RemoteRef {
|
||||||
|
if ref.State == RefPresent && ref.Object == "" && ref.Peeled != "" {
|
||||||
|
ref.State = RefConflict
|
||||||
|
}
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
|
||||||
func checkTagRef(ctx context.Context, repositoryDir, tag string) error {
|
func checkTagRef(ctx context.Context, repositoryDir, tag string) error {
|
||||||
if tag == "" {
|
if tag == "" {
|
||||||
return fmt.Errorf("invalid Git tag name %q", tag)
|
return fmt.Errorf("invalid Git tag name %q", tag)
|
||||||
|
|||||||
@@ -184,6 +184,52 @@ func TestMergeRefMarksDuplicateExactResultsConflicting(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTagQueryFinalizesCompleteResponse(t *testing.T) {
|
||||||
|
repo := newRemoteFixture(t)
|
||||||
|
endpoint := resolveFixture(t, repo)
|
||||||
|
realGit, err := exec.LookPath("git")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := t.TempDir()
|
||||||
|
response := filepath.Join(t.TempDir(), "response")
|
||||||
|
script := "#!/bin/sh\ncase \" $* \" in *' ls-remote '* ) case \" $* \" in *' --get-url '* ) ;; *) cat \"$RESPONSE\"; exit 0;; esac;; esac\nexec \"$REAL_GIT\" \"$@\"\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
t.Setenv("REAL_GIT", realGit)
|
||||||
|
t.Setenv("RESPONSE", response)
|
||||||
|
object, peeled := strings.Repeat("a", 40), strings.Repeat("b", 40)
|
||||||
|
exact := object + "\trefs/tags/v1.2.3\n"
|
||||||
|
peel := peeled + "\trefs/tags/v1.2.3^{}\n"
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, output string
|
||||||
|
want RemoteRef
|
||||||
|
}{
|
||||||
|
{"empty", "", RemoteRef{State: RefAbsent}},
|
||||||
|
{"exact", exact, RemoteRef{State: RefPresent, Object: object}},
|
||||||
|
{"exact then peeled", exact + peel, RemoteRef{State: RefPresent, Object: object, Peeled: peeled}},
|
||||||
|
{"peeled then exact", peel + exact, RemoteRef{State: RefPresent, Object: object, Peeled: peeled}},
|
||||||
|
{"peeled only", peel, RemoteRef{State: RefConflict, Peeled: peeled}},
|
||||||
|
{"duplicate", exact + exact, RemoteRef{State: RefConflict, Object: object}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if err := os.WriteFile(response, []byte(tc.output), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snapshot, err := endpoint.Query(context.Background(), repo.work, "v1.2.3")
|
||||||
|
if err != nil || snapshot.Tag != tc.want {
|
||||||
|
t.Fatalf("Query tag = %#v, error = %v, want %#v", snapshot.Tag, err, tc.want)
|
||||||
|
}
|
||||||
|
got, err := endpoint.QueryTag(context.Background(), repo.work, "v1.2.3")
|
||||||
|
if err != nil || got != tc.want {
|
||||||
|
t.Fatalf("QueryTag = %#v, error = %v, want %#v", got, err, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type remoteFixture struct {
|
type remoteFixture struct {
|
||||||
work string
|
work string
|
||||||
bare string
|
bare string
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, tag string
|
|||||||
remoteTag = mergeRef(remoteTag, object, true)
|
remoteTag = mergeRef(remoteTag, object, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return remoteTag, nil
|
return finalizeRef(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.
|
||||||
|
|||||||
@@ -749,6 +749,31 @@ func TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError(t *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPublishPeeledOnlyReconciliationRemainsUnknown(t *testing.T) {
|
||||||
|
repo := newPublishFixture(t)
|
||||||
|
wrapper := installGitWrapper(t, repo, "peeled-only")
|
||||||
|
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",
|
||||||
|
AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil },
|
||||||
|
})
|
||||||
|
if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation || result.TagObject == "" {
|
||||||
|
t.Fatalf("result = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 77 || !strings.Contains(err.Error(), "lost acknowledgment") {
|
||||||
|
t.Fatalf("push cause not retained: %v", err)
|
||||||
|
}
|
||||||
|
if count := countLines(t, wrapper.queryLog); count != 4 {
|
||||||
|
t.Fatalf("tag query count = %d, want one recovery query", count)
|
||||||
|
}
|
||||||
|
for _, dir := range []string{repo.work, repo.bare} {
|
||||||
|
if got := gitText(t, dir, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject {
|
||||||
|
t.Fatalf("tag in %s = %s, want %s", dir, got, result.TagObject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPublishUninterpretableReconciliationRemainsUnknown(t *testing.T) {
|
func TestPublishUninterpretableReconciliationRemainsUnknown(t *testing.T) {
|
||||||
repo := newPublishFixture(t)
|
repo := newPublishFixture(t)
|
||||||
other := gitText(t, repo.work, "rev-parse", "refs/tags/v0.9.0")
|
other := gitText(t, repo.work, "rev-parse", "refs/tags/v0.9.0")
|
||||||
@@ -931,11 +956,24 @@ if [ "$IS_LS_REMOTE" = "1" ]; then
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
if [ "$MODE" = "peeled-only" ] && [ -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"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
if [ "$IS_PUSH" = "1" ]; then
|
if [ "$IS_PUSH" = "1" ]; then
|
||||||
"$REAL_GIT" "$@"
|
"$REAL_GIT" "$@"
|
||||||
STATUS=$?
|
STATUS=$?
|
||||||
: > "$PUSH_DONE"
|
: > "$PUSH_DONE"
|
||||||
|
if [ "$MODE" = "peeled-only" ] && [ "$STATUS" = "0" ]; then
|
||||||
|
echo "lost acknowledgment after tag was accepted" >&2
|
||||||
|
exit 77
|
||||||
|
fi
|
||||||
if [ "$MODE" = "lost-ack-cancel" ] && [ "$STATUS" = "0" ]; then kill -STOP $$; fi
|
if [ "$MODE" = "lost-ack-cancel" ] && [ "$STATUS" = "0" ]; then kill -STOP $$; fi
|
||||||
if [ "$MODE" = "lost-ack-rewrite" ] && [ "$STATUS" = "0" ]; then
|
if [ "$MODE" = "lost-ack-rewrite" ] && [ "$STATUS" = "0" ]; then
|
||||||
"$REAL_GIT" config --local "url.\"$MUTATE_OBJECT\".insteadOf" %s
|
"$REAL_GIT" config --local "url.\"$MUTATE_OBJECT\".insteadOf" %s
|
||||||
|
|||||||
Reference in New Issue
Block a user