Skip to content

Commit df01070

Browse files
authored
Merge pull request #6461 from hpcaitech/ci/fork-cpu-ci
[ci]: add fork-safe lint and CPU PR gate
2 parents 99e44f1 + 5adcf44 commit df01070

15 files changed

Lines changed: 479 additions & 12 deletions
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
#!/usr/bin/env python3
2+
"""Validate the repository's fork-safe pull request runner policy."""
3+
4+
from __future__ import annotations
5+
6+
import re
7+
import sys
8+
from pathlib import Path
9+
from typing import Any, Iterable
10+
11+
import yaml
12+
13+
GITHUB_HOSTED_LABEL = re.compile(r"^(ubuntu|windows|macos)-")
14+
HEAD_REPOSITORY = "github.event.pull_request.head.repo.full_name"
15+
CURRENT_REPOSITORY = "github.repository"
16+
MERGED_PULL_REQUEST = "github.event.pull_request.merged == true"
17+
18+
19+
def load_workflow(path: Path) -> dict[str, Any]:
20+
with path.open(encoding="utf-8") as file:
21+
workflow = yaml.load(file, Loader=yaml.BaseLoader)
22+
if not isinstance(workflow, dict):
23+
raise ValueError("workflow root must be a mapping")
24+
return workflow
25+
26+
27+
def event_enabled(workflow: dict[str, Any], event: str) -> bool:
28+
triggers = workflow.get("on")
29+
if isinstance(triggers, str):
30+
return triggers == event
31+
if isinstance(triggers, list):
32+
return event in triggers
33+
if isinstance(triggers, dict):
34+
return event in triggers
35+
return False
36+
37+
38+
def runner_labels(runs_on: Any) -> list[str]:
39+
if isinstance(runs_on, str):
40+
return [runs_on]
41+
if isinstance(runs_on, list):
42+
return [str(label) for label in runs_on]
43+
return []
44+
45+
46+
def is_github_hosted(runs_on: Any) -> bool:
47+
labels = runner_labels(runs_on)
48+
return len(labels) == 1 and GITHUB_HOSTED_LABEL.match(labels[0]) is not None
49+
50+
51+
def workflow_paths(workflows_dir: Path) -> Iterable[Path]:
52+
return sorted((*workflows_dir.glob("*.yml"), *workflows_dir.glob("*.yaml")))
53+
54+
55+
def validate_private_pr_runners(workflows_dir: Path) -> list[str]:
56+
errors: list[str] = []
57+
for path in workflow_paths(workflows_dir):
58+
try:
59+
workflow = load_workflow(path)
60+
except (OSError, ValueError, yaml.YAMLError) as error:
61+
errors.append(f"{path.name}: cannot parse workflow: {error}")
62+
continue
63+
if not event_enabled(workflow, "pull_request"):
64+
continue
65+
jobs = workflow.get("jobs", {})
66+
if not isinstance(jobs, dict):
67+
errors.append(f"{path.name}: jobs must be a mapping")
68+
continue
69+
for job_id, job in jobs.items():
70+
if not isinstance(job, dict) or is_github_hosted(job.get("runs-on")):
71+
continue
72+
condition = str(job.get("if", ""))
73+
guarded_head = HEAD_REPOSITORY in condition and CURRENT_REPOSITORY in condition
74+
trusted_after_merge = MERGED_PULL_REQUEST in condition
75+
if not guarded_head and not trusted_after_merge:
76+
errors.append(
77+
f"{path.name}:{job_id}: a private pull_request runner must be limited "
78+
"to a head repository matching github.repository or an already merged PR"
79+
)
80+
return errors
81+
82+
83+
def validate_fork_workflow(path: Path) -> list[str]:
84+
errors: list[str] = []
85+
try:
86+
workflow = load_workflow(path)
87+
except (OSError, ValueError, yaml.YAMLError) as error:
88+
return [f"{path.name}: cannot parse workflow: {error}"]
89+
90+
if not event_enabled(workflow, "pull_request"):
91+
errors.append(f"{path.name}: must use the pull_request event")
92+
if event_enabled(workflow, "pull_request_target"):
93+
errors.append(f"{path.name}: must not use pull_request_target")
94+
95+
permissions = workflow.get("permissions")
96+
if not isinstance(permissions, dict) or permissions.get("contents") != "read":
97+
errors.append(f"{path.name}: top-level permissions must include contents: read")
98+
elif any(value not in {"read", "none"} for value in permissions.values()):
99+
errors.append(f"{path.name}: fork-safe permissions must be read-only")
100+
101+
raw_workflow = path.read_text(encoding="utf-8")
102+
if re.search(r"\bsecrets\.", raw_workflow):
103+
errors.append(f"{path.name}: fork-safe workflow must not reference secrets")
104+
105+
jobs = workflow.get("jobs", {})
106+
required_jobs = {"lint", "cpu-unit"}
107+
if not isinstance(jobs, dict):
108+
return [*errors, f"{path.name}: jobs must be a mapping"]
109+
if not required_jobs.issubset(jobs):
110+
errors.append(f"{path.name}: must define lint and cpu-unit jobs")
111+
for job_id, job in jobs.items():
112+
if not isinstance(job, dict):
113+
errors.append(f"{path.name}:{job_id}: job must be a mapping")
114+
continue
115+
if not is_github_hosted(job.get("runs-on")):
116+
errors.append(f"{path.name}:{job_id}: fork-safe jobs must use a GitHub-hosted runner")
117+
steps = job.get("steps", [])
118+
if not isinstance(steps, list):
119+
errors.append(f"{path.name}:{job_id}: steps must be a list")
120+
continue
121+
for step in steps:
122+
if not isinstance(step, dict) or not str(step.get("uses", "")).startswith("actions/checkout@"):
123+
continue
124+
inputs = step.get("with", {})
125+
if not isinstance(inputs, dict) or inputs.get("persist-credentials") != "false":
126+
errors.append(f"{path.name}:{job_id}: checkout must set persist-credentials: false")
127+
return errors
128+
129+
130+
def validate_repository(repository: Path) -> list[str]:
131+
workflows_dir = repository / ".github" / "workflows"
132+
fork_workflow = workflows_dir / "fork_pr_checks.yml"
133+
return [
134+
*validate_private_pr_runners(workflows_dir),
135+
*validate_fork_workflow(fork_workflow),
136+
]
137+
138+
139+
def main() -> int:
140+
repository = Path(__file__).resolve().parents[3]
141+
errors = validate_repository(repository)
142+
if errors:
143+
print("Pull request runner policy violations:", file=sys.stderr)
144+
for error in errors:
145+
print(f"- {error}", file=sys.stderr)
146+
return 1
147+
print("Pull request runner policy is valid.")
148+
return 0
149+
150+
151+
if __name__ == "__main__":
152+
raise SystemExit(main())
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#!/usr/bin/env bash
2+
3+
set -Eeuo pipefail
4+
5+
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
6+
REPO="${COLOSSAL_REPO:-$(cd -- "${SCRIPT_DIR}/../../.." && pwd)}"
7+
RESULTS_ROOT="${COLOSSAL_RESULTS:-${RUNNER_TEMP:-/tmp}/colossalai-fork-pr-results}"
8+
PYTHON="${COLOSSAL_PYTHON:-python}"
9+
10+
TESTS=(
11+
.github/workflows/scripts/example_checks/test_run_test_ci.py
12+
tests/test_config/test_load_config.py
13+
tests/test_infer/test_async_engine/test_request_tracer.py
14+
tests/test_optimizer/test_lr_scheduler.py
15+
tests/test_pipeline/test_pipeline_utils/test_t5_pipeline_utils.py
16+
tests/test_pipeline/test_pipeline_utils/test_whisper_pipeline_utils.py
17+
tests/test_pipeline/test_schedule/test_pipeline_schedule_utils.py
18+
tests/test_tensor/test_dtensor/test_dtensor_sharding_spec.py
19+
tests/test_tensor/test_shape_consistency.py
20+
tests/test_tensor/test_sharding_spec.py
21+
)
22+
23+
if [[ ! -d "${REPO}/tests" ]]; then
24+
printf 'Repository not found: %s\n' "${REPO}" >&2
25+
exit 2
26+
fi
27+
28+
if ! command -v "${PYTHON}" >/dev/null 2>&1; then
29+
printf 'Python executable not found: %s\n' "${PYTHON}" >&2
30+
exit 2
31+
fi
32+
33+
export CUDA_VISIBLE_DEVICES=""
34+
export FAST_TEST="${FAST_MODE:-1}"
35+
export PYTHONPATH="${REPO}${PYTHONPATH:+:${PYTHONPATH}}"
36+
37+
mkdir -p "${RESULTS_ROOT}"
38+
cd "${REPO}"
39+
40+
"${PYTHON}" - <<'PY'
41+
import os
42+
import sys
43+
44+
import torch
45+
46+
print("python:", sys.executable)
47+
print("torch:", torch.__version__)
48+
print("torch CUDA:", torch.version.cuda)
49+
print("CUDA_VISIBLE_DEVICES:", os.environ.get("CUDA_VISIBLE_DEVICES"))
50+
print("visible GPU count:", torch.cuda.device_count())
51+
assert not torch.cuda.is_available(), "CPU gate unexpectedly has CUDA access"
52+
assert torch.cuda.device_count() == 0
53+
PY
54+
55+
RESULT_PREFIX="${RESULTS_ROOT}/cpu-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}"
56+
set +e
57+
timeout --signal=TERM --kill-after=60s 15m \
58+
"${PYTHON}" -m pytest \
59+
-v -ra --tb=short \
60+
--maxfail=10 \
61+
--durations=20 \
62+
--junitxml="${RESULT_PREFIX}.xml" \
63+
"${TESTS[@]}" \
64+
2>&1 | tee "${RESULT_PREFIX}.log"
65+
PYTEST_STATUS="${PIPESTATUS[0]}"
66+
set -e
67+
68+
printf 'pytest_exit_code=%s\n' "${PYTEST_STATUS}"
69+
exit "${PYTEST_STATUS}"
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import tempfile
2+
import textwrap
3+
import unittest
4+
from pathlib import Path
5+
6+
from check_pr_runner_policy import validate_fork_workflow, validate_private_pr_runners
7+
8+
9+
class PullRequestRunnerPolicyTest(unittest.TestCase):
10+
def write_workflow(self, directory: Path, name: str, source: str) -> Path:
11+
path = directory / name
12+
path.write_text(textwrap.dedent(source), encoding="utf-8")
13+
return path
14+
15+
def test_private_pull_request_runner_requires_head_repository_guard(self):
16+
with tempfile.TemporaryDirectory() as temp_dir:
17+
workflows = Path(temp_dir)
18+
self.write_workflow(
19+
workflows,
20+
"unsafe.yml",
21+
"""
22+
on: pull_request
23+
jobs:
24+
test:
25+
runs-on: [self-hosted, linux]
26+
steps: []
27+
""",
28+
)
29+
30+
errors = validate_private_pr_runners(workflows)
31+
32+
self.assertEqual(len(errors), 1)
33+
self.assertIn("unsafe.yml:test", errors[0])
34+
35+
def test_guarded_private_pull_request_runner_is_valid(self):
36+
with tempfile.TemporaryDirectory() as temp_dir:
37+
workflows = Path(temp_dir)
38+
self.write_workflow(
39+
workflows,
40+
"guarded.yml",
41+
"""
42+
on:
43+
workflow_dispatch:
44+
pull_request:
45+
jobs:
46+
test:
47+
if: >-
48+
github.event_name != 'pull_request' ||
49+
github.event.pull_request.head.repo.full_name == github.repository
50+
runs-on: project-runner
51+
steps: []
52+
""",
53+
)
54+
55+
errors = validate_private_pr_runners(workflows)
56+
57+
self.assertEqual(errors, [])
58+
59+
def test_non_pull_request_private_runner_is_ignored(self):
60+
with tempfile.TemporaryDirectory() as temp_dir:
61+
workflows = Path(temp_dir)
62+
self.write_workflow(
63+
workflows,
64+
"schedule.yml",
65+
"""
66+
on: workflow_dispatch
67+
jobs:
68+
test:
69+
runs-on: self-hosted
70+
steps: []
71+
""",
72+
)
73+
74+
errors = validate_private_pr_runners(workflows)
75+
76+
self.assertEqual(errors, [])
77+
78+
def test_private_runner_after_merge_is_valid(self):
79+
with tempfile.TemporaryDirectory() as temp_dir:
80+
workflows = Path(temp_dir)
81+
self.write_workflow(
82+
workflows,
83+
"release.yml",
84+
"""
85+
on:
86+
pull_request:
87+
types: [closed]
88+
jobs:
89+
release:
90+
if: github.event.pull_request.merged == true
91+
runs-on: self-hosted
92+
steps: []
93+
""",
94+
)
95+
96+
errors = validate_private_pr_runners(workflows)
97+
98+
self.assertEqual(errors, [])
99+
100+
def test_fork_workflow_rejects_private_runner_and_secrets(self):
101+
with tempfile.TemporaryDirectory() as temp_dir:
102+
workflow = self.write_workflow(
103+
Path(temp_dir),
104+
"fork_pr_checks.yml",
105+
"""
106+
on: pull_request
107+
permissions:
108+
contents: write
109+
jobs:
110+
lint:
111+
runs-on: self-hosted
112+
steps:
113+
- uses: actions/checkout@v7
114+
cpu-unit:
115+
runs-on: ubuntu-24.04
116+
steps:
117+
- run: echo '${{ secrets.TOKEN }}'
118+
""",
119+
)
120+
121+
errors = validate_fork_workflow(workflow)
122+
123+
self.assertTrue(any("contents: read" in error for error in errors))
124+
self.assertTrue(any("must not reference secrets" in error for error in errors))
125+
self.assertTrue(any("GitHub-hosted" in error for error in errors))
126+
self.assertTrue(any("persist-credentials" in error for error in errors))
127+
128+
129+
if __name__ == "__main__":
130+
unittest.main()

