diff --git a/.github/scripts/check-content-ci-path.py b/.github/scripts/check-content-ci-path.py
new file mode 100644
index 0000000000..4ee250935a
--- /dev/null
+++ b/.github/scripts/check-content-ci-path.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""check-content-ci-path: fail closed when content CI leaves GitSync's publish path."""
+
+from __future__ import annotations
+
+import argparse
+import tempfile
+from pathlib import Path, PurePosixPath
+
+
+DEFAULT_PATH = ".github/workflows/ci.yml"
+
+
+def check(root: Path, expected: str = DEFAULT_PATH) -> list[str]:
+ relative = PurePosixPath(expected)
+ if relative.is_absolute() or ".." in relative.parts or not relative.parts:
+ return [f"invalid content-CI path '{expected}'"]
+ # Resolve each component by exact directory-entry name. Path.is_file() follows the host file
+ # system's case rules, which would let CI.yml satisfy ci.yml on a default macOS checkout even
+ # though GitHub's workflow path and the webhook discriminator are case-sensitive.
+ candidate = root
+ for part in relative.parts:
+ try:
+ candidate = next(entry for entry in candidate.iterdir() if entry.name == part)
+ except (FileNotFoundError, NotADirectoryError, StopIteration):
+ break
+ else:
+ if candidate.is_file():
+ return []
+ return [
+ f"{expected} is missing โ GitSync accepts BuildCompletion only from that exact workflow "
+ "path; moving or renaming content CI would leave every installation on the previous build"
+ ]
+
+
+def self_test() -> int:
+ failures: list[str] = []
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ workflows = root / ".github" / "workflows"
+ workflows.mkdir(parents=True)
+
+ if not check(root):
+ failures.append("a missing content workflow passed")
+ (workflows / "other.yml").write_text("name: unrelated\n", encoding="utf-8")
+ if not check(root):
+ failures.append("an unrelated workflow substituted for content CI")
+ (workflows / "CI.yml").write_text("name: wrong case\n", encoding="utf-8")
+ if not check(root):
+ failures.append("a case-mismatched Git path passed")
+ (workflows / "CI.yml").unlink()
+ (workflows / "ci.yml").mkdir()
+ if not check(root):
+ failures.append("a directory at the expected path passed")
+ (workflows / "ci.yml").rmdir()
+ (workflows / "ci.yml").write_text("name: content CI\n", encoding="utf-8")
+ if check(root):
+ failures.append("the exact content workflow failed")
+ if not check(root, "../outside.yml"):
+ failures.append("a path outside the repository passed")
+
+ if failures:
+ for failure in failures:
+ print(f"::error::{failure}")
+ return 1
+ print("content-CI path guard: 6 assertions passed")
+ return 0
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--root", default=".")
+ parser.add_argument("--expected", default=DEFAULT_PATH)
+ parser.add_argument("--self-test", action="store_true")
+ args = parser.parse_args()
+ if args.self_test:
+ return self_test()
+
+ failures = check(Path(args.root), args.expected)
+ for failure in failures:
+ print(f"::error file={args.expected}::{failure}")
+ if failures:
+ return 1
+ print(f"content-CI publish path: {args.expected} exists")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/.github/workflows/node-repo-validate.yml b/.github/workflows/node-repo-validate.yml
index dfe9029cf5..b2d4616185 100644
--- a/.github/workflows/node-repo-validate.yml
+++ b/.github/workflows/node-repo-validate.yml
@@ -90,6 +90,25 @@ jobs:
- uses: actions/setup-python@v7
with:
python-version: "3.12"
+ # GitSync accepts a BuildCompletion only from this exact repository-level path (#3978).
+ # If a maintainer renames content CI, the renamed workflow still runs this reusable lane;
+ # this gate then fails RED instead of letting every installation silently freeze on its
+ # previous imported commit. The guard is fetched at the lane's own scripts ref for the same
+ # reason as the other centralized policy below: a caller cannot silently retain an old rule.
+ - name: Fetch the content-CI publish-path guard at the lane's scripts ref
+ env:
+ GH_TOKEN: ${{ github.token }}
+ SCRIPTS_REF: ${{ inputs.scripts-ref || inputs.platform-ref }}
+ run: |
+ set -euo pipefail
+ [ -n "${SCRIPTS_REF:-}" ] || { echo "::error::scripts-ref / platform-ref are both empty โ the content-CI path guard has no ref to be fetched at"; exit 1; }
+ gh api "repos/Systemorph/MeshWeaver/contents/.github/scripts/check-content-ci-path.py?ref=${SCRIPTS_REF}" --jq .content | base64 -d > "$RUNNER_TEMP/check-content-ci-path.py" \
+ || { echo "::error::could not fetch .github/scripts/check-content-ci-path.py at ${SCRIPTS_REF} from Systemorph/MeshWeaver โ a pin older than the guard needs the older lane"; exit 1; }
+ head -c 400 "$RUNNER_TEMP/check-content-ci-path.py" | grep -q "check-content-ci-path" || { echo "::error::the fetched check-content-ci-path.py at ${SCRIPTS_REF} does not look like the guard"; exit 1; }
+ - name: "The content-CI publish path is enforced โ the gate can fail (self-test)"
+ run: python3 "$RUNNER_TEMP/check-content-ci-path.py" --self-test
+ - name: "Content CI remains at GitSync's publish-signal path"
+ run: python3 "$RUNNER_TEMP/check-content-ci-path.py" --root . --expected .github/workflows/ci.yml
- name: Validate every node JSON (+ NodeType Source)
run: python3 scripts/validate-repos.py
# ๐จ THE 45-MINUTE CAP, fleet-wide (maintainer, 2026-09-02: "hard cut CI runs after 45min โ we pay all
diff --git a/src/MeshWeaver.Documentation/Data/Architecture/GitSyncTriggerCost.md b/src/MeshWeaver.Documentation/Data/Architecture/GitSyncTriggerCost.md
index 25f6e46320..eed9307605 100644
--- a/src/MeshWeaver.Documentation/Data/Architecture/GitSyncTriggerCost.md
+++ b/src/MeshWeaver.Documentation/Data/Architecture/GitSyncTriggerCost.md
@@ -23,17 +23,20 @@ The short version:
> re-clone-per-delivery is *also* the retry loop for transient failures, so the new skip has to
> distinguish a verdict re-running cannot change from one that might.
-## 1. What actually fires โ a green build is not a merge
-
-`GitHubWebhookProcessor` acts on a `workflow_run` delivery when **both** guards pass: the run's
-`conclusion` is `success` **and** its `head_branch` is the repository's default branch, **and** the
-run's own trigger is in the publish-signal allow-list โ `push`, `repository_dispatch`, `schedule`,
-`workflow_dispatch`. (A run triggered *by another workflow* โ `event: workflow_run` โ is not a
-publish signal and is ignored.) Every accepted delivery rewrites
+## 1. What actually fires โ content CI, not merely something green
+
+`GitHubWebhookProcessor` acts on a `workflow_run` delivery only when the run's conclusion is
+`success`, its `head_branch` is the repository's default branch, its trigger is in the
+publish-signal allow-list (`push`, `repository_dispatch`, `schedule`, `workflow_dispatch`), **and
+its workflow file is the repository's content CI**. The repository-level convention is
+`.github/workflows/ci.yml`; core's established exception is `.github/workflows/dotnet-test.yml`.
+A run triggered *by another workflow* (`event: workflow_run`) is not a publish signal, and neither
+is a green workflow at any other path. Every accepted delivery rewrites
`Admin/_Build/{owner}.{repo}` ([`BuildCompletion`](/Doc/Architecture/SyncRefContract)) and then fans
out to every sync source of that repository.
-**One merge is not one delivery.** Measured on `Systemorph/MeshWeaver` for the 24 h ending
+**Before #3978, trigger and branch were the whole decision.** One merge was not one delivery.
+Measured on `Systemorph/MeshWeaver` for the 24 h ending
2026-09-10T17:35Z โ 254 green publish-signal runs on `main`:
| Workflow | Trigger | Runs |
@@ -50,29 +53,31 @@ out to every sync source of that repository.
256 of them in the last 24 h (10.7/h, one every 5.6 minutes). The version series matches the run
census above, which is what makes the model checkable rather than inferred.
-Two consequences follow, and both are load-bearing:
+That old decision had two consequences:
- **Three workflows go green on the same commit**, so a single merge produces roughly three
deliveries carrying the *same* `head_sha`.
-- **A third of the deliveries are a cron, and it builds nothing.** `Prod synthetic probe`
+- **A third of the deliveries were a cron, and it builds nothing.** `Prod synthetic probe`
(`.github/workflows/prod-synthetic-probe.yml`) is `cron: "*/15 * * * *"` โ it makes HTTP requests
to the live portals and asserts on the answers. It produces no artefact and touches no tree. But
- it is a `schedule` run that goes green on the default branch, so it is a publish signal, and its
- `head_sha` is whatever `main`'s tip happens to be โ unchanged between merges.
+ it is a `schedule` run that goes green on the default branch, so the old decision called it a
+ publish signal. Its `head_sha` is whatever `main`'s tip happens to be โ unchanged between merges.
-For a healthy source both are harmless, and the allow-list says so explicitly
-(`PublishSignalTriggers`):
+For a healthy source those extra deliveries looked harmless, and the old allow-list said so
+explicitly (`PublishSignalTriggers`):
> **Widening this cannot cause churn.** A sync source already sitting on the built sha is skipped by
> `SkipReason` ("already at this commit"), so a scheduled or dispatched re-verification of an
> unchanged default branch triggers no import at all.
-That invariant is the design. Section 4 is about the sources for which it was **false**, and ยง7
-about what makes it true again. ๐จ **Note what the fix is not: the trigger list is deliberately
-unchanged.** Dropping `schedule` would take out 87 of these 254 deliveries at a stroke โ and it
-would be the same mistake the 2026-09-02 measurement corrected in the other direction, because a
-cron run is the only green verdict some repositories ever produce, and for a *converging* source
-those 87 deliveries already cost nothing. The trigger was never the defect.
+Section 4 shows why that claim was false for the sources where cost mattered. More importantly,
+#3978 showed the correctness failure: `Auto-update green armed PRs` wrote more than twenty build
+completions for a Reinsurance commit whose real content CI was red. A green Chart Gate could do the
+same on core. The fix keys the signal on the workflow path, so probes, deploys and PR updaters can no
+longer authorize an import at all. The trigger list is deliberately unchanged: dropping `schedule`
+or `repository_dispatch` would also remove genuine content-CI verdicts. Section 4 is about the
+accepted deliveries for which the cheapness claim was still false, and ยง7 is #3945's independent
+fix for that cost.
## 2. The one gate that makes a delivery free
@@ -154,21 +159,21 @@ Now put that beside section 2. **The gate that makes a delivery free and the gua
un-landed content are reading the same field**, so holding the field for the second reason
necessarily disarms the first:
-> A source whose import does not fully converge pays a **full fetch + parse on every delivery**,
-> for as long as it does not converge โ and the rate is the **source repository's CI cadence**, not
-> anything about the source. A cron probe that never changes the tip re-clones the repository just
-> as hard as a merge does.
+> A source whose import does not fully converge pays a **full fetch + parse on every accepted
+> content-CI delivery**, for as long as it does not converge โ and the rate is the repository's
+> content-CI cadence, not anything about the source.
๐จ **None of the three clauses may be relaxed** โ each is an incident, and the failure mode of
relaxing one is silent data loss or silently-missing content, not a slow sync. The field stays
exactly as conservative as it is; ยง7 adds a *different* field for the *different* question.
-So the allow-list's stated invariant โ *"a sync source already sitting on the built sha is skipped โฆ
-so a scheduled re-verification triggers no import at all"* โ holds for every source that converges
-and **for no source that does not**. On the converging sources the 15-minute probe costs nothing at
-all; on one that cannot converge, the same probe is a full clone of that repository โ 87 times in the
-measured 24 h (the cron offers 96; only the green runs are publish signals) โ on top of roughly three
-per merge.
+Before these fixes, the allow-list's stated invariant โ *"a sync source already sitting on the
+built sha is skipped โฆ so a scheduled re-verification triggers no import at all"* โ held for every
+source that converged and **for no source that did not**. The 15-minute core probe was therefore a
+full clone for a non-converging source 87 times in the measured 24 h. After #3978 that probe is not a
+delivery at all: only `.github/workflows/dotnet-test.yml` can create core's build record. For a
+repeated verdict from the actual content CI, ยง7's separate attempt pointer makes the unchanged
+commit free without weakening any baseline clause.
None of the three freeze conditions resolves by itself:
@@ -410,13 +415,13 @@ proved โ the proposition `SyncRefContract` exists to guarantee.
**The conclusion: the trigger event is a proxy for the wrong proposition.**
`PublishSignalTriggers` answers *"how was this run started"*; what the record needs is *"did this
repository's own CONTENT CI prove this tree"*. The discriminator for that is already in the payload โ
-`workflow_run.name` / `.path` โ and every repository has exactly one such workflow (`ci.yml` in each
-satellite, `dotnet-test.yml` in core). Keyed on that, the probe, the PR-updater, `Deployment Smoke`,
-`Arm credential`, `Chart Gate` and `Hosting Operator` all fall out under one rule, no repository
-loses its only signal, and the trigger allow-list can stay as wide as it is. That is a separate
-change with its own risk โ a repository that declares the wrong workflow stops syncing silently,
-which is exactly MeshWeaver.Plugins#1194 recreated โ and it is filed as #3978, together with the
-correctness finding above.
+`workflow_run.path` โ and every repository has exactly one such workflow (`ci.yml` in each
+satellite, `dotnet-test.yml` in core). #3978 keys admission on that stable path, so the probe,
+PR updater, `Deployment Smoke`, `Arm credential`, `Chart Gate` and `Hosting Operator` all fall out
+under one rule, no repository loses its genuine signal, and the trigger allow-list stays as wide as
+it is. The rule fails closed, with a positive guard against silent freeze: core's policy test pins
+its path, and the shared node-repo validation lane fails red if a satellite moves or renames
+`.github/workflows/ci.yml`.
## See also
diff --git a/src/MeshWeaver.Documentation/Data/Architecture/PluginUpdateOnGreenBuild.md b/src/MeshWeaver.Documentation/Data/Architecture/PluginUpdateOnGreenBuild.md
index 10dec98f14..50dff0fac3 100644
--- a/src/MeshWeaver.Documentation/Data/Architecture/PluginUpdateOnGreenBuild.md
+++ b/src/MeshWeaver.Documentation/Data/Architecture/PluginUpdateOnGreenBuild.md
@@ -33,30 +33,45 @@ per installed module: ModuleVersion changed?
## ๐จ Which green runs count as a publish signal
-`workflow_run` fires for far more than a repo's content CI, so the webhook applies **two independent
+`workflow_run` fires for far more than a repo's content CI, so the webhook applies **three independent
guards** before a delivery becomes a `BuildCompletion` (`GitHubWebhookProcessor.ProcessWorkflowRun`):
1. **The trigger must be on the allow-list** โ `push`, `repository_dispatch`, `schedule`,
- `workflow_dispatch`. Each of those means *a build of the default branch's own tree*.
+ `workflow_dispatch`. This says how the workflow started, not what it checked.
2. **`head_branch` must BE the repository's default branch.**
-
-Both fail closed: an unknown trigger is refused, and a payload whose branch cannot be read records
-nothing.
+3. **The stable workflow file path must BE the repository's content CI** โ
+ `.github/workflows/ci.yml` for every node/content repository, and the platform's established
+ `.github/workflows/dotnet-test.yml` exception.
+
+An arbitrary repository whose content CI lives elsewhere declares one repository-level override
+under `GitHub:ContentWorkflows:Repositories` โ `{ Repository: "owner/repo", Path:
+".github/workflows/content.yml" }`. It is deployment policy, not a field copied into every Space:
+all Spaces targeting one repository must trust the same evidence. Repository identity matching is
+case-insensitive; the Git workflow path is deliberately case-sensitive.
+
+All three fail closed: an unknown trigger, an unreadable branch/path, or a different green workflow
+records nothing. The path is the declaration: display names may change; moving the content CI away
+from the conventional path removes the automatic publish signal. Core pins its path in the policy
+test; every node repository's shared validation lane checks `.github/workflows/ci.yml` from inside
+the content workflow itself. A rename therefore fails that run red rather than silently freezing
+GitSync or falling back to an unrelated green run.
| Trigger | Admitted | Why |
|---|---|---|
-| `push` | โ
| The branch moved and its CI ran โ the original case. |
-| `repository_dispatch` | โ
| GitHub only ever runs a dispatched workflow from the **default branch**, and `head_sha` is that branch's tip. This is how a platform release re-verifies every satellite repo: no commit to push, same tree, a genuine green verdict on it. |
-| `schedule` | โ
| Same โ a cron run only ever exists on the default branch. |
+| `push` | โ
| Eligible when it is the content-CI workflow: the branch moved and its CI ran. |
+| `repository_dispatch` | โ
| Eligible when it is the content CI. This is how a platform release re-verifies every satellite repo: no commit to push, same tree, a genuine green verdict on it. |
+| `schedule` | โ
| Eligible when it is the content CI's cron. An unrelated scheduled probe or PR updater is rejected by guard 3. |
| `workflow_dispatch` | โ
| May target any ref, so guard 2 does the discriminating. On the default branch it is a manual re-verification of that tree, and the only recovery lever when a merge burst cancelled the push-triggered run. |
| `pull_request` / `pull_request_target` | โ | Green **unmerged** code. Note both can report `head_branch=main`, so guard 1 โ not guard 2 โ is what rejects them. |
| `dynamic` | โ | GitHub's Copilot reviewer. Completes green on the default branch and is not a build at all. |
| `merge_group` | โ | A merge-queue run's `head_branch` is the temporary `gh-readonly-queue/{base}/pr-{n}-{sha}` ref, so guard 2 already rejects it. Listing it would be unreachable. |
| anything else | โ | Fail closed. An allow-list means the next trigger GitHub invents does not publish by accident. |
-**Widening the list cannot cause churn.** A sync source already sitting on the built sha is skipped
-("already at this commit"), so a scheduled or dispatched re-verification of an unchanged default
-branch triggers no import at all.
+๐จ Trigger and branch were once the whole decision. That admitted a successful scheduled PR updater
+that compiled nothing: it wrote more than twenty `BuildCompletion` records for a Reinsurance commit
+whose actual content CI was red. Core had the same risk in the other direction: a green push-triggered
+Chart Gate could authorize a tree whose `MeshWeaver Build and Test` run failed. Workflow identity is
+therefore not an optimization; it is the evidence that makes the record true (#3978).
### ๐จ The single-value test that dropped real signals (2026-09-02)
@@ -69,8 +84,9 @@ behind a merged main โ with the webhook armed, every delivery answering 200 OK
reporting a problem. A dropped publish signal has no symptom except content that quietly stops
arriving; there is no scheduled poll behind it to paper over the gap, by design.
-The decision table above is pinned by `GreenBuildPublishSignalTest`, in both directions โ a test that
-only listed the admitted triggers would go green against a gate that admits everything.
+The decision table above is pinned by `GreenBuildPublishSignalTest`, in both directions and across
+all three guards โ a test that only listed the admitted triggers would go green against a gate that
+admits everything.
## ๐จ Two inputs, one decision โ and which one your installation has
@@ -497,7 +513,7 @@ registered with it. An installation that configures none of this is reached by t
| Symptom | Cause |
|---|---|
-| Nothing happens on a green build | The webhook does not send **Workflow runs**; or no catalog's `SourceRepoPath` matches the repo; or the run's conclusion was not `success` โ only completed+successful runs are recorded; or the run's **trigger is not on the allow-list** (`push`, `repository_dispatch`, `schedule`, `workflow_dispatch` record; `pull_request`, `dynamic` and anything unknown do not โ see *Which green runs count as a publish signal* above); or the run was **not on the repository's default branch** โ a green PR-branch build is unmerged code and is deliberately never recorded (fail-closed: a payload with no readable branch records nothing either). |
+| Nothing happens on a green build | The webhook does not send **Workflow runs**; or no catalog's `SourceRepoPath` matches the repo; or the run's conclusion was not `success`; or its **workflow path is not the content-CI convention** (`.github/workflows/ci.yml`, with core at `.github/workflows/dotnet-test.yml`); or the run's **trigger is not on the allow-list** (`push`, `repository_dispatch`, `schedule`, `workflow_dispatch` are eligible; `pull_request`, `dynamic` and anything unknown are not โ see *Which green runs count as a publish signal* above); or the run was **not on the repository's default branch**. Every leg fails closed: a payload with no readable workflow path or branch records nothing. |
| A module never updates, and the log says it "has no module content identity" | The module's `manifest.lock` is missing or unparseable, so there is no `ModuleVersion` to compare and "has it changed" is unanswerable. A missing hash is the **absence of evidence**, not evidence of a change: treating it as changed would re-install the module on every green build of the repo *and* on every pod start, which is acting on the event rather than the content. It is refused, loudly, and the catalog card's manual **Update** stays available. Fix the module's CI to emit the sidecar. |
| Nothing happens on a green build, **and the log says the delivery "matched NONE of the N sync config(s)"** | No `_GitSync` targets that repository โ usually because the repository was **renamed** and the configs still store its old name. The matcher falls back to GitHub's canonical `full_name` (which follows the rename redirect) and repoints the config when it finds one, so this line surviving means the lookup could not be made either: the repository is unreachable with the config creator's credential, or the hook really is installed on a repository this mesh does not sync. The Warning names both sides โ the incoming repository and everything it was compared against. |
| A green build produced nothing, and the log says the fact is **NOT recorded AND the sync did not run** | The `Admin/_Build/{owner}.{repo}` write failed. GitHub was answered 200, so there is no redelivery. The payload is kept at `Admin/_MissedBuild/{owner}.{repo}` (#3374) โ read it with `MissedBuildFact.WatchQuery`, and compare it against the current build record to see whether a later green run of the SAME workflow has already superseded it. If a second Warning says the miss could not be recorded either, the log line is the only witness and the fact must be replayed from GitHub. |
diff --git a/src/MeshWeaver.Documentation/Data/Architecture/SyncRefContract.md b/src/MeshWeaver.Documentation/Data/Architecture/SyncRefContract.md
index 68eac49a29..18f7e3d268 100644
--- a/src/MeshWeaver.Documentation/Data/Architecture/SyncRefContract.md
+++ b/src/MeshWeaver.Documentation/Data/Architecture/SyncRefContract.md
@@ -13,6 +13,14 @@ read a branch tip.**
That is the whole rule. Everything below is why it has to be a rule rather than a habit, and what
enforces it.
+"A build" means the repository's **content CI**, not any workflow that happened to finish green at
+the same commit. The webhook verifies the stable workflow file path:
+`.github/workflows/ci.yml` for content/node repositories, and core's established
+`.github/workflows/dotnet-test.yml` exception. Trigger, branch and workflow identity are independent
+guards. This matters in both directions: a scheduled PR updater once authorized a red Reinsurance
+tree more than twenty times, while a green core Chart Gate could have masked a red build-and-test
+run (#3978).
+
## The two refs, and why they are not the same ref
A GitSync'd Space is configured with a repository and a **branch**
diff --git a/src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-10-only-content-ci-can-publish-a-repository.md b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-10-only-content-ci-can-publish-a-repository.md
new file mode 100644
index 0000000000..3675536983
--- /dev/null
+++ b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-10-only-content-ci-can-publish-a-repository.md
@@ -0,0 +1,12 @@
+---
+Name: Only content CI can publish a repository
+Category: Fix
+Description: A green probe, deployment check or pull-request updater can no longer make GitSync import a repository whose actual content build is red.
+Icon: Checkmark
+Order: -20260910
+---
+
+GitSync now accepts a build completion only from the repository's content-CI workflow. The workflow
+trigger and branch are still checked, but they can no longer substitute for evidence about what the
+workflow compiled. This prevents unrelated green workflows from publishing a commit while its real
+content build is failing.
diff --git a/src/MeshWeaver.GitSync/GitHubContentWorkflowOptions.cs b/src/MeshWeaver.GitSync/GitHubContentWorkflowOptions.cs
new file mode 100644
index 0000000000..439245d9b3
--- /dev/null
+++ b/src/MeshWeaver.GitSync/GitHubContentWorkflowOptions.cs
@@ -0,0 +1,27 @@
+namespace MeshWeaver.GitSync;
+
+///
+/// Repository-level overrides for the workflow whose green verdict GitSync may publish as a
+/// build completion. Bound from GitHub:ContentWorkflows. The declaration belongs to a
+/// repository, never to one Space: every Space targeting the same repository must trust the same
+/// content evidence.
+///
+public sealed record GitHubContentWorkflowOptions
+{
+ /// The configuration section containing .
+ public const string ConfigSection = "GitHub:ContentWorkflows";
+
+ /// Overrides for repositories whose content CI does not use the conventional
+ /// .github/workflows/ci.yml path.
+ public List Repositories { get; } = [];
+}
+
+/// One repository and the stable Git workflow path that proves its content.
+public sealed record GitHubContentWorkflow
+{
+ /// Repository identity as owner/repo or its GitHub URL.
+ public string Repository { get; init; } = "";
+
+ /// Case-sensitive Git path, for example .github/workflows/content.yml.
+ public string Path { get; init; } = "";
+}
diff --git a/src/MeshWeaver.GitSync/GitHubSyncConfiguration.cs b/src/MeshWeaver.GitSync/GitHubSyncConfiguration.cs
index 9be3392704..1fd9c82979 100644
--- a/src/MeshWeaver.GitSync/GitHubSyncConfiguration.cs
+++ b/src/MeshWeaver.GitSync/GitHubSyncConfiguration.cs
@@ -32,6 +32,21 @@ public static class GitHubSyncConfiguration
public static IServiceCollection AddGitHubSyncServices(this IServiceCollection services)
{
services.AddOptions();
+ services.AddOptions()
+ .Configure((options, configuration) =>
+ {
+ foreach (var child in configuration
+ .GetSection(GitHubContentWorkflowOptions.ConfigSection)
+ .GetSection(nameof(GitHubContentWorkflowOptions.Repositories))
+ .GetChildren())
+ {
+ options.Repositories.Add(new GitHubContentWorkflow
+ {
+ Repository = child[nameof(GitHubContentWorkflow.Repository)] ?? "",
+ Path = child[nameof(GitHubContentWorkflow.Path)] ?? "",
+ });
+ }
+ });
// GitHub App (machine identity): server-side operations โ the plugin registry's sync of
// the plugins repo, boot imports โ authenticate as the App installation instead of a
// personal credential. The host binds GitHub:App next to GitHub:OAuth; left unconfigured,
diff --git a/src/MeshWeaver.GitSync/GitHubWebhookProcessor.cs b/src/MeshWeaver.GitSync/GitHubWebhookProcessor.cs
index 632ca4dfbd..bcb6576a14 100644
--- a/src/MeshWeaver.GitSync/GitHubWebhookProcessor.cs
+++ b/src/MeshWeaver.GitSync/GitHubWebhookProcessor.cs
@@ -15,6 +15,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
namespace MeshWeaver.GitSync;
@@ -64,6 +65,7 @@ public sealed class GitHubWebhookProcessor
private readonly IMeshService meshService;
private readonly GitHubRepoIdentityResolver? identities;
private readonly ILogger? logger;
+ private readonly IReadOnlyList contentWorkflows;
/// Initializes a new instance of the class.
/// The hub this processor issues its reads and writes from.
@@ -74,16 +76,19 @@ public sealed class GitHubWebhookProcessor
/// the zero-match warning then says.
///
/// Optional logger.
+ /// Repository-level content-workflow overrides.
public GitHubWebhookProcessor(
IMessageHub hub,
IMeshService meshService,
GitHubRepoIdentityResolver? identities = null,
- ILogger? logger = null)
+ ILogger? logger = null,
+ IOptions? contentWorkflowOptions = null)
{
this.hub = hub;
this.meshService = meshService;
this.identities = identities;
this.logger = logger;
+ contentWorkflows = contentWorkflowOptions?.Value.Repositories.ToArray() ?? [];
}
///
@@ -501,18 +506,21 @@ private IObservable> QueryConfigNodesAsSystem()
// โโ workflow_run โ build-completion record โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
///
- /// ๐จ The workflow_run triggers that mean "a completed build of THE DEFAULT BRANCH'S
- /// OWN TREE" โ an ALLOW-LIST, so an event name nobody has considered is REFUSED rather than
- /// admitted. A deny-list here fails open: the next trigger GitHub invents would publish.
+ /// ๐จ The workflow_run triggers under which the repository's CONTENT CI is eligible to
+ /// publish โ an ALLOW-LIST, so an event name nobody has considered is REFUSED rather than
+ /// admitted. This is HOW the workflow started, never evidence of WHAT it checked; the
+ /// independent guard supplies that identity. A
+ /// deny-list here fails open: the next trigger GitHub invents would publish.
///
/// Why each one is admitted.
///
- /// - push โ the branch moved and its CI ran. The original case.
+ /// - push โ the branch moved and its content CI ran. The original case.
/// - repository_dispatch โ GitHub only ever runs a dispatched workflow from the
- /// DEFAULT branch, and the run's head_sha is that branch's tip. This is how a platform
- /// release re-verifies every satellite repo: no commit to push, the same tree, a genuine green
- /// verdict on it.
- /// - schedule โ same reason: a cron run only ever exists on the default branch.
+ /// DEFAULT branch, and the run's head_sha is that branch's tip. This is how the CONTENT
+ /// CI re-verifies every satellite after a platform release: no commit to push, the same tree, a
+ /// genuine green verdict on it.
+ /// - schedule โ the content CI's scheduled self-check; a cron run only exists on the
+ /// default branch. An unrelated scheduled workflow is rejected by workflow identity.
/// - workflow_dispatch โ may target ANY ref, so it is admitted here and
/// DISCRIMINATED by the head_branch check. Aimed at the default branch it is a manual
/// re-verification of that tree โ and the only recovery lever when a merge burst cancelled the
@@ -538,23 +546,19 @@ private IObservable> QueryConfigNodesAsSystem()
/// warning, nothing to grep. (The other half of that incident was a genuinely red push lane,
/// where this gate behaved correctly and is meant to.)
///
- /// Widening this cannot cause churn. A sync source already sitting on the built sha
- /// is skipped by ("already at this commit"), so a scheduled or dispatched
- /// re-verification of an unchanged default branch triggers no import at all.
+ /// ๐จ Before #3978 this allow-list and the branch check were the WHOLE decision. A green
+ /// scheduled PR updater therefore published a red repository tree twenty times, and a green
+ /// push-triggered chart check could do the same. The workflow-path guard is deliberately not
+ /// folded into this trigger predicate: HOW and WHAT are independent payload facts and each must
+ /// fail closed.
///
- /// ๐จ That claim was FALSE for two years for one class of source, and #3945 is what
- /// makes it true again โ the trigger set is deliberately NOT narrowed. It rests on a source
- /// being "already at the built sha", and lastSyncCommitSha is held back for every source
- /// whose import does not converge, so a scheduled re-verification of an unchanged tip was a full
- /// clone for exactly those sources: on Systemorph/MeshWeaver main, 87 of the 254
- /// green publish-signal runs in the 24 h to 2026-09-10T17:35Z were Prod synthetic probe
- /// (cron: */15), which builds nothing and never moves head_sha between merges.
- /// Dropping schedule would have been the wrong fix: a cron run IS the only green
- /// verdict some repositories ever produce (the exact shape of the 2026-09-02 measurement that
- /// widened this list, where dropping repository_dispatch left Underwriting/_GitSync
- /// 38 h behind with every delivery answering 200 OK), and for a CONVERGING source those 87
- /// deliveries always were free. The defect was never the trigger; it was that the skip could not
- /// say "we have already looked at exactly these bytes". 's last arm can.
+ /// An accepted re-verification of unchanged content must not cause churn. A source
+ /// already sitting on the built sha is skipped by . ๐จ That claim was
+ /// FALSE for two years for a source whose import did not converge, because
+ /// lastSyncCommitSha is deliberately held back there. #3945 supplies the separate
+ /// "already attempted these exact bytes with a final verdict" arm. Dropping schedule or
+ /// repository_dispatch would lose genuine content-CI signals; the trigger set remains
+ /// broad, while #3978 independently proves which workflow may carry the verdict.
///
private static readonly ImmutableHashSet PublishSignalTriggers =
ImmutableHashSet.Create(
@@ -567,8 +571,8 @@ private IObservable> QueryConfigNodesAsSystem()
string.Join(", ", PublishSignalTriggers.OrderBy(t => t, StringComparer.Ordinal));
///
- /// Whether a workflow_run trigger means "a build of the default branch's own tree".
- /// Fail-closed: an empty, missing or unrecognised event name is NOT a publish signal.
+ /// Whether a workflow_run trigger is eligible to carry a content-CI verdict.
+ /// Fail-closed: an empty, missing or unrecognised event name is NOT eligible.
///
/// See for why each admitted trigger is admitted,
/// and for the 2026-09-02 measurement that replaced the single-value == "push" test.
@@ -584,6 +588,56 @@ internal static bool IsDefaultBranchBuild(string? headBranch, string? defaultBra
=> headBranch is { Length: > 0 } && defaultBranch is { Length: > 0 }
&& string.Equals(headBranch, defaultBranch, StringComparison.OrdinalIgnoreCase);
+ /// The conventional content-CI path every GitSync repository uses. The node-repo
+ /// scaffold and the shared gate both own this filename, so it is a repository contract rather
+ /// than a display name a maintainer can casually edit.
+ internal const string StandardContentWorkflowPath = ".github/workflows/ci.yml";
+
+ /// Core predates the node-repo convention and is the one intentional exception.
+ internal const string CoreContentWorkflowPath = ".github/workflows/dotnet-test.yml";
+
+ private static readonly RepoIdentity CoreRepository = new("Systemorph", "MeshWeaver");
+
+ ///
+ /// The one workflow whose green verdict proves a repository's CONTENT. A trigger says why a
+ /// workflow ran; it does not say what that workflow checked. GitHub sends the stable workflow
+ /// file path in every workflow_run payload, so identity is keyed on that path rather than
+ /// on the mutable display name.
+ ///
+ /// Fail-closed by construction: ordinary content/node repositories use
+ /// .github/workflows/ci.yml; Systemorph/MeshWeaver uses its established
+ /// .github/workflows/dotnet-test.yml; and an arbitrary repository with a different path
+ /// declares one repository-level override. A
+ /// repository with no workflow at its expected path has no automatic publish signal โ exactly
+ /// like a repository with no content CI at all. An unrelated green workflow can never stand in
+ /// for it.
+ ///
+ internal static string ExpectedContentWorkflowPath(
+ RepoIdentity repository,
+ IEnumerable? overrides = null)
+ {
+ var configured = overrides?.FirstOrDefault(entry =>
+ GitHubRepoIdentityResolver.Parse(entry.Repository)?.Matches(repository) == true
+ && !string.IsNullOrWhiteSpace(entry.Path));
+ if (configured is not null)
+ return configured.Path.Trim();
+ return CoreRepository.Matches(repository)
+ ? CoreContentWorkflowPath
+ : StandardContentWorkflowPath;
+ }
+
+ /// Whether this run is the repository's declared-by-convention content CI. Paths are
+ /// Git paths and therefore compared case-sensitively; a missing path means nothing was proved.
+ internal static bool IsRepositoryContentWorkflow(
+ RepoIdentity repository,
+ string? workflowPath,
+ IEnumerable? overrides = null)
+ => workflowPath is { Length: > 0 }
+ && string.Equals(
+ workflowPath,
+ ExpectedContentWorkflowPath(repository, overrides),
+ StringComparison.Ordinal);
+
///
/// A verified workflow_run โ the repository's node.
///
@@ -593,17 +647,19 @@ internal static bool IsDefaultBranchBuild(string? headBranch, string? defaultBra
/// keeps the two sides decoupled at compile time (the node's content type lives in
/// MeshWeaver.Graph, which both reference) and means a second consumer costs nothing here.
///
- /// Only a completed, successful run is recorded. A failed or cancelled run may still
+ /// Only a completed, successful CONTENT-CI run is recorded. A failed or cancelled run may still
/// have produced artifacts; writing that as a build completion is how a broken build reaches
- /// consumers. Every green run rewrites the node โ including doc-only commits, reverts, and
- /// re-runs of an unchanged tree โ because deciding "did anything change" needs content identity,
- /// which is the consumer's business, not the webhook's.
+ /// consumers. Every green run of that one workflow rewrites the node โ including doc-only
+ /// commits, reverts, and re-runs of an unchanged tree โ because deciding "did anything change"
+ /// needs content identity, which is the consumer's business, not the webhook's. Green probes,
+ /// deploys, chart checks and PR updaters prove no content and are ignored.
///
- /// Two independent guards decide "is this a publish signal". The run's TRIGGER must
+ /// Three independent guards decide "is this a publish signal". The run's TRIGGER must
/// be one of (an allow-list โ unknown events fail closed),
- /// AND its head_branch must be the repository's default branch
- /// (). Neither subsumes the other: the trigger check states the
- /// requirement, the branch check discriminates the triggers that can target any ref.
+ /// its head_branch must be the repository's default branch
+ /// (), AND the run's stable workflow file path must be that
+ /// repository's content CI (). None subsumes another:
+ /// trigger is HOW it ran, branch is WHICH tree, and workflow path is WHAT it proved.
///
/// Written under the SYSTEM identity: the webhook request is anonymous (its authorization
/// is the verified HMAC signature), so an ambient-identity write would be refused on an
@@ -677,6 +733,32 @@ private IObservable ProcessWorkflowRun(JsonElement payload)
}
var (owner, repo) = (target.Owner, target.Repo);
+ // ๐จ A trigger is only HOW the workflow started. It says nothing about WHAT the workflow
+ // proved. A scheduled PR updater and a push-triggered chart check both used to pass the two
+ // guards above and overwrite BuildCompletion even while the repository's content CI was
+ // red (#3978). The workflow FILE is the repository-level identity: display names are mutable,
+ // while the fleet's content-CI path is part of the node-repo contract.
+ var workflowPath = GetString(run, "path");
+ if (!IsRepositoryContentWorkflow(target, workflowPath, contentWorkflows))
+ {
+ var expected = ExpectedContentWorkflowPath(target, contentWorkflows);
+ if (string.IsNullOrWhiteSpace(workflowPath))
+ {
+ logger?.LogWarning(
+ "workflow_run webhook for {Repo}: green '{Workflow}' run carried no workflow path, "
+ + "so it cannot be verified as the repository content CI at '{Expected}' โ no build record.",
+ repoUrl, GetString(run, "name"), expected);
+ }
+ else
+ {
+ logger?.LogDebug(
+ "workflow_run webhook for {Repo}: green '{Workflow}' is '{Actual}', not the repository "
+ + "content CI at '{Expected}' โ not a publish signal.",
+ repoUrl, GetString(run, "name"), workflowPath, expected);
+ }
+ return Observable.Return(0);
+ }
+
var completion = new BuildCompletion
{
RepositoryUrl = repoUrl,
diff --git a/test/MeshWeaver.Documentation.Test/GreenBuildPublishSignalTest.cs b/test/MeshWeaver.Documentation.Test/GreenBuildPublishSignalTest.cs
index 01b16c06f3..14c1eafd0f 100644
--- a/test/MeshWeaver.Documentation.Test/GreenBuildPublishSignalTest.cs
+++ b/test/MeshWeaver.Documentation.Test/GreenBuildPublishSignalTest.cs
@@ -1,8 +1,12 @@
#pragma warning disable CS1591
using System;
+using System.Collections.Generic;
using System.IO;
using MeshWeaver.GitSync;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
using Xunit;
namespace MeshWeaver.Documentation.Test;
@@ -15,9 +19,10 @@ namespace MeshWeaver.Documentation.Test;
/// Why this lives here, and why it is a pure test. The suite that boots a mesh and
/// drives GitHubWebhookProcessor.Process end to end (MeshWeaver.GitSync.Test) moved to
/// MeshWeaver.Plugins with the boots-a-mesh split, and it pins the WIRING โ that a green default-branch
-/// run records and imports while a PR-branch one does not. The POLICY โ which triggers count as "a
-/// build of the default branch's own tree" โ is two pure functions of a payload, needs no mesh, and
-/// is the part that was wrong; so it is pinned in core, next to the code it governs.
+/// run records and imports while a PR-branch one does not. The POLICY โ which trigger, branch and
+/// workflow file together count as "the repository's content CI proved its default-branch tree" โ
+/// is three pure functions of a payload, needs no mesh, and is the part that was wrong; so it is
+/// pinned in core, next to the code it governs.
///
/// The failure it exists for (2026-09-02,
/// Systemorph/MeshWeaver.Plugins#1194). The trigger test was event == "push", a
@@ -36,18 +41,20 @@ namespace MeshWeaver.Documentation.Test;
public class GreenBuildPublishSignalTest
{
private const string Default = "main";
+ private const string ContentWorkflow = ".github/workflows/ci.yml";
+ private static readonly RepoIdentity Repository = new("Systemorph", "MeshWeaver.Reinsurance");
///
- /// Every trigger admitted as "a build of the default branch's own tree", with why. Stated here
+ /// Every trigger under which the content CI is eligible to publish, with why. Stated here
/// rather than read off the production set: a test that enumerates its subject's own list
/// asserts nothing about what that list should contain.
///
public static TheoryData AdmittedTriggers() => new()
{
- { "push", "the branch moved and its CI ran โ the original case" },
+ { "push", "the branch moved and its content CI ran โ the original case" },
{ "repository_dispatch", "only ever runs on the default branch; how a platform release "
+ "re-verifies a satellite with no commit to push (#1194)" },
- { "schedule", "a cron run only ever exists on the default branch" },
+ { "schedule", "the content CI's cron run only exists on the default branch" },
{ "workflow_dispatch", "may target any ref โ admitted here, discriminated by head_branch" },
};
@@ -144,17 +151,126 @@ public void TheBranchGuardMatchesCaseInsensitivelyAndFailsClosedOnEitherSideMiss
Assert.False(GitHubWebhookProcessor.IsDefaultBranchBuild("mainline", "main"));
}
+ // โโ the workflow identity: the repository's content CI, not any green run โโโโโโโโโโโโโโโโ
+
+ [Theory]
+ [InlineData("Systemorph", "MeshWeaver", ".github/workflows/dotnet-test.yml", true)]
+ [InlineData("Systemorph", "MeshWeaver", ".github/workflows/chart-gate.yml", false)]
+ [InlineData("Systemorph", "MeshWeaver", ".github/workflows/prod-synthetic-probe.yml", false)]
+ [InlineData("someone", "MeshWeaver", ".github/workflows/ci.yml", true)]
+ [InlineData("someone", "MeshWeaver", ".github/workflows/dotnet-test.yml", false)]
+ [InlineData("Systemorph", "MeshWeaver.Reinsurance", ".github/workflows/ci.yml", true)]
+ [InlineData("Systemorph", "MeshWeaver.Reinsurance", ".github/workflows/auto-update-green-prs.yml", false)]
+ [InlineData("someone", "content-repo", ".github/workflows/ci.yml", true)]
+ [InlineData("someone", "content-repo", ".github/workflows/deploy.yml", false)]
+ public void OnlyTheRepositorysContentCiWorkflowCanPublish(
+ string owner, string repo, string workflowPath, bool expected)
+ {
+ Assert.Equal(expected,
+ GitHubWebhookProcessor.IsRepositoryContentWorkflow(
+ new RepoIdentity(owner, repo), workflowPath));
+ }
+
+ [Fact]
+ public void AWorkflowWithNoPathCannotClaimItCheckedContent()
+ {
+ var repository = new RepoIdentity("Systemorph", "MeshWeaver.Plugins");
+ Assert.False(GitHubWebhookProcessor.IsRepositoryContentWorkflow(repository, null));
+ Assert.False(GitHubWebhookProcessor.IsRepositoryContentWorkflow(repository, ""));
+ Assert.False(GitHubWebhookProcessor.IsRepositoryContentWorkflow(repository, " "));
+ }
+
+ [Fact]
+ public void AnArbitraryRepositoryCanDeclareItsOneRepositoryLevelWorkflowPath()
+ {
+ var repository = new RepoIdentity("customer", "knowledge");
+ var overrides = new[]
+ {
+ new GitHubContentWorkflow
+ {
+ Repository = "https://github.com/CUSTOMER/Knowledge",
+ Path = ".github/workflows/publish-content.yml",
+ },
+ };
+
+ Assert.True(GitHubWebhookProcessor.IsRepositoryContentWorkflow(
+ repository, ".github/workflows/publish-content.yml", overrides));
+ Assert.False(GitHubWebhookProcessor.IsRepositoryContentWorkflow(
+ repository, ".github/workflows/ci.yml", overrides));
+ }
+
+ [Fact]
+ public void RepositoryLevelWorkflowOverridesBindFromTheDocumentedConfiguration()
+ {
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["GitHub:ContentWorkflows:Repositories:0:Repository"] = "customer/knowledge",
+ ["GitHub:ContentWorkflows:Repositories:0:Path"] =
+ ".github/workflows/publish-content.yml",
+ })
+ .Build();
+ using var services = new ServiceCollection()
+ .AddSingleton(configuration)
+ .AddGitHubSyncServices()
+ .BuildServiceProvider();
+
+ var configured = services
+ .GetRequiredService>()
+ .Value.Repositories;
+ var entry = Assert.Single(configured);
+ Assert.Equal("customer/knowledge", entry.Repository);
+ Assert.Equal(".github/workflows/publish-content.yml", entry.Path);
+ }
+
+ [Fact]
+ public void WorkflowPathsAreGitPaths_AndTheirCaseIsNotInventedAway()
+ {
+ var repository = new RepoIdentity("Systemorph", "MeshWeaver.Plugins");
+ Assert.True(GitHubWebhookProcessor.IsRepositoryContentWorkflow(
+ repository, GitHubWebhookProcessor.StandardContentWorkflowPath));
+ Assert.False(GitHubWebhookProcessor.IsRepositoryContentWorkflow(
+ repository, ".github/workflows/CI.yml"));
+ }
+
+ [Fact]
+ public void AGreenScheduledPrUpdaterOnMain_IsNotAPublishSignal()
+ {
+ Assert.True(GitHubWebhookProcessor.IsPublishSignalTrigger("schedule"),
+ "the trigger stays broad because the real content CI also runs on a schedule");
+ Assert.True(GitHubWebhookProcessor.IsDefaultBranchBuild(Default, Default));
+ Assert.False(IsPublishSignal(
+ "schedule", Default, workflowPath: ".github/workflows/auto-update-green-prs.yml"));
+ }
+
+ [Fact]
+ public void CoreContentCiRemainsAtThePublishSignalPath()
+ {
+ var root = SourceScan.FindRepoRoot();
+ var workflowNames = Directory
+ .EnumerateFiles(Path.Combine(root, ".github", "workflows"))
+ .Select(Path.GetFileName);
+ Assert.Contains(workflowNames,
+ name => string.Equals(name, "dotnet-test.yml", StringComparison.Ordinal));
+ Assert.True(File.Exists(Path.Combine(root, ".github", "workflows", "dotnet-test.yml")),
+ "moving core content CI would make every workflow_run fail the path discriminator; "
+ + "the renamed workflow must fail red instead of silently freezing GitSync");
+ Assert.Equal(".github/workflows/dotnet-test.yml",
+ GitHubWebhookProcessor.ExpectedContentWorkflowPath(
+ new RepoIdentity("Systemorph", "MeshWeaver")));
+ }
+
// โโ the composition is the processor's, not this file's โโโโโโโโโโโโโโโโโโ
///
- /// ๐จ Control arm. Everything above tests two predicates; this asserts that
- /// ProcessWorkflowRun still CALLS BOTH of them โ otherwise a refactor that drops one guard
+ /// ๐จ Control arm. Everything above tests three predicates; this asserts that
+ /// ProcessWorkflowRun still CALLS all three โ otherwise a refactor that drops one guard
/// leaves every assertion above green while the gate is gone, which is exactly the shape
/// AGENTS.md warns about ("a guard whose subject moved and whose roots did not passes having
/// checked nothing").
///
[Fact]
- public void ProcessWorkflowRunStillRunsBothGuards()
+ public void ProcessWorkflowRunStillRunsAllThreeGuards()
{
var source = File.ReadAllText(Path.Combine(
SourceScan.FindRepoRoot(), "src", "MeshWeaver.GitSync", "GitHubWebhookProcessor.cs"));
@@ -162,7 +278,8 @@ public void ProcessWorkflowRunStillRunsBothGuards()
var body = MethodBody(source, "private IObservable ProcessWorkflowRun(JsonElement payload)");
Assert.Contains("IsPublishSignalTrigger(", body, StringComparison.Ordinal);
Assert.Contains("IsDefaultBranchBuild(", body, StringComparison.Ordinal);
- // The conclusion gate is the third leg of the same decision and is asserted end-to-end by the
+ Assert.Contains("IsRepositoryContentWorkflow(", body, StringComparison.Ordinal);
+ // The conclusion gate is the fourth leg of the same decision and is asserted end-to-end by the
// mesh suite; pinned here too because losing it would publish RED builds, silently.
Assert.Contains("\"success\"", body, StringComparison.Ordinal);
}
@@ -192,9 +309,15 @@ private static string MethodBody(string source, string signature)
///
/// The processor's own composition, in the order it applies it: a delivery publishes when the
- /// trigger is admitted AND the run is on the default branch.
+ /// trigger is admitted AND the run is on the default branch AND the workflow path is the
+ /// repository's content CI.
///
- private static bool IsPublishSignal(string trigger, string headBranch, string defaultBranch = Default)
+ private static bool IsPublishSignal(
+ string trigger,
+ string headBranch,
+ string defaultBranch = Default,
+ string workflowPath = ContentWorkflow)
=> GitHubWebhookProcessor.IsPublishSignalTrigger(trigger)
- && GitHubWebhookProcessor.IsDefaultBranchBuild(headBranch, defaultBranch);
+ && GitHubWebhookProcessor.IsDefaultBranchBuild(headBranch, defaultBranch)
+ && GitHubWebhookProcessor.IsRepositoryContentWorkflow(Repository, workflowPath);
}
diff --git a/test/MeshWeaver.Hosting.Test/BuildTriggeredSyncPinsTheBuiltCommitTest.cs b/test/MeshWeaver.Hosting.Test/BuildTriggeredSyncPinsTheBuiltCommitTest.cs
index 18df08843d..5fe56e2a4b 100644
--- a/test/MeshWeaver.Hosting.Test/BuildTriggeredSyncPinsTheBuiltCommitTest.cs
+++ b/test/MeshWeaver.Hosting.Test/BuildTriggeredSyncPinsTheBuiltCommitTest.cs
@@ -175,13 +175,36 @@ await Assert.ThrowsAsync(() =>
await repoClient.FetchedRefs.Should().NotEmit(within: TestTimeouts.Quick);
}
- private static JsonElement GreenBuildPayload(string headSha) => JsonDocument.Parse($$"""
+ ///
+ /// A green workflow that did not compile the repository's content is not a weaker build signal;
+ /// it is no build signal at all. This is the live #3978 shape: the scheduled PR updater wrote
+ /// twenty build completions for a commit whose real content CI was red.
+ ///
+ [Fact(Timeout = 120_000)]
+ public async Task GreenUnrelatedWorkflow_DoesNotRecordOrTriggerAnImport()
+ {
+ var noFetch = repoClient.FetchedRefs.Should().NotEmit(within: TestTimeouts.Quick);
+
+ var triggered = await Webhooks.Process(
+ "workflow_run",
+ GreenBuildPayload(BuiltSha, ".github/workflows/auto-update-green-prs.yml"))
+ .Timeout(TestTimeouts.Convergence).Await();
+
+ triggered.Should().Be(0,
+ "a workflow's trigger says how it started, not that it compiled this repository's content");
+ await noFetch;
+ }
+
+ private static JsonElement GreenBuildPayload(
+ string headSha,
+ string workflowPath = ".github/workflows/ci.yml") => JsonDocument.Parse($$"""
{
"action": "completed",
"repository": { "full_name": "{{RepoFullName}}", "default_branch": "main" },
"workflow_run": {
"conclusion": "success", "head_branch": "main", "head_sha": "{{headSha}}",
"id": 34061098155, "run_number": 2026, "name": "Content CI", "event": "push",
+ "path": "{{workflowPath}}",
"updated_at": "2026-09-06T22:38:18Z"
}
}
diff --git a/test/MeshWeaver.Hosting.Test/GreenBuildSkipsASettledSourceTest.cs b/test/MeshWeaver.Hosting.Test/GreenBuildSkipsASettledSourceTest.cs
index a067e75e57..b71e7ead6a 100644
--- a/test/MeshWeaver.Hosting.Test/GreenBuildSkipsASettledSourceTest.cs
+++ b/test/MeshWeaver.Hosting.Test/GreenBuildSkipsASettledSourceTest.cs
@@ -325,6 +325,7 @@ private static JsonElement GreenBuildPayload(string headSha) => JsonDocument.Par
"workflow_run": {
"conclusion": "success", "head_branch": "main", "head_sha": "{{headSha}}",
"id": 39450000001, "run_number": 3945, "name": "Content CI", "event": "push",
+ "path": ".github/workflows/ci.yml",
"updated_at": "2026-09-10T17:35:00Z"
}
}