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
This commit is contained in:
Jakub Zych
2026-10-29 17:12:13 +01:00
parent 4916ee3d43
commit e8d3d724f5
15 changed files with 770 additions and 15 deletions

View File

@@ -85,6 +85,10 @@ files: every build rewrites them.`,
if line == "" {
continue
}
if rest, ok := strings.CutPrefix(line, "warning: "); ok {
out.Warning(strings.TrimSuffix(rest, "\n"))
continue
}
if rest, ok := strings.CutPrefix(line, "built "); ok && err == nil {
out.Success("Built bin/" + strings.TrimSuffix(rest, "\n"))
continue

View File

@@ -86,6 +86,13 @@ build:
`summer build` turns this list into `plugins.gen.go`. The order is the manifest order, adjusted so that each plugin comes after the plugins it requires. Plugins with no dependency between them keep the order you wrote.
`plugins.gen.go` also carries what the build knew about each plugin and the framework:
- `PluginInfo`, one `party.Info` per plugin in manifest order: the ID, the Go module and package, the name and description from the plugin's `plugin.yaml` (see [Publishing plugins](../plugins/marketplace.md)), the version and the source. A plugin from the module cache (`go get`) reports its module version and the source `module`. A plugin directory that is its own git repository, such as a submodule, reports `git describe --tags` output like `v0.3.1-4-gabc1234` and the source `submodule`; an uncommitted change does not alter it. A plugin inside the application's own repository has no version and the source `local`. A plugin without `plugin.yaml` has no name or description.
- `FrameworkVersion`, the framework version the application was built against. When `go.mod` or `go.work` points the framework at a local checkout, it is the newest tag reachable in that checkout; otherwise it is the version `go.mod` requires.
The generated `main` publishes both with `party.Describe` before activating the plugins, so the admin can show the installed plugins with `app.Lookup[party.Installed]()`. When the metadata cannot be resolved, `summer build` prints a warning and bakes the IDs only; it never fails a build. A submodule bump or a new framework tag therefore changes `plugins.gen.go`: commit the regenerated file with the bump.
`build.tags` lists Go build tags that `summer build` (and `summer dev`) passes to `go build` as `-tags=<tag>,<tag>`, printing them as `build tags: …`. Each tag must consist of lower-case letters, digits and underscores; a duplicate is ignored. Framework features that pull in large dependencies sit behind a tag, such as `summercms_s3` for S3 uploads buckets (see [Storage](../services/storage.md#s3-and-s3-compatible-buckets)). `summer plugin:add` keeps the list when it rewrites the manifest. A script that runs its own `go build` must pass the same tags.
## Local plugins in a workspace

View File

@@ -88,6 +88,10 @@ func TestPluginManifest(t *testing.T) {
`kiosk.CheckT` runs the same check as `summer plugin:manifest --check` and names the command to rerun. A nested plugin gets its own test in its own directory.
## Installed plugin metadata
`summer build` reads the `plugin.yaml` of every plugin the application lists and bakes the name and description into the generated `plugins.gen.go` as `PluginInfo`, next to each plugin's version and the framework version (`FrameworkVersion`). At start-up the application publishes them as `party.Installed`, which the admin's Plugins screen reads. [Go modules and workspaces](../architecture/go-modules-and-workspaces.md#the-summeryaml-manifest) explains where the versions come from.
## Registry API v1
summercms.io publishes the plugins it lists through a read-only JSON API. The marketplace pages, `summer` and the admin's Plugins screen read it. Its base path is `/api/registry/v1` (`kiosk.RegistryAPIPath`), and the `kiosk` module holds its wire types, so every client and the server share one definition of the shape.

View File

@@ -34,6 +34,9 @@ func run(args []string, out io.Writer) int {
return bonfire.Fail(os.Stderr, "hello", err, g)
}
app := backpack.New(cfg)
if err := party.Describe(app, PluginInfo, FrameworkVersion); err != nil {
return bonfire.Fail(os.Stderr, "hello", err, g)
}
plugins, err := party.Activate(app, PluginIDs)
if err != nil {
return bonfire.Fail(os.Stderr, "hello", err, g)

View File

@@ -6,6 +6,8 @@ import (
_ "git.golem15.com/golem15/summercms/examples/hello/plugins/base"
_ "git.golem15.com/golem15/summercms/examples/hello/plugins/greeter"
_ "git.golem15.com/golem15/summercms/examples/hello/plugins/optional"
"git.golem15.com/golem15/summercms/modules/party"
)
// PluginIDs is the ordered plugin list from summer.yaml.
@@ -14,3 +16,14 @@ var PluginIDs = []string{
"golem15.greeter",
"golem15.optional",
}
// PluginInfo is each plugin's metadata in summer.yaml order: name and
// description from its plugin.yaml, version and source from summer build.
var PluginInfo = []party.Info{
{ID: "golem15.hello", Module: "git.golem15.com/golem15/summercms/examples/hello/plugins/base", Package: "git.golem15.com/golem15/summercms/examples/hello/plugins/base", Source: party.SourceLocal},
{ID: "golem15.greeter", Module: "git.golem15.com/golem15/summercms/examples/hello/plugins/greeter", Package: "git.golem15.com/golem15/summercms/examples/hello/plugins/greeter", Source: party.SourceLocal},
{ID: "golem15.optional", Module: "git.golem15.com/golem15/summercms/examples/hello/plugins/optional", Package: "git.golem15.com/golem15/summercms/examples/hello/plugins/optional", Source: party.SourceLocal},
}
// FrameworkVersion is the framework version the app was built against.
const FrameworkVersion = "v0.5.3"

View File

@@ -13,6 +13,8 @@ import (
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/party"
)
// App generates app sources from summer.yaml and runs go build in appDir.
@@ -34,7 +36,11 @@ func App(ctx context.Context, appDir string, out io.Writer) error {
if err != nil {
return err
}
if err := generate(appDir, m); err != nil {
meta, warnings := resolveBuildMeta(ctx, appDir, m)
for _, w := range warnings {
fmt.Fprintf(out, "warning: %s\n", w)
}
if err := generate(appDir, m, meta); err != nil {
return err
}
if err := checkModelsLeaf(appDir, m); err != nil {
@@ -73,12 +79,12 @@ func goBuildArgs(binRel string, tags []string) []string {
return append(args, "-o", binRel, ".")
}
func generate(appDir string, m Manifest) error {
func generate(appDir string, m Manifest, meta buildMeta) error {
mainSrc, err := generateMain(m)
if err != nil {
return err
}
pluginsSrc, err := generatePluginsGen(m)
pluginsSrc, err := generatePluginsGen(m, meta)
if err != nil {
return err
}
@@ -122,6 +128,7 @@ func generateMain(m Manifest) ([]byte, error) {
b.WriteString("\tif err != nil {\n" + fail + "\t}\n")
b.WriteString("\tif err := diary.Setup(cfg, diary.FromGlobals(g)); err != nil {\n" + fail + "\t}\n")
b.WriteString("\tapp := backpack.New(cfg)\n")
b.WriteString("\tif err := party.Describe(app, PluginInfo, FrameworkVersion); err != nil {\n" + fail + "\t}\n")
b.WriteString("\tplugins, err := party.Activate(app, PluginIDs)\n")
b.WriteString("\tif err != nil {\n" + fail + "\t}\n")
b.WriteString("\tcommands := lagoon.RuntimeCommands(app, plugins)\n")
@@ -142,26 +149,72 @@ func generateMain(m Manifest) ([]byte, error) {
return formatSource([]byte(b.String()))
}
func generatePluginsGen(m Manifest) ([]byte, error) {
// generatePluginsGen writes plugins.gen.go: the blank plugin imports, the
// ordered PluginIDs, the PluginInfo metadata and FrameworkVersion. Every
// baked string goes through strconv.Quote.
func generatePluginsGen(m Manifest, meta buildMeta) ([]byte, error) {
var b strings.Builder
b.WriteString("// Code generated by summer build. DO NOT EDIT.\n\n")
b.WriteString("package main\n\n")
if len(m.Plugins) > 0 {
b.WriteString("import (\n")
for _, p := range m.Plugins {
fmt.Fprintf(&b, "\t_ %s\n", strconv.Quote(p.Module))
}
b.WriteString(")\n\n")
b.WriteString("import (\n")
for _, p := range m.Plugins {
fmt.Fprintf(&b, "\t_ %s\n", strconv.Quote(p.Module))
}
if len(m.Plugins) > 0 {
b.WriteString("\n")
}
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/party\"\n")
b.WriteString(")\n\n")
b.WriteString("// PluginIDs is the ordered plugin list from summer.yaml.\n")
b.WriteString("var PluginIDs = []string{\n")
for _, p := range m.Plugins {
fmt.Fprintf(&b, "\t%s,\n", strconv.Quote(p.ID))
}
b.WriteString("}\n")
b.WriteString("}\n\n")
b.WriteString("// PluginInfo is each plugin's metadata in summer.yaml order: name and\n")
b.WriteString("// description from its plugin.yaml, version and source from summer build.\n")
b.WriteString("var PluginInfo = []party.Info{\n")
for i, p := range m.Plugins {
info := party.Info{ID: p.ID}
if i < len(meta.Plugins) && meta.Plugins[i].ID == p.ID {
info = meta.Plugins[i]
}
fmt.Fprintf(&b, "\t{%s},\n", infoLiteral(info))
}
b.WriteString("}\n\n")
b.WriteString("// FrameworkVersion is the framework version the app was built against.\n")
fmt.Fprintf(&b, "const FrameworkVersion = %s\n", strconv.Quote(meta.Framework))
return formatSource([]byte(b.String()))
}
// infoLiteral is the field list of a party.Info composite literal with the
// empty fields left out.
func infoLiteral(info party.Info) string {
fields := []string{"ID: " + strconv.Quote(info.ID)}
add := func(name, v string) {
if v != "" {
fields = append(fields, name+": "+strconv.Quote(v))
}
}
add("Module", info.Module)
add("Package", info.Package)
add("Name", info.Name)
add("Description", info.Description)
add("Version", info.Version)
switch info.Source {
case "":
case party.SourceModule:
fields = append(fields, "Source: party.SourceModule")
case party.SourceSubmodule:
fields = append(fields, "Source: party.SourceSubmodule")
case party.SourceLocal:
fields = append(fields, "Source: party.SourceLocal")
default:
fields = append(fields, "Source: "+strconv.Quote(info.Source))
}
return strings.Join(fields, ", ")
}
func formatSource(src []byte) ([]byte, error) {
formatted, err := format.Source(src)
if err != nil {

View File

@@ -66,11 +66,11 @@ func TestGenerateStableQuotedImportsInManifestOrder(t *testing.T) {
{ID: "golem15.greeter", Module: "example.com/app/plugins/greeter"},
},
}
first, err := generatePluginsGen(m)
first, err := generatePluginsGen(m, buildMeta{})
if err != nil {
t.Fatal(err)
}
second, err := generatePluginsGen(m)
second, err := generatePluginsGen(m, buildMeta{})
if err != nil {
t.Fatal(err)
}

View File

@@ -450,7 +450,9 @@ func populateApp(ctx context.Context, opts NewOptions, target string, files []ge
if err != nil {
return err
}
return generate(target, m)
// summer new bakes IDs only and an empty FrameworkVersion; the first
// summer build fills both in.
return generate(target, m, idOnlyMeta(m))
}
// addStarterPlugin creates golem15.<name> through the make:plugin and

View File

@@ -0,0 +1,332 @@
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
}

View File

@@ -0,0 +1,242 @@
package build
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/party"
)
// TestPluginInfoSmoke covers the build-time plugin metadata (D-06, D-07):
// generation, plugin directory resolution, versions, the effective
// framework version, the fallback and the runtime publication.
func TestPluginInfoSmoke(t *testing.T) {
m := Manifest{
Module: "example.com/app",
Binary: "app",
Plugins: []Plugin{
{ID: "acme.blog", Module: "example.com/sm-blog-plugin"},
{ID: "acme.local", Module: "example.com/app/plugins/acme/local"},
},
}
t.Run("generate", func(t *testing.T) {
meta := buildMeta{
Framework: "v0.6.0",
Plugins: []party.Info{
{ID: "acme.blog", Module: "example.com/sm-blog-plugin", Package: "example.com/sm-blog-plugin",
Name: "Blog", Description: "Posts with \"quotes\",\nnewlines and `ticks`.", Version: "v0.2.5", Source: party.SourceModule},
{ID: "acme.local", Package: "example.com/app/plugins/acme/local", Source: party.SourceLocal},
},
}
first, err := generatePluginsGen(m, meta)
if err != nil {
t.Fatal(err)
}
second, err := generatePluginsGen(m, meta)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(first, second) {
t.Fatal("plugins.gen.go is not byte-stable")
}
src := string(first)
for _, want := range []string{
`"git.golem15.com/golem15/summercms/modules/party"`,
"var PluginInfo = []party.Info{",
`{ID: "acme.blog", Module: "example.com/sm-blog-plugin", Package: "example.com/sm-blog-plugin", Name: "Blog", Description: "Posts with \"quotes\",\nnewlines and ` + "`ticks`" + `.", Version: "v0.2.5", Source: party.SourceModule},`,
`{ID: "acme.local", Package: "example.com/app/plugins/acme/local", Source: party.SourceLocal},`,
`const FrameworkVersion = "v0.6.0"`,
} {
if !strings.Contains(src, want) {
t.Errorf("plugins.gen.go lacks %s\n%s", want, src)
}
}
if strings.Index(src, `ID: "acme.blog"`) > strings.Index(src, `ID: "acme.local"`) {
t.Error("PluginInfo is not in summer.yaml order")
}
if strings.Index(src, "var PluginIDs") > strings.Index(src, "var PluginInfo") {
t.Error("PluginInfo comes before PluginIDs")
}
// Missing metadata degrades to ID-only entries.
idOnly, err := generatePluginsGen(m, buildMeta{})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(idOnly), `{ID: "acme.blog"},`) || !strings.Contains(string(idOnly), `const FrameworkVersion = ""`) {
t.Errorf("ID-only plugins.gen.go:\n%s", idOnly)
}
})
t.Run("main", func(t *testing.T) {
src, err := generateMain(m)
if err != nil {
t.Fatal(err)
}
describe := strings.Index(string(src), "if err := party.Describe(app, PluginInfo, FrameworkVersion); err != nil {")
activate := strings.Index(string(src), "party.Activate(app, PluginIDs)")
if describe < 0 || activate < 0 || describe > activate {
t.Fatalf("Describe must come before Activate:\n%s", src)
}
})
t.Run("dirs", func(t *testing.T) {
requireGit(t)
app := t.TempDir()
write(t, filepath.Join(app, "go.mod"), "module example.test/app\n\ngo 1.27.0\n\n"+
"require (\n\texample.test/acme/inapp v0.0.0\n\texample.test/acme/sub v0.0.0\n)\n\n"+
"replace example.test/acme/inapp => ./plugins/acme/inapp\n\nreplace example.test/acme/sub => ./plugins/acme/sub\n")
write(t, filepath.Join(app, "main.go"), "package main\n\nimport (\n\t_ \"example.test/acme/inapp\"\n\t_ \"example.test/acme/sub\"\n)\n\nfunc main() {}\n")
for _, name := range []string{"inapp", "sub"} {
dir := filepath.Join(app, "plugins", "acme", name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
write(t, filepath.Join(dir, "go.mod"), "module example.test/acme/"+name+"\n\ngo 1.27.0\n")
write(t, filepath.Join(dir, "plugin.go"), "package "+name+"\n")
}
inapp := filepath.Join(app, "plugins", "acme", "inapp")
sub := filepath.Join(app, "plugins", "acme", "sub")
write(t, filepath.Join(inapp, "plugin.yaml"), "schema: [not, a, manifest\n")
write(t, filepath.Join(sub, "plugin.yaml"), "# Generated by summer plugin:manifest. Do not edit; rerun the command.\n"+
"schema: 1\nid: acme.sub\nmodule: example.test/acme/sub\npackage: example.test/acme/sub\n"+
"name: Sub\ndescription: A plugin in its own repository.\nframework: v0.6.0\n")
runTestGit(t, app, "init", "-q")
runTestGit(t, sub, "init", "-q")
runTestGit(t, sub, "add", ".")
runTestGit(t, sub, "commit", "-q", "-m", "first")
runTestGit(t, sub, "tag", "v0.1.0")
write(t, filepath.Join(sub, "extra.go"), "package sub\n")
runTestGit(t, sub, "add", ".")
runTestGit(t, sub, "commit", "-q", "-m", "second")
// Uncommitted change: the version must not carry -dirty.
write(t, filepath.Join(sub, "plugin.go"), "package sub\n\n// changed\n")
manifest := Manifest{Module: "example.test/app", Binary: "app", Plugins: []Plugin{
{ID: "acme.inapp", Module: "example.test/acme/inapp"},
{ID: "acme.sub", Module: "example.test/acme/sub"},
}}
infos, warnings := resolvePluginInfo(t.Context(), app, manifest)
if len(infos) != 2 {
t.Fatalf("infos = %+v", infos)
}
in, sb := infos[0], infos[1]
if in.ID != "acme.inapp" || in.Source != party.SourceLocal || in.Version != "" || in.Name != "" ||
in.Module != "example.test/acme/inapp" || in.Package != "example.test/acme/inapp" {
t.Errorf("in-app plugin = %+v", in)
}
if sb.ID != "acme.sub" || sb.Source != party.SourceSubmodule || !strings.HasPrefix(sb.Version, "v0.1.0-1-g") ||
strings.Contains(sb.Version, "dirty") || sb.Name != "Sub" || sb.Description != "A plugin in its own repository." {
t.Errorf("own-repository plugin = %+v", sb)
}
if len(warnings) != 1 || !strings.Contains(warnings[0], "plugin.yaml") {
t.Errorf("warnings = %q, want one about the malformed plugin.yaml", warnings)
}
for _, info := range infos {
for _, s := range []string{info.Module, info.Package, info.Version, info.Name, info.Description} {
if strings.Contains(s, app) {
t.Errorf("a filesystem path leaked into %+v", info)
}
}
}
})
t.Run("framework", func(t *testing.T) {
requireGit(t)
ctx := t.Context()
fw := t.TempDir()
write(t, filepath.Join(fw, "go.mod"), "module "+frameworkModule+"\n\ngo 1.27.0\n")
runTestGit(t, fw, "init", "-q")
runTestGit(t, fw, "add", ".")
runTestGit(t, fw, "commit", "-q", "-m", "one")
runTestGit(t, fw, "tag", "v0.5.3")
write(t, filepath.Join(fw, "a.go"), "package summercms\n")
runTestGit(t, fw, "add", ".")
runTestGit(t, fw, "commit", "-q", "-m", "two")
runTestGit(t, fw, "tag", "v0.6.0")
write(t, filepath.Join(fw, "b.go"), "package summercms\n")
runTestGit(t, fw, "add", ".")
runTestGit(t, fw, "commit", "-q", "-m", "three")
replaced := t.TempDir()
write(t, filepath.Join(replaced, "go.mod"), "module example.test/app\n\ngo 1.27.0\n\nrequire "+frameworkModule+" v0.0.0\n\nreplace "+frameworkModule+" => "+fw+"\n")
if v, err := EffectiveFramework(ctx, replaced); err != nil || v != "v0.6.0" {
t.Errorf("replaced framework = %q, %v; want v0.6.0", v, err)
}
required := t.TempDir()
write(t, filepath.Join(required, "go.mod"), "module example.test/app\n\ngo 1.27.0\n\nrequire "+frameworkModule+" v0.5.3\n")
if v, err := EffectiveFramework(ctx, required); err != nil || v != "v0.5.3" {
t.Errorf("required framework = %q, %v; want v0.5.3", v, err)
}
notGit := t.TempDir()
noRepo := t.TempDir()
write(t, filepath.Join(noRepo, "go.mod"), "module "+frameworkModule+"\n\ngo 1.27.0\n")
write(t, filepath.Join(notGit, "go.mod"), "module example.test/app\n\ngo 1.27.0\n\nrequire "+frameworkModule+" v0.5.1\n\nreplace "+frameworkModule+" => "+noRepo+"\n")
if v, err := EffectiveFramework(ctx, notGit); err == nil || v != "v0.5.1" {
t.Errorf("framework without git = %q, %v; want v0.5.1 and a warning", v, err)
}
})
t.Run("fallback", func(t *testing.T) {
dir := copyHelloApp(t)
orig := listPackages
listPackages = func(context.Context, string, []string) ([]listedPackage, error) {
return nil, errors.New("go list failed")
}
t.Cleanup(func() { listPackages = orig })
var out bytes.Buffer
if err := App(t.Context(), dir, &out); err != nil {
t.Fatalf("build with failing metadata: %v\n%s", err, out.String())
}
if n := strings.Count(out.String(), "warning:"); n != 1 {
t.Errorf("warnings = %d, want 1:\n%s", n, out.String())
}
gen, err := os.ReadFile(filepath.Join(dir, "plugins.gen.go"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(gen), `{ID: "golem15.hello"},`) || !strings.Contains(string(gen), "const FrameworkVersion = ") {
t.Errorf("plugins.gen.go after a metadata failure:\n%s", gen)
}
})
t.Run("describe", func(t *testing.T) {
app := backpack.New(&compass.Config{})
plugins := []party.Info{{ID: "acme.blog", Name: "Blog", Version: "v0.2.5", Source: party.SourceModule}}
if err := party.Describe(app, plugins, "v0.6.0"); err != nil {
t.Fatal(err)
}
got, ok := app.Lookup[party.Installed]()
if !ok || got.Framework != "v0.6.0" || len(got.Plugins) != 1 || got.Plugins[0] != plugins[0] {
t.Fatalf("Installed = %+v, %v", got, ok)
}
if err := party.Describe(nil, plugins, "v0.6.0"); err == nil {
t.Fatal("Describe(nil app) succeeded")
}
})
}
// git runs git in dir with a test-local identity and no signing.
func runTestGit(t *testing.T, dir string, args ...string) {
t.Helper()
full := append([]string{"-c", "user.name=Test", "-c", "user.email=test@example.test",
"-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", "-c", "init.defaultBranch=master"}, args...)
cmd := exec.CommandContext(t.Context(), "git", full...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
}

View File

@@ -2,5 +2,16 @@
package main
import (
"git.golem15.com/golem15/summercms/modules/party"
)
// PluginIDs is the ordered plugin list from summer.yaml.
var PluginIDs = []string{}
// PluginInfo is each plugin's metadata in summer.yaml order: name and
// description from its plugin.yaml, version and source from summer build.
var PluginInfo = []party.Info{}
// FrameworkVersion is the framework version the app was built against.
const FrameworkVersion = ""

View File

@@ -34,6 +34,9 @@ func run(args []string, out io.Writer) int {
return bonfire.Fail(os.Stderr, "demo", err, g)
}
app := backpack.New(cfg)
if err := party.Describe(app, PluginInfo, FrameworkVersion); err != nil {
return bonfire.Fail(os.Stderr, "demo", err, g)
}
plugins, err := party.Activate(app, PluginIDs)
if err != nil {
return bonfire.Fail(os.Stderr, "demo", err, g)

View File

@@ -4,9 +4,20 @@ package main
import (
_ "git.golem15.com/golem15/sm-demo-app/plugins/golem15/demo"
"git.golem15.com/golem15/summercms/modules/party"
)
// PluginIDs is the ordered plugin list from summer.yaml.
var PluginIDs = []string{
"golem15.demo",
}
// PluginInfo is each plugin's metadata in summer.yaml order: name and
// description from its plugin.yaml, version and source from summer build.
var PluginInfo = []party.Info{
{ID: "golem15.demo"},
}
// FrameworkVersion is the framework version the app was built against.
const FrameworkVersion = ""

View File

@@ -26,6 +26,7 @@ Use cases:
- A process-wide, concurrency-safe registry filled by `party.Register` (nil plugins are ignored).
- `party.Activate` selects plugins by manifest ID and fails on an empty ID, a duplicate ID, an unregistered plugin, a missing requirement or a dependency cycle.
- Stable topological ordering: plugins without a dependency relation keep their manifest order.
- Build-time metadata: `party.Describe` publishes `party.Installed`, the plugin metadata (`party.Info`: ID, module, package, name, description, version and source) and the framework version that `summer build` baked into the application's `plugins.gen.go`. The generated `main` calls it before `party.Activate`.
- Activation sequence: records the ordered IDs on the `backpack.App`, publishes `party.Activated` (the ordered plugin list for commands that need plugin scopes at Run time), merges each `pact.HasConfig` tree into the app config under the plugin ID, runs every Register, publishes the translator ([phrasebook](../phrasebook/README.md)), the mailer ([postcard](../postcard/README.md)) and the menu item type registry ([signpost](../signpost/README.md), collected from every `pact.HasMenuItemTypes` plugin; a duplicate type key fails the activation), then registers each plugin's mail templates and runs its Boot.
## Usage
@@ -52,7 +53,7 @@ func init() {
}
```
The application activates the plugins it lists, in dependency order:
The application publishes its build-time metadata and activates the plugins it lists, in dependency order (`summer build` generates both lists):
```go
cfg, err := compass.Load("config")
@@ -60,6 +61,13 @@ if err != nil {
return err
}
app := backpack.New(cfg)
info := []party.Info{
{ID: "acme.user", Source: party.SourceLocal},
{ID: "acme.blog", Version: "v0.2.5", Source: party.SourceModule},
}
if err := party.Describe(app, info, "v0.6.0"); err != nil {
return err
}
plugins, err := party.Activate(app, []string{"acme.user", "acme.blog"})
if err != nil {
return err
@@ -73,6 +81,12 @@ if err != nil {
| `party.Plugin` | Interface every compiled plugin implements: ID, required plugin IDs, Register and Boot. |
| `party.Register` | Adds a plugin to the process-wide registry; called from the plugin's `init`. |
| `party.Activate` | Selects registered plugins by ID, orders them by `party.Plugin.Requires` and runs the config, Register, translation, mail, menu item type and Boot steps; returns the ordered plugins. |
| `party.Info` | One plugin's build-time metadata: `ID`, `Module`, `Package`, `Name`, `Description` (from its `plugin.yaml`), `Version` and `Source`. |
| `party.SourceModule` | Source of a plugin built from the Go module cache; its version is the module version. |
| `party.SourceSubmodule` | Source of a plugin directory that is its own git repository; its version is `git describe --tags` output. |
| `party.SourceLocal` | Source of a plugin inside the application repository; it has no version. |
| `party.Installed` | What the application was built with: `Framework` and `Plugins` in manifest order; look it up with `app.Lookup[party.Installed]()`. |
| `party.Describe` | Publishes `party.Installed` on the app; a nil app is an error. |
| `party.Activated` | The ordered `[]party.Plugin` published by `party.Activate`; commands look it up at Run time with `app.Lookup[party.Activated]()`. |
## Dependencies

56
modules/party/info.go Normal file
View File

@@ -0,0 +1,56 @@
package party
import (
"fmt"
"git.golem15.com/golem15/summercms/modules/backpack"
)
// Where an installed plugin's source came from at build time.
const (
// SourceModule is a plugin built from the Go module cache (go get).
SourceModule = "module"
// SourceSubmodule is a plugin directory that is its own git repository,
// such as a git submodule; its version is git describe output.
SourceSubmodule = "submodule"
// SourceLocal is a plugin directory inside the application repository;
// it has no version of its own.
SourceLocal = "local"
)
// Info is the metadata summer build bakes into plugins.gen.go for one
// plugin: its ID, Go module and package, the name and description from its
// plugin.yaml, its version and where it came from (SourceModule,
// SourceSubmodule or SourceLocal). A plugin without plugin.yaml has no
// name or description.
type Info struct {
ID string
Module string
Package string
Name string
Description string
Version string
Source string
}
// Installed is what the application was built with: the effective
// framework version and the plugin metadata in summer.yaml order. Describe
// publishes it; look it up with app.Lookup[party.Installed]().
type Installed struct {
Framework string
Plugins []Info
}
// Describe publishes the build-time plugin metadata and framework version
// as Installed. The generated main calls it before Activate.
func Describe(app *backpack.App, plugins []Info, framework string) error {
if app == nil {
return fmt.Errorf("party: app is nil")
}
list := make([]Info, len(plugins))
copy(list, plugins)
if err := app.Publish(Installed{Framework: framework, Plugins: list}); err != nil {
return fmt.Errorf("party: %w", err)
}
return nil
}