1use gh_workflow::{
2 Event, Expression, Job, Level, Run, Step, Strategy, Use, Workflow, WorkflowDispatch,
3};
4use indoc::formatdoc;
5use indoc::indoc;
6use serde_json::json;
7
8use crate::tasks::workflows::steps::CheckoutStep;
9use crate::tasks::workflows::steps::cache_rust_dependencies_namespace;
10use crate::tasks::workflows::vars::JobOutput;
11use crate::tasks::workflows::{
12 runners,
13 steps::{
14 self, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, RepositoryTarget, generate_token, named,
15 },
16 vars::{self, StepOutput, WorkflowInput},
17};
18
19const ROLLOUT_TAG_NAME: &str = "extension-workflows";
20const WORKFLOW_ARTIFACT_NAME: &str = "extension-workflow-files";
21
22pub(crate) fn extension_workflow_rollout() -> Workflow {
23 let filter_repos_input = WorkflowInput::string("filter-repos", Some(String::new()))
24 .description(
25 "Comma-separated list of repository names to rollout to. Leave empty for all repos.",
26 );
27 let extra_context_input = WorkflowInput::string("change-description", Some(String::new()))
28 .description("Description for the changes to be expected with this rollout");
29
30 let (fetch_repos, removed_ci, removed_shared) = fetch_extension_repos(&filter_repos_input);
31 let rollout_workflows = rollout_workflows_to_extension(
32 &fetch_repos,
33 removed_ci,
34 removed_shared,
35 &extra_context_input,
36 );
37 let create_tag = create_rollout_tag(&rollout_workflows, &filter_repos_input);
38
39 named::workflow()
40 .on(Event::default().workflow_dispatch(
41 WorkflowDispatch::default()
42 .add_input(filter_repos_input.name, filter_repos_input.input())
43 .add_input(extra_context_input.name, extra_context_input.input()),
44 ))
45 .add_env(("CARGO_TERM_COLOR", "always"))
46 .add_job(fetch_repos.name, fetch_repos.job)
47 .add_job(rollout_workflows.name, rollout_workflows.job)
48 .add_job(create_tag.name, create_tag.job)
49}
50
51fn fetch_extension_repos(filter_repos_input: &WorkflowInput) -> (NamedJob, JobOutput, JobOutput) {
52 fn get_repositories(filter_repos_input: &WorkflowInput) -> (Step<Use>, StepOutput) {
53 let step = named::uses("actions", "github-script", "f28e40c7f34bde8b3046d885e986cb6290c5673b")
54 .id("list-repos")
55 .add_with((
56 "script",
57 formatdoc! {r#"
58 const repos = await github.paginate(github.rest.repos.listForOrg, {{
59 org: 'zed-extensions',
60 type: 'public',
61 per_page: 100,
62 }});
63
64 let filteredRepos = repos
65 .filter(repo => !repo.archived)
66 .map(repo => repo.name);
67
68 const filterInput = `{filter_repos_input}`.trim();
69 if (filterInput.length > 0) {{
70 const allowedNames = filterInput.split(',').map(s => s.trim()).filter(s => s.length > 0);
71 filteredRepos = filteredRepos.filter(name => allowedNames.includes(name));
72 console.log(`Filter applied. Matched ${{filteredRepos.length}} repos from ${{allowedNames.length}} requested.`);
73 }}
74
75 console.log(`Found ${{filteredRepos.length}} extension repos`);
76 return filteredRepos;
77 "#},
78 ))
79 .add_with(("result-encoding", "json"));
80
81 let filtered_repos = StepOutput::new(&step, "result");
82
83 (step, filtered_repos)
84 }
85
86 fn checkout_zed_repo() -> CheckoutStep {
87 steps::checkout_repo()
88 .with_full_history()
89 .with_custom_name("checkout_zed_repo")
90 }
91
92 fn get_previous_tag_commit() -> (Step<Run>, StepOutput) {
93 let step = named::bash(formatdoc! {r#"
94 PREV_COMMIT=$(git rev-parse "{ROLLOUT_TAG_NAME}^{{commit}}" 2>/dev/null || echo "")
95 if [ -z "$PREV_COMMIT" ]; then
96 echo "::error::No previous rollout tag '{ROLLOUT_TAG_NAME}' found. Cannot determine file changes."
97 exit 1
98 fi
99 echo "Found previous rollout at commit: $PREV_COMMIT"
100 echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT"
101 "#})
102 .id("prev-tag");
103
104 let step_output = StepOutput::new(&step, "prev_commit");
105
106 (step, step_output)
107 }
108
109 fn get_removed_files(prev_commit: &StepOutput) -> (Step<Run>, StepOutput, StepOutput) {
110 let step = named::bash(indoc! {r#"
111 for workflow_type in "ci" "shared"; do
112 if [ "$workflow_type" = "ci" ]; then
113 WORKFLOW_DIR="extensions/workflows"
114 else
115 WORKFLOW_DIR="extensions/workflows/shared"
116 fi
117
118 REMOVED=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \
119 awk '/^D/ { print $2 } /^R/ { print $2 }' | \
120 xargs -I{} basename {} 2>/dev/null | \
121 tr '\n' ' ' || echo "")
122 REMOVED=$(echo "$REMOVED" | xargs)
123
124 echo "Removed files for $workflow_type: $REMOVED"
125 echo "removed_${workflow_type}=$REMOVED" >> "$GITHUB_OUTPUT"
126 done
127 "#})
128 .id("calc-changes")
129 .add_env(("PREV_COMMIT", prev_commit.to_string()));
130
131 // These are created in the for-loop above and thus do exist
132 let removed_ci = StepOutput::new_unchecked(&step, "removed_ci");
133 let removed_shared = StepOutput::new_unchecked(&step, "removed_shared");
134
135 (step, removed_ci, removed_shared)
136 }
137
138 fn generate_workflow_files() -> Step<Run> {
139 named::bash(indoc! {r#"
140 cargo xtask workflows "$COMMIT_SHA"
141 "#})
142 .add_env(("COMMIT_SHA", "${{ github.sha }}"))
143 }
144
145 fn upload_workflow_files() -> Step<Use> {
146 named::uses(
147 "actions",
148 "upload-artifact",
149 "330a01c490aca151604b8cf639adc76d48f6c5d4", // v5
150 )
151 .add_with(("name", WORKFLOW_ARTIFACT_NAME))
152 .add_with(("path", "extensions/workflows/**/*.yml"))
153 .add_with(("if-no-files-found", "error"))
154 }
155
156 let (get_org_repositories, list_repos_output) = get_repositories(filter_repos_input);
157 let (get_prev_tag, prev_commit) = get_previous_tag_commit();
158 let (calc_changes, removed_ci, removed_shared) = get_removed_files(&prev_commit);
159
160 let job = Job::default()
161 .cond(Expression::new(format!(
162 "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.ref == 'refs/heads/main'"
163 )))
164 .runs_on(runners::LINUX_SMALL)
165 .timeout_minutes(10u32)
166 .outputs([
167 ("repos".to_owned(), list_repos_output.to_string()),
168 ("prev_commit".to_owned(), prev_commit.to_string()),
169 ("removed_ci".to_owned(), removed_ci.to_string()),
170 ("removed_shared".to_owned(), removed_shared.to_string()),
171 ])
172 .add_step(checkout_zed_repo())
173 .add_step(get_prev_tag)
174 .add_step(calc_changes)
175 .add_step(get_org_repositories)
176 .add_step(cache_rust_dependencies_namespace())
177 .add_step(generate_workflow_files())
178 .add_step(upload_workflow_files());
179
180 let job = named::job(job);
181 let (removed_ci, removed_shared) = (
182 removed_ci.as_job_output(&job),
183 removed_shared.as_job_output(&job),
184 );
185
186 (job, removed_ci, removed_shared)
187}
188
189fn rollout_workflows_to_extension(
190 fetch_repos_job: &NamedJob,
191 removed_ci: JobOutput,
192 removed_shared: JobOutput,
193 extra_context_input: &WorkflowInput,
194) -> NamedJob {
195 fn checkout_extension_repo(token: &StepOutput) -> CheckoutStep {
196 steps::checkout_repo()
197 .with_custom_name("checkout_extension_repo")
198 .with_token(token)
199 .with_repository("zed-extensions/${{ matrix.repo }}")
200 .with_path("extension")
201 }
202
203 fn download_workflow_files() -> Step<Use> {
204 named::uses(
205 "actions",
206 "download-artifact",
207 "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0
208 )
209 .add_with(("name", WORKFLOW_ARTIFACT_NAME))
210 .add_with(("path", "workflow-files"))
211 }
212
213 fn sync_workflow_files(removed_ci: JobOutput, removed_shared: JobOutput) -> Step<Run> {
214 named::bash(indoc! {r#"
215 mkdir -p extension/.github/workflows
216
217 if [ "$MATRIX_REPO" = "workflows" ]; then
218 REMOVED_FILES="$REMOVED_CI"
219 else
220 REMOVED_FILES="$REMOVED_SHARED"
221 fi
222
223 cd extension/.github/workflows
224
225 if [ -n "$REMOVED_FILES" ]; then
226 for file in $REMOVED_FILES; do
227 if [ -f "$file" ]; then
228 rm -f "$file"
229 fi
230 done
231 fi
232
233 cd - > /dev/null
234
235 if [ "$MATRIX_REPO" = "workflows" ]; then
236 cp workflow-files/*.yml extension/.github/workflows/
237 else
238 cp workflow-files/shared/*.yml extension/.github/workflows/
239 fi
240 "#})
241 .add_env(("REMOVED_CI", removed_ci))
242 .add_env(("REMOVED_SHARED", removed_shared))
243 .add_env(("MATRIX_REPO", "${{ matrix.repo }}"))
244 }
245
246 fn get_short_sha() -> (Step<Run>, StepOutput) {
247 let step = named::bash(indoc! {r#"
248 echo "sha_short=$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT"
249 "#})
250 .id("short-sha");
251
252 let step_output = StepOutput::new(&step, "sha_short");
253
254 (step, step_output)
255 }
256
257 fn create_pull_request(
258 token: &StepOutput,
259 short_sha: &StepOutput,
260 context_input: &WorkflowInput,
261 ) -> Step<Use> {
262 let title = format!("Update CI workflows to `{short_sha}`");
263
264 let body = formatdoc! {r#"
265 This PR updates the CI workflow files from the main Zed repository
266 based on the commit zed-industries/zed@${{{{ github.sha }}}}
267
268 {context_input}
269 "#,
270 };
271
272 named::uses(
273 "peter-evans",
274 "create-pull-request",
275 "98357b18bf14b5342f975ff684046ec3b2a07725",
276 )
277 .add_with(("path", "extension"))
278 .add_with(("title", title.clone()))
279 .add_with(("body", body))
280 .add_with(("commit-message", title))
281 .add_with(("branch", "update-workflows"))
282 .add_with((
283 "committer",
284 "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
285 ))
286 .add_with((
287 "author",
288 "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
289 ))
290 .add_with(("base", "main"))
291 .add_with(("delete-branch", true))
292 .add_with(("token", token.to_string()))
293 .add_with(("sign-commits", true))
294 .id("create-pr")
295 }
296
297 fn enable_auto_merge(token: &StepOutput) -> Step<gh_workflow::Run> {
298 named::bash(indoc! {r#"
299 if [ -n "$PR_NUMBER" ]; then
300 gh pr merge "$PR_NUMBER" --auto --squash
301 fi
302 "#})
303 .working_directory("extension")
304 .add_env(("GH_TOKEN", token.to_string()))
305 .add_env((
306 "PR_NUMBER",
307 "${{ steps.create-pr.outputs.pull-request-number }}",
308 ))
309 }
310
311 let (authenticate, token) =
312 generate_token(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY).for_repository(
313 RepositoryTarget::new("zed-extensions", &["${{ matrix.repo }}"]).permissions([
314 ("permission-pull-requests".to_owned(), Level::Write),
315 ("permission-contents".to_owned(), Level::Write),
316 ("permission-workflows".to_owned(), Level::Write),
317 ]),
318 );
319
320 let (calculate_short_sha, short_sha) = get_short_sha();
321
322 let job = Job::default()
323 .needs([fetch_repos_job.name.clone()])
324 .cond(Expression::new(format!(
325 "needs.{}.outputs.repos != '[]'",
326 fetch_repos_job.name
327 )))
328 .runs_on(runners::LINUX_SMALL)
329 .timeout_minutes(10u32)
330 .strategy(
331 Strategy::default()
332 .fail_fast(false)
333 .max_parallel(10u32)
334 .matrix(json!({
335 "repo": format!("${{{{ fromJson(needs.{}.outputs.repos) }}}}", fetch_repos_job.name)
336 })),
337 )
338 .add_step(authenticate)
339 .add_step(checkout_extension_repo(&token))
340 .add_step(download_workflow_files())
341 .add_step(sync_workflow_files(removed_ci, removed_shared))
342 .add_step(calculate_short_sha)
343 .add_step(create_pull_request(&token, &short_sha, extra_context_input))
344 .add_step(enable_auto_merge(&token));
345
346 named::job(job)
347}
348
349fn create_rollout_tag(rollout_job: &NamedJob, filter_repos_input: &WorkflowInput) -> NamedJob {
350 fn checkout_zed_repo(token: &StepOutput) -> CheckoutStep {
351 steps::checkout_repo().with_full_history().with_token(token)
352 }
353
354 fn update_rollout_tag() -> Step<Run> {
355 named::bash(formatdoc! {r#"
356 if git rev-parse "{ROLLOUT_TAG_NAME}" >/dev/null 2>&1; then
357 git tag -d "{ROLLOUT_TAG_NAME}"
358 git push origin ":refs/tags/{ROLLOUT_TAG_NAME}" || true
359 fi
360
361 echo "Creating new tag '{ROLLOUT_TAG_NAME}' at $(git rev-parse --short HEAD)"
362 git tag "{ROLLOUT_TAG_NAME}"
363 git push origin "{ROLLOUT_TAG_NAME}"
364 "#})
365 }
366
367 fn configure_git() -> Step<Run> {
368 named::bash(indoc! {r#"
369 git config user.name "zed-zippy[bot]"
370 git config user.email "234243425+zed-zippy[bot]@users.noreply.github.com"
371 "#})
372 }
373
374 let (authenticate, token) =
375 generate_token(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY).for_repository(
376 RepositoryTarget::current()
377 .permissions([("permission-contents".to_owned(), Level::Write)]),
378 );
379
380 let job = Job::default()
381 .needs([rollout_job.name.clone()])
382 .cond(Expression::new(format!(
383 "{filter_repos} == ''",
384 filter_repos = filter_repos_input.expr(),
385 )))
386 .runs_on(runners::LINUX_SMALL)
387 .timeout_minutes(1u32)
388 .add_step(authenticate)
389 .add_step(checkout_zed_repo(&token))
390 .add_step(configure_git())
391 .add_step(update_rollout_tag());
392
393 named::job(job)
394}