.github/workflows: Run Sparse and Smatch with KUnit

Run isolated tooling checks and separate Sparse and Smatch Make targets
on pushes to master and manual dispatch. Use the pinned kernel with
KUnit enabled and preserve full logs, policy and tool provenance.

During the source-fix rollout, completed source findings are advisory.
Tool, build, parser and coverage failures remain blocking, including an
empty scan or missing KUnit inputs. Local Make targets stay strict.
Validate the current run's report together with Make's exit status,
since Make maps both findings and incomplete analysis to status 2.

Prepare the runner and container for Coccinelle and Cppcheck as well,
but leave their matrix entries commented out. Cover all four report
formats and GNU Make exit mapping with isolated tooling fixtures.
This commit is contained in:
Gleb Chesnokov
2026-10-19 21:38:56 +03:00
parent 6c3405f5fe
commit 0c5a41bba8
6 changed files with 401 additions and 1 deletions
+77
View File
@@ -0,0 +1,77 @@
name: Static analyzers
on:
push:
branches: [master]
workflow_dispatch:
permissions:
contents: read
defaults:
run:
shell: bash
jobs:
tooling:
name: Tooling
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Check analyzer tooling
run: make lint-test
native:
name: ${{ matrix.analyzer }} with KUnit
needs: tooling
runs-on: ubuntu-24.04
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
analyzer:
- sparse
- smatch
# Supported by the runner; enable after reviewing native coverage.
# - coccicheck
# - cppcheck
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Build pinned analyzer environment
run: |
docker build -f scripts/ci/analyzers.Dockerfile -t scst-analyzers:local . \
2>&1 | tee "$RUNNER_TEMP/analyzer-image.log"
- name: Run make ${{ matrix.analyzer }}
env:
ANALYZER: ${{ matrix.analyzer }}
run: |
source scripts/ci/kernel.env
# Temporary rollout mode: only completed source findings are advisory.
# Tool, build and coverage failures still fail this job.
docker run --rm --init --network=none --cap-drop=ALL \
--security-opt=no-new-privileges --user "$(id -u):$(id -g)" \
--env HOME=/tmp --env GITHUB_STEP_SUMMARY=/scst/.cache/analyzer-summary \
--env "KDIR=/usr/src/kernels/$KERNEL_BUILD.x86_64" \
--mount "type=bind,src=$PWD,dst=/scst" scst-analyzers:local \
python3 -B scripts/ci/run-analyzer.py "$ANALYZER"
- name: Publish summary
if: always()
run: |
if [[ -f .cache/analyzer-summary ]]; then
cat .cache/analyzer-summary >> "$GITHUB_STEP_SUMMARY"
fi
- name: Save complete analyzer evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.analyzer }}-evidence
retention-days: 7
path: |
${{ runner.temp }}/analyzer-image.log
.cache/lint/
.cache/analyzer-summary
+5
View File
@@ -9,6 +9,7 @@ Workflows live in [`.github/workflows`](../.github/workflows). PR checks target
| [CI](../.github/workflows/ci.yml) | Clang build and DEB packaging | PR, push, manual |
| [Packages](../.github/workflows/rpm.yml) | Ubuntu DEBs, RPMs and a published package snapshot | Push to master only |
| [Regression](../.github/workflows/run_regression_tests.yaml) | Module and patched-kernel builds across the kernel matrix | PR, push |
| [Static analyzers](../.github/workflows/analyzers.yml) | Sparse/Smatch with KUnit sources, tooling checks and full reports | Push to master, manual |
| [KUnit](../.github/workflows/kunit.yml) | Kernel unit tests in QEMU | PR, push, manual |
| [Ginkgo](../.github/workflows/ginkgo.yml) | Local and TCP iSCSI component tests in QEMU | PR, push, manual |
| [Coverity](../.github/workflows/coverity.yml) | Static analysis | Push, upstream repository only |
@@ -39,6 +40,10 @@ kernel diagnostics and incomplete reports fail the run. Package artifacts
are attached to the Packages workflow; upstream master builds also update
the `master-rpm` prerelease snapshot.
Analyzer jobs retain full reports in CI artifacts. See the
[analyzer guide](../scripts/README.md#analyzer-ci) for reproduction and the
temporary nonblocking policy for source findings.
## Local reproduction
Use the commands in the relevant workflow for build and package checks;
+13
View File
@@ -232,6 +232,19 @@ analysis. Source findings return 1 and incomplete analysis returns 2; Make
maps both to 2. Full logs and the summary are in `.cache/lint/cppcheck.{log,json}`;
the summary links to captured commands, native XML and checker coverage.
## Analyzer CI
The [workflow](../.github/workflows/analyzers.yml) runs on pushes to `master`
and manual dispatch. Separate jobs run `make sparse` and `make smatch` with
`KUNIT_TEST=y`. Reports in `.cache/lint/` are kept for seven days. Use the
commands above to run either analyzer locally. The runner and image also
support Coccinelle and Cppcheck; their matrix entries remain commented out.
CI temporarily reports completed source findings without blocking while fixes
land. Build/tool failures, incomplete analysis and missing KUnit coverage
still fail. This policy lives in `ci/run-analyzer.py`; local Make targets
always fail on findings.
## Dependencies and tooling tests
Shared analyzer tooling requires Linux, Git and Python 3.10+, using only the
+1 -1
View File
@@ -3,7 +3,7 @@ FROM fedora:44@sha256:43b29f65a41eb9c35e1cd5323e3bdf3b655c2357a9f4f1ff2f9c2798e5
RUN dnf install -y --setopt=install_weak_deps=False \
gcc make git python3 perl elfutils-libelf-devel openssl openssl-devel \
sqlite-devel flex bison pkgconf-pkg-config diffutils findutils \
curl-minimal xz tar gzip dwarves && dnf clean all
curl-minimal xz tar gzip dwarves coccinelle cppcheck && dnf clean all
COPY scripts/ci/kernel.env scripts/ci/kernel.SHA256SUMS /opt/scst-ci/
COPY scripts/ci/analyzers.env scripts/ci/install-analyzers /opt/scst-ci/
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-only
"""Run one analyzer Make target with CI coverage and failure checks."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import uuid
sys.dont_write_bytecode = True
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "scripts"))
from analyzer_policy import source_policy # noqa: E402
from lint_common import copy_output, entrypoint, run # noqa: E402
TOOLS = {"sparse": "sparse", "smatch": "smatch", "coccicheck": "spatch", "cppcheck": "cppcheck"}
def complete_native_report(report):
tool = report.get("tool")
if tool in {"sparse", "smatch"}:
return (bool(report.get("translation_units")) and report.get("make_status") == 0 and
report.get("errors") == 0 and not report.get("unchecked_files") and
not report.get("unrecorded_checks"))
if tool == "coccicheck":
rules = report.get("rules")
return (isinstance(rules, list) and bool(rules) and all(
row.get("complete") is True and row.get("native_status") == 0 and
not row.get("failures") and row.get("handled") for row in rules))
if tool == "cppcheck":
profiles = report.get("profiles")
return (report.get("kernel_make_status") == 0 and
report.get("userspace_make_status") == 0 and
not report.get("unchecked_files") and not report.get("unrecorded_checks") and
isinstance(profiles, list) and len(profiles) == 2 and
{row.get("profile") for row in profiles} == {"kernel", "userspace"} and all(
row.get("complete") is True and row.get("native_status") in {0, 3} and
not row.get("failures") and row.get("translation_units") and
row.get("checked_files") for row in profiles))
return False
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def job_status(make_status, report):
"""Only a completed scan with source findings may become advisory."""
status = report.get("exit_code")
if (report.get("tool") not in TOOLS or
type(status) is not int or status not in (0, 1) or
make_status != (2 if status else 0) or
report.get("outcome") != ("findings" if status else "clean") or
not complete_native_report(report) or
not isinstance(report.get("checked_files"), list)):
return 2
remaining = report.get("remaining")
if type(remaining) is not int or remaining < 0 or bool(status) != bool(remaining):
return 2
tool = report["tool"]
scope = source_policy(tool)
excluded = {rule["path"] for rule in scope["exclude_directories"]}
roots = [root for root in scope["source_roots"] if root not in excluded]
checked = report["checked_files"]
if (any(not any(path.startswith(root + "/") for path in checked) for root in roots) or
not any("/kunit/" in path for path in checked)):
print("CI requires analysis records for every module component and KUnit sources, "
"plus userspace where selected by policy.",
file=sys.stderr)
return 2
# Temporary rollout policy: completed source findings are advisory.
return 0
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("tool", choices=tuple(TOOLS))
args = parser.parse_args()
kernel = Path(os.environ.get("SCST_LINT_KDIR") or os.environ.get("KDIR", ""))
if not (kernel / "Module.symvers").is_file():
raise ValueError("Set KDIR to a prepared kernel build tree with Module.symvers")
config = (kernel / ".config").read_text()
if not any(line in {"CONFIG_KUNIT=y", "CONFIG_KUNIT=m"} for line in config.splitlines()):
raise ValueError("The CI analysis kernel must enable CONFIG_KUNIT")
executable = shutil.which(TOOLS[args.tool])
if not executable:
raise ValueError(f"{args.tool} is not installed")
directory = ROOT / ".cache/lint"
directory.mkdir(parents=True, exist_ok=True)
provenance = Path("/usr/local/share/scst-ci") / (args.tool + "-source.txt")
environment = {
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(),
"kernel": str(kernel.resolve()), "kernel_config_sha256": digest(kernel / ".config"),
"tool": executable, "tool_sha256": digest(Path(executable)),
"version": subprocess.check_output([executable, "--version"], text=True).strip(),
"source_provenance": provenance.read_text() if provenance.exists() else "unavailable",
"kunit_test": "y",
}
environment_text = json.dumps(environment, indent=2) + "\n"
(directory / f"{args.tool}.environment.json").write_text(environment_text)
print(environment_text, flush=True)
(directory / "kernel.config").write_text(config)
models = provenance.with_name("smatch-data.sha256")
if args.tool == "smatch" and models.exists():
shutil.copyfile(models, directory / "smatch-data.sha256")
policy = subprocess.check_output([sys.executable, "-B", str(ROOT / "scripts/analyzer_policy.py"),
"show", args.tool, "--format=json"], text=True)
(directory / f"{args.tool}.policy.json").write_text(policy)
print(policy, flush=True)
identity = str(uuid.uuid4())
env = dict(os.environ, KDIR=str(kernel.resolve()), SCST_LINT_KDIR=str(kernel.resolve()),
KUNIT_TEST="y", KUNIT_LABEL="ALL", DIFFBR="", LINT_DEFER_REPORTS="1",
SCST_LINT_RUN_ID=identity)
report = {}
try:
# Make maps both findings and driver failures to 2. The current run's
# report distinguishes them; neither a failed make nor a report alone
# is sufficient to classify the run as completed source findings.
result = run(["make", "-j2", args.tool, f"KDIR={kernel.resolve()}",
"KUNIT_TEST=y", "KUNIT_LABEL=ALL", "DIFFBR=", "WERROR=n"], cwd=ROOT, env=env)
finally:
path = directory / f"{args.tool}.json"
if path.exists():
candidate = json.loads(path.read_text())
if (isinstance(candidate, dict) and candidate.get("run_id") == identity and
candidate.get("tool") == args.tool):
report = candidate
log = directory / f"{args.tool}.log"
if log.exists():
print(f"\nFull report: {log}", flush=True)
copy_output(log, sys.stdout.buffer)
status = job_status(result.returncode, report)
if status == 0 and report["exit_code"] == 1:
print(f"::warning::{args.tool}: {report['remaining']} source finding(s); "
"temporary report mode, analysis completed.")
summary = (f"{args.tool}: outcome={report.get('outcome', 'missing')}, "
f"remaining={report.get('remaining', 'unknown')}, "
f"make exit={result.returncode}, job exit={status}\n")
print(summary, end="")
if os.environ.get("GITHUB_STEP_SUMMARY"):
with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a") as stream:
stream.write(summary)
return status
if __name__ == "__main__":
sys.exit(entrypoint(main))
+153
View File
@@ -0,0 +1,153 @@
"""Exercise Make exit mapping and CI reports without a build or container."""
import json
import os
from pathlib import Path
import shlex
import shutil
import subprocess
import sys
import tempfile
import unittest
class AnalyzerCiTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory(prefix="scst-analyzer-ci-")
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
repository = Path(__file__).resolve().parents[2]
shutil.copytree(repository / "scripts", self.root / "scripts",
ignore=shutil.ignore_patterns("__pycache__"))
self.bin = self.root / "bin"
self.bin.mkdir()
(self.bin / "make").symlink_to(shutil.which("make"))
# Exercise real GNU Make's mapping of recipe failures to status 2.
# The fixture driver below produces reports without compiling SCST.
(self.root / "Makefile").write_text(
"SHELL := /bin/sh\n.PHONY: sparse smatch coccicheck cppcheck\n"
"sparse smatch coccicheck cppcheck:\n"
f"\t@{shlex.quote(sys.executable)} -B scripts/sparse_smatch/smp.py $@\n"
"\t@exit $(FIXTURE_MAKE_TAIL_EXIT)\n")
for name in ("git", "sparse", "smatch", "spatch", "cppcheck"):
path = self.bin / name
path.write_text(f"#!{sys.executable}\nprint('fixture-version')\n")
path.chmod(0o755)
kernel = self.root / "kernel"
kernel.mkdir()
(kernel / "Module.symvers").touch()
(kernel / ".config").write_text("CONFIG_KUNIT=y\n")
self.env = dict(os.environ, KDIR=str(kernel), SCST_LINT_KDIR=str(kernel),
PATH=str(self.bin), MAKEFLAGS="", MFLAGS="", MAKEOVERRIDES="",
GNUMAKEFLAGS="", FIXTURE_STATUS="0", FIXTURE_MAKE_TAIL_EXIT="0",
FIXTURE_OUTCOME="clean", FIXTURE_COVERAGE="full")
self.env.pop("GITHUB_STEP_SUMMARY", None)
driver = self.root / "scripts/sparse_smatch/smp.py"
driver.write_text('''import json, os, sys
from pathlib import Path
root = Path(__file__).resolve().parents[2]
assert os.environ['KUNIT_TEST'] == 'y'
assert os.environ['KUNIT_LABEL'] == 'ALL'
assert os.environ['DIFFBR'] == ''
assert os.environ['LINT_DEFER_REPORTS'] == '1'
status = int(os.environ['FIXTURE_STATUS'])
directory = root / '.cache/lint'
directory.mkdir(parents=True, exist_ok=True)
tool = sys.argv[1]
if os.environ.get('FIXTURE_NO_REPORT'):
sys.exit(2)
checked = [p + '/fixture.c' for p in ('scst', 'iscsi-scst',
'qla2x00t-32gbit/qla2x00-target', 'srpt', 'fcst', 'scst_local')]
checked += ['scst/src/kunit/fixture.c']
if tool in ('coccicheck', 'cppcheck'):
checked += ['usr/fileio/fixture.c', 'iscsi-scst/usr/fixture.c']
if os.environ['FIXTURE_COVERAGE'] != 'full':
checked = checked[:1]
report = dict(tool=os.environ.get('FIXTURE_REPORT_TOOL', tool), run_id=os.environ['SCST_LINT_RUN_ID'],
outcome=os.environ['FIXTURE_OUTCOME'], exit_code=status, make_status=0,
errors=0, unchecked_files=[], unrecorded_checks=[],
translation_units=len(checked), checked_files=checked,
remaining=1 if status == 1 else 0)
report['rules'] = [dict(complete=True, native_status=0, failures=[], handled=checked)]
report['kernel_make_status'] = report['userspace_make_status'] = 0
report['profiles'] = [dict(profile=profile, complete=True, native_status=0, failures=[],
translation_units=1, checked_files=checked) for profile in ('kernel', 'userspace')]
if os.environ.get('FIXTURE_NATIVE_INCOMPLETE'):
report['rules'][0]['complete'] = False
report['profiles'][0]['complete'] = False
if os.environ.get('FIXTURE_STALE'):
report['run_id'] = 'previous run'
(directory / (tool + '.json')).write_text(json.dumps(report))
(directory / (tool + '.log')).write_text('raw diagnostic fixture\\n')
print('summary fixture')
sys.exit(status)
''')
def run_ci(self, tool="sparse"):
return subprocess.run([sys.executable, "-B", str(self.root / "scripts/ci/run-analyzer.py"),
tool], cwd=self.root, env=self.env,
capture_output=True, text=True, timeout=10)
def test_only_completed_findings_can_be_advisory(self):
for tool in ("sparse", "smatch", "coccicheck", "cppcheck"):
for status, outcome, expected in (
(0, "clean", 0), (1, "findings", 0), (2, "incomplete", 2),
(1, "incomplete", 2), (0, "skipped", 2), (0, "findings", 2)):
with self.subTest(tool=tool, status=status, outcome=outcome):
self.env.update(FIXTURE_STATUS=str(status), FIXTURE_OUTCOME=outcome)
result = self.run_ci(tool)
self.assertEqual(result.returncode, expected, result.stdout + result.stderr)
self.assertEqual(result.stdout.count("raw diagnostic fixture"), 1)
self.assertEqual("::warning::" in result.stdout,
outcome == "findings" and expected == 0)
self.assertIn(f"make exit={2 if status else 0}", result.stdout)
def test_make_failure_after_clean_analysis_cannot_pass(self):
self.env["FIXTURE_MAKE_TAIL_EXIT"] = "7"
result = self.run_ci()
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
self.assertIn("make exit=2, job exit=2", result.stdout)
def test_basic_analyzers_cannot_downgrade_incomplete_native_reports(self):
self.env.update(FIXTURE_STATUS="1", FIXTURE_OUTCOME="findings", FIXTURE_NATIVE_INCOMPLETE="1")
for tool in ("coccicheck", "cppcheck"):
with self.subTest(tool=tool):
result = self.run_ci(tool)
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
self.assertNotIn("::warning::", result.stdout)
def test_make_failure_without_a_new_report_cannot_reuse_previous_results(self):
self.assertEqual(self.run_ci().returncode, 0)
self.env["FIXTURE_NO_REPORT"] = "1"
result = self.run_ci()
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
self.assertNotIn("raw diagnostic fixture", result.stdout)
def test_report_from_another_tool_is_not_accepted(self):
self.env["FIXTURE_REPORT_TOOL"] = "smatch"
result = self.run_ci("sparse")
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
self.assertNotIn("raw diagnostic fixture", result.stdout)
def test_missing_components_or_kunit_cannot_pass(self):
self.env["FIXTURE_COVERAGE"] = "partial"
result = self.run_ci()
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
self.assertIn("every module component and KUnit", result.stderr)
def test_previous_report_is_not_printed_or_accepted(self):
self.env["FIXTURE_STALE"] = "1"
result = self.run_ci()
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
self.assertNotIn("raw diagnostic fixture", result.stdout)
def test_failed_preparation_does_not_dump_old_logs(self):
self.assertEqual(self.run_ci().returncode, 0)
(self.root / "kernel/.config").write_text("# CONFIG_KUNIT is not set\n")
result = self.run_ci()
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
self.assertNotIn("raw diagnostic fixture", result.stdout)
if __name__ == "__main__":
unittest.main()