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