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

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