xref: /aosp_15_r20/external/pytorch/tools/testing/explicit_ci_jobs.py (revision da0073e96a02ea20f0ac840b70461e3646d07c45)
1#!/usr/bin/env python3
2
3from __future__ import annotations
4
5import argparse
6import fnmatch
7import subprocess
8import textwrap
9from pathlib import Path
10from typing import Any
11
12import yaml
13
14
15REPO_ROOT = Path(__file__).parent.parent.parent
16CONFIG_YML = REPO_ROOT / ".circleci" / "config.yml"
17WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows"
18
19
20WORKFLOWS_TO_CHECK = [
21    "binary_builds",
22    "build",
23    "master_build",
24    # These are formatted slightly differently, skip them
25    # "scheduled-ci",
26    # "debuggable-scheduled-ci",
27    # "slow-gradcheck-scheduled-ci",
28    # "promote",
29]
30
31
32def add_job(
33    workflows: dict[str, Any],
34    workflow_name: str,
35    type: str,
36    job: dict[str, Any],
37    past_jobs: dict[str, Any],
38) -> None:
39    """
40    Add job 'job' under 'type' and 'workflow_name' to 'workflow' in place. Also
41    add any dependencies (they must already be in 'past_jobs')
42    """
43    if workflow_name not in workflows:
44        workflows[workflow_name] = {"when": "always", "jobs": []}
45
46    requires = job.get("requires", None)
47    if requires is not None:
48        for requirement in requires:
49            dependency = past_jobs[requirement]
50            add_job(
51                workflows,
52                dependency["workflow_name"],
53                dependency["type"],
54                dependency["job"],
55                past_jobs,
56            )
57
58    workflows[workflow_name]["jobs"].append({type: job})
59
60
61def get_filtered_circleci_config(
62    workflows: dict[str, Any], relevant_jobs: list[str]
63) -> dict[str, Any]:
64    """
65    Given an existing CircleCI config, remove every job that's not listed in
66    'relevant_jobs'
67    """
68    new_workflows: dict[str, Any] = {}
69    past_jobs: dict[str, Any] = {}
70    for workflow_name, workflow in workflows.items():
71        if workflow_name not in WORKFLOWS_TO_CHECK:
72            # Don't care about this workflow, skip it entirely
73            continue
74
75        for job_dict in workflow["jobs"]:
76            for type, job in job_dict.items():
77                if "name" not in job:
78                    # Job doesn't have a name so it can't be handled
79                    print("Skipping", type)
80                else:
81                    if job["name"] in relevant_jobs:
82                        # Found a job that was specified at the CLI, add it to
83                        # the new result
84                        add_job(new_workflows, workflow_name, type, job, past_jobs)
85
86                    # Record the job in case it's needed as a dependency later
87                    past_jobs[job["name"]] = {
88                        "workflow_name": workflow_name,
89                        "type": type,
90                        "job": job,
91                    }
92
93    return new_workflows
94
95
96def commit_ci(files: list[str], message: str) -> None:
97    # Check that there are no other modified files than the ones edited by this
98    # tool
99    stdout = subprocess.run(
100        ["git", "status", "--porcelain"], stdout=subprocess.PIPE
101    ).stdout.decode()
102    for line in stdout.split("\n"):
103        if line == "":
104            continue
105        if line[0] != " ":
106            raise RuntimeError(
107                f"Refusing to commit while other changes are already staged: {line}"
108            )
109
110    # Make the commit
111    subprocess.run(["git", "add"] + files)
112    subprocess.run(["git", "commit", "-m", message])
113
114
115if __name__ == "__main__":
116    parser = argparse.ArgumentParser(
117        description="make .circleci/config.yml only have a specific set of jobs and delete GitHub actions"
118    )
119    parser.add_argument("--job", action="append", help="job name", default=[])
120    parser.add_argument(
121        "--filter-gha", help="keep only these github actions (glob match)", default=""
122    )
123    parser.add_argument(
124        "--make-commit",
125        action="store_true",
126        help="add change to git with to a do-not-merge commit",
127    )
128    args = parser.parse_args()
129
130    touched_files = [CONFIG_YML]
131    with open(CONFIG_YML) as f:
132        config_yml = yaml.safe_load(f.read())
133
134    config_yml["workflows"] = get_filtered_circleci_config(
135        config_yml["workflows"], args.job
136    )
137
138    with open(CONFIG_YML, "w") as f:
139        yaml.dump(config_yml, f)
140
141    if args.filter_gha:
142        for relative_file in WORKFLOWS_DIR.iterdir():
143            path = REPO_ROOT.joinpath(relative_file)
144            if not fnmatch.fnmatch(path.name, args.filter_gha):
145                touched_files.append(path)
146                path.resolve().unlink()
147
148    if args.make_commit:
149        jobs_str = "\n".join([f" * {job}" for job in args.job])
150        message = textwrap.dedent(
151            f"""
152        [skip ci][do not merge] Edit config.yml to filter specific jobs
153
154        Filter CircleCI to only run:
155        {jobs_str}
156
157        See [Run Specific CI Jobs](https://github.com/pytorch/pytorch/blob/master/CONTRIBUTING.md#run-specific-ci-jobs) for details.
158        """
159        ).strip()
160        commit_ci([str(f.relative_to(REPO_ROOT)) for f in touched_files], message)
161