A run: body the runner declines to interpolate is DROPPED and the job still concludes success (#751). In review-verdict.yml that is fail-CLOSED; in docker-build.yml's `test` and `migrations` — the other two required contexts on main — it is fail-OPEN: the required check goes green having done no work. Every consequential run: step now records that it began, and a trailing guard fails the job when the expected set is incomplete. Per STEP, not per job as proposed: a job-start marker only proves step 1 ran, and the dangerous drop is `Test` or the migration replay. Refs #756
162 lines
8.2 KiB
Bash
Executable File
162 lines
8.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Per-step execution markers for the two REQUIRED docker-build.yml jobs (ersatztv#756).
|
|
#
|
|
# WHY THIS EXISTS. A `run:` body the runner declines to interpolate is DROPPED, and the job still
|
|
# concludes `success` (ersatztv#751, `ci.workflow-run-body-no-expressions`). In
|
|
# `review-verdict.yml` that is fail-CLOSED — the required `review-verdict/h10` is simply absent and
|
|
# the merge is blocked. In `docker-build.yml` it is fail-OPEN: `Build & test (.NET)` and
|
|
# `EF migration integrity (SQLite + MySql)` are the other two required contexts on `main`, so a
|
|
# dropped step there sends a required check GREEN having done no work. #751 guarded the safe
|
|
# direction because that is where the live bug was, not because these were checked.
|
|
#
|
|
# WHY PER STEP, NOT PER JOB, which is what #756 proposed. A marker written by the job's FIRST step
|
|
# only proves the job started. The dangerous drop is not step 1 — it is `Test`, or the migration
|
|
# replay: the job runs everything around them, reports green, and nothing ran that anyone cared
|
|
# about. A guard that cannot see the fail-open case it was built for is the "guard that never
|
|
# executed" failure one level up. So every consequential step marks itself and the trailing
|
|
# `if: always()` guard asserts the whole expected SET.
|
|
#
|
|
# WHY A SCRIPT AND NOT AN INLINE BODY, unlike the #751 guard. Two reasons, and the second is the
|
|
# load-bearing one:
|
|
#
|
|
# * The path literal exists ONCE. The #751 guard carries it twice (write + assert) and its tests
|
|
# spend real effort proving the two copies agree, because a divergence reddens every run and
|
|
# then gets deleted as broken. Here they cannot diverge.
|
|
# * A one-line `run: scripts/ci-step-ran.sh …` CANNOT CONTAIN AN EXPRESSION DELIMITER, so the
|
|
# mechanism this guards against cannot drop the guard itself. #751's own record names this as
|
|
# the stronger construction ("the body would have had to move into scripts/, where a one-line
|
|
# run: makes the class unreachable") and settled for inline only because the measurement showed
|
|
# it was not required there.
|
|
#
|
|
# WHY A SCRIPT IS ACCEPTABLE HERE THOUGH IT WOULD NOT BE IN review-verdict.yml. That workflow
|
|
# checks out the PR's BASE precisely so a PR cannot supply the code that judges it. `docker-build.yml`
|
|
# is head-resolved by design — a PR already supplies every test this job runs — so calling a script
|
|
# from the head adds no authority a PR did not already have. This is a CORRECTNESS gate against
|
|
# silent no-ops, not a security gate against a hostile PR; that job belongs to `review-verdict/h10`.
|
|
# Do not copy this reasoning back into the gate workflow.
|
|
#
|
|
# THE MARKER FILE IS KEYED ON THE RUN, not a fixed name. `RUNNER_TEMP` was measured to be `/tmp` on
|
|
# this instance (ersatztv#751) — a shared directory, not a private per-job dir — so a fixed name
|
|
# lives where another job can also write it, and a STALE marker would satisfy the guard on a run
|
|
# whose step was dropped: a silent PASS, the exact failure being removed. Job id, run id and attempt
|
|
# together mean no other run or job can answer for this one. If the runner exports none of them the
|
|
# path degrades to a fixed name, which is no worse than having no key at all.
|
|
|
|
set -euo pipefail
|
|
|
|
usage() {
|
|
cat >&2 <<'EOF'
|
|
usage:
|
|
ci-step-ran.sh mark <key>
|
|
Record that this step began executing. Call it as the step's FIRST act, before
|
|
anything in the body can fail.
|
|
|
|
ci-step-ran.sh assert --always <key>... [--gated <key>...]
|
|
Fail unless every expected key was marked. --always keys are always required.
|
|
--gated keys are required only when the job's skip gates did NOT fire, read from
|
|
ETV_DOCS_ONLY / ETV_REVALIDATE_SKIP so this mirrors the steps' own `if:`.
|
|
EOF
|
|
exit 2
|
|
}
|
|
|
|
marker_path() {
|
|
printf '%s/etv-ci-steps-ran-%s-%s-%s' \
|
|
"${RUNNER_TEMP:-${GITHUB_WORKSPACE:-/tmp}}" \
|
|
"${GITHUB_JOB:-nojob}" \
|
|
"${GITHUB_RUN_ID:-norunid}" \
|
|
"${GITHUB_RUN_ATTEMPT:-1}"
|
|
}
|
|
|
|
cmd_mark() {
|
|
[ "$#" -eq 1 ] && [ -n "$1" ] || usage
|
|
# Appended, never truncated: every step in the job shares one file, and a `>` here would erase
|
|
# its predecessors and make the guard red on every run.
|
|
#
|
|
# A failure to write is NOT swallowed. The step is running under `bash -e`, so a non-zero here
|
|
# fails the step and reddens the job — which is the same direction the guard would take a moment
|
|
# later, but with a message pointing at the real cause instead of at a missing marker.
|
|
printf '%s\n' "$1" >> "$(marker_path)"
|
|
}
|
|
|
|
cmd_assert() {
|
|
local -a always=() gated=()
|
|
local bucket=""
|
|
while [ "$#" -gt 0 ]; do
|
|
case "$1" in
|
|
--always) bucket=always ;;
|
|
--gated) bucket=gated ;;
|
|
-*) usage ;;
|
|
*)
|
|
case "$bucket" in
|
|
always) always+=("$1") ;;
|
|
gated) gated+=("$1") ;;
|
|
*) usage ;;
|
|
esac ;;
|
|
esac
|
|
shift
|
|
done
|
|
# ANTI-VACUITY, at runtime rather than only in the test suite. An `assert` called with no
|
|
# expectations passes unconditionally and reports "every expected step executed" — a guard that
|
|
# proves nothing while looking like it proved everything. Refuse instead.
|
|
if [ "${#always[@]}" -eq 0 ] && [ "${#gated[@]}" -eq 0 ]; then
|
|
echo "::error::ci-step-ran.sh assert was called with no expected keys, so it would pass unconditionally. This is a workflow bug, not a build failure." >&2
|
|
exit 2
|
|
fi
|
|
|
|
# The skip gates, mirroring the `if:` every gated step carries:
|
|
# steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
|
# Anything other than the exact string `true` means the step was expected to run — including the
|
|
# EMPTY string, which is what these read as when the detect step itself was dropped. That
|
|
# direction is deliberate: a dropped detect step must widen what is required, never narrow it.
|
|
local skipped=no
|
|
if [ "${ETV_DOCS_ONLY:-}" = "true" ] || [ "${ETV_REVALIDATE_SKIP:-}" = "true" ]; then
|
|
skipped=yes
|
|
fi
|
|
|
|
local marker
|
|
marker="$(marker_path)"
|
|
# `${arr[@]+"${arr[@]}"}` rather than a bare `"${arr[@]}"`: under `set -u` bash 3.2 (the system
|
|
# bash on the Macs this suite also runs on) treats expanding an EMPTY array as an unbound
|
|
# variable and aborts. The CI image ships bash 5, where it is fine — which is exactly the kind of
|
|
# difference that makes a guard pass locally and die on the runner, or the reverse.
|
|
local -a expected=(${always[@]+"${always[@]}"})
|
|
if [ "$skipped" = no ]; then
|
|
expected+=(${gated[@]+"${gated[@]}"})
|
|
else
|
|
echo "Skip gate fired (docs_only='${ETV_DOCS_ONLY:-}', already_validated='${ETV_REVALIDATE_SKIP:-}') — the gated steps were not expected to run."
|
|
fi
|
|
|
|
local -a missing=()
|
|
local key
|
|
for key in "${expected[@]}"; do
|
|
# `grep -qxF` over a FILE, never a pipeline: `grep -q` exits at its first match and would
|
|
# SIGPIPE a producer, which under `set -o pipefail` inverts the result for large inputs
|
|
# (ersatztv#698). Reading the file directly has no producer to kill. `-x` so a key cannot be
|
|
# satisfied by another key that contains it, `-F` so a key is never read as a pattern.
|
|
if ! grep -qxF "$key" "$marker" 2>/dev/null; then
|
|
missing+=("$key")
|
|
fi
|
|
done
|
|
|
|
if [ "${#missing[@]}" -gt 0 ]; then
|
|
echo "::error::These steps of job '${GITHUB_JOB:-?}' never executed: ${missing[*]}. The runner DROPPED them (an interpolation failure over a run: body does this and still reports the job GREEN — ersatztv#751/#756) or their \`if:\` no longer matches the guard's expectations. This job is a REQUIRED check, so a green here would mean a required context passed having done no work. Failing the job so it is visible."
|
|
if [ -f "$marker" ]; then
|
|
echo "Marker file ${marker} recorded:"
|
|
sed 's/^/ /' "$marker"
|
|
else
|
|
echo "There is no marker file at ${marker} at all — not one step of this job executed."
|
|
fi
|
|
exit 1
|
|
fi
|
|
echo "All ${#expected[@]} expected step(s) executed: ${expected[*]}"
|
|
}
|
|
|
|
[ "$#" -ge 1 ] || usage
|
|
sub="$1"
|
|
shift
|
|
case "$sub" in
|
|
mark) cmd_mark "$@" ;;
|
|
assert) cmd_assert "$@" ;;
|
|
*) usage ;;
|
|
esac
|