Check expected publication push URL

This commit is contained in:
2026-10-07 22:31:58 +00:00
parent cabc56b134
commit 368043984e
13 changed files with 186 additions and 31 deletions

View File

@@ -6,12 +6,17 @@ import (
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/publish"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation" "gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
) )
// ErrInvalidVersion reports a version that is not stable vMAJOR.MINOR.PATCH. // ErrInvalidVersion reports a version that is not stable vMAJOR.MINOR.PATCH.
var ErrInvalidVersion = candidate.ErrInvalidVersion 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 // Candidate identifies one committed local source revision selected for
// validation. // validation.
type Candidate struct { type Candidate struct {
@@ -28,6 +33,10 @@ type ReleaseRequest struct {
Product string Product string
Version string Version string
Remote 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 selects optional checks run against the captured candidate.
Validation ValidationSpec Validation ValidationSpec
// AdditionalChecks optionally runs product checks after shared validation. // AdditionalChecks optionally runs product checks after shared validation.

View File

@@ -112,15 +112,16 @@ type CheckCommand struct {
Env map[string]string `json:"env,omitempty"` Env map[string]string `json:"env,omitempty"`
} }
// Settings contains the product, remote, shared validation selections, and // Settings contains the product, remote, publication expectation, shared
// check commands read from a releasekit.json profile. // validation selections, and check commands read from a releasekit.json profile.
type Settings struct { type Settings struct {
Product string `json:"product"` Product string `json:"product"`
Remote string `json:"remote,omitempty"` Remote string `json:"remote,omitempty"`
Checks []CheckCommand `json:"checks,omitempty"` ExpectedPushURL string `json:"expected_push_url,omitempty"`
Validation *ValidationSettings `json:"validation,omitempty"` Checks []CheckCommand `json:"checks,omitempty"`
Build *BuildSettings `json:"build,omitempty"` Validation *ValidationSettings `json:"validation,omitempty"`
Delivery *DeliverySettings `json:"delivery,omitempty"` Build *BuildSettings `json:"build,omitempty"`
Delivery *DeliverySettings `json:"delivery,omitempty"`
} }
// ValidationSettings selects shared candidate checks in a release profile. // ValidationSettings selects shared candidate checks in a release profile.

View File

@@ -144,7 +144,7 @@ func TestLoadSettingsDefaultsRemoteAndConfigPath(t *testing.T) {
func TestLoadSettingsReadsGoValidation(t *testing.T) { func TestLoadSettingsReadsGoValidation(t *testing.T) {
repoDir := t.TempDir() repoDir := t.TempDir()
profilePath := filepath.Join(repoDir, "releasekit.json") 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) 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 { 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) 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) { func TestExampleProfileLoads(t *testing.T) {

View File

@@ -42,6 +42,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
} }
request := candidateRequest(invocation, settings, stderr) request := candidateRequest(invocation, settings, stderr)
if invocation.Command == cli.PublishAction { if invocation.Command == cli.PublishAction {
request.ExpectedPushURL = settings.ExpectedPushURL
fmt.Fprintf(stderr, "validating and publishing %s\n", invocation.Version) fmt.Fprintf(stderr, "validating and publishing %s\n", invocation.Version)
result, err := releasekit.Publish(ctx, request) result, err := releasekit.Publish(ctx, request)
if err != nil { if err != nil {

View File

@@ -114,7 +114,8 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
func TestCandidateRequestMapsGoValidation(t *testing.T) { func TestCandidateRequestMapsGoValidation(t *testing.T) {
request := candidateRequest(cli.Invocation{Repository: "repository", Version: "v1.2.3"}, cli.Settings{ 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{ Validation: &cli.ValidationSettings{
Go: cli.GoValidationSettings{Race: true, FullVet: true, CompileTargets: []string{"linux/amd64", "darwin/arm64"}}, 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 { if !request.Validation.Go.Race || !request.Validation.Go.FullVet {
t.Fatalf("candidate request validation = %#v, want Go race and full vet enabled", request.Validation) 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"}} wantTargets := []releasekit.Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}}
if len(request.Validation.Go.CompileTargets) != len(wantTargets) { if len(request.Validation.Go.CompileTargets) != len(wantTargets) {
t.Fatalf("candidate request compile targets = %#v, want %#v", request.Validation.Go.CompileTargets, 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, "add", "docs/releases/v1.2.3.md", "scripts/check-release-note.sh")
cliGitRun(t, repository, "commit", "-m", "add compatibility note and check") cliGitRun(t, repository, "commit", "-m", "add compatibility note and check")
checkLog := filepath.Join(t.TempDir(), "compatibility-check.log") checkLog := filepath.Join(t.TempDir(), "compatibility-check.log")
remote := filepath.Join(t.TempDir(), "origin.git")
settings := cli.Settings{ settings := cli.Settings{
Product: "Example Product", Product: "Example Product",
Remote: "origin", Remote: "origin",
ExpectedPushURL: remote,
Checks: []cli.CheckCommand{{ Checks: []cli.CheckCommand{{
Args: []string{"sh", "scripts/check-release-note.sh"}, Args: []string{"sh", "scripts/check-release-note.sh"},
Env: map[string]string{"RELEASEKIT_COMPATIBILITY_LOG": checkLog}, 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, "add", "releasekit.json")
cliGitRun(t, repository, "commit", "-m", "configure source release") cliGitRun(t, repository, "commit", "-m", "configure source release")
remote := filepath.Join(t.TempDir(), "origin.git")
cliGitRun(t, repository, "init", "--bare", remote) cliGitRun(t, repository, "init", "--bare", remote)
cliGitRun(t, repository, "remote", "add", "origin", remote) cliGitRun(t, repository, "remote", "add", "origin", remote)
cliGitRun(t, repository, "push", "--set-upstream", "origin", "main") cliGitRun(t, repository, "push", "--set-upstream", "origin", "main")

View File

@@ -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 as `package-compile` with its selected target; this does not create build
artifacts or execute target binaries. 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 The command returns status 0 for confirmed publication, 1 for a known
publication failure or conflict, 2 for usage, profile, or invalid-version publication failure or conflict, 2 for usage, profile, or invalid-version
errors, and 3 when the push outcome remains unknown. Unknown status must be errors, and 3 when the push outcome remains unknown. Unknown status must be

View File

@@ -10,6 +10,7 @@ The current profile fields are:
| --- | --- | --- | | --- | --- | --- |
| `product` | Yes | Product name used by release operations. | | `product` | Yes | Product name used by release operations. |
| `remote` | No | Selected Git remote; defaults to `origin`. | | `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. | | `checks` | No | Ordered additional child commands, each with an argument vector and optional environment map. |
| `validation` | No | Optional shared checks selected by ReleaseKit. | | `validation` | No | Optional shared checks selected by ReleaseKit. |
| `build` | No | Optional main package, binary name, and selected `GOOS/GOARCH` target strings. | | `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 `RELEASEKIT_GITEA_TOKEN`. The CLI reads this variable only for `deliver` and
passes its value explicitly to the library operation. 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 The `check` and `publish` commands run one
`go test -mod=readonly -count=1 ./...` suite with Go module and toolchain network `go test -mod=readonly -count=1 ./...` suite with Go module and toolchain network
access disabled, adding `-race` when `validation.go.race` is true. When access disabled, adding `-race` when `validation.go.race` is true. When

View File

@@ -5,9 +5,10 @@ annotated source tag:
```go ```go
result, err := releasekit.Publish(ctx, releasekit.ReleaseRequest{ result, err := releasekit.Publish(ctx, releasekit.ReleaseRequest{
RepositoryDir: "/work/project", RepositoryDir: "/work/project",
Product: "Example", Product: "Example",
Version: "v1.2.3", Version: "v1.2.3",
ExpectedPushURL: "ssh://git.example/owner/example.git",
Validation: releasekit.ValidationSpec{ Validation: releasekit.ValidationSpec{
Go: releasekit.GoValidationSpec{Race: true, FullVet: true}, 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 Publication captures a clean candidate on `main`, validates its committed
release note, and checks the selected remote before running candidate checks. 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 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 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 `-race`. Set `Validation.Go.FullVet` to run `go vet -mod=readonly ./...` after

View File

@@ -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` | | 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` | | 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` | | 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` | | 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 managed or prebuilt binary delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlySkipsBuildAndHostedAssets`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites` | | Page-only and managed or prebuilt binary delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlySkipsBuildAndHostedAssets`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites` |

View File

@@ -295,6 +295,11 @@ Reuse the existing Go environment tests for controls already protected there.
## Stage 4: Compare the optional expected push URL ## 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. **Outcome:** Publication rejects a different effective destination before validation.
1. Add `ExpectedPushURL` and `expected_push_url`, with an empty value selecting 1. Add `ExpectedPushURL` and `expected_push_url`, with an empty value selecting

View File

@@ -36,11 +36,16 @@ const (
const recoveryTimeout = 30 * time.Second 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 { type Request struct {
RepositoryDir string RepositoryDir string
Product string Product string
Version string Version string
Remote string Remote string
ExpectedPushURL string
Validation validation.Settings Validation validation.Settings
AdditionalChecks func(context.Context, candidate.Candidate) error AdditionalChecks func(context.Context, candidate.Candidate) error
AfterValidate func(context.Context, candidate.Candidate) error AfterValidate func(context.Context, candidate.Candidate) error
@@ -56,8 +61,9 @@ type Result struct {
State State State State
} }
// Publish validates one captured candidate and pushes only its annotated tag // Publish captures a candidate, resolves and checks the optional expected push
// object. It runs one standard Go suite, with the selected settings, then // 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. // AdditionalChecks when supplied.
// A failed push receives one bounded authoritative tag query. Exact // A failed push receives one bounded authoritative tag query. Exact
// object equality confirms publication; absence and conflict are classified, // 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) return result, fmt.Errorf("resolve publication endpoint: %w", err)
} }
result.Endpoint = endpoint.PushURL 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 { if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil {
return result, fmt.Errorf("pre-validation endpoint guard: %w", err) return result, fmt.Errorf("pre-validation endpoint guard: %w", err)
} }

View File

@@ -48,21 +48,24 @@ type PublicationResult struct {
State PublicationState // publication outcome for this invocation State PublicationState // publication outcome for this invocation
} }
// Publish always runs one offline Go test suite for the captured candidate. // After publication preflight succeeds, Publish runs one offline Go test suite
// Validation.Go.Race enables the race detector for that suite, and // 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.FullVet runs go vet afterward instead of test-time vet, and
// Validation.Go.CompileTargets compiles all packages for each selected target // Validation.Go.CompileTargets compiles all packages for each selected target
// with cgo disabled after tests and vet. Compilation produces no artifacts and // with cgo disabled after tests and vet. Compilation produces no artifacts and
// does not execute target binaries. It then calls AdditionalChecks when // does not execute target binaries. ExpectedPushURL, when nonempty, is compared
// supplied. It optionally builds selected // exactly with the resolved effective push URL before validation; a mismatch
// targets in temporary storage outside the worktree and pushes the exact // returns ErrUnexpectedPushURL. It then calls AdditionalChecks when supplied.
// annotated tag object to the selected remote. It returns a result with errors: preflight // It optionally builds selected targets in temporary storage outside the
// failures are not_attempted, failed authoritative guards are conflict, and // worktree and pushes the exact annotated tag object to the selected remote.
// errors after local tag creation but before push are not_published. A failed // It returns a result with errors: preflight failures are not_attempted, failed
// push gets one bounded authoritative tag query: an exact object match confirms // authoritative guards are conflict, and errors after local tag creation but
// publication, absence is not_published, a different object is conflict, and // before push are not_published. A failed push gets one bounded authoritative
// an unreadable result is unknown. A nil error means publication is confirmed // tag query: an exact object match confirms publication, absence is
// by the successful push or exact remote object match. // 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) { func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, error) {
candidateInput := candidateRequest(request) candidateInput := candidateRequest(request)
result, err := publish.Publish(ctx, publish.Request{ result, err := publish.Publish(ctx, publish.Request{
@@ -70,6 +73,7 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er
Product: candidateInput.Product, Product: candidateInput.Product,
Version: candidateInput.Version, Version: candidateInput.Version,
Remote: request.Remote, Remote: request.Remote,
ExpectedPushURL: request.ExpectedPushURL,
Validation: candidateInput.Validation, Validation: candidateInput.Validation,
AdditionalChecks: candidateInput.AdditionalChecks, AdditionalChecks: candidateInput.AdditionalChecks,
AfterValidate: candidateInput.AfterValidate, AfterValidate: candidateInput.AfterValidate,

View File

@@ -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) { func TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks(t *testing.T) {
requireRacePrerequisites(t) requireRacePrerequisites(t)
repo := newPublishFixture(t) repo := newPublishFixture(t)