Present validation errors consistently in the CLI

This commit is contained in:
2026-10-07 18:36:37 +00:00
parent 8bac90a9a8
commit 1ebd45c975
5 changed files with 95 additions and 23 deletions

View File

@@ -40,28 +40,12 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "load profile: %v\n", err) fmt.Fprintf(stderr, "load profile: %v\n", err)
return 2 return 2
} }
request := releasekit.ReleaseRequest{ request := candidateRequest(invocation, settings, stderr)
RepositoryDir: invocation.Repository,
Product: settings.Product,
Version: invocation.Version,
Remote: settings.Remote,
Build: configuredBuildSpec(settings.Build),
Output: stderr,
}
if len(settings.Checks) != 0 {
checks := settings.Checks
request.AdditionalChecks = func(ctx context.Context, candidate releasekit.Candidate) error {
return cli.RunChecks(ctx, candidate.RepositoryDir, checks, map[string]string{
"RELEASEKIT_VERSION": candidate.Version,
"RELEASEKIT_CANDIDATE": candidate.Commit,
}, stderr, stderr)
}
}
if invocation.Command == cli.PublishAction { if invocation.Command == cli.PublishAction {
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 {
fmt.Fprintf(stderr, "publication state=%s phase=%s version=%s commit=%s tag-object=%s endpoint=%q: %v\n", result.State, result.Phase, result.Version, result.CandidateCommit, result.TagObject, result.Endpoint, err) fmt.Fprintf(stderr, "publication state=%s phase=%s version=%s commit=%s tag-object=%s endpoint=%q: %s\n", result.State, result.Phase, result.Version, result.CandidateCommit, result.TagObject, result.Endpoint, candidateFailureText(err))
if errors.Is(err, releasekit.ErrInvalidVersion) { if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2 return 2
} }
@@ -75,7 +59,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
} }
candidate, err := releasekit.Check(ctx, request) candidate, err := releasekit.Check(ctx, request)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "check failed: %v\n", err) fmt.Fprintf(stderr, "check failed: %s\n", candidateFailureText(err))
if errors.Is(err, releasekit.ErrInvalidVersion) { if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2 return 2
} }
@@ -149,6 +133,35 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return 2 return 2
} }
func candidateRequest(invocation cli.Invocation, settings cli.Settings, output io.Writer) releasekit.ReleaseRequest {
request := releasekit.ReleaseRequest{
RepositoryDir: invocation.Repository,
Product: settings.Product,
Version: invocation.Version,
Remote: settings.Remote,
Build: configuredBuildSpec(settings.Build),
Output: output,
}
if len(settings.Checks) != 0 {
checks := settings.Checks
request.AdditionalChecks = func(ctx context.Context, candidate releasekit.Candidate) error {
return cli.RunChecks(ctx, candidate.RepositoryDir, checks, map[string]string{
"RELEASEKIT_VERSION": candidate.Version,
"RELEASEKIT_CANDIDATE": candidate.Commit,
}, output, output)
}
}
return request
}
func candidateFailureText(err error) string {
var validationError *releasekit.ValidationError
if errors.As(err, &validationError) {
return validationError.Error()
}
return err.Error()
}
func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int { func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath) settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
if err != nil { if err != nil {

View File

@@ -97,6 +97,58 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
} }
} }
func TestCandidateCommandsPresentValidationDiagnosticsOnce(t *testing.T) {
repository := newCLIRepository(t)
profile, err := json.Marshal(cli.Settings{Product: "Example Product"})
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repository, "releasekit.json", string(profile))
cliGitRun(t, repository, "add", "releasekit.json")
cliGitRun(t, repository, "commit", "-m", "configure candidate commands")
remote := filepath.Join(t.TempDir(), "origin.git")
cliGitRun(t, repository, "init", "--bare", remote)
cliGitRun(t, repository, "remote", "add", "origin", remote)
cliGitRun(t, repository, "push", "origin", "HEAD:refs/heads/main")
goPath, err := exec.LookPath("go")
if err != nil {
t.Fatal(err)
}
goDirectory := t.TempDir()
shim := filepath.Join(goDirectory, "go")
if err := os.WriteFile(shim, []byte("#!/bin/sh\nif [ \"$1\" = test ]; then echo 'fixture Go suite failure' >&2; exit 1; fi\nexec \"$RELEASEKIT_REAL_GO\" \"$@\"\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("RELEASEKIT_REAL_GO", goPath)
t.Setenv("PATH", goDirectory+string(os.PathListSeparator)+os.Getenv("PATH"))
for _, command := range []struct {
name string
wantStatus int
args []string
}{
{name: "check", wantStatus: 1, args: []string{"check", "--repo", repository, "v1.2.3"}},
{name: "publish", wantStatus: 1, args: []string{"publish", "--repo", repository, "v1.2.3"}},
} {
t.Run(command.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
if status := run(context.Background(), command.args, &stdout, &stderr); status != command.wantStatus {
t.Fatalf("run() status = %d; stdout=%q stderr=%q", status, stdout.String(), stderr.String())
}
if got := strings.Count(stderr.String(), "go-test validation failed"); got != 1 {
t.Fatalf("validation diagnostic appeared %d times: %q", got, stderr.String())
}
if !strings.Contains(stderr.String(), "fixture Go suite failure") {
t.Fatalf("stderr omitted the validation cause: %q", stderr.String())
}
if command.name == "publish" && !strings.Contains(stderr.String(), "state=not_attempted phase=validation") {
t.Fatalf("publication validation error lost its state: %q", stderr.String())
}
})
}
}
func TestPublishCommandPublishesConfiguredCandidate(t *testing.T) { func TestPublishCommandPublishesConfiguredCandidate(t *testing.T) {
repository := newCLIRepository(t) repository := newCLIRepository(t)
writeRepositoryFile(t, repository, "docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\n## Compatibility\n\nNo action required.\n") writeRepositoryFile(t, repository, "docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\n## Compatibility\n\nNo action required.\n")

View File

@@ -30,8 +30,9 @@ publish a tag.
`check` writes errors and diagnostics to standard error. It returns status 0 `check` writes errors and diagnostics to standard error. It returns status 0
after local validation, 1 after a candidate validation failure, and 2 for usage, after local validation, 1 after a candidate validation failure, and 2 for usage,
profile, or invalid-version errors. Unsupported argument sequences print usage profile, or invalid-version errors. Shared validation failures identify the
to standard error. failed check and retain useful findings and cause context in one diagnostic.
Unsupported argument sequences print usage to standard error.
Publish a source tag: Publish a source tag:
@@ -49,6 +50,8 @@ progress and failed publication state, including the captured publication
endpoint for recovery, are written to standard error. A signal endpoint for recovery, are written to standard error. A signal
cancels the active operation; if it arrives during a push, the library's cancels the active operation; if it arrives during a push, the library's
bounded reconciliation determines whether the exact tag was accepted. bounded reconciliation determines whether the exact tag was accepted.
Shared validation failures use the same check and finding presentation as
`check`, alongside publication state and phase.
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

View File

@@ -76,7 +76,7 @@ not a separate release matrix:
| Capability | Implementation owner | Existing evidence | | Capability | Implementation owner | Existing evidence |
| --- | --- | --- | | --- | --- | --- |
| 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` | `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation`, `TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate` | | Candidate capture, shared validation, public diagnostics, additional checks, and rechecks | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation`, `TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate`, `TestCandidateCommandsPresentValidationDiagnosticsOnce` |
| 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`, `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` |

View File

@@ -1,6 +1,6 @@
# Validation model implementation plan # Validation model implementation plan
Status: In progress; shared execution, committed-file inspection, destination resolution, and facade diagnostics are complete. Status: In progress; shared execution, candidate inspection, facade diagnostics, and CLI mapping are complete.
This plan implements the enabling work in [model.md](model.md), guided by This plan implements the enabling work in [model.md](model.md), guided by
[ADR 0003](../adr/0003-declarative-validation.md). It is written for a Luna [ADR 0003](../adr/0003-declarative-validation.md). It is written for a Luna
@@ -311,6 +311,10 @@ framework. Run `go run ./internal/apicheck`.
## Stage 6: Establish equivalent profile mapping and CLI presentation ## Stage 6: Establish equivalent profile mapping and CLI presentation
**Completion:** Complete. Check and publication share profile conversion, and
the CLI presents typed validation errors through one diagnostic path while
preserving existing status, publication-state, and phase reporting.
**Outcome:** Profile conversion has one owner and the CLI reports structured **Outcome:** Profile conversion has one owner and the CLI reports structured
validation failures consistently while retaining current exit classification. validation failures consistently while retaining current exit classification.