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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.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/<name>.json` adds the rendered README, changelog and release history.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a "What the directory publishes" section to docs/CONTRACT.md (ffc0bca) explaining that stars comes from the repo's GitHub star count and is best-effort. The full index field list stays in the README, which is the consumer-facing doc.

- `cmd/registry` β€” the tool CI runs: `validate` on pull requests, `build` on merge and every six hours.

```bash
Expand Down
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
4 changes: 4 additions & 0 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
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
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
12 changes: 12 additions & 0 deletions internal/registry/source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.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) }
13 changes: 13 additions & 0 deletions internal/registry/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +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
starsErr error // when set, RepoStars fails for every repo
}

func (m *memSource) Releases(_ context.Context, owner, repo string) ([]Release, error) {
Expand All @@ -38,6 +40,16 @@ func (m *memSource) RenderMarkdown(_ context.Context, ownerRepo, md string) (str
return "<p>" + md + "</p>", 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
}

const helloSrc = "package main\n// hello plugin\n"

func helloSource() *memSource {
Expand All @@ -56,6 +68,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},
}
}

Expand Down
Loading