Skip to content

Commit 3f78ce9

Browse files
authored
Unrolled build for rust-lang#134898
Rollup merge of rust-lang#134898 - Kobzol:ci-python-script, r=MarcoIeni Make it easier to run CI jobs locally This PR extends the Python CI script to perform a poor man's CI-like execution of a given CI job locally. It's not perfect, but it's better than nothing. r? `@jieyouxu`
2 parents 251206c + 65819b1 commit 3f78ce9

File tree

7 files changed

+229
-112
lines changed

7 files changed

+229
-112
lines changed

.github/workflows/ci.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# and also on pushes to special branches (auto, try).
33
#
44
# The actual definition of the executed jobs is calculated by a Python
5-
# script located at src/ci/github-actions/calculate-job-matrix.py, which
5+
# script located at src/ci/github-actions/ci.py, which
66
# uses job definition data from src/ci/github-actions/jobs.yml.
77
# You should primarily modify the `jobs.yml` file if you want to modify
88
# what jobs are executed in CI.
@@ -56,7 +56,7 @@ jobs:
5656
- name: Calculate the CI job matrix
5757
env:
5858
COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
59-
run: python3 src/ci/github-actions/calculate-job-matrix.py >> $GITHUB_OUTPUT
59+
run: python3 src/ci/github-actions/ci.py calculate-job-matrix >> $GITHUB_OUTPUT
6060
id: jobs
6161
job:
6262
name: ${{ matrix.name }}

src/ci/docker/README.md

+14-13
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,30 @@
11
# Docker images for CI
22

33
This folder contains a bunch of docker images used by the continuous integration
4-
(CI) of Rust. An script is accompanied (`run.sh`) with these images to actually
5-
execute them. To test out an image execute:
4+
(CI) of Rust. A script is accompanied (`run.sh`) with these images to actually
5+
execute them.
66

7-
```
8-
./src/ci/docker/run.sh $image_name
9-
```
7+
Note that a single Docker image can be used by multiple CI jobs, so the job name
8+
is the important thing that you should know. You can examine the existing CI jobs in
9+
the [`jobs.yml`](../github-actions/jobs.yml) file.
1010

11-
for example:
11+
To run a specific CI job locally, you can use the following script:
1212

1313
```
14-
./src/ci/docker/run.sh x86_64-gnu
14+
python3 ./src/ci/github-actions/ci.py run-local <job-name>
1515
```
1616

17-
Images will output artifacts in an `obj/$image_name` dir at the root of a repository. Note
18-
that the script will overwrite the contents of this directory.
19-
20-
To match conditions in rusts CI, also set the environment variable `DEPLOY=1`, e.g.:
17+
For example, to run the `x86_64-gnu-llvm-18-1` job:
2118
```
22-
DEPLOY=1 ./src/ci/docker/run.sh x86_64-gnu
19+
python3 ./src/ci/github-actions/ci.py run-local x86_64-gnu-llvm-18-1
2320
```
2421

22+
The job will output artifacts in an `obj/<image-name>` dir at the root of a repository. Note
23+
that the script will overwrite the contents of this directory. `<image-name>` is set based on the
24+
Docker image executed in the given CI job.
25+
2526
**NOTE**: In CI, the script outputs the artifacts to the `obj` directory,
26-
while locally, to the `obj/$image_name` directory. This is primarily to prevent
27+
while locally, to the `obj/<image-name>` directory. This is primarily to prevent
2728
strange linker errors when using multiple Docker images.
2829

2930
For some Linux workflows (for example `x86_64-gnu-llvm-18-N`), the process is more involved. You will need to see which script is executed for the given workflow inside the [`jobs.yml`](../github-actions/jobs.yml) file and pass it through the `DOCKER_SCRIPT` environment variable. For example, to reproduce the `x86_64-gnu-llvm-18-3` workflow, you can run the following script:

src/ci/github-actions/calculate-job-matrix.py renamed to src/ci/github-actions/ci.py

+127-19
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
#!/usr/bin/env python3
22

33
"""
4-
This script serves for generating a matrix of jobs that should
5-
be executed on CI.
4+
This script contains CI functionality.
5+
It can be used to generate a matrix of jobs that should
6+
be executed on CI, or run a specific CI job locally.
67
7-
It reads job definitions from `src/ci/github-actions/jobs.yml`
8-
and filters them based on the event that happened on CI.
8+
It reads job definitions from `src/ci/github-actions/jobs.yml`.
99
"""
1010

