From 07673e4f30194901bce55cc86370c72b85a841fe Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 29 Oct 2026 22:03:57 +0100 Subject: [PATCH] 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. --- .../architecture/go-modules-and-workspaces.md | 2 +- internal/build/build.go | 42 ++++++++++- internal/build/plugindirs.go | 75 +++++++++++++++++++ internal/build/plugindirs_test.go | 65 ++++++++++++++++ 4 files changed, 180 insertions(+), 4 deletions(-) diff --git a/docs/architecture/go-modules-and-workspaces.md b/docs/architecture/go-modules-and-workspaces.md index fd15e249..2f55a46c 100644 --- a/docs/architecture/go-modules-and-workspaces.md +++ b/docs/architecture/go-modules-and-workspaces.md @@ -91,7 +91,7 @@ build: - `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. +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. An application whose framework predates `party.Describe` (before v0.6.0) still builds with a newer `summer`: the build checks the framework's `party` package as `go list` resolves it in the application, and when it has no `party.Describe` it writes `main.go` and `plugins.gen.go` as before (imports and `PluginIDs` only), with a warning to move the framework requirement to v0.6.0 or newer. 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=,`, 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` and `summer plugin:install` append their entries to the `plugins:` list in place, so the rest of the file, comments and blank lines included, stays as it was. A script that runs its own `go build` must pass the same tags. diff --git a/internal/build/build.go b/internal/build/build.go index d74fb4af..b8a2eda9 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -80,7 +80,7 @@ func goBuildArgs(binRel string, tags []string) []string { } func generate(appDir string, m Manifest, meta buildMeta) error { - mainSrc, err := generateMain(m) + mainSrc, err := generateMainFor(m, !meta.Legacy) if err != nil { return err } @@ -97,7 +97,14 @@ func generate(appDir string, m Manifest, meta buildMeta) error { return nil } +// generateMain writes main.go for a framework that has party.Describe. func generateMain(m Manifest) ([]byte, error) { + return generateMainFor(m, true) +} + +// generateMainFor writes main.go; describe adds the party.Describe call, +// which a framework before v0.6.0 does not have (WR-01). +func generateMainFor(m Manifest, describe bool) ([]byte, error) { var b strings.Builder b.WriteString("// Code generated by summer build. DO NOT EDIT.\n\n") b.WriteString("package main\n\n") @@ -128,7 +135,9 @@ 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") + if describe { + 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") @@ -151,8 +160,13 @@ func generateMain(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. +// baked string goes through strconv.Quote. For a framework without +// party.Info (meta.Legacy) it writes the imports and PluginIDs only, as +// summer build did before v0.6.0. func generatePluginsGen(m Manifest, meta buildMeta) ([]byte, error) { + if meta.Legacy { + return generateLegacyPluginsGen(m) + } var b strings.Builder b.WriteString("// Code generated by summer build. DO NOT EDIT.\n\n") b.WriteString("package main\n\n") @@ -187,6 +201,28 @@ func generatePluginsGen(m Manifest, meta buildMeta) ([]byte, error) { return formatSource([]byte(b.String())) } +// generateLegacyPluginsGen is plugins.gen.go for a framework before +// party.Info: the blank plugin imports and PluginIDs. +func generateLegacyPluginsGen(m Manifest) ([]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("// 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") + 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 { diff --git a/internal/build/plugindirs.go b/internal/build/plugindirs.go index 2d0ceefe..a9da7603 100644 --- a/internal/build/plugindirs.go +++ b/internal/build/plugindirs.go @@ -6,6 +6,9 @@ import ( "encoding/json" "errors" "fmt" + "go/ast" + "go/parser" + "go/token" "os" "os/exec" "path/filepath" @@ -20,8 +23,16 @@ import ( 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 { @@ -84,6 +95,12 @@ func idOnlyMeta(m Manifest) buildMeta { // 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 { @@ -92,6 +109,64 @@ func resolveBuildMeta(ctx context.Context, appDir string, m Manifest) (buildMeta 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 diff --git a/internal/build/plugindirs_test.go b/internal/build/plugindirs_test.go index d13c7d22..d083e9b3 100644 --- a/internal/build/plugindirs_test.go +++ b/internal/build/plugindirs_test.go @@ -301,3 +301,68 @@ func TestEffectiveFramework(t *testing.T) { } }) } + +// TestLegacyFramework pins WR-01: an application whose framework has no +// party.Describe (before v0.6.0) gets the sources summer build wrote +// before, without the plugin metadata, so it still compiles. +func TestLegacyFramework(t *testing.T) { + ctx := t.Context() + app := func(t *testing.T, partySrc string) string { + t.Helper() + root := t.TempDir() + fw := filepath.Join(root, "framework") + writeAll(t, filepath.Join(fw, "go.mod"), "module "+frameworkModule+"\n\ngo 1.27.0\n") + writeAll(t, filepath.Join(fw, "modules", "party", "party.go"), partySrc) + writeAll(t, filepath.Join(fw, "modules", "party", "party_test.go"), "package party\n\nfunc Describe() {}\n\ntype Info struct{}\n") + dir := filepath.Join(root, "app") + writeAll(t, filepath.Join(dir, "go.mod"), "module example.test/app\n\ngo 1.27.0\n\nrequire "+frameworkModule+" v0.5.3\n\nreplace "+frameworkModule+" => ../framework\n") + return dir + } + m := Manifest{Module: "example.test/app", Binary: "app", Plugins: []Plugin{{ID: "acme.blog", Module: "example.test/blog"}}} + + t.Run("before party.Describe", func(t *testing.T) { + meta, warnings := resolveBuildMeta(ctx, app(t, "package party\n\nfunc Activate() {}\n"), Manifest{Module: "example.test/app", Binary: "app"}) + if !meta.Legacy || len(warnings) != 1 || !strings.Contains(warnings[0], "has no party.Describe") || !strings.Contains(warnings[0], "v0.6.0") { + t.Fatalf("meta %+v, warnings %q", meta, warnings) + } + mainSrc, err := generateMainFor(m, !meta.Legacy) + if err != nil { + t.Fatal(err) + } + gen, err := generatePluginsGen(m, meta) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(mainSrc), "party.Describe") || !strings.Contains(string(mainSrc), "party.Activate(app, PluginIDs)") { + t.Errorf("main.go:\n%s", mainSrc) + } + want := "// Code generated by summer build. DO NOT EDIT.\n\npackage main\n\nimport (\n\t_ \"example.test/blog\"\n)\n\n" + + "// PluginIDs is the ordered plugin list from summer.yaml.\nvar PluginIDs = []string{\n\t\"acme.blog\",\n}\n" + if string(gen) != want { + t.Errorf("plugins.gen.go:\n%s\nwant:\n%s", gen, want) + } + }) + + t.Run("with party.Describe", func(t *testing.T) { + src := "package party\n\ntype Info struct{ ID string }\n\nfunc Describe(any, []Info, string) error { return nil }\n" + meta, warnings := resolveBuildMeta(ctx, app(t, src), Manifest{Module: "example.test/app", Binary: "app"}) + if meta.Legacy { + t.Fatalf("meta %+v, warnings %q", meta, warnings) + } + mainSrc, err := generateMainFor(m, !meta.Legacy) + if err != nil || !strings.Contains(string(mainSrc), "party.Describe(app, PluginInfo, FrameworkVersion)") { + t.Errorf("main.go: %v\n%s", err, mainSrc) + } + }) + + t.Run("unresolvable party package keeps the current sources", func(t *testing.T) { + orig := listPackages + listPackages = func(context.Context, string, []string) ([]listedPackage, error) { + return nil, os.ErrPermission + } + t.Cleanup(func() { listPackages = orig }) + if !frameworkDescribes(ctx, t.TempDir()) { + t.Error("a failing go list switched to the legacy sources") + } + }) +}