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