Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 60 additions & 3 deletions .github/workflows/hydra-gates-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -366,23 +366,80 @@ jobs:
|| { echo "::error::${_na} not-applicable + ${_unrun} unrun gates are counted, but only ${_named} said so on their own line. A gate is disappearing from the per-gate output while still being tallied."; exit 1; }
echo "OK — exit 0, coverage stated, waivers stated, and ran=${_ran} + na=${_na} + unrun=${_unrun} = declared=${_decl}, each named."

- name: "Unresolvable base ref must exit 99 with no green"
# WHY THIS STEP NOW NAMES ITS SCOPE.
#
# Until ADR-020 was superseded, --scope-to-diff was the DEFAULT, so
# "unresolvable base" and "no scope at all" were the same condition and
# this step could leave the scope unstated. They are now two different
# conditions and they have two different correct answers, so the step
# that asserts the first must say which one it is testing.
#
# This is the diff-scoped half, and it is UNCHANGED: with
# --scope-to-diff, the base IS the scope, so an unresolvable base leaves
# nothing to inspect and refusing is the only honest verdict. Every
# assertion below is byte-for-byte the one that guarded this before the
# default moved; only the invocation names the mode it was always about.
# Verified against the fixture: exit 99, the "NOTHING WAS CHECKED" line,
# zero `[gate-N]` lines.
- name: "Unresolvable base ref, diff scope: must exit 99 with no green"
run: |
set -eu
cd /work/fixture
set +e
OUT="$(HYDRA_GATE_BASE_REF=origin/definitely-not-a-branch /work/consumer/vendor/bin/hydra-gates --app-dir /work/fixture 2>&1)"
OUT="$(HYDRA_GATE_BASE_REF=origin/definitely-not-a-branch /work/consumer/vendor/bin/hydra-gates --app-dir /work/fixture --scope-to-diff 2>&1)"
RC=$?
set -e
printf '%s\n' "${OUT}"

echo "--- assertions ---"
[ "${RC}" -eq 99 ] \
|| { echo "::error::expected exit 99 for an unresolvable base, got ${RC}"; exit 1; }
|| { echo "::error::expected exit 99 for an unresolvable base under --scope-to-diff, got ${RC}"; exit 1; }
printf '%s' "${OUT}" | grep -q "NOTHING WAS CHECKED" \
|| { echo "::error::did not state that nothing was checked"; exit 1; }
printf '%s' "${OUT}" | grep -qiE "ALL .* (GATES )?(PASSED|GREEN)" \
&& { echo "::error::an unresolvable base printed a green — this is the exact bug"; exit 1; }
printf '%s' "${OUT}" | grep -qE '^\[gate-[0-9]+\]' \
&& { echo "::error::gate lines were emitted for a run that could not be scoped"; exit 1; }
echo "OK — exit 99, no green, no gate lines."

# THE FULL-SCOPE HALF — the new default, and the harder one to get right.
#
# Here the base is NOT the scope. The whole tracked tree is, and it is
# readable whatever the caller typed, so 59 gates have a real verdict and
# refusing would throw those away to punish a bad input. The five DELTA
# gates — 16, 29, 47, 48, 61 — are the ones that lose their subject.
#
# So the property this step defends is not "the run failed". It is: THE
# FIVE MUST NOT PASS, AND THE RUN MUST SAY THE BASE WAS UNUSABLE. A green
# here is legitimate and is a green over 59 gates, not over 64 — which is
# precisely the distinction the COVERAGE accounting exists to carry.
#
# Note the last assertion is the one with teeth: it fails if any of the
# five prints PASS. Without it, a future change that quietly restored a
# confident PASS-over-an-empty-diff for those gates would satisfy every
# other clause here.
- name: "Unresolvable base ref, full scope: the five delta gates must not pass"
run: |
set -eu
cd /work/fixture
set +e
OUT="$(HYDRA_GATE_BASE_REF=origin/definitely-not-a-branch /work/consumer/vendor/bin/hydra-gates --app-dir /work/fixture 2>&1)"
RC=$?
set -e
printf '%s\n' "${OUT}"

