From 1ebd45c97595d1073673a773cb80ca2192437214 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 7 Oct 2026 18:36:37 +0000 Subject: [PATCH] Present validation errors consistently in the CLI --- cmd/releasekit/main.go | 51 ++++++++++++++++++++------------- cmd/releasekit/main_test.go | 52 ++++++++++++++++++++++++++++++++++ docs/cli.md | 7 +++-- docs/internal/overview.md | 2 +- docs/roadmap/implementation.md | 6 +++- 5 files changed, 95 insertions(+), 23 deletions(-) diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index eed6aaf..41f4e84 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -40,28 +40,12 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "load profile: %v\n", err) return 2 } - request := releasekit.ReleaseRequest{ - 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) - } - } + request := candidateRequest(invocation, settings, stderr) if invocation.Command == cli.PublishAction { fmt.Fprintf(stderr, "validating and publishing %s\n", invocation.Version) result, err := releasekit.Publish(ctx, request) 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) { return 2 } @@ -75,7 +59,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { } candidate, err := releasekit.Check(ctx, request) 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) { return 2 } @@ -149,6 +133,35 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { 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 { settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath) if err != nil { diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index d25af38..9dd3455 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -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) { 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") diff --git a/docs/cli.md b/docs/cli.md index f652942..650cf75 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -30,8 +30,9 @@ publish a tag. `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, -profile, or invalid-version errors. Unsupported argument sequences print usage -to standard error. +profile, or invalid-version errors. Shared validation failures identify the +failed check and retain useful findings and cause context in one diagnostic. +Unsupported argument sequences print usage to standard error. 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 cancels the active operation; if it arrives during a push, the library's 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 publication failure or conflict, 2 for usage, profile, or invalid-version diff --git a/docs/internal/overview.md b/docs/internal/overview.md index c817472..ee0e1bf 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -76,7 +76,7 @@ not a separate release matrix: | Capability | Implementation owner | Existing evidence | | --- | --- | --- | | 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` | | 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` | diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 965aafd..98d9e38 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # 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 [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 +**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 validation failures consistently while retaining current exit classification.