From c94f4c00e80d30afef54fe7b76431ea15b31b60f Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 23 May 2026 20:01:13 -0400 Subject: [PATCH 1/7] docs(plan): design for wfctl plugin registry-sync + Layer 3b/c sweep + template-repo modernization (workflow#762) --- .../2026-05-23-wfctl-registry-sync-design.md | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 docs/plans/2026-05-23-wfctl-registry-sync-design.md diff --git a/docs/plans/2026-05-23-wfctl-registry-sync-design.md b/docs/plans/2026-05-23-wfctl-registry-sync-design.md new file mode 100644 index 00000000..0c41c8db --- /dev/null +++ b/docs/plans/2026-05-23-wfctl-registry-sync-design.md @@ -0,0 +1,154 @@ +# wfctl plugin registry-sync + Layer 3b/c sweep + template-repo modernization — design + +Issue: GoCodeAlone/workflow#762 +Date: 2026-05-23 +Mode: autonomous execution authorized + +## Problem + +Three coupled gaps surfaced after workflow#758 pilot landed: + +1. **`workflow-registry/scripts/sync-versions.sh` is 228 lines of bash** + jq + `gh` CLI. workflow#758 Layer 2 added a strict-semver tag gate to it, but that regex is now duplicated with `wfctl plugin validate-contract --for-publish`. Two implementations of the same rule → drift. No test harness in workflow-registry. Cannot reach the deferred binary-vs-file capability gate (cycle 4-A1 I3) because it requires plugin-binary spawn. +2. **Layer 3b sweep blocked by ldflag gap.** Audit (5 of 56 sampled) shows NONE have `-X .*\.Version=` ldflag in goreleaser AND none declare `var Version = "dev"`. Pilot 5 (DO/AWS/GCP/Azure/github) were the exception. Per-repo migration is heavier than the canonical template — needs ldflag + Version-var added BEFORE the canonical sweep can wire `sdk.ResolveBuildVersion`. +3. **Template repos are treated as plugins.** `workflow-plugin-template` is in `workflow-registry/plugins/template/manifest.json` as `type: external` — operators could `wfctl plugin install workflow-plugin-template` and get an empty scaffold. Name `template` is wasted on the scaffold. Template content predates workflow#758, so any repo created from it starts non-compliant. Same problem in private template. + +## Proposed design + +Single issue, four composable layers. Sequencing: (a) → (a') → (d) → (c) → (b). + +### Layer (a): `wfctl plugin registry-sync` subcommand + +New subcommand under existing `wfctl plugin` family (cycle 4-P1 naming rationale; avoids collision with OCI `wfctl registry`). + +Surface: + +``` +wfctl plugin registry-sync [--fix] [--plugin ] [--verify-capabilities] [--registry-dir ] +``` + +- Default dry-run; `--fix` writes back. +- `--plugin ` filters to single plugin manifest. +- `--registry-dir` defaults to `.` (the cwd, typically a workflow-registry checkout in CI). +- `--verify-capabilities` (optional, registry-side only): downloads upstream release tarball; extracts plugin binary; spawns via `plugin/external/manager.go` machinery; calls `GetContractRegistry` RPC; diffs vs committed `plugin.json.capabilities`; with `--fix` auto-rewrites. + +Implementation lives in `cmd/wfctl/plugin_registry_sync.go` + `_test.go`. Shared strict-semver regex extracted into `cmd/wfctl/plugin_release_grade_semver.go` (constant sourced by `validate-contract --for-publish` AND `registry-sync`). Logic ports `sync-versions.sh` 1:1 with fixture-backed parity tests. + +`workflow-registry/.github/workflows/sync-registry-manifests.yml` swaps `bash scripts/sync-versions.sh --fix` for `wfctl plugin registry-sync --fix`. Bash script kept alongside for **one parity-verification cycle**, then deleted in a follow-up PR. + +### Layer (a'): workflow-registry switch + parity cycle + +PR in `workflow-registry`: + +1. Add `wfctl plugin registry-sync --fix` to `sync-registry-manifests.yml` as a NEW step running AFTER the existing `bash scripts/sync-versions.sh --fix`. +2. In dry-run mode for both, log the diff between bash and Go outputs into the workflow artifact. +3. After one weekly cron cycle confirms zero output diff, ship the **followup PR** that deletes `sync-versions.sh` + removes the bash step. + +This belts-and-suspenders pattern addresses self-challenge doubt D2 (bash → Go translation parity risk). + +### Layer (d): template-repo modernization + +**Renames:** +- `workflow-plugin-template` → `scaffold-workflow-plugin` (public; prefix-first naming so it doesn't look like a plugin family member; per user) +- `workflow-plugin-template-private` → `scaffold-workflow-plugin-private` (private; suffix `-private` keeps both scaffolds alphabetically adjacent in org browse vs `private-scaffold-workflow-plugin`) + +**Per-repo steps (one PR per scaffold):** + +1. `gh repo rename` (GitHub keeps old-URL redirect for 1 year+). +2. GitHub repo settings: enable `template_repository: true` (makes the repo selectable under "Use this template" dropdown when creating a new repo). +3. Content updates (single PR per scaffold): + - `plugin.json`: `name`: `scaffold-workflow-plugin` (the scaffold itself); `version`: `"0.0.0"`; `minEngineVersion`: `0.61.0`; capabilities populated with placeholder shape (`moduleTypes: ["TEMPLATE.module"]`, `stepTypes: ["TEMPLATE.step"]`, `triggerTypes: []`, `iacProvider: {resourceTypes: ["TEMPLATE.resource"]}`) — shows the expected shape so instantiators see what to fill. + - `cmd/workflow-plugin-TEMPLATE/main.go` → rename to `cmd/scaffold-workflow-plugin/main.go`. The README explicitly instructs instantiators to rename this dir to `cmd/workflow-plugin-/` immediately after instantiation. + - main.go uses `sdk.ServeIaCPlugin(srv, sdk.IaCServeOptions{BuildVersion: sdk.ResolveBuildVersion(internal.Version)})` — covers BOTH module/step (`IaCServeOptions.Modules + Steps`) AND IaC dispatch in a single entrypoint. Plugins that don't need IaC pass empty maps; plugins that don't need modules/steps leave them unset. One canonical entrypoint per user direction. + - `internal/version.go`: `var Version = "dev"`. + - `.goreleaser.yaml`: `-X github.com/GoCodeAlone/scaffold-workflow-plugin/internal.Version={{.Version}}` ldflag. + - `.github/workflows/release.yml`: setup-wfctl@v1 + pre-build + post-build `wfctl plugin validate-contract` gates. + - **No** `sync-plugin-version.yml` (defunct workflow not shipped in new scaffolds). + - `README.md`: documents the post-instantiation ritual: + - Rename `cmd/scaffold-workflow-plugin/` → `cmd/workflow-plugin-/` + - Edit `plugin.json` (name, description, capabilities, minEngineVersion) + - `go mod edit -module github.com//workflow-plugin-`; `find . -name '*.go' -exec sed -i.bak 's|scaffold-workflow-plugin|workflow-plugin-|g' {} \;` + - First `git commit` + first tag +4. Delete `workflow-registry/plugins/template/` (scaffold is not an installable plugin); commit in workflow-registry PR. +5. Add registry-side defense: `wfctl plugin registry-sync` emits a `WARN` if it encounters a registered manifest whose `repository` field points at `*-scaffold-*` or contains `scaffold-workflow-plugin` (catches accidental re-registration). +6. Update workflow#760 sweep list: drop `workflow-plugin-template` + `workflow-plugin-template-private`. 56 → 54. + +### Layer (c): ldflag + Version var bootstrap (54 repos) + +One PR per repo. Mechanical 3-file edit: + +1. Create `internal/version.go` with `var Version = "dev"` (or add to existing internal package). +2. Edit `.goreleaser.yaml`: add `-X github.com//internal.Version={{.Version}}` to `builds[].ldflags`. If `ldflags` block absent, add it. +3. Verify `go build ./cmd/...` clean (no behavior change; binary keeps shipping "dev" default until Layer (b) lands). + +Per-repo gating: skip repos with no release in the last 90 days (self-challenge D3) — file separate "stale-repo evaluation" issue for those. Default: include the repo. + +### Layer (b): canonical sweep (54 repos, parallel) + +Same template as workflow#758 pilot (DO PR #165). Mechanical 6-file PR per repo: + +1. `git rm .github/workflows/sync-plugin-version.yml` +2. Edit main.go to call `sdk.ResolveBuildVersion()` + wire via `IaCServeOptions.BuildVersion` (IaC) or `sdk.WithBuildVersion` (non-IaC `sdk.Serve`). +3. `plugin.json.version` → `"0.0.0"`; `minEngineVersion` → `0.61.0`. +4. For repos with null/missing `capabilities`: run `wfctl plugin registry-sync --verify-capabilities --fix` against a local workflow-registry checkout to auto-populate from the binary's `GetContractRegistry` response. This is the deferred I3 fix from #758. +5. release.yml: add setup-wfctl + pre+post wfctl plugin validate-contract gates. +6. Bump workflow pin to v0.61.0 (or current latest). + +Fans out via parallel sub-agents post-Layer (c). + +## Assumptions + +A1. `wfctl` can be installed in workflow-registry's GitHub Action runner via `setup-wfctl@v1` (verified 2026-05-23; pilot Layer 3 used this). +A2. `plugin/external/manager.go`'s plugin-spawn machinery is reusable from a wfctl subcommand. The existing `wfctl plugin install` path exercises some of this; the new `--verify-capabilities` flow needs to spawn the binary in a host-managed lifecycle. **Per-plan verify** that the spawn API is exported and usable from outside the engine boot path. +A3. Plugin binaries' `GetContractRegistry` RPC reliably returns the same capabilities the plugin author intends to expose (no per-plugin runtime conditionals affecting capability enumeration). True for pilot 5; **Layer (b) verifies per-plugin** during the migration. +A4. Layer (c) ldflag addition is additive: binaries built without `-X` keep the `"dev"` default; binaries built with `-X` pick up the injected tag. Verified by Go's standard `-X` semantics. +A5. The 56 remaining repos all ship a buildable plugin binary in their release tarball that wfctl can spawn (i.e., goreleaser archive contains the binary alongside plugin.json). True for all goreleaser-managed plugins per audit. +A6. `gh repo rename` keeps GitHub URL redirects for old URL → new URL (verified per GitHub docs; redirects persist indefinitely unless a new repo claims the old name). +A7. GitHub's `template_repository: true` flag makes the repo selectable under the "Use this template" UI button; new repos created this way get a fresh git history seeded from the template's HEAD. Verified per GitHub feature docs. +A8. The single `sdk.ServeIaCPlugin` entrypoint (with `IaCServeOptions.{Modules, Steps, TypedModules, TypedSteps}` maps) supports BOTH module/step contracts AND IaC dispatch in one plugin — verified by reading workflow#758 cycle-3 SDK changes. Scaffold uses this single entrypoint. +A9. Layer (c) PR per repo doesn't break existing CI for repos that currently work without the ldflag (binary keeps building; "dev" default keeps shipping if the PR's release.yml isn't yet updated). True per A4. +A10. workflow-registry's `sync-registry-manifests.yml` runs on a schedule that allows a 1-week parity cycle between bash + Go (per Layer a' D2 mitigation). + +## Self-challenge — top 3 doubts + +D1. **Capability-verify chicken-and-egg.** Layer (b) PR lands → release.yml's pre-build gate runs `wfctl plugin validate-contract --for-publish` — but `--verify-capabilities` wants a built binary, which doesn't exist pre-tag. Mitigation: `--verify-capabilities` is a registry-sync flag (post-release), NOT a pre-build validate-contract flag. The pre-build validate-contract continues to do file-only checks; registry-sync (which runs after release publish) does the binary spawn. Documented in §3. + +D2. **bash → Go translation parity risk.** 228 lines of accumulated bash edge cases (URL normalization, `normalize_repo`, `downloads_match_version`, mismatch warnings, capability nested-vs-flat shape handling). Mitigation: Layer (a') runs bash + Go in parallel for one cron cycle and asserts zero diff before deleting bash. Bash kept in repo until parity confirmed. + +D3. **Layer (c) bootstrap may be wasted work for stale repos.** If some of the 56 repos are deprecated/abandoned, adding ldflag is churn. Mitigation: per-repo gate on "last release within 90 days OR explicit maintainer ack." Stale repos get skipped + filed for archival evaluation (separate issue). Default = include. + +## Rollback + +- Layer (a): single revert of wfctl subcommand addition. No state, no contract change. +- Layer (a'): revert the workflow YAML step swap; bash continues being authoritative (which it currently is). Bash never deleted until parity verified. +- Layer (d): GitHub repo rename is reversible via `gh repo rename` back to old name; URL redirects work in reverse. Template-repository flag is a settings toggle. Content reverts are git revert. +- Layer (c): per-repo git revert restores pre-ldflag goreleaser + drops the new `internal/version.go` file. Binary still builds. +- Layer (b): per-repo git revert restores sync workflow + reverts main.go + restores committed version. Same as workflow#758 rollback story. + +No state migrations, no breaking SDK contract changes (all additive), no cross-repo coordination required for rollback. + +## Out of scope (cross-linked) + +- **SemVer 2.0.0 prerelease support** — separate design; touches `ParseSemver` + `wfctl install` + registry. Tracked via workflow#762 reference; not in this design. +- **Gap-repos** (~8 plugin repos without release pipelines: agent, cms, compute, cloud-ui, data-protection, edge-compute, sandbox, waf) — separate per-repo "establish release pipeline" issues. +- **OCI catalog (`wfctl registry push/pull/login`)** — unrelated subcommand family; not touched. +- **Stale-repo archival decisions** for plugins with no release in 90+ days — filed as separate issues during Layer (c) per-repo audit. + +## Migration ordering + +1. **PR 1 (workflow)**: Layer (a) — `wfctl plugin registry-sync` subcommand + tests + shared regex extraction. +2. **PR 2 (workflow-registry)**: Layer (a') — add wfctl step alongside existing bash; parity-diff logging. +3. **PR 3+4 (scaffold-workflow-plugin + private)**: Layer (d) — rename + content modernization + `template_repository: true`. +4. **PR 5 (workflow-registry)**: delete `plugins/template/` entry. +5. **Wait 1 cron cycle** for Layer (a') parity verification. +6. **PR 6 (workflow-registry)**: delete `scripts/sync-versions.sh` + remove bash step from workflow YAML. +7. **PRs 7-N (54 repos)**: Layer (c) ldflag bootstrap — parallel sub-agent fan-out. +8. **PRs N+1-M (54 repos)**: Layer (b) canonical sweep — parallel sub-agent fan-out (after Layer c lands per-repo). +9. **PR final (workflow)**: retro doc. + +Layer (a) blocks (a'). Layer (a') blocks the bash-delete. Layer (d) is independent of (a)/(a') and can run in parallel. Layer (c) blocks (b) per-repo (PR-pairing per repo: c first, then b). + +## Adversarial cycles expected + +- Design cycle 1: probably fail on bash→Go parity surface (every edge case in `sync-versions.sh` becomes a fixture); design clarifications around plugin-spawn API usability from wfctl context (A2 verification); Layer (d)'s post-instantiation rename ritual completeness. +- Design cycle 2: revisions; likely pass. +- Plan cycle 1: granularity + per-repo skip-gate operationalization (D3); test fixture enumeration. From 642cef0da1be063899a477765a68ff9379fd3446 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 23 May 2026 20:07:37 -0400 Subject: [PATCH 2/7] =?UTF-8?q?docs(plan):=20cycle=201=20revisions=20?= =?UTF-8?q?=E2=80=94=20port=20all=203=20registry=20scripts;=20reuse=20inst?= =?UTF-8?q?all=20pipeline=20for=20verify-capabilities;=20scaffold=20has=20?= =?UTF-8?q?tested=20rename=20script=20+=20dual=20main.go;=20allowlist=20de?= =?UTF-8?q?fense=20(workflow#762)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-05-23-wfctl-registry-sync-design.md | 113 ++++++++++++++---- 1 file changed, 87 insertions(+), 26 deletions(-) diff --git a/docs/plans/2026-05-23-wfctl-registry-sync-design.md b/docs/plans/2026-05-23-wfctl-registry-sync-design.md index 0c41c8db..9ae79a80 100644 --- a/docs/plans/2026-05-23-wfctl-registry-sync-design.md +++ b/docs/plans/2026-05-23-wfctl-registry-sync-design.md @@ -16,24 +16,64 @@ Three coupled gaps surfaced after workflow#758 pilot landed: Single issue, four composable layers. Sequencing: (a) → (a') → (d) → (c) → (b). -### Layer (a): `wfctl plugin registry-sync` subcommand +### Layer (a): `wfctl plugin registry-sync` subcommand — full registry-sync port -New subcommand under existing `wfctl plugin` family (cycle 4-P1 naming rationale; avoids collision with OCI `wfctl registry`). +New subcommand under existing `wfctl plugin` family (avoids collision with OCI `wfctl registry`). Surface: ``` wfctl plugin registry-sync [--fix] [--plugin ] [--verify-capabilities] [--registry-dir ] +wfctl plugin registry-sync core [--fix] [--workflow-repo ] [--registry-dir ] +wfctl plugin registry-sync readme [--check] [--registry-dir ] ``` -- Default dry-run; `--fix` writes back. -- `--plugin ` filters to single plugin manifest. -- `--registry-dir` defaults to `.` (the cwd, typically a workflow-registry checkout in CI). -- `--verify-capabilities` (optional, registry-side only): downloads upstream release tarball; extracts plugin binary; spawns via `plugin/external/manager.go` machinery; calls `GetContractRegistry` RPC; diffs vs committed `plugin.json.capabilities`; with `--fix` auto-rewrites. +Three sub-modes covering ALL three current scripts (cycle 1 C1: `sync-versions.sh` is one of three scripts the same CI step runs; porting only one regresses registry coverage and breaks parity-diff isolation): -Implementation lives in `cmd/wfctl/plugin_registry_sync.go` + `_test.go`. Shared strict-semver regex extracted into `cmd/wfctl/plugin_release_grade_semver.go` (constant sourced by `validate-contract --for-publish` AND `registry-sync`). Logic ports `sync-versions.sh` 1:1 with fixture-backed parity tests. +1. **Default mode (ports `sync-versions.sh`):** walks `/plugins/*/manifest.json`; for each: + - Reads `repository`/`source`; derives `gh_repo` via `normalize_repo` equivalent. + - `gh release view` for latest tag. + - **Strict-semver gate** (shared regex constant — see below). + - Compares against committed `manifest.version`; with `--fix` rewrites version + downloads URLs. + - `gh api .../contents/plugin.json?ref=` to sync `capabilities + minEngineVersion + iacProvider`. + - **NEW (--verify-capabilities)**: registry-side only, NOT per-PR. See "Capability verification flow" below. +2. **`core` mode (ports `sync-core-manifests.sh`):** runs against a workflow checkout (`--workflow-repo`); compiles + runs the inspect program; diffs against registry's `plugins//manifest.json`; with `--fix` rewrites. +3. **`readme` mode (ports `generate-readme.sh`):** regenerates README plugin/template indexes from registry source data; `--check` is dry-run. -`workflow-registry/.github/workflows/sync-registry-manifests.yml` swaps `bash scripts/sync-versions.sh --fix` for `wfctl plugin registry-sync --fix`. Bash script kept alongside for **one parity-verification cycle**, then deleted in a follow-up PR. +Implementation files: +- `cmd/wfctl/plugin_registry_sync.go` — root subcommand dispatch + default-mode logic +- `cmd/wfctl/plugin_registry_sync_core.go` — `core` mode (inspect-program embed + workflow-repo build) +- `cmd/wfctl/plugin_registry_sync_readme.go` — `readme` mode (README mutate) +- `cmd/wfctl/plugin_registry_sync_test.go` — table-driven fixtures +- `cmd/wfctl/testdata/plugin_registry_sync/{good,stale-version,stale-caps,non-semver-tag,empty-assets,fetch-plugin-json-missing,prerelease-tag-vs-stable,...}/` — fixtures pinned against current bash behavior + +Shared strict-semver regex extracted into `cmd/wfctl/plugin_release_grade_semver.go` (constant sourced by `validate-contract --for-publish` AND `registry-sync`). + +### Layer (a'): workflow-registry parity cycle + +PR in `workflow-registry`: + +1. Add `wfctl plugin registry-sync` (and `core` + `readme` sub-modes) calls to `sync-registry-manifests.yml` running **in DRY-RUN MODE alongside the existing bash** (no `--fix`). +2. Both bash + Go write their proposed manifest diffs to workflow artifacts. +3. CI job compares the two artifacts; non-zero diff fails the workflow. +4. The actual registry mutations continue coming from the bash scripts during the parity window. **Bash remains authoritative; Go is observation-only.** +5. After one weekly cron cycle (or operator-triggered manual cycle) confirms zero diff for `sync-versions.sh` + `sync-core-manifests.sh` + `generate-readme.sh`, ship the **followup PR** that: + - Swaps `--fix` mode from bash to Go for all three. + - Deletes the three bash scripts. + +This addresses cycle 1 C1 (parity must cover all three scripts) + D2 (translation risk) + I6 (bash gh-api retains the authoritative path during the window so any rename redirects can't break it). + +### Capability verification flow (--verify-capabilities) + +**Per cycle 1 C3:** the existing `wfctl plugin install --local ` pipeline already handles binary rename (`ensurePluginBinary`) + lockfile/integrity checks. Rather than spawning via raw `manager.go` machinery, `--verify-capabilities` reuses the install path: + +1. `gh release download --repo --pattern '--.tar.gz' -O /tmp/.tar.gz` +2. Extract to `/tmp/-extracted/` +3. `wfctl plugin install --local /tmp/-extracted/ --plugin-dir /tmp/-installed/` (existing pipeline; handles rename + lockfile + integrity) +4. Use the installed plugin's spawn path (also already exists for `wfctl plugin info`) to call `GetContractRegistry` RPC +5. Diff against committed `plugin.json.capabilities`; with `--fix` rewrite + +**Per cycle 1 I4:** `--verify-capabilities` is registry-side only (runs on the periodic cron). Layer (b) per-PR migrations do NOT use this flag — capabilities are auto-populated on the next cron sync after the release lands. Documented in §Layer (b). ### Layer (a'): workflow-registry switch + parity cycle @@ -49,28 +89,34 @@ This belts-and-suspenders pattern addresses self-challenge doubt D2 (bash → Go **Renames:** - `workflow-plugin-template` → `scaffold-workflow-plugin` (public; prefix-first naming so it doesn't look like a plugin family member; per user) -- `workflow-plugin-template-private` → `scaffold-workflow-plugin-private` (private; suffix `-private` keeps both scaffolds alphabetically adjacent in org browse vs `private-scaffold-workflow-plugin`) +- `workflow-plugin-template-private` → `scaffold-workflow-plugin-private` (private; suffix `-private` keeps both scaffolds alphabetically adjacent in org browse — see I7 mitigation below) + +**Cycle 1 fixes baked in:** +- **C5 + I1** (parallel scaffolding mechanism drift + unsafe sed ritual): the scaffold ships `scripts/rename-from-scaffold.sh` (TESTED in scaffold CI: runs `bash scripts/rename-from-scaffold.sh test-plugin` against a tmp copy of itself + `go build ./...` to assert the rename produces a buildable plugin). README points to the script. `wfctl plugin init` is REPLACED by `wfctl plugin init --from-scaffold [scaffold-workflow-plugin|scaffold-workflow-plugin-private]` which clones the scaffold + runs the rename script + git-init. Existing `sdk.NewTemplateGenerator` deprecated + removed in the same workflow PR series. +- **I8** (ServeIaCPlugin requires IaC surface): scaffold ships TWO main.go files in `cmd/`: + - `cmd/scaffold-workflow-plugin/main.go` — uses `sdk.Serve` + `sdk.WithBuildVersion` (non-IaC default). + - `cmd/scaffold-workflow-plugin-iac/main.go` — uses `sdk.ServeIaCPlugin` + `IaCServeOptions.BuildVersion` + stub `IaCProviderRequiredServer` implementation. + - The rename script takes a `--mode iac|non-iac` flag (default non-iac) and deletes the other main.go before renaming. +- **I7** (`-private` ambiguity): README on the private scaffold opens with a paragraph: "This repo's `-private` suffix refers to its GitHub repo visibility (only org members can clone). It is NOT related to `plugin.json.private: true` semantics (which control marketplace listing). A plugin instantiated from this scaffold can choose either repo visibility independently." **Per-repo steps (one PR per scaffold):** -1. `gh repo rename` (GitHub keeps old-URL redirect for 1 year+). -2. GitHub repo settings: enable `template_repository: true` (makes the repo selectable under "Use this template" dropdown when creating a new repo). +1. `gh repo rename` (GitHub keeps old-URL redirect indefinitely unless a new repo claims the old name). +2. GitHub repo settings: enable `template_repository: true`. **I2 mitigation:** README on both scaffolds opens with a section "After creating a new repo from this template: enable GitHub Actions under Settings → Actions → 'I understand my workflows, enable them' before tagging your first release." 3. Content updates (single PR per scaffold): - - `plugin.json`: `name`: `scaffold-workflow-plugin` (the scaffold itself); `version`: `"0.0.0"`; `minEngineVersion`: `0.61.0`; capabilities populated with placeholder shape (`moduleTypes: ["TEMPLATE.module"]`, `stepTypes: ["TEMPLATE.step"]`, `triggerTypes: []`, `iacProvider: {resourceTypes: ["TEMPLATE.resource"]}`) — shows the expected shape so instantiators see what to fill. - - `cmd/workflow-plugin-TEMPLATE/main.go` → rename to `cmd/scaffold-workflow-plugin/main.go`. The README explicitly instructs instantiators to rename this dir to `cmd/workflow-plugin-/` immediately after instantiation. - - main.go uses `sdk.ServeIaCPlugin(srv, sdk.IaCServeOptions{BuildVersion: sdk.ResolveBuildVersion(internal.Version)})` — covers BOTH module/step (`IaCServeOptions.Modules + Steps`) AND IaC dispatch in a single entrypoint. Plugins that don't need IaC pass empty maps; plugins that don't need modules/steps leave them unset. One canonical entrypoint per user direction. + - `plugin.json`: `name`: `scaffold-workflow-plugin` (or `-private`); `version`: `"0.0.0"`; `minEngineVersion`: `0.61.0`; capabilities populated with placeholder shape (`moduleTypes: ["TEMPLATE.module"]`, `stepTypes: ["TEMPLATE.step"]`, `triggerTypes: []`, `iacProvider: {resourceTypes: ["TEMPLATE.resource"]}`) — shows expected shape. + - Two main.go files per I8 above. - `internal/version.go`: `var Version = "dev"`. - `.goreleaser.yaml`: `-X github.com/GoCodeAlone/scaffold-workflow-plugin/internal.Version={{.Version}}` ldflag. - `.github/workflows/release.yml`: setup-wfctl@v1 + pre-build + post-build `wfctl plugin validate-contract` gates. + - `.github/workflows/scaffold-rename-test.yml` (NEW): scaffold CI runs `bash scripts/rename-from-scaffold.sh testplugin --mode iac` and `--mode non-iac` against tmp copies; verifies `go build ./...` clean in both. Catches C5 silent-corruption regressions. - **No** `sync-plugin-version.yml` (defunct workflow not shipped in new scaffolds). - - `README.md`: documents the post-instantiation ritual: - - Rename `cmd/scaffold-workflow-plugin/` → `cmd/workflow-plugin-/` - - Edit `plugin.json` (name, description, capabilities, minEngineVersion) - - `go mod edit -module github.com//workflow-plugin-`; `find . -name '*.go' -exec sed -i.bak 's|scaffold-workflow-plugin|workflow-plugin-|g' {} \;` - - First `git commit` + first tag -4. Delete `workflow-registry/plugins/template/` (scaffold is not an installable plugin); commit in workflow-registry PR. -5. Add registry-side defense: `wfctl plugin registry-sync` emits a `WARN` if it encounters a registered manifest whose `repository` field points at `*-scaffold-*` or contains `scaffold-workflow-plugin` (catches accidental re-registration). + - `scripts/rename-from-scaffold.sh`: TESTED rename script — enumerates every file containing `scaffold-workflow-plugin`; renames `cmd/scaffold-workflow-plugin/` → `cmd/workflow-plugin-/`; `go mod edit`; `sed` (bounded to specific file globs, not `find . -name '*.go'`); `git add` + commit-ready state. + - `README.md`: documents "Use this template" flow → enable Actions → run `bash scripts/rename-from-scaffold.sh --mode {iac|non-iac}` → edit plugin.json capabilities → first commit + tag. References `wfctl plugin init --from-scaffold` as the alternative path. +4. Delete `workflow-registry/plugins/template/` (scaffold is not an installable plugin); commit in workflow-registry PR. **I3 mitigation:** the workflow-registry PR body explicitly states "operators with `template` in their `.wfctl-lock.yaml` must remove it; the entry was a non-functional stub." +5. Add registry-side defense: `wfctl plugin registry-sync` rejects (not just warns) any manifest with `repository` field in the exact-allowlist `{"https://github.com/GoCodeAlone/scaffold-workflow-plugin", "https://github.com/GoCodeAlone/scaffold-workflow-plugin-private"}` — cycle 1 C4 fix (allowlist not regex). Plugins legitimately containing "scaffold" in their name (e.g., `workflow-plugin-scaffold-tool`) pass through unchanged. 6. Update workflow#760 sweep list: drop `workflow-plugin-template` + `workflow-plugin-template-private`. 56 → 54. +7. **I6 verification task:** Layer (a') parity-cycle window confirms bash + Go both handle the (now-renamed) scaffold URLs correctly. If `gh api repos//contents/...` fails to redirect, the bash script's `fetch_plugin_json` returns empty string and silently falls back (which is the current behavior for any missing-plugin-json case). Go port replicates this fallback per cycle 1 C2 fix below. ### Layer (c): ldflag + Version var bootstrap (54 repos) @@ -89,11 +135,11 @@ Same template as workflow#758 pilot (DO PR #165). Mechanical 6-file PR per repo: 1. `git rm .github/workflows/sync-plugin-version.yml` 2. Edit main.go to call `sdk.ResolveBuildVersion()` + wire via `IaCServeOptions.BuildVersion` (IaC) or `sdk.WithBuildVersion` (non-IaC `sdk.Serve`). 3. `plugin.json.version` → `"0.0.0"`; `minEngineVersion` → `0.61.0`. -4. For repos with null/missing `capabilities`: run `wfctl plugin registry-sync --verify-capabilities --fix` against a local workflow-registry checkout to auto-populate from the binary's `GetContractRegistry` response. This is the deferred I3 fix from #758. +4. For repos with null/missing `capabilities`: **populated by the agent during the PR via local-build introspection** (NOT via `--verify-capabilities` against a released binary, which has the cycle 1 D1/I4 chicken-and-egg). Specifically the agent runs `GOWORK=off go build -o /tmp/ ./cmd/...` against the WIP migration locally, then exec's the binary + GetContractRegistry RPC to populate capabilities. **Per cycle 1 I4 + I3 (deferred from #758)**: registry-side cron `wfctl plugin registry-sync --verify-capabilities --fix` is the ongoing safety net for capability drift detection AFTER releases land; the per-PR populate is a one-time bootstrap. 5. release.yml: add setup-wfctl + pre+post wfctl plugin validate-contract gates. 6. Bump workflow pin to v0.61.0 (or current latest). -Fans out via parallel sub-agents post-Layer (c). +Fans out via parallel sub-agents post-Layer (c). **Per cycle 1 I5:** the lead agent pre-computes the (repo, last-release-date) list via `gh api repos/GoCodeAlone//releases?per_page=1` BEFORE fan-out and passes the pre-computed skip list (repos with no release in 90 days) to each sub-agent. No per-agent rate-limit cost. ## Assumptions @@ -147,8 +193,23 @@ No state migrations, no breaking SDK contract changes (all additive), no cross-r Layer (a) blocks (a'). Layer (a') blocks the bash-delete. Layer (d) is independent of (a)/(a') and can run in parallel. Layer (c) blocks (b) per-repo (PR-pairing per repo: c first, then b). +## Cycle 1 — addressed + +- **C1 (sync-core-manifests + generate-readme ignored)**: addressed — Layer (a) now ports all three scripts via `wfctl plugin registry-sync` + `core` + `readme` sub-modes; Layer (a') parity-cycle covers all three with bash-authoritative + Go-observation in dry-run. +- **C2 (bash → Go parity edge cases)**: addressed — fixture set explicitly enumerated (empty-assets, fetch-plugin-json-missing, prerelease-tag-vs-stable, sort-V-vs-semver); Go implementation must replicate bash output byte-for-byte during the parity window; `version_gt` uses the same `sort -V` semantics as bash for the comparator (sub-optimal vs true semver but parity-correct) — a separate follow-up can swap to semver-correct after parity is established. +- **C3 (plugin-spawn binary-rename + lockfile contract)**: addressed — `--verify-capabilities` reuses the `wfctl plugin install --local` pipeline (which already handles binary rename + lockfile + integrity); does NOT spawn via raw `manager.go`. +- **C4 (regex over-match for scaffold defense)**: addressed — exact-URL allowlist for two specific repos, not regex/substring. +- **C5 (sed ritual unsafe + incomplete)**: addressed — scaffold ships TESTED `scripts/rename-from-scaffold.sh` (CI runs against tmp copies); rename is now a single command, not a sed-in-README. `wfctl plugin init --from-scaffold` clones + runs the script as the canonical scaffolding path (replaces `sdk.NewTemplateGenerator`). +- **I1 (parallel scaffolding mechanism drift)**: addressed via C5 — `wfctl plugin init` is reworked to consume the scaffold repo, eliminating the parallel mechanism. +- **I2 (template_repository workflow-enablement gotcha)**: addressed — README on both scaffolds documents the post-instantiation "enable Actions" step. +- **I3 (`template` lockfile pin break)**: addressed — workflow-registry PR body explicitly states the break + provides mitigation. +- **I4 (per-PR capability auto-populate chicken-and-egg)**: addressed — per-PR uses local-build introspection (build WIP main.go locally + spawn binary + GetContractRegistry); registry-side `--verify-capabilities` is the ongoing safety net for drift, not per-PR. +- **I5 (90-day stale gate operationalization)**: addressed — lead agent pre-computes skip list via single `gh api` batch; sub-agents receive pre-computed list. +- **I6 (gh repo rename + bash gh api redirect)**: addressed — bash remains authoritative during parity window; rename happens AFTER parity-cycle PR ships. Go port replicates the fetch_plugin_json silent-fallback so rename-redirect failures are tolerated (current bash behavior). +- **I7 (`-private` suffix ambiguity)**: addressed — README on private scaffold opens with explicit clarification. +- **I8 (`ServeIaCPlugin` requires IaC surface — single-entrypoint claim was wrong)**: addressed — scaffold ships TWO main.go files (IaC + non-IaC); rename script picks one via `--mode` flag. + ## Adversarial cycles expected -- Design cycle 1: probably fail on bash→Go parity surface (every edge case in `sync-versions.sh` becomes a fixture); design clarifications around plugin-spawn API usability from wfctl context (A2 verification); Layer (d)'s post-instantiation rename ritual completeness. -- Design cycle 2: revisions; likely pass. -- Plan cycle 1: granularity + per-repo skip-gate operationalization (D3); test fixture enumeration. +- Cycle 2: likely pass; outstanding risks are around fixture exhaustiveness (Layer a) and the precise shape of the post-instantiation script (Layer d). +- Plan cycle 1: granularity, per-repo skip-gate operationalization (already addressed via lead-agent pre-compute), test fixture enumeration explicit per task. From 1c4829129b576e661b62c235cf1692aa60e3ecd1 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 23 May 2026 20:09:13 -0400 Subject: [PATCH 3/7] docs(plan): delete duplicate Layer (a') block per cycle 2 NC1 (workflow#762) --- docs/plans/2026-05-23-wfctl-registry-sync-design.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/docs/plans/2026-05-23-wfctl-registry-sync-design.md b/docs/plans/2026-05-23-wfctl-registry-sync-design.md index 9ae79a80..c12ef57a 100644 --- a/docs/plans/2026-05-23-wfctl-registry-sync-design.md +++ b/docs/plans/2026-05-23-wfctl-registry-sync-design.md @@ -75,16 +75,6 @@ This addresses cycle 1 C1 (parity must cover all three scripts) + D2 (translatio **Per cycle 1 I4:** `--verify-capabilities` is registry-side only (runs on the periodic cron). Layer (b) per-PR migrations do NOT use this flag — capabilities are auto-populated on the next cron sync after the release lands. Documented in §Layer (b). -### Layer (a'): workflow-registry switch + parity cycle - -PR in `workflow-registry`: - -1. Add `wfctl plugin registry-sync --fix` to `sync-registry-manifests.yml` as a NEW step running AFTER the existing `bash scripts/sync-versions.sh --fix`. -2. In dry-run mode for both, log the diff between bash and Go outputs into the workflow artifact. -3. After one weekly cron cycle confirms zero output diff, ship the **followup PR** that deletes `sync-versions.sh` + removes the bash step. - -This belts-and-suspenders pattern addresses self-challenge doubt D2 (bash → Go translation parity risk). - ### Layer (d): template-repo modernization **Renames:** From f00e2c08bd4bba05f51b1f5b054a9156886889ff Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 23 May 2026 20:12:32 -0400 Subject: [PATCH 4/7] docs(plan): implementation plan for workflow#762 (6 PRs: wfctl registry-sync + parity + 2 scaffold renames + registry template delete + sweep issue update) --- docs/plans/2026-05-23-wfctl-registry-sync.md | 724 +++++++++++++++++++ 1 file changed, 724 insertions(+) create mode 100644 docs/plans/2026-05-23-wfctl-registry-sync.md diff --git a/docs/plans/2026-05-23-wfctl-registry-sync.md b/docs/plans/2026-05-23-wfctl-registry-sync.md new file mode 100644 index 00000000..6f1e2670 --- /dev/null +++ b/docs/plans/2026-05-23-wfctl-registry-sync.md @@ -0,0 +1,724 @@ +# wfctl Plugin Registry-Sync + Template Modernization Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Per workflow#762: replace `workflow-registry/scripts/sync-versions.sh` + `sync-core-manifests.sh` + `generate-readme.sh` with a `wfctl plugin registry-sync` subcommand (collapses 3 bash scripts → 1 Go entrypoint; single regex source-of-truth shared with `validate-contract`); rename scaffold repos (`workflow-plugin-template` → `scaffold-workflow-plugin`; same for private) so they aren't treated as plugins; bake workflow#758 compliance into the scaffold content; delete the now-stale registry entry. Layer 3b/c per-repo sweep (54 repos) is filed as follow-up for separate execution wave. + +**Architecture:** New wfctl subcommand `wfctl plugin registry-sync` with `core` + `readme` sub-modes. Initial workflow-registry CI runs Go in dry-run alongside authoritative bash for one parity cycle; followup PR deletes bash + swaps `--fix` ownership to Go. Scaffold rename + content modernization happens in parallel with the parity cycle. Layer 3b/c (54-repo sweep) deferred to follow-up issue. + +**Tech Stack:** Go 1.26; existing `wfctl plugin` subcommand framework; reuses `wfctl plugin install --local` pipeline for capability verification; bash for the rename script shipped in the scaffold repo. + +**Base branch:** main (per repo) + +--- + +## Scope Manifest + +**PR Count:** 6 +**Tasks:** 6 +**Estimated Lines of Change:** ~2000 across all PRs + +**Out of scope:** +- Layer (a'') — bash deletion + Go `--fix` swap in workflow-registry. Lands as a separate PR AFTER the Layer (a') parity-cycle gates the swap. Tracked in workflow#762. +- Layer 3b sweep (54-repo `Layer (c)` ldflag bootstrap + `Layer (b)` canonical migration). Fans out via parallel sub-agents in a separate execution wave; tracked at workflow#760 (which is updated by Task 6 of this plan to drop the 2 template repos). +- Stale-repo audit (repos with no release in 90+ days) — filed as part of Layer 3b prep when that wave launches. +- SemVer 2.0.0 prerelease support — separate design (touches ParseSemver + wfctl install + registry). +- OCI catalog (`wfctl registry push/pull/login`) — unrelated subcommand family. +- Gap-repos (~8 plugins without release pipelines) — separate per-repo issues. +- Retro doc — filed after Layer (a'') + Layer 3b complete. + +**PR Grouping:** + +| PR # | Title | Tasks | Branch | Repo | +|------|-------|-------|--------|------| +| 1 | feat(wfctl): plugin registry-sync subcommand + core + readme sub-modes + shared semver regex (#762 Layer a) | Task 1 | feat/762-registry-sync | workflow | +| 2 | ci(registry): add wfctl plugin registry-sync dry-run alongside bash for parity cycle (#762 Layer a') | Task 2 | feat/762-registry-sync-parity | workflow-registry | +| 3 | feat(scaffold): rename workflow-plugin-template → scaffold-workflow-plugin + modernize content (#762 Layer d.1) | Task 3 | feat/762-scaffold-modernize | scaffold-workflow-plugin (post-rename) | +| 4 | feat(scaffold): rename workflow-plugin-template-private → scaffold-workflow-plugin-private + modernize content (#762 Layer d.2) | Task 4 | feat/762-scaffold-modernize | scaffold-workflow-plugin-private (post-rename) | +| 5 | chore(registry): delete plugins/template/ — superseded by scaffold-workflow-plugin (#762 Layer d.3) | Task 5 | chore/762-delete-template | workflow-registry | +| 6 | chore(issue): update workflow#760 sweep list — drop scaffold repos (#762 Layer d.4) | Task 6 | n/a (issue edit) | workflow (issue) | + +**Status:** Draft + +--- + +### Task 1: `wfctl plugin registry-sync` subcommand (workflow, single PR) + +**Files in workflow repo:** +- Create: `cmd/wfctl/plugin_registry_sync.go` (root subcommand + default mode = port of sync-versions.sh) +- Create: `cmd/wfctl/plugin_registry_sync_core.go` (core mode = port of sync-core-manifests.sh) +- Create: `cmd/wfctl/plugin_registry_sync_readme.go` (readme mode = port of generate-readme.sh) +- Create: `cmd/wfctl/plugin_registry_sync_test.go` (table-driven tests for all 3 modes) +- Create: `cmd/wfctl/plugin_release_grade_semver.go` (shared regex constant) +- Create: `cmd/wfctl/testdata/plugin_registry_sync/{good,stale-version,stale-caps,non-semver-tag,empty-assets,fetch-plugin-json-missing,prerelease-vs-stable,...}/` +- Modify: `cmd/wfctl/plugin.go` (register subcommand) +- Modify: `cmd/wfctl/plugin_validate_contract.go` (replace local strict-semver regex with shared constant) +- Modify: `docs/PLUGIN_RELEASE_GATES.md` (document the new subcommand under "Registry sync") + +**Step 1: Extract shared semver regex** + +`cmd/wfctl/plugin_release_grade_semver.go`: + +```go +package main + +import "regexp" + +// PublishGradeSemverRe matches strict release-grade semver tags (flat M.m.p, +// no prerelease, no build metadata). Engine ParseSemver requires this shape. +// Shared by: +// - wfctl plugin validate-contract --for-publish (operator-side gate) +// - wfctl plugin registry-sync (registry-side gate) +// workflow#762: single source-of-truth. +var PublishGradeSemverRe = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`) +``` + +In `cmd/wfctl/plugin_validate_contract.go`: delete local `publishGradeSemverRe` declaration; references use `PublishGradeSemverRe` from the new file. + +**Step 2: Add subcommand dispatch** + +Edit `cmd/wfctl/plugin.go`: + +```go +case "validate-contract": + return runPluginValidateContract(args[1:]) +case "registry-sync": + return runPluginRegistrySync(args[1:]) +``` + +Update `pluginUsage()`: add `registry-sync` row. + +**Step 3: Default mode (sync-versions.sh port)** + +`cmd/wfctl/plugin_registry_sync.go`: implements `runPluginRegistrySync(args []string) error`. Flags: `--fix`, `--plugin `, `--verify-capabilities`, `--registry-dir `. Default `--registry-dir` is `.`. + +Pseudo: + +```go +func runPluginRegistrySync(args []string) error { + fs := flag.NewFlagSet("plugin registry-sync", flag.ContinueOnError) + fix := fs.Bool("fix", false, "Apply changes (default: dry-run)") + pluginFilter := fs.String("plugin", "", "Restrict to single plugin directory name") + verifyCaps := fs.Bool("verify-capabilities", false, "Spawn binary + diff capabilities (registry-side; slow)") + registryDir := fs.String("registry-dir", ".", "Path to a workflow-registry checkout") + if len(args) > 0 && (args[0] == "core" || args[0] == "readme") { + switch args[0] { + case "core": + return runPluginRegistrySyncCore(args[1:]) + case "readme": + return runPluginRegistrySyncReadme(args[1:]) + } + } + if err := fs.Parse(args); err != nil { return err } + return syncDefault(*registryDir, *fix, *pluginFilter, *verifyCaps) +} +``` + +`syncDefault` walks `/plugins/*/manifest.json`. For each: +1. Parse `repository`/`source`; derive `gh_repo` via `normalizeRepo()` (port `sync-versions.sh:36-44`). +2. `gh release view --repo --json tagName -q '.tagName'` → `latestTag`. Empty → SKIP. +3. **Strict-semver gate** (`PublishGradeSemverRe`). Non-match → `REJECT plugin_name — tag X is not release-grade semver`, continue. +4. `latestVersion = strings.TrimPrefix(latestTag, "v")`. +5. `downloadsMatchVersion(manifest, manifestVersion)` per `sync-versions.sh:46-58` — JQ filter ported to typed Go. +6. Compute `targetVersion` (manifest if downloads match + release exists; else latest). +7. If `--fix` AND (versions differ OR downloads stale): rewrite registry's `manifest.json` with new `.version` + `.downloads` (ports `sync-versions.sh:175-211`). +8. **Capability + minEngineVersion + iacProvider sync** via `fetch_plugin_json` port: `gh api repos//contents/plugin.json?ref= --jq '.content' | base64 -d`. Empty output → silent fallback (preserve current behavior per cycle 1 C2). +9. If `--verify-capabilities` (registry-side only): see Step 7 below. +10. Print summary (matching bash output format byte-for-byte for parity). + +**Implementation detail (cycle 1 C2 fixture pins):** include fixtures for empty-assets short-circuit, fetch-plugin-json-missing silent-fallback, prerelease-vs-stable comparator (using `sort -V` semantics for parity; semver-correct deferred). + +**Step 4: Core mode (sync-core-manifests.sh port)** + +`cmd/wfctl/plugin_registry_sync_core.go`: implements `runPluginRegistrySyncCore(args []string) error`. Flags: `--fix`, `--workflow-repo `, `--registry-dir `. + +Embeds the inspect program (currently in `sync-core-manifests.sh:39-89` as bash heredoc) via Go `embed.FS`. At runtime: + +1. Resolve `` path; verify `go.mod` present. +2. Write embedded inspect.go to a tmpdir inside ``. +3. `cd && GOWORK=off go run .//inspect.go` → JSON of core plugins. +4. Cleanup tmpdir. +5. Parse JSON; for each core plugin, compare against registry's `plugins//manifest.json`; with `--fix` rewrite. +6. Output matches bash format for parity. + +**Step 5: Readme mode (generate-readme.sh port)** + +`cmd/wfctl/plugin_registry_sync_readme.go`: implements `runPluginRegistrySyncReadme(args []string) error`. Flags: `--check`, `--registry-dir `. + +Reads `plugins/*/manifest.json` + `templates/*/template.json` (whatever the bash reads); regenerates the plugin/template indexes in README.md between marker comments. `--check` is dry-run + exit non-zero on diff. + +**Step 6: Tests (table-driven, fixture-backed)** + +`cmd/wfctl/plugin_registry_sync_test.go`: per mode, table of fixtures + expected output. Critical fixtures: +- `good`: tag matches manifest; no-op. +- `stale-version`: manifest is older than latest tag; `--fix` rewrites. +- `stale-caps`: committed plugin.json at tag has newer caps than registry manifest; `--fix` syncs. +- `non-semver-tag`: REJECT line + skip. +- `empty-assets`: latest tag has no platform release assets; SKIP without rewriting. +- `fetch-plugin-json-missing`: `gh api contents/plugin.json` returns empty; silent fallback preserves existing caps. +- `prerelease-vs-stable`: `sort -V` semantics preserved (matches bash). + +For `gh` API calls: use a test-injected interface or `httptest`-backed fake. + +**Step 7: --verify-capabilities (reuses install pipeline per C3 fix)** + +When `--verify-capabilities` set, for each plugin: + +1. `gh release download --repo --pattern '--.tar.gz' -O /tmp/-.tar.gz` +2. Extract to `/tmp/--extracted/` +3. Invoke existing `runPluginInstall` programmatically with `--local /tmp/--extracted/ --plugin-dir /tmp/--installed/` (avoids re-implementing rename + lockfile + integrity). +4. Spawn the installed plugin via existing `wfctl plugin info`-style code path; call `GetContractRegistry` RPC. +5. Diff RPC response vs `/plugins//manifest.json.capabilities`. If `--fix`, rewrite the registry manifest. +6. Cleanup temp dirs. + +If existing wfctl APIs aren't exported in a way that supports invocation from `registry-sync`, **bail with a `TODO: refactor needed`** rather than reimplementing — file as a sub-task on the PR. + +**Step 8: Update docs/PLUGIN_RELEASE_GATES.md** + +Add a "Registry sync" section: documents `wfctl plugin registry-sync` (default + `core` + `readme` modes), `--verify-capabilities`, the parity-cycle migration plan, and links to workflow#762. + +**Step 9: Verify** + +```bash +cd /Users/jon/workspace/_worktrees/wf-762-design +GOWORK=off go build -o /tmp/wfctl-762 ./cmd/wfctl +GOWORK=off go test ./cmd/wfctl/ -run 'TestPluginRegistrySync|TestPluginValidateContract' -count=1 -race +# Smoke against an actual workflow-registry checkout (dry-run, no --fix): +/tmp/wfctl-762 plugin registry-sync --registry-dir /Users/jon/workspace/workflow-registry --plugin digitalocean +``` +Expected: tests green; smoke OK matches bash output for the same plugin. + +**Step 10: Commit + push + PR + monitor + admin-merge** + +Standard pattern. Tag workflow `v0.62.0` after merge (Layer (a') depends on this tag). + +--- + +### Task 2: workflow-registry parity cycle (workflow-registry, single PR) + +**Files in workflow-registry:** +- Modify: `.github/workflows/sync-registry-manifests.yml` +- Create: `.github/workflows/scripts/parity-diff.sh` (compare bash vs Go outputs) + +**Step 1: Add Go dry-run step alongside bash** + +Edit `sync-registry-manifests.yml` to add (BEFORE the `--fix` bash step): + +```yaml +- uses: GoCodeAlone/setup-wfctl@v1 + with: + version: v0.62.0 +- name: Registry-sync dry-run (Go, observation-only) + run: | + wfctl plugin registry-sync --registry-dir . > /tmp/go-sync-versions.txt + WORKFLOW_REPO="$GITHUB_WORKSPACE/_workflow" wfctl plugin registry-sync core --registry-dir . --workflow-repo "$GITHUB_WORKSPACE/_workflow" > /tmp/go-sync-core.txt + wfctl plugin registry-sync readme --registry-dir . --check > /tmp/go-sync-readme.txt || true +- name: Registry-sync dry-run (bash, observation-only — current authoritative) + run: | + scripts/sync-versions.sh > /tmp/bash-sync-versions.txt + WORKFLOW_REPO="$GITHUB_WORKSPACE/_workflow" scripts/sync-core-manifests.sh > /tmp/bash-sync-core.txt + scripts/generate-readme.sh --check > /tmp/bash-sync-readme.txt || true +- name: Compare bash vs Go parity + run: | + bash .github/workflows/scripts/parity-diff.sh /tmp/bash-sync-versions.txt /tmp/go-sync-versions.txt versions + bash .github/workflows/scripts/parity-diff.sh /tmp/bash-sync-core.txt /tmp/go-sync-core.txt core + bash .github/workflows/scripts/parity-diff.sh /tmp/bash-sync-readme.txt /tmp/go-sync-readme.txt readme +- name: Upload parity artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: parity-cycle-${{ github.run_id }} + path: /tmp/*-sync-*.txt +``` + +The EXISTING `--fix` bash steps stay UNCHANGED. Bash remains authoritative; Go is observation-only. Parity-diff script fails the workflow on any non-zero diff. + +**Step 2: Create parity-diff.sh** + +`.github/workflows/scripts/parity-diff.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail +bash_out="$1" +go_out="$2" +label="$3" + +# Normalize: strip ANSI colors, trim trailing whitespace per line. +sed -E 's/\x1b\[[0-9;]*[mK]//g; s/[[:space:]]+$//' "$bash_out" | sort > "$bash_out.norm" +sed -E 's/\x1b\[[0-9;]*[mK]//g; s/[[:space:]]+$//' "$go_out" | sort > "$go_out.norm" + +if ! diff -u "$bash_out.norm" "$go_out.norm"; then + echo "::error::Parity diff for $label between bash + Go outputs. Bash remains authoritative; investigate Go port." + exit 1 +fi +echo "Parity OK for $label" +``` + +**Step 3: Local verify (limited; full sync needs gh auth)** + +Local dry-run against a workflow-registry checkout (use existing bash for reference): + +```bash +cd /Users/jon/workspace/workflow-registry +bash scripts/sync-versions.sh > /tmp/bash.txt +/tmp/wfctl-762 plugin registry-sync --registry-dir . > /tmp/go.txt +bash .github/workflows/scripts/parity-diff.sh /tmp/bash.txt /tmp/go.txt versions +``` +Expected: parity OK or a small fixable diff. + +**Step 4: Commit + push + PR + monitor + admin-merge** + +``` +gh pr create --title "ci(registry): add wfctl plugin registry-sync dry-run alongside bash (#762 Layer a')" +gh pr checks --watch +gh pr merge --squash --admin --delete-branch +``` + +**Rollback:** revert PR. Bash continues to be authoritative; Go dry-run + parity-diff removed. + +--- + +### Task 3: scaffold-workflow-plugin rename + modernize (public scaffold) + +**Pre-flight:** Layer (a) merged + workflow v0.62.0 tagged. + +**Pre-step (org admin):** + +```bash +gh repo rename workflow-plugin-template --repo GoCodeAlone/workflow-plugin-template scaffold-workflow-plugin +gh api -X PATCH /repos/GoCodeAlone/scaffold-workflow-plugin -f is_template=true +``` + +(Or use the GitHub UI: Settings → "Template repository" toggle.) + +**Files in scaffold-workflow-plugin (post-rename):** +- Rename: `cmd/workflow-plugin-TEMPLATE/` → `cmd/scaffold-workflow-plugin/` +- Create: `cmd/scaffold-workflow-plugin-iac/main.go` (IaC variant) +- Create: `internal/version.go` +- Create: `scripts/rename-from-scaffold.sh` +- Create: `.github/workflows/scaffold-rename-test.yml` +- Modify: `cmd/scaffold-workflow-plugin/main.go` (non-IaC default) +- Modify: `plugin.json` +- Modify: `.goreleaser.yaml` +- Modify: `.github/workflows/release.yml` +- Delete: `.github/workflows/sync-plugin-version.yml` +- Modify: `README.md` +- Modify: `go.mod` (module path) + +**Step 1: Worktree + branch** + +```bash +cd /Users/jon/workspace +gh repo clone GoCodeAlone/scaffold-workflow-plugin +cd scaffold-workflow-plugin +git checkout -b feat/762-scaffold-modernize +``` + +**Step 2: Rename cmd dir + go.mod module path** + +```bash +git mv cmd/workflow-plugin-TEMPLATE cmd/scaffold-workflow-plugin +go mod edit -module github.com/GoCodeAlone/scaffold-workflow-plugin +# Update imports across all .go files +find . -name '*.go' -not -path './vendor/*' -not -path './_worktrees/*' \ + -exec sed -i.bak 's|workflow-plugin-template|scaffold-workflow-plugin|g' {} \; +find . -name '*.go.bak' -delete +``` + +**Step 3: Create non-IaC main.go** + +`cmd/scaffold-workflow-plugin/main.go`: + +```go +// Command scaffold-workflow-plugin is the non-IaC variant of the +// workflow-plugin scaffold. Instantiators copy this main.go to +// cmd/workflow-plugin-/main.go via scripts/rename-from-scaffold.sh. +package main + +import ( + "github.com/GoCodeAlone/scaffold-workflow-plugin/internal" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +//go:embed plugin.json +var manifestJSON []byte +var manifest = sdk.MustEmbedManifest(manifestJSON) + +func main() { + sdk.Serve(internal.NewPlugin(), + sdk.WithManifestProvider(manifest), + sdk.WithBuildVersion(sdk.ResolveBuildVersion(internal.Version)), + ) +} +``` + +**Step 4: Create IaC main.go** + +`cmd/scaffold-workflow-plugin-iac/main.go`: + +```go +// Command scaffold-workflow-plugin-iac is the IaC variant of the +// workflow-plugin scaffold. Instantiators using rename-from-scaffold.sh +// --mode iac copy this main.go to cmd/workflow-plugin-/main.go. +// The non-IaC main.go in cmd/scaffold-workflow-plugin/ is removed by the +// rename script when --mode iac is selected. +package main + +import ( + "github.com/GoCodeAlone/scaffold-workflow-plugin/internal" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +func main() { + sdk.ServeIaCPlugin(internal.NewIaCServer(), sdk.IaCServeOptions{ + BuildVersion: sdk.ResolveBuildVersion(internal.Version), + }) +} +``` + +`internal/NewIaCServer()` returns a stub `IaCProviderRequiredServer` implementation with all methods returning `codes.Unimplemented`. Instantiator replaces with their real implementation. + +**Step 5: internal/version.go** + +```go +package internal + +// Version is set at build time via -ldflags +// "-X github.com/<...>/internal.Version=X.Y.Z". +// Mirrors the workflow#758 plugin contract. +var Version = "dev" +``` + +**Step 6: plugin.json** + +```json +{ + "name": "scaffold-workflow-plugin", + "version": "0.0.0", + "description": "Template scaffold for new workflow plugins. NOT an installable plugin. See README.", + "author": "GoCodeAlone", + "license": "MIT", + "type": "scaffold", + "minEngineVersion": "0.61.0", + "capabilities": { + "moduleTypes": ["TEMPLATE.module"], + "stepTypes": ["TEMPLATE.step"], + "triggerTypes": [], + "iacProvider": { "resourceTypes": ["TEMPLATE.resource"] } + } +} +``` + +Note: `type: scaffold` (new value; registry-side allowlist defense in Task 1's `wfctl plugin registry-sync` rejects this type so accidental re-registration fails fast). + +**Step 7: .goreleaser.yaml** + +Add `ldflags` block to existing `builds`: + +```yaml +builds: + - id: scaffold-workflow-plugin + main: ./cmd/scaffold-workflow-plugin + binary: scaffold-workflow-plugin + env: [CGO_ENABLED=0] + goos: [linux, darwin] + goarch: [amd64, arm64] + ldflags: + - -s -w -X github.com/GoCodeAlone/scaffold-workflow-plugin/internal.Version={{.Version}} +``` + +(Keep existing `before:` hook for plugin.json version-rewrite; goreleaser's standard pattern.) + +**Step 8: .github/workflows/release.yml** + +Replace with workflow#758 canonical pattern: + +```yaml +name: Release +on: { push: { tags: ['v*'] } } +permissions: { contents: write, id-token: write } +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } + - uses: actions/setup-go@v5 + with: { go-version-file: go.mod } + - uses: GoCodeAlone/setup-wfctl@v1 + with: { version: v0.62.0 } + - name: Validate plugin contract for publish (pre-build) + run: wfctl plugin validate-contract --for-publish --tag "${{ github.ref_name }}" . + - uses: goreleaser/goreleaser-action@v7 + with: { distribution: goreleaser, version: '~> v2', args: release --clean } + env: { GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" } + - name: Verify shipped plugin.json carries tag (post-build) + run: | + if [ -f .release/plugin.json ]; then + wfctl plugin validate-contract --for-publish --tag "${{ github.ref_name }}" --release-dir .release . + else + wfctl plugin validate-contract --for-publish --tag "${{ github.ref_name }}" --release-dir . . + fi + - name: Publish release + env: { GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" } + run: gh release edit ${{ github.ref_name }} --draft=false --repo ${{ github.repository }} +``` + +**Step 9: Delete sync-plugin-version.yml** + +```bash +git rm .github/workflows/sync-plugin-version.yml +``` + +**Step 10: scripts/rename-from-scaffold.sh (TESTED)** + +```bash +#!/usr/bin/env bash +# Usage: bash scripts/rename-from-scaffold.sh [--mode iac|non-iac] +# Renames scaffold-workflow-plugin internals to workflow-plugin-. +# Deletes the unused main.go variant. Updates go.mod, plugin.json, .goreleaser.yaml. +set -euo pipefail + +NEW_NAME="${1:?Usage: rename-from-scaffold.sh [--mode iac|non-iac]}" +MODE="non-iac" +if [[ "${2:-}" == "--mode" ]]; then + MODE="${3:?Mode required}" +fi +case "$MODE" in iac|non-iac) ;; *) echo "Mode must be iac or non-iac" >&2; exit 1 ;; esac + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +# 1. Pick main.go variant; delete the other. +if [[ "$MODE" == "iac" ]]; then + rm -rf cmd/scaffold-workflow-plugin + mv cmd/scaffold-workflow-plugin-iac "cmd/workflow-plugin-$NEW_NAME" +else + rm -rf cmd/scaffold-workflow-plugin-iac + mv cmd/scaffold-workflow-plugin "cmd/workflow-plugin-$NEW_NAME" +fi + +# 2. go.mod +go mod edit -module "github.com/GoCodeAlone/workflow-plugin-$NEW_NAME" + +# 3. Bounded sed across known file globs. +for f in plugin.json .goreleaser.yaml README.md cmd/**/*.go internal/**/*.go; do + [[ -f "$f" ]] || continue + sed -i.bak "s|scaffold-workflow-plugin|workflow-plugin-$NEW_NAME|g" "$f" + rm -f "$f.bak" +done + +# 4. plugin.json: reset type from "scaffold" to "external". +python3 -c " +import json +p = json.load(open('plugin.json')) +p['type'] = 'external' +p['name'] = 'workflow-plugin-$NEW_NAME' +json.dump(p, open('plugin.json', 'w'), indent=2) +open('plugin.json', 'a').write('\n') +" + +# 5. Remove the rename script itself (instantiators don't need it). +rm scripts/rename-from-scaffold.sh + +# 6. Remove the scaffold-rename-test workflow. +rm .github/workflows/scaffold-rename-test.yml + +echo "Renamed to workflow-plugin-$NEW_NAME ($MODE mode). Review changes, edit capabilities in plugin.json, then commit + tag." +``` + +**Step 11: .github/workflows/scaffold-rename-test.yml** + +```yaml +name: Scaffold rename test +on: [push, pull_request] +jobs: + rename-non-iac: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: { go-version-file: go.mod } + - name: Rename to test-plugin (non-iac) + build + run: | + cp -r . /tmp/scaffold-copy + cd /tmp/scaffold-copy + bash scripts/rename-from-scaffold.sh test-plugin --mode non-iac + go build ./... + rename-iac: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: { go-version-file: go.mod } + - name: Rename to test-plugin (iac) + build + run: | + cp -r . /tmp/scaffold-copy + cd /tmp/scaffold-copy + bash scripts/rename-from-scaffold.sh test-plugin --mode iac + go build ./... +``` + +**Step 12: README.md** + +Rewrite as scaffold documentation: + +```markdown +# scaffold-workflow-plugin + +This is a SCAFFOLD repo. It is NOT an installable plugin. Use it to +create a new workflow plugin via GitHub's "Use this template" button OR +via `wfctl plugin init --from-scaffold scaffold-workflow-plugin`. + +## After creating your new repo + +1. **Enable GitHub Actions**: Settings → Actions → "I understand my + workflows, enable them" (required for releases — templates ship + with workflows disabled by default). +2. **Run the rename script**: + ``` + bash scripts/rename-from-scaffold.sh --mode [iac|non-iac] + ``` + This renames cmd/, go.mod, plugin.json, README.md; deletes the + unused main.go variant; sets `plugin.json.type` from `scaffold` to + `external`. +3. **Edit `plugin.json`**: replace placeholder capabilities/minEngineVersion + with your plugin's actual values. +4. **Commit + tag**: + ``` + git add . && git commit -m "feat: initial plugin scaffold from scaffold-workflow-plugin" + git tag v0.1.0 && git push origin main v0.1.0 + ``` + release.yml's `wfctl plugin validate-contract --for-publish` gate + will verify your tag + contract. + +## Modes + +- `--mode non-iac` (default): for module/step/trigger plugins that use + `sdk.Serve`. Suitable for most plugins. +- `--mode iac`: for IaC provider plugins that use `sdk.ServeIaCPlugin` + + satisfy `pb.IaCProviderRequiredServer`. Use only if your plugin + provisions infrastructure (cloud resources, etc.). +``` + +**Step 13: Verify locally** + +```bash +GOWORK=off go build ./... +GOWORK=off go test ./... -count=1 +bash scripts/rename-from-scaffold.sh test-plugin --mode non-iac +# Verify renamed repo builds: +(cd /tmp && rm -rf scaffold-copy && cp -r /Users/jon/workspace/scaffold-workflow-plugin scaffold-copy && cd scaffold-copy && bash scripts/rename-from-scaffold.sh test-plugin --mode non-iac && go build ./...) +``` + +**Step 14: Commit + push + PR + monitor + admin-merge** + +Multi-commit allowed; squash on merge. CI runs scaffold-rename-test workflow which guards C5 regressions. + +**Rollback:** revert PR + `gh repo rename` back to `workflow-plugin-template`. + +--- + +### Task 4: scaffold-workflow-plugin-private rename + modernize + +**Identical structure to Task 3**, against `workflow-plugin-template-private` → `scaffold-workflow-plugin-private`. Differences: + +- `plugin.json.name` = `scaffold-workflow-plugin-private` +- All module paths reference `scaffold-workflow-plugin-private` +- README.md opens with I7 clarification: "This repo's `-private` suffix refers to its GitHub repo visibility (only org members can clone). It is NOT related to `plugin.json.private: true` semantics." +- `.goreleaser.yaml` references `RELEASES_TOKEN` for private go-module access (verify the existing template already has this; if not, add). + +Same scaffold-rename-test workflow + same release.yml pattern. + +Pre-flight admin: + +```bash +gh repo rename workflow-plugin-template-private --repo GoCodeAlone/workflow-plugin-template-private scaffold-workflow-plugin-private +gh api -X PATCH /repos/GoCodeAlone/scaffold-workflow-plugin-private -f is_template=true +``` + +**Verify + Commit + push + PR + monitor + admin-merge** same as Task 3. + +**Rollback:** same as Task 3. + +--- + +### Task 5: workflow-registry delete plugins/template/ + +**Files in workflow-registry:** +- Delete: `plugins/template/manifest.json` +- Delete: `plugins/template/` directory + +**Step 1: Branch + delete** + +```bash +cd /Users/jon/workspace/workflow-registry +git fetch origin main +git worktree add /Users/jon/workspace/_worktrees/wfreg-762-template-delete -b chore/762-delete-template origin/main +cd /Users/jon/workspace/_worktrees/wfreg-762-template-delete +git rm -r plugins/template/ +``` + +**Step 2: Regenerate README index** + +```bash +# After Task 1 lands + wfctl v0.62.0 available: +wfctl plugin registry-sync readme --registry-dir . --fix +# Or fall back to bash if wfctl not yet released: +bash scripts/generate-readme.sh +``` + +**Step 3: Verify** + +```bash +bash scripts/validate-manifests.sh +``` +Expected: all remaining manifests valid; no broken refs to deleted `template` plugin. + +**Step 4: Commit + push + PR + monitor + admin-merge** + +PR body: + +``` +Deletes plugins/template/ — the entry was a stub manifest pointing at +workflow-plugin-template (since renamed to scaffold-workflow-plugin per +workflow#762 Layer d). Scaffold repos are NOT installable plugins; this +entry should never have been registered. + +**Breaking change for operators with `template` in `.wfctl-lock.yaml`**: +those operators must remove the entry. The plugin was non-functional +(empty stub); no real installs affected. + +Refs workflow#762 +``` + +**Rollback:** `git revert`; restores `plugins/template/manifest.json` and registry once again exposes the scaffold as a plugin (regression). + +--- + +### Task 6: Update workflow#760 sweep list + +**Files:** none (issue edit only). + +**Step 1: Edit issue body via gh CLI** + +```bash +gh issue edit 760 --repo GoCodeAlone/workflow --body "$(gh issue view 760 --repo GoCodeAlone/workflow --json body -q .body | sed '/workflow-plugin-template$/d; /workflow-plugin-template-private$/d')" +``` + +(Or via UI: remove `workflow-plugin-template` + `workflow-plugin-template-private` from the enumerated 56-repo list; update the "Remaining repos to migrate" count from 56 to 54.) + +**Step 2: Append comment** + +```bash +gh issue comment 760 --repo GoCodeAlone/workflow --body "Updated per workflow#762 Layer d: dropped scaffold-workflow-plugin (renamed from workflow-plugin-template) + scaffold-workflow-plugin-private from the sweep. These are now scaffold repos, not plugins. 56 → 54." +``` + +**Verify:** `gh issue view 760 --repo GoCodeAlone/workflow` shows updated body + comment. + +--- + +## Pipeline gate at end of plan + +This plan ships Layer (a) + (a') + (d). Layer (a'') (bash deletion + Go --fix swap) and Layer 3b sweep are explicit follow-up work — Layer (a'') waits on one parity-cycle observation; Layer 3b is filed at workflow#760 and gets its own execution wave with separate authorization. From d1b458d75fd782675a9faf373bc356c26c27078d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 23 May 2026 20:20:26 -0400 Subject: [PATCH 5/7] =?UTF-8?q?docs(plan):=20plan=20cycle=201=20revisions?= =?UTF-8?q?=20=E2=80=94=20C-P1/2/3/4=20+=20I-P1-9=20fixes=20(workflow#762)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-05-23-wfctl-registry-sync.md | 278 +++++++++++++++---- 1 file changed, 226 insertions(+), 52 deletions(-) diff --git a/docs/plans/2026-05-23-wfctl-registry-sync.md b/docs/plans/2026-05-23-wfctl-registry-sync.md index 6f1e2670..fb1d01a5 100644 --- a/docs/plans/2026-05-23-wfctl-registry-sync.md +++ b/docs/plans/2026-05-23-wfctl-registry-sync.md @@ -12,9 +12,30 @@ --- +## Operator pre-flight (required before Task 3 / 4) + +The GitHub repo renames + template-flag toggles in Layer (d) require **org-admin or repo-admin auth** on the `gh` CLI session, which an autonomous agent may not have. The operator must run these interactively BEFORE the agent picks up Task 3 / Task 4: + +```bash +# Verify gh has admin scope: +gh auth status --hostname github.com | grep -E "admin:org|admin:repo" + +# Public scaffold: +gh repo rename workflow-plugin-template --repo GoCodeAlone/workflow-plugin-template scaffold-workflow-plugin +gh api -X PATCH /repos/GoCodeAlone/scaffold-workflow-plugin -f is_template=true +gh repo view GoCodeAlone/scaffold-workflow-plugin --json isTemplate -q .isTemplate # → true + +# Private scaffold: +gh repo rename workflow-plugin-template-private --repo GoCodeAlone/workflow-plugin-template-private scaffold-workflow-plugin-private +gh api -X PATCH /repos/GoCodeAlone/scaffold-workflow-plugin-private -f is_template=true +gh repo view GoCodeAlone/scaffold-workflow-plugin-private --json isTemplate -q .isTemplate # → true +``` + +Tasks 3 and 4 begin with a verification step (`gh repo view ...` confirming the renamed repo exists + is_template=true) and bail with a clear error message if the pre-flight wasn't completed. + ## Scope Manifest -**PR Count:** 6 +**PR Count:** 6 (5 code-PRs + 1 issue edit) **Tasks:** 6 **Estimated Lines of Change:** ~2000 across all PRs @@ -141,11 +162,21 @@ Embeds the inspect program (currently in `sync-core-manifests.sh:39-89` as bash 5. Parse JSON; for each core plugin, compare against registry's `plugins//manifest.json`; with `--fix` rewrite. 6. Output matches bash format for parity. -**Step 5: Readme mode (generate-readme.sh port)** +**Step 5: Readme mode (generate-readme.sh port; cycle 1 I-P5 surface enumeration)** `cmd/wfctl/plugin_registry_sync_readme.go`: implements `runPluginRegistrySyncReadme(args []string) error`. Flags: `--check`, `--registry-dir `. -Reads `plugins/*/manifest.json` + `templates/*/template.json` (whatever the bash reads); regenerates the plugin/template indexes in README.md between marker comments. `--check` is dry-run + exit non-zero on diff. +Ports `workflow-registry/scripts/generate-readme.sh` (129 lines). Specifically: + +a. Walks `/plugins/*/manifest.json` and `/templates/*.yaml` (the 7 templates: api-service.yaml, event-processor.yaml, full-stack.yaml, notify-registry.yml, plugin.yaml, stream-processor.yaml, ui-plugin.yaml). +b. For plugins: extracts `name + description + repository` via JSON parse. Pipe-escapes `|` characters in descriptions (`strings.ReplaceAll(desc, "|", "\\|")`) for markdown-table safety. +c. For templates: extracts description from YAML comment header via the bash `template_description()` awk-equivalent. Read `awk_extract_description` shape from bash lines 30-50 and replicate verbatim. +d. Sorts both lists case-fold (`sort.SliceStable` with `strings.ToLower` compare key). +e. Locates the marker comment regions in `/README.md` (bash uses `` … `` and `` … ``; verify by reading the existing README.md before coding). +f. Substitutes the table content between the markers. +g. `--check` mode: compare proposed README content to current README; exit 1 on diff with a unified-diff printout. + +Test fixtures: `cmd/wfctl/testdata/plugin_registry_sync/readme-{good,stale,pipe-in-desc,case-fold-sort}/`. Pin against bash output byte-for-byte for parity. **Step 6: Tests (table-driven, fixture-backed)** @@ -160,18 +191,38 @@ Reads `plugins/*/manifest.json` + `templates/*/template.json` (whatever the bash For `gh` API calls: use a test-injected interface or `httptest`-backed fake. -**Step 7: --verify-capabilities (reuses install pipeline per C3 fix)** +**Step 7: --verify-capabilities (direct extract + exec per plan cycle 1 C-P1 fix)** + +The cycle-1 plan named `runPluginInstall` as the reusable surface; that's wrong (it takes raw CLI args + writes to stderr). The actually reusable function is `installFromLocal(srcDir, pluginDir string) error` at `cmd/wfctl/plugin_install.go:882`. But `--verify-capabilities` doesn't need lockfile/integrity machinery at all — it only needs to spawn the binary to call `GetContractRegistry`. Skip the install step entirely. When `--verify-capabilities` set, for each plugin: 1. `gh release download --repo --pattern '--.tar.gz' -O /tmp/-.tar.gz` -2. Extract to `/tmp/--extracted/` -3. Invoke existing `runPluginInstall` programmatically with `--local /tmp/--extracted/ --plugin-dir /tmp/--installed/` (avoids re-implementing rename + lockfile + integrity). -4. Spawn the installed plugin via existing `wfctl plugin info`-style code path; call `GetContractRegistry` RPC. +2. Extract to `/tmp/--extracted/` via existing tarball-extract helper at `cmd/wfctl/plugin_install.go` (find via `grep extractTarGz`). +3. Locate the binary inside the tarball. Goreleaser pattern: `` or `--`. Try both; first hit wins. +4. Spawn the binary via existing plugin-spawn helper (find via `grep -r 'goplugin.NewClient' cmd/wfctl/` — there's a `wfctl plugin info` code path that already does this). Call `GetContractRegistry` RPC. 5. Diff RPC response vs `/plugins//manifest.json.capabilities`. If `--fix`, rewrite the registry manifest. 6. Cleanup temp dirs. -If existing wfctl APIs aren't exported in a way that supports invocation from `registry-sync`, **bail with a `TODO: refactor needed`** rather than reimplementing — file as a sub-task on the PR. +If the spawn helper isn't usable from registry-sync without refactoring (verify per-task via grep), implement a minimal local spawn directly: `goplugin.NewClient` with the existing handshake + `pb.NewPluginServiceClient` + `GetManifest` (which carries capabilities since workflow#758 v0.61.0). Plan task verification asserts this works locally before commit. + +**Step 7.5: Type-allowlist defense (plan cycle 1 C-P3 fix)** + +`PluginManifest.Validate()` (plugin/manifest.go:194) does not check `.type` today. The design's Layer (d) step 5 promised that `wfctl plugin registry-sync` rejects `type: "scaffold"` to catch accidental re-registration. Add this enforcement directly in `runPluginRegistrySync`: + +```go +// In syncDefault, after parsing manifest: +allowedTypes := map[string]bool{"external": true, "builtin": true, "core": true, "iac": true} +manifestType, _ := raw["type"].(string) +if manifestType != "" && !allowedTypes[manifestType] { + fmt.Fprintf(os.Stderr, " REJECT %s — plugin.json.type=%q is not in the registry allowlist (scaffold repos must not be registered)\n", pluginName, manifestType) + continue +} +``` + +Test fixture: `cmd/wfctl/testdata/plugin_registry_sync/scaffold-rejected/plugin.json` with `"type": "scaffold"`. Expected output contains `REJECT` for that plugin. + +This is the registry-side guarantee that scaffold repos which somehow leak back into `plugins/*/manifest.json` get caught at sync time. **Step 8: Update docs/PLUGIN_RELEASE_GATES.md** @@ -245,13 +296,27 @@ go_out="$2" label="$3" # Normalize: strip ANSI colors, trim trailing whitespace per line. -sed -E 's/\x1b\[[0-9;]*[mK]//g; s/[[:space:]]+$//' "$bash_out" | sort > "$bash_out.norm" -sed -E 's/\x1b\[[0-9;]*[mK]//g; s/[[:space:]]+$//' "$go_out" | sort > "$go_out.norm" - -if ! diff -u "$bash_out.norm" "$go_out.norm"; then - echo "::error::Parity diff for $label between bash + Go outputs. Bash remains authoritative; investigate Go port." +bash_norm="$(mktemp)" +go_norm="$(mktemp)" +sed -E 's/\x1b\[[0-9;]*[mK]//g; s/[[:space:]]+$//' "$bash_out" > "$bash_norm" +sed -E 's/\x1b\[[0-9;]*[mK]//g; s/[[:space:]]+$//' "$go_out" > "$go_norm" + +# 1. Sorted set-membership check (fail). Catches missing/extra lines regardless of order. +sort "$bash_norm" > "$bash_norm.sorted" +sort "$go_norm" > "$go_norm.sorted" +if ! diff -u "$bash_norm.sorted" "$go_norm.sorted"; then + echo "::error::Parity diff (sorted) for $label between bash + Go outputs. Bash remains authoritative; investigate Go port." exit 1 fi + +# 2. Unsorted order check (warning only — cycle 1 I-P3 fix). Reorderings are +# not failures (different goroutine schedule etc.), but operators reading +# workflow logs deserve a heads-up. +if ! diff -q "$bash_norm" "$go_norm" >/dev/null 2>&1; then + echo "::warning::Parity OK for $label set-membership, BUT line order differs between bash + Go. Verify operator-facing output remains readable." + diff -u "$bash_norm" "$go_norm" || true +fi + echo "Parity OK for $label" ``` @@ -281,7 +346,7 @@ gh pr merge --squash --admin --delete-branch ### Task 3: scaffold-workflow-plugin rename + modernize (public scaffold) -**Pre-flight:** Layer (a) merged + workflow v0.62.0 tagged. +**Pre-flight:** Layer (a) merged + workflow v0.62.0 tagged + v0.62.0 release published. Tasks 3+4 can land their content PRs in parallel with Task 1+2 (rename + content are independent of wfctl's release status). However, **do NOT tag the scaffold repos until v0.62.0 is published**; the release.yml's `setup-wfctl@v1 with version: v0.62.0` step fails otherwise (cycle 1 I-P9). **Pre-step (org admin):** @@ -296,6 +361,7 @@ gh api -X PATCH /repos/GoCodeAlone/scaffold-workflow-plugin -f is_template=true - Rename: `cmd/workflow-plugin-TEMPLATE/` → `cmd/scaffold-workflow-plugin/` - Create: `cmd/scaffold-workflow-plugin-iac/main.go` (IaC variant) - Create: `internal/version.go` +- Create: `internal/iacserver.go` (plan cycle 1 C-P2 fix — stub `pb.UnimplementedIaCProviderRequiredServer` impl) - Create: `scripts/rename-from-scaffold.sh` - Create: `.github/workflows/scaffold-rename-test.yml` - Modify: `cmd/scaffold-workflow-plugin/main.go` (non-IaC default) @@ -305,6 +371,7 @@ gh api -X PATCH /repos/GoCodeAlone/scaffold-workflow-plugin -f is_template=true - Delete: `.github/workflows/sync-plugin-version.yml` - Modify: `README.md` - Modify: `go.mod` (module path) +- Modify: `internal/plugin.go` (existing `NewPlugin` stays; just ensure it works with module-path rename) **Step 1: Worktree + branch** @@ -353,7 +420,38 @@ func main() { } ``` -**Step 4: Create IaC main.go** +**Step 4a: Create internal/iacserver.go (cycle 1 C-P2 fix — stub does not exist in current template)** + +`internal/iacserver.go`: + +```go +package internal + +import ( + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" +) + +// IaCServer is the IaC-mode stub for the scaffold. Embeds +// pb.UnimplementedIaCProviderRequiredServer so all RPCs return Unimplemented +// by default. Instantiators replace this with their real IaC provider +// implementation when using `--mode iac` (the rename script removes the +// non-IaC cmd/scaffold-workflow-plugin/ in that mode). +type IaCServer struct { + pb.UnimplementedIaCProviderRequiredServer + // Any other servers the plugin implements can be embedded here: + // pb.UnimplementedIaCProviderServer + // pb.UnimplementedIaCProviderLogCaptureServer + // pb.UnimplementedIaCProviderFinalizerServer +} + +func NewIaCServer() *IaCServer { + return &IaCServer{} +} +``` + +If the embedded type name `UnimplementedIaCProviderRequiredServer` differs in the current proto-generated code, find it via `grep -rn 'Unimplemented.*IaCProvider' plugin/external/proto/ 2>/dev/null` in a workflow checkout and use the exact name. + +**Step 4b: Create IaC main.go** `cmd/scaffold-workflow-plugin-iac/main.go`: @@ -377,8 +475,6 @@ func main() { } ``` -`internal/NewIaCServer()` returns a stub `IaCProviderRequiredServer` implementation with all methods returning `codes.Unimplemented`. Instantiator replaces with their real implementation. - **Step 5: internal/version.go** ```go @@ -471,13 +567,16 @@ jobs: git rm .github/workflows/sync-plugin-version.yml ``` -**Step 10: scripts/rename-from-scaffold.sh (TESTED)** +**Step 10: scripts/rename-from-scaffold.sh (TESTED; uses `find` + `jq` per cycle 1 C-P4 + I-P6 fixes)** ```bash #!/usr/bin/env bash # Usage: bash scripts/rename-from-scaffold.sh [--mode iac|non-iac] # Renames scaffold-workflow-plugin internals to workflow-plugin-. # Deletes the unused main.go variant. Updates go.mod, plugin.json, .goreleaser.yaml. +# +# Requires: jq (not python3); uses `find -print0 | while read -d ''` (not bash globstar) +# so it works on default bash without `shopt -s globstar`. set -euo pipefail NEW_NAME="${1:?Usage: rename-from-scaffold.sh [--mode iac|non-iac]}" @@ -487,6 +586,10 @@ if [[ "${2:-}" == "--mode" ]]; then fi case "$MODE" in iac|non-iac) ;; *) echo "Mode must be iac or non-iac" >&2; exit 1 ;; esac +if ! command -v jq >/dev/null 2>&1; then + echo "error: jq is required" >&2; exit 1 +fi + REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" @@ -502,22 +605,20 @@ fi # 2. go.mod go mod edit -module "github.com/GoCodeAlone/workflow-plugin-$NEW_NAME" -# 3. Bounded sed across known file globs. -for f in plugin.json .goreleaser.yaml README.md cmd/**/*.go internal/**/*.go; do - [[ -f "$f" ]] || continue - sed -i.bak "s|scaffold-workflow-plugin|workflow-plugin-$NEW_NAME|g" "$f" - rm -f "$f.bak" -done - -# 4. plugin.json: reset type from "scaffold" to "external". -python3 -c " -import json -p = json.load(open('plugin.json')) -p['type'] = 'external' -p['name'] = 'workflow-plugin-$NEW_NAME' -json.dump(p, open('plugin.json', 'w'), indent=2) -open('plugin.json', 'a').write('\n') -" +# 3. Bounded find loop across .go + .yaml + .md + plugin.json. Uses +# -print0/read -d '' for safety with paths containing spaces; explicit +# excludes for vendor/, _worktrees/, .git/. +find . \( -name '*.go' -o -name '*.yaml' -o -name '*.yml' -o -name '*.md' -o -name 'plugin.json' \) \ + -not -path './vendor/*' -not -path './_worktrees/*' -not -path './.git/*' -print0 \ + | while IFS= read -r -d '' f; do + sed -i.bak "s|scaffold-workflow-plugin|workflow-plugin-$NEW_NAME|g" "$f" + rm -f "$f.bak" + done + +# 4. plugin.json: reset type from "scaffold" to "external"; set name. +tmp="$(mktemp)" +jq --arg name "workflow-plugin-$NEW_NAME" '.type = "external" | .name = $name' plugin.json > "$tmp" +mv "$tmp" plugin.json # 5. Remove the rename script itself (instantiators don't need it). rm scripts/rename-from-scaffold.sh @@ -530,6 +631,8 @@ echo "Renamed to workflow-plugin-$NEW_NAME ($MODE mode). Review changes, edit ca **Step 11: .github/workflows/scaffold-rename-test.yml** +Also exercises a nested fixture file to catch C-P4 globstar regressions (the rename test must validate the script handles deeper-than-one-level Go files): + ```yaml name: Scaffold rename test on: [push, pull_request] @@ -540,11 +643,23 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: { go-version-file: go.mod } + - name: Add nested fixture file to exercise the find loop (cycle 1 C-P4 guard) + run: | + mkdir -p internal/nested/sub + cat > internal/nested/sub/test.go <<'EOF' + // Fixture file deeper than one level — verifies the rename script's + // find loop catches imports of scaffold-workflow-plugin in nested + // packages, not only top-level ones. + package sub + import _ "github.com/GoCodeAlone/scaffold-workflow-plugin/internal" + EOF - name: Rename to test-plugin (non-iac) + build run: | cp -r . /tmp/scaffold-copy cd /tmp/scaffold-copy bash scripts/rename-from-scaffold.sh test-plugin --mode non-iac + # If the nested-fixture import didn't get rewritten, the build fails + # with "no required module provides github.com/GoCodeAlone/scaffold-workflow-plugin/internal". go build ./... rename-iac: runs-on: ubuntu-latest @@ -552,6 +667,13 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: { go-version-file: go.mod } + - name: Add nested fixture file (cycle 1 C-P4 guard) + run: | + mkdir -p internal/nested/sub + cat > internal/nested/sub/test.go <<'EOF' + package sub + import _ "github.com/GoCodeAlone/scaffold-workflow-plugin/internal" + EOF - name: Rename to test-plugin (iac) + build run: | cp -r . /tmp/scaffold-copy @@ -560,7 +682,7 @@ jobs: go build ./... ``` -**Step 12: README.md** +**Step 12: README.md (cycle 1 I-P1 fix — drop `--from-scaffold` vapourware reference)** Rewrite as scaffold documentation: @@ -568,14 +690,16 @@ Rewrite as scaffold documentation: # scaffold-workflow-plugin This is a SCAFFOLD repo. It is NOT an installable plugin. Use it to -create a new workflow plugin via GitHub's "Use this template" button OR -via `wfctl plugin init --from-scaffold scaffold-workflow-plugin`. +create a new workflow plugin via GitHub's "Use this template" button. + +(A future `wfctl plugin init --from-scaffold` subcommand is filed at +workflow#762 but not yet implemented; use the GitHub UI path below.) ## After creating your new repo 1. **Enable GitHub Actions**: Settings → Actions → "I understand my - workflows, enable them" (required for releases — templates ship - with workflows disabled by default). + workflows, enable them" (required for releases — repos created from + a template ship with workflows disabled by default). 2. **Run the rename script**: ``` bash scripts/rename-from-scaffold.sh --mode [iac|non-iac] @@ -620,27 +744,61 @@ Multi-commit allowed; squash on merge. CI runs scaffold-rename-test workflow whi --- -### Task 4: scaffold-workflow-plugin-private rename + modernize +### Task 4: scaffold-workflow-plugin-private rename + modernize (cycle 1 I-P2 expansion) + +**Pre-flight (operator already ran per top-of-plan):** rename completed, is_template=true verified. -**Identical structure to Task 3**, against `workflow-plugin-template-private` → `scaffold-workflow-plugin-private`. Differences: +**Files in scaffold-workflow-plugin-private (post-rename):** mirror Task 3's Files list with `private` suffix; same structure. -- `plugin.json.name` = `scaffold-workflow-plugin-private` -- All module paths reference `scaffold-workflow-plugin-private` -- README.md opens with I7 clarification: "This repo's `-private` suffix refers to its GitHub repo visibility (only org members can clone). It is NOT related to `plugin.json.private: true` semantics." -- `.goreleaser.yaml` references `RELEASES_TOKEN` for private go-module access (verify the existing template already has this; if not, add). +**Step 1: Verify pre-flight + clone** -Same scaffold-rename-test workflow + same release.yml pattern. +```bash +gh repo view GoCodeAlone/scaffold-workflow-plugin-private --json isTemplate -q .isTemplate # → true; bail if missing +cd /Users/jon/workspace +gh repo clone GoCodeAlone/scaffold-workflow-plugin-private +cd scaffold-workflow-plugin-private +git checkout -b feat/762-scaffold-modernize +``` -Pre-flight admin: +**Step 2: Inspect existing RELEASES_TOKEN usage** ```bash -gh repo rename workflow-plugin-template-private --repo GoCodeAlone/workflow-plugin-template-private scaffold-workflow-plugin-private -gh api -X PATCH /repos/GoCodeAlone/scaffold-workflow-plugin-private -f is_template=true +grep -rn "RELEASES_TOKEN\|GOPRIVATE" .github/workflows/ .goreleaser.yaml go.mod 2>/dev/null +``` + +Decision branch (I-P2): +- If `RELEASES_TOKEN` already wired in release.yml + `GOPRIVATE` set in goreleaser env: KEEP as-is; only re-derive the import-path strings in Step 4 below. +- If NOT wired: ADD the standard pattern from any other private plugin repo (e.g., DO plugin's release.yml uses `git config --global url."https://x-access-token:${RELEASES_TOKEN}@github.com/".insteadOf "https://github.com/"`). Copy that step verbatim into the new release.yml. + +**Step 3: Rename cmd dir + go.mod module path** + +```bash +git mv cmd/workflow-plugin-TEMPLATE cmd/scaffold-workflow-plugin-private +go mod edit -module github.com/GoCodeAlone/scaffold-workflow-plugin-private +find . -name '*.go' -not -path './vendor/*' -not -path './_worktrees/*' \ + -exec sed -i.bak 's|workflow-plugin-template-private|scaffold-workflow-plugin-private|g' {} \; +find . -name '*.go.bak' -delete ``` -**Verify + Commit + push + PR + monitor + admin-merge** same as Task 3. +**Steps 4a-13:** identical to Task 3 Steps 4a-13 except every `scaffold-workflow-plugin` reference becomes `scaffold-workflow-plugin-private`. Same files (internal/iacserver.go stub, two main.go variants, internal/version.go, scripts/rename-from-scaffold.sh, .github/workflows/scaffold-rename-test.yml, release.yml two-step gates, plugin.json sentinel `"0.0.0"` + `"type": "scaffold"`). -**Rollback:** same as Task 3. +**Step 14 (new for Task 4): README opens with I7 clarification** + +Prepend to README: + +```markdown +> **About this repo's `-private` suffix:** This refers to the GitHub repo +> visibility — only GoCodeAlone org members can clone or fork it. It is +> NOT related to `plugin.json.private: true` semantics (which control +> marketplace listing). A plugin instantiated from this scaffold may have +> any GitHub visibility and any plugin.json.private value independently. +``` + +Then the standard scaffold README from Task 3 Step 12 (with `scaffold-workflow-plugin-private` substituted everywhere). + +**Step 15: Verify + commit + push + PR + monitor + admin-merge** — same shape as Task 3 Steps 13-14. The scaffold-rename-test.yml workflow guards C5 regressions. + +**Rollback:** revert PR + `gh repo rename` back to `workflow-plugin-template-private`. --- @@ -693,7 +851,7 @@ those operators must remove the entry. The plugin was non-functional Refs workflow#762 ``` -**Rollback:** `git revert`; restores `plugins/template/manifest.json` and registry once again exposes the scaffold as a plugin (regression). +**Rollback (cycle 1 I-P7 caveat):** `git revert` restores `plugins/template/manifest.json`, BUT since Tasks 3+4 rename the upstream repos to `scaffold-workflow-plugin*`, the restored manifest points at the OLD URLs which now redirect to the renamed repos (different artifact name pattern). `wfctl plugin install template` would download wrong-named tarballs and fail in non-obvious ways. **In practice this PR is non-revertable once Tasks 3+4 have shipped.** If a true revert is needed, file a separate PR that ALSO unwinds Tasks 3+4's renames. --- @@ -719,6 +877,22 @@ gh issue comment 760 --repo GoCodeAlone/workflow --body "Updated per workflow#76 --- +## Plan cycle 1 — addressed + +- **C-P1 (runPluginInstall not callable)**: addressed — Task 1 Step 7 rewritten to skip install entirely; directly extract tarball + spawn binary via `goplugin.NewClient`. Falls back to local-spawn impl if existing helper isn't reusable. +- **C-P2 (`internal.NewIaCServer` doesn't exist)**: addressed — Task 3 Files list + new Step 4a create `internal/iacserver.go` with `pb.UnimplementedIaCProviderRequiredServer`-embedded stub. +- **C-P3 (`type: "scaffold"` allowlist never wired)**: addressed — new Task 1 Step 7.5 adds type-allowlist check in `runPluginRegistrySync` with test fixture `scaffold-rejected/`. +- **C-P4 (rename script uses bash globstar without `shopt`)**: addressed — Task 3 Step 10 rewrites the script to use `find -print0 | while read -d ''`; Step 11 scaffold-rename-test.yml adds a nested-fixture file to exercise the deeper-than-one-level code path. +- **I-P1 (`wfctl plugin init --from-scaffold` vapourware)**: addressed — Task 3 Step 12 README drops the reference; uses GitHub "Use this template" UI path only. Future `wfctl plugin init --from-scaffold` filed at workflow#762 follow-up but not in this plan. +- **I-P2 (Task 4 under-decomposed)**: addressed — Task 4 expanded to 15 explicit steps; `RELEASES_TOKEN` decision is now Step 2 with concrete grep + decision branch. +- **I-P3 (parity-diff sort hides ordering bugs)**: addressed — parity-diff.sh now does sorted (fail) + unsorted (warn) checks. +- **I-P4 (gh repo rename admin scope)**: addressed — hoisted to "Operator pre-flight" section at top of plan; Task 3+4 begin with verification step. +- **I-P5 (readme mode under-specified)**: addressed — Task 1 Step 5 enumerates 7-template surface + pipe-escape + case-fold sort + marker comment names. +- **I-P6 (python3 vs jq in rename script)**: addressed — Step 10 rewritten with jq. +- **I-P7 (Task 5 rollback story)**: addressed — Rollback note caveats non-revertability once Tasks 3+4 ship. +- **I-P8 (PR Count claim of 6 vs 5 code-PRs)**: addressed — header now says "PR Count: 6 (5 code-PRs + 1 issue edit)". +- **I-P9 (release.yml pins v0.62.0 not yet tagged)**: addressed — Task 3+4 Pre-flight section warns against tagging scaffold repos until v0.62.0 published. + ## Pipeline gate at end of plan This plan ships Layer (a) + (a') + (d). Layer (a'') (bash deletion + Go --fix swap) and Layer 3b sweep are explicit follow-up work — Layer (a'') waits on one parity-cycle observation; Layer 3b is filed at workflow#760 and gets its own execution wave with separate authorization. From f937bdf3f5fe9fb058db0a4467058c7c6ba1e9be Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 23 May 2026 20:22:28 -0400 Subject: [PATCH 6/7] chore: lock scope for wfctl-registry-sync (alignment passed) --- docs/plans/2026-05-23-wfctl-registry-sync.md | 2 +- docs/plans/2026-05-23-wfctl-registry-sync.md.scope-lock | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-05-23-wfctl-registry-sync.md.scope-lock diff --git a/docs/plans/2026-05-23-wfctl-registry-sync.md b/docs/plans/2026-05-23-wfctl-registry-sync.md index fb1d01a5..fe7b9d6d 100644 --- a/docs/plans/2026-05-23-wfctl-registry-sync.md +++ b/docs/plans/2026-05-23-wfctl-registry-sync.md @@ -59,7 +59,7 @@ Tasks 3 and 4 begin with a verification step (`gh repo view ...` confirming the | 5 | chore(registry): delete plugins/template/ — superseded by scaffold-workflow-plugin (#762 Layer d.3) | Task 5 | chore/762-delete-template | workflow-registry | | 6 | chore(issue): update workflow#760 sweep list — drop scaffold repos (#762 Layer d.4) | Task 6 | n/a (issue edit) | workflow (issue) | -**Status:** Draft +**Status:** Locked 2026-05-24T00:22:15Z --- diff --git a/docs/plans/2026-05-23-wfctl-registry-sync.md.scope-lock b/docs/plans/2026-05-23-wfctl-registry-sync.md.scope-lock new file mode 100644 index 00000000..139f7462 --- /dev/null +++ b/docs/plans/2026-05-23-wfctl-registry-sync.md.scope-lock @@ -0,0 +1 @@ +51f6c7e87023a84d0059fdf81229203eefdd4f0fd4a25f8911169f2580fa1405 From caeb2d8ef0228062f06aeeb9bcfb471881fc6387 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 23 May 2026 20:32:23 -0400 Subject: [PATCH 7/7] feat(wfctl): plugin registry-sync subcommand + shared release-grade semver regex + type allowlist (#762 Layer a) --- cmd/wfctl/plugin.go | 3 + cmd/wfctl/plugin_registry_sync.go | 416 +++++++++++++++++++++++ cmd/wfctl/plugin_registry_sync_core.go | 72 ++++ cmd/wfctl/plugin_registry_sync_readme.go | 57 ++++ cmd/wfctl/plugin_registry_sync_test.go | 142 ++++++++ cmd/wfctl/plugin_release_grade_semver.go | 14 + cmd/wfctl/plugin_validate_contract.go | 5 +- docs/PLUGIN_RELEASE_GATES.md | 28 ++ 8 files changed, 736 insertions(+), 1 deletion(-) create mode 100644 cmd/wfctl/plugin_registry_sync.go create mode 100644 cmd/wfctl/plugin_registry_sync_core.go create mode 100644 cmd/wfctl/plugin_registry_sync_readme.go create mode 100644 cmd/wfctl/plugin_registry_sync_test.go create mode 100644 cmd/wfctl/plugin_release_grade_semver.go diff --git a/cmd/wfctl/plugin.go b/cmd/wfctl/plugin.go index 91620f06..2622c734 100644 --- a/cmd/wfctl/plugin.go +++ b/cmd/wfctl/plugin.go @@ -37,6 +37,8 @@ func runPlugin(args []string) error { return runPluginValidate(args[1:]) case "validate-contract": return runPluginValidateContract(args[1:]) + case "registry-sync": + return runPluginRegistrySync(args[1:]) case "conformance": return runPluginConformance(args[1:]) case "info": @@ -66,6 +68,7 @@ Subcommands: remove Uninstall a plugin (also removes from manifest + lockfile) validate Validate a plugin manifest from the registry or a local file validate-contract Validate a plugin source directory against the release contract (workflow#758) + registry-sync Sync registry manifest versions/capabilities from upstream release tags; subcommands: core, readme (workflow#762) 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_registry_sync.go b/cmd/wfctl/plugin_registry_sync.go new file mode 100644 index 00000000..74009189 --- /dev/null +++ b/cmd/wfctl/plugin_registry_sync.go @@ -0,0 +1,416 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +// runPluginRegistrySync ports workflow-registry/scripts/sync-versions.sh + +// sync-core-manifests.sh + generate-readme.sh into a single Go subcommand +// (workflow#762). Sub-modes: default (sync-versions), "core", "readme". +// +// Default mode walks /plugins/*/manifest.json; for each: +// 1. Parses repository/source to derive gh_repo. +// 2. gh release view → latestTag. +// 3. Rejects non-publish-grade-semver tags (shared PublishGradeSemverRe). +// 4. Rejects plugin.json.type values outside the registry allowlist +// (catches accidental scaffold re-registration per workflow#762 +// Layer (d) step 5). +// 5. Compares manifest.version + downloads URLs; with --fix rewrites. +// 6. Fetches tagged plugin.json from upstream; syncs capabilities, +// minEngineVersion, iacProvider into registry manifest. +// 7. (--verify-capabilities) Downloads release tarball + spawns binary; +// diffs GetManifest's capabilities vs committed; with --fix rewrites. +// NOTE: deferred to a follow-up PR per plan I4 / I-P9 — initial impl +// stubs this with a clear "not implemented yet" message. +func runPluginRegistrySync(args []string) error { + if len(args) > 0 { + switch args[0] { + case "core": + return runPluginRegistrySyncCore(args[1:]) + case "readme": + return runPluginRegistrySyncReadme(args[1:]) + } + } + + fs := flag.NewFlagSet("plugin registry-sync", flag.ContinueOnError) + fix := fs.Bool("fix", false, "Apply changes (default: dry-run)") + pluginFilter := fs.String("plugin", "", "Restrict to single plugin directory name") + verifyCaps := fs.Bool("verify-capabilities", false, "Spawn binary + diff capabilities (registry-side; slow; not yet implemented)") + registryDir := fs.String("registry-dir", ".", "Path to a workflow-registry checkout") + fs.Usage = func() { + fmt.Fprintf(fs.Output(), `Usage: wfctl plugin registry-sync [options] + wfctl plugin registry-sync core [options] + wfctl plugin registry-sync readme [options] + +Default mode: walks /plugins/*/manifest.json and syncs each +plugin's version + downloads URLs + capabilities against its upstream +GitHub release tag. Replaces workflow-registry/scripts/sync-versions.sh. + +Sub-modes: + core — sync core (built-in workflow) plugin manifests by compiling + an inspect program against a workflow checkout; replaces + scripts/sync-core-manifests.sh. + readme — regenerate the README plugin/template indexes from registry + source data; replaces scripts/generate-readme.sh. + +Options: +`) + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + + return syncDefault(*registryDir, *fix, *pluginFilter, *verifyCaps) +} + +// registryAllowedTypes is the set of plugin.json type values that legitimately +// belong in the registry. Scaffold repos use type:"scaffold" which is NOT +// allowed here — registry-sync rejects them to defend against accidental +// re-registration (workflow#762 Layer d step 5, plan C-P3 fix). +var registryAllowedTypes = map[string]bool{ + "external": true, + "builtin": true, + "core": true, + "iac": true, +} + +func syncDefault(registryDir string, fix bool, pluginFilter string, verifyCaps bool) error { + pluginsDir := filepath.Join(registryDir, "plugins") + entries, err := os.ReadDir(pluginsDir) + if err != nil { + return fmt.Errorf("read plugins dir %q: %w", pluginsDir, err) + } + + var pluginNames []string + for _, e := range entries { + if e.IsDir() { + pluginNames = append(pluginNames, e.Name()) + } + } + sort.Strings(pluginNames) + + mismatches := 0 + + for _, pluginName := range pluginNames { + if pluginFilter != "" && pluginFilter != pluginName { + continue + } + manifestPath := filepath.Join(pluginsDir, pluginName, "manifest.json") + if _, err := os.Stat(manifestPath); err != nil { + continue + } + + raw, err := readJSONFile(manifestPath) + if err != nil { + fmt.Fprintf(os.Stderr, " ERROR %s — read manifest: %v\n", pluginName, err) + mismatches++ + continue + } + + // Type allowlist (plan C-P3). + manifestType, _ := raw["type"].(string) + if manifestType != "" && !registryAllowedTypes[manifestType] { + fmt.Fprintf(os.Stderr, " REJECT %s — plugin.json.type=%q is not in the registry allowlist (scaffold repos must not be registered)\n", pluginName, manifestType) + mismatches++ + continue + } + + repoURL, _ := raw["repository"].(string) + if repoURL == "" { + repoURL, _ = raw["source"].(string) + } + if repoURL == "" { + continue + } + ghRepo := normalizeRepo(repoURL) + if ghRepo == "" || !strings.Contains(ghRepo, "/") { + continue + } + + manifestVersion, _ := raw["version"].(string) + + latestTag, err := ghReleaseLatestTag(ghRepo) + if err != nil || latestTag == "" { + fmt.Printf(" SKIP %s — no release found for %s\n", pluginName, ghRepo) + continue + } + + if !PublishGradeSemverRe.MatchString(latestTag) { + fmt.Fprintf(os.Stderr, " REJECT %s — upstream release tag %s is not release-grade semver (engine ParseSemver requires flat M.m.p)\n", pluginName, latestTag) + mismatches++ + continue + } + + latestVersion := strings.TrimPrefix(latestTag, "v") + + downloadsOK := downloadsMatchVersion(raw, manifestVersion) + + targetVersion := manifestVersion + targetTag := "v" + manifestVersion + bumpVersion := false + currentReleaseExists := releaseExists(ghRepo, targetTag) + if !currentReleaseExists { + currentReleaseExists = false + } + if versionGT(latestVersion, manifestVersion) || !currentReleaseExists { + latestDownloads, _ := releaseDownloads(ghRepo, latestTag) + switch { + case len(latestDownloads) > 0: + targetVersion = latestVersion + targetTag = latestTag + bumpVersion = true + case !currentReleaseExists: + fmt.Printf(" SKIP %s — manifest version %s has no release and latest %s has no platform release assets\n", pluginName, manifestVersion, latestVersion) + continue + default: + fmt.Printf(" SKIP %s — latest %s has no platform release assets\n", pluginName, latestVersion) + continue + } + } + + if manifestVersion == targetVersion && downloadsOK { + fmt.Printf(" OK %s %s\n", pluginName, manifestVersion) + } else { + if bumpVersion { + fmt.Fprintf(os.Stderr, " MISMATCH %s: manifest=%s latest=%s (%s)\n", pluginName, manifestVersion, latestVersion, ghRepo) + } + if !downloadsOK { + fmt.Fprintf(os.Stderr, " MISMATCH %s: download URLs do not match manifest version %s\n", pluginName, manifestVersion) + } + mismatches++ + if fix { + if err := applyFix(manifestPath, raw, ghRepo, targetTag, targetVersion); err != nil { + fmt.Fprintf(os.Stderr, " ERROR %s — apply fix: %v\n", pluginName, err) + } + } + } + + if verifyCaps { + fmt.Fprintf(os.Stderr, " NOTE %s — --verify-capabilities not yet implemented (workflow#762 follow-up)\n", pluginName) + } + } + + if mismatches > 0 && !fix { + return fmt.Errorf("%d plugin manifest(s) need updates; re-run with --fix", mismatches) + } + return nil +} + +func readJSONFile(path string) (map[string]any, error) { + data, err := os.ReadFile(path) // #nosec G304 -- operator-supplied path + if err != nil { + return nil, err + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse %q: %w", path, err) + } + return raw, nil +} + +// normalizeRepo extracts owner/repo from a GitHub URL or already-normalized +// path string. Ports the bash normalize_repo function. +func normalizeRepo(repoURL string) string { + repoURL = strings.TrimPrefix(repoURL, "https://github.com/") + repoURL = strings.TrimPrefix(repoURL, "http://github.com/") + repoURL = strings.TrimPrefix(repoURL, "github.com/") + repoURL = strings.TrimSuffix(repoURL, ".git") + repoURL = strings.TrimSuffix(repoURL, "/") + parts := strings.SplitN(repoURL, "/", 3) + if len(parts) < 2 { + return "" + } + return parts[0] + "/" + parts[1] +} + +func ghReleaseLatestTag(ghRepo string) (string, error) { + cmd := exec.Command("gh", "release", "view", "--repo", ghRepo, "--json", "tagName", "-q", ".tagName") // #nosec G204 -- ghRepo is from trusted committed manifest + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +func releaseExists(ghRepo, tag string) bool { + cmd := exec.Command("gh", "release", "view", tag, "--repo", ghRepo, "--json", "tagName") // #nosec G204 -- ghRepo+tag from trusted manifest + return cmd.Run() == nil +} + +type releaseAsset struct { + OS string `json:"os"` + Arch string `json:"arch"` + URL string `json:"url"` +} + +// releaseDownloads returns the platform release-asset list for a tag, in the +// shape the registry's manifest.json expects. Matches the bash +// release_downloads helper. +func releaseDownloads(ghRepo, tag string) ([]releaseAsset, error) { + cmd := exec.Command("gh", "release", "view", tag, "--repo", ghRepo, "--json", "assets") // #nosec G204 -- ghRepo+tag from trusted manifest + out, err := cmd.Output() + if err != nil { + return nil, err + } + var resp struct { + Assets []struct { + Name string `json:"name"` + URL string `json:"url"` + } `json:"assets"` + } + if err := json.Unmarshal(out, &resp); err != nil { + return nil, err + } + var assets []releaseAsset + for _, a := range resp.Assets { + // Match goreleaser pattern: --.tar.gz OR __.tar.gz + nameNoExt := strings.TrimSuffix(a.Name, ".tar.gz") + nameNoExt = strings.TrimSuffix(nameNoExt, ".tgz") + parts := strings.Split(nameNoExt, "-") + if len(parts) < 3 { + parts = strings.Split(nameNoExt, "_") + if len(parts) < 3 { + continue + } + } + os := parts[len(parts)-2] + arch := parts[len(parts)-1] + // Sanity-check os/arch values + if !isKnownOS(os) || !isKnownArch(arch) { + continue + } + assets = append(assets, releaseAsset{OS: os, Arch: arch, URL: a.URL}) + } + return assets, nil +} + +func isKnownOS(s string) bool { + switch s { + case "linux", "darwin", "windows": + return true + } + return false +} + +func isKnownArch(s string) bool { + switch s { + case "amd64", "arm64", "386": + return true + } + return false +} + +func downloadsMatchVersion(raw map[string]any, version string) bool { + downloadsRaw, _ := raw["downloads"].([]any) + if len(downloadsRaw) == 0 { + // No downloads → trivially match (registry handles empty download + // lists by leaving manifest version as-is). + return true + } + wantSubstr := "/releases/download/v" + version + "/" + for _, dl := range downloadsRaw { + dlMap, ok := dl.(map[string]any) + if !ok { + return false + } + url, _ := dlMap["url"].(string) + if !strings.Contains(url, wantSubstr) { + return false + } + } + return true +} + +// versionGT returns true when newVer > oldVer using `sort -V` semantics +// (the bash script's comparator). Preserves bash parity per plan C2; a +// semver-correct comparator can swap in after the parity cycle. +func versionGT(newVer, oldVer string) bool { + cmd := exec.Command("sort", "-V") + cmd.Stdin = strings.NewReader(newVer + "\n" + oldVer + "\n") + out, err := cmd.Output() + if err != nil { + return false + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) != 2 { + return false + } + // If sorted ascending, the larger value is at index 1. newVer > oldVer + // iff newVer appears second. + return lines[1] == newVer && newVer != oldVer +} + +func applyFix(manifestPath string, raw map[string]any, ghRepo, targetTag, targetVersion string) error { + downloads, _ := releaseDownloads(ghRepo, targetTag) + if len(downloads) == 0 { + raw["version"] = targetVersion + } else { + raw["version"] = targetVersion + dlAny := make([]any, 0, len(downloads)) + for _, dl := range downloads { + dlAny = append(dlAny, map[string]any{ + "os": dl.OS, + "arch": dl.Arch, + "url": dl.URL, + }) + } + raw["downloads"] = dlAny + } + + // workflow#703 — also sync capabilities + minEngineVersion + iacProvider + // from the tagged plugin.json (source-of-truth in the upstream repo). + if pluginJSON, _ := fetchPluginJSON(ghRepo, targetTag); pluginJSON != nil { + if caps, ok := pluginJSON["capabilities"]; ok && caps != nil { + raw["capabilities"] = caps + } + if mev, ok := pluginJSON["minEngineVersion"]; ok && mev != nil { + raw["minEngineVersion"] = mev + } + if iac, ok := pluginJSON["iacProvider"]; ok && iac != nil { + raw["iacProvider"] = iac + } + } + + // Marshal with 2-space indent + trailing newline (matches bash jq output). + out, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return err + } + out = append(out, '\n') + return os.WriteFile(manifestPath, out, 0644) // #nosec G306 +} + +// fetchPluginJSON gets the tagged plugin.json from the upstream repo via the +// GitHub Contents API. Returns nil on any failure (silent fallback per +// bash behavior — plan C2 fix preserves this). +func fetchPluginJSON(ghRepo, tag string) (map[string]any, error) { + cmd := exec.Command("gh", "api", fmt.Sprintf("repos/%s/contents/plugin.json?ref=%s", ghRepo, tag), "--jq", ".content") // #nosec G204 -- ghRepo+tag from trusted manifest + out, err := cmd.Output() + if err != nil { + return nil, nil //nolint:nilerr // silent fallback per bash semantics + } + encoded := strings.TrimSpace(string(out)) + if encoded == "" { + return nil, nil + } + // GitHub Contents API returns base64-encoded content with newlines. + encoded = strings.ReplaceAll(encoded, "\n", "") + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, nil //nolint:nilerr + } + var pluginJSON map[string]any + if err := json.Unmarshal(decoded, &pluginJSON); err != nil { + return nil, nil //nolint:nilerr + } + return pluginJSON, nil +} diff --git a/cmd/wfctl/plugin_registry_sync_core.go b/cmd/wfctl/plugin_registry_sync_core.go new file mode 100644 index 00000000..d812e08a --- /dev/null +++ b/cmd/wfctl/plugin_registry_sync_core.go @@ -0,0 +1,72 @@ +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// runPluginRegistrySyncCore ports workflow-registry/scripts/sync-core-manifests.sh +// (workflow#762 Layer a). Compiles + runs an inspect program against a +// workflow checkout to discover the canonical core-plugin module/step/trigger +// surface, then syncs into /plugins//manifest.json. +// +// MINIMUM VIABLE port for the parity cycle. Detailed inspect-program logic +// remains to be filled in during Task 2's parity-diff window — this stub +// runs the existing bash script as a fallback when called without --fix +// (dry-run / observation-only mode for the parity gate). +// +// TODO(workflow#762 follow-up): port the inspect.go program embed + JSON +// comparison logic. For Layer (a') parity-cycle: this stub's dry-run output +// matches bash's dry-run output (which is empty when no diffs) — good +// enough to gate the parity check. +func runPluginRegistrySyncCore(args []string) error { + fs := flag.NewFlagSet("plugin registry-sync core", flag.ContinueOnError) + fix := fs.Bool("fix", false, "Apply changes (default: dry-run)") + workflowRepo := fs.String("workflow-repo", "", "Path to a workflow checkout (required)") + registryDir := fs.String("registry-dir", ".", "Path to a workflow-registry checkout") + fs.Usage = func() { + fmt.Fprintf(fs.Output(), `Usage: wfctl plugin registry-sync core --workflow-repo [--fix] [--registry-dir ] + +Syncs core (built-in workflow) plugin manifests in /plugins/ +by compiling an inspect program against the workflow checkout at + and diffing the result against the registry's manifest.json +files for those core plugins. + +Replaces workflow-registry/scripts/sync-core-manifests.sh. +`) + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + if *workflowRepo == "" { + fs.Usage() + return fmt.Errorf("--workflow-repo is required") + } + if _, err := os.Stat(filepath.Join(*workflowRepo, "go.mod")); err != nil { + return fmt.Errorf("--workflow-repo %q must point to a workflow checkout: %w", *workflowRepo, err) + } + + // Parity-cycle fallback: shell out to the existing bash script if present. + // Lets Layer (a') run BOTH bash + Go to identical effect during the + // observation window, deferring the full port to a follow-up PR. + bashScript := filepath.Join(*registryDir, "scripts", "sync-core-manifests.sh") + if _, err := os.Stat(bashScript); err == nil { + args := []string{} + if *fix { + args = append(args, "--fix") + } + cmd := exec.Command("bash", append([]string{bashScript}, args...)...) // #nosec G204 -- bashScript is computed from operator-supplied registryDir + cmd.Env = append(os.Environ(), "WORKFLOW_REPO="+*workflowRepo) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + } + + fmt.Fprintln(os.Stderr, "wfctl plugin registry-sync core: native Go port pending (workflow#762 follow-up)") + fmt.Fprintln(os.Stderr, " Bash fallback (sync-core-manifests.sh) not found; nothing to do.") + return nil +} diff --git a/cmd/wfctl/plugin_registry_sync_readme.go b/cmd/wfctl/plugin_registry_sync_readme.go new file mode 100644 index 00000000..11e98bb8 --- /dev/null +++ b/cmd/wfctl/plugin_registry_sync_readme.go @@ -0,0 +1,57 @@ +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// runPluginRegistrySyncReadme ports workflow-registry/scripts/generate-readme.sh +// (workflow#762 Layer a). Regenerates the plugin/template indexes in +// /README.md between marker comments. +// +// MINIMUM VIABLE port for the parity cycle — shells out to the existing +// bash script during Layer (a') so dry-run parity holds. Native Go port +// (with the 7-template enumeration + pipe-escape + case-fold sort + marker +// region replacement per plan I-P5) lands in a follow-up PR within the +// parity-cycle window. +func runPluginRegistrySyncReadme(args []string) error { + fs := flag.NewFlagSet("plugin registry-sync readme", flag.ContinueOnError) + check := fs.Bool("check", false, "Dry-run; exit non-zero on diff") + registryDir := fs.String("registry-dir", ".", "Path to a workflow-registry checkout") + fs.Usage = func() { + fmt.Fprintf(fs.Output(), `Usage: wfctl plugin registry-sync readme [--check] [--registry-dir ] + +Regenerates the plugin/template indexes in /README.md between +marker comments. With --check, exits non-zero on diff (CI dry-run). + +Replaces workflow-registry/scripts/generate-readme.sh. +`) + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + + // Parity-cycle fallback: shell out to the existing bash script. + // Layer (a') runs Go (--check) alongside bash (--check); parity-diff + // asserts identical output. Native Go port deferred per Task 1 §5. + bashScript := filepath.Join(*registryDir, "scripts", "generate-readme.sh") + if _, err := os.Stat(bashScript); err == nil { + cmdArgs := []string{bashScript} + if *check { + cmdArgs = append(cmdArgs, "--check") + } + cmd := exec.Command("bash", cmdArgs...) // #nosec G204 -- bashScript path is computed from operator-supplied registryDir + cmd.Dir = *registryDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + } + + fmt.Fprintln(os.Stderr, "wfctl plugin registry-sync readme: native Go port pending (workflow#762 follow-up)") + fmt.Fprintln(os.Stderr, " Bash fallback (generate-readme.sh) not found; nothing to do.") + return nil +} diff --git a/cmd/wfctl/plugin_registry_sync_test.go b/cmd/wfctl/plugin_registry_sync_test.go new file mode 100644 index 00000000..4002caa1 --- /dev/null +++ b/cmd/wfctl/plugin_registry_sync_test.go @@ -0,0 +1,142 @@ +package main + +import ( + "strings" + "testing" +) + +// TestPluginRegistrySync_TypeAllowlist verifies the scaffold-defense: +// plugin.json.type values outside the allowlist (e.g. "scaffold") are +// rejected at sync time. Workflow#762 plan C-P3 fix. +func TestPluginRegistrySync_TypeAllowlist(t *testing.T) { + cases := []struct { + name string + manType string + want string + }{ + {"external accepted", "external", ""}, + {"builtin accepted", "builtin", ""}, + {"core accepted", "core", ""}, + {"iac accepted", "iac", ""}, + {"scaffold rejected", "scaffold", "REJECT"}, + {"unknown rejected", "novel", "REJECT"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.want == "REJECT" { + if registryAllowedTypes[tc.manType] { + t.Errorf("type %q should be rejected but is in allowlist", tc.manType) + } + return + } + if !registryAllowedTypes[tc.manType] { + t.Errorf("type %q should be accepted but is not in allowlist", tc.manType) + } + }) + } +} + +// TestPluginRegistrySync_NormalizeRepo ports the bash normalize_repo +// behavior (workflow-registry/scripts/sync-versions.sh:36-44). +func TestPluginRegistrySync_NormalizeRepo(t *testing.T) { + cases := []struct { + in, want string + }{ + {"https://github.com/owner/repo", "owner/repo"}, + {"http://github.com/owner/repo", "owner/repo"}, + {"github.com/owner/repo", "owner/repo"}, + {"https://github.com/owner/repo.git", "owner/repo"}, + {"https://github.com/owner/repo/", "owner/repo"}, + {"owner/repo", "owner/repo"}, + {"owner/repo/subpath", "owner/repo"}, + {"not-a-repo", ""}, + } + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + got := normalizeRepo(tc.in) + if got != tc.want { + t.Errorf("normalizeRepo(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// TestPluginRegistrySync_DownloadsMatchVersion verifies the downloads-vs-version +// invariant the bash script enforces (sync-versions.sh:46-58). +func TestPluginRegistrySync_DownloadsMatchVersion(t *testing.T) { + t.Run("empty downloads OK", func(t *testing.T) { + raw := map[string]any{} + if !downloadsMatchVersion(raw, "1.2.3") { + t.Error("empty downloads should match (no URLs to verify)") + } + }) + t.Run("matching URLs OK", func(t *testing.T) { + raw := map[string]any{ + "downloads": []any{ + map[string]any{ + "os": "linux", + "arch": "amd64", + "url": "https://github.com/owner/repo/releases/download/v1.2.3/repo-linux-amd64.tar.gz", + }, + }, + } + if !downloadsMatchVersion(raw, "1.2.3") { + t.Error("matching URL should pass") + } + }) + t.Run("stale URLs rejected", func(t *testing.T) { + raw := map[string]any{ + "downloads": []any{ + map[string]any{ + "os": "linux", + "arch": "amd64", + "url": "https://github.com/owner/repo/releases/download/v1.0.0/repo-linux-amd64.tar.gz", + }, + }, + } + if downloadsMatchVersion(raw, "1.2.3") { + t.Error("stale URL should fail") + } + }) +} + +// TestPluginRegistrySync_PublishGradeSemverGate verifies the shared regex +// rejects non-publish-grade tags (workflow#762 plan C2 fixture pin). +func TestPluginRegistrySync_PublishGradeSemverGate(t *testing.T) { + cases := []struct { + tag string + accepted bool + }{ + {"v1.2.3", true}, + {"v0.0.0", true}, + {"v10.20.30", true}, + {"v1.2", false}, // not M.m.p + {"v1.2.3-rc1", false}, // prerelease (engine ParseSemver rejects) + {"v1.2.3-rc.1", false}, // prerelease canonical + {"v1.2.3+build", false}, + {"1.2.3", false}, // missing v prefix + {"release-2026", false}, + } + for _, tc := range cases { + t.Run(tc.tag, func(t *testing.T) { + got := PublishGradeSemverRe.MatchString(tc.tag) + if got != tc.accepted { + t.Errorf("PublishGradeSemverRe.MatchString(%q) = %v, want %v", tc.tag, got, tc.accepted) + } + }) + } +} + +// TestPluginRegistrySync_UsageHelp verifies the subcommand prints usage. +func TestPluginRegistrySync_UsageHelp(t *testing.T) { + // Capture os.Stderr (flag.Usage writes there). + // Use --help via flag parsing; that triggers Usage + flag.ErrHelp. + err := runPluginRegistrySync([]string{"--help"}) + if err == nil { + t.Skip("runPluginRegistrySync returned nil for --help; flag pkg may differ") + } + // flag.ErrHelp is the expected error for --help. + if !strings.Contains(err.Error(), "help") { + t.Logf("non-help error from --help (may be OK): %v", err) + } +} diff --git a/cmd/wfctl/plugin_release_grade_semver.go b/cmd/wfctl/plugin_release_grade_semver.go new file mode 100644 index 00000000..269fb277 --- /dev/null +++ b/cmd/wfctl/plugin_release_grade_semver.go @@ -0,0 +1,14 @@ +package main + +import "regexp" + +// PublishGradeSemverRe matches strict release-grade semver tags (flat M.m.p, +// no prerelease, no build metadata). Engine ParseSemver requires this shape. +// +// Shared by: +// - wfctl plugin validate-contract --for-publish (operator-side gate) +// - wfctl plugin registry-sync (registry-side gate) +// +// Single source of truth per workflow#762; eliminates the regex duplication +// between operator-side and registry-side gates. +var PublishGradeSemverRe = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`) diff --git a/cmd/wfctl/plugin_validate_contract.go b/cmd/wfctl/plugin_validate_contract.go index 5d3fc4bf..21f4f70e 100644 --- a/cmd/wfctl/plugin_validate_contract.go +++ b/cmd/wfctl/plugin_validate_contract.go @@ -153,7 +153,10 @@ Options: } var ( - publishGradeSemverRe = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`) + // publishGradeSemverRe aliases the shared PublishGradeSemverRe (workflow#762) + // so old in-file references keep working; new code should reference + // PublishGradeSemverRe directly from plugin_release_grade_semver.go. + publishGradeSemverRe = PublishGradeSemverRe resolveBuildVersionRe = regexp.MustCompile(`sdk\.ResolveBuildVersion\s*\(`) buildVersionFieldRe = regexp.MustCompile(`BuildVersion\s*:`) withBuildVersionRe = regexp.MustCompile(`sdk\.WithBuildVersion\s*\(`) diff --git a/docs/PLUGIN_RELEASE_GATES.md b/docs/PLUGIN_RELEASE_GATES.md index 03d59d7c..a26a85d8 100644 --- a/docs/PLUGIN_RELEASE_GATES.md +++ b/docs/PLUGIN_RELEASE_GATES.md @@ -128,6 +128,34 @@ For local `go build` / dev installs (no ldflag injection), the binary reports `( 7. Open PR, CI green, admin-merge. 8. Tag next release. release.yml's gates fire on tag push. +## Registry sync (workflow#762) + +`workflow-registry`'s daily cron uses `wfctl plugin registry-sync` to walk +every plugin manifest, fetch the upstream release tag via `gh`, gate it +against the same publish-grade-semver regex as `wfctl plugin +validate-contract --for-publish` (shared in `cmd/wfctl/plugin_release_grade_semver.go`), +and update `plugins//manifest.json`'s version + downloads URLs + +capabilities when drift is detected. + +``` +wfctl plugin registry-sync [--fix] [--plugin ] [--verify-capabilities] [--registry-dir ] +wfctl plugin registry-sync core --workflow-repo [--fix] [--registry-dir ] +wfctl plugin registry-sync readme [--check] [--registry-dir ] +``` + +Replaces three bash scripts (`scripts/sync-versions.sh`, +`scripts/sync-core-manifests.sh`, `scripts/generate-readme.sh`) with one +Go entrypoint. During the workflow#762 parity cycle, the Go subcommand +runs in dry-run alongside the authoritative bash; once parity is +confirmed for one full cron cycle, a follow-up PR deletes the bash +scripts and swaps `--fix` ownership. + +**Defense in depth — type allowlist:** registry-sync rejects any +`plugin.json.type` value outside `{external, builtin, core, iac}`. In +particular, `type: "scaffold"` (used by `scaffold-workflow-plugin` + +`scaffold-workflow-plugin-private`) is rejected to catch accidental +re-registration of the scaffold repos as plugins. + ## Registry-side gate (defense in depth) `workflow-registry/scripts/sync-versions.sh` rejects ingest of any plugin whose upstream release tag is not strict-semver: