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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..27acb62 --- /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. `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 new file mode 100644 index 0000000..18aa98a --- /dev/null +++ b/cmd/registry/main.go @@ -0,0 +1,124 @@ +// 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() { + 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, func(image string) registry.Validator { + return 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, newValidator func(image string) 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") + 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") + + 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 + } + val := newValidator(*image) + 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..6c83a61 --- /dev/null +++ b/cmd/registry/main_test.go @@ -0,0 +1,127 @@ +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 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, 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, 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, okFactory); 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, okFactory) + 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, 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") { + 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..8649ef2 --- /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 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 + +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/internal/registry/build.go b/internal/registry/build.go new file mode 100644 index 0000000..b7f6ef0 --- /dev/null +++ b/internal/registry/build.go @@ -0,0 +1,187 @@ +package registry + +import ( + "bytes" + "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 { + 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, buf.Bytes(), 0644) +} diff --git a/internal/registry/build_test.go b/internal/registry/build_test.go new file mode 100644 index 0000000..d64d0af --- /dev/null +++ b/internal/registry/build_test.go @@ -0,0 +1,134 @@ +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) + } + 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) + } + 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) + } +} diff --git a/internal/registry/validate.go b/internal/registry/validate.go new file mode 100644 index 0000000..3be4170 --- /dev/null +++ b/internal/registry/validate.go @@ -0,0 +1,108 @@ +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") + + // 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) + } + 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..04d05e2 --- /dev/null +++ b/internal/registry/validate_test.go @@ -0,0 +1,164 @@ +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_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. + 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..8de9b56 --- /dev/null +++ b/internal/registry/validator.go @@ -0,0 +1,117 @@ +package registry + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// 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" + +// 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, Timeout: defaultValidateTimeout} +} + +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 { + return Info{}, err + } + defer os.RemoveAll(dir) + if err := os.WriteFile(filepath.Join(dir, "plugin.go"), src, 0644); err != nil { + return Info{}, err + } + + 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. 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 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())) + } + 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..0a9997f --- /dev/null +++ b/internal/registry/validator_test.go @@ -0,0 +1,118 @@ +package registry + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// 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", "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) { + 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]) + } + } +} + +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" 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() + 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 + _, 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) + } + 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) + } +} diff --git a/registry.yaml b/registry.yaml index 03f23d9..223fac1 100644 --- a/registry.yaml +++ b/registry.yaml @@ -3,3 +3,4 @@ # repository must contain. CI validates every entry on each PR. plugins: - repo: goblogplatform/goblog-plugin-hello + - repo: goblogplatform/goblog 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" + } + ] +}