From 1f386a3d140d115ade5883ddcced83db4e094de7 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 24 Oct 2026 03:36:18 -0400 Subject: [PATCH] fix(registry-sync): select archives and metadata by catalog role --- cmd/wfctl/plugin_registry_sync.go | 50 ++- cmd/wfctl/plugin_registry_sync_roles.go | 116 +++++ cmd/wfctl/plugin_registry_sync_roles_test.go | 449 +++++++++++++++++++ cmd/wfctl/plugin_registry_sync_target.go | 38 +- docs/PLUGIN_RELEASE_GATES.md | 16 + 5 files changed, 647 insertions(+), 22 deletions(-) create mode 100644 cmd/wfctl/plugin_registry_sync_roles.go create mode 100644 cmd/wfctl/plugin_registry_sync_roles_test.go diff --git a/cmd/wfctl/plugin_registry_sync.go b/cmd/wfctl/plugin_registry_sync.go index beb17bb3..1bf74d6e 100644 --- a/cmd/wfctl/plugin_registry_sync.go +++ b/cmd/wfctl/plugin_registry_sync.go @@ -136,6 +136,7 @@ func syncDefault(registryDir string, fix bool, pluginFilter string, verifyCaps b sort.Strings(pluginNames) mismatches := 0 + failures := 0 for _, pluginName := range pluginNames { if pluginFilter != "" && pluginFilter != pluginName { @@ -193,7 +194,7 @@ func syncDefault(registryDir string, fix bool, pluginFilter string, verifyCaps b latestVersion := strings.TrimPrefix(latestTag, "v") - downloadsOK := downloadsMatchVersion(raw, manifestVersion) + downloadsOK := downloadsMatchVersion(raw, manifestVersion) && registrySyncDownloadsMatchRole(raw, ghRepo, pluginName) targetVersion := manifestVersion targetTag := "v" + manifestVersion @@ -203,10 +204,11 @@ func syncDefault(registryDir string, fix bool, pluginFilter string, verifyCaps b currentReleaseExists = false } if versionGT(latestVersion, manifestVersion) || !currentReleaseExists { - latestDownloads, err := releaseDownloads(ghRepo, latestTag) + latestDownloads, err := registrySyncCatalogDownloads(ghRepo, latestTag, pluginName) if err != nil { fmt.Fprintf(os.Stderr, " ERROR %s — list release downloads for %s: %v\n", pluginName, latestTag, err) mismatches++ + failures++ continue } switch { @@ -224,7 +226,13 @@ func syncDefault(registryDir string, fix bool, pluginFilter string, verifyCaps b } metadataChanged := false - if pluginJSON, _ := registrySyncFetchPluginJSON(ghRepo, targetTag); pluginJSON != nil { + pluginJSON, err := registrySyncCatalogMetadata(ghRepo, targetTag, pluginName) + if err != nil { + fmt.Fprintf(os.Stderr, " ERROR %s — read catalog metadata: %v\n", pluginName, err) + failures++ + continue + } + if pluginJSON != nil { metadataChanged = syncManifestMetadataFromPluginJSON(raw, pluginJSON) } @@ -235,15 +243,17 @@ func syncDefault(registryDir string, fix bool, pluginFilter string, verifyCaps b 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) + fmt.Fprintf(os.Stderr, " MISMATCH %s: downloads do not match catalog role or manifest version %s\n", pluginName, manifestVersion) } if metadataChanged { - fmt.Fprintf(os.Stderr, " MISMATCH %s: registry metadata differs from %s plugin.json\n", pluginName, targetTag) + fmt.Fprintf(os.Stderr, " MISMATCH %s: registry metadata differs from %s %s\n", pluginName, targetTag, registrySyncRole(ghRepo, pluginName).metadataFile) } 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) + failures++ + continue } } } @@ -256,12 +266,16 @@ func syncDefault(registryDir string, fix bool, pluginFilter string, verifyCaps b if err := verifyRegistryPluginCapabilities(verifyName, manifestPath, ghRepo, targetTag); err != nil { fmt.Fprintf(os.Stderr, " ERROR %s — verify capabilities: %v\n", pluginName, err) mismatches++ + failures++ continue } fmt.Printf(" OK %s capabilities verified against %s (%s/%s)\n", pluginName, targetTag, runtime.GOOS, runtime.GOARCH) } } + if failures > 0 { + return fmt.Errorf("%d plugin manifest(s) could not be synced; see errors above", failures) + } if mismatches > 0 && !fix { return fmt.Errorf("%d plugin manifest(s) need updates; re-run with --fix", mismatches) } @@ -494,7 +508,7 @@ var ( ) func verifyRegistryPluginCapabilities(pluginName, manifestPath, ghRepo, tag string) error { - assets, err := registrySyncReleaseDownloads(ghRepo, tag) + assets, err := registrySyncCatalogDownloads(ghRepo, tag, registrySyncCatalog(manifestPath)) if err != nil { return fmt.Errorf("list release downloads for %s %s: %w", ghRepo, tag, err) } @@ -677,10 +691,15 @@ func versionGT(newVer, oldVer string) bool { } func applyFix(manifestPath string, raw map[string]any, ghRepo, targetTag, targetVersion string) error { - downloads, err := registrySyncReleaseDownloads(ghRepo, targetTag) + catalog := registrySyncCatalog(manifestPath) + downloads, err := registrySyncCatalogDownloads(ghRepo, targetTag, catalog) if err != nil { return fmt.Errorf("list release downloads: %w", err) } + pluginJSON, err := registrySyncCatalogMetadata(ghRepo, targetTag, catalog) + if err != nil { + return fmt.Errorf("read catalog metadata: %w", err) + } if len(downloads) == 0 { raw["version"] = targetVersion } else { @@ -707,7 +726,7 @@ func applyFix(manifestPath string, raw map[string]any, ghRepo, targetTag, target // workflow#703 — also sync capabilities + minEngineVersion + iacProvider // from the tagged plugin.json (source-of-truth in the upstream repo). - if pluginJSON, _ := registrySyncFetchPluginJSON(ghRepo, targetTag); pluginJSON != nil { + if pluginJSON != nil { syncManifestMetadataFromPluginJSON(raw, pluginJSON) } @@ -821,24 +840,29 @@ func appendUniqueStrings(base []string, values ...string) []string { // 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 + metadata, _ := fetchPluginJSONAtPath(ghRepo, tag, "plugin.json") + return metadata, nil // silent fallback per bash semantics +} + +func fetchPluginJSONAtPath(ghRepo, tag, filename string) (map[string]any, error) { + cmd := exec.Command("gh", "api", fmt.Sprintf("repos/%s/contents/%s?ref=%s", ghRepo, filename, tag), "--jq", ".content") // #nosec G204 -- ghRepo+tag from trusted manifest; filename is a catalog constant out, err := cmd.Output() if err != nil { - return nil, nil //nolint:nilerr // silent fallback per bash semantics + return nil, fmt.Errorf("fetch %s at %s: %w", filename, tag, err) } encoded := strings.TrimSpace(string(out)) if encoded == "" { - return nil, nil + return nil, fmt.Errorf("empty %s at %s", filename, tag) } // 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 + return nil, fmt.Errorf("decode %s: %w", filename, err) } var pluginJSON map[string]any if err := json.Unmarshal(decoded, &pluginJSON); err != nil { - return nil, nil //nolint:nilerr + return nil, fmt.Errorf("parse %s: %w", filename, err) } return pluginJSON, nil } diff --git a/cmd/wfctl/plugin_registry_sync_roles.go b/cmd/wfctl/plugin_registry_sync_roles.go new file mode 100644 index 00000000..e80736c2 --- /dev/null +++ b/cmd/wfctl/plugin_registry_sync_roles.go @@ -0,0 +1,116 @@ +package main + +import ( + "fmt" + "net/url" + "path" + "path/filepath" + "slices" + "strings" +) + +// These catalogs share releases with standalone tools or other plugins. Keep +// their archive and metadata roles aligned with the registry catalog contract. +// Use the catalog directory and repository, not the editable manifest name. +type registrySyncCatalogRole struct { + artifact string + metadataFile string + platformOS []string +} + +func registrySyncRole(repo, catalog string) registrySyncCatalogRole { + role := registrySyncCatalogRole{metadataFile: "plugin.json"} + switch { + case strings.EqualFold(repo, "GoCodeAlone/workflow-plugin-github") && catalog == "github": + role.artifact = "workflow-plugin-github" + case strings.EqualFold(repo, "GoCodeAlone/workflow-plugin-migrations") && catalog == "workflow-plugin-migrations": + role.artifact = "workflow-plugin-migrations" + case strings.EqualFold(repo, "GoCodeAlone/workflow-plugin-migrations") && catalog == "workflow-plugin-atlas-migrate": + role.artifact = "workflow-plugin-atlas-migrate" + role.metadataFile = "plugin.atlas.json" + } + if role.artifact != "" { + role.platformOS = []string{"darwin", "linux", "windows"} + if role.metadataFile == "plugin.atlas.json" { + role.platformOS = []string{"darwin", "linux"} + } + } + return role +} + +func registrySyncCatalog(manifestPath string) string { + return filepath.Base(filepath.Dir(manifestPath)) +} + +func (role registrySyncCatalogRole) selectDownloads(assets []releaseAsset) ([]releaseAsset, error) { + if role.artifact == "" { + return assets, nil + } + var selected []releaseAsset + seen := map[string]bool{} + for _, asset := range assets { + if !slices.Contains(role.platformOS, asset.OS) || (asset.Arch != "amd64" && asset.Arch != "arm64") || + asset.Name != role.artifact+"-"+asset.OS+"-"+asset.Arch+".tar.gz" { + continue + } + platform := asset.OS + "/" + asset.Arch + if seen[platform] { + return nil, fmt.Errorf("duplicate %s release platform %s", role.artifact, platform) + } + seen[platform] = true + selected = append(selected, asset) + } + for _, goos := range role.platformOS { + for _, arch := range []string{"amd64", "arm64"} { + if !seen[goos+"/"+arch] { + return nil, fmt.Errorf("missing full plugin archive %s-%s-%s.tar.gz", role.artifact, goos, arch) + } + } + } + return selected, nil +} + +func registrySyncCatalogDownloads(repo, tag, catalog string) ([]releaseAsset, error) { + assets, err := registrySyncReleaseDownloads(repo, tag) + if err != nil { + return nil, err + } + return registrySyncRole(repo, catalog).selectDownloads(assets) +} + +func registrySyncDownloadsMatchRole(raw map[string]any, repo, catalog string) bool { + role := registrySyncRole(repo, catalog) + if role.artifact == "" { + return true + } + downloads, _ := raw["downloads"].([]any) + assets := make([]releaseAsset, 0, len(downloads)) + for _, value := range downloads { + entry, _ := value.(map[string]any) + assetURL, _ := entry["url"].(string) + parsed, err := url.Parse(assetURL) + if err != nil { + return false + } + goos, _ := entry["os"].(string) + arch, _ := entry["arch"].(string) + assets = append(assets, releaseAsset{Name: path.Base(parsed.Path), OS: goos, Arch: arch}) + } + selected, err := role.selectDownloads(assets) + return err == nil && len(selected) == len(downloads) +} + +func registrySyncCatalogMetadata(repo, tag, catalog string) (map[string]any, error) { + role := registrySyncRole(repo, catalog) + if role.metadataFile == "plugin.json" { + return registrySyncFetchPluginJSON(repo, tag) + } + metadata, err := fetchPluginJSONAtPath(repo, tag, role.metadataFile) + if err != nil { + return nil, err + } + if metadata["name"] != role.artifact { + return nil, fmt.Errorf("%s name must be %s", role.metadataFile, role.artifact) + } + return metadata, nil +} diff --git a/cmd/wfctl/plugin_registry_sync_roles_test.go b/cmd/wfctl/plugin_registry_sync_roles_test.go new file mode 100644 index 00000000..ebd69646 --- /dev/null +++ b/cmd/wfctl/plugin_registry_sync_roles_test.go @@ -0,0 +1,449 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "strings" + "sync" + "testing" +) + +// These fixtures model the catalog contract, not real release checksums. Mixed +// releases deliberately place standalone binaries before the plugin archives. +type catalogRoleFixture struct { + root, manifest, catalog, repo, family string + assets []releaseAsset + atlasVersion string + atlasName string + missingAtlas bool + mutateSibling bool + apiURL string + handler http.Handler + mu sync.Mutex + requests []string + releaseReads int +} + +func newCatalogRoleFixture(t *testing.T, catalog string) *catalogRoleFixture { + t.Helper() + restoreRegistrySyncTestHooks(t) + f := &catalogRoleFixture{root: t.TempDir(), catalog: catalog, atlasVersion: "0.4.1", atlasName: "workflow-plugin-atlas-migrate"} + f.repo, f.family = "GoCodeAlone/workflow-plugin-migrations", catalog + families := []string{"db-migrate", "workflow-plugin-migrations", "workflow-plugin-atlas-migrate"} + if catalog == "github" { + f.repo, f.family = "GoCodeAlone/workflow-plugin-github", "workflow-plugin-github" + families = []string{"github", "workflow-plugin-github"} + } + for _, family := range families { + for _, goos := range []string{"darwin", "linux", "windows"} { + if family == "workflow-plugin-atlas-migrate" && goos == "windows" { + continue + } + for _, arch := range []string{"amd64", "arm64"} { + name := family + "-" + goos + "-" + arch + ".tar.gz" + f.assets = append(f.assets, releaseAsset{Name: name, OS: goos, Arch: arch, + URL: "https://github.com/" + f.repo + "/releases/download/v0.4.1/" + name, + SHA256: fmt.Sprintf("%064x", len(f.assets)+1)}) + } + } + } + f.manifest = filepath.Join(f.root, "plugins", catalog, "manifest.json") + f.writeManifest(t, "0.4.1", f.selected()) + f.handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + f.requests = append(f.requests, r.URL.RequestURI()) + prefix := "/repos/" + f.repo + var out any + switch { + case r.URL.Path == prefix+"/releases/latest": + out = map[string]any{"tag_name": "v0.4.1"} + case strings.HasPrefix(r.URL.Path, prefix+"/releases/tags/"): + f.releaseReads++ + assets := []any{map[string]any{"id": 100, "name": "checksums.txt", "size": 42, "browser_download_url": f.apiURL + "/checksums"}} + for i, asset := range f.assets { + id := 200 + i + if f.mutateSibling && f.releaseReads > 1 && i == 0 { + id += 1000 + } + assets = append(assets, map[string]any{"id": id, "name": asset.Name, "size": 42, + "browser_download_url": asset.URL, "digest": "sha256:" + asset.SHA256}) + } + out = map[string]any{"id": 1, "tag_name": strings.TrimPrefix(r.URL.Path, prefix+"/releases/tags/"), + "draft": false, "prerelease": false, "immutable": true, "assets": assets} + case strings.HasPrefix(r.URL.Path, prefix+"/git/ref/tags/"): + out = map[string]any{"object": map[string]any{"type": "commit", "sha": strings.Repeat("a", 40)}} + case r.URL.Path == "/checksums": + for _, asset := range f.assets { + fmt.Fprintf(w, "%s %s\n", asset.SHA256, asset.Name) + } + return + case r.URL.Path == prefix+"/contents/plugin.json" || r.URL.Path == prefix+"/contents/plugin.atlas.json": + if r.URL.Query().Get("ref") != strings.Repeat("a", 40) { + http.Error(w, "source must be pinned to the tag commit", http.StatusBadRequest) + return + } + atlas := strings.HasSuffix(r.URL.Path, "/plugin.atlas.json") + if atlas && f.missingAtlas { + http.NotFound(w, r) + return + } + out = map[string]any{"encoding": "base64", "content": base64.StdEncoding.EncodeToString(f.metadata(atlas))} + default: + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(out); err != nil { + t.Error(err) + } + }) + srv := httptest.NewServer(f.handler) + t.Cleanup(srv.Close) + f.apiURL = srv.URL + oldURL, oldClient := gitHubAPIBaseURL, gitHubAPIClient + gitHubAPIBaseURL, gitHubAPIClient = srv.URL, srv.Client() + t.Cleanup(func() { gitHubAPIBaseURL, gitHubAPIClient = oldURL, oldClient }) + + // Exercise the actual legacy contents-path selection without invoking a real + // GitHub CLI or contacting GitHub. Every unsupported command fails closed. + bin := t.TempDir() + script := "#!/bin/sh\n" + + "case \"$*\" in\n" + + " 'api repos/'*'/contents/plugin.atlas.json?ref='*) [ \"$CATALOG_TEST_MISSING_ATLAS\" != 1 ] || exit 1; printf '%s\\n' \"$CATALOG_TEST_ATLAS\" ;;\n" + + " 'api repos/'*'/contents/plugin.json?ref='*) printf '%s\\n' \"$CATALOG_TEST_MAIN\" ;;\n" + + " *) exit 1 ;;\nesac\n" + if err := os.WriteFile(filepath.Join(bin, "gh"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("CATALOG_TEST_MAIN", base64.StdEncoding.EncodeToString(f.metadata(false))) + t.Setenv("CATALOG_TEST_ATLAS", base64.StdEncoding.EncodeToString(f.metadata(true))) + t.Setenv("CATALOG_TEST_MISSING_ATLAS", "0") + return f +} + +func (f *catalogRoleFixture) metadata(atlas bool) []byte { + if atlas { + return []byte(fmt.Sprintf(`{"name":%q,"version":%q,"type":"external","minEngineVersion":"0.80.24","capabilities":{"moduleTypes":["database.migrations","database.migration_driver"],"migrationDrivers":["atlas"],"stepTypes":[],"cliCommands":[],"triggerTypes":[],"buildHooks":[],"configProvider":false}}`, f.atlasName, f.atlasVersion)) + } + if f.catalog == "github" { + return []byte(`{"name":"workflow-plugin-github","version":"0.4.1","type":"external","capabilities":{"moduleTypes":["github.app"],"stepTypes":["step.gh_pr_create"]}}`) + } + return []byte(`{"name":"workflow-plugin-migrations","version":"0.4.1","type":"external","minEngineVersion":"0.80.24","capabilities":{"moduleTypes":["database.migrations","database.migration_driver"],"migrationDrivers":["golang-migrate","goose"],"stepTypes":["step.migrate_up"],"cliCommands":[{"name":"db-migrate"}],"triggerTypes":[],"buildHooks":[],"configProvider":false}}`) +} + +func (f *catalogRoleFixture) selected() []releaseAsset { + var selected []releaseAsset + for _, asset := range f.assets { + if assetBinaryName(asset.Name) == f.family { + selected = append(selected, asset) + } + } + return selected +} + +func (f *catalogRoleFixture) writeManifest(t *testing.T, version string, assets []releaseAsset) { + t.Helper() + var metadata map[string]any + if err := json.Unmarshal(f.metadata(f.catalog == "workflow-plugin-atlas-migrate"), &metadata); err != nil { + t.Fatal(err) + } + raw := map[string]any{"name": f.catalog, "version": version, "type": "external", "repository": "https://github.com/" + f.repo, + "description": "preserved", "capabilities": metadata["capabilities"]} + if value, ok := metadata["minEngineVersion"]; ok { + raw["minEngineVersion"] = value + } + downloads := []any{} + for _, asset := range assets { + downloads = append(downloads, map[string]any{"os": asset.OS, "arch": asset.Arch, "url": strings.ReplaceAll(asset.URL, "/v0.4.1/", "/v"+version+"/"), "sha256": asset.SHA256}) + } + raw["downloads"] = downloads + data, err := json.MarshalIndent(raw, "", " ") + if err != nil { + t.Fatal(err) + } + mustWrite(t, f.manifest, string(data)+"\n") +} + +func (f *catalogRoleFixture) assertSelectedManifest(t *testing.T) { + t.Helper() + raw, err := readJSONFile(f.manifest) + if err != nil { + t.Fatal(err) + } + downloads, _ := raw["downloads"].([]any) + want := map[string]releaseAsset{} + for _, asset := range f.selected() { + want[asset.URL] = asset + } + if len(downloads) != len(want) { + t.Errorf("%s selected %d downloads, want only %d full-plugin archives", f.catalog, len(downloads), len(want)) + } + for _, value := range downloads { + entry, _ := value.(map[string]any) + url, _ := entry["url"].(string) + asset, ok := want[url] + if !ok { + t.Errorf("unexpected sibling/standalone download %s", url) + continue + } + if entry["sha256"] != asset.SHA256 || entry["os"] != asset.OS || entry["arch"] != asset.Arch { + t.Errorf("selected archive lost platform/checksum identity: %v", entry) + } + delete(want, url) + } + if len(want) != 0 { + t.Errorf("missing selected archives: %v", want) + } + var metadata map[string]any + if err := json.Unmarshal(f.metadata(f.catalog == "workflow-plugin-atlas-migrate"), &metadata); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(raw["capabilities"], metadata["capabilities"]) { + t.Errorf("capabilities came from the wrong release role: got %v, want %v", raw["capabilities"], metadata["capabilities"]) + } + if raw["description"] != "preserved" { + t.Error("unrelated manifest metadata changed") + } +} + +func TestPluginRegistrySync_CatalogRoles_ApplyFix(t *testing.T) { + for _, catalog := range []string{"github", "workflow-plugin-migrations", "workflow-plugin-atlas-migrate"} { + t.Run(catalog, func(t *testing.T) { + f := newCatalogRoleFixture(t, catalog) + raw, err := readJSONFile(f.manifest) + if err != nil { + t.Fatal(err) + } + if err := applyFix(f.manifest, raw, f.repo, "v0.4.1", "0.4.1"); err != nil { + t.Fatal(err) + } + f.assertSelectedManifest(t) + }) + } +} + +func TestPluginRegistrySync_CatalogRoles_SameVersionDrift(t *testing.T) { + f := newCatalogRoleFixture(t, "github") + f.writeManifest(t, "0.4.1", f.assets) + before, _ := os.ReadFile(f.manifest) + if err := syncDefault(f.root, false, f.catalog, false); err == nil { + t.Error("same-version standalone downloads were incorrectly reported as current") + } + if after, _ := os.ReadFile(f.manifest); !bytes.Equal(before, after) { + t.Fatal("check-only sync changed manifest bytes") + } + if err := syncDefault(f.root, true, f.catalog, false); err != nil { + t.Fatal(err) + } + f.assertSelectedManifest(t) +} + +func TestPluginRegistrySync_CatalogRoles_RejectInvalidFamilyWithoutWrites(t *testing.T) { + for _, scenario := range []string{"missing-family", "missing-platform", "duplicate-platform", "missing-atlas-metadata"} { + t.Run(scenario, func(t *testing.T) { + f := newCatalogRoleFixture(t, "workflow-plugin-atlas-migrate") + f.writeManifest(t, "0.4.0", f.selected()) + switch scenario { + case "missing-family": + var assets []releaseAsset + for _, asset := range f.assets { + if assetBinaryName(asset.Name) != f.family { + assets = append(assets, asset) + } + } + f.assets = assets + case "missing-platform": + f.assets = f.assets[:len(f.assets)-1] + case "duplicate-platform": + f.assets = append(f.assets, f.selected()[0]) + case "missing-atlas-metadata": + f.missingAtlas = true + t.Setenv("CATALOG_TEST_MISSING_ATLAS", "1") + } + before, _ := os.ReadFile(f.manifest) + if err := syncDefault(f.root, true, f.catalog, false); err == nil { + t.Errorf("%s must return an actionable error from fix mode", scenario) + } + if after, _ := os.ReadFile(f.manifest); !bytes.Equal(before, after) { + t.Errorf("%s changed manifest bytes despite invalid role evidence", scenario) + } + }) + } +} + +func TestPluginRegistrySync_CatalogRoles_VerifyCapabilities(t *testing.T) { + for _, catalog := range []string{"github", "workflow-plugin-migrations", "workflow-plugin-atlas-migrate"} { + t.Run(catalog, func(t *testing.T) { + f := newCatalogRoleFixture(t, catalog) + if runtime.GOOS == "windows" && catalog == "workflow-plugin-atlas-migrate" { + t.Skip("Atlas has no Windows release role") + } + var downloaded string + registrySyncDownloadReleaseAsset = func(_, _, name, dir string) (string, error) { + downloaded = name + path := filepath.Join(dir, assetBinaryName(name)) + return path, os.WriteFile(path, []byte("synthetic executable; verification is stubbed"), 0o755) + } + verified := false + registrySyncVerifyManifest = func(_, manifest string, opts manifestCompareOptions) error { + verified = true + if manifest != f.manifest || !opts.SkipName { + t.Error("capability verification contract changed") + } + return nil + } + if err := verifyRegistryPluginCapabilities(catalog, f.manifest, f.repo, "v0.4.1"); err != nil { + t.Fatal(err) + } + want := f.family + "-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz" + if !verified || downloaded != want { + t.Errorf("verified=%v archive=%s, want selected role %s", verified, downloaded, want) + } + }) + } +} + +func TestRegistrySyncTarget_CatalogRoles_SelectPinnedMetadata(t *testing.T) { + for _, catalog := range []string{"github", "workflow-plugin-migrations", "workflow-plugin-atlas-migrate"} { + t.Run(catalog, func(t *testing.T) { + f := newCatalogRoleFixture(t, catalog) + f.writeManifest(t, "0.4.0", f.selected()) + if err := syncRegistryTarget(f.root, catalog, "v0.4.1", true, false); err != nil { + t.Fatalf("valid mixed release must select one full-plugin family: %v", err) + } + f.assertSelectedManifest(t) + filename := "plugin.json" + if catalog == "workflow-plugin-atlas-migrate" { + filename = "plugin.atlas.json" + } + f.mu.Lock() + requests := strings.Join(f.requests, "\n") + f.mu.Unlock() + if !strings.Contains(requests, "/contents/"+filename+"?ref="+strings.Repeat("a", 40)) { + t.Errorf("selected metadata was not read at pinned source commit: %s", requests) + } + }) + } +} + +func TestRegistrySyncTarget_CatalogRoles_PreserveSourceVersionGate(t *testing.T) { + f := newCatalogRoleFixture(t, "workflow-plugin-atlas-migrate") + // The real v0.4.1 Atlas source currently retains this placeholder. Role + // selection must not silently relax strict target source-version policy. + f.atlasVersion = "0.0.0" + before, _ := os.ReadFile(f.manifest) + err := syncRegistryTarget(f.root, f.catalog, "v0.4.1", true, false) + if err == nil || !strings.Contains(err.Error(), "version does not match target release") { + t.Errorf("want pinned Atlas source-version rejection, got %v", err) + } + if after, _ := os.ReadFile(f.manifest); !bytes.Equal(before, after) { + t.Fatal("source-version rejection changed manifest bytes") + } +} + +func TestRegistrySyncTarget_CatalogRoles_PreserveWholeReleaseBinding(t *testing.T) { + f := newCatalogRoleFixture(t, "workflow-plugin-atlas-migrate") + f.mutateSibling = true + before, _ := os.ReadFile(f.manifest) + err := syncRegistryTarget(f.root, f.catalog, "v0.4.1", true, false) + if err == nil || !strings.Contains(err.Error(), "changed") { + t.Errorf("want mutation rejection for nonselected sibling asset identity, got %v", err) + } + if after, _ := os.ReadFile(f.manifest); !bytes.Equal(before, after) { + t.Fatal("release mutation rejection changed manifest bytes") + } +} + +func TestPluginRegistrySync_CatalogRoles_CanonicalCatalogOverridesMutableName(t *testing.T) { + f := newCatalogRoleFixture(t, "workflow-plugin-atlas-migrate") + raw, err := readJSONFile(f.manifest) + if err != nil { + t.Fatal(err) + } + // A stale display name must not redirect this catalog to its sibling role. + raw["name"] = "workflow-plugin-migrations" + data, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + mustWrite(t, f.manifest, string(data)+"\n") + if err := applyFix(f.manifest, raw, f.repo, "v0.4.1", "0.4.1"); err != nil { + t.Fatal(err) + } + f.assertSelectedManifest(t) +} + +func TestPluginRegistrySync_CatalogRoles_RejectWrongMetadataIdentity(t *testing.T) { + for _, mode := range []string{"default", "target"} { + t.Run(mode, func(t *testing.T) { + f := newCatalogRoleFixture(t, "workflow-plugin-atlas-migrate") + f.atlasName = "workflow-plugin-migrations" + t.Setenv("CATALOG_TEST_ATLAS", base64.StdEncoding.EncodeToString(f.metadata(true))) + before, _ := os.ReadFile(f.manifest) + var err error + if mode == "default" { + err = syncDefault(f.root, true, f.catalog, false) + } else { + err = syncRegistryTarget(f.root, f.catalog, "v0.4.1", true, false) + } + // Default mode reports each actionable cause on stderr and returns + // an aggregate error; target mode returns the identity error itself. + if err == nil || (mode == "target" && !strings.Contains(err.Error(), "name")) { + t.Errorf("want selected source name mismatch error, got %v", err) + } + if after, _ := os.ReadFile(f.manifest); !bytes.Equal(before, after) { + t.Fatal("wrong metadata identity changed manifest bytes") + } + }) + } +} + +func TestPluginRegistrySync_CatalogRoles_VerifyFailureIsNotSwallowedByFix(t *testing.T) { + f := newCatalogRoleFixture(t, "workflow-plugin-atlas-migrate") + // The existing manifest is valid and already at the latest version. Only + // capability verification discovers that the release lost a required role + // archive, so --fix must propagate that failure even without a planned edit. + f.assets = f.assets[:len(f.assets)-1] + registrySyncDownloadReleaseAsset = func(_, _, _, _ string) (string, error) { + t.Fatal("incomplete role must be rejected before downloading any binary") + return "", nil + } + before, _ := os.ReadFile(f.manifest) + if err := syncDefault(f.root, true, f.catalog, true); err == nil { + t.Error("fix mode swallowed capability verification's incomplete-role error") + } + if after, _ := os.ReadFile(f.manifest); !bytes.Equal(before, after) { + t.Fatal("capability verification failure changed manifest bytes") + } +} + +func TestPluginRegistrySync_CatalogRoles_ActualDefaultCLI(t *testing.T) { + f := newCatalogRoleFixture(t, "workflow-plugin-atlas-migrate") + binary := registrySyncActualCLIBinary(t, runtime.GOOS, f.apiURL) + env := registrySyncHostEnvironment(os.Environ()) + var proxy *registrySyncReleaseProxy + if runtime.GOOS == "linux" { + proxy = newRegistrySyncReleaseProxy(t, f.handler) + env = append(env, "HTTPS_PROXY="+proxy.url, "NO_PROXY=127.0.0.1,localhost", "SSL_CERT_FILE="+proxy.caFile, "SSL_CERT_DIR="+proxy.caDirectory) + } + cmd := exec.CommandContext(t.Context(), binary, "plugin", "registry-sync", "--registry-dir", f.root, "--plugin", f.catalog, "--fix") + cmd.Env = env + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("actual default CLI failed: %v\n%s", err, out) + } + f.assertSelectedManifest(t) + if proxy != nil && (proxy.connects.Load() == 0 || proxy.requests.Load() == 0) { + t.Fatal("actual Linux CLI bypassed the credential-free default API fixture") + } +} diff --git a/cmd/wfctl/plugin_registry_sync_target.go b/cmd/wfctl/plugin_registry_sync_target.go index e6ccff1d..7aa4ef9f 100644 --- a/cmd/wfctl/plugin_registry_sync_target.go +++ b/cmd/wfctl/plugin_registry_sync_target.go @@ -96,7 +96,7 @@ func syncRegistryRelease(registryDir, plugin, tag, reportPath string, fix, verif tag = current } } - first, err := registrySyncReadRelease(repo, tag) + first, err := registrySyncReadRelease(repo, tag, plugin) if err != nil { return err } @@ -112,7 +112,7 @@ func syncRegistryRelease(registryDir, plugin, tag, reportPath string, fix, verif return err } } - second, err := registrySyncReadRelease(repo, tag) + second, err := registrySyncReadRelease(repo, tag, plugin) if err != nil { return fmt.Errorf("release/source changed or became unreadable during sync: %w", err) } @@ -196,7 +196,7 @@ func registrySyncRepository(repository string) (string, error) { return repo, nil } -func registrySyncReadRelease(repo, tag string) (*registrySyncReleaseSnapshot, error) { +func registrySyncReadRelease(repo, tag, catalog string) (*registrySyncReleaseSnapshot, error) { apiRepo := "repos/" + escapedGitHubRepoPath(repo) var ref struct { Object registrySyncGitObject `json:"object"` @@ -260,7 +260,7 @@ func registrySyncReadRelease(repo, tag string) (*registrySyncReleaseSnapshot, er return nil, err } snapshot.Checksums = checksums - seenPlatforms := map[string]bool{} + var downloads []releaseAsset for _, asset := range release.Assets { goos, goarch, ok := releaseAssetPlatform(asset.Name) if !ok { @@ -270,12 +270,23 @@ func registrySyncReadRelease(repo, tag string) (*registrySyncReleaseSnapshot, er if checksum == "" || (asset.Digest != "" && asset.Digest != "sha256:"+checksum) { return nil, fmt.Errorf("missing or inconsistent checksum for asset %s", asset.Name) } - platform := goos + "/" + goarch + downloads = append(downloads, releaseAsset{Name: asset.Name, OS: goos, Arch: goarch, URL: asset.BrowserDownloadURL, SHA256: checksum}) + } + // Validate every archive's identity and checksum above and keep the whole + // release in the snapshot. Only platform uniqueness is scoped to the chosen + // catalog role; sibling mutations must still invalidate the second read. + role := registrySyncRole(repo, catalog) + snapshot.Downloads, err = role.selectDownloads(downloads) + if err != nil { + return nil, err + } + seenPlatforms := map[string]bool{} + for _, asset := range snapshot.Downloads { + platform := asset.OS + "/" + asset.Arch if seenPlatforms[platform] { return nil, fmt.Errorf("duplicate release platform %s", platform) } seenPlatforms[platform] = true - snapshot.Downloads = append(snapshot.Downloads, releaseAsset{Name: asset.Name, OS: goos, Arch: goarch, URL: asset.BrowserDownloadURL, SHA256: checksum}) } if len(snapshot.Downloads) == 0 { return nil, fmt.Errorf("release has no platform assets") @@ -284,15 +295,24 @@ func registrySyncReadRelease(repo, tag string) (*registrySyncReleaseSnapshot, er Encoding string `json:"encoding"` Content string `json:"content"` } - if err := registrySyncGitHubJSON(apiRepo+"/contents/plugin.json?ref="+snapshot.SourceSHA, &content); err != nil { + if err := registrySyncGitHubJSON(apiRepo+"/contents/"+role.metadataFile+"?ref="+snapshot.SourceSHA, &content); err != nil { return nil, err } if content.Encoding != "base64" { - return nil, fmt.Errorf("source plugin.json must have base64 encoding") + return nil, fmt.Errorf("source %s must have base64 encoding", role.metadataFile) } snapshot.PluginJSON, err = base64.StdEncoding.DecodeString(strings.ReplaceAll(content.Content, "\n", "")) if err != nil { - return nil, fmt.Errorf("decode source plugin.json: %w", err) + return nil, fmt.Errorf("decode source %s: %w", role.metadataFile, err) + } + if role.metadataFile == "plugin.atlas.json" { + var metadata map[string]any + if err := registrySyncDecodeJSON(snapshot.PluginJSON, &metadata); err != nil { + return nil, fmt.Errorf("parse source %s: %w", role.metadataFile, err) + } + if metadata["name"] != role.artifact { + return nil, fmt.Errorf("%s name must be %s", role.metadataFile, role.artifact) + } } return snapshot, nil } diff --git a/docs/PLUGIN_RELEASE_GATES.md b/docs/PLUGIN_RELEASE_GATES.md index 2cb53073..1614748b 100644 --- a/docs/PLUGIN_RELEASE_GATES.md +++ b/docs/PLUGIN_RELEASE_GATES.md @@ -159,6 +159,22 @@ Go entrypoint. `registry-sync`, `registry-sync core`, and thin compatibility wrappers during migration, but the source of truth should be the `wfctl` implementation. +For releases containing multiple products, selection follows the catalog +directory and repository. The `github` and `workflow-plugin-migrations` +catalogs select their full plugin archives for Darwin, Linux, and Windows +(amd64 and arm64). `workflow-plugin-atlas-migrate` selects only the four +Darwin/Linux Atlas plugin archives from the migrations repository and reads +`plugin.atlas.json` for its driver-only capabilities. Standalone tools and +sibling plugins are excluded, including when repairing same-version drift +or choosing the binary for `--verify-capabilities`. Missing or duplicate +required archives, or missing/incorrect Atlas metadata, fail without writing +that manifest; `--fix` returns an error when a repair cannot be completed. + +Explicit target/report mode applies the same catalog selection while retaining +whole-release identity/checksum and source-commit checks across both reads. +Its source-version requirement is unchanged: a tagged source manifest with +the `0.0.0` development sentinel still fails an explicit version target. + **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` +