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
1112import dataclasses
1213import json
1314import logging
1415import os
1516import re
17+ import subprocess
1618import typing
1719from pathlib import Path
1820from typing import List , Dict , Any , Optional
2527Job = 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
3745def 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
117129def 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