Keep push recovery on captured endpoint

This commit is contained in:
2026-10-06 16:04:35 +00:00
parent b240e3cfa9
commit 12e866a94a
6 changed files with 73 additions and 12 deletions

View File

@@ -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/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/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/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/gitea` | Gitea API transport, hosted release pages, asset inspection, manifests, and uploads. |
| `internal/tempdir` | Disposable system temporary storage outside source worktrees. | | `internal/tempdir` | Disposable system temporary storage outside source worktrees. |
| `internal/process` | Cancellable child-process execution with per-command environment overlays. | | `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` | | 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` | | 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` | | 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` | | 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-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` | | Page, manifest, asset comparison, and additive retry outcomes | `internal/gitea` | `TestEnsureReleaseCreatesMissingPageFromCommittedNote`, `TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes`, `TestUploadMissingPartialFailureThenRerunOnlyMissingAssets` |

View File

@@ -73,15 +73,19 @@ offline validation begins.
## Resolve failures ## Resolve failures
A failed push triggers one exact authoritative tag query. An exact tag-object A failed push triggers a URL-resolution check against the captured endpoint,
match confirms publication even when Git reported an error. A proven absent tag then at most one exact authoritative tag query. If the endpoint no longer
is `not_published`; a different object is a conflict; an unreadable query is resolves to itself, recovery reports `unknown` without querying the rewritten
`unknown`. The command retains its local tag and reports the state, phase, 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. candidate commit, tag object, and captured publication endpoint when available.
Do not retry an unknown result until you resolve the authoritative ref at that 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 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. 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 ```sh
releasekit_endpoint='COPY_CAPTURED_ENDPOINT' releasekit_endpoint='COPY_CAPTURED_ENDPOINT'

View File

@@ -1,6 +1,6 @@
# ReleaseKit audit implementation plan # 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, Baseline: `e4be7fa` (2026-10-06). The [audit](audit.md) records the evidence,
product goals, and selected direction. This document owns implementation order product goals, and selected direction. This document owns implementation order
and completion criteria for that work. 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 absence, conflict, and unreadable-recovery tests continue to pass. Test the
behavior through publication; avoid a second copy of the full recovery matrix. 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 ## Stage 2 — Own effective Go settings for validation and builds
**Covers:** F1, F3, Go-environment portion of F8. **Primary owners:** **Covers:** F1, F3, Go-environment portion of F8. **Primary owners:**

View File

@@ -92,12 +92,16 @@ func InspectLocalTag(ctx context.Context, repositoryDir, version, expectedObject
return nil return nil
} }
// QueryTag reads one exact authoritative version tag and its peeled commit. // QueryTag verifies the captured endpoint still resolves to itself, then reads
// Failed reads are unreadable and never interpreted as absence. // 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) { func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, version string) (RemoteRef, error) {
if !validVersionRef(version) { if !validVersionRef(version) {
return RemoteRef{}, fmt.Errorf("invalid version tag name %q", 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 ref := "refs/tags/" + version
peeledRef := ref + "^{}" peeledRef := ref + "^{}"
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, ref, peeledRef) output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, ref, peeledRef)

View File

@@ -161,7 +161,7 @@ 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 (%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 { if remoteTag.State == git.RefAbsent {
result.State = NotPublished result.State = NotPublished

View File

@@ -12,6 +12,7 @@ import (
"time" "time"
releasekit "gitea.maximumdirect.net/eric/ReleaseKit" 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" 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) { func TestPublishConflictingRemoteTagIsNotReportedAsAbsent(t *testing.T) {
repo := newPublishFixture(t) repo := newPublishFixture(t)
wrapper := installGitWrapper(t, repo, "pass") wrapper := installGitWrapper(t, repo, "pass")
@@ -393,6 +435,11 @@ if [ "$IS_PUSH" = "1" ]; then
STATUS=$? STATUS=$?
: > "$PUSH_DONE" : > "$PUSH_DONE"
if [ "$MODE" = "lost-ack-cancel" ] && [ "$STATUS" = "0" ]; then kill -STOP $$; fi 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" exit "$STATUS"
fi fi
if [ "$MODE" = "mutate-tag" ]; then if [ "$MODE" = "mutate-tag" ]; then
@@ -406,7 +453,7 @@ if [ "$MODE" = "mutate-tag" ]; then
esac esac
fi fi
exec "$REAL_GIT" "$@" 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 { if err := os.WriteFile(filepath.Join(bin, "git"), []byte(contents), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }