From d3815b6def63ccd6855243fb156ed1feee362b7b Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 14 Sep 2026 18:27:03 -0700 Subject: [PATCH 1/9] Validate a plugin repository: release tag, manifest, entry and identity Co-Authored-By: Claude Opus 5 (1M context) --- internal/registry/validate.go | 96 +++++++++++++++++++ internal/registry/validate_test.go | 144 ++++++++++++++++++++++++++++ internal/registry/validator.go | 66 +++++++++++++ internal/registry/validator_test.go | 75 +++++++++++++++ 4 files changed, 381 insertions(+) create mode 100644 internal/registry/validate.go create mode 100644 internal/registry/validate_test.go create mode 100644 internal/registry/validator.go create mode 100644 internal/registry/validator_test.go diff --git a/internal/registry/validate.go b/internal/registry/validate.go new file mode 100644 index 0000000..0489486 --- /dev/null +++ b/internal/registry/validate.go @@ -0,0 +1,96 @@ +package registry + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "regexp" + "sort" + "strings" +) + +// TagPattern is the release tag rule: vX.Y.Z, nothing else. +var TagPattern = regexp.MustCompile(`^v\d+\.\d+\.\d+$`) + +// Validated is a plugin repository that passed every check, with everything +// the index builder needs from it. +type Validated struct { + Repo string // owner/name + Owner string + Name string // repository name (not the plugin name) + Manifest Manifest + Release Release // the latest published, non-prerelease release + Version string // Release.Tag without the leading v + Releases []Release // all published, non-prerelease releases, newest first + Entry []byte // the plugin source at Release.Tag + SHA256 string // hex sha256 of Entry +} + +// ValidateEntry checks one registry entry end to end: a published release +// tagged vX.Y.Z, a valid manifest and README at that tag, an entry file that +// loads in goblog, and an identity that matches the manifest and the tag. +func ValidateEntry(ctx context.Context, src Source, val Validator, repo string) (*Validated, error) { + owner, name, ok := strings.Cut(repo, "/") + if !ok || owner == "" || name == "" || strings.Contains(name, "/") { + return nil, fmt.Errorf("%q: repo must be owner/name", repo) + } + + all, err := src.Releases(ctx, owner, name) + if err != nil { + return nil, err + } + var releases []Release + for _, r := range all { + if !r.Draft && !r.Prerelease { + releases = append(releases, r) + } + } + if len(releases) == 0 { + return nil, fmt.Errorf("%s: no published release (drafts and pre-releases are ignored)", repo) + } + sort.SliceStable(releases, func(i, j int) bool { return releases[i].PublishedAt.After(releases[j].PublishedAt) }) + latest := releases[0] + if !TagPattern.MatchString(latest.Tag) { + return nil, fmt.Errorf("%s: release tag %q must be vX.Y.Z", repo, latest.Tag) + } + version := strings.TrimPrefix(latest.Tag, "v") + + mb, err := src.File(ctx, owner, name, latest.Tag, "goblog-plugin.json") + if err != nil { + return nil, fmt.Errorf("%s@%s: goblog-plugin.json: %w", repo, latest.Tag, err) + } + manifest, err := ParseManifest(mb) + if err != nil { + return nil, fmt.Errorf("%s@%s: %w", repo, latest.Tag, err) + } + if _, err := src.File(ctx, owner, name, latest.Tag, "README.md"); err != nil { + return nil, fmt.Errorf("%s@%s: README.md: %w", repo, latest.Tag, err) + } + entry, err := src.File(ctx, owner, name, latest.Tag, manifest.Entry) + if err != nil { + return nil, fmt.Errorf("%s@%s: entry %s: %w", repo, latest.Tag, manifest.Entry, err) + } + + info, err := val.Validate(ctx, entry) + if err != nil { + return nil, fmt.Errorf("%s@%s: %s does not load: %w", repo, latest.Tag, manifest.Entry, err) + } + if info.Name != manifest.Name { + return nil, fmt.Errorf("%s@%s: Name() is %q but the manifest says %q", repo, latest.Tag, info.Name, manifest.Name) + } + if info.Version != version { + return nil, fmt.Errorf("%s@%s: Version() is %q but the release tag says %q", repo, latest.Tag, info.Version, version) + } + + h := sha256.Sum256(entry) + return &Validated{ + Repo: repo, Owner: owner, Name: name, + Manifest: manifest, Release: latest, Version: version, Releases: releases, + Entry: entry, SHA256: hex.EncodeToString(h[:]), + }, nil +} + +// isNotFound reports whether err is a missing-file error from a Source. +func isNotFound(err error) bool { return errors.Is(err, ErrNotFound) } diff --git a/internal/registry/validate_test.go b/internal/registry/validate_test.go new file mode 100644 index 0000000..d9bcbf2 --- /dev/null +++ b/internal/registry/validate_test.go @@ -0,0 +1,144 @@ +package registry + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +// memSource is an in-memory Source for validator/builder tests. +type memSource struct { + releases map[string][]Release // "owner/repo" → releases + files map[string]string // "owner/repo@ref:path" → content + rendered int // RenderMarkdown call count +} + +func (m *memSource) Releases(_ context.Context, owner, repo string) ([]Release, error) { + rels, ok := m.releases[owner+"/"+repo] + if !ok { + return nil, errors.New("no such repo") + } + return rels, nil +} + +func (m *memSource) File(_ context.Context, owner, repo, ref, path string) ([]byte, error) { + if c, ok := m.files[owner+"/"+repo+"@"+ref+":"+path]; ok { + return []byte(c), nil + } + return nil, ErrNotFound +} + +func (m *memSource) RenderMarkdown(_ context.Context, ownerRepo, md string) (string, error) { + if md == "" { + return "", nil + } + m.rendered++ + return "

" + md + "

", nil +} + +const helloSrc = "package main\n// hello plugin\n" + +func helloSource() *memSource { + return &memSource{ + releases: map[string][]Release{ + "o/hello": { + {Tag: "v1.1.0", Body: "Second", URL: "https://github.com/o/hello/releases/tag/v1.1.0", PublishedAt: time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC)}, + {Tag: "v2.0.0-rc1", Prerelease: true, PublishedAt: time.Date(2026, 9, 16, 0, 0, 0, 0, time.UTC)}, + {Tag: "v3.0.0", Draft: true, PublishedAt: time.Date(2026, 9, 17, 0, 0, 0, 0, time.UTC)}, + {Tag: "v1.0.0", Body: "First", URL: "https://github.com/o/hello/releases/tag/v1.0.0", PublishedAt: time.Date(2026, 9, 14, 0, 0, 0, 0, time.UTC)}, + }, + }, + files: map[string]string{ + "o/hello@v1.1.0:goblog-plugin.json": goodManifest, + "o/hello@v1.1.0:plugin.go": helloSrc, + "o/hello@v1.1.0:README.md": "# Hello", + "o/hello@v1.1.0:CHANGELOG.md": "## 1.1.0\n- second", + }, + } +} + +func helloValidator() *FakeValidator { + return &FakeValidator{Infos: map[string]Info{sum([]byte(helloSrc)): {Name: "hello", DisplayName: "Hello", Version: "1.1.0"}}} +} + +func TestValidateEntry_Good(t *testing.T) { + v, err := ValidateEntry(context.Background(), helloSource(), helloValidator(), "o/hello") + if err != nil { + t.Fatal(err) + } + if v.Owner != "o" || v.Name != "hello" || v.Manifest.Name != "hello" || v.Release.Tag != "v1.1.0" || v.Version != "1.1.0" { + t.Errorf("validated = %+v", v) + } + if len(v.Releases) != 2 || v.Releases[0].Tag != "v1.1.0" || v.Releases[1].Tag != "v1.0.0" { + t.Errorf("releases should exclude drafts/prereleases, newest first: %+v", v.Releases) + } + if string(v.Entry) != helloSrc || v.SHA256 != sum([]byte(helloSrc)) { + t.Errorf("entry/sha mismatch") + } +} + +func TestValidateEntry_PicksLatestByDate(t *testing.T) { + src := helloSource() + // GitHub order is not trusted: put the older release first. + rels := src.releases["o/hello"] + src.releases["o/hello"] = []Release{rels[3], rels[0]} + v, err := ValidateEntry(context.Background(), src, helloValidator(), "o/hello") + if err != nil { + t.Fatal(err) + } + if v.Release.Tag != "v1.1.0" { + t.Errorf("latest should be v1.1.0 by published date, got %s", v.Release.Tag) + } +} + +func TestValidateEntry_Errors(t *testing.T) { + type tc struct { + mutate func(s *memSource, f *FakeValidator) + want string + } + cases := map[string]tc{ + "bad repo string": {func(s *memSource, f *FakeValidator) {}, "owner/name"}, + "no releases": {func(s *memSource, f *FakeValidator) { + s.releases["o/hello"] = []Release{{Tag: "v9.0.0", Draft: true}} + }, "no published release"}, + "tag not semver": {func(s *memSource, f *FakeValidator) { + s.releases["o/hello"][0].Tag = "1.1.0" + s.files["o/hello@1.1.0:goblog-plugin.json"] = goodManifest + }, "vX.Y.Z"}, + "missing manifest": {func(s *memSource, f *FakeValidator) { + delete(s.files, "o/hello@v1.1.0:goblog-plugin.json") + }, "goblog-plugin.json"}, + "invalid manifest": {func(s *memSource, f *FakeValidator) { + s.files["o/hello@v1.1.0:goblog-plugin.json"] = `{"name":"Bad"}` + }, "goblog-plugin.json"}, + "missing entry": {func(s *memSource, f *FakeValidator) { + delete(s.files, "o/hello@v1.1.0:plugin.go") + }, "plugin.go"}, + "missing readme": {func(s *memSource, f *FakeValidator) { + delete(s.files, "o/hello@v1.1.0:README.md") + }, "README.md"}, + "does not load": {func(s *memSource, f *FakeValidator) { + f.Err = errors.New("yaegi: boom") + }, "boom"}, + "name mismatch": {func(s *memSource, f *FakeValidator) { + f.Infos[sum([]byte(helloSrc))] = Info{Name: "other", Version: "1.1.0"} + }, "Name()"}, + "version mismatch": {func(s *memSource, f *FakeValidator) { + f.Infos[sum([]byte(helloSrc))] = Info{Name: "hello", Version: "1.0.9"} + }, "Version()"}, + } + for name, c := range cases { + s, f := helloSource(), helloValidator() + c.mutate(s, f) + repo := "o/hello" + if name == "bad repo string" { + repo = "hello" + } + _, err := ValidateEntry(context.Background(), s, f, repo) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: want error containing %q, got %v", name, c.want, err) + } + } +} diff --git a/internal/registry/validator.go b/internal/registry/validator.go new file mode 100644 index 0000000..aef1e12 --- /dev/null +++ b/internal/registry/validator.go @@ -0,0 +1,66 @@ +package registry + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Info is what `goblog validate-plugin` prints for a plugin file. +type Info struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + Version string `json:"version"` +} + +// Validator loads a plugin source file the way goblog would and reports its +// identity. The real one runs goblog's validate-plugin in Docker; tests use +// a fake. +type Validator interface { + Validate(ctx context.Context, src []byte) (Info, error) +} + +// GoblogEntrypoint is the goblog binary inside the release image, whose +// ENTRYPOINT is a shell command and therefore has to be overridden. +const GoblogEntrypoint = "/go/src/github.com/compscidr/goblog/goblog" + +// DockerValidator runs `goblog validate-plugin` inside the pinned goblog +// image with networking disabled: the file is interpreted, so it can run +// arbitrary Go, and this is the only sandbox the registry gives it. +type DockerValidator struct { + Image string +} + +func NewDockerValidator(image string) *DockerValidator { return &DockerValidator{Image: image} } + +func (d *DockerValidator) args(dir string) []string { + return []string{"run", "--rm", "--network", "none", "-v", dir + ":/p:ro", + "--entrypoint", GoblogEntrypoint, d.Image, "validate-plugin", "/p/plugin.go"} +} + +func (d *DockerValidator) Validate(ctx context.Context, src []byte) (Info, error) { + dir, err := os.MkdirTemp("", "goblog-plugin-") + if err != nil { + return Info{}, err + } + defer os.RemoveAll(dir) + if err := os.WriteFile(filepath.Join(dir, "plugin.go"), src, 0644); err != nil { + return Info{}, err + } + cmd := exec.CommandContext(ctx, "docker", d.args(dir)...) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Run(); err != nil { + return Info{}, fmt.Errorf("validate-plugin failed: %s", strings.TrimSpace(stderr.String()+" "+err.Error())) + } + var info Info + if err := json.Unmarshal(stdout.Bytes(), &info); err != nil { + return Info{}, fmt.Errorf("validate-plugin printed %q: %w", stdout.String(), err) + } + return info, nil +} diff --git a/internal/registry/validator_test.go b/internal/registry/validator_test.go new file mode 100644 index 0000000..1be4864 --- /dev/null +++ b/internal/registry/validator_test.go @@ -0,0 +1,75 @@ +package registry + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "os/exec" + "testing" +) + +// FakeValidator answers by the sha256 of the source it is given. +type FakeValidator struct { + Infos map[string]Info + Err error +} + +func sum(b []byte) string { + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} + +func (f *FakeValidator) Validate(_ context.Context, src []byte) (Info, error) { + if f.Err != nil { + return Info{}, f.Err + } + if info, ok := f.Infos[sum(src)]; ok { + return info, nil + } + return Info{}, errors.New("fake: does not load") +} + +// TestDockerValidator_Real runs the actual goblog image; skipped unless +// docker is available and REGISTRY_DOCKER_TESTS=1 (it pulls ~100 MB). +func TestDockerValidator_Real(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil || os.Getenv("REGISTRY_DOCKER_TESTS") == "" { + t.Skip("set REGISTRY_DOCKER_TESTS=1 with docker available") + } + src := []byte(`package main +import "goblog/plugin" +type P struct{ plugin.BasePlugin } +func NewPlugin() plugin.Plugin { return &P{} } +func (P) Name() string { return "p" } +func (P) DisplayName() string { return "P" } +func (P) Version() string { return "1.2.3" } +`) + v := NewDockerValidator("compscidr/goblog:v0.2.7") + info, err := v.Validate(context.Background(), src) + if err != nil { + t.Fatal(err) + } + if info.Name != "p" || info.Version != "1.2.3" { + t.Errorf("info = %+v", info) + } + if _, err := v.Validate(context.Background(), []byte("package main\nfunc NewPlugin() int { return 1 ")); err == nil { + t.Error("broken source should fail") + } +} + +func TestDockerValidator_CommandShape(t *testing.T) { + v := NewDockerValidator("compscidr/goblog:v0.2.7") + args := v.args("/tmp/x") + want := []string{"run", "--rm", "--network", "none", "-v", "/tmp/x:/p:ro", + "--entrypoint", "/go/src/github.com/compscidr/goblog/goblog", "compscidr/goblog:v0.2.7", + "validate-plugin", "/p/plugin.go"} + if len(args) != len(want) { + t.Fatalf("args = %v", args) + } + for i := range want { + if args[i] != want[i] { + t.Errorf("args[%d] = %q, want %q", i, args[i], want[i]) + } + } +} From 8f5f3506842537d4245874e77ed3ab08c7f32ea1 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 14 Sep 2026 18:27:56 -0700 Subject: [PATCH 2/9] Build index.json and per-plugin detail documents from validated entries Co-Authored-By: Claude Opus 5 (1M context) --- internal/registry/build.go | 183 ++++++++++++++++++++++++++++++++ internal/registry/build_test.go | 127 ++++++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 internal/registry/build.go create mode 100644 internal/registry/build_test.go diff --git a/internal/registry/build.go b/internal/registry/build.go new file mode 100644 index 0000000..e686924 --- /dev/null +++ b/internal/registry/build.go @@ -0,0 +1,183 @@ +package registry + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// IndexEntry is one element of index.json: the latest release of a plugin. +// Field names are the contract consumed by goblog's directory plugin and +// the admin installer; do not rename them. +type IndexEntry struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Version string `json:"version"` + Author string `json:"author"` + License string `json:"license"` + SourceURL string `json:"source_url"` + DownloadURL string `json:"download_url"` + SHA256 string `json:"sha256"` + MinGoblogVersion string `json:"min_goblog_version"` + InstallType string `json:"install_type"` + ReleasedAt string `json:"released_at"` + DetailURL string `json:"detail_url"` +} + +// ReleaseDoc is one release in a plugin's history. +type ReleaseDoc struct { + Version string `json:"version"` + ReleasedAt string `json:"released_at"` + NotesHTML string `json:"notes_html"` + URL string `json:"url"` +} + +// DetailDoc is plugins/.json: the index entry plus rendered README, +// changelog and release history. +type DetailDoc struct { + IndexEntry + ReadmeHTML string `json:"readme_html"` + ChangelogHTML string `json:"changelog_html"` + Releases []ReleaseDoc `json:"releases"` +} + +// BuildResult says which repositories made it into the index. +type BuildResult struct { + Built []string + Skipped map[string]error +} + +const indexHTML = ` + +goblog plugin registry +

This is the machine-readable goblog plugin index. Browse the directory at +goblog.live/plugins, or fetch +index.json. To publish a plugin, see +goblogplatform/plugins.

