Skip to content

Commit afb38e6

Browse files
ci: skip builds for documentation-only changes
1 parent d11736d commit afb38e6

3 files changed

Lines changed: 326 additions & 6 deletions

File tree

‎.github/workflows/build.yml‎

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ permissions:
88
actions: read
99
contents: read
1010
packages: write
11+
pull-requests: read
1112

1213
concurrency:
1314
group: |
@@ -25,6 +26,7 @@ jobs:
2526
runs-on: ${{ vars.RUNNER_CHECK_SKIP || 'ubuntu-24.04-arm' }}
2627
outputs:
2728
skip: ${{ steps.skip-check.outputs.skip }}
29+
run-build-tests: ${{ steps.classify-changes.outputs.run-build-tests }}
2830
runner-amd64: ${{ steps.select-runner.outputs.runner_amd64 }}
2931
runner-arm64: ${{ steps.select-runner.outputs.runner_arm64 }}
3032
use-blacksmith: ${{ steps.select-runner.outputs.use_blacksmith }}
@@ -39,18 +41,87 @@ jobs:
3941
run: |
4042
if [[ "${{ github.event_name }}" == "push" && "${{ vars.SKIP_ON_PUSH }}" != "" ]]; then
4143
echo "Skipping build on push due to SKIP_ON_PUSH environment variable"
42-
echo "skip=true" >> $GITHUB_OUTPUT
44+
echo "skip=true" >> "$GITHUB_OUTPUT"
4345
elif [[ "${{ github.event_name }}" == "pull_request_target" && "${{ vars.SKIP_ON_PR }}" != "" ]]; then
4446
echo "Skipping build on pull request due to SKIP_ON_PR environment variable"
45-
echo "skip=true" >> $GITHUB_OUTPUT
47+
echo "skip=true" >> "$GITHUB_OUTPUT"
4648
else
47-
echo "skip=false" >> $GITHUB_OUTPUT
49+
echo "skip=false" >> "$GITHUB_OUTPUT"
4850
fi
4951
5052
- name: Checkout code
5153
if: ${{ steps.skip-check.outputs.skip == 'false' }}
5254
uses: actions/checkout@v6
5355

