diff --git a/.github/workflows/hydra-gates-package.yml b/.github/workflows/hydra-gates-package.yml index bd6508f8..d7694e67 100644 --- a/.github/workflows/hydra-gates-package.yml +++ b/.github/workflows/hydra-gates-package.yml @@ -252,16 +252,31 @@ jobs: # The offending FILES must be named, so we know the gates fired on # what we injected and not on something else that happened to be # there. stdout carries only the COUNT per gate by design; the file - # list goes to the gate's own /tmp/hydra-gate-.log, so that is - # where the attribution has to be checked. A gate that reported FAIL - # while writing no evidence at all would itself be a defect, so a - # missing log is a failure here too. + # list goes to the gate's own log, so that is where the attribution + # has to be checked. A gate that reported FAIL while writing no + # evidence at all would itself be a defect, so a missing log is a + # failure here too. + # + # The log DIRECTORY is read from the run's own first line rather than + # hardcoded. It used to be the shared /tmp/hydra-gate-.log, and + # that was the same defect this assertion exists to catch, one level + # up: two runs on one host overwrote each other's evidence, so this + # check could read a DIFFERENT run's log and pass, or read a truncated + # one and fail, without either outcome saying anything about the + # fixture. Each invocation now mints its own directory and announces + # it; asking the run where it wrote is the only way to be sure the + # evidence belongs to it. + LOGDIR="$(printf '%s\n' "${OUT}" | sed -n 's/^\[hydra-gates\] findings logs: //p' | head -1)" + if [ -z "${LOGDIR}" ] || [ ! -d "${LOGDIR}" ]; then + echo "::error::the run did not announce a findings-log directory (expected a '[hydra-gates] findings logs: ' line). Without it there is no way to attribute a verdict to its evidence." + exit 1 + fi for probe in "forbidden-patterns:lib/GateControl.php" "window-confirm:src/gateControl.js"; do gate="${probe%%:*}"; want="${probe##*:}" # shellcheck disable=SC2012 - log="$(ls -t "/tmp/hydra-gate-${gate}.log" /tmp/hydra-gate-"${gate}".*.log 2>/dev/null | head -1)" + log="$(ls -t "${LOGDIR}/hydra-gate-${gate}.log" "${LOGDIR}"/hydra-gate-"${gate}".*.log 2>/dev/null | head -1)" if [ -z "${log}" ]; then - echo "::error::gate ${gate} reported a result but wrote no /tmp/hydra-gate-${gate}*.log — no evidence for its verdict" + echo "::error::gate ${gate} reported a result but wrote no ${LOGDIR}/hydra-gate-${gate}*.log — no evidence for its verdict" exit 1 fi echo "--- ${log}"; cat "${log}" diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 91ef65bb..a36a54ee 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -3262,6 +3262,52 @@ jobs: echo "hydra-gates @ ${GATES_REF} predates the coverage accounting fix (v1.3.0)." fi + # ── the floating-caller / pinned-callee contract ────────────────────── + # + # THIS WORKFLOW FLOATS ON @main; THE PACKAGE IT DRIVES IS PINNED. + # + # A caller references this file as + # `ConductionNL/.github/.github/workflows/quality.yml@main` while + # pinning `hydra-gates-ref` to a tag. So the two halves of one system + # move independently, and every path this workflow executes inside + # `gates/hydra-gates/` is an UNVERSIONED INTERFACE between them. + # + # That interface has broken three times in one day. The decisive one + # was #168: a change on @main made this workflow execute + # `gates/hydra-gates/scripts/axe-run.cjs` BY PATH, and every repo whose + # pin predated that file got a failure whose message named neither the + # pin nor the file. + # + # Pinning both halves from one tag, or floating both, is the real fix + # and is a human decision — it changes how 22 repos receive gate fixes. + # Until then this step makes the desync SAY SO. A named refusal is not + # a fix, but it is the difference between "gate-33 is broken" and + # "your pin is four fixes behind and here is the file it is missing". + - name: Verify the pinned gates package satisfies this workflow + run: | + set -uo pipefail + REF='${{ inputs.hydra-gates-ref }}' + MISSING="" + for p in \ + bin/hydra-gates \ + scripts/run-hydra-gates.sh \ + scripts/axe-run.cjs \ + scripts/lib/check_spec_anchors.py \ + scripts/lib/check_form_labels.py \ + scripts/lib/check_license_triangle.py \ + scripts/lib/check_no_admin_idor.py \ + scripts/lib/check_semantic_auth.py \ + scripts/lib/check_orphan_auth.py + do + [ -e "${GITHUB_WORKSPACE}/gates/hydra-gates/${p}" ] || MISSING="${MISSING} ${p}" + done + if [ -n "${MISSING}" ]; then + echo "::error::hydra-gates-ref '${REF}' does not contain:${MISSING}" + echo "::error::This workflow floats on @main and executes those paths by name inside the PINNED package, so a pin older than them cannot run the gates they implement. Bump hydra-gates-ref to a tag that includes them. This is NOT a code-quality finding about your repository." + exit 1 + fi + echo "Pinned gates package at '${REF}' provides every path this workflow executes." + - name: Setup Node.js uses: actions/setup-node@v4 with: diff --git a/hydra-gates/scripts/lib/check_form_labels.py b/hydra-gates/scripts/lib/check_form_labels.py new file mode 100644 index 00000000..d7ff9a9b --- /dev/null +++ b/hydra-gates/scripts/lib/check_form_labels.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: EUPL-1.2 +"""Gate-40 form-label-association — every form control must have an +accessible name (WCAG 2.2 AA, SC 1.3.1 Info and Relationships and SC 3.3.2 +Labels or Instructions). + +WHY THIS WAS REWRITTEN +---------------------- +The previous implementation flattened the file's newlines into spaces and +ran four independent regexes over the result. It had no notion of nesting, +of a component's slot, or of where the template ends — so it could only see +attributes ON the element, and an accessible name that comes from anywhere +else read as an absent one. Measured across 21 fleet repos: **1,211 +findings, 58% of them false**, in four shapes: + + implicit label wrapping `` + is the canonical HTML association and needs no + `for`/`id` pair at all. 268 findings. + + NcCheckboxRadioSwitch `Installed + default slot apps only` — nc-vue + renders the default slot INTO the `