+` + +// Build validates every repository and writes index.json, plugins/.json +// and index.html under outDir. A repository that fails validation is +// skipped and reported in the result so one broken release cannot take the +// directory down; the build fails outright only when nothing is valid. +func Build(ctx context.Context, src Source, val Validator, repos []string, outDir, baseURL string) (BuildResult, error) { + res := BuildResult{Skipped: map[string]error{}} + baseURL = strings.TrimSuffix(baseURL, "/") + var index []IndexEntry + var details []DetailDoc + byName := map[string]string{} // plugin name → repo that claimed it + + for _, repo := range repos { + v, err := ValidateEntry(ctx, src, val, repo) + if err != nil { + log.Printf("skip %s: %v", repo, err) + res.Skipped[repo] = err + continue + } + if prev, taken := byName[v.Manifest.Name]; taken { + err := fmt.Errorf("%s: plugin name %q is already published by %s", repo, v.Manifest.Name, prev) + log.Printf("skip %s: %v", repo, err) + res.Skipped[repo] = err + continue + } + d, err := buildDetail(ctx, src, v, baseURL) + if err != nil { + log.Printf("skip %s: %v", repo, err) + res.Skipped[repo] = err + continue + } + byName[v.Manifest.Name] = repo + index = append(index, d.IndexEntry) + details = append(details, d) + res.Built = append(res.Built, repo) + } + if len(index) == 0 { + return res, fmt.Errorf("no valid plugins; refusing to publish an empty index") + } + sort.Slice(index, func(i, j int) bool { return index[i].Name < index[j].Name }) + + if err := os.MkdirAll(filepath.Join(outDir, "plugins"), 0755); err != nil { + return res, err + } + if err := writeJSON(filepath.Join(outDir, "index.json"), index); err != nil { + return res, err + } + for _, d := range details { + if err := writeJSON(filepath.Join(outDir, "plugins", d.Name+".json"), d); err != nil { + return res, err + } + } + if err := os.WriteFile(filepath.Join(outDir, "index.html"), []byte(indexHTML), 0644); err != nil { + return res, err + } + if err := os.WriteFile(filepath.Join(outDir, ".nojekyll"), nil, 0644); err != nil { + return res, err + } + return res, nil +} + +func buildDetail(ctx context.Context, src Source, v *Validated, baseURL string) (DetailDoc, error) { + ownerRepo := v.Owner + "/" + v.Name + entry := IndexEntry{ + Name: v.Manifest.Name, + DisplayName: v.Manifest.DisplayName, + Description: v.Manifest.Description, + Version: v.Version, + Author: v.Manifest.Author, + License: v.Manifest.License, + SourceURL: "https://github.com/" + ownerRepo, + DownloadURL: fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s", ownerRepo, v.Release.Tag, v.Manifest.Entry), + SHA256: v.SHA256, + MinGoblogVersion: v.Manifest.MinGoblogVersion, + InstallType: "dynamic", + ReleasedAt: v.Release.PublishedAt.UTC().Format(time.RFC3339), + DetailURL: fmt.Sprintf("%s/plugins/%s.json", baseURL, v.Manifest.Name), + } + + readme, err := src.File(ctx, v.Owner, v.Name, v.Release.Tag, "README.md") + if err != nil { + return DetailDoc{}, fmt.Errorf("README.md: %w", err) + } + readmeHTML, err := src.RenderMarkdown(ctx, ownerRepo, string(readme)) + if err != nil { + return DetailDoc{}, err + } + changelogHTML := "" + if cl, err := src.File(ctx, v.Owner, v.Name, v.Release.Tag, "CHANGELOG.md"); err == nil { + if changelogHTML, err = src.RenderMarkdown(ctx, ownerRepo, string(cl)); err != nil { + return DetailDoc{}, err + } + } else if !isNotFound(err) { + return DetailDoc{}, fmt.Errorf("CHANGELOG.md: %w", err) + } + + releases := make([]ReleaseDoc, 0, len(v.Releases)) + for _, r := range v.Releases { + notes, err := src.RenderMarkdown(ctx, ownerRepo, r.Body) + if err != nil { + return DetailDoc{}, err + } + releases = append(releases, ReleaseDoc{ + Version: strings.TrimPrefix(r.Tag, "v"), + ReleasedAt: r.PublishedAt.UTC().Format(time.RFC3339), + NotesHTML: notes, + URL: r.URL, + }) + } + return DetailDoc{IndexEntry: entry, ReadmeHTML: readmeHTML, ChangelogHTML: changelogHTML, Releases: releases}, nil +} + +func writeJSON(path string, v any) error { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(b, '\n'), 0644) +} diff --git a/internal/registry/build_test.go b/internal/registry/build_test.go new file mode 100644 index 0000000..3bf1303 --- /dev/null +++ b/internal/registry/build_test.go @@ -0,0 +1,127 @@ +package registry + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestBuild_WritesIndexAndDetails(t *testing.T) { + src := helloSource() + // A second plugin, older release, to check sorting and skipping. + src.releases["o/zeta"] = []Release{{Tag: "v0.1.0", Body: "z", URL: "https://github.com/o/zeta/releases/tag/v0.1.0", PublishedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}} + src.files["o/zeta@v0.1.0:goblog-plugin.json"] = strings.Replace(strings.Replace(goodManifest, `"hello"`, `"zeta"`, 1), `"Hello"`, `"Zeta"`, 1) + src.files["o/zeta@v0.1.0:plugin.go"] = "package main // zeta\n" + src.files["o/zeta@v0.1.0:README.md"] = "# Zeta" + val := helloValidator() + val.Infos[sum([]byte("package main // zeta\n"))] = Info{Name: "zeta", DisplayName: "Zeta", Version: "0.1.0"} + + out := t.TempDir() + res, err := Build(context.Background(), src, val, []string{"o/zeta", "o/hello"}, out, "https://example.test/plugins") + if err != nil { + t.Fatal(err) + } + if len(res.Built) != 2 || len(res.Skipped) != 0 { + t.Fatalf("result = %+v", res) + } + + var index []IndexEntry + mustJSON(t, filepath.Join(out, "index.json"), &index) + if len(index) != 2 || index[0].Name != "hello" || index[1].Name != "zeta" { + t.Fatalf("index should be sorted by name: %+v", index) + } + e := index[0] + want := IndexEntry{ + Name: "hello", DisplayName: "Hello", Description: "Says hi.", Version: "1.1.0", Author: "Jason Ernst", + License: "Apache-2.0", SourceURL: "https://github.com/o/hello", + DownloadURL: "https://raw.githubusercontent.com/o/hello/v1.1.0/plugin.go", SHA256: sum([]byte(helloSrc)), + MinGoblogVersion: "0.2.6", InstallType: "dynamic", ReleasedAt: "2026-09-15T00:00:00Z", + DetailURL: "https://example.test/plugins/plugins/hello.json", + } + if e != want { + t.Errorf("entry =\n%+v\nwant\n%+v", e, want) + } + + var d DetailDoc + mustJSON(t, filepath.Join(out, "plugins", "hello.json"), &d) + if d.Name != "hello" || d.ReadmeHTML != "

# Hello

" || d.ChangelogHTML != "

## 1.1.0\n- second

" { + t.Errorf("detail = %+v", d) + } + if len(d.Releases) != 2 || d.Releases[0].Version != "1.1.0" || d.Releases[0].NotesHTML != "

Second

" || d.Releases[0].ReleasedAt != "2026-09-15T00:00:00Z" || d.Releases[0].URL == "" || d.Releases[1].Version != "1.0.0" { + t.Errorf("releases = %+v", d.Releases) + } + var z DetailDoc + mustJSON(t, filepath.Join(out, "plugins", "zeta.json"), &z) + if z.ChangelogHTML != "" { + t.Errorf("missing CHANGELOG.md should give empty changelog_html, got %q", z.ChangelogHTML) + } + + html, _ := os.ReadFile(filepath.Join(out, "index.html")) + if !strings.Contains(string(html), "goblog.live/plugins") || !strings.Contains(string(html), "index.json") { + t.Errorf("index.html should point readers at goblog.live/plugins and index.json, got %q", html) + } + if _, err := os.Stat(filepath.Join(out, ".nojekyll")); err != nil { + t.Error(".nojekyll should exist so Pages serves files as-is") + } + // The raw index must be exactly what a consumer parses: check it round-trips byte-for-byte. + raw, _ := os.ReadFile(filepath.Join(out, "index.json")) + var generic any + if err := json.Unmarshal(raw, &generic); err != nil { + t.Errorf("index.json is not valid JSON: %v", err) + } +} + +func TestBuild_SkipsBrokenEntriesAndDuplicates(t *testing.T) { + src := helloSource() + // "o/copy" is a valid repo whose manifest reuses the name "hello". + src.releases["o/copy"] = src.releases["o/hello"] + for k, v := range src.files { + if strings.HasPrefix(k, "o/hello@") { + src.files[strings.Replace(k, "o/hello@", "o/copy@", 1)] = v + } + } + out := t.TempDir() + res, err := Build(context.Background(), src, helloValidator(), []string{"o/hello", "o/nope", "o/copy"}, out, "https://example.test/plugins") + if err != nil { + t.Fatal(err) + } + if len(res.Built) != 1 || res.Built[0] != "o/hello" { + t.Errorf("built = %v", res.Built) + } + if len(res.Skipped) != 2 || res.Skipped["o/nope"] == nil || res.Skipped["o/copy"] == nil { + t.Errorf("skipped = %v", res.Skipped) + } + if !strings.Contains(res.Skipped["o/copy"].Error(), "already") { + t.Errorf("duplicate name error should say so: %v", res.Skipped["o/copy"]) + } + var index []IndexEntry + mustJSON(t, filepath.Join(out, "index.json"), &index) + if len(index) != 1 { + t.Errorf("index should contain only the good entry, got %d", len(index)) + } + if _, err := os.Stat(filepath.Join(out, "plugins", "nope.json")); err == nil { + t.Error("no detail file for a skipped entry") + } +} + +func TestBuild_FailsWhenNothingBuilt(t *testing.T) { + src := helloSource() + if _, err := Build(context.Background(), src, helloValidator(), []string{"o/nope"}, t.TempDir(), "https://example.test/plugins"); err == nil { + t.Error("a build with zero valid entries must fail rather than publish an empty index") + } +} + +func mustJSON(t *testing.T, path string, v any) { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, v); err != nil { + t.Fatalf("%s: %v", path, err) + } +} From 71447f373d28d967a8b18ba094d76335b90d4a80 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 14 Sep 2026 18:34:48 -0700 Subject: [PATCH 3/9] Harden the Docker validator and tidy build output - DockerValidator now has a Timeout (default 120s) and generates a named container per run so it can be docker-killed; cmd.Cancel and WaitDelay ensure a cancelled/timed-out context actually stops the container instead of leaking it. Adds --memory 512m --pids-limit 256. - ValidateEntry filters the release history in Validated.Releases to tags matching vX.Y.Z, so a pre-convention tag like release-2024 never shows up in a plugin's history (the latest-release check still runs against the unfiltered list first). - writeJSON no longer HTML-escapes JSON output, so readme_html contains literal

rather than

. Co-Authored-By: Claude Opus 5 (1M context) --- internal/registry/build.go | 10 ++++-- internal/registry/build_test.go | 7 +++++ internal/registry/validate.go | 12 +++++++ internal/registry/validate_test.go | 20 ++++++++++++ internal/registry/validator.go | 49 ++++++++++++++++++++++++++--- internal/registry/validator_test.go | 35 +++++++++++++++++++-- 6 files changed, 124 insertions(+), 9 deletions(-) diff --git a/internal/registry/build.go b/internal/registry/build.go index e686924..b7f6ef0 100644 --- a/internal/registry/build.go +++ b/internal/registry/build.go @@ -1,6 +1,7 @@ package registry import ( + "bytes" "context" "encoding/json" "fmt" @@ -175,9 +176,12 @@ func buildDetail(ctx context.Context, src Source, v *Validated, baseURL string) } func writeJSON(path string, v any) error { - b, err := json.MarshalIndent(v, "", " ") - if err != nil { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { return err } - return os.WriteFile(path, append(b, '\n'), 0644) + return os.WriteFile(path, buf.Bytes(), 0644) } diff --git a/internal/registry/build_test.go b/internal/registry/build_test.go index 3bf1303..d64d0af 100644 --- a/internal/registry/build_test.go +++ b/internal/registry/build_test.go @@ -51,6 +51,13 @@ func TestBuild_WritesIndexAndDetails(t *testing.T) { if d.Name != "hello" || d.ReadmeHTML != "

# Hello

" || d.ChangelogHTML != "

## 1.1.0\n- second

" { t.Errorf("detail = %+v", d) } + detailRaw, err := os.ReadFile(filepath.Join(out, "plugins", "hello.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(detailRaw), "

# Hello

") { + t.Errorf("readme_html should not be HTML-escaped, got %s", detailRaw) + } if len(d.Releases) != 2 || d.Releases[0].Version != "1.1.0" || d.Releases[0].NotesHTML != "

Second

" || d.Releases[0].ReleasedAt != "2026-09-15T00:00:00Z" || d.Releases[0].URL == "" || d.Releases[1].Version != "1.0.0" { t.Errorf("releases = %+v", d.Releases) } diff --git a/internal/registry/validate.go b/internal/registry/validate.go index 0489486..3be4170 100644 --- a/internal/registry/validate.go +++ b/internal/registry/validate.go @@ -57,6 +57,18 @@ func ValidateEntry(ctx context.Context, src Source, val Validator, repo string) } version := strings.TrimPrefix(latest.Tag, "v") + // The "latest" check above still runs against the unfiltered list (so a + // bad tag on the newest release is still an error); the history shown to + // consumers drops anything that was never a valid vX.Y.Z tag, such as an + // old release tagged before the convention was adopted. + filtered := releases[:0:0] + for _, r := range releases { + if TagPattern.MatchString(r.Tag) { + filtered = append(filtered, r) + } + } + releases = filtered + mb, err := src.File(ctx, owner, name, latest.Tag, "goblog-plugin.json") if err != nil { return nil, fmt.Errorf("%s@%s: goblog-plugin.json: %w", repo, latest.Tag, err) diff --git a/internal/registry/validate_test.go b/internal/registry/validate_test.go index d9bcbf2..04d05e2 100644 --- a/internal/registry/validate_test.go +++ b/internal/registry/validate_test.go @@ -79,6 +79,26 @@ func TestValidateEntry_Good(t *testing.T) { } } +func TestValidateEntry_FiltersHistoryByTagPattern(t *testing.T) { + src := helloSource() + src.releases["o/hello"] = append(src.releases["o/hello"], Release{ + Tag: "weird-tag", Body: "old", URL: "https://github.com/o/hello/releases/tag/weird-tag", + PublishedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + }) + v, err := ValidateEntry(context.Background(), src, helloValidator(), "o/hello") + if err != nil { + t.Fatal(err) + } + for _, r := range v.Releases { + if r.Tag == "weird-tag" { + t.Errorf("releases should exclude tags that don't match vX.Y.Z: %+v", v.Releases) + } + } + if len(v.Releases) != 2 { + t.Errorf("releases = %+v", v.Releases) + } +} + func TestValidateEntry_PicksLatestByDate(t *testing.T) { src := helloSource() // GitHub order is not trusted: put the older release first. diff --git a/internal/registry/validator.go b/internal/registry/validator.go index aef1e12..1bf9f45 100644 --- a/internal/registry/validator.go +++ b/internal/registry/validator.go @@ -3,12 +3,15 @@ package registry import ( "bytes" "context" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" + "time" ) // Info is what `goblog validate-plugin` prints for a plugin file. @@ -29,20 +32,41 @@ type Validator interface { // ENTRYPOINT is a shell command and therefore has to be overridden. const GoblogEntrypoint = "/go/src/github.com/compscidr/goblog/goblog" +// defaultValidateTimeout bounds how long a single `docker run` is allowed to +// take before its container is killed and the plugin is rejected. +const defaultValidateTimeout = 120 * time.Second + // DockerValidator runs `goblog validate-plugin` inside the pinned goblog // image with networking disabled: the file is interpreted, so it can run // arbitrary Go, and this is the only sandbox the registry gives it. type DockerValidator struct { Image string + // Timeout bounds a single validation run. Defaults to 120s in + // NewDockerValidator. + Timeout time.Duration } -func NewDockerValidator(image string) *DockerValidator { return &DockerValidator{Image: image} } +func NewDockerValidator(image string) *DockerValidator { + return &DockerValidator{Image: image, Timeout: defaultValidateTimeout} +} -func (d *DockerValidator) args(dir string) []string { - return []string{"run", "--rm", "--network", "none", "-v", dir + ":/p:ro", +func (d *DockerValidator) args(dir, name string) []string { + return []string{"run", "--rm", "--network", "none", "--memory", "512m", "--pids-limit", "256", + "--name", name, "-v", dir + ":/p:ro", "--entrypoint", GoblogEntrypoint, d.Image, "validate-plugin", "/p/plugin.go"} } +// containerName generates a unique name for the container running one +// validation, so it can be targeted by `docker kill` when the context is +// cancelled or times out. +func containerName() (string, error) { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + return "", err + } + return "goblog-validate-" + hex.EncodeToString(b), nil +} + func (d *DockerValidator) Validate(ctx context.Context, src []byte) (Info, error) { dir, err := os.MkdirTemp("", "goblog-plugin-") if err != nil { @@ -52,10 +76,27 @@ func (d *DockerValidator) Validate(ctx context.Context, src []byte) (Info, error if err := os.WriteFile(filepath.Join(dir, "plugin.go"), src, 0644); err != nil { return Info{}, err } - cmd := exec.CommandContext(ctx, "docker", d.args(dir)...) + + name, err := containerName() + if err != nil { + return Info{}, err + } + + ctx, cancel := context.WithTimeout(ctx, d.Timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "docker", d.args(dir, name)...) + // Cancelling the context only terminates the "docker" CLI process, not + // the container it started; kill the container by name so a timeout (or + // caller cancellation) actually stops it instead of leaking it. + cmd.Cancel = func() error { return exec.Command("docker", "kill", name).Run() } + cmd.WaitDelay = 5 * time.Second var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr if err := cmd.Run(); err != nil { + if ctx.Err() != nil { + return Info{}, fmt.Errorf("validate-plugin: plugin timed out after %s: %s", d.Timeout, strings.TrimSpace(stderr.String())) + } return Info{}, fmt.Errorf("validate-plugin failed: %s", strings.TrimSpace(stderr.String()+" "+err.Error())) } var info Info diff --git a/internal/registry/validator_test.go b/internal/registry/validator_test.go index 1be4864..b002aa9 100644 --- a/internal/registry/validator_test.go +++ b/internal/registry/validator_test.go @@ -7,7 +7,10 @@ import ( "errors" "os" "os/exec" + "path/filepath" + "strings" "testing" + "time" ) // FakeValidator answers by the sha256 of the source it is given. @@ -60,8 +63,9 @@ func (P) Version() string { return "1.2.3" } func TestDockerValidator_CommandShape(t *testing.T) { v := NewDockerValidator("compscidr/goblog:v0.2.7") - args := v.args("/tmp/x") - want := []string{"run", "--rm", "--network", "none", "-v", "/tmp/x:/p:ro", + args := v.args("/tmp/x", "goblog-validate-abc123") + want := []string{"run", "--rm", "--network", "none", "--memory", "512m", "--pids-limit", "256", + "--name", "goblog-validate-abc123", "-v", "/tmp/x:/p:ro", "--entrypoint", "/go/src/github.com/compscidr/goblog/goblog", "compscidr/goblog:v0.2.7", "validate-plugin", "/p/plugin.go"} if len(args) != len(want) { @@ -73,3 +77,30 @@ func TestDockerValidator_CommandShape(t *testing.T) { } } } + +func TestNewDockerValidator_DefaultTimeout(t *testing.T) { + v := NewDockerValidator("img") + if v.Timeout != 120*time.Second { + t.Errorf("default Timeout = %v, want 120s", v.Timeout) + } +} + +// TestDockerValidator_Timeout uses a fake "docker" on PATH that ignores +// "run" and hangs briefly, and answers "kill" instantly, to check that a +// short Timeout produces a "timed out" error rather than hanging until the +// real 120s default. +func TestDockerValidator_Timeout(t *testing.T) { + dir := t.TempDir() + script := "#!/bin/sh\ncase \"$1\" in\n kill) exit 0 ;;\n *) sleep 0.3; exit 1 ;;\nesac\n" + if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + v := NewDockerValidator("img") + v.Timeout = 50 * time.Millisecond + _, err := v.Validate(context.Background(), []byte("package main\n")) + if err == nil || !strings.Contains(err.Error(), "timed out") { + t.Errorf("want a timed out error, got %v", err) + } +} From 4b2bede4cac880042ed4d6f0cae345bd0a4d94e4 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 14 Sep 2026 18:36:45 -0700 Subject: [PATCH 4/9] Add the registry CLI, contributor contract, README and Renovate config cmd/registry is the tool CI runs: `validate` on pull requests and `build` on merge and every six hours, wired to the real GitHub source and Docker validator with a testable run() core. docs/CONTRACT.md is what a plugin author reads before opening a PR; README.md is the registry's own front door. renovate.json keeps the pinned goblog image current in workflows, cmd/registry/main.go and docs/CONTRACT.md. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 17 +++++ cmd/registry/main.go | 129 ++++++++++++++++++++++++++++++++++++++ cmd/registry/main_test.go | 108 +++++++++++++++++++++++++++++++ docs/CONTRACT.md | 66 +++++++++++++++++++ renovate.json | 17 +++++ 5 files changed, 337 insertions(+) create mode 100644 README.md create mode 100644 cmd/registry/main.go create mode 100644 cmd/registry/main_test.go create mode 100644 docs/CONTRACT.md create mode 100644 renovate.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab250cf --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# goblog plugin registry + +The curated list of [goblog](https://github.com/goblogplatform/goblog) plugins behind [goblog.live/plugins](https://goblog.live/plugins). + +- `registry.yaml` — the list. Add your repository in a PR; see [docs/CONTRACT.md](docs/CONTRACT.md). +- `https://goblogplatform.github.io/plugins/index.json` — the machine-readable index (latest release of each plugin, with `download_url` and `sha256`); `plugins/.json` adds the rendered README, changelog and release history. +- `cmd/registry` — the tool CI runs: `validate` on pull requests, `build` on merge and every six hours. + +```bash +go run ./cmd/registry validate # every entry +go run ./cmd/registry validate --repo you/plugin # one entry +go run ./cmd/registry build --out dist # what gets published +``` + +Set `GITHUB_TOKEN` to avoid API rate limits. With snap-installed Docker, set `TMPDIR` to a directory under your home; snap's Docker cannot bind-mount `/tmp`. `validate`/`build` run `goblog validate-plugin` in the `compscidr/goblog` Docker image (`--image` to override; Renovate keeps the default current). + +License: Apache-2.0. diff --git a/cmd/registry/main.go b/cmd/registry/main.go new file mode 100644 index 0000000..15d3243 --- /dev/null +++ b/cmd/registry/main.go @@ -0,0 +1,129 @@ +// Command registry validates the plugin repositories listed in registry.yaml +// and builds the directory index published to GitHub Pages. +// +// registry validate [--registry registry.yaml] [--image compscidr/goblog:vX.Y.Z] [--repo owner/name] +// registry build [--registry registry.yaml] [--image ...] [--out dist] [--base-url URL] +// +// GITHUB_TOKEN is used when set. Exit codes: 0 ok; 1 a validation failed or +// a fatal error; 2 (build only) output was written but some entries were skipped. +package main + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "sort" + + "github.com/goblogplatform/plugins/internal/registry" +) + +const ( + defaultImage = "compscidr/goblog:v0.2.7" + defaultBaseURL = "https://goblogplatform.github.io/plugins" +) + +func main() { + // Flags are parsed twice: once here to build the real Source/Validator, + // once in run for everything else. Keep the flag names in sync. + image := defaultImage + for i, a := range os.Args { + if a == "--image" && i+1 < len(os.Args) { + image = os.Args[i+1] + } + } + src, err := registry.NewGitHubSource(os.Getenv("GITHUB_TOKEN"), "") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, src, registry.NewDockerValidator(image))) +} + +func usage(w io.Writer) { + fmt.Fprintln(w, "usage: registry validate [--registry FILE] [--image IMAGE] [--repo owner/name]") + fmt.Fprintln(w, " registry build [--registry FILE] [--image IMAGE] [--out DIR] [--base-url URL]") +} + +func run(args []string, stdout, stderr io.Writer, src registry.Source, val registry.Validator) int { + if len(args) == 0 { + usage(stderr) + return 2 + } + fs := flag.NewFlagSet(args[0], flag.ContinueOnError) + fs.SetOutput(stderr) + regPath := fs.String("registry", "registry.yaml", "path to registry.yaml") + fs.String("image", defaultImage, "goblog image used to load plugins (read in main)") + repo := fs.String("repo", "", "validate only this owner/name (must be listed)") + out := fs.String("out", "dist", "build output directory") + baseURL := fs.String("base-url", defaultBaseURL, "public URL the output is served from") + + switch args[0] { + case "validate", "build": + default: + usage(stderr) + return 2 + } + if err := fs.Parse(args[1:]); err != nil { + return 2 + } + repos, err := registry.LoadRegistry(*regPath) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + ctx := context.Background() + + switch args[0] { + case "validate": + if *repo != "" { + found := false + for _, r := range repos { + found = found || r == *repo + } + if !found { + fmt.Fprintf(stderr, "%s is not listed in %s\n", *repo, *regPath) + return 1 + } + repos = []string{*repo} + } + failed := 0 + for _, r := range repos { + v, err := registry.ValidateEntry(ctx, src, val, r) + if err != nil { + fmt.Fprintf(stderr, "%s: FAIL: %v\n", r, err) + failed++ + continue + } + fmt.Fprintf(stdout, "%s: ok (%s %s)\n", r, v.Manifest.Name, v.Version) + } + if failed > 0 { + return 1 + } + return 0 + + case "build": + res, err := registry.Build(ctx, src, val, repos, *out, *baseURL) + for _, r := range res.Built { + fmt.Fprintf(stdout, "%s: built\n", r) + } + skipped := make([]string, 0, len(res.Skipped)) + for r := range res.Skipped { + skipped = append(skipped, r) + } + sort.Strings(skipped) + for _, r := range skipped { + fmt.Fprintf(stderr, "%s: SKIPPED: %v\n", r, res.Skipped[r]) + } + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + if len(skipped) > 0 { + return 2 + } + return 0 + } + return 2 +} diff --git a/cmd/registry/main_test.go b/cmd/registry/main_test.go new file mode 100644 index 0000000..5c24f7d --- /dev/null +++ b/cmd/registry/main_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/goblogplatform/plugins/internal/registry" +) + +type memSource struct { + releases map[string][]registry.Release + files map[string]string +} + +func (m *memSource) Releases(_ context.Context, owner, repo string) ([]registry.Release, error) { + if r, ok := m.releases[owner+"/"+repo]; ok { + return r, nil + } + return nil, errors.New("no such repo") +} +func (m *memSource) File(_ context.Context, owner, repo, ref, path string) ([]byte, error) { + if c, ok := m.files[owner+"/"+repo+"@"+ref+":"+path]; ok { + return []byte(c), nil + } + return nil, registry.ErrNotFound +} +func (m *memSource) RenderMarkdown(_ context.Context, _, md string) (string, error) { + return "

