Files
mesh-catalog/modules/mesh-build-node/README.md
T
jschoubben af280050d6 CAT-BN3: mesh-build-node publishes through the registry seats and holds no registry credential
Bundles go through oci-artifact-registry and answer the seat's reference;
a trunk package is packed under the builder's version and published through
npm-package-registry, so the holder can check it with the builder. Reads are
anonymous, so the write pair, the sealed secret and the store's address go.
The one-part sender moves into buildstep for both build modules, so the move
to the registry clients is made in one place.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:30:06 +01:00

9.7 KiB

mesh-build-node — the Node and TypeScript build module

Design 47, release R4c-3 (novox/hq ADR 0405, ADR 0466 §1, ADR 0469 §1 and §4). The module that builds and checks TypeScript code on a build machine, as an account of its own (runs-as, no login shell, home at mode 0700), with the packages it declares (git, openssh; nodejs and npm come from the nodejs module, which this module requires as node-toolchain) and the capability user-namespaces. The bundle is Go (cmd/mesh-build-node). It claims no seat: it is assigned to the build machines (ADR 0469 clause 6) and runs dark until the builder uses it. Its forge key and ssh files are the same as mesh-build-go's (issue 1484), filled on the node by the provisioner's client (issue 1537).

The recipe

The module builds with the nodejs and npm its node has installed and names no Node version (ADR 0477 §1, issue 1572); the versions of the build nodes are agreed by the indexer (ADR 0465) and kept equal by the weekly upgrade of every node's packages (ADR 0477 §5). recipe.go holds the recipe's own version (RecipeSteps). The recipe version is a hash of that and of the bytes of the lock file toolchain/package-lock.json, never of a Node version (ADR 0477 §3), so one changed byte of the lock or another recipe moves it, and typescript and esbuild change only when the lock does.

The toolchain directory

toolchain/package.json and its committed package-lock.json name typescript 5.9.3, esbuild 0.25.12, @types/node 22.20.1 (the types tsc checks node imports against), @novox/mesh-sdk 0.1.14 and nats 2.29.3. The mesh unpacks both, as the archive artifact toolchain, into <home>/toolchain/ (resource toolchain-dir; a manifest names no host, and the lock does, so it is not a template); the program reads the lock from there. When <cache dir>/toolchain/<recipe version> is missing the module runs npm ci --ignore-scripts there once (a directory without its .installed mark counts as missing).

How the registry is reached (the choice of issue 1488): the lock holds @novox/mesh-sdk at the forge's public address, and npm 12 installs a locked tarball only from the registry it is configured with. The module therefore requires npm-package-registry (provided by gitea, form none: reads are anonymous) and, like the lab module, takes its binding as a file in its home. The install gets a private npm config file with the @novox scope at the binding's public-root (else scheme, address, port and npm path), with no credential, and removes it afterwards; every other package resolves from the public registry. This is the way today's build agent names it.

The runtime tree for bundles with externals (the pruned sdk and nats today in /app/runtime) is not installed separately: sdk and nats are in this directory's node_modules, and the build step (issue 1490) takes them from there.

The guard and the health

