Files
summercms/internal/build/plugindirs.go
Jakub Zych 07673e4f30 fix(34): WR-01 keep summer build working for frameworks before party.Describe
summer build always emitted party.Describe and []party.Info, so an
application whose framework requirement predates v0.6.0 stopped compiling
once the CLI was updated. The build now checks the party package go list
resolves in the application (a static look for Describe and Info, not a
version compare, since a local checkout reports the previous tag until
it is tagged) and, when they are missing, writes main.go and
plugins.gen.go exactly as before, with a warning to move the framework
to v0.6.0. An unresolvable party package keeps the current sources.
2026-10-29 22:03:57 +01:00

413 lines
13 KiB
Go

package build
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"go/ast"
"go/parser"
"go/token"
"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
// Legacy is set when the framework the application builds against has
// no party.Describe (before v0.6.0): the generated sources then leave
// the metadata out, as they did before, so the application still
// compiles (WR-01).
Legacy bool
}
// partyPackage is the framework package that holds party.Describe.
const partyPackage = frameworkModule + "/modules/party"
// 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) {
if !frameworkDescribes(ctx, appDir) {
fw, _ := EffectiveFramework(ctx, appDir)
return buildMeta{Framework: fw, Legacy: true}, []string{fmt.Sprintf(
"framework %s has no party.Describe (added in v0.6.0); the generated sources carry no plugin metadata, so the admin's Plugins screen lists plugin IDs only. Update the framework requirement to v0.6.0 or newer to get it.",
displayVersion(fw))}
}
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
}
// frameworkDescribes reports whether the framework the application builds
// against provides party.Describe and party.Info: the party package that
// go list resolves in appDir (go.mod, its replaces and go.work alike)
// declares both. The check is static, not by version, because a local
// framework checkout reports the previous tag until it is tagged. When
// the package cannot be resolved it answers true: the sources stay the
// current ones and go build reports the real problem.
func frameworkDescribes(ctx context.Context, appDir string) bool {
pkgs, err := listPackages(ctx, appDir, []string{partyPackage})
if err != nil {
return true
}
for _, p := range pkgs {
if p.ImportPath != partyPackage || p.Dir == "" || p.Error != nil {
continue
}
return declaresDescribe(p.Dir)
}
return true
}
// declaresDescribe reports whether the Go package in dir declares the
// function Describe and the type Info; a directory it cannot read
// answers true.
func declaresDescribe(dir string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return true
}
describe, info := false, false
fset := token.NewFileSet()
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
f, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, parser.SkipObjectResolution)
if err != nil {
continue
}
for _, d := range f.Decls {
switch d := d.(type) {
case *ast.FuncDecl:
if d.Recv == nil && d.Name.Name == "Describe" {
describe = true
}
case *ast.GenDecl:
for _, spec := range d.Specs {
if ts, ok := spec.(*ast.TypeSpec); ok && ts.Name.Name == "Info" {
info = true
}
}
}
}
}
return describe && info
}
// 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, appDir, 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. A
// package of the application's own module is in-app even though the
// application directory is the top level of a repository.
func pluginVersion(ctx context.Context, appDir string, 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 abs, err := filepath.Abs(appDir); err == nil {
appDir = abs
}
if !samePath(root, appDir) && 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
}