echo "--- assertions ---"
printf '%s' "${OUT}" | grep -q "does not resolve" \
|| { echo "::error::the run did not say the named base was unusable — a typo'd base must never pass silently"; exit 1; }
printf '%s' "${OUT}" | grep -qE '^\[gate-[0-9]+\]' \
|| { echo "::error::no gate reported at all — full scope must still read the whole tree when the base is unusable"; exit 1; }
_bad=""
for g in 16 29 47 48 61; do
printf '%s\n' "${OUT}" | grep -qE "^\[gate-${g}\] [a-z0-9-]+: (NOT APPLICABLE|SKIPPED)" \
|| _bad="${_bad} ${g}"
printf '%s\n' "${OUT}" | grep -qE "^\[gate-${g}\] [a-z0-9-]+: PASS" \
&& { echo "::error::gate-${g} is a DELTA gate and PASSED with no usable base — it passed over an empty diff, which is the defect this whole contract exists to prevent"; exit 1; }
done
[ -z "${_bad}" ] \
|| { echo "::error::delta gate(s)${_bad} did not report NOT APPLICABLE by name with no usable base"; exit 1; }
echo "OK — base reported unusable, the tree was still read, and gates 16/29/47/48/61 each declined by name."
133 changes: 133 additions & 0 deletions hydra-gates/ADR-020-SUPERSEDED.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<!-- SPDX-License-Identifier: EUPL-1.2 -->

# ADR-020 is superseded: the gates scan the whole tree by default

**Status:** accepted · **Date:** 2026-08-12 · **Decided by:** Ruben van der Linde
**Supersedes:** ADR-020 (diff-scoped gates) · **Applies to:** all 18 fleet apps at once
**Canonical ADR home:** the numbered ADR registry lives in `hydra/openspec/`, not in
this repository. This file is the decision record for the **gate package**, and it is
what `run-hydra-gates.sh` and `bin/hydra-gates` cite. Allocating a superseding ADR
number in `hydra/openspec/` is a follow-up, not a precondition.

---

## The decision

**The Hydra gates scan the ENTIRE tracked codebase by default. Diff scoping is now
opt-in (`--scope-to-diff` / `--diff` / `HYDRA_GATE_SCOPE=diff`).**

Until now it was the other way round: `bin/hydra-gates` defaulted to
`--scope-to-diff` and `--full` was the audit-only escape hatch.

In Ruben's words:

> if a gate changes or is added we want the next push to beta to fail unless the old
> code is fixed to the new standard. This will force developers to take updates to
> the gates along in their new releases.

## What ADR-020 decided, and why it was right at the time

ADR-020 scoped every gate to the PR's diff so that **inherited debt could never block
a PR**. That was a deliberate, defensible trade: a gate that blocks legitimate work
gets switched off, and a switched-off control is worth less than a loud one. It is why
`enable-hydra-gates` could be turned on repo by repo at all.

## Why it is being reversed

Diff scoping means a gate only ever judges code written **after** the gate landed.
The consequences are structural, not incidental:

1. **Tightening a gate has no effect on existing code.** The new rule applies to the
next hunk anyone happens to touch. Nobody is ever asked to bring old code up to the
new standard, so the standard is only aspirational for everything already shipped.
2. **The debt is unowned and unmeasured.** It is visible only to a `--full` audit that
nothing gates on, so it accumulates without a moment at which anyone must look at it.
3. **The empty-diff failure modes are endless.** This package's own history is mostly a
list of them: `#242`, `#240`, `#258`, `#268`, `#276`, `#347`, `#361`, `#364`, `#371`,
`#374`. Every one is a variation on *a gate that had nothing to look at printed the
same word as a gate that looked and found nothing.* Full scope removes the empty set
in the common case. (It does **not** remove the fall-through — see below.)

## What this costs, stated up front

