Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3e49bff
docs(plan): design for plugin version as ldflag (workflow#758)
intel352 May 23, 2026
081f21e
docs(plan): pivot design per adversarial cycle 1 (workflow#758)
intel352 May 23, 2026
f0ce7ca
docs(plan): restore ldflag contract + close NI1-4 per adversarial cyc…
intel352 May 23, 2026
2e81c5a
docs(plan): implementation plan for workflow#758 (26 PRs, 29 tasks; d…
intel352 May 23, 2026
fea833e
docs(plan): expand collapsed task headings so scope-check passes (wor…
intel352 May 23, 2026
6d25311
docs(plan): record cycle-1 adversarial findings + pause for user auth…
intel352 May 23, 2026
ddb69e9
docs(plan): cycle-4 simplified design — delete sync + wfctl validate-…
intel352 May 23, 2026
00a637e
docs(plan): cycle 4-A1 revisions — drop prerelease scope; sdk.BuildVe…
intel352 May 23, 2026
f4e012f
docs(plan): cycle 4-A2 revisions — restore ldflag-arg + sweep §6 + ma…
intel352 May 23, 2026
fa293ff
docs(plan): cycle-4 plan — Layer 1 + Layer 2 + Layer 3 pilot 5 repos …
intel352 May 23, 2026
2a0c6be
docs(plan): apply plan-cycle-4-P1 Important fixes inline (workflow#758)
intel352 May 23, 2026
4f9227e
docs(plan): alignment-check nits — clarify PR-count + tag-source fall…
intel352 May 23, 2026
33faa74
chore: lock scope for plugin-version-discipline (alignment passed)
intel352 May 23, 2026
e359763
feat(sdk): ResolveBuildVersion + IaCServeOptions.BuildVersion + WithB…
intel352 May 23, 2026
21de74b
feat(wfctl): plugin validate-contract subcommand + PLUGIN_RELEASE_GAT…
intel352 May 23, 2026
73540bb
fix(wfctl): propagate filepath.Walk error in scanMainGoFilesForContra…
intel352 May 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/wfctl/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ func runPlugin(args []string) error {
return runPluginRemove(args[1:])
case "validate":
return runPluginValidate(args[1:])
case "validate-contract":
return runPluginValidateContract(args[1:])
case "conformance":
return runPluginConformance(args[1:])
case "info":
Expand Down Expand Up @@ -63,6 +65,7 @@ Subcommands:
update Update an installed plugin to its latest version
remove Uninstall a plugin (also removes from manifest + lockfile)
validate Validate a plugin manifest from the registry or a local file
validate-contract Validate a plugin source directory against the release contract (workflow#758)
conformance Run executable plugin/host conformance checks
info Show details about an installed plugin
deps List dependencies for a plugin
Expand Down
237 changes: 237 additions & 0 deletions cmd/wfctl/plugin_validate_contract.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
package main

import (
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"

"github.com/GoCodeAlone/workflow/plugin"
)

// runPluginValidateContract implements `wfctl plugin validate-contract`
// (workflow#758). Verifies that a plugin source directory satisfies the
// release contract: parseable plugin.json + populated capabilities +
// minEngineVersion + sdk.ResolveBuildVersion call site + goreleaser ldflag.
//
// With --for-publish, additionally enforces the strict-semver tag whitelist
// (^v\d+\.\d+\.\d+$). With --release-dir, asserts the shipped plugin.json's
// .version equals --tag (post-goreleaser-build verification).
func runPluginValidateContract(args []string) error {
fs := flag.NewFlagSet("plugin validate-contract", flag.ContinueOnError)
forPublish := fs.Bool("for-publish", false, "Apply publish-grade checks (strict-semver tag, etc.)")
tag := fs.String("tag", "", "Release tag (e.g. v1.2.3); falls back to $GITHUB_REF_NAME then `git describe --tags --exact-match HEAD`")
releaseDir := fs.String("release-dir", "", "Post-build verification: assert this dir's plugin.json carries --tag")
fs.Usage = func() {
fmt.Fprintf(fs.Output(), `Usage: wfctl plugin validate-contract [options] <plugin-dir>

Validate a plugin source directory against the workflow release contract.

Checks (always):
1. plugin.json exists, parses, passes PluginManifest.Validate()
2. capabilities populated (non-empty)
3. minEngineVersion populated
4. main.go calls sdk.ResolveBuildVersion(...) and wires it via
IaCServeOptions.BuildVersion or sdk.WithBuildVersion(...)
5. .goreleaser.{yaml,yml} carries -X *.Version= ldflag injection

Additional with --for-publish:
6. Resolved tag matches ^v\d+\.\d+\.\d+$
7. With --release-dir: <dir>/plugin.json .version equals tag (minus leading v)

Examples:
wfctl plugin validate-contract .
wfctl plugin validate-contract --for-publish --tag v1.2.3 .
wfctl plugin validate-contract --for-publish --tag v1.2.3 --release-dir .release .

See docs/PLUGIN_RELEASE_GATES.md for the full contract spec.

Options:
`)
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 1 {
fs.Usage()
return fmt.Errorf("exactly one <plugin-dir> argument required")
}
pluginDir := fs.Arg(0)
abs, err := filepath.Abs(pluginDir)
if err != nil {
return fmt.Errorf("resolve %q: %w", pluginDir, err)
}

var failures []string
addFail := func(msg string) { failures = append(failures, msg) }

// Check 1: plugin.json parses + Validate() OK
manifestPath := filepath.Join(abs, "plugin.json")
manifestBytes, err := os.ReadFile(manifestPath) // #nosec G304 -- operator-supplied path
if err != nil {
addFail(fmt.Sprintf("plugin.json: %v", err))
}
var manifest plugin.PluginManifest
if err == nil {
if jerr := json.Unmarshal(manifestBytes, &manifest); jerr != nil {
addFail(fmt.Sprintf("plugin.json: parse: %v", jerr))
} else if verr := manifest.Validate(); verr != nil {
addFail(fmt.Sprintf("plugin.json: validate: %v", verr))
} else if manifest.Version == "0.0.0" {
fmt.Fprintln(os.Stderr, " INFO plugin.json.version is dev sentinel \"0.0.0\" — release builds inject the tag via goreleaser ldflag")
}
}

// Check 2 + 3: capabilities + minEngineVersion populated
if err == nil {
var raw map[string]any
if jerr := json.Unmarshal(manifestBytes, &raw); jerr == nil {
caps, ok := raw["capabilities"].(map[string]any)
if !ok || len(caps) == 0 {
addFail("plugin.json.capabilities: missing or empty")
}
mev, _ := raw["minEngineVersion"].(string)
if strings.TrimSpace(mev) == "" {
addFail("plugin.json.minEngineVersion: missing or empty")
}
}
}

// Check 4: any cmd/**/main.go contains ResolveBuildVersion AND BuildVersion wiring
mainFound, mainHasContract := scanMainGoFilesForContract(abs)
if !mainFound {
addFail("no cmd/**/main.go (or .go file under repo root) found to scan for contract")
} else if !mainHasContract {
addFail("no main.go contains both sdk.ResolveBuildVersion(...) AND (IaCServeOptions.BuildVersion: ... OR sdk.WithBuildVersion(...))")
}

// Check 5: goreleaser config carries -X *.Version= ldflag
if !goreleaserHasVersionLdflag(abs) {
addFail(".goreleaser.{yaml,yml}: missing `-X *.Version=` ldflag (mandatory mechanism to deliver release tag into binary)")
}

// --for-publish: check 6 (tag format) + check 7 (release-dir match)
if *forPublish {
resolved := resolveTag(*tag)
if resolved == "" {
addFail("--for-publish: no tag supplied via --tag, $GITHUB_REF_NAME, or `git describe --tags --exact-match HEAD`")
} else if !publishGradeSemverRe.MatchString(resolved) {
addFail(fmt.Sprintf("--for-publish: tag %q is not release-grade semver (allowed: vN.N.N)", resolved))
}
if *releaseDir != "" && resolved != "" {
rdManifest := filepath.Join(*releaseDir, "plugin.json")
rdBytes, rerr := os.ReadFile(rdManifest) // #nosec G304 -- operator-supplied path
if rerr != nil {
addFail(fmt.Sprintf("--release-dir %q: %v", *releaseDir, rerr))
} else {
var rdRaw map[string]any
_ = json.Unmarshal(rdBytes, &rdRaw)
rdVer, _ := rdRaw["version"].(string)
want := strings.TrimPrefix(resolved, "v")
if rdVer != want {
addFail(fmt.Sprintf("--release-dir %q: plugin.json.version=%q does not match --tag %q (want %q)", *releaseDir, rdVer, resolved, want))
}
}
}
}

if len(failures) > 0 {
fmt.Fprintln(os.Stderr, "wfctl plugin validate-contract: FAIL")
for _, f := range failures {
fmt.Fprintf(os.Stderr, " - %s\n", f)
}
fmt.Fprintln(os.Stderr, "See docs/PLUGIN_RELEASE_GATES.md for contract details.")
return fmt.Errorf("%d contract check(s) failed", len(failures))
}
fmt.Println("wfctl plugin validate-contract: PASS")
return nil
}

var (
publishGradeSemverRe = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`)
resolveBuildVersionRe = regexp.MustCompile(`sdk\.ResolveBuildVersion\s*\(`)
buildVersionFieldRe = regexp.MustCompile(`BuildVersion\s*:`)
withBuildVersionRe = regexp.MustCompile(`sdk\.WithBuildVersion\s*\(`)
goreleaserLdflagRe = regexp.MustCompile(`-X\s+\S*\.Version=`)
)

// scanMainGoFilesForContract walks dir/cmd/**/*.go and dir/*.go looking for
// the contract pattern. Returns (anyMainFound, anySatisfiesContract). The
// contract pattern is "file contains sdk.ResolveBuildVersion( AND (BuildVersion:
// OR sdk.WithBuildVersion()" — whole-file scoped (gofmt formats across lines).
func scanMainGoFilesForContract(dir string) (mainFound, satisfies bool) {
candidates := []string{}
// Walk cmd/**/main.go
cmdDir := filepath.Join(dir, "cmd")
if info, err := os.Stat(cmdDir); err == nil && info.IsDir() {
_ = filepath.Walk(cmdDir, func(path string, fi os.FileInfo, werr error) error {
if werr != nil {
return werr
}
if fi.IsDir() {
return nil
}
if filepath.Base(path) == "main.go" {
candidates = append(candidates, path)
}
return nil
})
}
// Also include *.go at repo root (some single-file plugins put main package there)
if entries, err := os.ReadDir(dir); err == nil {
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".go") {
candidates = append(candidates, filepath.Join(dir, e.Name()))
}
}
}
for _, c := range candidates {
mainFound = true
body, err := os.ReadFile(c) // #nosec G304 -- bounded set, operator-supplied root
if err != nil {
continue
}
hasResolve := resolveBuildVersionRe.Match(body)
hasField := buildVersionFieldRe.Match(body)
hasOpt := withBuildVersionRe.Match(body)
if hasResolve && (hasField || hasOpt) {
satisfies = true
return
}
}
return
}

func goreleaserHasVersionLdflag(dir string) bool {
for _, name := range []string{".goreleaser.yaml", ".goreleaser.yml"} {
body, err := os.ReadFile(filepath.Join(dir, name)) // #nosec G304 -- bounded set
if err != nil {
continue
}
if goreleaserLdflagRe.Match(body) {
return true
}
}
return false
}

// resolveTag returns explicit --tag > GITHUB_REF_NAME env > git describe.
func resolveTag(explicit string) string {
if t := strings.TrimSpace(explicit); t != "" {
return t
}
if t := strings.TrimSpace(os.Getenv("GITHUB_REF_NAME")); t != "" {
return t
}
cmd := exec.Command("git", "describe", "--tags", "--exact-match", "HEAD")
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
103 changes: 103 additions & 0 deletions cmd/wfctl/plugin_validate_contract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package main

import (
"strings"
"testing"
)

func TestRunPluginValidateContract_GoodPasses(t *testing.T) {
err := runPluginValidateContract([]string{"testdata/plugin_validate_contract/good"})
if err != nil {
t.Fatalf("expected PASS for good fixture, got %v", err)
}
}

func TestRunPluginValidateContract_BadMissingCapsFails(t *testing.T) {
err := runPluginValidateContract([]string{"testdata/plugin_validate_contract/bad-missing-caps"})
if err == nil {
t.Fatal("expected FAIL for bad-missing-caps fixture, got nil")
}
if !strings.Contains(err.Error(), "contract check") {
t.Errorf("error should mention contract check, got %v", err)
}
}

func TestRunPluginValidateContract_BadMissingLdflagFails(t *testing.T) {
err := runPluginValidateContract([]string{"testdata/plugin_validate_contract/bad-missing-ldflag"})
if err == nil {
t.Fatal("expected FAIL for bad-missing-ldflag fixture, got nil")
}
}

func TestRunPluginValidateContract_ForPublishGoodTag(t *testing.T) {
err := runPluginValidateContract([]string{
"--for-publish", "--tag", "v1.2.3",
"testdata/plugin_validate_contract/good",
})
if err != nil {
t.Fatalf("expected PASS for good fixture + good tag, got %v", err)
}
}

func TestRunPluginValidateContract_ForPublishBadTag(t *testing.T) {
err := runPluginValidateContract([]string{
"--for-publish", "--tag", "v1.2.3-rc.1",
"testdata/plugin_validate_contract/good",
})
if err == nil {
t.Fatal("expected FAIL for prerelease tag, got nil")
}
if !strings.Contains(err.Error(), "contract check") {
t.Errorf("error should mention contract check, got %v", err)
}
}

func TestRunPluginValidateContract_ForPublishBadTagShape(t *testing.T) {
err := runPluginValidateContract([]string{
"--for-publish", "--tag", "release-2026",
"testdata/plugin_validate_contract/good",
})
if err == nil {
t.Fatal("expected FAIL for non-semver tag, got nil")
}
}

func TestRunPluginValidateContract_ReleaseDirGoodMatches(t *testing.T) {
err := runPluginValidateContract([]string{
"--for-publish", "--tag", "v1.2.3",
"--release-dir", "testdata/plugin_validate_contract/release-dir-good/.release",
"testdata/plugin_validate_contract/release-dir-good",
})
if err != nil {
t.Fatalf("expected PASS for release-dir-good, got %v", err)
}
}

func TestRunPluginValidateContract_ReleaseDirStaleFails(t *testing.T) {
err := runPluginValidateContract([]string{
"--for-publish", "--tag", "v1.2.3",
"--release-dir", "testdata/plugin_validate_contract/release-dir-stale/.release",
"testdata/plugin_validate_contract/release-dir-stale",
})
if err == nil {
t.Fatal("expected FAIL for release-dir-stale (.release plugin.json has 1.0.0 not 1.2.3)")
}
}

func TestRunPluginValidateContract_GithubRefNameFallback(t *testing.T) {
t.Setenv("GITHUB_REF_NAME", "v1.2.3")
err := runPluginValidateContract([]string{
"--for-publish",
"testdata/plugin_validate_contract/good",
})
if err != nil {
t.Fatalf("expected PASS via GITHUB_REF_NAME fallback, got %v", err)
}
}

func TestRunPluginValidateContract_MissingArg(t *testing.T) {
err := runPluginValidateContract([]string{})
if err == nil {
t.Fatal("expected error for missing plugin-dir arg")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
version: 2
builds:
- id: test
ldflags:
- -X github.com/example/internal.Version={{.Version}}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Fixture (not compiled).
package main

func main() {
_ = "sdk.ResolveBuildVersion(internal.Version)" // contract token present in string
_ = "BuildVersion:"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "workflow-plugin-test-bad",
"version": "0.0.0",
"author": "test",
"description": "fixture missing capabilities"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
version: 2
builds:
- id: test
ldflags:
- -s -w
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Fixture (not compiled).
package main

func main() {
_ = "sdk.ResolveBuildVersion(x) BuildVersion:"
}
Loading
Loading