11+
import argparse
1112
import dataclasses
1213
import json
1314
import logging
1415
import os
1516
import re
17+
import subprocess
1618
import typing
1719
from pathlib import Path
1820
from typing import List, Dict, Any, Optional
@@ -25,25 +27,35 @@
2527
Job = Dict[str, Any]
2628

2729

28-
def name_jobs(jobs: List[Dict], prefix: str) -> List[Job]:
30+
def add_job_properties(jobs: List[Dict], prefix: str) -> List[Job]:
2931
"""
30-
Add a `name` attribute to each job, based on its image and the given `prefix`.
32+
Modify the `name` attribute of each job, based on its base name and the given `prefix`.
33+
Add an `image` attribute to each job, based on its image.
3134
"""
35+
modified_jobs = []
3236
for job in jobs:
33-
job["name"] = f"{prefix} - {job['image']}"
34-
return jobs
37+
# Create a copy of the `job` dictionary to avoid modifying `jobs`
38+
new_job = dict(job)
39+
new_job["image"] = get_job_image(new_job)
40+
new_job["name"] = f"{prefix} - {new_job['name']}"
41+
modified_jobs.append(new_job)
42+
return modified_jobs
3543

3644

3745
def add_base_env(jobs: List[Job], environment: Dict[str, str]) -> List[Job]:
3846
"""
3947
Prepends `environment` to the `env` attribute of each job.
4048
The `env` of each job has higher precedence than `environment`.
4149
"""
50+
modified_jobs = []
4251
for job in jobs:
4352
env = environment.copy()
4453
env.update(job.get("env", {}))
45-
job["env"] = env
46-
return jobs
54+
55+
new_job = dict(job)
56+
new_job["env"] = env
57+
modified_jobs.append(new_job)
58+
return modified_jobs
4759

4860

4961
@dataclasses.dataclass
@@ -116,7 +128,9 @@ def find_run_type(ctx: GitHubCtx) -> Optional[WorkflowRunType]:
116128

