Keep push recovery on captured endpoint
This commit is contained in:
@@ -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` |
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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:**
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user