From 7472a3b872198826e3012f05bad0e74cb21a02f6 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:58:14 -0500 Subject: [PATCH] CI: run the jobs a change can reach; a release bump and docs run nothing, a weekly run checks everything --- .github/workflows/clean-build.yml | 29 ++++++ .github/workflows/lint.yml | 32 ++++++ .github/workflows/test-cpython.yml | 39 ++++++++ AGENTS.md | 1 + tests/test_ci_scope.py | 81 +++++++++++++++ tools/ci_scope.py | 155 +++++++++++++++++++++++++++++ 6 files changed, 337 insertions(+) create mode 100644 tests/test_ci_scope.py create mode 100644 tools/ci_scope.py diff --git a/.github/workflows/clean-build.yml b/.github/workflows/clean-build.yml index 2870f961..9df70e96 100644 --- a/.github/workflows/clean-build.yml +++ b/.github/workflows/clean-build.yml @@ -16,13 +16,40 @@ on: - '**.md' - 'docs/**' - 'LICENSE' + schedule: + - cron: '43 5 * * 1' workflow_dispatch: +# A new push to a pull request cancels the run it supersedes. A push to main is +# never cancelled: each one keeps its own record. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read jobs: + # What this change can reach (tools/ci_scope.py). A pull request is diffed + # against its base and a push to main from the commit it moved from; a manual + # or weekly run, or a base git can't find, runs everything. + scope: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + build: ${{ steps.scope.outputs.build }} + steps: + - uses: actions/checkout@v7 + - name: Scope + id: scope + env: + EVENT: ${{ github.event_name }} + BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + run: python3 tools/ci_scope.py --event "$EVENT" --base "$BASE" + unix-usermod: + needs: scope + if: needs.scope.outputs.build == 'true' runs-on: ubuntu-latest timeout-minutes: 30 strategy: @@ -342,6 +369,8 @@ jobs: # link into `usermod`, every named source present on disk, and the voice # ceiling defined. cmake-configure: + needs: scope + if: needs.scope.outputs.build == 'true' runs-on: ubuntu-latest timeout-minutes: 10 name: cmake configure (standalone) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c0c316b2..4a43c024 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,8 +14,40 @@ on: - 'docs/**' - 'LICENSE' + # Everything, weekly: the scope skips what a change can't reach, so this is the + # run that would notice drift in what no change touched (new runner images, + # moved dependencies). + schedule: + - cron: '31 5 * * 1' + workflow_dispatch: + +# A new push to a pull request cancels the run it supersedes. A push to main is +# never cancelled: each one keeps its own record. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: + # What this change can reach (tools/ci_scope.py). A pull request is diffed + # against its base and a push to main from the commit it moved from; a manual + # or weekly run, or a base git can't find, runs everything. + scope: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + lint: ${{ steps.scope.outputs.lint }} + steps: + - uses: actions/checkout@v7 + - name: Scope + id: scope + env: + EVENT: ${{ github.event_name }} + BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + run: python3 tools/ci_scope.py --event "$EVENT" --base "$BASE" + flake8: + needs: scope + if: needs.scope.outputs.lint == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 diff --git a/.github/workflows/test-cpython.yml b/.github/workflows/test-cpython.yml index 3126b309..ccffd8bb 100644 --- a/.github/workflows/test-cpython.yml +++ b/.github/workflows/test-cpython.yml @@ -14,8 +14,41 @@ on: - 'docs/**' - 'LICENSE' + # Everything, weekly: the scope skips what a change can't reach, so this is the + # run that would notice drift in what no change touched (new runner images, + # moved dependencies). + schedule: + - cron: '37 5 * * 1' + workflow_dispatch: + +# A new push to a pull request cancels the run it supersedes. A push to main is +# never cancelled: each one keeps its own record. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: + # What this change can reach (tools/ci_scope.py). A pull request is diffed + # against its base and a push to main from the commit it moved from; a manual + # or weekly run, or a base git can't find, runs everything. + scope: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + unit: ${{ steps.scope.outputs.unit }} + wheels: ${{ steps.scope.outputs.wheels }} + steps: + - uses: actions/checkout@v7 + - name: Scope + id: scope + env: + EVENT: ${{ github.event_name }} + BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + run: python3 tools/ci_scope.py --event "$EVENT" --base "$BASE" + test: + needs: scope + if: needs.scope.outputs.unit == 'true' strategy: fail-fast: false matrix: @@ -141,6 +174,8 @@ jobs: # in each wheel. Nothing is uploaded anywhere. Release wheels still come # from the org reusable workflow; this job proves they would build. macos-wheels: + needs: scope + if: needs.scope.outputs.wheels == 'true' runs-on: macos-latest steps: - uses: actions/checkout@v7 @@ -156,6 +191,8 @@ jobs: # what closing that gap costs. Proving the build here first means the org # change, when it comes, is a matrix line rather than a discovery. linux-arm-wheels: + needs: scope + if: needs.scope.outputs.wheels == 'true' runs-on: ubuntu-24.04-arm steps: - uses: actions/checkout@v7 @@ -177,6 +214,8 @@ jobs: # warning, so the extra's work is proved by numpy arriving, not by pip's # exit code. Fresh venvs, since this runner's Python has numpy in it. render-extra: + needs: scope + if: needs.scope.outputs.wheels == 'true' runs-on: ubuntu-latest timeout-minutes: 15 name: the [render] extra diff --git a/AGENTS.md b/AGENTS.md index 598a18c8..25533fd3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,3 +10,4 @@ - A frozen module wins over `/lib` on MicroPython and CircuitPython, so a copied or mip-installed `audiodsp` never runs until you rebuild or put `/lib` first. On a source checkout set `PYTHONPATH` (CPython) or `MICROPYPATH` (MicroPython). - What each module is held to: [the correctness standard](docs/how-it-works/correctness-standard.md). Where everything is: [How audiodsp works](docs/how-it-works/index.md). - Examples follow one style. Start a file under `examples/` with the three REUSE licence lines the repo's other files use, then a docstring: a one-line imperative summary under 72 characters, a short paragraph on what it shows, then `Needs:`, `Run it:` and `Tested on:` in that order. `Tested on:` names only where the file ran, so run it before you write the line. Put the settings a reader may change in UPPER_CASE constants under a `# Change these to match your setup.` comment, and put a full-sentence comment above each step. Leave out issue numbers, bench port names and session history. If an example draws, use pygraphics, never `framebuf`. A style-only rewrite changes comments, docstrings and layout, never behaviour: compare the code with docstrings and comments stripped, before and after. +- CI runs the jobs a change can reach: `python3 tools/ci_scope.py --files ` prints which (lint, unit, wheels, build). A path it has no rule for runs all of them, so a new directory costs nothing until you add a rule; the weekly run and manual runs check everything. diff --git a/tests/test_ci_scope.py b/tests/test_ci_scope.py new file mode 100644 index 00000000..43412d81 --- /dev/null +++ b/tests/test_ci_scope.py @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: 2026 PyDevices contributors +# SPDX-License-Identifier: MIT +"""tools/ci_scope.py decides which CI jobs a change reaches; this holds its rules.""" + +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +TOOL = Path(__file__).resolve().parent.parent / "tools" / "ci_scope.py" +sys.path.insert(0, str(TOOL.parent)) +import ci_scope # noqa: E402 + + +class Rules(unittest.TestCase): + def jobs(self, *files): + return ci_scope.scope(files) + + def test_docs_reach_nothing(self): + self.assertEqual(self.jobs("README.md", "docs/how-it-works/x.md", "docs/img/a.png", "LICENSE"), set()) + + def test_a_release_bump_reaches_nothing(self): + self.assertEqual(self.jobs("VERSION", "CHANGELOG.md"), set()) + + def test_examples_are_only_linted(self): + self.assertEqual(self.jobs("examples/audiodsp_simpletest.py"), {"lint"}) + + def test_a_pump_probe_skips_the_wheel_jobs(self): + self.assertEqual(self.jobs("tests/pump/pump_probe.py"), {"lint", "unit", "build"}) + + def test_a_unit_test_skips_the_interpreter_build(self): + self.assertEqual(self.jobs("tests/test_cpython_audiograph.py"), {"lint", "unit"}) + + def test_a_workflow_reaches_its_own_jobs(self): + self.assertEqual(self.jobs(".github/workflows/clean-build.yml"), {"build"}) + self.assertEqual(self.jobs(".github/workflows/lint.yml"), {"lint"}) + self.assertEqual(self.jobs(".github/workflows/prepare-release.yml"), set()) + + def test_the_sources_packaging_and_unknown_files_reach_everything(self): + for path in ("src/shared/audiodsp_graph.c", "src/cpython/_audiodsp.c", "setup.py", "pyproject.toml", + "MANIFEST.in", "micropython.mk", "DEPENDENCIES.lock", "third_party/tremor/x.c", + "a_file_no_rule_names.txt", "tools/ci_scope.py", "tests/vendor/test_x.py"): + self.assertEqual(self.jobs(path), set(ci_scope.JOBS), path) + + def test_a_star_stays_inside_one_directory(self): + self.assertEqual(self.jobs("tests/test_cpython_x.py"), {"lint", "unit"}) + self.assertEqual(self.jobs("tests/nested/test_x.py"), set(ci_scope.JOBS)) + + def test_a_union_over_the_files(self): + self.assertEqual(self.jobs("examples/a.py", "tests/circuitpython/ring_through_mixer.py"), {"lint", "build"}) + + +class Command(unittest.TestCase): + def run_tool(self, *args, env=None): + with tempfile.TemporaryDirectory() as tmp: + out = os.path.join(tmp, "out") + e = dict(os.environ, GITHUB_OUTPUT=out) + r = subprocess.run([sys.executable, str(TOOL), *args], capture_output=True, text=True, env=e, + cwd=str(TOOL.parent.parent)) + self.assertEqual(r.returncode, 0, r.stderr) + return dict(line.split("=") for line in Path(out).read_text().split()), r.stdout + + def test_files_mode(self): + got, _ = self.run_tool("--files", "VERSION", "examples/a.py") + self.assertEqual(got, {"lint": "true", "unit": "false", "wheels": "false", "build": "false"}) + + def test_a_manual_or_scheduled_run_runs_everything(self): + for event in ("workflow_dispatch", "schedule", ""): + got, _ = self.run_tool("--event", event, "--base", "HEAD") + self.assertEqual(set(got.values()), {"true"}, event) + + def test_no_usable_base_runs_everything(self): + for base in ("", "0" * 40, "f" * 40): + got, _ = self.run_tool("--event", "push", "--base", base) + self.assertEqual(set(got.values()), {"true"}, base) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/ci_scope.py b/tools/ci_scope.py new file mode 100644 index 00000000..803419ef --- /dev/null +++ b/tools/ci_scope.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 PyDevices contributors +# SPDX-License-Identifier: MIT +"""Say which CI jobs a change can reach. + +A pull request is diffed against its base, a push to main from the commit main +was at before the push to the one it moved to. Each changed file names the +jobs it can reach in RULES; a file no rule names, and any case with no usable +base (a manual or scheduled run, an unreachable commit), reaches every job. + +Jobs: lint (lint.yml), unit (test-cpython.yml's matrix), wheels (the +cibuildwheel and [render] jobs there) and build (clean-build.yml). + + ci_scope.py --event pull_request --base [--head HEAD] + ci_scope.py --files tools/check_portable.py tests/pump/pump_probe.py + +Prints one line per job and, under GitHub Actions, writes the same to +$GITHUB_OUTPUT. `--self-test` is not needed: tests/test_ci_scope.py holds it. +""" + +import argparse +import os +import re +import subprocess +import sys + +JOBS = ("lint", "unit", "wheels", "build") +ALL = frozenset(JOBS) + +# First match wins. A pattern is a glob over the path from the repo root: +# `*` stays inside one directory, `**` crosses them. Anything that matches no pattern reaches +# every job: src/, third_party/, setup.py, pyproject.toml, MANIFEST.in, the +# micropython and cmake build files, the pins and every file new to the repo. +RULES = ( + # Nothing reads these. The workflows already skip md, docs and LICENSE. + ("**.md", ()), + ("docs/**", ()), + ("LICENSE", ()), + # VERSION reaches the wheel's version string and the firmware's __version__, + # which every test takes from the file and none compares with a number. + ("VERSION", ()), + # Other workflows: they publish, tag and prepare releases, and run on their + # own events. Each of the three CI workflows reaches only its own jobs. + (".github/workflows/lint.yml", ("lint",)), + (".github/workflows/test-cpython.yml", ("unit", "wheels")), + (".github/workflows/clean-build.yml", ("build",)), + (".github/workflows/android-wheels.yml", ()), + (".github/workflows/prepare-release.yml", ()), + (".github/workflows/publish-release-packages.yml", ()), + (".github/workflows/tag-release.yml", ()), + # flake8 reads the examples; nothing else does. + ("examples/**", ("lint",)), + (".flake8", ("lint",)), + ("scripts/check_attribution.py", ("lint",)), + ("scripts/test_testpypi_install.py", ("lint",)), + ("tools/check_*.py", ("lint",)), + ("tools/design_*.py", ("lint",)), + ("tools/prefetch_android_python.py", ("lint", "unit")), + # Probes, pump gates and CircuitPython's mixer check run on an interpreter + # build and, for the first two, on CPython; none of them goes into a wheel. + ("tests/circuitpython/**", ("lint", "build")), + ("tests/pump/**", ("lint", "unit", "build")), + ("tests/parity/**", ("lint", "unit", "build")), + ("tests/data/**", ("lint", "unit", "build")), + ("tests/cmake/**", ("build",)), + ("tests/test_*.py", ("lint", "unit")), + ("tests/support/**", ("lint", "unit")), + # This tool and its own test. + ("tools/ci_scope.py", tuple(JOBS)), +) + + +def compile_glob(pattern): + return re.compile( + "".join(".*" if p == "**" else "[^/]*" if p == "*" else re.escape(p) + for p in re.split(r"(\*\*|\*)", pattern)) + r"\Z") + + +COMPILED = [(compile_glob(p), j) for p, j in RULES] + + +def reach(path): + for pattern, jobs in COMPILED: + if pattern.match(path): + return frozenset(jobs) + return ALL + + +def scope(files): + """The jobs a set of changed files can reach, and why each was kept.""" + jobs = set() + for path in files: + jobs |= reach(path) + return jobs + + +def git(*args): + return subprocess.run(["git", *args], check=True, capture_output=True, text=True).stdout + + +def changed_files(base, head): + """The files that differ between base and head, or None if git can't say.""" + if not base or re.fullmatch(r"0+", base): + return None + try: + git("cat-file", "-e", base + "^{commit}") + except subprocess.CalledProcessError: + try: + git("fetch", "--no-tags", "--depth=1", "origin", base) + except subprocess.CalledProcessError: + return None + try: + return git("diff", "--name-only", "--no-renames", base, head).split("\n")[:-1] + except subprocess.CalledProcessError: + return None + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--event", default="", help="GitHub's event name; only pull_request and push are scoped") + ap.add_argument("--base", default="", help="the commit to diff from") + ap.add_argument("--head", default="HEAD") + ap.add_argument("--files", nargs="*", help="changed files, instead of a diff") + args = ap.parse_args(argv) + + if args.files is not None: + files, why = args.files, "the files given" + elif args.event not in ("pull_request", "push"): + files, why = None, "event %r is not a change" % args.event + else: + files = changed_files(args.base, args.head) + why = "%s..%s" % (args.base[:9], args.head) + if files is None: + why = "no usable base %r" % args.base + + if files is None: + jobs = set(ALL) + print("Everything runs: %s." % why) + else: + jobs = scope(files) + print("Scope (%s): %d changed files." % (why, len(files))) + for path in files: + print(" %-60s %s" % (path, ", ".join(sorted(reach(path))) or "-")) + for job in JOBS: + print("%s=%s" % (job, "true" if job in jobs else "false")) + out = os.environ.get("GITHUB_OUTPUT") + if out: + with open(out, "a") as f: + for job in JOBS: + f.write("%s=%s\n" % (job, "true" if job in jobs else "false")) + return 0 + + +if __name__ == "__main__": + sys.exit(main())