Skip to content

Commit 20e0680

Browse files
authored
Merge pull request #147 from basecubedev/fix/ci-image-df-probe
fix(ci): measure free space with a df the runner accepts
2 parents c804e50 + d163bfe commit 20e0680

3 files changed

Lines changed: 143 additions & 3 deletions

File tree

.github/workflows/appliance-image.yml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,16 @@ jobs:
153153
- name: Reclaim runner disk
154154
run: |
155155
set -euo pipefail
156-
before="$(df -PB1 --output=avail / | tail -n1)"
156+
# The free-space idiom is scripts/lib/workdir.sh's, which cannot be
157+
# sourced here: this step runs before the checkout. -P and --output
158+
# are mutually exclusive in coreutils, and a df that refuses to run is
159+
# a step that fails on its first line.
160+
avail() { df -PB1 "$1" | awk 'NR==2 {print $4}'; }
161+
before="$(avail /)"
157162
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
158163
/opt/hostedtoolcache/CodeQL /usr/local/share/boost /usr/local/.ghcup || true
159164
sudo docker image prune --all --force >/dev/null 2>&1 || true
160-
after="$(df -PB1 --output=avail / | tail -n1)"
165+
after="$(avail /)"
161166
echo "reclaimed $(( (after - before) / 1024 / 1024 )) MiB on /"
162167
df -h / /mnt || true
163168
@@ -185,7 +190,7 @@ jobs:
185190
done
186191
[ -n "${root}" ] || { echo "::error::no writable build root on this runner"; exit 1; }
187192
188-
avail="$(df -PB1 --output=avail "${root}" | tail -n1)"
193+
avail="$(df -PB1 "${root}" | awk 'NR==2 {print $4}')"
189194
need=$((30 * 1024 * 1024 * 1024))
190195
if [ "${avail}" -lt "${need}" ]; then
191196
echo "::error::${root} has $(( avail / 1024 / 1024 / 1024 )) GiB free; the build needs 30 GiB"

docs/developer/testing.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,8 @@ The documentation-content ones carry the `documentation` marker, so
298298
- `tests/test_test_classification.py` — the marker registry, the documented
299299
tier selections and the pull-request group partition.
300300
- `tests/test_ci_workflow_docker_split.py` — how the CI groups are split.
301+
- `tests/test_ci_workflow_contexts.py`, `tests/test_ci_workflow_commands.py`
302+
that a workflow names its contexts where they exist and runs shell that runs.
301303

302304
When you move or rename docs, update these tests (or the redirect stubs) so the
303305
links stay honest.
@@ -396,6 +398,15 @@ same bytes and an unattested builder is no objection to it.
396398
CI cannot be asked to build fails that test rather than leaving a release one
397399
artefact short.
398400

401+
A workflow's shell is not executed until the run that needs it, and the first
402+
dispatch of this one died on its opening line: `df -PB1 --output=avail /` is a
403+
combination coreutils refuses, which YAML parses and `bash -n` accepts.
404+
`tests/test_ci_workflow_commands.py` closes that class by running it —
405+
every `df` invocation in every workflow is executed here against a directory
406+
that exists, with each operand substituted, so the option list is the only thing
407+
under test. It also parses every shell step with `bash -n`, which is the cheap
408+
half and would not have caught this one.
409+
399410
The gate builds the images itself, so it needs the generator's prerequisites and
400411
cannot reach `RESULT: PASS` on a workstation that deliberately lacks them.
401412
`--release-gate` runs it where those prerequisites are, and brings the verdict

tests/test_ci_workflow_commands.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# SPDX-License-Identifier: AGPL-3.0-or-later
2+
"""Static contract: the shell a workflow runs is shell that runs.
3+
4+
A workflow step's script is never executed until the run that needs it. The
5+
image build's first step opened with ``df -PB1 --output=avail /`` -- coreutils
6+
refuses that combination, ``-P`` and ``--output`` being mutually exclusive -- so
7+
three matrix jobs died on their first line, before the checkout, after the plan
8+
and package jobs had already passed. Nothing local could have caught it: YAML
9+
parses it, ``bash -n`` parses it, and the flags are only rejected when df runs.
10+
11+
So the flags are run here. Every ``df`` invocation in every workflow is executed
12+
against a directory that exists, with each operand replaced, which leaves the
13+
option list as the only thing under test.
14+
"""
15+
16+
import re
17+
import shlex
18+
import subprocess
19+
from pathlib import Path
20+
21+
import pytest
22+
import yaml
23+
24+
pytestmark = [pytest.mark.contract]
25+
26+
ROOT = Path(__file__).resolve().parents[1]
27+
WORKFLOWS = sorted((ROOT / ".github" / "workflows").glob("*.yml"))
28+
29+
EXPRESSION = re.compile(r"\$\{\{.*?\}\}", re.S)
30+
# A shell variable, in either form. Replaced before the line is cut up, so that
31+
# the brace in "${root}" is not read as the end of a command.
32+
VARIABLE = re.compile(r"\$\{[^{}]*\}|\$[A-Za-z_0-9]+")
33+
# Where a command ends and the next thing begins, for the purpose of lifting one
34+
# invocation out of a line.
35+
TERMINATOR = re.compile(r"[|;>&)}\n]")
36+
37+
38+
def scripts():
39+
"""(workflow, job, step, script) for every shell step in the repository."""
40+
41+
for path in WORKFLOWS:
42+
document = yaml.safe_load(path.read_text(encoding="utf-8"))
43+
for job_name, job in (document.get("jobs") or {}).items():
44+
for step in job.get("steps") or []:
45+
if step.get("run") and step.get("shell") in (None, "bash", "sh"):
46+
yield path.name, job_name, step.get("name") or "?", step["run"]
47+
48+
49+
def test_there_are_shell_steps_to_check():
50+
assert list(scripts())
51+
52+
53+
@pytest.mark.parametrize("workflow", [path.name for path in WORKFLOWS])
54+
def test_every_shell_step_parses_as_bash(workflow):
55+
"""The cheap half. It would not have caught the df line, and it catches the
56+
quoting mistake that the df line taught us to look for."""
57+
58+
for name, job, step, script in scripts():
59+
if name != workflow:
60+
continue
61+
parsed = subprocess.run(
62+
["bash", "-n"],
63+
input=EXPRESSION.sub("GHEXPR", script),
64+
text=True,
65+
capture_output=True,
66+
timeout=60,
67+
)
68+
69+
assert parsed.returncode == 0, f"{name}: {job} / {step}\n{parsed.stderr}"
70+
71+
72+
def df_invocations():
73+
for name, job, step, script in scripts():
74+
for line in script.splitlines():
75+
if line.lstrip().startswith("#"):
76+
continue
77+
line = VARIABLE.sub("OPERAND", EXPRESSION.sub("OPERAND", line))
78+
for match in re.finditer(r"\bdf\b", line):
79+
rest = line[match.end() :]
80+
end = TERMINATOR.search(rest)
81+
yield name, job, step, rest[: end.start()] if end else rest
82+
83+
84+
def test_there_are_df_invocations_to_run():
85+
"""The workflows measure free space in three places. If they stop, this file
86+
stops proving anything and should be reconsidered rather than left green."""
87+
88+
assert list(df_invocations())
89+
90+
91+
def options(argument_text):
92+
"""The invocation with every operand replaced, so only the flags are tested."""
93+
94+
return [token for token in shlex.split(argument_text) if token.startswith("-")]
95+
96+
97+
@pytest.mark.parametrize(
98+
"where,arguments",
99+
[
100+
(f"{name}: {job} / {step}", arguments)
101+
for name, job, step, arguments in df_invocations()
102+
],
103+
ids=lambda value: value if isinstance(value, str) else "",
104+
)
105+
def test_every_df_invocation_is_a_form_df_accepts(where, arguments, tmp_path):
106+
run = subprocess.run(
107+
["df", *options(arguments), str(tmp_path)],
108+
capture_output=True,
109+
text=True,
110+
timeout=60,
111+
)
112+
113+
assert run.returncode == 0, f"{where}: df {arguments.strip()}\n{run.stderr}"
114+
115+
116+
def test_the_combination_that_failed_is_still_one_df_refuses():
117+
"""The premise. If a future coreutils accepts -P beside --output, the test
118+
above stops distinguishing the defect from the fix and should be revisited."""
119+
120+
run = subprocess.run(
121+
["df", "-PB1", "--output=avail", "/"], capture_output=True, text=True, timeout=60
122+
)
123+
124+
assert run.returncode != 0, "this df accepts the combination that broke the image build"

0 commit comments

Comments
 (0)