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

@@ -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")