Skip to content
74 changes: 72 additions & 2 deletions .github/workflows/dotnet-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,12 @@ jobs:
# bar. The timeout is deliberately well below the shard job's 20 min — if the gate ever becomes
# the long pole it should fail and be re-scoped, not silently stretch every PR.
timeout-minutes: 12
# The failing CHECK and the node(s) it failed on, lifted verbatim from the tester's verdict
# line, so the required check downstream can name the real cause instead of guessing one.
# Empty whenever the gate died before producing a verdict (checkout fault, missing tester) —
# consumers must degrade to "see the job", never to an invented cause.
outputs:
failure: ${{ steps.gate.outputs.failure }}
steps:
- uses: actions/checkout@v6
with: { fetch-depth: 1 }
Expand Down Expand Up @@ -829,6 +835,7 @@ jobs:
ssh-key: ${{ secrets.PLUGINS_REPO_SSH_KEY }}
fetch-depth: 1
- name: "Compile + run the VITAL plugins against this PR"
id: gate
env:
# 🚨 The vital organs only — NOT the whole repo. This gate sits in the PR critical path,
# so it buys the most breakage-detection per minute rather than maximum coverage:
Expand Down Expand Up @@ -865,7 +872,40 @@ jobs:
# plugin-gate.allow is the plugins repo's known-debt ratchet: listed failures are
# tolerated, NEW failures fail this PR. Same one-way contract the plugins CI uses, so the
# two gates cannot disagree about what is acceptable.
time dotnet "$TESTER" "$STAGE" --allow "$STAGE/plugin-gate.allow"
#
# 🚨 The run is TEED, not swallowed: the log still streams to the job, and the copy is read
# afterwards for the tester's verdict line. `if !` (not `set +e`) so `set -e` stays armed
# for everything else, and PIPESTATUS[0] is the TESTER's code — tee always exits 0.
log="${RUNNER_TEMP:-/tmp}/plugin-gate.log"
status=0
if ! time dotnet "$TESTER" "$STAGE" --allow "$STAGE/plugin-gate.allow" 2>&1 | tee "$log"; then
status="${PIPESTATUS[0]}"
# tee always exits 0, so a non-zero pipeline with a zero head means tee itself failed.
if [ "$status" = "0" ]; then status=1; fi
fi
# The tester's verdict line IS the message — `GATE FAILED — tests: Store/Catalog — 1 new
# failure(s), …` (GateReport.FailedPrefix). Lifted verbatim: nothing here re-derives or
# re-words a cause, which is exactly how this job came to annotate a `tests` failure as
# "does not compile — a public API changed" and send investigators to diff public
# signatures that were fine (#1077).
verdict="$(grep -m1 -- '^GATE FAILED' "$log" || true)"
# Multi-line-safe even though the verdict is one line: a bare KEY=VALUE with an unexpected
# newline would corrupt every later output in the file.
{
echo "failure<<GATE_EOF"
echo "$verdict"
echo "GATE_EOF"
} >> "$GITHUB_OUTPUT"
if [ "$status" != "0" ]; then
if [ -n "$verdict" ]; then
echo "::error::$verdict"
else
# No verdict = the gate died before judging anything (missing tester, install fault,
# crash). Say THAT — an unexplained failure is cheaper than a wrong explanation.
echo "::error::The plugins gate failed before it produced a verdict — no check was judged. See this job's log."
fi
exit "$status"
fi

collect-results:
name: "Consolidate test results"
Expand Down Expand Up @@ -1033,10 +1073,40 @@ jobs:
# 🚨 `needs:` alone is NOT enough: this job runs with `if: always()`, so a failed dependency
# does not fail it — it would report success while the plugins are broken, which is precisely
# the silent-green failure the gate exists to end.
# 🚨 NAME THE PHASE THAT FAILED — never a guessed cause. The gate judges three checks per node
# (compile / render / tests) and its verdict line says which one failed on which node; this
# step relays that line verbatim. The message it replaced asserted ONE cause for all three:
#
# "MeshWeaver.Plugins does not compile against this PR … A public API change here breaks
# plugin node source that is Roslyn-compiled at runtime"
#
# …which is the most expensive wrong lead available. A public-API break here genuinely DOES
# break that repo — plugin node source is Roslyn-compiled from mesh content, so a repo-local
# `rg` can never prove a deletion safe (that is how `AddSkillSource` broke 8 NodeTypes). So the
# claim reads as authoritative and sends the investigator to diff public signatures. On
# 2026-08-10 it did exactly that for a `Store/Catalog` verdict of `compile=Ok render=ok
# tests=FAILED`, on PR #1043 whose whole diff was MessageService.cs plus two test files
# (issue #1077). Keep the API sentence CONDITIONAL on the phase actually being `compile`.
- name: Fail if the plugin gate failed
if: needs.plugin-gate.result == 'failure'
env:
# Empty when the gate produced no verdict (it died before judging), and — depending on how
# GitHub propagates a failed job's outputs — possibly empty even when it did. Both degrade
# to "see the job", which is true either way.
GATE_VERDICT: ${{ needs.plugin-gate.outputs.failure }}
run: |
echo "::error::MeshWeaver.Plugins does not compile against this PR — see the 'Plugins compile against this PR' job. A public API change here breaks plugin node source that is Roslyn-compiled at runtime from the plugins repo."
set -uo pipefail
verdict="$(printf '%s' "${GATE_VERDICT}" | head -1)"
if [ -n "$verdict" ]; then
echo "::error::The plugins gate failed: ${verdict} — see the 'Plugins compile against this PR' job."
else
echo "::error::The plugins gate failed — see the 'Plugins compile against this PR' job for the failing check (compile / render / tests) and the node(s) it failed on."
fi
case "$verdict" in
*compile:*)
echo "::error::A failing 'compile' check means a public API change here broke plugin node source that is Roslyn-compiled at runtime from the plugins repo."
;;
esac
exit 1