117129
def calculate_jobs(run_type: WorkflowRunType, job_data: Dict[str, Any]) -> List[Job]:
118130
if isinstance(run_type, PRRunType):
119-
return add_base_env(name_jobs(job_data["pr"], "PR"), job_data["envs"]["pr"])
131+
return add_base_env(
132+
add_job_properties(job_data["pr"], "PR"), job_data["envs"]["pr"]
133+
)
120134
elif isinstance(run_type, TryRunType):
121135
jobs = job_data["try"]
122136
custom_jobs = run_type.custom_jobs
@@ -130,7 +144,7 @@ def calculate_jobs(run_type: WorkflowRunType, job_data: Dict[str, Any]) -> List[
130144
jobs = []
131145
unknown_jobs = []
132146
for custom_job in custom_jobs:
133-
job = [j for j in job_data["auto"] if j["image"] == custom_job]
147+
job = [j for j in job_data["auto"] if j["name"] == custom_job]
134148
if not job:
135149
unknown_jobs.append(custom_job)
136150
continue
@@ -140,10 +154,10 @@ def calculate_jobs(run_type: WorkflowRunType, job_data: Dict[str, Any]) -> List[
140154
f"Custom job(s) `{unknown_jobs}` not found in auto jobs"
141155
)
142156

143-
return add_base_env(name_jobs(jobs, "try"), job_data["envs"]["try"])
157+
return add_base_env(add_job_properties(jobs, "try"), job_data["envs"]["try"])
144158
elif isinstance(run_type, AutoRunType):
145159
return add_base_env(
146-
name_jobs(job_data["auto"], "auto"), job_data["envs"]["auto"]
160+
add_job_properties(job_data["auto"], "auto"), job_data["envs"]["auto"]
147161
)
148162

149163
return []
@@ -181,12 +195,64 @@ def format_run_type(run_type: WorkflowRunType) -> str:
181195
raise AssertionError()
182196

183197

184-
if __name__ == "__main__":
185-
logging.basicConfig(level=logging.INFO)
198+
def get_job_image(job: Job) -> str:
199+
"""
200+
By default, the Docker image of a job is based on its name.
201+
However, it can be overridden by its IMAGE environment variable.
202+
"""
203+
env = job.get("env", {})
204+
# Return the IMAGE environment variable if it exists, otherwise return the job name
205+
return env.get("IMAGE", job["name"])
186206

187-
with open(JOBS_YAML_PATH) as f:
188-
data = yaml.safe_load(f)
189207

208+
def is_linux_job(job: Job) -> bool:
209+
return "ubuntu" in job["os"]
210+
211+
212+
def find_linux_job(job_data: Dict[str, Any], job_name: str, pr_jobs: bool) -> Job:
213+
candidates = job_data["pr"] if pr_jobs else job_data["auto"]
214+
jobs = [job for job in candidates if job.get("name") == job_name]
215+
if len(jobs) == 0:
216+
available_jobs = "\n".join(
217+
sorted(job["name"] for job in candidates if is_linux_job(job))
218+
)
219+
raise Exception(f"""Job `{job_name}` not found in {'pr' if pr_jobs else 'auto'} jobs.
220+
The following jobs are available:
221+
{available_jobs}""")
222+
assert len(jobs) == 1
223+
224+
job = jobs[0]
225+
if not is_linux_job(job):
226+
raise Exception("Only Linux jobs can be executed locally")
227+
return job
228+
229+
230+
def run_workflow_locally(job_data: Dict[str, Any], job_name: str, pr_jobs: bool):
231+
DOCKER_DIR = Path(__file__).absolute().parent.parent / "docker"
232+
233+
job = find_linux_job(job_data, job_name=job_name, pr_jobs=pr_jobs)
234+
235+
custom_env = {}
236+
# Replicate src/ci/scripts/setup-environment.sh
237+
# Adds custom environment variables to the job
238+
if job_name.startswith("dist-"):
239+
if job_name.endswith("-alt"):
240+
custom_env["DEPLOY_ALT"] = "1"
241+
else:
242+
custom_env["DEPLOY"] = "1"
243+
custom_env.update({k: str(v) for (k, v) in job.get("env", {}).items()})
244+
245+
args = [str(DOCKER_DIR / "run.sh"), get_job_image(job)]
246+
env_formatted = [f"{k}={v}" for (k, v) in sorted(custom_env.items())]
247+
print(f"Executing `{' '.join(env_formatted)} {' '.join(args)}`")
248+
249+
env = os.environ.copy()
250+
env.update(custom_env)
251+
252+
subprocess.run(args, env=env)
253+
254+
255+
def calculate_job_matrix(job_data: Dict[str, Any]):
190256
github_ctx = get_github_ctx()
191257

192258
run_type = find_run_type(github_ctx)
@@ -197,7 +263,7 @@ def format_run_type(run_type: WorkflowRunType) -> str:
197263

198264
jobs = []
199265
if run_type is not None:
200-
jobs = calculate_jobs(run_type, data)
266+
jobs = calculate_jobs(run_type, job_data)
201267
jobs = skip_jobs(jobs, channel)
202268

203269
if not jobs:
@@ -208,3 +274,45 @@ def format_run_type(run_type: WorkflowRunType) -> str:
208274
logging.info(f"Output:\n{yaml.dump(dict(jobs=jobs, run_type=run_type), indent=4)}")
209275
print(f"jobs={json.dumps(jobs)}")
210276
print(f"run_type={run_type}")
277+
278+
279+
def create_cli_parser():
280+
parser = argparse.ArgumentParser(
281+
prog="ci.py", description="Generate or run CI workflows"
282+
)
283+
subparsers = parser.add_subparsers(
284+
help="Command to execute", dest="command", required=True
285+
)
286+
subparsers.add_parser(
287+
"calculate-job-matrix",
288+
help="Generate a matrix of jobs that should be executed in CI",
289+
)
290+
run_parser = subparsers.add_parser(
291+
"run-local", help="Run a CI jobs locally (on Linux)"
292+
)
293+
run_parser.add_argument(
294+
"job_name",
295+
help="CI job that should be executed. By default, a merge (auto) "
296+
"job with the given name will be executed",
297+
)
298+
run_parser.add_argument(
299+
"--pr", action="store_true", help="Run a PR job instead of an auto job"
300+
)
301+
return parser
302+
303+
304+
if __name__ == "__main__":
305+
logging.basicConfig(level=logging.INFO)
306+
307+
with open(JOBS_YAML_PATH) as f:
308+
data = yaml.safe_load(f)
309+
310+
parser = create_cli_parser()
311+
args = parser.parse_args()
312+
313+
if args.command == "calculate-job-matrix":
314+
calculate_job_matrix(data)
315+
elif args.command == "run-local":
316+
run_workflow_locally(data, args.job_name, args.pr)
317+
else:
318+
raise Exception(f"Unknown command {args.command}")

0 commit comments

Comments
 (0)