Repository and CI health β the checks a repository cannot run on itself
Hosting/RepoHealth is the fleet's repository audit. Every check it performs was done BY HAND on
2026-09-01, and every one of them found a live defect in a repository whose CI was entirely
green. That is the common thread and the reason this is a node type rather than a shell script:
each of these failures is a failure of reporting, and a wall of ticks reports none of them.
π¨ An unmeasured check is never clean. A default report reads
unmeasured, notclean; a repository the scan could not read, a pull-request file list that was truncated, a rename whose previous path GitHub did not report β each lands inUnmeasured, which forces the verdict away from clean whatever the finding count says. An audit that turns a read failure into a green tick is worse than no audit, because it is believed.
Where it lives
| Type | Hosting/RepoHealth β content RepoHealthReport |
| Areas | Content (one recorded scan) Β· Fleet (every recorded scan, merged, newest per repository) Β· Scan (measure NOW against GitHub) Β· Tests |
| Credential | the fleet's existing GitHubAppTokenService installation token β the same one DeploymentGitSync opens record PRs with. No new secret. An instance without the App configured gets a report that says the scan was skipped; it never gets a clean one. |
| I/O | one IIoPool leaf (IoPoolNames.Http). Everything above it is IObservable<T>; the only await is inside the leaf. |
| Policy | RepoHealthPolicy β authorable on the node, defaulting to RepoHealthPolicy.Fleet: the platform plus this catalog and the four satellites. |
The six checks
1. Required contexts β read BOTH protection shapes or report the wrong repo
GitHub has two independent protection mechanisms and enforces both. Classic branch protection
lives at /repos/{slug}/branches/main/protection; rulesets are separate, and their effective
rules for a branch are at /repos/{slug}/rules/branches/main. RepoGateRules.Read reads both and
UNIONs the required contexts.
π¨ The platform repo answers 404 Branch not protected on the classic endpoint while a ruleset
named main pr protection requires a context, runs a merge queue and forbids force-pushes. An audit
that reads one endpoint reports the most protected repository in the fleet as the least protected
one β and gets discarded as broken, which is how a real audit dies.
π¨ And a read that FAILED is not "unprotected" either. A 403, a 500, a token without the scope
all return no contexts, and a rule that counts contexts cannot tell them from a branch that requires
none. BranchProtection.Measured separates them; the finding for an unmeasured branch is a Warning
that says so, never a Critical that says "unprotected".
What it found. At 2026-09-01 ~14:24 UTC this catalog's main required exactly two contexts β
Validate node repos and Compile every NodeType (vs core). The job that builds the portal hosts
and runs ~3,000 tests, Build + test the portal hosts, was not one of them, so a PR merged with
it RED and main was broken for five hours, blocking 21 pull requests. Nobody knew, because nothing
reported it. (Re-measured at 14:29 the same day the context was present β remediated by hand during
the session that found it. The check stays, because nothing prevents it recurring.)
On 2026-09-11 the same hole was closed one lane further along: test-repos, the only lane that
executes a NodeType's Tests area, was reporting on every PR and gating nothing β #1624
auto-merged with it RED. It is now the seventh required context here. Which seven, what each proves,
and the 45-PR measurement that made adding it safe rather than repo-wide fatal:
RequiredStatusChecks.md.
A sixth thing this rule catches for free: a required context that names no job in the repository's own workflows. It never turns green β the PR sits at "Expected" forever and reads as merely pending. Adopting a reusable workflow renames a repo's contexts, so this is the failure mode of every CI migration. It is a Warning rather than a Critical, and it is suppressed entirely when no workflow could be read: an unread workflow set is an absence of evidence, not evidence of absence.
2. Armed state β a queue entry OR an auto-merge request, never one alone
public bool IsArmed() => QueueState is not null || AutoMergeEnabled;
π¨ On a merge-queue repository gh pr merge <N> --merge --auto behaves two different ways:
| The required checks are⦠| What happens | mergeQueueEntry |
autoMergeRequest |
|---|---|---|---|
| already green | it ENQUEUES immediately | set | null |
| not yet green | it records an auto-merge request | null | set |
Both mean armed. Reading autoMergeRequest alone calls every enqueued PR idle; reading
mergeQueueEntry alone calls every auto-merge-armed PR idle. This was measured backwards three
times in one session before the predicate was written down.
What it found. Ten pull requests on the platform repo sat green, mergeable and unarmed. A PR in that state is invisible: not failing, not blocked, not in anybody's way, and nothing will ever land it. The finding names the number and prints the exact command that arms it.
3. Queue health β UNMERGEABLE is not a conflict with main
An UNMERGEABLE merge-queue entry conflicts with the pull requests ahead of it in the queue,
not with the base branch. So its own PR page keeps reading MERGEABLE/CLEAN while every
speculative build stacked behind it is wasted. Anyone looking at the PR sees a healthy PR; anyone
looking at the queue sees a stalled queue; nothing connects the two.
The report states the contradiction explicitly β the queue state, the position, and what the PR page still says β because reading either half alone leads away from the fix.
A genuinely conflicting PR is a different, separately reported finding: it gets no check suite
at all, so the green it renders was earned before the conflict and proves nothing about its
current head. (This is the same trap scripts/pr-ready.py exists for.)
4. Cross-repo breaks β the highest-value check, and nothing else can run it
Neither repository's CI can see this one. The platform does not build this catalog; this catalog
reaches the platform through $(MeshWeaverRoot) and β at four sites in ci.yml β checks it out with
no ref:, so it builds against the platform's moving main.
CrossRepoRules closes it: parse every ProjectReference that goes through $(MeshWeaverRoot),
read the upstream's open pull requests with the files each one changes, and report any reference to
a path an open PR deletes or moves.
- A removal vacates its own path; a rename vacates the path it came FROM β which is the one anything references. GraphQL does not report a rename's previous path, so the probe falls back to REST for exactly the pull requests that moved a project file, and a rename it still cannot resolve is recorded as unmeasured rather than passed over.
- A vacated project directory takes every file under it, however GitHub reported the deletion.
- A conditional reference grades to Warning; an unconditional one to Critical.
What it found. Platform PR #2941 ("The indexing pipeline, the map control and Cornerstone leave
the content surface") is still open, and it deletes three project files, not one. Run over this
repo's real tree β 567 $(MeshWeaverRoot) references across 116 project files, against all 14 open
platform pull requests β the rule reports:
| Deleted by #2941 | References here | Verdict |
|---|---|---|
src/MeshWeaver.Maps/MeshWeaver.Maps.csproj |
11, 0 unconditional | Warning |
src/MeshWeaver.ContentCollections.Indexing/β¦csproj |
2, 2 unconditional | π΄ Critical |
src/MeshWeaver.ContentCollections.Indexing.Graph/β¦csproj |
2, 2 unconditional | π΄ Critical |
The Maps row is the one that was caught by hand: it reached merge-queue position 2, and its eleven
references have since been hardened with an Exists() fallback that prefers this repo's own copy and
falls back to $(MeshWeaverRoot) β which is why the rule now grades it Warning rather than Critical,
exactly as designed.
The two Indexing rows are the ones the hand audit missed. Those projects already exist in THIS
repo, and src/MeshWeaver.AI/MeshWeaver.AI.csproj (lines 51β52) and
src/Memex.Portal.Gui/Memex.Portal.Gui.csproj (lines 103β104) still reach for the platform's copies
unconditionally. When #2941 merges, restore fails for the AI engine and the portal host β which is
the Build + test the portal hosts job. The same Exists() fallback the Maps references got is the
fix, and the sequencing belongs with #2941.
That is the whole argument for this check being a node type rather than a person: the hand audit found one of three, because a person greps for the project they already suspect.
5. Unpinned upstream β a gate that is a function of somebody else's trunk
WorkflowRules classifies every actions/checkout of the upstream:
| Pin | Meaning | Finding |
|---|---|---|
no ref: |
the action's default β the upstream's default branch | Warning |
a branch, or ${{ vars.X \|\| 'main' }} |
moving, but at least declared | Info |
| an expression with no literal fallback | not classifiable β said so, never guessed | β |
| a tag or a 40-hex sha | immutable | β |
A ref: belongs to the step whose with: block it sits in; the scan reads indentation rather than
parsing YAML, and a step with no ref: must never borrow the next step's β a case the tests pin.
What it found in this repo: four unpinned checkouts of the platform (ci.yml lines 531, 1035,
1248, 1776), three moving-ref ones (log-watcher-image.yml, portal-ai-image.yml,
portal-next-image.yml), and one expression-pinned (ci.yml:1075, the catalog pin). The
hand-written brief said three; the measurement says four.
6. Main health β is the trunk RED, and is the answer even decisive
The first five checks all audit work trying to land: which contexts a branch requires, whether a
pull request is armed, whether a queue entry is poisoning the stack behind it, what an upstream PR
would break. None of them looks at what has already landed. MainBranchRules asks the one
question nothing else here asks, about the one branch everything else is built from.
What it cost to be missing. This catalog's main was red from 09:52Z on 2026-09-06 β runs
4642, 4644 and 4648 of Plugin Catalog CI, at 09:52, 10:08 and 11:03 β and it was found by a person
hours later while every board this type renders was reporting on pull requests. Core is no better
covered: its protection is a merge queue, which gates what enters and watches nothing afterwards.
π¨ "The newest completed run" is the wrong reader, and this fleet is full of the proof. A
cancelled run (a concurrency group superseding it) and a skipped one (every job filtered out) are
both completed and both decided nothing:
| Read on 2026-09-06 | Newest completed run | What it decided |
|---|---|---|
Plugin Catalog CI, 12:10Z |
12:09 cancelled |
nothing β the 11:03 failure below it was the state of the branch |
Retry known transients, 17:05Z |
17:04 skipped (six of them in a row) |
nothing β the 16:45 success below them was |
So the rule reads the newest decisive run per workflow, stepping over anything that decided nothing. Taking the newest completed run literally would have read the first row as healthy while the trunk was red, and the second as a pass on evidence nobody produced.
Severity, and the one thing that softens it. A red trunk with nothing newer running is
Critical β it is the current state of the branch, and everything built from it carries the
failure. When a newer run of the same workflow is already in flight the finding drops to
Warning and says so, because "newest completed" and "current state" diverge exactly while
something is being repaired β which is precisely when somebody is looking. It never disappears, and
the in-flight run is named, so a run parked in queued for ever is visible rather than a silent
excuse for the red beneath it.
Every workflow is judged, with no per-repository list of "the ones that count". Such a list is exactly the configuration that goes stale and then measures nothing. A workflow permanently red on the trunk is a defect to fix or a workflow to delete; the finding names it so the reader can tell.
REST, not GraphQL β deliberately, and the reason is a trap this fleet has paid for. GraphQL's
answer to "is it red" is statusCheckRollup, whose conclusion comes back as the empty string
rather than null when nothing ran, so every naive comparison reads it as "not failing". REST returns
status and conclusion as separate fields, so not finished, finished with no verdict and
success cannot be confused with one another. Belt and braces anyway: a completed run with an
empty or unrecognised conclusion is never green β an empty one is unmeasured, and an
unrecognised one is reported, so the day GitHub adds a verdict this check says so instead of
quietly reading a repository as clean.
What it cannot see, stated rather than implied. A run that concluded success because a job
inside it was skipped reads green here. That is the skip-trapdoor, and the rule against it (no
if: asking whether a secret is set; a preflight that fails RED) is what covers it β a
run-conclusion reader cannot. Likewise, a check suite an installed App never posts runs to stays
queued for ever: it cannot reach this check at all, which reads this repository's own Actions runs,
never check suites, and never waits for anything.
Unmeasured, not clean. If the API refused, if the branch has no runs at all, or if every run in
the window was cancelled, skipped or still going, the repository lands in Unmeasured β which forces
the report's verdict away from clean. A branch nobody could read is not a healthy branch.
Reading a report
- Verdict is DERIVED from the findings and the unmeasured list, never stored and never believed
from elsewhere.
unmeasuredoutranks everything. - Findings sort attention-first and a finding that names no repository or subject is dropped and the drop reported β a rule that cannot say what to fix is a bug in the rule. "Repo unhealthy" is not a finding.
- The
Fleetarea merges recorded scans: newest per repository wins, unmeasured lines survive the merge (so a merged board cannot read cleaner than its inputs), and the merged instant is the oldest contributing scan β a board is only as current as its stalest part. - Every stamp renders in the viewer's time zone through
AccessService.ToDisplayTime, resolved once on the render turn.
Cost
One scan is roughly three REST calls plus one GraphQL round trip per repository, one GraphQL call for
the upstream's open pull requests (files included β the expensive part is one request, not one per
PR), and two more calls only when an upstream PR actually removes or moves a project file. On
the default six-repo fleet that is about twenty-six requests against a 5,000/hour installation
budget. The default-branch run list is ONE page of at most 100 runs per repository, sized so the
cancelled and skipped runs that stack above a real verdict cannot push it out of the window.
The upstream PR page size is capped by maxUpstreamPullRequests; hitting the cap is recorded as
unmeasured, never silently truncated.
What it deliberately does NOT do
It reports; it does not command. It does not arm a pull request, dequeue an entry, edit branch protection, re-run a red workflow, revert a commit, or open a PR against anything. Branch protection is system-changing (AGENTS.md), so the node's job ends at naming the repository, the context, the PR number and the file.