ci(build): run pull request jobs only when their files change
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
9aa5fd2900
commit
d9d00f0e34
|
|
@ -39,6 +39,8 @@ jobs:
|
|||
pr: ${{ steps.gate.outputs.pr }}
|
||||
head: ${{ steps.gate.outputs.head }}
|
||||
relinker: ${{ steps.gate.outputs.relinker }}
|
||||
register-context: ${{ steps.gate.outputs.register-context }}
|
||||
write-watch: ${{ steps.gate.outputs.write-watch }}
|
||||
full: ${{ steps.gate.outputs.full }}
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
|
|
@ -55,6 +57,8 @@ jobs:
|
|||
core.setOutput('pr', '');
|
||||
core.setOutput('head', '');
|
||||
core.setOutput('relinker', true);
|
||||
core.setOutput('register-context', true);
|
||||
core.setOutput('write-watch', true);
|
||||
core.setOutput('full', true);
|
||||
await core.summary.addRaw(`Building main at ${context.sha}: all builds and tests enabled`).write();
|
||||
return;
|
||||
|
|
@ -97,16 +101,32 @@ jobs:
|
|||
});
|
||||
relinker = commits.length > 0;
|
||||
}
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number, per_page: 100 });
|
||||
const paths = files.flatMap((file) => [file.filename, file.previous_filename].filter(Boolean));
|
||||
const touches = (...prefixes) => manual || files.length >= 3000 ||
|
||||
paths.some((path) => prefixes.some((prefix) => prefix.endsWith('/') ? path.startsWith(prefix) : path === prefix));
|
||||
const workflow = '.github/workflows/build.yml';
|
||||
const libc = 'core/libs/prx/libc/';
|
||||
relinker = relinker && touches(
|
||||
'core/relinker/', libc, 'core/libs/prx/libkernel/AppMetadata/', 'core/libs/tests/ParamJsonParser.cpp',
|
||||
'core/shader/recompiler/tests/CacheFileReadTests.cpp', 'core/shader/recompiler/ShaderCacheDirectory.hpp',
|
||||
'tools/', 'cmake/', 'CMakeLists.txt', workflow
|
||||
);
|
||||
const registerContext = touches('core/libs/tests/register_context/', 'core/libs/tests/RegisterContext.cpp', libc, workflow);
|
||||
const writeWatch = touches('core/libs/tests/write_watch/', 'core/libs/tests/WriteWatch.cpp', libc, workflow);
|
||||
const { data: main } = await github.rest.repos.getBranch({ owner, repo, branch: 'main' });
|
||||
core.setOutput('sha', main.commit.sha);
|
||||
core.setOutput('pr', String(pull_number));
|
||||
core.setOutput('head', pr.head.sha);
|
||||
core.setOutput('relinker', relinker);
|
||||
core.setOutput('register-context', registerContext);
|
||||
core.setOutput('write-watch', writeWatch);
|
||||
core.setOutput('full', manual);
|
||||
core.info(`PR #${pull_number} at ${pr.head.sha} merged into main at ${main.commit.sha}`);
|
||||
await core.summary.addRaw(
|
||||
`PR #${pull_number} at ${pr.head.sha} merged into main at ${main.commit.sha}: full builds ${manual ? 'enabled' : 'disabled'}, ` +
|
||||
`relinker ${relinker ? 'enabled' : 'skipped because the author has no commit in main'}`
|
||||
`relinker ${relinker ? 'enabled' : 'skipped: no relinker file changed or the author has no commit in main'}, ` +
|
||||
`register context tests ${registerContext ? 'enabled' : 'skipped'}, write watch tests ${writeWatch ? 'enabled' : 'skipped'}`
|
||||
).write();
|
||||
|
||||
relinker:
|
||||
|
|
@ -162,6 +182,8 @@ jobs:
|
|||
- run: ctest --test-dir build-relinker --output-on-failure --timeout 120
|
||||
|
||||
register-context:
|
||||
needs: eligibility
|
||||
if: ${{ needs.eligibility.outputs.register-context == 'true' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
|
@ -176,6 +198,8 @@ jobs:
|
|||
- run: ctest --test-dir build/register-context --output-on-failure --repeat until-fail:3 --no-tests=error
|
||||
|
||||
write-watch:
|
||||
needs: eligibility
|
||||
if: ${{ needs.eligibility.outputs.write-watch == 'true' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ if(BUILD_TESTING)
|
|||
${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_check_conventions_comments.py)
|
||||
add_test(NAME conventions_wrapped_declarations COMMAND ${Python3_EXECUTABLE}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_check_conventions_wrapped_declarations.py)
|
||||
add_test(NAME conventions_ci_gate COMMAND ${Python3_EXECUTABLE}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_check_conventions_ci_gate.py)
|
||||
add_test(NAME test_registration COMMAND ${Python3_EXECUTABLE}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_test_registration.py)
|
||||
add_test(NAME check_skips COMMAND ${Python3_EXECUTABLE}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ For pull requests, full Linux and Windows builds and tests run only when a colla
|
|||
gh workflow run build.yml --repo boykopovar/AnyPS5 --ref main --field pr=1234
|
||||
```
|
||||
|
||||
This manual run also builds and tests the relinker on Linux, Windows and macOS, regardless of the author's previous contributions or the changed files. The relinker matrix also runs automatically when a pull request targeting `main` is opened, updated or reopened, if its author already has a commit in `main`; full builds never run automatically for pull requests. All build jobs build the selected pull request merged into the current `main` (GitHub's merge commit), so a pull request that no longer merges cleanly must be rebased first. New commits require another manual run for full builds.
|
||||
This manual run also builds and tests the relinker on Linux, Windows and macOS, regardless of the author's previous contributions or the changed files. The relinker matrix also runs automatically when a pull request targeting `main` is opened, updated or reopened, if its author already has a commit in `main` and it changes a file the relinker-only build uses; full builds never run automatically for pull requests. Every job that runs automatically for pull requests runs only when the pull request changes its files: a `paths` filter on the `pull_request` trigger, or `needs: eligibility` with an `if` on the files output it sets in `build.yml`. The Conventions check fails a workflow change that adds a job without one. All build jobs build the selected pull request merged into the current `main` (GitHub's merge commit), so a pull request that no longer merges cleanly must be rebased first. New commits require another manual run for full builds.
|
||||
|
||||
The Conventions check runs automatically for every contributor, including first-time contributors. It uses the base branch checker and reads pull request Git objects without executing pull request code. The Conventions check runs on every pull request and fails when a rule on this page is broken. It accepts code comments only when the pull request also changes [TechnicalDebt](docs/dev/TechnicalDebt.md), and only UTF-8 text files. Run it locally before pushing:
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ RULES = {
|
|||
"extension": ("error", "CONTRIBUTING.md#code", "Avoid non-standard extensions where standard C++ is enough"),
|
||||
"notes-file": ("error", "CONTRIBUTING.md#branches-and-pull-requests", "Notes, investigation and agent files go in the pull request, not in the repository"),
|
||||
"binary": ("error", "docs/dev/CONVENTIONS.md", "Only UTF-8 text files in the repository; images go in the gist comments"),
|
||||
"ci-gate": ("error", "CONTRIBUTING.md#build-and-test", "A job that runs for pull requests needs a file filter: paths on the pull_request trigger, or needs eligibility and an if on its outputs"),
|
||||
"doc-link": ("error", "CONTRIBUTING.md#documentation", "Relative link to a file that does not exist"),
|
||||
"commit-subject": ("error", "docs/dev/CONVENTIONS.md", "Commit subject is not Conventional Commits"),
|
||||
"pr-title": ("error", "docs/dev/CONVENTIONS.md", "Pull request title is not Conventional Commits"),
|
||||
|
|
@ -41,6 +42,7 @@ TITLE = re.compile(r"\b(PPSA|CUSA)\d{5}\b")
|
|||
EXTENSION = re.compile(r"__attribute__|__declspec|#\s*pragma\s+(?!once\b)")
|
||||
LINK = re.compile(r"\]\(([^)\s]+)")
|
||||
SECTION = re.compile(r"^###\s*(.+?)\s*$", re.M)
|
||||
KEY = re.compile(r"^( *)([A-Za-z0-9_-]+|'[^']*'|\"[^\"]*\"):(?:\s+(.*?))?\s*$")
|
||||
|
||||
|
||||
def git(*args, text=True):
|
||||
|
|
@ -174,6 +176,20 @@ class Check:
|
|||
if resolved not in self.tree and resolved not in self.dirs:
|
||||
self.report("doc-link", path, number, target)
|
||||
|
||||
def workflows(self):
|
||||
for path in sorted(self.status):
|
||||
if self.status[path] == "D" or not re.fullmatch(r"\.github/workflows/[^/]+\.ya?ml", path):
|
||||
continue
|
||||
source = self.show(path).splitlines()
|
||||
lines = [(number, KEY.match(re.sub(r"(^|\s)#.*", "", text))) for number, text in enumerate(source, 1)]
|
||||
lines = [(number, len(match[1]), match[2].strip("'\""), match[3] or "") for number, match in lines if match]
|
||||
triggers = [entry for entry in lines if entry[1] == 0 and entry[2] == "on"]
|
||||
if not triggers or pull_request_filtered(lines, triggers[0]):
|
||||
continue
|
||||
for number, name, needs, condition in jobs(lines, source):
|
||||
if name != "eligibility" and ("eligibility" not in needs or "needs.eligibility.outputs." not in condition):
|
||||
self.report("ci-gate", path, number, name)
|
||||
|
||||
def commits(self):
|
||||
for line in git("log", "--no-merges", "--format=%h %s", f"{self.base}..{self.head}").splitlines():
|
||||
sha, subject = line.split(" ", 1)
|
||||
|
|
@ -232,6 +248,48 @@ def silent_stub_debt(text):
|
|||
return debt
|
||||
|
||||
|
||||
def block(lines, start):
|
||||
_, indent, _, _ = lines[start]
|
||||
end = start + 1
|
||||
while end < len(lines) and lines[end][1] > indent:
|
||||
end += 1
|
||||
return lines[start + 1:end]
|
||||
|
||||
|
||||
def pull_request_filtered(lines, trigger):
|
||||
_, _, _, value = trigger
|
||||
if value:
|
||||
return "pull_request" not in re.findall(r"[A-Za-z_]+", value)
|
||||
triggers = block(lines, lines.index(trigger))
|
||||
for index, (_, indent, name, value) in enumerate(triggers):
|
||||
if name == "pull_request" and indent == triggers[0][1]:
|
||||
return any(entry[2] in ("paths", "paths-ignore") for entry in block(triggers, index))
|
||||
return True
|
||||
|
||||
|
||||
def field(source, entry):
|
||||
number, indent, _, value = entry
|
||||
for line in (line for line in source[number:] if line.strip()):
|
||||
if len(line) - len(line.lstrip(" ")) <= indent:
|
||||
break
|
||||
value += " " + line.strip()
|
||||
return value
|
||||
|
||||
|
||||
def jobs(lines, source):
|
||||
top = [index for index, entry in enumerate(lines) if entry[1] == 0 and entry[2] == "jobs"]
|
||||
if not top:
|
||||
return []
|
||||
entries = block(lines, top[0])
|
||||
result = []
|
||||
for index, (number, indent, name, _) in enumerate(entries):
|
||||
if indent != entries[0][1]:
|
||||
continue
|
||||
fields = {entry[2]: field(source, entry) for entry in block(entries, index) if entry[1] == indent + 2}
|
||||
result.append((number, name, re.findall(r"[A-Za-z0-9_-]+", fields.get("needs", "")), fields.get("if", "")))
|
||||
return result
|
||||
|
||||
|
||||
def listed(debt, name, path):
|
||||
own = PurePosixPath(path).parts[:4]
|
||||
for text, targets in debt:
|
||||
|
|
@ -314,6 +372,7 @@ if __name__ == "__main__":
|
|||
check.build()
|
||||
check.repository()
|
||||
check.docs()
|
||||
check.workflows()
|
||||
check.commits()
|
||||
pr = json.load(open(args.event, encoding="utf-8")).get("pull_request") if args.event else None
|
||||
if pr:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
CHECKER = Path(__file__).resolve().parents[1] / "check_conventions.py"
|
||||
WORKFLOWS = Path(__file__).resolve().parents[2] / ".github/workflows"
|
||||
|
||||
GATE = """ eligibility:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tests: ${{ steps.gate.outputs.tests }}
|
||||
steps:
|
||||
- run: echo
|
||||
"""
|
||||
|
||||
|
||||
def workflow(trigger, job):
|
||||
return f"name: Test\n\non:\n{trigger}\njobs:\n{GATE}{job}"
|
||||
|
||||
|
||||
PULL_REQUEST = " pull_request:\n branches: [main]\n"
|
||||
|
||||
|
||||
class CiGateTests(unittest.TestCase):
|
||||
def check(self, files):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
|
||||
def git(*args):
|
||||
return subprocess.run(
|
||||
["git", "-c", f"safe.directory={root}", "-c", "user.name=Conventions Test",
|
||||
"-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", *args],
|
||||
cwd=root, capture_output=True, text=True, check=True,
|
||||
)
|
||||
|
||||
git("init", "-q")
|
||||
(root / "README.md").write_text("readme\n", encoding="utf-8")
|
||||
git("add", ".")
|
||||
git("commit", "-qm", "docs: add readme")
|
||||
for name, text in files.items():
|
||||
path = root / ".github/workflows" / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
git("add", ".")
|
||||
git("commit", "-qm", "ci: add workflow")
|
||||
env = {key: value for key, value in os.environ.items()
|
||||
if key not in ("GITHUB_EVENT_PATH", "GITHUB_STEP_SUMMARY", "GITHUB_ACTIONS")}
|
||||
env.update(GIT_CONFIG_COUNT="1", GIT_CONFIG_KEY_0="safe.directory", GIT_CONFIG_VALUE_0=str(root))
|
||||
return subprocess.run(
|
||||
[sys.executable, "-I", str(CHECKER), "--base", "HEAD~1", "--head", "HEAD"],
|
||||
cwd=root, capture_output=True, text=True, env=env, timeout=20,
|
||||
)
|
||||
|
||||
def assertGated(self, text):
|
||||
result = self.check({"build.yml": text})
|
||||
self.assertNotIn("ci-gate", result.stdout, result.stdout + result.stderr)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def assertUngated(self, text, job):
|
||||
result = self.check({"build.yml": text})
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
self.assertIn("[ci-gate]", result.stdout)
|
||||
self.assertIn(job, result.stdout)
|
||||
|
||||
def test_job_without_gate_fails(self):
|
||||
self.assertUngated(workflow(PULL_REQUEST, " tests:\n runs-on: ubuntu-latest\n steps:\n - run: make\n"), "tests")
|
||||
|
||||
def test_job_needing_the_gate_without_if_fails(self):
|
||||
self.assertUngated(workflow(PULL_REQUEST, " tests:\n needs: eligibility\n runs-on: ubuntu-latest\n"), "tests")
|
||||
|
||||
def test_if_on_another_condition_fails(self):
|
||||
self.assertUngated(workflow(PULL_REQUEST, " tests:\n needs: eligibility\n if: github.event_name == 'pull_request'\n"), "tests")
|
||||
|
||||
def test_gated_job_passes(self):
|
||||
self.assertGated(workflow(PULL_REQUEST, " tests:\n needs: eligibility\n if: ${{ needs.eligibility.outputs.tests == 'true' }}\n"))
|
||||
|
||||
def test_gate_written_over_several_lines_passes(self):
|
||||
self.assertGated(workflow(PULL_REQUEST, " tests:\n needs:\n - other\n - eligibility\n if: >-\n github.event_name == 'push' ||\n needs.eligibility.outputs.tests == 'true'\n"))
|
||||
|
||||
def test_paths_filter_passes(self):
|
||||
self.assertGated(workflow(" pull_request:\n paths:\n - 'core/**'\n", " tests:\n runs-on: ubuntu-latest\n"))
|
||||
|
||||
def test_paths_of_another_trigger_do_not_count(self):
|
||||
self.assertUngated(workflow(" push:\n paths:\n - 'core/**'\n pull_request:\n branches: [main]\n", " tests:\n runs-on: ubuntu-latest\n"), "tests")
|
||||
|
||||
def test_inline_trigger_list_fails(self):
|
||||
self.assertUngated("name: Test\non: [push, pull_request]\njobs:\n tests:\n runs-on: ubuntu-latest\n", "tests")
|
||||
|
||||
def test_other_triggers_pass(self):
|
||||
self.assertGated(workflow(" pull_request_target:\n types: [opened]\n push:\n branches: [main]\n", " tests:\n runs-on: ubuntu-latest\n"))
|
||||
|
||||
def test_repository_workflows_pass(self):
|
||||
files = {path.name: path.read_text(encoding="utf-8") for path in WORKFLOWS.glob("*.y*ml")}
|
||||
self.assertIn("build.yml", files)
|
||||
result = self.check(files)
|
||||
self.assertNotIn("ci-gate", result.stdout, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in New Issue