CI: skip the build on branches that only change docs #286
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
Executable
+71
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user