Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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/<name>.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.
124 changes: 124 additions & 0 deletions cmd/registry/main.go
Original file line number Diff line number Diff line change
@@ -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
}
127 changes: 127 additions & 0 deletions cmd/registry/main_test.go
Original file line number Diff line number Diff line change
@@ -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 "<p>" + md + "</p>", 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)
}
}
Loading
Loading