mesh-build-node: narrow the fetch to the named paths, build members against the SDK their lock names, and build, pack and publish a package (issues 1758, 1587 S15-S19; ADR 0499 §4, ADR 0498 §5, ADR 0500 §4-§6; lead decision L10)

build_node_fetch takes buildstep.FetchRequest (dir and paths narrow the workspace) and an
optional package {repository, commit, ref}: the group's package member, built and packed
shut off and published nowhere. Each member's lock decides its SDK: the group's packed
file when the version and integrity are the lock's, else the registry's (integrity
checked); a version the registry lacks fails waits for: @novox/mesh-sdk@<version>. A
package artifact is built by its own script and packed with npm pack, shut off; only a
trunk build publishes it, from this process with the write credential (never in a
workspace): absent is published, the same file is done, another is version taken:. The
registry configuration file now lies outside every workspace.
This commit is contained in:
2026-10-22 18:38:22 +02:00
parent fde561075d
commit b9cdca78b0
13 changed files with 1217 additions and 29 deletions
@@ -59,6 +59,18 @@ type FetchResult struct {
Conflicts []string `json:"conflicts,omitempty"`
Changed []string `json:"changed,omitempty"`
Trees map[string]buildstep.ModuleTree `json:"trees,omitempty"`
// Group is the group's package the fetch packed and served to the TypeScript members (the call's `package`):
// "<name>@<version> <integrity>".
Group string `json:"group,omitempty"`
}
// narrowedMark is the file beside a workspace's tree saying the fetch narrowed it to a module's named paths.
const narrowedMark = "narrowed"
// narrowed says whether the fetch narrowed the workspace whose tree this is.
func narrowed(tree string) bool {
_, err := os.Stat(filepath.Join(filepath.Dir(tree), narrowedMark))
return err == nil
}
// Refusal is a module a fetch did not install the packages of.
@@ -79,7 +91,12 @@ func (m *Module) fetchTool() StepTool {
"repository": map[string]any{"type": "string", "description": "<owner>/<repository> on the forge"},
"commit": map[string]any{"type": "string", "description": "the full commit, 40 hex digits"},
"ref": map[string]any{"type": "string", "description": "refs/pull/<n>/head when only a pull request's head carries the commit"},
"base": map[string]any{"type": "string", "description": "optional trunk commit, 40 hex digits: the head is merged onto it as the forge's merge would, and the answer carries the merge commit, the files in conflict (an answer, the workspace is then left at the trunk commit), the changed paths and the tree of each module directory"},
"dir": buildstep.FetchRequestProperties()["dir"],
"paths": buildstep.FetchRequestProperties()["paths"],
"package": map[string]any{"type": "object", "description": "optional: the group's package member (ADR 0500 §5), packed here, publishing nothing, and served to each TypeScript member whose lock names its version, in place of the registry's",
"properties": map[string]any{"repository": map[string]any{"type": "string"}, "commit": map[string]any{"type": "string"}, "ref": map[string]any{"type": "string"}},
"required": []string{"repository", "commit"}},
"base": map[string]any{"type": "string", "description": "optional trunk commit, 40 hex digits: the head is merged onto it as the forge's merge would, and the answer carries the merge commit, the files in conflict (an answer, the workspace is then left at the trunk commit), the changed paths and the tree of each module directory"},
},
"required": []string{"repository", "commit"},
},
@@ -89,15 +106,16 @@ func (m *Module) fetchTool() StepTool {
}
func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
repository, _ := args["repository"].(string)
commit, _ := args["commit"].(string)
ref, _ := args["ref"].(string)
base, _ := args["base"].(string)
if base != "" && !commitOK.MatchString(base) {
return nil, fmt.Errorf("%q is not a trunk commit (40 hex digits)", base)
// What the builder and the checker ask, in mesh-sdk's one form (ADR 0499 §4): with the module's directory and its
// named paths, the workspace is narrowed to them (narrow.go).
req, err := buildstep.ParseFetchRequest(args)
if err != nil {
return nil, err
}
if !repositoryOK.MatchString(repository) || strings.Contains(repository, "..") {
return nil, fmt.Errorf("%q is not a repository, <owner>/<name>", repository)
repository, commit, ref, base := req.Repository, req.Commit, req.Ref, req.Base
group, err := groupPackageArg(args["package"])
if err != nil {
return nil, err
}
m.CleanWorkspaces(m.now())
id := buildstep.NewID()
@@ -123,16 +141,49 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
return res, nil // a conflict: the tree is at the trunk, nothing of the head is installed
}
}
dirs, err := m.typeScriptModules(ctx, tree)
if len(req.Paths) > 0 {
if err := narrow(tree, req); err != nil {
return nil, err
}
// Said beside the tree, so the listing of the tree's modules leaves out those narrowed away.
if err := os.WriteFile(filepath.Join(root, narrowedMark), nil, 0o600); err != nil {
return nil, err
}
}
// The group's package, packed here and served to its members in place of the registry's version (ADR 0500 §5).
var served *packed
if group != nil {
p, err := m.packGroup(ctx, root, gitRun, group)
if err != nil {
return nil, err
}
served, res.Group = &p, p.Name+"@"+p.Version+" "+p.Integrity
}
dirs, packages, err := m.typeScriptModules(ctx, tree)
if err != nil {
return nil, err
}
for _, d := range dirs {
res.Modules = append(res.Modules, d)
did, refused, err := m.installOwn(ctx, root, filepath.Join(tree, filepath.FromSlash(d)), d)
dir := filepath.Join(tree, filepath.FromSlash(d))
if packages[d] {
// A package is built from its own lock's dependencies, its devDependencies included (ADR 0498 §5).
if err := m.installDev(ctx, root, dir, d); err != nil {
return nil, err
}
res.Installed = append(res.Installed, d)
continue
}
did, refused, err := m.installOwn(ctx, root, dir, d)
if err != nil {
return nil, err
}
if refused == "" {
// The SDK the module's own lock names, from the group or the registry (the lead's decision L10).
if err := m.placeSDK(ctx, dir, d, served); err != nil {
return nil, err
}
}
if refused != "" {
res.Refused = append(res.Refused, Refusal{Module: d, Reason: refused})
}
@@ -145,19 +196,22 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
// typeScriptModules are the directories of the tree whose module.json declares a typescript artifact, relative to
// it, in order.
func (m *Module) typeScriptModules(ctx context.Context, tree string) ([]string, error) {
func (m *Module) typeScriptModules(ctx context.Context, tree string) (dirs []string, packages map[string]bool, err error) {
r := m.Run(ctx, m.baseEnv(), "git", "-C", tree, "ls-files", "--", "module.json", "*/module.json")
if r.Status != 0 || r.Err != "" {
return nil, fmt.Errorf("git ls-files: %s", firstLine(r.Err+" "+r.Stderr))
return nil, nil, fmt.Errorf("git ls-files: %s", firstLine(r.Err+" "+r.Stderr))
}
var dirs []string
packages = map[string]bool{}
for _, l := range strings.Split(r.Stdout, "\n") {
if l = strings.TrimSpace(l); l == "" {
continue
}
raw, err := os.ReadFile(filepath.Join(tree, filepath.FromSlash(l)))
if os.IsNotExist(err) && narrowed(tree) {
continue // outside a narrowed workspace's paths (narrow.go): no module of this build
}
if err != nil {
return nil, err
return nil, nil, err
}
var mj struct {
Build struct {
@@ -169,13 +223,15 @@ func (m *Module) typeScriptModules(ctx context.Context, tree string) ([]string,
}
for _, a := range mj.Build.Artifacts {
if a.Language == "typescript" {
dirs = append(dirs, filepath.ToSlash(filepath.Dir(l)))
d := filepath.ToSlash(filepath.Dir(l))
dirs = append(dirs, d)
packages[d] = a.Kind == "package"
break
}
}
}
sort.Strings(dirs)
return dirs, nil
return dirs, packages, nil
}
// ownDependencies are the package.json's dependencies beyond the SDK and beyond what is build-time: the types and
@@ -229,13 +285,6 @@ func (m *Module) installOwn(ctx context.Context, root, dir, rel string) (install
if err != nil {
return false, "", err
}
if m.Toolchain == nil || m.Toolchain.Registry == nil {
return false, "", fmt.Errorf("the module has no package registry to install %s from", rel)
}
reg, err := m.Toolchain.Registry()
if err != nil {
return false, "", err
}
// A scratch copy, so the module's package.json and lock are never rewritten (dependencies.go installSteps).
scratch := filepath.Join(root, "npm-"+strings.NewReplacer("/", "-", ".", "root").Replace(rel))
if err := os.MkdirAll(scratch, 0o700); err != nil {
@@ -255,10 +304,11 @@ func (m *Module) installOwn(ctx context.Context, root, dir, rel string) (install
if err := os.WriteFile(filepath.Join(scratch, "package-lock.json"), lock, 0o600); err != nil {
return false, "", err
}
// The registry is given through a config file of its own, never on the command line, and removed after.
npmrc := filepath.Join(scratch, ".npmrc-install")
if err := os.WriteFile(npmrc, []byte(reg.Npmrc()), 0o600); err != nil {
return false, "", err
// The registry is given through a config file of its own, never on the command line, outside the workspace, and
// removed after (npmrcIn).
npmrc, err := m.npmrcIn(root)
if err != nil {
return false, "", fmt.Errorf("installing the packages of %s: %w", rel, err)
}
env := append(m.baseEnv(), "npm_config_cache="+filepath.Join(m.Toolchain.Paths.Cache, "npm"))
r := m.Run(ctx, env, "npm", "ci", "--omit=dev", "--omit=peer", "--ignore-scripts", "--no-bin-links",