Skip to content

Commit 16b5a92

Browse files
committed
ci: enforce warning-clean Clang production build
1 parent 9f8e929 commit 16b5a92

4 files changed

Lines changed: 320 additions & 1 deletion

File tree

.github/workflows/components.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,10 @@ jobs:
5555
# current hosted-runner image ships with an empty ping_group_range, which
5656
# makes every loopback Ping test fail before the request is sent.
5757
- run: sudo sysctl --write net.ipv4.ping_group_range='0 2147483647'
58-
- run: scripts/local_ci_check.sh build
58+
# local_ci_check includes the production-only Clang -Werror build before its full GCC
59+
# build/tests, so ticket #37's two-compiler warning contract is exercised on every run.
60+
- name: Full GCC tests and Clang production warnings
61+
run: scripts/local_ci_check.sh build
5962

6063
documentation:
6164
name: Doxygen warning baseline
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: MIT
3+
# Copyright (c) Robert Vokac and contributors
4+
5+
# Compile every production component with Clang and the repository warning policy, without
6+
# paying for GoogleTest or any test translation unit. Ticket #37 established -Werror for both
7+
# GCC and Clang, but the ordinary Linux build uses the default GCC and therefore cannot see
8+
# Clang-only diagnostics.
9+
set -euo pipefail
10+
export PYTHONDONTWRITEBYTECODE=1
11+
12+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
13+
cd "$REPO_ROOT"
14+
15+
if [ "$#" -ne 0 ]; then
16+
echo "Usage: $0" >&2
17+
exit 2
18+
fi
19+
20+
resolve_compiler() {
21+
local requested="$1"
22+
local fallback="$2"
23+
local label="$3"
24+
local candidate="${requested:-$fallback}"
25+
local resolved
26+
if ! resolved="$(command -v "$candidate")"; then
27+
echo "FAIL: $label compiler '$candidate' was not found" >&2
28+
exit 2
29+
fi
30+
printf '%s\n' "$resolved"
31+
}
32+
33+
CLANG_C_COMPILER="$(resolve_compiler "${SHARP_RUNTIME_CLANG_C_COMPILER:-}" clang "Clang C")"
34+
CLANG_CXX_COMPILER="$(
35+
resolve_compiler "${SHARP_RUNTIME_CLANG_CXX_COMPILER:-}" clang++ "Clang C++"
36+
)"
37+
38+
# Use the repository-wide resolver instead of accepting CMake's or the generator's unbounded
39+
# default. Values above two fail before a temporary tree or compiler process is created.
40+
BUILD_JOBS="$(python3 "$REPO_ROOT/scripts/job_count_policy.py")"
41+
export SHARP_RUNTIME_BUILD_JOBS="$BUILD_JOBS"
42+
43+
# A second compiler cannot safely reuse build/'s GCC cache. The gate therefore uses the one
44+
# repository-local temporary root the build policy permits and removes the fresh Clang tree on
45+
# every exit. It never creates a build under /tmp. Explicitly empty launchers also prevent an
46+
# inherited CMake launcher setting from silently retrofitting ccache into this fresh tree.
47+
mkdir -p "$REPO_ROOT/build-tmp"
48+
GATE_ROOT="$(TMPDIR="$REPO_ROOT/build-tmp" mktemp -d)"
49+
trap 'rm -rf "$GATE_ROOT"' EXIT
50+
51+
BUILD_DIR="$GATE_ROOT/build"
52+
CONFIGURE_LOG="$GATE_ROOT/configure.log"
53+
BUILD_LOG="$GATE_ROOT/build.log"
54+
55+
CMAKE_OPTIONS=(
56+
-S "$REPO_ROOT"
57+
-B "$BUILD_DIR"
58+
-DCMAKE_C_COMPILER="$CLANG_C_COMPILER"
59+
-DCMAKE_CXX_COMPILER="$CLANG_CXX_COMPILER"
60+
-DCMAKE_C_COMPILER_LAUNCHER=
61+
-DCMAKE_CXX_COMPILER_LAUNCHER=
62+
-DCMAKE_EXPORT_COMPILE_COMMANDS=ON
63+
-DSHARP_RUNTIME_COMPONENTS=All
64+
-DSHARP_RUNTIME_BUILD_TESTS=OFF
65+
-DSHARP_RUNTIME_BUILD_BENCHMARKS=OFF
66+
)
67+
68+
CLANG_VERSION="$($CLANG_CXX_COMPILER --version)"
69+
echo "==> Configuring Clang production-only build (${CLANG_VERSION%%$'\n'*})"
70+
if ! cmake "${CMAKE_OPTIONS[@]}" >"$CONFIGURE_LOG" 2>&1; then
71+
echo "FAIL: Clang production configure failed" >&2
72+
tail -60 "$CONFIGURE_LOG" >&2
73+
exit 1
74+
fi
75+
76+
# Do not merely trust the name of the gate. Every module implementation compile command must
77+
# carry -Werror; otherwise a future CMake refactor could leave this build green while silently
78+
# weakening the policy it exists to enforce. Vendor commands are intentionally outside this
79+
# count because sharp_runtime_apply_build_options applies to first-party targets.
80+
PRODUCTION_COMMANDS="$(
81+
grep -E '"command":.*[/]modules[/].*[/]src[/].*[.]cpp' \
82+
"$BUILD_DIR/compile_commands.json" || true
83+
)"
84+
if [ -z "$PRODUCTION_COMMANDS" ]; then
85+
echo "FAIL: Clang compile database contains no production module commands" >&2
86+
exit 1
87+
fi
88+
if printf '%s\n' "$PRODUCTION_COMMANDS" | grep -Fv -- ' -Werror ' >/dev/null; then
89+
echo "FAIL: a Clang production compile command does not carry -Werror" >&2
90+
printf '%s\n' "$PRODUCTION_COMMANDS" | grep -Fv -- ' -Werror ' >&2
91+
exit 1
92+
fi
93+
94+
echo "==> Building every production component with Clang ($BUILD_JOBS job(s))"
95+
if ! cmake --build "$BUILD_DIR" --parallel "$BUILD_JOBS" >"$BUILD_LOG" 2>&1; then
96+
echo "FAIL: Clang production build failed" >&2
97+
tail -80 "$BUILD_LOG" >&2
98+
exit 1
99+
fi
100+
101+
WARNING_COUNT="$(grep -c 'warning:' "$BUILD_LOG" || true)"
102+
ERROR_COUNT="$(grep -c 'error:' "$BUILD_LOG" || true)"
103+
if [ "$WARNING_COUNT" -ne 0 ] || [ "$ERROR_COUNT" -ne 0 ]; then
104+
printf 'FAIL: Clang production build produced %s warning(s) and %s error(s)\n' \
105+
"$WARNING_COUNT" "$ERROR_COUNT" >&2
106+
grep -E 'warning:|error:' "$BUILD_LOG" >&2
107+
exit 1
108+
fi
109+
110+
PRODUCTION_COMMAND_COUNT="$(printf '%s\n' "$PRODUCTION_COMMANDS" | wc -l)"
111+
printf ' Clang production build clean: %s translation unit(s), 0 warnings, 0 errors\n' \
112+
"$PRODUCTION_COMMAND_COUNT"