56+
- name: Collect changed paths
57+
id: changed-paths
58+
if: ${{ steps.skip-check.outputs.skip == 'false' }}
59+
uses: actions/github-script@v8
60+
env:
61+
CHANGED_PATHS_FILE: ${{ runner.temp }}/changed-paths.json
62+
with:
63+
script: |
64+
const fs = require('fs');
65+
let complete = true;
66+
let files = [];
67+
68+
try {
69+
if (context.eventName === 'pull_request_target') {
70+
files = await github.paginate(github.rest.pulls.listFiles, {
71+
owner: context.repo.owner,
72+
repo: context.repo.repo,
73+
pull_number: context.payload.pull_request.number,
74+
per_page: 100,
75+
});
76+
if (files.length >= 3000) {
77+
complete = false;
78+
core.warning('Pull request file list reached the GitHub API limit');
79+
}
80+
} else if (context.eventName === 'push') {
81+
const before = context.payload.before;
82+
const after = context.payload.after;
83+
if (context.ref.startsWith('refs/tags/') || !before || /^0+$/.test(before)) {
84+
complete = false;
85+
} else {
86+
const comparison = await github.rest.repos.compareCommitsWithBasehead({
87+
owner: context.repo.owner,
88+
repo: context.repo.repo,
89+
basehead: `${before}...${after}`,
90+
});
91+
files = comparison.data.files || [];
92+
if (files.length >= 300) {
93+
complete = false;
94+
core.warning('Push file list reached the GitHub API limit');
95+
}
96+
}
97+
} else {
98+
complete = false;
99+
}
100+
} catch (error) {
101+
complete = false;
102+
core.warning(`Unable to collect a complete changed path list: ${error.message}`);
103+
}
104+
105+
const paths = [...new Set(files.flatMap(file =>
106+
[file.filename, file.previous_filename].filter(Boolean)
107+
))].sort();
108+
fs.writeFileSync(process.env.CHANGED_PATHS_FILE, JSON.stringify(paths));
109+
core.setOutput('complete', complete.toString());
110+
111+
- name: Classify changed paths
112+
id: classify-changes
113+
if: ${{ steps.skip-check.outputs.skip == 'false' }}
114+
env:
115+
CHANGED_PATHS_COMPLETE: ${{ steps.changed-paths.outputs.complete }}
116+
CHANGED_PATHS_FILE: ${{ runner.temp }}/changed-paths.json
117+
run: |
118+
if ! python3 .github/workflows/classify_ci_changes.py \
119+
--complete "$CHANGED_PATHS_COMPLETE" "$CHANGED_PATHS_FILE"; then
120+
echo "Path classification failed; running the full build and test matrix"
121+
echo "run-build-tests=true" >> "$GITHUB_OUTPUT"
122+
echo "decision-reason=path classification failed" >> "$GITHUB_OUTPUT"
123+
fi
124+
54125
- name: Select runners
55126
id: select-runner
56127
if: ${{ steps.skip-check.outputs.skip == 'false' }}
@@ -65,7 +136,9 @@ jobs:
65136
66137
- name: Get base image digest
67138
id: base-image
68-
if: ${{ steps.skip-check.outputs.skip == 'false' }}
139+
if: |
140+
steps.skip-check.outputs.skip == 'false' &&
141+
steps.classify-changes.outputs.run-build-tests == 'true'
69142
run: |
70143
# Fetch the canonical manifest digest for ubuntu:noble so the
71144
# depends cache key changes when Canonical pushes base image
@@ -86,15 +159,19 @@ jobs:
86159
cache-sources:
87160
name: Cache depends sources
88161
needs: [check-skip]
89-
if: ${{ needs.check-skip.outputs.skip == 'false' }}
162+
if: |
163+
needs.check-skip.outputs.skip == 'false' &&
164+
needs.check-skip.outputs.run-build-tests == 'true'
90165
uses: ./.github/workflows/cache-depends-sources.yml
91166
with:
92167
runs-on: ${{ needs.check-skip.outputs['runner-arm64'] }}
93168

