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" } }