2121""" # noqa: E501
2222
2323import hashlib
24+ import json
2425import os
2526import subprocess
2627import tempfile
@@ -32,10 +33,193 @@ def main() -> None:
3233 """Run the migration steps."""
3334 # Add a separation line like this one after each migration step.
3435 print ("=" * 72 )
36+ print ("Migrating workflows to use ubuntu-slim runner for lightweight jobs..." )
37+ migrate_to_ubuntu_slim ()
38+ print ("=" * 72 )
3539 print ("Migration script finished. Remember to follow any manual instructions." )
3640 print ("=" * 72 )
3741
3842
43+ def migrate_to_ubuntu_slim () -> None :
44+ """Migrate workflow files to use ubuntu-slim runner for lightweight jobs.
45+
46+ This updates several workflow files to use the new cost-effective ubuntu-slim
47+ runner for jobs that are lightweight (e.g., labeling, release notes checks,
48+ simple API calls).
49+ """
50+ workflows_dir = Path (".github" ) / "workflows"
51+ project_type = read_project_type ()
52+ include_protolint = project_type == "api"
53+ if project_type is None :
54+ include_protolint = True
55+ manual_step (
56+ "Unable to detect the cookiecutter project type from "
57+ ".cookiecutter-replay.json; protolint migrations will run anyway. "
58+ "Please verify any protolint jobs and keep them only if this is an api "
59+ "project."
60+ )
61+
62+ migrations = {
63+ "ci.yaml" : [
64+ {
65+ "job" : "nox-all" ,
66+ "old" : (
67+ " if: always() && needs.nox.result != 'skipped'\n "
68+ " runs-on: ubuntu-24.04"
69+ ),
70+ "new" : (
71+ " if: always() && needs.nox.result != 'skipped'\n "
72+ " runs-on: ubuntu-slim"
73+ ),
74+ },
75+ {
76+ "job" : "test-installation-all" ,
77+ "old" : (
78+ " if: always() && needs.test-installation.result != 'skipped'\n "
79+ " runs-on: ubuntu-24.04"
80+ ),
81+ "new" : (
82+ " if: always() && needs.test-installation.result != 'skipped'\n "
83+ " runs-on: ubuntu-slim"
84+ ),
85+ },
86+ {
87+ "job" : "create-github-release" ,
88+ "old" : " discussions: write\n runs-on: ubuntu-24.04" ,
89+ "new" : " discussions: write\n runs-on: ubuntu-slim" ,
90+ },
91+ {
92+ "job" : "publish-to-pypi" ,
93+ "old" : ' needs: ["create-github-release"]\n runs-on: ubuntu-24.04' ,
94+ "new" : ' needs: ["create-github-release"]\n runs-on: ubuntu-slim' ,
95+ },
96+ ],
97+ "auto-dependabot.yaml" : [
98+ {
99+ "job" : "auto-merge" ,
100+ "old" : (
101+ " auto-merge:\n "
102+ " if: github.actor == 'dependabot[bot]'\n "
103+ " runs-on: ubuntu-latest"
104+ ),
105+ "new" : (
106+ " auto-merge:\n "
107+ " if: github.actor == 'dependabot[bot]'\n "
108+ " runs-on: ubuntu-slim"
109+ ),
110+ }
111+ ],
112+ "release-notes-check.yml" : [
113+ {
114+ "job" : "check-release-notes" ,
115+ "old" : (
116+ " check-release-notes:\n "
117+ " name: Check release notes are updated\n "
118+ " runs-on: ubuntu-latest"
119+ ),
120+ "new" : (
121+ " check-release-notes:\n "
122+ " name: Check release notes are updated\n "
123+ " runs-on: ubuntu-slim"
124+ ),
125+ }
126+ ],
127+ "dco-merge-queue.yml" : [
128+ {
129+ "job" : "DCO" ,
130+ "old" : "jobs:\n DCO:\n runs-on: ubuntu-latest" ,
131+ "new" : "jobs:\n DCO:\n runs-on: ubuntu-slim" ,
132+ }
133+ ],
134+ "labeler.yml" : [
135+ {
136+ "job" : "Label" ,
137+ "old" : (
138+ " Label:\n "
139+ " permissions:\n "
140+ " contents: read\n "
141+ " pull-requests: write\n "
142+ " runs-on: ubuntu-latest"
143+ ),
144+ "new" : (
145+ " Label:\n "
146+ " permissions:\n "
147+ " contents: read\n "
148+ " pull-requests: write\n "
149+ " runs-on: ubuntu-slim"
150+ ),
151+ }
152+ ],
153+ }
154+ if include_protolint :
155+ protolint_rule = {
156+ "job" : "protolint" ,
157+ "old" : (
158+ " protolint:\n "
159+ " name: Check proto files with protolint\n "
160+ " runs-on: ubuntu-24.04"
161+ ),
162+ "new" : (
163+ " protolint:\n "
164+ " name: Check proto files with protolint\n "
165+ " runs-on: ubuntu-slim"
166+ ),
167+ }
168+ migrations .setdefault ("ci-pr.yaml" , []).append (protolint_rule )
169+ migrations .setdefault ("ci.yaml" , []).append (protolint_rule )
170+
171+ for filename , rules in migrations .items ():
172+ filepath = workflows_dir / filename
173+ if not filepath .exists ():
174+ print (f" Skipping { filepath } (file not found)" )
175+ continue
176+
177+ for rule in rules :
178+ job = rule ["job" ]
179+ old = rule ["old" ]
180+ new = rule ["new" ]
181+ try :
182+ content = filepath .read_text (encoding = "utf-8" )
183+ except FileNotFoundError :
184+ continue
185+
186+ if old in content :
187+ replace_file_contents_atomically (filepath , old , new )
188+ print (f" Updated { filepath } : migrated job { job } to ubuntu-slim" )
189+ continue
190+
191+ if new in content :
192+ print (f" Skipped { filepath } : already uses ubuntu-slim for job { job } " )
193+ continue
194+
195+ manual_step (
196+ f" Pattern not found in { filepath } : please switch job { job } to use "
197+ "`runs-on: ubuntu-slim` where appropriate."
198+ )
199+
200+
201+ def read_project_type () -> str | None :
202+ """Read the cookiecutter project type from the replay file."""
203+ replay_path = Path (".cookiecutter-replay.json" )
204+ if not replay_path .exists ():
205+ return None
206+
207+ try :
208+ data = json .loads (replay_path .read_text (encoding = "utf-8" ))
209+ except (json .JSONDecodeError , OSError ):
210+ return None
211+
212+ cookiecutter_data = data .get ("cookiecutter" )
213+ if not isinstance (cookiecutter_data , dict ):
214+ return None
215+
216+ project_type = cookiecutter_data .get ("type" )
217+ if not isinstance (project_type , str ):
218+ return None
219+
220+ return project_type
221+
222+
39223def apply_patch (patch_content : str ) -> None :
40224 """Apply a patch using the patch utility."""
41225 subprocess .run (["patch" , "-p1" ], input = patch_content .encode (), check = True )
0 commit comments