Publish and reconcile complete module tags

This commit is contained in:
2026-10-10 02:18:02 +00:00
parent 3e516d0ac1
commit fa34cbaff3
3 changed files with 164 additions and 9 deletions

View File

@@ -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 unknown-outcome evidence cases. Reuse existing endpoint and lost-acknowledgment
tests for unchanged recovery mechanics. Run publication and Git tests. 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 ## Stage 4: Construct the selected committed file inputs
**Outcome:** One ordered path set defines automatic validation inputs, while the **Outcome:** One ordered path set defines automatic validation inputs, while the

View File

@@ -109,7 +109,7 @@ func Publish(ctx context.Context, request Request) (Result, error) {
if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil { if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil {
return result, fmt.Errorf("pre-validation endpoint guard: %w", err) 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) { if isRemoteConflict(err) {
result.State = Conflict 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 { if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil {
return result, fmt.Errorf("pre-publication endpoint guard: %w", err) 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) { if isRemoteConflict(err) {
result.State = Conflict result.State = Conflict
} }
@@ -143,7 +143,7 @@ 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.Version, captured.Commit, []byte(captured.Note)) tagObject, err := git.CreateAnnotatedTag(ctx, captured.RepositoryDir, captured.Tag, captured.Commit, []byte(captured.Note))
if err != nil { if err != nil {
return result, err 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 { if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil {
return result, fmt.Errorf("pre-push endpoint guard: %w", err) 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) { if isRemoteConflict(err) {
result.State = Conflict result.State = Conflict
} }
@@ -163,7 +163,7 @@ 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.Tag, tagObject); err != nil {
return reconcilePush(endpoint, captured, tagObject, result, err) return reconcilePush(endpoint, captured, tagObject, result, err)
} }
result.Phase = Complete result.Phase = Complete
@@ -175,7 +175,7 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec
result.Phase = Reconciliation result.Phase = Reconciliation
recoveryCtx, cancel := context.WithTimeout(context.Background(), recoveryTimeout) recoveryCtx, cancel := context.WithTimeout(context.Background(), recoveryTimeout)
defer cancel() 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 { if queryErr != nil || remoteTag.State == git.RefUnreadable || remoteTag.State == git.RefConflict {
result.State = Unknown result.State = Unknown
if queryErr == nil { if queryErr == nil {
@@ -185,11 +185,11 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec
queryErr = git.ErrRemoteStateUnreadable 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 { if remoteTag.State == git.RefAbsent {
result.State = NotPublished 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) { if remoteTag.State == git.RefPresent && remoteTag.Object == tagObject && (remoteTag.Peeled == "" || remoteTag.Peeled == captured.Commit) {
result.Phase = Complete result.Phase = Complete
@@ -197,7 +197,7 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec
return result, nil return result, nil
} }
result.State = Conflict 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 { func isRemoteConflict(err error) bool {

View File

@@ -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) { func TestPublishRejectsUnexpectedPushURLBeforeValidation(t *testing.T) {
repo := newPublishFixture(t) repo := newPublishFixture(t)
suiteRuns := filepath.Join(t.TempDir(), "suite-runs") 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, "") 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 { func installGitWrapperWithMutation(t *testing.T, repo publishFixture, object string) gitWrapper {
t.Helper() t.Helper()
return installGitWrapperWithMutationMode(t, repo, "mutate-tag", object) return installGitWrapperWithMutationMode(t, repo, "mutate-tag", object)