From 768d7e8fd7efbbda39cbd6b6d27f6c876665be50 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 10 May 2026 22:07:21 -0400 Subject: [PATCH 01/29] docs: design plugin conformance compatibility --- .../0030-plugin-conformance-evidence-index.md | 18 ++ ...-05-11-plugin-conformance-compat-design.md | 237 ++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 decisions/0030-plugin-conformance-evidence-index.md create mode 100644 docs/plans/2026-05-11-plugin-conformance-compat-design.md diff --git a/decisions/0030-plugin-conformance-evidence-index.md b/decisions/0030-plugin-conformance-evidence-index.md new file mode 100644 index 00000000..c4139a66 --- /dev/null +++ b/decisions/0030-plugin-conformance-evidence-index.md @@ -0,0 +1,18 @@ +# 0030. Use Generated Plugin Compatibility Evidence + +**Status:** Accepted +**Date:** 2026-05-11 +**Decision-makers:** GoCodeAlone maintainers +**Related:** docs/plans/2026-05-11-plugin-conformance-compat-design.md + +## Context + +Workflow plugins currently declare `minEngineVersion`, but that field is only a compatibility claim. Recent DigitalOcean plugin CI showed that executable conformance can catch real host/plugin mismatches, such as a plugin loading on one Workflow release and failing on another. Keeping those checks as plugin-local scripts would duplicate engine-version lookup, private-release auth, output formatting, and install semantics. + +## Decision + +We will centralize plugin compatibility checks in `wfctl plugin conformance` and store generated compatibility evidence in a versioned index. Manifests may carry a short summary and pointer to that index, but pass/fail claims come from CI-generated output. Rejected alternatives: per-plugin shell scripts, because they drift and cannot guide installs; a hosted compatibility service first, because it is heavier than the local/CI contract needed now. + +## Consequences + +This makes plugin CI and local development use one contract, and it gives `wfctl plugin install` data for compatibility-aware resolution. It also adds responsibility to `wfctl`: conformance modes must stay precise, and the registry needs additive metadata without breaking older clients. Rollback is straightforward because compatibility fields are optional and plugin-local scripts can remain during the transition. diff --git a/docs/plans/2026-05-11-plugin-conformance-compat-design.md b/docs/plans/2026-05-11-plugin-conformance-compat-design.md new file mode 100644 index 00000000..a6ef70b8 --- /dev/null +++ b/docs/plans/2026-05-11-plugin-conformance-compat-design.md @@ -0,0 +1,237 @@ +# Plugin Conformance Compatibility Design + +## Goal + +Make Workflow plugin compatibility executable, discoverable, and install-time enforceable. Plugin repositories should call one `wfctl` command for host conformance, publish generated compatibility evidence, and allow `wfctl plugin install` to select or reject versions using that evidence instead of relying on stale `minEngineVersion` claims. + +## Decision + +Use a central `wfctl plugin conformance` command plus a generated compatibility index. + +The first implementation should: + +1. Add `wfctl plugin conformance` to run real host/plugin boundary checks. +2. Add compatibility evidence fields that can be generated by CI and consumed by `wfctl`. +3. Teach install/update/lock resolution to reject incompatible versions by default and allow explicit `--force`. + +See `decisions/0030-plugin-conformance-evidence-index.md`. + +## Context + +Today, plugin compatibility is split across several incomplete surfaces: + +- `wfctl plugin validate` checks registry and local manifest shape, optional strict contract descriptors, and download reachability. +- `wfctl plugin test` is manifest-only; it does not launch an external plugin binary through the real host adapter. +- Plugin manifests declare `minEngineVersion`, but that is a claim, not executable evidence. +- DigitalOcean and AWS now have repo-local conformance workflows, but those scripts duplicate logic that belongs in `wfctl`. + +The recent `workflow-plugin-digitalocean` conformance work proved this is useful: the gate caught incompatibility with Workflow `v0.51.1` and confirmed compatibility with `v0.51.2`. That signal should become a reusable platform capability. + +Best-practice references considered: + +- Go module compatibility is versioned and machine-resolved through semantic versions: https://go.dev/doc/modules/version-numbers +- Terraform providers use executable acceptance/conformance-style tests to verify provider behavior against real hosts/services: https://developer.hashicorp.com/terraform/plugin/testing/acceptance-tests +- GitHub Actions secrets are unavailable in fork-origin PRs and must have fallbacks when CI needs cross-repo reads: https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions + +## Approaches + +### A. Keep per-plugin conformance scripts + +Each plugin repository owns shell scripts and CI matrices. + +Pros: +- Fastest continuation from DigitalOcean and AWS. +- No Workflow release needed before adoption. + +Cons: +- Logic drifts across plugins. +- `wfctl plugin install` cannot consume script results. +- Every plugin reinvents host version resolution, private release auth, output shape, and failure semantics. + +Rejected. + +### B. Centralize conformance in `wfctl` + +Add `wfctl plugin conformance` as the executable contract. Plugin CI calls it against a local plugin build and selected Workflow engine versions. + +Pros: +- One compatibility vocabulary across all plugins. +- CI and local development use the same command. +- Install/update can consume evidence generated by the same tool. +- Supports strict mode by default while preserving explicit legacy modes. + +Cons: +- Requires careful mode boundaries so legacy plugins do not accidentally pass typed checks. +- `wfctl` must learn enough about plugin packaging to test local builds and installed plugins. + +Accepted. + +### C. Build a hosted compatibility service + +A central service watches Workflow releases and plugin releases, runs compatibility jobs, and publishes results. + +Pros: +- Best long-term automation. +- Could provide dashboards and historical trends. + +Cons: +- Too much infrastructure before the local/CI contract is stable. +- Does not replace the need for a local conformance command. + +Deferred. + +## Architecture + +### Command + +Add: + +```sh +wfctl plugin conformance [options] +``` + +Initial options: + +- `--mode host-load|legacy-module|typed-iac` +- `--engine-version ` for report metadata +- `--plugin-dir ` only when checking installed plugins by name in later tasks +- `--format text|json` +- `--timeout ` +- `--strict` default true, with `--strict=false` allowed only for legacy transition checks + +Initial modes: + +- `host-load`: build or find the plugin binary, launch it through `plugin/external.ExternalPluginManager`, perform handshake, fetch adapter metadata, and unload. +- `legacy-module`: verify legacy module plugin registration through the existing module contract path. +- `typed-iac`: verify typed IaC service registration and call required low-risk methods that must not mutate remote infrastructure. + +Mode names are explicit. A legacy plugin passing `legacy-module` must not be recorded as passing `typed-iac`. + +### Evidence + +Add generated compatibility evidence as a separate index, with a short manifest summary. + +Proposed detailed shape: + +```json +{ + "plugin": "workflow-plugin-digitalocean", + "pluginVersion": "v0.14.4", + "generatedAt": "2026-05-11T00:00:00Z", + "results": [ + { + "engineVersion": "v0.51.2", + "wfctlVersion": "v0.51.2", + "mode": "typed-iac", + "status": "pass", + "os": "linux", + "arch": "amd64", + "commit": "abc123" + } + ] +} +``` + +Manifest summary: + +```json +{ + "minEngineVersion": "0.51.2", + "compatibility": { + "index": "compatibility/workflow-plugin-digitalocean/v0.14.4.json", + "latestKnownGoodEngine": "v0.51.2", + "modes": ["typed-iac"] + } +} +``` + +The index is generated by CI. Humans should not hand-edit pass/fail claims. + +### Install Resolution + +Default install behavior: + +1. Resolve candidate plugin versions from registry metadata. +2. Filter out versions with `minEngineVersion` greater than current engine. +3. Prefer candidates with passing conformance evidence for the current engine. +4. If no evidence exists, allow install only when `minEngineVersion` is compatible, but print a clear warning that compatibility is unverified. +5. If evidence says incompatible for the current engine, fail by default. +6. `--force` allows explicit incompatible install and records that choice in the lockfile. + +This keeps `minEngineVersion` as a lower-bound hint while allowing real conformance results to override stale optimism. + +### Registry Layout + +Do not force every registry source to rewrite its directory structure in the first task. + +First pass: +- Extend `RegistryManifest` with optional `Compatibility`. +- Add a registry source method or helper for fetching compatibility evidence when a manifest points to it. +- Allow static registries to serve compatibility JSON beside manifests. + +Later pass: +- Add multi-version indexes so `wfctl plugin install ` can choose from more than the single manifest currently returned by `FetchManifest`. + +## Failure Handling + +- Missing plugin binary: fail with packaging guidance. +- Handshake failure: fail and include plugin stderr tail when available. +- Unsupported conformance mode: fail with available mode suggestions from manifest capabilities. +- Engine version not found: fail unless the caller supplied `--engine-version local`. +- Compatibility index unavailable: warn for installs, fail for explicit `wfctl plugin conformance --publish-evidence` style commands when added later. +- Fork PR without `RELEASES_TOKEN`: CI may fall back to public release lookup; private-release checks should skip or fail with a token-specific message. + +## Security + +- `wfctl plugin conformance` launches untrusted plugin binaries. It must default to local developer/CI usage only and make execution explicit in help text. +- The command must not pass cloud provider secrets to conformance checks unless the user supplies a config file for an explicit live test mode. +- Initial typed-IaC checks must call only non-mutating methods. +- JSON output must not include environment variables, secret values, or full process command lines containing tokens. +- Install-time `--force` should be visible in lockfiles so reviewers can detect intentionally bypassed compatibility checks. + +## Testing + +Unit tests: +- Command parsing and help text. +- Compatibility evidence parsing and status precedence. +- Install resolver behavior for compatible, incompatible, missing-evidence, and forced installs. + +Integration tests: +- Fake external plugin passing `host-load`. +- Fake legacy module plugin passing `legacy-module`. +- Fake typed-IaC plugin passing `typed-iac`. +- Fake plugin that lacks `GetManifest` still passes when strict typed service evidence is sufficient. + +Runtime validation: +- Build local `wfctl`. +- Run `wfctl plugin conformance --mode host-load` against a fixture plugin. +- Run `wfctl plugin conformance --mode typed-iac` against the DigitalOcean plugin in CI after the plugin adopts the command. + +## Rollback + +Runtime-affecting rollback path: + +1. Revert the `wfctl plugin conformance` command and resolver commits. +2. Leave plugin-local conformance scripts in place until the central command is proven. +3. If install resolution blocks legitimate users, temporarily default evidence failures to warnings behind a feature flag while keeping `minEngineVersion` enforcement. +4. Registry compatibility fields are additive and can remain ignored by older `wfctl` versions. + +## Assumptions + +- Workflow engine releases remain tagged and fetchable from GitHub. +- Plugin CI has access to `RELEASES_TOKEN` for private Workflow release metadata and assets when required. +- Compatibility evidence can initially be generated per released engine version rather than per engine commit. +- Plugin manifests can grow additive optional fields without breaking older `wfctl` versions. +- The first compatibility consumers are Go external plugins; UI-only plugin compatibility can be modeled later. + +## Self-Challenge + +1. The laziest plausible solution is to keep one shared shell script copied into each plugin. That would solve CI drift for a few repos but would not let `wfctl plugin install` reason about compatibility, so it fails the install-resolution goal. +2. The most fragile assumption is that the current registry can expose enough version history. Today `FetchManifest` returns one manifest. The design scopes multi-version resolution as a later pass and starts with evidence for the currently resolved manifest. +3. The design risks adding too much generality through many modes. The first implementation should keep only three modes: `host-load`, `legacy-module`, and `typed-iac`. + +## Open Questions + +- Whether compatibility evidence belongs in `workflow-registry` long-term or in per-plugin release assets with registry pointers. +- Whether install resolution should fail on missing evidence after an adoption window. +- Whether live provider acceptance checks should be part of `wfctl plugin conformance` or a separate `wfctl plugin acceptance` command. From 23325c3b1bb7de5c278cae42a1123f25ac285022 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 10 May 2026 22:12:34 -0400 Subject: [PATCH 02/29] docs: tighten plugin compatibility design --- .../0030-plugin-conformance-evidence-index.md | 6 +- ...ance-compat-design.adversarial-review-1.md | 33 +++ ...-05-11-plugin-conformance-compat-design.md | 197 ++++++++++++------ 3 files changed, 171 insertions(+), 65 deletions(-) create mode 100644 docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-1.md diff --git a/decisions/0030-plugin-conformance-evidence-index.md b/decisions/0030-plugin-conformance-evidence-index.md index c4139a66..ce2e657c 100644 --- a/decisions/0030-plugin-conformance-evidence-index.md +++ b/decisions/0030-plugin-conformance-evidence-index.md @@ -7,12 +7,12 @@ ## Context -Workflow plugins currently declare `minEngineVersion`, but that field is only a compatibility claim. Recent DigitalOcean plugin CI showed that executable conformance can catch real host/plugin mismatches, such as a plugin loading on one Workflow release and failing on another. Keeping those checks as plugin-local scripts would duplicate engine-version lookup, private-release auth, output formatting, and install semantics. +Workflow plugins currently declare `minEngineVersion`, but that field is only a compatibility claim. Recent DigitalOcean plugin CI showed that executable conformance can catch real host/plugin mismatches, such as a plugin loading on one Workflow release and failing on another. Keeping those checks as plugin-local scripts would duplicate engine-version lookup, private-release auth, output formatting, and install semantics. Install-time compatible-version sorting also requires registry version indexes rather than the current single-manifest lookup. ## Decision -We will centralize plugin compatibility checks in `wfctl plugin conformance` and store generated compatibility evidence in a versioned index. Manifests may carry a short summary and pointer to that index, but pass/fail claims come from CI-generated output. Rejected alternatives: per-plugin shell scripts, because they drift and cannot guide installs; a hosted compatibility service first, because it is heavier than the local/CI contract needed now. +We will centralize plugin compatibility checks in `wfctl plugin conformance` and store generated, artifact-digest-bound compatibility evidence in a registry-native version index. Manifests may carry a short summary and pointer to that index, but pass/fail claims come from CI-generated output with provenance. Rejected alternatives: per-plugin shell scripts, because they drift and cannot guide installs; a hosted compatibility service first, because it is heavier than the local/CI contract needed now; using unsigned evidence for enforcement, because compatibility data affects supply-chain decisions. ## Consequences -This makes plugin CI and local development use one contract, and it gives `wfctl plugin install` data for compatibility-aware resolution. It also adds responsibility to `wfctl`: conformance modes must stay precise, and the registry needs additive metadata without breaking older clients. Rollback is straightforward because compatibility fields are optional and plugin-local scripts can remain during the transition. +This makes plugin CI and local development use one contract, and it gives `wfctl plugin install` data for compatibility-aware resolution. It also adds responsibility to `wfctl`: conformance modes must stay precise, evidence must match artifact digests and platform, and the registry source API must support version indexes without breaking older clients. Rollback is straightforward because compatibility fields are optional, install enforcement can temporarily switch to warn mode, and plugin-local scripts can remain during the transition. diff --git a/docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-1.md b/docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-1.md new file mode 100644 index 00000000..5a8ff7c2 --- /dev/null +++ b/docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-1.md @@ -0,0 +1,33 @@ +### Adversarial Review Report + +**Phase:** design +**Artifact:** docs/plans/2026-05-11-plugin-conformance-compat-design.md +**Status:** FAIL + +**Findings (Critical):** +- None. + +**Findings (Important):** +- [user-intent drift / repo-precedent conflict] The design promised install-time compatible-version sorting while deferring the version index required to do it. Current `RegistrySource.FetchManifest`, `MultiRegistry.FetchManifest`, and `runPluginInstall` resolve one manifest and rewrite URLs for requested versions. +- [security / missing failure modes] Compatibility evidence would affect install decisions but had no artifact digest binding, provenance, signature, or workflow identity. +- [repo-precedent conflict / user-intent drift] The design introduced `legacy-module` and `--strict=false` transition behavior without reconciling recent strict IaC hard-cutover precedent. +- [missing failure modes] Evidence matching did not define precedence across engine version, `wfctl` version, mode, platform, artifact digest, and failure status. +- [rollback gap / repo-precedent conflict] Forced incompatible installs require lockfile schema support, and the rollback feature flag was unnamed. + +**Findings (Minor):** +- [YAGNI / API shape] Positional `` plus `--plugin-dir` was redundant. +- [missing failure modes] “Low-risk methods” for typed IaC conformance was undefined. + +**Bug-class scan transcript:** +| Class | Result | Note | +|---|---|---| +| Unstated assumptions | Finding | CI-generated evidence trust and registry version history were assumed. | +| Repo-precedent conflicts | Finding | Current registry API is single-manifest; strict IaC precedent was not explicit enough. | +| YAGNI violations | Finding | Legacy modes and strict-disable knob were premature. | +| Missing failure modes | Finding | Evidence matching and non-mutating IaC boundaries were underspecified. | +| Security / privacy | Finding | Evidence lacked provenance and digest binding. | +| Rollback story | Finding | Lockfile and feature-flag rollback were not concrete. | +| Simpler alternative not considered | Finding | Registry-native compatibility matrix should be a first primitive. | +| User-intent drift | Finding | Install sorting was not actually delivered by the first design slice. | + +**Verdict reasoning:** FAIL. The revised design must make version indexes part of the first scope, make unsigned/unbound evidence advisory only, remove legacy IaC compatibility ambiguity, define evidence precedence, and specify lockfile/rollback behavior. diff --git a/docs/plans/2026-05-11-plugin-conformance-compat-design.md b/docs/plans/2026-05-11-plugin-conformance-compat-design.md index a6ef70b8..9df013e6 100644 --- a/docs/plans/2026-05-11-plugin-conformance-compat-design.md +++ b/docs/plans/2026-05-11-plugin-conformance-compat-design.md @@ -2,28 +2,29 @@ ## Goal -Make Workflow plugin compatibility executable, discoverable, and install-time enforceable. Plugin repositories should call one `wfctl` command for host conformance, publish generated compatibility evidence, and allow `wfctl plugin install` to select or reject versions using that evidence instead of relying on stale `minEngineVersion` claims. +Make Workflow plugin compatibility executable, discoverable, and install-time enforceable. Plugin repositories should call one `wfctl` command for host conformance, publish generated compatibility evidence, and allow `wfctl plugin install` to select or reject versions using that evidence instead of relying only on stale `minEngineVersion` claims. ## Decision -Use a central `wfctl plugin conformance` command plus a generated compatibility index. +Use a central `wfctl plugin conformance` command plus a generated, artifact-digest-bound registry version index. The first implementation should: -1. Add `wfctl plugin conformance` to run real host/plugin boundary checks. -2. Add compatibility evidence fields that can be generated by CI and consumed by `wfctl`. -3. Teach install/update/lock resolution to reject incompatible versions by default and allow explicit `--force`. +1. Add `wfctl plugin conformance` for strict typed IaC host/plugin checks. +2. Add registry-native version indexes with compatibility evidence. +3. Teach install/update/lock resolution to sort compatible versions using the index, reject known-incompatible versions by default, and allow explicit `--force`. See `decisions/0030-plugin-conformance-evidence-index.md`. ## Context -Today, plugin compatibility is split across several incomplete surfaces: +Today, plugin compatibility is split across incomplete surfaces: - `wfctl plugin validate` checks registry and local manifest shape, optional strict contract descriptors, and download reachability. - `wfctl plugin test` is manifest-only; it does not launch an external plugin binary through the real host adapter. - Plugin manifests declare `minEngineVersion`, but that is a claim, not executable evidence. - DigitalOcean and AWS now have repo-local conformance workflows, but those scripts duplicate logic that belongs in `wfctl`. +- Current registry APIs resolve one manifest. Compatible-version sorting requires a version index, not just `FetchManifest(name)`. The recent `workflow-plugin-digitalocean` conformance work proved this is useful: the gate caught incompatibility with Workflow `v0.51.1` and confirmed compatibility with `v0.51.2`. That signal should become a reusable platform capability. @@ -35,7 +36,7 @@ Best-practice references considered: ## Approaches -### A. Keep per-plugin conformance scripts +### A. Keep Per-Plugin Conformance Scripts Each plugin repository owns shell scripts and CI matrices. @@ -50,23 +51,24 @@ Cons: Rejected. -### B. Centralize conformance in `wfctl` +### B. Centralize Conformance And Add Version Indexes -Add `wfctl plugin conformance` as the executable contract. Plugin CI calls it against a local plugin build and selected Workflow engine versions. +Add `wfctl plugin conformance` as the executable contract, and add registry version indexes as the install resolver input. Pros: -- One compatibility vocabulary across all plugins. +- One compatibility vocabulary across plugins. - CI and local development use the same command. - Install/update can consume evidence generated by the same tool. -- Supports strict mode by default while preserving explicit legacy modes. +- Directly supports compatible-version sorting. +- Keeps strict typed IaC evidence separate from future Module/Step/Trigger evidence. Cons: -- Requires careful mode boundaries so legacy plugins do not accidentally pass typed checks. -- `wfctl` must learn enough about plugin packaging to test local builds and installed plugins. +- Registry source APIs must grow beyond one-manifest lookup. +- `wfctl` must match evidence to artifact digests and platforms. Accepted. -### C. Build a hosted compatibility service +### C. Build A Hosted Compatibility Service A central service watches Workflow releases and plugin releases, runs compatibility jobs, and publishes results. @@ -92,41 +94,66 @@ wfctl plugin conformance [options] Initial options: -- `--mode host-load|legacy-module|typed-iac` +- `--mode typed-iac` - `--engine-version ` for report metadata -- `--plugin-dir ` only when checking installed plugins by name in later tasks - `--format text|json` - `--timeout ` -- `--strict` default true, with `--strict=false` allowed only for legacy transition checks -Initial modes: +Initial mode: -- `host-load`: build or find the plugin binary, launch it through `plugin/external.ExternalPluginManager`, perform handshake, fetch adapter metadata, and unload. -- `legacy-module`: verify legacy module plugin registration through the existing module contract path. -- `typed-iac`: verify typed IaC service registration and call required low-risk methods that must not mutate remote infrastructure. +- `typed-iac`: build or find the plugin binary, launch it through `plugin/external.ExternalPluginManager`, verify typed IaC service registration, call only allowed local metadata methods, and unload. -Mode names are explicit. A legacy plugin passing `legacy-module` must not be recorded as passing `typed-iac`. +There is no `--strict=false` in the initial command. IaC install compatibility is strict typed-only, matching the hard-cutover direction in `decisions/0024-iac-typed-force-cutover.md`. Future Module/Step/Trigger conformance modes may be added, but their evidence must not satisfy IaC compatibility decisions. -### Evidence +Allowed typed-IaC calls in the first command: -Add generated compatibility evidence as a separate index, with a short manifest summary. +- Load plugin manifest and adapter metadata. +- Assert typed IaC service registration. +- Call `SupportedCanonicalKeys` if implemented; this is local metadata and must not require provider credentials. +- Call no resource `Read`, `Plan`, `Apply`, `Destroy`, bootstrap, or credential methods. -Proposed detailed shape: +Any live provider/API exercise belongs in a future `acceptance` mode or command with explicit credentials and cost-bearing opt-in. + +### Version Index And Evidence + +Add generated compatibility evidence as a registry-native version index, with a short current-manifest summary. + +Proposed version index shape: ```json { "plugin": "workflow-plugin-digitalocean", - "pluginVersion": "v0.14.4", "generatedAt": "2026-05-11T00:00:00Z", - "results": [ + "versions": [ { - "engineVersion": "v0.51.2", - "wfctlVersion": "v0.51.2", - "mode": "typed-iac", - "status": "pass", - "os": "linux", - "arch": "amd64", - "commit": "abc123" + "version": "v0.14.4", + "minEngineVersion": "v0.51.2", + "downloads": [ + { + "os": "linux", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-digitalocean/releases/download/v0.14.4/workflow-plugin-digitalocean-linux-amd64.tar.gz", + "sha256": "..." + } + ], + "compatibility": [ + { + "engineVersion": "v0.51.2", + "wfctlVersion": "v0.51.2", + "mode": "typed-iac", + "status": "pass", + "os": "linux", + "arch": "amd64", + "artifactSHA256": "...", + "pluginManifestSHA256": "...", + "repository": "GoCodeAlone/workflow-plugin-digitalocean", + "ref": "refs/tags/v0.14.4", + "commit": "abc123", + "workflowRunURL": "https://github.com/GoCodeAlone/workflow-plugin-digitalocean/actions/runs/123", + "generatedBy": "wfctl plugin conformance", + "signature": "" + } + ] } ] } @@ -138,39 +165,79 @@ Manifest summary: { "minEngineVersion": "0.51.2", "compatibility": { - "index": "compatibility/workflow-plugin-digitalocean/v0.14.4.json", + "index": "compatibility/workflow-plugin-digitalocean/index.json", "latestKnownGoodEngine": "v0.51.2", "modes": ["typed-iac"] } } ``` -The index is generated by CI. Humans should not hand-edit pass/fail claims. +Evidence must bind to the artifact digest that install will fetch. A pass record is authoritative only when: + +1. `plugin`, `version`, `os`, and `arch` match the candidate install. +2. `artifactSHA256` matches the candidate download SHA-256. +3. `engineVersion` equals the current engine version, or a later explicit range rule says the current engine is compatible. +4. `mode` satisfies the capability being installed. For IaC providers, this is `typed-iac`. +5. The evidence comes from a trusted registry source, or is signed by a configured trusted key. + +Unsigned evidence from untrusted/community registries is advisory only. It may warn or help humans choose, but it must not override `minEngineVersion` or block/allow installs by itself. The first implementation can accept trusted first-party registry evidence without signature, but the schema reserves `signature` so public/community trust can be tightened later. + +Failure precedence: + +1. Exact matching trusted `fail` for artifact + engine + mode + platform blocks by default. +2. Exact matching trusted `pass` allows the candidate if `minEngineVersion` is also compatible. +3. Missing exact evidence falls back to `minEngineVersion` with a warning. +4. Evidence for a different OS/arch, mode, artifact digest, or engine version does not match. + +### Registry API + +Add a registry source API for version indexes in the first implementation: + +```go +type RegistrySource interface { + FetchManifest(name string) (*RegistryManifest, error) + FetchVersionIndex(name string) (*PluginVersionIndex, error) + SearchPlugins(query string) ([]PluginSearchResult, error) + ListPlugins() ([]string, error) + Name() string +} +``` + +Existing sources may synthesize a single-version index from `FetchManifest` so older/static registries keep working. GitHub/static registry sources can then add native `compatibility//index.json` lookup without breaking callers. ### Install Resolution Default install behavior: -1. Resolve candidate plugin versions from registry metadata. -2. Filter out versions with `minEngineVersion` greater than current engine. -3. Prefer candidates with passing conformance evidence for the current engine. -4. If no evidence exists, allow install only when `minEngineVersion` is compatible, but print a clear warning that compatibility is unverified. -5. If evidence says incompatible for the current engine, fail by default. +1. Resolve candidate plugin versions from the registry version index. +2. Filter out versions with `minEngineVersion` greater than the current engine. +3. For the current OS/arch, rank candidates: + - exact trusted pass evidence for current engine + required mode + artifact digest + - compatible `minEngineVersion` but missing evidence + - exact trusted fail evidence is excluded +4. Install the newest highest-ranked compatible candidate. +5. If all candidates have exact trusted fail evidence, fail by default. 6. `--force` allows explicit incompatible install and records that choice in the lockfile. This keeps `minEngineVersion` as a lower-bound hint while allowing real conformance results to override stale optimism. -### Registry Layout - -Do not force every registry source to rewrite its directory structure in the first task. - -First pass: -- Extend `RegistryManifest` with optional `Compatibility`. -- Add a registry source method or helper for fetching compatibility evidence when a manifest points to it. -- Allow static registries to serve compatibility JSON beside manifests. +Lockfile additive fields: + +```yaml +plugins: + digitalocean: + version: v0.14.4 + sha256: ... + compatibility: + engineVersion: v0.51.2 + mode: typed-iac + status: pass + evidenceDigest: ... + forced: false + reason: "" +``` -Later pass: -- Add multi-version indexes so `wfctl plugin install ` can choose from more than the single manifest currently returned by `FetchManifest`. +Older clients should ignore the additive `compatibility` mapping. New clients should preserve unknown lockfile fields when rewriting if practical; if not practical in the first pass, the plan must include a regression test that documents current behavior and a follow-up issue. ## Failure Handling @@ -178,33 +245,38 @@ Later pass: - Handshake failure: fail and include plugin stderr tail when available. - Unsupported conformance mode: fail with available mode suggestions from manifest capabilities. - Engine version not found: fail unless the caller supplied `--engine-version local`. -- Compatibility index unavailable: warn for installs, fail for explicit `wfctl plugin conformance --publish-evidence` style commands when added later. +- Compatibility index unavailable: warn for installs, fail for explicit evidence publishing when added later. - Fork PR without `RELEASES_TOKEN`: CI may fall back to public release lookup; private-release checks should skip or fail with a token-specific message. +- Conformance timeout: kill the plugin subprocess, unload it, and report timeout as failure. +- Mismatched evidence digest: ignore the evidence and warn; never use digest-mismatched evidence to allow or block. ## Security - `wfctl plugin conformance` launches untrusted plugin binaries. It must default to local developer/CI usage only and make execution explicit in help text. -- The command must not pass cloud provider secrets to conformance checks unless the user supplies a config file for an explicit live test mode. -- Initial typed-IaC checks must call only non-mutating methods. +- The command must not pass cloud provider secrets to conformance checks unless the user supplies a config file for an explicit live acceptance mode. +- Initial typed-IaC checks must call only local metadata methods and must not call resource or credential operations. - JSON output must not include environment variables, secret values, or full process command lines containing tokens. - Install-time `--force` should be visible in lockfiles so reviewers can detect intentionally bypassed compatibility checks. +- Compatibility evidence that lacks a matching artifact digest or trusted provenance is advisory only. ## Testing Unit tests: - Command parsing and help text. - Compatibility evidence parsing and status precedence. +- Registry source single-manifest fallback to synthetic version index. - Install resolver behavior for compatible, incompatible, missing-evidence, and forced installs. +- Lockfile compatibility metadata write/read and old-client additive-field tolerance where supported. Integration tests: -- Fake external plugin passing `host-load`. -- Fake legacy module plugin passing `legacy-module`. - Fake typed-IaC plugin passing `typed-iac`. - Fake plugin that lacks `GetManifest` still passes when strict typed service evidence is sufficient. +- Fake version index with two plugin versions where the latest is incompatible and the older version is selected. +- Fake digest mismatch where evidence is ignored. Runtime validation: - Build local `wfctl`. -- Run `wfctl plugin conformance --mode host-load` against a fixture plugin. +- Run `wfctl plugin conformance --mode typed-iac` against a fixture plugin. - Run `wfctl plugin conformance --mode typed-iac` against the DigitalOcean plugin in CI after the plugin adopts the command. ## Rollback @@ -213,8 +285,9 @@ Runtime-affecting rollback path: 1. Revert the `wfctl plugin conformance` command and resolver commits. 2. Leave plugin-local conformance scripts in place until the central command is proven. -3. If install resolution blocks legitimate users, temporarily default evidence failures to warnings behind a feature flag while keeping `minEngineVersion` enforcement. +3. If install resolution blocks legitimate users, set `WFCTL_PLUGIN_COMPAT_MODE=warn` to make trusted fail evidence warn instead of block while keeping `minEngineVersion` enforcement. Default remains `enforce`. 4. Registry compatibility fields are additive and can remain ignored by older `wfctl` versions. +5. If lockfile compatibility metadata causes trouble, old clients can ignore the additive mapping; new clients can remove only the `compatibility` mapping and rerun `wfctl plugin lock`. ## Assumptions @@ -222,16 +295,16 @@ Runtime-affecting rollback path: - Plugin CI has access to `RELEASES_TOKEN` for private Workflow release metadata and assets when required. - Compatibility evidence can initially be generated per released engine version rather than per engine commit. - Plugin manifests can grow additive optional fields without breaking older `wfctl` versions. -- The first compatibility consumers are Go external plugins; UI-only plugin compatibility can be modeled later. +- The first compatibility consumers are Go external IaC plugins; UI-only and non-IaC plugin compatibility can be modeled later. +- First-party registry evidence can be treated as trusted when served from GoCodeAlone-controlled registries; community evidence needs signatures before it can enforce install decisions. ## Self-Challenge 1. The laziest plausible solution is to keep one shared shell script copied into each plugin. That would solve CI drift for a few repos but would not let `wfctl plugin install` reason about compatibility, so it fails the install-resolution goal. -2. The most fragile assumption is that the current registry can expose enough version history. Today `FetchManifest` returns one manifest. The design scopes multi-version resolution as a later pass and starts with evidence for the currently resolved manifest. -3. The design risks adding too much generality through many modes. The first implementation should keep only three modes: `host-load`, `legacy-module`, and `typed-iac`. +2. The most fragile assumption is that evidence can safely influence installs. The design binds evidence to artifact digests and treats unsigned/untrusted evidence as advisory only. +3. The design risks adding too much generality through many modes. The first implementation keeps only `typed-iac`; other plugin families can add modes after the evidence path is proven. ## Open Questions -- Whether compatibility evidence belongs in `workflow-registry` long-term or in per-plugin release assets with registry pointers. - Whether install resolution should fail on missing evidence after an adoption window. - Whether live provider acceptance checks should be part of `wfctl plugin conformance` or a separate `wfctl plugin acceptance` command. From 929bb5d3baad7f3a2ff6e73e7f780b979e319314 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 10 May 2026 22:15:12 -0400 Subject: [PATCH 03/29] docs: specify plugin compat rollout semantics --- .../0030-plugin-conformance-evidence-index.md | 2 +- ...ance-compat-design.adversarial-review-2.md | 33 +++++ ...-05-11-plugin-conformance-compat-design.md | 123 +++++++++++++++++- 3 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-2.md diff --git a/decisions/0030-plugin-conformance-evidence-index.md b/decisions/0030-plugin-conformance-evidence-index.md index ce2e657c..8fe75b0c 100644 --- a/decisions/0030-plugin-conformance-evidence-index.md +++ b/decisions/0030-plugin-conformance-evidence-index.md @@ -15,4 +15,4 @@ We will centralize plugin compatibility checks in `wfctl plugin conformance` and ## Consequences -This makes plugin CI and local development use one contract, and it gives `wfctl plugin install` data for compatibility-aware resolution. It also adds responsibility to `wfctl`: conformance modes must stay precise, evidence must match artifact digests and platform, and the registry source API must support version indexes without breaking older clients. Rollback is straightforward because compatibility fields are optional, install enforcement can temporarily switch to warn mode, and plugin-local scripts can remain during the transition. +This makes plugin CI and local development use one contract, and it gives `wfctl plugin install`, `plugin update`, and `plugin lock` data for compatibility-aware resolution. It also adds responsibility to `wfctl`: conformance modes must stay precise, evidence must match artifact digests and platform, registry trust must be explicit, and the registry source API must support version indexes without breaking older clients. Rollback is straightforward because compatibility fields are optional, enforcement can temporarily switch to warn mode, and plugin-local scripts can remain during the transition. diff --git a/docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-2.md b/docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-2.md new file mode 100644 index 00000000..7dd25269 --- /dev/null +++ b/docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-2.md @@ -0,0 +1,33 @@ +### Adversarial Review Report + +**Phase:** design +**Artifact:** docs/plans/2026-05-11-plugin-conformance-compat-design.md +**Status:** FAIL + +**Findings (Critical):** +- None. + +**Findings (Important):** +- [missing failure modes / repo-precedent conflict] Local plugin conformance assumed `ExternalPluginManager` could launch arbitrary plugin dirs, but it only loads installed-layout plugins. +- [missing failure modes / repo-precedent conflict] `--timeout` was promised, but `ExternalPluginManager.LoadPlugin` has no context/deadline path around handshake and dispense. +- [security / unstated assumptions] “Trusted registry source” was load-bearing but undefined. +- [user-intent drift / missing design surface] Install/update/lock resolution was promised, but update and lock algorithms were not designed. +- [rollback gap] Warn-mode rollback was mentioned in the ADR but not wired into config/CLI/env precedence. + +**Findings (Minor):** +- [schema consistency] Version grammar mixed `0.51.2` and `v0.51.2`. +- [missing failure modes] Multi-registry/name-normalization behavior for version indexes was underspecified. + +**Bug-class scan transcript:** +| Class | Result | Note | +|---|---|---| +| Unstated assumptions | Finding | Trust policy, build/staging behavior, engine discovery, and timeout launch were assumed. | +| Repo-precedent conflicts | Finding | Current external manager and registry/lock code are single-layout/single-manifest. | +| YAGNI violations | Clean | Deferred hosted service and acceptance modes stayed future work. | +| Missing failure modes | Finding | Hung handshake, staging, lock/update drift, index mismatch, and version grammar needed more detail. | +| Security / privacy | Finding | Evidence trust needed concrete identity rules. | +| Rollback story | Finding | Warn-mode rollback needed explicit wiring. | +| Simpler alternative not considered | Finding | A conformance+lock-first rollout was not evaluated. | +| User-intent drift | Finding | Update/lock sorting was promised but not designed. | + +**Verdict reasoning:** FAIL. The final revision must define conformance staging/timeout, registry trust, install/update/lock algorithms, warning-mode rollback, version normalization, and same-source registry/index resolution. diff --git a/docs/plans/2026-05-11-plugin-conformance-compat-design.md b/docs/plans/2026-05-11-plugin-conformance-compat-design.md index 9df013e6..8f6d78ad 100644 --- a/docs/plans/2026-05-11-plugin-conformance-compat-design.md +++ b/docs/plans/2026-05-11-plugin-conformance-compat-design.md @@ -114,6 +114,36 @@ Allowed typed-IaC calls in the first command: Any live provider/API exercise belongs in a future `acceptance` mode or command with explicit credentials and cost-bearing opt-in. +### Conformance Staging And Timeout + +The conformance command should use a conformance-specific launcher instead of changing production `ExternalPluginManager` first. + +Staging contract: + +1. Read `/plugin.json`. +2. Resolve plugin install name from `plugin.json.name`, normalized the same way `wfctl plugin install` normalizes names. +3. Create a temp installed layout: + + ```text + /plugins//plugin.json + /plugins// + ``` + +4. If `/` exists and is executable, copy it into the staged binary path. +5. Otherwise run `go build -o /plugins// .` with working directory ``. +6. Copy `plugin.contracts.json` and other metadata files only when a conformance mode needs them. Do not copy provider credentials or local `.env` files. +7. Compute SHA-256 over the staged binary and plugin manifest. These become `artifactSHA256` and `pluginManifestSHA256` for local evidence output. +8. Launch the staged binary with working directory `/plugins/`. +9. Capture stderr/stdout into bounded ring buffers and include only tails in failure output. +10. Remove the temp directory after unload unless `--keep-temp` is added in a later debugging task. + +Timeout contract: + +- The launcher owns process creation and uses `context.WithTimeout`. +- On timeout, kill the process group, wait for exit, report timeout as failure, and include stderr/stdout tails. +- The first implementation should not rely on `ExternalPluginManager.LoadPlugin` for timeout enforcement because it has no context-aware handshake path today. +- A later refactor may share launcher internals with `ExternalPluginManager` after the conformance path proves the semantics. + ### Version Index And Evidence Add generated compatibility evidence as a registry-native version index, with a short current-manifest summary. @@ -163,7 +193,7 @@ Manifest summary: ```json { - "minEngineVersion": "0.51.2", + "minEngineVersion": "v0.51.2", "compatibility": { "index": "compatibility/workflow-plugin-digitalocean/index.json", "latestKnownGoodEngine": "v0.51.2", @@ -189,6 +219,51 @@ Failure precedence: 3. Missing exact evidence falls back to `minEngineVersion` with a warning. 4. Evidence for a different OS/arch, mode, artifact digest, or engine version does not match. +Version grammar: + +- Inputs may include or omit a leading `v`. +- Internal comparison canonicalizes by stripping leading `v` and parsing semver. +- JSON output and indexes emit leading `v` for engine and plugin versions. +- Schema validation should reject non-semver strings after optional `v` stripping. + +### Registry Trust + +Add explicit trust metadata to registry config: + +```yaml +registries: + - name: default + type: github + owner: GoCodeAlone + repo: workflow-registry + branch: main + priority: 0 + compatibilityEvidence: + trust: first_party + - name: internal + type: static + url: https://example.com/workflow-registry + priority: 10 + compatibilityEvidence: + trust: signed + keys: + - "SHA256:..." + - name: community + type: static + url: https://example.net/workflow-registry + priority: 20 + compatibilityEvidence: + trust: advisory +``` + +Default rules: + +- Built-in `GoCodeAlone/workflow-registry` on `main` is `first_party`. +- Static mirror `https://gocodealone.github.io/workflow-registry/v1` is `first_party` only when it is the built-in default entry. +- User-configured registries default to `advisory`. +- `signed` evidence requires a matching configured key before it can enforce install decisions. +- `advisory` evidence can produce warnings and ranking notes, but cannot block or allow an install beyond `minEngineVersion`. + ### Registry API Add a registry source API for version indexes in the first implementation: @@ -205,9 +280,11 @@ type RegistrySource interface { Existing sources may synthesize a single-version index from `FetchManifest` so older/static registries keep working. GitHub/static registry sources can then add native `compatibility//index.json` lookup without breaking callers. -### Install Resolution +`MultiRegistry` must resolve manifests and indexes from the same source. It should try original name then normalized name using the same source-priority order as `FetchManifest`. Once a source resolves a plugin, its version index must come from that same source unless the manifest explicitly points to a cross-source index and that target is trusted. + +### Install, Update, And Lock Resolution -Default install behavior: +Shared resolver behavior: 1. Resolve candidate plugin versions from the registry version index. 2. Filter out versions with `minEngineVersion` greater than the current engine. @@ -221,6 +298,27 @@ Default install behavior: This keeps `minEngineVersion` as a lower-bound hint while allowing real conformance results to override stale optimism. +`wfctl plugin install `: + +- Uses the shared resolver with no requested version. +- Installs the newest highest-ranked candidate. +- If the user passes `@`, the resolver evaluates only that version and fails by default on exact trusted fail evidence. +- `--force` bypasses compatibility blocking but still verifies checksums unless `--skip-checksum` is separately supplied. + +`wfctl plugin update `: + +- Uses the shared resolver with installed version as lower bound. +- Does not update to a newer version with exact trusted fail evidence unless `--force`. +- If the installed version later becomes known-fail, `update` reports it and suggests either an available compatible version or `--force`. + +`wfctl plugin lock`: + +- Reads `wfctl.yaml` requested plugins. +- For entries with an explicit version, evaluates that exact version and records compatibility metadata. +- For entries without an explicit version, selects the newest highest-ranked compatible candidate and writes that version into `.wfctl-lock.yaml`. +- Preserves existing compatibility metadata when the selected plugin version, platform digest, engine version, and evidence digest are unchanged. +- If a previously locked version becomes known-fail, lock regeneration fails by default and can be run with warn mode to keep the lock while recording `status: fail`. + Lockfile additive fields: ```yaml @@ -239,6 +337,15 @@ plugins: Older clients should ignore the additive `compatibility` mapping. New clients should preserve unknown lockfile fields when rewriting if practical; if not practical in the first pass, the plan must include a regression test that documents current behavior and a follow-up issue. +Enforcement controls: + +1. CLI flag on install/update/lock: `--compat-mode enforce|warn`. +2. Environment fallback: `WFCTL_PLUGIN_COMPAT_MODE=enforce|warn`. +3. Registry/project config fallback: `plugin.compatibilityEnforcement: enforce|warn`. +4. Default: `enforce`. + +Precedence is CLI > env > config > default. In `warn` mode, trusted fail evidence never blocks install/update/lock, but the command must print the warning and record `compatibility.status: fail` with `forced: true` and `reason: compat-mode=warn`. + ## Failure Handling - Missing plugin binary: fail with packaging guidance. @@ -249,6 +356,7 @@ Older clients should ignore the additive `compatibility` mapping. New clients sh - Fork PR without `RELEASES_TOKEN`: CI may fall back to public release lookup; private-release checks should skip or fail with a token-specific message. - Conformance timeout: kill the plugin subprocess, unload it, and report timeout as failure. - Mismatched evidence digest: ignore the evidence and warn; never use digest-mismatched evidence to allow or block. +- Registry/index source mismatch: ignore the index unless the manifest's cross-source pointer is trusted. ## Security @@ -267,12 +375,18 @@ Unit tests: - Registry source single-manifest fallback to synthetic version index. - Install resolver behavior for compatible, incompatible, missing-evidence, and forced installs. - Lockfile compatibility metadata write/read and old-client additive-field tolerance where supported. +- Update resolver behavior for newer compatible, newer known-fail, and installed known-fail versions. +- `--compat-mode`, `WFCTL_PLUGIN_COMPAT_MODE`, and config precedence. +- Same-source manifest/index resolution in `MultiRegistry`. +- Version canonicalization for `v0.51.2` and `0.51.2`. Integration tests: - Fake typed-IaC plugin passing `typed-iac`. - Fake plugin that lacks `GetManifest` still passes when strict typed service evidence is sufficient. - Fake version index with two plugin versions where the latest is incompatible and the older version is selected. - Fake digest mismatch where evidence is ignored. +- Fake conformance plugin that hangs during handshake and is killed on timeout. +- Fake local plugin directory staged into installed layout with expected binary name. Runtime validation: - Build local `wfctl`. @@ -285,7 +399,7 @@ Runtime-affecting rollback path: 1. Revert the `wfctl plugin conformance` command and resolver commits. 2. Leave plugin-local conformance scripts in place until the central command is proven. -3. If install resolution blocks legitimate users, set `WFCTL_PLUGIN_COMPAT_MODE=warn` to make trusted fail evidence warn instead of block while keeping `minEngineVersion` enforcement. Default remains `enforce`. +3. If install/update/lock resolution blocks legitimate users, set `WFCTL_PLUGIN_COMPAT_MODE=warn` or `plugin.compatibilityEnforcement: warn` to make trusted fail evidence warn instead of block while keeping `minEngineVersion` enforcement. Default remains `enforce`. 4. Registry compatibility fields are additive and can remain ignored by older `wfctl` versions. 5. If lockfile compatibility metadata causes trouble, old clients can ignore the additive mapping; new clients can remove only the `compatibility` mapping and rerun `wfctl plugin lock`. @@ -297,6 +411,7 @@ Runtime-affecting rollback path: - Plugin manifests can grow additive optional fields without breaking older `wfctl` versions. - The first compatibility consumers are Go external IaC plugins; UI-only and non-IaC plugin compatibility can be modeled later. - First-party registry evidence can be treated as trusted when served from GoCodeAlone-controlled registries; community evidence needs signatures before it can enforce install decisions. +- The first conformance launcher can duplicate a small amount of production plugin launch behavior to obtain timeout and staging semantics without destabilizing runtime plugin loading. ## Self-Challenge From fba34960a7c810c752ff2d109d703353743a5177 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 10 May 2026 22:17:20 -0400 Subject: [PATCH 04/29] docs: record final compat design review --- ...ance-compat-design.adversarial-review-3.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-3.md diff --git a/docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-3.md b/docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-3.md new file mode 100644 index 00000000..6f9383d4 --- /dev/null +++ b/docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-3.md @@ -0,0 +1,38 @@ +### Adversarial Review Report + +**Phase:** design +**Artifact:** docs/plans/2026-05-11-plugin-conformance-compat-design.md +**Status:** FAIL + +**Findings (Critical):** +- None. + +**Findings (Important):** +- [user-intent drift / missing design surface] The design specifies version index shape and install consumption, but not the producer/publisher path that makes registry indexes exist. First scope needs CI/index generation, `wfctl plugin conformance --format json` output schema, merge/update command or registry push path, atomic index update rules, and required plugin CI matrix wiring. +- [unstated assumptions / user-intent drift] Current engine version and released engine versions are load-bearing but undefined. The design needs engine version source of truth, release discovery, plugin CI matrix policy, stale-evidence behavior, and whether compatibility is tested per latest engine only or across a supported engine window. +- [repo-precedent conflict / schema mismatch] Proposed lockfile compatibility is plugin-level, but evidence is platform/artifact-specific. Current lockfile stores platform-specific URLs and SHA-256 under `platforms`, so compatibility should live under each platform entry or be a repeated compatibility list with `os`, `arch`, and `artifactSHA256`. +- [missing failure modes / strict-default ambiguity] Default `enforce` blocks known trusted fails, but missing evidence still installs via `minEngineVersion` with only a warning. The adoption policy must explicitly say whether first-party plugins require evidence or whether this is a named transitional mode with a sunset condition. + +**Findings (Minor):** +- [missing failure modes] `--force` and warn mode both need lockfile recording. Define exact `--force` reason behavior for installs and updates. +- [schema consistency] `evidenceDigest` needs canonical bytes and digest algorithm, e.g. SHA-256 over canonical JSON for the exact evidence record. +- [security / privacy] CI trust boundaries are missing. Fork PRs should not execute arbitrary released plugin binaries while organization tokens are available. + +**Bug-class scan transcript:** +| Class | Result | Note | +|---|---|---| +| Unstated assumptions | Finding | Engine version discovery, tested engine matrix breadth, stale evidence refresh, and evidence publishing are assumed. | +| Repo-precedent conflicts | Finding | Current lockfile archive metadata is platform-scoped; the design used plugin-level compatibility. | +| YAGNI violations | Clean | Hosted service, acceptance mode, and non-IaC modes remain deferred. | +| Missing failure modes | Finding | Missing/stale evidence, mixed platform evidence, force recording, evidence digest mismatch semantics, and publishing failures need sharper behavior. | +| Security / privacy | Finding | Untrusted binary execution is acknowledged, but fork/tag/secret-bearing CI boundaries are undefined. | +| Rollback story | Clean | Warn mode, env/config precedence, additive registry fields, retained scripts, and lock metadata removal are covered. | +| Simpler alternative not considered | Finding | A lockfile-first rollout with generated local evidence before registry enforcement was not evaluated. | +| User-intent drift | Finding | Consumption is designed, but automatic compatibility determination and index publishing remain underdesigned. | + +**Options the author may not have considered:** +1. Evidence producer first, resolver second: ship `wfctl plugin conformance --format json` plus `wfctl registry compatibility update` before install enforcement. +2. Platform-scoped compatibility in lockfiles: store compatibility under `plugins..platforms..compatibility`. +3. First-party evidence-required mode: require exact evidence for GoCodeAlone registries after an adoption marker; keep advisory fallback for community/user registries. + +**Verdict reasoning:** FAIL. The design fixed earlier blockers but still lacks central producer/publisher semantics, engine matrix policy, and platform-scoped lockfile evidence. These are central to the user's compatibility automation goal. From 97081843fffa7f24eac7d8a9b56105b9c7a15b80 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 00:28:27 -0400 Subject: [PATCH 05/29] docs: add compatibility evidence production path --- .../0030-plugin-conformance-evidence-index.md | 2 +- ...-05-11-plugin-conformance-compat-design.md | 100 +++++++++++++++--- 2 files changed, 86 insertions(+), 16 deletions(-) diff --git a/decisions/0030-plugin-conformance-evidence-index.md b/decisions/0030-plugin-conformance-evidence-index.md index 8fe75b0c..0486be83 100644 --- a/decisions/0030-plugin-conformance-evidence-index.md +++ b/decisions/0030-plugin-conformance-evidence-index.md @@ -15,4 +15,4 @@ We will centralize plugin compatibility checks in `wfctl plugin conformance` and ## Consequences -This makes plugin CI and local development use one contract, and it gives `wfctl plugin install`, `plugin update`, and `plugin lock` data for compatibility-aware resolution. It also adds responsibility to `wfctl`: conformance modes must stay precise, evidence must match artifact digests and platform, registry trust must be explicit, and the registry source API must support version indexes without breaking older clients. Rollback is straightforward because compatibility fields are optional, enforcement can temporarily switch to warn mode, and plugin-local scripts can remain during the transition. +This makes plugin CI and local development use one contract, gives registry CI a no-hand-edit index update command, and gives `wfctl plugin install`, `plugin update`, and `plugin lock` data for compatibility-aware resolution. It also adds responsibility to `wfctl`: conformance modes must stay precise, evidence must match artifact digests and platform, registry trust must be explicit, and the registry source API must support version indexes without breaking older clients. Rollback is straightforward because compatibility fields are optional, enforcement can temporarily switch to warn mode, and plugin-local scripts can remain during the transition. diff --git a/docs/plans/2026-05-11-plugin-conformance-compat-design.md b/docs/plans/2026-05-11-plugin-conformance-compat-design.md index 8f6d78ad..7cc5b0cb 100644 --- a/docs/plans/2026-05-11-plugin-conformance-compat-design.md +++ b/docs/plans/2026-05-11-plugin-conformance-compat-design.md @@ -12,7 +12,8 @@ The first implementation should: 1. Add `wfctl plugin conformance` for strict typed IaC host/plugin checks. 2. Add registry-native version indexes with compatibility evidence. -3. Teach install/update/lock resolution to sort compatible versions using the index, reject known-incompatible versions by default, and allow explicit `--force`. +3. Add an index update command so CI can publish evidence without hand-editing JSON. +4. Teach install/update/lock resolution to sort compatible versions using the index, reject known-incompatible versions by default, and allow explicit `--force`. See `decisions/0030-plugin-conformance-evidence-index.md`. @@ -97,6 +98,7 @@ Initial options: - `--mode typed-iac` - `--engine-version ` for report metadata - `--format text|json` +- `--output ` when `--format json` - `--timeout ` Initial mode: @@ -154,6 +156,11 @@ Proposed version index shape: { "plugin": "workflow-plugin-digitalocean", "generatedAt": "2026-05-11T00:00:00Z", + "evidencePolicy": { + "firstParty": "required", + "community": "advisory", + "requiredFromEngine": "v0.52.0" + }, "versions": [ { "version": "v0.14.4", @@ -172,6 +179,7 @@ Proposed version index shape: "wfctlVersion": "v0.51.2", "mode": "typed-iac", "status": "pass", + "evidenceDigest": "sha256:...", "os": "linux", "arch": "amd64", "artifactSHA256": "...", @@ -212,12 +220,15 @@ Evidence must bind to the artifact digest that install will fetch. A pass record Unsigned evidence from untrusted/community registries is advisory only. It may warn or help humans choose, but it must not override `minEngineVersion` or block/allow installs by itself. The first implementation can accept trusted first-party registry evidence without signature, but the schema reserves `signature` so public/community trust can be tightened later. +`evidenceDigest` is `sha256:` over canonical JSON for the exact evidence record with `evidenceDigest` and `signature` omitted, object keys sorted lexicographically, and UTF-8 encoding. The resolver uses it as an audit handle in lockfiles; artifact integrity still comes from download SHA-256. + Failure precedence: 1. Exact matching trusted `fail` for artifact + engine + mode + platform blocks by default. 2. Exact matching trusted `pass` allows the candidate if `minEngineVersion` is also compatible. -3. Missing exact evidence falls back to `minEngineVersion` with a warning. -4. Evidence for a different OS/arch, mode, artifact digest, or engine version does not match. +3. Missing exact evidence for first-party registries with `evidencePolicy.firstParty=required` blocks when current engine ≥ `requiredFromEngine`. +4. Missing exact evidence for transitional or advisory registries falls back to `minEngineVersion` with a warning. +5. Evidence for a different OS/arch, mode, artifact digest, or engine version does not match. Version grammar: @@ -282,6 +293,52 @@ Existing sources may synthesize a single-version index from `FetchManifest` so o `MultiRegistry` must resolve manifests and indexes from the same source. It should try original name then normalized name using the same source-priority order as `FetchManifest`. Once a source resolves a plugin, its version index must come from that same source unless the manifest explicitly points to a cross-source index and that target is trusted. +### Evidence Production And Publishing + +First-scope producer path: + +1. Plugin CI builds release artifacts and checksums as it does today. +2. Plugin CI runs `wfctl plugin conformance --mode typed-iac --engine-version --format json --output compatibility/--.json .` for each engine/platform matrix cell. +3. Plugin CI uploads those JSON files as workflow artifacts for PRs. +4. Release CI or a maintainer-run registry job checks out `workflow-registry` and runs: + + ```sh + wfctl registry compatibility update \ + --registry-dir . \ + --plugin workflow-plugin-digitalocean \ + --version v0.14.4 \ + --evidence compatibility/*.json + ``` + +5. The update command reads `plugins//manifest.json`, reads or creates `compatibility//index.json`, validates evidence against manifest/download checksums, upserts exact records, sorts versions descending by semver, sorts evidence by engine/mode/os/arch, writes a temp file, fsyncs where supported, then atomically renames into place. +6. CI opens or updates a registry PR containing only manifest/index/checksum changes. No pass/fail claim should be hand-authored. + +`wfctl plugin conformance --format json` output is one evidence record, not the whole index. It includes: + +- plugin name/version +- engine version and `wfctl` version +- mode/status +- os/arch +- artifact SHA-256 +- plugin manifest SHA-256 +- repo/ref/commit/workflow URL when CI env provides them +- stderr/stdout tails only on failure +- `evidenceDigest` + +Engine matrix policy: + +- For each plugin release, test declared `minEngineVersion` and latest Workflow release. +- If they are equal, one matrix row is enough. +- `WORKFLOW_CURRENT_VERSION` may pin latest during release incidents. +- `wfctl registry compatibility update` marks index stale when latest Workflow release is newer than newest evidence for that plugin. +- In required first-party mode, stale evidence blocks install/update/lock for the newer engine. In transitional/advisory mode, stale evidence warns. + +Engine version source of truth: + +- `wfctl plugin conformance --engine-version` wins. +- Install/update/lock use the compiled `wfctl version` when it is a release semver. +- If local `wfctl version` is a pseudo-version or `v0.0.0-*`, users may set `WFCTL_ENGINE_VERSION` or `--engine-version`; otherwise resolver treats evidence as advisory and prints that local engine version is not comparable. + ### Install, Update, And Lock Resolution Shared resolver behavior: @@ -303,13 +360,13 @@ This keeps `minEngineVersion` as a lower-bound hint while allowing real conforma - Uses the shared resolver with no requested version. - Installs the newest highest-ranked candidate. - If the user passes `@`, the resolver evaluates only that version and fails by default on exact trusted fail evidence. -- `--force` bypasses compatibility blocking but still verifies checksums unless `--skip-checksum` is separately supplied. +- `--force` bypasses compatibility blocking but still verifies checksums unless `--skip-checksum` is separately supplied. Lockfile reason: `force-install`. `wfctl plugin update `: - Uses the shared resolver with installed version as lower bound. - Does not update to a newer version with exact trusted fail evidence unless `--force`. -- If the installed version later becomes known-fail, `update` reports it and suggests either an available compatible version or `--force`. +- If the installed version later becomes known-fail, `update` reports it and suggests either an available compatible version or `--force`. Lockfile reason for forced update: `force-update`. `wfctl plugin lock`: @@ -325,14 +382,18 @@ Lockfile additive fields: plugins: digitalocean: version: v0.14.4 - sha256: ... - compatibility: - engineVersion: v0.51.2 - mode: typed-iac - status: pass - evidenceDigest: ... - forced: false - reason: "" + platforms: + linux-amd64: + url: https://... + sha256: ... + compatibility: + engineVersion: v0.51.2 + mode: typed-iac + status: pass + artifactSHA256: ... + evidenceDigest: sha256:... + forced: false + reason: "" ``` Older clients should ignore the additive `compatibility` mapping. New clients should preserve unknown lockfile fields when rewriting if practical; if not practical in the first pass, the plan must include a regression test that documents current behavior and a follow-up issue. @@ -344,7 +405,7 @@ Enforcement controls: 3. Registry/project config fallback: `plugin.compatibilityEnforcement: enforce|warn`. 4. Default: `enforce`. -Precedence is CLI > env > config > default. In `warn` mode, trusted fail evidence never blocks install/update/lock, but the command must print the warning and record `compatibility.status: fail` with `forced: true` and `reason: compat-mode=warn`. +Precedence is CLI > env > config > default. In `warn` mode, trusted fail evidence never blocks install/update/lock, but the command must print the warning and record platform compatibility with `status: fail`, `forced: true`, and `reason: compat-mode=warn`. Explicit `--force` in enforce mode records `forced: true` and `reason: force-install`, `force-update`, or `force-lock`. ## Failure Handling @@ -353,6 +414,7 @@ Precedence is CLI > env > config > default. In `warn` mode, trusted fail evidenc - Unsupported conformance mode: fail with available mode suggestions from manifest capabilities. - Engine version not found: fail unless the caller supplied `--engine-version local`. - Compatibility index unavailable: warn for installs, fail for explicit evidence publishing when added later. +- Compatibility update fails validation: leave existing index untouched; write no partial file. - Fork PR without `RELEASES_TOKEN`: CI may fall back to public release lookup; private-release checks should skip or fail with a token-specific message. - Conformance timeout: kill the plugin subprocess, unload it, and report timeout as failure. - Mismatched evidence digest: ignore the evidence and warn; never use digest-mismatched evidence to allow or block. @@ -366,6 +428,7 @@ Precedence is CLI > env > config > default. In `warn` mode, trusted fail evidenc - JSON output must not include environment variables, secret values, or full process command lines containing tokens. - Install-time `--force` should be visible in lockfiles so reviewers can detect intentionally bypassed compatibility checks. - Compatibility evidence that lacks a matching artifact digest or trusted provenance is advisory only. +- Secret-bearing release/registry jobs should run only on tags, protected branches, or maintainer-triggered workflows. Fork PRs may run fixture conformance and build checks, but must not execute arbitrary released plugin binaries while organization tokens are available. ## Testing @@ -375,6 +438,11 @@ Unit tests: - Registry source single-manifest fallback to synthetic version index. - Install resolver behavior for compatible, incompatible, missing-evidence, and forced installs. - Lockfile compatibility metadata write/read and old-client additive-field tolerance where supported. +- Platform-scoped lockfile compatibility metadata for mixed pass/fail platforms. +- Evidence digest canonicalization. +- Registry compatibility update validation, sort order, and atomic no-partial-write behavior. +- Engine version source precedence and pseudo-version advisory behavior. +- Evidence-required policy for first-party registries after `requiredFromEngine`. - Update resolver behavior for newer compatible, newer known-fail, and installed known-fail versions. - `--compat-mode`, `WFCTL_PLUGIN_COMPAT_MODE`, and config precedence. - Same-source manifest/index resolution in `MultiRegistry`. @@ -387,10 +455,12 @@ Integration tests: - Fake digest mismatch where evidence is ignored. - Fake conformance plugin that hangs during handshake and is killed on timeout. - Fake local plugin directory staged into installed layout with expected binary name. +- Fake registry compatibility update from two conformance JSON files. Runtime validation: - Build local `wfctl`. - Run `wfctl plugin conformance --mode typed-iac` against a fixture plugin. +- Run `wfctl registry compatibility update` against a temp registry checkout and verify stable index diff. - Run `wfctl plugin conformance --mode typed-iac` against the DigitalOcean plugin in CI after the plugin adopts the command. ## Rollback @@ -407,7 +477,7 @@ Runtime-affecting rollback path: - Workflow engine releases remain tagged and fetchable from GitHub. - Plugin CI has access to `RELEASES_TOKEN` for private Workflow release metadata and assets when required. -- Compatibility evidence can initially be generated per released engine version rather than per engine commit. +- Compatibility evidence can initially be generated for plugin `minEngineVersion` and latest released engine rather than every engine release in between. - Plugin manifests can grow additive optional fields without breaking older `wfctl` versions. - The first compatibility consumers are Go external IaC plugins; UI-only and non-IaC plugin compatibility can be modeled later. - First-party registry evidence can be treated as trusted when served from GoCodeAlone-controlled registries; community evidence needs signatures before it can enforce install decisions. From 8f0f2f30d7d2e3ef5b458e909fb3519e7e04b2de Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 00:33:07 -0400 Subject: [PATCH 06/29] docs: make plugin conformance artifact-first --- .../0030-plugin-conformance-evidence-index.md | 4 +- ...-05-11-plugin-conformance-compat-design.md | 106 ++++++++++++------ 2 files changed, 73 insertions(+), 37 deletions(-) diff --git a/decisions/0030-plugin-conformance-evidence-index.md b/decisions/0030-plugin-conformance-evidence-index.md index 0486be83..afc857e6 100644 --- a/decisions/0030-plugin-conformance-evidence-index.md +++ b/decisions/0030-plugin-conformance-evidence-index.md @@ -11,8 +11,8 @@ Workflow plugins currently declare `minEngineVersion`, but that field is only a ## Decision -We will centralize plugin compatibility checks in `wfctl plugin conformance` and store generated, artifact-digest-bound compatibility evidence in a registry-native version index. Manifests may carry a short summary and pointer to that index, but pass/fail claims come from CI-generated output with provenance. Rejected alternatives: per-plugin shell scripts, because they drift and cannot guide installs; a hosted compatibility service first, because it is heavier than the local/CI contract needed now; using unsigned evidence for enforcement, because compatibility data affects supply-chain decisions. +We will centralize plugin compatibility checks in `wfctl plugin conformance` and store generated, archive-digest-bound compatibility evidence in a registry-native version index. Release CI should test the same archive users install via `wfctl plugin conformance --artifact`. Manifests may carry a short summary and pointer to that index, but pass/fail claims come from CI-generated output with provenance. Rejected alternatives: per-plugin shell scripts, because they drift and cannot guide installs; a hosted compatibility service first, because it is heavier than the local/CI contract needed now; signed third-party enforcement in first scope, because signature envelope/key semantics need a separate ADR. ## Consequences -This makes plugin CI and local development use one contract, gives registry CI a no-hand-edit index update command, and gives `wfctl plugin install`, `plugin update`, and `plugin lock` data for compatibility-aware resolution. It also adds responsibility to `wfctl`: conformance modes must stay precise, evidence must match artifact digests and platform, registry trust must be explicit, and the registry source API must support version indexes without breaking older clients. Rollback is straightforward because compatibility fields are optional, enforcement can temporarily switch to warn mode, and plugin-local scripts can remain during the transition. +This makes plugin CI and local development use one contract, gives registry CI a no-hand-edit index update command, and gives `wfctl plugin install`, `plugin update`, and `plugin lock` data for compatibility-aware resolution. It also adds responsibility to `wfctl`: conformance modes must stay precise, evidence must match archive digests and platform, registry trust must be explicit, and the registry source API must support version indexes without breaking older clients. Rollback is straightforward because compatibility fields are optional, enforcement can temporarily switch to warn mode, and plugin-local scripts can remain during the transition. diff --git a/docs/plans/2026-05-11-plugin-conformance-compat-design.md b/docs/plans/2026-05-11-plugin-conformance-compat-design.md index 7cc5b0cb..b23e5eee 100644 --- a/docs/plans/2026-05-11-plugin-conformance-compat-design.md +++ b/docs/plans/2026-05-11-plugin-conformance-compat-design.md @@ -65,7 +65,7 @@ Pros: Cons: - Registry source APIs must grow beyond one-manifest lookup. -- `wfctl` must match evidence to artifact digests and platforms. +- `wfctl` must match evidence to archive digests and platforms. Accepted. @@ -96,6 +96,7 @@ wfctl plugin conformance [options] Initial options: - `--mode typed-iac` +- `--artifact ` for release-archive conformance - `--engine-version ` for report metadata - `--format text|json` - `--output ` when `--format json` @@ -103,7 +104,7 @@ Initial options: Initial mode: -- `typed-iac`: build or find the plugin binary, launch it through `plugin/external.ExternalPluginManager`, verify typed IaC service registration, call only allowed local metadata methods, and unload. +- `typed-iac`: stage a release archive or local plugin dir, launch via conformance-specific external-plugin launcher using the same gRPC protocol/adapter checks as production, verify typed IaC service registration, call only allowed local metadata methods, and unload. There is no `--strict=false` in the initial command. IaC install compatibility is strict typed-only, matching the hard-cutover direction in `decisions/0024-iac-typed-force-cutover.md`. Future Module/Step/Trigger conformance modes may be added, but their evidence must not satisfy IaC compatibility decisions. @@ -122,22 +123,25 @@ The conformance command should use a conformance-specific launcher instead of ch Staging contract: -1. Read `/plugin.json`. -2. Resolve plugin install name from `plugin.json.name`, normalized the same way `wfctl plugin install` normalizes names. -3. Create a temp installed layout: +1. If `--artifact` is set, hash archive bytes as `archiveSHA256`, extract archive into temp source dir, and use extracted contents as sole test input. +2. If `--artifact` is absent, read `/plugin.json` and treat run as local/advisory unless caller later binds it to an archive through the registry update command. +3. Resolve plugin install name from staged `plugin.json.name`, normalized the same way `wfctl plugin install` normalizes names. +4. Create a temp installed layout: ```text /plugins//plugin.json /plugins// ``` -4. If `/` exists and is executable, copy it into the staged binary path. -5. Otherwise run `go build -o /plugins// .` with working directory ``. -6. Copy `plugin.contracts.json` and other metadata files only when a conformance mode needs them. Do not copy provider credentials or local `.env` files. -7. Compute SHA-256 over the staged binary and plugin manifest. These become `artifactSHA256` and `pluginManifestSHA256` for local evidence output. -8. Launch the staged binary with working directory `/plugins/`. -9. Capture stderr/stdout into bounded ring buffers and include only tails in failure output. -10. Remove the temp directory after unload unless `--keep-temp` is added in a later debugging task. +5. If staged source contains `` and it is executable, copy it into staged binary path. +6. Otherwise run `go build -o /plugins// .` with working directory staged source dir. +7. Copy `plugin.contracts.json` and other metadata files only when a conformance mode needs them. Do not copy provider credentials or local `.env` files. +8. Compute SHA-256 over staged binary as `binarySHA256` and staged plugin manifest as `pluginManifestSHA256`. +9. For `--artifact`, evidence includes both `archiveSHA256` and `binarySHA256`; resolver matches downloads on `archiveSHA256`. +10. For local-dir runs, evidence includes `binarySHA256` but no `archiveSHA256`; registry update treats such evidence as advisory unless paired with an archive checksum matching a manifest download. +11. Launch staged binary with working directory `/plugins/`. +12. Capture stderr/stdout into bounded ring buffers and include only tails in failure output. +13. Remove temp directory after unload unless `--keep-temp` is added in a later debugging task. Timeout contract: @@ -182,14 +186,20 @@ Proposed version index shape: "evidenceDigest": "sha256:...", "os": "linux", "arch": "amd64", - "artifactSHA256": "...", + "archiveSHA256": "...", + "binarySHA256": "...", "pluginManifestSHA256": "...", + "compatibleEngineRange": { + "min": "v0.51.2", + "max": "v0.53.0", + "derivation": "min-and-latest-pass" + }, "repository": "GoCodeAlone/workflow-plugin-digitalocean", "ref": "refs/tags/v0.14.4", "commit": "abc123", "workflowRunURL": "https://github.com/GoCodeAlone/workflow-plugin-digitalocean/actions/runs/123", "generatedBy": "wfctl plugin conformance", - "signature": "" + "signature": null } ] } @@ -210,15 +220,15 @@ Manifest summary: } ``` -Evidence must bind to the artifact digest that install will fetch. A pass record is authoritative only when: +Evidence must bind to the archive digest that install will fetch. A pass record is authoritative only when: 1. `plugin`, `version`, `os`, and `arch` match the candidate install. -2. `artifactSHA256` matches the candidate download SHA-256. -3. `engineVersion` equals the current engine version, or a later explicit range rule says the current engine is compatible. +2. `archiveSHA256` matches the candidate download SHA-256. +3. `engineVersion` equals the current engine version, or `compatibleEngineRange` covers current engine and its derivation is trusted. 4. `mode` satisfies the capability being installed. For IaC providers, this is `typed-iac`. -5. The evidence comes from a trusted registry source, or is signed by a configured trusted key. +5. The evidence comes from a trusted first-party registry source. Signed third-party enforcement is deferred. -Unsigned evidence from untrusted/community registries is advisory only. It may warn or help humans choose, but it must not override `minEngineVersion` or block/allow installs by itself. The first implementation can accept trusted first-party registry evidence without signature, but the schema reserves `signature` so public/community trust can be tightened later. +Unsigned evidence from untrusted/community registries is advisory only. It may warn or help humans choose, but it must not override `minEngineVersion` or block/allow installs by itself. The first implementation accepts trusted first-party registry evidence without signatures. Signed third-party enforcement is deferred; `signature` remains `null` and non-enforcing until an ADR defines envelope, key IDs, algorithm, and canonical signed bytes. `evidenceDigest` is `sha256:` over canonical JSON for the exact evidence record with `evidenceDigest` and `signature` omitted, object keys sorted lexicographically, and UTF-8 encoding. The resolver uses it as an audit handle in lockfiles; artifact integrity still comes from download SHA-256. @@ -226,9 +236,10 @@ Failure precedence: 1. Exact matching trusted `fail` for artifact + engine + mode + platform blocks by default. 2. Exact matching trusted `pass` allows the candidate if `minEngineVersion` is also compatible. -3. Missing exact evidence for first-party registries with `evidencePolicy.firstParty=required` blocks when current engine ≥ `requiredFromEngine`. -4. Missing exact evidence for transitional or advisory registries falls back to `minEngineVersion` with a warning. -5. Evidence for a different OS/arch, mode, artifact digest, or engine version does not match. +3. Trusted pass range allows the candidate only if current engine is within `compatibleEngineRange`. +4. Missing exact or ranged evidence for first-party registries with `evidencePolicy.firstParty=required` blocks when current engine ≥ `requiredFromEngine`. +5. Missing exact/ranged evidence for transitional or advisory registries falls back to `minEngineVersion` with a warning. +6. Evidence for a different OS/arch, mode, archive digest, or engine version/range does not match. Version grammar: @@ -256,9 +267,7 @@ registries: url: https://example.com/workflow-registry priority: 10 compatibilityEvidence: - trust: signed - keys: - - "SHA256:..." + trust: advisory - name: community type: static url: https://example.net/workflow-registry @@ -272,7 +281,7 @@ Default rules: - Built-in `GoCodeAlone/workflow-registry` on `main` is `first_party`. - Static mirror `https://gocodealone.github.io/workflow-registry/v1` is `first_party` only when it is the built-in default entry. - User-configured registries default to `advisory`. -- `signed` evidence requires a matching configured key before it can enforce install decisions. +- Signed third-party evidence is out of first scope. `signed` trust mode must be rejected until a signature ADR lands. - `advisory` evidence can produce warnings and ranking notes, but cannot block or allow an install beyond `minEngineVersion`. ### Registry API @@ -298,7 +307,7 @@ Existing sources may synthesize a single-version index from `FetchManifest` so o First-scope producer path: 1. Plugin CI builds release artifacts and checksums as it does today. -2. Plugin CI runs `wfctl plugin conformance --mode typed-iac --engine-version --format json --output compatibility/--.json .` for each engine/platform matrix cell. +2. Plugin CI runs `wfctl plugin conformance --mode typed-iac --artifact dist/.tar.gz --engine-version --format json --output compatibility/--.json` for each engine/platform matrix cell. 3. Plugin CI uploads those JSON files as workflow artifacts for PRs. 4. Release CI or a maintainer-run registry job checks out `workflow-registry` and runs: @@ -310,7 +319,7 @@ First-scope producer path: --evidence compatibility/*.json ``` -5. The update command reads `plugins//manifest.json`, reads or creates `compatibility//index.json`, validates evidence against manifest/download checksums, upserts exact records, sorts versions descending by semver, sorts evidence by engine/mode/os/arch, writes a temp file, fsyncs where supported, then atomically renames into place. +5. The update command reads `plugins//manifest.json`, reads or creates `compatibility//index.json`, validates evidence `archiveSHA256` against manifest/download checksums, validates `binarySHA256` format, upserts exact records, derives range records when policy allows, sorts versions descending by semver, sorts evidence by engine/mode/os/arch, writes a temp file, fsyncs where supported, then atomically renames into place. 6. CI opens or updates a registry PR containing only manifest/index/checksum changes. No pass/fail claim should be hand-authored. `wfctl plugin conformance --format json` output is one evidence record, not the whole index. It includes: @@ -319,7 +328,8 @@ First-scope producer path: - engine version and `wfctl` version - mode/status - os/arch -- artifact SHA-256 +- archive SHA-256 when `--artifact` provided +- binary SHA-256 - plugin manifest SHA-256 - repo/ref/commit/workflow URL when CI env provides them - stderr/stdout tails only on failure @@ -329,6 +339,9 @@ Engine matrix policy: - For each plugin release, test declared `minEngineVersion` and latest Workflow release. - If they are equal, one matrix row is enough. +- Exact evidence is always recorded. +- A range is derived only when min and latest both pass, no fail evidence exists inside that closed range, and the registry update command can enumerate Workflow releases in the range. +- If any intermediate engine has explicit fail evidence, no range crosses that version. - `WORKFLOW_CURRENT_VERSION` may pin latest during release incidents. - `wfctl registry compatibility update` marks index stale when latest Workflow release is newer than newest evidence for that plugin. - In required first-party mode, stale evidence blocks install/update/lock for the newer engine. In transitional/advisory mode, stale evidence warns. @@ -339,6 +352,21 @@ Engine version source of truth: - Install/update/lock use the compiled `wfctl version` when it is a release semver. - If local `wfctl version` is a pseudo-version or `v0.0.0-*`, users may set `WFCTL_ENGINE_VERSION` or `--engine-version`; otherwise resolver treats evidence as advisory and prints that local engine version is not comparable. +Configuration ownership: + +- `--compat-mode` belongs to `plugin install`, `plugin update`, and `plugin lock`. +- `WFCTL_PLUGIN_COMPAT_MODE` is process-wide fallback. +- Project config fallback lives in `wfctl.yaml`: + + ```yaml + version: 1 + plugin: + compatibilityEnforcement: enforce + ``` + +- User/global fallback may live in `~/.config/wfctl/config.yaml` under the same `plugin.compatibilityEnforcement` key. +- Registry config owns registry trust only, not enforcement mode. + ### Install, Update, And Lock Resolution Shared resolver behavior: @@ -346,7 +374,8 @@ Shared resolver behavior: 1. Resolve candidate plugin versions from the registry version index. 2. Filter out versions with `minEngineVersion` greater than the current engine. 3. For the current OS/arch, rank candidates: - - exact trusted pass evidence for current engine + required mode + artifact digest + - exact trusted pass evidence for current engine + required mode + archive digest + - trusted range pass covering current engine + required mode + archive digest - compatible `minEngineVersion` but missing evidence - exact trusted fail evidence is excluded 4. Install the newest highest-ranked compatible candidate. @@ -390,7 +419,8 @@ plugins: engineVersion: v0.51.2 mode: typed-iac status: pass - artifactSHA256: ... + archiveSHA256: ... + binarySHA256: ... evidenceDigest: sha256:... forced: false reason: "" @@ -419,6 +449,7 @@ Precedence is CLI > env > config > default. In `warn` mode, trusted fail evidenc - Conformance timeout: kill the plugin subprocess, unload it, and report timeout as failure. - Mismatched evidence digest: ignore the evidence and warn; never use digest-mismatched evidence to allow or block. - Registry/index source mismatch: ignore the index unless the manifest's cross-source pointer is trusted. +- Conformance evidence without `archiveSHA256`: advisory only for registry enforcement. ## Security @@ -427,7 +458,7 @@ Precedence is CLI > env > config > default. In `warn` mode, trusted fail evidenc - Initial typed-IaC checks must call only local metadata methods and must not call resource or credential operations. - JSON output must not include environment variables, secret values, or full process command lines containing tokens. - Install-time `--force` should be visible in lockfiles so reviewers can detect intentionally bypassed compatibility checks. -- Compatibility evidence that lacks a matching artifact digest or trusted provenance is advisory only. +- Compatibility evidence that lacks a matching archive digest or trusted provenance is advisory only. - Secret-bearing release/registry jobs should run only on tags, protected branches, or maintainer-triggered workflows. Fork PRs may run fixture conformance and build checks, but must not execute arbitrary released plugin binaries while organization tokens are available. ## Testing @@ -447,6 +478,9 @@ Unit tests: - `--compat-mode`, `WFCTL_PLUGIN_COMPAT_MODE`, and config precedence. - Same-source manifest/index resolution in `MultiRegistry`. - Version canonicalization for `v0.51.2` and `0.51.2`. +- Archive SHA vs binary SHA validation and matching. +- Range derivation from min/latest pass and explicit intermediate fail. +- `wfctl.yaml` and global config loading for `plugin.compatibilityEnforcement`. Integration tests: - Fake typed-IaC plugin passing `typed-iac`. @@ -456,10 +490,12 @@ Integration tests: - Fake conformance plugin that hangs during handshake and is killed on timeout. - Fake local plugin directory staged into installed layout with expected binary name. - Fake registry compatibility update from two conformance JSON files. +- Fake archive conformance proving extracted release artifact, not source dir. Runtime validation: - Build local `wfctl`. - Run `wfctl plugin conformance --mode typed-iac` against a fixture plugin. +- Run `wfctl plugin conformance --mode typed-iac --artifact ` and verify archive/binary hashes in JSON. - Run `wfctl registry compatibility update` against a temp registry checkout and verify stable index diff. - Run `wfctl plugin conformance --mode typed-iac` against the DigitalOcean plugin in CI after the plugin adopts the command. @@ -477,16 +513,16 @@ Runtime-affecting rollback path: - Workflow engine releases remain tagged and fetchable from GitHub. - Plugin CI has access to `RELEASES_TOKEN` for private Workflow release metadata and assets when required. -- Compatibility evidence can initially be generated for plugin `minEngineVersion` and latest released engine rather than every engine release in between. +- Compatibility evidence can initially be generated for plugin `minEngineVersion` and latest released engine, with explicit derived ranges only when registry update can enumerate releases and sees no fail inside range. - Plugin manifests can grow additive optional fields without breaking older `wfctl` versions. - The first compatibility consumers are Go external IaC plugins; UI-only and non-IaC plugin compatibility can be modeled later. -- First-party registry evidence can be treated as trusted when served from GoCodeAlone-controlled registries; community evidence needs signatures before it can enforce install decisions. +- First-party registry evidence can be treated as trusted when served from GoCodeAlone-controlled registries; community evidence remains advisory until a signature ADR lands. - The first conformance launcher can duplicate a small amount of production plugin launch behavior to obtain timeout and staging semantics without destabilizing runtime plugin loading. ## Self-Challenge 1. The laziest plausible solution is to keep one shared shell script copied into each plugin. That would solve CI drift for a few repos but would not let `wfctl plugin install` reason about compatibility, so it fails the install-resolution goal. -2. The most fragile assumption is that evidence can safely influence installs. The design binds evidence to artifact digests and treats unsigned/untrusted evidence as advisory only. +2. The most fragile assumption is that evidence can safely influence installs. The design binds evidence to archive digests and treats unsigned/untrusted evidence as advisory only. 3. The design risks adding too much generality through many modes. The first implementation keeps only `typed-iac`; other plugin families can add modes after the evidence path is proven. ## Open Questions From 9c7a770cf4d7c3723c09c3ea9a2676f6c5c12ff5 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 00:35:18 -0400 Subject: [PATCH 07/29] docs: record artifact-first design review --- ...ance-compat-design.adversarial-review-4.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-4.md diff --git a/docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-4.md b/docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-4.md new file mode 100644 index 00000000..b8dd970e --- /dev/null +++ b/docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-4.md @@ -0,0 +1,36 @@ +### Adversarial Review Report + +**Phase:** design +**Artifact:** `docs/plans/2026-05-11-plugin-conformance-compat-design.md` +**Status:** FAIL + +**Findings (Critical):** +- None. + +**Findings (Important):** +- [missing failure modes] Conformance evidence was bound primarily to a staged binary hash, while users install release archives. A maintainer could test one binary and publish a different archive with the same plugin version. Recommendation: make release archive SHA-256 the resolver's authoritative digest and keep binary SHA-256 as diagnostic metadata. +- [unstated assumptions] The design treated local source-tree conformance as equivalent to release artifact conformance. That collapses if packaging scripts omit files, mutate manifests, or build different platform binaries. Recommendation: add `wfctl plugin conformance --artifact ` and mark local-dir runs advisory unless later bound to archive checksums. +- [missing failure modes] The engine compatibility matrix used exact evidence plus min/latest checks but did not define how intermediate Workflow releases become trusted. Recommendation: store exact evidence by default; derive ranges only when the updater can enumerate and prove no failed engine exists inside the range. +- [repo-precedent conflicts] Configuration ownership for warn/enforce/ignore was undefined and risked scattering policy across registry metadata, environment variables, and installer flags. Recommendation: define CLI/env/project/global precedence and keep registry metadata limited to evidence trust. + +**Findings (Minor):** +- [YAGNI] A `signature` field appeared in first-scope evidence without key, envelope, or canonical-byte semantics. Recommendation: reject signed trust mode until a separate signature ADR exists. +- [repo-precedent conflicts] The design referenced production `ExternalPluginManager` while also requiring conformance-specific timeout behavior. Recommendation: use a dedicated conformance launcher first, then share internals after semantics prove out. + +**Bug-class scan transcript:** +| Class | Result | Note | +|---|---|---| +| Unstated assumptions | Finding | Local source-tree tests were assumed to prove packaged release artifacts. | +| Repo-precedent conflicts | Finding | Policy ownership was not aligned with existing wfctl project/global config patterns. | +| YAGNI violations | Finding | Signature support appeared before trust envelope requirements existed. | +| Missing failure modes | Finding | Release archive substitution and intermediate engine gaps were not covered. | +| Security / privacy at architecture level | Clean | No new secret flow or network-exposed service in this design. | +| Rollback story | Clean | Resolver modes permit warning-only rollout and emergency ignore. | +| Simpler alternative not considered | Clean | Manifest-only minEngine was already compared and rejected because it cannot catch strict protocol drift. | +| User-intent drift | Clean | The design stays focused on scalable plugin/engine compatibility evidence for wfctl and plugins. | + +**Options the author may not have considered:** +1. Archive-only evidence, no binary hash: simpler resolver state, but weaker diagnostics when a packaged archive contains multiple binaries or the staged binary differs by platform. +2. Per-release GitHub artifact attestations first: stronger provenance, but pushes this work into a signature/attestation design before the immediate compatibility workflow can ship. + +**Verdict reasoning:** FAIL because install decisions must bind to the artifact users actually download, and because trusted range semantics and policy ownership must be explicit before implementation. From fd1246a11c8ba895ed9399f586143ebbca35de5c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 00:36:25 -0400 Subject: [PATCH 08/29] docs: clarify conformance evidence scope --- ...ance-compat-design.adversarial-review-5.md | 35 +++++++++++++++++++ ...-05-11-plugin-conformance-compat-design.md | 16 +++++---- 2 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-5.md diff --git a/docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-5.md b/docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-5.md new file mode 100644 index 00000000..70a32c9a --- /dev/null +++ b/docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-5.md @@ -0,0 +1,35 @@ +### Adversarial Review Report + +**Phase:** design +**Artifact:** `docs/plans/2026-05-11-plugin-conformance-compat-design.md` +**Status:** FAIL + +**Findings (Critical):** +- None. + +**Findings (Important):** +- [unstated assumptions] Command syntax only showed `` even though authoritative evidence requires `--artifact`. Recommendation: document `--artifact` as an alternate required input and require exactly one source. +- [YAGNI] The first-scope evidence schema still included `signature: null` while signed third-party trust was deferred. Recommendation: remove the field until signature envelope/key semantics are designed. +- [repo-precedent conflicts] Enforcement controls said `Registry/project config fallback`, conflicting with the design's statement that registry config owns trust only. Recommendation: make enforcement precedence CLI > env > project/global config > default. +- [missing failure modes] Compatibility index unavailability said installs should warn, contradicting required first-party evidence behavior. Recommendation: route unavailable index behavior through evidence policy and compatibility mode. + +**Findings (Minor):** +- [missing failure modes] Resolver engine override was mentioned but not assigned to install/update/lock command flags. Recommendation: state ownership for `--engine-version` and `WFCTL_ENGINE_VERSION`. + +**Bug-class scan transcript:** +| Class | Result | Note | +|---|---|---| +| Unstated assumptions | Finding | Artifact mode was required for authoritative evidence but not encoded in command syntax. | +| Repo-precedent conflicts | Finding | Registry trust and user enforcement mode were still partly conflated. | +| YAGNI violations | Finding | Signature placeholder leaked into first-scope schema without a signing design. | +| Missing failure modes | Finding | Required evidence and index-unavailable behavior conflicted. | +| Security / privacy at architecture level | Clean | Secret handling remains limited to release/registry jobs and no provider credentials enter conformance. | +| Rollback story | Clean | Warn mode and additive metadata provide rollback. | +| Simpler alternative not considered | Clean | Manifest-only compatibility remains insufficient for strict host/plugin drift. | +| User-intent drift | Clean | The design continues to target scalable plugin compatibility for wfctl. | + +**Options the author may not have considered:** +1. Make `--artifact` mandatory always: stronger evidence, but worse local developer ergonomics; local advisory mode is acceptable if clearly marked non-authoritative. +2. Remove install-time enforcement from first scope: lowers risk, but misses the user goal of letting `wfctl` choose compatible plugin versions. + +**Verdict reasoning:** FAIL because command input, schema scope, config ownership, and unavailable-index behavior must be unambiguous before planning. diff --git a/docs/plans/2026-05-11-plugin-conformance-compat-design.md b/docs/plans/2026-05-11-plugin-conformance-compat-design.md index b23e5eee..94ff0fca 100644 --- a/docs/plans/2026-05-11-plugin-conformance-compat-design.md +++ b/docs/plans/2026-05-11-plugin-conformance-compat-design.md @@ -91,8 +91,11 @@ Add: ```sh wfctl plugin conformance [options] +wfctl plugin conformance --artifact [options] ``` +Exactly one of `` or `--artifact ` is required. + Initial options: - `--mode typed-iac` @@ -198,8 +201,7 @@ Proposed version index shape: "ref": "refs/tags/v0.14.4", "commit": "abc123", "workflowRunURL": "https://github.com/GoCodeAlone/workflow-plugin-digitalocean/actions/runs/123", - "generatedBy": "wfctl plugin conformance", - "signature": null + "generatedBy": "wfctl plugin conformance" } ] } @@ -228,9 +230,9 @@ Evidence must bind to the archive digest that install will fetch. A pass record 4. `mode` satisfies the capability being installed. For IaC providers, this is `typed-iac`. 5. The evidence comes from a trusted first-party registry source. Signed third-party enforcement is deferred. -Unsigned evidence from untrusted/community registries is advisory only. It may warn or help humans choose, but it must not override `minEngineVersion` or block/allow installs by itself. The first implementation accepts trusted first-party registry evidence without signatures. Signed third-party enforcement is deferred; `signature` remains `null` and non-enforcing until an ADR defines envelope, key IDs, algorithm, and canonical signed bytes. +Unsigned evidence from untrusted/community registries is advisory only. It may warn or help humans choose, but it must not override `minEngineVersion` or block/allow installs by itself. The first implementation accepts trusted first-party registry evidence without signatures. Signed third-party enforcement is deferred; the first-scope schema does not include a signature field, and any future signature support needs an ADR defining envelope, key IDs, algorithm, and canonical signed bytes. -`evidenceDigest` is `sha256:` over canonical JSON for the exact evidence record with `evidenceDigest` and `signature` omitted, object keys sorted lexicographically, and UTF-8 encoding. The resolver uses it as an audit handle in lockfiles; artifact integrity still comes from download SHA-256. +`evidenceDigest` is `sha256:` over canonical JSON for the exact evidence record with `evidenceDigest` omitted, object keys sorted lexicographically, and UTF-8 encoding. The resolver uses it as an audit handle in lockfiles; artifact integrity still comes from download SHA-256. Failure precedence: @@ -355,7 +357,9 @@ Engine version source of truth: Configuration ownership: - `--compat-mode` belongs to `plugin install`, `plugin update`, and `plugin lock`. +- `--engine-version` belongs to `plugin install`, `plugin update`, and `plugin lock` for local pseudo-version and test scenarios. - `WFCTL_PLUGIN_COMPAT_MODE` is process-wide fallback. +- `WFCTL_ENGINE_VERSION` is process-wide fallback for resolver engine comparison. - Project config fallback lives in `wfctl.yaml`: ```yaml @@ -432,7 +436,7 @@ Enforcement controls: 1. CLI flag on install/update/lock: `--compat-mode enforce|warn`. 2. Environment fallback: `WFCTL_PLUGIN_COMPAT_MODE=enforce|warn`. -3. Registry/project config fallback: `plugin.compatibilityEnforcement: enforce|warn`. +3. Project/global config fallback: `plugin.compatibilityEnforcement: enforce|warn`. 4. Default: `enforce`. Precedence is CLI > env > config > default. In `warn` mode, trusted fail evidence never blocks install/update/lock, but the command must print the warning and record platform compatibility with `status: fail`, `forced: true`, and `reason: compat-mode=warn`. Explicit `--force` in enforce mode records `forced: true` and `reason: force-install`, `force-update`, or `force-lock`. @@ -443,7 +447,7 @@ Precedence is CLI > env > config > default. In `warn` mode, trusted fail evidenc - Handshake failure: fail and include plugin stderr tail when available. - Unsupported conformance mode: fail with available mode suggestions from manifest capabilities. - Engine version not found: fail unless the caller supplied `--engine-version local`. -- Compatibility index unavailable: warn for installs, fail for explicit evidence publishing when added later. +- Compatibility index unavailable: enforce/warn according to evidence policy and compatibility mode. Required first-party evidence blocks in enforce mode; advisory/transitional evidence warns. - Compatibility update fails validation: leave existing index untouched; write no partial file. - Fork PR without `RELEASES_TOKEN`: CI may fall back to public release lookup; private-release checks should skip or fail with a token-specific message. - Conformance timeout: kill the plugin subprocess, unload it, and report timeout as failure. From 361b803253239006fa71ca140eb612b81fd125c2 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 00:36:58 -0400 Subject: [PATCH 09/29] docs: pass conformance design review --- ...ance-compat-design.adversarial-review-6.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-6.md diff --git a/docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-6.md b/docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-6.md new file mode 100644 index 00000000..058461b3 --- /dev/null +++ b/docs/plans/2026-05-11-plugin-conformance-compat-design.adversarial-review-6.md @@ -0,0 +1,34 @@ +### Adversarial Review Report + +**Phase:** design +**Artifact:** `docs/plans/2026-05-11-plugin-conformance-compat-design.md` +**Status:** PASS + +**Findings (Critical):** +- None. + +**Findings (Important):** +- None. + +**Findings (Minor):** +- [missing failure modes] `--engine-version local` is named in failure handling but not fully specified as a literal accepted value. Recommendation: plan should either implement `local` as a documented advisory sentinel or remove that wording during implementation. +- [YAGNI] Cross-source compatibility index pointers are allowed when trusted, but first-scope implementation may not need them. Recommendation: plan should keep cross-source pointers out of first task unless existing manifest fields already require them. +- [repo-precedent conflicts] The design adds a global config file path while some current plugin-lock tests intentionally avoid home/default registry lookup. Recommendation: plan should isolate user-global config reads to install/update flows or add explicit tests for lock behavior. + +**Bug-class scan transcript:** +| Class | Result | Note | +|---|---|---| +| Unstated assumptions | Clean | Artifact-first evidence, pseudo-version override, trust source, and policy ownership are now explicit. | +| Repo-precedent conflicts | Finding | Global config behavior needs careful tests because lock code currently avoids some home/default registry lookups. | +| YAGNI violations | Finding | Cross-source index pointers are future-facing and should not expand first implementation unless already present. | +| Missing failure modes | Finding | Engine sentinel wording needs exact implementation semantics. | +| Security / privacy at architecture level | Clean | Conformance uses local metadata calls only, avoids provider credentials, and treats untrusted evidence as advisory. | +| Rollback story | Clean | Warn mode, additive metadata, and plugin-local script retention provide rollback. | +| Simpler alternative not considered | Clean | Manifest-only and per-plugin scripts are explicitly considered and rejected. | +| User-intent drift | Clean | Design targets wfctl/plugin compatibility, version selection, and reusable plugin CI conformance as requested. | + +**Options the author may not have considered:** +1. Implement conformance evidence and registry update first, leaving install enforcement warn-only for one release. This reduces breakage risk but delays the install-time protection the user asked for. +2. Make compatibility index fetch best-effort until registries publish indexes for all first-party plugins. This smooths rollout but weakens the "required from engine" semantics. + +**Verdict reasoning:** PASS. Remaining findings are implementation-plan guardrails, not design blockers. The artifact-first evidence model, trust boundaries, config precedence, resolver behavior, rollback path, and test surface are specific enough for planning. From 3af5fad98ecae6411fa8032ac6d59939fc0fb14b Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 00:39:52 -0400 Subject: [PATCH 10/29] docs: plan plugin compat implementation --- .../2026-05-11-plugin-conformance-compat.md | 455 ++++++++++++++++++ 1 file changed, 455 insertions(+) create mode 100644 docs/plans/2026-05-11-plugin-conformance-compat.md diff --git a/docs/plans/2026-05-11-plugin-conformance-compat.md b/docs/plans/2026-05-11-plugin-conformance-compat.md new file mode 100644 index 00000000..9507a5ed --- /dev/null +++ b/docs/plans/2026-05-11-plugin-conformance-compat.md @@ -0,0 +1,455 @@ +# Plugin Conformance Compatibility Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add artifact-bound plugin conformance evidence to `wfctl` and use it during plugin install/update/lock resolution. + +**Architecture:** `wfctl plugin conformance` produces one strict typed-IaC evidence record for a local plugin dir or release archive. Registry sources fetch/synthesize compatibility indexes, and shared resolver code ranks or rejects plugin versions using trusted archive-digest-bound evidence. Lockfiles record platform-scoped compatibility metadata without breaking older fields. + +**Tech Stack:** Go CLI (`flag`), `gopkg.in/yaml.v3`, stdlib JSON/tar/gzip/sha256/process APIs, existing `go-plugin` external plugin protocol, existing wfctl registry/install/lock code. + +**Base branch:** main + +--- + +## Scope Manifest + +**PR Count:** 1 +**Tasks:** 7 +**Estimated Lines of Change:** ~1800 + +**Out of scope:** +- Plugin repo adoption PRs before the `wfctl plugin conformance` command lands. Follow-up PRs should update `workflow-plugin-digitalocean`, `workflow-plugin-aws`, and other typed-IaC plugins to call this command. +- Signed third-party compatibility evidence. +- Live provider acceptance tests that call cloud APIs or require credentials. +- Hosted compatibility service. +- Cross-source compatibility index pointers unless current manifests already require them. + +**PR Grouping:** + +| PR # | Title | Tasks | Branch | +|------|-------|-------|--------| +| 1 | Add wfctl plugin conformance compatibility | Task 1, Task 2, Task 3, Task 4, Task 5, Task 6, Task 7 | codex/plugin-conformance-compat | + +**Status:** Draft + +## Task 1: Evidence Model And Semver Utilities + +**Files:** +- Create: `cmd/wfctl/plugin_compat_model.go` +- Create: `cmd/wfctl/plugin_compat_model_test.go` +- Modify: `cmd/wfctl/registry_config.go` + +**Step 1: Write failing tests** + +Add tests for: +- optional leading `v` canonicalization: `0.51.2` and `v0.51.2` both emit `v0.51.2` +- invalid version rejection: `main`, `v0.0.0-20260510`, `1.2` +- `evidenceDigest` canonical JSON excludes only `evidenceDigest` +- `archiveSHA256` and `binarySHA256` validation require lowercase/uppercase hex acceptance but normalized lowercase output +- `RegistrySourceConfig.compatibilityEvidence.trust` parses `first_party` and `advisory`, rejects `signed` + +Run: `GOWORK=off go test ./cmd/wfctl -run 'TestPluginCompat(Model|Digest|Version|Trust)' -count=1` + +Expected: FAIL with missing types/functions. + +**Step 2: Implement model helpers** + +Create: +- `PluginVersionIndex` +- `PluginVersionRecord` +- `PluginCompatibilityEvidence` +- `PluginCompatibilityRange` +- `CompatibilityEvidencePolicy` +- `CompatibilityTrustMode` +- `CanonicalPluginVersion` +- `CanonicalEngineVersion` +- `NormalizeSHA256Hex` +- `ComputeEvidenceDigest` +- `ValidateCompatibilityEvidence` + +Extend `RegistrySourceConfig`: + +```go +CompatibilityEvidence RegistryCompatibilityEvidenceConfig `yaml:"compatibilityEvidence,omitempty" json:"compatibilityEvidence,omitempty"` +``` + +Use strict enums: +- `first_party` +- `advisory` + +Reject `signed` with an error until the signature ADR exists. + +**Step 3: Verify** + +Run: `GOWORK=off go test ./cmd/wfctl -run 'TestPluginCompat(Model|Digest|Version|Trust)' -count=1` + +Expected: PASS. + +**Step 4: Commit** + +```bash +git add cmd/wfctl/plugin_compat_model.go cmd/wfctl/plugin_compat_model_test.go cmd/wfctl/registry_config.go +git commit -m "feat(wfctl): add plugin compat evidence model" +``` + +Rollback: revert this commit; no runtime paths use the new model yet. + +## Task 2: Registry Version Index Fetching + +**Files:** +- Modify: `cmd/wfctl/registry_source.go` +- Modify: `cmd/wfctl/multi_registry.go` +- Modify: `cmd/wfctl/registry_source_test.go` +- Modify: `cmd/wfctl/multi_registry_test.go` + +**Step 1: Write failing tests** + +Add tests for: +- GitHub source fetches `compatibility//index.json` +- Static source fetches `{baseURL}/compatibility//index.json` +- missing index synthesizes a single-version index from `FetchManifest` +- `MultiRegistry.FetchVersionIndex` returns index from same source that resolved manifest +- original-name then normalized-name lookup matches `FetchManifest` +- default GoCodeAlone registry entries are `first_party`; user registries default `advisory` + +Run: `GOWORK=off go test ./cmd/wfctl -run 'Test(GitHub|Static|MultiRegistry).*VersionIndex|TestRegistryCompatibilityTrustDefaults' -count=1` + +Expected: FAIL with missing `FetchVersionIndex`. + +**Step 2: Implement source API** + +Add `FetchVersionIndex(name string) (*PluginVersionIndex, error)` to `RegistrySource`. + +Implement: +- `GitHubRegistrySource.FetchVersionIndex` +- `StaticRegistrySource.FetchVersionIndex` +- `MultiRegistry.FetchVersionIndex` +- synthetic single-version fallback from manifest when native index is missing +- trust derivation from `RegistrySourceConfig` + +Keep same-source invariant: once a manifest resolves from source `S`, index lookup for install/update/lock uses `S` unless there is no native index and synthetic fallback is needed. + +**Step 3: Verify** + +Run: `GOWORK=off go test ./cmd/wfctl -run 'Test(GitHub|Static|MultiRegistry).*VersionIndex|TestRegistryCompatibilityTrustDefaults' -count=1` + +Expected: PASS. + +**Step 4: Commit** + +```bash +git add cmd/wfctl/registry_source.go cmd/wfctl/multi_registry.go cmd/wfctl/registry_source_test.go cmd/wfctl/multi_registry_test.go +git commit -m "feat(wfctl): fetch plugin version indexes" +``` + +Rollback: revert this commit plus Task 1 if interface churn blocks builds. + +## Task 3: `wfctl plugin conformance` + +**Files:** +- Modify: `cmd/wfctl/plugin.go` +- Create: `cmd/wfctl/plugin_conformance.go` +- Create: `cmd/wfctl/plugin_conformance_test.go` +- Create: `cmd/wfctl/testdata/conformance/iac-pass/go.mod` +- Create: `cmd/wfctl/testdata/conformance/iac-pass/main.go` +- Create: `cmd/wfctl/testdata/conformance/iac-pass/plugin.json` +- Create: `cmd/wfctl/testdata/conformance/iac-hang/go.mod` +- Create: `cmd/wfctl/testdata/conformance/iac-hang/main.go` +- Create: `cmd/wfctl/testdata/conformance/iac-hang/plugin.json` + +**Step 1: Write failing tests** + +Add tests for: +- `wfctl plugin conformance --help` lists `--artifact`, `--mode`, `--engine-version`, `--timeout` +- exactly one of `` or `--artifact` is required +- fake typed-IaC plugin passes and emits JSON with `status:"pass"`, `mode:"typed-iac"`, `binarySHA256`, `pluginManifestSHA256`, `evidenceDigest` +- archive mode emits `archiveSHA256` matching the tarball and uses extracted contents, not source dir +- fake plugin with no typed-IaC service fails +- hanging plugin is killed on timeout and emits bounded stderr/stdout tails +- no provider credential/env data appears in JSON output + +Run: `GOWORK=off go test ./cmd/wfctl -run 'TestPluginConformance' -count=1` + +Expected: FAIL with unknown subcommand. + +**Step 2: Implement command** + +Add `conformance` to `runPlugin` and usage. + +Implement: +- local-dir staging +- `--artifact` tar.gz extraction and archive hashing +- installed layout staging under temp dir +- build fallback with `go build -o /plugins// .` +- conformance-specific launcher with timeout and process kill +- typed-IaC service discovery through existing plugin protocol/contract registry +- metadata-only RPC checks; no resource/credential calls +- JSON/text output + +Document `--engine-version local` as an advisory sentinel, or remove sentinel wording and make non-semver engine versions advisory through `WFCTL_ENGINE_VERSION`. + +**Step 3: Verify CLI behavior** + +Run: `GOWORK=off go test ./cmd/wfctl -run 'TestPluginConformance' -count=1` + +Expected: PASS. + +Run: `GOWORK=off go run ./cmd/wfctl plugin conformance --mode typed-iac --format json ./cmd/wfctl/testdata/conformance/iac-pass` + +Expected: JSON contains `"status":"pass"` and no environment variable values. + +**Step 4: Commit** + +```bash +git add cmd/wfctl/plugin.go cmd/wfctl/plugin_conformance.go cmd/wfctl/plugin_conformance_test.go cmd/wfctl/testdata/conformance +git commit -m "feat(wfctl): add plugin conformance command" +``` + +Rollback: revert this commit; plugin loading runtime falls back to existing `ExternalPluginManager` behavior. + +## Task 4: Registry Compatibility Update Command + +**Files:** +- Modify: `cmd/wfctl/registry_container.go` +- Create: `cmd/wfctl/registry_compatibility.go` +- Create: `cmd/wfctl/registry_compatibility_test.go` + +**Step 1: Write failing tests** + +Add tests for: +- `wfctl registry compatibility update --help` +- update reads `plugins//manifest.json` +- update validates evidence plugin/version/mode/status/os/arch/engine +- update rejects evidence whose `archiveSHA256` does not match a manifest download +- update writes `compatibility//index.json` atomically +- update sorts versions descending and evidence by engine/mode/os/arch +- update leaves existing index untouched on validation failure +- range derivation only occurs when min/latest pass and no explicit fail exists inside enumerated range + +Run: `GOWORK=off go test ./cmd/wfctl -run 'TestRegistryCompatibilityUpdate' -count=1` + +Expected: FAIL with unknown subcommand. + +**Step 2: Implement command** + +Add `compatibility update` under `wfctl registry` because this command edits registry files, not plugin install config. + +Implement flags: +- `--registry-dir` +- `--plugin` +- `--version` +- repeatable `--evidence` +- optional `--derive-ranges` +- optional `--latest-engine` + +Use temp-file write, fsync where supported, and atomic rename. + +**Step 3: Verify CLI behavior** + +Run: `GOWORK=off go test ./cmd/wfctl -run 'TestRegistryCompatibilityUpdate' -count=1` + +Expected: PASS. + +Run against a temp test registry fixture and inspect stable JSON diff. + +Expected: index file contains one version record with validated evidence. + +**Step 4: Commit** + +```bash +git add cmd/wfctl/registry_container.go cmd/wfctl/registry_compatibility.go cmd/wfctl/registry_compatibility_test.go +git commit -m "feat(wfctl): update registry compat indexes" +``` + +Rollback: revert this commit; generated indexes can still be produced manually from evidence JSON but should not be trusted for install enforcement. + +## Task 5: Compatibility Resolver For Install And Update + +**Files:** +- Create: `cmd/wfctl/plugin_compat_resolver.go` +- Create: `cmd/wfctl/plugin_compat_resolver_test.go` +- Modify: `cmd/wfctl/plugin_install.go` +- Modify: `cmd/wfctl/plugin_update_test.go` +- Modify: `cmd/wfctl/plugin_install_test.go` + +**Step 1: Write failing tests** + +Add tests for: +- newest exact trusted pass wins +- newer exact trusted fail is skipped in favor of older pass +- requested `@` fails on exact trusted fail in enforce mode +- `--compat-mode warn` permits known-fail and records forced reason +- `--force` permits known-fail while still enforcing checksum unless `--skip-checksum` +- missing required first-party evidence blocks at/after `requiredFromEngine` +- missing advisory evidence falls back to `minEngineVersion` with warning +- pseudo local `wfctl version` makes evidence advisory unless `WFCTL_ENGINE_VERSION` or `--engine-version` supplies semver + +Run: `GOWORK=off go test ./cmd/wfctl -run 'TestPluginCompatResolver|TestRunPluginInstall.*Compat|TestRunPluginUpdate.*Compat' -count=1` + +Expected: FAIL with missing resolver and flags. + +**Step 2: Implement resolver** + +Implement shared resolver: +- candidate collection from version index +- semver filter by `minEngineVersion` +- current platform evidence match by `archiveSHA256` +- exact fail/pass/range precedence +- `enforce|warn` mode from CLI > env > config > default +- `--engine-version` and `WFCTL_ENGINE_VERSION` +- force reason values: `force-install`, `force-update`, `compat-mode=warn` + +Wire into: +- `runPluginInstall` +- `runPluginUpdate` + +Keep direct `--url`, `--local`, and GitHub fallback paths outside compatibility enforcement because they are not registry-index-backed. + +**Step 3: Verify** + +Run: `GOWORK=off go test ./cmd/wfctl -run 'TestPluginCompatResolver|TestRunPluginInstall.*Compat|TestRunPluginUpdate.*Compat' -count=1` + +Expected: PASS. + +Run: `GOWORK=off go test ./cmd/wfctl -run 'TestPluginInstallE2E|TestRunPluginUpdate' -count=1` + +Expected: PASS. + +**Step 4: Commit** + +```bash +git add cmd/wfctl/plugin_compat_resolver.go cmd/wfctl/plugin_compat_resolver_test.go cmd/wfctl/plugin_install.go cmd/wfctl/plugin_install_test.go cmd/wfctl/plugin_update_test.go +git commit -m "feat(wfctl): resolve plugins by compat evidence" +``` + +Rollback: set `WFCTL_PLUGIN_COMPAT_MODE=warn` for emergency rollout; revert this commit to restore manifest-only install/update selection. + +## Task 6: Lockfile Compatibility Metadata + +**Files:** +- Modify: `config/wfctl_lockfile.go` +- Modify: `config/wfctl_lockfile_test.go` +- Modify: `cmd/wfctl/plugin_lock.go` +- Modify: `cmd/wfctl/plugin_lock_test.go` + +**Step 1: Write failing tests** + +Add tests for: +- lockfile writes platform compatibility metadata under `platforms..compatibility` +- lockfile round-trips additive fields without dropping URL/SHA256 +- `plugin lock` chooses newest compatible version when manifest omits version +- explicit manifest version fails on known-fail evidence in enforce mode +- warn mode keeps known-fail with `forced:true` and `reason: compat-mode=warn` +- project-local registry config still controls lock registry enrichment; user-global config must not silently replace it unless explicitly tested and intended +- older lockfile fixtures without compatibility metadata still load + +Run: `GOWORK=off go test ./config ./cmd/wfctl -run 'Test(WfctlLockfile|PluginLock).*Compat|TestPluginLock_FromManifest' -count=1` + +Expected: FAIL with missing compatibility fields. + +**Step 2: Implement lock metadata** + +Add `WfctlLockCompatibility` to `config`. + +Extend `WfctlLockPlatform` with: + +```go +Compatibility *WfctlLockCompatibility `yaml:"compatibility,omitempty"` +``` + +Update deterministic YAML writer to include compatibility fields in stable order. + +Wire `runPluginLockFromManifest` through the shared compatibility resolver, preserving the project-local registry lookup behavior already covered by tests. + +**Step 3: Verify** + +Run: `GOWORK=off go test ./config ./cmd/wfctl -run 'Test(WfctlLockfile|PluginLock).*Compat|TestPluginLock_FromManifest' -count=1` + +Expected: PASS. + +Run: `GOWORK=off go test ./cmd/wfctl -run 'TestPluginLock' -count=1` + +Expected: PASS. + +**Step 4: Commit** + +```bash +git add config/wfctl_lockfile.go config/wfctl_lockfile_test.go cmd/wfctl/plugin_lock.go cmd/wfctl/plugin_lock_test.go +git commit -m "feat(wfctl): lock plugin compat metadata" +``` + +Rollback: revert this commit; generated lockfiles can be regenerated without compatibility metadata. + +## Task 7: Documentation, Broad Verification, And Runtime Validation + +**Files:** +- Modify: `docs/WFCTL.md` +- Modify: `docs/plans/2026-05-11-plugin-conformance-compat.md` + +**Step 1: Document commands** + +Add concise docs for: +- `wfctl plugin conformance` +- `wfctl registry compatibility update` +- registry `compatibilityEvidence.trust` +- install/update/lock `--compat-mode` +- `WFCTL_PLUGIN_COMPAT_MODE` +- `WFCTL_ENGINE_VERSION` +- plugin CI adoption sketch using `setup-wfctl` + +**Step 2: Focused verification** + +Run: + +```bash +GOWORK=off go test ./cmd/wfctl ./config ./plugin/external ./plugin/external/sdk -count=1 +``` + +Expected: PASS. + +**Step 3: Broader verification** + +Run: + +```bash +GOWORK=off go test ./... -count=1 +``` + +Expected: PASS, or documented unrelated existing failures. + +**Step 4: Runtime validation** + +Run: + +```bash +GOWORK=off go build -o /tmp/wfctl-compat ./cmd/wfctl +/tmp/wfctl-compat plugin conformance --mode typed-iac --format json ./cmd/wfctl/testdata/conformance/iac-pass +/tmp/wfctl-compat plugin conformance --mode typed-iac --artifact /tmp/wfctl-iac-pass.tar.gz --format json +/tmp/wfctl-compat registry compatibility update --registry-dir /tmp/wfctl-test-registry --plugin workflow-plugin-test --version v0.1.0 --evidence /tmp/wfctl-evidence.json +``` + +Expected: +- build exits 0 +- conformance JSON includes `status:"pass"` +- archive conformance JSON includes matching `archiveSHA256` +- registry update writes `compatibility/workflow-plugin-test/index.json` + +**Step 5: Commit** + +```bash +git add docs/WFCTL.md docs/plans/2026-05-11-plugin-conformance-compat.md +git commit -m "docs(wfctl): document plugin compat conformance" +``` + +Rollback: docs-only revert. + +## Final PR Checklist + +- Run `git status --short`. +- Run `git log --oneline origin/main..HEAD`. +- Run `GOWORK=off go test ./cmd/wfctl ./config ./plugin/external ./plugin/external/sdk -count=1`. +- Run runtime validation from Task 7. +- Open PR against `GoCodeAlone/workflow`. +- Start PR monitoring and address CI/review findings. +- After merge and release, create follow-up plugin PRs to replace repo-local conformance scripts with `wfctl plugin conformance --artifact`. From 43a3c125bd399710d5dfc70151a5b2dacccf4125 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 00:40:49 -0400 Subject: [PATCH 11/29] docs: align compat plan with plugin registry --- ...-05-11-plugin-conformance-compat-design.md | 6 +-- ...conformance-compat.adversarial-review-1.md | 37 +++++++++++++++++++ .../2026-05-11-plugin-conformance-compat.md | 12 +++--- 3 files changed, 46 insertions(+), 9 deletions(-) create mode 100644 docs/plans/2026-05-11-plugin-conformance-compat.adversarial-review-1.md diff --git a/docs/plans/2026-05-11-plugin-conformance-compat-design.md b/docs/plans/2026-05-11-plugin-conformance-compat-design.md index 94ff0fca..cea216cb 100644 --- a/docs/plans/2026-05-11-plugin-conformance-compat-design.md +++ b/docs/plans/2026-05-11-plugin-conformance-compat-design.md @@ -314,7 +314,7 @@ First-scope producer path: 4. Release CI or a maintainer-run registry job checks out `workflow-registry` and runs: ```sh - wfctl registry compatibility update \ + wfctl plugin-registry compatibility update \ --registry-dir . \ --plugin workflow-plugin-digitalocean \ --version v0.14.4 \ @@ -345,7 +345,7 @@ Engine matrix policy: - A range is derived only when min and latest both pass, no fail evidence exists inside that closed range, and the registry update command can enumerate Workflow releases in the range. - If any intermediate engine has explicit fail evidence, no range crosses that version. - `WORKFLOW_CURRENT_VERSION` may pin latest during release incidents. -- `wfctl registry compatibility update` marks index stale when latest Workflow release is newer than newest evidence for that plugin. +- `wfctl plugin-registry compatibility update` marks index stale when latest Workflow release is newer than newest evidence for that plugin. - In required first-party mode, stale evidence blocks install/update/lock for the newer engine. In transitional/advisory mode, stale evidence warns. Engine version source of truth: @@ -500,7 +500,7 @@ Runtime validation: - Build local `wfctl`. - Run `wfctl plugin conformance --mode typed-iac` against a fixture plugin. - Run `wfctl plugin conformance --mode typed-iac --artifact ` and verify archive/binary hashes in JSON. -- Run `wfctl registry compatibility update` against a temp registry checkout and verify stable index diff. +- Run `wfctl plugin-registry compatibility update` against a temp registry checkout and verify stable index diff. - Run `wfctl plugin conformance --mode typed-iac` against the DigitalOcean plugin in CI after the plugin adopts the command. ## Rollback diff --git a/docs/plans/2026-05-11-plugin-conformance-compat.adversarial-review-1.md b/docs/plans/2026-05-11-plugin-conformance-compat.adversarial-review-1.md new file mode 100644 index 00000000..874cb47b --- /dev/null +++ b/docs/plans/2026-05-11-plugin-conformance-compat.adversarial-review-1.md @@ -0,0 +1,37 @@ +### Adversarial Review Report + +**Phase:** plan +**Artifact:** `docs/plans/2026-05-11-plugin-conformance-compat.md` +**Status:** FAIL + +**Findings (Critical):** +- None. + +**Findings (Important):** +- [repo-precedent conflicts] Task 4 placed compatibility index editing under `wfctl registry`, but current `cmd/wfctl/registry_container.go` makes that command container-registry-only and sends plugin catalog users to `wfctl plugin-registry`. Recommendation: implement compatibility updates under `wfctl plugin-registry compatibility update` and modify `cmd/wfctl/registry_cmd.go`, not `registry_container.go`. + +**Findings (Minor):** +- [verification-class mismatch] Runtime validation used the stale `wfctl registry compatibility update` command. Recommendation: update validation to invoke `wfctl plugin-registry compatibility update`. +- [missing rollback wiring] Task 4 rollback referenced generated indexes but not the command-surface conflict. Recommendation: keep rollback as commit revert; no registry-container command should be introduced. + +**Bug-class scan transcript:** +| Class | Result | Note | +|---|---|---| +| Unstated assumptions | Clean | Plan states artifact-first evidence and policy modes. | +| Repo-precedent conflicts | Finding | Plugin registry command surface was misidentified. | +| YAGNI violations | Clean | Out-of-scope list excludes signatures, live provider acceptance, and hosted services. | +| Missing failure modes | Finding | Runtime validation used the wrong command surface. | +| Security / privacy at architecture level | Clean | Plan keeps conformance metadata-only and excludes secrets. | +| Rollback story | Finding | Rollback needed to preserve container-registry command ownership. | +| Simpler alternative not considered | Clean | Plan rejects per-plugin scripts and manifest-only checks via the design. | +| User-intent drift | Clean | Plan targets wfctl-native plugin conformance as requested. | +| Over-decomposition / under-decomposition | Clean | Seven tasks map to coherent implementation slices. | +| Verification-class mismatch | Finding | Task 4 validation invoked the wrong command. | +| Hidden serial dependencies | Clean | Tasks are intentionally serial within one PR because later tasks depend on earlier models and resolver APIs. | +| Missing rollback wiring | Finding | Task 4 rollback needed command ownership guardrail. | + +**Options the author may not have considered:** +1. Add a deprecated alias under `wfctl registry`: this would preserve the design's original spelling, but fights the recent container-registry split and risks more user confusion. +2. Put index updates under `wfctl plugin compatibility update`: this keeps plugin-related commands together, but the command mutates registry files rather than a local plugin install. + +**Verdict reasoning:** FAIL because implementing under the wrong CLI surface would conflict with existing wfctl command ownership and user-facing help. diff --git a/docs/plans/2026-05-11-plugin-conformance-compat.md b/docs/plans/2026-05-11-plugin-conformance-compat.md index 9507a5ed..c359b1ef 100644 --- a/docs/plans/2026-05-11-plugin-conformance-compat.md +++ b/docs/plans/2026-05-11-plugin-conformance-compat.md @@ -211,14 +211,14 @@ Rollback: revert this commit; plugin loading runtime falls back to existing `Ext ## Task 4: Registry Compatibility Update Command **Files:** -- Modify: `cmd/wfctl/registry_container.go` +- Modify: `cmd/wfctl/registry_cmd.go` - Create: `cmd/wfctl/registry_compatibility.go` - Create: `cmd/wfctl/registry_compatibility_test.go` **Step 1: Write failing tests** Add tests for: -- `wfctl registry compatibility update --help` +- `wfctl plugin-registry compatibility update --help` - update reads `plugins//manifest.json` - update validates evidence plugin/version/mode/status/os/arch/engine - update rejects evidence whose `archiveSHA256` does not match a manifest download @@ -233,7 +233,7 @@ Expected: FAIL with unknown subcommand. **Step 2: Implement command** -Add `compatibility update` under `wfctl registry` because this command edits registry files, not plugin install config. +Add `compatibility update` under `wfctl plugin-registry` because this command edits the plugin catalog registry. Do not add it under `wfctl registry`; that surface now owns container registry login/push/prune/logout. Implement flags: - `--registry-dir` @@ -258,7 +258,7 @@ Expected: index file contains one version record with validated evidence. **Step 4: Commit** ```bash -git add cmd/wfctl/registry_container.go cmd/wfctl/registry_compatibility.go cmd/wfctl/registry_compatibility_test.go +git add cmd/wfctl/registry_cmd.go cmd/wfctl/registry_compatibility.go cmd/wfctl/registry_compatibility_test.go git commit -m "feat(wfctl): update registry compat indexes" ``` @@ -391,7 +391,7 @@ Rollback: revert this commit; generated lockfiles can be regenerated without com Add concise docs for: - `wfctl plugin conformance` -- `wfctl registry compatibility update` +- `wfctl plugin-registry compatibility update` - registry `compatibilityEvidence.trust` - install/update/lock `--compat-mode` - `WFCTL_PLUGIN_COMPAT_MODE` @@ -426,7 +426,7 @@ Run: GOWORK=off go build -o /tmp/wfctl-compat ./cmd/wfctl /tmp/wfctl-compat plugin conformance --mode typed-iac --format json ./cmd/wfctl/testdata/conformance/iac-pass /tmp/wfctl-compat plugin conformance --mode typed-iac --artifact /tmp/wfctl-iac-pass.tar.gz --format json -/tmp/wfctl-compat registry compatibility update --registry-dir /tmp/wfctl-test-registry --plugin workflow-plugin-test --version v0.1.0 --evidence /tmp/wfctl-evidence.json +/tmp/wfctl-compat plugin-registry compatibility update --registry-dir /tmp/wfctl-test-registry --plugin workflow-plugin-test --version v0.1.0 --evidence /tmp/wfctl-evidence.json ``` Expected: From d46a134e6185d929bd85473d3c8d77ed1cd4ed10 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 00:41:19 -0400 Subject: [PATCH 12/29] docs: specify conformance fixture setup --- docs/plans/2026-05-11-plugin-conformance-compat.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/plans/2026-05-11-plugin-conformance-compat.md b/docs/plans/2026-05-11-plugin-conformance-compat.md index c359b1ef..8789edcc 100644 --- a/docs/plans/2026-05-11-plugin-conformance-compat.md +++ b/docs/plans/2026-05-11-plugin-conformance-compat.md @@ -169,6 +169,12 @@ Add tests for: - hanging plugin is killed on timeout and emits bounded stderr/stdout tails - no provider credential/env data appears in JSON output +Fixture `go.mod` files must use: + +```go +replace github.com/GoCodeAlone/workflow => ../../../../.. +``` + Run: `GOWORK=off go test ./cmd/wfctl -run 'TestPluginConformance' -count=1` Expected: FAIL with unknown subcommand. @@ -425,6 +431,7 @@ Run: ```bash GOWORK=off go build -o /tmp/wfctl-compat ./cmd/wfctl /tmp/wfctl-compat plugin conformance --mode typed-iac --format json ./cmd/wfctl/testdata/conformance/iac-pass +tar -czf /tmp/wfctl-iac-pass.tar.gz -C ./cmd/wfctl/testdata/conformance/iac-pass . /tmp/wfctl-compat plugin conformance --mode typed-iac --artifact /tmp/wfctl-iac-pass.tar.gz --format json /tmp/wfctl-compat plugin-registry compatibility update --registry-dir /tmp/wfctl-test-registry --plugin workflow-plugin-test --version v0.1.0 --evidence /tmp/wfctl-evidence.json ``` From eadf5fcfa07170e292b57082e3c103f4f49186a7 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 00:41:48 -0400 Subject: [PATCH 13/29] docs: pass compat plan review --- ...conformance-compat.adversarial-review-2.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/plans/2026-05-11-plugin-conformance-compat.adversarial-review-2.md diff --git a/docs/plans/2026-05-11-plugin-conformance-compat.adversarial-review-2.md b/docs/plans/2026-05-11-plugin-conformance-compat.adversarial-review-2.md new file mode 100644 index 00000000..8f065dbb --- /dev/null +++ b/docs/plans/2026-05-11-plugin-conformance-compat.adversarial-review-2.md @@ -0,0 +1,38 @@ +### Adversarial Review Report + +**Phase:** plan +**Artifact:** `docs/plans/2026-05-11-plugin-conformance-compat.md` +**Status:** PASS + +**Findings (Critical):** +- None. + +**Findings (Important):** +- None. + +**Findings (Minor):** +- [over-decomposition / under-decomposition] Some tasks are larger than the ideal 2-5 minute unit, especially conformance launch and resolver integration. Recommendation: during execution, commit at the task boundaries already listed and split locally if a task starts crossing too many files at once. +- [verification-class mismatch] Task 7 runtime validation depends on a temp registry fixture not fully described in shell commands. Recommendation: build the fixture from `registry_compatibility_test.go` helpers or add a small shell-created fixture during execution. +- [missing rollback wiring] Follow-up plugin repo adoption is out of scope, so rollout risk remains until plugin PRs land. Recommendation: after this PR merges and releases, immediately open plugin adoption PRs that replace repo-local scripts with `wfctl plugin conformance --artifact`. + +**Bug-class scan transcript:** +| Class | Result | Note | +|---|---|---| +| Unstated assumptions | Clean | Plan names artifact binding, local replace path, trust modes, pseudo-version handling, and command ownership. | +| Repo-precedent conflicts | Clean | Compatibility update now belongs to `wfctl plugin-registry`, matching current wfctl command split. | +| YAGNI violations | Clean | Signatures, hosted service, live provider acceptance, and cross-source pointers are explicitly out of scope. | +| Missing failure modes | Clean | Timeout, digest mismatch, stale/missing evidence, advisory mode, and pseudo-version behavior are covered. | +| Security / privacy at architecture level | Clean | Plan avoids provider credentials and checks JSON output for secret leakage. | +| Rollback story | Finding | Core rollback is wired per task; plugin repo rollout remains a follow-up risk. | +| Simpler alternative not considered | Clean | Design rejects manifest-only and per-plugin shell scripts with rationale. | +| User-intent drift | Clean | Plan builds wfctl-native conformance and install-time compatibility, matching the user’s dogfooding request. | +| Over-decomposition / under-decomposition | Finding | Tasks are large but bounded by subsystem and commit checkpoints. | +| Verification-class mismatch | Finding | Runtime temp registry fixture needs concrete construction during execution. | +| Hidden serial dependencies | Clean | Serial dependency chain is explicit inside one PR. | +| Missing rollback wiring | Clean | Runtime-affecting tasks include rollback notes. | + +**Options the author may not have considered:** +1. Split into two workflow PRs: conformance/evidence producer first, resolver enforcement second. This reduces review size but delays install-time enforcement and complicates plugin follow-up timing. +2. Land resolver in warn-only mode first. This reduces user impact but weakens the design’s default strictness goal. + +**Verdict reasoning:** PASS. Remaining issues are execution guardrails, not blockers. The plan covers the design’s artifact-first evidence, registry source/index work, conformance command, resolver enforcement, lock metadata, docs, verification, and rollback. From df6c5aa5fb8dcafbfab976645ff2ca5fb58b4af5 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 00:43:05 -0400 Subject: [PATCH 14/29] docs: cover compat plan alignment gaps --- ...in-conformance-compat.alignment-check-1.md | 29 +++++++++++++++++++ .../2026-05-11-plugin-conformance-compat.md | 5 ++++ 2 files changed, 34 insertions(+) create mode 100644 docs/plans/2026-05-11-plugin-conformance-compat.alignment-check-1.md diff --git a/docs/plans/2026-05-11-plugin-conformance-compat.alignment-check-1.md b/docs/plans/2026-05-11-plugin-conformance-compat.alignment-check-1.md new file mode 100644 index 00000000..29d7204d --- /dev/null +++ b/docs/plans/2026-05-11-plugin-conformance-compat.alignment-check-1.md @@ -0,0 +1,29 @@ +### Alignment Report + +**Status:** FAIL + +**Coverage:** +| Design Requirement | Plan Task(s) | Status | +|---|---|---| +| `wfctl plugin conformance --output ` writes JSON evidence | — | MISSING | +| `--format text|json` is supported | Task 3 | Partial: JSON covered; text not explicit | +| `SupportedCanonicalKeys` may be called, but resource/credential methods must not be called | — | MISSING | +| local-dir conformance evidence lacks `archiveSHA256` and remains advisory | — | MISSING | +| registry update marks indexes stale when latest engine is newer than evidence | — | MISSING | +| manifest/index scope, resolver behavior, lock metadata, trust, rollback | Tasks 1-7 | Covered | + +**Scope Check:** +| Plan Task | Design Requirement | Status | +|---|---|---| +| Task 1 | Evidence model, digest, version grammar, trust modes | Covered | +| Task 2 | Registry source index API and same-source resolution | Covered | +| Task 3 | Conformance command, staging, timeout, typed-IaC checks | Covered with missing sub-requirements | +| Task 4 | Registry compatibility update command | Covered with missing stale marker | +| Task 5 | Install/update compatibility resolver | Covered | +| Task 6 | Lockfile compatibility metadata | Covered | +| Task 7 | Docs, broad verification, runtime validation | Covered | + +**Drift Items:** +- Add Task 3 checks for `--output`, text format, local advisory evidence, and `SupportedCanonicalKeys`/no-resource-call behavior. +- Add Task 4 check for stale index marking. +- `tests/plan-scope-check.sh` does not exist in this repository; manifest was checked manually for PR count, task count, and PR row/task references. diff --git a/docs/plans/2026-05-11-plugin-conformance-compat.md b/docs/plans/2026-05-11-plugin-conformance-compat.md index 8789edcc..01ef9049 100644 --- a/docs/plans/2026-05-11-plugin-conformance-compat.md +++ b/docs/plans/2026-05-11-plugin-conformance-compat.md @@ -164,7 +164,11 @@ Add tests for: - `wfctl plugin conformance --help` lists `--artifact`, `--mode`, `--engine-version`, `--timeout` - exactly one of `` or `--artifact` is required - fake typed-IaC plugin passes and emits JSON with `status:"pass"`, `mode:"typed-iac"`, `binarySHA256`, `pluginManifestSHA256`, `evidenceDigest` +- `--output ` writes the JSON evidence file and stdout stays concise +- `--format text` emits human-readable pass/fail without JSON - archive mode emits `archiveSHA256` matching the tarball and uses extracted contents, not source dir +- local-dir mode emits no `archiveSHA256` and is marked advisory for registry enforcement +- provider fixture implementing `SupportedCanonicalKeys` is called, but resource `Read`, `Plan`, `Apply`, `Destroy`, bootstrap, and credential methods are not called - fake plugin with no typed-IaC service fails - hanging plugin is killed on timeout and emits bounded stderr/stdout tails - no provider credential/env data appears in JSON output @@ -232,6 +236,7 @@ Add tests for: - update sorts versions descending and evidence by engine/mode/os/arch - update leaves existing index untouched on validation failure - range derivation only occurs when min/latest pass and no explicit fail exists inside enumerated range +- stale marker is set when `--latest-engine` is newer than the newest evidence for that plugin Run: `GOWORK=off go test ./cmd/wfctl -run 'TestRegistryCompatibilityUpdate' -count=1` From 0de82b6da11fc3b3eecf9d133f11e593794385d7 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 00:43:45 -0400 Subject: [PATCH 15/29] docs: pass compat alignment check --- ...in-conformance-compat.alignment-check-2.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/plans/2026-05-11-plugin-conformance-compat.alignment-check-2.md diff --git a/docs/plans/2026-05-11-plugin-conformance-compat.alignment-check-2.md b/docs/plans/2026-05-11-plugin-conformance-compat.alignment-check-2.md new file mode 100644 index 00000000..e5f7a2eb --- /dev/null +++ b/docs/plans/2026-05-11-plugin-conformance-compat.alignment-check-2.md @@ -0,0 +1,34 @@ +### Alignment Report + +**Status:** PASS + +**Coverage:** +| Design Requirement | Plan Task(s) | Status | +|---|---|---| +| central `wfctl plugin conformance` command | Task 3 | Covered | +| strict typed-IaC mode only, no `--strict=false` | Task 3 | Covered | +| artifact and local-dir staging, installed layout, build fallback, hashes | Task 3 | Covered | +| timeout, process kill, bounded output tails | Task 3 | Covered | +| `--format`, `--output`, `--engine-version` evidence output | Task 3 | Covered | +| `SupportedCanonicalKeys` allowed; resource/credential calls forbidden | Task 3 | Covered | +| registry-native version index and manifest summary | Task 1, Task 2, Task 4 | Covered | +| archive digest binding and evidence digest validation | Task 1, Task 3, Task 4, Task 5 | Covered | +| trust modes and unsigned/community advisory handling | Task 1, Task 2, Task 5 | Covered | +| same-source manifest/index resolution | Task 2 | Covered | +| plugin-registry compatibility update with validation, sort, atomic write, ranges, stale marker | Task 4 | Covered | +| install/update resolver with pass/fail/range/missing evidence policy and force/warn modes | Task 5 | Covered | +| lockfile platform compatibility metadata | Task 6 | Covered | +| docs, runtime validation, rollback | Task 7 and per-task rollback notes | Covered | + +**Scope Check:** +| Plan Task | Design Requirement | Status | +|---|---|---| +| Task 1 | Evidence schema, digest, semver, trust config | Justified | +| Task 2 | Registry source API and same-source index resolution | Justified | +| Task 3 | Conformance command, staging, typed-IaC checks, timeout | Justified | +| Task 4 | Registry compatibility update/publishing path | Justified | +| Task 5 | Install/update compatibility enforcement | Justified | +| Task 6 | Lockfile compatibility metadata | Justified | +| Task 7 | Documentation, broad verification, runtime validation | Justified | + +**Drift Items:** None. Manifest checked manually because `tests/plan-scope-check.sh` is not present in this repository: PR count 1 equals row count 1, task count 7 equals seven `## Task` headings, and the PR row references Task 1 through Task 7. From 4a3bf96ffd9a5e1ec8f45a061ed4d049e0272b9a Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 00:44:19 -0400 Subject: [PATCH 16/29] chore: lock scope for plugin compat --- docs/plans/2026-05-11-plugin-conformance-compat.md | 2 +- docs/plans/2026-05-11-plugin-conformance-compat.md.scope-lock | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-05-11-plugin-conformance-compat.md.scope-lock diff --git a/docs/plans/2026-05-11-plugin-conformance-compat.md b/docs/plans/2026-05-11-plugin-conformance-compat.md index 01ef9049..fb758a31 100644 --- a/docs/plans/2026-05-11-plugin-conformance-compat.md +++ b/docs/plans/2026-05-11-plugin-conformance-compat.md @@ -31,7 +31,7 @@ |------|-------|-------|--------| | 1 | Add wfctl plugin conformance compatibility | Task 1, Task 2, Task 3, Task 4, Task 5, Task 6, Task 7 | codex/plugin-conformance-compat | -**Status:** Draft +**Status:** Locked 2026-05-11T04:43:54Z ## Task 1: Evidence Model And Semver Utilities diff --git a/docs/plans/2026-05-11-plugin-conformance-compat.md.scope-lock b/docs/plans/2026-05-11-plugin-conformance-compat.md.scope-lock new file mode 100644 index 00000000..7cfd3cb5 --- /dev/null +++ b/docs/plans/2026-05-11-plugin-conformance-compat.md.scope-lock @@ -0,0 +1,3 @@ +sha256 21e34aa2f8f8d637085c6dd4a4743436edbbc463ae327cea03b466fd1089d777 +status Locked 2026-05-11T04:43:54Z +plan docs/plans/2026-05-11-plugin-conformance-compat.md From a9b4a678e434f49edfd39e9c9821f5b7633bfd6a Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 00:48:02 -0400 Subject: [PATCH 17/29] feat(wfctl): add plugin compat evidence model --- cmd/wfctl/plugin_compat_model.go | 281 ++++++++++++++++++++++++++ cmd/wfctl/plugin_compat_model_test.go | 170 ++++++++++++++++ cmd/wfctl/registry_config.go | 17 +- 3 files changed, 460 insertions(+), 8 deletions(-) create mode 100644 cmd/wfctl/plugin_compat_model.go create mode 100644 cmd/wfctl/plugin_compat_model_test.go diff --git a/cmd/wfctl/plugin_compat_model.go b/cmd/wfctl/plugin_compat_model.go new file mode 100644 index 00000000..0ccc1e62 --- /dev/null +++ b/cmd/wfctl/plugin_compat_model.go @@ -0,0 +1,281 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "regexp" + "slices" + "strings" + + "golang.org/x/mod/semver" +) + +const ( + PluginCompatibilityModeTypedIaC = "typed-iac" + + PluginCompatibilityStatusPass = "pass" + PluginCompatibilityStatusFail = "fail" + + CompatibilityTrustFirstParty CompatibilityTrustMode = "first_party" + CompatibilityTrustAdvisory CompatibilityTrustMode = "advisory" +) + +var strictSemverRe = regexp.MustCompile(`^v?\d+\.\d+\.\d+$`) + +// CompatibilityTrustMode controls whether compatibility evidence can affect +// install/update/lock decisions for a registry source. +type CompatibilityTrustMode string + +func (m *CompatibilityTrustMode) UnmarshalYAML(unmarshal func(any) error) error { + var raw string + if err := unmarshal(&raw); err != nil { + return err + } + trust, err := ParseCompatibilityTrustMode(raw) + if err != nil { + return err + } + *m = trust + return nil +} + +func ParseCompatibilityTrustMode(raw string) (CompatibilityTrustMode, error) { + switch CompatibilityTrustMode(raw) { + case "", CompatibilityTrustAdvisory: + return CompatibilityTrustAdvisory, nil + case CompatibilityTrustFirstParty: + return CompatibilityTrustFirstParty, nil + case "signed": + return "", fmt.Errorf("compatibility evidence trust mode %q requires a signature ADR before it can be used", raw) + default: + return "", fmt.Errorf("unknown compatibility evidence trust mode %q", raw) + } +} + +type RegistryCompatibilityEvidenceConfig struct { + Trust CompatibilityTrustMode `yaml:"trust,omitempty" json:"trust,omitempty"` +} + +type CompatibilityEvidencePolicy struct { + FirstParty string `json:"firstParty,omitempty"` + Community string `json:"community,omitempty"` + RequiredFromEngine string `json:"requiredFromEngine,omitempty"` +} + +type PluginVersionIndex struct { + Plugin string `json:"plugin"` + GeneratedAt string `json:"generatedAt,omitempty"` + EvidencePolicy CompatibilityEvidencePolicy `json:"evidencePolicy,omitempty"` + Versions []PluginVersionRecord `json:"versions"` +} + +type PluginVersionRecord struct { + Version string `json:"version"` + MinEngineVersion string `json:"minEngineVersion,omitempty"` + Downloads []PluginDownload `json:"downloads,omitempty"` + Compatibility []PluginCompatibilityEvidence `json:"compatibility,omitempty"` +} + +type PluginCompatibilityRange struct { + Min string `json:"min"` + Max string `json:"max"` + Derivation string `json:"derivation"` +} + +type PluginCompatibilityEvidence struct { + Plugin string `json:"plugin,omitempty"` + Version string `json:"version,omitempty"` + EngineVersion string `json:"engineVersion,omitempty"` + WfctlVersion string `json:"wfctlVersion,omitempty"` + Mode string `json:"mode,omitempty"` + Status string `json:"status,omitempty"` + EvidenceDigest string `json:"evidenceDigest,omitempty"` + OS string `json:"os,omitempty"` + Arch string `json:"arch,omitempty"` + ArchiveSHA256 string `json:"archiveSHA256,omitempty"` + BinarySHA256 string `json:"binarySHA256,omitempty"` + PluginManifestSHA256 string `json:"pluginManifestSHA256,omitempty"` + CompatibleEngineRange *PluginCompatibilityRange `json:"compatibleEngineRange,omitempty"` + Repository string `json:"repository,omitempty"` + Ref string `json:"ref,omitempty"` + Commit string `json:"commit,omitempty"` + WorkflowRunURL string `json:"workflowRunURL,omitempty"` + GeneratedBy string `json:"generatedBy,omitempty"` + StdoutTail string `json:"stdoutTail,omitempty"` + StderrTail string `json:"stderrTail,omitempty"` +} + +func CanonicalPluginVersion(version string) (string, error) { + return canonicalStrictSemver(version, "plugin version") +} + +func CanonicalEngineVersion(version string) (string, error) { + return canonicalStrictSemver(version, "engine version") +} + +func canonicalStrictSemver(version, label string) (string, error) { + version = strings.TrimSpace(version) + if version == "" { + return "", fmt.Errorf("%s is required", label) + } + if !strings.HasPrefix(version, "v") { + version = "v" + version + } + if !strictSemverRe.MatchString(version) || !semver.IsValid(version) { + return "", fmt.Errorf("%s %q must be semver MAJOR.MINOR.PATCH with optional leading v", label, strings.TrimPrefix(version, "v")) + } + return version, nil +} + +func NormalizeSHA256Hex(value string) (string, error) { + value = strings.TrimSpace(value) + if len(value) != sha256.Size*2 { + return "", fmt.Errorf("sha256 must be %d hex characters", sha256.Size*2) + } + decoded, err := hex.DecodeString(value) + if err != nil { + return "", fmt.Errorf("sha256 must be hex: %w", err) + } + return hex.EncodeToString(decoded), nil +} + +func ValidateCompatibilityEvidence(ev PluginCompatibilityEvidence) (PluginCompatibilityEvidence, error) { + var err error + if ev.Plugin == "" { + return ev, fmt.Errorf("plugin is required") + } + if ev.Version, err = CanonicalPluginVersion(ev.Version); err != nil { + return ev, err + } + if ev.EngineVersion, err = CanonicalEngineVersion(ev.EngineVersion); err != nil { + return ev, err + } + if ev.WfctlVersion != "" { + if ev.WfctlVersion, err = CanonicalEngineVersion(ev.WfctlVersion); err != nil { + return ev, fmt.Errorf("wfctl version: %w", err) + } + } + if ev.Mode != PluginCompatibilityModeTypedIaC { + return ev, fmt.Errorf("unsupported compatibility mode %q", ev.Mode) + } + if ev.Status != PluginCompatibilityStatusPass && ev.Status != PluginCompatibilityStatusFail { + return ev, fmt.Errorf("unsupported compatibility status %q", ev.Status) + } + if ev.OS == "" || ev.Arch == "" { + return ev, fmt.Errorf("os and arch are required") + } + if ev.ArchiveSHA256 != "" { + if ev.ArchiveSHA256, err = NormalizeSHA256Hex(ev.ArchiveSHA256); err != nil { + return ev, fmt.Errorf("archiveSHA256: %w", err) + } + } + if ev.BinarySHA256 != "" { + if ev.BinarySHA256, err = NormalizeSHA256Hex(ev.BinarySHA256); err != nil { + return ev, fmt.Errorf("binarySHA256: %w", err) + } + } + if ev.PluginManifestSHA256 != "" { + if ev.PluginManifestSHA256, err = NormalizeSHA256Hex(ev.PluginManifestSHA256); err != nil { + return ev, fmt.Errorf("pluginManifestSHA256: %w", err) + } + } + if ev.CompatibleEngineRange != nil { + if ev.CompatibleEngineRange.Min, err = CanonicalEngineVersion(ev.CompatibleEngineRange.Min); err != nil { + return ev, fmt.Errorf("compatibleEngineRange.min: %w", err) + } + if ev.CompatibleEngineRange.Max, err = CanonicalEngineVersion(ev.CompatibleEngineRange.Max); err != nil { + return ev, fmt.Errorf("compatibleEngineRange.max: %w", err) + } + } + ev.EvidenceDigest, err = ComputeEvidenceDigest(ev) + if err != nil { + return ev, err + } + return ev, nil +} + +func ComputeEvidenceDigest(ev PluginCompatibilityEvidence) (string, error) { + data, err := canonicalJSONWithoutEvidenceDigest(ev) + if err != nil { + return "", err + } + sum := sha256.Sum256(data) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +func canonicalJSONWithoutEvidenceDigest(ev PluginCompatibilityEvidence) ([]byte, error) { + data, err := json.Marshal(ev) + if err != nil { + return nil, err + } + var value any + if err := json.Unmarshal(data, &value); err != nil { + return nil, err + } + if m, ok := value.(map[string]any); ok { + delete(m, "evidenceDigest") + } + var buf bytes.Buffer + if err := writeCanonicalJSON(&buf, value); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func writeCanonicalJSON(buf *bytes.Buffer, value any) error { + switch v := value.(type) { + case nil: + buf.WriteString("null") + case bool: + if v { + buf.WriteString("true") + } else { + buf.WriteString("false") + } + case float64, string: + data, err := json.Marshal(v) + if err != nil { + return err + } + buf.Write(data) + case []any: + buf.WriteByte('[') + for i, item := range v { + if i > 0 { + buf.WriteByte(',') + } + if err := writeCanonicalJSON(buf, item); err != nil { + return err + } + } + buf.WriteByte(']') + case map[string]any: + keys := make([]string, 0, len(v)) + for key := range v { + keys = append(keys, key) + } + slices.Sort(keys) + buf.WriteByte('{') + for i, key := range keys { + if i > 0 { + buf.WriteByte(',') + } + keyData, err := json.Marshal(key) + if err != nil { + return err + } + buf.Write(keyData) + buf.WriteByte(':') + if err := writeCanonicalJSON(buf, v[key]); err != nil { + return err + } + } + buf.WriteByte('}') + default: + return fmt.Errorf("unsupported JSON value %T", value) + } + return nil +} diff --git a/cmd/wfctl/plugin_compat_model_test.go b/cmd/wfctl/plugin_compat_model_test.go new file mode 100644 index 00000000..5409fac4 --- /dev/null +++ b/cmd/wfctl/plugin_compat_model_test.go @@ -0,0 +1,170 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestPluginCompatVersionCanonicalization(t *testing.T) { + for _, input := range []string{"0.51.2", "v0.51.2"} { + got, err := CanonicalEngineVersion(input) + if err != nil { + t.Fatalf("CanonicalEngineVersion(%q): %v", input, err) + } + if got != "v0.51.2" { + t.Fatalf("CanonicalEngineVersion(%q) = %q, want v0.51.2", input, got) + } + } + + got, err := CanonicalPluginVersion("1.2.3") + if err != nil { + t.Fatalf("CanonicalPluginVersion: %v", err) + } + if got != "v1.2.3" { + t.Fatalf("CanonicalPluginVersion = %q, want v1.2.3", got) + } +} + +func TestPluginCompatVersionRejectsInvalid(t *testing.T) { + for _, input := range []string{"main", "v0.0.0-20260510", "1.2", "v1.2.3+meta"} { + if _, err := CanonicalEngineVersion(input); err == nil { + t.Fatalf("CanonicalEngineVersion(%q) succeeded, want error", input) + } + } +} + +func TestPluginCompatDigestOmitsEvidenceDigest(t *testing.T) { + ev := PluginCompatibilityEvidence{ + Plugin: "workflow-plugin-test", + Version: "v0.1.0", + EngineVersion: "v0.51.2", + WfctlVersion: "v0.51.2", + Mode: PluginCompatibilityModeTypedIaC, + Status: PluginCompatibilityStatusPass, + EvidenceDigest: "sha256:old", + OS: "linux", + Arch: "amd64", + ArchiveSHA256: strings.Repeat("a", 64), + BinarySHA256: strings.Repeat("b", 64), + PluginManifestSHA256: strings.Repeat("c", 64), + Repository: "GoCodeAlone/workflow-plugin-test", + GeneratedBy: "wfctl plugin conformance", + } + + got, err := ComputeEvidenceDigest(ev) + if err != nil { + t.Fatalf("ComputeEvidenceDigest: %v", err) + } + if got == "" || !strings.HasPrefix(got, "sha256:") { + t.Fatalf("digest = %q, want sha256 prefix", got) + } + + ev.EvidenceDigest = "sha256:different" + got2, err := ComputeEvidenceDigest(ev) + if err != nil { + t.Fatalf("ComputeEvidenceDigest second: %v", err) + } + if got != got2 { + t.Fatalf("digest changed when only evidenceDigest changed: %q vs %q", got, got2) + } + + data, err := canonicalJSONWithoutEvidenceDigest(ev) + if err != nil { + t.Fatalf("canonicalJSONWithoutEvidenceDigest: %v", err) + } + if strings.Contains(string(data), "evidenceDigest") { + t.Fatalf("canonical JSON contains evidenceDigest: %s", string(data)) + } +} + +func TestPluginCompatSHA256Normalization(t *testing.T) { + upper := strings.Repeat("A", 64) + got, err := NormalizeSHA256Hex(upper) + if err != nil { + t.Fatalf("NormalizeSHA256Hex: %v", err) + } + if got != strings.ToLower(upper) { + t.Fatalf("NormalizeSHA256Hex = %q, want lowercase", got) + } + for _, input := range []string{"", "abc", strings.Repeat("g", 64)} { + if _, err := NormalizeSHA256Hex(input); err == nil { + t.Fatalf("NormalizeSHA256Hex(%q) succeeded, want error", input) + } + } +} + +func TestPluginCompatTrustParsing(t *testing.T) { + var cfg RegistryConfig + data := []byte(` +registries: + - name: default + type: github + owner: GoCodeAlone + repo: workflow-registry + compatibilityEvidence: + trust: first_party + - name: community + type: static + url: https://example.test + compatibilityEvidence: + trust: advisory +`) + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("unmarshal trust config: %v", err) + } + if cfg.Registries[0].CompatibilityEvidence.Trust != CompatibilityTrustFirstParty { + t.Fatalf("first trust = %q", cfg.Registries[0].CompatibilityEvidence.Trust) + } + if cfg.Registries[1].CompatibilityEvidence.Trust != CompatibilityTrustAdvisory { + t.Fatalf("second trust = %q", cfg.Registries[1].CompatibilityEvidence.Trust) + } +} + +func TestPluginCompatTrustRejectsSigned(t *testing.T) { + var cfg RegistryConfig + data := []byte(` +registries: + - name: signed + type: static + url: https://example.test + compatibilityEvidence: + trust: signed +`) + if err := yaml.Unmarshal(data, &cfg); err == nil { + t.Fatal("unmarshal signed trust succeeded, want error") + } +} + +func TestPluginCompatEvidenceValidation(t *testing.T) { + ev := PluginCompatibilityEvidence{ + Plugin: "workflow-plugin-test", + Version: "0.1.0", + EngineVersion: "0.51.2", + WfctlVersion: "0.51.2", + Mode: PluginCompatibilityModeTypedIaC, + Status: PluginCompatibilityStatusPass, + OS: "linux", + Arch: "amd64", + ArchiveSHA256: strings.Repeat("A", 64), + BinarySHA256: strings.Repeat("B", 64), + } + got, err := ValidateCompatibilityEvidence(ev) + if err != nil { + t.Fatalf("ValidateCompatibilityEvidence: %v", err) + } + if got.Version != "v0.1.0" || got.EngineVersion != "v0.51.2" { + t.Fatalf("versions not canonicalized: %#v", got) + } + if got.ArchiveSHA256 != strings.Repeat("a", 64) { + t.Fatalf("archive hash = %q", got.ArchiveSHA256) + } + if got.EvidenceDigest == "" { + t.Fatal("EvidenceDigest not populated") + } + if _, err := json.Marshal(got); err != nil { + t.Fatalf("marshal normalized evidence: %v", err) + } +} diff --git a/cmd/wfctl/registry_config.go b/cmd/wfctl/registry_config.go index f14cbb7a..288ab007 100644 --- a/cmd/wfctl/registry_config.go +++ b/cmd/wfctl/registry_config.go @@ -16,14 +16,15 @@ type RegistryConfig struct { // RegistrySourceConfig defines a single registry source. type RegistrySourceConfig struct { - Name string `yaml:"name" json:"name"` // e.g. "default", "my-org" - Type string `yaml:"type" json:"type"` // "github" or "static" - Owner string `yaml:"owner" json:"owner"` // GitHub owner/org (type: github) - Repo string `yaml:"repo" json:"repo"` // GitHub repo name (type: github) - Branch string `yaml:"branch" json:"branch"` // Git branch, default "main" (type: github) - Priority int `yaml:"priority" json:"priority"` // Lower = higher priority - URL string `yaml:"url" json:"url"` // Base URL (type: static) - Token string `yaml:"token" json:"token"` // Auth token (optional) + Name string `yaml:"name" json:"name"` // e.g. "default", "my-org" + Type string `yaml:"type" json:"type"` // "github" or "static" + Owner string `yaml:"owner" json:"owner"` // GitHub owner/org (type: github) + Repo string `yaml:"repo" json:"repo"` // GitHub repo name (type: github) + Branch string `yaml:"branch" json:"branch"` // Git branch, default "main" (type: github) + Priority int `yaml:"priority" json:"priority"` // Lower = higher priority + URL string `yaml:"url" json:"url"` // Base URL (type: static) + Token string `yaml:"token" json:"token"` // Auth token (optional) + CompatibilityEvidence RegistryCompatibilityEvidenceConfig `yaml:"compatibilityEvidence,omitempty" json:"compatibilityEvidence,omitempty"` } // DefaultRegistryConfig returns the built-in registry config. From 7933f53e96711c6550072e3ea86f47e81f471191 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 00:52:00 -0400 Subject: [PATCH 18/29] feat(wfctl): fetch plugin version indexes --- cmd/wfctl/multi_registry.go | 55 ++++++++++++ cmd/wfctl/multi_registry_test.go | 103 ++++++++++++++++++++++- cmd/wfctl/registry_config.go | 19 ++++- cmd/wfctl/registry_source.go | 81 ++++++++++++++++++ cmd/wfctl/registry_source_test.go | 134 ++++++++++++++++++++++++++++++ 5 files changed, 386 insertions(+), 6 deletions(-) diff --git a/cmd/wfctl/multi_registry.go b/cmd/wfctl/multi_registry.go index 329a3add..3007517f 100644 --- a/cmd/wfctl/multi_registry.go +++ b/cmd/wfctl/multi_registry.go @@ -135,6 +135,61 @@ func (m *MultiRegistry) FetchManifest(name string) (*RegistryManifest, string, e return nil, "", fmt.Errorf("plugin %q not found in any configured registry", name) } +// FetchVersionIndex tries each source in priority order, using the same +// original-name then normalized-name lookup order as FetchManifest. +func (m *MultiRegistry) FetchVersionIndex(name string) (*PluginVersionIndex, string, error) { + if len(m.sources) == 0 { + return nil, "", fmt.Errorf("plugin %q not found: no registry sources configured"+ + " (missing .wfctl.yaml? run `wfctl registry list` or set WFCTL_DEBUG=1)", name) + } + + normalized := normalizePluginName(name) + if debugRegistryLog { + fmt.Fprintf(os.Stderr, "[wfctl debug] FetchVersionIndex %q: %d source(s), normalized=%q\n", + name, len(m.sources), normalized) + } + + var lastErr error + for _, src := range m.sources { + index, err := src.FetchVersionIndex(name) + if debugRegistryLog { + if err != nil { + fmt.Fprintf(os.Stderr, "[wfctl debug] %s (original %q index): %v\n", src.Name(), name, err) + } else { + fmt.Fprintf(os.Stderr, "[wfctl debug] %s (original %q index): found %d version(s)\n", + src.Name(), name, len(index.Versions)) + } + } + if err == nil { + return index, src.Name(), nil + } + lastErr = err + } + + if normalized != name { + for _, src := range m.sources { + index, err := src.FetchVersionIndex(normalized) + if debugRegistryLog { + if err != nil { + fmt.Fprintf(os.Stderr, "[wfctl debug] %s (normalized %q index): %v\n", src.Name(), normalized, err) + } else { + fmt.Fprintf(os.Stderr, "[wfctl debug] %s (normalized %q index): found %d version(s)\n", + src.Name(), normalized, len(index.Versions)) + } + } + if err == nil { + return index, src.Name(), nil + } + lastErr = err + } + } + + if lastErr != nil { + return nil, "", lastErr + } + return nil, "", fmt.Errorf("plugin %q compatibility index not found in any configured registry", name) +} + // SearchPlugins searches all sources and returns deduplicated results. // When the same plugin appears in multiple registries, the higher-priority source wins. // The query is normalized (stripping "workflow-plugin-" prefix) before searching. diff --git a/cmd/wfctl/multi_registry_test.go b/cmd/wfctl/multi_registry_test.go index 4f766f9c..f3e8541e 100644 --- a/cmd/wfctl/multi_registry_test.go +++ b/cmd/wfctl/multi_registry_test.go @@ -15,10 +15,11 @@ import ( // --------------------------------------------------------------------------- type mockRegistrySource struct { - name string - manifests map[string]*RegistryManifest - listErr error - fetchErr map[string]error + name string + manifests map[string]*RegistryManifest + versionIndexes map[string]*PluginVersionIndex + listErr error + fetchErr map[string]error } func (m *mockRegistrySource) Name() string { return m.name } @@ -48,6 +49,19 @@ func (m *mockRegistrySource) FetchManifest(name string) (*RegistryManifest, erro return manifest, nil } +func (m *mockRegistrySource) FetchVersionIndex(name string) (*PluginVersionIndex, error) { + if m.versionIndexes != nil { + if index, ok := m.versionIndexes[name]; ok { + return index, nil + } + } + manifest, err := m.FetchManifest(name) + if err != nil { + return nil, err + } + return synthesizeVersionIndexFromManifest(manifest), nil +} + func (m *mockRegistrySource) SearchPlugins(query string) ([]PluginSearchResult, error) { if m.listErr != nil { return nil, m.listErr @@ -127,6 +141,9 @@ func TestDefaultRegistryConfig(t *testing.T) { if r.Priority != 0 { t.Errorf("priority: got %d, want 0", r.Priority) } + if r.CompatibilityEvidence.Trust != CompatibilityTrustFirstParty { + t.Errorf("default trust: got %q, want %q", r.CompatibilityEvidence.Trust, CompatibilityTrustFirstParty) + } // Secondary fallback: static mirror (GitHub Pages CDN — lower priority). fb := cfg.Registries[1] if fb.Name != "static-mirror" { @@ -141,6 +158,21 @@ func TestDefaultRegistryConfig(t *testing.T) { if fb.Priority != 100 { t.Errorf("fallback priority: got %d, want 100", fb.Priority) } + if fb.CompatibilityEvidence.Trust != CompatibilityTrustFirstParty { + t.Errorf("static mirror trust: got %q, want %q", fb.CompatibilityEvidence.Trust, CompatibilityTrustFirstParty) + } +} + +func TestRegistryCompatibilityTrustDefaults(t *testing.T) { + cfg := &RegistryConfig{Registries: []RegistrySourceConfig{{ + Name: "community", + Type: "static", + URL: "https://example.test", + }}} + applyRegistryConfigDefaults(cfg) + if got := cfg.Registries[0].CompatibilityEvidence.Trust; got != CompatibilityTrustAdvisory { + t.Fatalf("user registry trust = %q, want %q", got, CompatibilityTrustAdvisory) + } } func TestLoadRegistryConfigFromFile(t *testing.T) { @@ -403,6 +435,69 @@ func TestMultiRegistryFetchOriginalNameFirst(t *testing.T) { } } +func TestMultiRegistryFetchVersionIndex_UsesSameSourceAsManifest(t *testing.T) { + srcA := &mockRegistrySource{ + name: "primary", + manifests: map[string]*RegistryManifest{ + "shared-plugin": {Name: "shared-plugin", Version: "1.0.0"}, + }, + versionIndexes: map[string]*PluginVersionIndex{ + "shared-plugin": { + Plugin: "shared-plugin", + Versions: []PluginVersionRecord{{Version: "v1.0.0"}}, + }, + }, + } + srcB := &mockRegistrySource{ + name: "secondary", + manifests: map[string]*RegistryManifest{ + "shared-plugin": {Name: "shared-plugin", Version: "2.0.0"}, + }, + versionIndexes: map[string]*PluginVersionIndex{ + "shared-plugin": { + Plugin: "shared-plugin", + Versions: []PluginVersionRecord{{Version: "v2.0.0"}}, + }, + }, + } + + mr := NewMultiRegistryFromSources(srcA, srcB) + index, source, err := mr.FetchVersionIndex("shared-plugin") + if err != nil { + t.Fatalf("FetchVersionIndex: %v", err) + } + if source != "primary" { + t.Fatalf("source = %q, want primary", source) + } + if got := index.Versions[0].Version; got != "v1.0.0" { + t.Fatalf("version index came from wrong source: got %q", got) + } +} + +func TestMultiRegistryFetchVersionIndex_NormalizedFallback(t *testing.T) { + srcA := &mockRegistrySource{ + name: "registry", + manifests: map[string]*RegistryManifest{ + "auth": {Name: "auth", Version: "1.0.0"}, + }, + versionIndexes: map[string]*PluginVersionIndex{ + "auth": { + Plugin: "auth", + Versions: []PluginVersionRecord{{Version: "v1.0.0"}}, + }, + }, + } + + mr := NewMultiRegistryFromSources(srcA) + index, _, err := mr.FetchVersionIndex("workflow-plugin-auth") + if err != nil { + t.Fatalf("FetchVersionIndex: %v", err) + } + if index.Plugin != "auth" { + t.Fatalf("plugin = %q, want auth", index.Plugin) + } +} + // TestMultiRegistryFetchNormalizedFallback verifies that when the full name is not // found in any source, the normalized short name is used as a fallback. This allows // users to omit the "workflow-plugin-" prefix in their config. diff --git a/cmd/wfctl/registry_config.go b/cmd/wfctl/registry_config.go index 288ab007..4034cc7e 100644 --- a/cmd/wfctl/registry_config.go +++ b/cmd/wfctl/registry_config.go @@ -42,12 +42,18 @@ func DefaultRegistryConfig() *RegistryConfig { Repo: registryRepo, Branch: registryBranch, Priority: 0, + CompatibilityEvidence: RegistryCompatibilityEvidenceConfig{ + Trust: CompatibilityTrustFirstParty, + }, }, { Name: "static-mirror", Type: "static", URL: "https://gocodealone.github.io/workflow-registry/v1", Priority: 100, + CompatibilityEvidence: RegistryCompatibilityEvidenceConfig{ + Trust: CompatibilityTrustFirstParty, + }, }, }, } @@ -103,7 +109,14 @@ func loadRegistryConfigFile(path string) (*RegistryConfig, bool, error) { if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, false, fmt.Errorf("parse registry config %s: %w", path, err) } - // Ensure defaults. + applyRegistryConfigDefaults(&cfg) + return &cfg, true, nil +} + +func applyRegistryConfigDefaults(cfg *RegistryConfig) { + if cfg == nil { + return + } for i := range cfg.Registries { if cfg.Registries[i].Branch == "" { cfg.Registries[i].Branch = "main" @@ -111,8 +124,10 @@ func loadRegistryConfigFile(path string) (*RegistryConfig, bool, error) { if cfg.Registries[i].Type == "" { cfg.Registries[i].Type = "github" } + if cfg.Registries[i].CompatibilityEvidence.Trust == "" { + cfg.Registries[i].CompatibilityEvidence.Trust = CompatibilityTrustAdvisory + } } - return &cfg, true, nil } // SaveRegistryConfig writes a registry config to a YAML file. diff --git a/cmd/wfctl/registry_source.go b/cmd/wfctl/registry_source.go index 053bcf24..7d084f74 100644 --- a/cmd/wfctl/registry_source.go +++ b/cmd/wfctl/registry_source.go @@ -18,6 +18,8 @@ type RegistrySource interface { ListPlugins() ([]string, error) // FetchManifest retrieves the manifest for a named plugin. FetchManifest(name string) (*RegistryManifest, error) + // FetchVersionIndex retrieves generated compatibility evidence for a named plugin. + FetchVersionIndex(name string) (*PluginVersionIndex, error) // SearchPlugins returns plugins matching the query string. SearchPlugins(query string) ([]PluginSearchResult, error) } @@ -116,6 +118,41 @@ func (g *GitHubRegistrySource) FetchManifest(name string) (*RegistryManifest, er return &m, nil } +func (g *GitHubRegistrySource) FetchVersionIndex(name string) (*PluginVersionIndex, error) { + url := fmt.Sprintf( + "https://raw.githubusercontent.com/%s/%s/%s/compatibility/%s/index.json", + g.owner, g.repo, g.branch, name, + ) + req, err := http.NewRequest(http.MethodGet, url, nil) //nolint:gosec // URL constructed from configured registry + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + resp, err := registryHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch compatibility index for %q from %s: %w", name, g.name, err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + manifest, manifestErr := g.FetchManifest(name) + if manifestErr != nil { + return nil, manifestErr + } + return synthesizeVersionIndexFromManifest(manifest), nil + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("registry %s returned HTTP %d for compatibility index %q", g.name, resp.StatusCode, name) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read compatibility index for %q from %s: %w", name, g.name, err) + } + var idx PluginVersionIndex + if err := json.Unmarshal(data, &idx); err != nil { + return nil, fmt.Errorf("parse compatibility index for %q from %s: %w", name, g.name, err) + } + return &idx, nil +} + func (g *GitHubRegistrySource) SearchPlugins(query string) ([]PluginSearchResult, error) { names, err := g.ListPlugins() if err != nil { @@ -177,6 +214,26 @@ func (s *StaticRegistrySource) FetchManifest(name string) (*RegistryManifest, er return &m, nil } +func (s *StaticRegistrySource) FetchVersionIndex(name string) (*PluginVersionIndex, error) { + url := fmt.Sprintf("%s/compatibility/%s/index.json", s.baseURL, name) + data, err := s.fetch(url) + if err != nil { + if strings.Contains(err.Error(), "HTTP 404") || strings.Contains(err.Error(), "not found") { + manifest, manifestErr := s.FetchManifest(name) + if manifestErr != nil { + return nil, manifestErr + } + return synthesizeVersionIndexFromManifest(manifest), nil + } + return nil, fmt.Errorf("fetch compatibility index for %q from %s: %w", name, s.name, err) + } + var idx PluginVersionIndex + if err := json.Unmarshal(data, &idx); err != nil { + return nil, fmt.Errorf("parse compatibility index for %q from %s: %w", name, s.name, err) + } + return &idx, nil +} + // staticIndexEntry is a single entry in the registry index.json file. type staticIndexEntry struct { Name string `json:"name"` @@ -262,6 +319,30 @@ func (s *StaticRegistrySource) fetch(url string) ([]byte, error) { return io.ReadAll(resp.Body) } +func synthesizeVersionIndexFromManifest(manifest *RegistryManifest) *PluginVersionIndex { + if manifest == nil { + return &PluginVersionIndex{} + } + version := manifest.Version + if canonical, err := CanonicalPluginVersion(version); err == nil { + version = canonical + } + minEngineVersion := manifest.MinEngineVersion + if minEngineVersion != "" { + if canonical, err := CanonicalEngineVersion(minEngineVersion); err == nil { + minEngineVersion = canonical + } + } + return &PluginVersionIndex{ + Plugin: manifest.Name, + Versions: []PluginVersionRecord{{ + Version: version, + MinEngineVersion: minEngineVersion, + Downloads: manifest.Downloads, + }}, + } +} + // matchesRegistryQuery checks if a manifest matches a search query. func matchesRegistryQuery(m *RegistryManifest, q string) bool { if q == "" { diff --git a/cmd/wfctl/registry_source_test.go b/cmd/wfctl/registry_source_test.go index 3d525f18..b4e601bb 100644 --- a/cmd/wfctl/registry_source_test.go +++ b/cmd/wfctl/registry_source_test.go @@ -1,9 +1,12 @@ package main import ( + "bytes" "encoding/json" + "io" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -47,6 +50,51 @@ func buildStaticRegistryServer(t *testing.T, index []staticIndexEntry, manifests return srv } +func buildStaticRegistryServerWithCompat( + t *testing.T, + index []staticIndexEntry, + manifests map[string]*RegistryManifest, + compat map[string]*PluginVersionIndex, +) *httptest.Server { + t.Helper() + indexData, err := json.Marshal(index) + if err != nil { + t.Fatalf("marshal index: %v", err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/index.json": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(indexData) //nolint:errcheck + case strings.HasPrefix(r.URL.Path, "/compatibility/") && strings.HasSuffix(r.URL.Path, "/index.json"): + name := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/compatibility/"), "/index.json") + if idx, ok := compat[name]; ok { + data, _ := json.Marshal(idx) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(data) //nolint:errcheck + return + } + http.NotFound(w, r) + default: + var pluginName string + if _, err := splitPluginManifestPath(r.URL.Path, &pluginName); err == nil { + if m, ok := manifests[pluginName]; ok { + data, _ := json.Marshal(m) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(data) //nolint:errcheck + return + } + } + http.NotFound(w, r) + } + })) + return srv +} + // splitPluginManifestPath parses /plugins//manifest.json and extracts // the plugin name. Returns an error if the path does not match. func splitPluginManifestPath(path string, name *string) (string, error) { @@ -73,6 +121,12 @@ type errSentinel string func (e errSentinel) Error() string { return string(e) } +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + // mustNewStaticRegistrySource is a test helper that calls NewStaticRegistrySource // and fails the test if an error is returned. func mustNewStaticRegistrySource(t *testing.T, cfg RegistrySourceConfig) *StaticRegistrySource { @@ -121,6 +175,86 @@ func TestStaticRegistrySource_FetchManifest(t *testing.T) { } } +func TestStaticRegistrySource_FetchVersionIndex_Native(t *testing.T) { + manifest := &RegistryManifest{Name: "alpha", Version: "1.0.0"} + index := &PluginVersionIndex{ + Plugin: "alpha", + Versions: []PluginVersionRecord{{ + Version: "v1.0.0", + MinEngineVersion: "v0.51.2", + }}, + } + srv := buildStaticRegistryServerWithCompat(t, nil, map[string]*RegistryManifest{"alpha": manifest}, map[string]*PluginVersionIndex{"alpha": index}) + defer srv.Close() + + src := mustNewStaticRegistrySource(t, RegistrySourceConfig{Name: "test-static", URL: srv.URL}) + got, err := src.FetchVersionIndex("alpha") + if err != nil { + t.Fatalf("FetchVersionIndex: %v", err) + } + if got.Plugin != "alpha" || len(got.Versions) != 1 || got.Versions[0].Version != "v1.0.0" { + t.Fatalf("unexpected index: %#v", got) + } +} + +func TestStaticRegistrySource_FetchVersionIndex_SynthesizesFromManifest(t *testing.T) { + manifest := &RegistryManifest{ + Name: "alpha", + Version: "1.0.0", + MinEngineVersion: "0.51.2", + Downloads: []PluginDownload{{ + OS: "linux", + Arch: "amd64", + URL: "https://example.test/alpha.tar.gz", + SHA256: strings.Repeat("a", 64), + }}, + } + srv := buildStaticRegistryServerWithCompat(t, nil, map[string]*RegistryManifest{"alpha": manifest}, nil) + defer srv.Close() + + src := mustNewStaticRegistrySource(t, RegistrySourceConfig{Name: "test-static", URL: srv.URL}) + got, err := src.FetchVersionIndex("alpha") + if err != nil { + t.Fatalf("FetchVersionIndex: %v", err) + } + if got.Plugin != "alpha" || len(got.Versions) != 1 { + t.Fatalf("unexpected synthetic index: %#v", got) + } + rec := got.Versions[0] + if rec.Version != "v1.0.0" || rec.MinEngineVersion != "v0.51.2" { + t.Fatalf("synthetic versions = %#v", rec) + } + if len(rec.Downloads) != 1 || rec.Downloads[0].SHA256 != strings.Repeat("a", 64) { + t.Fatalf("synthetic downloads = %#v", rec.Downloads) + } +} + +func TestGitHubRegistrySource_FetchVersionIndex_Native(t *testing.T) { + oldClient := registryHTTPClient + t.Cleanup(func() { registryHTTPClient = oldClient }) + registryHTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if !strings.Contains(req.URL.Path, "/compatibility/alpha/index.json") { + t.Fatalf("unexpected path %s", req.URL.Path) + } + body := `{"plugin":"alpha","versions":[{"version":"v1.0.0"}]}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(body)), + Header: make(http.Header), + Request: req, + }, nil + })} + + src := NewGitHubRegistrySource(RegistrySourceConfig{Name: "github", Owner: "o", Repo: "r", Branch: "main"}) + got, err := src.FetchVersionIndex("alpha") + if err != nil { + t.Fatalf("FetchVersionIndex: %v", err) + } + if got.Plugin != "alpha" || got.Versions[0].Version != "v1.0.0" { + t.Fatalf("unexpected index: %#v", got) + } +} + // TestStaticRegistrySource_FetchManifest_NotFound verifies that fetching a // non-existent plugin returns an error. func TestStaticRegistrySource_FetchManifest_NotFound(t *testing.T) { From d30eec641398879d902e3e25f163f12de9d917d8 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 01:24:02 -0400 Subject: [PATCH 19/29] feat(wfctl): add plugin conformance command --- cmd/wfctl/plugin.go | 3 + cmd/wfctl/plugin_conformance.go | 426 ++++++++++++++++++ cmd/wfctl/plugin_conformance_test.go | 302 +++++++++++++ .../testdata/conformance/iac-hang/go.mod | 3 + .../testdata/conformance/iac-hang/main.go | 7 + .../testdata/conformance/iac-hang/plugin.json | 6 + .../testdata/conformance/iac-pass/go.mod | 7 + .../testdata/conformance/iac-pass/main.go | 28 ++ .../testdata/conformance/iac-pass/plugin.json | 6 + cmd/wfctl/testdata/conformance/no-iac/go.mod | 7 + cmd/wfctl/testdata/conformance/no-iac/main.go | 18 + .../testdata/conformance/no-iac/plugin.json | 6 + 12 files changed, 819 insertions(+) create mode 100644 cmd/wfctl/plugin_conformance.go create mode 100644 cmd/wfctl/plugin_conformance_test.go create mode 100644 cmd/wfctl/testdata/conformance/iac-hang/go.mod create mode 100644 cmd/wfctl/testdata/conformance/iac-hang/main.go create mode 100644 cmd/wfctl/testdata/conformance/iac-hang/plugin.json create mode 100644 cmd/wfctl/testdata/conformance/iac-pass/go.mod create mode 100644 cmd/wfctl/testdata/conformance/iac-pass/main.go create mode 100644 cmd/wfctl/testdata/conformance/iac-pass/plugin.json create mode 100644 cmd/wfctl/testdata/conformance/no-iac/go.mod create mode 100644 cmd/wfctl/testdata/conformance/no-iac/main.go create mode 100644 cmd/wfctl/testdata/conformance/no-iac/plugin.json diff --git a/cmd/wfctl/plugin.go b/cmd/wfctl/plugin.go index a284d2dd..4f4b4b38 100644 --- a/cmd/wfctl/plugin.go +++ b/cmd/wfctl/plugin.go @@ -35,6 +35,8 @@ func runPlugin(args []string) error { return runPluginRemove(args[1:]) case "validate": return runPluginValidate(args[1:]) + case "conformance": + return runPluginConformance(args[1:]) case "info": return runPluginInfo(args[1:]) case "deps": @@ -59,6 +61,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 + conformance Run executable plugin/host conformance checks info Show details about an installed plugin deps List dependencies for a plugin diff --git a/cmd/wfctl/plugin_conformance.go b/cmd/wfctl/plugin_conformance.go new file mode 100644 index 00000000..8197f937 --- /dev/null +++ b/cmd/wfctl/plugin_conformance.go @@ -0,0 +1,426 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + goplugin "github.com/GoCodeAlone/go-plugin" + engineplugin "github.com/GoCodeAlone/workflow/plugin" + "github.com/GoCodeAlone/workflow/plugin/external" + "github.com/hashicorp/go-hclog" + "golang.org/x/mod/modfile" +) + +func runPluginConformance(args []string) error { + fs := flag.NewFlagSet("plugin conformance", flag.ContinueOnError) + mode := fs.String("mode", PluginCompatibilityModeTypedIaC, "Conformance mode (typed-iac)") + artifact := fs.String("artifact", "", "Release artifact tar.gz to test") + engineVersion := fs.String("engine-version", "", "Workflow engine version for evidence metadata") + format := fs.String("format", "text", "Output format: text or json") + output := fs.String("output", "", "Write JSON evidence to this path") + timeout := fs.Duration("timeout", 30*time.Second, "Plugin launch/check timeout") + fs.Usage = func() { + printPluginConformanceUsage(fs.Output(), fs) + } + if err := fs.Parse(args); err != nil { + return err + } + if *mode != PluginCompatibilityModeTypedIaC { + return fmt.Errorf("unsupported conformance mode %q", *mode) + } + if *format != "text" && *format != "json" { + return fmt.Errorf("--format must be text or json") + } + if *artifact != "" && fs.NArg() > 0 { + return fmt.Errorf("specify exactly one of or --artifact") + } + if *artifact == "" && fs.NArg() != 1 { + fs.Usage() + return fmt.Errorf("specify exactly one of or --artifact") + } + if *engineVersion == "" { + *engineVersion = resolveConformanceEngineVersion() + } else if strings.EqualFold(*engineVersion, "local") { + *engineVersion = "v0.0.0" + } + + source := "" + if fs.NArg() == 1 { + source = fs.Arg(0) + } + ev, err := runPluginConformanceCheck(pluginConformanceOptions{ + Mode: *mode, + SourceDir: source, + ArtifactPath: *artifact, + EngineVersion: *engineVersion, + Timeout: *timeout, + }) + if err != nil && ev.Plugin == "" { + return err + } + + if *output != "" { + data, err := json.MarshalIndent(ev, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(*output, append(data, '\n'), 0o600); err != nil { + return fmt.Errorf("write evidence: %w", err) + } + } + switch *format { + case "json": + if *output == "" { + data, err := json.MarshalIndent(ev, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + } + case "text": + fmt.Printf("%s %s %s %s/%s\n", ev.Status, ev.Plugin, ev.Version, ev.OS, ev.Arch) + } + if err != nil { + return err + } + return nil +} + +func printPluginConformanceUsage(w io.Writer, fs *flag.FlagSet) { + fmt.Fprintf(w, "Usage: wfctl plugin conformance [options] \n wfctl plugin conformance --artifact [options]\n\nRun executable plugin/host conformance checks. This executes plugin code; run only on trusted local sources or CI artifacts.\n\nFlags: --artifact --mode --engine-version --format --output --timeout\n\nOptions:\n") + fs.PrintDefaults() +} + +func resolveConformanceEngineVersion() string { + if env := strings.TrimSpace(os.Getenv("WFCTL_ENGINE_VERSION")); env != "" { + return env + } + if version := buildVersion(); version != "" { + if _, err := CanonicalEngineVersion(version); err == nil { + return version + } + } + return "v0.0.0" +} + +type pluginConformanceOptions struct { + Mode string + SourceDir string + ArtifactPath string + EngineVersion string + Timeout time.Duration +} + +func runPluginConformanceCheck(opts pluginConformanceOptions) (PluginCompatibilityEvidence, error) { + tmp, err := os.MkdirTemp("", "wfctl-plugin-conformance-*") + if err != nil { + return PluginCompatibilityEvidence{}, err + } + defer os.RemoveAll(tmp) //nolint:errcheck + + sourceDir := filepath.Join(tmp, "source") + if err := os.MkdirAll(sourceDir, 0o750); err != nil { + return PluginCompatibilityEvidence{}, err + } + archiveSHA := "" + if opts.ArtifactPath != "" { + sha, err := hashFileSHA256(opts.ArtifactPath) + if err != nil { + return PluginCompatibilityEvidence{}, fmt.Errorf("hash artifact: %w", err) + } + archiveSHA = sha + data, err := os.ReadFile(opts.ArtifactPath) + if err != nil { + return PluginCompatibilityEvidence{}, fmt.Errorf("read artifact: %w", err) + } + if err := extractTarGz(data, sourceDir); err != nil { + return PluginCompatibilityEvidence{}, fmt.Errorf("extract artifact: %w", err) + } + } else { + if err := copyConformanceSourceDir(opts.SourceDir, sourceDir); err != nil { + return PluginCompatibilityEvidence{}, fmt.Errorf("stage plugin dir: %w", err) + } + if err := absolutizeStagedGoModReplaces(sourceDir, opts.SourceDir); err != nil { + return PluginCompatibilityEvidence{}, err + } + } + if err := removeConformanceSensitiveFiles(sourceDir); err != nil { + return PluginCompatibilityEvidence{}, err + } + + manifest, err := engineplugin.LoadManifest(filepath.Join(sourceDir, "plugin.json")) + if err != nil { + return PluginCompatibilityEvidence{}, err + } + if err := manifest.Validate(); err != nil { + return PluginCompatibilityEvidence{}, err + } + installName := normalizePluginName(manifest.Name) + installDir := filepath.Join(tmp, "plugins", installName) + if err := os.MkdirAll(installDir, 0o750); err != nil { + return PluginCompatibilityEvidence{}, err + } + if err := copyFile(filepath.Join(sourceDir, "plugin.json"), filepath.Join(installDir, "plugin.json"), 0o600); err != nil { + return PluginCompatibilityEvidence{}, err + } + binaryPath := filepath.Join(installDir, installName) + if info, statErr := os.Stat(filepath.Join(sourceDir, installName)); statErr == nil && !info.IsDir() && info.Mode()&0o111 != 0 { + if err := copyFile(filepath.Join(sourceDir, installName), binaryPath, info.Mode()); err != nil { + return PluginCompatibilityEvidence{}, err + } + } else { + cmd := exec.Command("go", "build", "-mod=mod", "-o", binaryPath, ".") //nolint:gosec // command args are fixed; dir is staged source. + cmd.Dir = sourceDir + cmd.Env = append(os.Environ(), "GOWORK=off") + out, err := cmd.CombinedOutput() + if err != nil { + return PluginCompatibilityEvidence{}, fmt.Errorf("build plugin: %w: %s", err, string(out)) + } + } + + binarySHA, err := hashFileSHA256(binaryPath) + if err != nil { + return PluginCompatibilityEvidence{}, err + } + manifestSHA, err := hashFileSHA256(filepath.Join(installDir, "plugin.json")) + if err != nil { + return PluginCompatibilityEvidence{}, err + } + + stdout, stderr, err := checkTypedIaCPlugin(opts.Timeout, filepath.Join(tmp, "plugins"), installName) + ev := PluginCompatibilityEvidence{ + Plugin: manifest.Name, + Version: manifest.Version, + EngineVersion: opts.EngineVersion, + WfctlVersion: opts.EngineVersion, + Mode: opts.Mode, + Status: PluginCompatibilityStatusPass, + OS: runtime.GOOS, + Arch: runtime.GOARCH, + ArchiveSHA256: archiveSHA, + BinarySHA256: binarySHA, + PluginManifestSHA256: manifestSHA, + GeneratedBy: "wfctl plugin conformance", + StdoutTail: stdout, + StderrTail: stderr, + } + if err != nil { + ev.Status = PluginCompatibilityStatusFail + if normalized, normErr := ValidateCompatibilityEvidence(ev); normErr == nil { + ev = normalized + } + return ev, err + } + ev, err = ValidateCompatibilityEvidence(ev) + if err != nil { + return ev, err + } + return ev, nil +} + +func checkTypedIaCPlugin(timeout time.Duration, pluginsDir, name string) (string, string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + pluginDir := filepath.Join(pluginsDir, name) + binaryPath, err := filepath.Abs(filepath.Join(pluginDir, name)) + if err != nil { + return "", "", err + } + var stdout, stderr tailBuffer + cmd := exec.CommandContext(ctx, binaryPath) //nolint:gosec // staged plugin binary path. + cmd.Dir = pluginDir + cmd.Env = conformancePluginEnv() + client := goplugin.NewClient(&goplugin.ClientConfig{ + HandshakeConfig: external.Handshake, + Plugins: goplugin.PluginSet{"plugin": &external.GRPCPlugin{}}, + Cmd: cmd, + AllowedProtocols: []goplugin.Protocol{goplugin.ProtocolGRPC}, + Logger: hclog.NewNullLogger(), + }) + defer client.Kill() + + rpcClient, err := client.Client() + if err != nil { + if ctx.Err() != nil { + return stdout.String(), stderr.String(), fmt.Errorf("timeout waiting for plugin handshake") + } + return stdout.String(), stderr.String(), err + } + raw, err := rpcClient.Dispense("plugin") + if err != nil { + return stdout.String(), stderr.String(), err + } + pluginClient, ok := raw.(*external.PluginClient) + if !ok { + return stdout.String(), stderr.String(), fmt.Errorf("dispensed object is %T, want *external.PluginClient", raw) + } + adapter, err := external.NewExternalPluginAdapter(name, pluginClient) + if err != nil { + return stdout.String(), stderr.String(), err + } + if regErr := adapter.ContractRegistryError(); regErr != nil { + return stdout.String(), stderr.String(), regErr + } + if err := AssertIaCPluginAdvertisesRequiredService(name, "", adapter.ContractRegistry()); err != nil { + return stdout.String(), stderr.String(), err + } + registered := registeredIaCServices(adapter.ContractRegistry()) + typed := newTypedIaCAdapter(adapter.Conn(), registered) + _ = typed.SupportedCanonicalKeys() + return stdout.String(), stderr.String(), nil +} + +func conformancePluginEnv() []string { + env := make([]string, 0, 4) + for _, key := range []string{"PATH", "TMPDIR", "TEMP", "TMP"} { + if value := os.Getenv(key); value != "" { + env = append(env, key+"="+value) + } + } + return env +} + +func copyConformanceSourceDir(src, dst string) error { + if err := os.MkdirAll(dst, 0o750); err != nil { + return err + } + return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + if shouldSkipConformancePath(rel, d.IsDir()) { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + target := filepath.Join(dst, rel) + info, err := d.Info() + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return nil + } + if d.IsDir() { + return os.MkdirAll(target, info.Mode()) + } + return copyFile(path, target, info.Mode()) + }) +} + +func removeConformanceSensitiveFiles(root string) error { + return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + if !shouldSkipConformancePath(rel, d.IsDir()) { + return nil + } + if d.IsDir() { + if err := os.RemoveAll(path); err != nil { + return err + } + return filepath.SkipDir + } + return os.Remove(path) + }) +} + +func absolutizeStagedGoModReplaces(stagedDir, originalDir string) error { + goModPath := filepath.Join(stagedDir, "go.mod") + data, err := os.ReadFile(goModPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read staged go.mod: %w", err) + } + mf, err := modfile.Parse(goModPath, data, nil) + if err != nil { + return fmt.Errorf("parse staged go.mod: %w", err) + } + changed := false + for _, repl := range mf.Replace { + if repl.New.Version != "" || filepath.IsAbs(repl.New.Path) || isModulePath(repl.New.Path) { + continue + } + abs, err := filepath.Abs(filepath.Join(originalDir, filepath.FromSlash(repl.New.Path))) + if err != nil { + return fmt.Errorf("resolve go.mod replace %q: %w", repl.New.Path, err) + } + if err := mf.AddReplace(repl.Old.Path, repl.Old.Version, filepath.ToSlash(abs), ""); err != nil { + return fmt.Errorf("update go.mod replace %q: %w", repl.Old.Path, err) + } + changed = true + } + if !changed { + return nil + } + formatted, err := mf.Format() + if err != nil { + return fmt.Errorf("format staged go.mod: %w", err) + } + if err := os.WriteFile(goModPath, formatted, 0o600); err != nil { + return fmt.Errorf("write staged go.mod: %w", err) + } + return nil +} + +func isModulePath(path string) bool { + return !strings.HasPrefix(path, ".") && !strings.HasPrefix(path, "/") +} + +func shouldSkipConformancePath(rel string, isDir bool) bool { + rel = filepath.ToSlash(rel) + if rel == "." { + return false + } + base := pathBaseSlash(rel) + if isDir && (base == ".git" || base == ".wfctl") { + return true + } + return base == ".env" || strings.HasPrefix(base, ".env.") +} + +func pathBaseSlash(path string) string { + if idx := strings.LastIndex(path, "/"); idx >= 0 { + return path[idx+1:] + } + return path +} + +type tailBuffer struct { + buf []byte +} + +func (b *tailBuffer) Write(p []byte) (int, error) { + const maxTail = 4096 + b.buf = append(b.buf, p...) + if len(b.buf) > maxTail { + b.buf = b.buf[len(b.buf)-maxTail:] + } + return len(p), nil +} + +func (b *tailBuffer) String() string { + return string(b.buf) +} diff --git a/cmd/wfctl/plugin_conformance_test.go b/cmd/wfctl/plugin_conformance_test.go new file mode 100644 index 00000000..64a17905 --- /dev/null +++ b/cmd/wfctl/plugin_conformance_test.go @@ -0,0 +1,302 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "errors" + "flag" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPluginConformanceRequiresExactlyOneSource(t *testing.T) { + if err := runPluginConformance([]string{"--mode", "typed-iac"}); err == nil { + t.Fatal("expected missing source error") + } + if err := runPluginConformance([]string{"--mode", "typed-iac", "--artifact", "x.tar.gz", "cmd/wfctl/testdata/conformance/iac-pass"}); err == nil { + t.Fatal("expected mutually exclusive source error") + } +} + +func TestPluginConformanceHelpListsFlags(t *testing.T) { + output, err := captureStderr(t, func() error { + return runPluginConformance([]string{"--help"}) + }) + if !errors.Is(err, flag.ErrHelp) { + t.Fatalf("runPluginConformance --help error = %v, want flag.ErrHelp", err) + } + for _, want := range []string{"--artifact", "--mode", "--engine-version", "--timeout", "executes plugin code"} { + if !strings.Contains(output, want) { + t.Fatalf("help output missing %q:\n%s", want, output) + } + } +} + +func TestPluginConformanceLocalJSONPass(t *testing.T) { + fixture := prepareIACPassFixture(t) + out := filepath.Join(t.TempDir(), "evidence.json") + t.Setenv("WFCTL_CONFORMANCE_SECRET", "secret-value-that-must-not-leak") + stdout, err := captureStdout(t, func() error { + return runPluginConformance([]string{ + "--mode", "typed-iac", + "--engine-version", "v0.51.2", + "--format", "json", + "--output", out, + fixture, + }) + }) + if err != nil { + t.Fatalf("runPluginConformance: %v", err) + } + if strings.Contains(stdout, "{") { + t.Fatalf("stdout should stay concise when --output is used, got %q", stdout) + } + raw, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read evidence: %v", err) + } + if bytes.Contains(raw, []byte("secret-value-that-must-not-leak")) { + t.Fatalf("evidence JSON leaked environment data: %s", raw) + } + ev := readEvidence(t, out) + if ev.Status != PluginCompatibilityStatusPass { + t.Fatalf("status = %q, want pass", ev.Status) + } + if ev.Mode != PluginCompatibilityModeTypedIaC { + t.Fatalf("mode = %q", ev.Mode) + } + if ev.Plugin != "iac-pass" || ev.Version != "v0.1.0" || ev.EngineVersion != "v0.51.2" { + t.Fatalf("unexpected evidence identity: %#v", ev) + } + if ev.BinarySHA256 == "" || ev.PluginManifestSHA256 == "" || ev.EvidenceDigest == "" { + t.Fatalf("missing hashes/digest: %#v", ev) + } + if ev.ArchiveSHA256 != "" { + t.Fatalf("local-dir evidence should not include archiveSHA256: %#v", ev) + } +} + +func TestPluginConformanceDefaultEngineVersionIsStrictSemver(t *testing.T) { + t.Setenv("WFCTL_ENGINE_VERSION", "") + got := resolveConformanceEngineVersion() + if _, err := CanonicalEngineVersion(got); err != nil { + t.Fatalf("default engine version %q is not strict semver: %v", got, err) + } + t.Setenv("WFCTL_ENGINE_VERSION", "v0.51.2") + if got := resolveConformanceEngineVersion(); got != "v0.51.2" { + t.Fatalf("env engine version = %q, want v0.51.2", got) + } +} + +func TestPluginConformancePluginEnvIsScrubbed(t *testing.T) { + t.Setenv("COMPUTE_API_TOKEN", "must-not-reach-plugin") + t.Setenv("DIGITALOCEAN_TOKEN", "must-not-reach-plugin") + for _, kv := range conformancePluginEnv() { + if strings.HasPrefix(kv, "COMPUTE_API_TOKEN=") || strings.HasPrefix(kv, "DIGITALOCEAN_TOKEN=") { + t.Fatalf("sensitive env leaked to conformance plugin: %q", kv) + } + } +} + +func TestPluginConformanceSourceCopySkipsSensitiveFilesAndSymlinks(t *testing.T) { + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, "main.go"), []byte("package main\n"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + if err := os.WriteFile(filepath.Join(src, ".env"), []byte("SECRET=value\n"), 0o600); err != nil { + t.Fatalf("write env: %v", err) + } + outside := filepath.Join(t.TempDir(), "outside-secret") + if err := os.WriteFile(outside, []byte("outside"), 0o600); err != nil { + t.Fatalf("write outside: %v", err) + } + if err := os.Symlink(outside, filepath.Join(src, "linked-secret")); err != nil { + t.Fatalf("symlink: %v", err) + } + dst := filepath.Join(t.TempDir(), "stage") + if err := copyConformanceSourceDir(src, dst); err != nil { + t.Fatalf("copyConformanceSourceDir: %v", err) + } + if _, err := os.Stat(filepath.Join(dst, "main.go")); err != nil { + t.Fatalf("expected normal file copied: %v", err) + } + for _, forbidden := range []string{".env", "linked-secret"} { + if _, err := os.Stat(filepath.Join(dst, forbidden)); !os.IsNotExist(err) { + t.Fatalf("%s should not be staged, stat err=%v", forbidden, err) + } + } +} + +func TestPluginConformanceNoTypedIaCServiceFails(t *testing.T) { + fixture := prepareNoIACFixture(t) + out := filepath.Join(t.TempDir(), "failure.json") + err := runPluginConformance([]string{ + "--mode", "typed-iac", + "--engine-version", "v0.51.2", + "--format", "json", + "--output", out, + fixture, + }) + if err == nil { + t.Fatal("expected no typed-IaC service error") + } + if !strings.Contains(err.Error(), "typed") && !strings.Contains(err.Error(), "IaC") && !strings.Contains(err.Error(), "legacy") { + t.Fatalf("error = %v, want typed-IaC context", err) + } + ev := readEvidence(t, out) + if ev.Status != PluginCompatibilityStatusFail { + t.Fatalf("status = %q, want fail", ev.Status) + } + if ev.EvidenceDigest == "" { + t.Fatalf("failure evidence missing digest: %#v", ev) + } +} + +func TestPluginConformanceTextFormat(t *testing.T) { + fixture := prepareIACPassFixture(t) + if err := runPluginConformance([]string{ + "--mode", "typed-iac", + "--engine-version", "v0.51.2", + "--format", "text", + fixture, + }); err != nil { + t.Fatalf("runPluginConformance text: %v", err) + } +} + +func TestPluginConformanceArchiveIncludesArchiveHash(t *testing.T) { + dir := t.TempDir() + fixture := prepareIACPassFixture(t) + archive := filepath.Join(dir, "iac-pass.tar.gz") + writeTarGzFromDir(t, fixture, archive) + out := filepath.Join(dir, "evidence.json") + if err := runPluginConformance([]string{ + "--mode", "typed-iac", + "--artifact", archive, + "--engine-version", "v0.51.2", + "--format", "json", + "--output", out, + }); err != nil { + t.Fatalf("runPluginConformance archive: %v", err) + } + ev := readEvidence(t, out) + if ev.ArchiveSHA256 == "" { + t.Fatalf("archive evidence missing archiveSHA256: %#v", ev) + } + want, err := hashFileSHA256(archive) + if err != nil { + t.Fatalf("hash archive: %v", err) + } + if ev.ArchiveSHA256 != want { + t.Fatalf("archiveSHA256 = %q, want %q", ev.ArchiveSHA256, want) + } +} + +func TestPluginConformanceTimeoutKillsPlugin(t *testing.T) { + err := runPluginConformance([]string{ + "--mode", "typed-iac", + "--engine-version", "v0.51.2", + "--timeout", "200ms", + "testdata/conformance/iac-hang", + }) + if err == nil { + t.Fatal("expected timeout error") + } + if !strings.Contains(err.Error(), "timeout") { + t.Fatalf("error = %v, want timeout", err) + } +} + +func readEvidence(t *testing.T, path string) PluginCompatibilityEvidence { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read evidence: %v", err) + } + var ev PluginCompatibilityEvidence + if err := json.Unmarshal(data, &ev); err != nil { + t.Fatalf("parse evidence: %v", err) + } + return ev +} + +func prepareIACPassFixture(t *testing.T) string { + t.Helper() + return prepareConformanceFixture(t, "iac-pass") +} + +func prepareNoIACFixture(t *testing.T) string { + t.Helper() + return prepareConformanceFixture(t, "no-iac") +} + +func prepareConformanceFixture(t *testing.T, name string) string { + t.Helper() + dst := filepath.Join(t.TempDir(), name) + if err := copyDir(filepath.Join("testdata/conformance", name), dst); err != nil { + t.Fatalf("copy fixture: %v", err) + } + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("repo root: %v", err) + } + goMod := "module example.com/" + name + "\n\ngo 1.26.0\n\nrequire github.com/GoCodeAlone/workflow v0.0.0\n\nreplace github.com/GoCodeAlone/workflow => " + filepath.ToSlash(root) + "\n" + if err := os.WriteFile(filepath.Join(dst, "go.mod"), []byte(goMod), 0o600); err != nil { + t.Fatalf("write fixture go.mod: %v", err) + } + if err := copyFile(filepath.Join(root, "go.sum"), filepath.Join(dst, "go.sum"), 0o600); err != nil { + t.Fatalf("copy fixture go.sum: %v", err) + } + return dst +} + +func writeTarGzFromDir(t *testing.T, srcDir, dest string) { + t.Helper() + f, err := os.Create(dest) + if err != nil { + t.Fatalf("create archive: %v", err) + } + defer f.Close() + gw := gzip.NewWriter(f) + defer gw.Close() + tw := tar.NewWriter(gw) + defer tw.Close() + if err := filepath.WalkDir(srcDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + rel, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + hdr, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + hdr.Name = filepath.ToSlash(filepath.Join("iac-pass", rel)) + if err := tw.WriteHeader(hdr); err != nil { + return err + } + in, err := os.Open(path) + if err != nil { + return err + } + defer in.Close() + _, err = io.Copy(tw, in) + return err + }); err != nil { + t.Fatalf("write archive: %v", err) + } +} diff --git a/cmd/wfctl/testdata/conformance/iac-hang/go.mod b/cmd/wfctl/testdata/conformance/iac-hang/go.mod new file mode 100644 index 00000000..e94e6e2c --- /dev/null +++ b/cmd/wfctl/testdata/conformance/iac-hang/go.mod @@ -0,0 +1,3 @@ +module example.com/iac-hang + +go 1.26.0 diff --git a/cmd/wfctl/testdata/conformance/iac-hang/main.go b/cmd/wfctl/testdata/conformance/iac-hang/main.go new file mode 100644 index 00000000..7c0f5652 --- /dev/null +++ b/cmd/wfctl/testdata/conformance/iac-hang/main.go @@ -0,0 +1,7 @@ +package main + +import "time" + +func main() { + time.Sleep(10 * time.Second) +} diff --git a/cmd/wfctl/testdata/conformance/iac-hang/plugin.json b/cmd/wfctl/testdata/conformance/iac-hang/plugin.json new file mode 100644 index 00000000..be4f8124 --- /dev/null +++ b/cmd/wfctl/testdata/conformance/iac-hang/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "iac-hang", + "version": "0.1.0", + "author": "workflow", + "description": "Hanging conformance fixture" +} diff --git a/cmd/wfctl/testdata/conformance/iac-pass/go.mod b/cmd/wfctl/testdata/conformance/iac-pass/go.mod new file mode 100644 index 00000000..62871939 --- /dev/null +++ b/cmd/wfctl/testdata/conformance/iac-pass/go.mod @@ -0,0 +1,7 @@ +module example.com/iac-pass + +go 1.26.0 + +require github.com/GoCodeAlone/workflow v0.0.0 + +replace github.com/GoCodeAlone/workflow => ../../../../.. diff --git a/cmd/wfctl/testdata/conformance/iac-pass/main.go b/cmd/wfctl/testdata/conformance/iac-pass/main.go new file mode 100644 index 00000000..e13d59d7 --- /dev/null +++ b/cmd/wfctl/testdata/conformance/iac-pass/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "context" + + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +type provider struct { + pb.UnimplementedIaCProviderRequiredServer +} + +func (p *provider) Name(context.Context, *pb.NameRequest) (*pb.NameResponse, error) { + return &pb.NameResponse{Name: "iac-pass"}, nil +} + +func (p *provider) Version(context.Context, *pb.VersionRequest) (*pb.VersionResponse, error) { + return &pb.VersionResponse{Version: "0.1.0"}, nil +} + +func (p *provider) Capabilities(context.Context, *pb.CapabilitiesRequest) (*pb.CapabilitiesResponse, error) { + return &pb.CapabilitiesResponse{CanonicalKeys: []string{"region"}}, nil +} + +func main() { + sdk.ServeIaCPlugin(&provider{}, sdk.IaCServeOptions{}) +} diff --git a/cmd/wfctl/testdata/conformance/iac-pass/plugin.json b/cmd/wfctl/testdata/conformance/iac-pass/plugin.json new file mode 100644 index 00000000..accba100 --- /dev/null +++ b/cmd/wfctl/testdata/conformance/iac-pass/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "iac-pass", + "version": "0.1.0", + "author": "workflow", + "description": "Passing typed IaC conformance fixture" +} diff --git a/cmd/wfctl/testdata/conformance/no-iac/go.mod b/cmd/wfctl/testdata/conformance/no-iac/go.mod new file mode 100644 index 00000000..8f7a1f7a --- /dev/null +++ b/cmd/wfctl/testdata/conformance/no-iac/go.mod @@ -0,0 +1,7 @@ +module example.com/no-iac + +go 1.26.0 + +require github.com/GoCodeAlone/workflow v0.0.0 + +replace github.com/GoCodeAlone/workflow => ../../../../.. diff --git a/cmd/wfctl/testdata/conformance/no-iac/main.go b/cmd/wfctl/testdata/conformance/no-iac/main.go new file mode 100644 index 00000000..78c09b4e --- /dev/null +++ b/cmd/wfctl/testdata/conformance/no-iac/main.go @@ -0,0 +1,18 @@ +package main + +import "github.com/GoCodeAlone/workflow/plugin/external/sdk" + +type provider struct{} + +func (p *provider) Manifest() sdk.PluginManifest { + return sdk.PluginManifest{ + Name: "no-iac", + Version: "0.1.0", + Author: "workflow", + Description: "Plugin fixture without typed IaC service", + } +} + +func main() { + sdk.Serve(&provider{}) +} diff --git a/cmd/wfctl/testdata/conformance/no-iac/plugin.json b/cmd/wfctl/testdata/conformance/no-iac/plugin.json new file mode 100644 index 00000000..deae30f5 --- /dev/null +++ b/cmd/wfctl/testdata/conformance/no-iac/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "no-iac", + "version": "0.1.0", + "author": "workflow", + "description": "Plugin fixture without typed IaC service" +} From 8feb2fc856d81a684c2cb3883148f196ab3e6ab5 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 01:33:17 -0400 Subject: [PATCH 20/29] feat(wfctl): update registry compat indexes --- cmd/wfctl/plugin_compat_model.go | 2 + cmd/wfctl/registry_cmd.go | 11 +- cmd/wfctl/registry_compatibility.go | 403 +++++++++++++++++++++++ cmd/wfctl/registry_compatibility_test.go | 320 ++++++++++++++++++ 4 files changed, 732 insertions(+), 4 deletions(-) create mode 100644 cmd/wfctl/registry_compatibility.go create mode 100644 cmd/wfctl/registry_compatibility_test.go diff --git a/cmd/wfctl/plugin_compat_model.go b/cmd/wfctl/plugin_compat_model.go index 0ccc1e62..b92af299 100644 --- a/cmd/wfctl/plugin_compat_model.go +++ b/cmd/wfctl/plugin_compat_model.go @@ -63,6 +63,8 @@ type CompatibilityEvidencePolicy struct { FirstParty string `json:"firstParty,omitempty"` Community string `json:"community,omitempty"` RequiredFromEngine string `json:"requiredFromEngine,omitempty"` + LatestEngine string `json:"latestEngine,omitempty"` + Stale bool `json:"stale,omitempty"` } type PluginVersionIndex struct { diff --git a/cmd/wfctl/registry_cmd.go b/cmd/wfctl/registry_cmd.go index 27b58010..7930f4c8 100644 --- a/cmd/wfctl/registry_cmd.go +++ b/cmd/wfctl/registry_cmd.go @@ -30,18 +30,21 @@ func runRegistryPluginCatalog(args []string) error { return runRegistryAdd(args[1:]) case "remove": return runRegistryRemove(args[1:]) + case "compatibility": + return runRegistryCompatibility(args[1:]) default: return registryUsage() } } func registryUsage() error { - fmt.Fprintf(flag.CommandLine.Output(), `Usage: wfctl registry [options] + fmt.Fprintf(flag.CommandLine.Output(), `Usage: wfctl plugin-registry [options] Subcommands: - list Show configured plugin registries - add Add a plugin registry source - remove Remove a plugin registry source + list Show configured plugin registries + add Add a plugin registry source + remove Remove a plugin registry source + compatibility Manage generated plugin compatibility indexes `) return fmt.Errorf("registry subcommand is required") } diff --git a/cmd/wfctl/registry_compatibility.go b/cmd/wfctl/registry_compatibility.go new file mode 100644 index 00000000..bffd01ed --- /dev/null +++ b/cmd/wfctl/registry_compatibility.go @@ -0,0 +1,403 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "golang.org/x/mod/semver" +) + +type evidenceFlag []string + +func (f *evidenceFlag) String() string { + return strings.Join(*f, ",") +} + +func (f *evidenceFlag) Set(value string) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("--evidence path must not be empty") + } + *f = append(*f, value) + return nil +} + +func runRegistryCompatibility(args []string) error { + if len(args) < 1 { + return registryCompatibilityUsage() + } + switch args[0] { + case "update": + return runRegistryCompatibilityUpdate(args[1:]) + default: + return registryCompatibilityUsage() + } +} + +func registryCompatibilityUsage() error { + fmt.Fprintf(flag.CommandLine.Output(), `Usage: wfctl plugin-registry compatibility [options] + +Subcommands: + update Update compatibility//index.json from evidence files +`) + return fmt.Errorf("registry compatibility subcommand is required") +} + +func runRegistryCompatibilityUpdate(args []string) error { + fs := flag.NewFlagSet("plugin-registry compatibility update", flag.ContinueOnError) + registryDir := fs.String("registry-dir", "", "Path to local plugin registry checkout") + pluginName := fs.String("plugin", "", "Plugin name") + version := fs.String("version", "", "Plugin version") + deriveRanges := fs.Bool("derive-ranges", false, "Derive pass ranges from enumerated evidence") + latestEngine := fs.String("latest-engine", "", "Latest engine version used to mark stale evidence") + var evidence evidenceFlag + fs.Var(&evidence, "evidence", "Path to compatibility evidence JSON (repeatable)") + fs.Usage = func() { + fmt.Fprintf(fs.Output(), "Usage: wfctl plugin-registry compatibility update --registry-dir --plugin --version --evidence [--evidence ]\n\nUpdate a plugin catalog compatibility index atomically.\n\nFlags: --registry-dir --plugin --version --evidence --derive-ranges --latest-engine\n\nOptions:\n") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + if *registryDir == "" || *pluginName == "" || *version == "" { + fs.Usage() + return fmt.Errorf("--registry-dir, --plugin, and --version are required") + } + if len(evidence) == 0 { + return fmt.Errorf("at least one --evidence file is required") + } + return updateRegistryCompatibilityIndex(registryCompatibilityUpdateOptions{ + RegistryDir: *registryDir, + Plugin: *pluginName, + Version: *version, + Evidence: evidence, + DeriveRanges: *deriveRanges, + LatestEngine: *latestEngine, + }) +} + +type registryCompatibilityUpdateOptions struct { + RegistryDir string + Plugin string + Version string + Evidence []string + DeriveRanges bool + LatestEngine string +} + +func updateRegistryCompatibilityIndex(opts registryCompatibilityUpdateOptions) error { + pluginName := strings.TrimSpace(opts.Plugin) + version, err := CanonicalPluginVersion(opts.Version) + if err != nil { + return err + } + manifest, err := loadRegistryCompatibilityManifest(opts.RegistryDir, pluginName) + if err != nil { + return err + } + if manifest.Name != pluginName && normalizePluginName(manifest.Name) != normalizePluginName(pluginName) { + return fmt.Errorf("manifest plugin %q does not match --plugin %q", manifest.Name, opts.Plugin) + } + manifestVersion, err := CanonicalPluginVersion(manifest.Version) + if err != nil { + return fmt.Errorf("manifest version: %w", err) + } + if manifestVersion != version { + return fmt.Errorf("manifest version %s does not match --version %s", manifestVersion, version) + } + + validated := make([]PluginCompatibilityEvidence, 0, len(opts.Evidence)) + for _, path := range opts.Evidence { + ev, err := loadRegistryCompatibilityEvidence(path) + if err != nil { + return err + } + if ev.Plugin != pluginName && normalizePluginName(ev.Plugin) != normalizePluginName(pluginName) { + return fmt.Errorf("evidence plugin %q does not match --plugin %q", ev.Plugin, opts.Plugin) + } + if ev.Version != version { + return fmt.Errorf("evidence version %s does not match --version %s", ev.Version, version) + } + if err := validateEvidenceArchiveMatchesDownload(ev, manifest); err != nil { + return err + } + validated = append(validated, ev) + } + + indexPath := filepath.Join(opts.RegistryDir, "compatibility", pluginName, "index.json") + index, err := loadRegistryCompatibilityIndex(indexPath, pluginName) + if err != nil { + return err + } + record := buildCompatibilityVersionRecord(version, manifest, validated) + upsertCompatibilityRecord(index, record) + sortCompatibilityIndex(index) + if opts.DeriveRanges { + deriveCompatibilityRanges(index) + sortCompatibilityIndex(index) + } + if opts.LatestEngine != "" { + latest, err := CanonicalEngineVersion(opts.LatestEngine) + if err != nil { + return fmt.Errorf("latest engine: %w", err) + } + index.EvidencePolicy.LatestEngine = latest + index.EvidencePolicy.Stale = compatibilityIndexIsStale(index, latest) + } + + data, err := json.MarshalIndent(index, "", " ") + if err != nil { + return fmt.Errorf("marshal compatibility index: %w", err) + } + if err := atomicWriteFile(indexPath, append(data, '\n'), 0o600); err != nil { + return fmt.Errorf("write compatibility index: %w", err) + } + fmt.Printf("Updated compatibility index %s\n", indexPath) + return nil +} + +func loadRegistryCompatibilityManifest(registryDir, plugin string) (*RegistryManifest, error) { + path := filepath.Join(registryDir, "plugins", plugin, "manifest.json") + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read plugin manifest: %w", err) + } + var manifest RegistryManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return nil, fmt.Errorf("parse plugin manifest: %w", err) + } + return &manifest, nil +} + +func loadRegistryCompatibilityEvidence(path string) (PluginCompatibilityEvidence, error) { + data, err := os.ReadFile(path) + if err != nil { + return PluginCompatibilityEvidence{}, fmt.Errorf("read evidence %s: %w", path, err) + } + var ev PluginCompatibilityEvidence + if err := json.Unmarshal(data, &ev); err != nil { + return PluginCompatibilityEvidence{}, fmt.Errorf("parse evidence %s: %w", path, err) + } + ev, err = ValidateCompatibilityEvidence(ev) + if err != nil { + return PluginCompatibilityEvidence{}, fmt.Errorf("validate evidence %s: %w", path, err) + } + return ev, nil +} + +func loadRegistryCompatibilityIndex(path, plugin string) (*PluginVersionIndex, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &PluginVersionIndex{Plugin: plugin}, nil + } + return nil, fmt.Errorf("read compatibility index: %w", err) + } + var index PluginVersionIndex + if err := json.Unmarshal(data, &index); err != nil { + return nil, fmt.Errorf("parse compatibility index: %w", err) + } + if index.Plugin == "" { + index.Plugin = plugin + } + if index.Plugin != plugin && normalizePluginName(index.Plugin) != normalizePluginName(plugin) { + return nil, fmt.Errorf("compatibility index plugin %q does not match %q", index.Plugin, plugin) + } + index.Plugin = plugin + return &index, nil +} + +func buildCompatibilityVersionRecord(version string, manifest *RegistryManifest, evidence []PluginCompatibilityEvidence) PluginVersionRecord { + rec := PluginVersionRecord{ + Version: version, + MinEngineVersion: manifest.MinEngineVersion, + Downloads: normalizeCompatibilityDownloads(manifest.Downloads), + Compatibility: dedupeCompatibilityEvidence(evidence), + } + if rec.MinEngineVersion != "" { + if canonical, err := CanonicalEngineVersion(rec.MinEngineVersion); err == nil { + rec.MinEngineVersion = canonical + } + } + sortCompatibilityEvidence(rec.Compatibility) + return rec +} + +func normalizeCompatibilityDownloads(downloads []PluginDownload) []PluginDownload { + out := slices.Clone(downloads) + for i := range out { + if out[i].SHA256 == "" { + continue + } + if normalized, err := NormalizeSHA256Hex(out[i].SHA256); err == nil { + out[i].SHA256 = normalized + } + } + return out +} + +func validateEvidenceArchiveMatchesDownload(ev PluginCompatibilityEvidence, manifest *RegistryManifest) error { + if ev.ArchiveSHA256 == "" { + return nil + } + for _, download := range manifest.Downloads { + if download.OS != ev.OS || download.Arch != ev.Arch || download.SHA256 == "" { + continue + } + sha, err := NormalizeSHA256Hex(download.SHA256) + if err != nil { + return fmt.Errorf("manifest download sha256 for %s/%s: %w", download.OS, download.Arch, err) + } + if sha == ev.ArchiveSHA256 { + return nil + } + return fmt.Errorf("evidence archiveSHA256 %s does not match manifest download sha256 %s for %s/%s", ev.ArchiveSHA256, sha, ev.OS, ev.Arch) + } + return fmt.Errorf("evidence archiveSHA256 %s has no matching manifest download for %s/%s", ev.ArchiveSHA256, ev.OS, ev.Arch) +} + +func upsertCompatibilityRecord(index *PluginVersionIndex, rec PluginVersionRecord) { + for i := range index.Versions { + if index.Versions[i].Version == rec.Version { + index.Versions[i] = rec + return + } + } + index.Versions = append(index.Versions, rec) +} + +func sortCompatibilityIndex(index *PluginVersionIndex) { + slices.SortFunc(index.Versions, func(a, b PluginVersionRecord) int { + return -semver.Compare(a.Version, b.Version) + }) + for i := range index.Versions { + sortCompatibilityEvidence(index.Versions[i].Compatibility) + } +} + +func sortCompatibilityEvidence(evidence []PluginCompatibilityEvidence) { + slices.SortFunc(evidence, func(a, b PluginCompatibilityEvidence) int { + if c := semver.Compare(a.EngineVersion, b.EngineVersion); c != 0 { + return c + } + if c := strings.Compare(a.Mode, b.Mode); c != 0 { + return c + } + if c := strings.Compare(a.OS, b.OS); c != 0 { + return c + } + if c := strings.Compare(a.Arch, b.Arch); c != 0 { + return c + } + return strings.Compare(a.Status, b.Status) + }) +} + +func dedupeCompatibilityEvidence(evidence []PluginCompatibilityEvidence) []PluginCompatibilityEvidence { + out := make([]PluginCompatibilityEvidence, 0, len(evidence)) + seen := map[string]bool{} + for _, ev := range evidence { + key := strings.Join([]string{ + ev.Plugin, ev.Version, ev.EngineVersion, ev.Mode, ev.Status, ev.OS, ev.Arch, ev.ArchiveSHA256, + }, "\x00") + if seen[key] { + continue + } + seen[key] = true + out = append(out, ev) + } + return out +} + +func deriveCompatibilityRanges(index *PluginVersionIndex) { + for recIdx := range index.Versions { + rec := &index.Versions[recIdx] + byKey := map[string][]PluginCompatibilityEvidence{} + hasFail := map[string]bool{} + for _, ev := range rec.Compatibility { + if ev.CompatibleEngineRange != nil { + continue + } + key := strings.Join([]string{ev.Mode, ev.OS, ev.Arch, ev.ArchiveSHA256}, "\x00") + if ev.Status == PluginCompatibilityStatusFail { + hasFail[key] = true + continue + } + if ev.Status == PluginCompatibilityStatusPass { + byKey[key] = append(byKey[key], ev) + } + } + for key, passes := range byKey { + if hasFail[key] || len(passes) < 2 { + continue + } + sortCompatibilityEvidence(passes) + first, last := passes[0], passes[len(passes)-1] + if first.EngineVersion == last.EngineVersion { + continue + } + rangeEvidence := last + rangeEvidence.CompatibleEngineRange = &PluginCompatibilityRange{ + Min: first.EngineVersion, + Max: last.EngineVersion, + Derivation: "enumerated-pass", + } + normalized, err := ValidateCompatibilityEvidence(rangeEvidence) + if err == nil { + rec.Compatibility = append(rec.Compatibility, normalized) + } + } + } +} + +func compatibilityIndexIsStale(index *PluginVersionIndex, latestEngine string) bool { + newest := "" + for _, rec := range index.Versions { + for _, ev := range rec.Compatibility { + if newest == "" || semver.Compare(ev.EngineVersion, newest) > 0 { + newest = ev.EngineVersion + } + } + } + return newest == "" || semver.Compare(newest, latestEngine) < 0 +} + +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o750); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".tmp-index-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) //nolint:errcheck + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpName, path); err != nil { + return err + } + if dirFile, err := os.Open(dir); err == nil { + _ = dirFile.Sync() + _ = dirFile.Close() + } + return nil +} diff --git a/cmd/wfctl/registry_compatibility_test.go b/cmd/wfctl/registry_compatibility_test.go new file mode 100644 index 00000000..0aac462e --- /dev/null +++ b/cmd/wfctl/registry_compatibility_test.go @@ -0,0 +1,320 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "os" + "path/filepath" + "strings" + "testing" +) + +const ( + testArchiveSHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + testOtherArchiveSHA256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) + +func TestRegistryCompatibilityUpdateHelp(t *testing.T) { + output, err := captureStderr(t, func() error { + return runPluginRegistry([]string{"compatibility", "update", "--help"}) + }) + if !errors.Is(err, flag.ErrHelp) { + t.Fatalf("runPluginRegistry compatibility update --help error = %v, want flag.ErrHelp", err) + } + for _, want := range []string{"--registry-dir", "--plugin", "--version", "--evidence", "--derive-ranges", "--latest-engine"} { + if !strings.Contains(output, want) { + t.Fatalf("help output missing %q:\n%s", want, output) + } + } +} + +func TestRegistryCompatibilityUpdateWritesStableIndex(t *testing.T) { + registryDir := prepareCompatibilityRegistry(t, "workflow-plugin-test", "v0.1.0", testArchiveSHA256) + evPath := writeCompatibilityEvidence(t, registryDir, PluginCompatibilityEvidence{ + Plugin: "workflow-plugin-test", + Version: "0.1.0", + EngineVersion: "0.51.2", + WfctlVersion: "0.51.2", + Mode: PluginCompatibilityModeTypedIaC, + Status: PluginCompatibilityStatusPass, + OS: "darwin", + Arch: "arm64", + ArchiveSHA256: testArchiveSHA256, + GeneratedBy: "test", + }) + + if err := runPluginRegistry([]string{ + "compatibility", "update", + "--registry-dir", registryDir, + "--plugin", "workflow-plugin-test", + "--version", "v0.1.0", + "--evidence", evPath, + }); err != nil { + t.Fatalf("compatibility update: %v", err) + } + + idx := readCompatibilityIndex(t, registryDir, "workflow-plugin-test") + if idx.Plugin != "workflow-plugin-test" || len(idx.Versions) != 1 { + t.Fatalf("unexpected index: %#v", idx) + } + rec := idx.Versions[0] + if rec.Version != "v0.1.0" || rec.MinEngineVersion != "v0.50.0" { + t.Fatalf("unexpected version record: %#v", rec) + } + if len(rec.Compatibility) != 1 { + t.Fatalf("compatibility count = %d, want 1", len(rec.Compatibility)) + } + ev := rec.Compatibility[0] + if ev.Plugin != "workflow-plugin-test" || ev.Version != "v0.1.0" || ev.EngineVersion != "v0.51.2" { + t.Fatalf("evidence not normalized: %#v", ev) + } + if ev.EvidenceDigest == "" { + t.Fatalf("missing evidence digest: %#v", ev) + } +} + +func TestRegistryCompatibilityUpdateRejectsArchiveMismatchAndLeavesIndex(t *testing.T) { + registryDir := prepareCompatibilityRegistry(t, "workflow-plugin-test", "v0.1.0", testArchiveSHA256) + indexPath := filepath.Join(registryDir, "compatibility", "workflow-plugin-test", "index.json") + if err := os.MkdirAll(filepath.Dir(indexPath), 0o750); err != nil { + t.Fatalf("mkdir index dir: %v", err) + } + original := []byte(`{"plugin":"workflow-plugin-test","versions":[]}` + "\n") + if err := os.WriteFile(indexPath, original, 0o600); err != nil { + t.Fatalf("write original index: %v", err) + } + evPath := writeCompatibilityEvidence(t, registryDir, PluginCompatibilityEvidence{ + Plugin: "workflow-plugin-test", + Version: "v0.1.0", + EngineVersion: "v0.51.2", + Mode: PluginCompatibilityModeTypedIaC, + Status: PluginCompatibilityStatusPass, + OS: "darwin", + Arch: "arm64", + ArchiveSHA256: testOtherArchiveSHA256, + }) + + err := runPluginRegistry([]string{ + "compatibility", "update", + "--registry-dir", registryDir, + "--plugin", "workflow-plugin-test", + "--version", "v0.1.0", + "--evidence", evPath, + }) + if err == nil { + t.Fatal("expected archive mismatch error") + } + if !strings.Contains(err.Error(), "archiveSHA256") { + t.Fatalf("error = %v, want archiveSHA256 context", err) + } + got, readErr := os.ReadFile(indexPath) + if readErr != nil { + t.Fatalf("read index: %v", readErr) + } + if string(got) != string(original) { + t.Fatalf("index changed after failure:\n%s", got) + } +} + +func TestRegistryCompatibilityUpdateSortsVersionsEvidenceAndMarksStale(t *testing.T) { + registryDir := prepareCompatibilityRegistry(t, "workflow-plugin-test", "v0.2.0", testArchiveSHA256) + writeInitialCompatibilityIndex(t, registryDir, PluginVersionIndex{ + Plugin: "workflow-plugin-test", + Versions: []PluginVersionRecord{{ + Version: "v0.1.0", + Compatibility: []PluginCompatibilityEvidence{{ + Plugin: "workflow-plugin-test", + Version: "v0.1.0", + EngineVersion: "v0.51.0", + Mode: PluginCompatibilityModeTypedIaC, + Status: PluginCompatibilityStatusPass, + OS: "linux", + Arch: "amd64", + }}, + }}, + }) + ev1 := writeCompatibilityEvidenceNamed(t, registryDir, "ev1.json", PluginCompatibilityEvidence{ + Plugin: "workflow-plugin-test", + Version: "v0.2.0", + EngineVersion: "v0.51.2", + Mode: PluginCompatibilityModeTypedIaC, + Status: PluginCompatibilityStatusPass, + OS: "linux", + Arch: "amd64", + ArchiveSHA256: testArchiveSHA256, + }) + ev2 := writeCompatibilityEvidenceNamed(t, registryDir, "ev2.json", PluginCompatibilityEvidence{ + Plugin: "workflow-plugin-test", + Version: "v0.2.0", + EngineVersion: "v0.51.1", + Mode: PluginCompatibilityModeTypedIaC, + Status: PluginCompatibilityStatusPass, + OS: "darwin", + Arch: "arm64", + ArchiveSHA256: testArchiveSHA256, + }) + + if err := runPluginRegistry([]string{ + "compatibility", "update", + "--registry-dir", registryDir, + "--plugin", "workflow-plugin-test", + "--version", "v0.2.0", + "--evidence", ev1, + "--evidence", ev2, + "--latest-engine", "v0.51.3", + }); err != nil { + t.Fatalf("compatibility update: %v", err) + } + + idx := readCompatibilityIndex(t, registryDir, "workflow-plugin-test") + if len(idx.Versions) != 2 || idx.Versions[0].Version != "v0.2.0" || idx.Versions[1].Version != "v0.1.0" { + t.Fatalf("versions not sorted descending: %#v", idx.Versions) + } + if !idx.EvidencePolicy.Stale || idx.EvidencePolicy.LatestEngine != "v0.51.3" { + t.Fatalf("stale policy not set: %#v", idx.EvidencePolicy) + } + if got := idx.Versions[0].Compatibility[0]; got.EngineVersion != "v0.51.1" || got.OS != "darwin" { + t.Fatalf("evidence not sorted/attached: %#v", got) + } +} + +func TestRegistryCompatibilityUpdateDerivesPassRange(t *testing.T) { + registryDir := prepareCompatibilityRegistry(t, "workflow-plugin-test", "v0.1.0", testArchiveSHA256) + ev1 := writeCompatibilityEvidenceNamed(t, registryDir, "ev1.json", PluginCompatibilityEvidence{ + Plugin: "workflow-plugin-test", + Version: "v0.1.0", + EngineVersion: "v0.51.1", + Mode: PluginCompatibilityModeTypedIaC, + Status: PluginCompatibilityStatusPass, + OS: "darwin", + Arch: "arm64", + ArchiveSHA256: testArchiveSHA256, + }) + ev2 := writeCompatibilityEvidenceNamed(t, registryDir, "ev2.json", PluginCompatibilityEvidence{ + Plugin: "workflow-plugin-test", + Version: "v0.1.0", + EngineVersion: "v0.51.2", + Mode: PluginCompatibilityModeTypedIaC, + Status: PluginCompatibilityStatusPass, + OS: "darwin", + Arch: "arm64", + ArchiveSHA256: testArchiveSHA256, + }) + if err := runPluginRegistry([]string{ + "compatibility", "update", + "--registry-dir", registryDir, + "--plugin", "workflow-plugin-test", + "--version", "v0.1.0", + "--evidence", ev1, + "--evidence", ev2, + "--derive-ranges", + }); err != nil { + t.Fatalf("compatibility update: %v", err) + } + idx := readCompatibilityIndex(t, registryDir, "workflow-plugin-test") + foundRange := false + for _, ev := range idx.Versions[0].Compatibility { + if ev.CompatibleEngineRange != nil { + foundRange = true + if ev.CompatibleEngineRange.Min != "v0.51.1" || ev.CompatibleEngineRange.Max != "v0.51.2" { + t.Fatalf("unexpected range: %#v", ev.CompatibleEngineRange) + } + } + } + if !foundRange { + t.Fatalf("missing derived range: %#v", idx.Versions[0].Compatibility) + } +} + +func prepareCompatibilityRegistry(t *testing.T, plugin, version, archiveSHA string) string { + t.Helper() + dir := t.TempDir() + writeManifest(t, dir, plugin, version, archiveSHA) + return dir +} + +func writeManifest(t *testing.T, registryDir, plugin, version, archiveSHA string) { + t.Helper() + manifest := RegistryManifest{ + Name: plugin, + Version: version, + Author: "workflow", + Description: "test plugin", + Type: "external", + Tier: "community", + MinEngineVersion: "v0.50.0", + Downloads: []PluginDownload{{ + OS: "darwin", + Arch: "arm64", + URL: "https://example.invalid/plugin.tar.gz", + SHA256: archiveSHA, + }, { + OS: "linux", + Arch: "amd64", + URL: "https://example.invalid/plugin-linux.tar.gz", + SHA256: archiveSHA, + }}, + } + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + path := filepath.Join(registryDir, "plugins", plugin, "manifest.json") + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("mkdir manifest dir: %v", err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } +} + +func writeCompatibilityEvidence(t *testing.T, registryDir string, ev PluginCompatibilityEvidence) string { + t.Helper() + return writeCompatibilityEvidenceNamed(t, registryDir, "evidence.json", ev) +} + +func writeCompatibilityEvidenceNamed(t *testing.T, registryDir, name string, ev PluginCompatibilityEvidence) string { + t.Helper() + normalized, err := ValidateCompatibilityEvidence(ev) + if err != nil { + t.Fatalf("validate evidence: %v", err) + } + data, err := json.MarshalIndent(normalized, "", " ") + if err != nil { + t.Fatalf("marshal evidence: %v", err) + } + path := filepath.Join(registryDir, name) + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + t.Fatalf("write evidence: %v", err) + } + return path +} + +func writeInitialCompatibilityIndex(t *testing.T, registryDir string, idx PluginVersionIndex) { + t.Helper() + data, err := json.MarshalIndent(idx, "", " ") + if err != nil { + t.Fatalf("marshal index: %v", err) + } + path := filepath.Join(registryDir, "compatibility", idx.Plugin, "index.json") + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("mkdir index dir: %v", err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + t.Fatalf("write index: %v", err) + } +} + +func readCompatibilityIndex(t *testing.T, registryDir, plugin string) PluginVersionIndex { + t.Helper() + data, err := os.ReadFile(filepath.Join(registryDir, "compatibility", plugin, "index.json")) + if err != nil { + t.Fatalf("read index: %v", err) + } + var idx PluginVersionIndex + if err := json.Unmarshal(data, &idx); err != nil { + t.Fatalf("parse index: %v", err) + } + return idx +} From e171ce7054fcdc94242e352879bb99d64d4a02f1 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 01:49:51 -0400 Subject: [PATCH 21/29] feat(wfctl): resolve plugins by compat evidence --- cmd/wfctl/multi_registry.go | 33 +++ cmd/wfctl/plugin_compat_resolver.go | 266 +++++++++++++++++++++++ cmd/wfctl/plugin_compat_resolver_test.go | 207 ++++++++++++++++++ cmd/wfctl/plugin_install.go | 88 +++++++- cmd/wfctl/plugin_install_test.go | 170 +++++++++++++++ cmd/wfctl/registry_config.go | 7 +- 6 files changed, 765 insertions(+), 6 deletions(-) create mode 100644 cmd/wfctl/plugin_compat_resolver.go create mode 100644 cmd/wfctl/plugin_compat_resolver_test.go diff --git a/cmd/wfctl/multi_registry.go b/cmd/wfctl/multi_registry.go index 3007517f..86bff9d1 100644 --- a/cmd/wfctl/multi_registry.go +++ b/cmd/wfctl/multi_registry.go @@ -190,6 +190,39 @@ func (m *MultiRegistry) FetchVersionIndex(name string) (*PluginVersionIndex, str return nil, "", fmt.Errorf("plugin %q compatibility index not found in any configured registry", name) } +func (m *MultiRegistry) FetchManifestAndVersionIndex(name string) (*RegistryManifest, *PluginVersionIndex, string, error) { + if len(m.sources) == 0 { + return nil, nil, "", fmt.Errorf("plugin %q not found: no registry sources configured"+ + " (missing .wfctl.yaml? run `wfctl registry list` or set WFCTL_DEBUG=1)", name) + } + normalized := normalizePluginName(name) + var lastErr error + for _, candidate := range []string{name, normalized} { + if candidate == "" { + continue + } + for _, src := range m.sources { + manifest, err := src.FetchManifest(candidate) + if err != nil { + lastErr = err + continue + } + index, idxErr := src.FetchVersionIndex(candidate) + if idxErr != nil { + return manifest, nil, src.Name(), idxErr + } + return manifest, index, src.Name(), nil + } + if candidate == normalized { + break + } + } + if lastErr != nil { + return nil, nil, "", lastErr + } + return nil, nil, "", fmt.Errorf("plugin %q not found in any configured registry", name) +} + // SearchPlugins searches all sources and returns deduplicated results. // When the same plugin appears in multiple registries, the higher-priority source wins. // The query is normalized (stripping "workflow-plugin-" prefix) before searching. diff --git a/cmd/wfctl/plugin_compat_resolver.go b/cmd/wfctl/plugin_compat_resolver.go new file mode 100644 index 00000000..71b6df8d --- /dev/null +++ b/cmd/wfctl/plugin_compat_resolver.go @@ -0,0 +1,266 @@ +package main + +import ( + "fmt" + "os" + "runtime" + "slices" + "strings" + + "golang.org/x/mod/semver" +) + +const ( + PluginCompatModeEnforce = "enforce" + PluginCompatModeWarn = "warn" + + PluginCompatForceInstall = "force-install" + PluginCompatForceUpdate = "force-update" + PluginCompatWarnReason = "compat-mode=warn" +) + +type PluginCompatResolverOptions struct { + RequestedVersion string + EngineVersion string + CompatMode string + Force bool + ForceReason string + Trust CompatibilityTrustMode + OS string + Arch string +} + +type PluginCompatDecision struct { + Version string + Forced bool + Reason string + Warning string + Evidence *PluginCompatibilityEvidence +} + +func ResolvePluginCompatibility(index *PluginVersionIndex, manifest *RegistryManifest, opts PluginCompatResolverOptions) (PluginCompatDecision, error) { + if index == nil { + return PluginCompatDecision{}, fmt.Errorf("compatibility index is required") + } + engine, comparable := resolvePluginCompatEngineVersion(opts.EngineVersion) + mode, err := parsePluginCompatMode(opts.CompatMode) + if err != nil { + return PluginCompatDecision{}, err + } + if opts.Trust == "" { + opts.Trust = CompatibilityTrustAdvisory + } + if opts.OS == "" { + opts.OS = runtime.GOOS + } + if opts.Arch == "" { + opts.Arch = runtime.GOARCH + } + forceReason := opts.ForceReason + if forceReason == "" { + forceReason = PluginCompatForceInstall + } + + versions := slices.Clone(index.Versions) + sortCompatibilityIndex(&PluginVersionIndex{Versions: versions}) + if opts.RequestedVersion != "" { + requested, err := CanonicalPluginVersion(opts.RequestedVersion) + if err != nil { + return PluginCompatDecision{}, err + } + for _, rec := range versions { + if rec.Version == requested { + return evaluatePluginCompatRecord(rec, index.EvidencePolicy, manifest, engine, comparable, mode, opts, forceReason) + } + } + if manifest != nil && manifest.Version != "" && !shouldRequireCompatibilityEvidence(index.EvidencePolicy, engine, comparable, opts.Trust) { + return PluginCompatDecision{Version: requested, Warning: "requested version not found in compatibility index; falling back to manifest pinning"}, nil + } + return PluginCompatDecision{}, fmt.Errorf("requested version %s not found in compatibility index for %s", requested, index.Plugin) + } + for _, rec := range versions { + decision, err := evaluatePluginCompatRecord(rec, index.EvidencePolicy, manifest, engine, comparable, mode, opts, forceReason) + if err == nil { + return decision, nil + } + if !isKnownFailCompatError(err) { + return PluginCompatDecision{}, err + } + } + if manifest != nil && manifest.Version != "" { + version, err := CanonicalPluginVersion(manifest.Version) + if err == nil { + return PluginCompatDecision{Version: version, Warning: "no compatible indexed version found; falling back to manifest version"}, nil + } + } + return PluginCompatDecision{}, fmt.Errorf("no compatible version found for %s", index.Plugin) +} + +func evaluatePluginCompatRecord(rec PluginVersionRecord, policy CompatibilityEvidencePolicy, manifest *RegistryManifest, engine string, comparable bool, mode string, opts PluginCompatResolverOptions, forceReason string) (PluginCompatDecision, error) { + version, err := CanonicalPluginVersion(rec.Version) + if err != nil { + return PluginCompatDecision{}, err + } + if comparable && rec.MinEngineVersion != "" { + minEngine, err := CanonicalEngineVersion(rec.MinEngineVersion) + if err != nil { + return PluginCompatDecision{}, fmt.Errorf("minEngineVersion for %s: %w", version, err) + } + if semver.Compare(engine, minEngine) < 0 { + return PluginCompatDecision{}, fmt.Errorf("engine %s is below minEngineVersion %s for %s", engine, minEngine, version) + } + } + archiveSHA := platformArchiveSHA(rec.Downloads, manifest, opts.OS, opts.Arch) + ev, ok := findCompatibilityEvidence(rec.Compatibility, engine, comparable, opts.OS, opts.Arch, archiveSHA) + if ok { + if ev.Status == PluginCompatibilityStatusPass { + return PluginCompatDecision{Version: version, Evidence: &ev}, nil + } + if opts.Force { + return PluginCompatDecision{Version: version, Forced: true, Reason: forceReason, Evidence: &ev}, nil + } + if mode == PluginCompatModeWarn { + return PluginCompatDecision{Version: version, Forced: true, Reason: PluginCompatWarnReason, Warning: "compatibility evidence is fail; continuing because compat-mode=warn", Evidence: &ev}, nil + } + return PluginCompatDecision{}, knownFailCompatError{version: version, engine: engine} + } + if shouldRequireCompatibilityEvidence(policy, engine, comparable, opts.Trust) { + if opts.Force { + return PluginCompatDecision{Version: version, Forced: true, Reason: forceReason, Warning: "missing required compatibility evidence; continuing because --force is set"}, nil + } + if mode == PluginCompatModeWarn { + return PluginCompatDecision{Version: version, Forced: true, Reason: PluginCompatWarnReason, Warning: "missing required compatibility evidence; continuing because compat-mode=warn"}, nil + } + return PluginCompatDecision{}, fmt.Errorf("missing required compatibility evidence for %s on engine %s", version, engine) + } + decision := PluginCompatDecision{Version: version} + if !comparable { + decision.Warning = "local wfctl engine version is not comparable; compatibility evidence is advisory" + } else if opts.Trust == CompatibilityTrustAdvisory { + decision.Warning = "compatibility evidence is advisory for this registry" + } + return decision, nil +} + +func parsePluginCompatMode(raw string) (string, error) { + switch strings.TrimSpace(raw) { + case "", PluginCompatModeEnforce: + return PluginCompatModeEnforce, nil + case PluginCompatModeWarn: + return PluginCompatModeWarn, nil + default: + return "", fmt.Errorf("unsupported compat mode %q", raw) + } +} + +func resolvePluginCompatMode(cliValue string, cfg *RegistryConfig) (string, error) { + if strings.TrimSpace(cliValue) != "" { + return parsePluginCompatMode(cliValue) + } + if env := strings.TrimSpace(os.Getenv("WFCTL_PLUGIN_COMPAT_MODE")); env != "" { + return parsePluginCompatMode(env) + } + if cfg != nil && strings.TrimSpace(cfg.Compatibility.Mode) != "" { + return parsePluginCompatMode(cfg.Compatibility.Mode) + } + return PluginCompatModeEnforce, nil +} + +func resolvePluginCompatEngineVersion(raw string) (string, bool) { + if raw == "" { + raw = strings.TrimSpace(os.Getenv("WFCTL_ENGINE_VERSION")) + } + if raw == "" { + raw = buildVersion() + } + engine, err := CanonicalEngineVersion(raw) + if err != nil { + return "v0.0.0", false + } + return engine, true +} + +func platformArchiveSHA(recordDownloads []PluginDownload, manifest *RegistryManifest, goos, goarch string) string { + for _, d := range recordDownloads { + if d.OS == goos && d.Arch == goarch { + if sha, err := NormalizeSHA256Hex(d.SHA256); err == nil { + return sha + } + } + } + if manifest != nil { + for _, d := range manifest.Downloads { + if d.OS == goos && d.Arch == goarch { + if sha, err := NormalizeSHA256Hex(d.SHA256); err == nil { + return sha + } + } + } + } + return "" +} + +func findCompatibilityEvidence(evidence []PluginCompatibilityEvidence, engine string, comparable bool, goos, goarch, archiveSHA string) (PluginCompatibilityEvidence, bool) { + var rangeMatch *PluginCompatibilityEvidence + for i := range evidence { + ev := evidence[i] + if ev.Mode != PluginCompatibilityModeTypedIaC || ev.OS != goos || ev.Arch != goarch { + continue + } + if archiveSHA != "" && ev.ArchiveSHA256 != "" && ev.ArchiveSHA256 != archiveSHA { + continue + } + if comparable && ev.EngineVersion == engine { + return ev, true + } + if comparable && ev.CompatibleEngineRange != nil && + semver.Compare(engine, ev.CompatibleEngineRange.Min) >= 0 && + semver.Compare(engine, ev.CompatibleEngineRange.Max) <= 0 { + rangeMatch = &ev + } + } + if rangeMatch != nil { + return *rangeMatch, true + } + return PluginCompatibilityEvidence{}, false +} + +func shouldRequireCompatibilityEvidence(policy CompatibilityEvidencePolicy, engine string, comparable bool, trust CompatibilityTrustMode) bool { + if !comparable || trust != CompatibilityTrustFirstParty || policy.RequiredFromEngine == "" { + return false + } + requiredFrom, err := CanonicalEngineVersion(policy.RequiredFromEngine) + if err != nil { + return false + } + return semver.Compare(engine, requiredFrom) >= 0 +} + +type knownFailCompatError struct { + version string + engine string +} + +func (e knownFailCompatError) Error() string { + return fmt.Sprintf("compatibility evidence marks %s failed for engine %s", e.version, e.engine) +} + +func isKnownFailCompatError(err error) bool { + _, ok := err.(knownFailCompatError) + return ok +} + +func registryTrustMode(cfg *RegistryConfig, sourceName string) CompatibilityTrustMode { + if cfg == nil { + return CompatibilityTrustAdvisory + } + for _, source := range cfg.Registries { + if source.Name == sourceName { + if source.CompatibilityEvidence.Trust != "" { + return source.CompatibilityEvidence.Trust + } + return CompatibilityTrustAdvisory + } + } + return CompatibilityTrustAdvisory +} diff --git a/cmd/wfctl/plugin_compat_resolver_test.go b/cmd/wfctl/plugin_compat_resolver_test.go new file mode 100644 index 00000000..56ed8df3 --- /dev/null +++ b/cmd/wfctl/plugin_compat_resolver_test.go @@ -0,0 +1,207 @@ +package main + +import ( + "strings" + "testing" +) + +func TestPluginCompatResolverNewestExactTrustedPassWins(t *testing.T) { + idx := resolverIndex( + resolverRecord("v0.1.0", passEvidence("v0.1.0", "v0.51.2")), + resolverRecord("v0.2.0", passEvidence("v0.2.0", "v0.51.2")), + ) + decision, err := ResolvePluginCompatibility(idx, nil, resolverOptions()) + if err != nil { + t.Fatalf("ResolvePluginCompatibility: %v", err) + } + if decision.Version != "v0.2.0" || decision.Forced { + t.Fatalf("decision = %#v, want latest pass v0.2.0", decision) + } +} + +func TestPluginCompatResolverNewerFailSkipsOlderPass(t *testing.T) { + idx := resolverIndex( + resolverRecord("v0.1.0", passEvidence("v0.1.0", "v0.51.2")), + resolverRecord("v0.2.0", failEvidence("v0.2.0", "v0.51.2")), + ) + decision, err := ResolvePluginCompatibility(idx, nil, resolverOptions()) + if err != nil { + t.Fatalf("ResolvePluginCompatibility: %v", err) + } + if decision.Version != "v0.1.0" { + t.Fatalf("version = %s, want v0.1.0", decision.Version) + } +} + +func TestPluginCompatResolverRequestedKnownFailEnforces(t *testing.T) { + idx := resolverIndex(resolverRecord("v0.2.0", failEvidence("v0.2.0", "v0.51.2"))) + _, err := ResolvePluginCompatibility(idx, nil, PluginCompatResolverOptions{ + RequestedVersion: "v0.2.0", + EngineVersion: "v0.51.2", + CompatMode: PluginCompatModeEnforce, + Trust: CompatibilityTrustFirstParty, + OS: "darwin", + Arch: "arm64", + }) + if err == nil { + t.Fatal("expected known-fail error") + } + if !strings.Contains(err.Error(), "failed") { + t.Fatalf("error = %v, want failed context", err) + } +} + +func TestPluginCompatResolverWarnAndForcePermitKnownFail(t *testing.T) { + idx := resolverIndex(resolverRecord("v0.2.0", failEvidence("v0.2.0", "v0.51.2"))) + warn, err := ResolvePluginCompatibility(idx, nil, PluginCompatResolverOptions{ + RequestedVersion: "v0.2.0", + EngineVersion: "v0.51.2", + CompatMode: PluginCompatModeWarn, + Trust: CompatibilityTrustFirstParty, + OS: "darwin", + Arch: "arm64", + }) + if err != nil { + t.Fatalf("warn ResolvePluginCompatibility: %v", err) + } + if !warn.Forced || warn.Reason != PluginCompatWarnReason { + t.Fatalf("warn decision = %#v, want forced compat-mode=warn", warn) + } + forced, err := ResolvePluginCompatibility(idx, nil, PluginCompatResolverOptions{ + RequestedVersion: "v0.2.0", + EngineVersion: "v0.51.2", + CompatMode: PluginCompatModeEnforce, + Force: true, + ForceReason: PluginCompatForceUpdate, + Trust: CompatibilityTrustFirstParty, + OS: "darwin", + Arch: "arm64", + }) + if err != nil { + t.Fatalf("force ResolvePluginCompatibility: %v", err) + } + if !forced.Forced || forced.Reason != PluginCompatForceUpdate { + t.Fatalf("force decision = %#v, want forced update reason", forced) + } +} + +func TestPluginCompatResolverMissingRequiredFirstPartyEvidenceBlocks(t *testing.T) { + idx := resolverIndex(resolverRecord("v0.2.0")) + idx.EvidencePolicy.RequiredFromEngine = "v0.51.0" + _, err := ResolvePluginCompatibility(idx, nil, resolverOptions()) + if err == nil { + t.Fatal("expected missing required evidence error") + } + if !strings.Contains(err.Error(), "missing required compatibility evidence") { + t.Fatalf("error = %v, want missing evidence context", err) + } +} + +func TestPluginCompatResolverRequestedMissingFromRequiredIndexBlocks(t *testing.T) { + idx := resolverIndex(resolverRecord("v0.2.0", passEvidence("v0.2.0", "v0.51.2"))) + idx.EvidencePolicy.RequiredFromEngine = "v0.51.0" + _, err := ResolvePluginCompatibility(idx, &RegistryManifest{Version: "v0.2.0"}, PluginCompatResolverOptions{ + RequestedVersion: "v0.3.0", + EngineVersion: "v0.51.2", + CompatMode: PluginCompatModeEnforce, + Trust: CompatibilityTrustFirstParty, + OS: "darwin", + Arch: "arm64", + }) + if err == nil { + t.Fatal("expected requested missing required-index error") + } + if !strings.Contains(err.Error(), "not found") { + t.Fatalf("error = %v, want not found context", err) + } +} + +func TestPluginCompatResolverAdvisoryEvidenceFallsBackToMinEngine(t *testing.T) { + idx := resolverIndex(resolverRecord("v0.2.0")) + decision, err := ResolvePluginCompatibility(idx, nil, PluginCompatResolverOptions{ + EngineVersion: "v0.51.2", + CompatMode: PluginCompatModeEnforce, + Trust: CompatibilityTrustAdvisory, + OS: "darwin", + Arch: "arm64", + }) + if err != nil { + t.Fatalf("ResolvePluginCompatibility: %v", err) + } + if decision.Version != "v0.2.0" || decision.Warning == "" { + t.Fatalf("decision = %#v, want advisory fallback warning", decision) + } +} + +func TestPluginCompatResolverPseudoLocalVersionIsAdvisory(t *testing.T) { + idx := resolverIndex(resolverRecord("v0.2.0")) + idx.EvidencePolicy.RequiredFromEngine = "v0.51.0" + decision, err := ResolvePluginCompatibility(idx, nil, PluginCompatResolverOptions{ + EngineVersion: "dev", + CompatMode: PluginCompatModeEnforce, + Trust: CompatibilityTrustFirstParty, + OS: "darwin", + Arch: "arm64", + }) + if err != nil { + t.Fatalf("ResolvePluginCompatibility: %v", err) + } + if decision.Warning == "" { + t.Fatalf("decision = %#v, want advisory warning", decision) + } +} + +func resolverOptions() PluginCompatResolverOptions { + return PluginCompatResolverOptions{ + EngineVersion: "v0.51.2", + CompatMode: PluginCompatModeEnforce, + Trust: CompatibilityTrustFirstParty, + OS: "darwin", + Arch: "arm64", + } +} + +func resolverIndex(records ...PluginVersionRecord) *PluginVersionIndex { + return &PluginVersionIndex{ + Plugin: "workflow-plugin-test", + Versions: records, + } +} + +func resolverRecord(version string, evidence ...PluginCompatibilityEvidence) PluginVersionRecord { + return PluginVersionRecord{ + Version: version, + MinEngineVersion: "v0.50.0", + Downloads: []PluginDownload{{ + OS: "darwin", + Arch: "arm64", + SHA256: testArchiveSHA256, + }}, + Compatibility: evidence, + } +} + +func passEvidence(pluginVersion, engineVersion string) PluginCompatibilityEvidence { + return resolverEvidence(pluginVersion, engineVersion, PluginCompatibilityStatusPass) +} + +func failEvidence(pluginVersion, engineVersion string) PluginCompatibilityEvidence { + return resolverEvidence(pluginVersion, engineVersion, PluginCompatibilityStatusFail) +} + +func resolverEvidence(pluginVersion, engineVersion, status string) PluginCompatibilityEvidence { + ev, err := ValidateCompatibilityEvidence(PluginCompatibilityEvidence{ + Plugin: "workflow-plugin-test", + Version: pluginVersion, + EngineVersion: engineVersion, + Mode: PluginCompatibilityModeTypedIaC, + Status: status, + OS: "darwin", + Arch: "arm64", + ArchiveSHA256: testArchiveSHA256, + }) + if err != nil { + panic(err) + } + return ev +} diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 176ff9cd..a0544f33 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -80,6 +80,9 @@ func runPluginInstall(args []string) error { fromConfig := fs.String("from-config", "", "Install all requires.plugins[] from a workflow config file") sha256Flag := fs.String("sha256", "", "Expected SHA256 hex digest of the downloaded archive (for --url installs)") skipChecksum := fs.Bool("skip-checksum", false, "Skip integrity verification (WARNING: disables supply-chain protection)") + compatMode := fs.String("compat-mode", "", "Compatibility mode for registry installs: enforce or warn") + engineVersion := fs.String("engine-version", "", "Workflow engine version for compatibility resolution") + forceCompat := fs.Bool("force", false, "Permit known-failing compatibility evidence while still enforcing checksums") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: wfctl plugin install [options] [[@]]\n\nInstall a plugin from the registry, a URL, a local directory, or from the lockfile.\n\n wfctl plugin install Install latest from registry\n wfctl plugin install @v1.0.0 Install specific version\n wfctl plugin install --url Install from a direct download URL\n wfctl plugin install --local Install from a local build directory\n wfctl plugin install --from-config Install all requires.plugins[] from workflow config\n wfctl plugin install Install all plugins from .wfctl-lock.yaml\n\nOptions:\n") fs.PrintDefaults() @@ -165,7 +168,7 @@ func runPluginInstall(args []string) error { // to the normalized short name "auth". pluginName (normalized) is used only // for the on-disk install directory path. fmt.Fprintf(os.Stderr, "Fetching manifest for %q...\n", rawName) - manifest, sourceName, registryErr := mr.FetchManifest(rawName) + manifest, index, sourceName, registryErr := mr.FetchManifestAndVersionIndex(rawName) if registryErr != nil { // Registry lookup failed. Try GitHub direct install if input looks like owner/repo[@version]. @@ -186,13 +189,34 @@ func runPluginInstall(args []string) error { } fmt.Fprintf(os.Stderr, "Found in registry %q.\n", sourceName) + resolvedCompatMode, err := resolvePluginCompatMode(*compatMode, cfg) + if err != nil { + return err + } + decision, err := ResolvePluginCompatibility(index, manifest, PluginCompatResolverOptions{ + RequestedVersion: requestedVersion, + EngineVersion: *engineVersion, + CompatMode: resolvedCompatMode, + Force: *forceCompat, + ForceReason: PluginCompatForceInstall, + Trust: registryTrustMode(cfg, sourceName), + }) + if err != nil { + return err + } + if decision.Warning != "" { + fmt.Fprintf(os.Stderr, "warning: %s\n", decision.Warning) + } + if decision.Forced { + fmt.Fprintf(os.Stderr, "warning: forcing compatibility decision (%s)\n", decision.Reason) + } // Pin the manifest to the requested version when it differs from what the registry has. // The registry manifest may be stale (e.g. v0.1.0) while the user requests v0.2.1. // pinManifestToVersion rewrites download URLs in-place so the right release is fetched. registryVersion := manifest.Version - if requestedVersion != "" && requestedVersion != manifest.Version { - pinManifestToVersion(manifest, requestedVersion) + if decision.Version != "" && decision.Version != manifest.Version { + manifest = manifestForCompatibilityVersion(manifest, index, decision.Version) } // Resolve and install dependencies before installing the plugin itself. @@ -387,6 +411,10 @@ func runPluginUpdate(args []string) error { pinVersion := fs.String("version", "", "Pin to this specific version in wfctl.yaml (skips registry lookup)") manifestPath := fs.String("manifest", wfctlManifestPath, "Path to wfctl.yaml manifest") lockPath := fs.String("lock-file", wfctlLockPath, "Path to lockfile") + compatMode := fs.String("compat-mode", "", "Compatibility mode for registry updates: enforce or warn") + engineVersion := fs.String("engine-version", "", "Workflow engine version for compatibility resolution") + forceCompat := fs.Bool("force", false, "Permit known-failing compatibility evidence while still enforcing checksums") + skipChecksum := fs.Bool("skip-checksum", false, "Skip integrity verification (WARNING: disables supply-chain protection)") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: wfctl plugin update [options] \n\nUpdate an installed plugin to its latest version.\n\nOptions:\n") fs.PrintDefaults() @@ -434,16 +462,39 @@ func runPluginUpdate(args []string) error { mr := NewMultiRegistry(cfg) fmt.Fprintf(os.Stderr, "Fetching manifest for %q...\n", pluginName) - manifest, sourceName, registryErr := mr.FetchManifest(pluginName) + manifest, index, sourceName, registryErr := mr.FetchManifestAndVersionIndex(pluginName) if registryErr == nil { fmt.Fprintf(os.Stderr, "Found in registry %q.\n", sourceName) + resolvedCompatMode, err := resolvePluginCompatMode(*compatMode, cfg) + if err != nil { + return err + } + decision, err := ResolvePluginCompatibility(index, manifest, PluginCompatResolverOptions{ + EngineVersion: *engineVersion, + CompatMode: resolvedCompatMode, + Force: *forceCompat, + ForceReason: PluginCompatForceUpdate, + Trust: registryTrustMode(cfg, sourceName), + }) + if err != nil { + return err + } + if decision.Warning != "" { + fmt.Fprintf(os.Stderr, "warning: %s\n", decision.Warning) + } + if decision.Forced { + fmt.Fprintf(os.Stderr, "warning: forcing compatibility decision (%s)\n", decision.Reason) + } + if decision.Version != "" && decision.Version != manifest.Version { + manifest = manifestForCompatibilityVersion(manifest, index, decision.Version) + } installedVer := readInstalledVersion(pluginDir) if installedVer == manifest.Version { fmt.Printf("already at latest version (%s)\n", manifest.Version) return nil } fmt.Fprintf(os.Stderr, "Updating from %s to %s...\n", installedVer, manifest.Version) - return installPluginFromManifest(pluginDirVal, pluginName, manifest, nil, false) + return installPluginFromManifest(pluginDirVal, pluginName, manifest, nil, *skipChecksum) } // Registry lookup failed. If the plugin's manifest declares a repository @@ -832,6 +883,33 @@ func pinManifestToVersion(manifest *RegistryManifest, requestedVersion string) { } } +func manifestForCompatibilityVersion(manifest *RegistryManifest, index *PluginVersionIndex, version string) *RegistryManifest { + if manifest == nil { + return nil + } + out := *manifest + out.Downloads = append([]PluginDownload(nil), manifest.Downloads...) + out.Dependencies = append([]PluginDependency(nil), manifest.Dependencies...) + out.Keywords = append([]string(nil), manifest.Keywords...) + out.Contracts = append([]pluginContractDescriptor(nil), manifest.Contracts...) + if index != nil { + for _, rec := range index.Versions { + if rec.Version == version { + out.Version = version + if len(rec.Downloads) > 0 { + out.Downloads = append([]PluginDownload(nil), rec.Downloads...) + } + if rec.MinEngineVersion != "" { + out.MinEngineVersion = rec.MinEngineVersion + } + return &out + } + } + } + pinManifestToVersion(&out, version) + return &out +} + func rewriteArchiveFilenameVersion(rawURL, oldVersion, newVersion string) string { if oldVersion == "" || oldVersion == newVersion { return rawURL diff --git a/cmd/wfctl/plugin_install_test.go b/cmd/wfctl/plugin_install_test.go index 9319d370..05525dcb 100644 --- a/cmd/wfctl/plugin_install_test.go +++ b/cmd/wfctl/plugin_install_test.go @@ -6,6 +6,7 @@ import ( "compress/gzip" "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -672,3 +673,172 @@ func TestRunPluginInstall_SkipChecksumAndSHA256Contradiction_Errors(t *testing.T t.Errorf("expected error to mention both flags, got: %v", err) } } + +func TestRunPluginInstallCompatSkipsNewerKnownFail(t *testing.T) { + reg := newCompatInstallRegistry(t, "test", "v0.2.0", []compatInstallVersion{ + {Version: "v0.1.0", Status: PluginCompatibilityStatusPass}, + {Version: "v0.2.0", Status: PluginCompatibilityStatusFail}, + }) + pluginDir := t.TempDir() + if err := runPluginInstall([]string{ + "--config", reg.ConfigPath, + "--plugin-dir", pluginDir, + "--engine-version", "v0.51.2", + "test", + }); err != nil { + t.Fatalf("runPluginInstall: %v", err) + } + if got := readInstalledVersion(filepath.Join(pluginDir, "test")); got != "v0.1.0" { + t.Fatalf("installed version = %q, want v0.1.0", got) + } +} + +func TestRunPluginInstallCompatRequestedFailErrorsAndWarnPermits(t *testing.T) { + reg := newCompatInstallRegistry(t, "test", "v0.2.0", []compatInstallVersion{ + {Version: "v0.2.0", Status: PluginCompatibilityStatusFail}, + }) + err := runPluginInstall([]string{ + "--config", reg.ConfigPath, + "--plugin-dir", t.TempDir(), + "--engine-version", "v0.51.2", + "test@v0.2.0", + }) + if err == nil { + t.Fatal("expected requested known-fail error") + } + if !strings.Contains(err.Error(), "failed") { + t.Fatalf("error = %v, want failed context", err) + } + + pluginDir := t.TempDir() + if err := runPluginInstall([]string{ + "--config", reg.ConfigPath, + "--plugin-dir", pluginDir, + "--engine-version", "v0.51.2", + "--compat-mode", "warn", + "test@v0.2.0", + }); err != nil { + t.Fatalf("runPluginInstall warn: %v", err) + } + if got := readInstalledVersion(filepath.Join(pluginDir, "test")); got != "v0.2.0" { + t.Fatalf("installed version = %q, want v0.2.0", got) + } +} + +func TestRunPluginUpdateCompatUsesOlderPassingVersion(t *testing.T) { + reg := newCompatInstallRegistry(t, "test", "v0.2.0", []compatInstallVersion{ + {Version: "v0.1.0", Status: PluginCompatibilityStatusPass}, + {Version: "v0.2.0", Status: PluginCompatibilityStatusFail}, + }) + pluginDir := t.TempDir() + installed := filepath.Join(pluginDir, "test") + if err := os.MkdirAll(installed, 0o750); err != nil { + t.Fatalf("mkdir installed plugin: %v", err) + } + if err := os.WriteFile(filepath.Join(installed, "plugin.json"), []byte(`{"name":"test","version":"v0.0.1","author":"test","description":"old"}`), 0o600); err != nil { + t.Fatalf("write installed plugin.json: %v", err) + } + if err := runPluginUpdate([]string{ + "--config", reg.ConfigPath, + "--plugin-dir", pluginDir, + "--engine-version", "v0.51.2", + "test", + }); err != nil { + t.Fatalf("runPluginUpdate: %v", err) + } + if got := readInstalledVersion(installed); got != "v0.1.0" { + t.Fatalf("installed version = %q, want v0.1.0", got) + } +} + +type compatInstallRegistry struct { + ConfigPath string +} + +type compatInstallVersion struct { + Version string + Status string +} + +func newCompatInstallRegistry(t *testing.T, plugin, manifestVersion string, versions []compatInstallVersion) compatInstallRegistry { + t.Helper() + archiveData := makeTestTarGz(t, plugin) + sum := sha256.Sum256(archiveData) + archiveSHA := hex.EncodeToString(sum[:]) + var serverURL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/downloads/"): + _, _ = w.Write(archiveData) + case r.URL.Path == "/plugins/"+plugin+"/manifest.json": + writeCompatRegistryManifest(t, w, plugin, manifestVersion, serverURL, archiveSHA) + case r.URL.Path == "/compatibility/"+plugin+"/index.json": + writeCompatRegistryIndex(t, w, plugin, serverURL, archiveSHA, versions) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + serverURL = srv.URL + cfgPath := filepath.Join(t.TempDir(), "wfctl-registry.yaml") + cfg := "registries:\n" + + " - name: local\n" + + " type: static\n" + + " url: " + srv.URL + "\n" + + " compatibilityEvidence:\n" + + " trust: first_party\n" + if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil { + t.Fatalf("write registry config: %v", err) + } + return compatInstallRegistry{ConfigPath: cfgPath} +} + +func writeCompatRegistryManifest(t *testing.T, w http.ResponseWriter, plugin, version, baseURL, archiveSHA string) { + t.Helper() + manifest := makeTestManifest(plugin, baseURL+"/downloads/"+plugin+"-"+version+".tar.gz", archiveSHA) + manifest.Version = version + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) +} + +func writeCompatRegistryIndex(t *testing.T, w http.ResponseWriter, plugin, baseURL, archiveSHA string, versions []compatInstallVersion) { + t.Helper() + idx := PluginVersionIndex{ + Plugin: plugin, + EvidencePolicy: CompatibilityEvidencePolicy{ + RequiredFromEngine: "v0.51.0", + }, + } + for _, v := range versions { + ev := resolverEvidence(v.Version, "v0.51.2", v.Status) + ev.Plugin = plugin + ev.OS = runtime.GOOS + ev.Arch = runtime.GOARCH + ev.ArchiveSHA256 = archiveSHA + ev, err := ValidateCompatibilityEvidence(ev) + if err != nil { + t.Fatalf("validate evidence: %v", err) + } + idx.Versions = append(idx.Versions, PluginVersionRecord{ + Version: v.Version, + MinEngineVersion: "v0.50.0", + Downloads: []PluginDownload{{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + URL: baseURL + "/downloads/" + plugin + "-" + v.Version + ".tar.gz", + SHA256: archiveSHA, + }}, + Compatibility: []PluginCompatibilityEvidence{ev}, + }) + } + data, err := json.MarshalIndent(idx, "", " ") + if err != nil { + t.Fatalf("marshal index: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) +} diff --git a/cmd/wfctl/registry_config.go b/cmd/wfctl/registry_config.go index 4034cc7e..b6081f3f 100644 --- a/cmd/wfctl/registry_config.go +++ b/cmd/wfctl/registry_config.go @@ -11,7 +11,12 @@ import ( // RegistryConfig defines wfctl plugin registry configuration. type RegistryConfig struct { - Registries []RegistrySourceConfig `yaml:"registries" json:"registries"` + Registries []RegistrySourceConfig `yaml:"registries" json:"registries"` + Compatibility RegistryCompatibilityConfig `yaml:"compatibility,omitempty" json:"compatibility,omitempty"` +} + +type RegistryCompatibilityConfig struct { + Mode string `yaml:"mode,omitempty" json:"mode,omitempty"` } // RegistrySourceConfig defines a single registry source. From adbda393fdf7b389af5d063fa5af4fa0d0154f25 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 01:57:25 -0400 Subject: [PATCH 22/29] feat(wfctl): lock plugin compat metadata --- cmd/wfctl/plugin_lock.go | 106 +++++++++++++++++-- cmd/wfctl/plugin_lock_test.go | 189 ++++++++++++++++++++++++++++++++++ config/wfctl_lockfile.go | 43 +++++++- config/wfctl_lockfile_test.go | 49 +++++++++ 4 files changed, 375 insertions(+), 12 deletions(-) diff --git a/cmd/wfctl/plugin_lock.go b/cmd/wfctl/plugin_lock.go index aea4c49b..18a8b72f 100644 --- a/cmd/wfctl/plugin_lock.go +++ b/cmd/wfctl/plugin_lock.go @@ -21,13 +21,20 @@ func runPluginLock(args []string) error { cfgPath := fs.String("config", "workflow.yaml", "Path to workflow config file") manifestPath := fs.String("manifest", wfctlManifestPath, "Path to wfctl.yaml manifest") lockPath := fs.String("lock-file", wfctlLockPath, "Path to lockfile to write") + compatMode := fs.String("compat-mode", "", "Compatibility mode for registry lock resolution: enforce or warn") + engineVersion := fs.String("engine-version", "", "Workflow engine version for compatibility resolution") + forceCompat := fs.Bool("force", false, "Permit known-failing compatibility evidence in the lockfile") if err := fs.Parse(args); err != nil { return err } // Prefer wfctl.yaml manifest if it exists. if _, err := os.Stat(*manifestPath); err == nil { - return runPluginLockFromManifest(*manifestPath, *lockPath) + return runPluginLockFromManifestWithOptions(*manifestPath, *lockPath, pluginLockCompatibilityOptions{ + CompatMode: *compatMode, + EngineVersion: *engineVersion, + Force: *forceCompat, + }) } // Fall back to legacy workflow.yaml requires.plugins[]. @@ -38,6 +45,16 @@ func runPluginLock(args []string) error { // Existing platform data must be refreshed from a project-local registry so the // lockfile records portable archive checksums instead of host-specific binary hashes. func runPluginLockFromManifest(manifestPath, lockPath string) error { + return runPluginLockFromManifestWithOptions(manifestPath, lockPath, pluginLockCompatibilityOptions{}) +} + +type pluginLockCompatibilityOptions struct { + CompatMode string + EngineVersion string + Force bool +} + +func runPluginLockFromManifestWithOptions(manifestPath, lockPath string, compatOpts pluginLockCompatibilityOptions) error { m, err := config.LoadWfctlManifest(manifestPath) if err != nil { return fmt.Errorf("load manifest: %w", err) @@ -81,8 +98,11 @@ func runPluginLockFromManifest(manifestPath, lockPath string) error { previousHasPlatforms := previous != nil && len(previous.Platforms) > 0 if registries != nil { - if platforms, err := lockPlatformsFromRegistry(registries, p.Name, p.Version); err == nil { + if platforms, resolvedVersion, err := lockPlatformsFromRegistry(registries, registryConfig, p.Name, p.Version, compatOpts); err == nil { entry.Platforms = platforms + if resolvedVersion != "" { + entry.Version = resolvedVersion + } } else { switch { case errors.Is(err, errInvalidRegistrySHA256): @@ -133,13 +153,37 @@ func loadPluginLockRegistryConfig(manifestPath, lockPath string) (*RegistryConfi var errInvalidRegistrySHA256 = errors.New("invalid sha256") -func lockPlatformsFromRegistry(registries *MultiRegistry, pluginName, version string) (map[string]config.WfctlLockPlatform, error) { - manifest, _, err := registries.FetchManifest(pluginName) +func lockPlatformsFromRegistry(registries *MultiRegistry, registryConfig *RegistryConfig, pluginName, version string, compatOpts pluginLockCompatibilityOptions) (map[string]config.WfctlLockPlatform, string, error) { + manifest, index, sourceName, err := registries.FetchManifestAndVersionIndex(pluginName) if err != nil { - return nil, err + return nil, "", err } - if version != "" && !samePluginVersion(version, manifest.Version) { - return nil, fmt.Errorf("registry manifest version %q does not match requested version %q", manifest.Version, version) + resolvedCompatMode, err := resolvePluginCompatMode(compatOpts.CompatMode, registryConfig) + if err != nil { + return nil, "", err + } + decision, err := ResolvePluginCompatibility(index, manifest, PluginCompatResolverOptions{ + RequestedVersion: version, + EngineVersion: compatOpts.EngineVersion, + CompatMode: resolvedCompatMode, + Force: compatOpts.Force, + ForceReason: PluginCompatForceInstall, + Trust: registryTrustMode(registryConfig, sourceName), + }) + if err != nil { + return nil, "", err + } + if decision.Warning != "" { + fmt.Fprintf(os.Stderr, "warning: %s\n", decision.Warning) + } + if decision.Forced { + fmt.Fprintf(os.Stderr, "warning: forcing compatibility decision (%s)\n", decision.Reason) + } + if version != "" && decision.Version != "" && !compatIndexHasVersion(index, decision.Version) && !samePluginVersion(version, manifest.Version) { + return nil, "", fmt.Errorf("registry manifest version %q does not match requested version %q", manifest.Version, version) + } + if decision.Version != "" && decision.Version != manifest.Version { + manifest = manifestForCompatibilityVersion(manifest, index, decision.Version) } platforms := make(map[string]config.WfctlLockPlatform, len(manifest.Downloads)) @@ -148,15 +192,57 @@ func lockPlatformsFromRegistry(registries *MultiRegistry, pluginName, version st continue } if !sha256Regex.MatchString(dl.SHA256) { - return nil, fmt.Errorf("%w for %s download %d (%s/%s): must be a 64-character hex string", errInvalidRegistrySHA256, pluginName, i, dl.OS, dl.Arch) + return nil, "", fmt.Errorf("%w for %s download %d (%s/%s): must be a 64-character hex string", errInvalidRegistrySHA256, pluginName, i, dl.OS, dl.Arch) } key := dl.OS + "-" + dl.Arch platforms[key] = config.WfctlLockPlatform{URL: dl.URL, SHA256: dl.SHA256} } if len(platforms) == 0 { - return nil, fmt.Errorf("no usable platform downloads for %s@%s", pluginName, version) + return nil, "", fmt.Errorf("no usable platform downloads for %s@%s", pluginName, version) + } + if decision.Evidence != nil { + key := decision.Evidence.OS + "-" + decision.Evidence.Arch + if p, ok := platforms[key]; ok { + p.Compatibility = lockCompatibilityFromDecision(decision) + platforms[key] = p + } + } else if decision.Forced || decision.Warning != "" { + key := currentPlatformKey() + if p, ok := platforms[key]; ok { + p.Compatibility = lockCompatibilityFromDecision(decision) + platforms[key] = p + } + } + return platforms, manifest.Version, nil +} + +func compatIndexHasVersion(index *PluginVersionIndex, version string) bool { + if index == nil { + return false + } + for _, rec := range index.Versions { + if samePluginVersion(rec.Version, version) { + return true + } + } + return false +} + +func lockCompatibilityFromDecision(decision PluginCompatDecision) *config.WfctlLockCompatibility { + c := &config.WfctlLockCompatibility{ + Forced: decision.Forced, + Reason: decision.Reason, + } + if decision.Evidence != nil { + c.Mode = decision.Evidence.Mode + c.Status = decision.Evidence.Status + c.EngineVersion = decision.Evidence.EngineVersion + c.EvidenceDigest = decision.Evidence.EvidenceDigest + } + if c.Mode == "" && c.Status == "" && c.EngineVersion == "" && c.EvidenceDigest == "" && !c.Forced && c.Reason == "" { + return nil } - return platforms, nil + return c } func samePluginVersion(a, b string) bool { diff --git a/cmd/wfctl/plugin_lock_test.go b/cmd/wfctl/plugin_lock_test.go index f6746154..352afc59 100644 --- a/cmd/wfctl/plugin_lock_test.go +++ b/cmd/wfctl/plugin_lock_test.go @@ -6,9 +6,11 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "strings" "testing" + "github.com/GoCodeAlone/workflow/config" "gopkg.in/yaml.v3" ) @@ -219,6 +221,175 @@ plugins: } } +func TestPluginLock_FromManifest_UsesCompatibilityResolverAndWritesMetadata(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "wfctl.yaml") + lockPath := filepath.Join(dir, ".wfctl-lock.yaml") + archiveSHA := sha256Hex([]byte("foo archive")) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/plugins/workflow-plugin-foo/manifest.json": + manifest := RegistryManifest{ + Name: "workflow-plugin-foo", + Version: "v0.2.0", + Downloads: []PluginDownload{{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + URL: "https://example.test/foo-v0.2.0.tar.gz", + SHA256: archiveSHA, + }}, + } + data, _ := json.Marshal(manifest) + w.Header().Set("Content-Type", "application/json") + w.Write(data) //nolint:errcheck + case "/compatibility/workflow-plugin-foo/index.json": + pass := lockTestEvidence(t, "workflow-plugin-foo", "v0.1.0", PluginCompatibilityStatusPass, archiveSHA) + fail := lockTestEvidence(t, "workflow-plugin-foo", "v0.2.0", PluginCompatibilityStatusFail, archiveSHA) + idx := PluginVersionIndex{ + Plugin: "workflow-plugin-foo", + EvidencePolicy: CompatibilityEvidencePolicy{ + RequiredFromEngine: "v0.51.0", + }, + Versions: []PluginVersionRecord{ + { + Version: "v0.2.0", + Downloads: []PluginDownload{{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + URL: "https://example.test/foo-v0.2.0.tar.gz", + SHA256: archiveSHA, + }}, + Compatibility: []PluginCompatibilityEvidence{fail}, + }, + { + Version: "v0.1.0", + Downloads: []PluginDownload{{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + URL: "https://example.test/foo-v0.1.0.tar.gz", + SHA256: archiveSHA, + }}, + Compatibility: []PluginCompatibilityEvidence{pass}, + }, + }, + } + data, _ := json.Marshal(idx) + w.Header().Set("Content-Type", "application/json") + w.Write(data) //nolint:errcheck + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + registryConfig := "registries:\n - name: test\n type: static\n url: " + srv.URL + "\n priority: 0\n compatibilityEvidence:\n trust: first_party\n" + if err := os.WriteFile(filepath.Join(dir, ".wfctl.yaml"), []byte(registryConfig), 0o600); err != nil { + t.Fatalf("write registry config: %v", err) + } + manifest := `version: 1 +plugins: + - name: workflow-plugin-foo + source: github.com/GoCodeAlone/workflow-plugin-foo +` + if err := os.WriteFile(manifestPath, []byte(manifest), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + if err := runPluginLockFromManifestWithOptions(manifestPath, lockPath, pluginLockCompatibilityOptions{EngineVersion: "v0.51.2"}); err != nil { + t.Fatalf("runPluginLockFromManifestWithOptions: %v", err) + } + lf, err := config.LoadWfctlLockfile(lockPath) + if err != nil { + t.Fatalf("load lockfile: %v", err) + } + entry := lf.Plugins["workflow-plugin-foo"] + if entry.Version != "v0.1.0" { + t.Fatalf("locked version = %q, want v0.1.0", entry.Version) + } + platform := entry.Platforms[currentPlatformKey()] + if platform.URL != "https://example.test/foo-v0.1.0.tar.gz" { + t.Fatalf("platform URL = %q, want v0.1.0 URL", platform.URL) + } + if platform.Compatibility == nil || platform.Compatibility.Status != PluginCompatibilityStatusPass || platform.Compatibility.EvidenceDigest == "" { + t.Fatalf("compatibility metadata missing/incomplete: %#v", platform.Compatibility) + } +} + +func TestPluginLock_FromManifest_WarnModeRecordsForcedKnownFail(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "wfctl.yaml") + lockPath := filepath.Join(dir, ".wfctl-lock.yaml") + archiveSHA := sha256Hex([]byte("foo archive")) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/plugins/workflow-plugin-foo/manifest.json": + manifest := RegistryManifest{ + Name: "workflow-plugin-foo", + Version: "v0.2.0", + Downloads: []PluginDownload{{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + URL: "https://example.test/foo-v0.2.0.tar.gz", + SHA256: archiveSHA, + }}, + } + data, _ := json.Marshal(manifest) + w.Header().Set("Content-Type", "application/json") + w.Write(data) //nolint:errcheck + case "/compatibility/workflow-plugin-foo/index.json": + fail := lockTestEvidence(t, "workflow-plugin-foo", "v0.2.0", PluginCompatibilityStatusFail, archiveSHA) + idx := PluginVersionIndex{ + Plugin: "workflow-plugin-foo", + EvidencePolicy: CompatibilityEvidencePolicy{ + RequiredFromEngine: "v0.51.0", + }, + Versions: []PluginVersionRecord{{ + Version: "v0.2.0", + Downloads: []PluginDownload{{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + URL: "https://example.test/foo-v0.2.0.tar.gz", + SHA256: archiveSHA, + }}, + Compatibility: []PluginCompatibilityEvidence{fail}, + }}, + } + data, _ := json.Marshal(idx) + w.Header().Set("Content-Type", "application/json") + w.Write(data) //nolint:errcheck + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + registryConfig := "registries:\n - name: test\n type: static\n url: " + srv.URL + "\n priority: 0\n compatibilityEvidence:\n trust: first_party\n" + if err := os.WriteFile(filepath.Join(dir, ".wfctl.yaml"), []byte(registryConfig), 0o600); err != nil { + t.Fatalf("write registry config: %v", err) + } + manifest := `version: 1 +plugins: + - name: workflow-plugin-foo + version: v0.2.0 + source: github.com/GoCodeAlone/workflow-plugin-foo +` + if err := os.WriteFile(manifestPath, []byte(manifest), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + if err := runPluginLockFromManifestWithOptions(manifestPath, lockPath, pluginLockCompatibilityOptions{EngineVersion: "v0.51.2", CompatMode: PluginCompatModeWarn}); err != nil { + t.Fatalf("runPluginLockFromManifestWithOptions: %v", err) + } + lf, err := config.LoadWfctlLockfile(lockPath) + if err != nil { + t.Fatalf("load lockfile: %v", err) + } + compat := lf.Plugins["workflow-plugin-foo"].Platforms[currentPlatformKey()].Compatibility + if compat == nil || !compat.Forced || compat.Reason != PluginCompatWarnReason || compat.Status != PluginCompatibilityStatusFail { + t.Fatalf("forced known-fail metadata missing/incomplete: %#v", compat) + } +} + func TestPluginLock_FromManifest_RefreshesExistingPlatformSHA256FromRegistry(t *testing.T) { dir := t.TempDir() manifestPath := filepath.Join(dir, "wfctl.yaml") @@ -306,6 +477,24 @@ plugins: } } +func lockTestEvidence(t *testing.T, plugin, version, status, archiveSHA string) PluginCompatibilityEvidence { + t.Helper() + ev, err := ValidateCompatibilityEvidence(PluginCompatibilityEvidence{ + Plugin: plugin, + Version: version, + EngineVersion: "v0.51.2", + Mode: PluginCompatibilityModeTypedIaC, + Status: status, + OS: runtime.GOOS, + Arch: runtime.GOARCH, + ArchiveSHA256: archiveSHA, + }) + if err != nil { + t.Fatalf("validate evidence: %v", err) + } + return ev +} + func TestPluginLock_FromManifest_FailsWhenExistingPlatformsCannotBeRefreshed(t *testing.T) { tests := []struct { name string diff --git a/config/wfctl_lockfile.go b/config/wfctl_lockfile.go index f7990827..7784cbdf 100644 --- a/config/wfctl_lockfile.go +++ b/config/wfctl_lockfile.go @@ -31,8 +31,18 @@ type WfctlLockPluginEntry struct { // WfctlLockPlatform holds platform-specific download info. type WfctlLockPlatform struct { - URL string `yaml:"url"` - SHA256 string `yaml:"sha256"` + URL string `yaml:"url"` + SHA256 string `yaml:"sha256"` + Compatibility *WfctlLockCompatibility `yaml:"compatibility,omitempty"` +} + +type WfctlLockCompatibility struct { + Mode string `yaml:"mode,omitempty"` + Status string `yaml:"status,omitempty"` + EngineVersion string `yaml:"engine_version,omitempty"` + EvidenceDigest string `yaml:"evidence_digest,omitempty"` + Forced bool `yaml:"forced,omitempty"` + Reason string `yaml:"reason,omitempty"` } // LoadWfctlLockfile reads and parses a .wfctl-lock.yaml file. @@ -111,6 +121,35 @@ func SaveWfctlLockfile(path string, lf *WfctlLockfile) error { &yaml.Node{Kind: yaml.ScalarNode, Value: "sha256"}, &yaml.Node{Kind: yaml.ScalarNode, Value: p.SHA256}, ) + if p.Compatibility != nil { + cNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + addCompatField := func(k, v string) { + if v == "" { + return + } + cNode.Content = append(cNode.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: k}, + &yaml.Node{Kind: yaml.ScalarNode, Value: v}, + ) + } + addCompatField("mode", p.Compatibility.Mode) + addCompatField("status", p.Compatibility.Status) + addCompatField("engine_version", p.Compatibility.EngineVersion) + addCompatField("evidence_digest", p.Compatibility.EvidenceDigest) + if p.Compatibility.Forced { + cNode.Content = append(cNode.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: "forced"}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: "true"}, + ) + } + addCompatField("reason", p.Compatibility.Reason) + if len(cNode.Content) > 0 { + pNode.Content = append(pNode.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: "compatibility"}, + cNode, + ) + } + } platNode.Content = append(platNode.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: pk}, pNode, diff --git a/config/wfctl_lockfile_test.go b/config/wfctl_lockfile_test.go index 5603e139..bba704ea 100644 --- a/config/wfctl_lockfile_test.go +++ b/config/wfctl_lockfile_test.go @@ -21,6 +21,12 @@ func TestWfctlLockfile_RoundTrip(t *testing.T) { "linux-amd64": { URL: "https://github.com/GoCodeAlone/workflow-plugin-digitalocean/releases/download/v0.7.6/plugin-linux-amd64.tar.gz", SHA256: "archive-sha", + Compatibility: &WfctlLockCompatibility{ + Mode: "typed-iac", + Status: "pass", + EngineVersion: "v0.51.2", + EvidenceDigest: "sha256:abc", + }, }, }, }, @@ -56,6 +62,9 @@ func TestWfctlLockfile_RoundTrip(t *testing.T) { if plat.SHA256 != "archive-sha" { t.Errorf("platform sha256 = %q, want archive-sha", plat.SHA256) } + if plat.Compatibility == nil || plat.Compatibility.EvidenceDigest != "sha256:abc" { + t.Fatalf("compatibility metadata not round-tripped: %#v", plat.Compatibility) + } } func TestWfctlLockfile_SaveOmitsTopLevelSHA256WhenPlatformsExist(t *testing.T) { @@ -94,6 +103,46 @@ func TestWfctlLockfile_SaveOmitsTopLevelSHA256WhenPlatformsExist(t *testing.T) { } } +func TestWfctlLockfile_WritesCompatibilityMetadata(t *testing.T) { + lf := WfctlLockfile{ + Version: 1, + GeneratedAt: time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC), + Plugins: map[string]WfctlLockPluginEntry{ + "workflow-plugin-auth": { + Version: "v1.2.3", + Source: "github.com/GoCodeAlone/workflow-plugin-auth", + Platforms: map[string]WfctlLockPlatform{ + "linux-amd64": { + URL: "https://example.test/auth-linux-amd64.tar.gz", + SHA256: "archive-sha-linux", + Compatibility: &WfctlLockCompatibility{ + Mode: "typed-iac", + Status: "fail", + EngineVersion: "v0.51.2", + EvidenceDigest: "sha256:def", + Forced: true, + Reason: "compat-mode=warn", + }, + }, + }, + }, + }, + } + path := filepath.Join(t.TempDir(), ".wfctl-lock.yaml") + if err := SaveWfctlLockfile(path, &lf); err != nil { + t.Fatalf("save: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + for _, want := range []string{"compatibility:", "mode: typed-iac", "status: fail", "forced: true", "reason: compat-mode=warn"} { + if !strings.Contains(string(data), want) { + t.Fatalf("lockfile missing %q:\n%s", want, data) + } + } +} + func TestWfctlLockfile_DeterministicOutput(t *testing.T) { // Two lockfiles with identical content should produce byte-identical YAML. mkLockfile := func() WfctlLockfile { From dc4e73fec1c5ee4f6d161aa4afc988eed946fb6b Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 02:08:14 -0400 Subject: [PATCH 23/29] docs(wfctl): document plugin compat conformance --- docs/WFCTL.md | 155 ++++++++++++++++-- .../2026-05-11-plugin-conformance-compat.md | 21 +++ 2 files changed, 165 insertions(+), 11 deletions(-) diff --git a/docs/WFCTL.md b/docs/WFCTL.md index aadccaba..f8a7e90b 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -64,6 +64,7 @@ graph TD wfctl --> ci wfctl --> git wfctl --> registry + wfctl --> plugin-registry["plugin-registry"] wfctl --> update wfctl --> mcp wfctl --> editor-schemas["editor-schemas"] @@ -109,8 +110,10 @@ graph TD plugin --> plugin-init["init"] plugin --> plugin-docs["docs"] plugin --> plugin-test["test"] + plugin --> plugin-conformance["conformance"] plugin --> plugin-search["search"] plugin --> plugin-install["install"] + plugin --> plugin-lock["lock"] plugin --> plugin-list["list"] plugin --> plugin-update["update"] plugin --> plugin-remove["remove"] @@ -149,6 +152,12 @@ graph TD registry --> registry-list["list"] registry --> registry-add["add"] registry --> registry-remove["remove"] + + plugin-registry --> plugin-registry-list["list"] + plugin-registry --> plugin-registry-add["add"] + plugin-registry --> plugin-registry-remove["remove"] + plugin-registry --> plugin-registry-compat["compatibility"] + plugin-registry-compat --> plugin-registry-compat-update["update"] ``` --- @@ -165,7 +174,7 @@ graph TD | **Infrastructure** | `infra plan/apply/destroy/status/drift/import/bootstrap/outputs`, `infra state list/export/import` | | **CI/CD** | `ci generate`, `generate github-actions` | | **Documentation** | `docs generate` | -| **Plugin Management** | `plugin`, `registry`, `publish` | +| **Plugin Management** | `plugin`, `plugin-registry`, `registry`, `publish` | | **UI Generation** | `ui scaffold`, `build-ui` | | **Database Migrations** | `migrate status/diff/apply` | | **Git Integration** | `git connect`, `git push` | @@ -536,6 +545,31 @@ Run a plugin through its full lifecycle in a test harness. wfctl plugin test [options] ``` +#### `plugin conformance` + +Run executable plugin/host compatibility checks and emit strict evidence for registry compatibility indexes. This executes plugin code, so use trusted source trees or CI-built release artifacts. + +``` +wfctl plugin conformance [options] +wfctl plugin conformance --artifact [options] +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--mode` | `typed-iac` | Conformance mode. Currently checks strict typed IaC plugin launch/contract compatibility | +| `--artifact` | _(none)_ | Release artifact tar.gz to test instead of a local plugin directory | +| `--engine-version` | build version or `WFCTL_ENGINE_VERSION` | Workflow engine version recorded in evidence | +| `--format` | `text` | Output format: `text` or `json` | +| `--output` | _(none)_ | Write JSON evidence to a file | +| `--timeout` | `30s` | Plugin launch/check timeout | + +Local directory evidence is useful during development. Registry enforcement should use artifact evidence so `archiveSHA256` can be matched against the registry manifest download checksum. + +```bash +wfctl plugin conformance --mode typed-iac --format json ./workflow-plugin-digitalocean +wfctl plugin conformance --artifact dist/workflow-plugin-digitalocean.tar.gz --engine-version v0.51.2 --output evidence.json +``` + #### `plugin search` Search the plugin registry by name, description, or keyword. @@ -563,9 +597,14 @@ wfctl plugin install [options] [@] | Flag | Default | Description | |------|---------|-------------| -| `-data-dir` | `data/plugins` | Plugin data directory | +| `--plugin-dir` | `data/plugins` | Plugin directory | +| `--data-dir` | `data/plugins` | Deprecated alias for `--plugin-dir` | | `-config` | _(default registry)_ | Registry config file path | | `-registry` | _(all registries)_ | Use a specific registry by name | +| `--compat-mode` | `enforce` | Compatibility mode for registry installs: `enforce` or `warn` | +| `--engine-version` | build version or `WFCTL_ENGINE_VERSION` | Workflow engine version used for compatibility resolution | +| `--force` | `false` | Permit known-failing or missing required compatibility evidence while still enforcing archive checksums | +| `--skip-checksum` | `false` | Skip archive integrity verification. Use only for trusted internal URLs | ```bash wfctl plugin install my-plugin @@ -573,6 +612,25 @@ wfctl plugin install my-plugin@1.2.0 wfctl plugin install --data-dir /opt/plugins my-plugin ``` +Registry installs resolve compatibility before selecting a version. Direct URL installs, local installs, GitHub repository fallback, and lockfile installs do not use registry evidence unless they are backed by registry metadata. + +#### `plugin lock` + +Regenerate `.wfctl-lock.yaml` from `wfctl.yaml` or legacy `requires.plugins[]`. + +``` +wfctl plugin lock [options] +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--config` | `workflow.yaml` | Legacy workflow config path | +| `--manifest` | `wfctl.yaml` | wfctl project manifest path | +| `--lock-file` | `.wfctl-lock.yaml` | Lockfile path to write | +| `--compat-mode` | `enforce` | Compatibility mode for registry lock resolution: `enforce` or `warn` | +| `--engine-version` | build version or `WFCTL_ENGINE_VERSION` | Workflow engine version used for compatibility resolution | +| `--force` | `false` | Permit known-failing or missing required compatibility evidence and record forced metadata in the lockfile | + #### `plugin list` List installed plugins. @@ -595,7 +653,16 @@ wfctl plugin update [options] | Flag | Default | Description | |------|---------|-------------| -| `-data-dir` | `data/plugins` | Plugin data directory | +| `--plugin-dir` | `data/plugins` | Plugin directory | +| `--data-dir` | `data/plugins` | Deprecated alias for `--plugin-dir` | +| `--config` | _(default registry)_ | Registry config file path | +| `--manifest` | `wfctl.yaml` | wfctl project manifest path | +| `--lock-file` | `.wfctl-lock.yaml` | Lockfile path | +| `--version` | _(none)_ | Pin this exact version in `wfctl.yaml` instead of installing | +| `--compat-mode` | `enforce` | Compatibility mode for registry updates: `enforce` or `warn` | +| `--engine-version` | build version or `WFCTL_ENGINE_VERSION` | Workflow engine version used for compatibility resolution | +| `--force` | `false` | Permit known-failing or missing required compatibility evidence while still enforcing archive checksums | +| `--skip-checksum` | `false` | Skip archive integrity verification. Use only for trusted internal URLs | #### `plugin remove` @@ -2183,12 +2250,16 @@ wfctl git push -config-only --- -### `registry list` +### `plugin-registry` + +Plugin catalog registry management. `wfctl registry` remains a compatibility alias for this plugin catalog surface until the container registry dispatcher replaces it. + +#### `plugin-registry list` Show configured plugin registries. ``` -wfctl registry list [options] +wfctl plugin-registry list [options] ``` | Flag | Default | Description | @@ -2197,12 +2268,12 @@ wfctl registry list [options] --- -### `registry add` +#### `plugin-registry add` Add a plugin registry source. ``` -wfctl registry add [options] +wfctl plugin-registry add [options] ``` | Flag | Default | Description | @@ -2215,17 +2286,17 @@ wfctl registry add [options] | `--priority` | `10` | Priority (lower = higher priority) | ```bash -wfctl registry add --owner myorg --repo my-registry my-registry +wfctl plugin-registry add --owner myorg --repo my-registry my-registry ``` --- -### `registry remove` +#### `plugin-registry remove` Remove a plugin registry source. Cannot remove the `default` registry. ``` -wfctl registry remove [options] +wfctl plugin-registry remove [options] ``` | Flag | Default | Description | @@ -2233,7 +2304,69 @@ wfctl registry remove [options] | `--config` | `~/.config/wfctl/config.yaml` | Registry config file path | ```bash -wfctl registry remove my-registry +wfctl plugin-registry remove my-registry +``` + +#### `plugin-registry compatibility update` + +Update `compatibility//index.json` in a local plugin registry checkout from one or more conformance evidence files. + +``` +wfctl plugin-registry compatibility update --registry-dir --plugin --version --evidence [--evidence ] +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--registry-dir` | _(required)_ | Local plugin registry checkout | +| `--plugin` | _(required)_ | Plugin name | +| `--version` | _(required)_ | Plugin version | +| `--evidence` | _(required)_ | Compatibility evidence JSON path. Repeat for multiple platforms or engines | +| `--derive-ranges` | `false` | Derive pass ranges from enumerated evidence | +| `--latest-engine` | _(none)_ | Latest engine version used to mark stale evidence metadata | + +The updater validates that evidence matches the requested plugin/version, the registry manifest version, current platform fields, and artifact checksum. It writes the compatibility index atomically. + +```bash +wfctl plugin-registry compatibility update \ + --registry-dir ../workflow-registry \ + --plugin workflow-plugin-digitalocean \ + --version v1.0.1 \ + --evidence evidence/linux-amd64-v0.51.2.json \ + --latest-engine v0.51.2 +``` + +Registry config can mark whether compatibility evidence is enforceable: + +```yaml +compatibility: + mode: enforce +registries: + - name: internal + type: static + url: https://registry.example.com/workflow/v1 + compatibilityEvidence: + trust: first_party +``` + +`compatibilityEvidence.trust: first_party` allows enforcement. User-added registries default to advisory evidence unless trust is set explicitly. `signed` is reserved for a future signature-backed mode and is rejected today. `compatibility.mode` may be `enforce` or `warn`; CLI flags override the environment, which overrides this config. + +Compatibility environment variables: + +| Variable | Description | +|----------|-------------| +| `WFCTL_PLUGIN_COMPAT_MODE` | Default plugin compatibility mode: `enforce` or `warn` | +| `WFCTL_ENGINE_VERSION` | Workflow engine version used for conformance evidence and resolver decisions | + +Plugin CI should generate evidence with the released artifact, then update the registry index: + +```yaml +steps: + - uses: actions/checkout@v4 + - uses: GoCodeAlone/workflow/.github/actions/setup-wfctl@main + with: + version: v0.51.2 + - run: wfctl plugin conformance --mode typed-iac --artifact dist/plugin.tar.gz --engine-version v0.51.2 --format json --output evidence.json + - run: wfctl plugin-registry compatibility update --registry-dir ../workflow-registry --plugin workflow-plugin-example --version "$PLUGIN_VERSION" --evidence evidence.json --latest-engine v0.51.2 ``` --- diff --git a/docs/plans/2026-05-11-plugin-conformance-compat.md b/docs/plans/2026-05-11-plugin-conformance-compat.md index fb758a31..c403618a 100644 --- a/docs/plans/2026-05-11-plugin-conformance-compat.md +++ b/docs/plans/2026-05-11-plugin-conformance-compat.md @@ -456,6 +456,27 @@ git commit -m "docs(wfctl): document plugin compat conformance" Rollback: docs-only revert. +**Verification log:** + +```text +2026-05-11 T7: +- docs/WFCTL.md updated: plugin conformance, plugin-registry compatibility update, registry trust, compat mode env/flags, setup-wfctl CI sketch. +- PASS: GOWORK=off go build -o /tmp/wfctl-compat ./cmd/wfctl +- PASS: /tmp/wfctl-compat plugin conformance --mode typed-iac --format json --engine-version v0.51.2 ./cmd/wfctl/testdata/conformance/iac-pass + got: status=pass plugin=iac-pass version=v0.1.0 os=darwin arch=arm64 +- PASS: /tmp/wfctl-compat plugin conformance --mode typed-iac --artifact /tmp/wfctl-iac-pass.tar.gz --format json --engine-version v0.51.2 --output /tmp/wfctl-evidence.json + got: status=pass archiveSHA256=c02a688b26161848785db015780b6d87fe028d85d8fd5a1a3b93e17537adbb47 +- PASS: /tmp/wfctl-compat plugin-registry compatibility update --registry-dir /tmp/wfctl-test-registry --plugin iac-pass --version v0.1.0 --evidence /tmp/wfctl-evidence.json --latest-engine v0.51.2 + wrote: /tmp/wfctl-test-registry/compatibility/iac-pass/index.json +- KNOWN-FAIL: GOWORK=off go test ./cmd/wfctl ./config ./plugin/external ./plugin/external/sdk -count=1 + fail-pkg: ./cmd/wfctl + fail-tests: TestConfigMigrate_DefaultWriterIsStderr; TestInfraMultiEnv_E2E/staging_plan_excludes_dns; TestInfraMultiEnv_E2E/prod_plan_includes_dns_with_large_db + note: same cmd/wfctl drift observed before T7; TestRunCIRunTestFallsBackToGoTestWhenNoConfiguredTests prints an intentional failing fixture but did not fail itself. +- KNOWN-FAIL: GOWORK=off go test ./... -count=1 + fail-pkg: ./cmd/wfctl with same failures as focused run. + note: dynamic panic-recovery test printed expected panic text without package failure. +``` + ## Final PR Checklist - Run `git status --short`. From e939623d4ec9cecbec9be163693275e1b33a6d7f Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 02:11:37 -0400 Subject: [PATCH 24/29] fix(wfctl): tidy conformance dependency --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 9f797c19..ce0d364b 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 + github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/golang-lru v1.0.2 github.com/hashicorp/vault/api v1.23.0 github.com/itchyny/gojq v0.12.18 @@ -191,7 +192,6 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-memdb v1.3.5 // indirect github.com/hashicorp/go-metrics v0.5.4 // indirect From 4847c180dc8854bd028ebfef0ce3abea2c68ea50 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 02:22:37 -0400 Subject: [PATCH 25/29] fix(wfctl): avoid compat range value copies --- cmd/wfctl/multi_registry.go | 10 +++++----- cmd/wfctl/plugin_compat_resolver.go | 8 ++++---- cmd/wfctl/plugin_install.go | 6 +++--- cmd/wfctl/registry_cmd.go | 13 +++++++------ cmd/wfctl/registry_compatibility.go | 16 ++++++++++------ 5 files changed, 29 insertions(+), 24 deletions(-) diff --git a/cmd/wfctl/multi_registry.go b/cmd/wfctl/multi_registry.go index 86bff9d1..f82c4403 100644 --- a/cmd/wfctl/multi_registry.go +++ b/cmd/wfctl/multi_registry.go @@ -24,12 +24,12 @@ func NewMultiRegistry(cfg *RegistryConfig) *MultiRegistry { }) sources := make([]RegistrySource, 0, len(sorted)) - for _, sc := range sorted { - switch sc.Type { + for i := range sorted { + switch sorted[i].Type { case "github": - sources = append(sources, NewGitHubRegistrySource(sc)) + sources = append(sources, NewGitHubRegistrySource(sorted[i])) case "static": - src, err := NewStaticRegistrySource(sc) + src, err := NewStaticRegistrySource(sorted[i]) if err != nil { fmt.Fprintf(os.Stderr, "warning: %v, skipping\n", err) continue @@ -37,7 +37,7 @@ func NewMultiRegistry(cfg *RegistryConfig) *MultiRegistry { sources = append(sources, src) default: // Skip unknown types - fmt.Fprintf(os.Stderr, "warning: unknown registry type %q for %q, skipping\n", sc.Type, sc.Name) + fmt.Fprintf(os.Stderr, "warning: unknown registry type %q for %q, skipping\n", sorted[i].Type, sorted[i].Name) } } diff --git a/cmd/wfctl/plugin_compat_resolver.go b/cmd/wfctl/plugin_compat_resolver.go index 71b6df8d..8ca3c82f 100644 --- a/cmd/wfctl/plugin_compat_resolver.go +++ b/cmd/wfctl/plugin_compat_resolver.go @@ -254,10 +254,10 @@ func registryTrustMode(cfg *RegistryConfig, sourceName string) CompatibilityTrus if cfg == nil { return CompatibilityTrustAdvisory } - for _, source := range cfg.Registries { - if source.Name == sourceName { - if source.CompatibilityEvidence.Trust != "" { - return source.CompatibilityEvidence.Trust + for i := range cfg.Registries { + if cfg.Registries[i].Name == sourceName { + if cfg.Registries[i].CompatibilityEvidence.Trust != "" { + return cfg.Registries[i].CompatibilityEvidence.Trust } return CompatibilityTrustAdvisory } diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index a0544f33..a7484902 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -149,9 +149,9 @@ func runPluginInstall(args []string) error { if *registryName != "" { // Filter config to only the requested registry filtered := &RegistryConfig{} - for _, r := range cfg.Registries { - if r.Name == *registryName { - filtered.Registries = append(filtered.Registries, r) + for i := range cfg.Registries { + if cfg.Registries[i].Name == *registryName { + filtered.Registries = append(filtered.Registries, cfg.Registries[i]) break } } diff --git a/cmd/wfctl/registry_cmd.go b/cmd/wfctl/registry_cmd.go index 7930f4c8..a52d0a3b 100644 --- a/cmd/wfctl/registry_cmd.go +++ b/cmd/wfctl/registry_cmd.go @@ -67,7 +67,8 @@ func runRegistryList(args []string) error { fmt.Printf("%-15s %-10s %-25s %-25s %s\n", "NAME", "TYPE", "OWNER", "REPO", "PRIORITY") fmt.Printf("%-15s %-10s %-25s %-25s %s\n", "----", "----", "-----", "----", "--------") - for _, r := range cfg.Registries { + for i := range cfg.Registries { + r := &cfg.Registries[i] fmt.Printf("%-15s %-10s %-25s %-25s %d\n", r.Name, r.Type, r.Owner, r.Repo, r.Priority) } return nil @@ -106,8 +107,8 @@ func runRegistryAdd(args []string) error { } // Check for duplicate - for _, r := range cfg.Registries { - if r.Name == name { + for i := range cfg.Registries { + if cfg.Registries[i].Name == name { return fmt.Errorf("registry %q already exists", name) } } @@ -168,12 +169,12 @@ func runRegistryRemove(args []string) error { found := false filtered := make([]RegistrySourceConfig, 0, len(cfg.Registries)) - for _, r := range cfg.Registries { - if r.Name == name { + for i := range cfg.Registries { + if cfg.Registries[i].Name == name { found = true continue } - filtered = append(filtered, r) + filtered = append(filtered, cfg.Registries[i]) } if !found { return fmt.Errorf("registry %q not found", name) diff --git a/cmd/wfctl/registry_compatibility.go b/cmd/wfctl/registry_compatibility.go index bffd01ed..9acaa6c6 100644 --- a/cmd/wfctl/registry_compatibility.go +++ b/cmd/wfctl/registry_compatibility.go @@ -300,7 +300,8 @@ func sortCompatibilityEvidence(evidence []PluginCompatibilityEvidence) { func dedupeCompatibilityEvidence(evidence []PluginCompatibilityEvidence) []PluginCompatibilityEvidence { out := make([]PluginCompatibilityEvidence, 0, len(evidence)) seen := map[string]bool{} - for _, ev := range evidence { + for i := range evidence { + ev := &evidence[i] key := strings.Join([]string{ ev.Plugin, ev.Version, ev.EngineVersion, ev.Mode, ev.Status, ev.OS, ev.Arch, ev.ArchiveSHA256, }, "\x00") @@ -308,7 +309,7 @@ func dedupeCompatibilityEvidence(evidence []PluginCompatibilityEvidence) []Plugi continue } seen[key] = true - out = append(out, ev) + out = append(out, *ev) } return out } @@ -318,7 +319,8 @@ func deriveCompatibilityRanges(index *PluginVersionIndex) { rec := &index.Versions[recIdx] byKey := map[string][]PluginCompatibilityEvidence{} hasFail := map[string]bool{} - for _, ev := range rec.Compatibility { + for i := range rec.Compatibility { + ev := &rec.Compatibility[i] if ev.CompatibleEngineRange != nil { continue } @@ -328,7 +330,7 @@ func deriveCompatibilityRanges(index *PluginVersionIndex) { continue } if ev.Status == PluginCompatibilityStatusPass { - byKey[key] = append(byKey[key], ev) + byKey[key] = append(byKey[key], *ev) } } for key, passes := range byKey { @@ -356,8 +358,10 @@ func deriveCompatibilityRanges(index *PluginVersionIndex) { func compatibilityIndexIsStale(index *PluginVersionIndex, latestEngine string) bool { newest := "" - for _, rec := range index.Versions { - for _, ev := range rec.Compatibility { + for recIdx := range index.Versions { + rec := &index.Versions[recIdx] + for evIdx := range rec.Compatibility { + ev := &rec.Compatibility[evIdx] if newest == "" || semver.Compare(ev.EngineVersion, newest) > 0 { newest = ev.EngineVersion } From b9924795c03fd07a93f50cfce60274a6ca133c4d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 02:35:25 -0400 Subject: [PATCH 26/29] fix(wfctl): harden plugin compat review gaps --- cmd/wfctl/plugin_compat_model.go | 58 +++++++++++++++++++ cmd/wfctl/plugin_compat_resolver.go | 20 +++++-- cmd/wfctl/plugin_compat_resolver_test.go | 34 +++++++++++ cmd/wfctl/plugin_conformance.go | 3 + cmd/wfctl/plugin_conformance_test.go | 6 ++ cmd/wfctl/plugin_lock.go | 2 - cmd/wfctl/registry_compatibility.go | 6 +- cmd/wfctl/registry_source.go | 26 +++++++-- cmd/wfctl/registry_source_test.go | 4 +- .../testdata/conformance/iac-pass/main.go | 3 + 10 files changed, 148 insertions(+), 14 deletions(-) diff --git a/cmd/wfctl/plugin_compat_model.go b/cmd/wfctl/plugin_compat_model.go index b92af299..baf82a03 100644 --- a/cmd/wfctl/plugin_compat_model.go +++ b/cmd/wfctl/plugin_compat_model.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "regexp" "slices" @@ -13,6 +14,8 @@ import ( "golang.org/x/mod/semver" ) +var errInvalidRegistrySHA256 = errors.New("invalid sha256") + const ( PluginCompatibilityModeTypedIaC = "typed-iac" @@ -110,6 +113,61 @@ type PluginCompatibilityEvidence struct { StderrTail string `json:"stderrTail,omitempty"` } +func NormalizePluginVersionIndex(index *PluginVersionIndex, defaultPlugin string) (*PluginVersionIndex, error) { + if index == nil { + return nil, fmt.Errorf("compatibility index is required") + } + out := *index + if strings.TrimSpace(out.Plugin) == "" { + out.Plugin = defaultPlugin + } + if strings.TrimSpace(out.Plugin) == "" { + return nil, fmt.Errorf("compatibility index plugin is required") + } + out.Versions = slices.Clone(index.Versions) + for i := range out.Versions { + version, err := CanonicalPluginVersion(out.Versions[i].Version) + if err != nil { + return nil, fmt.Errorf("compatibility index %s version: %w", out.Plugin, err) + } + out.Versions[i].Version = version + if out.Versions[i].MinEngineVersion != "" { + minEngine, err := CanonicalEngineVersion(out.Versions[i].MinEngineVersion) + if err != nil { + return nil, fmt.Errorf("compatibility index %s minEngineVersion: %w", version, err) + } + out.Versions[i].MinEngineVersion = minEngine + } + out.Versions[i].Downloads = slices.Clone(index.Versions[i].Downloads) + for j := range out.Versions[i].Downloads { + if out.Versions[i].Downloads[j].SHA256 == "" { + continue + } + sha, err := NormalizeSHA256Hex(out.Versions[i].Downloads[j].SHA256) + if err != nil { + return nil, fmt.Errorf("%w: compatibility index %s download %s/%s sha256: %w", errInvalidRegistrySHA256, version, out.Versions[i].Downloads[j].OS, out.Versions[i].Downloads[j].Arch, err) + } + out.Versions[i].Downloads[j].SHA256 = sha + } + out.Versions[i].Compatibility = slices.Clone(index.Versions[i].Compatibility) + for j := range out.Versions[i].Compatibility { + ev, err := ValidateCompatibilityEvidence(out.Versions[i].Compatibility[j]) + if err != nil { + return nil, fmt.Errorf("compatibility index %s evidence[%d]: %w", version, j, err) + } + if ev.Plugin != out.Plugin && normalizePluginName(ev.Plugin) != normalizePluginName(out.Plugin) { + return nil, fmt.Errorf("compatibility index evidence plugin %q does not match %q", ev.Plugin, out.Plugin) + } + if ev.Version != version { + return nil, fmt.Errorf("compatibility index evidence version %q does not match %q", ev.Version, version) + } + out.Versions[i].Compatibility[j] = ev + } + } + sortCompatibilityIndex(&out) + return &out, nil +} + func CanonicalPluginVersion(version string) (string, error) { return canonicalStrictSemver(version, "plugin version") } diff --git a/cmd/wfctl/plugin_compat_resolver.go b/cmd/wfctl/plugin_compat_resolver.go index 8ca3c82f..6976951c 100644 --- a/cmd/wfctl/plugin_compat_resolver.go +++ b/cmd/wfctl/plugin_compat_resolver.go @@ -42,6 +42,11 @@ func ResolvePluginCompatibility(index *PluginVersionIndex, manifest *RegistryMan if index == nil { return PluginCompatDecision{}, fmt.Errorf("compatibility index is required") } + normalizedIndex, err := NormalizePluginVersionIndex(index, index.Plugin) + if err != nil { + return PluginCompatDecision{}, err + } + index = normalizedIndex engine, comparable := resolvePluginCompatEngineVersion(opts.EngineVersion) mode, err := parsePluginCompatMode(opts.CompatMode) if err != nil { @@ -111,7 +116,8 @@ func evaluatePluginCompatRecord(rec PluginVersionRecord, policy CompatibilityEvi } } archiveSHA := platformArchiveSHA(rec.Downloads, manifest, opts.OS, opts.Arch) - ev, ok := findCompatibilityEvidence(rec.Compatibility, engine, comparable, opts.OS, opts.Arch, archiveSHA) + requireEvidence := shouldRequireCompatibilityEvidence(policy, engine, comparable, opts.Trust) + ev, ok := findCompatibilityEvidence(rec.Compatibility, engine, comparable, opts.OS, opts.Arch, archiveSHA, requireEvidence) if ok { if ev.Status == PluginCompatibilityStatusPass { return PluginCompatDecision{Version: version, Evidence: &ev}, nil @@ -124,7 +130,7 @@ func evaluatePluginCompatRecord(rec PluginVersionRecord, policy CompatibilityEvi } return PluginCompatDecision{}, knownFailCompatError{version: version, engine: engine} } - if shouldRequireCompatibilityEvidence(policy, engine, comparable, opts.Trust) { + if requireEvidence { if opts.Force { return PluginCompatDecision{Version: version, Forced: true, Reason: forceReason, Warning: "missing required compatibility evidence; continuing because --force is set"}, nil } @@ -200,14 +206,20 @@ func platformArchiveSHA(recordDownloads []PluginDownload, manifest *RegistryMani return "" } -func findCompatibilityEvidence(evidence []PluginCompatibilityEvidence, engine string, comparable bool, goos, goarch, archiveSHA string) (PluginCompatibilityEvidence, bool) { +func findCompatibilityEvidence(evidence []PluginCompatibilityEvidence, engine string, comparable bool, goos, goarch, archiveSHA string, requireArchive bool) (PluginCompatibilityEvidence, bool) { var rangeMatch *PluginCompatibilityEvidence for i := range evidence { ev := evidence[i] if ev.Mode != PluginCompatibilityModeTypedIaC || ev.OS != goos || ev.Arch != goarch { continue } - if archiveSHA != "" && ev.ArchiveSHA256 != "" && ev.ArchiveSHA256 != archiveSHA { + if requireArchive && (archiveSHA == "" || ev.ArchiveSHA256 == "") { + continue + } + if archiveSHA == "" && ev.ArchiveSHA256 != "" { + continue + } + if archiveSHA != "" && ev.ArchiveSHA256 != archiveSHA { continue } if comparable && ev.EngineVersion == engine { diff --git a/cmd/wfctl/plugin_compat_resolver_test.go b/cmd/wfctl/plugin_compat_resolver_test.go index 56ed8df3..84c1b57f 100644 --- a/cmd/wfctl/plugin_compat_resolver_test.go +++ b/cmd/wfctl/plugin_compat_resolver_test.go @@ -19,6 +19,20 @@ func TestPluginCompatResolverNewestExactTrustedPassWins(t *testing.T) { } } +func TestPluginCompatResolverCanonicalizesIndexVersionsBeforeSorting(t *testing.T) { + idx := resolverIndex( + resolverRecord("0.9.0", passEvidence("0.9.0", "v0.51.2")), + resolverRecord("0.10.0", passEvidence("0.10.0", "v0.51.2")), + ) + decision, err := ResolvePluginCompatibility(idx, nil, resolverOptions()) + if err != nil { + t.Fatalf("ResolvePluginCompatibility: %v", err) + } + if decision.Version != "v0.10.0" { + t.Fatalf("version = %s, want v0.10.0", decision.Version) + } +} + func TestPluginCompatResolverNewerFailSkipsOlderPass(t *testing.T) { idx := resolverIndex( resolverRecord("v0.1.0", passEvidence("v0.1.0", "v0.51.2")), @@ -33,6 +47,26 @@ func TestPluginCompatResolverNewerFailSkipsOlderPass(t *testing.T) { } } +func TestPluginCompatResolverRequiredEvidenceMustBindArchive(t *testing.T) { + ev := passEvidence("v0.2.0", "v0.51.2") + ev.ArchiveSHA256 = strings.Repeat("a", 64) + ev, err := ValidateCompatibilityEvidence(ev) + if err != nil { + t.Fatalf("ValidateCompatibilityEvidence: %v", err) + } + rec := resolverRecord("v0.2.0", ev) + rec.Downloads = nil + idx := resolverIndex(rec) + idx.EvidencePolicy.RequiredFromEngine = "v0.51.0" + _, err = ResolvePluginCompatibility(idx, &RegistryManifest{Version: "v0.2.0"}, resolverOptions()) + if err == nil { + t.Fatal("expected missing required evidence when archive digest cannot be matched") + } + if !strings.Contains(err.Error(), "missing required compatibility evidence") { + t.Fatalf("error = %v, want missing evidence context", err) + } +} + func TestPluginCompatResolverRequestedKnownFailEnforces(t *testing.T) { idx := resolverIndex(resolverRecord("v0.2.0", failEvidence("v0.2.0", "v0.51.2"))) _, err := ResolvePluginCompatibility(idx, nil, PluginCompatResolverOptions{ diff --git a/cmd/wfctl/plugin_conformance.go b/cmd/wfctl/plugin_conformance.go index 8197f937..91c638bc 100644 --- a/cmd/wfctl/plugin_conformance.go +++ b/cmd/wfctl/plugin_conformance.go @@ -245,6 +245,9 @@ func checkTypedIaCPlugin(timeout time.Duration, pluginsDir, name string) (string Plugins: goplugin.PluginSet{"plugin": &external.GRPCPlugin{}}, Cmd: cmd, AllowedProtocols: []goplugin.Protocol{goplugin.ProtocolGRPC}, + Stderr: &stderr, + SyncStdout: &stdout, + SyncStderr: &stderr, Logger: hclog.NewNullLogger(), }) defer client.Kill() diff --git a/cmd/wfctl/plugin_conformance_test.go b/cmd/wfctl/plugin_conformance_test.go index 64a17905..47561424 100644 --- a/cmd/wfctl/plugin_conformance_test.go +++ b/cmd/wfctl/plugin_conformance_test.go @@ -56,6 +56,9 @@ func TestPluginConformanceLocalJSONPass(t *testing.T) { if strings.Contains(stdout, "{") { t.Fatalf("stdout should stay concise when --output is used, got %q", stdout) } + if strings.Contains(stdout, "iac-pass stderr marker") { + t.Fatalf("plugin output leaked to wfctl stdout: %q", stdout) + } raw, err := os.ReadFile(out) if err != nil { t.Fatalf("read evidence: %v", err) @@ -79,6 +82,9 @@ func TestPluginConformanceLocalJSONPass(t *testing.T) { if ev.ArchiveSHA256 != "" { t.Fatalf("local-dir evidence should not include archiveSHA256: %#v", ev) } + if !strings.Contains(ev.StderrTail, "iac-pass stderr marker") { + t.Fatalf("stderr tail missing plugin output: %#v", ev) + } } func TestPluginConformanceDefaultEngineVersionIsStrictSemver(t *testing.T) { diff --git a/cmd/wfctl/plugin_lock.go b/cmd/wfctl/plugin_lock.go index 18a8b72f..296ddcb0 100644 --- a/cmd/wfctl/plugin_lock.go +++ b/cmd/wfctl/plugin_lock.go @@ -151,8 +151,6 @@ func loadPluginLockRegistryConfig(manifestPath, lockPath string) (*RegistryConfi return nil, nil } -var errInvalidRegistrySHA256 = errors.New("invalid sha256") - func lockPlatformsFromRegistry(registries *MultiRegistry, registryConfig *RegistryConfig, pluginName, version string, compatOpts pluginLockCompatibilityOptions) (map[string]config.WfctlLockPlatform, string, error) { manifest, index, sourceName, err := registries.FetchManifestAndVersionIndex(pluginName) if err != nil { diff --git a/cmd/wfctl/registry_compatibility.go b/cmd/wfctl/registry_compatibility.go index 9acaa6c6..baff4cbc 100644 --- a/cmd/wfctl/registry_compatibility.go +++ b/cmd/wfctl/registry_compatibility.go @@ -208,7 +208,11 @@ func loadRegistryCompatibilityIndex(path, plugin string) (*PluginVersionIndex, e return nil, fmt.Errorf("compatibility index plugin %q does not match %q", index.Plugin, plugin) } index.Plugin = plugin - return &index, nil + normalized, err := NormalizePluginVersionIndex(&index, plugin) + if err != nil { + return nil, fmt.Errorf("normalize compatibility index: %w", err) + } + return normalized, nil } func buildCompatibilityVersionRecord(version string, manifest *RegistryManifest, evidence []PluginCompatibilityEvidence) PluginVersionRecord { diff --git a/cmd/wfctl/registry_source.go b/cmd/wfctl/registry_source.go index 7d084f74..56ac363a 100644 --- a/cmd/wfctl/registry_source.go +++ b/cmd/wfctl/registry_source.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "errors" "fmt" "io" "net/http" @@ -10,6 +11,8 @@ import ( "time" ) +var errRegistryNotFound = errors.New("registry resource not found") + // RegistrySource is the interface for a plugin registry backend. type RegistrySource interface { // Name returns the configured name of this registry. @@ -150,7 +153,11 @@ func (g *GitHubRegistrySource) FetchVersionIndex(name string) (*PluginVersionInd if err := json.Unmarshal(data, &idx); err != nil { return nil, fmt.Errorf("parse compatibility index for %q from %s: %w", name, g.name, err) } - return &idx, nil + normalized, err := NormalizePluginVersionIndex(&idx, name) + if err != nil { + return nil, fmt.Errorf("normalize compatibility index for %q from %s: %w", name, g.name, err) + } + return normalized, nil } func (g *GitHubRegistrySource) SearchPlugins(query string) ([]PluginSearchResult, error) { @@ -218,7 +225,7 @@ func (s *StaticRegistrySource) FetchVersionIndex(name string) (*PluginVersionInd url := fmt.Sprintf("%s/compatibility/%s/index.json", s.baseURL, name) data, err := s.fetch(url) if err != nil { - if strings.Contains(err.Error(), "HTTP 404") || strings.Contains(err.Error(), "not found") { + if errors.Is(err, errRegistryNotFound) { manifest, manifestErr := s.FetchManifest(name) if manifestErr != nil { return nil, manifestErr @@ -231,7 +238,11 @@ func (s *StaticRegistrySource) FetchVersionIndex(name string) (*PluginVersionInd if err := json.Unmarshal(data, &idx); err != nil { return nil, fmt.Errorf("parse compatibility index for %q from %s: %w", name, s.name, err) } - return &idx, nil + normalized, err := NormalizePluginVersionIndex(&idx, name) + if err != nil { + return nil, fmt.Errorf("normalize compatibility index for %q from %s: %w", name, s.name, err) + } + return normalized, nil } // staticIndexEntry is a single entry in the registry index.json file. @@ -311,7 +322,7 @@ func (s *StaticRegistrySource) fetch(url string) ([]byte, error) { } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("not found (HTTP 404) at %s", url) + return nil, fmt.Errorf("%w: HTTP 404 at %s", errRegistryNotFound, url) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) @@ -333,7 +344,7 @@ func synthesizeVersionIndexFromManifest(manifest *RegistryManifest) *PluginVersi minEngineVersion = canonical } } - return &PluginVersionIndex{ + index := &PluginVersionIndex{ Plugin: manifest.Name, Versions: []PluginVersionRecord{{ Version: version, @@ -341,6 +352,11 @@ func synthesizeVersionIndexFromManifest(manifest *RegistryManifest) *PluginVersi Downloads: manifest.Downloads, }}, } + normalized, err := NormalizePluginVersionIndex(index, manifest.Name) + if err != nil { + return index + } + return normalized } // matchesRegistryQuery checks if a manifest matches a search query. diff --git a/cmd/wfctl/registry_source_test.go b/cmd/wfctl/registry_source_test.go index b4e601bb..0dbf0bc2 100644 --- a/cmd/wfctl/registry_source_test.go +++ b/cmd/wfctl/registry_source_test.go @@ -180,8 +180,8 @@ func TestStaticRegistrySource_FetchVersionIndex_Native(t *testing.T) { index := &PluginVersionIndex{ Plugin: "alpha", Versions: []PluginVersionRecord{{ - Version: "v1.0.0", - MinEngineVersion: "v0.51.2", + Version: "1.0.0", + MinEngineVersion: "0.51.2", }}, } srv := buildStaticRegistryServerWithCompat(t, nil, map[string]*RegistryManifest{"alpha": manifest}, map[string]*PluginVersionIndex{"alpha": index}) diff --git a/cmd/wfctl/testdata/conformance/iac-pass/main.go b/cmd/wfctl/testdata/conformance/iac-pass/main.go index e13d59d7..6df52fae 100644 --- a/cmd/wfctl/testdata/conformance/iac-pass/main.go +++ b/cmd/wfctl/testdata/conformance/iac-pass/main.go @@ -2,6 +2,8 @@ package main import ( "context" + "fmt" + "os" pb "github.com/GoCodeAlone/workflow/plugin/external/proto" "github.com/GoCodeAlone/workflow/plugin/external/sdk" @@ -24,5 +26,6 @@ func (p *provider) Capabilities(context.Context, *pb.CapabilitiesRequest) (*pb.C } func main() { + fmt.Fprintln(os.Stderr, "iac-pass stderr marker") sdk.ServeIaCPlugin(&provider{}, sdk.IaCServeOptions{}) } From c7cdef032c87b9df9917fac94e19df01621ba120 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 02:56:54 -0400 Subject: [PATCH 27/29] fix(wfctl): harden conformance ci --- .github/workflows/ci.yml | 15 ++++++++++++++- cmd/wfctl/plugin_conformance.go | 6 ++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d30068ed..935fb1d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,10 +65,23 @@ jobs: run: | go test -v -race -coverprofile=coverage.out ./... + - name: Check Codecov token + id: codecov-token + if: always() + run: | + if [ -n "$CODECOV_TOKEN" ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + fi + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + - name: Upload coverage reports uses: codecov/codecov-action@v5 - if: always() + if: always() && steps.codecov-token.outputs.available == 'true' with: + token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage.out fail_ci_if_error: false diff --git a/cmd/wfctl/plugin_conformance.go b/cmd/wfctl/plugin_conformance.go index 91c638bc..74ccfb0e 100644 --- a/cmd/wfctl/plugin_conformance.go +++ b/cmd/wfctl/plugin_conformance.go @@ -11,6 +11,7 @@ import ( "path/filepath" "runtime" "strings" + "sync" "time" goplugin "github.com/GoCodeAlone/go-plugin" @@ -412,11 +413,14 @@ func pathBaseSlash(path string) string { } type tailBuffer struct { + mu sync.Mutex buf []byte } func (b *tailBuffer) Write(p []byte) (int, error) { const maxTail = 4096 + b.mu.Lock() + defer b.mu.Unlock() b.buf = append(b.buf, p...) if len(b.buf) > maxTail { b.buf = b.buf[len(b.buf)-maxTail:] @@ -425,5 +429,7 @@ func (b *tailBuffer) Write(p []byte) (int, error) { } func (b *tailBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() return string(b.buf) } From d693d74204cf5bf06ded39aea7a9dc36ae7fc4ec Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 03:02:02 -0400 Subject: [PATCH 28/29] fix(wfctl): close compat review gaps --- cmd/wfctl/plugin_compat_model.go | 5 +- cmd/wfctl/plugin_compat_model_test.go | 19 +++++ cmd/wfctl/plugin_compat_resolver.go | 1 + cmd/wfctl/plugin_conformance.go | 2 +- cmd/wfctl/plugin_conformance_test.go | 3 + cmd/wfctl/plugin_lock.go | 2 +- cmd/wfctl/plugin_lock_test.go | 13 ++++ cmd/wfctl/registry_compatibility.go | 8 ++- cmd/wfctl/registry_compatibility_test.go | 89 +++++++++++++++++++++--- 9 files changed, 125 insertions(+), 17 deletions(-) diff --git a/cmd/wfctl/plugin_compat_model.go b/cmd/wfctl/plugin_compat_model.go index baf82a03..0f3fad95 100644 --- a/cmd/wfctl/plugin_compat_model.go +++ b/cmd/wfctl/plugin_compat_model.go @@ -214,8 +214,9 @@ func ValidateCompatibilityEvidence(ev PluginCompatibilityEvidence) (PluginCompat return ev, err } if ev.WfctlVersion != "" { - if ev.WfctlVersion, err = CanonicalEngineVersion(ev.WfctlVersion); err != nil { - return ev, fmt.Errorf("wfctl version: %w", err) + ev.WfctlVersion = strings.TrimSpace(ev.WfctlVersion) + if canonical, err := CanonicalEngineVersion(ev.WfctlVersion); err == nil { + ev.WfctlVersion = canonical } } if ev.Mode != PluginCompatibilityModeTypedIaC { diff --git a/cmd/wfctl/plugin_compat_model_test.go b/cmd/wfctl/plugin_compat_model_test.go index 5409fac4..e42052ed 100644 --- a/cmd/wfctl/plugin_compat_model_test.go +++ b/cmd/wfctl/plugin_compat_model_test.go @@ -96,6 +96,25 @@ func TestPluginCompatSHA256Normalization(t *testing.T) { } } +func TestPluginCompatEvidenceAllowsDevWfctlVersion(t *testing.T) { + got, err := ValidateCompatibilityEvidence(PluginCompatibilityEvidence{ + Plugin: "workflow-plugin-test", + Version: "v0.1.0", + EngineVersion: "v0.51.2", + WfctlVersion: "dev", + Mode: PluginCompatibilityModeTypedIaC, + Status: PluginCompatibilityStatusPass, + OS: "linux", + Arch: "amd64", + }) + if err != nil { + t.Fatalf("ValidateCompatibilityEvidence: %v", err) + } + if got.WfctlVersion != "dev" { + t.Fatalf("wfctlVersion = %q, want dev", got.WfctlVersion) + } +} + func TestPluginCompatTrustParsing(t *testing.T) { var cfg RegistryConfig data := []byte(` diff --git a/cmd/wfctl/plugin_compat_resolver.go b/cmd/wfctl/plugin_compat_resolver.go index 6976951c..ce44a568 100644 --- a/cmd/wfctl/plugin_compat_resolver.go +++ b/cmd/wfctl/plugin_compat_resolver.go @@ -16,6 +16,7 @@ const ( PluginCompatForceInstall = "force-install" PluginCompatForceUpdate = "force-update" + PluginCompatForceLock = "force-lock" PluginCompatWarnReason = "compat-mode=warn" ) diff --git a/cmd/wfctl/plugin_conformance.go b/cmd/wfctl/plugin_conformance.go index 74ccfb0e..64077281 100644 --- a/cmd/wfctl/plugin_conformance.go +++ b/cmd/wfctl/plugin_conformance.go @@ -202,7 +202,7 @@ func runPluginConformanceCheck(opts pluginConformanceOptions) (PluginCompatibili Plugin: manifest.Name, Version: manifest.Version, EngineVersion: opts.EngineVersion, - WfctlVersion: opts.EngineVersion, + WfctlVersion: buildVersion(), Mode: opts.Mode, Status: PluginCompatibilityStatusPass, OS: runtime.GOOS, diff --git a/cmd/wfctl/plugin_conformance_test.go b/cmd/wfctl/plugin_conformance_test.go index 47561424..c1138a6b 100644 --- a/cmd/wfctl/plugin_conformance_test.go +++ b/cmd/wfctl/plugin_conformance_test.go @@ -76,6 +76,9 @@ func TestPluginConformanceLocalJSONPass(t *testing.T) { if ev.Plugin != "iac-pass" || ev.Version != "v0.1.0" || ev.EngineVersion != "v0.51.2" { t.Fatalf("unexpected evidence identity: %#v", ev) } + if ev.WfctlVersion != buildVersion() { + t.Fatalf("wfctlVersion = %q, want actual build version %q", ev.WfctlVersion, buildVersion()) + } if ev.BinarySHA256 == "" || ev.PluginManifestSHA256 == "" || ev.EvidenceDigest == "" { t.Fatalf("missing hashes/digest: %#v", ev) } diff --git a/cmd/wfctl/plugin_lock.go b/cmd/wfctl/plugin_lock.go index 296ddcb0..ca80c351 100644 --- a/cmd/wfctl/plugin_lock.go +++ b/cmd/wfctl/plugin_lock.go @@ -165,7 +165,7 @@ func lockPlatformsFromRegistry(registries *MultiRegistry, registryConfig *Regist EngineVersion: compatOpts.EngineVersion, CompatMode: resolvedCompatMode, Force: compatOpts.Force, - ForceReason: PluginCompatForceInstall, + ForceReason: PluginCompatForceLock, Trust: registryTrustMode(registryConfig, sourceName), }) if err != nil { diff --git a/cmd/wfctl/plugin_lock_test.go b/cmd/wfctl/plugin_lock_test.go index 352afc59..dfda3d7a 100644 --- a/cmd/wfctl/plugin_lock_test.go +++ b/cmd/wfctl/plugin_lock_test.go @@ -388,6 +388,19 @@ plugins: if compat == nil || !compat.Forced || compat.Reason != PluginCompatWarnReason || compat.Status != PluginCompatibilityStatusFail { t.Fatalf("forced known-fail metadata missing/incomplete: %#v", compat) } + + forceLockPath := filepath.Join(dir, ".wfctl-force-lock.yaml") + if err := runPluginLockFromManifestWithOptions(manifestPath, forceLockPath, pluginLockCompatibilityOptions{EngineVersion: "v0.51.2", Force: true}); err != nil { + t.Fatalf("runPluginLockFromManifestWithOptions force: %v", err) + } + forcedLF, err := config.LoadWfctlLockfile(forceLockPath) + if err != nil { + t.Fatalf("load forced lockfile: %v", err) + } + forcedCompat := forcedLF.Plugins["workflow-plugin-foo"].Platforms[currentPlatformKey()].Compatibility + if forcedCompat == nil || !forcedCompat.Forced || forcedCompat.Reason != PluginCompatForceLock { + t.Fatalf("force-lock metadata missing/incomplete: %#v", forcedCompat) + } } func TestPluginLock_FromManifest_RefreshesExistingPlatformSHA256FromRegistry(t *testing.T) { diff --git a/cmd/wfctl/registry_compatibility.go b/cmd/wfctl/registry_compatibility.go index baff4cbc..2b954272 100644 --- a/cmd/wfctl/registry_compatibility.go +++ b/cmd/wfctl/registry_compatibility.go @@ -246,12 +246,14 @@ func normalizeCompatibilityDownloads(downloads []PluginDownload) []PluginDownloa func validateEvidenceArchiveMatchesDownload(ev PluginCompatibilityEvidence, manifest *RegistryManifest) error { if ev.ArchiveSHA256 == "" { - return nil + return fmt.Errorf("evidence archiveSHA256 is required for registry compatibility updates") } + matchedPlatform := false for _, download := range manifest.Downloads { if download.OS != ev.OS || download.Arch != ev.Arch || download.SHA256 == "" { continue } + matchedPlatform = true sha, err := NormalizeSHA256Hex(download.SHA256) if err != nil { return fmt.Errorf("manifest download sha256 for %s/%s: %w", download.OS, download.Arch, err) @@ -259,7 +261,9 @@ func validateEvidenceArchiveMatchesDownload(ev PluginCompatibilityEvidence, mani if sha == ev.ArchiveSHA256 { return nil } - return fmt.Errorf("evidence archiveSHA256 %s does not match manifest download sha256 %s for %s/%s", ev.ArchiveSHA256, sha, ev.OS, ev.Arch) + } + if matchedPlatform { + return fmt.Errorf("evidence archiveSHA256 %s does not match any manifest download sha256 for %s/%s", ev.ArchiveSHA256, ev.OS, ev.Arch) } return fmt.Errorf("evidence archiveSHA256 %s has no matching manifest download for %s/%s", ev.ArchiveSHA256, ev.OS, ev.Arch) } diff --git a/cmd/wfctl/registry_compatibility_test.go b/cmd/wfctl/registry_compatibility_test.go index 0aac462e..593dbeb1 100644 --- a/cmd/wfctl/registry_compatibility_test.go +++ b/cmd/wfctl/registry_compatibility_test.go @@ -117,6 +117,68 @@ func TestRegistryCompatibilityUpdateRejectsArchiveMismatchAndLeavesIndex(t *test } } +func TestRegistryCompatibilityUpdateRejectsMissingArchiveHash(t *testing.T) { + registryDir := prepareCompatibilityRegistry(t, "workflow-plugin-test", "v0.1.0", testArchiveSHA256) + evPath := writeCompatibilityEvidence(t, registryDir, PluginCompatibilityEvidence{ + Plugin: "workflow-plugin-test", + Version: "v0.1.0", + EngineVersion: "v0.51.2", + Mode: PluginCompatibilityModeTypedIaC, + Status: PluginCompatibilityStatusPass, + OS: "darwin", + Arch: "arm64", + }) + + err := runPluginRegistry([]string{ + "compatibility", "update", + "--registry-dir", registryDir, + "--plugin", "workflow-plugin-test", + "--version", "v0.1.0", + "--evidence", evPath, + }) + if err == nil { + t.Fatal("expected missing archiveSHA256 error") + } + if !strings.Contains(err.Error(), "archiveSHA256 is required") { + t.Fatalf("error = %v, want archiveSHA256 required context", err) + } +} + +func TestRegistryCompatibilityUpdateChecksAllPlatformDownloads(t *testing.T) { + registryDir := t.TempDir() + writeManifestWithDownloads(t, registryDir, "workflow-plugin-test", "v0.1.0", []PluginDownload{{ + OS: "darwin", + Arch: "arm64", + URL: "https://example.invalid/plugin-first.tar.gz", + SHA256: testOtherArchiveSHA256, + }, { + OS: "darwin", + Arch: "arm64", + URL: "https://example.invalid/plugin-second.tar.gz", + SHA256: testArchiveSHA256, + }}) + evPath := writeCompatibilityEvidence(t, registryDir, PluginCompatibilityEvidence{ + Plugin: "workflow-plugin-test", + Version: "v0.1.0", + EngineVersion: "v0.51.2", + Mode: PluginCompatibilityModeTypedIaC, + Status: PluginCompatibilityStatusPass, + OS: "darwin", + Arch: "arm64", + ArchiveSHA256: testArchiveSHA256, + }) + + if err := runPluginRegistry([]string{ + "compatibility", "update", + "--registry-dir", registryDir, + "--plugin", "workflow-plugin-test", + "--version", "v0.1.0", + "--evidence", evPath, + }); err != nil { + t.Fatalf("compatibility update: %v", err) + } +} + func TestRegistryCompatibilityUpdateSortsVersionsEvidenceAndMarksStale(t *testing.T) { registryDir := prepareCompatibilityRegistry(t, "workflow-plugin-test", "v0.2.0", testArchiveSHA256) writeInitialCompatibilityIndex(t, registryDir, PluginVersionIndex{ @@ -235,6 +297,21 @@ func prepareCompatibilityRegistry(t *testing.T, plugin, version, archiveSHA stri } func writeManifest(t *testing.T, registryDir, plugin, version, archiveSHA string) { + t.Helper() + writeManifestWithDownloads(t, registryDir, plugin, version, []PluginDownload{{ + OS: "darwin", + Arch: "arm64", + URL: "https://example.invalid/plugin.tar.gz", + SHA256: archiveSHA, + }, { + OS: "linux", + Arch: "amd64", + URL: "https://example.invalid/plugin-linux.tar.gz", + SHA256: archiveSHA, + }}) +} + +func writeManifestWithDownloads(t *testing.T, registryDir, plugin, version string, downloads []PluginDownload) { t.Helper() manifest := RegistryManifest{ Name: plugin, @@ -244,17 +321,7 @@ func writeManifest(t *testing.T, registryDir, plugin, version, archiveSHA string Type: "external", Tier: "community", MinEngineVersion: "v0.50.0", - Downloads: []PluginDownload{{ - OS: "darwin", - Arch: "arm64", - URL: "https://example.invalid/plugin.tar.gz", - SHA256: archiveSHA, - }, { - OS: "linux", - Arch: "amd64", - URL: "https://example.invalid/plugin-linux.tar.gz", - SHA256: archiveSHA, - }}, + Downloads: downloads, } data, err := json.MarshalIndent(manifest, "", " ") if err != nil { From e8056a70d691515847fd164a064bfd845b332634 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 11 May 2026 03:20:41 -0400 Subject: [PATCH 29/29] fix(wfctl): address compat review followups --- cmd/wfctl/plugin_conformance.go | 7 ++++--- cmd/wfctl/plugin_install.go | 8 ++++++-- cmd/wfctl/plugin_install_e2e_test.go | 9 ++++----- cmd/wfctl/registry_cmd.go | 12 ++++++------ cmd/wfctl/registry_cmd_test.go | 23 +++++++++++++++++++++++ 5 files changed, 43 insertions(+), 16 deletions(-) diff --git a/cmd/wfctl/plugin_conformance.go b/cmd/wfctl/plugin_conformance.go index 64077281..38e912b0 100644 --- a/cmd/wfctl/plugin_conformance.go +++ b/cmd/wfctl/plugin_conformance.go @@ -139,11 +139,12 @@ func runPluginConformanceCheck(opts pluginConformanceOptions) (PluginCompatibili return PluginCompatibilityEvidence{}, fmt.Errorf("hash artifact: %w", err) } archiveSHA = sha - data, err := os.ReadFile(opts.ArtifactPath) + file, err := os.Open(opts.ArtifactPath) //nolint:gosec // user-supplied local artifact path. if err != nil { - return PluginCompatibilityEvidence{}, fmt.Errorf("read artifact: %w", err) + return PluginCompatibilityEvidence{}, fmt.Errorf("open artifact: %w", err) } - if err := extractTarGz(data, sourceDir); err != nil { + defer file.Close() + if err := extractTarGzReader(file, sourceDir); err != nil { return PluginCompatibilityEvidence{}, fmt.Errorf("extract artifact: %w", err) } } else { diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index a7484902..f0344692 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -515,7 +515,7 @@ func runPluginUpdate(args []string) error { return nil } fmt.Fprintf(os.Stderr, "Updating from %s to %s...\n", installedVer, manifest.Version) - return installPluginFromManifest(pluginDirVal, pluginName, manifest, nil, false) + return installPluginFromManifest(pluginDirVal, pluginName, manifest, nil, *skipChecksum) } return registryErr @@ -1181,7 +1181,11 @@ func parseGitHubRepoURL(repoURL string) (owner, repo string, err error) { // extractTarGz decompresses and extracts a .tar.gz archive into destDir. // It guards against path traversal (zip-slip) attacks. func extractTarGz(data []byte, destDir string) error { - gzr, err := gzip.NewReader(bytes.NewReader(data)) + return extractTarGzReader(bytes.NewReader(data), destDir) +} + +func extractTarGzReader(r io.Reader, destDir string) error { + gzr, err := gzip.NewReader(r) if err != nil { return fmt.Errorf("open gzip: %w", err) } diff --git a/cmd/wfctl/plugin_install_e2e_test.go b/cmd/wfctl/plugin_install_e2e_test.go index 63132878..5205bc22 100644 --- a/cmd/wfctl/plugin_install_e2e_test.go +++ b/cmd/wfctl/plugin_install_e2e_test.go @@ -802,7 +802,6 @@ func TestPluginUpdateFallbackToRepo(t *testing.T) { topDir + "/" + pluginName: binaryContent, } tarball := buildTarGz(t, tarEntries, 0755) - checksum := sha256Hex(tarball) // manifest served by the plugin's repo when registry lookup fails. updatedManifest := &RegistryManifest{ @@ -828,10 +827,9 @@ func TestPluginUpdateFallbackToRepo(t *testing.T) { // Fill in the tarball URL dynamically (srv.URL is known here). m := *updatedManifest m.Downloads = []PluginDownload{{ - OS: runtime.GOOS, - Arch: runtime.GOARCH, - URL: "/tarball", // relative, will be prefixed by srv.URL in the handler - SHA256: checksum, + OS: runtime.GOOS, + Arch: runtime.GOARCH, + URL: "/tarball", // relative, will be prefixed by srv.URL in the handler }} // Use absolute URL for the tarball. m.Downloads[0].URL = "http://" + r.Host + "/tarball" @@ -891,6 +889,7 @@ func TestPluginUpdateFallbackToRepo(t *testing.T) { err := runPluginUpdate([]string{ "-plugin-dir", pluginsDir, "-config", cfgFile, + "-skip-checksum", pluginName, }) if err != nil { diff --git a/cmd/wfctl/registry_cmd.go b/cmd/wfctl/registry_cmd.go index a52d0a3b..a9e8b3c7 100644 --- a/cmd/wfctl/registry_cmd.go +++ b/cmd/wfctl/registry_cmd.go @@ -50,10 +50,10 @@ Subcommands: } func runRegistryList(args []string) error { - fs := flag.NewFlagSet("registry list", flag.ContinueOnError) + fs := flag.NewFlagSet("plugin-registry list", flag.ContinueOnError) cfgPath := fs.String("config", "", "Registry config file path") fs.Usage = func() { - fmt.Fprintf(fs.Output(), "Usage: wfctl registry list [options]\n\nShow configured plugin registries.\n\nOptions:\n") + fmt.Fprintf(fs.Output(), "Usage: wfctl plugin-registry list [options]\n\nShow configured plugin registries.\n\nOptions:\n") fs.PrintDefaults() } if err := fs.Parse(args); err != nil { @@ -78,7 +78,7 @@ func runRegistryAdd(args []string) error { if err := checkTrailingFlags(args); err != nil { return err } - fs := flag.NewFlagSet("registry add", flag.ContinueOnError) + fs := flag.NewFlagSet("plugin-registry add", flag.ContinueOnError) cfgPath := fs.String("config", "", "Registry config file path (default: ~/.config/wfctl/config.yaml)") regType := fs.String("type", "github", "Registry type (github)") owner := fs.String("owner", "", "GitHub owner/org (required)") @@ -86,7 +86,7 @@ func runRegistryAdd(args []string) error { branch := fs.String("branch", "main", "Git branch") priority := fs.Int("priority", 10, "Priority (lower = higher priority)") fs.Usage = func() { - fmt.Fprintf(fs.Output(), "Usage: wfctl registry add [options] \n\nAdd a plugin registry source.\n\nOptions:\n") + fmt.Fprintf(fs.Output(), "Usage: wfctl plugin-registry add [options] \n\nAdd a plugin registry source.\n\nOptions:\n") fs.PrintDefaults() } if err := fs.Parse(args); err != nil { @@ -143,10 +143,10 @@ func runRegistryRemove(args []string) error { if err := checkTrailingFlags(args); err != nil { return err } - fs := flag.NewFlagSet("registry remove", flag.ContinueOnError) + fs := flag.NewFlagSet("plugin-registry remove", flag.ContinueOnError) cfgPath := fs.String("config", "", "Registry config file path (default: ~/.config/wfctl/config.yaml)") fs.Usage = func() { - fmt.Fprintf(fs.Output(), "Usage: wfctl registry remove [options] \n\nRemove a plugin registry source.\n\nOptions:\n") + fmt.Fprintf(fs.Output(), "Usage: wfctl plugin-registry remove [options] \n\nRemove a plugin registry source.\n\nOptions:\n") fs.PrintDefaults() } if err := fs.Parse(args); err != nil { diff --git a/cmd/wfctl/registry_cmd_test.go b/cmd/wfctl/registry_cmd_test.go index 775d55bd..6a84e632 100644 --- a/cmd/wfctl/registry_cmd_test.go +++ b/cmd/wfctl/registry_cmd_test.go @@ -1,6 +1,8 @@ package main import ( + "errors" + "flag" "strings" "testing" ) @@ -27,6 +29,27 @@ func TestRunRegistryDeprecated_EmitsWarning(t *testing.T) { } } +func TestRunPluginRegistrySubcommandHelpUsesCanonicalName(t *testing.T) { + for _, args := range [][]string{ + {"list", "--help"}, + {"add", "--help"}, + {"remove", "--help"}, + } { + output, err := captureStderr(t, func() error { + return runPluginRegistry(args) + }) + if !errors.Is(err, flag.ErrHelp) { + t.Fatalf("runPluginRegistry(%v) error = %v, want flag.ErrHelp", args, err) + } + if !strings.Contains(output, "wfctl plugin-registry "+args[0]) { + t.Fatalf("help for %v missing canonical command name:\n%s", args, output) + } + if strings.Contains(output, "wfctl registry "+args[0]) { + t.Fatalf("help for %v still mentions deprecated command name:\n%s", args, output) + } + } +} + func TestRunBuildImageRouting(t *testing.T) { // Verify build.go routes "image" subcommand to runBuildImage. // Use dry-run + no config to trigger a graceful "no containers" message (not panic).