‎.github/workflows/README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- [Table of Contents](#table-of-contents)
77
- [Overview](#overview)
88
- [Workflows](#workflows)
9+
- [Fork-safe Pull Request Checks](#fork-safe-pull-request-checks)
910
- [Code Style Check](#code-style-check)
1011
- [Unit Test](#unit-test)
1112
- [Example Test](#example-test)
@@ -32,6 +33,22 @@ I will provide the details of each workflow below.
3233

3334
**A PR which changes the `version.txt` is considered as a release PR in the following context.**
3435

36+
### Fork-safe Pull Request Checks
37+
38+
| Workflow Name | File name | Description |
39+
| ------------------------ | -------------------- | --------------------------------------------------------------------------------------------------- |
40+
| `CI / Fork-safe PR Gate` | `fork_pr_checks.yml` | Runs repository lint and selected CPU unit tests on isolated GitHub-hosted runners for every PR. |
41+
42+
The workflow uses the `pull_request` event, a read-only token, no secrets, and no GPU or self-hosted
43+
runner. Results are reported on the upstream pull request's Checks page, including pull requests from
44+
forks. GitHub may require a maintainer to approve the first workflow run from an outside contributor.
45+
46+
Existing jobs that execute unmerged code on GPU or private runners are limited to branches in this repository. Because a
47+
`pull_request` workflow definition is itself part of the untrusted pull request merge commit, that
48+
condition is defense in depth rather than an administrative security boundary. Repository owners must
49+
also enforce organization or enterprise Actions policies, or restrict self-hosted runner groups to
50+
approved workflows, in GitHub settings.
51+
3552

3653
### Code Style Check
3754

0 commit comments

Comments
 (0)