# ── …and a missing CI INPUT decides it too ──────────────────────────────────────────────
Expand Down
20 changes: 11 additions & 9 deletions src/MeshWeaver.Hosting/PathResolutionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -463,15 +463,17 @@ bool Affected(string key) =>
/// Repro: <c>PathResolutionCachePoisonTest.SynthesizedPartitionRoot_IsNotCached</c>.</para>
///
/// <para>🚨 <b>Not caching it is necessary, not sufficient — and that is now covered.</b> The
/// same symptom recurred on a build that already carried the no-cache fix (#1104), because a
/// hub the fabrication had ALREADY activated stays pinned: <c>Mesh.GetHostedHub</c> is keyed
/// by address, routing short-circuits on it for a hosted address, and the hub never re-reads
/// its NodeType. <i>Not cached is not not-pinned.</i> Repairing resolution can only help the
/// NEXT activation. The un-pin is the framework's job and lives in
/// <c>NodeTypeRebindWatcher</c>: every activation watches the mesh change feed for its own
/// path and recycles the hub the first time the node reports a different NodeType. So the two
/// halves are: this method must not PIN a fabrication (here), and a hub bound to one must be
/// able to LET GO of it (there).</para>
/// same symptom recurred on a build that already carried the no-cache fix (#1104) — plugin-gate
/// run 31361446933, again listing EXACTLY this default set and not one area of the node's real
/// type, which is the fingerprint to recognise it by — because a hub the fabrication had
/// ALREADY activated stays pinned: <c>Mesh.GetHostedHub</c> is keyed by address, routing
/// short-circuits on it for a hosted address, and the hub never re-reads its NodeType.
/// <i>Not cached is not not-pinned.</i> Repairing resolution can only help the NEXT activation.
/// The un-pin is the framework's job and lives in <c>NodeTypeRebindWatcher</c>: every
/// activation watches the mesh change feed for its own path and recycles the hub the first time
/// the node reports a different NodeType. So the two halves are: this method must not PIN a
/// fabrication (here), and a hub bound to one must be able to LET GO of it (there).
/// Issue #1077 carries the gate-side analysis that isolated the second half.</para>
/// </summary>
private IObservable<AddressResolution?> SynthesizePartitionRoot(string[] segments)
{
Expand Down
19 changes: 12 additions & 7 deletions src/MeshWeaver.PluginCatalog/PackageInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1792,13 +1792,18 @@ placeholderRoot is not null && root is not null
// synthesized resolutions are never cached, and a fill that lands after its
// own invalidation is discarded (PathResolutionCachePoisonTest).
//
// 🚨 …and it recurred AGAIN on a build carrying that fix (#1104), because
// fixing RESOLUTION cannot help a hub a bad resolution has ALREADY activated:
// GetHostedHub pins by address and the hub never re-reads its NodeType. That
// is why this Post is no longer where the guarantee lives. It is fire-and-
// forget, conditional on the placeholder dance having run, and available to
// nobody but this installer — while ANY writer can retype a node. The
// framework now un-pins on its own: every activation arms
// 🚨 …and it recurred AGAIN on a build carrying that fix (#1104) — plugin-gate
// run 31361446933, whose available-areas list was EXACTLY
// ConfigureDefaultNodeHub's set (AddDefaultLayoutAreas + Invite) and not one
// area of the node's real type. That list IS the fingerprint: it says the hub
// bound the mesh DEFAULT configuration, not a stale type and not the Space
// placeholder, which is what distinguishes this defect from a compile failure.
// It recurred because fixing RESOLUTION cannot help a hub a bad resolution has
// ALREADY activated: GetHostedHub pins by address and the hub never re-reads
// its NodeType. That is why this Post is no longer where the guarantee lives.
// It is fire-and-forget, conditional on the placeholder dance having run, and
// available to nobody but this installer — while ANY writer can retype a node.
// The framework now un-pins on its own: every activation arms
// NodeTypeRebindWatcher, which recycles the hub the first time the mesh change
// feed reports a different NodeType for its path. This Post stays as the fast
// path (it recycles immediately rather than on the feed hop) and as the marker
Expand Down
Loading
Loading