feat(gates): host conduction/hydra-gates here, and wire it into the shared quality workflow - #131
Merged
Merged
Conversation
…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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
pathrepository at../hydra, which works on a developer machine and fails in CI withSource path "../hydra" is not found(10 failed composer jobs). hydra is private, so avcsrepository 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 distributablebin/hydra-gates. The rootcomposer.jsonnames the package, so a plainvcsrepository resolves it — composer has no subdirectory-package support, which is why the manifest is at the root rather than insidehydra-gates/.hydradelegates; 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):
composer (gates inside
check:strict):"no-api": truemakes 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.ymlchangeA new job, opt-in per repo,
enable-hydra-gatesdefaulting tofalse. 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'sneeds, 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) andhydra-gates-require-full-coverage.The properties that had to survive
&& / ||list — arun:block's default shell isbash -e, so a bare call followed byRC=$?would abort before the assignment and collapse the count to a boolean. That is one character away at all times.pull_requestthe 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". NoHEAD~1fallback either. An unresolvable base exits 99, prints no green, and the job reports 99 distinctly from "N gates failed".fetch-depth: 0on the app checkout is required, not an optimisation: a shallow clone has no base branch, and the run would (correctly) refuse to scope.|| echo '...skipping'. Missing prerequisites are named with what they left uncovered.Verification
.github/workflows/hydra-gates-package.ymlmakes 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 cleanphp:8.3-clicontainer — no local checkout of the package on the box, no credentials — and then runs the positive control in both directions:error_log()(gate-2) andwindow.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;git ls-files --error-unmatchthat the revert actually applied before re-measuring. (A prior run of this exercise usedgit 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;nodeis 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
--fullrun 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 ismain, but gate work lands ondevelopment(hydra#504's base), andmainis far behind at 45 declared gates. Extracting frommainwould 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 saidTried: @{upstream}, ..., while the code correctly omits it. Fixed — that comment is exactly how the removed fallback gets re-added by someone trusting the docs.