From 368043984e8e69f10f6a6c11bfd4d8fb7cdd939b Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 7 Oct 2026 22:31:58 +0000 Subject: [PATCH] Check expected publication push URL --- check.go | 9 ++ cmd/releasekit/internal/cli/config.go | 17 ++-- cmd/releasekit/internal/cli/config_test.go | 5 +- cmd/releasekit/main.go | 1 + cmd/releasekit/main_test.go | 13 ++- docs/cli.md | 6 ++ docs/config.md | 8 ++ docs/consumers/publish.md | 13 ++- docs/internal/overview.md | 2 +- docs/roadmap/implementation.md | 5 ++ internal/publish/publish.go | 13 ++- publish.go | 28 ++++--- publish_test.go | 97 ++++++++++++++++++++++ 13 files changed, 186 insertions(+), 31 deletions(-) diff --git a/check.go b/check.go index 91be31f..76d0adf 100644 --- a/check.go +++ b/check.go @@ -6,12 +6,17 @@ import ( internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" + "gitea.maximumdirect.net/eric/ReleaseKit/internal/publish" "gitea.maximumdirect.net/eric/ReleaseKit/internal/validation" ) // ErrInvalidVersion reports a version that is not stable vMAJOR.MINOR.PATCH. var ErrInvalidVersion = candidate.ErrInvalidVersion +// ErrUnexpectedPushURL reports that the effective publication push URL does +// not exactly match ReleaseRequest.ExpectedPushURL. +var ErrUnexpectedPushURL = publish.ErrUnexpectedPushURL + // Candidate identifies one committed local source revision selected for // validation. type Candidate struct { @@ -28,6 +33,10 @@ type ReleaseRequest struct { Product string Version string Remote string + // ExpectedPushURL optionally requires Publish's resolved push URL to match + // this string exactly before validation or tag creation. Check ignores this + // publication-only expectation. A mismatch returns ErrUnexpectedPushURL. + ExpectedPushURL string // Validation selects optional checks run against the captured candidate. Validation ValidationSpec // AdditionalChecks optionally runs product checks after shared validation. diff --git a/cmd/releasekit/internal/cli/config.go b/cmd/releasekit/internal/cli/config.go index ba18be3..55538ea 100644 --- a/cmd/releasekit/internal/cli/config.go +++ b/cmd/releasekit/internal/cli/config.go @@ -112,15 +112,16 @@ type CheckCommand struct { Env map[string]string `json:"env,omitempty"` } -// Settings contains the product, remote, shared validation selections, and -// check commands read from a releasekit.json profile. +// Settings contains the product, remote, publication expectation, shared +// validation selections, and check commands read from a releasekit.json profile. type Settings struct { - Product string `json:"product"` - Remote string `json:"remote,omitempty"` - Checks []CheckCommand `json:"checks,omitempty"` - Validation *ValidationSettings `json:"validation,omitempty"` - Build *BuildSettings `json:"build,omitempty"` - Delivery *DeliverySettings `json:"delivery,omitempty"` + Product string `json:"product"` + Remote string `json:"remote,omitempty"` + ExpectedPushURL string `json:"expected_push_url,omitempty"` + Checks []CheckCommand `json:"checks,omitempty"` + Validation *ValidationSettings `json:"validation,omitempty"` + Build *BuildSettings `json:"build,omitempty"` + Delivery *DeliverySettings `json:"delivery,omitempty"` } // ValidationSettings selects shared candidate checks in a release profile. diff --git a/cmd/releasekit/internal/cli/config_test.go b/cmd/releasekit/internal/cli/config_test.go index cda44d3..735d559 100644 --- a/cmd/releasekit/internal/cli/config_test.go +++ b/cmd/releasekit/internal/cli/config_test.go @@ -144,7 +144,7 @@ func TestLoadSettingsDefaultsRemoteAndConfigPath(t *testing.T) { func TestLoadSettingsReadsGoValidation(t *testing.T) { repoDir := t.TempDir() profilePath := filepath.Join(repoDir, "releasekit.json") - if err := os.WriteFile(profilePath, []byte(`{"product":"Example","validation":{"go":{"race":true,"full_vet":true,"compile_targets":["linux/amd64","linux/arm64","darwin/arm64"]}}}`), 0o600); err != nil { + if err := os.WriteFile(profilePath, []byte(`{"product":"Example","expected_push_url":"ssh://git.example/owner/repo.git","validation":{"go":{"race":true,"full_vet":true,"compile_targets":["linux/amd64","linux/arm64","darwin/arm64"]}}}`), 0o600); err != nil { t.Fatal(err) } @@ -158,6 +158,9 @@ func TestLoadSettingsReadsGoValidation(t *testing.T) { if got, want := strings.Join(settings.Validation.Go.CompileTargets, ","), "linux/amd64,linux/arm64,darwin/arm64"; got != want { t.Fatalf("compile targets = %q, want %q", got, want) } + if settings.ExpectedPushURL != "ssh://git.example/owner/repo.git" { + t.Fatalf("expected push URL = %q", settings.ExpectedPushURL) + } } func TestExampleProfileLoads(t *testing.T) { diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index b87261a..d0a3257 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -42,6 +42,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { } request := candidateRequest(invocation, settings, stderr) if invocation.Command == cli.PublishAction { + request.ExpectedPushURL = settings.ExpectedPushURL fmt.Fprintf(stderr, "validating and publishing %s\n", invocation.Version) result, err := releasekit.Publish(ctx, request) if err != nil { diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index d4f4a94..163afa2 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -114,7 +114,8 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) { func TestCandidateRequestMapsGoValidation(t *testing.T) { request := candidateRequest(cli.Invocation{Repository: "repository", Version: "v1.2.3"}, cli.Settings{ - Product: "Example Product", + Product: "Example Product", + ExpectedPushURL: "publication-only value", Validation: &cli.ValidationSettings{ Go: cli.GoValidationSettings{Race: true, FullVet: true, CompileTargets: []string{"linux/amd64", "darwin/arm64"}}, }, @@ -122,6 +123,9 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) { if !request.Validation.Go.Race || !request.Validation.Go.FullVet { t.Fatalf("candidate request validation = %#v, want Go race and full vet enabled", request.Validation) } + if request.ExpectedPushURL != "" { + t.Fatalf("candidate request carried publication-only expected push URL %q", request.ExpectedPushURL) + } wantTargets := []releasekit.Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}} if len(request.Validation.Go.CompileTargets) != len(wantTargets) { t.Fatalf("candidate request compile targets = %#v, want %#v", request.Validation.Go.CompileTargets, wantTargets) @@ -217,9 +221,11 @@ printf '%s %s\n' "$version" "$candidate" >> "$RELEASEKIT_COMPATIBILITY_LOG" cliGitRun(t, repository, "add", "docs/releases/v1.2.3.md", "scripts/check-release-note.sh") cliGitRun(t, repository, "commit", "-m", "add compatibility note and check") checkLog := filepath.Join(t.TempDir(), "compatibility-check.log") + remote := filepath.Join(t.TempDir(), "origin.git") settings := cli.Settings{ - Product: "Example Product", - Remote: "origin", + Product: "Example Product", + Remote: "origin", + ExpectedPushURL: remote, Checks: []cli.CheckCommand{{ Args: []string{"sh", "scripts/check-release-note.sh"}, Env: map[string]string{"RELEASEKIT_COMPATIBILITY_LOG": checkLog}, @@ -237,7 +243,6 @@ printf '%s %s\n' "$version" "$candidate" >> "$RELEASEKIT_COMPATIBILITY_LOG" } cliGitRun(t, repository, "add", "releasekit.json") cliGitRun(t, repository, "commit", "-m", "configure source release") - remote := filepath.Join(t.TempDir(), "origin.git") cliGitRun(t, repository, "init", "--bare", remote) cliGitRun(t, repository, "remote", "add", "origin", remote) cliGitRun(t, repository, "push", "--set-upstream", "origin", "main") diff --git a/docs/cli.md b/docs/cli.md index b20822c..ea63dac 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -71,6 +71,12 @@ after tests and full vet and before configured checks. Each failure is reported as `package-compile` with its selected target; this does not create build artifacts or execute target binaries. +The optional profile field `expected_push_url` applies only to `publish`. A +nonempty value must exactly match the effective push URL resolved by Git; +mismatches fail during preflight before Go validation or tag creation. Git URL +rewrites are resolved first, and the push URL is used when fetch and push URLs +differ. `check` ignores this field. + The command returns status 0 for confirmed publication, 1 for a known publication failure or conflict, 2 for usage, profile, or invalid-version errors, and 3 when the push outcome remains unknown. Unknown status must be diff --git a/docs/config.md b/docs/config.md index 5ae8da0..0217a93 100644 --- a/docs/config.md +++ b/docs/config.md @@ -10,6 +10,7 @@ The current profile fields are: | --- | --- | --- | | `product` | Yes | Product name used by release operations. | | `remote` | No | Selected Git remote; defaults to `origin`. | +| `expected_push_url` | No | Exact effective push URL required by `publish`; ignored by local `check`. | | `checks` | No | Ordered additional child commands, each with an argument vector and optional environment map. | | `validation` | No | Optional shared checks selected by ReleaseKit. | | `build` | No | Optional main package, binary name, and selected `GOOS/GOARCH` target strings. | @@ -58,6 +59,13 @@ environment variable containing the Gitea token; it defaults to `RELEASEKIT_GITEA_TOKEN`. The CLI reads this variable only for `deliver` and passes its value explicitly to the library operation. +`expected_push_url`, when nonempty, is compared exactly with Git's resolved +effective push URL during `publish`, before candidate validation. It applies +to the push destination when fetch and push URLs differ, and Git URL rewrites +such as `insteadOf` are resolved before comparison. A mismatch fails preflight; +omitting the field keeps the selected remote's existing behavior. `check` is +local-only and ignores this publication expectation. + The `check` and `publish` commands run one `go test -mod=readonly -count=1 ./...` suite with Go module and toolchain network access disabled, adding `-race` when `validation.go.race` is true. When diff --git a/docs/consumers/publish.md b/docs/consumers/publish.md index c8639f5..829dd72 100644 --- a/docs/consumers/publish.md +++ b/docs/consumers/publish.md @@ -5,9 +5,10 @@ annotated source tag: ```go result, err := releasekit.Publish(ctx, releasekit.ReleaseRequest{ - RepositoryDir: "/work/project", - Product: "Example", - Version: "v1.2.3", + RepositoryDir: "/work/project", + Product: "Example", + Version: "v1.2.3", + ExpectedPushURL: "ssh://git.example/owner/example.git", Validation: releasekit.ValidationSpec{ Go: releasekit.GoValidationSpec{Race: true, FullVet: true}, }, @@ -23,6 +24,12 @@ fmt.Println(result.Version, result.CandidateCommit, result.TagObject) Publication captures a clean candidate on `main`, validates its committed release note, and checks the selected remote before running candidate checks. +Set `ExpectedPushURL` to require the effective push destination to match an +exact string. Git URL rewrites are resolved first, and the push URL is compared +when fetch and push URLs differ. A mismatch fails preflight before Go validation, +callbacks, or tag creation; detect it with +`errors.Is(err, releasekit.ErrUnexpectedPushURL)`. `Check` ignores this +publication-only field. It runs one `go test -mod=readonly -count=1 ./...` suite with the available compatible local Go toolchain. Set `Validation.Go.Race` to run that suite with `-race`. Set `Validation.Go.FullVet` to run `go vet -mod=readonly ./...` after diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 1050a3e..3fadc58 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -80,7 +80,7 @@ not a separate release matrix: | Runtime version lookup and injected application version | `buildinfo`, `internal/runtimeinfo`, `internal/versioncontract`, `internal/build` | `TestVersionInConsumerExecutable`, `TestVersion` | | Candidate capture, shared validation, public diagnostics, additional checks, and rechecks | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestRunUsesOneSuiteWithSelectedRaceSetting`, `TestRunReportsRacePrerequisiteFailureWithoutFallback`, `TestRunExecutesRaceEnabledSuiteOnHost`, `TestRunCompilesTargetsAfterTestAndVetInSelectionOrder`, `TestRunAttributesTargetCompileFailureAndStops`, `TestRunCompilesSourceOnlyAndExecutablePackagesWithoutArtifacts`, `TestCandidateRequestMapsGoValidation`, `TestLoadSettingsReadsGoValidation`, `TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks`, `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation`, `TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate`, `TestCandidateCommandsPresentValidationDiagnosticsOnce`, `TestDeliverPageOnlyCreatesAndReusesRelease` | | Shared Go settings for validation and builds | `internal/gotool`, `internal/process`, `internal/candidate`, `internal/build` | `TestDefaultValidationDoesNotInheritGOFLAGSRunFilter`, `TestNativeAndStaticCommandsUseOwnedBaseline`, `TestPersistentSettingsPreserveCachesAndNativeCTools`, `TestBuildIgnoresAmbientAndPersistentArchitectureTuning` | -| 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`, `TestPublishRejectsUnexpectedPushURLBeforeValidation`, `TestPublishComparesExpectedURLWithResolvedInsteadOfDestination`, `TestPublishExpectedPushURLUsesPushURLWhenFetchAndPushDiffer`, `TestCheckIgnoresExpectedPushURL`, `TestResolveUsesPushURLAndQueriesWithoutMutation` | | 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 managed or prebuilt binary delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlySkipsBuildAndHostedAssets`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites` | diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 5bfd531..f6241ef 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -295,6 +295,11 @@ Reuse the existing Go environment tests for controls already protected there. ## Stage 4: Compare the optional expected push URL +**Completion:** Complete. `ReleaseRequest.ExpectedPushURL` and profile +`expected_push_url` require an exact match with Git's resolved effective push +URL before validation. Mismatches return `ErrUnexpectedPushURL` with expected +and resolved values; `Check` ignores the publication-only expectation. + **Outcome:** Publication rejects a different effective destination before validation. 1. Add `ExpectedPushURL` and `expected_push_url`, with an empty value selecting diff --git a/internal/publish/publish.go b/internal/publish/publish.go index bb795a5..623e5da 100644 --- a/internal/publish/publish.go +++ b/internal/publish/publish.go @@ -36,11 +36,16 @@ const ( const recoveryTimeout = 30 * time.Second +// ErrUnexpectedPushURL reports that the resolved push URL differs from the +// caller's expected publication destination. +var ErrUnexpectedPushURL = errors.New("unexpected publication push URL") + type Request struct { RepositoryDir string Product string Version string Remote string + ExpectedPushURL string Validation validation.Settings AdditionalChecks func(context.Context, candidate.Candidate) error AfterValidate func(context.Context, candidate.Candidate) error @@ -56,8 +61,9 @@ type Result struct { State State } -// Publish validates one captured candidate and pushes only its annotated tag -// object. It runs one standard Go suite, with the selected settings, then +// Publish captures a candidate, resolves and checks the optional expected push +// URL, then validates the candidate and pushes only its annotated tag object. +// It runs one standard Go suite, with the selected settings, then // AdditionalChecks when supplied. // A failed push receives one bounded authoritative tag query. Exact // object equality confirms publication; absence and conflict are classified, @@ -87,6 +93,9 @@ func Publish(ctx context.Context, request Request) (Result, error) { return result, fmt.Errorf("resolve publication endpoint: %w", err) } result.Endpoint = endpoint.PushURL + if request.ExpectedPushURL != "" && request.ExpectedPushURL != endpoint.PushURL { + return result, fmt.Errorf("%w: expected %q, resolved %q", ErrUnexpectedPushURL, request.ExpectedPushURL, endpoint.PushURL) + } if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil { return result, fmt.Errorf("pre-validation endpoint guard: %w", err) } diff --git a/publish.go b/publish.go index 51d1a60..a64572c 100644 --- a/publish.go +++ b/publish.go @@ -48,21 +48,24 @@ type PublicationResult struct { State PublicationState // publication outcome for this invocation } -// Publish always runs one offline Go test suite for the captured candidate. -// Validation.Go.Race enables the race detector for that suite, and +// After publication preflight succeeds, Publish runs one offline Go test suite +// for the captured candidate. +// Validation.Go.Race enables the race detector for that suite, // Validation.Go.FullVet runs go vet afterward instead of test-time vet, and // Validation.Go.CompileTargets compiles all packages for each selected target // with cgo disabled after tests and vet. Compilation produces no artifacts and -// does not execute target binaries. It then calls AdditionalChecks when -// supplied. It optionally builds selected -// targets in temporary storage outside the worktree and pushes the exact -// annotated tag object to the selected remote. It returns a result with errors: preflight -// failures are not_attempted, failed authoritative guards are conflict, and -// errors after local tag creation but before push are not_published. A failed -// push gets one bounded authoritative tag query: an exact object match confirms -// publication, absence is not_published, a different object is conflict, and -// an unreadable result is unknown. A nil error means publication is confirmed -// by the successful push or exact remote object match. +// does not execute target binaries. ExpectedPushURL, when nonempty, is compared +// exactly with the resolved effective push URL before validation; a mismatch +// returns ErrUnexpectedPushURL. It then calls AdditionalChecks when supplied. +// It optionally builds selected targets in temporary storage outside the +// worktree and pushes the exact annotated tag object to the selected remote. +// It returns a result with errors: preflight failures are not_attempted, failed +// authoritative guards are conflict, and errors after local tag creation but +// before push are not_published. A failed push gets one bounded authoritative +// tag query: an exact object match confirms publication, absence is +// not_published, a different object is conflict, and an unreadable result is +// unknown. A nil error means publication is confirmed by the successful push +// or exact remote object match. func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, error) { candidateInput := candidateRequest(request) result, err := publish.Publish(ctx, publish.Request{ @@ -70,6 +73,7 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er Product: candidateInput.Product, Version: candidateInput.Version, Remote: request.Remote, + ExpectedPushURL: request.ExpectedPushURL, Validation: candidateInput.Validation, AdditionalChecks: candidateInput.AdditionalChecks, AfterValidate: candidateInput.AfterValidate, diff --git a/publish_test.go b/publish_test.go index 09b3b9b..ac51e76 100644 --- a/publish_test.go +++ b/publish_test.go @@ -94,6 +94,103 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) { } } +func TestPublishRejectsUnexpectedPushURLBeforeValidation(t *testing.T) { + repo := newPublishFixture(t) + suiteRuns := filepath.Join(t.TempDir(), "suite-runs") + t.Setenv("RELEASEKIT_FIXTURE_SUITE_RUNS", suiteRuns) + expected := repo.bare + string(os.PathSeparator) + var callbacks int + result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ + RepositoryDir: repo.work, + Product: "Sample App", + Version: "v1.2.3", + ExpectedPushURL: expected, + AdditionalChecks: func(context.Context, releasekit.Candidate) error { + callbacks++ + return nil + }, + }) + if !errors.Is(err, releasekit.ErrUnexpectedPushURL) { + t.Fatalf("Publish() error = %v, want ErrUnexpectedPushURL", err) + } + if !strings.Contains(err.Error(), fmt.Sprintf("expected %q", expected)) || !strings.Contains(err.Error(), fmt.Sprintf("resolved %q", repo.bare)) { + t.Fatalf("Publish() error = %v, want expected and resolved URLs", err) + } + if result.State != releasekit.PublicationNotAttempted || result.Phase != releasekit.PublicationPreflight || result.Endpoint != repo.bare || result.TagObject != "" { + t.Fatalf("publication result = %#v, want preflight failure at resolved endpoint", result) + } + if callbacks != 0 { + t.Fatalf("additional checks ran %d times, want none", callbacks) + } + if _, statErr := os.Stat(suiteRuns); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("standard-suite marker stat error = %v, want no suite run", statErr) + } + if got := gitText(t, repo.work, "tag", "--list", "v1.2.3"); got != "" { + t.Fatalf("local tag exists after URL mismatch: %q", got) + } +} + +func TestPublishComparesExpectedURLWithResolvedInsteadOfDestination(t *testing.T) { + repo := newPublishFixture(t) + alias := "releasekit-expected-push:" + gitRun(t, repo.work, "config", "url."+repo.bare+".insteadOf", alias) + gitRun(t, repo.work, "remote", "set-url", "origin", alias) + + result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ + RepositoryDir: repo.work, + Product: "Sample App", + Version: "v1.2.3", + ExpectedPushURL: repo.bare, + }) + if err != nil { + t.Fatalf("Publish() with the resolved insteadOf destination failed: %v", err) + } + if result.Endpoint != repo.bare || result.State != releasekit.PublicationPublished { + t.Fatalf("publication result = %#v, want resolved push URL and successful publication", result) + } +} + +func TestPublishExpectedPushURLUsesPushURLWhenFetchAndPushDiffer(t *testing.T) { + repo := newPublishFixture(t) + fetchURL := filepath.Join(t.TempDir(), "fetch-only.git") + gitRun(t, repo.work, "remote", "set-url", "origin", fetchURL) + gitRun(t, repo.work, "config", "remote.origin.pushurl", repo.bare) + + result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ + RepositoryDir: repo.work, + Product: "Sample App", + Version: "v1.2.3", + ExpectedPushURL: fetchURL, + }) + if !errors.Is(err, releasekit.ErrUnexpectedPushURL) { + t.Fatalf("Publish() error = %v, want ErrUnexpectedPushURL", err) + } + if result.Endpoint != repo.bare || !strings.Contains(err.Error(), fmt.Sprintf("resolved %q", repo.bare)) { + t.Fatalf("result = %#v, error = %v; want the effective push URL %q", result, err, repo.bare) + } +} + +func TestCheckIgnoresExpectedPushURL(t *testing.T) { + repo := newPublishFixture(t) + var callbacks int + _, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{ + RepositoryDir: repo.work, + Product: "Sample App", + Version: "v1.2.3", + ExpectedPushURL: "unrelated destination", + AdditionalChecks: func(context.Context, releasekit.Candidate) error { + callbacks++ + return nil + }, + }) + if err != nil { + t.Fatalf("Check() considered publication URL expectation: %v", err) + } + if callbacks != 1 { + t.Fatalf("additional checks ran %d times, want one", callbacks) + } +} + func TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks(t *testing.T) { requireRacePrerequisites(t) repo := newPublishFixture(t)