Publish and reconcile complete module tags
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
148
publish_test.go
148
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) {
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user