" + md + "

", nil +} + +type okValidator struct{} + +func (okValidator) Validate(_ context.Context, _ []byte) (registry.Info, error) { + return registry.Info{Name: "hello", DisplayName: "Hello", Version: "1.0.0"}, nil +} + +func fixture(t *testing.T) (string, *memSource) { + t.Helper() + dir := t.TempDir() + reg := filepath.Join(dir, "registry.yaml") + os.WriteFile(reg, []byte("plugins:\n - repo: o/hello\n - repo: o/broken\n"), 0644) + src := &memSource{ + releases: map[string][]registry.Release{ + "o/hello": {{Tag: "v1.0.0", Body: "First", URL: "u", PublishedAt: time.Date(2026, 9, 14, 0, 0, 0, 0, time.UTC)}}, + "o/broken": {}, + }, + files: map[string]string{ + "o/hello@v1.0.0:goblog-plugin.json": `{"name":"hello","display_name":"Hello","description":"d","author":"a","license":"MIT","min_goblog_version":"0.2.6"}`, + "o/hello@v1.0.0:plugin.go": "package main\n", + "o/hello@v1.0.0:README.md": "# Hello", + }, + } + return reg, src +} + +func TestRun_Validate(t *testing.T) { + reg, src := fixture(t) + var out, errOut bytes.Buffer + code := run([]string{"validate", "--registry", reg, "--repo", "o/hello"}, &out, &errOut, src, okValidator{}) + if code != 0 || !strings.Contains(out.String(), "o/hello: ok (hello 1.0.0)") { + t.Errorf("code=%d out=%q err=%q", code, out.String(), errOut.String()) + } + out.Reset() + errOut.Reset() + code = run([]string{"validate", "--registry", reg}, &out, &errOut, src, okValidator{}) + if code != 1 || !strings.Contains(errOut.String(), "o/broken") || !strings.Contains(out.String(), "o/hello: ok") { + t.Errorf("all entries: code=%d out=%q err=%q", code, out.String(), errOut.String()) + } + if code := run([]string{"validate", "--registry", reg, "--repo", "o/nothere"}, &out, &errOut, src, okValidator{}); code != 1 { + t.Errorf("unknown --repo should fail, got %d", code) + } +} + +func TestRun_Build(t *testing.T) { + reg, src := fixture(t) + dist := filepath.Join(t.TempDir(), "dist") + var out, errOut bytes.Buffer + code := run([]string{"build", "--registry", reg, "--out", dist, "--base-url", "https://x.test/p"}, &out, &errOut, src, okValidator{}) + if code != 2 { + t.Errorf("a build with a skipped entry should exit 2, got %d (err=%q)", code, errOut.String()) + } + if _, err := os.Stat(filepath.Join(dist, "index.json")); err != nil { + t.Error("index.json should still be written") + } + if !strings.Contains(errOut.String(), "o/broken") { + t.Errorf("skipped entry should be reported on stderr, got %q", errOut.String()) + } + os.WriteFile(reg, []byte("plugins:\n - repo: o/hello\n"), 0644) + if code := run([]string{"build", "--registry", reg, "--out", dist}, &out, &errOut, src, okValidator{}); code != 0 { + t.Errorf("clean build should exit 0, got %d", code) + } +} + +func TestRun_Usage(t *testing.T) { + var out, errOut bytes.Buffer + if code := run(nil, &out, &errOut, nil, nil); code != 2 || !strings.Contains(errOut.String(), "usage") { + t.Errorf("code=%d err=%q", code, errOut.String()) + } + if code := run([]string{"frobnicate"}, &out, &errOut, nil, nil); code != 2 { + t.Errorf("unknown command: code=%d", code) + } +} diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md new file mode 100644 index 0000000..55da1b0 --- /dev/null +++ b/docs/CONTRACT.md @@ -0,0 +1,66 @@ +# Publishing a goblog plugin + +The directory at [goblog.live/plugins](https://goblog.live/plugins) lists plugins from this registry. A plugin is a GitHub repository; each GitHub release is a version. Submitting means adding your repository to `registry.yaml` in a pull request — CI validates it and, once merged, the index is rebuilt (on every merge and every six hours). + +## What the repository must contain + +At the root, at every release tag: + +| File | Required | Notes | +|---|---|---| +| `goblog-plugin.json` | yes | the manifest, below | +| the entry file (default `plugin.go`) | yes | a [dynamic plugin](https://github.com/goblogplatform/goblog#dynamic-plugins): `package main`, `func NewPlugin() plugin.Plugin` | +| `README.md` | yes | shown on the plugin's directory page | +| `CHANGELOG.md` | no | shown when present | +| `LICENSE` | recommended | should match `license` in the manifest | + +### `goblog-plugin.json` + +```json +{ + "name": "hello", + "display_name": "Hello", + "description": "One sentence shown in the listing.", + "author": "Your Name", + "license": "Apache-2.0", + "entry": "plugin.go", + "min_goblog_version": "0.2.6", + "homepage": "https://example.com/optional" +} +``` + +- `name`: `^[a-z0-9-]+$`, unique across the registry, and equal to what your plugin's `Name()` returns. +- `license`: an SPDX identifier from the list in `internal/registry/manifest.go` (MIT, Apache-2.0, BSD-2/3-Clause, ISC, MPL-2.0, GPL/LGPL/AGPL `-only`/`-or-later`, Unlicense, 0BSD). Open an issue to add another. +- `entry`: a `.go` file at the repository root; defaults to `plugin.go`. +- `min_goblog_version`: plain semver (`0.2.6`, no `v`) — the oldest goblog your plugin works with. + +### Releases + +- Tag releases `vX.Y.Z` (exactly three numbers). Drafts and pre-releases are ignored. +- The tag without `v` must equal the string your plugin's `Version()` returns. +- The GitHub release body is shown as the version's release notes. +- The directory lists the **latest** published release; the detail page shows all of them. + +## Check before you submit + +```bash +docker run --rm --network none -v "$PWD:/p:ro" \ + --entrypoint /go/src/github.com/compscidr/goblog/goblog compscidr/goblog:v0.2.7 \ + validate-plugin /p/plugin.go +# {"name":"hello","display_name":"Hello","version":"1.0.0"} +``` + +The registry's CI runs exactly this, then compares `name` and `version` with your manifest and tag. Your file is executed by the Go interpreter during the check, which is why it runs with networking off. + +## Submit + +1. Fork this repository and add a line to `registry.yaml`: + ```yaml + plugins: + - repo: goblogplatform/goblog-plugin-hello + - repo: you/goblog-plugin-yours + ``` +2. Open a pull request. The `validate` workflow must pass. +3. After merge, `https://goblogplatform.github.io/plugins/index.json` and goblog.live/plugins pick it up within a few minutes. New releases of your plugin are picked up automatically on the next scheduled build. + +Plugins run inside the goblog process of whoever installs them. Keep them small and readable; the registry is curated and maintainers may decline or remove entries. diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..401aa99 --- /dev/null +++ b/renovate.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "packageRules": [ + { "matchUpdateTypes": ["minor", "patch"], "automerge": true } + ], + "customManagers": [ + { + "customType": "regex", + "description": "Pin of the goblog image used to validate plugins, in workflows and code", + "managerFilePatterns": ["/^\\.github/workflows/.*\\.ya?ml$/", "/^cmd/registry/main\\.go$/", "/^docs/CONTRACT\\.md$/"], + "matchStrings": ["compscidr/goblog:(?v\\d+\\.\\d+\\.\\d+)"], + "depNameTemplate": "compscidr/goblog", + "datasourceTemplate": "docker" + } + ] +} From 5432a93da73964704a0b3ea4cfb11d9a02add437 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 14 Sep 2026 18:43:32 -0700 Subject: [PATCH 5/9] Fix round 1: --image=VALUE, prove the docker kill path, doc tweaks - cmd/registry: run() now takes a Validator factory (func(image string) registry.Validator) built from the parsed --image flag instead of main pre-scanning os.Args for "--image VALUE" only; --image=VALUE and -image VALUE now reach the real DockerValidator too. Drop the "flags parsed twice" doc comment, now stale. - validator.go: run `docker kill` with its own 5s timeout context so a hung daemon can't block forever; only report "plugin timed out" when ctx.Err() is context.DeadlineExceeded specifically, so a plain caller cancellation surfaces as the underlying error instead. Note in a comment why a failed `docker kill` is still safe (--rm plus a random per-run name). - TestDockerValidator_Timeout now proves the kill path ran, not just that the error message says "timed out": the fake docker touches a marker file named after the container it was asked to kill, and the test asserts that marker exists. - docs/CONTRACT.md, README.md: note CI's timeout/memory/process limits on the validate-plugin check, and move/expand the TMPDIR note to sit with the Docker mention plus a note on rootless Docker and cgroups. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- cmd/registry/main.go | 17 ++++++----------- cmd/registry/main_test.go | 29 ++++++++++++++++++++++++----- docs/CONTRACT.md | 2 +- internal/registry/validator.go | 16 +++++++++++++--- internal/registry/validator_test.go | 20 ++++++++++++++++---- 6 files changed, 61 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index ab250cf..27acb62 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,6 @@ go run ./cmd/registry validate --repo you/plugin # one entry go run ./cmd/registry build --out dist # what gets published ``` -Set `GITHUB_TOKEN` to avoid API rate limits. With snap-installed Docker, set `TMPDIR` to a directory under your home; snap's Docker cannot bind-mount `/tmp`. `validate`/`build` run `goblog validate-plugin` in the `compscidr/goblog` Docker image (`--image` to override; Renovate keeps the default current). +Set `GITHUB_TOKEN` to avoid API rate limits. `validate`/`build` run `goblog validate-plugin` in the `compscidr/goblog` Docker image (`--image` to override; Renovate keeps the default current). With snap-installed Docker, set `TMPDIR` to a directory under your home; snap's Docker cannot bind-mount `/tmp`. Resource limits (`--memory`, `--pids-limit`) need cgroup controllers; on rootless Docker they may be downgraded or rejected — pass `--image` to a local build or run on a rootful daemon. License: Apache-2.0. diff --git a/cmd/registry/main.go b/cmd/registry/main.go index 15d3243..18aa98a 100644 --- a/cmd/registry/main.go +++ b/cmd/registry/main.go @@ -25,20 +25,14 @@ const ( ) func main() { - // Flags are parsed twice: once here to build the real Source/Validator, - // once in run for everything else. Keep the flag names in sync. - image := defaultImage - for i, a := range os.Args { - if a == "--image" && i+1 < len(os.Args) { - image = os.Args[i+1] - } - } src, err := registry.NewGitHubSource(os.Getenv("GITHUB_TOKEN"), "") if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, src, registry.NewDockerValidator(image))) + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, src, func(image string) registry.Validator { + return registry.NewDockerValidator(image) + })) } func usage(w io.Writer) { @@ -46,7 +40,7 @@ func usage(w io.Writer) { fmt.Fprintln(w, " registry build [--registry FILE] [--image IMAGE] [--out DIR] [--base-url URL]") } -func run(args []string, stdout, stderr io.Writer, src registry.Source, val registry.Validator) int { +func run(args []string, stdout, stderr io.Writer, src registry.Source, newValidator func(image string) registry.Validator) int { if len(args) == 0 { usage(stderr) return 2 @@ -54,7 +48,7 @@ func run(args []string, stdout, stderr io.Writer, src registry.Source, val regis fs := flag.NewFlagSet(args[0], flag.ContinueOnError) fs.SetOutput(stderr) regPath := fs.String("registry", "registry.yaml", "path to registry.yaml") - fs.String("image", defaultImage, "goblog image used to load plugins (read in main)") + image := fs.String("image", defaultImage, "goblog image used to load plugins") repo := fs.String("repo", "", "validate only this owner/name (must be listed)") out := fs.String("out", "dist", "build output directory") baseURL := fs.String("base-url", defaultBaseURL, "public URL the output is served from") @@ -73,6 +67,7 @@ func run(args []string, stdout, stderr io.Writer, src registry.Source, val regis fmt.Fprintln(stderr, err) return 1 } + val := newValidator(*image) ctx := context.Background() switch args[0] { diff --git a/cmd/registry/main_test.go b/cmd/registry/main_test.go index 5c24f7d..6c83a61 100644 --- a/cmd/registry/main_test.go +++ b/cmd/registry/main_test.go @@ -59,20 +59,22 @@ func fixture(t *testing.T) (string, *memSource) { return reg, src } +func okFactory(string) registry.Validator { return okValidator{} } + func TestRun_Validate(t *testing.T) { reg, src := fixture(t) var out, errOut bytes.Buffer - code := run([]string{"validate", "--registry", reg, "--repo", "o/hello"}, &out, &errOut, src, okValidator{}) + code := run([]string{"validate", "--registry", reg, "--repo", "o/hello"}, &out, &errOut, src, okFactory) if code != 0 || !strings.Contains(out.String(), "o/hello: ok (hello 1.0.0)") { t.Errorf("code=%d out=%q err=%q", code, out.String(), errOut.String()) } out.Reset() errOut.Reset() - code = run([]string{"validate", "--registry", reg}, &out, &errOut, src, okValidator{}) + code = run([]string{"validate", "--registry", reg}, &out, &errOut, src, okFactory) if code != 1 || !strings.Contains(errOut.String(), "o/broken") || !strings.Contains(out.String(), "o/hello: ok") { t.Errorf("all entries: code=%d out=%q err=%q", code, out.String(), errOut.String()) } - if code := run([]string{"validate", "--registry", reg, "--repo", "o/nothere"}, &out, &errOut, src, okValidator{}); code != 1 { + if code := run([]string{"validate", "--registry", reg, "--repo", "o/nothere"}, &out, &errOut, src, okFactory); code != 1 { t.Errorf("unknown --repo should fail, got %d", code) } } @@ -81,7 +83,7 @@ func TestRun_Build(t *testing.T) { reg, src := fixture(t) dist := filepath.Join(t.TempDir(), "dist") var out, errOut bytes.Buffer - code := run([]string{"build", "--registry", reg, "--out", dist, "--base-url", "https://x.test/p"}, &out, &errOut, src, okValidator{}) + code := run([]string{"build", "--registry", reg, "--out", dist, "--base-url", "https://x.test/p"}, &out, &errOut, src, okFactory) if code != 2 { t.Errorf("a build with a skipped entry should exit 2, got %d (err=%q)", code, errOut.String()) } @@ -92,11 +94,28 @@ func TestRun_Build(t *testing.T) { t.Errorf("skipped entry should be reported on stderr, got %q", errOut.String()) } os.WriteFile(reg, []byte("plugins:\n - repo: o/hello\n"), 0644) - if code := run([]string{"build", "--registry", reg, "--out", dist}, &out, &errOut, src, okValidator{}); code != 0 { + if code := run([]string{"build", "--registry", reg, "--out", dist}, &out, &errOut, src, okFactory); code != 0 { t.Errorf("clean build should exit 0, got %d", code) } } +func TestRun_ImageFlagReachesValidatorFactory(t *testing.T) { + reg, src := fixture(t) + var out, errOut bytes.Buffer + var gotImage string + factory := func(image string) registry.Validator { + gotImage = image + return okValidator{} + } + code := run([]string{"validate", "--registry", reg, "--repo", "o/hello", "--image=custom:1"}, &out, &errOut, src, factory) + if code != 0 { + t.Fatalf("code=%d out=%q err=%q", code, out.String(), errOut.String()) + } + if gotImage != "custom:1" { + t.Errorf("--image=custom:1 should reach the validator factory, got %q", gotImage) + } +} + func TestRun_Usage(t *testing.T) { var out, errOut bytes.Buffer if code := run(nil, &out, &errOut, nil, nil); code != 2 || !strings.Contains(errOut.String(), "usage") { diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 55da1b0..8649ef2 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -50,7 +50,7 @@ docker run --rm --network none -v "$PWD:/p:ro" \ # {"name":"hello","display_name":"Hello","version":"1.0.0"} ``` -The registry's CI runs exactly this, then compares `name` and `version` with your manifest and tag. Your file is executed by the Go interpreter during the check, which is why it runs with networking off. +The registry's CI runs this (plus a timeout and memory/process limits), then compares `name` and `version` with your manifest and tag. Your file is executed by the Go interpreter during the check, which is why it runs with networking off. ## Submit diff --git a/internal/registry/validator.go b/internal/registry/validator.go index 1bf9f45..8de9b56 100644 --- a/internal/registry/validator.go +++ b/internal/registry/validator.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "errors" "fmt" "os" "os/exec" @@ -88,13 +89,22 @@ func (d *DockerValidator) Validate(ctx context.Context, src []byte) (Info, error cmd := exec.CommandContext(ctx, "docker", d.args(dir, name)...) // Cancelling the context only terminates the "docker" CLI process, not // the container it started; kill the container by name so a timeout (or - // caller cancellation) actually stops it instead of leaking it. - cmd.Cancel = func() error { return exec.Command("docker", "kill", name).Run() } + // caller cancellation) actually stops it instead of leaking it. Give the + // kill its own bounded context so a hung daemon can't block forever (Go + // only starts the WaitDelay timer below once Cancel returns). If docker + // kill fails (the container was never created, or already exited), + // --rm still cleans up whatever did start, and the random name rules + // out colliding with a concurrent run. + cmd.Cancel = func() error { + killCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return exec.CommandContext(killCtx, "docker", "kill", name).Run() + } cmd.WaitDelay = 5 * time.Second var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr if err := cmd.Run(); err != nil { - if ctx.Err() != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { return Info{}, fmt.Errorf("validate-plugin: plugin timed out after %s: %s", d.Timeout, strings.TrimSpace(stderr.String())) } return Info{}, fmt.Errorf("validate-plugin failed: %s", strings.TrimSpace(stderr.String()+" "+err.Error())) diff --git a/internal/registry/validator_test.go b/internal/registry/validator_test.go index b002aa9..0a9997f 100644 --- a/internal/registry/validator_test.go +++ b/internal/registry/validator_test.go @@ -86,16 +86,21 @@ func TestNewDockerValidator_DefaultTimeout(t *testing.T) { } // TestDockerValidator_Timeout uses a fake "docker" on PATH that ignores -// "run" and hangs briefly, and answers "kill" instantly, to check that a -// short Timeout produces a "timed out" error rather than hanging until the -// real 120s default. +// "run" and hangs briefly, and answers "kill" by touching a marker file +// named after the container it was asked to kill, to check both that a +// short Timeout produces a "timed out" error (rather than hanging until the +// real 120s default) and that the kill path actually ran against the right +// container name, not just that the error message happens to say "timed +// out" (which it would even with cmd.Cancel left nil). func TestDockerValidator_Timeout(t *testing.T) { dir := t.TempDir() - script := "#!/bin/sh\ncase \"$1\" in\n kill) exit 0 ;;\n *) sleep 0.3; exit 1 ;;\nesac\n" + markerDir := t.TempDir() + script := "#!/bin/sh\ncase \"$1\" in\n kill) touch \"$KILL_MARKER_DIR/$2\"; exit 0 ;;\n *) sleep 0.3; exit 1 ;;\nesac\n" if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0755); err != nil { t.Fatal(err) } t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("KILL_MARKER_DIR", markerDir) v := NewDockerValidator("img") v.Timeout = 50 * time.Millisecond @@ -103,4 +108,11 @@ func TestDockerValidator_Timeout(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "timed out") { t.Errorf("want a timed out error, got %v", err) } + markers, err := filepath.Glob(filepath.Join(markerDir, "goblog-validate-*")) + if err != nil { + t.Fatal(err) + } + if len(markers) != 1 { + t.Errorf("want docker kill to have run against exactly one goblog-validate-* container, got %v", markers) + } } From a3ba70c82d995223a2476129b281a5899d463e09 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 14 Sep 2026 18:46:57 -0700 Subject: [PATCH 6/9] Validate registry entries on PRs and publish the index to GitHub Pages Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/publish.yml | 55 ++++++++++++++++++++++++++++++++++ .github/workflows/validate.yml | 20 +++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/validate.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..fb59567 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,55 @@ +name: Publish index +on: + push: + branches: [main] + schedule: + - cron: "0 */6 * * *" + workflow_dispatch: +permissions: + contents: read + pages: write + id-token: write +concurrency: + group: pages + cancel-in-progress: false +jobs: + build: + runs-on: ubuntu-latest + outputs: + skipped: ${{ steps.build.outputs.skipped }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - name: Build the index + id: build + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set +e + go run ./cmd/registry build --out dist --image compscidr/goblog:v0.2.7 + code=$? + set -e + if [ "$code" = "2" ]; then echo "skipped=true" >> "$GITHUB_OUTPUT"; exit 0; fi + exit $code + - uses: actions/upload-pages-artifact@v5 + with: + path: dist + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v5 + report-skipped: + needs: [build, deploy] + if: needs.build.outputs.skipped == 'true' + runs-on: ubuntu-latest + steps: + - run: | + echo "::error::Some registry entries were skipped; see the build job log." + exit 1 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..332ca30 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,20 @@ +name: Validate +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - name: Unit tests + run: go test ./... + - name: Validate every registry entry + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: go run ./cmd/registry validate --image compscidr/goblog:v0.2.7 From f1d9ccd5f0e7cbb18e30353e87e0c5597cfd8954 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 14 Sep 2026 18:58:10 -0700 Subject: [PATCH 7/9] Fix publish workflow exit-code handling, scope pages permissions, and tighten entry validation - go run collapsed registry build's exit code 2; build a binary first so skipped-entry builds (exit 2) still trigger the skipped-output path instead of failing the job outright - move pages/id-token permissions off the workflow-wide block and onto the deploy job only, since build runs untrusted plugin code - tighten manifest.go's entry check to a regex so odd characters (query, space) are rejected, not just missing .go/slash - add internal/registry/validator_test.go to renovate's regex manager so its hard-coded image tag gets bumped too - clarify CONTRACT.md wording and source.go's RenderMarkdown doc comment - reject --out/--base-url on validate and --repo on build in registry main.go, with tests Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/publish.yml | 9 ++++++--- cmd/registry/main.go | 17 +++++++++++++++++ cmd/registry/main_test.go | 18 ++++++++++++++++++ docs/CONTRACT.md | 2 +- internal/registry/manifest.go | 8 ++++++-- internal/registry/manifest_test.go | 26 ++++++++++++++------------ internal/registry/source.go | 3 ++- renovate.json | 2 +- 8 files changed, 65 insertions(+), 20 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fb59567..dea825f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,8 +7,6 @@ on: workflow_dispatch: permissions: contents: read - pages: write - id-token: write concurrency: group: pages cancel-in-progress: false @@ -27,8 +25,9 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + go build -o "$RUNNER_TEMP/registry" ./cmd/registry set +e - go run ./cmd/registry build --out dist --image compscidr/goblog:v0.2.7 + "$RUNNER_TEMP/registry" build --out dist --image compscidr/goblog:v0.2.7 code=$? set -e if [ "$code" = "2" ]; then echo "skipped=true" >> "$GITHUB_OUTPUT"; exit 0; fi @@ -39,6 +38,10 @@ jobs: deploy: needs: build runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/cmd/registry/main.go b/cmd/registry/main.go index 18aa98a..f7406d3 100644 --- a/cmd/registry/main.go +++ b/cmd/registry/main.go @@ -62,6 +62,23 @@ func run(args []string, stdout, stderr io.Writer, src registry.Source, newValida if err := fs.Parse(args[1:]); err != nil { return 2 } + var disallowed map[string]bool + switch args[0] { + case "validate": + disallowed = map[string]bool{"out": true, "base-url": true} + case "build": + disallowed = map[string]bool{"repo": true} + } + scopeErr := "" + fs.Visit(func(f *flag.Flag) { + if scopeErr == "" && disallowed[f.Name] { + scopeErr = fmt.Sprintf("--%s is not valid for %s", f.Name, args[0]) + } + }) + if scopeErr != "" { + fmt.Fprintln(stderr, scopeErr) + return 2 + } repos, err := registry.LoadRegistry(*regPath) if err != nil { fmt.Fprintln(stderr, err) diff --git a/cmd/registry/main_test.go b/cmd/registry/main_test.go index 6c83a61..b7f32bb 100644 --- a/cmd/registry/main_test.go +++ b/cmd/registry/main_test.go @@ -116,6 +116,24 @@ func TestRun_ImageFlagReachesValidatorFactory(t *testing.T) { } } +func TestRun_FlagScoping(t *testing.T) { + reg, src := fixture(t) + var out, errOut bytes.Buffer + if code := run([]string{"validate", "--registry", reg, "--out", "dist"}, &out, &errOut, src, okFactory); code != 2 || !strings.Contains(errOut.String(), "--out is not valid for validate") { + t.Errorf("--out on validate: code=%d err=%q", code, errOut.String()) + } + out.Reset() + errOut.Reset() + if code := run([]string{"validate", "--registry", reg, "--base-url", "https://x.test"}, &out, &errOut, src, okFactory); code != 2 || !strings.Contains(errOut.String(), "--base-url is not valid for validate") { + t.Errorf("--base-url on validate: code=%d err=%q", code, errOut.String()) + } + out.Reset() + errOut.Reset() + if code := run([]string{"build", "--registry", reg, "--repo", "o/hello"}, &out, &errOut, src, okFactory); code != 2 || !strings.Contains(errOut.String(), "--repo is not valid for build") { + t.Errorf("--repo on build: code=%d err=%q", code, errOut.String()) + } +} + func TestRun_Usage(t *testing.T) { var out, errOut bytes.Buffer if code := run(nil, &out, &errOut, nil, nil); code != 2 || !strings.Contains(errOut.String(), "usage") { diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 8649ef2..0f3c139 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -4,7 +4,7 @@ The directory at [goblog.live/plugins](https://goblog.live/plugins) lists plugin ## What the repository must contain -At the root, at every release tag: +At the root of the repository, at the release tag being published (the tool checks the latest release): | File | Required | Notes | |---|---|---| diff --git a/internal/registry/manifest.go b/internal/registry/manifest.go index fdd4152..cbe4eb7 100644 --- a/internal/registry/manifest.go +++ b/internal/registry/manifest.go @@ -26,6 +26,10 @@ type Manifest struct { // NamePattern is the rule for plugin names; the directory routes on it. var NamePattern = regexp.MustCompile(`^[a-z0-9-]+$`) +// entryPattern is the rule for the manifest's entry file name: a .go file +// name at the repository root, with no path separators or odd characters. +var entryPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+\.go$`) + // knownLicenses is the set of SPDX identifiers accepted in a manifest. It is // deliberately short; add to it when a submission needs another one. var knownLicenses = map[string]bool{ @@ -57,8 +61,8 @@ func ParseManifest(b []byte) (Manifest, error) { if !knownLicenses[m.License] { problems = append(problems, fmt.Sprintf("license %q is not a known SPDX identifier", m.License)) } - if !strings.HasSuffix(m.Entry, ".go") || strings.Contains(m.Entry, "/") { - problems = append(problems, "entry must be a .go file at the repository root") + if !entryPattern.MatchString(m.Entry) { + problems = append(problems, "entry must be a .go file name at the repository root (letters, digits, `_`, `.`, `-`)") } if strings.HasPrefix(m.MinGoblogVersion, "v") || !semver.IsValid("v"+m.MinGoblogVersion) || semver.Prerelease("v"+m.MinGoblogVersion) != "" { problems = append(problems, "min_goblog_version must be a plain semver like 0.2.6") diff --git a/internal/registry/manifest_test.go b/internal/registry/manifest_test.go index d33db9a..f0f4f0d 100644 --- a/internal/registry/manifest_test.go +++ b/internal/registry/manifest_test.go @@ -38,18 +38,20 @@ func TestParseManifest_EntryDefaultsToPluginGo(t *testing.T) { func TestParseManifest_Errors(t *testing.T) { cases := map[string]string{ - "not json": `{`, - "missing name": strings.Replace(goodManifest, `"name": "hello",`, "", 1), - "bad name": strings.Replace(goodManifest, `"name": "hello"`, `"name": "Hello_World"`, 1), - "missing display": strings.Replace(goodManifest, `"display_name": "Hello",`, "", 1), - "missing description": strings.Replace(goodManifest, `"description": "Says hi.",`, "", 1), - "missing author": strings.Replace(goodManifest, `"author": "Jason Ernst",`, "", 1), - "unknown license": strings.Replace(goodManifest, `"Apache-2.0"`, `"MyLicense"`, 1), - "entry not go": strings.Replace(goodManifest, `"plugin.go"`, `"plugin.txt"`, 1), - "entry with slash": strings.Replace(goodManifest, `"plugin.go"`, `"src/plugin.go"`, 1), - "min version with v": strings.Replace(goodManifest, `"0.2.6"`, `"v0.2.6"`, 1), - "min version junk": strings.Replace(goodManifest, `"0.2.6"`, `"latest"`, 1), - "missing min version": strings.Replace(goodManifest, `"min_goblog_version": "0.2.6",`, "", 1), + "not json": `{`, + "missing name": strings.Replace(goodManifest, `"name": "hello",`, "", 1), + "bad name": strings.Replace(goodManifest, `"name": "hello"`, `"name": "Hello_World"`, 1), + "missing display": strings.Replace(goodManifest, `"display_name": "Hello",`, "", 1), + "missing description": strings.Replace(goodManifest, `"description": "Says hi.",`, "", 1), + "missing author": strings.Replace(goodManifest, `"author": "Jason Ernst",`, "", 1), + "unknown license": strings.Replace(goodManifest, `"Apache-2.0"`, `"MyLicense"`, 1), + "entry not go": strings.Replace(goodManifest, `"plugin.go"`, `"plugin.txt"`, 1), + "entry with slash": strings.Replace(goodManifest, `"plugin.go"`, `"src/plugin.go"`, 1), + "entry with query char": strings.Replace(goodManifest, `"plugin.go"`, `"a?b.go"`, 1), + "entry with space": strings.Replace(goodManifest, `"plugin.go"`, `"a b.go"`, 1), + "min version with v": strings.Replace(goodManifest, `"0.2.6"`, `"v0.2.6"`, 1), + "min version junk": strings.Replace(goodManifest, `"0.2.6"`, `"latest"`, 1), + "missing min version": strings.Replace(goodManifest, `"min_goblog_version": "0.2.6",`, "", 1), } for name, src := range cases { if _, err := ParseManifest([]byte(src)); err == nil { diff --git a/internal/registry/source.go b/internal/registry/source.go index bc36e1a..45e69b6 100644 --- a/internal/registry/source.go +++ b/internal/registry/source.go @@ -33,7 +33,8 @@ type Source interface { // File returns the contents of path at ref; ErrNotFound when absent. File(ctx context.Context, owner, repo, ref, path string) ([]byte, error) // RenderMarkdown renders GitHub-flavoured markdown to sanitized HTML in - // the context of ownerRepo (so #123 and relative links resolve). + // the context of ownerRepo (so `#123` and `@user` references resolve; + // relative links and images are left as-is). RenderMarkdown(ctx context.Context, ownerRepo, markdown string) (string, error) } diff --git a/renovate.json b/renovate.json index 401aa99..8f9d68a 100644 --- a/renovate.json +++ b/renovate.json @@ -8,7 +8,7 @@ { "customType": "regex", "description": "Pin of the goblog image used to validate plugins, in workflows and code", - "managerFilePatterns": ["/^\\.github/workflows/.*\\.ya?ml$/", "/^cmd/registry/main\\.go$/", "/^docs/CONTRACT\\.md$/"], + "managerFilePatterns": ["/^\\.github/workflows/.*\\.ya?ml$/", "/^cmd/registry/main\\.go$/", "/^docs/CONTRACT\\.md$/", "/^internal/registry/validator_test\\.go$/"], "matchStrings": ["compscidr/goblog:(?v\\d+\\.\\d+\\.\\d+)"], "depNameTemplate": "compscidr/goblog", "datasourceTemplate": "docker" From e7c8a9b05836389758d819d0a38886a4f6c7abee Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 14 Sep 2026 19:14:06 -0700 Subject: [PATCH 8/9] Document that exit code 2 also covers usage errors Co-Authored-By: Claude Opus 5 (1M context) --- cmd/registry/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/registry/main.go b/cmd/registry/main.go index f7406d3..751fac6 100644 --- a/cmd/registry/main.go +++ b/cmd/registry/main.go @@ -5,7 +5,8 @@ // registry build [--registry registry.yaml] [--image ...] [--out dist] [--base-url URL] // // GITHUB_TOKEN is used when set. Exit codes: 0 ok; 1 a validation failed or -// a fatal error; 2 (build only) output was written but some entries were skipped. +// a fatal error; 2 either a usage error (unknown command, bad flags) or, for +// build, output was written but some entries were skipped. package main import ( From b0707cab989efa2eef50bcd5517a3a8455176558 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 14 Sep 2026 19:14:49 -0700 Subject: [PATCH 9/9] Contract: display_name is the directory label, independent of DisplayName() Co-Authored-By: Claude Opus 5 (1M context) --- docs/CONTRACT.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 0f3c139..e714e44 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -30,6 +30,7 @@ At the root of the repository, at the release tag being published (the tool chec ``` - `name`: `^[a-z0-9-]+$`, unique across the registry, and equal to what your plugin's `Name()` returns. +- `display_name`: the label shown in the directory. It does not have to equal your plugin's `DisplayName()`, which labels its settings group in the admin UI. - `license`: an SPDX identifier from the list in `internal/registry/manifest.go` (MIT, Apache-2.0, BSD-2/3-Clause, ISC, MPL-2.0, GPL/LGPL/AGPL `-only`/`-or-later`, Unlicense, 0BSD). Open an issue to add another. - `entry`: a `.go` file at the repository root; defaults to `plugin.go`. - `min_goblog_version`: plain semver (`0.2.6`, no `v`) — the oldest goblog your plugin works with.