diff --git a/docs/internal/overview.md b/docs/internal/overview.md index ff72890..5479e65 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -37,7 +37,7 @@ these implemented packages: | `internal/build` | Candidate-build coordination, selected Go target compilation, version injection, native smoke check, and artifact digests. | | `internal/candidate` | Stable-version, note, repository-state, candidate capture, and post-validation checks. | | `internal/delivery` | Tagged-checkout verification, artifact inventory/hash validation, manifest assembly, and coordination of hosted delivery results. | -| `internal/git` | Git command boundary, push-destination resolution, exact ref inspection, and tag operations. | +| `internal/git` | Git command boundary, push-destination resolution, exact ref inspection, and tag operations, including captured-endpoint verification during recovery. | | `internal/gitea` | Gitea API transport, hosted release pages, asset inspection, manifests, and uploads. | | `internal/tempdir` | Disposable system temporary storage outside source worktrees. | | `internal/process` | Cancellable child-process execution with per-command environment overlays. | @@ -63,7 +63,7 @@ not a separate release matrix: | Runtime version lookup and injected application version | `buildinfo`, `internal/runtimeinfo`, `internal/versioncontract`, `internal/build` | `TestVersionInConsumerExecutable`, `TestVersion` | | Candidate capture, committed note, one validation, and rechecks | `releasekit.Check`, `internal/candidate`, `cmd/releasekit/internal/cli` | `TestCheckCapturesCandidateAndCallsValidationOnce`, `TestCheckRechecksHeadAndWorktreeAfterCallback`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate` | | Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestResolveUsesPushURLAndQueriesWithoutMutation` | -| Publication partial and uncertain recovery | `internal/publish` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` | +| Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` | | Cross-builds, version smoke checks, empty target sets, and partial outputs | `releasekit.Build`, `internal/build`, `buildinfo` | `TestBuildCreatesSelectedTargetsAndInjectsVersion`, `TestBuildWithNoTargetsDoesNotInvokeCompiler`, `TestBuildReturnsPartialArtifactsAfterTargetFailure` | | Page-only and binary tagged-source delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlyCreatesAndReusesRelease`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites` | | Page, manifest, asset comparison, and additive retry outcomes | `internal/gitea` | `TestEnsureReleaseCreatesMissingPageFromCommittedNote`, `TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes`, `TestUploadMissingPartialFailureThenRerunOnlyMissingAssets` | diff --git a/docs/release.md b/docs/release.md index baef48c..45ccd05 100644 --- a/docs/release.md +++ b/docs/release.md @@ -73,15 +73,19 @@ offline validation begins. ## Resolve failures -A failed push triggers one exact authoritative tag query. An exact tag-object -match confirms publication even when Git reported an error. A proven absent tag -is `not_published`; a different object is a conflict; an unreadable query is -`unknown`. The command retains its local tag and reports the state, phase, +A failed push triggers a URL-resolution check against the captured endpoint, +then at most one exact authoritative tag query. If the endpoint no longer +resolves to itself, recovery reports `unknown` without querying the rewritten +destination. An exact tag-object match confirms publication even when Git +reported an error. A proven absent tag is `not_published`; a different object +is a conflict; an unreadable query is `unknown`. The command retains its local +tag and reports the state, phase, candidate commit, tag object, and captured publication endpoint when available. Do not retry an unknown result until you resolve the authoritative ref at that exact endpoint. Copy the reported endpoint value into the variable below using normal shell quoting; retain the version and tag object from the same result. -Check that Git URL rewriting still resolves this value to itself before querying: +The command checks that Git URL rewriting still resolves this value to itself; +the manual check below applies the same rule before querying: ```sh releasekit_endpoint='COPY_CAPTURED_ENDPOINT' diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 16cd980..7834438 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # ReleaseKit audit implementation plan -Status: planned; implementation has not started. +Status: Stage 1 complete; Stage 2 not started. Baseline: `e4be7fa` (2026-10-06). The [audit](audit.md) records the evidence, product goals, and selected direction. This document owns implementation order and completion criteria for that work. @@ -100,6 +100,12 @@ remote must find the intended object. Existing unchanged-endpoint success, absence, conflict, and unreadable-recovery tests continue to pass. Test the behavior through publication; avoid a second copy of the full recovery matrix. +**Completed:** Recovery now verifies the captured URL before its one tag query. +The rewrite reproduction returns unknown, preserves the local tag and both +errors, and confirms the object on the original remote. Focused publication +tests, the full offline suite, API boundary checks, and documentation whitespace +checks pass. + ## Stage 2 — Own effective Go settings for validation and builds **Covers:** F1, F3, Go-environment portion of F8. **Primary owners:** diff --git a/internal/git/tag.go b/internal/git/tag.go index 9eaee10..fd3ae7b 100644 --- a/internal/git/tag.go +++ b/internal/git/tag.go @@ -92,12 +92,16 @@ func InspectLocalTag(ctx context.Context, repositoryDir, version, expectedObject return nil } -// QueryTag reads one exact authoritative version tag and its peeled commit. -// Failed reads are unreadable and never interpreted as absence. +// QueryTag verifies the captured endpoint still resolves to itself, then reads +// one exact authoritative version tag and its peeled commit. Failed +// verification or reads are unreadable and never interpreted as absence. func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, version string) (RemoteRef, error) { if !validVersionRef(version) { return RemoteRef{}, fmt.Errorf("invalid version tag name %q", version) } + if err := endpoint.verifyScopedURLs(ctx, repositoryDir); err != nil { + return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: verify authoritative endpoint before tag query: %w", ErrRemoteStateUnreadable, err) + } ref := "refs/tags/" + version peeledRef := ref + "^{}" output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, ref, peeledRef) diff --git a/internal/publish/publish.go b/internal/publish/publish.go index c191b10..751d81b 100644 --- a/internal/publish/publish.go +++ b/internal/publish/publish.go @@ -161,7 +161,7 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec queryErr = git.ErrRemoteStateUnreadable } } - return result, fmt.Errorf("push annotated tag %s failed; authoritative reconciliation failed (%v): %w", captured.Version, queryErr, pushErr) + return result, fmt.Errorf("push annotated tag %s failed; authoritative reconciliation failed (%w): %w", captured.Version, queryErr, pushErr) } if remoteTag.State == git.RefAbsent { result.State = NotPublished diff --git a/publish_test.go b/publish_test.go index 3dd08b4..20106da 100644 --- a/publish_test.go +++ b/publish_test.go @@ -12,6 +12,7 @@ import ( "time" releasekit "gitea.maximumdirect.net/eric/ReleaseKit" + "gitea.maximumdirect.net/eric/ReleaseKit/internal/git" ) const releaseNote = "# Sample App v1.2.3\n\nA source release.\n\nUpgrade notes stay intact.\n" @@ -163,6 +164,47 @@ func TestPublishLostAcknowledgmentConfirmsExactRemoteObject(t *testing.T) { } } +func TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown(t *testing.T) { + repo := newPublishFixture(t) + base := t.TempDir() + emptyRemote := filepath.Join(base, "empty.git") + gitRun(t, base, "init", "--bare", emptyRemote) + wrapper := installGitWrapperWithMutationMode(t, repo, "lost-ack-rewrite", emptyRemote) + 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", + Validate: func(context.Context, releasekit.Candidate) error { return nil }, + }) + if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation { + t.Fatalf("result = %#v, error = %v", result, err) + } + if !strings.Contains(err.Error(), "scoped URL resolves") || !strings.Contains(err.Error(), "lost acknowledgment") { + t.Fatalf("error does not preserve endpoint verification and push failures: %v", err) + } + if !errors.Is(err, git.ErrEndpointChanged) || !errors.Is(err, git.ErrRemoteStateUnreadable) { + t.Fatalf("endpoint verification error identity was not preserved: %v", err) + } + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 77 { + t.Fatalf("push cause identity was not preserved: %v", err) + } + if queries := countLines(t, wrapper.queryLog); queries != 3 { + t.Fatalf("authoritative tag query count = %d, want no tag query after endpoint verification failed", queries) + } + if got := gitText(t, repo.work, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject { + t.Fatalf("local tag object = %s, want retained object %s", got, result.TagObject) + } + if got := gitText(t, repo.bare, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject { + t.Fatalf("original remote tag object = %s, want published object %s", got, result.TagObject) + } + if got := gitText(t, repo.bare, "rev-parse", "refs/tags/v1.2.3^{}"); got != repo.commit { + t.Fatalf("original remote tag peels to %s, want candidate commit %s", got, repo.commit) + } +} + func TestPublishConflictingRemoteTagIsNotReportedAsAbsent(t *testing.T) { repo := newPublishFixture(t) wrapper := installGitWrapper(t, repo, "pass") @@ -393,6 +435,11 @@ if [ "$IS_PUSH" = "1" ]; then STATUS=$? : > "$PUSH_DONE" if [ "$MODE" = "lost-ack-cancel" ] && [ "$STATUS" = "0" ]; then kill -STOP $$; fi + if [ "$MODE" = "lost-ack-rewrite" ] && [ "$STATUS" = "0" ]; then + "$REAL_GIT" config --local "url.\"$MUTATE_OBJECT\".insteadOf" %s + echo "lost acknowledgment after tag was accepted" >&2 + exit 77 + fi exit "$STATUS" fi if [ "$MODE" = "mutate-tag" ]; then @@ -406,7 +453,7 @@ if [ "$MODE" = "mutate-tag" ]; then esac fi exec "$REAL_GIT" "$@" -`, shellQuote(realGit), shellQuote(queryLog), shellQuote(pushDone), shellQuote(mode), shellQuote(mutationObject)) +`, shellQuote(realGit), shellQuote(queryLog), shellQuote(pushDone), shellQuote(mode), shellQuote(mutationObject), shellQuote(repo.bare)) if err := os.WriteFile(filepath.Join(bin, "git"), []byte(contents), 0o755); err != nil { t.Fatal(err) }