feat(kiosk): registry v1 wire types and version rules

- RegistryAPIPath, RegistryPlugin, RegistryPluginDetail, RegistryVersion, envelopes, errors, ingest types
- ValidVersion, CompareVersions, BaseVersion, Satisfies, NewestCompatible
- docs: Registry API v1 reference in plugins/marketplace.md
This commit is contained in:
Jakub Zych
2026-10-29 17:05:41 +01:00
parent 51ec2f9daa
commit 4916ee3d43
6 changed files with 447 additions and 7 deletions

View File

@@ -87,3 +87,36 @@ func TestPluginManifest(t *testing.T) {
```
`kiosk.CheckT` runs the same check as `summer plugin:manifest --check` and names the command to rerun. A nested plugin gets its own test in its own directory.
## Registry API v1
summercms.io publishes the plugins it lists through a read-only JSON API. The marketplace pages, `summer` and the admin's Plugins screen read it. Its base path is `/api/registry/v1` (`kiosk.RegistryAPIPath`), and the `kiosk` module holds its wire types, so every client and the server share one definition of the shape.
| Request | Answer |
|---------|--------|
| `GET /api/registry/v1/plugins?q=&category=` | `{"data": [plugin, ...], "meta": {"total": N}}` (`kiosk.ListResponse`). `q` searches ID, name and description; `category` filters. |
| `GET /api/registry/v1/plugins/{id}` | `{"data": detail}` (`kiosk.DetailResponse`). |
| `GET /api/registry/v1/plugins/{id}/versions` | `{"data": [version, ...]}`, newest first (`kiosk.VersionsResponse`). |
| `POST /api/registry/v1/ingest` | Authenticated with an API token that has the `registry` scope; body `{"repository": "<https URL>"}` (`kiosk.IngestRequest`). Answers 202 with `{"data": {"repository": "...", "queued": true}}` (`kiosk.IngestResponse`). |
A plugin in a list (`kiosk.RegistryPlugin`) has these fields:
| Field | Meaning |
|-------|---------|
| `id`, `name`, `description` | From the latest release's `plugin.yaml`. |
| `module`, `package` | What `go get` and `summer.yaml` take. |
| `repository` | The HTTPS clone URL. |
| `category`, `featured`, `icon_url` | Presentation fields edited on summercms.io. |
| `latest_version`, `latest_framework` | The newest tag and its framework minimum. |
| `updated_at` | When the listing last changed, RFC 3339. |
The detail (`kiosk.RegistryPluginDetail`) adds `readme_markdown`, `readme_html`, `screenshots` (a list of `url` and `caption`) and `requires` of the latest release. A version (`kiosk.RegistryVersion`) has `version`, `framework`, `requires`, `module`, `package`, `commit` and `tagged_at`.
Errors answer with `{"error": {"code": "...", "message": "..."}}` (`kiosk.ErrorResponse`): 404 with `not_found` for an unknown ID, 400 with `invalid_query` for a bad parameter.
Compatibility rules:
- Every answer is an envelope object (`data`, `meta` or `error`), never a bare array.
- Clients ignore fields they do not know, and v1 changes only by adding fields. A change that removes or renames a field needs a `/api/registry/v2` path.
- The GET endpoints need no credentials, allow any origin (CORS) and answer with `Cache-Control: public, max-age=300` and an ETag.
- Versions are strict `vX.Y.Z` tags (`kiosk.ValidVersion`) compared numerically (`kiosk.CompareVersions`). A client picks the release to install with `kiosk.NewestCompatible`: the newest one whose framework minimum the application's framework version satisfies (`kiosk.Satisfies`).

View File

@@ -19,6 +19,8 @@ WinterCMS keeps this data in a plugin's `Plugin.php` `pluginDetails()` and in `c
- A schema version (`kiosk.SchemaVersion`) and a fixed header line (`kiosk.Header`); a manifest with a newer schema is refused with a hint to update summer.
- Go-style minimums: `framework` is the `go.mod` require of `kiosk.FrameworkModule`, and each required plugin gets the version its module is required at. A requirement at `v0.0.0` is an error (not a released version); a pseudo-version is accepted with a warning.
- Name and description overrides (`--name`, `--description`) stored in an `overrides:` block that regeneration keeps.
- The registry API v1 contract in Go: `kiosk.RegistryAPIPath` and the wire types (`kiosk.ListResponse`, `kiosk.DetailResponse`, `kiosk.VersionsResponse`, `kiosk.ErrorResponse`, `kiosk.IngestRequest`, `kiosk.IngestResponse`). The registry server encodes with them and the summer tool and the admin decode with them, so the JSON shape exists once.
- Strict version rules: `kiosk.ValidVersion` (only `vX.Y.Z`), `kiosk.CompareVersions`, `kiosk.BaseVersion` for `git describe` output, `kiosk.Satisfies` for Go-style minimums and `kiosk.NewestCompatible` to pick the release an application can install.
- `kiosk.Check` regenerates in memory and returns a `kiosk.StaleError` when the committed file is missing or differs, which `summer plugin:manifest --check` turns into exit status 1, and `kiosk.CheckT` turns into a failing test in the plugin's own `go test`.
## Usage
@@ -42,6 +44,20 @@ if err := kiosk.Check(ctx, "plugins/acme/blog"); err != nil {
}
```
Picking the newest release of a plugin that an application on framework v0.6.2 can install:
```go
var versions kiosk.VersionsResponse
if err := json.Unmarshal(body, &versions); err != nil {
return err
}
v, ok := kiosk.NewestCompatible(versions.Data, "v0.6.2")
if !ok {
return errors.New("every release needs a newer framework")
}
fmt.Println("go get", v.Module+"@"+v.Version)
```
A plugin repository keeps its manifest current with one test:
```go
@@ -73,6 +89,28 @@ func TestPluginManifest(t *testing.T) {
| `kiosk.TB` | The part of `testing.TB` that `kiosk.CheckT` calls (`Helper`, `Fatalf`); kiosk does not import `testing`. |
| `kiosk.CheckT` | Fails the test with the `kiosk.Check` error and the command to rerun when the manifest is missing or stale. |
| `kiosk.Validate` | Checks a manifest: schema, ID and required IDs, module and package paths (no leading `-`), name, description, and `vX.Y.Z` or pseudo-version minimums. `kiosk.Parse` and `kiosk.Generate` call it. |
| `kiosk.Requires.MarshalJSON` | Encodes the map as a JSON object, `{}` when empty, so the registry never sends `null` for requirements. |
| `kiosk.RegistryAPIPath` | `/api/registry/v1`, the base path of the registry API. |
| `kiosk.RegistryPlugin` | One listed plugin: `id`, `name`, `description`, `module`, `package`, `repository`, `category`, `featured`, `icon_url`, `latest_version`, `latest_framework`, `updated_at` (RFC 3339). |
| `kiosk.RegistryPluginDetail` | `kiosk.RegistryPlugin` plus `readme_markdown`, `readme_html`, `screenshots` and the `requires` of the latest version. |
| `kiosk.RegistryScreenshot` | One screenshot: `url`, `caption`. |
| `kiosk.RegistryVersion` | One tagged release: `version`, `framework`, `requires`, `module`, `package`, `commit`, `tagged_at`. |
| `kiosk.ListResponse` | `{"data": [...], "meta": {"total": N}}` for the plugin list. |
| `kiosk.ListMeta` | The `meta` object of a list: `total`. |
| `kiosk.DetailResponse` | `{"data": {...}}` for one plugin. |
| `kiosk.VersionsResponse` | `{"data": [...]}` for a plugin's releases, newest first. |
| `kiosk.ErrorResponse` | `{"error": {"code", "message"}}`, the body of every error. |
| `kiosk.RegistryError` | A stable `code` and a `message`. |
| `kiosk.RegistryErrorNotFound` | The `not_found` code of a 404. |
| `kiosk.RegistryErrorInvalidQuery` | The `invalid_query` code of a 400. |
| `kiosk.IngestRequest` | The body of the authenticated ingest request: `repository`. |
| `kiosk.IngestResponse` | The 202 answer to an ingest request: `{"data": {"repository", "queued"}}`. |
| `kiosk.IngestResult` | The `data` of an ingest answer. |
| `kiosk.ValidVersion` | Reports whether a version is a strict `vX.Y.Z` release (no pre-release, build metadata or leading zeros). |
| `kiosk.CompareVersions` | Compares two valid versions numerically per component: `v0.10.0` sorts after `v0.9.9`. |
| `kiosk.BaseVersion` | The `vX.Y.Z` tag a `git describe --tags` string starts from (`v0.3.1` for `v0.3.1-4-gabc1234`), or `""`. |
| `kiosk.Satisfies` | Whether a release meets a minimum; an empty minimum is always met, and a pseudo-version minimum is met by its base release and later. |
| `kiosk.NewestCompatible` | The newest release whose framework minimum a framework version satisfies. |
| `kiosk.Identity` | The plugin ID and the required plugin IDs. |
| `kiosk.ReadIdentity` | Reads what `ID()` and `Requires()` return from the sources of a directory, resolving constants across packages and modules without running code. Any other expression is an error naming the method. |
| `kiosk.StaleError` | `Path`, `Missing`, and the generated (`Want`) and committed (`Got`) bytes; its message names the first differing line. |
@@ -87,7 +125,7 @@ func TestPluginManifest(t *testing.T) {
- SummerCMS modules: none.
- Third-party: `github.com/goccy/go-yaml` (manifest YAML), `github.com/yuin/goldmark` (README headings and paragraphs).
- Standard library: `bytes`, `context`, `encoding/json`, `errors`, `fmt`, `go/ast`, `go/parser`, `go/token`, `go/types` (expression printing only), `os`, `os/exec`, `path/filepath`, `regexp`, `slices`, `sort`, `strconv`, `strings`.
- Standard library: `bytes`, `context`, `encoding/json`, `errors`, `fmt`, `go/ast`, `go/parser`, `go/token`, `go/types` (expression printing only), `os`, `os/exec`, `path/filepath`, `regexp`, `slices`, `sort`, `strconv`, `strings`, `time`.
- Tools: the `go` command (`go mod edit -json`, `go list -m -json`, and `go mod download -json` for a required module not in the module cache), run without a shell and with workspaces off.
## Testing
@@ -96,4 +134,4 @@ func TestPluginManifest(t *testing.T) {
go test ./modules/kiosk/...
```
`TestManifestSmoke` copies the fixture plugins in `testdata/` to a temporary directory and generates, checks and edits their manifests: a literal-ID plugin, a plugin whose identity is spread over constants of three packages and two modules, a nested plugin, the error cases, overrides, `kiosk.CheckT` and `kiosk.Validate`. Every fixture module replaces the framework with the stub in `testdata/framework`, so the tests run the local `go` command but need no network and no database.
`TestManifestSmoke` copies the fixture plugins in `testdata/` to a temporary directory and generates, checks and edits their manifests: a literal-ID plugin, a plugin whose identity is spread over constants of three packages and two modules, a nested plugin, the error cases, overrides, `kiosk.CheckT` and `kiosk.Validate`, the registry JSON (golden bytes, unknown fields ignored) and the version rules. Every fixture module replaces the framework with the stub in `testdata/framework`, so the tests run the local `go` command but need no network and no database.

View File

@@ -94,10 +94,7 @@ func Parse(data []byte) (Manifest, error) {
return m, nil
}
var (
pluginIDRe = regexp.MustCompile(`^[a-z][a-z0-9]*\.[a-z][a-z0-9]*$`)
releaseVersion = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
)
var pluginIDRe = regexp.MustCompile(`^[a-z][a-z0-9]*\.[a-z][a-z0-9]*$`)
// Validate checks a manifest: the schema is between 1 and SchemaVersion,
// the ID and every required ID are lowercase vendor.plugin, module and
@@ -141,7 +138,7 @@ func Validate(m Manifest) error {
}
func releaseOrPseudo(v string) bool {
return releaseVersion.MatchString(v) || isPseudoVersion(v)
return ValidVersion(v) || isPseudoVersion(v)
}
// validPath accepts a slash-separated module or import path.

View File

@@ -2,6 +2,7 @@ package kiosk
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
@@ -9,6 +10,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)
// copyTestdata copies testdata/<names...> into a fresh temp dir, keeping
@@ -348,8 +350,140 @@ func (*Plugin) Requires() []string { return []string{dep} }`, "Requires()"},
t.Errorf("Parse of a newer schema: err = %v", err)
}
})
t.Run("registry", func(t *testing.T) {
at := time.Date(2026, 10, 29, 10, 0, 0, 0, time.UTC)
plugin := RegistryPlugin{
ID: "acme.blog", Name: "Blog", Description: "A blog.", Module: "example.test/sm-blog-plugin",
Package: "example.test/sm-blog-plugin", Repository: "https://example.test/sm-blog-plugin.git",
Category: "content", Featured: true, IconURL: "https://example.test/icon.png",
LatestVersion: "v0.2.5", LatestFramework: "v0.6.0", UpdatedAt: at,
}
wantPlugin := `{"id":"acme.blog","name":"Blog","description":"A blog.","module":"example.test/sm-blog-plugin",` +
`"package":"example.test/sm-blog-plugin","repository":"https://example.test/sm-blog-plugin.git",` +
`"category":"content","featured":true,"icon_url":"https://example.test/icon.png",` +
`"latest_version":"v0.2.5","latest_framework":"v0.6.0","updated_at":"2026-10-29T10:00:00Z"}`
assertJSON(t, ListResponse{Data: []RegistryPlugin{plugin}, Meta: ListMeta{Total: 1}},
`{"data":[`+wantPlugin+`],"meta":{"total":1}}`)
detail := RegistryPluginDetail{
RegistryPlugin: plugin, ReadmeMarkdown: "# Blog", ReadmeHTML: "<h1>Blog</h1>",
Screenshots: []RegistryScreenshot{{URL: "https://example.test/1.png", Caption: "List"}},
Requires: Requires{"acme.translate": "v0.1.0"},
}
assertJSON(t, DetailResponse{Data: detail}, `{"data":`+wantPlugin[:len(wantPlugin)-1]+
`,"readme_markdown":"# Blog","readme_html":"\u003ch1\u003eBlog\u003c/h1\u003e",`+
`"screenshots":[{"url":"https://example.test/1.png","caption":"List"}],"requires":{"acme.translate":"v0.1.0"}}}`)
version := RegistryVersion{Version: "v0.2.5", Framework: "v0.6.0", Requires: Requires{"acme.translate": "v0.1.0"},
Module: "example.test/sm-blog-plugin", Package: "example.test/sm-blog-plugin", Commit: "abc123", TaggedAt: at}
assertJSON(t, VersionsResponse{Data: []RegistryVersion{version}},
`{"data":[{"version":"v0.2.5","framework":"v0.6.0","requires":{"acme.translate":"v0.1.0"},`+
`"module":"example.test/sm-blog-plugin","package":"example.test/sm-blog-plugin","commit":"abc123","tagged_at":"2026-10-29T10:00:00Z"}]}`)
assertJSON(t, RegistryVersion{Version: "v0.1.0", TaggedAt: at},
`{"version":"v0.1.0","framework":"","requires":{},"module":"","package":"","commit":"","tagged_at":"2026-10-29T10:00:00Z"}`)
assertJSON(t, ErrorResponse{Error: RegistryError{Code: RegistryErrorNotFound, Message: "no plugin acme.x"}},
`{"error":{"code":"not_found","message":"no plugin acme.x"}}`)
assertJSON(t, IngestRequest{Repository: "https://example.test/sm-blog-plugin.git"},
`{"repository":"https://example.test/sm-blog-plugin.git"}`)
assertJSON(t, IngestResponse{Data: IngestResult{Repository: "https://example.test/sm-blog-plugin.git", Queued: true}},
`{"data":{"repository":"https://example.test/sm-blog-plugin.git","queued":true}}`)
if RegistryAPIPath != "/api/registry/v1" || RegistryErrorInvalidQuery != "invalid_query" {
t.Errorf("RegistryAPIPath = %q, RegistryErrorInvalidQuery = %q", RegistryAPIPath, RegistryErrorInvalidQuery)
}
// Clients ignore fields a later v1 server adds.
var got DetailResponse
extra := `{"data":{"id":"acme.blog","latest_version":"v0.2.5","downloads":12,"requires":{"acme.x":""},"new":{"a":[1]}},"links":{}}`
if err := json.Unmarshal([]byte(extra), &got); err != nil {
t.Fatalf("unknown fields: %v", err)
}
if got.Data.ID != "acme.blog" || got.Data.LatestVersion != "v0.2.5" || len(got.Data.Requires) != 1 {
t.Errorf("decoded = %+v", got.Data)
}
})
t.Run("versions", func(t *testing.T) {
for _, v := range []string{"v0.6.0", "v10.2.33", "v0.0.0", "v1.0.0"} {
if !ValidVersion(v) {
t.Errorf("ValidVersion(%q) = false", v)
}
}
for _, v := range []string{"0.6.0", "v0.6", "v0.6.0-rc1", "v0.6.0+meta", "-v0.6.0", "", "v01.2.3", "v0.3.1-4-gabc1234"} {
if ValidVersion(v) {
t.Errorf("ValidVersion(%q) = true", v)
}
}
cmp := []struct {
a, b string
want int
}{
{"v0.10.0", "v0.9.9", 1}, {"v0.9.9", "v0.10.0", -1}, {"v1.2.3", "v1.2.3", 0},
{"v2.0.0", "v1.99.99", 1}, {"v0.5.3", "v0.6.0", -1}, {"v0.0.10", "v0.0.9", 1},
}
for _, c := range cmp {
if got := CompareVersions(c.a, c.b); got != c.want {
t.Errorf("CompareVersions(%s, %s) = %d, want %d", c.a, c.b, got, c.want)
}
}
for in, want := range map[string]string{
"v0.3.1-4-gabc1234": "v0.3.1", "v0.3.1": "v0.3.1", "abc1234": "", "": "",
"v0.3.1-rc1": "", "v0.3": "",
} {
if got := BaseVersion(in); got != want {
t.Errorf("BaseVersion(%q) = %q, want %q", in, got, want)
}
}
pseudo := "v0.5.4-0.20261029101500-abcdefabcdef"
sat := []struct {
have, min string
want bool
}{
{"v0.6.0", "", true}, {"", "", true}, {"v0.6.0", "v0.6.0", true}, {"v0.7.1", "v0.6.0", true},
{"v0.5.3", "v0.6.0", false}, {"", "v0.6.0", false}, {"v0.3.1-4-gabc1234", "v0.3.0", false},
{"v0.5.4", pseudo, true}, {"v0.6.0", pseudo, true}, {"v0.5.3", pseudo, false},
{"v0.6.0", "latest", false},
}
for _, c := range sat {
if got := Satisfies(c.have, c.min); got != c.want {
t.Errorf("Satisfies(%q, %q) = %v, want %v", c.have, c.min, got, c.want)
}
}
versions := []RegistryVersion{
{Version: "v0.3.0", Framework: "v0.7.0"},
{Version: "v0.2.5", Framework: "v0.6.0"},
{Version: "v0.10.0", Framework: "v0.8.0"},
{Version: "v0.2.4", Framework: "v0.5.0"},
{Version: "v0.2.6-rc1", Framework: "v0.5.0"},
}
if got, ok := NewestCompatible(versions, "v0.6.2"); !ok || got.Version != "v0.2.5" {
t.Errorf("NewestCompatible(v0.6.2) = %+v, %v", got, ok)
}
if got, ok := NewestCompatible(versions, "v0.9.0"); !ok || got.Version != "v0.10.0" {
t.Errorf("NewestCompatible(v0.9.0) = %+v, %v", got, ok)
}
if _, ok := NewestCompatible(versions, "v0.4.0"); ok {
t.Error("NewestCompatible(v0.4.0) found a version")
}
if _, ok := NewestCompatible(nil, "v1.0.0"); ok {
t.Error("NewestCompatible(nil) found a version")
}
})
}
// assertJSON marshals v and compares it with want byte for byte.
func assertJSON(t *testing.T, v any, want string) {
t.Helper()
got, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal %T: %v", v, err)
}
if string(got) != want {
t.Errorf("%T JSON\n got %s\nwant %s", v, got, want)
}
}
// fakeTB records CheckT's calls.
type fakeTB struct {
helpers int

133
modules/kiosk/registry.go Normal file
View File

@@ -0,0 +1,133 @@
package kiosk
import (
"encoding/json"
"time"
)
// The registry API v1 contract. The summercms.io registry encodes its
// responses with these types and the summer tool and the admin decode
// them, so no other copy of the shape exists. Rules: every response is an
// envelope object (data, meta or error); clients ignore unknown fields;
// changes within v1 are additive only. The API is GET-only for clients,
// needs no credentials and answers with open CORS and
// Cache-Control: public, max-age=300 plus an ETag.
// RegistryAPIPath is the base path of the registry API v1:
//
// GET {base}/plugins?q=&category= ListResponse
// GET {base}/plugins/{id} DetailResponse
// GET {base}/plugins/{id}/versions VersionsResponse, newest first
// POST {base}/ingest IngestRequest -> 202 IngestResponse (authenticated)
//
// Errors answer with an ErrorResponse: 404 not_found, 400 invalid_query.
const RegistryAPIPath = "/api/registry/v1"
// Error codes of a RegistryError.
const (
// RegistryErrorNotFound is the code of a 404: no such plugin.
RegistryErrorNotFound = "not_found"
// RegistryErrorInvalidQuery is the code of a 400: a bad query parameter.
RegistryErrorInvalidQuery = "invalid_query"
)
// RegistryPlugin is one plugin in the registry list.
type RegistryPlugin struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Module string `json:"module"`
Package string `json:"package"`
Repository string `json:"repository"`
Category string `json:"category"`
Featured bool `json:"featured"`
IconURL string `json:"icon_url"`
LatestVersion string `json:"latest_version"`
LatestFramework string `json:"latest_framework"`
UpdatedAt time.Time `json:"updated_at"`
}
// RegistryPluginDetail is one plugin with its README, screenshots and the
// requirements of its latest version.
type RegistryPluginDetail struct {
RegistryPlugin
ReadmeMarkdown string `json:"readme_markdown"`
ReadmeHTML string `json:"readme_html"`
Screenshots []RegistryScreenshot `json:"screenshots"`
Requires Requires `json:"requires"`
}
// RegistryScreenshot is one screenshot of a plugin listing.
type RegistryScreenshot struct {
URL string `json:"url"`
Caption string `json:"caption"`
}
// RegistryVersion is one tagged release of a plugin, with the minimums
// its plugin.yaml declared at that tag.
type RegistryVersion struct {
Version string `json:"version"`
Framework string `json:"framework"`
Requires Requires `json:"requires"`
Module string `json:"module"`
Package string `json:"package"`
Commit string `json:"commit"`
TaggedAt time.Time `json:"tagged_at"`
}
// ListResponse answers GET {base}/plugins.
type ListResponse struct {
Data []RegistryPlugin `json:"data"`
Meta ListMeta `json:"meta"`
}
// ListMeta carries the number of plugins that matched.
type ListMeta struct {
Total int `json:"total"`
}
// DetailResponse answers GET {base}/plugins/{id}.
type DetailResponse struct {
Data RegistryPluginDetail `json:"data"`
}
// VersionsResponse answers GET {base}/plugins/{id}/versions, newest first.
type VersionsResponse struct {
Data []RegistryVersion `json:"data"`
}
// ErrorResponse is the body of every registry error.
type ErrorResponse struct {
Error RegistryError `json:"error"`
}
// RegistryError is a stable code and a human-readable message.
type RegistryError struct {
Code string `json:"code"`
Message string `json:"message"`
}
// IngestRequest is the body of POST {base}/ingest.
type IngestRequest struct {
Repository string `json:"repository"`
}
// IngestResponse answers an accepted ingest request (202).
type IngestResponse struct {
Data IngestResult `json:"data"`
}
// IngestResult names the repository queued for ingestion.
type IngestResult struct {
Repository string `json:"repository"`
Queued bool `json:"queued"`
}
// MarshalJSON encodes the map as an object, {} when it is empty, so the
// registry never sends null for requires.
func (r Requires) MarshalJSON() ([]byte, error) {
if r == nil {
return []byte("{}"), nil
}
return json.Marshal(map[string]string(r))
}

