From c71204c343fbc1d0f34e5b2737bd5d9ffff2464e Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Sun, 20 Sep 2026 08:09:43 -0700 Subject: [PATCH] Retire the GitHub-driven registry; the directory lives in goblog.live Co-Authored-By: Claude Opus 5 (1M context) --- .github/ISSUE_TEMPLATE/config.yml | 1 - .github/ISSUE_TEMPLATE/submit-plugin.yml | 24 --- .github/workflows/publish.yml | 58 ------- .github/workflows/submit.yml | 145 ---------------- .github/workflows/validate.yml | 20 --- README.md | 26 +-- cmd/registry/main.go | 142 ---------------- cmd/registry/main_test.go | 152 ----------------- docs/CONTRACT.md | 122 -------------- go.mod | 11 -- go.sum | 13 -- internal/registry/build.go | 200 ---------------------- internal/registry/build_test.go | 183 -------------------- internal/registry/manifest.go | 105 ------------ internal/registry/manifest_test.go | 112 ------------- internal/registry/registry.go | 49 ------ internal/registry/registry_test.go | 48 ------ internal/registry/source.go | 173 ------------------- internal/registry/source_test.go | 145 ---------------- internal/registry/validate.go | 126 -------------- internal/registry/validate_test.go | 205 ----------------------- internal/registry/validator.go | 121 ------------- internal/registry/validator_test.go | 157 ----------------- registry.yaml | 7 - renovate.json | 17 -- 25 files changed, 6 insertions(+), 2356 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/ISSUE_TEMPLATE/submit-plugin.yml delete mode 100644 .github/workflows/publish.yml delete mode 100644 .github/workflows/submit.yml delete mode 100644 .github/workflows/validate.yml delete mode 100644 cmd/registry/main.go delete mode 100644 cmd/registry/main_test.go delete mode 100644 docs/CONTRACT.md delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 internal/registry/build.go delete mode 100644 internal/registry/build_test.go delete mode 100644 internal/registry/manifest.go delete mode 100644 internal/registry/manifest_test.go delete mode 100644 internal/registry/registry.go delete mode 100644 internal/registry/registry_test.go delete mode 100644 internal/registry/source.go delete mode 100644 internal/registry/source_test.go delete mode 100644 internal/registry/validate.go delete mode 100644 internal/registry/validate_test.go delete mode 100644 internal/registry/validator.go delete mode 100644 internal/registry/validator_test.go delete mode 100644 registry.yaml delete mode 100644 renovate.json diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 0086358..0000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1 +0,0 @@ -blank_issues_enabled: true diff --git a/.github/ISSUE_TEMPLATE/submit-plugin.yml b/.github/ISSUE_TEMPLATE/submit-plugin.yml deleted file mode 100644 index 0e1f88d..0000000 --- a/.github/ISSUE_TEMPLATE/submit-plugin.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Submit a plugin -description: Add your goblog plugin repository to the directory -title: "Submit: " -labels: [submission] -body: - - type: markdown - attributes: - value: | - Your repository must follow the [contract](https://github.com/goblogplatform/plugins/blob/main/docs/CONTRACT.md): `goblog-plugin.json` with `"runtime": "wasm"`, `README.md`, and a release tagged `vX.Y.Z` with `plugin.wasm` attached. A workflow validates it and, if it passes, opens the pull request for you. - - type: input - id: repo - attributes: - label: Repository - description: GitHub repository as owner/name - placeholder: goblogplatform/goblog-plugin-hello - validations: - required: true - - type: checkboxes - id: contract - attributes: - label: Contract - options: - - label: My repository has goblog-plugin.json, README.md, the plugin file, and a published vX.Y.Z release - required: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 53a91a5..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Publish index -on: - push: - branches: [main] - schedule: - - cron: "0 */6 * * *" - workflow_dispatch: -permissions: - contents: read -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: | - go build -o "$RUNNER_TEMP/registry" ./cmd/registry - set +e - "$RUNNER_TEMP/registry" build --out dist --image compscidr/goblog:v0.2.9 - 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 - permissions: - contents: read - pages: write - id-token: write - 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/submit.yml b/.github/workflows/submit.yml deleted file mode 100644 index d77cd33..0000000 --- a/.github/workflows/submit.yml +++ /dev/null @@ -1,145 +0,0 @@ -name: Submission -on: - issues: - types: [opened, edited] -concurrency: - group: submit-${{ github.event.issue.number }} - cancel-in-progress: true -permissions: {} -jobs: - check: - # Only issues from the "Submit a plugin" form carry this label. - if: contains(github.event.issue.labels.*.name, 'submission') - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - repo: ${{ steps.parse.outputs.repo }} - ok: ${{ steps.validate.outputs.ok }} - steps: - - uses: actions/checkout@v7 - with: - persist-credentials: false - - uses: actions/setup-go@v7 - with: - go-version-file: go.mod - - name: Parse the repository from the issue form - id: parse - env: - BODY: ${{ github.event.issue.body }} - run: | - repo=$(printf '%s\n' "$BODY" | awk '/^### Repository/{f=1; next} f && NF {print; exit}' | tr -d '[:space:]') - repo=${repo#https://github.com/} - repo=${repo%.git} - if ! printf '%s' "$repo" | grep -Eq '^[A-Za-z0-9-]+/[A-Za-z0-9_.-]+$'; then - echo "::error::Could not read an owner/name repository from the issue form" - exit 1 - fi - name=${repo#*/} - if [ "$name" = "." ] || [ "$name" = ".." ] || [[ "$name" == *.git ]]; then - echo "::error::Invalid repository name" - exit 1 - fi - echo "repo=$repo" >> "$GITHUB_OUTPUT" - - name: Validate the submission (runs the plugin in the sandbox) - id: validate - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ steps.parse.outputs.repo }} - run: | - if grep -Fxq " - repo: $REPO" registry.yaml; then - echo "already listed" > result.txt - echo "ok=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - printf ' - repo: %s\n' "$REPO" >> registry.yaml - go build -o "$RUNNER_TEMP/registry" ./cmd/registry - set +e - "$RUNNER_TEMP/registry" build --out "$RUNNER_TEMP/dist" --image compscidr/goblog:v0.2.9 > result.txt 2>&1 - set -e - cat result.txt - if grep -Fxq "$REPO: built" result.txt; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi - - uses: actions/upload-artifact@v7 - if: always() - with: - name: result - path: result.txt - respond: - needs: check - if: always() && needs.check.result != 'skipped' - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - issues: write - env: - GH_TOKEN: ${{ secrets.SUBMIT_TOKEN || secrets.GITHUB_TOKEN }} - HAS_TOKEN: ${{ secrets.SUBMIT_TOKEN != '' }} - REPO: ${{ needs.check.outputs.repo }} - ISSUE: ${{ github.event.issue.number }} - steps: - - uses: actions/checkout@v7 - with: - token: ${{ secrets.SUBMIT_TOKEN || secrets.GITHUB_TOKEN }} - - uses: actions/download-artifact@v8 - continue-on-error: true - with: - name: result - - name: Open the registry pull request - if: needs.check.outputs.ok == 'true' - run: | - branch="submit/$(printf '%s' "$REPO" | tr '/' '-')" - if [ "$HAS_TOKEN" != "true" ]; then - gh issue comment "$ISSUE" --body "Validation passed for \`$REPO\`, but the pull request could not be opened automatically — a maintainer needs to set the \`SUBMIT_TOKEN\` repository secret (see README). In the meantime, add \`- repo: $REPO\` to \`registry.yaml\` by hand." - exit 0 - fi - if gh pr list --head "$branch" --state open --json url --jq '.[0].url' | grep -q .; then - url=$(gh pr list --head "$branch" --state open --json url --jq '.[0].url') - gh issue comment "$ISSUE" --body "A pull request for \`$REPO\` is already open: $url" - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git checkout -b "$branch" - printf ' - repo: %s\n' "$REPO" >> registry.yaml - git add registry.yaml - git commit -m "Add $REPO" - if ! git push --force-with-lease -u origin "$branch"; then - gh issue comment "$ISSUE" --body "Validation passed for \`$REPO\`, but branch \`$branch\` could not be pushed (see run ${{ github.run_id }}). A maintainer needs to investigate." - exit 1 - fi - if ! url=$(gh pr create --base main --head "$branch" --title "Add $REPO" --body "Submitted in #$ISSUE. Validation passed in the submission workflow (run ${{ github.run_id }}). - - Closes #$ISSUE"); then - gh issue comment "$ISSUE" --body "Validation passed for \`$REPO\`, but the pull request could not be opened — a maintainer needs to set \`SUBMIT_TOKEN\`; branch \`$branch\` is pushed." - exit 1 - fi - gh issue comment "$ISSUE" --body "Validation passed — opened $url for a maintainer to merge. Thanks!" - - name: Report a failed validation - if: needs.check.outputs.ok != 'true' - run: | - if [ -z "$REPO" ]; then - gh issue comment "$ISSUE" --body "Could not read a repository from the form — edit the **Repository** field to \`owner/name\`." - exit 0 - fi - if [ -f result.txt ]; then - out=$(head -c 6000 result.txt) - if [ "$(wc -c < result.txt)" -gt 6000 ]; then - case "$out" in - *$'\n'*) out="${out%$'\n'*}";; - esac - out="$out"$'\n'"… (truncated)" - fi - out=$(printf '%s' "$out" | sed 's/@/@\xe2\x80\x8b/g; s/`/\xcb\x8b/g; s/~/\xe2\x88\xbc/g') - else - out="the workflow could not read a repository from the form (see run ${{ github.run_id }})" - fi - if [ "$out" = "already listed" ]; then - gh issue comment "$ISSUE" --body "\`$REPO\` is already listed in the registry." - else - gh issue comment "$ISSUE" --body "Validation of \`$REPO\` failed. Fix the repository (see [docs/CONTRACT.md](https://github.com/goblogplatform/plugins/blob/main/docs/CONTRACT.md)), then edit this issue to re-run. - - ~~~~ - $out - ~~~~" - fi diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml deleted file mode 100644 index efd9dab..0000000 --- a/.github/workflows/validate.yml +++ /dev/null @@ -1,20 +0,0 @@ -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.9 diff --git a/README.md b/README.md index c93c858..ec09cc8 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,9 @@ -# goblog plugin registry +# goblog plugins -The curated list of [goblog](https://github.com/goblogplatform/goblog) plugins behind [goblog.live/plugins](https://goblog.live/plugins). +This repository used to hold the curated list behind [goblog.live/plugins](https://goblog.live/plugins) and the CI that built its index. The directory is now run by goblog.live itself: -- `registry.yaml` — the list. Submit your repository via the [issue form](.github/ISSUE_TEMPLATE/submit-plugin.yml) (a pull request by hand is the alternative); see [docs/CONTRACT.md](docs/CONTRACT.md). -- `https://goblogplatform.github.io/plugins/index.json` — the machine-readable index: the latest release of each plugin, with `download_url` (the release asset named by the manifest's `entry`, `plugin.wasm` by default) and its `sha256`, `runtime` and `install_type` (both `wasm`), `allowed_hosts` (the network the module may reach; `[]` for none) and `stars` (GitHub stargazers, the directory's default ordering); `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. +- **Publish a plugin:** paste your repository URL at [goblog.live/plugins/submit](https://goblog.live/plugins/submit). It is validated immediately and listed once approved. +- **What a plugin repository must contain:** [docs/PLUGIN_CONTRACT.md](https://github.com/goblogplatform/goblog/blob/main/docs/PLUGIN_CONTRACT.md) in the goblog repository. +- **Examples:** [goblog-plugin-hello](https://github.com/goblogplatform/goblog-plugin-hello) (smallest complete plugin) and [goblog-plugin-scholar](https://github.com/goblogplatform/goblog-plugin-scholar). -```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` download each plugin's module — the release asset named by `entry` in its manifest, `plugin.wasm` by default — and run `goblog validate-plugin` on it 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. - -## Submissions - -The `Submission` workflow (`.github/workflows/submit.yml`) turns a [submission issue](.github/ISSUE_TEMPLATE/submit-plugin.yml) into a `registry.yaml` pull request without anyone touching Git. Its `check` job (`contents: read` only) parses the `owner/name` from the issue form and runs `registry build` against it in the sandbox; its `respond` job opens the pull request when that passes, or comments on the issue with the failure (or an "already listed" notice) when it doesn't. - -**`SUBMIT_TOKEN` is required**, not optional: this organization disables "Allow GitHub Actions to create and approve pull requests", so the default `GITHUB_TOKEN` cannot open pull requests here at all (separately, GitHub also prevents `GITHUB_TOKEN`-created PRs from triggering other workflows, so even where that org setting is allowed, `Validate` wouldn't run on them). Without `SUBMIT_TOKEN` the workflow still validates submissions and comments the result, but a maintainer must add passing entries to `registry.yaml` by hand. Set it as a repository secret: a fine-grained PAT with Contents, Pull requests and Issues write on this repo, minted from a **dedicated machine user or GitHub App** — not a maintainer's personal account, since the bot's comments and commits are attributed to whatever identity the token belongs to. - -License: Apache-2.0. +The machine-readable index is `https://www.goblog.live/plugins/index.json`; the GitHub Pages copy that used to live under `goblogplatform.github.io/plugins` is gone. diff --git a/cmd/registry/main.go b/cmd/registry/main.go deleted file mode 100644 index 6b7894f..0000000 --- a/cmd/registry/main.go +++ /dev/null @@ -1,142 +0,0 @@ -// 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 either a usage error (unknown command, bad flags) or, for -// build, 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.9" - 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 - } - 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) - 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 deleted file mode 100644 index ab24a7c..0000000 --- a/cmd/registry/main_test.go +++ /dev/null @@ -1,152 +0,0 @@ -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) ReleaseAsset(_ context.Context, _, _ string, id int64) ([]byte, error) { - if id == 11 { - return []byte("\x00asm hello"), nil - } - return nil, errors.New("no such asset") -} -func (m *memSource) RenderMarkdown(_ context.Context, _, md string) (string, error) { - return "

" + md + "

", nil -} -func (m *memSource) RepoStars(context.Context, string, string) (int, error) { return 3, nil } - -type okValidator struct{} - -func (okValidator) Validate(_ context.Context, _ []byte) (registry.Info, error) { - return registry.Info{Name: "hello", DisplayName: "Hello", Version: "1.0.0", Runtime: "wasm"}, 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), - Assets: []registry.Asset{{ID: 11, Name: "plugin.wasm", Size: 11, DownloadURL: "https://github.com/o/hello/releases/download/v1.0.0/plugin.wasm"}}}}, - "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","runtime":"wasm","min_goblog_version":"0.2.6"}`, - "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_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") { - 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 deleted file mode 100644 index 131c3d6..0000000 --- a/docs/CONTRACT.md +++ /dev/null @@ -1,122 +0,0 @@ -# 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 whose releases each carry a compiled WebAssembly module; 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). - -**Only WebAssembly plugins are accepted.** Yaegi-interpreted `.go` plugins (goblog's [dynamic plugins](https://github.com/goblogplatform/goblog#dynamic-plugins)) still work when an operator drops the file in by hand, but the directory does not list or install them: a `.wasm` module can bundle any dependency its author likes, runs with no filesystem and no network beyond the hosts it declares, and needs no goblog rebuild. - -## What the directory publishes - -The index entry for your plugin is built from the manifest, the latest release and its module asset (the one named by `entry`, `plugin.wasm` by default) (`download_url` is the asset's browser URL; `sha256` is of the asset), and your repository's GitHub star count (`stars`), which the directory uses for its default ordering. Stars are best-effort: if GitHub cannot be reached for them, the entry is published with `0`. The entry also carries `runtime: "wasm"`, `install_type: "wasm"` and the manifest's `allowed_hosts`, which goblog's admin page shows as "Talks to: …" before an operator installs. - -## What the repository must contain - -At the root of the repository, at the release tag being published (the tool checks the latest release): - -| File | Required | Notes | -|---|---|---| -| `goblog-plugin.json` | yes | the manifest, below | -| the plugin's source | yes | anything that builds the module — Go with [`github.com/extism/go-pdk`](https://github.com/extism/go-pdk), TinyGo, Rust, or any language with an [Extism PDK](https://extism.org/docs/concepts/pdk) | -| `README.md` | yes | shown on the plugin's directory page | -| `CHANGELOG.md` | no | shown when present | -| `LICENSE` | recommended | not checked by the validator; state the same license as `license` in the manifest | - -And attached to every release: the compiled module, named as `entry` in the manifest (default `plugin.wasm`). The module is a release **asset**, not a file in the repository. - -### `goblog-plugin.json` - -```json -{ - "name": "hello", - "display_name": "Hello", - "description": "One sentence shown in the listing.", - "author": "Your Name", - "license": "Apache-2.0", - "runtime": "wasm", - "entry": "plugin.wasm", - "allowed_hosts": ["api.example.com"], - "min_goblog_version": "0.2.9", - "homepage": "https://example.com/optional" -} -``` - -- `name`: `^[a-z0-9-]+$`, unique across the registry, and equal to the `name` your plugin's `identity` export returns. It keys the plugin's settings and its persistent store, so keep it stable across versions. -- `display_name`: the label shown in the directory. It does not have to equal your plugin's `display_name`, 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. -- `runtime`: must be `"wasm"`. Anything else is rejected. -- `entry`: the name of the `.wasm` asset attached to each release (letters, digits, `_`, `.`, `-`; no path); defaults to `plugin.wasm`. -- `allowed_hosts`: the hosts the module may reach over HTTP — exact hostnames (`api.example.com`), IPs, or globs (`*.example.com`), each optionally with a port; never a scheme or a path. A glob must still name a domain — `*` alone (or `**`, `*.*`) is rejected. Omit it, or leave it empty, and the plugin gets no network at all. goblog checks every request (and every redirect hop) against this list, and the directory shows it to operators as "Talks to" before they install, so declare only what you use. -- `min_goblog_version`: plain semver (`0.2.9`, no `v`) — the oldest goblog your plugin works with. WebAssembly plugins need at least `0.2.9`. - -### The module - -A plugin is one `.wasm` file built for [Extism](https://extism.org/): every export takes and returns JSON through Extism's input/output. Only `identity` is mandatory; the others (`settings`, `pages`, `jobs`, `template_head`, `template_footer`, `template_data`, `render_page`, `run_job`, `on_init`) are optional and mirror goblog's compiled-in plugin interface. Host functions give you a per-plugin key/value store (`store_get`/`store_set`/`store_delete`/`store_list`), logging through the PDK's logger, and Extism's `http_request` limited to `allowed_hosts`. Calls are capped at 10 s (120 s for jobs and `on_init`) and 64 MB of memory. The full contract — every export's input and output shape, `ctx`, the host functions and the limits — is in goblog's README under [WebAssembly plugins](https://github.com/goblogplatform/goblog#webassembly-plugins), and [`plugin/wasm/testdata/echo/main.go`](https://github.com/goblogplatform/goblog/blob/main/plugin/wasm/testdata/echo/main.go) implements all of it. [goblog-plugin-hello](https://github.com/goblogplatform/goblog-plugin-hello) is the smallest complete example and is meant to be copied. - -With the standard Go toolchain (1.24 or newer): - -```bash -GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -ldflags="-s -w" -o plugin.wasm . -``` - -The module must be 16 MiB or smaller; `-ldflags="-s -w"` keeps a Go build well under that. - -### Releases - -- Tag releases `vX.Y.Z` (exactly three numbers). Drafts and pre-releases are ignored. -- The tag without `v` must equal the `version` your plugin's `identity` export returns. -- **Every release must have the module attached** as the asset named by `entry`. The registry validates and publishes the asset, never a file from the repository, so a release without it fails validation with `has no asset named plugin.wasm`. -- 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. - -Copy this workflow into `.github/workflows/release.yml` and the asset is built and uploaded whenever you publish a release: - -```yaml -name: Release -on: - release: - types: [published] -permissions: - contents: write -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-go@v7 - with: - go-version-file: go.mod - - name: Build plugin.wasm - run: GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -ldflags="-s -w" -o plugin.wasm . - - name: Upload to the release - env: - GH_TOKEN: ${{ github.token }} - run: gh release upload "${{ github.event.release.tag_name }}" plugin.wasm --clobber -``` - -The registry reads the asset when it validates; if the workflow is still uploading when it looks, re-run validation once the asset is up (edit your submission issue, or re-run the pull request's checks). - -## Check before you submit - -```bash -GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -ldflags="-s -w" -o plugin.wasm . -docker run --rm --network none -v "$PWD:/p:ro" \ - --entrypoint /go/src/github.com/compscidr/goblog/goblog compscidr/goblog:v0.2.9 \ - validate-plugin /p/plugin.wasm -# {"name":"hello","display_name":"Hello","version":"1.0.0","runtime":"wasm"} -``` - -The registry's CI runs this (plus a timeout and memory/process limits) against the asset on your latest release, then requires `"runtime":"wasm"` and compares `name` and `version` with your manifest and tag. `validate-plugin` loads the module with no store and no network and calls `identity`, `settings`, `pages` and `jobs`, so those exports must not depend on either. - -## Submit - -1. Open a [submission issue](https://github.com/goblogplatform/plugins/issues/new?template=submit-plugin.yml) with your `owner/name` (the box on goblog.live/plugins does this for you). -2. The `Submission` workflow validates the repository and comments the result; if it passes it opens the `registry.yaml` pull request (this requires the repository secret `SUBMIT_TOKEN` to be set — see the main [README](../README.md#submissions); without it, the workflow still comments the validation result but a maintainer must open the pull request by hand). -3. A maintainer merges it; the index rebuilds within minutes. - -Alternatively, open a pull request by hand: fork this repository, add a line to `registry.yaml`, and open a PR — the `validate` workflow must pass. -```yaml -plugins: - - repo: goblogplatform/goblog-plugin-hello - - repo: you/goblog-plugin-yours -``` - -Plugins run inside the goblog process of whoever installs them, sandboxed but trusted with the hosts they declare and the settings they are given. Keep them small and readable; the registry is curated and maintainers may decline or remove entries. diff --git a/go.mod b/go.mod deleted file mode 100644 index 20da3b0..0000000 --- a/go.mod +++ /dev/null @@ -1,11 +0,0 @@ -module github.com/goblogplatform/plugins - -go 1.26.0 - -require ( - github.com/google/go-github/v92 v92.0.0 - golang.org/x/mod v0.41.0 - gopkg.in/yaml.v3 v3.0.1 -) - -require github.com/google/go-querystring v1.2.0 // indirect diff --git a/go.sum b/go.sum deleted file mode 100644 index a09ab88..0000000 --- a/go.sum +++ /dev/null @@ -1,13 +0,0 @@ -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v92 v92.0.0 h1:4vW4RVffwIvoEfIA4RX09mRSB47qQX557/2+HgeWRRg= -github.com/google/go-github/v92 v92.0.0/go.mod h1:w3CH62ZcmRfvW1cdXpyTztSOVMtdjxtKpgo0GouLmjY= -github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= -github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= -golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= -golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/registry/build.go b/internal/registry/build.go deleted file mode 100644 index e2ceee4..0000000 --- a/internal/registry/build.go +++ /dev/null @@ -1,200 +0,0 @@ -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"` // "wasm" - Runtime string `json:"runtime"` // "wasm" - AllowedHosts []string `json:"allowed_hosts"` // never null: [] when the plugin uses no network - ReleasedAt string `json:"released_at"` - DetailURL string `json:"detail_url"` - Stars int `json:"stars"` -} - -// 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: v.Asset.DownloadURL, - SHA256: v.SHA256, - MinGoblogVersion: v.Manifest.MinGoblogVersion, - InstallType: "wasm", - Runtime: "wasm", - AllowedHosts: v.Manifest.AllowedHosts, - ReleasedAt: v.Release.PublishedAt.UTC().Format(time.RFC3339), - DetailURL: fmt.Sprintf("%s/plugins/%s.json", baseURL, v.Manifest.Name), - } - - // Stars only order the directory; a failed lookup must not drop an - // otherwise valid plugin from the index. - if stars, err := src.RepoStars(ctx, v.Owner, v.Name); err != nil { - log.Printf("%s: stars unavailable, using 0: %v", ownerRepo, err) - } else { - entry.Stars = stars - } - - 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 deleted file mode 100644 index 2c35450..0000000 --- a/internal/registry/build_test.go +++ /dev/null @@ -1,183 +0,0 @@ -package registry - -import ( - "context" - "encoding/json" - "errors" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - "time" -) - -func TestBuild_WritesIndexAndDetails(t *testing.T) { - src := helloSource() - // A second plugin, older release and no allowed_hosts, to check sorting, - // skipping and that allowed_hosts never serialises as null. - zetaWasm := []byte("\x00asm zeta v0.1.0") - 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), - Assets: []Asset{{ID: 21, Name: "plugin.wasm", Size: len(zetaWasm), DownloadURL: "https://github.com/o/zeta/releases/download/v0.1.0/plugin.wasm"}}}} - zetaManifest := strings.Replace(strings.Replace(goodManifest, `"hello"`, `"zeta"`, 1), `"Hello"`, `"Zeta"`, 1) - src.files["o/zeta@v0.1.0:goblog-plugin.json"] = strings.Replace(zetaManifest, `"allowed_hosts": ["api.example.test"],`, "", 1) - src.files["o/zeta@v0.1.0:README.md"] = "# Zeta" - src.assets[21] = zetaWasm - val := helloValidator() - val.Infos[sum(zetaWasm)] = Info{Name: "zeta", DisplayName: "Zeta", Version: "0.1.0", Runtime: "wasm"} - - 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://github.com/o/hello/releases/download/v1.1.0/plugin.wasm", SHA256: sum(helloWasm), - MinGoblogVersion: "0.2.6", InstallType: "wasm", Runtime: "wasm", AllowedHosts: []string{"api.example.test"}, - ReleasedAt: "2026-09-15T00:00:00Z", - DetailURL: "https://example.test/plugins/plugins/hello.json", Stars: 7, - } - if !reflect.DeepEqual(e, want) { - t.Errorf("entry =\n%+v\nwant\n%+v", e, want) - } - if z := index[1]; z.Runtime != "wasm" || z.InstallType != "wasm" || z.AllowedHosts == nil || len(z.AllowedHosts) != 0 || - z.DownloadURL != "https://github.com/o/zeta/releases/download/v0.1.0/plugin.wasm" || z.SHA256 != sum(zetaWasm) { - t.Errorf("zeta entry = %+v", z) - } - - 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 d.Stars != 7 { - t.Errorf("detail stars = %d", d.Stars) - } - 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) - } - // goblog's directory client ranges over allowed_hosts; a plugin with no - // hosts must serialise as an empty array, never null. - if !strings.Contains(string(raw), `"allowed_hosts": []`) { - t.Errorf("a plugin without hosts should serialise \"allowed_hosts\": [], got %s", raw) - } - if strings.Contains(string(raw), "null") { - t.Errorf("index.json must not contain null, got %s", raw) - } - if !strings.Contains(string(raw), `"runtime": "wasm"`) || !strings.Contains(string(raw), `"install_type": "wasm"`) { - t.Errorf("index.json should carry runtime and install_type wasm, got %s", raw) - } - zetaRaw, _ := os.ReadFile(filepath.Join(out, "plugins", "zeta.json")) - if !strings.Contains(string(zetaRaw), `"allowed_hosts": []`) { - t.Errorf("detail doc should also serialise \"allowed_hosts\": [], got %s", zetaRaw) - } -} - -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") - } -} - -// TestBuild_StarsAreBestEffort: a failed star lookup must not drop a valid -// plugin; the entry is published with stars 0. -func TestBuild_StarsAreBestEffort(t *testing.T) { - src := helloSource() - src.starsErr = errors.New("rate limited") - out := t.TempDir() - res, err := Build(context.Background(), src, helloValidator(), []string{"o/hello"}, out, "https://example.test/plugins") - if err != nil { - t.Fatal(err) - } - if len(res.Built) != 1 || len(res.Skipped) != 0 { - t.Fatalf("result = %+v", res) - } - var index []IndexEntry - mustJSON(t, filepath.Join(out, "index.json"), &index) - if len(index) != 1 || index[0].Stars != 0 { - t.Errorf("entry should be published with stars 0, got %+v", index) - } -} - -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/manifest.go b/internal/registry/manifest.go deleted file mode 100644 index 87e042b..0000000 --- a/internal/registry/manifest.go +++ /dev/null @@ -1,105 +0,0 @@ -// Package registry validates goblog plugin repositories and builds the -// directory index published to GitHub Pages. -package registry - -import ( - "encoding/json" - "fmt" - "regexp" - "strings" - - "golang.org/x/mod/semver" -) - -// Manifest is goblog-plugin.json at the root of a plugin repository. -type Manifest struct { - Name string `json:"name"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - Author string `json:"author"` - License string `json:"license"` - Runtime string `json:"runtime"` - Entry string `json:"entry"` - AllowedHosts []string `json:"allowed_hosts"` - MinGoblogVersion string `json:"min_goblog_version"` - Homepage string `json:"homepage"` -} - -// 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: the name of a .wasm -// asset attached to each release, with no path separators or odd characters. -var entryPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+\.wasm$`) - -// hostPattern is the rule for one allowed_hosts entry: a hostname, IP or -// glob (goblog matches them with github.com/gobwas/glob), optionally with a -// port — never a scheme or a path. -var hostPattern = regexp.MustCompile(`^[A-Za-z0-9.*:-]+$`) - -// wildcardOnly matches an allowed_hosts entry made of nothing but `*` and -// `.` (`*`, `**`, `*.*`): a glob that names no host at all and would let the -// plugin reach anything. The registry is the curation point, so it refuses -// them rather than leaving it to the operator. -var wildcardOnly = regexp.MustCompile(`^[*.]+$`) - -// 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{ - "MIT": true, "Apache-2.0": true, "BSD-2-Clause": true, "BSD-3-Clause": true, - "ISC": true, "MPL-2.0": true, "Unlicense": true, "0BSD": true, - "GPL-2.0-only": true, "GPL-2.0-or-later": true, "GPL-3.0-only": true, "GPL-3.0-or-later": true, - "LGPL-2.1-only": true, "LGPL-2.1-or-later": true, "LGPL-3.0-only": true, "LGPL-3.0-or-later": true, - "AGPL-3.0-only": true, "AGPL-3.0-or-later": true, -} - -// ParseManifest decodes and validates a manifest. Entry defaults to -// plugin.wasm; AllowedHosts is never nil on success so the index serialises -// it as [] rather than null. -func ParseManifest(b []byte) (Manifest, error) { - var m Manifest - if err := json.Unmarshal(b, &m); err != nil { - return Manifest{}, fmt.Errorf("goblog-plugin.json: %w", err) - } - if m.Entry == "" { - m.Entry = "plugin.wasm" - } - if m.AllowedHosts == nil { - m.AllowedHosts = []string{} - } - var problems []string - if !NamePattern.MatchString(m.Name) { - problems = append(problems, "name must match ^[a-z0-9-]+$") - } - for field, v := range map[string]string{"display_name": m.DisplayName, "description": m.Description, "author": m.Author} { - if strings.TrimSpace(v) == "" { - problems = append(problems, field+" is required") - } - } - if !knownLicenses[m.License] { - problems = append(problems, fmt.Sprintf("license %q is not a known SPDX identifier", m.License)) - } - if m.Runtime != "wasm" { - problems = append(problems, "runtime must be \"wasm\": the directory only lists WebAssembly plugins; see docs/CONTRACT.md") - } - if !entryPattern.MatchString(m.Entry) { - problems = append(problems, "entry must be a .wasm release asset name (letters, digits, `_`, `.`, `-`)") - } - for _, h := range m.AllowedHosts { - if h == "" || !hostPattern.MatchString(h) { - problems = append(problems, "allowed_hosts entries must be hostnames, IPs or globs without scheme or path") - break - } - if wildcardOnly.MatchString(h) { - problems = append(problems, `allowed_hosts entries must name a host; "*" alone is not allowed`) - break - } - } - 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") - } - if len(problems) > 0 { - return Manifest{}, fmt.Errorf("goblog-plugin.json: %s", strings.Join(problems, "; ")) - } - return m, nil -} diff --git a/internal/registry/manifest_test.go b/internal/registry/manifest_test.go deleted file mode 100644 index a941d63..0000000 --- a/internal/registry/manifest_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package registry - -import ( - "strings" - "testing" -) - -const goodManifest = `{ - "name": "hello", - "display_name": "Hello", - "description": "Says hi.", - "author": "Jason Ernst", - "license": "Apache-2.0", - "runtime": "wasm", - "entry": "plugin.wasm", - "allowed_hosts": ["api.example.test"], - "min_goblog_version": "0.2.6", - "homepage": "https://example.test" -}` - -func TestParseManifest_Good(t *testing.T) { - m, err := ParseManifest([]byte(goodManifest)) - if err != nil { - t.Fatal(err) - } - if m.Name != "hello" || m.DisplayName != "Hello" || m.License != "Apache-2.0" || m.Runtime != "wasm" || m.Entry != "plugin.wasm" || m.MinGoblogVersion != "0.2.6" || m.Homepage != "https://example.test" { - t.Errorf("unexpected manifest: %+v", m) - } - if len(m.AllowedHosts) != 1 || m.AllowedHosts[0] != "api.example.test" { - t.Errorf("allowed_hosts = %v", m.AllowedHosts) - } -} - -func TestParseManifest_EntryDefaultsToPluginWasm(t *testing.T) { - m, err := ParseManifest([]byte(strings.Replace(goodManifest, `"entry": "plugin.wasm",`, "", 1))) - if err != nil { - t.Fatal(err) - } - if m.Entry != "plugin.wasm" { - t.Errorf("entry should default to plugin.wasm, got %q", m.Entry) - } -} - -// TestParseManifest_AllowedHostsNeverNil: a manifest without allowed_hosts -// must parse to an empty slice, so the index serialises "allowed_hosts": [] -// rather than null. -func TestParseManifest_AllowedHostsNeverNil(t *testing.T) { - m, err := ParseManifest([]byte(strings.Replace(goodManifest, `"allowed_hosts": ["api.example.test"],`, "", 1))) - if err != nil { - t.Fatal(err) - } - if m.AllowedHosts == nil || len(m.AllowedHosts) != 0 { - t.Errorf("allowed_hosts should default to an empty, non-nil slice, got %#v", m.AllowedHosts) - } -} - -// TestParseManifest_HostGlobs: a glob that still names a domain is fine; one -// made only of wildcards would let the plugin talk to anything and is refused -// with a message that says so. -func TestParseManifest_HostGlobs(t *testing.T) { - m, err := ParseManifest([]byte(strings.Replace(goodManifest, `["api.example.test"]`, `["*.example.test", "10.0.0.1:8443", "*-cdn.example.test"]`, 1))) - if err != nil { - t.Fatal(err) - } - if len(m.AllowedHosts) != 3 || m.AllowedHosts[0] != "*.example.test" { - t.Errorf("allowed_hosts = %v", m.AllowedHosts) - } - _, err = ParseManifest([]byte(strings.Replace(goodManifest, `["api.example.test"]`, `["*"]`, 1))) - if err == nil || !strings.Contains(err.Error(), `"*" alone is not allowed`) { - t.Errorf("a bare wildcard should be refused with a message naming it, got %v", err) - } -} - -func TestParseManifest_MissingRuntimeMentionsWebAssembly(t *testing.T) { - _, err := ParseManifest([]byte(strings.Replace(goodManifest, `"runtime": "wasm",`, "", 1))) - if err == nil || !strings.Contains(err.Error(), "WebAssembly") { - t.Errorf("a manifest without runtime should be rejected with a pointer to WebAssembly, got %v", err) - } -} - -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), - "missing runtime": strings.Replace(goodManifest, `"runtime": "wasm",`, "", 1), - "go runtime": strings.Replace(goodManifest, `"runtime": "wasm"`, `"runtime": "go"`, 1), - "entry not wasm": strings.Replace(goodManifest, `"plugin.wasm"`, `"plugin.go"`, 1), - "entry with slash": strings.Replace(goodManifest, `"plugin.wasm"`, `"src/plugin.wasm"`, 1), - "entry with query char": strings.Replace(goodManifest, `"plugin.wasm"`, `"a?b.wasm"`, 1), - "entry with space": strings.Replace(goodManifest, `"plugin.wasm"`, `"a b.wasm"`, 1), - "bad host": strings.Replace(goodManifest, `["api.example.test"]`, `["https://x"]`, 1), - "wildcard host": strings.Replace(goodManifest, `["api.example.test"]`, `["*"]`, 1), - "double wildcard host": strings.Replace(goodManifest, `["api.example.test"]`, `["**"]`, 1), - "dotted wildcard host": strings.Replace(goodManifest, `["api.example.test"]`, `["*.*"]`, 1), - "wildcard among hosts": strings.Replace(goodManifest, `["api.example.test"]`, `["api.example.test", "*"]`, 1), - "host with path": strings.Replace(goodManifest, `["api.example.test"]`, `["x/api"]`, 1), - "empty host": strings.Replace(goodManifest, `["api.example.test"]`, `[""]`, 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 { - t.Errorf("%s: expected an error", name) - } - } -} diff --git a/internal/registry/registry.go b/internal/registry/registry.go deleted file mode 100644 index beca7f8..0000000 --- a/internal/registry/registry.go +++ /dev/null @@ -1,49 +0,0 @@ -package registry - -import ( - "fmt" - "os" - "regexp" - "strings" - - "gopkg.in/yaml.v3" -) - -var repoPattern = regexp.MustCompile(`^[A-Za-z0-9-]+/[A-Za-z0-9_.-]+$`) - -// LoadRegistry reads registry.yaml and returns its repositories as -// owner/name strings in file order. -func LoadRegistry(path string) ([]string, error) { - b, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var doc struct { - Plugins []struct { - Repo string `yaml:"repo"` - } `yaml:"plugins"` - } - if err := yaml.Unmarshal(b, &doc); err != nil { - return nil, fmt.Errorf("%s: %w", path, err) - } - if len(doc.Plugins) == 0 { - return nil, fmt.Errorf("%s: no plugins listed", path) - } - seen := map[string]bool{} - repos := make([]string, 0, len(doc.Plugins)) - for i, p := range doc.Plugins { - if !repoPattern.MatchString(p.Repo) { - return nil, fmt.Errorf("%s: entry %d: repo %q must be owner/name", path, i+1, p.Repo) - } - name := p.Repo[strings.IndexByte(p.Repo, '/')+1:] - if name == "." || name == ".." || strings.HasSuffix(name, ".git") { - return nil, fmt.Errorf("%s: entry %d: repo %q: name must not be \".\", \"..\" or end in .git", path, i+1, p.Repo) - } - if seen[p.Repo] { - return nil, fmt.Errorf("%s: repo %q listed twice", path, p.Repo) - } - seen[p.Repo] = true - repos = append(repos, p.Repo) - } - return repos, nil -} diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go deleted file mode 100644 index eed3aaa..0000000 --- a/internal/registry/registry_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package registry - -import ( - "os" - "path/filepath" - "testing" -) - -func writeTemp(t *testing.T, name, content string) string { - t.Helper() - p := filepath.Join(t.TempDir(), name) - if err := os.WriteFile(p, []byte(content), 0644); err != nil { - t.Fatal(err) - } - return p -} - -func TestLoadRegistry(t *testing.T) { - repos, err := LoadRegistry(writeTemp(t, "registry.yaml", "plugins:\n - repo: goblogplatform/goblog-plugin-hello\n - repo: someone/goblog-plugin-x\n")) - if err != nil { - t.Fatal(err) - } - if len(repos) != 2 || repos[0] != "goblogplatform/goblog-plugin-hello" || repos[1] != "someone/goblog-plugin-x" { - t.Errorf("repos = %v", repos) - } -} - -func TestLoadRegistry_Errors(t *testing.T) { - cases := map[string]string{ - "empty": "plugins: []\n", - "no key": "repos:\n - repo: a/b\n", - "bad repo": "plugins:\n - repo: not-a-repo\n", - "url repo": "plugins:\n - repo: https://github.com/a/b\n", - "duplicate": "plugins:\n - repo: a/b\n - repo: a/b\n", - "not yaml": "plugins: [\n", - "dot owner": "plugins:\n - repo: ../evil\n", - "dot name": "plugins:\n - repo: a/..\n", - "git suffix": "plugins:\n - repo: a/b.git\n", - } - for name, src := range cases { - if _, err := LoadRegistry(writeTemp(t, "registry.yaml", src)); err == nil { - t.Errorf("%s: expected an error", name) - } - } - if _, err := LoadRegistry(filepath.Join(t.TempDir(), "missing.yaml")); err == nil { - t.Error("missing file: expected an error") - } -} diff --git a/internal/registry/source.go b/internal/registry/source.go deleted file mode 100644 index df1e493..0000000 --- a/internal/registry/source.go +++ /dev/null @@ -1,173 +0,0 @@ -package registry - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "time" - - "github.com/google/go-github/v92/github" -) - -// ErrNotFound is returned by Source.File when the ref or path does not exist. -var ErrNotFound = errors.New("not found") - -// MaxAssetBytes is the largest release asset the registry will download and -// validate (16 MiB); goblog's installer applies the same cap. -const MaxAssetBytes = 16 << 20 - -// Asset is a file attached to a GitHub release. -type Asset struct { - ID int64 - Name string - Size int - DownloadURL string // browser_download_url -} - -// Release is one GitHub release of a plugin repository. -type Release struct { - Tag string - Name string - Body string // release notes, markdown - URL string - PublishedAt time.Time - Draft bool - Prerelease bool - Assets []Asset -} - -// Source is what the registry needs from GitHub. It is an interface so the -// validator and builder are tested against an httptest fake. -type Source interface { - // Releases lists all releases, newest first as GitHub returns them, - // including drafts and pre-releases (callers filter). - Releases(ctx context.Context, owner, repo string) ([]Release, error) - // File returns the contents of path at ref; ErrNotFound when absent. - File(ctx context.Context, owner, repo, ref, path string) ([]byte, error) - // ReleaseAsset downloads a release asset by id (at most MaxAssetBytes). - ReleaseAsset(ctx context.Context, owner, repo string, assetID int64) ([]byte, error) - // RenderMarkdown renders GitHub-flavoured markdown to sanitized HTML in - // 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) - // RepoStars returns the repository's GitHub stargazer count (the - // directory's "top plugins" ordering). - RepoStars(ctx context.Context, owner, repo string) (stars int, err error) -} - -// GitHubSource implements Source with the GitHub REST API. -type GitHubSource struct { - client *github.Client -} - -// NewGitHubSource returns a Source for api.github.com (baseURL "") or a -// test server. token may be empty for unauthenticated access. -func NewGitHubSource(token, baseURL string) (*GitHubSource, error) { - opts := []github.ClientOptionsFunc{ - github.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), - github.WithUserAgent("goblog-plugin-registry"), - } - if token != "" { - opts = append(opts, github.WithAuthToken(token)) - } - if baseURL != "" { - opts = append(opts, github.WithURLs(&baseURL, &baseURL)) - } - c, err := github.NewClient(opts...) - if err != nil { - return nil, err - } - return &GitHubSource{client: c}, nil -} - -func (g *GitHubSource) Releases(ctx context.Context, owner, repo string) ([]Release, error) { - var out []Release - opts := &github.ListOptions{PerPage: 100} - for { - page, resp, err := g.client.Repositories.ListReleases(ctx, owner, repo, opts) - if err != nil { - return nil, fmt.Errorf("list releases for %s/%s: %w", owner, repo, err) - } - for _, r := range page { - rel := Release{Tag: r.TagName, URL: r.HTMLURL, Draft: r.Draft, Prerelease: r.Prerelease} - if r.Name != nil { - rel.Name = *r.Name - } - if r.Body != nil { - rel.Body = *r.Body - } - if r.PublishedAt != nil { - rel.PublishedAt = r.PublishedAt.Time - } - for _, a := range r.Assets { - rel.Assets = append(rel.Assets, Asset{ID: a.GetID(), Name: a.GetName(), Size: a.GetSize(), DownloadURL: a.GetBrowserDownloadURL()}) - } - out = append(out, rel) - } - if resp.NextPage == 0 { - break - } - opts.Page = resp.NextPage - } - return out, nil -} - -func (g *GitHubSource) File(ctx context.Context, owner, repo, ref, path string) ([]byte, error) { - fc, _, resp, err := g.client.Repositories.GetContents(ctx, owner, repo, path, &github.RepositoryContentGetOptions{Ref: ref}) - if err != nil { - if resp != nil && resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("%s/%s@%s:%s: %w", owner, repo, ref, path, ErrNotFound) - } - return nil, fmt.Errorf("get %s/%s@%s:%s: %w", owner, repo, ref, path, err) - } - if fc == nil { - return nil, fmt.Errorf("%s/%s@%s:%s is not a file", owner, repo, ref, path) - } - s, err := fc.GetContent() - if err != nil { - return nil, fmt.Errorf("decode %s/%s@%s:%s: %w", owner, repo, ref, path, err) - } - return []byte(s), nil -} - -// assetClient follows the API's redirect to the asset's storage host. It is -// separate from the API client because its timeout has to cover a download -// of up to MaxAssetBytes rather than one JSON response. -var assetClient = &http.Client{Timeout: 2 * time.Minute} - -func (g *GitHubSource) ReleaseAsset(ctx context.Context, owner, repo string, assetID int64) ([]byte, error) { - rc, _, err := g.client.Repositories.DownloadReleaseAsset(ctx, owner, repo, assetID, assetClient) - if err != nil { - return nil, fmt.Errorf("download asset %d of %s/%s: %w", assetID, owner, repo, err) - } - defer rc.Close() - b, err := io.ReadAll(io.LimitReader(rc, MaxAssetBytes+1)) - if err != nil { - return nil, fmt.Errorf("download asset %d of %s/%s: %w", assetID, owner, repo, err) - } - if len(b) > MaxAssetBytes { - return nil, fmt.Errorf("asset %d of %s/%s exceeds %d bytes", assetID, owner, repo, MaxAssetBytes) - } - return b, nil -} - -func (g *GitHubSource) RepoStars(ctx context.Context, owner, repo string) (int, error) { - r, _, err := g.client.Repositories.Get(ctx, owner, repo) - if err != nil { - return 0, fmt.Errorf("get repo %s/%s: %w", owner, repo, err) - } - return r.GetStargazersCount(), nil -} - -func (g *GitHubSource) RenderMarkdown(ctx context.Context, ownerRepo, markdown string) (string, error) { - if markdown == "" { - return "", nil - } - html, _, err := g.client.Markdown.Render(ctx, markdown, &github.MarkdownOptions{Mode: "gfm", Context: ownerRepo}) - if err != nil { - return "", fmt.Errorf("render markdown for %s: %w", ownerRepo, err) - } - return html, nil -} diff --git a/internal/registry/source_test.go b/internal/registry/source_test.go deleted file mode 100644 index 8626eac..0000000 --- a/internal/registry/source_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package registry - -import ( - "context" - "encoding/base64" - "encoding/json" - "errors" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -// fakeGitHub serves the REST endpoints GitHubSource uses. -func fakeGitHub(t *testing.T) *httptest.Server { - t.Helper() - mux := http.NewServeMux() - mux.HandleFunc("GET /repos/o/r/releases", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`[ - {"tag_name":"v1.1.0","name":"v1.1.0","body":"Second","draft":false,"prerelease":false,"published_at":"2026-09-15T00:00:00Z","html_url":"https://github.com/o/r/releases/tag/v1.1.0", - "assets":[{"id":11,"name":"plugin.wasm","size":4,"browser_download_url":"https://github.com/o/r/releases/download/v1.1.0/plugin.wasm"}]}, - {"tag_name":"v1.2.0-rc1","name":"rc","body":"","draft":false,"prerelease":true,"published_at":"2026-09-16T00:00:00Z","html_url":"https://github.com/o/r/releases/tag/v1.2.0-rc1"}, - {"tag_name":"v1.0.0","name":"v1.0.0","body":"First","draft":false,"prerelease":false,"published_at":"2026-09-14T00:00:00Z","html_url":"https://github.com/o/r/releases/tag/v1.0.0"} - ]`)) - }) - // DownloadReleaseAsset GETs the API asset path with Accept: - // application/octet-stream and streams the body on a 200 (no redirect). - mux.HandleFunc("GET /repos/o/r/releases/assets/11", func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Accept") != "application/octet-stream" { - http.Error(w, "expected Accept: application/octet-stream", 400) - return - } - w.Header().Set("Content-Type", "application/octet-stream") - w.Write([]byte("wasm")) - }) - // Asset 12 is one byte over the limit; ReleaseAsset must refuse it - // without reading it all into memory first. - mux.HandleFunc("GET /repos/o/r/releases/assets/12", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/octet-stream") - io.CopyN(w, zeroReader{}, MaxAssetBytes+1) - }) - mux.HandleFunc("GET /repos/o/r", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"full_name":"o/r","stargazers_count":42}`)) - }) - mux.HandleFunc("GET /repos/o/r/contents/plugin.go", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("ref") != "v1.1.0" { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"type":"file","encoding":"base64","content":"` + base64.StdEncoding.EncodeToString([]byte("package main\n")) + `"}`)) - }) - mux.HandleFunc("POST /markdown", func(w http.ResponseWriter, r *http.Request) { - var body struct{ Text, Mode, Context string } - if err := jsonDecode(r, &body); err != nil { - http.Error(w, err.Error(), 400) - return - } - if body.Mode != "gfm" || body.Context != "o/r" { - http.Error(w, "expected gfm mode with repo context", 400) - return - } - w.Header().Set("Content-Type", "text/html") - w.Write([]byte("

" + body.Text + "

")) - }) - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) }) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - return srv -} - -func TestGitHubSource(t *testing.T) { - srv := fakeGitHub(t) - src, err := NewGitHubSource("", srv.URL+"/") - if err != nil { - t.Fatal(err) - } - ctx := context.Background() - - rels, err := src.Releases(ctx, "o", "r") - if err != nil { - t.Fatal(err) - } - if len(rels) != 3 || rels[0].Tag != "v1.1.0" || rels[1].Prerelease != true || rels[2].Body != "First" || rels[0].URL == "" || rels[0].PublishedAt.IsZero() { - t.Errorf("releases = %+v", rels) - } - if len(rels[0].Assets) != 1 || rels[0].Assets[0].ID != 11 || rels[0].Assets[0].Name != "plugin.wasm" || rels[0].Assets[0].Size != 4 || - rels[0].Assets[0].DownloadURL != "https://github.com/o/r/releases/download/v1.1.0/plugin.wasm" { - t.Errorf("assets = %+v", rels[0].Assets) - } - if len(rels[1].Assets) != 0 { - t.Errorf("a release without assets should have none, got %+v", rels[1].Assets) - } - - asset, err := src.ReleaseAsset(ctx, "o", "r", 11) - if err != nil || string(asset) != "wasm" { - t.Errorf("ReleaseAsset: %q %v", asset, err) - } - if _, err := src.ReleaseAsset(ctx, "o", "r", 99); err == nil { - t.Error("ReleaseAsset with an unknown id should fail") - } - if _, err := src.ReleaseAsset(ctx, "o", "r", 12); err == nil || !strings.Contains(err.Error(), "exceeds") { - t.Errorf("an asset over MaxAssetBytes should be refused, got %v", err) - } - - b, err := src.File(ctx, "o", "r", "v1.1.0", "plugin.go") - if err != nil || string(b) != "package main\n" { - t.Errorf("File: %q %v", b, err) - } - if _, err := src.File(ctx, "o", "r", "v9.9.9", "plugin.go"); !errors.Is(err, ErrNotFound) { - t.Errorf("missing ref should be ErrNotFound, got %v", err) - } - if _, err := src.File(ctx, "o", "r", "v1.1.0", "CHANGELOG.md"); !errors.Is(err, ErrNotFound) { - t.Errorf("missing file should be ErrNotFound, got %v", err) - } - - html, err := src.RenderMarkdown(ctx, "o/r", "hi") - if err != nil || !strings.Contains(html, "

hi

") { - t.Errorf("RenderMarkdown: %q %v", html, err) - } - if html, err := src.RenderMarkdown(ctx, "o/r", ""); err != nil || html != "" { - t.Errorf("empty markdown should render to empty string without a request, got %q %v", html, err) - } - - stars, err := src.RepoStars(ctx, "o", "r") - if err != nil || stars != 42 { - t.Errorf("RepoStars = %d, %v", stars, err) - } - if _, err := src.RepoStars(ctx, "o", "missing"); err == nil { - t.Error("RepoStars on an unknown repo should fail") - } -} - -func jsonDecode(r *http.Request, v any) error { return json.NewDecoder(r.Body).Decode(v) } - -// zeroReader is an endless stream of zero bytes. -type zeroReader struct{} - -func (zeroReader) Read(p []byte) (int, error) { - clear(p) - return len(p), nil -} diff --git a/internal/registry/validate.go b/internal/registry/validate.go deleted file mode 100644 index 017b828..0000000 --- a/internal/registry/validate.go +++ /dev/null @@ -1,126 +0,0 @@ -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 - Asset Asset // the release asset named by Manifest.Entry - Entry []byte // the asset's bytes (the WebAssembly module) - 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, a release asset -// named by the manifest's entry that loads in goblog as a WebAssembly -// plugin, 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) - } - var asset *Asset - for i := range latest.Assets { - if latest.Assets[i].Name == manifest.Entry { - asset = &latest.Assets[i] - break - } - } - if asset == nil { - return nil, fmt.Errorf("%s: release %s has no asset named %s (the release workflow must upload it)", repo, latest.Tag, manifest.Entry) - } - if asset.Size > MaxAssetBytes { - return nil, fmt.Errorf("%s@%s: asset %s is %d bytes; the limit is %d (16 MiB)", repo, latest.Tag, asset.Name, asset.Size, MaxAssetBytes) - } - entry, err := src.ReleaseAsset(ctx, owner, name, asset.ID) - if err != nil { - return nil, fmt.Errorf("%s@%s: asset %s: %w", repo, latest.Tag, asset.Name, 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.Runtime != "wasm" { - return nil, fmt.Errorf("%s@%s: %s is not a WebAssembly plugin (runtime %q)", repo, latest.Tag, manifest.Entry, info.Runtime) - } - 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, - Asset: *asset, 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 deleted file mode 100644 index c1dadde..0000000 --- a/internal/registry/validate_test.go +++ /dev/null @@ -1,205 +0,0 @@ -package registry - -import ( - "context" - "errors" - "fmt" - "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 - assets map[int64][]byte // asset id → bytes - rendered int // RenderMarkdown call count - stars map[string]int // "owner/repo" → stargazers_count - starsErr error // when set, RepoStars fails for every repo -} - -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) ReleaseAsset(_ context.Context, owner, repo string, id int64) ([]byte, error) { - if b, ok := m.assets[id]; ok { - return b, nil - } - return nil, fmt.Errorf("%s/%s: no asset %d", owner, repo, id) -} - -func (m *memSource) RenderMarkdown(_ context.Context, ownerRepo, md string) (string, error) { - if md == "" { - return "", nil - } - m.rendered++ - return "

" + md + "

", nil -} - -func (m *memSource) RepoStars(_ context.Context, owner, repo string) (int, error) { - if m.starsErr != nil { - return 0, m.starsErr - } - if n, ok := m.stars[owner+"/"+repo]; ok { - return n, nil - } - return 0, nil -} - -// helloWasm stands in for the plugin.wasm asset attached to hello v1.1.0. -var helloWasm = []byte("\x00asm hello v1.1.0") - -// helloAsset is the release asset the validator downloads for hello v1.1.0. -var helloAsset = Asset{ID: 11, Name: "plugin.wasm", Size: len(helloWasm), DownloadURL: "https://github.com/o/hello/releases/download/v1.1.0/plugin.wasm"} - -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), Assets: []Asset{helloAsset}}, - {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:README.md": "# Hello", - "o/hello@v1.1.0:CHANGELOG.md": "## 1.1.0\n- second", - }, - assets: map[int64][]byte{11: helloWasm}, - stars: map[string]int{"o/hello": 7}, - } -} - -func helloValidator() *FakeValidator { - return &FakeValidator{Infos: map[string]Info{sum(helloWasm): {Name: "hello", DisplayName: "Hello", Version: "1.1.0", Runtime: "wasm"}}} -} - -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) != string(helloWasm) || v.SHA256 != sum(helloWasm) { - t.Errorf("entry/sha mismatch: entry=%q sha=%s", v.Entry, v.SHA256) - } - if v.Asset != helloAsset || v.Asset.DownloadURL != "https://github.com/o/hello/releases/download/v1.1.0/plugin.wasm" { - t.Errorf("asset = %+v, want %+v", v.Asset, helloAsset) - } -} - -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"}, - "no asset": {func(s *memSource, f *FakeValidator) { - s.releases["o/hello"][0].Assets = nil - }, "no asset named plugin.wasm"}, - "wrong asset name": {func(s *memSource, f *FakeValidator) { - s.releases["o/hello"][0].Assets = []Asset{{ID: 11, Name: "hello.wasm", Size: len(helloWasm)}} - }, "no asset named plugin.wasm"}, - "asset too big": {func(s *memSource, f *FakeValidator) { - s.releases["o/hello"][0].Assets = []Asset{{ID: 11, Name: "plugin.wasm", Size: MaxAssetBytes + 1}} - }, "16"}, - "asset download fails": {func(s *memSource, f *FakeValidator) { - delete(s.assets, 11) - }, "no asset 11"}, - "not wasm runtime": {func(s *memSource, f *FakeValidator) { - f.Infos[sum(helloWasm)] = Info{Name: "hello", DisplayName: "Hello", Version: "1.1.0"} - }, "runtime"}, - "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("wasm: boom") - }, "boom"}, - "name mismatch": {func(s *memSource, f *FakeValidator) { - f.Infos[sum(helloWasm)] = Info{Name: "other", Version: "1.1.0", Runtime: "wasm"} - }, "Name()"}, - "version mismatch": {func(s *memSource, f *FakeValidator) { - f.Infos[sum(helloWasm)] = Info{Name: "hello", Version: "1.0.9", Runtime: "wasm"} - }, "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 deleted file mode 100644 index f5d5906..0000000 --- a/internal/registry/validator.go +++ /dev/null @@ -1,121 +0,0 @@ -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. Runtime is -// "wasm" for a WebAssembly module (goblog omits it for a .go file). -type Info struct { - Name string `json:"name"` - DisplayName string `json:"display_name"` - Version string `json:"version"` - Runtime string `json:"runtime"` -} - -// Validator loads a plugin module 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, module []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` on plugin.wasm inside the -// pinned goblog image with networking disabled. goblog already sandboxes the -// module (no filesystem, no network, memory cap, timeouts); the container is -// a second fence around goblog itself, so a module that finds a bug in the -// runtime still cannot reach the registry's CI environment. -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.wasm"} -} - -// 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, module []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.wasm"), module, 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 deleted file mode 100644 index 925a877..0000000 --- a/internal/registry/validator_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package registry - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" -) - -// FakeValidator answers by the sha256 of the module bytes 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") -} - -// realImage is the goblog image the real Docker test runs; keep it equal to -// the default in cmd/registry and the workflows. -const realImage = "compscidr/goblog:v0.2.9" - -// echoWasmPath is goblog's committed echo fixture (identity echo/Echo/1.2.3), -// found when this registry is checked out next to goblog. -const echoWasmPath = "../../../goblog/plugin/wasm/testdata/echo.wasm" - -// TestDockerValidator_Real runs the actual goblog image against goblog's -// echo.wasm fixture; skipped unless docker is available, -// REGISTRY_DOCKER_TESTS=1 (it pulls ~100 MB) and the fixture is present. -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") - } - module, err := os.ReadFile(echoWasmPath) - if err != nil { - t.Skipf("goblog's echo.wasm fixture not found at %s: %v", echoWasmPath, err) - } - v := NewDockerValidator(realImage) - info, err := v.Validate(context.Background(), module) - if err != nil { - t.Fatal(err) - } - if info.Name != "echo" || info.DisplayName != "Echo" || info.Version != "1.2.3" || info.Runtime != "wasm" { - t.Errorf("info = %+v", info) - } - if _, err := v.Validate(context.Background(), []byte("\x00asm not a module")); err == nil { - t.Error("a broken module should fail") - } -} - -func TestDockerValidator_CommandShape(t *testing.T) { - v := NewDockerValidator(realImage) - 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.9", - "validate-plugin", "/p/plugin.wasm"} - 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("\x00asm")) - 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) - } -} - -// TestDockerValidator_WritesPluginWasm uses a fake "docker" that reads the -// mounted directory out of the -v argument and prints the sha256 of the -// plugin.wasm it finds there as the identity's name, so the test can check -// that the module bytes reach the container under the name validate-plugin -// is told to load. -func TestDockerValidator_WritesPluginWasm(t *testing.T) { - dir := t.TempDir() - script := `#!/bin/sh -prev="" -for a in "$@"; do - if [ "$prev" = "-v" ]; then mount="${a%%:*}"; fi - prev="$a" -done -last="" -for a in "$@"; do last="$a"; done -[ "$last" = "/p/plugin.wasm" ] || { echo "unexpected file $last" >&2; exit 1; } -sum=$(sha256sum "$mount/plugin.wasm" | cut -d' ' -f1) -printf '{"name":"%s","display_name":"X","version":"0.0.1","runtime":"wasm"}\n' "$sum" -` - 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")) - - module := []byte("\x00asm module bytes") - info, err := NewDockerValidator("img").Validate(context.Background(), module) - if err != nil { - t.Fatal(err) - } - if info.Name != sum(module) || info.Runtime != "wasm" { - t.Errorf("info = %+v, want name %s", info, sum(module)) - } -} diff --git a/registry.yaml b/registry.yaml deleted file mode 100644 index de14fd4..0000000 --- a/registry.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# Curated list of goblog plugin repositories. To publish a plugin, submit it -# via the issue form (a pull request by hand is the alternative) — see -# docs/CONTRACT.md for what the repository must contain. CI validates every -# entry on each PR. -plugins: - - repo: goblogplatform/goblog-plugin-hello - - repo: goblogplatform/goblog-plugin-scholar diff --git a/renovate.json b/renovate.json deleted file mode 100644 index 8f9d68a..0000000 --- a/renovate.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$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$/", "/^internal/registry/validator_test\\.go$/"], - "matchStrings": ["compscidr/goblog:(?v\\d+\\.\\d+\\.\\d+)"], - "depNameTemplate": "compscidr/goblog", - "datasourceTemplate": "docker" - } - ] -}