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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
blank_issues_enabled: true
24 changes: 24 additions & 0 deletions .github/ISSUE_TEMPLATE/submit-plugin.yml
Original file line number Diff line number Diff line change
@@ -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
145 changes: 145 additions & 0 deletions .github/workflows/submit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
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.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
Comment thread
compscidr marked this conversation as resolved.
- 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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

The curated list of [goblog](https://github.com/goblogplatform/goblog) plugins behind [goblog.live/plugins](https://goblog.live/plugins).

- `registry.yaml` — the list. Add your repository in a PR; see [docs/CONTRACT.md](docs/CONTRACT.md).
- `https://goblogplatform.github.io/plugins/index.json` — the machine-readable index (latest release of each plugin, with `download_url` and `sha256`); `plugins/<name>.json` adds the rendered README, changelog and release history.
- `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/<name>.json` adds the rendered README, changelog and release history.
- `cmd/registry` — the tool CI runs: `validate` on pull requests, `build` on merge and every six hours.

```bash
Expand All @@ -14,4 +14,10 @@ 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.

**`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.
1 change: 1 addition & 0 deletions cmd/registry/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<p>" + md + "</p>", nil
}
func (m *memSource) RepoStars(context.Context, string, string) (int, error) { return 3, nil }

type okValidator struct{}

Expand Down
22 changes: 14 additions & 8 deletions docs/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -55,13 +59,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 (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. Keep them small and readable; the registry is curated and maintainers may decline or remove entries.
9 changes: 9 additions & 0 deletions internal/registry/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -142,6 +143,14 @@ func buildDetail(ctx context.Context, src Source, v *Validated, baseURL string)
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)
Expand Down
26 changes: 25 additions & 1 deletion internal/registry/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package registry
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -40,7 +41,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)
Expand All @@ -51,6 +52,9 @@ func TestBuild_WritesIndexAndDetails(t *testing.T) {
if d.Name != "hello" || d.ReadmeHTML != "<p># Hello</p>" || d.ChangelogHTML != "<p>## 1.1.0\n- second</p>" {
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)
Expand Down Expand Up @@ -115,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 {
Expand Down
7 changes: 6 additions & 1 deletion internal/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import (
"fmt"
"os"
"regexp"
"strings"

"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.
Expand All @@ -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)
}
Expand Down
15 changes: 9 additions & 6 deletions internal/registry/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +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",
"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 {
Expand Down
11 changes: 11 additions & 0 deletions internal/registry/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// 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.
Expand Down Expand Up @@ -110,6 +113,14 @@ func (g *GitHubSource) File(ctx context.Context, owner, repo, ref, path string)
return []byte(s), 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
Expand Down
Loading
Loading