Files
summercms/internal/build/plugindirs.go
Jakub Zych e8d3d724f5 feat(build): bake plugin metadata and the framework version into plugins.gen.go
- party.Info, party.Installed, party.Describe (additive; Plugin and Activate unchanged)
- summer build resolves plugin dirs with one go list, reads plugin.yaml, versions per D-07 (module version, git describe without --dirty, none for in-app plugins)
- build.EffectiveFramework: newest tag of a locally replaced or used framework checkout, else the go.mod require
- generated main calls party.Describe(app, PluginInfo, FrameworkVersion) before Activate; metadata failures warn and fall back to IDs
- summer new goldens and examples/hello regenerated
2026-10-29 17:12:13 +01:00

333 lines
9.8 KiB
Go

package build
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"git.golem15.com/golem15/summercms/modules/kiosk"
"git.golem15.com/golem15/summercms/modules/party"
)
// buildMeta is the plugin metadata and framework version summer build
// bakes into plugins.gen.go.
type buildMeta struct {
Plugins []party.Info
Framework string
}
// listedPackage is the part of `go list -json` output the metadata step
// reads for one plugin package.
type listedPackage struct {
ImportPath string
Dir string
Module *listedPackageModule
Error *struct{ Err string }
}
type listedPackageModule struct {
Path string
Version string
Dir string
Main bool
Replace *struct {
Path string
Version string
Dir string
}
}
// listPackages runs `go list` for the plugin packages; tests replace it.
var listPackages = goListPackages
// goListPackages resolves import paths to directories and modules with one
// `go list -e -json` in appDir, honouring the app's go.work.
func goListPackages(ctx context.Context, appDir string, paths []string) ([]listedPackage, error) {
args := append([]string{"list", "-e", "-json=ImportPath,Dir,Module,Error", "--"}, paths...)
cmd := exec.CommandContext(ctx, "go", args...)
cmd.Dir = appDir
applyGoWork(cmd, appDir)
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("go list: %w: %s", err, bytes.TrimSpace(stderr.Bytes()))
}
var pkgs []listedPackage
dec := json.NewDecoder(bytes.NewReader(out))
for dec.More() {
var p listedPackage
if err := dec.Decode(&p); err != nil {
return nil, fmt.Errorf("go list: %w", err)
}
pkgs = append(pkgs, p)
}
return pkgs, nil
}
// idOnlyMeta is the metadata of a build that knows nothing but the IDs.
func idOnlyMeta(m Manifest) buildMeta {
infos := make([]party.Info, len(m.Plugins))
for i, p := range m.Plugins {
infos[i] = party.Info{ID: p.ID}
}
return buildMeta{Plugins: infos}
}
// resolveBuildMeta resolves the plugin metadata and the effective
// framework version. It never fails: what it cannot resolve stays empty
// and comes back as a warning, so metadata never breaks a build.
func resolveBuildMeta(ctx context.Context, appDir string, m Manifest) (buildMeta, []string) {
infos, warnings := resolvePluginInfo(ctx, appDir, m)
fw, err := EffectiveFramework(ctx, appDir)
if err != nil {
warnings = append(warnings, err.Error())
}
return buildMeta{Plugins: infos, Framework: fw}, warnings
}
// resolvePluginInfo returns one party.Info per summer.yaml plugin, in
// manifest order: module and package from go list, name and description
// from the plugin's plugin.yaml, and the version per D-07. No filesystem
// path is ever stored.
func resolvePluginInfo(ctx context.Context, appDir string, m Manifest) ([]party.Info, []string) {
infos := idOnlyMeta(m).Plugins
if len(m.Plugins) == 0 {
return infos, nil
}
paths := make([]string, len(m.Plugins))
for i, p := range m.Plugins {
paths[i] = p.Module
}
pkgs, err := listPackages(ctx, appDir, paths)
if err != nil {
return infos, []string{fmt.Sprintf("plugin metadata: %v; PluginInfo carries plugin IDs only", err)}
}
byPath := make(map[string]listedPackage, len(pkgs))
for _, p := range pkgs {
byPath[p.ImportPath] = p
}
var warnings []string
for i, p := range m.Plugins {
pkg, ok := byPath[p.Module]
if !ok || pkg.Dir == "" {
reason := "package not found"
if ok && pkg.Error != nil {
reason = pkg.Error.Err
}
warnings = append(warnings, fmt.Sprintf("plugin metadata: %s: %s", p.ID, reason))
continue
}
info := &infos[i]
info.Package = pkg.ImportPath
if pkg.Module != nil {
info.Module = pkg.Module.Path
}
if w := readPluginYAML(pkg.Dir, info); w != "" {
warnings = append(warnings, fmt.Sprintf("plugin metadata: %s: %s", p.ID, w))
}
pluginVersion(ctx, pkg, info)
}
return infos, warnings
}
// readPluginYAML copies name and description from dir/plugin.yaml into
// info. It returns a warning when the file exists but cannot be used.
func readPluginYAML(dir string, info *party.Info) string {
raw, err := os.ReadFile(filepath.Join(dir, kiosk.FileName))
if errors.Is(err, os.ErrNotExist) {
return ""
}
if err != nil {
return fmt.Sprintf("%s: %v; ignored", kiosk.FileName, err)
}
pm, err := kiosk.Parse(raw)
if err != nil {
return fmt.Sprintf("%s: %v; ignored", kiosk.FileName, err)
}
if pm.ID != info.ID {
return fmt.Sprintf("%s describes %s, not %s; ignored", kiosk.FileName, pm.ID, info.ID)
}
info.Name, info.Description = pm.Name, pm.Description
if info.Module == "" {
info.Module = pm.Module
}
return ""
}
// pluginVersion sets info.Source and info.Version (D-07): the module
// version for a plugin from the module cache, git describe for a plugin
// directory that is its own repository, nothing for an in-app plugin.
func pluginVersion(ctx context.Context, pkg listedPackage, info *party.Info) {
mod := pkg.Module
switch {
case mod == nil:
info.Source = party.SourceLocal
case mod.Replace != nil && mod.Replace.Version != "":
info.Source, info.Version = party.SourceModule, mod.Replace.Version
case !mod.Main && mod.Replace == nil:
info.Source, info.Version = party.SourceModule, mod.Version
default:
root := mod.Dir
if root == "" {
root = pkg.Dir
}
if isOwnRepository(ctx, root) {
info.Source = party.SourceSubmodule
info.Version = gitDescribe(ctx, root)
} else {
info.Source = party.SourceLocal
}
}
}
// isOwnRepository reports whether dir is the top level of a git work tree
// (its own repository or a submodule), not a directory inside the app's.
func isOwnRepository(ctx context.Context, dir string) bool {
out, err := gitOutput(ctx, dir, "rev-parse", "--show-toplevel")
if err != nil {
return false
}
return samePath(strings.TrimSpace(string(out)), dir)
}
// gitDescribe is `git describe --tags --match v[0-9]* --always` in dir,
// without --dirty: an uncommitted change in a plugin must not rewrite the
// app's tracked plugins.gen.go (research Pitfall 6).
func gitDescribe(ctx context.Context, dir string) string {
out, err := gitOutput(ctx, dir, "describe", "--tags", "--match", "v[0-9]*", "--always")
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func gitOutput(ctx context.Context, dir string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return nil, errors.New(msg)
}
return out, nil
}
func samePath(a, b string) bool {
ra, err := filepath.EvalSymlinks(a)
if err != nil {
ra = a
}
rb, err := filepath.EvalSymlinks(b)
if err != nil {
rb = b
}
return filepath.Clean(ra) == filepath.Clean(rb)
}
// goModJSON is the part of `go mod edit -json` and `go work edit -json`
// output EffectiveFramework reads.
type goModJSON struct {
Require []struct {
Path string
Version string
}
Replace []struct {
Old struct{ Path string }
New struct{ Path, Version string }
}
Use []struct {
DiskPath string
}
}
func editJSON(ctx context.Context, dir string, args ...string) (goModJSON, error) {
cmd := exec.CommandContext(ctx, "go", args...)
cmd.Dir = dir
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return goModJSON{}, fmt.Errorf("go %s: %w: %s", strings.Join(args, " "), err, bytes.TrimSpace(stderr.Bytes()))
}
var j goModJSON
if err := json.Unmarshal(out, &j); err != nil {
return goModJSON{}, fmt.Errorf("go %s: %w", strings.Join(args, " "), err)
}
return j, nil
}
// EffectiveFramework returns the framework version an app in appDir builds
// against (research F3). When go.work or go.mod replaces the framework with
// a local directory, or go.work uses one, it is the newest tag reachable
// from that checkout (`git describe --tags --abbrev=0`); a replace with a
// module version gives that version; otherwise it is the go.mod require.
// When git fails it returns the require version together with a non-nil
// error the caller prints as a warning.
func EffectiveFramework(ctx context.Context, appDir string) (string, error) {
gomod, err := editJSON(ctx, appDir, "mod", "edit", "-json", filepath.Join(appDir, "go.mod"))
if err != nil {
return "", fmt.Errorf("framework version: %w", err)
}
required := ""
for _, r := range gomod.Require {
if r.Path == frameworkModule {
required = r.Version
}
}
localDir, version := "", ""
for _, r := range gomod.Replace {
if r.Old.Path == frameworkModule {
localDir, version = r.New.Path, r.New.Version
if version == "" && !filepath.IsAbs(localDir) {
localDir = filepath.Join(appDir, localDir)
}
}
}
if gw := findGoWork(appDir); gw != "" {
work, err := editJSON(ctx, appDir, "work", "edit", "-json", gw)
if err != nil {
return required, fmt.Errorf("framework version: %w; using the go.mod require %s", err, required)
}
base := filepath.Dir(gw)
for _, u := range work.Use {
dir := u.DiskPath
if !filepath.IsAbs(dir) {
dir = filepath.Join(base, dir)
}
if mod, err := readModulePath(filepath.Join(dir, "go.mod")); err == nil && mod == frameworkModule {
localDir, version = dir, ""
}
}
for _, r := range work.Replace {
if r.Old.Path == frameworkModule {
localDir, version = r.New.Path, r.New.Version
if version == "" && !filepath.IsAbs(localDir) {
localDir = filepath.Join(base, localDir)
}
}
}
}
switch {
case version != "":
return version, nil
case localDir == "":
return required, nil
}
out, err := gitOutput(ctx, localDir, "describe", "--tags", "--abbrev=0", "--match", "v[0-9]*")
if err != nil {
return required, fmt.Errorf("framework version: git describe in %s: %v; using the go.mod require %s", localDir, err, required)
}
return strings.TrimSpace(string(out)), nil
}