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 let removed_ci = StepOutput::new(&step, "removed_ci");
131 let removed_shared = StepOutput::new(&step, "removed_shared");
132
133 (step, removed_ci, removed_shared)
134 }
135
136 fn generate_workflow_files() -> Step<Run> {
137 named::bash(indoc! {r#"
138 cargo xtask workflows "$COMMIT_SHA"
139 "#})
140 .add_env(("COMMIT_SHA", "${{ github.sha }}"))
141 }
142
143 fn upload_workflow_files() -> Step<Use> {
144 named::uses(
145 "actions",
146 "upload-artifact",
147 "330a01c490aca151604b8cf639adc76d48f6c5d4", // v5
148 )
149 .add_with(("name", WORKFLOW_ARTIFACT_NAME))
150 .add_with(("path", "extensions/workflows/**/*.yml"))
151 .add_with(("if-no-files-found", "error"))
152 }
153
154 let (get_org_repositories, list_repos_output) = get_repositories(filter_repos_input);
155 let (get_prev_tag, prev_commit) = get_previous_tag_commit();
156 let (calc_changes, removed_ci, removed_shared) = get_removed_files(&prev_commit);
157
158 let job = Job::default()
159 .cond(Expression::new(format!(
160 "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.ref == 'refs/heads/main'"
161 )))
162 .runs_on(runners::LINUX_SMALL)
163 .timeout_minutes(10u32)
164 .outputs([
165 ("repos".to_owned(), list_repos_output.to_string()),
166 ("prev_commit".to_owned(), prev_commit.to_string()),
167 ("removed_ci".to_owned(), removed_ci.to_string()),
168 ("removed_shared".to_owned(), removed_shared.to_string()),
169 ])
170 .add_step(checkout_zed_repo())
171 .add_step(get_prev_tag)
172 .add_step(calc_changes)
173 .add_step(get_org_repositories)
174 .add_step(cache_rust_dependencies_namespace())
175 .add_step(generate_workflow_files())
176 .add_step(upload_workflow_files());
177
178 let job = named::job(job);
179 let (removed_ci, removed_shared) = (
180 removed_ci.as_job_output(&job),
181 removed_shared.as_job_output(&job),
182 );
183
184 (job, removed_ci, removed_shared)
185}
186
187fn rollout_workflows_to_extension(
188 fetch_repos_job: &NamedJob,
189 removed_ci: JobOutput,
190 removed_shared: JobOutput,
191 extra_context_input: &WorkflowInput,
192) -> NamedJob {
193 fn checkout_extension_repo(token: &StepOutput) -> CheckoutStep {
194 steps::checkout_repo()
195 .with_custom_name("checkout_extension_repo")
196 .with_token(token)
197 .with_repository("zed-extensions/${{ matrix.repo }}")
198 .with_path("extension")
199 }
200
201 fn download_workflow_files() -> Step<Use> {
202 named::uses(
203 "actions",
204 "download-artifact",
205 "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0
206 )
207 .add_with(("name", WORKFLOW_ARTIFACT_NAME))
208 .add_with(("path", "workflow-files"))
209 }
210
211 fn sync_workflow_files(removed_ci: JobOutput, removed_shared: JobOutput) -> Step<Run> {
212 named::bash(indoc! {r#"
213 mkdir -p extension/.github/workflows
214
215 if [ "$MATRIX_REPO" = "workflows" ]; then
216 REMOVED_FILES="$REMOVED_CI"
217 else
218 REMOVED_FILES="$REMOVED_SHARED"
219 fi
220
221 cd extension/.github/workflows
222
223 if [ -n "$REMOVED_FILES" ]; then
224 for file in $REMOVED_FILES; do
225 if [ -f "$file" ]; then
226 rm -f "$file"
227 fi
228 done
229 fi
230
231 cd - > /dev/null
232
233 if [ "$MATRIX_REPO" = "workflows" ]; then
234 cp workflow-files/*.yml extension/.github/workflows/
235 else
236 cp workflow-files/shared/*.yml extension/.github/workflows/
237 fi
238 "#})
239 .add_env(("REMOVED_CI", removed_ci))
240 .add_env(("REMOVED_SHARED", removed_shared))
241 .add_env(("MATRIX_REPO", "${{ matrix.repo }}"))
242 }
243
244 fn get_short_sha() -> (Step<Run>, StepOutput) {
245 let step = named::bash(indoc! {r#"
246 echo "sha_short=$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT"
247 "#})
248 .id("short-sha");
249
250 let step_output = StepOutput::new(&step, "sha_short");
251
252 (step, step_output)
253 }
254
255 fn create_pull_request(
256 token: &StepOutput,
257 short_sha: &StepOutput,
258 context_input: &WorkflowInput,
259 ) -> Step<Use> {
260 let title = format!("Update CI workflows to `{short_sha}`");
261
262 let body = formatdoc! {r#"
263 This PR updates the CI workflow files from the main Zed repository
264 based on the commit zed-industries/zed@${{{{ github.sha }}}}
265
266 {context_input}
267 "#,
268 };
269
270 named::uses("peter-evans", "create-pull-request", "v7")
271 .add_with(("path", "extension"))
272 .add_with(("title", title.clone()))
273 .add_with(("body", body))
274 .add_with(("commit-message", title))
275 .add_with(("branch", "update-workflows"))
276 .add_with((
277 "committer",
278 "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
279 ))
280 .add_with((
281 "author",
282 "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
283 ))
284 .add_with(("base", "main"))
285 .add_with(("delete-branch", true))
286 .add_with(("token", token.to_string()))
287 .add_with(("sign-commits", true))
288 .id("create-pr")
289 }
290
291 fn enable_auto_merge(token: &StepOutput) -> Step<gh_workflow::Run> {
292 named::bash(indoc! {r#"
293 if [ -n "$PR_NUMBER" ]; then
294 gh pr merge "$PR_NUMBER" --auto --squash
295 fi
296 "#})
297 .working_directory("extension")
298 .add_env(("GH_TOKEN", token.to_string()))
299 .add_env((
300 "PR_NUMBER",
301 "${{ steps.create-pr.outputs.pull-request-number }}",
302 ))
303 }
304
305 let (authenticate, token) = generate_token(
306 vars::ZED_ZIPPY_APP_ID,
307 vars::ZED_ZIPPY_APP_PRIVATE_KEY,
308 Some(
309 RepositoryTarget::new("zed-extensions", &["${{ matrix.repo }}"]).permissions([
310 ("permission-pull-requests".to_owned(), Level::Write),
311 ("permission-contents".to_owned(), Level::Write),
312 ("permission-workflows".to_owned(), Level::Write),
313 ]),
314 ),
315 );
316 let (calculate_short_sha, short_sha) = get_short_sha();
317
318 let job = Job::default()
319 .needs([fetch_repos_job.name.clone()])
320 .cond(Expression::new(format!(
321 "needs.{}.outputs.repos != '[]'",
322 fetch_repos_job.name
323 )))
324 .runs_on(runners::LINUX_SMALL)
325 .timeout_minutes(10u32)
326 .strategy(
327 Strategy::default()
328 .fail_fast(false)
329 .max_parallel(10u32)
330 .matrix(json!({
331 "repo": format!("${{{{ fromJson(needs.{}.outputs.repos) }}}}", fetch_repos_job.name)
332 })),
333 )
334 .add_step(authenticate)
335 .add_step(checkout_extension_repo(&token))
336 .add_step(download_workflow_files())
337 .add_step(sync_workflow_files(removed_ci, removed_shared))
338 .add_step(calculate_short_sha)
339 .add_step(create_pull_request(&token, &short_sha, extra_context_input))
340 .add_step(enable_auto_merge(&token));
341
342 named::job(job)
343}
344
345fn create_rollout_tag(rollout_job: &NamedJob, filter_repos_input: &WorkflowInput) -> NamedJob {
346 fn checkout_zed_repo(token: &StepOutput) -> CheckoutStep {
347 steps::checkout_repo().with_full_history().with_token(token)
348 }
349
350 fn update_rollout_tag() -> Step<Run> {
351 named::bash(formatdoc! {r#"
352 if git rev-parse "{ROLLOUT_TAG_NAME}" >/dev/null 2>&1; then
353 git tag -d "{ROLLOUT_TAG_NAME}"
354 git push origin ":refs/tags/{ROLLOUT_TAG_NAME}" || true
355 fi
356
357 echo "Creating new tag '{ROLLOUT_TAG_NAME}' at $(git rev-parse --short HEAD)"
358 git tag "{ROLLOUT_TAG_NAME}"
359 git push origin "{ROLLOUT_TAG_NAME}"
360 "#})
361 }
362
363 fn configure_git() -> Step<Run> {
364 named::bash(indoc! {r#"
365 git config user.name "zed-zippy[bot]"
366 git config user.email "234243425+zed-zippy[bot]@users.noreply.github.com"
367 "#})
368 }
369
370 let (authenticate, token) = generate_token(
371 vars::ZED_ZIPPY_APP_ID,
372 vars::ZED_ZIPPY_APP_PRIVATE_KEY,
373 Some(
374 RepositoryTarget::current()
375 .permissions([("permission-contents".to_owned(), Level::Write)]),
376 ),
377 );
378
379 let job = Job::default()
380 .needs([rollout_job.name.clone()])
381 .cond(Expression::new(format!(
382 "{filter_repos} == ''",
383 filter_repos = filter_repos_input.expr(),
384 )))
385 .runs_on(runners::LINUX_SMALL)
386 .timeout_minutes(1u32)
387 .add_step(authenticate)
388 .add_step(checkout_zed_repo(&token))
389 .add_step(configure_git())
390 .add_step(update_rollout_tag());
391
392 named::job(job)
393}