From ae6813129df4c7a8fc625b4841955d7794931818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 30 Aug 2026 08:19:24 +0200 Subject: [PATCH 1/3] =?UTF-8?q?ci(plugins):=20nothing=20a=20node=20repo=20?= =?UTF-8?q?builds=20goes=20to=20NuGet=20=E2=80=94=20retire=20the=20NuGet-f?= =?UTF-8?q?loor=20pack=20lane=20and=20the=20tool's=20pack=20verb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plugin-publish.yml compiled every plugin's in-mesh source against a NuGet 'floor' — the newest version at which every MeshWeaver.* package was published — and wrote a .nupkg per plugin that nothing consumed (dry-run). The floor froze at rc7 the day MeshWeaver.AI and Markdown.Collaboration left the platform repo; every plugin using an rc8 type failed to pack and the proposed cure was a NuGet publish token. The maintainer's rule: in-mesh source runs INSIDE the portal image, so its only honest reference set is that image plus the modules its publication was sealed against — what node-repo-compile-check.yml and the gates already compile against — and nothing a node repo builds reaches a package feed. So: plugin-publish.yml deleted; meshweaver-plugin-build keeps module-pack + module-fetch only (FrameworkVersionResolver, ProjectEmitter, PluginPacker, PluginUnit(Resolver), SkeletonGenerator, CompilationEnvironment and FrameworkFloorTest removed — nothing else referenced them); node-repo-scope.py loses its lane (self-test 44/44 green); docs and README say why. Co-Authored-By: Claude Fable 5 --- .github/scripts/node-repo-scope.py | 61 +--- .github/workflows/plugin-publish.yml | 308 ------------------ .../ContinuousDeliveryContract.md | 11 +- ...othing-a-node-repo-builds-goes-to-nuget.md | 22 ++ .../CompilationEnvironment.cs | 86 ----- .../FrameworkVersionResolver.cs | 137 -------- .../ModulePackCommand.cs | 2 +- src/MeshWeaver.Plugin.Build/PluginPacker.cs | 165 ---------- src/MeshWeaver.Plugin.Build/PluginUnit.cs | 33 -- .../PluginUnitResolver.cs | 188 ----------- src/MeshWeaver.Plugin.Build/Program.cs | 233 +------------ src/MeshWeaver.Plugin.Build/ProjectEmitter.cs | 93 ------ src/MeshWeaver.Plugin.Build/README.md | 117 ++----- .../SkeletonGenerator.cs | 151 --------- .../FrameworkFloorTest.cs | 108 ------ 15 files changed, 84 insertions(+), 1631 deletions(-) delete mode 100644 .github/workflows/plugin-publish.yml create mode 100644 src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-30-nothing-a-node-repo-builds-goes-to-nuget.md delete mode 100644 src/MeshWeaver.Plugin.Build/CompilationEnvironment.cs delete mode 100644 src/MeshWeaver.Plugin.Build/FrameworkVersionResolver.cs delete mode 100644 src/MeshWeaver.Plugin.Build/PluginPacker.cs delete mode 100644 src/MeshWeaver.Plugin.Build/PluginUnit.cs delete mode 100644 src/MeshWeaver.Plugin.Build/PluginUnitResolver.cs delete mode 100644 src/MeshWeaver.Plugin.Build/ProjectEmitter.cs delete mode 100644 src/MeshWeaver.Plugin.Build/SkeletonGenerator.cs delete mode 100644 test/MeshWeaver.Graph.Test/FrameworkFloorTest.cs diff --git a/.github/scripts/node-repo-scope.py b/.github/scripts/node-repo-scope.py index 2756bd5e65..0bc3e26b2a 100755 --- a/.github/scripts/node-repo-scope.py +++ b/.github/scripts/node-repo-scope.py @@ -5,17 +5,14 @@ validate it" · "all plugins should only re-build what changed" · "there is always a notion of 'full rebuild' when the platform updates" · "unify all MeshWeaver.* repos" -TWO LANES ask this question, with the same rule and the same fallbacks, so they share one script: +ONE LANE asks this question (a second, `--for packages` for the retired NuGet-floor pack lane +`plugin-publish.yml`, was removed on 2026-08-30 — in-mesh source is type-checked against the +platform IMAGE by node-repo-compile-check.yml, and nothing a node repo builds goes to a feed): --for modules `node-repo-module-pack.yml` fans out ONE JOB per mixed package. That fan-out was the caller's literal list, every entry on every event: MeshWeaver.Plugins ran 29 module builds on every push of every PR, whether the diff touched one module's C# or a course's markdown. - --for packages `plugin-publish.yml` packs EVERY node package in one job — 53 in - MeshWeaver.Plugins, `Store` alone being 17 compilation units. It is the COMPILE - GATE over ~12k lines of in-mesh NodeType C# against the newest RELEASED - framework (a different compiler input from `compile-check.py --image`), and it - publishes nothing on a PR because that lane is `dry-run` by design. 🚨 THE BIAS IS THE DESIGN, and it is copied from bake-scope.sh deliberately. Narrowing a build is the shape that produces a SILENT under-build, and the evidence of the miss is the absence of @@ -43,11 +40,6 @@ serves this version" is NOT evidence the published bundle matches HEAD. (That is the same blind spot Plugins #878 records on the delivery side.) Until this lane stamps a source commit into what it publishes, a push packs everything. - * packages — `plugin-publish.yml`'s header spells out why there is no changed-plugin detection - on the publish path: the derived PATCH is the change detector, and a hand-written diff would - be a second detector free to disagree, whose failure mode is a plugin that silently never - ships. That argument is about PUBLISHING. On a `pull_request` nothing is published at all, so - it does not apply — and the unnarrowed `push` keeps the trunk exhaustive. So the whole win sits on `pull_request` / `merge_group`, which is exactly where the cost is: a PR pushes many times, a trunk commit lands once. @@ -67,7 +59,6 @@ USAGE node-repo-scope.py --for modules --modules @matrix.json --event pull_request --base-ref main - node-repo-scope.py --for packages --event schedule node-repo-scope.py --self-test OUTPUT — JSON on stdout, the human report on stderr, plus $GITHUB_OUTPUT / $GITHUB_STEP_SUMMARY. @@ -122,17 +113,6 @@ "(MeshWeaver#2088), so the git diff is irrelevant.", "workflow_dispatch": "a manual dispatch has no diff to narrow by — packing every module.", }, - "packages": { - "push": "the publish path's change detector is the DERIVED VERSION, not a diff " - "(plugin-publish.yml's header): a second detector free to disagree would let a " - "plugin silently never ship. Packing every plugin.", - "repository_dispatch": "the published framework moved — this gate's value is being " - "EXHAUSTIVE against that floor, because a package that did not " - "change can still stop compiling. Packing every plugin.", - "schedule": "the released-framework floor moves without this repo changing, and a package " - "that did not change can still stop compiling against it. Packing every plugin.", - "workflow_dispatch": "a manual dispatch has no diff to narrow by — packing every plugin.", - }, } @@ -260,16 +240,15 @@ def decide(root: Path, lane: str, entries: list[dict], event: str, diff_range: s override: list[str] | None, say, publishing: bool = False, always: frozenset[str] = frozenset()) -> dict: all_packages = sorted(node_packages(root)) - universe = ([e["module"] for e in entries] if lane == "modules" else all_packages) + universe = [e["module"] for e in entries] def full(reason: str) -> dict: return {"lane": lane, "scope": "full", "reason": reason, - "modules": [{**e, "test": True} for e in entries] if lane == "modules" else [], - "packages": all_packages if lane == "packages" else [], + "modules": [{**e, "test": True} for e in entries], "count": len(universe), "selected": sorted(universe), "skipped": [], "why": {}} def refuse(reason: str) -> dict: - return {"lane": lane, "scope": "refuse", "reason": reason, "modules": [], "packages": [], + return {"lane": lane, "scope": "refuse", "reason": reason, "modules": [], "count": 0, "selected": [], "skipped": [], "why": {}} # 🚨 THE ANSWER "BUILD EVERYTHING, AND EVERYTHING IS NOTHING" IS NOT AN ANSWER. `full` with a @@ -345,15 +324,6 @@ def refuse(reason: str) -> dict: "answered ALL, or could not be parsed) — running the full set.") affected = answer - if lane == "packages": - chosen = sorted(affected & set(all_packages)) - return {"lane": lane, "scope": "narrowed", - "reason": f"{len(chosen)} of {len(all_packages)} plugin(s) are reachable from this " - f"diff ({len(files)} file(s)).", - "modules": [], "packages": chosen, "count": len(chosen), "selected": chosen, - "skipped": sorted(set(all_packages) - set(chosen)), - "why": {p: "in the affected closure" for p in chosen}} - # ── the modules lane also needs the COMPILED half ── # An entry built from outside this checkout (a platform-hosted transition entry) can never be # shown unaffected by this repo's diff. @@ -439,7 +409,7 @@ def refuse(reason: str) -> dict: return {"lane": lane, "scope": "narrowed", "reason": f"{len(selected)} of {len(entries)} module bundle(s) are reachable from this " f"diff ({len(files)} file(s)).", - "modules": selected, "packages": [], "count": len(selected), + "modules": selected, "count": len(selected), "selected": sorted(chosen), "skipped": sorted(set(universe) - chosen), "why": why} @@ -557,7 +527,7 @@ def check(name: str, ok: bool, detail: str = "") -> None: _fixture(root) print("FULL-run fallbacks — the bias that makes narrowing safe (both lanes):") - for lane, n in (("modules", 2), ("packages", 3)): + for lane, n in (("modules", 2),): for event, label in ( ("push", "a push has no sound publication baseline / is the publish path"), ("repository_dispatch", "a framework release rebuilds everything"), @@ -598,10 +568,6 @@ def check(name: str, ok: bool, detail: str = "") -> None: got = _run(root, "modules", "pull_request", ["Beta/Board.json"]) check("[modules] a node package's DEPENDENT is selected too", got["selected"] == ["Acme.Alpha", "Acme.Beta"], f"{got['selected']}") - got = _run(root, "packages", "pull_request", ["Beta/Board.json"]) - check("[packages] the pack lane narrows to the same closure", - got["packages"] == ["Alpha", "Beta"] and got["skipped"] == ["Gamma"], - f"{got['packages']} skipped={got['skipped']}") print("the floor — bundles the caller's gates compose on EVERY run:") got = _run(root, "modules", "pull_request", ["docs/guide.md"], extra=["--always", "Acme.Alpha"]) @@ -642,7 +608,7 @@ def check(name: str, ok: bool, detail: str = "") -> None: got["scope"] == "narrowed" and got["selected"] == expect, f"{got['selected']}") print("the legitimate EMPTY answer — explicit, never a missing input:") - for lane in ("modules", "packages"): + for lane in ("modules",): got = _run(root, lane, "pull_request", ["docs/guide.md"]) check(f"[{lane}] a diff reaching nothing selects ZERO, and says so", got["scope"] == "narrowed" and got["count"] == 0 and got["skipped"], @@ -693,16 +659,16 @@ def check(name: str, ok: bool, detail: str = "") -> None: check("an EMPTY matrix input is refused (exit 2), never answered as full/0", proc.returncode == 2 and "nothing to select from" in proc.stderr, f"exit={proc.returncode}") - proc = _raw(root, "packages", "pull_request", ["Alpha/index.json"], + proc = _raw(root, "modules", "pull_request", ["Alpha/index.json"], extra=["--root", str(root / "no-such-dir")]) check("a --root that is not the checkout is refused, never answered as full/0", proc.returncode != 0 and '"count": 0' not in proc.stdout, f"exit={proc.returncode} stdout={proc.stdout[:120]}") print("a PUBLISHING run never narrows, whatever the event:") - got = _run(root, "packages", "pull_request", ["Alpha/index.json"], extra=["--publishing"]) + got = _run(root, "modules", "pull_request", ["Alpha/index.json"], extra=["--publishing"]) check("--publishing forces FULL even on a pull_request", - got["scope"] == "full" and got["count"] == 3 and "PUBLISHES" in got["reason"], + got["scope"] == "full" and got["count"] == 2 and "PUBLISHES" in got["reason"], f"scope={got['scope']} count={got['count']}") print("a stale matrix entry must fail on THIS PR, not on the next push to main:") @@ -783,7 +749,7 @@ def check(name: str, ok: bool, detail: str = "") -> None: def main() -> int: p = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) - p.add_argument("--for", dest="lane", choices=("modules", "packages")) + p.add_argument("--for", dest="lane", choices=("modules",)) p.add_argument("--modules", help="the module matrix as JSON, or @file (--for modules)") p.add_argument("--root", default=".", help="the caller repo checkout") p.add_argument("--event", default="", help="the triggering GitHub event name") @@ -848,7 +814,6 @@ def main() -> int: fh.write(f"scope={answer['scope']}\n") fh.write(f"count={answer['count']}\n") fh.write("modules=" + json.dumps(answer["modules"]) + "\n") - fh.write("packages=" + " ".join(answer["packages"]) + "\n") fh.write("selected=" + " ".join(answer["selected"]) + "\n") if os.environ.get("GITHUB_STEP_SUMMARY"): total = answer["count"] + len(answer["skipped"]) diff --git a/.github/workflows/plugin-publish.yml b/.github/workflows/plugin-publish.yml deleted file mode 100644 index df33e6b5bf..0000000000 --- a/.github/workflows/plugin-publish.yml +++ /dev/null @@ -1,308 +0,0 @@ -name: Plugin Publish - -# Publishes a repo's plugins as NuGet packages on merge to main. Called by every plugin repo. -# -# ═════════════════════════ WHAT A PACKAGE IS ═════════════════════════ -# One package per PLUGIN, carrying its node content plus one prebuilt assembly per compilation unit -# — the same conversion the portal's bake performs, done once in CI instead of on every portal. -# See Doc/Architecture/PluginPackaging. -# -# ═══════════════════ WHY EVERY PLUGIN, EVERY MERGE ═══════════════════ -# 🚨 Read this heading literally: EVERY MERGE. A `pull_request` run narrows (see the next block); -# a push, a dispatch and a schedule do not, and the paragraph below is the reason. -# -# There is no changed-plugin detection here, and that is deliberate rather than lazy. A module's -# PATCH is DERIVED by gen-manifests.py from its content hash: the version changes when, and only -# when, the tree changes. So publishing every plugin with `--skip-duplicate` publishes exactly the -# ones whose content moved — the version IS the change detector, and it is one the repo already -# maintains and tags. -# -# A hand-written diff would be a SECOND detector free to disagree with the first, and its failure -# mode is silent: a plugin the diff misses simply never ships, at a version nobody notices is -# absent until an install resolves the old content. -# -# ═════════════════ …AND WHY A PULL REQUEST IS DIFFERENT ═════════════════ -# "all plugins should only re-build what changed." -# -# The argument above is about PUBLISHING: a diff that misses a plugin means that plugin silently -# never ships. On a `pull_request` NOTHING is published — for MeshWeaver.Plugins the whole lane is -# `dry-run: true` by design — so the failure mode the version-as-change-detector protects against -# cannot occur. What a PR run buys is the COMPILE GATE: it is the only thing that type-checks -# in-mesh NodeType C# against the newest RELEASED framework, which is a different compiler input -# from `compile-check.py --image`. -# -# That gate still has to be exhaustive against a MOVING FLOOR — a package that did not change can -# stop compiling when the published framework advances. But the floor moves relative to the trunk, -# not relative to a PR base, and the unnarrowed `push` re-packs every plugin on every merge. So: -# -# pull_request / merge_group → the affected closure (changed plugins + every dependent) -# push, dispatch, schedule → EVERY plugin, exhaustively -# -# …which is the same rule the module lane runs (node-repo-module-pack.yml), computed by the same -# script (.github/scripts/node-repo-scope.py), whose every uncertainty resolves to the full set and -# says which fallback it took. Measured on MeshWeaver.Plugins: 53 packages packed per run, `Store` -# alone 17 compilation units; a PR touching one plugin now packs one. -# -# 🚨 The count is ASSERTED, not assumed: the loop below counts what it actually packed and fails if -# that is not exactly what the selection named. A pack loop that silently iterated an empty list -# would otherwise be a green tick over a gate that ran on nothing. - -# ══════════════════ WHAT THE PACK-THEN-PUSH ORDER BUYS ══════════════════ -# Every plugin is PACKED before any is PUSHED, so a BUILD failure ships nothing. That is the whole -# guarantee, and it is worth being precise about, because it is weaker than the image pipeline's. -# -# 🚨 The PUSH is NOT transactional and cannot be made so. `dotnet nuget push` uploads packages one -# at a time; a failure partway leaves the earlier ones published, and a published version cannot be -# unpublished or retagged. main-cd.yml's images get atomicity from a staging tag promoted only -# after every leg succeeds — NuGet has no equivalent, because there the version IS the identity. -# -# So the recovery is re-running, not rollback: `--skip-duplicate` makes an already-published package -# a no-op, so a re-fired run completes the remainder. The failure path below says this explicitly -# rather than leaving whoever finds the red run to work out whether anything shipped. - -on: - workflow_call: - inputs: - framework-version: - description: > - MeshWeaver package version the plugins compile against, or `latest` to resolve the newest - from the configured sources. `latest` freezes the FULL resolved version — including the - `.ci.` suffix — as the package's framework floor, because that suffix is where - new API lands: a floor of `3.0.0-rc3` is satisfied by `3.0.0-rc3.ci.1`, thousands of runs - before the API the plugin was written against. - required: true - type: string - meshweaver-ref: - description: > - Ref of Systemorph/MeshWeaver to build the plugin-build tool from — and, since the - narrowing landed, to read .github/scripts/node-repo-scope.py from. 🚨 PIN IT TO A COMMIT - THAT HAS BOTH: a caller using this workflow at a ref NEWER than its `meshweaver-ref` - calls a script that checkout does not have, and the run dies in the scope step instead - of anywhere that names the cause (run 33073476996's exact failure, one lane over). The - default keeps the two in step; if you pin one, pin both. - required: false - default: main - type: string - dry-run: - description: Pack and report, never push. For verifying a repo's wiring before it ships. - required: false - default: false - type: boolean - -permissions: - contents: read - packages: write - -jobs: - preflight: - name: Preflight - runs-on: ubuntu-latest - steps: - # Asserts what comes from outside the tree, RED, naming what to provision. Never an - # `if: inputs.x != ''` on a later step: a skipped job renders with the same tick as a passed - # one, so "never published" and "published" become indistinguishable. - - name: Assert inputs - run: | - set -euo pipefail - if [ -z "${{ inputs.framework-version }}" ]; then - echo "::error::missing required input: framework-version" - exit 1 - fi - echo "framework-version = ${{ inputs.framework-version }}" - - publish: - name: Pack and publish - needs: preflight - runs-on: ubuntu-latest - timeout-minutes: 45 - # 🚨 The fork gate, expressed on the EVENT — never on whether a secret happens to be set. A - # fork's PR head can also be named "main", and org secrets are withheld from it by design; a - # "the token is empty" check would look identical at job level and is the unsafe one. - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork != true - steps: - - name: Checkout plugin repo - uses: actions/checkout@v7 - with: - path: plugins - # 🚨 The affected closure diffs against the PR base — a shallow clone makes that range - # unresolvable, which the scope script turns into a full pack rather than an empty one, - # but paying for 53 packs silently is not the plan. - fetch-depth: 0 - - - name: Checkout MeshWeaver (for the plugin-build tool) - uses: actions/checkout@v7 - with: - repository: Systemorph/MeshWeaver - ref: ${{ inputs.meshweaver-ref }} - path: meshweaver - - - name: Setup .NET - uses: actions/setup-dotnet@v6 - with: - dotnet-version: '10.x' - - - uses: actions/setup-python@v7 - with: - python-version: "3.12" - - # ─────────────────── WHICH PLUGINS THIS RUN MUST PACK ─────────────────── - # One script, shared with the module lane, biased to FULL: a push/dispatch/schedule, a - # tooling change, a refusal from the caller's own selector, or an empty diff all resolve to - # every plugin, naming which fallback was taken in the log and the job summary. - - name: The selection can say no (self-test) - run: python3 meshweaver/.github/scripts/node-repo-scope.py --self-test - - - name: Decide which plugins this run must pack - id: scope - env: - EVENT: ${{ github.event_name }} - BASE_REF: ${{ github.base_ref }} - QUEUE_BASE_REF: ${{ github.event.merge_group.base_ref }} - run: | - set -euo pipefail - if [ -z "${BASE_REF:-}" ] && [ -n "${QUEUE_BASE_REF:-}" ]; then - BASE_REF="${QUEUE_BASE_REF#refs/heads/}" - fi - if [ -n "${BASE_REF:-}" ]; then - git -C plugins fetch --no-tags origin \ - "+refs/heads/$BASE_REF:refs/remotes/origin/$BASE_REF" - fi - # 🚨 `--publishing` when this run PUSHES, whatever the event. `merge_group` is a - # narrowable event, and the push step below is gated only on `dry-run` — so a caller - # with a merge queue and `dry-run: false` would have published a narrowed subset, - # contradicting the very argument this lane's header makes about the version being the - # change detector on the publish path. Belt on the reusable side, where it cannot be - # forgotten by a caller. - python3 meshweaver/.github/scripts/node-repo-scope.py \ - --for packages --root plugins \ - ${{ inputs.dry-run && ' ' || '--publishing' }} \ - --event "$EVENT" --base-ref "${BASE_REF:-}" > "$RUNNER_TEMP/scope.json" - - - name: Build the tool - run: dotnet build meshweaver/src/MeshWeaver.Plugin.Build/MeshWeaver.Plugin.Build.csproj -c Release - - - name: Pack the selected plugins - env: - SELECTED: ${{ steps.scope.outputs.packages }} - EXPECTED: ${{ steps.scope.outputs.count }} - SCOPE: ${{ steps.scope.outputs.scope }} - run: | - set -uo pipefail - mkdir -p "$RUNNER_TEMP/nupkgs" - failed=() - packed=0 - echo "scope=$SCOPE — the selection names $EXPECTED plugin(s)" - # 🚨 An INDEPENDENT recount. `$EXPECTED` and `$SELECTED` come from the same call and - # agree by construction, so on their own they can only catch a lost output — never a - # selection that is simply wrong. On a FULL scope there IS a second source: the tree. - if [ "$SCOPE" = "full" ]; then - onDisk=$(find plugins -mindepth 2 -maxdepth 2 -name index.json | wc -l | tr -d " ") - if [ "$onDisk" != "$EXPECTED" ]; then - echo "::error title=Pack count does not match the tree::the scope is FULL, so every \ - plugin in the checkout must be packed — the tree holds $onDisk and the selection named \ - $EXPECTED." - exit 1 - fi - echo "full scope cross-checked against the tree: $onDisk plugin(s)" - fi - # The loop word-splits $SELECTED deliberately; globbing is NOT wanted, and it is the one - # way `seen` could exceed the count for a reason that is not a real discrepancy. - set -f - for name in $SELECTED; do - plugin="plugins/$name" - # 🚨 NOT `|| continue`. The old loop globbed the tree and skipped anything without an - # index.json, which is correct for a glob and wrong for a selection: a named plugin - # that cannot be packed must be a failure, or the count below would silently agree - # with a shorter run. - if [ ! -f "$plugin/index.json" ]; then - echo "::error::the selection named '$name' but $plugin/index.json does not exist" - failed+=("$name") - continue - fi - echo "::group::$name" - if dotnet run --project meshweaver/src/MeshWeaver.Plugin.Build -c Release --no-build -- \ - "$plugin" \ - --framework-version "${{ inputs.framework-version }}" \ - --repo-root plugins \ - --out "$RUNNER_TEMP/plugin-build" \ - --pack "$RUNNER_TEMP/nupkgs"; then - packed=$((packed + 1)) - else - failed+=("$name") - fi - echo "::endgroup::" - done - set +f - echo "packed $packed plugin(s) of $EXPECTED selected" - # Written BEFORE the failure branches: a red run is the one whose summary matters. - { - echo "### 📦 Packed $packed of $EXPECTED selected plugin(s) — scope \`$SCOPE\`" - echo "" - echo "\`${SELECTED:-(nothing)}\`" - } >> "$GITHUB_STEP_SUMMARY" - # ══════════════ THE COUNT GATE ══════════════ - # 🚨 "we can estimate the required count and validate it." A loop over a list that came - # out empty, short, or mis-parsed packs less than it should and exits 0 — a green tick - # over a compile gate that compiled nothing. The estimate is the selection; this is the - # validation, and it runs before the failure report so a miscount is never masked by a - # genuine build failure. - seen=$((packed + ${#failed[@]})) - if [ "$seen" != "$EXPECTED" ]; then - echo "::error title=Pack count does not match the selection::the selection named \ - $EXPECTED plugin(s) and this loop reached $seen. Nothing here may silently pack less \ - than was selected — a compile gate that ran on fewer packages than it reported is the \ - failure this check exists for. Selected: $SELECTED" - exit 1 - fi - if [ ${#failed[@]} -gt 0 ]; then - echo "::error::packing failed for: ${failed[*]} — nothing was pushed" - exit 1 - fi - - # Reached only when EVERY plugin packed. A content-only plugin produces no package and that - # is a success, so an empty directory is not an error — but it must be SAID, or "published - # nothing" reads the same as "published everything". - - name: Push to GitHub Packages - if: ${{ !inputs.dry-run }} - env: - FEED: https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json - run: | - set -euo pipefail - count=$(find "$RUNNER_TEMP/nupkgs" -name '*.nupkg' | wc -l | tr -d ' ') - echo "$count package(s) to publish" - if [ "$count" = "0" ]; then - echo "no packages produced — every plugin in this repo is content-only" - exit 0 - fi - # --skip-duplicate is what makes this idempotent AND makes the version the change - # detector: an unchanged plugin keeps its version, the push 409s, and the flag turns that - # into a no-op instead of a failure. It also lets a half-finished run be re-fired to - # complete the remainder rather than tripping on the first already-published package. - # - # 🚨 The glob is UNQUOTED so the shell expands it, matching release-packages.yml. Quoted, - # the literal `*.nupkg` is passed through as a path and nothing publishes — a green step - # that shipped nothing. - if ! dotnet nuget push $RUNNER_TEMP/nupkgs/*.nupkg \ - --source "$FEED" \ - --api-key "${{ secrets.GITHUB_TOKEN }}" \ - --skip-duplicate; then - # A push that fails partway leaves earlier packages published and unretractable, so say - # so plainly instead of leaving the next person to infer it from a red run. - echo "::error::push failed partway. Packages already uploaded REMAIN published — a \ - version cannot be unpublished or retagged. Re-run this workflow to complete the rest; \ - --skip-duplicate makes the already-published ones no-ops." - exit 1 - fi - find "$RUNNER_TEMP/nupkgs" -name '*.nupkg' -exec basename {} \; | sed 's/^/ published: /' - - # 🚨 The two `if:`s above and below are a MODE SWITCH, not a skip-trapdoor, and the difference - # is what makes them safe: `dry-run` is an explicit caller-declared input with BOTH branches - # reporting what happened, whereas a trapdoor asks whether a secret happens to be set and - # leaves silence when it is not. Exactly one of these two steps always runs and always says - # whether anything shipped. Do not add an `if:` that probes for a credential. - - name: Report (dry run) - if: ${{ inputs.dry-run }} - run: | - set -euo pipefail - echo "dry run — nothing pushed. Packages that WOULD publish:" - find "$RUNNER_TEMP/nupkgs" -name '*.nupkg' -exec basename {} \; | sed 's/^/ /' diff --git a/src/MeshWeaver.Documentation/Data/Architecture/ContinuousDeliveryContract.md b/src/MeshWeaver.Documentation/Data/Architecture/ContinuousDeliveryContract.md index 6fc8ed53ec..482b76483b 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/ContinuousDeliveryContract.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/ContinuousDeliveryContract.md @@ -241,11 +241,12 @@ evidence is in this pipeline's own comments — read them before proposing anyth **What is actually true:** -- **`MeshWeaver.Plugins/publish-packages.yml` publishes NOTHING.** It packs and reports, `dry-run` by - design: *"nothing consumes a package feed"*, and a push to `nuget.pkg.github.com` would 403 on the - org's Packages billing limit and redden main on every merge. So there is no second publisher, and - **no package-release event to hang a webhook on** — the question is well-posed and the premise - does not hold here. +- **There is no node-package feed, and no lane that could publish to one.** Until 2026-08-30 + `MeshWeaver.Plugins/publish-packages.yml` packed every node package against a NuGet *floor* (dry + run, publishing nothing); the maintainer retired it — in-mesh source runs inside the portal + image, so its reference set is the image plus the modules the publication was sealed against + (`node-repo-compile-check.yml`), and nothing a node repo builds reaches a package feed. So there + is no second publisher, and **no package-release event to hang a webhook on**. - **Consumers fetch BUNDLES from the registry portal** (`/api/plugins/bundles`) — *"assembled from the very bytes that portal runs"*. Distribution is bundles, not a feed. - **Coordination already exists, and it is not a shared pipeline.** Each node repo's diff --git a/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-30-nothing-a-node-repo-builds-goes-to-nuget.md b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-30-nothing-a-node-repo-builds-goes-to-nuget.md new file mode 100644 index 0000000000..ef5f7b2ed3 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-30-nothing-a-node-repo-builds-goes-to-nuget.md @@ -0,0 +1,22 @@ +--- +Name: Nothing a node repository builds goes to NuGet +Category: Fix +Description: The lane that compiled every plugin's in-mesh source against a NuGet "floor" — and had been red since the AI module left the platform, because that floor stopped at rc7 — is retired. In-mesh source is checked against the portal image it runs in, and plugins reach consumers as bundles from a sealed publication, never from a package feed. +Icon: PackageDismiss +Order: -20260830 +--- + +# Nothing a node repository builds goes to NuGet + +Every node repository carries C# that compiles inside the portal at runtime. One lane checked that +source against the newest **released NuGet packages** — the "framework floor" — and packed a +`.nupkg` per plugin that nothing consumed. When the AI and collaboration modules moved out of the +platform repository their NuGet packages stopped at rc7, the floor froze there, and every plugin +using an rc8 type failed to pack. The proposed cure was a NuGet publish token. + +That lane is retired instead. In-mesh source runs *inside the portal image*, so the only honest +reference set is that image's assemblies plus the modules its publication was sealed against — +which is exactly what the compile check and the gates already use. Consumers fetch bundles assembled +from a sealed publication, never packages from a feed, so no repository's CI needs a NuGet +credential and none can publish there by accident. The build-side tool keeps its two module verbs +(`module-pack`, `module-fetch`) and nothing else. diff --git a/src/MeshWeaver.Plugin.Build/CompilationEnvironment.cs b/src/MeshWeaver.Plugin.Build/CompilationEnvironment.cs deleted file mode 100644 index da2bc30ffc..0000000000 --- a/src/MeshWeaver.Plugin.Build/CompilationEnvironment.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System.Collections.Immutable; - -namespace MeshWeaver.Plugin.Build; - -/// -/// The ambient environment the portal gives an in-mesh compilation, restated as MSBuild inputs. -/// -/// Why this class exists. At runtime a NodeType compiles against the portal's WHOLE -/// loaded assembly set, and its generated skeleton contributes a using preamble. Neither is -/// implicit in a .csproj, so a build that omits them fails on code that is perfectly -/// correct — and fails in ways that read like author error rather than harness error. The two -/// canonical mis-reads: -/// -/// CS0616 'MeshNode' is not an attribute class — -/// lacked MeshWeaver.Domain, so [MeshNode(…)] bound to the MeshNode RECORD in -/// MeshWeaver.Mesh instead of MeshNodeAttribute. Forty-seven units failed this way. -/// CS0246 on IPackageSource / PluginCatalogOptions — -/// carried only MeshWeaver.Graph. In-mesh code may use ANY -/// framework type the portal has loaded; Store alone drops from 40 errors to 1 once the rest are -/// referenced. -/// -/// -public static class CompilationEnvironment -{ - /// - /// The using preamble the runtime skeleton emits (see DynamicMeshNodeAttributeGenerator), - /// plus the two namespaces empirically required by shipped plugin source that relies on them - /// without a file-level using: System.Reactive (Unit) and - /// System.Globalization (CultureInfo). - /// - /// 🚨 This is NOT MeshScriptEnvironment.Imports. That set belongs to the kernel's - /// SCRIPT environment; it is narrower and using it here reintroduces the CS0616 above. - /// - public static ImmutableArray Usings { get; } = - [ - "System", - "System.Collections.Generic", - "System.Collections.Immutable", - "System.ComponentModel", - "System.ComponentModel.DataAnnotations", - "System.Globalization", - "System.Linq", - "System.Reactive", - "System.Reactive.Linq", - "System.Text.Json", - "System.Text.Json.Serialization", - "MeshWeaver.Application.Styles", - "MeshWeaver.ContentCollections", - "MeshWeaver.Data", - // The one that makes [MeshNode(…)] an ATTRIBUTE — see the class remarks. - "MeshWeaver.Domain", - "MeshWeaver.Graph", - "MeshWeaver.Graph.Configuration", - "MeshWeaver.Layout", - "MeshWeaver.Layout.Composition", - "MeshWeaver.Layout.DataGrid", - "MeshWeaver.Layout.Domain", - "MeshWeaver.Layout.Views", - "MeshWeaver.Mesh", - "MeshWeaver.Mesh.Services", - "MeshWeaver.Messaging", - "Microsoft.Extensions.Configuration", - "Microsoft.Extensions.DependencyInjection", - "Microsoft.Extensions.Logging", - ]; - - /// - /// The framework packages a plugin compiles against — the build-time stand-in for "every - /// assembly the portal has loaded". Deliberately broader than any single plugin needs: an - /// unused reference costs a restore entry, a missing one costs a false compile error that - /// looks like broken plugin code. - /// - public static ImmutableArray PackageIds { get; } = - [ - "MeshWeaver.AI", - "MeshWeaver.ContentCollections.Indexing", - "MeshWeaver.Graph", - // MapControl and its provider-neutral surface. The three map view packs' example - // galleries (AppleMaps, GoogleMaps, OpenStreetMap) `using MeshWeaver.Maps`, which the - // portal has loaded and this list did not carry — so they compiled in the mesh and failed - // to PACK with CS0234, and because a failed unit pushes NOTHING, one missing reference - // held back every package in the repo. - "MeshWeaver.Maps", - "MeshWeaver.PluginCatalog", - ]; -} diff --git a/src/MeshWeaver.Plugin.Build/FrameworkVersionResolver.cs b/src/MeshWeaver.Plugin.Build/FrameworkVersionResolver.cs deleted file mode 100644 index d427e7513b..0000000000 --- a/src/MeshWeaver.Plugin.Build/FrameworkVersionResolver.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System.Text.Json; - -using MeshWeaver.Plugin.Packaging; - -namespace MeshWeaver.Plugin.Build; - -/// -/// Resolves which framework version to compile against, and — the part that matters — records it -/// in full. -/// -/// 🚨 The .ci.N suffix is part of the identity, not noise. The framework has two -/// channels: a RELEASED build carries a clean 3.0.0-rc2, while every continuous build carries -/// 3.0.0-rc3.ci.<run-number>, monotonic per CI run. Those continuous builds are where -/// new API lands first. A floor of 3.0.0-rc3 is satisfied by 3.0.0-rc3.ci.1 — semver -/// orders more dot-identifiers as GREATER — so truncating the suffix declares a plugin compatible -/// with thousands of builds that predate the API it was written against, and the failure surfaces -/// as a compile error inside a customer's bake rather than here. -/// -/// Equally, a hard-coded default version silently compiles every plugin against whatever was -/// current when the default was written. Resolving the latest at build time is what keeps that -/// honest, which is why exists rather than a constant. -/// -public static class FrameworkVersionResolver -{ - /// The literal a caller passes to mean "resolve the newest available". - public const string Latest = "latest"; - - /// The package whose version IS the framework version. - public const string FrameworkPackage = "MeshWeaver.Graph"; - - /// - /// Returns unchanged, or — when it is — the - /// newest version of available from . - /// - /// An explicit version, or . - /// Package sources: v3 service-index URLs, or local directories. - /// Client for feed lookups. - public static string Resolve(string requested, IReadOnlyList sources, HttpClient http) - { - if (!string.Equals(requested, Latest, StringComparison.OrdinalIgnoreCase)) - return requested; - - // 🚨 The floor is the newest version at which EVERY referenced package is published — - // not the newest MeshWeaver.Graph. Every emitted project references all of - // CompilationEnvironment.PackageIds, so a version where one of them has no build is not a - // floor any plugin can honestly claim: NuGet resolves the missing one to whatever else it - // can find and the restore dies with an NU1605 downgrade before compiling anything. - // - // This is not hypothetical. When MeshWeaver.AI's source moved out of the platform repo, - // the platform stopped publishing it. The framework released 3.0.0-rc8, MeshWeaver.AI - // stopped at 3.0.0-rc7, and 33 of 33 code-bearing packages failed to pack. - // - // Intersecting rather than pinning each package separately is deliberate: a mixed floor - // resolves rc7-era transitives alongside rc8 ones, and a type that moved between - // assemblies then exists in both — CS0433, reported against innocent plugin source. - var candidates = CompilationEnvironment.PackageIds - .Select(package => sources - .SelectMany(source => VersionsFrom(package, source, http)) - .ToHashSet(StringComparer.OrdinalIgnoreCase)) - // A package no source can answer for constrains nothing — the alternative is that one - // unreachable feed empties the intersection and resolves nothing at all. - .Where(versions => versions.Count > 0) - .Aggregate( - (IEnumerable?)null, - (intersection, versions) => intersection is null - ? versions - : intersection.Intersect(versions, StringComparer.OrdinalIgnoreCase)) - ?.ToList() - ?? []; - - if (candidates.Count == 0) - throw new InvalidOperationException( - $"--framework-version {Latest} could not find a version published for ALL of " - + string.Join(", ", CompilationEnvironment.PackageIds) + " in: " - + string.Join(", ", sources) - + ". Resolving nothing must not silently fall back to a hard-coded version — that " - + "is how every plugin ends up compiled against a framework nobody runs."); - - return candidates.OrderBy(v => v, NuGetVersionComparer.Instance).Last(); - } - - - private static IEnumerable VersionsFrom(string packageId, string source, HttpClient http) - { - if (Directory.Exists(source)) - return Directory - .EnumerateFiles(source, $"{packageId}.*.nupkg") - .Select(f => Path.GetFileNameWithoutExtension(f)[(packageId.Length + 1)..]); - - try - { - return FeedVersions(packageId, source, http); - } - catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException) - { - // One unreachable source must not decide the version — but it must not be silent - // either, or "latest" quietly means "latest on the feeds that happened to answer". - Console.Error.WriteLine($"warning: could not read package source '{source}': {ex.Message}"); - return []; - } - } - - /// - /// Reads a v3 feed: service index → the PackageBaseAddress/3.0.0 resource → the - /// package's version list. Derived from the index rather than assumed, because the flat - /// container lives on a different host per feed (nuget.org serves it from - /// v3-flatcontainer, GitHub Packages from its own). - /// - private static IEnumerable FeedVersions(string packageId, string serviceIndexUrl, HttpClient http) - { - using var indexResponse = http.Send(new HttpRequestMessage(HttpMethod.Get, serviceIndexUrl)); - indexResponse.EnsureSuccessStatusCode(); - using var index = JsonDocument.Parse(indexResponse.Content.ReadAsStream()); - - var baseAddress = index.RootElement.TryGetProperty("resources", out var resources) - ? resources.EnumerateArray() - .Where(r => r.TryGetProperty("@type", out var t) - && t.GetString()?.StartsWith("PackageBaseAddress/3.0.0", StringComparison.Ordinal) == true) - .Select(r => r.TryGetProperty("@id", out var id) ? id.GetString() : null) - .FirstOrDefault(id => !string.IsNullOrEmpty(id)) - : null; - - if (baseAddress is null) - return []; - - var versionsUrl = baseAddress.TrimEnd('/') - + "/" + packageId.ToLowerInvariant() + "/index.json"; - using var versionsResponse = http.Send(new HttpRequestMessage(HttpMethod.Get, versionsUrl)); - if (!versionsResponse.IsSuccessStatusCode) - return []; - - using var versions = JsonDocument.Parse(versionsResponse.Content.ReadAsStream()); - return versions.RootElement.TryGetProperty("versions", out var array) - ? [.. array.EnumerateArray().Select(v => v.GetString()).Where(v => v is not null).Select(v => v!)] - : []; - } -} diff --git a/src/MeshWeaver.Plugin.Build/ModulePackCommand.cs b/src/MeshWeaver.Plugin.Build/ModulePackCommand.cs index 0faa03c52a..5f0f8c907b 100644 --- a/src/MeshWeaver.Plugin.Build/ModulePackCommand.cs +++ b/src/MeshWeaver.Plugin.Build/ModulePackCommand.cs @@ -382,7 +382,7 @@ the output folder. // The package tree, walked in a stable order so two packs of the same tree produce // byte-identical manifests. obj/bin are a developer's build residue, never package content - // — the same exclusions PluginPacker applies. + // — the exclusions the retired node-package packer applied (PluginPacker, removed 2026-08-30). // 🚨 STATIC WEB ASSETS ride the bundle. A view pack's CSS/JS reaches the browser through // the host's build-time static-web-assets graph — i.e. through the ProjectReference the // module lane removes — so without this a landed view pack renders unstyled and its diff --git a/src/MeshWeaver.Plugin.Build/PluginPacker.cs b/src/MeshWeaver.Plugin.Build/PluginPacker.cs deleted file mode 100644 index 20ed921416..0000000000 --- a/src/MeshWeaver.Plugin.Build/PluginPacker.cs +++ /dev/null @@ -1,165 +0,0 @@ -using System.Collections.Immutable; -using System.IO.Compression; -using System.Security; -using System.Text; -using System.Text.Json; - -using MeshWeaver.Plugin.Packaging; - -namespace MeshWeaver.Plugin.Build; - -/// -/// Writes a plugin's .nupkg: the node content it already ships, plus the prebuilt assembly -/// for every compilation unit, plus a manifest binding one to the other. -/// -public static class PluginPacker -{ - /// - /// 🚨 Assemblies go under meshweaver/assemblies/, NOT lib/net10.0/. - /// - /// A plugin's units are compiled SEPARATELY at runtime and may legitimately declare the - /// same type names — TaskAssignmentService exists in two UWDeepfield units. Under - /// lib/ NuGet would surface all of them as compile-time references of any consumer, so - /// those duplicates would collide and the CLR identity the runtime deliberately keeps separate - /// would be unified. These assemblies are payload for the assembly store, not a reference set. - /// - private const string AssemblyFolder = "meshweaver/assemblies"; - - /// Node content, shipped verbatim so install stays a copy rather than a re-render. - private const string ContentFolder = "meshweaver/content"; - - /// Packs the plugin and returns the written package path. - /// The plugin root in a repo checkout. - /// Metadata projected from index.json. - /// The plugin's compilation units. - /// Where wrote its projects. - /// The MeshWeaver version the assemblies were compiled against — - /// recorded in the package manifest because it is what HasUsableBuild compares at - /// activation. An assembly whose framework version cannot be established must be recompiled, - /// never loaded on faith. - /// Directory to write the .nupkg into. - public static string Pack( - string pluginDirectory, - PluginManifest manifest, - ImmutableArray units, - string buildOutputRoot, - string frameworkVersion, - string outputDirectory) - { - Directory.CreateDirectory(outputDirectory); - var packagePath = Path.Combine(outputDirectory, $"{manifest.PackageId}.{manifest.Version}.nupkg"); - if (File.Exists(packagePath)) - File.Delete(packagePath); - - var entries = new List(); - var assemblies = new List(); - - foreach (var unit in units) - { - var dll = Path.Combine(buildOutputRoot, unit.UnitName, "bin", "Release", "net10.0", unit.UnitName + ".dll"); - if (!File.Exists(dll)) - throw new InvalidOperationException( - $"missing assembly for {unit.NodePath} at {dll} — pack requires a completed build; " + - "a package with a partial assembly set is worse than none, because a consumer " + - "resolves the gap as a runtime TypeLoadException instead of a compile error"); - - var path = dll; - entries.Add(new NuGetPackageWriter.Entry( - $"{NuGetPackageWriter.AssemblyFolder}/{unit.UnitName}.dll", - () => File.OpenRead(path))); - assemblies.Add(new { nodePath = unit.NodePath, assembly = $"{unit.UnitName}.dll" }); - } - - foreach (var file in Directory.EnumerateFiles(pluginDirectory, "*", SearchOption.AllDirectories)) - { - var relative = Path.GetRelativePath(pluginDirectory, file).Replace(Path.DirectorySeparatorChar, '/'); - // obj/bin are build residue from a developer's tree, never plugin content. - if (relative.StartsWith("obj/", StringComparison.Ordinal) - || relative.StartsWith("bin/", StringComparison.Ordinal) - || relative.StartsWith(".worktrees/", StringComparison.Ordinal)) - continue; - var path = file; - entries.Add(new NuGetPackageWriter.Entry( - $"{NuGetPackageWriter.ContentFolder}/{relative}", () => File.OpenRead(path))); - } - - var manifestJson = JsonSerializer.Serialize( - new - { - plugin = manifest.Name, - version = manifest.Version, - frameworkVersion, - // 🚨 The identity an installer must check before seeding these bytes into the - // assembly store. The runtime compares MVIDs, not version strings. - frameworkMvid = FrameworkIdentity.ResolveFrameworkMvid(frameworkVersion), - assemblies, - }, - new JsonSerializerOptions { WriteIndented = true }); - - using (var output = File.Create(packagePath)) - NuGetPackageWriter.Write(output, manifest, frameworkVersion, entries, manifestJson); - - return packagePath; - } - - private static void Write(ZipArchive archive, string path, string content) - { - using var stream = archive.CreateEntry(path).Open(); - using var writer = new StreamWriter(stream, Encoding.UTF8); - writer.Write(content); - } - - private static string BuildNuspec(PluginManifest manifest, string frameworkVersion) - { - var sb = new StringBuilder(); - sb.AppendLine(""""""); - sb.AppendLine(""""""); - sb.AppendLine(" "); - sb.AppendLine($" {SecurityElement.Escape(manifest.PackageId)}"); - sb.AppendLine($" {SecurityElement.Escape(manifest.Version)}"); - sb.AppendLine($" {SecurityElement.Escape(manifest.Description)}"); - sb.AppendLine(" Systemorph"); - sb.AppendLine(" "); - sb.AppendLine(""" """); - - // 🚨 A MINIMUM, not an exact pin. The bare form is NuGet's ">= this version". - // - // The version the package was built against is a floor, not a requirement: the bake - // recompiles the plugin's source against whatever the consumer resolves, so a NEWER - // framework satisfies it. Pinning it exactly (`[x]`) would make every framework bump - // require republishing all ~29 plugins at new versions — and since a module's PATCH is - // derived from its CONTENT hash, those republishes would carry the same version as the - // unchanged tree, which the immutability rule forbids. The package would be unshippable - // without inventing a version the repo never released. - // - // The prebuilt assemblies remain an OPTIMISATION on top: PrebuiltAssemblySeeder adopts them - // only when the framework MVID matches exactly, and compiles when it does not. So a floor - // here never risks loading ABI-incompatible bytes — that gate is separate and stricter. - sb.AppendLine($""" """); - - foreach (var (id, range) in manifest.ResolveDependencies()) - sb.AppendLine(range is null - ? $""" """ - : $""" """); - - sb.AppendLine(" "); - sb.AppendLine(" "); - sb.AppendLine(" "); - sb.AppendLine(""); - return sb.ToString(); - } - - private const string ContentTypes = """ - - - - - - - - - - - - """; -} diff --git a/src/MeshWeaver.Plugin.Build/PluginUnit.cs b/src/MeshWeaver.Plugin.Build/PluginUnit.cs deleted file mode 100644 index abaff04cfe..0000000000 --- a/src/MeshWeaver.Plugin.Build/PluginUnit.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Collections.Immutable; - -namespace MeshWeaver.Plugin.Build; - -/// -/// One compilation unit of a plugin — the build-time mirror of what the portal compiles at -/// runtime for a single NodeType. -/// -/// 🚨 The unit is a Source/ directory, NOT a plugin. A plugin has many: UWDeepfield -/// has eleven (its root plus one per NodeType). They are compiled SEPARATELY at runtime, which is -/// why two of them may legitimately declare the same type name — TaskAssignmentService -/// exists in both UwPortfolio/Source and UWDeepfieldHome/Source. Merging a plugin's -/// units into one assembly produces ~200 bogus CS0111s and is the first thing anyone gets wrong. -/// -/// Mesh path of the NodeType that owns this unit (e.g. UWDeepfield/UwPortfolio). -/// Absolute path of the unit's own Source/ directory. -/// Every directory whose *.cs feeds this compilation — own Source first, -/// then each resolved shared= include, in declaration order. -/// The raw sources queries off the node, kept for diagnostics: -/// when a closure resolves short, the unresolved query is what you need to see. -public sealed record PluginUnit( - string NodePath, - string SourceDirectory, - ImmutableArray Closure, - ImmutableArray DeclaredSources) -{ - /// - /// The assembly / package-subdirectory name for this unit — the node path with separators - /// flattened, matching the runtime's own SanitizeNodeName shape so a prebuilt artifact - /// lands where the assembly store already looks for it. - /// - public string UnitName => NodePath.Replace('/', '_'); -} diff --git a/src/MeshWeaver.Plugin.Build/PluginUnitResolver.cs b/src/MeshWeaver.Plugin.Build/PluginUnitResolver.cs deleted file mode 100644 index 6fc5aa632b..0000000000 --- a/src/MeshWeaver.Plugin.Build/PluginUnitResolver.cs +++ /dev/null @@ -1,188 +0,0 @@ -using System.Collections.Immutable; -using System.Text.Json; -using System.Text.RegularExpressions; - -namespace MeshWeaver.Plugin.Build; - -/// -/// Discovers a plugin's compilation units in a repo checkout and resolves each one's source -/// closure from the sources queries declared on the owning node — the same closure the -/// portal's compiler assembles at runtime, resolved here against the filesystem instead of the mesh. -/// -public static partial class PluginUnitResolver -{ - /// - /// An include in any of the THREE forms a node's sources may declare: - /// - /// path — shared=@Store/Coupon/Source - /// query — shared=namespace:UWDeepfield/Source scope:subtree - /// ALIASED query — client=namespace:UWDeepfield/ReinsuranceClient/Source scope:subtree - /// - /// - /// 🚨 Every one of these is a SILENT under-resolution when unmatched: the include is - /// skipped, the unit compiles without it, and Roslyn reports a thoroughly convincing CS0246 / - /// CS0103 on a symbol that does exist. Matching only the path form cost UWDeepfield its - /// 55-file root across six units; matching only shared= cost IndustryNewsFeed its - /// client= include. The prefix is an arbitrary author-chosen alias — shared, - /// news, client — so it must not be part of the match. - /// - /// An unresolvable target is dropped deliberately: a bare namespace:Source - /// scope:subtree IS the unit's own directory, already first in the closure, and it will - /// not resolve against any repo root. - /// - [GeneratedRegex(@"^(?:[A-Za-z_][A-Za-z0-9_]*=)?(?:@|(?:namespace|path):)(?[A-Za-z0-9/._-]+)")] - private static partial Regex IncludeForm(); - - /// - /// Enumerates every compilation unit under . - /// - /// A plugin root in a repo checkout (the directory holding index.json). - /// Checkout roots a shared= mesh path may resolve against — plugins - /// reference each other across repos, so the search is over all of them, first hit wins. - /// The units, ordered by node path for a stable build order. - public static ImmutableArray Resolve(string pluginDirectory, IReadOnlyList repoRoots) - { - var pluginName = Path.GetFileName(pluginDirectory.TrimEnd(Path.DirectorySeparatorChar)); - var units = new List(); - - foreach (var sourceDir in Directory - .EnumerateDirectories(pluginDirectory, "Source", SearchOption.AllDirectories) - .Where(d => !d.Contains($"{Path.DirectorySeparatorChar}.worktrees{Path.DirectorySeparatorChar}", - StringComparison.Ordinal)) - .OrderBy(d => d, StringComparer.Ordinal)) - { - var owner = Path.GetDirectoryName(sourceDir)!; - var nodePath = owner == pluginDirectory.TrimEnd(Path.DirectorySeparatorChar) - ? pluginName - : $"{pluginName}/{Path.GetRelativePath(pluginDirectory, owner).Replace(Path.DirectorySeparatorChar, '/')}"; - - // 🚨 A Source/ directory is a compilation unit ONLY when a NodeType owns it. Of the 774 - // Source directories across the four repos just 221 qualify; the rest are: - // * SHARED-SOURCE libraries with no node at all (`Claims/SampleData/Source`, pulled in - // by consumers via `shared=@Claims/SampleData/Source`) — 552 of them; - // * a directory whose sibling node is a MARKDOWN page describing the sample data - // rather than declaring a type (`Claims/SampleData.json`). - // Building either standalone reports CS0246 for every type it legitimately borrows from - // its consumer — a false alarm on healthy content, which is worse than no check at all. - if (!IsCompilationUnitOwner(owner)) - continue; - - var declared = ReadDeclaredQueries(owner, "sources"); - var declaredTests = ReadDeclaredQueries(owner, "tests"); - units.Add(new PluginUnit( - nodePath, - sourceDir, - BuildClosure(sourceDir, declared.AddRange(declaredTests), repoRoots), - declared)); - } - - return [.. units]; - } - - /// - /// Reads a query array (sources or tests) off the node that owns a Source/ - /// directory. The node file is either <Owner>/index.json or <Owner>.json - /// beside it. - /// - private static ImmutableArray ReadDeclaredQueries(string ownerDirectory, string property) - { - var candidate = FindNodeFile(ownerDirectory); - if (candidate is not null) - { - // Same reason as IsCompilationUnitOwner: swallowing a parse failure here would drop the - // node's DECLARED includes and compile the unit against a short closure, producing a - // convincing CS0246 on a symbol that does exist — the harness blaming the author. - using var doc = ParseOrThrow(candidate); - if (doc.RootElement.TryGetProperty("content", out var content) - && content.TryGetProperty(property, out var queries) - && queries.ValueKind == JsonValueKind.Array) - { - return [.. queries.EnumerateArray() - .Select(e => e.GetString()) - .Where(s => !string.IsNullOrWhiteSpace(s)) - .Select(s => s!)]; - } - } - return []; - } - - /// - /// The node file describing a directory — <Owner>/index.json or - /// <Owner>.json beside it — or null when the directory is not a JSON-authored node. - /// - private static string? FindNodeFile(string ownerDirectory) => - new[] { Path.Combine(ownerDirectory, "index.json"), ownerDirectory + ".json" } - .FirstOrDefault(File.Exists); - - /// - /// Whether a directory's node declares something the portal compiles: a - /// NodeTypeDefinition (a NodeType, including a Space that owns a partition) or a - /// PluginContent root that carries its own shared model. - /// - private static bool IsCompilationUnitOwner(string ownerDirectory) - { - var nodeFile = FindNodeFile(ownerDirectory); - if (nodeFile is null) - return false; - - // 🚨 A malformed node file is NOT "not a unit". Swallowing the JsonException here would - // drop the unit from the build and report success — a false green precisely when the node - // is broken, which is the one case a build must not wave through. Fail loudly, naming the - // file: a bad node file is an authoring error with an obvious fix. - using var doc = ParseOrThrow(nodeFile); - return doc.RootElement.TryGetProperty("content", out var content) - && content.TryGetProperty("$type", out var type) - && type.GetString() is "NodeTypeDefinition" or "PluginContent"; - } - - /// Parses a node file, or throws naming it. - private static JsonDocument ParseOrThrow(string nodeFile) - { - try - { - return JsonDocument.Parse(File.ReadAllText(nodeFile)); - } - catch (JsonException ex) - { - throw new InvalidOperationException($"malformed node file: {nodeFile} — {ex.Message}", ex); - } - } - - /// - /// Own Source/ plus every resolvable shared= include, de-duplicated, order preserved. - /// A namespace:Source scope:subtree query needs no resolution — it IS the unit's own - /// directory, already first in the closure. - /// - private static ImmutableArray BuildClosure( - string sourceDir, ImmutableArray declared, IReadOnlyList repoRoots) - { - var closure = new List { sourceDir }; - - // 🚨 Tests are part of the COMPILATION, not a separate project. A NodeType's default - // `tests` query is `namespace:Test scope:subtree`, and the runtime folds it into the same - // assembly — the live Store/Plugin node's compiledSources lists `Store/Plugin/Test/*` - // right beside its Source. Omit it and production code that references a test type (an - // area rendering its own test results, as IndustryNewsFeed does) fails CS0103 in CI while - // compiling perfectly in the portal. - var testDir = Path.Combine(Path.GetDirectoryName(sourceDir)!, "Test"); - if (Directory.Exists(testDir)) - closure.Add(testDir); - - foreach (var query in declared) - { - var match = IncludeForm().Match(query); - if (!match.Success) - continue; - - var target = match.Groups["target"].Value.TrimEnd('.'); - var resolved = repoRoots - .Select(root => Path.Combine(root, target.Replace('/', Path.DirectorySeparatorChar))) - .FirstOrDefault(Directory.Exists); - - if (resolved is not null && !closure.Contains(resolved, StringComparer.Ordinal)) - closure.Add(resolved); - } - - return [.. closure]; - } -} diff --git a/src/MeshWeaver.Plugin.Build/Program.cs b/src/MeshWeaver.Plugin.Build/Program.cs index eb4b7d9b86..26b87c7849 100644 --- a/src/MeshWeaver.Plugin.Build/Program.cs +++ b/src/MeshWeaver.Plugin.Build/Program.cs @@ -1,225 +1,26 @@ -using MeshWeaver.Plugin.Packaging; -using System.Diagnostics; using MeshWeaver.Plugin.Build; -// meshweaver-plugin-build [--out ] [--framework-version ] -// [--repo-root ...] [--no-build] -// -// Resolves a plugin's compilation units the way the portal does, emits one .csproj per unit, and -// (unless --no-build) builds them. Exit code 0 only when EVERY unit built: a partial plugin is -// worse than an unbuilt one, because a consumer resolving a mixed set gets a silent ABI mismatch. - -// The module-pack verb (#1664): packs a built MODULE's closure into a bundle keyed to the -// framework MVID it was compiled against — invocable from any node repo's CI. Everything below -// this dispatch is the classic per-NodeType plugin build. +// 🚨 NO NuGet ANYWHERE IN THIS TOOL. Until 2026-08-30 the bare verb packed a node package by +// emitting a csproj that referenced the MeshWeaver.* NuGet packages at a "floor" (the newest +// version every package was published at) and ran `dotnet build` on it. That floor stopped at +// 3.0.0-rc7 the day MeshWeaver.AI and MeshWeaver.Markdown.Collaboration left the platform repo, so +// every plugin using an rc8 type failed to pack, and the proposed cure was a NuGet publish token. +// The maintainer's rule: in-mesh source runs INSIDE the portal image, so the only honest reference +// set is that image's assemblies plus the modules its publication was sealed against — which is +// exactly what node-repo-compile-check.yml and the gates compile against. Nothing a node repo +// builds goes to a package feed; the registry serves BUNDLES assembled from sealed publications. if (args.Length > 0 && args[0] == ModulePackCommand.Verb) return ModulePackCommand.Run(args.Skip(1).ToArray()); -// The module-fetch verb: the CONSUMING half. A repo that depends on another fetches its RELEASED -// package here instead of cloning and recompiling it — the mechanism "build only what you own" -// needs (Doc/Architecture/ReleaseGates). if (args.Length > 0 && args[0] == ModuleFetchCommand.Verb) return ModuleFetchCommand.Run(args.Skip(1).ToArray()); -if (args.Length == 0 || args[0] is "-h" or "--help") -{ - Console.WriteLine(""" - usage: meshweaver-plugin-build [options] - meshweaver-plugin-build module-pack [options] (see module-pack --help) - meshweaver-plugin-build module-fetch [options] (see module-fetch --help) - - --out where to write generated projects (default: ./obj/plugin-build) - --framework-version MeshWeaver package version to compile against, or `latest` - to resolve the newest from --source. Required — there is no - default, because a stale one compiles silently. - --repo-root checkout root for resolving shared= includes (repeatable; - defaults to the plugin's parent directory) - --no-build emit projects only - --pack after a fully successful build, write a .nupkg here - --package-version version for manifests that declare none (most do not) - --source extra package source (repeatable). Point at the framework - built from the current change to make this an ABI gate. - """); - return 0; -} - -var pluginDirectory = Path.GetFullPath(args[0]); -if (!Directory.Exists(pluginDirectory)) -{ - Console.Error.WriteLine($"error: plugin directory not found: {pluginDirectory}"); - return 2; -} - -var outputDirectory = Path.Combine(Environment.CurrentDirectory, "obj", "plugin-build"); -string? frameworkVersion = null; -var repoRoots = new List(); -var build = true; -string? packDirectory = null; -var restoreSources = new List(); -var packageVersion = "0.0.1"; - -for (var i = 1; i < args.Length; i++) -{ - switch (args[i]) - { - case "--out" when i + 1 < args.Length: - outputDirectory = Path.GetFullPath(args[++i]); - break; - case "--framework-version" when i + 1 < args.Length: - frameworkVersion = args[++i]; - break; - case "--repo-root" when i + 1 < args.Length: - repoRoots.Add(Path.GetFullPath(args[++i])); - break; - case "--no-build": - build = false; - break; - case "--pack" when i + 1 < args.Length: - packDirectory = Path.GetFullPath(args[++i]); - break; - case "--package-version" when i + 1 < args.Length: - packageVersion = args[++i]; - break; - case "--source" when i + 1 < args.Length: - restoreSources.Add(args[++i]); - break; - default: - Console.Error.WriteLine($"error: unrecognised argument '{args[i]}'"); - return 2; - } -} - -if (repoRoots.Count == 0) - repoRoots.Add(Path.GetDirectoryName(pluginDirectory.TrimEnd(Path.DirectorySeparatorChar))!); - -if (restoreSources.Count == 0) - restoreSources.Add("https://api.nuget.org/v3/index.json"); - -// 🚨 No default version. A hard-coded one compiles every plugin against whatever was current when -// it was written — silently, because that framework is real and the build succeeds. Resolving -// `latest` records the FULL version including the .ci. suffix, which is the part that says -// which build the API came from. -if (string.IsNullOrWhiteSpace(frameworkVersion)) -{ - Console.Error.WriteLine( - "error: --framework-version is required (an explicit version, or `latest` to resolve the " - + "newest from --source)"); - return 2; -} - -using var versionHttp = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; -try -{ - frameworkVersion = FrameworkVersionResolver.Resolve(frameworkVersion, restoreSources, versionHttp); -} -catch (InvalidOperationException ex) -{ - Console.Error.WriteLine($"error: {ex.Message}"); - return 2; -} - -Console.WriteLine($"framework: {frameworkVersion}"); - -// A malformed node file fails the build deliberately (silently skipping it would report success -// while testing less than claimed) — but it is an authoring error with an obvious fix, so it is -// reported as one line naming the file, not as an unhandled exception. A stack trace here says -// "the tool broke" when the truth is "this file has a typo". -System.Collections.Immutable.ImmutableArray units; -try -{ - units = PluginUnitResolver.Resolve(pluginDirectory, repoRoots); -} -catch (InvalidOperationException ex) -{ - Console.Error.WriteLine($"error: {ex.Message}"); - return 1; -} -if (units.Length == 0) -{ - // Content-only plugins are legitimate and common (courses, agent/skill packs): six of the - // twenty plugins in MeshWeaver.Plugins carry no C# at all. Nothing to compile is a SUCCESS. - Console.WriteLine($"{Path.GetFileName(pluginDirectory)}: no compilation units (content-only plugin)"); - return 0; -} - -Console.WriteLine($"{Path.GetFileName(pluginDirectory)}: {units.Length} compilation unit(s)"); - -var failures = 0; -foreach (var unit in units) -{ - var projectPath = ProjectEmitter.Emit(unit, frameworkVersion, outputDirectory, restoreSources); - var shared = unit.Closure.Length - 1; - Console.WriteLine($" {unit.NodePath} ({shared} shared include(s))"); - - if (!build) - continue; - - var process = Process.Start(new ProcessStartInfo("dotnet") - { - ArgumentList = { "build", projectPath, "-c", "Release", "--nologo" }, - RedirectStandardOutput = true, - RedirectStandardError = true, - })!; - // 🚨 BOTH reads must be started before either is awaited. Reading stdout to completion first - // deadlocks as soon as the child fills stderr's pipe buffer: it blocks writing stderr, so it - // never closes stdout, so this never returns. `dotnet build` emits plenty of both on a failing - // unit — precisely the case this tool exists to report. - var standardOutput = process.StandardOutput.ReadToEndAsync(); - var standardError = process.StandardError.ReadToEndAsync(); - Task.WaitAll(standardOutput, standardError); - process.WaitForExit(); - var output = standardOutput.Result + standardError.Result; - - if (process.ExitCode == 0) - continue; - - failures++; - Console.Error.WriteLine($" FAILED {unit.NodePath}"); - // 🚨 Match "error " generally, NOT "error CS". A unit fails for reasons the compiler never - // reaches: NU1605 when a referenced package has no build at the framework version, MSB when - // the SDK is wrong, a child that dies before emitting a diagnostic at all. Filtering to CS - // discarded ALL of those and printed a bare "FAILED", which is how 33 of 33 code-bearing - // packages once failed across a whole lane with not one diagnostic anywhere in the log. - var diagnostics = output - .Split('\n') - .Where(l => l.Contains("error ", StringComparison.Ordinal)) - .Take(5) - .ToArray(); - // Nothing matched: the build still failed, so it said SOMETHING. Print the tail rather than - // nothing — an unrecognised failure is exactly the case where the raw output is all there is. - if (diagnostics.Length == 0) - diagnostics = output - .Split('\n') - .Where(l => !string.IsNullOrWhiteSpace(l)) - .TakeLast(15) - .ToArray(); - foreach (var line in diagnostics) - Console.Error.WriteLine($" {line.Trim()}"); - // A silent child is itself the finding — say so instead of leaving the reader to wonder - // whether the output was empty or merely filtered away. - if (diagnostics.Length == 0) - Console.Error.WriteLine( - $" (dotnet build exited {process.ExitCode} without writing any output)"); - // A closure that resolved short is the likeliest cause, and the declared queries are the - // only place that shows it — print them rather than making the next person go find the node. - if (unit.DeclaredSources.Length > 0) - Console.Error.WriteLine($" declared sources: {string.Join(" | ", unit.DeclaredSources)}"); -} - -if (failures > 0) -{ - Console.Error.WriteLine($"{failures} of {units.Length} unit(s) failed"); - return 1; -} - -Console.WriteLine($"all {units.Length} unit(s) {(build ? "built" : "emitted")}"); - -if (packDirectory is null) - return 0; +Console.Error.WriteLine(""" + usage: meshweaver-plugin-build module-pack [options] (see module-pack --help) + meshweaver-plugin-build module-fetch [options] (see module-fetch --help) -// Reached only on a fully successful build — see the all-or-nothing note in the help text. -var manifest = PluginManifest.Read(pluginDirectory, packageVersion); -var packagePath = PluginPacker.Pack( - pluginDirectory, manifest, units, outputDirectory, frameworkVersion, packDirectory); -Console.WriteLine($"packed {Path.GetFileName(packagePath)}"); -return 0; + The node-package pack verb (NuGet-floor compile + .nupkg) was retired on 2026-08-30: in-mesh + source is type-checked against the platform IMAGE by node-repo-compile-check.yml, and packages + reach consumers as bundles from a sealed publication, never from a package feed. + """); +return args.Length == 0 || args[0] is "-h" or "--help" ? 0 : 2; diff --git a/src/MeshWeaver.Plugin.Build/ProjectEmitter.cs b/src/MeshWeaver.Plugin.Build/ProjectEmitter.cs deleted file mode 100644 index 9560dd28a2..0000000000 --- a/src/MeshWeaver.Plugin.Build/ProjectEmitter.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System.Security; -using System.Text; - -namespace MeshWeaver.Plugin.Build; - -/// -/// Writes the .csproj that builds one — the bridge between the -/// mesh's declarative sources and MSBuild. -/// -public static class ProjectEmitter -{ - /// - /// Emits <unitName>/<unitName>.csproj under - /// and returns its path. - /// - /// The unit to build. - /// MeshWeaver package version every reference is pinned to. This is - /// the value that must also be stamped on the produced release, because it is what - /// HasUsableBuild compares against at activation. - /// Directory to write project folders into. - /// Extra package sources. Supplying the framework built from the - /// CURRENT change turns this build into an ABI gate: the 181 in-mesh units are compiled against - /// the candidate framework, so deleting a public symbol any plugin uses fails on the PR rather - /// than as a CompileError overlay in production. Emitted as RestoreSources, which REPLACES - /// the configured sources — sidestepping a global packageSourceMapping that would - /// otherwise reject an added source outright. - public static string Emit( - PluginUnit unit, - string frameworkVersion, - string outputDirectory, - IReadOnlyList? restoreSources = null) - { - var projectDirectory = Path.Combine(outputDirectory, unit.UnitName); - Directory.CreateDirectory(projectDirectory); - - var sb = new StringBuilder(); - sb.AppendLine(""); - sb.AppendLine(); - sb.AppendLine($" "); - sb.AppendLine(); - sb.AppendLine(" "); - sb.AppendLine(" net10.0"); - sb.AppendLine(" enable"); - // The unit supplies its own preamble (CompilationEnvironment.Usings); the SDK's implicit - // set is a DIFFERENT list and layering both makes it unclear which one a symbol came from. - sb.AppendLine(" disable"); - sb.AppendLine(" false"); - sb.AppendLine(" false"); - // One artifact instead of a DLL/PDB pair to keep in sync across package, blob store and - // release record — and the symbols survive into production stack traces. - sb.AppendLine(" embedded"); - sb.AppendLine($" {SecurityElement.Escape(unit.UnitName)}"); - sb.AppendLine(" "); - // In-mesh source is authored without XML docs; CS1591 would drown the real diagnostics. - sb.AppendLine(" $(NoWarn);CS1591"); - if (restoreSources is { Count: > 0 }) - sb.AppendLine( - $" {SecurityElement.Escape(string.Join(';', restoreSources))}"); - sb.AppendLine(" "); - sb.AppendLine(); - - sb.AppendLine(" "); - foreach (var directory in unit.Closure) - sb.AppendLine($" "); - // The generated skeleton — assembly attribute, provider class, and the ConfigureHub method - // wrapping the `configuration` lambda. Without it the assembly carries the user's types and - // registers NOTHING, and the lambda (C# inside a JSON string) stays unchecked by any - // compiler — the 2026-08-09 AddTracking() outage. - if (SkeletonGenerator.Emit(unit, projectDirectory) is not null) - sb.AppendLine($" "); - sb.AppendLine(" "); - sb.AppendLine(); - - sb.AppendLine(" "); - foreach (var @using in CompilationEnvironment.Usings) - sb.AppendLine($" "); - sb.AppendLine(" "); - sb.AppendLine(); - - sb.AppendLine(" "); - foreach (var package in CompilationEnvironment.PackageIds) - sb.AppendLine( - $" "); - sb.AppendLine(" "); - sb.AppendLine(); - sb.AppendLine(""); - - var projectPath = Path.Combine(projectDirectory, unit.UnitName + ".csproj"); - File.WriteAllText(projectPath, sb.ToString()); - return projectPath; - } -} diff --git a/src/MeshWeaver.Plugin.Build/README.md b/src/MeshWeaver.Plugin.Build/README.md index ac499e1787..f4d4196a66 100644 --- a/src/MeshWeaver.Plugin.Build/README.md +++ b/src/MeshWeaver.Plugin.Build/README.md @@ -1,97 +1,30 @@ # MeshWeaver.Plugin.Build -Builds a plugin's **in-mesh C#** the way the portal compiles it at runtime — in CI, before it ships. +The build-side tool behind the module lane: -```bash -dotnet tool install -g MeshWeaver.Plugin.Build -meshweaver-plugin-build ./ThreeBody --framework-version 3.0.0-rc2 --pack ./nupkgs +``` +meshweaver-plugin-build module-pack [options] # a module's publish output → a registry bundle +meshweaver-plugin-build module-fetch [options] # a registry bundle → a local directory ``` -The tool is versioned **in lockstep with the framework** on purpose: it encodes how a given -MeshWeaver version compiles (using set, reference set, closure semantics). A separately-versioned -tool would drift from the runtime it exists to mirror. - -## Packaging - -`--pack` writes `MeshWeaver.Plugin...nupkg` containing the plugin's node content, -one prebuilt assembly per unit, and a manifest binding node paths to assemblies. The nuspec is a -projection of the mesh manifest that authors already write — `"requires": ["Store@^1.0.0"]` becomes -``. - -Assemblies go under `meshweaver/assemblies/`, **not** `lib/net10.0/`. Under `lib/` NuGet would -surface every unit as a compile-time reference of any consumer, colliding the duplicate type names -above and unifying CLR identity the runtime deliberately keeps separate. They are payload for the -assembly store, not a reference set. - -## Why - -Source stored in mesh nodes (`Source/*.cs` under a NodeType) compiles **at runtime, in the portal** -— never in `dotnet build`, never in a test. Across the four plugin repos that is hundreds of files -in a couple of hundred compilation units, and the portal recompiles every one of them whenever the -framework's content changes. The consequences are routine: a framework -symbol gets deleted, every check is green, and the breakage appears as `CompileError` overlays in -production against code the compiler was never shown. - -This tool closes that gap. It resolves each unit's closure from the node's own declared `sources`, -supplies the ambient environment the portal supplies, and compiles. - -## What a "unit" is - -**A `Source/` directory owned by a NodeType — not a plugin, and not every `Source/` directory.** - -UWDeepfield has eleven units: its root plus one per NodeType. They compile *separately* at runtime, -so two may legitimately declare the same type name (`TaskAssignmentService` exists in both -`UwPortfolio/Source` and `UWDeepfieldHome/Source`). Merging a plugin's units into one assembly -yields ~200 spurious `CS0111`s. - -Most `Source/` directories are not units at all — in the four plugin repos it is roughly one in -three. The rest are -**shared-source libraries** — `Claims/SampleData/Source` has no node at all and is pulled into its -consumers via `shared=@Claims/SampleData/Source`. Building one standalone reports `CS0246` for every -type it legitimately borrows from the consumer: a false alarm on healthy content, which is worse -than no check. A unit's owner declares `NodeTypeDefinition` or `PluginContent`; anything else -(a Markdown page describing the sample data, a course exercise authored as `index.md`) is skipped. - -## The generated skeleton - -Each unit compiles its sources **plus** a generated skeleton: the assembly attribute, the -`MeshNodeProviderAttribute` subclass, and the `ConfigureHub` method wrapping the NodeType's -`configuration` lambda. The tool calls the framework's own `DynamicMeshNodeAttributeGenerator` -rather than reproducing it, so CI and runtime emit identical source. - -Two reasons it is not optional: - -- **Without it the assembly is inert** — it carries the user's types and registers nothing, so it - cannot stand in for a runtime compile however cleanly it built. -- **The lambda is code no compiler has ever seen.** `content.configuration` is C# inside a JSON - string: invisible to `dotnet build` (node trees are ``) and to any `*.cs` grep. On - 2026-08-09 the framework deleted `AddTracking()` while `SocialMedia/Post`, `Profile` and - `PostsHub` each still called it from that field; CI was green and all three production portals - hit `REFUSING READINESS` on the next framework bump. - -## The three things a plain `.csproj` gets wrong - -1. **Includes have three syntaxes.** `shared=@Store/Coupon/Source` (path), - `shared=namespace:UWDeepfield/Source scope:subtree` (query), and an *aliased* - `client=namespace:UWDeepfield/ReinsuranceClient/Source scope:subtree`. Each unmatched form is a - silent under-resolution — the build then reports a thoroughly convincing `CS0246`/`CS0103` on a - symbol that does exist. -2. **`Test/` is part of the same assembly.** The live `Store/Plugin` node's `compiledSources` lists - `Store/Plugin/Test/*` beside its Source. Omit it and production code that references a test type - (an area rendering its own results, as `IndustryNewsFeed` does) fails `CS0103` in CI while - compiling perfectly in the portal. -3. **The ambient environment is not implicit.** In-mesh code compiles against every assembly the - portal has loaded, plus the generated skeleton's using preamble. Omit `MeshWeaver.Domain` and - `[MeshNode(…)]` binds to the `MeshNode` *record*, giving - `CS0616 'MeshNode' is not an attribute class`. See `CompilationEnvironment`. - -## Exit codes - -| Code | Meaning | -|---|---| -| 0 | every unit built (or the plugin is content-only — six of twenty carry no C#) | -| 1 | at least one unit failed; the first five errors and the unit's declared `sources` are printed | -| 2 | bad arguments | - -Failure is all-or-nothing on purpose: a partially built plugin is worse than an unbuilt one, -because a consumer resolving a mixed set gets an ABI mismatch that semver says is fine. +Run either verb with `--help` for its options. `module-pack` writes the bundle +(`MeshWeaver.Plugin...module.nupkg` — a zip in NuGet layout with the module's +assemblies under `meshweaver/modules/` and a manifest naming the entry assembly, its closure, its +`minMeshVersion` floor and the framework identity it was built against) that +`node-repo-module-pack.yml` uploads and, on a trunk push, POSTs to the registry. + +## What is deliberately NOT here + +Until 2026-08-30 the bare verb packed a **node package** (a Store/Plugin with in-mesh `Source/*.cs`) +by emitting a `.csproj` that referenced the `MeshWeaver.*` NuGet packages at a "floor" — the newest +version every referenced package was published at — running `dotnet build` on it and writing a +`.nupkg`. That is gone, for two reasons the maintainer stated: + +- **In-mesh source runs inside the portal image.** The only honest reference set is that image's + `/app` assemblies plus the modules its publication was sealed against — which is exactly what + `node-repo-compile-check.yml` type-checks against and what the gates run. A NuGet floor is a + different, older reference set (it stopped at rc7 the day `MeshWeaver.AI` left the platform repo), + and compiling against it recreated the very skew #2707 removed. +- **Nothing a node repo builds goes to a package feed.** Consumers fetch bundles assembled from a + sealed publication (`/api/plugins/bundles`), never packages from NuGet — so a `.nupkg` per node + package had no consumer, and a NuGet publish token had no place in CI. diff --git a/src/MeshWeaver.Plugin.Build/SkeletonGenerator.cs b/src/MeshWeaver.Plugin.Build/SkeletonGenerator.cs deleted file mode 100644 index cd5859a1de..0000000000 --- a/src/MeshWeaver.Plugin.Build/SkeletonGenerator.cs +++ /dev/null @@ -1,151 +0,0 @@ -using System.Text.Json; -using MeshWeaver.ContentCollections; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Mesh; - -using MeshWeaver.Compiler; -namespace MeshWeaver.Plugin.Build; - -/// -/// Emits the generated skeleton the portal compiles alongside a NodeType's own sources: the -/// assembly attribute, the MeshNodeProviderAttribute subclass with its Nodes property, -/// and the ConfigureHub method wrapping the NodeType's configuration lambda. -/// -/// 🚨 Without this the produced assembly is inert. It would carry the user's types and -/// nothing else — no provider attribute, so nothing registers the NodeType, so the assembly cannot -/// stand in for a runtime compile no matter how cleanly it built. Verified the hard way: an earlier -/// build of this tool packed a ThreeBody assembly containing NBodyAreas but no -/// MeshNodeProvider, ConfigureHub or MeshWeaver.Graph.Generated at all. -/// -/// 🚨 And the lambda is CODE. content.configuration is C# stored in a JSON -/// string, so no compiler has ever seen it: not dotnet build (node trees are -/// <None>), not a repo grep with a *.cs filter. On 2026-08-09 the framework -/// deleted AddTracking() while SocialMedia/Post, Profile and PostsHub -/// each still called it from that field; CI was green and all three production portals hit -/// REFUSING READINESS on the next framework bump. Compiling the skeleton type-checks it. -/// -/// The generation is delegated to — the -/// framework's own, reached through InternalsVisibleTo — never reproduced here. A second -/// implementation is free to drift from the one that actually runs, and a skeleton that differs -/// from the runtime's is worse than none: it compiles, packs, installs, and then behaves -/// differently from what every test exercised. -/// -public static class SkeletonGenerator -{ - /// File name of the emitted skeleton inside a unit's generated project. - public const string FileName = "__MeshWeaverSkeleton.g.cs"; - - /// - /// Writes the skeleton for into , - /// or returns null when the owning node cannot be read. - /// - public static string? Emit(PluginUnit unit, string projectDirectory) - { - var owner = Path.GetDirectoryName(unit.SourceDirectory)!; - var nodeFile = new[] { Path.Combine(owner, "index.json"), owner + ".json" } - .FirstOrDefault(File.Exists); - if (nodeFile is null) - return null; - - JsonElement root; - try - { - using var document = JsonDocument.Parse(File.ReadAllText(nodeFile)); - root = document.RootElement.Clone(); - } - catch (JsonException) - { - return null; - } - - var content = root.TryGetProperty("content", out var c) ? c : default; - - var node = new MeshNode( - Id: Text(root, "id") ?? Path.GetFileName(owner), - Namespace: Text(root, "namespace")) - { - Name = Text(root, "name") ?? string.Empty, - // The generator emits this verbatim into the Nodes property. A NodeType node's own - // nodeType is "NodeType"; a Space/plugin root carries its own — either way it is - // metadata, copied through. - NodeType = Text(root, "nodeType") ?? MeshNode.NodeTypePath, - Icon = Text(root, "icon") ?? string.Empty, - // 🚨 Carried through, not defaulted. The generator emits both verbatim into the Nodes - // property (`Order = …`, `LastModified = DateTimeOffset.Parse("…")`), so leaving them - // unset does not merely lose metadata — it makes the CI-built assembly DECLARE - // something different from what the runtime would declare for the same node - // (`0001-01-01` for a node the portal knows the real date of). The whole point of - // delegating to the framework's generator is that the two outputs agree; feeding it - // different inputs defeats that just as surely as reimplementing it. - Order = Number(root, "order"), - LastModified = Timestamp(root, "lastModified") ?? default, - }; - - var source = new DynamicMeshNodeAttributeGenerator().GenerateAttributeSource( - node, - // null suppresses user-code emission: the sources are separate Compile items, exactly - // as the runtime keeps them separate syntax trees (AssembleCompilationInputs). - codeFile: null, - hubConfiguration: Text(content, "configuration") ?? Text(content, "hubConfiguration"), - contentCollections: ReadContentCollections(content)); - - var path = Path.Combine(projectDirectory, FileName); - File.WriteAllText(path, source); - return path; - } - - private static int? Number(JsonElement element, string property) => - element.ValueKind == JsonValueKind.Object - && element.TryGetProperty(property, out var value) - && value.ValueKind == JsonValueKind.Number - && value.TryGetInt32(out var number) - ? number - : null; - - /// - /// A node timestamp. An unparsable value yields null (and therefore the default) rather than - /// throwing: a malformed date must not stop a plugin building, and the value is metadata. - /// - private static DateTimeOffset? Timestamp(JsonElement element, string property) => - Text(element, property) is { } text - && DateTimeOffset.TryParse( - text, System.Globalization.CultureInfo.InvariantCulture, - System.Globalization.DateTimeStyles.RoundtripKind, out var parsed) - ? parsed - : null; - - private static string? Text(JsonElement element, string property) => - element.ValueKind == JsonValueKind.Object - && element.TryGetProperty(property, out var value) - && value.ValueKind == JsonValueKind.String - ? value.GetString() - : null; - - /// - /// The node's declared content collections. They contribute an AddContentCollections(…) - /// block to ConfigureHub, so an assembly built without them would register a hub missing - /// its collections — the view renders, the files are absent. - /// - private static List? ReadContentCollections(JsonElement content) - { - if (content.ValueKind != JsonValueKind.Object - || !content.TryGetProperty("contentCollections", out var collections) - || collections.ValueKind != JsonValueKind.Array) - return null; - - var result = new List(); - foreach (var element in collections.EnumerateArray()) - { - var name = Text(element, "name"); - if (string.IsNullOrWhiteSpace(name)) - continue; - result.Add(new ContentCollectionConfig - { - Name = name, - SourceType = Text(element, "sourceType") ?? string.Empty, - DisplayName = Text(element, "displayName"), - }); - } - return result.Count > 0 ? result : null; - } -} diff --git a/test/MeshWeaver.Graph.Test/FrameworkFloorTest.cs b/test/MeshWeaver.Graph.Test/FrameworkFloorTest.cs deleted file mode 100644 index 53854d0330..0000000000 --- a/test/MeshWeaver.Graph.Test/FrameworkFloorTest.cs +++ /dev/null @@ -1,108 +0,0 @@ -#pragma warning disable CS1591 - -using MeshWeaver.Plugin.Build; -using Xunit; - -namespace MeshWeaver.Graph.Test; - -/// -/// The framework FLOOR () is the newest version at -/// which EVERY package in is published — not the -/// newest MeshWeaver.Graph. -/// -/// Resolving it from one package is a latent outage. Every emitted project references all of -/// them, so a version where one has no build is not a floor any plugin can claim: NuGet resolves -/// the missing package to whatever else it can find and the restore dies with an NU1605 downgrade -/// before compiling a line. When MeshWeaver.AI's source moved out of the platform repo the -/// platform stopped publishing it — the framework released 3.0.0-rc8, MeshWeaver.AI -/// stopped at 3.0.0-rc7, and 33 of 33 code-bearing packages failed to pack. -/// -/// Local directory sources are enough to pin this: the resolver globs -/// {packageId}.*.nupkg, so a directory of empty files IS a feed, and the test needs no -/// network. -/// -public class FrameworkFloorTest : IDisposable -{ - private readonly string feed = - Directory.CreateTempSubdirectory(nameof(FrameworkFloorTest)).FullName; - - private void Publish(string packageId, params string[] versions) - { - foreach (var version in versions) - File.WriteAllText(Path.Combine(feed, $"{packageId}.{version}.nupkg"), ""); - } - - /// Publishes every package at every version, so the feed is complete by default. - private void PublishAll(params string[] versions) - { - foreach (var package in CompilationEnvironment.PackageIds) - Publish(package, versions); - } - - private string Resolve() => - FrameworkVersionResolver.Resolve(FrameworkVersionResolver.Latest, [feed], new HttpClient()); - - [Fact] - public void ResolvesTheNewestVersionPublishedForEveryPackage() - { - PublishAll("3.0.0-rc6", "3.0.0-rc7"); - Assert.Equal("3.0.0-rc7", Resolve()); - } - - /// The regression this exists for: one package short at the newest version drops the - /// floor to the newest COMPLETE one, rather than naming a version that cannot restore. - [Fact] - public void DropsToTheNewestCompleteVersionWhenOnePackageIsNotPublishedThere() - { - PublishAll("3.0.0-rc6", "3.0.0-rc7"); - foreach (var package in CompilationEnvironment.PackageIds) - { - // MeshWeaver.AI is the one that actually stopped; assert on whichever it is, so the - // test keeps meaning if the list changes. - if (package == "MeshWeaver.AI") - continue; - Publish(package, "3.0.0-rc8"); - } - - Assert.Equal("3.0.0-rc7", Resolve()); - } - - /// A package no source can answer for must not empty the intersection — otherwise one - /// unreachable feed resolves nothing at all. - [Fact] - public void APackageAbsentFromEverySourceDoesNotConstrainTheFloor() - { - foreach (var package in CompilationEnvironment.PackageIds) - { - if (package == "MeshWeaver.AI") - continue; - Publish(package, "3.0.0-rc7", "3.0.0-rc8"); - } - - Assert.Equal("3.0.0-rc8", Resolve()); - } - - /// An explicit version is the caller's choice and is returned untouched — the floor - /// logic applies to latest, which is what CI passes. - [Fact] - public void AnExplicitVersionIsReturnedUnchanged() - { - PublishAll("3.0.0-rc7"); - Assert.Equal( - "3.0.0-rc8", - FrameworkVersionResolver.Resolve("3.0.0-rc8", [feed], new HttpClient())); - } - - /// Resolving nothing must throw rather than fall back to a hard-coded version. - [Fact] - public void NoCommonVersionThrows() - { - Publish("MeshWeaver.Graph", "3.0.0-rc8"); - Publish("MeshWeaver.AI", "3.0.0-rc7"); - - var ex = Assert.Throws(Resolve); - Assert.Contains("MeshWeaver.AI", ex.Message, StringComparison.Ordinal); - } - - public void Dispose() => Directory.Delete(feed, recursive: true); -} From 26e421afda34524c3010e63c3cd7bf62cdf40fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 30 Aug 2026 08:39:22 +0200 Subject: [PATCH 2/3] =?UTF-8?q?review:=20--for=20is=20required=20(no=20man?= =?UTF-8?q?ual=20check);=20record=20the=20seven=20deleted=20tool=20types?= =?UTF-8?q?=20in=20type-forwards.allow=20(deleted,=20not=20moved=20?= =?UTF-8?q?=E2=80=94=20a=20tool=20exe=20is=20never=20a=20module=20referenc?= =?UTF-8?q?e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .github/scripts/node-repo-scope.py | 4 +--- scripts/type-forwards.allow | 8 ++++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/scripts/node-repo-scope.py b/.github/scripts/node-repo-scope.py index 0bc3e26b2a..b1b05975ba 100755 --- a/.github/scripts/node-repo-scope.py +++ b/.github/scripts/node-repo-scope.py @@ -749,7 +749,7 @@ def check(name: str, ok: bool, detail: str = "") -> None: def main() -> int: p = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) - p.add_argument("--for", dest="lane", choices=("modules",)) + p.add_argument("--for", dest="lane", choices=("modules",), required=True) p.add_argument("--modules", help="the module matrix as JSON, or @file (--for modules)") p.add_argument("--root", default=".", help="the caller repo checkout") p.add_argument("--event", default="", help="the triggering GitHub event name") @@ -768,8 +768,6 @@ def main() -> int: args = p.parse_args() if args.self_test: return self_test() - if not args.lane: - p.error("--for {modules,packages} is required") entries: list[dict] = [] if args.lane == "modules": diff --git a/scripts/type-forwards.allow b/scripts/type-forwards.allow index 5139583126..ca8566586b 100644 --- a/scripts/type-forwards.allow +++ b/scripts/type-forwards.allow @@ -25,3 +25,11 @@ # $(MeshWeaverRoot) (core, MeshWeaver.Plugins, MeshWeaver.SocialMedia — the latter pinned # pre-split). A forwarder is impossible here: the base would have to reference the AI assembly, # which references the base (the documented reference-cycle case). +# #2721 — the NuGet-floor pack verb of the meshweaver-plugin-build TOOL was retired; its types are gone, not moved. +MeshWeaver.Plugin.Build:MeshWeaver.Plugin.Build.CompilationEnvironment #2721 — DELETED, not moved (retired NuGet-floor pack verb). MeshWeaver.Plugin.Build is a dotnet TOOL (an exe run by CI), never a module reference: no bundle, bake surface or NodeType compiles against it, so no shipped module can hold this TypeRef. Remove in the very next change after #2721 merges. +MeshWeaver.Plugin.Build:MeshWeaver.Plugin.Build.FrameworkVersionResolver #2721 — DELETED, not moved (retired NuGet-floor pack verb). MeshWeaver.Plugin.Build is a dotnet TOOL (an exe run by CI), never a module reference: no bundle, bake surface or NodeType compiles against it, so no shipped module can hold this TypeRef. Remove in the very next change after #2721 merges. +MeshWeaver.Plugin.Build:MeshWeaver.Plugin.Build.PluginPacker #2721 — DELETED, not moved (retired NuGet-floor pack verb). MeshWeaver.Plugin.Build is a dotnet TOOL (an exe run by CI), never a module reference: no bundle, bake surface or NodeType compiles against it, so no shipped module can hold this TypeRef. Remove in the very next change after #2721 merges. +MeshWeaver.Plugin.Build:MeshWeaver.Plugin.Build.PluginUnit #2721 — DELETED, not moved (retired NuGet-floor pack verb). MeshWeaver.Plugin.Build is a dotnet TOOL (an exe run by CI), never a module reference: no bundle, bake surface or NodeType compiles against it, so no shipped module can hold this TypeRef. Remove in the very next change after #2721 merges. +MeshWeaver.Plugin.Build:MeshWeaver.Plugin.Build.PluginUnitResolver #2721 — DELETED, not moved (retired NuGet-floor pack verb). MeshWeaver.Plugin.Build is a dotnet TOOL (an exe run by CI), never a module reference: no bundle, bake surface or NodeType compiles against it, so no shipped module can hold this TypeRef. Remove in the very next change after #2721 merges. +MeshWeaver.Plugin.Build:MeshWeaver.Plugin.Build.ProjectEmitter #2721 — DELETED, not moved (retired NuGet-floor pack verb). MeshWeaver.Plugin.Build is a dotnet TOOL (an exe run by CI), never a module reference: no bundle, bake surface or NodeType compiles against it, so no shipped module can hold this TypeRef. Remove in the very next change after #2721 merges. +MeshWeaver.Plugin.Build:MeshWeaver.Plugin.Build.SkeletonGenerator #2721 — DELETED, not moved (retired NuGet-floor pack verb). MeshWeaver.Plugin.Build is a dotnet TOOL (an exe run by CI), never a module reference: no bundle, bake surface or NodeType compiles against it, so no shipped module can hold this TypeRef. Remove in the very next change after #2721 merges. From 226b53fdf34e0aa158c1d9bdf4a91b1bd9b0a1cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 30 Aug 2026 08:39:44 +0200 Subject: [PATCH 3/3] review: --for stays optional for --self-test; every other run refuses without a lane, with the accurate message Co-Authored-By: Claude Fable 5 --- .github/scripts/node-repo-scope.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/scripts/node-repo-scope.py b/.github/scripts/node-repo-scope.py index b1b05975ba..644b1753a0 100755 --- a/.github/scripts/node-repo-scope.py +++ b/.github/scripts/node-repo-scope.py @@ -749,7 +749,7 @@ def check(name: str, ok: bool, detail: str = "") -> None: def main() -> int: p = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) - p.add_argument("--for", dest="lane", choices=("modules",), required=True) + p.add_argument("--for", dest="lane", choices=("modules",)) p.add_argument("--modules", help="the module matrix as JSON, or @file (--for modules)") p.add_argument("--root", default=".", help="the caller repo checkout") p.add_argument("--event", default="", help="the triggering GitHub event name") @@ -768,6 +768,8 @@ def main() -> int: args = p.parse_args() if args.self_test: return self_test() + if args.lane is None: + p.error("--for modules is required (only --self-test runs without a lane)") entries: list[dict] = [] if args.lane == "modules":