mirror of
https://github.com/SCST-project/scst.git
synced 2026-10-22 14:12:33 +00:00
.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:
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
```
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user