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

View File

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

View File

@@ -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:**

View File

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

View File

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

View File

@@ -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)
}