Skip to content

fix(quality): the coverage flag went ON at @main and reached OLD pinned runners — decidesk and doriath went red on gates 4, 24 and 33 - #173

Merged
rubenvdlinde merged 1 commit into
mainfrom
fix/coverage-flag-ref-skew-guard
Aug 5, 2026
Merged

fix(quality): the coverage flag went ON at @main and reached OLD pinned runners — decidesk and doriath went red on gates 4, 24 and 33#173
rubenvdlinde merged 1 commit into
mainfrom
fix/coverage-flag-ref-skew-guard

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

fix(quality): the coverage flag went ON at @main and reached OLD pinned runners — decidesk and doriath went red on gates 4, 24 and 33

hydra-gates-require-full-coverage was flipped to default TRUE (#164) once the
runner could tell a not-applicable gate from a real gap. But this workflow is
consumed at @main while the gate package is consumed at the caller's own
hydra-gates-ref, so the new default was handed to runners that predate the
accounting entirely — and those count EVERY gate they did not run as a gap.

Measured, not inferred, both after the flip and both failing on nothing else:

decidesk job 92432089644 ref v1.0.1 ALL 61 GATES GREEN
COVERAGE: 58 of 61 / DID NOT RUN: 4 24 33 -> exit 98
doriath job 92363547732 ref v1.0.1 same three gates, same verdict

Gates 4, 24 and 33 are the exact trio the product owner named as legitimately
not applicable. So the flip was producing the outcome it was made to prevent,
in every repo whose pin had not moved — and pins do not move on in-flight
branches.

The flag is now handed only to a package that can honour it. The probe reads the
package's own accounting (_NA_GATES in the runner, the NOT APPLICABLE
verdict in the wrapper) rather than comparing versions, because a pin may be a
tag, a branch, a SHA or a fork. Verified against every published tag:
v1.0.0/v1.0.1/v1.1.0/v1.2.0 -> withheld, v1.3.0/v1.4.0 -> applied. A checkout
missing either file is a broken checkout, not an old one, and fails.

Withholding is stated as a ::warning:: naming the pin and the fix. A control
that switches itself off quietly is a control that has stopped existing.

--axe-enabled is gated on the same probe. The old wrapper's parser ends in
*) APP_DIR="$1", so an unrecognised flag is swallowed as a POSITIONAL and
clobbers the app dir set moments earlier: a v1.0.1 pin handed it aborts with
FATAL: --app-dir '--axe-enabled' is not a directory. No gate ran. — exit 99,
every gate lost, for a flag the caller never typed.

Same ref skew, second instance: axe-run.cjs landed in v1.4.0 and every caller is
pinned v1.3.0, so enable-axe: true would die on Cannot find module and then
present as "the axe report never arrived" — pointing at Playwright, the one
place the fault is not. Asserted at the checkout with a named error instead.

The two probe literals are a CONTRACT: renaming either would silently disable
coverage enforcement fleet-wide, with a warning blaming the caller's pin. The
package's own suite now asserts both, plus a reverse control proving the probe
does not match the pre-v1.3.0 shape of either file. Positive-controlled: with
both literals renamed in a copy, those assertions go red.

Four-way measurement on one fixture, one variable at a time:

v1.0.1 + --require-full-coverage exit 98 <- the observed failure
v1.0.1, flag withheld (this change) exit 0
v1.4.0 + flag, only (a) gaps exit 0 33 of 33 applicable gates ran
v1.4.0 + flag + a structural gap exit 98 [gate-24] SKIPPED (structural)

Also removes a stale comment left by the #161/#166/#168 cycle that instructed
future readers to embed the axe runner inline — the thing that made the whole
workflow unresolvable fleet-wide.

