diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 27f35a1..4a45bd4 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -637,8 +637,22 @@ jobs: fi tar --null -czf /tmp/frontend-build-output.tar.gz -T /tmp/frontend-build-files.z echo "uploaded=true" >> "$GITHUB_OUTPUT" - tr '\0' '\n' < /tmp/frontend-build-files.z | head -40 - echo "…" + # `sed -n '1,40p'`, NOT `head -40`. `head` closes the pipe as soon as + # it has its 40 lines, `tr` then dies of SIGPIPE, and `set -o pipefail` + # (three lines up) turns that into a FAILED JOB — for a log-preview + # line that has no bearing on whether the build worked. + # + # It only fires when the producer is still writing when head leaves, + # i.e. on repos with many build outputs. openregister's build writes + # fewer than 40 files and passed; nextcloud-vue's dist is thousands of + # files and failed every run. Reproduced: 200k NUL-separated entries + # through `tr | head -40` under pipefail exits 141 and the step never + # reaches the next line. `sed` reads its input to the end, so there is + # nothing to break. + tr '\0' '\n' < /tmp/frontend-build-files.z | sed -n '1,40p' + if [ "$COUNT" -gt 40 ]; then + echo "… and $((COUNT - 40)) more" + fi - name: Upload build output if: steps.package.outputs.uploaded == 'true' @@ -2858,8 +2872,15 @@ jobs: # Coverage (if available) if [ -f "coverage/clover.xml" ]; then - COVERED=$(grep -oP 'coveredstatements="\K[0-9]+' coverage/clover.xml | head -1 || echo "0") - TOTAL_STMTS=$(grep -oP 'statements="\K[0-9]+' coverage/clover.xml | head -1 || echo "0") + # `grep -m1` rather than `grep | head -1`: this step also runs under + # `set -o pipefail`, so head closing the pipe on a clover.xml with + # many matches kills grep with SIGPIPE. The `|| echo "0"` then does + # not rescue the value — it APPENDS "0" to whatever head already + # printed, and the arithmetic below gets "1234\n0". Reporting a wrong + # coverage number is worse than failing, because nobody re-checks a + # number that rendered. -m1 stops grep itself, so there is no pipe. + COVERED=$(grep -oPm1 'coveredstatements="\K[0-9]+' coverage/clover.xml || echo "0") + TOTAL_STMTS=$(grep -oPm1 'statements="\K[0-9]+' coverage/clover.xml || echo "0") if [ "$TOTAL_STMTS" -gt 0 ]; then COVERAGE=$(echo "scale=1; $COVERED * 100 / $TOTAL_STMTS" | bc) echo "**Coverage:** ${COVERAGE}% ($COVERED/$TOTAL_STMTS statements)" >> "$REPORT" diff --git a/hydra-gates/scripts/run-hydra-gates.sh b/hydra-gates/scripts/run-hydra-gates.sh index 999b1ae..a952322 100755 --- a/hydra-gates/scripts/run-hydra-gates.sh +++ b/hydra-gates/scripts/run-hydra-gates.sh @@ -376,6 +376,27 @@ fi # --------------------------------------------------------------------------- # Gate 4: Composer audit +# +# Audits the LOCK FILE, not the installed tree, whenever a composer.lock +# exists. That is deliberate and it is a fix, not a convenience: +# +# `composer audit` with no vendor/ present does NOT audit the lock. What it +# does depends on the composer version, and BOTH behaviours are wrong: +# +# composer >= 2.8 : "No installed packages found. Please run composer install +# ... or pass --locked", exit 1. The gate then reported +# "CVEs or advisories" for a run that found no CVEs and +# audited nothing — a configuration error wearing a +# security finding's clothes. +# composer 2.7.x : "No packages - skipping audit", exit 0 — a SILENT +# FAIL-OPEN. The gate passed having audited nothing at all. +# +# Measured 2026-08-03 on openbuild in CI (composer 2.10.2, no vendor/): gate-4 +# FAILED, and `--locked` on the same lock reported no advisories whatsoever. +# +# The lock is also the right object to audit: it is what CI installs and what +# pins the transitive tree. `--locked` needs no vendor/, so this gate no longer +# depends on whether some earlier step happened to run `composer install`. # --------------------------------------------------------------------------- if [ -f composer.json ] && command -v composer >/dev/null 2>&1; then _run_audit=1 @@ -383,10 +404,29 @@ if [ -f composer.json ] && command -v composer >/dev/null 2>&1; then _in_scope "composer.json" || _in_scope "composer.lock" || _run_audit=0 fi if [ "${_run_audit}" = "1" ]; then - if composer audit --format=plain >/tmp/hydra-gate-composer-audit.log 2>&1; then - _pass 4 "composer-audit" + _ca_log=/tmp/hydra-gate-composer-audit.log + if [ -f composer.lock ]; then + _ca_mode="--locked" + else + # No lock to audit. Auditing the installed tree is the only option, + # and it is only meaningful if there IS one. + _ca_mode="" + fi + composer audit ${_ca_mode} --format=plain >"${_ca_log}" 2>&1 + _ca_rc=$? + if [ "${_ca_rc}" -eq 0 ]; then + # Distinguish "audited, clean" from "audited nothing, called it + # clean". The second is the 2.7.x fail-open above, and it must never + # be counted as a pass. + if grep -qiE "no packages|no installed packages" "${_ca_log}"; then + _fail 4 "composer-audit" "audited NOTHING (composer found no packages) — this is not a clean audit; see ${_ca_log}" + else + _pass 4 "composer-audit" + fi + elif grep -qiE "no installed packages found|please run \"?composer install" "${_ca_log}"; then + _fail 4 "composer-audit" "audit COULD NOT RUN (no installed packages and no lock to audit) — NOT a CVE finding; see ${_ca_log}" else - _fail 4 "composer-audit" "CVEs or advisories — see /tmp/hydra-gate-composer-audit.log" + _fail 4 "composer-audit" "CVEs or advisories — see ${_ca_log}" fi fi fi