diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 4206247..d298d01 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -246,6 +246,13 @@ Reject reuse of the full tag. Add representative nested-tag rejected-push and unknown-outcome evidence cases. Reuse existing endpoint and lost-acknowledgment tests for unchanged recovery mechanics. Run publication and Git tests. +**Completed:** Publication now uses the captured full tag for remote uniqueness, +local annotated-tag creation, exact-object push, and reconciliation. Recovery +errors identify the full tag and authoritative endpoint, while publication +results retain identity through preflight and later failures. Verified two +modules publishing the same version at one commit, full-tag reuse rejection, +nested rejected and unknown outcomes, and `go test . ./internal/git ./internal/publish`. + ## Stage 4: Construct the selected committed file inputs **Outcome:** One ordered path set defines automatic validation inputs, while the diff --git a/internal/publish/publish.go b/internal/publish/publish.go index 3de36b5..3a92da9 100644 --- a/internal/publish/publish.go +++ b/internal/publish/publish.go @@ -109,7 +109,7 @@ func Publish(ctx context.Context, request Request) (Result, error) { if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil { return result, fmt.Errorf("pre-validation endpoint guard: %w", err) } - if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Version, captured.Commit); err != nil { + if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Tag, captured.Commit); err != nil { if isRemoteConflict(err) { result.State = Conflict } @@ -134,7 +134,7 @@ func Publish(ctx context.Context, request Request) (Result, error) { if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil { return result, fmt.Errorf("pre-publication endpoint guard: %w", err) } - if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Version, captured.Commit); err != nil { + if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Tag, captured.Commit); err != nil { if isRemoteConflict(err) { result.State = Conflict } @@ -143,7 +143,7 @@ func Publish(ctx context.Context, request Request) (Result, error) { result.Phase = TagCreation result.State = NotPublished - tagObject, err := git.CreateAnnotatedTag(ctx, captured.RepositoryDir, captured.Version, captured.Commit, []byte(captured.Note)) + tagObject, err := git.CreateAnnotatedTag(ctx, captured.RepositoryDir, captured.Tag, captured.Commit, []byte(captured.Note)) if err != nil { return result, err } @@ -154,7 +154,7 @@ func Publish(ctx context.Context, request Request) (Result, error) { if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil { return result, fmt.Errorf("pre-push endpoint guard: %w", err) } - if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Version, captured.Commit); err != nil { + if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Tag, captured.Commit); err != nil { if isRemoteConflict(err) { result.State = Conflict } @@ -163,7 +163,7 @@ func Publish(ctx context.Context, request Request) (Result, error) { result.Phase = Push result.State = Unknown - if err := endpoint.PushTag(ctx, captured.RepositoryDir, captured.Version, tagObject); err != nil { + if err := endpoint.PushTag(ctx, captured.RepositoryDir, captured.Tag, tagObject); err != nil { return reconcilePush(endpoint, captured, tagObject, result, err) } result.Phase = Complete @@ -175,7 +175,7 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec result.Phase = Reconciliation recoveryCtx, cancel := context.WithTimeout(context.Background(), recoveryTimeout) defer cancel() - remoteTag, queryErr := endpoint.QueryTag(recoveryCtx, captured.RepositoryDir, captured.Version) + remoteTag, queryErr := endpoint.QueryTag(recoveryCtx, captured.RepositoryDir, captured.Tag) if queryErr != nil || remoteTag.State == git.RefUnreadable || remoteTag.State == git.RefConflict { result.State = Unknown if queryErr == nil { @@ -185,11 +185,11 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec queryErr = git.ErrRemoteStateUnreadable } } - return result, fmt.Errorf("push annotated tag %s failed; authoritative reconciliation failed (%w): %w", captured.Version, queryErr, pushErr) + return result, fmt.Errorf("push annotated tag %s to %s failed; authoritative reconciliation failed (%w): %w", captured.Tag, endpoint.PushURL, 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) + return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag is absent at %s: %w", captured.Tag, endpoint.PushURL, pushErr) } if remoteTag.State == git.RefPresent && remoteTag.Object == tagObject && (remoteTag.Peeled == "" || remoteTag.Peeled == captured.Commit) { result.Phase = Complete @@ -197,7 +197,7 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec 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) + return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag identifies object %s, not %s at %s: %w", captured.Tag, remoteTag.Object, tagObject, endpoint.PushURL, pushErr) } func isRemoteConflict(err error) bool { diff --git a/publish_test.go b/publish_test.go index 9425a17..5cc3f2a 100644 --- a/publish_test.go +++ b/publish_test.go @@ -94,6 +94,109 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) { } } +func TestPublishUsesSeparateFullTagsForModulesAtOneCommit(t *testing.T) { + repo := newPublishFixture(t) + firstDir, secondDir := "integrations/first", "integrations/second" + addNestedReleaseModule(t, repo.work, firstDir, "example.com/first", "v1.2.3") + addNestedReleaseModule(t, repo.work, secondDir, "example.com/second", "v1.2.3") + gitRun(t, repo.work, "add", firstDir, secondDir) + gitRun(t, repo.work, "commit", "-m", "add two release modules") + gitRun(t, repo.work, "push", "origin") + commit := gitText(t, repo.work, "rev-parse", "HEAD") + + for _, moduleDir := range []string{firstDir, secondDir} { + result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ + RepositoryDir: repo.work, + ModuleDir: moduleDir, + Product: "Sample App", + Version: "v1.2.3", + }) + if err != nil { + t.Fatalf("Publish(%q): %v", moduleDir, err) + } + wantTag := moduleDir + "/v1.2.3" + if result.CandidateCommit != commit || result.Tag != wantTag || result.Version != "v1.2.3" { + t.Fatalf("publication for %q = %#v", moduleDir, result) + } + if got := gitText(t, repo.bare, "rev-parse", "refs/tags/"+wantTag); got != result.TagObject { + t.Fatalf("remote tag %s object = %s, want %s", wantTag, got, result.TagObject) + } + } + refs := gitText(t, repo.bare, "for-each-ref", "--format=%(refname)", "refs/tags") + if strings.Contains(refs, "refs/tags/v1.2.3\n") || !strings.Contains(refs, "refs/tags/"+firstDir+"/v1.2.3") || !strings.Contains(refs, "refs/tags/"+secondDir+"/v1.2.3") { + t.Fatalf("published tag refs = %q", refs) + } + + firstTag := firstDir + "/v1.2.3" + gitRun(t, repo.work, "tag", "-d", firstTag) + conflict, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ + RepositoryDir: repo.work, + ModuleDir: firstDir, + Product: "Sample App", + Version: "v1.2.3", + }) + if !errors.Is(err, git.ErrRemoteTagConflict) || conflict.State != releasekit.PublicationConflict || conflict.Tag != firstTag { + t.Fatalf("reuse of remote tag result=%#v error=%v", conflict, err) + } +} + +func TestPublishNestedRejectedPushReportsFullTag(t *testing.T) { + repo := newPublishFixture(t) + moduleDir := "integrations/ckconfig" + addNestedReleaseModule(t, repo.work, moduleDir, "example.com/ckconfig", "v1.2.3") + gitRun(t, repo.work, "add", moduleDir) + gitRun(t, repo.work, "commit", "-m", "add nested module") + gitRun(t, repo.work, "push", "origin") + 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) + } + wantTag := moduleDir + "/v1.2.3" + result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ + RepositoryDir: repo.work, + ModuleDir: moduleDir, + Product: "Sample App", + Version: "v1.2.3", + }) + if err == nil || result.State != releasekit.PublicationNotPublished || result.Tag != wantTag { + t.Fatalf("nested rejected push result=%#v error=%v", result, err) + } + if !strings.Contains(err.Error(), wantTag) || !strings.Contains(err.Error(), repo.bare) || !strings.Contains(err.Error(), "authoritative tag is absent") { + t.Fatalf("nested rejected-push error = %v", err) + } + if _, err := exec.Command("git", "--git-dir", repo.bare, "show-ref", "--verify", "--quiet", "refs/tags/"+wantTag).CombinedOutput(); err == nil { + t.Fatal("rejected nested tag unexpectedly appeared on the remote") + } +} + +func TestPublishNestedTagKeepsUnknownReconciliationEvidence(t *testing.T) { + repo := newPublishFixture(t) + moduleDir := "integrations/ckconfig" + addNestedReleaseModule(t, repo.work, moduleDir, "example.com/ckconfig", "v1.2.3") + gitRun(t, repo.work, "add", moduleDir) + gitRun(t, repo.work, "commit", "-m", "add nested module") + gitRun(t, repo.work, "push", "origin") + 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) + } + wantTag := moduleDir + "/v1.2.3" + wrapper := installTagQueryFailureWrapper(t, wantTag) + t.Setenv("PATH", wrapper+string(os.PathListSeparator)+os.Getenv("PATH")) + result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ + RepositoryDir: repo.work, + ModuleDir: moduleDir, + Product: "Sample App", + Version: "v1.2.3", + }) + if err == nil || result.State != releasekit.PublicationUnknown || result.Tag != wantTag || result.TagObject == "" { + t.Fatalf("nested unknown result=%#v error=%v", result, err) + } + if !strings.Contains(err.Error(), wantTag) || !strings.Contains(err.Error(), repo.bare) || !strings.Contains(err.Error(), "reconciliation unavailable") || !strings.Contains(err.Error(), "failed to push some refs") { + t.Fatalf("nested reconciliation error = %v", err) + } +} + func TestPublishRejectsUnexpectedPushURLBeforeValidation(t *testing.T) { repo := newPublishFixture(t) suiteRuns := filepath.Join(t.TempDir(), "suite-runs") @@ -699,6 +802,51 @@ func installGitWrapper(t *testing.T, repo publishFixture, mode string) gitWrappe return installGitWrapperWithMutationMode(t, repo, mode, "") } +func addNestedReleaseModule(t *testing.T, repository, moduleDir, modulePath, version string) { + t.Helper() + writeFile(t, repository, moduleDir+"/go.mod", "module "+modulePath+"\n\ngo 1.25.0\n") + writeFile(t, repository, moduleDir+"/docs/releases/"+version+".md", "# Sample App "+version+"\n\nNested release.\n") +} + +func installTagQueryFailureWrapper(t *testing.T, tag string) string { + 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) + } + marker := filepath.Join(t.TempDir(), "push-attempted") + contents := fmt.Sprintf(`#!/bin/sh +REAL_GIT=%s +MARKER=%s +TAG_REF=%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" ] && [ -f "$MARKER" ]; then + case "$ARGS" in *"$TAG_REF"*) echo "reconciliation unavailable" >&2; exit 77;; esac +fi +if [ "$IS_PUSH" = "1" ]; then + "$REAL_GIT" "$@" + STATUS=$? + : > "$MARKER" + exit "$STATUS" +fi +exec "$REAL_GIT" "$@" +`, shellQuote(realGit), shellQuote(marker), shellQuote("refs/tags/"+tag)) + if err := os.WriteFile(filepath.Join(bin, "git"), []byte(contents), 0o755); err != nil { + t.Fatal(err) + } + return bin +} + func installGitWrapperWithMutation(t *testing.T, repo publishFixture, object string) gitWrapper { t.Helper() return installGitWrapperWithMutationMode(t, repo, "mutate-tag", object)