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"
"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.

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

View File

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

View File

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

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) {
requireRacePrerequisites(t)
repo := newPublishFixture(t)