diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f4db6bf..0f2a013d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,11 +104,29 @@ jobs: - if: always() run: python3 -m unittest discover -s tools/release/tests + # Whether this branch touches anything the build reads. tools/fork/docs-only.py + # says what counts, and main always builds. A skipped build still reports + # success below: Gitea's `github` job waits for that status, so this can't be + # a paths-ignore on the trigger, which would leave it waiting until it times + # out. If this job fails, the build is skipped and the report says failure. + changes: + if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'branch' }} + runs-on: ubuntu-latest + outputs: + docs_only: ${{ steps.check.outputs.docs_only }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - id: check + run: python3 tools/fork/docs-only.py + # The build, and that every test target compiles. The suites are not run: # they need a store, fixed ports and containers (docs/spec/ # container-tests.md), and are run by hand. build: - if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'branch' }} + needs: [changes] + if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'branch' && needs.changes.outputs.docs_only == 'false' }} runs-on: ubuntu-latest env: CARGO_INCREMENTAL: "0" @@ -583,7 +601,7 @@ jobs: # way round) count as passing; a failed or cancelled one does not. report: if: ${{ always() && vars.BUILD_ON == 'github' }} - needs: [start, fork-checks, build, version, publish, index, relay-publish, relay-index, release, binaries] + needs: [start, fork-checks, changes, build, version, publish, index, relay-publish, relay-index, release, binaries] runs-on: ubuntu-latest steps: - env: diff --git a/tools/fork/README.md b/tools/fork/README.md index eda05ab5..34966a35 100644 --- a/tools/fork/README.md +++ b/tools/fork/README.md @@ -127,3 +127,9 @@ tools/fork/expr-schema.py --check # exit 1 if it's out of date (CI) Re-run it after anything that regenerates the registry, an upstream import included. + +## docs-only.py + +Says whether a branch changes only top-level `*.md` files and `docs/`. GitHub's +`ci.yml` uses the answer to skip the build for those branches, and still reports +success to Gitea. `main` always builds. `fork-checks` runs either way. diff --git a/tools/fork/docs-only.py b/tools/fork/docs-only.py new file mode 100755 index 00000000..5e2209d2 --- /dev/null +++ b/tools/fork/docs-only.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Coffey Labs LLC +# SPDX-License-Identifier: AGPL-3.0-only +""" +Say whether a branch changes only documentation, so CI can skip its build. + + tools/fork/docs-only.py # prints docs_only=true or docs_only=false + +Under GitHub Actions the answer also goes to $GITHUB_OUTPUT. + +The whole branch is compared with where it left main, not just its latest +push. A newer push cancels the run for the older one, so a documentation commit +on top of a code commit must still build. + +Only top-level *.md files and docs/ count. Markdown elsewhere can be compiled +in: vendor/calcard and vendor/sieve-rs include their READMEs as doc comments. + +main always builds. A push to main cancels the run before it, so a documentation +merge skipping its build would leave the merge before it unchecked. When there's +any doubt, the answer is false: a needless build only costs time. +""" +import os +import re +import subprocess +import sys + +BASE = "origin/main" +MAIN_REF = "refs/heads/main" +DOCS = re.compile(r"(?:[^/]+\.md|docs/.+)", re.S) + + +def is_docs_only(paths): + return bool(paths) and all(DOCS.fullmatch(p) for p in paths) + + +def changed_paths(base=BASE, cwd=None): + """The branch's files, or None if git can't say. A rename lists both ends.""" + result = subprocess.run( + ["git", "diff", "--name-only", "--no-renames", "-z", f"{base}...HEAD"], + cwd=cwd, + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(result.stderr, file=sys.stderr) + return None + return [p for p in result.stdout.split("\0") if p] + + +def answer(ref, base=BASE, cwd=None): + if ref == MAIN_REF: + return False + paths = changed_paths(base, cwd) + if paths is None: + return False + for path in paths: + print(f"changed: {path}") + return is_docs_only(paths) + + +def main(): + line = f"docs_only={str(answer(os.environ.get('GITHUB_REF', ''))).lower()}" + print(line) + output = os.environ.get("GITHUB_OUTPUT") + if output: + with open(output, "a", encoding="utf-8") as f: + f.write(line + "\n") + + +if __name__ == "__main__": + main() diff --git a/tools/fork/tests/test_docs_only.py b/tools/fork/tests/test_docs_only.py new file mode 100644 index 00000000..d98d3ed7 --- /dev/null +++ b/tools/fork/tests/test_docs_only.py @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: 2026 Coffey Labs LLC +# SPDX-License-Identifier: AGPL-3.0-only +"""Tests for tools/fork/docs-only.py: python3 -m unittest discover -s tools/fork/tests""" +import contextlib +import importlib.util +import io +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +HERE = os.path.dirname(os.path.abspath(__file__)) +spec = importlib.util.spec_from_file_location("docs_only", os.path.join(HERE, "..", "docs-only.py")) +docs_only = importlib.util.module_from_spec(spec) +spec.loader.exec_module(docs_only) + + +class Paths(unittest.TestCase): + def test_documentation(self): + for paths in (["README.md"], ["CHANGELOG.md", "docs/spec/SPEC.md"], ["docs/img/a.png"]): + self.assertTrue(docs_only.is_docs_only(paths), paths) + + def test_not_documentation(self): + for paths in ( + [], + ["Cargo.toml"], + ["README.md", "crates/main/src/main.rs"], + ["vendor/calcard/README.md"], + ["crates/main/README.md"], + [".github/workflows/ci.yml"], + ["docs"], + ["README.md\nsrc/main.rs"], + ): + self.assertFalse(docs_only.is_docs_only(paths), paths) + + +class Branches(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.repo = Path(tmp.name) + self.git("init", "-q", "-b", "main") + self.commit({"README.md": "one\n", "src.rs": "fn main() {}\n"}) + self.git("branch", "base") + self.git("switch", "-q", "-c", "work") + + def git(self, *args): + subprocess.run( + [ + "git", + "-c", "user.name=t", + "-c", "user.email=t@example.org", + "-c", "commit.gpgsign=false", + *args, + ], + cwd=self.repo, + check=True, + capture_output=True, + ) + + def commit(self, files): + for name, text in files.items(): + path = self.repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + self.git("add", "-A") + self.git("commit", "-q", "-m", "change") + + def answer(self, ref="refs/heads/work", base="base"): + with contextlib.redirect_stdout(io.StringIO()): + return docs_only.answer(ref, base, self.repo) + + def test_docs_commit_is_docs_only(self): + self.commit({"README.md": "two\n", "docs/a.md": "a\n"}) + self.assertTrue(self.answer()) + + def test_code_commit_builds(self): + self.commit({"src.rs": "fn main() { }\n"}) + self.assertFalse(self.answer()) + + def test_docs_commit_on_top_of_code_still_builds(self): + self.commit({"src.rs": "fn main() { }\n"}) + self.commit({"README.md": "two\n"}) + self.assertFalse(self.answer()) + + def test_nothing_changed_builds(self): + self.assertFalse(self.answer()) + + def test_moving_code_into_docs_builds(self): + self.git("mv", "src.rs", "docs-src.rs") + (self.repo / "docs").mkdir() + self.git("mv", "docs-src.rs", "docs/src.rs") + self.git("commit", "-q", "-m", "move") + self.assertFalse(self.answer()) + + def test_code_landing_on_main_since_the_branch_left_it_does_not_count(self): + self.commit({"README.md": "two\n"}) + self.git("switch", "-q", "base") + self.commit({"src.rs": "fn main() { }\n"}) + self.git("switch", "-q", "work") + self.assertTrue(self.answer()) + + def test_main_always_builds(self): + self.commit({"README.md": "two\n"}) + self.assertFalse(self.answer("refs/heads/main")) + + def test_git_failing_builds(self): + self.commit({"README.md": "two\n"}) + with contextlib.redirect_stderr(io.StringIO()): + self.assertFalse(self.answer(base="no-such-ref")) + + +if __name__ == "__main__": + unittest.main()