At start, every ten minutes, before every step and at every health look, the module reads the lock again and the installed Node (node --version) and writes it with the recipe version to <node>.typescript of its toolchains state. Whatever the version, the module is healthy and build_node_health (the module's runtime-health) names it. Only a node that cannot be run, or a lock that cannot be read, refuses every step, the step ending refused on its key in steps with the cause named; the health is then unhealthy with the same text, so the controller raises the machine's condition. A step runs only after the toolchain directory is installed.

Three read tools answer what the module stored in its own states, without a grant (issue 1562): build_node_step {id} is a step's stored value (state, reason, result), build_node_steps {limit} this machine's latest steps without their results, and build_node_toolchains every <node>.<language> key of toolchains.

Tools

build_node_status and build_node_health answer in their call. The step tools run behind the guard and the toolchain install: a long step answers at once with its id and ends on <node>.<id> of steps (ADR 0469 §2).

  • build_node_fetch {repository, commit, ref} (issue 1489): the commit over ssh as the module's account (buildstep.FetchCommit, remote ssh://mesh-forge/<owner>/<name>.git) into <work>/<id>/tree, then for each TypeScript module (a module.json with a typescript artifact) the packages its package.json names beyond the SDK, typescript and @types/* (the toolchain carries those): none installs nothing; some need a package-lock.json, and are installed as the build agent does (scratch copy without the SDK, devDependencies and scripts; npm ci --omit=dev --omit=peer --ignore-scripts --no-bin-links; copies of the SDK removed). The mesh's registry reaches npm ci through a private config file (--userconfig), so other @novox/ packages install. A module with packages and no lock does not fail the fetch: its result lists it under refused with the reason, and a build of that bundle fails naming it (issue 1459). Every command runs through forgekey.ExecRunnerClean(0) with PATH, HOME, the locale and its own words, nothing of this process's environment. A failed fetch removes its own workspace; build_node_release {workspace} removes one at once (no guard, no floor; its caller calls it once its last step answered), and one no caller released is removed two hours after its last use (ADR 0509), never while a step holds it. Every step is refused, refused: <node> has N GiB free under the floor of 20 GiB (on <dir>), and the health is unhealthy with that text, while the work or the toolchain cache filesystem has less than 20 GiB free (issue 1839). The arguments are the build runner's buildstep.FetchRequest (ADR 0499 §4): with dir and paths (from the builder, never from a manifest) the workspace is narrowed to those paths, the manifest and the lock files before anything else. A member is built against the SDK version its own lock names (the lead's decision L10 of R4e, ADR 0500 §5-§6): the fetch places that version in the module's node_modules/@novox/mesh-sdk, from the group's package when the call names one (package: {repository, commit, ref}: fetched, its dependencies installed from its own lock, built and packed shut off, published nowhere) and its version and integrity are the lock's, else from the registry (integrity checked); a version the registry lacks fails waits for: @novox/mesh-sdk@<version> (the builder queues the key), or, with a group package of another version, fails naming both. A module whose lock names no SDK keeps the toolchain's. A module whose artifact is a package gets its dependencies, devDependencies included, from its own lock instead.
  • build_node_test {workspace, module, level} (issue 1491): the unit level is the type-check (the toolchain's tsc, the package's tsconfig.json or the build agent's flags over its .ts files) and the test script; the integration level is the test:integration script, the type-check being its setup. Bounds (the Go module's; ADR 0445 names none for TypeScript): unit 5 s, integration 30 s setup and 60 s running. The test script finds tsc and esbuild on its PATH through <work>/<id>/bin (shell scripts calling the toolchain's). A level over its bound says over_bound and passes; a failed one finishes the step with passed false and the output (the step fails only when the tests could not be run). Neither script nor .ts files: no tests. The toolchain makes no bin links, so a script runs node and files, not tsc by name.
  • build_node_build {workspace, module, artifact} (issue 1490): the build agent's tsc and esbuild lines (mesh-controller internal/builder, cited in build.go), the launchers, package.json, buildstep.Pack, and the digest. No network. The build runs in a staging copy of the module at <work>/<id>/stage-<artifact>/modules/module, with the toolchain's packages (not typescript or esbuild) copied to stage-<artifact>/node_modules: the build agent's depth (/app/modules/module, /app/node_modules), so esbuild's ../../node_modules/... path comments, and the bundle's digest, equal its. Copies, not links, because the mesh makes no symlink. A bundle that keeps an external gets them in its own node_modules. Once the build ended, this process publishes the packed archive through the seat oci-artifact-registry (design 51 Publishing; buildstep's one sender, buildstep.PublishOne, the manifest invokes publish and publish-part) under <module>/<artifact> by its digest, a candidate's as a trunk build's, and answers the seat's reference oci-artifact-registry/<module>/<artifact>@<digest>; a publish the holder refuses, or a seat nothing holds, fails the step not stored: <artifact>: <the holder's words>. The packed bytes never stay on the node. tsc and esbuild, like the type-check and the test script, run shut off in bubblewrap (ADR 0448): no network, none of the account, the toolchain read-only, the npm cache beneath a throwaway layer unless the step is told publish (a trunk build). A package artifact (ADR 0498 §5) is built by its own build script and packed with npm pack --ignore-scripts, both shut off; its digest is the packed file's. A trunk build the builder gave a version packs it under that version (its package.json's version set for the pack, then put back) and publishes it through npm-package-registry; the holder asks the builder, answers held for the same file and refuses another under that version. The reference <name>@<version> is answered only then. A candidate, or a trunk build not numbered, answers its digest alone. The module holds no credential for the registry: reads are anonymous (ADR 0346) and writes go through the seat.