105
modules/kiosk/version.go Normal file
View File

@@ -0,0 +1,105 @@
package kiosk
import (
"regexp"
"strings"
)
var (
validVersionRe = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`)
describeRe = regexp.MustCompile(`^(v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))(?:-[0-9]+-g[0-9a-f]+)?$`)
pseudoBaseRe = regexp.MustCompile(`^(v[0-9]+\.[0-9]+\.[0-9]+)-`)
)
// ValidVersion reports whether v is a release version in the strict form
// vX.Y.Z: no pre-release, no build metadata, no leading zeros.
func ValidVersion(v string) bool {
return validVersionRe.MatchString(v)
}
// CompareVersions compares two valid versions numerically per component
// and returns -1, 0 or +1. It is only defined for versions ValidVersion
// accepts; never pass a git describe string (use BaseVersion first).
func CompareVersions(a, b string) int {
pa := strings.Split(strings.TrimPrefix(a, "v"), ".")
pb := strings.Split(strings.TrimPrefix(b, "v"), ".")
for i := 0; i < len(pa) && i < len(pb); i++ {
if c := compareNumeric(pa[i], pb[i]); c != 0 {
return c
}
}
switch {
case len(pa) < len(pb):
return -1
case len(pa) > len(pb):
return 1
}
return 0
}
// compareNumeric compares two decimal strings without leading zeros, of
// any length.
func compareNumeric(a, b string) int {
switch {
case len(a) != len(b):
if len(a) < len(b) {
return -1
}
return 1
case a < b:
return -1
case a > b:
return 1
}
return 0
}
// BaseVersion returns the vX.Y.Z tag a `git describe --tags` string starts
// from: "v0.3.1" for "v0.3.1" and for "v0.3.1-4-gabc1234". Anything else
// (a bare commit hash, an empty string, a pre-release tag) gives "".
func BaseVersion(describe string) string {
m := describeRe.FindStringSubmatch(describe)
if m == nil {
return ""
}
return m[1]
}
// Satisfies reports whether the release have meets the minimum min. An
// empty minimum is always met. A pseudo-version minimum such as
// vX.Y.Z-0.<timestamp>-<hash> sorts just below vX.Y.Z, so it is met by
// vX.Y.Z and later. have must be a valid release; anything else meets
// only the empty minimum.
func Satisfies(have, min string) bool {
if min == "" {
return true
}
if !ValidVersion(have) {
return false
}
if ValidVersion(min) {
return CompareVersions(have, min) >= 0
}
if isPseudoVersion(min) {
base := pseudoBaseRe.FindStringSubmatch(min)
return base != nil && ValidVersion(base[1]) && CompareVersions(have, base[1]) >= 0
}
return false
}
// NewestCompatible returns the newest release in versions whose framework
// minimum the given framework version satisfies, and false when there is
// none. Entries whose Version is not a valid release are skipped.
func NewestCompatible(versions []RegistryVersion, framework string) (RegistryVersion, bool) {
var best RegistryVersion
found := false
for _, v := range versions {
if !ValidVersion(v.Version) || !Satisfies(framework, v.Framework) {
continue
}
if !found || CompareVersions(v.Version, best.Version) > 0 {
best, found = v, true
}
}
return best, found
}