Suite 31 -> 34 tests, 0 failing. actionlint clean (positive-controlled: a
typo'd step id is reported). Largest run: step 8,533 B, far below the ~19 KB
that makes a workflow unresolvable.


Gates 4, 24 and 33 — classified, with the evidence

gate verdict evidence
gate-4 composer-audit (a) in both of its common shapes — no composer.json at all, and composer.json/.lock not in a diff-scoped run (ADR-020). (c) in a third: composer.json present but composer not on PATH — a dependency tree that exists and was never audited. run-hydra-gates.sh:661,663,671 — three _skip 4 call sites, categorised na, wiring, na. Live: doriath job 92411975098 [gate-4] composer-audit: NOT APPLICABLE — neither composer.json nor composer.lock is in this diff.
gate-24 integration-parity (a) when the repo registers no integration leaves at all; (b) when it registers them and ships no parity check. Split on the gate's OWN subject matter, not on the presence of its checker. run-hydra-gates.sh:1964 (na) / :1966 (structural). Both measured on one fixture: with no leaves, NOT APPLICABLE, exit 0; after adding one new LeafDescriptor(, [gate-24] integration-parity: SKIPPED (structural) and exit 98.
gate-33 axe-core (a) with no src/; (a) with src/ but the caller never set enable-axe (that choice is a visible line in the caller's own workflow, not a gap hidden in the runner); (b) when enable-axe IS set and no report arrives. run-hydra-gates.sh:2494,2496,2498. Live: doriath job 92411975098 [gate-33] axe-core: NOT APPLICABLE — … the caller did not set enable-axe.

Note gate-33's (b) case was, until this PR, unreachable at every pin in the fleet: --axe-enabled cannot be passed to a v1.3.0 package without the axe runner (v1.4.0), and cannot be passed to anything older without destroying the run.

Both directions, on real runs

Either direction alone is satisfiable by a broken flag — one that never fails passes the first, one that always fails passes the second.

(1) only category-(a) non-reporters → PASSES with the flag on — live GitHub Actions, flag on, no local anything:

run ref verdict
doriath job 92411975098 v1.3.0 hydra-gates-require-full-coverage: true · NOT APPLICABLE: 4 6 7 24 33 · 58 of 58 applicable gates ran · job success
decidesk job 92441077855 v1.3.0 58 of 63 … (5 not applicable; 58 of 58 applicable gates ran) · job success

(2) a category-(b) non-reporter → FAILS with the flag on — one fixture, one variable at a time:

package flag injected exit line
v1.4.0 on 0 33 of 33 applicable gates ran
v1.4.0 on one new LeafDescriptor(, no parity script 98 [gate-24] integration-parity: SKIPPED (structural)
v1.4.0 on check_no_admin_idor.py removed counted against coverage [gate-7] no-admin-idor: SKIPPED (wiring) · 51 of 53 applicable gates ran
v1.0.1 on 98 the fleet failure this PR fixes
v1.0.1 withheld 0 what this PR does at an old pin

That the workflow turns exit 98 into a red job is proven by a real run too — decidesk job 92432089644 exited 98 and the job is failure. Its cause was the misclassification; its plumbing is the same path a genuine structural gap takes.

Verification

  • The probe was extracted out of the parsed YAML (983 B, asserted free of ${{ }}) and run against real checkouts of every published tag: v1.0.0 v1.0.1 v1.1.0 v1.2.0 -> categories=0, v1.3.0 v1.4.0 -> categories=1. Broken checkout (either file missing) -> exit 1 with a named error, no output variable written.
  • The ARGS assembly was extracted from the parsed YAML the same way and exercised as a matrix: flag on + new pin -> --require-full-coverage; flag on + old pin -> no flag, warning; axe on + old pin -> no flag, both warnings.
  • actionlint clean. Positive-controlled on this same file: a typo'd steps.gatecapp is reported at the exact line.
  • shellcheck clean on the modified suite. Positive-controlled.
  • Suite 31 -> 34 tests, 0 failing. Helper suites 18 passed / 2 quarantined — both quarantines pre-existing and documented (test_gate_glob_recursion_fixtures.sh, test_gate_orphaned_capability_fixtures.sh), neither touched here.
  • Largest run: step in the file: 8,533 B. The unresolvable-workflow threshold sits between ~19 KB and ~21 KB.

Not fixed here

Every caller pins hydra-gates-ref: v1.3.0 on its default branch, so nobody gets #168's axe DOM scoping or gate-46 anchor fix until those pins move to v1.4.0. That is a consumer-ref sweep, deliberately not folded into a workflow fix.

🤖 Generated with Claude Code

…ed runners — decidesk and doriath went red on gates 4, 24 and 33

`hydra-gates-require-full-coverage` was flipped to default TRUE (#164) once the
runner could tell a not-applicable gate from a real gap. But this workflow is
consumed at @main while the gate package is consumed at the caller's own
`hydra-gates-ref`, so the new default was handed to runners that predate the
accounting entirely — and those count EVERY gate they did not run as a gap.

Measured, not inferred, both after the flip and both failing on nothing else:

  decidesk job 92432089644  ref v1.0.1  ALL 61 GATES GREEN
                            COVERAGE: 58 of 61 / DID NOT RUN: 4 24 33 -> exit 98
  doriath  job 92363547732  ref v1.0.1  same three gates, same verdict

Gates 4, 24 and 33 are the exact trio the product owner named as legitimately
not applicable. So the flip was producing the outcome it was made to prevent,
in every repo whose pin had not moved — and pins do not move on in-flight
branches.

The flag is now handed only to a package that can honour it. The probe reads the
package's own accounting (`_NA_GATES` in the runner, the `NOT APPLICABLE`
verdict in the wrapper) rather than comparing versions, because a pin may be a
tag, a branch, a SHA or a fork. Verified against every published tag:
v1.0.0/v1.0.1/v1.1.0/v1.2.0 -> withheld, v1.3.0/v1.4.0 -> applied. A checkout
missing either file is a broken checkout, not an old one, and fails.

Withholding is stated as a `::warning::` naming the pin and the fix. A control
that switches itself off quietly is a control that has stopped existing.

`--axe-enabled` is gated on the same probe. The old wrapper's parser ends in
`*) APP_DIR="$1"`, so an unrecognised flag is swallowed as a POSITIONAL and
clobbers the app dir set moments earlier: a v1.0.1 pin handed it aborts with
`FATAL: --app-dir '--axe-enabled' is not a directory. No gate ran.` — exit 99,
every gate lost, for a flag the caller never typed.

Same ref skew, second instance: axe-run.cjs landed in v1.4.0 and every caller is
pinned v1.3.0, so `enable-axe: true` would die on `Cannot find module` and then
present as "the axe report never arrived" — pointing at Playwright, the one
place the fault is not. Asserted at the checkout with a named error instead.

The two probe literals are a CONTRACT: renaming either would silently disable
coverage enforcement fleet-wide, with a warning blaming the caller's pin. The
package's own suite now asserts both, plus a reverse control proving the probe
does not match the pre-v1.3.0 shape of either file. Positive-controlled: with
both literals renamed in a copy, those assertions go red.

Four-way measurement on one fixture, one variable at a time:

  v1.0.1 + --require-full-coverage        exit 98  <- the observed failure
  v1.0.1, flag withheld (this change)     exit 0
  v1.4.0 + flag, only (a) gaps            exit 0   33 of 33 applicable gates ran
  v1.4.0 + flag + a structural gap        exit 98  [gate-24] SKIPPED (structural)

Also removes a stale comment left by the #161/#166/#168 cycle that instructed
future readers to embed the axe runner inline — the thing that made the whole
workflow unresolvable fleet-wide.

Suite 31 -> 34 tests, 0 failing. actionlint clean (positive-controlled: a
typo'd step id is reported). Largest `run:` step 8,533 B, far below the ~19 KB
that makes a workflow unresolvable.
@rubenvdlinde
rubenvdlinde merged commit d649942 into main Aug 5, 2026
7 checks passed
@rubenvdlinde
rubenvdlinde deleted the fix/coverage-flag-ref-skew-guard branch August 5, 2026 21:10
rubenvdlinde pushed a commit that referenced this pull request Aug 5, 2026
Three reconciliations, each caught by an existing assertion rather than by
reading the diff:

* gate-28 — #172 gave this gate a four-way skip taxonomy (`na` for no lib/,
  `na` for no composer.json, `structural` for a composer.json without a
  `license`, `structural` for nothing carrying a tag) and made PASS
  conditional on having actually compared a file. Moving the read into a
  helper collapsed all of that into one `na`, undoing it. The taxonomy is
  restored verbatim; the helper now returns the compared-file count on
  stderr so `_lt_checked` still gates the PASS. The one state that
  short-circuits it is a MISSING HELPER — which #172's chain would have
  called `structural`, i.e. a claim about the REPOSITORY, when the
  repository is fine and the gate is broken. Those must not wear the same
  words.

* gates 40 and 46 — an empty in-scope file set is ordinary ADR-020 diff
  scoping, not "not applicable". tests/test-hydra-gates-bin.sh asserts that
  no src-guarded gate reports NOT APPLICABLE while src/ exists, and gate-40
  was tripping it. Answering differently from every sibling gate drifts the
  applicability table away from the guards it mirrors — the one way that
  table could hide a live gate.

* quality.yml — #173 added a preflight for the FLAG direction of the
  floating-caller / pinned-callee desync (does the pinned runner understand
  the coverage flag?). This branch adds one for the PATH direction (does the
  pinned package contain the files the workflow executes by name?). Both are
  kept: they are different halves of the same interface, and #168 broke the
  half #173 does not cover.

And one contract change, made explicit rather than incidental:

  test-hydra-gates-bin.sh's "empty diff exits 0" fixture set BASE_SHA to
  HEAD, so it was really asserting "scoping a commit against itself exits 0"
  — the shillinq shape. Those are different facts and only one is
  legitimate. The original assertion now runs against a real
  base-behind-HEAD empty diff and still expects 0; a new assertion covers
  base == HEAD, expects 99, and checks that NO gate printed PASS.

Verified on the merged tree:

  hydra-gates entry-point tests: 36 passed, 0 failed  (was 31 passed, 3 failed)
  helper suites:                 23 passed, 0 failed
  gate-46 fleet: 1,995 -> 918    gate-40 fleet: 1,211 -> 517  (unchanged by the merge)
rubenvdlinde added a commit that referenced this pull request Aug 6, 2026
… on the failure mode that has no red (#177)

* feat(quality): stop pinning the gates package per repo, and gate main on the failure mode that has no red

The fleet pinned `hydra-gates-ref` in 23 repositories. A pin is a silent
expiry date, and it has now cost us twice:

  #159  22 repos sat on v1.0.1, predating a batch of gate fixes. 16 gates
        were dead fleet-wide and every one reported PASS — a gate whose
        helper never runs emits a tick identical to a real one.
  #173  the coverage flag was flipped to default true here, at @main, and
        reached v1.0.1 runners predating the accounting that makes it
        survivable. decidesk and doriath went red (exit 98) on gates 4, 24
        and 33 — the not-applicable gates that must never fail a run.

Both are one defect: this workflow is consumed at @main while the package
was consumed at a pin, so the two sides moved independently and a change on
one reached a runner from the other's past. The fix is no pin. The default
was already `main`; this makes that the documented contract and states the
rollback levers, because "always latest" is only safe with a way back:
revert on main for the whole fleet, or set the input explicitly for one
repo. A moving `v1` tag was considered and rejected — a tag nobody
re-points is indistinguishable from the stale pin being removed.

Unpinning means a broken main now reaches 23 repos at once, so main is
gated on the one failure mode that does not announce itself. An
unresolvable reusable workflow is not a red check: GitHub produces a run
with no jobs, or no run, and every dashboard stays green while every caller
quietly stops being checked. That is what a ~22 KB `run:` step did in #161,
reverted by #166 and re-landed by #168.

  quality-selftest.yml       an in-repo caller of ./.github/workflows/
                             quality.yml. It exists to be RESOLVED, not to
                             pass; its conclusion is not a verdict.
  quality-resolve-probe.yml  counts the JOBS that caller materialised.
                             `jobs == 0` is the outage signature, so the
                             count is the measurement and a conclusion is
                             deliberately not read. Split across two files
                             on purpose: with the call in the probe itself,
                             an unresolvable quality.yml would delete the
                             probe too and the required check would sit
                             PENDING FOREVER rather than go red.

Both guards are positive-controlled on every run. The probe first counts a
workflow that cannot exist and requires the answer to be 0 — a counter
returning a constant would otherwise pass forever. The lint is re-run at a
1-byte limit it cannot meet. The lint also reproduces the real outage:
against quality.yml at d68fb72 it names the 26959-byte block at line 2338.

hydra-gates-package.yml loses its `paths:` filter. It was defensible while
callers pinned; with the fleet on @main the blast radius of any merge here
is the whole fleet, and a required check with a paths filter leaves every
excluded PR pending forever.

Refs #159, #161, #166, #168, #173

* fix(selftest): widen the caller's permissions — a narrow grant is a second silent way to zero jobs

The selftest shipped with `contents: read`. Run 31073908079 came back
startup_failure with ZERO jobs, because quality.yml's journeydoc-capture job
declares `contents: write` + `actions: write` and permissions are validated
when the run is CREATED — `enable-journeydoc-capture: false` never gets a
chance to matter, since the job's `if:` is not reached.

This is a second route into the #161 outage class and it applies to every
caller in the fleet: a permission grant narrower than the shared workflow
requests produces no jobs and no red, indistinguishable from a healthy run
on any dashboard.

The probe caught it on its first execution, which is the evidence that it
works. Run 31073907988 recorded, in order:

  control count = 0
  OK — the counter can return 0, and reports it.
  quality-selftest.yml materialised 0 jobs at 7c3d160
  ##[error]quality.yml produced ZERO jobs. This is the #161 outage signature

So the guard has now been shown to fail on a REAL zero-jobs condition, not
only against its synthetic control.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
This was referenced Aug 6, 2026
rubenvdlinde added a commit that referenced this pull request Aug 6, 2026
…g one state too far (#180)

* fix(gate-28): an EMPTY DIFF SCOPE is not a structural gap — #172 swung one state too far

#172 was right that gate-28 must not report PASS having opened zero files.
But there are TWO ways to read zero files and it gave them the same word:

  (a) lib/**/*.php files ARE in this diff and none carries a licence tag
      — a genuine gap in the repository, correctly `structural`;
  (b) no lib/**/*.php file is in this diff AT ALL — diff-scoping working
      exactly as ADR-020 designed it, and no statement about the repo.

(b) is the ordinary case. `_in_scope` filters to the PR's diff, so every
workflow-only and frontend-only PR reads zero lib PHP files. Calling that
`structural` counts it against coverage, and with
hydra-gates-require-full-coverage on by default the run fails with exit 98
for a licence problem the repository does not have.

MEASURED on the fleet's own unpinning PRs, each a single-file workflow diff:

  hrmq#74                     CLEAN baseline -> RED on nothing but gate-28
  app-versions#129            gate 28 its only gate that did not run
  opencatalogi#813            "
  nextcloud-app-template#132  "
  launchpad#60                "

hrmq's 168 lib PHP files all carry their tags. The gate had simply not been
handed any of them to read, and then reported that as the repository's
fault. This is the "a gate that is legitimately not applicable must NOT
fail the run" rule broken — the same shape as #173, one gate lower down.

It surfaced now because the fleet just unpinned (#177): at v1.3.0 gate-28
reported PASS on an empty read, so the false GREEN hid what is now a false
RED. Neither was a verdict.

The `structural` branch is preserved for case (a) and now names the count
of files that WERE in scope, so the two states no longer read alike.

TESTS — scripts/lib/test_gate_license_triangle_scope.sh, four control pairs,
discovered automatically by tests/run-helper-suites.sh:

  wf-only diff, tagged repo  -> NOT APPLICABLE  (b, the false red)
  untagged PHP in the diff   -> structural      (a, must stay red)
  tagged PHP in the diff     -> PASS            (the gate compared something)
  no lib/ at all             -> NOT APPLICABLE  (#172's original case)

Verified in BOTH directions rather than assumed: against the fixed runner
5/5 green; against the runner at 954a78a the first two FAIL and the last
three still pass, so the suite discriminates and the fix is targeted rather
than a mute for #172.

The did-not-run assertion took two attempts to measure anything — v1 matched
only a summary line this suite never triggers, v2 matched a roster the
runner also prints for NOT-APPLICABLE gates. Both are recorded in the file,
because a dead assertion inside a suite meant to tell two states apart is
the exact defect the suite exists to catch.

* fix(test): explicit if/else in _expect — `A && _ok || _bad` can count one assertion twice

ShellCheck SC2015 on the new suite, and it is not a style note here. In
`grep -q ... && _ok "$d" || _bad "$d"`, a non-zero return from _ok makes
_bad run as well, so the SAME assertion is counted once as a pass and once
as a failure. In a harness whose entire job is to count assertions
correctly, that is instrument corruption — the class this suite exists to
catch, one level up.

Also adds a `*)` arm: an unknown expectation string now fails loudly
instead of matching no case and silently asserting nothing.

Re-verified in both directions after the change: fixed runner 5 passed /
0 failed, runner at 954a78a 3 passed / 2 failed, entry-point invariants
39/0.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant