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
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

View File

@@ -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 {

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) {
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)