Skip to content

feat(gates): host conduction/hydra-gates here, and wire it into the shared quality workflow - #131

Merged
rubenvdlinde merged 2 commits into
mainfrom
feat/host-hydra-gates-composer-package
Aug 3, 2026
Merged

feat(gates): host conduction/hydra-gates here, and wire it into the shared quality workflow#131
rubenvdlinde merged 2 commits into
mainfrom
feat/host-hydra-gates-composer-package

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Why

The 61 mechanical gates were packaged and merged into hydra (ConductionNL/hydra#504), but the pilot — ConductionNL/openbuild#104 — could not ship. It consumed the package through a composer path repository at ../hydra, which works on a developer machine and fails in CI with Source path "../hydra" is not found (10 failed composer jobs). hydra is private, so a vcs repository pointed at it would need credentials in every consuming repo and every runner.

This repository is public and already owns the shared workflows, so hosting the package here solves distribution and puts the gates into the default checks.

What is here

hydra-gates/ is now the single source of truth for the gate runner, its 24 Python/JS helpers, the vendored manifest + icon schemas, and the distributable bin/hydra-gates. The root composer.json names the package, so a plain vcs repository resolves it — composer has no subdirectory-package support, which is why the manifest is at the root rather than inside hydra-gates/.

hydra delegates; it keeps no copy (see the companion hydra PR). Two copies that drift is the failure these gates exist to catch.

Git history was deliberately not imported. A subtree split would have published the private orchestrator's commit history into a public repository. Provenance is recorded in hydra-gates/README.md.

Only gate code was moved. hydra-internal helpers — forge-api.sh, github-rest.sh, env-credentials.sh, load-config.py, the record/board tooling — were explicitly not copied, and the payload was scanned for credential-shaped strings and for references back to them before commit.

How a repo adopts it

Shared workflow (no composer needed, works for non-PHP repos):

uses: ConductionNL/.github/.github/workflows/quality.yml@main
with:
  app-name: yourapp
  enable-hydra-gates: true

composer (gates inside check:strict):

"repositories": [
  { "type": "vcs", "url": "https://github.com/ConductionNL/.github.git", "no-api": true }
],
"require-dev": { "conduction/hydra-gates": "^1.0" }

"no-api": true makes composer clone over git instead of the GitHub API, so a rate-limited unauthenticated runner cannot turn into a failed install.

Blast radius of the quality.yml change

A new job, opt-in per repo, enable-hydra-gates defaulting to false. Zero repos change behaviour until they opt in.

This was the deliberate choice over a fleet-wide hard gate. The gates are diff-scoped per ADR-020, but switching them on everywhere at once turns red every repo whose in-flight branches happen to touch files carrying inherited debt — openbuild alone fails 16 gates on a full-repo run. Staged opt-in lets each repo enable it once its own diffs are clean.

When enabled, the job joins report's needs, so a gate failure blocks the Quality Report rather than being a green box nobody reads. Two further inputs are also opt-in: hydra-gates-ref (pin the gate version so a gate change cannot alter a repo's verdict without a commit in that repo) and hydra-gates-require-full-coverage.

The properties that had to survive

  • Exit code is the failure COUNT. The run step captures it through an && / || list — a run: block's default shell is bash -e, so a bare call followed by RC=$? would abort before the assignment and collapse the count to a boolean. That is one character away at all times.
  • Fail-closed base ref. On a pull_request the job names the PR's real target branch. @{upstream} is used nowhere: on a pushed feature branch it is the branch, so the diff is empty exactly when CI runs — that produced "58 gates green over 0 changed files". No HEAD~1 fallback either. An unresolvable base exits 99, prints no green, and the job reports 99 distinctly from "N gates failed".
  • fetch-depth: 0 on the app checkout is required, not an optimisation: a shallow clone has no base branch, and the run would (correctly) refuse to scope.
  • Coverage is asserted. The wrapper reads the gate inventory out of the runner, not from a hardcoded 61 — verified live, see the correction below.
  • No || echo '...skipping'. Missing prerequisites are named with what they left uncovered.
  • Waiver counts are stated on every green, so a green earned by passing is distinguishable from one earned by waiving.

