1
1
#!/usr/bin/env python3
2
2
3
3
"""
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.
6
7
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`.
9
9
"""
10
10
11
+ import argparse
11
12
import dataclasses
12
13
import json
13
14
import logging
14
15
import os
15
16
import re
17
+ import subprocess
16
18
import typing
17
19
from pathlib import Path
18
20
from typing import List , Dict , Any , Optional
25
27
Job = Dict [str , Any ]
26
28
27
29
28
- def name_jobs (jobs : List [Dict ], prefix : str ) -> List [Job ]:
30
+ def add_job_properties (jobs : List [Dict ], prefix : str ) -> List [Job ]:
29
31
"""
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.
31
34
"""
35
+ modified_jobs = []
32
36
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
35
43
36
44
37
45
def add_base_env (jobs : List [Job ], environment : Dict [str , str ]) -> List [Job ]:
38
46
"""
39
47
Prepends `environment` to the `env` attribute of each job.
40
48
The `env` of each job has higher precedence than `environment`.
41
49
"""
50
+ modified_jobs = []
42
51
for job in jobs :
43
52
env = environment .copy ()
44
53
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
47
59
48
60
49
61
@dataclasses .dataclass
@@ -116,7 +128,9 @@ def find_run_type(ctx: GitHubCtx) -> Optional[WorkflowRunType]:
116
128
117
129
def calculate_jobs (run_type : WorkflowRunType , job_data : Dict [str , Any ]) -> List [Job ]:
118
130
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
+ )
120
134
elif isinstance (run_type , TryRunType ):
121
135
jobs = job_data ["try" ]
122
136
custom_jobs = run_type .custom_jobs
@@ -130,7 +144,7 @@ def calculate_jobs(run_type: WorkflowRunType, job_data: Dict[str, Any]) -> List[
130
144
jobs = []
131
145
unknown_jobs = []
132
146
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 ]
134
148
if not job :
135
149
unknown_jobs .append (custom_job )
136
150
continue
@@ -140,10 +154,10 @@ def calculate_jobs(run_type: WorkflowRunType, job_data: Dict[str, Any]) -> List[
140
154
f"Custom job(s) `{ unknown_jobs } ` not found in auto jobs"
141
155
)
142
156
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" ])
144
158
elif isinstance (run_type , AutoRunType ):
145
159
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" ]
147
161
)
148
162
149
163
return []
@@ -181,12 +195,64 @@ def format_run_type(run_type: WorkflowRunType) -> str:
181
195
raise AssertionError ()
182
196
183
197
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" ])
186
206
187
- with open (JOBS_YAML_PATH ) as f :
188
- data = yaml .safe_load (f )
189
207
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 ]):
190
256
github_ctx = get_github_ctx ()
191
257
192
258
run_type = find_run_type (github_ctx )
@@ -197,7 +263,7 @@ def format_run_type(run_type: WorkflowRunType) -> str:
197
263
198
264
jobs = []
199
265
if run_type is not None :
200
- jobs = calculate_jobs (run_type , data )
266
+ jobs = calculate_jobs (run_type , job_data )
201
267
jobs = skip_jobs (jobs , channel )
202
268
203
269
if not jobs :
@@ -208,3 +274,45 @@ def format_run_type(run_type: WorkflowRunType) -> str:
208
274
logging .info (f"Output:\n { yaml .dump (dict (jobs = jobs , run_type = run_type ), indent = 4 )} " )
209
275
print (f"jobs={ json .dumps (jobs )} " )
210
276
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