CI: skip the build on branches that only change docs #286

Merged
jcoffey-dev merged 1 commits from ci/skip-build-docs-only into main 2026-11-03 01:19:46 +00:00
4 changed files with 212 additions and 2 deletions

No files matched your search

+20 -2
View File
@@ -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:
+6
View File
@@ -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.
+71
View File
@@ -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()
+115
View File
@@ -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", "[email protected]",
"-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()