94169
container:
95170
name: Build container
96171
needs: [check-skip]
97-
if: ${{ needs.check-skip.outputs.skip == 'false' }}
172+
if: |
173+
needs.check-skip.outputs.skip == 'false' &&
174+
needs.check-skip.outputs.run-build-tests == 'true'
98175
uses: ./.github/workflows/build-container.yml
99176
with:
100177
context: ./contrib/containers/ci
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# Copyright (c) 2026 The Dash Core developers
2+
# Distributed under the MIT software license, see the accompanying
3+
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
4+
5+
import argparse
6+
import json
7+
import os
8+
from pathlib import PurePosixPath
9+
from typing import Dict, List, Optional, Sequence
10+
11+
12+
EXCLUDED_EXACT_PATHS = {
13+
"COPYING",
14+
"doc/.gitignore",
15+
}
16+
EXCLUDED_PREFIXES = (
17+
".github/ISSUE_TEMPLATE/",
18+
".github/PULL_REQUEST_TEMPLATE/",
19+
)
20+
EXCLUDED_DOC_SUFFIXES = {".1", ".5", ".8", ".png", ".svg", ".txt"}
21+
22+
23+
def is_excluded_path(path: str) -> bool:
24+
if not path:
25+
return False
26+
if path in EXCLUDED_EXACT_PATHS or path.startswith(EXCLUDED_PREFIXES):
27+
return True
28+
29+
suffix = PurePosixPath(path).suffix
30+
if suffix == ".md":
31+
return True
32+
return path.startswith("doc/") and suffix in EXCLUDED_DOC_SUFFIXES
33+
34+
35+
def classify_changes(paths: Sequence[str], complete: bool) -> Dict[str, object]:
36+
unique_paths = sorted(set(paths))
37+
if not complete:
38+
return {
39+
"run_build_tests": True,
40+
"reason": "changed path list is incomplete",
41+
"paths": unique_paths,
42+
"triggering_paths": [],
43+
}
44+
if not unique_paths:
45+
return {
46+
"run_build_tests": True,
47+
"reason": "no changed paths were reported",
48+
"paths": unique_paths,
49+
"triggering_paths": [],
50+
}
51+
52+
triggering_paths = [path for path in unique_paths if not is_excluded_path(path)]
53+
if triggering_paths:
54+
reason = "build-relevant or unclassified paths changed"
55+
else:
56+
reason = "all changed paths are in CI exclusion zones"
57+
return {
58+
"run_build_tests": bool(triggering_paths),
59+
"reason": reason,
60+
"paths": unique_paths,
61+
"triggering_paths": triggering_paths,
62+
}
63+
64+
65+
def load_paths(path: str) -> List[str]:
66+
with open(path, "r", encoding="utf-8") as file:
67+
paths = json.load(file)
68+
if not isinstance(paths, list) or not all(isinstance(item, str) for item in paths):
69+
raise ValueError("changed paths must be a JSON array of strings")
70+
return paths
71+
72+
73+
def write_github_output(path: Optional[str], result: Dict[str, object]) -> None:
74+
if not path:
75+
return
76+
with open(path, "a", encoding="utf-8") as file:
77+
file.write(
78+
"run-build-tests={}\n".format(
79+
"true" if result["run_build_tests"] else "false"
80+
)
81+
)
82+
file.write("decision-reason={}\n".format(result["reason"]))
83+
84+
85+
def write_step_summary(
86+
path: Optional[str], result: Dict[str, object], complete: bool
87+
) -> None:
88+
if not path:
89+
return
90+
91+
triggering_paths = result["triggering_paths"]
92+
with open(path, "a", encoding="utf-8") as file:
93+
file.write("### Build and test path classification\n")
94+
file.write(
95+
"- Changed path list complete: {}\n".format(
96+
"yes" if complete else "no"
97+
)
98+
)
99+
file.write("- Changed paths examined: {}\n".format(len(result["paths"])))
100+
file.write(
101+
"- Full build and test matrix: {}\n".format(
102+
"required" if result["run_build_tests"] else "skipped"
103+
)
104+
)
105+
file.write("- Decision: `{}`\n".format(result["reason"]))
106+
if triggering_paths:
107+
file.write("- Triggering paths (up to 20):\n")
108+
for changed_path in triggering_paths[:20]:
109+
file.write(
110+
" - `{}`\n".format(json.dumps(changed_path, ensure_ascii=True))
111+
)
112+
113+
114+
def parse_args(argv: Sequence[str]) -> argparse.Namespace:
115+
parser = argparse.ArgumentParser(description="Classify changed paths for Dash CI.")
116+
parser.add_argument("paths_json", help="JSON file containing changed paths")
117+
parser.add_argument(
118+
"--complete",
119+
choices=("true", "false"),
120+
required=True,
121+
help="Whether the changed path list is known to be complete",
122+
)
123+
return parser.parse_args(argv)
124+
125+
126+
def main(argv: Sequence[str]) -> int:
127+
args = parse_args(argv)
128+
complete = args.complete == "true"
129+
result = classify_changes(load_paths(args.paths_json), complete)
130+
write_github_output(os.environ.get("GITHUB_OUTPUT"), result)
131+
write_step_summary(os.environ.get("GITHUB_STEP_SUMMARY"), result, complete)
132+
print(
133+
"run-build-tests={} ({})".format(
134+
"true" if result["run_build_tests"] else "false", result["reason"]
135+
)
136+
)
137+
return 0
138+
139+
140+
if __name__ == "__main__":
141+
import sys
142+
143+
sys.exit(main(sys.argv[1:]))
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import importlib.util
2+
import json
3+
import pathlib
4+
import tempfile
5+
import unittest
6+
7+
8+
MODULE_PATH = pathlib.Path(__file__).with_name("classify_ci_changes.py")
9+
SPEC = importlib.util.spec_from_file_location("classify_ci_changes", MODULE_PATH)
10+
MODULE = importlib.util.module_from_spec(SPEC)
11+
assert SPEC.loader is not None
12+
SPEC.loader.exec_module(MODULE)
13+
14+
15+
class ClassifyCIChangesTest(unittest.TestCase):
16+
def test_markdown_files_are_excluded_at_any_depth(self):
17+
for path in ("README.md", "doc/release-notes-1234.md", "depends/README.md"):
18+
with self.subTest(path=path):
19+
self.assertTrue(MODULE.is_excluded_path(path))
20+
21+
def test_static_documentation_files_are_excluded(self):
22+
for path in (
23+
"doc/assets/diagram.png",
24+
"doc/assets/diagram.svg",
25+
"doc/man/dashd.1",
26+
"doc/notes.txt",
27+
):
28+
with self.subTest(path=path):
29+
self.assertTrue(MODULE.is_excluded_path(path))
30+
31+
def test_documentation_build_inputs_are_not_excluded(self):
32+
for path in ("doc/Doxyfile.in", "doc/man/Makefile.am"):
33+
with self.subTest(path=path):
34+
self.assertFalse(MODULE.is_excluded_path(path))
35+
36+
def test_repository_templates_and_license_are_excluded(self):
37+
for path in (
38+
".github/ISSUE_TEMPLATE/config.yml",
39+
".github/PULL_REQUEST_TEMPLATE/release.md",
40+
".github/PULL_REQUEST_TEMPLATE.md",
41+
"COPYING",
42+
):
43+
with self.subTest(path=path):
44+
self.assertTrue(MODULE.is_excluded_path(path))
45+
46+
def test_build_relevant_and_unknown_paths_require_tests(self):
47+
for path in (
48+
"src/net.cpp",
49+
"test/functional/p2p_invalid_messages.py",
50+
"depends/packages/boost.mk",
51+
"ci/dash/matrix.sh",
52+
".github/workflows/build.yml",
53+
"configure.ac",
54+
"new-area/README.txt",
55+
):
56+
with self.subTest(path=path):
57+
self.assertFalse(MODULE.is_excluded_path(path))
58+
59+
def test_only_excluded_paths_skip_builds_and_tests(self):
60+
result = MODULE.classify_changes(
61+
["README.md", "doc/man/dashd.1", ".github/ISSUE_TEMPLATE/config.yml"],
62+
complete=True,
63+
)
64+
65+
self.assertFalse(result["run_build_tests"])
66+
self.assertEqual(result["triggering_paths"], [])
67+
68+
def test_mixed_changes_require_builds_and_tests(self):
69+
result = MODULE.classify_changes(
70+
["doc/release-notes-1234.md", "src/net.cpp"], complete=True
71+
)
72+
73+
self.assertTrue(result["run_build_tests"])
74+
self.assertEqual(result["triggering_paths"], ["src/net.cpp"])
75+
76+
def test_previous_rename_path_can_require_builds_and_tests(self):
77+
result = MODULE.classify_changes(
78+
["doc/retired-code.md", "src/retired-code.cpp"], complete=True
79+
)
80+
81+
self.assertTrue(result["run_build_tests"])
82+
self.assertEqual(result["triggering_paths"], ["src/retired-code.cpp"])
83+
84+
def test_incomplete_or_empty_path_lists_fail_open(self):
85+
for paths, complete in ((["README.md"], False), ([], True)):
86+
with self.subTest(paths=paths, complete=complete):
87+
result = MODULE.classify_changes(paths, complete)
88+
self.assertTrue(result["run_build_tests"])
89+
90+
def test_load_paths_rejects_malformed_input(self):
91+
with tempfile.TemporaryDirectory() as directory:
92+
path = pathlib.Path(directory) / "paths.json"
93+
path.write_text(json.dumps({"path": "README.md"}), encoding="utf-8")
94+
95+
with self.assertRaisesRegex(ValueError, "JSON array of strings"):
96+
MODULE.load_paths(str(path))
97+
98+
99+
if __name__ == "__main__":
100+
unittest.main()

0 commit comments

Comments
 (0)