Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/clean-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions .github/workflows/test-cpython.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <paths>` 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.
81 changes: 81 additions & 0 deletions tests/test_ci_scope.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading