From f9c3f569ae5061852446b86e607c5d78e1cf0974 Mon Sep 17 00:00:00 2001 From: Stephen Romano Date: Tue, 27 Oct 2026 10:34:43 -0400 Subject: [PATCH] ci: use lightweight checks for reviewed prose changes --- .github/workflows/ci.yml | 35 ++++++++++++++ AGENTS.md | 18 ++++++++ ci/prose_ci.py | 55 ++++++++++++++++++++++ ci/test_prose_ci.py | 98 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 206 insertions(+) create mode 100644 ci/prose_ci.py create mode 100644 ci/test_prose_ci.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 217a2b4..1f7f734 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,25 +39,45 @@ jobs: with: persist-credentials: false fetch-depth: 0 + - name: Check prose scope decisions + run: python3 -m unittest discover -s ci -p test_prose_ci.py + - name: Classify the complete change and check whitespace + id: prose + env: + CI_PROSE_BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + CI_PROSE_HEAD: ${{ github.sha }} + run: python3 ci/prose_ci.py - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: ${{ matrix.python-version }} cache: pip cache-dependency-path: requirements-dev.txt + if: ${{ steps.prose.outputs.prose_only != 'true' }} - name: Install pinned development tools and SDK run: | python -m pip install --require-hashes -r requirements-dev.txt python -m pip install --no-build-isolation -e . + if: ${{ steps.prose.outputs.prose_only != 'true' }} - name: Verify package, release proposal and tag versions env: PR_TITLE: ${{ github.event.pull_request.title }} run: python scripts/check_release_version.py + if: ${{ steps.prose.outputs.prose_only != 'true' }} - run: python -m ruff check . + if: ${{ steps.prose.outputs.prose_only != 'true' }} - run: python -m ruff format --check . + if: ${{ steps.prose.outputs.prose_only != 'true' }} - run: python -m mypy + if: ${{ steps.prose.outputs.prose_only != 'true' }} - run: python scripts/generate_notebook.py --check + if: ${{ steps.prose.outputs.prose_only != 'true' }} - run: python -m pytest -q + if: ${{ steps.prose.outputs.prose_only != 'true' }} - run: python scripts/check_package.py + if: ${{ steps.prose.outputs.prose_only != 'true' }} + - name: Prose-only change + if: ${{ steps.prose.outputs.prose_only == 'true' }} + run: echo "Reviewed prose-only paths; this job's build and runtime checks are not applicable." notebook-container: name: Docker notebook @@ -66,6 +86,21 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: persist-credentials: false + fetch-depth: 0 + - name: Check prose scope decisions + run: python3 -m unittest discover -s ci -p test_prose_ci.py + - name: Classify the complete change and check whitespace + id: prose + env: + CI_PROSE_BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + CI_PROSE_HEAD: ${{ github.sha }} + run: python3 ci/prose_ci.py - run: docker compose config --quiet + if: ${{ steps.prose.outputs.prose_only != 'true' }} - run: docker build -t morphiq-sdk-notebook:test . + if: ${{ steps.prose.outputs.prose_only != 'true' }} - run: python3 scripts/check_notebook_container.py --image morphiq-sdk-notebook:test + if: ${{ steps.prose.outputs.prose_only != 'true' }} + - name: Prose-only change + if: ${{ steps.prose.outputs.prose_only == 'true' }} + run: echo "Reviewed prose-only paths; this job's build and runtime checks are not applicable." diff --git a/AGENTS.md b/AGENTS.md index d8aef4c..085efc5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,24 @@ Be strategically pragmatic in all MorphIQ Labs work. Choose the simplest solutio Pragmatism governs which guarantees we undertake, not whether we honor them. If an existing commitment is disproportionate, make the tradeoff and proposed contract change explicit to the owner before narrowing it; do not disguise unfinished work as completion. +## Prose-only CI + +Changes confined to the root `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`, +`README.md`, or Markdown files directly under `docs/adr/` use lightweight prose +checks. This is an explicit exception to the general every-change build, test, +assurance and dependency-audit rules. Keep applicable title, repository-hygiene, +secret and whitespace checks, and a required successful pipeline/check result. +Do not use a blanket CI-skip marker or leave required checks pending. + +The allowlist is deliberately narrow. Any mixed change or other path retains +its existing CI gates, including unfamiliar files, code, dependencies, build/CI +configuration, release metadata, examples, notebooks, fixtures, generated inputs +and proof-related documentation. Review consumers before extending the allowlist; +Markdown is not automatically inert. Preserve focused checks that read an +allowlisted file as a policy input. Scheduled/operational pipelines and release +automation retain their existing responsibilities. CI-rule changes themselves +run full CI and exercise prose, mixed, renamed and unknown-path cases. + ## Client contract - Preserve wire semantics, numerical values, diagnostics/provenance, ordering diff --git a/ci/prose_ci.py b/ci/prose_ci.py new file mode 100644 index 0000000..c3f7d29 --- /dev/null +++ b/ci/prose_ci.py @@ -0,0 +1,55 @@ +"""Recognize the reviewed prose-only scope; all other changes need full CI.""" + +import os +import re +import subprocess +from pathlib import Path + +PROSE_ROOTS = frozenset({"AGENTS.md", "CLAUDE.md", "CONTRIBUTING.md", "README.md"}) + + +def is_prose(path: str) -> bool: + return path in PROSE_ROOTS or re.fullmatch(r"docs/adr/[^/\r\n]+\.md", path) is not None + + +def prose_only(paths: list[str]) -> bool: + return bool(paths) and all(is_prose(path) for path in paths) + + +def changed_paths(base: str, head: str) -> list[str]: + # No rename detection: both the removed source and added destination matter. + result = subprocess.check_output( + ["git", "diff", "--name-only", "--no-renames", "-z", base, head] + ) + return [os.fsdecode(path) for path in result.split(b"\0") if path] + + +def main() -> None: + # Tags and operational events keep their existing full checks. + event = os.environ.get("GITHUB_EVENT_NAME", "") + ref = os.environ.get("GITHUB_REF", "") + if event not in ("pull_request", "push") or ref.startswith("refs/tags/"): + with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output: + output.write("prose_only=false\n") + print("Full CI required for this event") + return + base = os.environ["CI_PROSE_BASE"] + head = os.environ["CI_PROSE_HEAD"] + if not base or not head: + raise SystemExit("Cannot classify CI scope without both revisions") + if base == "0" * 40: + base = ( + subprocess.check_output(["git", "hash-object", "-t", "tree", "--stdin"], input=b"") + .decode() + .strip() + ) + paths = changed_paths(base, head) + subprocess.run(["git", "diff", "--check", base, head], check=True) + verdict = "true" if prose_only(paths) else "false" + print(f"prose_only={verdict}; {len(paths)} changed paths") + with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output: + output.write(f"prose_only={verdict}\n") + + +if __name__ == "__main__": + main() diff --git a/ci/test_prose_ci.py b/ci/test_prose_ci.py new file mode 100644 index 0000000..85f2a52 --- /dev/null +++ b/ci/test_prose_ci.py @@ -0,0 +1,98 @@ +import os +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from prose_ci import changed_paths, main, prose_only + + +class ProseScopeTests(unittest.TestCase): + def test_reviewed_prose_is_lightweight(self): + self.assertTrue(prose_only(["AGENTS.md", "README.md", "docs/adr/0001-runtime.md"])) + + def test_mixed_unknown_release_and_proof_inputs_need_full_ci(self): + for path in ( + "src/lib.rs", + "Cargo.lock", + ".github/workflows/ci.yml", + "ci/prose_ci.py", + "CHANGELOG.md", + "formal/README.md", + "docs/MATHEMATICAL_VALIDATION.md", + "docs/fixture.json", + "examples/example.ipynb", + "new-unclassified-input", + "README.md\nsrc/lib.rs", + "docs/adr/note.md\n", + ): + with self.subTest(path=path): + self.assertFalse(prose_only(["AGENTS.md", path])) + self.assertFalse(prose_only([])) + + def test_git_rename_out_of_source_retains_both_paths(self): + with tempfile.TemporaryDirectory() as directory: + old = Path.cwd() + try: + os.chdir(directory) + + def git(*args): + return ( + subprocess.check_output( + ["git", "-c", "commit.gpgsign=false", *args], stderr=subprocess.DEVNULL + ) + .decode() + .strip() + ) + + git("init") + git("config", "user.name", "CI fixture") + git("config", "user.email", "ci-fixture@example.invalid") + Path("source.py").write_text("print('fixture')\n") + git("add", ".") + git("commit", "-m", "fixture: source") + base = git("rev-parse", "HEAD") + git("mv", "source.py", "README.md") + git("commit", "-m", "fixture: renamed source") + paths = changed_paths(base, "HEAD") + self.assertEqual(set(paths), {"source.py", "README.md"}) + self.assertFalse(prose_only(paths)) + with self.assertRaises(subprocess.CalledProcessError): + changed_paths("missing-revision", "HEAD") + finally: + os.chdir(old) + + def test_missing_comparison_cannot_report_prose_success(self): + with patch.dict( + os.environ, + { + "CI_PROSE_BASE": "", + "CI_PROSE_HEAD": "HEAD", + "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_REF": "refs/pull/1/merge", + }, + ): + with self.assertRaises(SystemExit): + main() + + def test_tags_and_operational_events_keep_full_ci(self): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "output" + for event, ref in (("push", "refs/tags/v1.0.0"), ("schedule", "refs/heads/main")): + with self.subTest(event=event, ref=ref): + output.write_text("") + with patch.dict( + os.environ, + { + "GITHUB_EVENT_NAME": event, + "GITHUB_REF": ref, + "GITHUB_OUTPUT": str(output), + }, + ): + main() + self.assertEqual(output.read_text(), "prose_only=false\n") + + +if __name__ == "__main__": + unittest.main()