Verification

.github/workflows/hydra-gates-package.yml makes the proof permanent rather than a one-off. It installs the package through composer, from this repository's public VCS URL, into a scratch project in a clean php:8.3-cli container — no local checkout of the package on the box, no credentials — and then runs the positive control in both directions:

  • inject error_log() (gate-2) and window.confirm() (gate-34) — violations no other fleet tool reports, so the signal cannot come from phpcs/psalm/phpstan/eslint — and assert both offending files are named, both gates are named, and the exit code is exactly 2, the count;
  • remove them, and assert via git ls-files --error-unmatch that the revert actually applied before re-measuring. (A prior run of this exercise used git revert -q, git 2.25 rejected it, the rejection went unchecked, and the identical violating tree was silently re-measured.) Then assert exit 0 with coverage and waiver counts stated;
  • unresolvable base ref → exit 99, no green, no gate lines emitted.

node is deliberately absent from that container, so the run must also name the gates that left uncovered instead of counting them toward the green.

Locally, before pushing: the package's own 13 invariant assertions pass from the relocated layout, and a --full run against openbuild reproduces its known 16-gate failure set with zero missing-helper errors — which is what confirms the extracted helper subset is complete.

Two corrections to what was briefed

"61 declared, 59 report" is right for origin/development, but the count is not fixed and the wrapper proves it. I first extracted the runner from a working checkout that had two uncommitted WIP gates in it; the coverage line came back "61 of 63 declared gates reported a result" — correctly, because the inventory is read from the runner. Caught it, re-extracted from the canonical branch, and the shipped runner declares 61 with 24 and 33 still the two that skip. The near-miss is worth recording: hydra's default branch is main, but gate work lands on development (hydra#504's base), and main is far behind at 45 declared gates. Extracting from main would have silently shipped a 16-gate regression.

The wrapper's own documentation contradicted its code. The usage header listed @{upstream} as step 3 of the base-ref chain and the fatal message said Tried: @{upstream}, ..., while the code correctly omits it. Fixed — that comment is exactly how the removed fallback gets re-added by someone trusting the docs.

…hared quality workflow

The 61 mechanical gates were merged into hydra as a composer package
(ConductionNL/hydra#504), but the pilot (ConductionNL/openbuild#104) could
not ship: the package was consumed through a `path` repository at `../hydra`,
which works on a developer machine and fails in CI with
`Source path "../hydra" is not found`. hydra is private, so a `vcs` repository
pointed at it would need credentials in every consuming repo and every runner.

This repository is public and already owns the shared workflows, so hosting
the package here solves distribution AND puts the gates in the default checks.

## What is here

`hydra-gates/` is now the SINGLE SOURCE OF TRUTH for the gate runner, its 24
Python/JS helpers, the vendored manifest + icon schemas, and the distributable
`bin/hydra-gates` entry point. The root `composer.json` names the package, so
a plain `vcs` repository resolves it — no subdirectory support required.

hydra delegates; it keeps no copy. Two copies that drift is the failure these
gates exist to catch.

Git history was NOT imported. A subtree split would have published the private
orchestrator's commit history into a public repository. Provenance is recorded
in hydra-gates/README.md instead.

## Wiring into quality.yml — blast radius

New `hydra-gates` job, gated on a new `enable-hydra-gates` input that
**defaults to false**. Nothing changes for any repo until that repo opts in.

This was deliberate. The gates are diff-scoped per ADR-020, but switching a
hard gate on fleet-wide turns every repo red whose in-flight branches happen to
touch files carrying inherited debt — openbuild alone fails 16 gates on a
full-repo run. Staged opt-in lets each repo enable it once its own diffs are
clean. Two further inputs, `hydra-gates-ref` (pin the gate version) and
`hydra-gates-require-full-coverage`, are also opt-in.

When enabled the job joins `report`'s `needs`, so a gate failure blocks the
Quality Report rather than being a green box nobody reads.

## The properties that had to survive, and how they are held

- **Exit code is the failure COUNT.** The run step captures it through an
  `&& / ||` list, because a `run:` block's default shell is `bash -e` and a
  bare call followed by `RC=$?` would abort before the assignment — collapsing
  the count to a boolean. That is one character away at all times.
- **Fail-closed base ref.** On a pull_request the job names the PR's real
  target branch. `@{upstream}` is not used anywhere (on a pushed feature
  branch it is the branch ITSELF, so the diff is empty exactly when CI runs —
  this produced "58 gates green over 0 changed files"). There is no `HEAD~1`
  fallback. An unresolvable base exits 99, prints no green, and the job
  reports 99 distinctly from "N gates failed".
- **`fetch-depth: 0`** on the app checkout is required, not an optimisation: a
  shallow clone has no base branch and the run would (correctly) refuse.
- **Coverage is asserted, not assumed.** The wrapper reads the gate inventory
  out of the runner rather than hardcoding 61, so gate 62 cannot go silently
  unmeasured.
- **No `|| echo '...skipping'` anywhere.** Missing prerequisites are named
  along with what they left uncovered.

## Verification

`.github/workflows/hydra-gates-package.yml` makes the proof permanent rather
than a one-off. It installs the package THROUGH COMPOSER from this repository's
public VCS URL into a scratch project in a clean `php:8.3-cli` container — no
local checkout of the package on the box, no credentials — and then runs the
positive control in both directions against a fixture repo:

- inject `error_log()` (gate-2) and `window.confirm()` (gate-34), violations no
  other fleet tool reports, and assert BOTH offending files are named, BOTH
  gates are named, and the exit code is exactly 2 — the count;
- remove them, and assert via `git ls-files --error-unmatch` that the revert
  actually applied before re-measuring, then assert exit 0 with coverage and
  waiver counts stated;
- unresolvable base ref -> exit 99, no green, no gate lines emitted.

node is deliberately absent from that container, so the run also has to name
the gates that left uncovered instead of counting them toward the green.
## The positive control asserted against the wrong stream

The install-from-published-location job asserted that the injected files were
named on stdout. They are not, by design: the runner prints a COUNT per gate
and writes the file list to /tmp/hydra-gate-<name>.log. The run itself was
correct — installed from the public VCS URL with no credentials, gate-2 and
gate-34 both fired, exit code 2 = the count — but the assertion was measuring
a stream that never carried the answer.

Now checked where the attribution actually lives, and strengthened: a gate that
reports FAIL while writing NO evidence log is also a failure, because "the gate
fired" and "the gate fired on what I injected" are different claims and only
the second one makes this a control.

## ShellCheck

This job was already failing for any PR touching a .sh file, on three
pre-existing scripts. Fixed rather than suppressed:

- docker/mail/seed-mail.sh — `email_data` (and the `cc_header` feeding it) were
  dead. Verified the python block below builds the message itself and reads
  $cc directly for both the header and the recipient list, so removing them
  changes nothing that is sent.
- docker/mail/seed-ox.sh — OX_ADMIN / OX_ADMIN_PASS were unread: every command
  authenticates as the CONTEXT admin. That made them a hardcoded master
  password sitting in a public repo for no reason. Removed.
  Also replaced `if [ $? -eq 0 ]` with a direct `if docker exec ...; then` —
  the two were separated by a blank line, so anything inserted between them
  would silently have become the command whose status was read.
- docker/test-database-compatibility.sh — quoted a comparison, and split two
  `local x=$(...)` assignments, which had been masking the pipeline's exit
  status behind `local`'s always-success.

The imported gate runner had never been ShellChecked at all — hydra has no
GitHub Actions — so moving it here surfaced 22 note/warning findings. They are
suppressed per-file with the reasoning recorded inline, NOT in a root
.shellcheckrc, which would have switched those checks off for every script in
this repository including ones written later. Rewriting 11 `sed` calls inside
gate logic that has no unit tests of its own is a change to what the gates
decide, and it belongs in its own PR with the gate output diffed either side.

One real fix in that set: tests/test-hydra-gates-bin.sh now does `cd || exit 1`.
Without it, a failed cd builds the fixture in the current directory and every
assertion measures the wrong repository while still looking like a normal run.
@rubenvdlinde
rubenvdlinde merged commit fdad254 into main Aug 3, 2026
7 checks passed
rubenvdlinde added a commit to ConductionNL/openbuild that referenced this pull request Aug 3, 2026
…th repo

The `path` repository at `../hydra` is what blocked this PR. It works on a
developer machine and fails in CI with `Source path "../hydra" is not found`,
because the fleet's sibling-checkout layout does not exist in a CI job and
hydra is private, so no runner can fetch it.

conduction/hydra-gates now lives in ConductionNL/.github, which is PUBLIC and
already owns the shared workflows (ConductionNL/.github#131). Pointing at it
needs no credentials in this repo or on any runner.

- repositories: `path ../hydra` -> `vcs https://github.com/ConductionNL/.github.git`
  with `"no-api": true`, so composer clones over git instead of the GitHub API
  and a rate-limited unauthenticated runner cannot become a failed install.
- require-dev: `@dev` -> `^1.0`, resolving to the v1.0.0 tag. That also drops
  the dev-stability requirement the previous commit needed, because a path
  repository derives its version from the checked-out branch and a tag does not.
- composer.lock pins commit fdad2546f2ac68aa64be5fecef898784c1847538.

Nothing else changes. `composer gates`, `composer gates:full` and the gate
handling inside `check:strict` are untouched, including the part that captures
the gate exit code separately: it carries the FAILURE COUNT, and 99 ("could not
run at all") is still reported distinctly from a gate failure so a
configuration error can never read as a clean tree.

Also merged origin/development in. The branch predated the fleet-wide
timeout-minutes work, so it was carrying a silent revert of the bounds on
exporter-e2e.yml and pull-request-lint-check.yaml.
rubenvdlinde added a commit to ConductionNL/openbuild that referenced this pull request Aug 3, 2026
…er script

Wiring the gates into `composer check:strict` was a local-only change. Nothing
in this repository's CI invokes check:strict — no workflow does, verified by
grepping .github/workflows — so this PR would have merged green with the 61
gates never having executed on any diff. A gate that runs only on a developer's
machine is not a gate, and a green that never ran it is the failure mode the
gates exist to catch.

The shared quality workflow now ships a `hydra-gates` job (ConductionNL/.github#131),
opt-in per repo and defaulting to off. openbuild is the pilot, so it opts in.

`hydra-gates-ref` is pinned to the v1.0.0 tag rather than tracking `main`, so a
change to the gates cannot move this repo's verdict without a commit here to
move the pin.

The job resolves the diff base from the PR's real target branch, checks out the
app with fetch-depth: 0 (a shallow clone has no base to diff against), and
reports exit 99 — "could not run at all" — distinctly from "N gates failed".
rubenvdlinde added a commit to ConductionNL/openbuild that referenced this pull request Aug 3, 2026
…104)

* build(quality): run the Hydra gates as part of composer check:strict

openbuild is the pilot for conduction/hydra-gates. The gates have until now only
run inside hydra's own containers, which means an agent finishing a task here
could report done without any of the 61 mechanical gates having looked at its
diff. Wiring them into check:strict makes "gates pass" part of the definition of
done in the same command everything else already runs.

The gates are diff-scoped per ADR-020, so this does NOT import openbuild's
inherited debt. A full-repo run of this tree fails 16 gates today (img-alt,
button-name, table-headers, spec-anchor-existence and friends); scoped to a PR's
own diff, only what that PR touched is enforced. `composer gates:full` is
available for the audit view, and is deliberately not what check:strict runs.

check:strict keeps its 0/1 contract, but the gate exit code is preserved
separately and reported, because it carries the failure COUNT and flows route on
it. 99 is distinguished from a gate failure in the summary: it means the gates
could not run at all, which is a configuration error and not a clean tree.

The dependency is declared against a `path` repository at ../hydra, which is the
fleet's checkout layout. That is the part of this change that is provisional —
see the PR description; how hydra-gates should be DISTRIBUTED (private Packagist,
a Satis mirror, or a split public repo) is an open decision, and a path
repository is the shape that is verifiable today without one.

* build(quality): lock conduction/hydra-gates at dev stability

A path repository derives its version from the checked-out branch, so the
default minimum-stability of 'stable' rejects a '*' constraint outright:
'found conduction/hydra-gates[dev-main, ...] but it does not match your
minimum-stability'. '@dev' is the constraint that resolves, and it keeps
working once the package is eventually tagged.

* build(quality): resolve hydra-gates from the public package, not a path repo

The `path` repository at `../hydra` is what blocked this PR. It works on a
developer machine and fails in CI with `Source path "../hydra" is not found`,
because the fleet's sibling-checkout layout does not exist in a CI job and
hydra is private, so no runner can fetch it.

conduction/hydra-gates now lives in ConductionNL/.github, which is PUBLIC and
already owns the shared workflows (ConductionNL/.github#131). Pointing at it
needs no credentials in this repo or on any runner.

- repositories: `path ../hydra` -> `vcs https://github.com/ConductionNL/.github.git`
  with `"no-api": true`, so composer clones over git instead of the GitHub API
  and a rate-limited unauthenticated runner cannot become a failed install.
- require-dev: `@dev` -> `^1.0`, resolving to the v1.0.0 tag. That also drops
  the dev-stability requirement the previous commit needed, because a path
  repository derives its version from the checked-out branch and a tag does not.
- composer.lock pins commit fdad2546f2ac68aa64be5fecef898784c1847538.

Nothing else changes. `composer gates`, `composer gates:full` and the gate
handling inside `check:strict` are untouched, including the part that captures
the gate exit code separately: it carries the FAILURE COUNT, and 99 ("could not
run at all") is still reported distinctly from a gate failure so a
configuration error can never read as a clean tree.

Also merged origin/development in. The branch predated the fleet-wide
timeout-minutes work, so it was carrying a silent revert of the bounds on
exporter-e2e.yml and pull-request-lint-check.yaml.

* ci(quality): actually run the gates in CI, not just in a local composer script

Wiring the gates into `composer check:strict` was a local-only change. Nothing
in this repository's CI invokes check:strict — no workflow does, verified by
grepping .github/workflows — so this PR would have merged green with the 61
gates never having executed on any diff. A gate that runs only on a developer's
machine is not a gate, and a green that never ran it is the failure mode the
gates exist to catch.

The shared quality workflow now ships a `hydra-gates` job (ConductionNL/.github#131),
opt-in per repo and defaulting to off. openbuild is the pilot, so it opts in.

`hydra-gates-ref` is pinned to the v1.0.0 tag rather than tracking `main`, so a
change to the gates cannot move this repo's verdict without a commit here to
move the pin.

The job resolves the diff base from the PR's real target branch, checks out the
app with fetch-depth: 0 (a shallow clone has no base to diff against), and
reports exit 99 — "could not run at all" — distinctly from "N gates failed".

* build(quality): bump hydra-gates to v1.0.1 for the gate-4 audit fix

The first real CI run of the gates on this PR failed on gate-4:
`composer-audit: FAIL — CVEs or advisories`. There are no CVEs — auditing this
repo's lock properly reports "No security vulnerability advisories found."

`composer audit` with no vendor/ present does not audit the lock. On the
runner's composer it errors and exits non-zero, which gate-4 reported as a
security finding. On composer 2.7.x the same situation prints "No packages -
skipping audit" and exits 0 — a silent fail-open that passes having audited
nothing.

Fixed in the package (ConductionNL/.github#135, released as v1.0.1): gate-4
audits `--locked`, treats an exit 0 that audited no packages as a failure, and
reports "could not run" in those words rather than as a CVE finding.

Both the workflow pin and composer.lock move to v1.0.1 so the two paths into
the gates cannot disagree about which version this repo is on.

---------

Co-authored-by: Ruben van der Linde <juan.claude@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