Skip to content

Commit 2e2e88d

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

3 files changed

Lines changed: 345 additions & 6 deletions

File tree

‎.github/workflows/build.yml‎

Lines changed: 98 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,102 @@ 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 after = context.payload.after;
82+
if (context.ref.startsWith('refs/tags/')) {
83+
complete = false;
84+
} else {
85+
// Comparing with the last successful run preserves coverage when a
86+
// later push cancels an in-flight run before its build matrix finishes.
87+
const previousRuns = await github.rest.actions.listWorkflowRuns({
88+
owner: context.repo.owner,
89+
repo: context.repo.repo,
90+
workflow_id: 'build.yml',
91+
branch: context.ref.replace('refs/heads/', ''),
92+
event: 'push',
93+
status: 'success',
94+
per_page: 1,
95+
});
96+
const previousSha = previousRuns.data.workflow_runs[0]?.head_sha;
97+
if (!previousSha) {
98+
complete = false;
99+
} else {
100+
const comparison = await github.rest.repos.compareCommitsWithBasehead({
101+
owner: context.repo.owner,
102+
repo: context.repo.repo,
103+
basehead: `${previousSha}...${after}`,
104+
});
105+
files = comparison.data.files || [];
106+
if (files.length >= 300) {
107+
complete = false;
108+
core.warning('Push file list reached the GitHub API limit');
109+
}
110+
}
111+
}
112+
} else {
113+
complete = false;
114+
}
115+
} catch (error) {
116+
complete = false;
117+
core.warning(`Unable to collect a complete changed path list: ${error.message}`);
118+
}
119+
120+
const paths = [...new Set(files.flatMap(file =>
121+
[file.filename, file.previous_filename].filter(Boolean)
122+
))].sort();
123+
fs.writeFileSync(process.env.CHANGED_PATHS_FILE, JSON.stringify(paths));
124+
core.setOutput('complete', complete.toString());
125+
126+
- name: Classify changed paths
127+
id: classify-changes
128+
if: ${{ steps.skip-check.outputs.skip == 'false' }}
129+
env:
130+
CHANGED_PATHS_COMPLETE: ${{ steps.changed-paths.outputs.complete }}
131+
CHANGED_PATHS_FILE: ${{ runner.temp }}/changed-paths.json
132+
run: |
133+
if ! python3 .github/workflows/classify_ci_changes.py \
134+
--complete "$CHANGED_PATHS_COMPLETE" "$CHANGED_PATHS_FILE"; then
135+
echo "Path classification failed; running the full build and test matrix"
136+
echo "run-build-tests=true" >> "$GITHUB_OUTPUT"
137+
echo "decision-reason=path classification failed" >> "$GITHUB_OUTPUT"
138+
fi
139+
54140
- name: Select runners
55141
id: select-runner
56142
if: ${{ steps.skip-check.outputs.skip == 'false' }}
@@ -65,7 +151,9 @@ jobs:
65151
66152
- name: Get base image digest
67153
id: base-image
68-
if: ${{ steps.skip-check.outputs.skip == 'false' }}
154+
if: |
155+
steps.skip-check.outputs.skip == 'false' &&
156+
steps.classify-changes.outputs.run-build-tests == 'true'
69157
run: |
70158
# Fetch the canonical manifest digest for ubuntu:noble so the
71159
# depends cache key changes when Canonical pushes base image
@@ -86,15 +174,19 @@ jobs:
86174
cache-sources:
87175
name: Cache depends sources
88176
needs: [check-skip]
89-
if: ${{ needs.check-skip.outputs.skip == 'false' }}
177+
if: |
178+
needs.check-skip.outputs.skip == 'false' &&
179+
needs.check-skip.outputs.run-build-tests == 'true'
90180
uses: ./.github/workflows/cache-depends-sources.yml
91181
with:
92182
runs-on: ${{ needs.check-skip.outputs['runner-arm64'] }}
93183

94184
container:
95185
name: Build container
96186
needs: [check-skip]
97-
if: ${{ needs.check-skip.outputs.skip == 'false' }}
187+
if: |
188+
needs.check-skip.outputs.skip == 'false' &&
189+
needs.check-skip.outputs.run-build-tests == 'true'
98190
uses: ./.github/workflows/build-container.yml
99191
with:
100192
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+
"doc/.gitignore",
14+
}
15+
EXCLUDED_PREFIXES = (
16+
".github/ISSUE_TEMPLATE/",
17+
".github/PULL_REQUEST_TEMPLATE/",
18+
)
19+
EXCLUDED_DOC_SUFFIXES = {".1", ".5", ".8", ".png", ".svg", ".txt"}
20+
BUILD_RELEVANT_PATHS = {"COPYING", "doc/README_windows.txt"}
21+
22+
23+
def is_excluded_path(path: str) -> bool:
24+
if not path or path in BUILD_RELEVANT_PATHS:
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:]))

0 commit comments

Comments
 (0)