Skip to content

Commit 7f530fd

Browse files
committed
ci: plan selective work by bindings package root
Review-only narrative layer 3 of 6 for #2737. Teach the dependency-aware CI planner to select package roots and Core ABI variants from changed paths or release tags, with full coverage for uncertain impact. Include the planner tests and the maintained conceptual CI diagram. Layer 4 connects these workplans to executable jobs. Implementation source: 6c3de41 Review baseline: ab9e20f Read these commits in order. Shared files are assigned as complete files to one layer, so intermediate commits need not build or pass CI. Only the final Git tree matches the implementation. This branch is a reading aid, not an alternate merge or release path. Validation belongs to the implementation source above.
1 parent c14f938 commit 7f530fd

3 files changed

Lines changed: 530 additions & 95 deletions

File tree

‎ci/ci-pipeline.mmd‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,18 @@ config:
2525
flowchart TB
2626
subgraph TRIGGER["TRIGGER EVENTS"]
2727
direction LR
28-
T1["Push to main branch"] ~~~ T2["Pull request"] ~~~ T3["Manual workflow dispatch"]
28+
T1["Main or approved pull request"] ~~~ T2["Immutable package tag"] ~~~ T3["Manual workflow dispatch"]
2929
end
3030

31+
PLAN["Select affected packages and dependencies<br/>A package tag selects its release line<br/>Registry: ci/versions.yml"]
32+
3133
subgraph BUILD["BUILD STAGE"]
3234
direction TB
3335
subgraph BUILD_PLATFORMS["Parallel Platform Builds"]
3436
direction LR
35-
B1["linux-64<br/>(Self-hosted)"] ~~~ B2["linux-aarch64<br/>(Self-hosted)"] ~~~ B3["win-64<br/>(GitHub-hosted)"]
37+
B1["Linux x86_64<br/>(Self-hosted)"] ~~~ B2["Linux ARM64<br/>(Self-hosted)"] ~~~ B3["Windows x86_64 / ARM64<br/>(GitHub-hosted)"]
3638
end
37-
BUILD_DETAILS["Python versions: 3.10, 3.11, 3.12, 3.13, 3.14<br/>CUDA version: 13.0.0 (build-time)<br/>Components: cuda-core, cuda-bindings,<br/>cuda-pathfinder, cuda-python"]
39+
BUILD_DETAILS["Python and platform matrices: ci.yml<br/>Bindings toolkits and roots: registry<br/>Build selected bindings, Core,<br/>Pathfinder and metapackage artifacts"]
3840
end
3941

4042
subgraph ARTIFACTS["ARTIFACT STORAGE"]
@@ -51,22 +53,22 @@ flowchart TB
5153
direction TB
5254
subgraph TEST_PLATFORMS["Parallel Platform Tests"]
5355
direction LR
54-
TS1["linux-64<br/>(Self-hosted)"] ~~~ TS2["linux-aarch64<br/>(Self-hosted)"] ~~~ TS3["win-64<br/>(GitHub-hosted)"]
56+
TS1["linux-64<br/>(Self-hosted)"] ~~~ TS2["linux-aarch64<br/>(Self-hosted)"] ~~~ TS3["win-64<br/>(Self-hosted GPU)"]
5557
end
56-
TEST_DETAILS["Download wheels from artifacts<br/>Test against multiple CUDA runtime versions<br/>Run Python unit tests, Cython tests, and examples"]
57-
ARTIFACT_FLOWS["Artifact Flows:<br/>cuda-pathfinder: main to backport<br/>cuda-bindings: backport to main"]
58+
TEST_DETAILS["Test sources match wheel build revision<br/>Test against multiple CUDA runtime versions<br/>Run Python unit tests, Cython tests, and examples"]
59+
ARTIFACT_FLOWS["Both bindings roots build from this source tree<br/>Reuse validated baseline for unaffected packages<br/>Bindings tag: selected line and published Pathfinder<br/>Core older-toolkit tests: published bindings floor"]
5860
end
5961

6062
subgraph RELEASE["RELEASE PIPELINE"]
6163
direction TB
6264
subgraph RELEASE_STAGES["Sequential Release Steps"]
6365
direction LR
64-
R1["Validation<br/>Artifact integrity<br/>Git tag verification"] --> R2["Publishing<br/>PyPI/TestPyPI<br/>Component or all releases"] --> R3["Documentation<br/>GitHub Pages<br/>Release notes"]
66+
R1["Validation<br/>Artifact integrity<br/>Git tag verification"] --> R2["Publishing<br/>PyPI/TestPyPI<br/>Selected component"] --> R3["Documentation<br/>GitHub Pages<br/>Release notes"]
6567
end
66-
RELEASE_DETAILS["Manual workflow dispatch with run ID<br/>Supports individual component or full releases"]
68+
RELEASE_DETAILS["Manual dry run for exact tag and successful CI run<br/>Explicit publication for selected component<br/>Bindings release does not require a Core release"]
6769
end
6870

69-
TRIGGER --> BUILD
71+
TRIGGER --> PLAN --> BUILD
7072
BUILD -. "wheel upload" .-> ARTIFACTS
7173
ARTIFACTS --> TEST
7274
TEST --> RELEASE
@@ -90,7 +92,7 @@ flowchart TB
9092
class TRIGGER triggerStyle
9193
class T1,T2,T3 triggerNodeStyle
9294
class BUILD,BUILD_PLATFORMS buildStyle
93-
class B1,B2,B3,BUILD_DETAILS buildNodeStyle
95+
class PLAN,B1,B2,B3,BUILD_DETAILS buildNodeStyle
9496
class ARTIFACTS,GITHUB_ARTIFACTS,GITHUB_CACHE artifactStyle
9597
class GA1,GC1 artifactNodeStyle
9698
class TEST,TEST_PLATFORMS testStyle

‎ci/tools/compute_ci_plan.py‎

Lines changed: 190 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,19 @@
1212
import json
1313
import subprocess
1414
from 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

1622
REPO_ROOT = Path(__file__).resolve().parents[2]
17-
MODULES = ("pathfinder", "bindings", "core", "python")
1823
PLATFORMS = ("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

3130
IGNORED_BASENAMES = {"AGENTS.md", "CLAUDE.md", "pixi.lock", "pixi.toml"}
@@ -53,22 +52,72 @@
5352
"ci/tools/setup-sanitizer": "linux",
5453
}
5554

55+
Target = tuple[str, str | None]
56+
5657

5758
def 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

212351
if __name__ == "__main__":

0 commit comments

Comments
 (0)