Files
summercms/internal/build/update.go
Jakub Zych d024ab4bb8 feat(build): summer plugin:update
- module installs move to the newest compatible registry release with
  one go get; submodules fetch tags, check out the release tag and
  require it in go.mod; go.work overrides and version pins are refreshed
- unlisted submodules (git URL installs) update from their own tags and
  plugin.yaml; --offline restricts to that
- without an ID every plugin that resolves is updated; in-app, unlisted
  and incompatible ones are reported; failure only when a resolved
  update cannot be carried out
- same prompt rule as plugin:install
2026-10-29 18:46:14 +01:00

516 lines
15 KiB
Go

package build
import (
"context"
"errors"
"fmt"
"io"
"path/filepath"
"strings"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/kiosk"
)
// sourceLocal marks a plugin that is part of the application's own
// repository: it has no release to update.
const sourceLocal = "local"
// UpdateOptions are the inputs of summer plugin:update.
type UpdateOptions struct {
// StartDir is a directory inside the application.
StartDir string
// ID names one plugin; empty updates every plugin that resolves.
ID string
// Yes skips the confirmation prompt.
Yes bool
// Offline makes no registry call: only submodule installs are
// updated, from their own tags.
Offline bool
// Interactive reports that Confirm can ask a person.
Interactive bool
// Registry answers release lookups; nil when registry calls are
// disabled.
Registry Registry
// Confirm asks the question and returns the answer.
Confirm func(question string) (bool, error)
// Out receives the plan and progress lines; nil discards them.
Out io.Writer
}
// pluginSource is how a summer.yaml plugin is installed.
type pluginSource struct {
// Kind is ModeModule, ModeSubmodule or sourceLocal.
Kind string
// 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 string
// Dir is the module root of a submodule or local plugin.
Dir string
}
// installedSource tells how the plugin package pkg is installed, from the
// app's go.mod and go.work without any network: the module that provides
// it (the longest go.mod require that prefixes it, or a go.work use
// module), whether a replace points it at a directory, and whether that
// directory is its own git repository (a submodule).
func installedSource(ctx context.Context, appDir, pkg string) (pluginSource, error) {
gomod, err := editJSON(ctx, appDir, "mod", "edit", "-json", filepath.Join(appDir, "go.mod"))
if err != nil {
return pluginSource{}, fmt.Errorf("build: %w", err)
}
var src pluginSource
for _, r := range gomod.Require {
if (pkg == r.Path || strings.HasPrefix(pkg, r.Path+"/")) && len(r.Path) > len(src.Module) {
src.Module, src.Version = r.Path, r.Version
}
}
var work goModJSON
workDir := ""
if gw := findGoWork(appDir); gw != "" {
if w, err := editJSON(ctx, appDir, "work", "edit", "-json", gw); err == nil {
work, workDir = w, filepath.Dir(gw)
}
}
if src.Module == "" {
// A module only go.work uses, or a package of the app itself.
for _, u := range work.Use {
dir := u.DiskPath
if !filepath.IsAbs(dir) {
dir = filepath.Join(workDir, dir)
}
mod, err := readModulePath(filepath.Join(dir, "go.mod"))
if err != nil || samePath(dir, appDir) {
continue
}
if (pkg == mod || strings.HasPrefix(pkg, mod+"/")) && len(mod) > len(src.Module) {
src.Module, src.Dir = mod, filepath.Clean(dir)
}
}
if src.Module == "" {
return pluginSource{Kind: sourceLocal, Dir: appDir}, nil
}
}
apply := func(replaces []struct {
Old struct{ Path string }
New struct{ Path, Version string }
}, base string) {
for _, r := range replaces {
if r.Old.Path != src.Module {
continue
}
if r.New.Version != "" {
src.Version, src.Dir = r.New.Version, ""
continue
}
src.Dir = r.New.Path
if !filepath.IsAbs(src.Dir) {
src.Dir = filepath.Join(base, src.Dir)
}
src.Dir = filepath.Clean(src.Dir)
}
}
apply(gomod.Replace, appDir)
apply(work.Replace, workDir)
if src.Dir == "" {
src.Kind = ModeModule
if !kiosk.ValidVersion(src.Version) {
src.Version = ""
}
return src, nil
}
if !isOwnRepository(ctx, src.Dir) {
src.Kind, src.Version = sourceLocal, ""
return src, nil
}
src.Kind = ModeSubmodule
src.Version = kiosk.BaseVersion(gitDescribe(ctx, src.Dir))
return src, nil
}
// updateStep is one plugin plugin:update moves.
type updateStep struct {
ID, Package string
Src pluginSource
To kiosk.RegistryVersion
// Newer names a release above To that needs a newer framework.
Newer string
}
// updater carries the state of one Update run.
type updater struct {
ctx context.Context
opts UpdateOptions
out io.Writer
appDir string
fw string
steps []updateStep
notes []string // "id: why it is not updated"
failed []string // IDs whose update could not be resolved or applied
reasons map[string]string
}
// Update moves installed plugins to the newest release the application's
// framework version allows (D-10): a module install with go get, a
// submodule install by checking its repository out at the release tag and
// requiring that tag in go.mod, followed by the go.work overrides. A
// listed plugin's releases come from the registry; a submodule the
// registry does not list (a git URL install) is updated from its own tags
// and their plugin.yaml. Without opts.ID every plugin in summer.yaml that
// resolves is updated; in-app plugins, unlisted module installs and
// plugins whose newer releases need a newer framework are reported and
// skipped. The plan is printed and confirmed like plugin:install. The run
// fails when an update that resolved could not be carried out, or when
// the one plugin asked for cannot be updated.
func Update(ctx context.Context, opts UpdateOptions) error {
appDir, err := findAppDir(opts.StartDir)
if err != nil {
return err
}
m, err := LoadManifest(filepath.Join(appDir, manifestName))
if err != nil {
return err
}
u := &updater{ctx: ctx, opts: opts, out: opts.Out, appDir: appDir, reasons: map[string]string{}}
if u.out == nil {
u.out = io.Discard
}
fw, err := EffectiveFramework(ctx, appDir)
if err != nil {
if fw == "" {
return err
}
fmt.Fprintf(u.out, "warning: %v\n", err)
}
u.fw = fw
targets := m.Plugins
if opts.ID != "" {
targets = nil
for _, p := range m.Plugins {
if p.ID == opts.ID {
targets = []Plugin{p}
}
}
if targets == nil {
return bonfire.WithHint(fmt.Errorf("build: %s is not in summer.yaml", opts.ID),
"plugin:update moves plugins the application already has; add a new one with plugin:install.",
"{bin} plugin:install "+opts.ID)
}
}
installed := map[string]pluginSource{}
seenDir := map[string]string{}
for _, p := range targets {
src, err := installedSource(ctx, appDir, p.Module)
if err != nil {
u.fail(p.ID, err.Error())
continue
}
installed[p.ID] = src
switch src.Kind {
case sourceLocal:
u.note(p.ID, "part of the application's repository; it has no releases")
case ModeModule:
u.planModule(p, src)
case ModeSubmodule:
if first, ok := seenDir[src.Dir]; ok {
u.note(p.ID, "ships in the same repository as "+first+" and moves with it")
continue
}
seenDir[src.Dir] = p.ID
u.planSubmodule(p, src)
}
}
u.checkRequires(m, installed)
u.report()
if opts.ID != "" && len(u.steps) == 0 {
if why, ok := u.reasons[opts.ID]; ok && len(u.failed) > 0 {
return fmt.Errorf("build: cannot update %s: %s", opts.ID, why)
}
return nil
}
if len(u.steps) == 0 {
return u.failure()
}
n := len(u.steps)
noun := "plugin"
if n != 1 {
noun = "plugins"
}
if err := askToProceed(opts.Yes, opts.Interactive, opts.Confirm, fmt.Sprintf("Update %d %s?", n, noun)); err != nil {
return err
}
if err := u.apply(); err != nil {
return err
}
return u.failure()
}
func (u *updater) note(id, why string) {
u.notes = append(u.notes, id+": "+why)
u.reasons[id] = why
}
func (u *updater) fail(id, why string) {
u.note(id, "failed: "+why)
u.failed = append(u.failed, id)
}
func (u *updater) failure() error {
if len(u.failed) == 0 {
return nil
}
return fmt.Errorf("build: plugin:update could not update %s", strings.Join(u.failed, ", "))
}
// registryReason says why the registry cannot be asked, or "".
func (u *updater) registryReason() string {
switch {
case u.opts.Offline:
return "--offline makes no registry calls"
case u.opts.Registry == nil:
return "the plugin registry is disabled (plugins.registry_url is empty)"
}
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.
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 {
newer = append(newer, v)
}
}
if best, ok := kiosk.NewestCompatible(newer, u.fw); ok {
if n := newestOf(newer); kiosk.CompareVersions(n.Version, best.Version) > 0 {
return best, fmt.Sprintf("%s needs framework %s", n.Version, n.Framework)
}
return best, ""
}
if len(newer) > 0 {
n := newestOf(newer)
return kiosk.RegistryVersion{}, fmt.Sprintf("stays at %s; %s needs framework %s (this application builds against %s)", displayRef(have), n.Version, n.Framework, displayVersion(u.fw))
}
return kiosk.RegistryVersion{}, "up to date at " + displayRef(have)
}
// planModule plans a module install's update from the registry.
func (u *updater) planModule(p Plugin, src pluginSource) {
if why := u.registryReason(); why != "" {
u.note(p.ID, "installed as a Go module, and "+why)
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")
return
}
if err != nil {
u.fail(p.ID, err.Error())
return
}
var same []kiosk.RegistryVersion
for _, v := range versions {
if v.Module == src.Module {
same = append(same, v)
}
}
to, why := u.pick(same, src.Version)
if to.Version == "" {
u.note(p.ID, why)
return
}
step := InstallStep{ID: p.ID, Version: to.Version, Module: to.Module, Package: to.Package, Mode: ModeModule}
if err := validateStep(step); err != nil {
u.fail(p.ID, err.Error())
return
}
u.steps = append(u.steps, updateStep{ID: p.ID, Package: p.Module, Src: src, To: to, Newer: why})
}
// planSubmodule plans a submodule install's update: the registry's
// releases when it lists the plugin, otherwise the repository's own tags.
func (u *updater) planSubmodule(p Plugin, src pluginSource) {
if _, err := runCLIGit(u.ctx, src.Dir, u.opts.Interactive, "fetch", "--quiet", "--tags"); err != nil {
u.fail(p.ID, err.Error())
return
}
rel := strings.TrimPrefix(strings.TrimPrefix(p.Module, src.Module), "/")
var to kiosk.RegistryVersion
why := ""
fromRegistry := false
if u.registryReason() == "" {
versions, err := u.opts.Registry.Versions(u.ctx, p.ID)
switch {
case errors.Is(err, kiosk.ErrNotFound):
case err != nil:
u.fail(p.ID, err.Error())
return
default:
fromRegistry = true
var same []kiosk.RegistryVersion
for _, v := range versions {
if v.Module == src.Module {
same = append(same, v)
}
}
to, why = u.pick(same, src.Version)
}
}
if !fromRegistry {
tags, err := releaseTags(u.ctx, src.Dir)
if err != nil {
u.fail(p.ID, err.Error())
return
}
var own []kiosk.RegistryVersion
for _, tag := range tags {
if src.Version != "" && kiosk.CompareVersions(tag, src.Version) <= 0 {
continue
}
pm, err := manifestAt(u.ctx, src.Dir, tag, rel)
if err != nil || pm == nil || pm.ID != p.ID {
continue
}
own = append(own, kiosk.RegistryVersion{Version: tag, Framework: pm.Framework, Module: pm.Module, Package: pm.Package, Requires: pm.Requires})
}
to, why = u.pick(own, src.Version)
}
if to.Version == "" {
u.note(p.ID, why)
return
}
if _, err := runCLIGit(u.ctx, src.Dir, false, "rev-parse", "--verify", "--quiet", "refs/tags/"+to.Version+"^{commit}"); err != nil {
u.fail(p.ID, fmt.Sprintf("the registry lists %s, but its repository has no tag %s", to.Version, to.Version))
return
}
pm, err := manifestAt(u.ctx, src.Dir, to.Version, rel)
if err != nil {
u.fail(p.ID, err.Error())
return
}
if pm != nil && (pm.ID != p.ID || pm.Module != src.Module) {
u.fail(p.ID, fmt.Sprintf("%s at %s describes %s (%s); refused", kiosk.FileName, to.Version, pm.ID, pm.Module))
return
}
if pm != nil && to.Requires == nil {
to.Requires = pm.Requires
}
u.steps = append(u.steps, updateStep{ID: p.ID, Package: p.Module, Src: src, To: to, Newer: why})
}
// checkRequires drops planned updates whose new release requires a plugin
// the application lacks, or at a version it will not have after this run.
func (u *updater) checkRequires(m Manifest, installed map[string]pluginSource) {
final := map[string]string{}
listed := map[string]string{}
for _, p := range m.Plugins {
listed[p.ID] = p.Module
}
for id, src := range installed {
final[id] = src.Version
}
for _, s := range u.steps {
final[s.ID] = s.To.Version
}
kept := u.steps[:0]
for _, s := range u.steps {
why := ""
for _, dep := range sortedKeys(s.To.Requires) {
min := s.To.Requires[dep]
if _, ok := listed[dep]; !ok {
why = fmt.Sprintf("%s requires %s, which is not installed; run summer plugin:install %s first", s.To.Version, dep, dep)
break
}
have, known := final[dep]
if !known {
src, err := installedSource(u.ctx, u.appDir, listed[dep])
if err == nil {
have = src.Version
}
}
if have != "" && !kiosk.Satisfies(have, min) {
why = fmt.Sprintf("%s needs %s %s or newer, and the application has %s", s.To.Version, dep, min, have)
break
}
}
if why != "" {
u.note(s.ID, "stays at "+displayRef(s.Src.Version)+"; "+why)
continue
}
kept = append(kept, s)
}
u.steps = kept
}
// report prints the planned updates and the plugins left as they are.
func (u *updater) report() {
if n := len(u.steps); n > 0 {
noun := "plugin"
if n != 1 {
noun = "plugins"
}
fmt.Fprintf(u.out, "Updating %d %s in %s (framework %s):\n", n, noun, u.appDir, displayVersion(u.fw))
for _, s := range u.steps {
line := fmt.Sprintf(" %s %s -> %s (%s)", s.ID, displayRef(s.Src.Version), s.To.Version, s.Src.Kind)
if s.Newer != "" {
line += "; " + s.Newer
}
fmt.Fprintln(u.out, line)
}
} else {
fmt.Fprintln(u.out, "Nothing to update.")
}
if len(u.notes) > 0 {
fmt.Fprintln(u.out, "Not updated:")
for _, n := range u.notes {
fmt.Fprintf(u.out, " %s\n", n)
}
}
}
// apply checks submodules out at their new tags, requires the tags in
// go.mod, runs one go get for the module updates and refreshes the
// go.work overrides.
func (u *updater) apply() error {
var getArgs []string
for _, s := range u.steps {
switch s.Src.Kind {
case ModeModule:
getArgs = append(getArgs, s.Src.Module+"@"+s.To.Version)
case ModeSubmodule:
if err := checkout(u.ctx, s.Src.Dir, s.To.Version); err != nil {
return err
}
if err := runGo(u.ctx, u.appDir, "mod", "edit", "-require="+s.Src.Module+"@"+s.To.Version); err != nil {
return err
}
fmt.Fprintf(u.out, "Checked out %s at %s\n", s.ID, s.To.Version)
}
}
if len(getArgs) > 0 {
fmt.Fprintf(u.out, "go get %s\n", strings.Join(getArgs, " "))
if err := runGo(u.ctx, u.appDir, append([]string{"get", "--"}, getArgs...)...); err != nil {
return err
}
}
if err := refreshWorkOverrides(u.ctx, u.appDir, u.out); err != nil {
return err
}
fmt.Fprintln(u.out, "Updated; run summer build")
return nil
}