.github/workflows: Select PR checks by changed paths

Pull requests that only change development guides or independent tooling
still compile kernels and run the VM suites. Select Ginkgo, KUnit, Clang
and kernel regression through one shared path policy before expensive
work. Compare the whole PR against its merge base, retaining deleted
paths and both sides of renames. Run checks for unclassified inputs.

Keep path rules in .github/ci-checks.toml and validate their structure,
check names, literal paths and conflicting entries before selection.
Provide a validation-only command. Changes to the config or selector
exercise every check.

Select kernel regression once in the preparation job and gate the build
job before expanding its matrix. Unrelated PRs do not allocate a runner
for every kernel. Selection failures fail the workflow and successful
skips include a reason in the job summary.

Run the Packages workflow only after pushes to master, preserving its
DEB/RPM builds and publication. Keep the separate Clang DEB build on PRs.
Retain full master and manual checks and the existing Checkpatch runs.
Cover path ownership, config validation and Git histories with tests,
and shorten the CI guide around all workflows, selection and results.

Quote the CC environment key so Checkpatch does not interpret unchanged
YAML diff context as a Cc trailer.
This commit is contained in:
Gleb Chesnokov
2026-10-19 13:44:33 +03:00
parent ecec432722
commit dc506a0474
9 changed files with 805 additions and 64 deletions
+97
View File
@@ -0,0 +1,97 @@
# SPDX-License-Identifier: GPL-2.0-only
# PR selection only; push and manual runs always run their checks.
# Paths are literal repository-relative names, not globs. Prefixes end in '/'.
# Skip rules take precedence, then exact paths, then non-overlapping prefixes.
# Unlisted paths, this config and the selector itself always run every check.
# Validate with: python3 scripts/ci/select_checks.py --validate-config
[skip]
basenames = ["AGENTS.md"]
paths = [
# Do not exempt all documentation: some files are package/kernel inputs.
"CONTRIBUTING.md",
"INSTALL.md",
"README.md",
"README.clang",
"README.cross-compilation",
"README.module-signing",
"README.performance",
"doc/CI.md",
"doc/KUNIT.md",
"scripts/README.md",
"scripts/kunit/upstream/README.md",
"tests/ginkgo/README.md",
# Independent source checks.
"scripts/checkpatch",
"scripts/checkpatch_commits",
"scripts/checkpatch_diff",
"scripts/checkpatch_scan",
"scripts/source/list-double-blank-lines",
"scripts/source/run-shellcheck",
# Release-only inputs are exercised by Packages on master.
".github/workflows/rpm.yml",
"scripts/release/build-rpm-artifacts",
"scripts/release/generate-release-archive",
"scripts/release/prepare-package-release",
"scripts/release/update-version",
]
[[rules]]
# VM inputs shared by both suites.
paths = [
"scripts/ci/Dockerfile",
"scripts/ci/build-guest",
"scripts/ci/build-image",
"scripts/ci/guest",
"scripts/ci/kernel.SHA256SUMS",
"scripts/ci/kernel.env",
"scripts/ci/scst-ci.service",
"scripts/ci/scst-ci.target",
"scripts/ci/test_vm.py",
"scripts/ci/vm.py",
]
checks = ["ginkgo", "kunit"]
[[rules]]
paths = [".github/workflows/ginkgo.yml"]
checks = ["ginkgo"]
[[rules]]
paths = [".github/workflows/kunit.yml"]
checks = ["kunit"]
[[rules]]
paths = [".github/workflows/ci.yml"]
checks = ["clang"]
[[rules]]
paths = [".github/workflows/run_regression_tests.yaml"]
checks = ["regression"]
[[rules]]
# KUnit tooling tests execute kernel patch generation and regression helpers.
paths = [
"scripts/kernel/generate-kernel-patch",
"scripts/kernel/run-regression-tests",
"scripts/kernel/kernel-functions",
"scripts/kernel/filter-trace-entry-exit",
"scripts/kernel/specialize-patch",
]
checks = ["kunit", "regression"]
[[rules]]
paths = ["doc/scst_pg.sgml", "doc/scst_user_spec.sgml", "doc/fig1.png", "doc/fig2.png"]
checks = ["kunit", "clang", "regression"]
[[rules]]
prefixes = ["tests/ginkgo/"]
checks = ["ginkgo"]
[[rules]]
# Both VM suites import kernel_diagnostics from the KUnit runner.
prefixes = ["scripts/kunit/"]
checks = ["ginkgo", "kunit"]
[[rules]]
prefixes = ["debian/"]
checks = ["clang"]
+17 -2
View File
@@ -28,17 +28,32 @@ jobs:
arch: [x86_64]
steps:
- uses: actions/checkout@main
with:
persist-credentials: false
fetch-depth: 0
- name: Check CI selection
run: python3 -m unittest discover -s scripts/ci -p 'test_select_checks.py'
- name: Select Clang build
id: changes
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
python3 scripts/ci/select_checks.py --check clang --event "$GITHUB_EVENT_NAME" \
--base "$BASE_SHA" --head "$HEAD_SHA"
- name: Install build-essential
if: steps.changes.outputs.run == 'true'
run: |
sudo apt-get update
sudo apt-get install -y build-essential debhelper devscripts dpkg-dev quilt
- name: Install multilib
run: |
sudo apt-get install -y gcc-multilib g++-multilib
if: ${{ matrix.arch == 'x86' }}
if: steps.changes.outputs.run == 'true' && matrix.arch == 'x86'
- name: Build
if: steps.changes.outputs.run == 'true'
env:
CC: ${{ matrix.compiler }}
'CC': ${{ matrix.compiler }}
CFLAGS: ${{ matrix.arch == 'x86' && '-m32' || '' }}
LDFLAGS: ${{ matrix.arch == 'x86' && '-m32' || '' }}
run: make CC=${{matrix.compiler}} dpkg
+16 -1
View File
@@ -27,21 +27,36 @@ jobs:
- uses: actions/checkout@v4
with:
persist-credentials: false
# Compare the whole PR against its merge base, including fork PRs.
fetch-depth: 0
- name: Check CI selection
run: python3 -m unittest discover -s scripts/ci -p 'test_select_checks.py'
- name: Select Ginkgo checks
id: changes
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
python3 scripts/ci/select_checks.py --check ginkgo --event "$GITHUB_EVENT_NAME" \
--base "$BASE_SHA" --head "$HEAD_SHA"
- name: Install build and VM tools
if: steps.changes.outputs.run == 'true'
run: |
sudo apt-get update
sudo apt-get install -y qemu-system-x86 qemu-utils openssh-client e2fsprogs fakeroot
- name: Build SCST image and check Ginkgo framework
if: steps.changes.outputs.run == 'true'
timeout-minutes: 40
run: bash scripts/ci/build-image ginkgo "$RUNNER_TEMP/ginkgo-image"
- name: Run local and iSCSI tests against the compiled SCST
if: steps.changes.outputs.run == 'true'
env:
GINKGO_LABEL: ${{ github.event_name == 'pull_request' && 'ci-pr' || github.event_name == 'push' && 'ci-push' || '' }}
run: |
python3 scripts/ci/vm.py ginkgo "$RUNNER_TEMP/ginkgo-image" \
"$RUNNER_TEMP/ginkgo-results" --timeout 10000 --ginkgo-label "$GINKGO_LABEL"
- name: Save build logs, reports and serial consoles
if: always()
if: always() && steps.changes.outputs.run == 'true'
uses: actions/upload-artifact@v4
with:
name: ginkgo-results
+17 -3
View File
@@ -27,24 +27,38 @@ jobs:
- uses: actions/checkout@v4
with:
persist-credentials: false
# Compare the whole PR against its merge base, including fork PRs.
fetch-depth: 0
- name: Check VM supervisor and CI selection
run: python3 -m unittest discover -s scripts/ci -p 'test_*.py'
- name: Select KUnit checks
id: changes
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
python3 scripts/ci/select_checks.py --check kunit --event "$GITHUB_EVENT_NAME" \
--base "$BASE_SHA" --head "$HEAD_SHA"
- name: Install VM tools
if: steps.changes.outputs.run == 'true'
run: |
sudo apt-get update
sudo apt-get install -y qemu-system-x86 qemu-utils e2fsprogs fakeroot
- name: Check VM supervisor
run: python3 -m unittest discover -s scripts/ci -p 'test_*.py'
- name: Build kernel-matched image and KUnit modules
if: steps.changes.outputs.run == 'true'
run: bash scripts/ci/build-image kunit "$RUNNER_TEMP/kunit-image"
- name: Exercise VM success, panic and timeout handling
if: steps.changes.outputs.run == 'true'
run: |
python3 scripts/ci/vm.py self-test "$RUNNER_TEMP/kunit-image" \
"$RUNNER_TEMP/kunit-supervisor" --timeout 180 --accel tcg
- name: Run KUnit
if: steps.changes.outputs.run == 'true'
run: |
python3 scripts/ci/vm.py kunit "$RUNNER_TEMP/kunit-image" \
"$RUNNER_TEMP/kunit-results" --timeout 1200
- name: Save build and runtime evidence
if: always()
if: always() && steps.changes.outputs.run == 'true'
uses: actions/upload-artifact@v4
with:
name: kunit-results
-7
View File
@@ -4,13 +4,6 @@ on:
push:
branches:
- master
pull_request:
branches:
- master
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
@@ -20,12 +20,29 @@ jobs:
name: Prepare kernel matrix
runs-on: ubuntu-latest
outputs:
run: ${{ steps.changes.outputs.run }}
regression: ${{ steps.matrix.outputs.regression }}
steps:
- name: Checkout code
uses: actions/checkout@main
with:
persist-credentials: false
fetch-depth: 0
- name: Check CI selection
run: python3 -m unittest discover -s scripts/ci -p 'test_select_checks.py'
- name: Select kernel regression checks
id: changes
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
python3 scripts/ci/select_checks.py --check regression --event "$GITHUB_EVENT_NAME" \
--base "$BASE_SHA" --head "$HEAD_SHA"
- name: Resolve kernel matrix
if: steps.changes.outputs.run == 'true'
id: matrix
shell: bash
run: |
@@ -35,6 +52,8 @@ jobs:
regression_tests:
name: ${{ matrix.version }}
needs: prepare_matrix
# Job conditions are evaluated before expanding the matrix.
if: needs.prepare_matrix.outputs.run == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
+51 -51
View File
@@ -1,73 +1,73 @@
# Tests in GitHub Actions
# Continuous integration
KUnit and Ginkgo have independent workflows. Each builds its own SCST modules
and runs tests in disposable QEMU guests within a single job:
Workflows live in [`.github/workflows`](../.github/workflows). PR checks target
`master`; push checks run after changes reach `master`.
- `.github/workflows/kunit.yml`: KUnit modules, supervisor checks for guest
success, panic and timeout, then `make kunit-run`.
- `.github/workflows/ginkgo.yml`: ordinary SCST modules, `make ginkgo-check`,
then local and TCP iSCSI tests through `make ginkgo-test`. Pull requests use
the `ci-pr` label set; pushes to `master` use `ci-push`. Define these sets
in [labels.json](../tests/ginkgo/internal/testrun/labels.json) by combining
ordinary Ginkgo labels. Manual runs select all tests, including
timing-dependent race scenarios.
| Workflow | Purpose | Events |
| --- | --- | --- |
| Checkpatch ([PR](../.github/workflows/checkpatch_pull.yml), [push](../.github/workflows/checkpatch_push.yml)) | Patch and commit style | PR, push |
| [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 |
| [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 |
| [Mailing list](../.github/workflows/mail_notification.yml) | Commit notifications | Push, upstream repository only |
Both run on pushes and pull requests to `master`, or via `workflow_dispatch`.
The Debian package build in `.github/workflows/ci.yml` remains independent.
Ginkgo uses its locally built image directly; no image artifact is uploaded.
On PRs, [select_checks.py](../scripts/ci/select_checks.py) selects Clang, kernel
regression, KUnit and Ginkgo using [ci-checks.toml](../.github/ci-checks.toml).
Edit that config to change path rules; it is validated before selection.
Unknown paths and changes to the config or selector run every check.
Known unrelated changes skip expensive work; regression selection happens
before creating its matrix jobs. Some documentation is a package or kernel-patch
input and still needs validation. Checkpatch always runs. Packages runs only
after a push to master; the separate Clang build still checks DEB packaging on
PRs. Push and manual runs retain their full checks. Selection errors fail the
workflow; skip reasons appear in the job summary.
Docker builds a committed Git snapshot with the pinned Fedora kernel and
matching headers. QEMU loads SCST only into the guest kernel. It tries KVM
first and falls back to TCG; use `--accel tcg` or `--accel kvm` to select one.
The kernel pins are in `scripts/ci/kernel.env` and `kernel.SHA256SUMS`; update
them together. The Fedora base image is pinned in `scripts/ci/Dockerfile`.
Ginkgo uses `ci-pr` for PRs, `ci-push` for master, and all tests for manual runs.
See [test selection](../tests/ginkgo/README.md#select-tests) for the label sets.
Kernel matrices are maintained through `.github/kernel-matrix.conf` and
`nightly/conf/nightly.conf`; VM kernel pins are in `scripts/ci/kernel.env`
and `kernel.SHA256SUMS` and must be updated together.
Ginkgo uses separate target and initiator VMs on a private QEMU network,
a disposable 1 GiB block disk and guest ext4 backing files. No host block
devices or external initiators are used.
## Results
## Reports and failures
The supervisor keeps serial consoles outside the guests and enforces a hard
deadline. Kernel diagnostics, missing or incomplete test reports, a lost
initiator and timeouts fail the job, even if QEMU exits with status zero.
The KUnit workflow checks panic and timeout handling explicitly under TCG.
After each VM run, the step prints the native test output, including summaries
and failures. Failed runs also print kernel diagnostics and the tail of each
serial console; full logs remain in the artifacts.
Build logs, test reports and serial consoles are uploaded with `if: always()`
and retained for seven days. Ginkgo also saves build metadata and package
versions. VM disks and private SSH keys are not uploaded. A guest crash may
prevent its final report, but the external serial log survives. Upload still
requires a working GitHub runner.
Start with the failed job's log. KUnit/Ginkgo upload build logs, test reports
and serial consoles as artifacts, retained for seven days. Timeouts, guest
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.
## Local reproduction
Image construction requires Docker with Buildx, fakeroot, tar and mkfs.ext4.
Execution requires Python 3.11+, QEMU with the stream network backend,
qemu-img, ssh-keygen and sha256sum. Use new output directories for each run.
These commands boot disposable guests; self-test intentionally crashes one.
Use the commands in the relevant workflow for build and package checks;
[CONTRIBUTING.md](../CONTRIBUTING.md#builds-and-tests) describes local entry
points. [KUnit](KUNIT.md) and [Ginkgo](../tests/ginkgo/README.md) describe their
standalone runners.
To reproduce the CI VM environment, install Docker with Buildx, fakeroot,
tar, mkfs.ext4, Python 3.11+, QEMU with the stream network backend, qemu-img,
ssh-keygen and sha256sum. Builds use a committed Git snapshot and matching
kernel headers. SCST runs inside disposable guests; host devices are not
exported. Use new output directories for each run.
```sh
bash scripts/ci/build-image kunit /tmp/scst-kunit-image
python3 scripts/ci/vm.py self-test /tmp/scst-kunit-image /tmp/scst-vm-selftest --timeout 180 --accel tcg
python3 scripts/ci/vm.py kunit /tmp/scst-kunit-image /tmp/scst-kunit-results --timeout 1200
bash scripts/ci/build-image ginkgo /tmp/scst-ginkgo-image
python3 scripts/ci/vm.py ginkgo /tmp/scst-ginkgo-image /tmp/scst-ginkgo-results --timeout 10000
python3 scripts/ci/vm.py ginkgo /tmp/scst-ginkgo-image /tmp/scst-ginkgo-results --ginkgo-label ci-pr
```
Add `--ginkgo-label ci-pr` to reproduce the pull-request selection.
Use `--ginkgo-label ci-push` for the master push selection; omit the option
for the full manual selection.
Ordinary Ginkgo expressions are also accepted. The resolved filter is printed
in stdout and recorded in `result.json`; both transport reports must confirm
that filter.
Use `--ginkgo-label ci-push` for the master selection; omit it for all specs.
QEMU tries KVM then TCG; `--accel tcg` forces emulation. To check the VM
supervisor itself, use `vm.py self-test` with a KUnit image; this intentionally
crashes a guest.
Supervisor checks without VM or SCST access:
CI tooling checks without builds or VM access:
```sh
python3 scripts/ci/select_checks.py --validate-config
python3 -m unittest discover -s scripts/ci -p 'test_*.py'
```
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-only
"""Decide whether changed paths require a CI check; filter pull requests only."""
import argparse
import os
from pathlib import Path, PurePosixPath
import subprocess
import sys
import tomllib
CHECKS = ('ginkgo', 'kunit', 'clang', 'regression')
ROOT = Path(__file__).resolve().parents[2]
CONFIG_PATH = ROOT / '.github/ci-checks.toml'
CONTROL_PATHS = {CONFIG_PATH.relative_to(ROOT).as_posix(),
Path(__file__).resolve().relative_to(ROOT).as_posix()}
def string_list(value, name):
if not isinstance(value, list) or any(not isinstance(item, str) or not item
for item in value):
raise ValueError(f'{name} must be an array of nonempty strings')
if len(value) != len(set(value)):
raise ValueError(f'{name} contains duplicate entries')
return value
def validate_paths(values, kind, name):
for value in string_list(values, name):
path = value[:-1] if kind == 'prefixes' and value.endswith('/') else value
if (not path or any(part in ('', '.', '..') for part in path.split('/'))
or any(char in value for char in '\\*?[]\x00\r\n')
or value != value.strip()
or (kind == 'prefixes' and not value.endswith('/'))
or (kind == 'basenames' and '/' in value)):
raise ValueError(f'{name}: invalid literal {kind} entry {value!r}')
def validate_config(config):
if set(config) != {'skip', 'rules'}:
raise ValueError('Config must contain exactly skip and rules')
skip = config['skip']
if not isinstance(skip, dict) or set(skip) != {'paths', 'basenames'}:
raise ValueError('skip must contain exactly paths and basenames')
for kind, values in skip.items():
validate_paths(values, kind, f'skip.{kind}')
if not isinstance(config['rules'], list):
raise ValueError('rules must be an array of tables')
seen_paths = set(skip['paths'])
seen_prefixes = set()
for index, rule in enumerate(config['rules'], 1):
name = f'rules[{index}]'
if (not isinstance(rule, dict)
or set(rule) not in ({'paths', 'checks'}, {'prefixes', 'checks'})):
raise ValueError(f'{name} must contain checks and either paths or prefixes')
checks = string_list(rule['checks'], f'{name}.checks')
if not checks or set(checks) - set(CHECKS):
raise ValueError(f'{name}.checks must select from {", ".join(CHECKS)}')
kind = 'paths' if 'paths' in rule else 'prefixes'
validate_paths(rule[kind], kind, f'{name}.{kind}')
if not rule[kind]:
raise ValueError(f'{name}.{kind} must not be empty')
for value in rule[kind]:
if kind == 'paths':
if value in seen_paths:
raise ValueError(f'{name}: duplicate path {value!r}')
seen_paths.add(value)
else:
if any(value.startswith(other) or other.startswith(value)
for other in seen_prefixes):
raise ValueError(f'{name}: overlapping prefix {value!r}')
seen_prefixes.add(value)
def load_config(path=CONFIG_PATH):
with path.open('rb') as stream:
config = tomllib.load(stream)
validate_config(config)
return config
def can_skip(check, path, config):
# Changes to the selection mechanism must exercise every check, regardless
# of the policy being edited in the same PR.
if path in CONTROL_PATHS:
return False
skip = config['skip']
if PurePosixPath(path).name in skip['basenames'] or path in skip['paths']:
return True
for rule in config['rules']:
if path in rule.get('paths', []):
return check not in rule['checks']
for rule in config['rules']:
if any(path.startswith(prefix) for prefix in rule.get('prefixes', [])):
return check not in rule['checks']
# New code, shared build inputs and unclassified paths run every check.
return False
def changed_paths(base, head):
# Resolve refs before constructing a revision range; reject missing commits.
commits = [subprocess.check_output(
['git', 'rev-parse', '--verify', '--end-of-options', f'{ref}^{{commit}}'],
text=True).strip() for ref in (base, head)]
output = subprocess.check_output([
'git', 'diff', '--no-ext-diff', '--no-renames', '--name-only', '-z',
f'{commits[0]}...{commits[1]}', '--',
])
# No rename detection: both the deleted and added path must be skip-safe.
return [os.fsdecode(path) for path in output.split(b'\0') if path]
def select_run(check, event, base, head, config):
if event != 'pull_request':
return True, f'{check}: required for push and manual runs.'
if not base or not head:
raise ValueError('Pull-request selection requires base and head commits')
paths = changed_paths(base, head)
if not paths:
return True, f'{check}: required because the PR diff is empty.'
for path in paths:
print(f'Changed path: {path!r}')
if any(not can_skip(check, path, config) for path in paths):
return True, f'{check}: required by relevant or unclassified changes.'
return False, f'{check}: skipped because all changed paths are known unrelated inputs.'
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--validate-config', action='store_true',
help='validate the CI path policy without selecting a check')
parser.add_argument('--check', choices=CHECKS)
parser.add_argument('--event',
choices=('pull_request', 'push', 'workflow_dispatch'))
parser.add_argument('--base', default='')
parser.add_argument('--head', default='')
args = parser.parse_args()
if not args.validate_config and (not args.check or not args.event):
parser.error('--check and --event are required unless using --validate-config')
try:
config = load_config()
if args.validate_config:
print('CI path config is valid.')
return 0
required, reason = select_run(args.check, args.event, args.base, args.head, config)
except (OSError, ValueError, subprocess.CalledProcessError) as error:
print(f'Cannot determine CI selection: {error}', file=sys.stderr)
return 1
print(reason)
result = f'run={str(required).lower()}\n'
print(result, end='')
if os.environ.get('GITHUB_OUTPUT'):
with Path(os.environ['GITHUB_OUTPUT']).open('a') as stream:
stream.write(result)
if os.environ.get('GITHUB_STEP_SUMMARY'):
with Path(os.environ['GITHUB_STEP_SUMMARY']).open('a') as stream:
stream.write(reason + '\n')
return 0
if __name__ == '__main__':
sys.exit(main())
+425
View File
@@ -0,0 +1,425 @@
# SPDX-License-Identifier: GPL-2.0-only
"""Exercise PR selection with real Git histories; no VM or SCST access."""
from copy import deepcopy
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
import select_checks
class PathPolicyTests(unittest.TestCase):
def setUp(self):
self.config = select_checks.load_config()
def test_known_guides_and_independent_tools_skip_all_builds(self):
paths = ('README.md', 'README.clang', 'tests/ginkgo/README.md',
'scst/AGENTS.md', 'doc/CI.md', 'doc/KUNIT.md',
'CONTRIBUTING.md', 'INSTALL.md', 'scripts/README.md',
'scripts/checkpatch', 'scripts/checkpatch_commits',
'scripts/checkpatch_diff', 'scripts/checkpatch_scan',
'scripts/source/list-double-blank-lines',
'scripts/source/run-shellcheck')
for check in select_checks.CHECKS:
for path in paths:
with self.subTest(check=check, path=path):
self.assertTrue(select_checks.can_skip(check, path, self.config))
def test_shared_build_inputs_and_unknown_paths_run_all_checks(self):
paths = ('scst/src/scst_lib.c', 'iscsi-scst/kernel/iscsi.c',
'scst_local/scst_local.c', 'usr/fileio/fileio.c',
'scstadmin/scstadmin', 'scst/src/kunit/select.mk',
'Makefile', 'scst/Makefile', 'doc/Makefile', '.dockerignore',
'.github/kernel-matrix.conf', '.github/ci-checks.toml',
'scripts/kernel/kernel-matrix',
'scripts/kernel/sign-modules', 'scripts/source/list-source-files',
'scripts/source/clean-source-tree', 'scripts/ci/select_checks.py',
f'scripts/ci/{Path(__file__).name}', 'scripts/ci/new-helper.py',
'scripts/checkpatch_new', 'scripts/source/new-helper',
'doc/new-helper.py', 'scst/README.c', 'new-component/config.json')
for check in select_checks.CHECKS:
for path in paths:
with self.subTest(check=check, path=path):
self.assertFalse(select_checks.can_skip(check, path, self.config))
def test_known_inputs_select_their_consumers(self):
cases = {
'tests/ginkgo/suite/suite_test.go': {'ginkgo'},
'tests/ginkgo/internal/testrun/labels.json': {'ginkgo'},
'scripts/kunit/run.py': {'ginkgo', 'kunit'},
'scripts/kunit/inventory.py': {'ginkgo', 'kunit'},
'scripts/ci/kernel.env': {'ginkgo', 'kunit'},
'scripts/ci/vm.py': {'ginkgo', 'kunit'},
'scripts/ci/build-guest': {'ginkgo', 'kunit'},
'scripts/kernel/generate-kernel-patch': {'kunit', 'regression'},
'scripts/kernel/run-regression-tests': {'kunit', 'regression'},
'debian/rules': {'clang'},
'scripts/release/build-rpm-artifacts': set(),
'scripts/release/generate-release-archive': set(),
'scripts/release/prepare-package-release': set(),
'scripts/release/update-version': set(),
'.github/workflows/ci.yml': {'clang'},
'.github/workflows/rpm.yml': set(),
'.github/workflows/kunit.yml': {'kunit'},
'.github/workflows/ginkgo.yml': {'ginkgo'},
'.github/workflows/run_regression_tests.yaml': {'regression'},
'doc/scst_pg.sgml': {'kunit', 'clang', 'regression'},
}
for path, expected in cases.items():
with self.subTest(path=path):
self.assertEqual({check for check in select_checks.CHECKS
if not select_checks.can_skip(check, path, self.config)}, expected)
def test_packaged_and_kernel_exported_documentation_is_not_a_blanket_exemption(self):
paths = ('scstadmin/scstadmin.sysfs/scst-1.0.0/README',
'iscsi-scst/doc/manpages/iscsi-scstd.8',
'fcst/README', 'scst/README_in-tree')
for check in select_checks.CHECKS:
for path in paths:
with self.subTest(check=check, path=path):
self.assertFalse(select_checks.can_skip(check, path, self.config))
def test_push_and_manual_runs_do_not_consult_paths(self):
with patch('select_checks.changed_paths') as changed:
for check in select_checks.CHECKS:
for event in ('push', 'workflow_dispatch'):
self.assertTrue(select_checks.select_run(check, event, '', '', self.config)[0])
changed.assert_not_called()
class ConfigTests(unittest.TestCase):
def setUp(self):
self.config = {'skip': {'paths': [], 'basenames': []}, 'rules': []}
def test_unknown_or_missing_fields_are_rejected(self):
cases = [
{}, {'skip': self.config['skip']},
dict(self.config, typo=[]), dict(self.config, skip=[]),
dict(self.config, skip={'paths': []}),
dict(self.config, skip={'paths': [], 'basenames': [], 'typo': []}),
dict(self.config, rules={}), dict(self.config, rules=[[]]),
]
for config in cases:
with self.subTest(config=config), self.assertRaises(ValueError):
select_checks.validate_config(config)
for rule in ({'paths': ['doc/file']},
{'paths': ['doc/file'], 'checks': ['ginkgo'], 'typo': []},
{'paths': ['doc/file'], 'prefixes': ['doc/'], 'checks': ['ginkgo']}):
with self.subTest(rule=rule), self.assertRaises(ValueError):
select_checks.validate_config(dict(self.config, rules=[rule]))
def test_check_names_and_types_are_validated(self):
for checks in ([], ['unknown'], ['ginkgo', 'ginkgo'], 'ginkgo', [True], [1], ['']):
self.config['rules'] = [{'paths': ['file'], 'checks': checks}]
with self.subTest(checks=checks), self.assertRaisesRegex(ValueError, 'checks'):
select_checks.validate_config(self.config)
def test_selectors_must_be_literal_relative_paths(self):
bad = ['', '/absolute', '../outside', './relative', 'a/../b', 'a//b',
'a\\b', 'a\nb', 'a\rb', 'a\0b', 'a*', 'a?', 'a[bc]', 'a ', ' a']
for kind in ('paths', 'prefixes'):
values = bad + (['trailing/'] if kind == 'paths' else ['missing-slash'])
for value in values:
self.config['rules'] = [{kind: [value], 'checks': ['ginkgo']}]
with self.subTest(kind=kind, value=value), self.assertRaises(ValueError):
select_checks.validate_config(self.config)
for values in ([], 'file', [False], ['file/', 'file/']):
self.config['rules'] = [{kind: values, 'checks': ['ginkgo']}]
with self.subTest(kind=kind, values=values), self.assertRaises(ValueError):
select_checks.validate_config(self.config)
self.config['rules'] = []
for values in (['doc/README'], ['.'], ['..'], ['*'], [''], ['AGENTS.md', 'AGENTS.md']):
self.config['skip']['basenames'] = values
with self.subTest(basenames=values), self.assertRaises(ValueError):
select_checks.validate_config(self.config)
def test_duplicate_paths_and_overlapping_prefixes_are_rejected(self):
for kind, first, second in (('paths', 'file', 'file'),
('prefixes', 'scripts/', 'scripts/'),
('prefixes', 'scripts/', 'scripts/kunit/'),
('prefixes', 'scripts/kunit/', 'scripts/')):
self.config['rules'] = [{kind: [first], 'checks': ['ginkgo']},
{kind: [second], 'checks': ['kunit']}]
with self.subTest(kind=kind, first=first), self.assertRaises(ValueError):
select_checks.validate_config(self.config)
self.config['skip']['paths'] = ['file']
self.config['rules'] = [{'paths': ['file'], 'checks': ['ginkgo']}]
with self.assertRaisesRegex(ValueError, 'duplicate path'):
select_checks.validate_config(self.config)
def test_exact_paths_override_prefixes_regardless_of_rule_order(self):
rules = [{'prefixes': ['tests/'], 'checks': ['ginkgo']},
{'paths': ['tests/tool.py'], 'checks': ['kunit']}]
for ordered in (rules, list(reversed(rules))):
self.config['rules'] = ordered
select_checks.validate_config(self.config)
self.assertTrue(select_checks.can_skip('ginkgo', 'tests/tool.py', self.config))
self.assertFalse(select_checks.can_skip('kunit', 'tests/tool.py', self.config))
self.assertFalse(select_checks.can_skip('ginkgo', 'tests/new.py', self.config))
self.assertFalse(select_checks.can_skip('ginkgo', 'tests-other/new.py', self.config))
self.config['skip'] = {'paths': ['tests/tool.py'], 'basenames': ['AGENTS.md']}
self.config['rules'] = rules[:1]
select_checks.validate_config(self.config)
for path in ('tests/tool.py', 'tests/AGENTS.md'):
self.assertTrue(select_checks.can_skip('ginkgo', path, self.config))
def test_control_files_cannot_be_exempted_by_config(self):
for path in select_checks.CONTROL_PATHS:
for kind in ('skip', 'paths', 'prefixes'):
config = deepcopy(self.config)
if kind == 'skip':
config['skip']['paths'] = [path]
else:
value = path if kind == 'paths' else path.rsplit('/', 1)[0] + '/'
config['rules'] = [{kind: [value], 'checks': ['ginkgo']}]
select_checks.validate_config(config)
for check in select_checks.CHECKS:
with self.subTest(path=path, kind=kind, check=check):
self.assertFalse(select_checks.can_skip(check, path, config))
class ConfigCLITests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.script = self.root / 'repo/scripts/ci/select_checks.py'
self.script.parent.mkdir(parents=True)
self.script.write_text(Path(select_checks.__file__).read_text())
self.config = self.root / 'repo/.github/ci-checks.toml'
self.config.parent.mkdir(parents=True)
self.config.write_text(select_checks.CONFIG_PATH.read_text())
self.output = self.root / 'output'
self.summary = self.root / 'summary'
def run_cli(self, *args):
return subprocess.run([sys.executable, str(self.script), *args], cwd=self.root,
env=dict(os.environ, GITHUB_OUTPUT=str(self.output),
GITHUB_STEP_SUMMARY=str(self.summary)),
capture_output=True, text=True, check=False)
def test_validation_resolves_config_from_script_and_emits_no_selection(self):
result = self.run_cli('--validate-config')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('config is valid', result.stdout)
self.assertFalse(self.output.exists())
self.assertFalse(self.summary.exists())
def test_invalid_or_missing_config_fails_before_selection_for_every_event(self):
for content in (None, '[broken', 'skip = {}\nrules = []',
'[skip]\npaths = []\nbasenames = []\n'
'[[rules]]\npaths = ["file"]\nchecks = ["typo"]'):
if content is None:
self.config.unlink()
else:
self.config.write_text(content)
cases = [('--validate-config',)] + [
('--check', 'ginkgo', '--event', event)
for event in ('pull_request', 'push', 'workflow_dispatch')]
for args in cases:
with self.subTest(content=content, args=args):
result = self.run_cli(*args)
self.assertNotEqual(result.returncode, 0)
self.assertIn('Cannot determine CI selection:', result.stderr)
self.assertNotIn('Traceback', result.stderr)
self.assertFalse(self.output.exists())
self.assertFalse(self.summary.exists())
def test_selection_still_requires_check_and_event(self):
for args in ((), ('--check', 'ginkgo'), ('--event', 'push')):
with self.subTest(args=args):
result = self.run_cli(*args)
self.assertNotEqual(result.returncode, 0)
self.assertFalse(self.output.exists())
class GitSelectionTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.repo = self.root / 'repo'
self.repo.mkdir()
self.output = self.root / 'output'
self.summary = self.root / 'summary'
self.git('init', '-q', '--initial-branch=main')
self.write('scst/core.c', 'core\n')
self.write('doc/CI.md', 'documentation\n')
self.base = self.commit()
def git(self, *args):
return subprocess.check_output([
'git', '-c', 'user.name=CI test', '-c', 'user.email=ci@example.invalid',
'-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', *args,
], cwd=self.repo, text=True).strip()
def write(self, name, text):
path = self.repo / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
def commit(self):
self.git('add', '-A')
self.git('commit', '-qm', 'Test change')
return self.git('rev-parse', 'HEAD')
def select(self, *, check='ginkgo', base=None, head='HEAD', event='pull_request'):
self.output.unlink(missing_ok=True)
self.summary.unlink(missing_ok=True)
return subprocess.run([
sys.executable, str(Path(select_checks.__file__).resolve()),
'--check', check, '--event', event,
'--base', self.base if base is None else base,
'--head', head,
], cwd=self.repo, env=dict(os.environ, GITHUB_OUTPUT=str(self.output),
GITHUB_STEP_SUMMARY=str(self.summary)),
capture_output=True, text=True, check=False)
def assert_selection(self, expected, **kwargs):
result = self.select(**kwargs)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.output.read_text(), f'run={str(expected).lower()}\n')
self.assertIn(kwargs.get('check', 'ginkgo') + ':', self.summary.read_text())
def test_docs_and_checkpatch_only(self):
self.write('doc/CI.md', 'updated documentation\n')
self.write('scripts/checkpatch', '#!/bin/bash\n')
self.commit()
for check in select_checks.CHECKS:
with self.subTest(check=check):
self.assert_selection(False, check=check)
def test_earlier_code_change_is_not_hidden_by_latest_docs_commit(self):
self.write('scst/core.c', 'changed core\n')
self.commit()
self.write('doc/CI.md', 'updated documentation\n')
self.commit()
self.assert_selection(True)
def test_kunit_tooling_change_is_not_exempted_with_documentation(self):
self.write('doc/CI.md', 'updated documentation\n')
self.write('scripts/kunit/run.py', '# changed KUnit runner\n')
self.commit()
self.assert_selection(True)
def test_changes_on_base_branch_are_excluded_from_pr_diff(self):
self.git('checkout', '-qb', 'topic')
self.write('doc/CI.md', 'updated documentation\n')
head = self.commit()
self.git('checkout', '-q', 'main')
self.write('scst/core.c', 'upstream core change\n')
base = self.commit()
self.assert_selection(False, base=base, head=head)
def test_source_renamed_to_documentation_still_requires_checks(self):
self.git('mv', 'scst/core.c', 'doc/core.md')
self.commit()
self.assert_selection(True)
def test_documentation_renamed_to_source_requires_checks(self):
self.git('mv', 'doc/CI.md', 'scst/new.c')
self.commit()
self.assert_selection(True)
def test_documentation_rename_can_skip(self):
self.git('mv', 'doc/CI.md', 'doc/KUNIT.md')
self.commit()
self.assert_selection(False)
def test_deletions_are_classified(self):
self.git('rm', '-q', 'doc/CI.md')
self.commit()
self.assert_selection(False)
self.git('rm', '-q', 'scst/core.c')
self.commit()
self.assert_selection(True)
def test_changed_file_list_is_not_truncated(self):
for index in range(350):
self.write(f'guides/{index}/AGENTS.md', 'instructions\n')
self.write('scst/core.c', 'changed core\n')
self.commit()
self.assert_selection(True)
def test_paths_with_newlines_are_not_split_into_exempt_names(self):
self.write('doc/CI.md\nREADME.md', 'unknown input\n')
self.commit()
self.assert_selection(True)
def test_empty_diff_runs_conservatively(self):
for check in select_checks.CHECKS:
self.assert_selection(True, check=check)
def test_config_change_runs_every_check(self):
self.write('.github/ci-checks.toml', select_checks.CONFIG_PATH.read_text())
self.commit()
for check in select_checks.CHECKS:
self.assert_selection(True, check=check)
def test_push_and_manual_events_run_even_for_docs_only(self):
self.write('doc/CI.md', 'updated documentation\n')
self.commit()
for check in select_checks.CHECKS:
for event in ('push', 'workflow_dispatch'):
with self.subTest(check=check, event=event):
self.assert_selection(True, check=check, event=event, base='', head='')
def test_debian_only_change_selects_only_clang(self):
self.write('debian/rules', '# packaging change\n')
self.commit()
for check in select_checks.CHECKS:
with self.subTest(check=check):
self.assert_selection(check == 'clang', check=check)
def test_release_only_change_skips_pr_checks_but_keeps_master_runs(self):
self.write('.github/workflows/rpm.yml', '# master-only workflow\n')
self.write('scripts/release/build-rpm-artifacts', '# RPM build\n')
self.commit()
for check in select_checks.CHECKS:
with self.subTest(check=check):
self.assert_selection(False, check=check)
self.assert_selection(True, check=check, event='push', base='', head='')
def test_cross_component_rename_requires_both_consumers(self):
self.write('tests/ginkgo/example.go', '// test\n')
self.base = self.commit()
(self.repo / 'debian').mkdir()
self.git('mv', 'tests/ginkgo/example.go', 'debian/example.go')
self.commit()
for check in select_checks.CHECKS:
with self.subTest(check=check):
self.assert_selection(check in ('ginkgo', 'clang'), check=check)
def test_invalid_check_fails_without_skip_output(self):
result = self.select(check='unknown')
self.assertNotEqual(result.returncode, 0)
self.assertFalse(self.output.exists())
def test_missing_or_invalid_revisions_fail_without_skip_output(self):
for base, head in (('', 'HEAD'), (self.base, ''),
('missing-ref', 'HEAD'), (self.base, 'missing-ref')):
with self.subTest(base=base, head=head):
result = self.select(base=base, head=head)
self.assertNotEqual(result.returncode, 0)
self.assertIn('Cannot determine CI selection', result.stderr)
self.assertFalse(self.output.exists())
self.assertFalse(self.summary.exists())
def test_missing_merge_base_fails_without_skip_output(self):
self.git('checkout', '-q', '--orphan', 'unrelated')
self.write('doc/other.md', 'unrelated history\n')
self.commit()
result = self.select()
self.assertNotEqual(result.returncode, 0)
self.assertIn('Cannot determine CI selection', result.stderr)
self.assertFalse(self.output.exists())
self.assertFalse(self.summary.exists())
if __name__ == '__main__':
unittest.main()