diff --git a/cmd/wfctl/main.go b/cmd/wfctl/main.go index 87308ea8..7e910e5d 100644 --- a/cmd/wfctl/main.go +++ b/cmd/wfctl/main.go @@ -32,11 +32,22 @@ var version = buildVersion() func buildVersion() string { if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" { - return info.Main.Version + return cleanBuildVersion(info.Main.Version) } return "dev" } +// cleanBuildVersion strips the +dirty suffix that the Go toolchain appends when +// the working tree has uncommitted changes. The marker reflects the build-time +// VCS state of the wfctl binary itself, not a meaningful version difference. +// Pseudo-version strings with +dirty (e.g. v0.22.8-20260510180701-a851625d3bf0+dirty) +// are also not valid Go module pseudo-versions, so downstream callers like +// CanonicalEvidenceEngineVersion would silently fall back to "v0.0.0". Stripping +// the suffix here lets the real commit-bound pseudo-version propagate. +func cleanBuildVersion(raw string) string { + return strings.TrimSuffix(raw, "+dirty") +} + // isHelpRequested reports whether the error originated from the user // requesting help (--help / -h). flag.ErrHelp propagates through the // pipeline engine as a step failure; catching it here lets us exit 0 diff --git a/cmd/wfctl/main_test.go b/cmd/wfctl/main_test.go index 1e485a8f..43c92e12 100644 --- a/cmd/wfctl/main_test.go +++ b/cmd/wfctl/main_test.go @@ -47,6 +47,44 @@ func TestHelpFlagDoesNotLeakEngineError(t *testing.T) { } } +func TestBuildVersionStripsDirtyMarker(t *testing.T) { + // cleanBuildVersion must strip +dirty from both release tags and pseudo-versions. + for _, tc := range []struct { + in string + want string + }{ + { + in: "v0.22.8-0.20260510180701-a851625d3bf0+dirty", + want: "v0.22.8-0.20260510180701-a851625d3bf0", + }, + { + in: "v0.51.2+dirty", + want: "v0.51.2", + }, + { + in: "v0.51.2", + want: "v0.51.2", + }, + { + in: "v0.22.8-0.20260510180701-a851625d3bf0", + want: "v0.22.8-0.20260510180701-a851625d3bf0", + }, + } { + t.Run(tc.in, func(t *testing.T) { + got := cleanBuildVersion(tc.in) + if got != tc.want { + t.Fatalf("cleanBuildVersion(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } + + // buildVersion() itself must never return a value ending in +dirty. + v := buildVersion() + if strings.HasSuffix(v, "+dirty") { + t.Fatalf("buildVersion() = %q, must not end in +dirty", v) + } +} + func writeTestConfig(t *testing.T, dir, name, content string) string { t.Helper() path := filepath.Join(dir, name) diff --git a/cmd/wfctl/plugin_compat_model.go b/cmd/wfctl/plugin_compat_model.go index 831915b6..c753c751 100644 --- a/cmd/wfctl/plugin_compat_model.go +++ b/cmd/wfctl/plugin_compat_model.go @@ -112,6 +112,7 @@ type PluginCompatibilityEvidence struct { GeneratedBy string `json:"generatedBy,omitempty"` StdoutTail string `json:"stdoutTail,omitempty"` StderrTail string `json:"stderrTail,omitempty"` + FailureReason string `json:"failureReason,omitempty"` } func NormalizePluginVersionIndex(index *PluginVersionIndex, defaultPlugin string) (*PluginVersionIndex, error) { diff --git a/cmd/wfctl/plugin_conformance.go b/cmd/wfctl/plugin_conformance.go index 7cdfe6cf..46c78e56 100644 --- a/cmd/wfctl/plugin_conformance.go +++ b/cmd/wfctl/plugin_conformance.go @@ -272,6 +272,7 @@ func runPluginConformanceCheck(opts pluginConformanceOptions) (PluginCompatibili } if err != nil { ev.Status = PluginCompatibilityStatusFail + ev.FailureReason = err.Error() if normalized, normErr := ValidateCompatibilityEvidence(ev); normErr == nil { ev = normalized } diff --git a/cmd/wfctl/plugin_conformance_test.go b/cmd/wfctl/plugin_conformance_test.go index 72e4fecc..9d43dc43 100644 --- a/cmd/wfctl/plugin_conformance_test.go +++ b/cmd/wfctl/plugin_conformance_test.go @@ -89,6 +89,14 @@ func TestPluginConformanceLocalJSONPass(t *testing.T) { if !strings.Contains(ev.StderrTail, "iac-pass stderr marker") { t.Fatalf("stderr tail missing plugin output: %#v", ev) } + // Passing evidence must not include a failureReason. + if ev.FailureReason != "" { + t.Fatalf("passing evidence should not have failureReason, got %q", ev.FailureReason) + } + // WfctlVersion must never end in +dirty. + if strings.HasSuffix(ev.WfctlVersion, "+dirty") { + t.Fatalf("wfctlVersion %q must not contain +dirty marker", ev.WfctlVersion) + } } func TestPluginConformanceBuildsRequestedPackage(t *testing.T) { @@ -301,6 +309,14 @@ func TestPluginConformanceNoTypedIaCServiceFails(t *testing.T) { if ev.EvidenceDigest == "" { t.Fatalf("failure evidence missing digest: %#v", ev) } + // Failure evidence must include a human-readable reason so maintainers + // can diagnose the failure without local reproduction. + if ev.FailureReason == "" { + t.Fatalf("failure evidence missing failureReason: %#v", ev) + } + if !strings.Contains(ev.FailureReason, "typed") && !strings.Contains(ev.FailureReason, "IaC") && !strings.Contains(ev.FailureReason, "legacy") { + t.Fatalf("failureReason = %q, want typed-IaC context", ev.FailureReason) + } } func TestPluginConformanceTextFormat(t *testing.T) {