1212import json
1313import subprocess
1414from pathlib import Path , PurePosixPath
15+ from typing import TYPE_CHECKING
16+
17+ from .bindings_config import BindingsConfigError , load_config
18+
19+ if TYPE_CHECKING :
20+ from .bindings_config import BindingsConfig
1521
1622REPO_ROOT = Path (__file__ ).resolve ().parents [2 ]
17- MODULES = ("pathfinder" , "bindings" , "core" , "python" )
1823PLATFORMS = ("linux" , "windows" )
19- PACKAGE_MODULES = {f"cuda_{ module } " : module for module in MODULES }
20-
21- # Source changes have different build and test consumers. In particular,
22- # cuda-python source needs a same-version bindings wheel, while a core-only
23- # change can reuse the baseline cuda-python wheel.
24- SOURCE_IMPACT = {
25- "pathfinder" : (set (MODULES ), set (MODULES )),
26- "bindings" : ({"bindings" , "core" , "python" }, {"bindings" , "core" , "python" }),
27- "core" : ({"core" }, {"core" , "python" }),
28- "python" : ({"bindings" , "python" }, {"python" }),
24+ PACKAGE_TARGETS : dict [str , tuple [str , None ]] = {
25+ "cuda_pathfinder" : ("pathfinder" , None ),
26+ "cuda_core" : ("core" , None ),
27+ "cuda_python" : ("python" , None ),
2928}
3029
3130IGNORED_BASENAMES = {"AGENTS.md" , "CLAUDE.md" , "pixi.lock" , "pixi.toml" }
5352 "ci/tools/setup-sanitizer" : "linux" ,
5453}
5554
55+ Target = tuple [str , str | None ]
56+
5657
5758def compute_workplan (
58- paths : list [str ],
5959 * ,
60+ bindings_config : BindingsConfig ,
61+ paths : list [str ],
62+ linked_paths : set [str ],
6063 merge_base : str ,
6164 baseline_run_id : str ,
62- linked_paths : set [ str ] | None = None ,
65+ release_tag : str ,
6366) -> dict [str , object ]:
6467 """Return the final CI decisions for the supplied changed paths."""
65- linked_paths = linked_paths or set ()
66- source_changes : set [str ] = set ()
67- test_changes : set [str ] = set ()
68+ packages = bindings_config .package_roots
69+ package_roots = tuple (package .package_root for package in packages )
70+ cuda_variants = tuple (package .cuda_variant for package in packages )
71+
72+ bindings_targets = frozenset (("bindings" , package_root ) for package_root in package_roots )
73+ core_targets = frozenset (("core" , variant ) for variant in cuda_variants )
74+ python_targets = frozenset (("python" , package_root ) for package_root in package_roots )
75+ all_targets = frozenset ({("pathfinder" , None ), * bindings_targets , * core_targets , * python_targets })
76+
77+ package_targets = {PurePosixPath (package .package_root ): ("bindings" , package .package_root ) for package in packages }
78+ package_targets .update ((PurePosixPath (source_dir ), target ) for source_dir , target in PACKAGE_TARGETS .items ())
79+
80+ # Source changes have different build/test consumers. Core-only changes can
81+ # reuse baseline cuda-python wheels; cuda-python changes cannot reuse bindings.
82+ source_impacts : dict [Target , tuple [frozenset [Target ], frozenset [Target ], frozenset [str ]]] = {
83+ ("pathfinder" , None ): (all_targets , all_targets , frozenset (package_roots )),
84+ ("core" , None ): (core_targets , frozenset ({* core_targets , * python_targets }), frozenset (package_roots )),
85+ ("python" , None ): (
86+ frozenset ({* bindings_targets , * python_targets }),
87+ python_targets ,
88+ frozenset (package_roots ),
89+ ),
90+ ** {
91+ ("bindings" , package .package_root ): (
92+ frozenset (
93+ {
94+ ("bindings" , package .package_root ),
95+ ("python" , package .package_root ),
96+ * core_targets ,
97+ }
98+ ),
99+ frozenset (
100+ {
101+ ("bindings" , package .package_root ),
102+ ("core" , package .cuda_variant ),
103+ ("python" , package .package_root ),
104+ }
105+ ),
106+ frozenset ({package .package_root }),
107+ )
108+ for package in packages
109+ },
110+ }
111+
112+ source_changes : set [tuple [str , str | None ]] = set ()
113+ test_changes : set [tuple [str , str | None ]] = set ()
68114 test_platforms : set [str ] = set ()
69- force_all = not merge_base or not baseline_run_id
115+ release_package = bindings_config .match_tag (release_tag ) if release_tag else None
116+ if release_tag .startswith ("v" ) and release_package is None :
117+ raise BindingsConfigError (f"no configured CUDA bindings package root matches release tag: { release_tag !r} " )
118+ force_all = release_package is None and (bool (release_tag ) or not merge_base or not baseline_run_id )
70119
71- if not force_all :
120+ if release_package is None and not force_all :
72121 for path in paths :
73122 path_parts = PurePosixPath (path ).parts
74123 if not path_parts :
@@ -87,71 +136,127 @@ def compute_workplan(
87136 if path_parts [0 ] == ".github" or path_parts [- 1 ] in IGNORED_BASENAMES :
88137 continue
89138
90- module = PACKAGE_MODULES .get (path_parts [0 ])
91- if module is not None and len (path_parts ) > 1 :
92- relative = path_parts [1 :]
139+ target_match = next (
140+ (
141+ (root , target )
142+ for root , target in sorted (
143+ package_targets .items (), key = lambda item : len (item [0 ].parts ), reverse = True
144+ )
145+ if len (path_parts ) > len (root .parts ) and path_parts [: len (root .parts )] == root .parts
146+ ),
147+ None ,
148+ )
149+ if target_match is not None :
150+ root , target = target_match
151+ module , _ = target
152+ relative = path_parts [len (root .parts ) :]
93153 if relative [0 ] == "docs" :
94154 continue
95155 if (
96156 any (part in {"test" , "tests" } for part in relative [:- 1 ])
97157 or relative [0 ] == "examples"
98158 or (module == "core" and relative == ("pytest.ini" ,))
99159 ):
100- test_changes .add (module )
160+ if module == "core" :
161+ test_changes .update (core_targets )
162+ elif module == "python" :
163+ test_changes .update (python_targets )
164+ else :
165+ test_changes .add (target )
101166 elif PurePosixPath (path ).suffix in IGNORED_SUFFIXES and path not in linked_paths :
102167 continue
103168 else :
104- source_changes .add (module )
169+ source_changes .add (target )
105170 continue
106171
107172 is_test_path = any (part in {"test" , "tests" } for part in path_parts [:- 1 ])
108173 if is_test_path :
109- test_changes .update (MODULES )
174+ test_changes .update (all_targets )
110175 elif (
111176 path in IGNORED_PATHS
112177 or PurePosixPath (path ).suffix in IGNORED_SUFFIXES
113178 or path .startswith (IGNORED_PREFIXES )
114179 ):
115180 continue
116181 elif path_parts [0 ] in {"benchmarks" , "cuda_python_test_helpers" }:
117- test_changes .update (MODULES )
182+ test_changes .update (all_targets )
118183 else :
119184 force_all = True
120185 break
121186
122- if force_all :
123- builds = set (MODULES )
124- tests = set (MODULES )
187+ if release_package is not None :
188+ builds = {
189+ ("bindings" , release_package .package_root ),
190+ ("python" , release_package .package_root ),
191+ }
192+ tests = set (builds )
125193 test_platforms = set (PLATFORMS )
194+ sdist_package_roots = {release_package .package_root }
195+ elif force_all :
196+ builds = set (all_targets )
197+ tests = set (all_targets )
198+ test_platforms = set (PLATFORMS )
199+ sdist_package_roots = set (package_roots )
126200 else :
127- builds : set [str ] = set ()
128- tests = set (MODULES ) if test_platforms else set (test_changes )
129- for module in source_changes :
130- build_impact , test_impact = SOURCE_IMPACT [module ]
201+ builds : set [tuple [str , str | None ]] = set ()
202+ tests = set (all_targets ) if test_platforms else set (test_changes )
203+ sdist_package_roots : set [str ] = set ()
204+ for target in source_changes :
205+ build_impact , test_impact , sdist_impact = source_impacts [target ]
131206 builds .update (build_impact )
132207 tests .update (test_impact )
208+ sdist_package_roots .update (sdist_impact )
133209 if source_changes or test_changes :
134210 test_platforms .update (PLATFORMS )
135211
136- modules = {
137- module : {
138- "needs_build" : module in builds ,
139- "needs_test" : module in tests ,
212+ def flags (module : str , selectors : tuple [str | None , ...]) -> dict [str , bool ]:
213+ return {
214+ "needs_build" : any ((module , selector ) in builds for selector in selectors ),
215+ "needs_test" : any ((module , selector ) in tests for selector in selectors ),
216+ }
217+
218+ modules : dict [str , dict [str , object ]] = {"pathfinder" : flags ("pathfinder" , (None ,))}
219+ for module in ("bindings" , "python" ):
220+ package_decisions = {package .package_root : flags (module , (package .package_root ,)) for package in packages }
221+ modules [module ] = {
222+ ** flags (module , package_roots ),
223+ "package_roots" : package_decisions ,
140224 }
141- for module in MODULES
225+
226+ cuda_major_decisions = {variant : flags ("core" , (variant ,)) for variant in cuda_variants }
227+ modules ["core" ] = {
228+ ** flags ("core" , cuda_variants ),
229+ "cuda_majors" : cuda_major_decisions ,
230+ }
231+
232+ pathfinder_test = ("pathfinder" , None ) in tests
233+ test_cuda_variants = {
234+ package .cuda_variant
235+ for package in packages
236+ if pathfinder_test
237+ or ("core" , package .cuda_variant ) in tests
238+ or any ((module , package .package_root ) in tests for module in ("bindings" , "python" ))
142239 }
143240 return {
144241 "modules" : modules ,
242+ "sources" : {
243+ # Focused bindings releases intentionally omit unrelated artifacts.
244+ "pathfinder" : "published" if release_package is not None else "artifact" ,
245+ },
145246 "jobs" : {
146247 # These gates cover both optional artifact builds and wheel tests.
147248 "platforms" : {platform : platform in test_platforms for platform in PLATFORMS },
148- "sdist_tests" : bool (builds ),
149- "core_api_checks" : force_all or "core" in source_changes ,
249+ "sdist_tests" : bool (sdist_package_roots ),
250+ "core_api_checks" : force_all or ("core" , None ) in source_changes ,
251+ "test_cuda_majors" : {variant : variant in test_cuda_variants for variant in cuda_variants },
252+ "sdist_package_roots" : {
253+ package_root : package_root in sdist_package_roots for package_root in package_roots
254+ },
150255 },
151256 "merge_base" : merge_base ,
152257 "baseline" : {
153- "run_id" : baseline_run_id if not force_all else "" ,
154- "sha" : merge_base if not force_all else "" ,
258+ "run_id" : baseline_run_id if release_package is None and not force_all else "" ,
259+ "sha" : merge_base if release_package is None and not force_all else "" ,
155260 },
156261 }
157262
@@ -192,21 +297,55 @@ def _expand_linked_paths(paths: list[str], symlink_paths: list[str], *, root: Pa
192297 return expanded
193298
194299
195- def main () -> None :
300+ def main (argv : list [ str ] | None = None ) -> None :
196301 parser = argparse .ArgumentParser ()
197302 parser .add_argument ("--merge-base" , default = "" )
198303 parser .add_argument ("--baseline-run-id" , default = "" )
199- args = parser .parse_args ()
304+ parser .add_argument ("--release-tag" , default = "" )
305+ parser .add_argument (
306+ "--github-output" ,
307+ type = Path ,
308+ help = "append normalized CI records to this GitHub output file" ,
309+ )
310+ parser .add_argument (
311+ "--github-step-summary" ,
312+ type = Path ,
313+ help = "append a formatted workplan to this GitHub step summary file" ,
314+ )
315+ args = parser .parse_args (argv )
316+ if args .github_step_summary is not None and args .github_output is None :
317+ parser .error ("--github-step-summary requires --github-output" )
200318
201- reusable_baseline = bool (args .merge_base and args .baseline_run_id )
319+ config = load_config ()
320+ reusable_baseline = bool (args .merge_base and args .baseline_run_id and not args .release_tag )
202321 paths , linked_paths = _changed_paths (args .merge_base ) if reusable_baseline else ([], set ())
203- plan = compute_workplan (
204- paths ,
205- merge_base = args .merge_base ,
206- baseline_run_id = args .baseline_run_id ,
207- linked_paths = linked_paths ,
208- )
209- print (json .dumps (plan , separators = ("," , ":" ), sort_keys = True ))
322+ try :
323+ plan = compute_workplan (
324+ bindings_config = config ,
325+ paths = paths ,
326+ linked_paths = linked_paths ,
327+ merge_base = args .merge_base ,
328+ baseline_run_id = args .baseline_run_id ,
329+ release_tag = args .release_tag ,
330+ )
331+ except BindingsConfigError as error :
332+ parser .error (str (error ))
333+ workplan = json .dumps (plan , separators = ("," , ":" ), sort_keys = True )
334+ if args .github_output is None :
335+ print (workplan )
336+ return
337+
338+ records = {
339+ "bindings-config" : config .to_json (),
340+ "workplan" : workplan ,
341+ }
342+ with args .github_output .open ("a" , encoding = "utf-8" ) as output :
343+ for name , value in records .items ():
344+ output .write (f"{ name } ={ value } \n " )
345+ if args .github_step_summary is not None :
346+ formatted = json .dumps (plan , indent = 2 , sort_keys = True )
347+ with args .github_step_summary .open ("a" , encoding = "utf-8" ) as summary :
348+ summary .write (f"\n ### CI workplan\n ```json\n { formatted } \n ```\n " )
210349
211350
212351if __name__ == "__main__" :
0 commit comments