fix(34): CR-01 never move a pseudo-version install back to its base release

plugin:update dropped a go.mod require that was not a strict vX.Y.Z and
treated it as v0.0.0, so a module at a commit after v0.4.0
(v0.4.1-0.<ts>-<hash>) was moved back to v0.4.0. kiosk.Newer now places
a release against a release, a git describe string or a pseudo-version
(by the tag it builds on, in Go's version order); plugin:update and the
admin Plugins screen badge share it. A pseudo-version with no tag before
its commit is left alone with a note naming go get <module>@<tag>.
This commit is contained in:
Jakub Zych
2026-10-29 21:58:07 +01:00
parent d30ef5de47
commit 60551485ed
10 changed files with 200 additions and 28 deletions

View File

@@ -163,7 +163,7 @@ summer plugin:update --yes
summer plugin:update --offline
```
With an ID it updates that plugin; without one it goes through every plugin in `summer.yaml`. A module install moves with `go get <module>@<version>`. A submodule install fetches the tags of its repository, is checked out at the release tag, and `go.mod` requires that tag; the `go.work` overrides are refreshed, so a version pin follows a moved framework requirement. A listed plugin's releases come from the registry. A submodule the registry does not list, such as a plugin installed from a git URL, is updated from its own tags, reading `plugin.yaml` at each tag for the framework minimum. `--offline` makes no registry call and updates submodules from their own tags only.
With an ID it updates that plugin; without one it goes through every plugin in `summer.yaml`. A module install moves with `go get <module>@<version>`, and only to a release newer than the one installed (`kiosk.Newer`): a module required at a pseudo-version, such as `v0.4.1-0.20261001120000-abcdef123456` after `go get <module>@main`, is a commit after `v0.4.0`, so it moves to `v0.4.1` or later and never back to `v0.4.0`. A pseudo-version with no release before its commit (`v0.0.0-<timestamp>-<hash>`) or a pre-release is left as it is; pin a release with `go get <module>@<tag>` first. A submodule install fetches the tags of its repository, is checked out at the release tag, and `go.mod` requires that tag; the `go.work` overrides are refreshed, so a version pin follows a moved framework requirement. A listed plugin's releases come from the registry. A submodule the registry does not list, such as a plugin installed from a git URL, is updated from its own tags, reading `plugin.yaml` at each tag for the framework minimum. `--offline` makes no registry call and updates submodules from their own tags only.
The command prints what it will move (`acme.blog v0.1.0 -> v0.1.1 (module)`) and asks for confirmation like `summer plugin:install`; `--yes` skips the question. A newer release that needs a newer framework is named but not installed. The plugins it leaves as they are come under "Not updated" with the reason: plugins that are part of the application's own repository, module installs the registry does not list, plugins already at their newest compatible release, and releases that require a plugin the application lacks or has at too old a version. The command exits with an error only when an update it resolved could not be carried out, such as a release the registry lists but the repository has no tag for. Run `summer build` afterwards.
@@ -184,7 +184,7 @@ Every registry call goes through `kiosk.Client`, which uses `fetchguard` with a
The admin's Settings → System → Plugins screen shows administrators holding `backend.view_plugins` (`cabana.PermissionViewPlugins`) what the application was built with and what the registry offers. The screen changes nothing: plugins are installed and updated with the commands above, followed by `summer build` and a deploy of the new binary, and the screen shows those commands with a copy button. See [Admin SPA](../backend/admin-spa.md#plugins-screen).
The Installed tab reads `party.Installed` through `GET <prefix>/api/v1/plugins` (`cabana.PluginsOverview`) and lists every plugin with its name, description, version and source. An application built before this metadata existed lists only the plugin IDs. For each plugin with a version the server asks the registry for its releases and applies the rule `summer plugin:update` uses: the newest release whose framework minimum the built framework version satisfies (`kiosk.NewestCompatible`) is an update when it is newer than the installed version, and a submodule version such as `v0.1.1-3-gabc1234` compares by its base tag (`kiosk.BaseVersion`). Such a plugin gets an update badge and the command `summer plugin:update <id>`. A newer release that needs a newer framework is named with the framework version it needs. A plugin the registry does not list, and a plugin that is part of the application (no version), gets no badge.
The Installed tab reads `party.Installed` through `GET <prefix>/api/v1/plugins` (`cabana.PluginsOverview`) and lists every plugin with its name, description, version and source. An application built before this metadata existed lists only the plugin IDs. For each plugin with a version the server asks the registry for its releases and applies the rule `summer plugin:update` uses: the newest release whose framework minimum the built framework version satisfies (`kiosk.NewestCompatible`) is an update when it is newer than the installed version (`kiosk.Newer`): a submodule version such as `v0.1.1-3-gabc1234` compares by its base tag (`kiosk.BaseVersion`), and a module at a pseudo-version by the release it builds on. Such a plugin gets an update badge and the command `summer plugin:update <id>`. A newer release that needs a newer framework is named with the framework version it needs. A plugin the registry does not list, and a plugin that is part of the application (no version), gets no badge.
The Marketplace tab searches the registry through `GET <prefix>/api/v1/plugins/marketplace?q=&category=` (`cabana.MarketplaceResponse`): the search text (at most 100 characters) and the category are passed on to the registry. Each result shows its name, description, newest version and icon, a marker when the application already has the plugin or when the built framework is too old for its newest release, and the command `summer plugin:install <id>` with a copy button. There is no install button.

View File

@@ -342,13 +342,14 @@ func (in *installer) installedSatisfies(id, min string) (bool, error) {
// installedVersion returns the installed release of the plugin package
// pkg (see installedSource): the module version of a module install, the
// base tag of git describe for a submodule, "" for an in-app plugin.
// base tag of git describe for a submodule, "" for an in-app plugin or a
// module at a pseudo-version.
func installedVersion(ctx context.Context, appDir, pkg string) (string, error) {
src, err := installedSource(ctx, appDir, pkg)
if err != nil {
return "", err
}
return src.Version, nil
return releaseOrEmpty(src.Version), nil
}
func sortedKeys(m kiosk.Requires) []string {

View File

@@ -377,3 +377,64 @@ func TestAskToProceed(t *testing.T) {
t.Errorf("accepted: %v", err)
}
}
// TestUpdatePseudoVersion pins CR-01: a module required at a
// pseudo-version (a commit after a release, as go get M@main leaves it) is
// never moved to the release it builds on or an older one, and one with no
// release before its commit is not moved at all.
func TestUpdatePseudoVersion(t *testing.T) {
const after040 = "v0.4.1-0.20261001120000-abcdef123456"
plan := func(t *testing.T, installed string, releases ...string) (string, error) {
t.Helper()
reg := &memRegistry{}
reg.add("acme.blog", nil, releases...)
app := installApp(t, "v0.6.0", map[string]string{"acme.blog": installed})
var out bytes.Buffer
err := Update(t.Context(), UpdateOptions{
StartDir: app, ID: "acme.blog", Registry: reg, Out: &out, Interactive: true,
Confirm: func(string) (bool, error) { return false, nil },
})
return out.String(), err
}
t.Run("only the base release and older", func(t *testing.T) {
out, err := plan(t, after040, "v0.4.0:v0.6.0", "v0.3.0:v0.6.0")
if err != nil {
t.Fatalf("update: %v\n%s", err, out)
}
if !strings.Contains(out, "Nothing to update.") || !strings.Contains(out, "up to date at "+after040) || strings.Contains(out, "-> v0.4.0") {
t.Errorf("a commit after v0.4.0 is moved back:\n%s", out)
}
})
t.Run("a newer release", func(t *testing.T) {
out, err := plan(t, after040, "v0.4.1:v0.6.0", "v0.4.0:v0.6.0")
if !errors.Is(err, ErrCancelled) {
t.Fatalf("update: %v\n%s", err, out)
}
if !strings.Contains(out, "acme.blog "+after040+" -> v0.4.1 (module)") {
t.Errorf("plan:\n%s", out)
}
})
t.Run("no release before the commit", func(t *testing.T) {
out, err := plan(t, "v0.0.0-20261001120000-abcdef123456", "v0.1.0:v0.6.0")
if err != nil {
t.Fatalf("update: %v\n%s", err, out)
}
if !strings.Contains(out, "Nothing to update.") || !strings.Contains(out, "plugin:update does not guess") || !strings.Contains(out, "go get example.test/blog@<tag>") {
t.Errorf("an unplaceable commit is moved:\n%s", out)
}
})
t.Run("a requirement at a pseudo-version", func(t *testing.T) {
reg := &memRegistry{}
reg.add("acme.shop", kiosk.Requires{"acme.user": "v0.1.2"}, "v0.1.0:v0.6.0")
app := installApp(t, "v0.6.0", map[string]string{"acme.user": "v0.1.3-0.20261001120000-abcdef123456"})
var out bytes.Buffer
p, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.shop", Yes: true, Registry: reg, Out: &out})
if err != nil || planSteps(p) != "acme.shop@v0.1.0" || !strings.Contains(out.String(), "cannot be read") {
t.Errorf("plan: %v, %v\n%s", p, err, out.String())
}
})
}

View File

@@ -45,8 +45,9 @@ type pluginSource struct {
// Module is the module providing the package ("" for a package of the
// application module).
Module string
// Version is the module version (module), the base release tag of
// git describe (submodule) or "".
// Version is the go.mod module version (module: a release or a
// pseudo-version, see kiosk.Newer), the base release tag of git
// describe (submodule) or "".
Version string
// Dir is the module root of a submodule or local plugin.
Dir string
@@ -116,10 +117,9 @@ func installedSource(ctx context.Context, appDir, pkg string) (pluginSource, err
apply(gomod.Replace, appDir)
apply(work.Replace, workDir)
if src.Dir == "" {
// The version stays as go.mod has it: a pseudo-version is a commit
// after a release, and only kiosk.Newer may compare it (CR-01).
src.Kind = ModeModule
if !kiosk.ValidVersion(src.Version) {
src.Version = ""
}
return src, nil
}
if !isOwnRepository(ctx, src.Dir) {
@@ -131,6 +131,16 @@ func installedSource(ctx context.Context, appDir, pkg string) (pluginSource, err
return src, nil
}
// releaseOrEmpty returns v when it is a vX.Y.Z release and "" otherwise:
// a requirement check accepts a version it cannot read (a pseudo-version)
// rather than refuse it.
func releaseOrEmpty(v string) string {
if kiosk.ValidVersion(v) {
return v
}
return ""
}
// updateStep is one plugin plugin:update moves.
type updateStep struct {
ID, Package string
@@ -279,18 +289,17 @@ func (u *updater) registryReason() string {
return ""
}
// pick chooses the newest release above have whose framework minimum the
// app satisfies, with a note naming a still newer release that needs a
// newer framework. When there is none it returns only a note: the newer
// release that needs a newer framework, or that have is the newest.
// pick chooses the newest release above have (kiosk.Newer: never at or
// below the release a pseudo-version or describe string builds on) whose
// framework minimum the app satisfies, with a note naming a still newer
// release that needs a newer framework. An empty have (a submodule with no
// release tag behind its commit) takes any release. When there is none it
// returns only a note: the newer release that needs a newer framework, or
// that have is the newest.
func (u *updater) pick(versions []kiosk.RegistryVersion, have string) (kiosk.RegistryVersion, string) {
base := have
if base == "" {
base = "v0.0.0"
}
var newer []kiosk.RegistryVersion
for _, v := range versions {
if kiosk.ValidVersion(v.Version) && kiosk.CompareVersions(v.Version, base) > 0 {
if kiosk.ValidVersion(v.Version) && (have == "" || kiosk.Newer(v.Version, have)) {
newer = append(newer, v)
}
}
@@ -313,6 +322,12 @@ func (u *updater) planModule(p Plugin, src pluginSource) {
u.note(p.ID, "installed as a Go module, and "+why)
return
}
if !kiosk.Comparable(src.Version) {
// A commit with no release tag before it, or a pre-release: no
// release is known to be newer, and guessing could go back.
u.note(p.ID, fmt.Sprintf("installed at %s, which is not after a release; plugin:update does not guess, so pin a release with go get %s@<tag>", displayVersion(src.Version), src.Module))
return
}
versions, err := u.opts.Registry.Versions(u.ctx, p.ID)
if errors.Is(err, kiosk.ErrNotFound) {
u.note(p.ID, "not in the registry")
@@ -421,7 +436,7 @@ func (u *updater) checkRequires(m Manifest, installed map[string]pluginSource) {
listed[p.ID] = p.Module
}
for id, src := range installed {
final[id] = src.Version
final[id] = releaseOrEmpty(src.Version)
}
for _, s := range u.steps {
final[s.ID] = s.To.Version
@@ -439,7 +454,7 @@ func (u *updater) checkRequires(m Manifest, installed map[string]pluginSource) {
if !known {
src, err := installedSource(u.ctx, u.appDir, listed[dep])
if err == nil {
have = src.Version
have = releaseOrEmpty(src.Version)
}
}
if have != "" && !kiosk.Satisfies(have, min) {

View File

@@ -57,7 +57,7 @@ Use cases:
- Roles screen: Settings → System → Roles (controller `cabana.RolesControllerID`, permission `cabana.PermissionManageRoles`) lists, creates, edits and deletes backend roles with name, code (slugged from the name), description and a checkbox permission editor. System roles (`is_system`, or a code that code-declared `pact.Permission` grants attach to) keep their code and cannot be deleted, a role any administrator holds (deleted ones included) cannot be deleted, a code another role uses is refused, and a non-superuser editor cannot add or remove a permission they lack. The update form's Administrators tab lists the role's holders read-only (a form partial): superusers only for superuser editors, deleted and deactivated holders marked, the first 100 with a note naming the total, each login linked to the Administrators preview for an editor holding `cabana.PermissionManageUsers`; roles are assigned only on the Administrators form.
- Record actions on another plugin's controller: a plugin implementing `pact.HasAdminActionExtensions` adds `pact.AdminRecordAction` entries to any admin controller by its ID (`pact.AdminActionExtension`), its own, another plugin's or the admin's own Administrators screen (`cabana.AdministratorsControllerID`). They are merged at boot after the target's own record actions, in plugin activation order, offered on the target's preview screen and run through the same row-locked record action route; the target's `RequiredPermissions` and the action's own `Permissions` both apply. Contributions targeting `cabana.AdministratorsControllerID` run `guardOutranked` before `Run` (403 `user.outranked`, `Run` not called). An unknown controller ID, a target without a preview block, a name the target already registers (the owner's or an earlier contribution), a reserved or non-identifier name, a missing `Run` or an empty label stops the boot. Extensions are compiled only when the admin is active.
- Form fields on another plugin's controller: a `pact.AdminActionExtension` with `Form` set to a `pact.AdminFormExtension` places fields and tabs in any admin controller's form by its ID. Its fields document is the `fields.yaml` format plus `before:`/`after:` anchors, `zone:` (only the tab zone for now) and a `tabs:` list, compiled at boot with every `fields.yaml` check; extensions apply in plugin activation order and the form is sent in the compiled order, so the SPA adds no ordering. `Hide` removes owner fields, `Permissions` and `Visible` decide per request who sees the fields (schema, show and save alike), widget fields run the extension's `Actions` and partial fields render from its `Dir` through `PartialData`. Extension values leave the save body before any owner path reads them; `Save` runs in the record's save transaction after the owner row is written (`pact.AdminFormErrors` answers 422, its notices join `meta.notices`) and `Load` serves the values on show. A form no extension lays out compiles unchanged. Unknown anchors, duplicate names, anchor cycles, anchors outside `Requires()`, other zones, `fileupload` fields and clashing widget action or partial names stop the boot naming the plugin, the controller and the name.
- Plugins screen: Settings → System → Plugins opens the admin SPA route `/system/plugins` (the settings entry carries `path` instead of a controller) for administrators holding `cabana.PermissionViewPlugins` (`backend.view_plugins`, granted to no role in code; superusers always pass). GET `/plugins` lists the plugins the application was built with from the `party.Installed` metadata `summer build` bakes (name, description, version, source, module and package), or only the IDs of `party.Activated` for an application built before that metadata existed, with the framework version and the state of the plugin registry (`plugins.registry_url`, read through `kiosk.RegistryURL`). With the registry configured, cabana (never the SPA) looks up each versioned plugin's releases through `kiosk.Client` and marks an update when `kiosk.NewestCompatible` finds a release newer than the installed version's `kiosk.BaseVersion`, with the `summer plugin:update <id>` command; GET `/plugins/marketplace` searches the registry and adds installed and compatibility markers and the `summer plugin:install <id>` command. Registry calls have a 4 second timeout, at most four run at once, and answers are cached in process (searches 5 minutes, releases 15 minutes, failures 1 minute); a failing registry gives `status` unavailable and never a non-200 answer.
- Plugins screen: Settings → System → Plugins opens the admin SPA route `/system/plugins` (the settings entry carries `path` instead of a controller) for administrators holding `cabana.PermissionViewPlugins` (`backend.view_plugins`, granted to no role in code; superusers always pass). GET `/plugins` lists the plugins the application was built with from the `party.Installed` metadata `summer build` bakes (name, description, version, source, module and package), or only the IDs of `party.Activated` for an application built before that metadata existed, with the framework version and the state of the plugin registry (`plugins.registry_url`, read through `kiosk.RegistryURL`). With the registry configured, cabana (never the SPA) looks up each versioned plugin's releases through `kiosk.Client` and marks an update when `kiosk.NewestCompatible` finds a release newer than the installed version by `kiosk.Newer` (a `git describe` string or a pseudo-version compares by the tag it builds on), with the `summer plugin:update <id>` command; GET `/plugins/marketplace` searches the registry and adds installed and compatibility markers and the `summer plugin:install <id>` command. Registry calls have a 4 second timeout, at most four run at once, and answers are cached in process (searches 5 minutes, releases 15 minutes, failures 1 minute); a failing registry gives `status` unavailable and never a non-200 answer.
- Administrators screen: when some plugin registers admin controllers, `cabana.Activate` also compiles the admin's own screens, an internal plugin with ID `summercms.cabana`. Settings → System → Administrators (controller `cabana.AdministratorsControllerID`, permission `cabana.PermissionManageUsers`) lists backend administrators with their role and last sign-in, opens a preview of each, where record actions (the admin's own and contributed ones) are offered, and creates and edits administrators: login, email, names, a password (hashed at `admin.password.bcrypt_cost`, at least 4 characters and at most 72 bytes), the role and per-administrator permission overrides. The server enforces the privilege rules in the save transaction: only a superuser sees superusers or sends the superuser switch, an administrator can neither grant permissions or roles above their own grants nor change an administrator who holds more than they do, their own role, superuser flag, permissions and password are not changed here, and the last active superuser cannot be demoted. A password set for another administrator ends their sessions (personal API tokens stay valid, as with `admin:reset-password`). Administrators are activated, deactivated and restored by record and bulk actions and deleted softly through `deleted_at` (never hard-deleted); the list shows deleted and deactivated rows with their row states and filters deleted ones, and the preview shows a status hint. Deactivating or deleting an administrator ends their sessions and stops their personal API tokens; the last active superuser cannot be demoted, deactivated or deleted on any of these paths (a transaction-scoped advisory lock serializes the check). The permission codes `cabana.PermissionManageUsers` and `cabana.PermissionManageRoles` are registered with no role grant, so after an upgrade only superusers see the screens until an administrator grants them. An application without admin controllers still activates no admin.
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.
- Prompted record actions: a `pact.AdminRecordAction` with a `Prompt` (`pact.AdminActionPrompt`) asks the administrator for one text value in its confirm dialog. The record response serves it as `prompt` on the action's `cabana.RecordAction` (absent for every other action, so existing payloads are unchanged), and the action route accepts the body key `payload`, a JSON string, only for such an action: trimmed, not empty and at most `max_length` runes (422 on `payload` with `backend::lang.form.action_prompt_required` or `action_prompt_too_long`, `backend::lang.form.action_prompt_invalid` for another JSON type), then handed to `Run` as `pact.AdminRecordActionInput.Value`. Every other record action keeps refusing `record_id`, `values` and `payload`. A prompt without a label fails boot, owned or contributed.

View File

@@ -324,17 +324,18 @@ func (c *pluginCatalog) applyUpdates(ctx context.Context, overview *PluginsOverv
// applyUpdate sets the registry fields of a listed plugin: Latest is the
// newest release framework allows, an update is available when Latest is
// newer than the installed version's base tag, and LatestFramework names
// the minimum of a newer release that framework does not satisfy. The rule
// is summer plugin:update's.
// newer than the installed version by kiosk.Newer (a git describe string
// or a pseudo-version compares by the release it builds on, so a commit
// after a release is never offered that release or an older one), and
// LatestFramework names the minimum of a newer release that framework
// does not satisfy. The rule is summer plugin:update's.
func applyUpdate(plugin *InstalledPlugin, versions []kiosk.RegistryVersion, framework string) {
plugin.Listed = true
latest, compatible := kiosk.NewestCompatible(versions, framework)
if compatible {
plugin.Latest = latest.Version
}
base := kiosk.BaseVersion(plugin.Version)
if compatible && base != "" && kiosk.CompareVersions(latest.Version, base) > 0 {
if compatible && kiosk.Newer(latest.Version, plugin.Version) {
plugin.UpdateAvailable = true
plugin.UpdateCommand = "summer plugin:update " + plugin.ID
}
@@ -347,7 +348,7 @@ func applyUpdate(plugin *InstalledPlugin, versions []kiosk.RegistryVersion, fram
if newest.Version == "" || (compatible && kiosk.CompareVersions(newest.Version, latest.Version) <= 0) {
return
}
if base != "" && kiosk.CompareVersions(newest.Version, base) <= 0 {
if kiosk.Comparable(plugin.Version) && !kiosk.Newer(newest.Version, plugin.Version) {
return
}
plugin.LatestFramework = newest.Framework

View File

@@ -195,6 +195,13 @@ func TestPluginsEndpoints(t *testing.T) {
{"describe base equal to latest", "v0.1.1-3-gabc1234", "v0.6.0", []kiosk.RegistryVersion{rel("v0.1.1", "v0.6.0")}, "v0.1.1", "", false},
{"describe base below latest", "v0.1.1-3-gabc1234", "v0.6.0", []kiosk.RegistryVersion{rel("v0.1.2", "v0.6.0"), rel("v0.1.1", "v0.6.0")}, "v0.1.2", "", true},
{"untagged commit", "abc1234", "v0.6.0", []kiosk.RegistryVersion{rel("v0.1.2", "v0.6.0")}, "v0.1.2", "", false},
// A module at a commit after v0.4.0 (go get M@main): v0.4.0 is
// no update, v0.4.1 is.
{"pseudo-version after the latest", "v0.4.1-0.20261001120000-abcdef123456", "v0.6.0", []kiosk.RegistryVersion{rel("v0.4.0", "v0.6.0")}, "v0.4.0", "", false},
{"pseudo-version before the latest", "v0.4.1-0.20261001120000-abcdef123456", "v0.6.0", []kiosk.RegistryVersion{rel("v0.4.1", "v0.6.0"), rel("v0.4.0", "v0.6.0")}, "v0.4.1", "", true},
{"pseudo-version without a tag before it", "v0.0.0-20261001120000-abcdef123456", "v0.6.0", []kiosk.RegistryVersion{rel("v0.1.0", "v0.6.0")}, "v0.1.0", "", false},
{"pseudo-version, newer incompatible", "v0.4.1-0.20261001120000-abcdef123456", "v0.6.0", []kiosk.RegistryVersion{rel("v0.5.0", "v0.7.0"), rel("v0.4.0", "v0.6.0")}, "v0.4.0", "v0.7.0", false},
{"pseudo-version, older incompatible", "v0.4.1-0.20261001120000-abcdef123456", "v0.6.0", []kiosk.RegistryVersion{rel("v0.4.0", "v0.7.0")}, "", "", false},
{"newer incompatible only", "v0.1.0", "v0.6.0", []kiosk.RegistryVersion{rel("v0.3.0", "v0.7.0"), rel("v0.1.0", "v0.5.0")}, "v0.1.0", "v0.7.0", false},
{"compatible update and newer incompatible", "v0.1.0", "v0.6.0", []kiosk.RegistryVersion{rel("v0.3.0", "v0.8.0"), rel("v0.2.0", "v0.6.0")}, "v0.2.0", "v0.8.0", true},
{"incompatible but not newer than installed", "v0.3.0", "v0.6.0", []kiosk.RegistryVersion{rel("v0.3.0", "v0.7.0")}, "", "", false},

View File

@@ -20,7 +20,7 @@ WinterCMS keeps this data in a plugin's `Plugin.php` `pluginDetails()` and in `c
- 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.
- Strict version rules: `kiosk.ValidVersion` (only `vX.Y.Z`), `kiosk.CompareVersions`, `kiosk.BaseVersion` for `git describe` output, `kiosk.Satisfies` for Go-style minimums, `kiosk.NewestCompatible` to pick the release an application can install, and `kiosk.Newer` with `kiosk.Comparable` to tell whether a release is an upgrade for an installed version (a release, a `git describe` string or a pseudo-version), so an update never goes back.
- A registry client, `kiosk.Client`, for the summer tool and the admin: `kiosk.NewClient` builds it over a `fetchguard` client in trusted mode with a 4 MiB body cap, a 10 second timeout and no redirects, and `kiosk.RegistryURL` reads the registry origin from the `plugins.registry_url` config key (default `kiosk.DefaultRegistryURL`; an empty value disables registry calls).
- `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`.
@@ -126,6 +126,8 @@ func TestPluginManifest(t *testing.T) {
| `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.Newer` | Whether a release is an upgrade for an installed version: above a release or the base tag of a `git describe` string, and for a pseudo-version at or above the release after the tag it builds on (`v0.4.1` and later for `v0.4.1-0.<timestamp>-<hash>`, never `v0.4.0`). Nothing is newer than a version `kiosk.Comparable` refuses. |
| `kiosk.Comparable` | Whether `kiosk.Newer` can place an installed version: a release, a `git describe` string, or a pseudo-version that builds on a tag (not `vX.0.0-<timestamp>-<hash>`, a bare hash or a pre-release). |
| `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.DefaultRegistryURL` | `https://summercms.io`, the registry used when `plugins.registry_url` is not set. |

View File

@@ -9,6 +9,10 @@ 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]+)-`)
// pseudoNoBaseRe is a pseudo-version with no tag before its commit:
// vX.0.0-<timestamp>-<hash>, without the "0." or "<pre>.0." of one
// that builds on a release or a pre-release.
pseudoNoBaseRe = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+-[0-9]{14}-[0-9a-f]{12}$`)
)
// ValidVersion reports whether v is a release version in the strict form
@@ -65,6 +69,47 @@ func BaseVersion(describe string) string {
return m[1]
}
// Newer reports whether release is an upgrade for a plugin installed at
// installed, so moving to it never goes back. installed may be a release
// vX.Y.Z (newer: above it), a git describe string vX.Y.Z-N-g<hash>, a
// commit after vX.Y.Z (newer: above vX.Y.Z), or a Go pseudo-version, a
// commit after the tag it builds on: vX.Y.(Z+1)-0.<timestamp>-<hash>
// after the release vX.Y.Z, vX.Y.Z-<pre>.0.<timestamp>-<hash> after the
// pre-release vX.Y.Z-<pre>; newer is at or above vX.Y.(Z+1) and vX.Y.Z,
// Go's own version order. Nothing is newer than an installed version
// Comparable refuses (a pseudo-version with no tag before its commit, a
// bare commit hash, a pre-release, ""). release must be a valid release.
func Newer(release, installed string) bool {
if !ValidVersion(release) {
return false
}
if base := BaseVersion(installed); base != "" {
return CompareVersions(release, base) > 0
}
if !isPseudoVersion(installed) || pseudoNoBaseRe.MatchString(installed) {
return false
}
core := pseudoBaseRe.FindStringSubmatch(installed)
if core == nil || !ValidVersion(core[1]) {
return false
}
return CompareVersions(release, core[1]) >= 0
}
// Comparable reports whether Newer can place installed among releases: a
// release, a git describe string, or a pseudo-version that builds on a
// release or a pre-release.
func Comparable(installed string) bool {
if BaseVersion(installed) != "" {
return true
}
if !isPseudoVersion(installed) || pseudoNoBaseRe.MatchString(installed) {
return false
}
core := pseudoBaseRe.FindStringSubmatch(installed)
return core != nil && ValidVersion(core[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

View File

@@ -99,6 +99,46 @@ func TestVersions(t *testing.T) {
}
})
t.Run("Newer", func(t *testing.T) {
cases := []struct {
release, installed string
want bool
}{
{"v0.4.1", "v0.4.0", true}, {"v0.4.0", "v0.4.0", false}, {"v0.3.9", "v0.4.0", false},
{"v0.4.1", "v0.4.0-3-gabc1234", true}, {"v0.4.0", "v0.4.0-3-gabc1234", false},
// A commit after v0.4.0 (go get M@main): v0.4.0 and older are
// downgrades; v0.4.1 is the next release in Go's order.
{"v0.4.0", "v0.4.1-0.20261001120000-abcdef123456", false},
{"v0.3.0", "v0.4.1-0.20261001120000-abcdef123456", false},
{"v0.4.1", "v0.4.1-0.20261001120000-abcdef123456", true},
{"v0.5.0", "v0.4.1-0.20261001120000-abcdef123456", true},
// A commit after the pre-release v0.5.0-rc.1.
{"v0.4.9", "v0.5.0-rc.1.0.20261001120000-abcdef123456", false},
{"v0.5.0", "v0.5.0-rc.1.0.20261001120000-abcdef123456", true},
// No tag before the commit, a bare hash, a pre-release, nothing:
// never newer.
{"v0.1.0", "v0.0.0-20261001120000-abcdef123456", false},
{"v9.0.0", "abc1234", false}, {"v9.0.0", "v0.5.0-rc.1", false}, {"v9.0.0", "", false},
{"main", "v0.4.0", false}, {"v0.5.0-rc.1", "v0.4.0", false},
}
for _, c := range cases {
if got := Newer(c.release, c.installed); got != c.want {
t.Errorf("Newer(%q, %q) = %v, want %v", c.release, c.installed, got, c.want)
}
}
for v, want := range map[string]bool{
"v0.4.0": true, "v0.4.0-3-gabc1234": true,
"v0.4.1-0.20261001120000-abcdef123456": true,
"v0.5.0-rc.1.0.20261001120000-abcdef123456": true,
"v0.0.0-20261001120000-abcdef123456": false,
"abc1234": false, "v0.5.0-rc.1": false, "": false,
} {
if got := Comparable(v); got != want {
t.Errorf("Comparable(%q) = %v, want %v", v, got, want)
}
}
})
t.Run("NewestCompatible", func(t *testing.T) {
if _, ok := NewestCompatible(nil, "v0.6.0"); ok {
t.Error("nil list found a version")