From 7ac0aef8c0004afb3864cf05fb695bf9ed702cc4 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Thu, 17 Sep 2026 17:56:54 -0700 Subject: [PATCH 1/6] Add GitHub stars to the index Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- cmd/registry/main_test.go | 1 + internal/registry/build.go | 7 +++++++ internal/registry/build_test.go | 5 ++++- internal/registry/source.go | 11 +++++++++++ internal/registry/source_test.go | 12 ++++++++++++ internal/registry/validate_test.go | 9 +++++++++ 7 files changed, 45 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 27acb62..799e520 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ The curated list of [goblog](https://github.com/goblogplatform/goblog) plugins behind [goblog.live/plugins](https://goblog.live/plugins). - `registry.yaml` — the list. Add your repository in a PR; see [docs/CONTRACT.md](docs/CONTRACT.md). -- `https://goblogplatform.github.io/plugins/index.json` — the machine-readable index (latest release of each plugin, with `download_url` and `sha256`); `plugins/.json` adds the rendered README, changelog and release history. +- `https://goblogplatform.github.io/plugins/index.json` — the machine-readable index (latest release of each plugin, with `download_url` and `sha256`) 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. ```bash diff --git a/cmd/registry/main_test.go b/cmd/registry/main_test.go index b7f32bb..1a47881 100644 --- a/cmd/registry/main_test.go +++ b/cmd/registry/main_test.go @@ -33,6 +33,7 @@ func (m *memSource) File(_ context.Context, owner, repo, ref, path string) ([]by func (m *memSource) RenderMarkdown(_ context.Context, _, md string) (string, error) { return "

" + md + "

", nil } +func (m *memSource) RepoInfo(context.Context, string, string) (int, error) { return 3, nil } type okValidator struct{} diff --git a/internal/registry/build.go b/internal/registry/build.go index b7f6ef0..3b5c12f 100644 --- a/internal/registry/build.go +++ b/internal/registry/build.go @@ -30,6 +30,7 @@ type IndexEntry struct { InstallType string `json:"install_type"` ReleasedAt string `json:"released_at"` DetailURL string `json:"detail_url"` + Stars int `json:"stars"` } // ReleaseDoc is one release in a plugin's history. @@ -142,6 +143,12 @@ func buildDetail(ctx context.Context, src Source, v *Validated, baseURL string) DetailURL: fmt.Sprintf("%s/plugins/%s.json", baseURL, v.Manifest.Name), } + stars, err := src.RepoInfo(ctx, v.Owner, v.Name) + if err != nil { + return DetailDoc{}, err + } + 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) diff --git a/internal/registry/build_test.go b/internal/registry/build_test.go index d64d0af..04a154e 100644 --- a/internal/registry/build_test.go +++ b/internal/registry/build_test.go @@ -40,7 +40,7 @@ func TestBuild_WritesIndexAndDetails(t *testing.T) { License: "Apache-2.0", SourceURL: "https://github.com/o/hello", DownloadURL: "https://raw.githubusercontent.com/o/hello/v1.1.0/plugin.go", SHA256: sum([]byte(helloSrc)), MinGoblogVersion: "0.2.6", InstallType: "dynamic", ReleasedAt: "2026-09-15T00:00:00Z", - DetailURL: "https://example.test/plugins/plugins/hello.json", + DetailURL: "https://example.test/plugins/plugins/hello.json", Stars: 7, } if e != want { t.Errorf("entry =\n%+v\nwant\n%+v", e, want) @@ -51,6 +51,9 @@ func TestBuild_WritesIndexAndDetails(t *testing.T) { if d.Name != "hello" || d.ReadmeHTML != "

# Hello

" || d.ChangelogHTML != "

## 1.1.0\n- second

" { t.Errorf("detail = %+v", d) } + 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) diff --git a/internal/registry/source.go b/internal/registry/source.go index 45e69b6..97f491b 100644 --- a/internal/registry/source.go +++ b/internal/registry/source.go @@ -36,6 +36,9 @@ type Source interface { // 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) + // RepoInfo returns the repository's GitHub stargazer count (the + // directory's "top plugins" ordering). + RepoInfo(ctx context.Context, owner, repo string) (stars int, err error) } // GitHubSource implements Source with the GitHub REST API. @@ -110,6 +113,14 @@ func (g *GitHubSource) File(ctx context.Context, owner, repo, ref, path string) return []byte(s), nil } +func (g *GitHubSource) RepoInfo(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 diff --git a/internal/registry/source_test.go b/internal/registry/source_test.go index 2c72a0e..050871a 100644 --- a/internal/registry/source_test.go +++ b/internal/registry/source_test.go @@ -23,6 +23,10 @@ func fakeGitHub(t *testing.T) *httptest.Server { {"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"} ]`)) }) + 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) @@ -84,6 +88,14 @@ func TestGitHubSource(t *testing.T) { 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.RepoInfo(ctx, "o", "r") + if err != nil || stars != 42 { + t.Errorf("RepoInfo = %d, %v", stars, err) + } + if _, err := src.RepoInfo(ctx, "o", "missing"); err == nil { + t.Error("RepoInfo on an unknown repo should fail") + } } func jsonDecode(r *http.Request, v any) error { return json.NewDecoder(r.Body).Decode(v) } diff --git a/internal/registry/validate_test.go b/internal/registry/validate_test.go index 04d05e2..1869c92 100644 --- a/internal/registry/validate_test.go +++ b/internal/registry/validate_test.go @@ -13,6 +13,7 @@ type memSource struct { releases map[string][]Release // "owner/repo" → releases files map[string]string // "owner/repo@ref:path" → content rendered int // RenderMarkdown call count + stars map[string]int // "owner/repo" → stargazers_count } func (m *memSource) Releases(_ context.Context, owner, repo string) ([]Release, error) { @@ -38,6 +39,13 @@ func (m *memSource) RenderMarkdown(_ context.Context, ownerRepo, md string) (str return "

" + md + "

", nil } +func (m *memSource) RepoInfo(_ context.Context, owner, repo string) (int, error) { + if n, ok := m.stars[owner+"/"+repo]; ok { + return n, nil + } + return 0, nil +} + const helloSrc = "package main\n// hello plugin\n" func helloSource() *memSource { @@ -56,6 +64,7 @@ func helloSource() *memSource { "o/hello@v1.1.0:README.md": "# Hello", "o/hello@v1.1.0:CHANGELOG.md": "## 1.1.0\n- second", }, + stars: map[string]int{"o/hello": 7}, } } From 0c045c06f754eae9d3b4975903fcaa612d1d34c2 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Thu, 17 Sep 2026 18:00:06 -0700 Subject: [PATCH 2/6] Add the plugin submission issue form and workflow Co-Authored-By: Claude Opus 5 (1M context) --- .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ISSUE_TEMPLATE/submit-plugin.yml | 24 +++++ .github/workflows/submit.yml | 109 +++++++++++++++++++++++ README.md | 4 + docs/CONTRACT.md | 18 ++-- 5 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/submit-plugin.yml create mode 100644 .github/workflows/submit.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0086358 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true diff --git a/.github/ISSUE_TEMPLATE/submit-plugin.yml b/.github/ISSUE_TEMPLATE/submit-plugin.yml new file mode 100644 index 0000000..5b026ae --- /dev/null +++ b/.github/ISSUE_TEMPLATE/submit-plugin.yml @@ -0,0 +1,24 @@ +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`, `README.md`, the plugin `.go` file, and a release tagged `vX.Y.Z`. 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/submit.yml b/.github/workflows/submit.yml new file mode 100644 index 0000000..dcbfdf5 --- /dev/null +++ b/.github/workflows/submit.yml @@ -0,0 +1,109 @@ +name: Submission +on: + issues: + types: [opened, edited] +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 + - 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 + 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.7 > 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 }} + 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 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" + git push -u origin "$branch" + 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") + 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 [ -f result.txt ]; then out=$(head -c 6000 result.txt); 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/README.md b/README.md index 799e520..5a866cd 100644 --- a/README.md +++ b/README.md @@ -14,4 +14,8 @@ go run ./cmd/registry build --out dist # what gets published Set `GITHUB_TOKEN` to avoid API rate limits. `validate`/`build` run `goblog validate-plugin` in the `compscidr/goblog` Docker image (`--image` to override; Renovate keeps the default current). With snap-installed Docker, set `TMPDIR` to a directory under your home; snap's Docker cannot bind-mount `/tmp`. Resource limits (`--memory`, `--pids-limit`) need cgroup controllers; on rootless Docker they may be downgraded or rejected — pass `--image` to a local build or run on a rootful daemon. +## 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. Set a repository secret `SUBMIT_TOKEN` (fine-grained PAT with Contents, Pull requests and Issues write on this repo) so the bot's pull requests trigger the `Validate` check; with the default `GITHUB_TOKEN` they don't (GitHub prevents token-created PRs from starting workflows). + License: Apache-2.0. diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index e714e44..f19d7bb 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -55,13 +55,15 @@ The registry's CI runs this (plus a timeout and memory/process limits), then com ## Submit -1. Fork this repository and add a line to `registry.yaml`: - ```yaml - plugins: - - repo: goblogplatform/goblog-plugin-hello - - repo: you/goblog-plugin-yours - ``` -2. Open a pull request. The `validate` workflow must pass. -3. After merge, `https://goblogplatform.github.io/plugins/index.json` and goblog.live/plugins pick it up within a few minutes. New releases of your plugin are picked up automatically on the next scheduled build. +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. +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. Keep them small and readable; the registry is curated and maintainers may decline or remove entries. From 64c51e77fb81479767022b83bf29a66d6add3ad3 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Thu, 17 Sep 2026 18:11:43 -0700 Subject: [PATCH 3/6] Fix submission workflow: required SUBMIT_TOKEN, sanitize echoed output Without SUBMIT_TOKEN the org blocks Action-created PRs, so a passing submission previously wedged: it pushed a branch, failed at gh pr create, posted no comment, and every re-run then failed to push (non-fast-forward). Guard on HAS_TOKEN to comment and stop before pushing when the secret is unset, force-with-lease the bot branch push, and comment (then exit 1) if PR creation still fails. Document SUBMIT_TOKEN as required, from a dedicated machine identity. Sanitize submitted-plugin output before echoing it into issue comments (zero-width space after '@', modifier-letter grave for backticks, a ~~~~ fence) so a submission can't inject mentions or close the fence; cut the 6000-byte cap on a line boundary. Give empty-REPO parses a friendly comment instead of the generic failure text. Tighten the owner half of the repo pattern to exclude '.', matching goblogplatform/goblog#570; apply the same tightening to internal/registry/registry.go's repoPattern. Reject submission names that are '.'/'..' or still end in '.git' after the URL strip. Add a submit-scoped concurrency group, persist-credentials: false on check's checkout, and a build test covering a RepoInfo failure being skipped rather than failing the whole build. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/submit.yml | 48 +++++++++++++++++++++++++----- README.md | 6 ++-- docs/CONTRACT.md | 2 +- internal/registry/build_test.go | 26 ++++++++++++++++ internal/registry/registry.go | 2 +- internal/registry/registry_test.go | 1 + internal/registry/validate_test.go | 4 +++ registry.yaml | 7 +++-- 8 files changed, 82 insertions(+), 14 deletions(-) diff --git a/.github/workflows/submit.yml b/.github/workflows/submit.yml index dcbfdf5..ef14cdd 100644 --- a/.github/workflows/submit.yml +++ b/.github/workflows/submit.yml @@ -2,6 +2,9 @@ name: Submission on: issues: types: [opened, edited] +concurrency: + group: submit-${{ github.event.issue.number }} + cancel-in-progress: true permissions: {} jobs: check: @@ -15,6 +18,8 @@ jobs: 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 @@ -26,10 +31,15 @@ jobs: 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 + 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 @@ -64,6 +74,7 @@ jobs: 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: @@ -78,6 +89,10 @@ jobs: 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" @@ -89,21 +104,40 @@ jobs: printf ' - repo: %s\n' "$REPO" >> registry.yaml git add registry.yaml git commit -m "Add $REPO" - git push -u origin "$branch" - 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 }}). + 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") + 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 [ -f result.txt ]; then out=$(head -c 6000 result.txt); else out="the workflow could not read a repository from the form (see run ${{ github.run_id }})"; fi + 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 + if [ "$(wc -c < result.txt)" -gt 6000 ]; then + out=$(head -c 6000 result.txt | head -n -1) + else + out=$(cat result.txt) + fi + out=$(printf '%s' "$out" | sed 's/@/@\xe2\x80\x8b/g; s/`/\xcb\x8b/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/README.md b/README.md index 5a866cd..6303d16 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The curated list of [goblog](https://github.com/goblogplatform/goblog) plugins behind [goblog.live/plugins](https://goblog.live/plugins). -- `registry.yaml` — the list. Add your repository in a PR; see [docs/CONTRACT.md](docs/CONTRACT.md). +- `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 (latest release of each plugin, with `download_url` and `sha256`) 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. @@ -16,6 +16,8 @@ Set `GITHUB_TOKEN` to avoid API rate limits. `validate`/`build` run `goblog vali ## 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. Set a repository secret `SUBMIT_TOKEN` (fine-grained PAT with Contents, Pull requests and Issues write on this repo) so the bot's pull requests trigger the `Validate` check; with the default `GITHUB_TOKEN` they don't (GitHub prevents token-created PRs from starting workflows). +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. diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index f19d7bb..dca8e7f 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -56,7 +56,7 @@ The registry's CI runs this (plus a timeout and memory/process limits), then com ## 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. +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. diff --git a/internal/registry/build_test.go b/internal/registry/build_test.go index 04a154e..931d335 100644 --- a/internal/registry/build_test.go +++ b/internal/registry/build_test.go @@ -3,6 +3,7 @@ package registry import ( "context" "encoding/json" + "errors" "os" "path/filepath" "strings" @@ -118,6 +119,31 @@ func TestBuild_SkipsBrokenEntriesAndDuplicates(t *testing.T) { } } +func TestBuild_SkipsWhenRepoInfoFails(t *testing.T) { + src := helloSource() + // A second plugin whose stargazer lookup errors; it should be skipped + // without taking the whole build down. + src.releases["o/zeta"] = []Release{{Tag: "v0.1.0", Body: "z", URL: "https://github.com/o/zeta/releases/tag/v0.1.0", PublishedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}} + src.files["o/zeta@v0.1.0:goblog-plugin.json"] = strings.Replace(strings.Replace(goodManifest, `"hello"`, `"zeta"`, 1), `"Hello"`, `"Zeta"`, 1) + src.files["o/zeta@v0.1.0:plugin.go"] = "package main // zeta\n" + src.files["o/zeta@v0.1.0:README.md"] = "# Zeta" + val := helloValidator() + val.Infos[sum([]byte("package main // zeta\n"))] = Info{Name: "zeta", DisplayName: "Zeta", Version: "0.1.0"} + src.starsErr = map[string]error{"o/zeta": errors.New("stars: rate limited")} + + 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) != 1 || res.Built[0] != "o/hello" { + t.Errorf("built = %v", res.Built) + } + if res.Skipped["o/zeta"] == nil || !strings.Contains(res.Skipped["o/zeta"].Error(), "rate limited") { + t.Errorf("skipped[o/zeta] = %v", res.Skipped["o/zeta"]) + } +} + 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 { diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 61bd04f..bfe0ed9 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -8,7 +8,7 @@ import ( "gopkg.in/yaml.v3" ) -var repoPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`) +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. diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 6dd11b2..77e5a0c 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -33,6 +33,7 @@ func TestLoadRegistry_Errors(t *testing.T) { "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", } for name, src := range cases { if _, err := LoadRegistry(writeTemp(t, "registry.yaml", src)); err == nil { diff --git a/internal/registry/validate_test.go b/internal/registry/validate_test.go index 1869c92..7b69e28 100644 --- a/internal/registry/validate_test.go +++ b/internal/registry/validate_test.go @@ -14,6 +14,7 @@ type memSource struct { files map[string]string // "owner/repo@ref:path" → content rendered int // RenderMarkdown call count stars map[string]int // "owner/repo" → stargazers_count + starsErr map[string]error // "owner/repo" → error RepoInfo should return } func (m *memSource) Releases(_ context.Context, owner, repo string) ([]Release, error) { @@ -40,6 +41,9 @@ func (m *memSource) RenderMarkdown(_ context.Context, ownerRepo, md string) (str } func (m *memSource) RepoInfo(_ context.Context, owner, repo string) (int, error) { + if err, ok := m.starsErr[owner+"/"+repo]; ok { + return 0, err + } if n, ok := m.stars[owner+"/"+repo]; ok { return n, nil } diff --git a/registry.yaml b/registry.yaml index 03f23d9..c885a5e 100644 --- a/registry.yaml +++ b/registry.yaml @@ -1,5 +1,6 @@ -# Curated list of goblog plugin repositories. To publish a plugin, open a PR -# that adds your repository here — see docs/CONTRACT.md for what the -# repository must contain. CI validates every entry on each PR. +# 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 From 63e9022d35b211aa253381e6f8400968b6002d13 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Thu, 17 Sep 2026 18:19:40 -0700 Subject: [PATCH 4/6] Neutralise ~~~~ fence escapes and fix single-line truncation A submitted plugin could still print a line of tildes to close the ~~~~ fence around its echoed output; replace every tilde (in addition to '@' and backtick) with a lookalike so no fence-syntax character survives sanitisation. head -c 6000 | head -n -1 emptied the comment when result.txt was a single line at or past the cap (head -n -1 drops the only line). Compute the capped chunk first, then only trim a trailing partial line when the file was actually truncated and the chunk contains a newline to trim, appending a "(truncated)" marker either way. internal/registry/registry.go: repoPattern's tightened owner charset still let the name half be "." or "..", or end in ".git" (e.g. owner/.. or owner/repo.git). Reject those explicitly in LoadRegistry after the regex match, with two new TestLoadRegistry_Errors cases. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/submit.yml | 10 ++++++---- internal/registry/registry.go | 5 +++++ internal/registry/registry_test.go | 16 +++++++++------- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/.github/workflows/submit.yml b/.github/workflows/submit.yml index ef14cdd..1cc7b48 100644 --- a/.github/workflows/submit.yml +++ b/.github/workflows/submit.yml @@ -123,12 +123,14 @@ jobs: exit 0 fi if [ -f result.txt ]; then + out=$(head -c 6000 result.txt) if [ "$(wc -c < result.txt)" -gt 6000 ]; then - out=$(head -c 6000 result.txt | head -n -1) - else - out=$(cat result.txt) + 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') + 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 diff --git a/internal/registry/registry.go b/internal/registry/registry.go index bfe0ed9..beca7f8 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "regexp" + "strings" "gopkg.in/yaml.v3" ) @@ -34,6 +35,10 @@ func LoadRegistry(path string) ([]string, error) { 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) } diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 77e5a0c..eed3aaa 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -27,13 +27,15 @@ func TestLoadRegistry(t *testing.T) { 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", + "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 { From ffc0bca412bcf5ba858ee4d39bb1b90d3017cf4a Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Fri, 18 Sep 2026 08:31:45 -0700 Subject: [PATCH 5/6] Make stars best-effort and rename RepoInfo to RepoStars Co-Authored-By: Claude Opus 5 (1M context) --- cmd/registry/main_test.go | 2 +- docs/CONTRACT.md | 4 ++++ internal/registry/build.go | 10 ++++++---- internal/registry/build_test.go | 21 +++++++++++++++++++++ internal/registry/source.go | 6 +++--- internal/registry/source_test.go | 8 ++++---- internal/registry/validate_test.go | 8 ++++++-- 7 files changed, 45 insertions(+), 14 deletions(-) diff --git a/cmd/registry/main_test.go b/cmd/registry/main_test.go index 1a47881..62af1b3 100644 --- a/cmd/registry/main_test.go +++ b/cmd/registry/main_test.go @@ -33,7 +33,7 @@ func (m *memSource) File(_ context.Context, owner, repo, ref, path string) ([]by func (m *memSource) RenderMarkdown(_ context.Context, _, md string) (string, error) { return "

" + md + "

", nil } -func (m *memSource) RepoInfo(context.Context, string, string) (int, error) { return 3, nil } +func (m *memSource) RepoStars(context.Context, string, string) (int, error) { return 3, nil } type okValidator struct{} diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index e714e44..3ebc097 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -2,6 +2,10 @@ The directory at [goblog.live/plugins](https://goblog.live/plugins) lists plugins from this registry. A plugin is a GitHub repository; each GitHub release is a version. Submitting means adding your repository to `registry.yaml` in a pull request — CI validates it and, once merged, the index is rebuilt (on every merge and every six hours). +## What the directory publishes + +The index entry for your plugin is built from the manifest, the latest release, 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`. + ## What the repository must contain At the root of the repository, at the release tag being published (the tool checks the latest release): diff --git a/internal/registry/build.go b/internal/registry/build.go index 3b5c12f..9c6412a 100644 --- a/internal/registry/build.go +++ b/internal/registry/build.go @@ -143,11 +143,13 @@ func buildDetail(ctx context.Context, src Source, v *Validated, baseURL string) DetailURL: fmt.Sprintf("%s/plugins/%s.json", baseURL, v.Manifest.Name), } - stars, err := src.RepoInfo(ctx, v.Owner, v.Name) - if err != nil { - return DetailDoc{}, err + // 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 } - entry.Stars = stars readme, err := src.File(ctx, v.Owner, v.Name, v.Release.Tag, "README.md") if err != nil { diff --git a/internal/registry/build_test.go b/internal/registry/build_test.go index 04a154e..65ee891 100644 --- a/internal/registry/build_test.go +++ b/internal/registry/build_test.go @@ -3,6 +3,7 @@ package registry import ( "context" "encoding/json" + "errors" "os" "path/filepath" "strings" @@ -118,6 +119,26 @@ func TestBuild_SkipsBrokenEntriesAndDuplicates(t *testing.T) { } } +// 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 { diff --git a/internal/registry/source.go b/internal/registry/source.go index 97f491b..daf09be 100644 --- a/internal/registry/source.go +++ b/internal/registry/source.go @@ -36,9 +36,9 @@ type Source interface { // 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) - // RepoInfo returns the repository's GitHub stargazer count (the + // RepoStars returns the repository's GitHub stargazer count (the // directory's "top plugins" ordering). - RepoInfo(ctx context.Context, owner, repo string) (stars int, err error) + RepoStars(ctx context.Context, owner, repo string) (stars int, err error) } // GitHubSource implements Source with the GitHub REST API. @@ -113,7 +113,7 @@ func (g *GitHubSource) File(ctx context.Context, owner, repo, ref, path string) return []byte(s), nil } -func (g *GitHubSource) RepoInfo(ctx context.Context, owner, repo string) (int, error) { +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) diff --git a/internal/registry/source_test.go b/internal/registry/source_test.go index 050871a..c150269 100644 --- a/internal/registry/source_test.go +++ b/internal/registry/source_test.go @@ -89,12 +89,12 @@ func TestGitHubSource(t *testing.T) { t.Errorf("empty markdown should render to empty string without a request, got %q %v", html, err) } - stars, err := src.RepoInfo(ctx, "o", "r") + stars, err := src.RepoStars(ctx, "o", "r") if err != nil || stars != 42 { - t.Errorf("RepoInfo = %d, %v", stars, err) + t.Errorf("RepoStars = %d, %v", stars, err) } - if _, err := src.RepoInfo(ctx, "o", "missing"); err == nil { - t.Error("RepoInfo on an unknown repo should fail") + if _, err := src.RepoStars(ctx, "o", "missing"); err == nil { + t.Error("RepoStars on an unknown repo should fail") } } diff --git a/internal/registry/validate_test.go b/internal/registry/validate_test.go index 1869c92..decfc41 100644 --- a/internal/registry/validate_test.go +++ b/internal/registry/validate_test.go @@ -13,7 +13,8 @@ type memSource struct { releases map[string][]Release // "owner/repo" → releases files map[string]string // "owner/repo@ref:path" → content rendered int // RenderMarkdown call count - stars map[string]int // "owner/repo" → stargazers_count + stars map[string]int + starsErr error // when set, RepoStars fails for every repo } func (m *memSource) Releases(_ context.Context, owner, repo string) ([]Release, error) { @@ -39,7 +40,10 @@ func (m *memSource) RenderMarkdown(_ context.Context, ownerRepo, md string) (str return "

" + md + "

", nil } -func (m *memSource) RepoInfo(_ context.Context, owner, repo string) (int, error) { +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 } From 9b0042919325009318cc26bb55fbfb35edf46873 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Fri, 18 Sep 2026 08:31:56 -0700 Subject: [PATCH 6/6] Restore the stars fake comment Co-Authored-By: Claude Opus 5 (1M context) --- internal/registry/validate_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/registry/validate_test.go b/internal/registry/validate_test.go index decfc41..b91b6b4 100644 --- a/internal/registry/validate_test.go +++ b/internal/registry/validate_test.go @@ -13,8 +13,8 @@ type memSource struct { releases map[string][]Release // "owner/repo" → releases files map[string]string // "owner/repo@ref:path" → content rendered int // RenderMarkdown call count - stars map[string]int - starsErr error // when set, RepoStars fails for every repo + 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) {