**The first `development → beta` run after this lands will surface the entire backlog
at once.** The last fleet-wide wide-scope measurement was **~3,900 findings**. That is
the point of the change, and it is also the whole risk in it: 18 repos change verdict
simultaneously.

Two things follow, and they are part of the decision rather than caveats to it:

- **This is a sequencing problem, not a correctness one.** Every finding it surfaces
was already true. Nothing about the code changed.
- **The number is a FLOOR, not a total.** It predates several gate fixes that make
gates stricter, and several gates are still `no-fixture-yet`. Quote it as a floor.

## What did NOT change: delta gates keep their base

Five gates ask what a **change** did, and a whole tree cannot answer that:

| gate | name | the question |
|---|---|---|
| 16 | spec-coverage | which methods did this change add or modify without an `@spec` anchor? |
| 29 | gitignore-then-commit | did this change add an ignore rule over already-tracked files? |
| 47 | security-change-has-tests | did this change touch security code without touching a test? |
| 48 | csrf-cochange | did this change REMOVE `@NoCSRFRequired`? |
| 61 | listener-work-placement | did this change add a post-event listener doing work in the wrong plane? |

Keying those on the file scope would have **silently retired all five on every PR in
the fleet** the moment this default flipped — trading the gates that protect the change
in front of you for coverage they cannot use. So the scope is now **two independent,
named inputs**:

| input | controls | default |
|---|---|---|
| `SCOPE_TO_DIFF` / `HYDRA_GATE_SCOPE` | which files the **state** gates open | `full` |
| the resolved **delta base** (`--base`, `$HYDRA_GATE_BASE_REF`, auto-detect, `github.event.before`) | what the **delta** gates compare against | resolved whenever possible |

A PR therefore gets **whole-tree state coverage AND every delta gate**. A
`workflow_dispatch` has no base, and those five report `NOT APPLICABLE` **by name, with
a reason** — never `PASS`, and never counted as one.

This also closes a defect the old conflation caused: *"I ran it with no base" was never
a scope.* gate-19 swept the whole tree while gate-16 fell back to a hardcoded
`origin/development` and printed `PASS` over nothing (`#361`), and gate-61 declined
citing a diff the run had never computed (`#347`) — one package answering one question
two ways. `BASE_REF` now starts **empty**; a base is stated or it does not exist.

**An unresolvable base is no longer fatal at full scope.** On a diff-scoped run it still
exits 99, because without a base there is no scope at all. On a full run it costs five
gates out of 64, so refusing would discard 59 real verdicts to punish one bad input.

## What did NOT change: the empty-scope fall-through is still a bug

Full scope removes the empty set in the common case. It does **not** make the
fall-through safe, and treating it as the fix would have been the mistake:
`--scope-to-diff` still exists, and a repo can genuinely ship no `src/`.

So the fall-through is fixed **at the fall-through** (`_skip_empty_scope` in
`run-hydra-gates.sh`), across **eighteen** gates — the sixteen `#374` enumerates plus
**14** and **20**, found by sweeping the table rather than from the issue. And the
property is now enforced by
`scripts/lib/test_gate_empty_scope_never_passes.sh` **ARM 6**, gate-agnostically over
the whole package, instead of by seven gates named one at a time.

## Rollout

1. **Do not merge this alongside anything else.** It changes the verdict for 18 repos
simultaneously; the next fleet measurement must be attributable to exactly one cause.
2. **Capture a wide-scope baseline per app first.** The standing `development → beta`
PR already runs a wide-scope job and its log carries the full table — no
`workflow_dispatch` needed, and dispatching one cancels that very run.
3. **Expect `--require-full-coverage` to be the loudest change**, not the gate findings:
a `PASS` that was really an unopened scope now reports `NOT APPLICABLE`, so
`COVERAGE: N of 64` drops in some repos. That is the line getting *more* honest, not
coverage getting worse.

## How to get the old behaviour

```bash
hydra-gates --scope-to-diff --base origin/development # ADR-020, explicitly
HYDRA_GATE_SCOPE=diff hydra-gates # the same, via the environment
```

