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