scripts/local_ci_check.sh

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ python3 test/parse_gtest_summary_test.py
5252
echo "==> Validating repository-local temporary paths"
5353
python3 test/temporary_path_policy_test.py
5454

55+
echo "==> Validating the Clang production warning gate"
56+
python3 test/check_clang_production_build_test.py
57+
58+
# Ticket #37 established -Werror as a GCC/Clang contract, but the ordinary Linux build below
59+
# uses the configured/default GCC. Compile the complete production graph without tests so a
60+
# Clang-only diagnostic cannot survive behind that otherwise-green GCC gate.
61+
echo "==> Checking Clang production warnings"
62+
scripts/check_clang_production_build.sh
63+
5564
echo "==> Validating test-only access seams (ticket #1800)"
5665
python3 scripts/check_version_seam_odr.py
5766
python3 test/check_version_seam_odr_test.py
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
#!/usr/bin/env python3
2+
"""Focused regression tests for the cheap Clang production warning gate."""
3+
4+
from __future__ import annotations
5+
6+
import json
7+
import os
8+
from pathlib import Path
9+
import stat
10+
import subprocess
11+
import tempfile
12+
import textwrap
13+
import unittest
14+
15+
16+
REPOSITORY = Path(__file__).resolve().parents[1]
17+
SCRIPT = REPOSITORY / "scripts" / "check_clang_production_build.sh"
18+
LOCAL_CI = REPOSITORY / "scripts" / "local_ci_check.sh"
19+
WORKFLOW = REPOSITORY / ".github" / "workflows" / "components.yml"
20+
COMMON_CMAKE = REPOSITORY / "cmake" / "SharpRuntimeCommon.cmake"
21+
22+
23+
class ClangProductionBuildGateTests(unittest.TestCase):
24+
def setUp(self) -> None:
25+
self.temporary = tempfile.TemporaryDirectory()
26+
self.root = Path(self.temporary.name)
27+
self.bin = self.root / "bin"
28+
self.bin.mkdir()
29+
self.calls = self.root / "cmake-calls.jsonl"
30+
31+
self.write_executable(
32+
"clang",
33+
"""
34+
#!/usr/bin/env bash
35+
if [ "${1:-}" = "--version" ]; then
36+
echo "mock clang version 1.0"
37+
exit 0
38+
fi
39+
exit 0
40+
""",
41+
)
42+
self.write_executable(
43+
"clang++",
44+
"""
45+
#!/usr/bin/env bash
46+
if [ "${1:-}" = "--version" ]; then
47+
echo "mock clang++ version 1.0"
48+
exit 0
49+
fi
50+
exit 0
51+
""",
52+
)
53+
self.write_executable(
54+
"cmake",
55+
"""
56+
#!/usr/bin/env python3
57+
import json
58+
import os
59+
from pathlib import Path
60+
import sys
61+
62+
args = sys.argv[1:]
63+
with Path(os.environ["FAKE_CMAKE_CALLS"]).open("a", encoding="utf-8") as stream:
64+
stream.write(json.dumps(args) + "\\n")
65+
66+
mode = os.environ.get("FAKE_CMAKE_MODE", "ok")
67+
if "--build" in args:
68+
if mode == "build-warning":
69+
print("mock.cpp:1: warning: warning that escaped -Werror")
70+
if mode == "build-error":
71+
print("mock.cpp:1: error: compile failed")
72+
raise SystemExit(1)
73+
raise SystemExit(0)
74+
75+
build_dir = Path(args[args.index("-B") + 1])
76+
build_dir.mkdir(parents=True, exist_ok=True)
77+
if mode == "no-production-commands":
78+
commands = []
79+
else:
80+
warning_flag = "" if mode == "missing-werror" else " -Werror"
81+
commands = [{
82+
"directory": str(build_dir),
83+
"command": (
84+
"mock-clang++ -Wall -Wextra" + warning_flag
85+
+ " -c /repo/modules/core/src/System/Mock.cpp"
86+
),
87+
"file": "/repo/modules/core/src/System/Mock.cpp",
88+
}]
89+
(build_dir / "compile_commands.json").write_text(
90+
json.dumps(commands, indent=2), encoding="utf-8"
91+
)
92+
""",
93+
)
94+
95+
def tearDown(self) -> None:
96+
self.temporary.cleanup()
97+
98+
def write_executable(self, name: str, body: str) -> None:
99+
path = self.bin / name
100+
path.write_text(textwrap.dedent(body).lstrip(), encoding="utf-8")
101+
path.chmod(path.stat().st_mode | stat.S_IXUSR)
102+
103+
def run_gate(
104+
self,
105+
*,
106+
mode: str = "ok",
107+
jobs: str = "2",
108+
extra_environment: dict[str, str] | None = None,
109+
) -> subprocess.CompletedProcess[str]:
110+
environment = os.environ.copy()
111+
environment.update(
112+
{
113+
"PATH": f"{self.bin}{os.pathsep}{environment['PATH']}",
114+
"FAKE_CMAKE_CALLS": str(self.calls),
115+
"FAKE_CMAKE_MODE": mode,
116+
"SHARP_RUNTIME_BUILD_JOBS": jobs,
117+
}
118+
)
119+
if extra_environment:
120+
environment.update(extra_environment)
121+
return subprocess.run(
122+
[str(SCRIPT)],
123+
cwd=REPOSITORY,
124+
env=environment,
125+
capture_output=True,
126+
text=True,
127+
check=False,
128+
)
129+
130+
def read_calls(self) -> list[list[str]]:
131+
if not self.calls.exists():
132+
return []
133+
return [json.loads(line) for line in self.calls.read_text(encoding="utf-8").splitlines()]
134+
135+
def test_gate_configures_every_production_component_with_clang_and_no_tests(self) -> None:
136+
completed = self.run_gate()
137+
self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr)
138+
calls = self.read_calls()
139+
self.assertEqual(len(calls), 2)
140+
configure, build = calls
141+
142+
self.assertIn("-DSHARP_RUNTIME_COMPONENTS=All", configure)
143+
self.assertIn("-DSHARP_RUNTIME_BUILD_TESTS=OFF", configure)
144+
self.assertIn("-DSHARP_RUNTIME_BUILD_BENCHMARKS=OFF", configure)
145+
self.assertIn(f"-DCMAKE_C_COMPILER={self.bin / 'clang'}", configure)
146+
self.assertIn(f"-DCMAKE_CXX_COMPILER={self.bin / 'clang++'}", configure)
147+
self.assertIn("-DCMAKE_C_COMPILER_LAUNCHER=", configure)
148+
self.assertIn("-DCMAKE_CXX_COMPILER_LAUNCHER=", configure)
149+
150+
build_dir = Path(configure[configure.index("-B") + 1])
151+
self.assertTrue(build_dir.is_relative_to(REPOSITORY / "build-tmp"))
152+
self.assertFalse(build_dir.exists(), "the temporary Clang tree was not removed")
153+
self.assertEqual(build[-2:], ["--parallel", "2"])
154+
self.assertIn("1 translation unit(s), 0 warnings, 0 errors", completed.stdout)
155+
156+
def test_gate_rejects_a_production_command_without_werror(self) -> None:
157+
completed = self.run_gate(mode="missing-werror")
158+
self.assertNotEqual(completed.returncode, 0)
159+
self.assertIn("does not carry -Werror", completed.stderr)
160+
self.assertEqual(len(self.read_calls()), 1, "build must not start after policy drift")
161+
162+
def test_gate_rejects_a_warning_even_if_the_build_command_exits_zero(self) -> None:
163+
completed = self.run_gate(mode="build-warning")
164+
self.assertNotEqual(completed.returncode, 0)
165+
self.assertIn("produced 1 warning(s) and 0 error(s)", completed.stderr)
166+
167+
def test_gate_rejects_more_than_two_jobs_before_configuring(self) -> None:
168+
completed = self.run_gate(jobs="3")
169+
self.assertEqual(completed.returncode, 2)
170+
self.assertIn("aggregate compilation ceiling is two jobs", completed.stderr)
171+
self.assertEqual(self.read_calls(), [])
172+
173+
def test_gate_rejects_a_missing_explicit_clang_compiler(self) -> None:
174+
completed = self.run_gate(
175+
extra_environment={"SHARP_RUNTIME_CLANG_CXX_COMPILER": "missing-clang++"}
176+
)
177+
self.assertEqual(completed.returncode, 2)
178+
self.assertIn("was not found", completed.stderr)
179+
self.assertEqual(self.read_calls(), [])
180+
181+
def test_warning_policy_and_local_ci_workflow_wiring_are_pinned(self) -> None:
182+
common = COMMON_CMAKE.read_text(encoding="utf-8")
183+
local_ci = LOCAL_CI.read_text(encoding="utf-8")
184+
workflow = WORKFLOW.read_text(encoding="utf-8")
185+
186+
self.assertIn(
187+
'target_compile_options("${target}" PRIVATE -Wall -Wextra -Werror)', common
188+
)
189+
self.assertIn("python3 test/check_clang_production_build_test.py", local_ci)
190+
self.assertIn("scripts/check_clang_production_build.sh", local_ci)
191+
self.assertIn("scripts/local_ci_check.sh build", workflow)
192+
193+
194+
if __name__ == "__main__":
195+
unittest.main()

0 commit comments

Comments
 (0)