Both print `SCOPE-MODE: diff` and say what they are not judging.
50 changes: 39 additions & 11 deletions hydra-gates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,22 +194,50 @@ wrong.

---

## Diff scoping and the base ref
## Scope: full by default (ADR-020 is superseded)

The gates are diff-scoped per ADR-020: a PR is judged on what it changed, not on
what it inherited. This matters — openbuild fails 16 gates on a full-repo run
today and passes when scoped to a real diff. `composer gates:full` gives the
audit view and is deliberately not what `check:strict` runs.
**The gates scan the ENTIRE tracked tree by default. Diff scoping is opt-in.**

Diff scoping is only as trustworthy as the base ref, and a base that resolves to
nothing produces a report of zero failures that is indistinguishable from a
clean one. So:
ADR-020 scoped every gate to the PR's diff so inherited debt could never block a
PR. That is reversed, deliberately — see
[ADR-020-SUPERSEDED.md](ADR-020-SUPERSEDED.md) for the decision, the reasoning,
and what it costs (the first `development → beta` run after it lands surfaces
the fleet backlog at once; the last wide-scope measurement was ~3,900 findings).

The scope is **two independent, named inputs**, and both are printed every run:

| input | controls | default | how to set it |
|---|---|---|---|
| **file scope** | which files the **state** gates open | `full` | `--scope-to-diff` / `--diff`, or `HYDRA_GATE_SCOPE=full\|diff` |
| **delta base** | what the five **delta** gates compare against | resolved whenever possible | `--base REF` / `$HYDRA_GATE_BASE_REF` / auto-detect / `github.event.before` |

Every run emits exactly one machine-readable `[hydra-gates] SCOPE-MODE: full|diff`
line. **Read that, never the prose** — inferring the scope from whether a base was
printed, or from a gate's own wording, has been wrong at least once each.

Gates **16, 29, 47, 48 and 61** ask what a *change* did and cannot be answered by a
checkout. With a base they run at any file scope; with none they report
`NOT APPLICABLE` **by name, with a reason** — never `PASS`, and never counted as one.

To get the old behaviour explicitly:

```bash
hydra-gates --scope-to-diff --base origin/development
HYDRA_GATE_SCOPE=diff hydra-gates
```

### The base ref

A base that resolves to nothing produces a report of zero failures that is
indistinguishable from a clean one. So:

- The base is resolved from a stated precedence chain and **printed** every run:
`--base` → `$HYDRA_GATE_BASE_REF` → `origin/HEAD` → `origin/development` →
`origin/main` → `origin/master`.
- **An unresolvable base stops the run with exit 99.** It is never treated as an
empty diff, and no green is printed.
- **On a diff-scoped run, an unresolvable base stops the run with exit 99.** It is
never treated as an empty diff, and no green is printed. **At full scope it is
NOT fatal** — it costs the five delta gates and nothing else, so refusing would
discard 59 real verdicts to punish one bad input. The five say so by name.
- **A base you named explicitly is never silently replaced.** Substituting a
different one would scope the run to something you did not ask for and would
not read about.
Expand Down Expand Up @@ -381,7 +409,7 @@ reporting that it did nothing did nothing. But only two of the three fail:

| verdict | meaning | counts against coverage? |
|---|---|---|
| `NOT APPLICABLE` | the gate's subject matter does not exist in this repo or this diff — no `src/` at all, or a diff with no composer file under ADR-020 scoping | **no** |
| `NOT APPLICABLE` | the gate's subject matter does not exist in this repo, or (on an opt-in diff-scoped run) in this diff — no `src/` at all, no composer file in the change set; **or the gate is a DELTA gate and this run has no base** | **no** |
| `SKIPPED (structural)` | the subject matter EXISTS and nothing produced the gate's input — e.g. a repo that registers integration leaves but ships no parity check | yes |
| `SKIPPED (wiring)` | the gate's own machinery is missing — a helper script, a tool not on PATH | yes |

Expand Down
Loading
Loading