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::{
10 extension_bump::{RepositoryTarget, generate_token},
11 runners,
12 steps::{self, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, named},
13 vars::{self, StepOutput},
14};
15
16const ROLLOUT_TAG_NAME: &str = "extension-workflows";
17
18pub(crate) fn extension_workflow_rollout() -> Workflow {
19 let fetch_repos = fetch_extension_repos();
20 let rollout_workflows = rollout_workflows_to_extension(&fetch_repos);
21 let create_tag = create_rollout_tag(&rollout_workflows);
22
23 named::workflow()
24 .on(Event::default().workflow_dispatch(WorkflowDispatch::default()))
25 .add_env(("CARGO_TERM_COLOR", "always"))
26 .add_job(fetch_repos.name, fetch_repos.job)
27 .add_job(rollout_workflows.name, rollout_workflows.job)
28 .add_job(create_tag.name, create_tag.job)
29}
30
31fn fetch_extension_repos() -> NamedJob {
32 fn get_repositories() -> (Step<Use>, StepOutput) {
33 let step = named::uses("actions", "github-script", "v7")
34 .id("list-repos")
35 .add_with((
36 "script",
37 indoc::indoc! {r#"
38 const repos = await github.paginate(github.rest.repos.listForOrg, {
39 org: 'zed-extensions',
40 type: 'public',
41 per_page: 100,
42 });
43
44 const filteredRepos = repos
45 .filter(repo => !repo.archived)
46 .map(repo => repo.name);
47
48 console.log(`Found ${filteredRepos.length} extension repos`);
49 return filteredRepos;
50 "#},
51 ))
52 .add_with(("result-encoding", "json"));
53
54 let filtered_repos = StepOutput::new(&step, "result");
55
56 (step, filtered_repos)
57 }
58
59 let (get_org_repositories, list_repos_output) = get_repositories();
60
61 let job = Job::default()
62 .cond(Expression::new(format!(
63 "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.ref == 'refs/heads/main'"
64 )))
65 .runs_on(runners::LINUX_SMALL)
66 .timeout_minutes(5u32)
67 .outputs([("repos".to_owned(), list_repos_output.to_string())])
68 .add_step(get_org_repositories);
69
70 named::job(job)
71}
72
73fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
74 fn checkout_zed_repo() -> CheckoutStep {
75 steps::checkout_repo()
76 .with_full_history()
77 .with_path("zed")
78 .with_custom_name("checkout_zed_repo")
79 }
80
81 fn checkout_extension_repo(token: &StepOutput) -> CheckoutStep {
82 steps::checkout_repo()
83 .with_custom_name("checkout_extension_repo")
84 .with_token(token)
85 .with_repository("zed-extensions/${{ matrix.repo }}")
86 .with_path("extension")
87 }
88
89 fn get_previous_tag_commit() -> (Step<Run>, StepOutput) {
90 let step = named::bash(formatdoc! {r#"
91 PREV_COMMIT=$(git rev-parse "{ROLLOUT_TAG_NAME}^{{commit}}" 2>/dev/null || echo "")
92 if [ -z "$PREV_COMMIT" ]; then
93 echo "::error::No previous rollout tag '{ROLLOUT_TAG_NAME}' found. Cannot determine file changes."
94 exit 1
95 fi
96 echo "Found previous rollout at commit: $PREV_COMMIT"
97 echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT"
98 "#})
99 .id("prev-tag")
100 .working_directory("zed");
101
102 let step_output = StepOutput::new(&step, "prev_commit");
103
104 (step, step_output)
105 }
106
107 fn get_removed_files(prev_commit: &StepOutput) -> (Step<Run>, StepOutput) {
108 let step = named::bash(indoc::indoc! {r#"
109 if [ "$MATRIX_REPO" = "workflows" ]; then
110 WORKFLOW_DIR="extensions/workflows"
111 else
112 WORKFLOW_DIR="extensions/workflows/shared"
113 fi
114
115 echo "Calculating changes from $PREV_COMMIT to HEAD for $WORKFLOW_DIR"
116
117 # Get deleted files (status D) and renamed files (status R - old name needs removal)
118 # Using -M to detect renames, then extracting files that are gone from their original location
119 REMOVED_FILES=$(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
124 REMOVED_FILES=$(echo "$REMOVED_FILES" | xargs)
125
126 echo "Files to remove: $REMOVED_FILES"
127 echo "removed_files=$REMOVED_FILES" >> "$GITHUB_OUTPUT"
128 "#})
129 .id("calc-changes")
130 .working_directory("zed")
131 .add_env(("PREV_COMMIT", prev_commit.to_string()))
132 .add_env(("MATRIX_REPO", "${{ matrix.repo }}"));
133
134 let removed_files = StepOutput::new(&step, "removed_files");
135
136 (step, removed_files)
137 }
138
139 fn sync_workflow_files(removed_files: &StepOutput) -> Step<Run> {
140 named::bash(indoc::indoc! {r#"
141 mkdir -p extension/.github/workflows
142 cd extension/.github/workflows
143
144 if [ -n "$REMOVED_FILES" ]; then
145 for file in $REMOVED_FILES; do
146 if [ -f "$file" ]; then
147 rm -f "$file"
148 fi
149 done
150 fi
151
152 cd - > /dev/null
153
154 if [ "$MATRIX_REPO" = "workflows" ]; then
155 cp zed/extensions/workflows/*.yml extension/.github/workflows/
156 else
157 cp zed/extensions/workflows/shared/*.yml extension/.github/workflows/
158 fi
159 "#})
160 .add_env(("REMOVED_FILES", removed_files.to_string()))
161 .add_env(("MATRIX_REPO", "${{ matrix.repo }}"))
162 }
163
164 fn get_short_sha() -> (Step<Run>, StepOutput) {
165 let step = named::bash(indoc::indoc! {r#"
166 echo "sha_short=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
167 "#})
168 .id("short-sha")
169 .working_directory("zed");
170
171 let step_output = StepOutput::new(&step, "sha_short");
172
173 (step, step_output)
174 }
175
176 fn create_pull_request(token: &StepOutput, short_sha: &StepOutput) -> Step<Use> {
177 let title = format!("Update CI workflows to `{short_sha}`");
178
179 named::uses("peter-evans", "create-pull-request", "v7")
180 .add_with(("path", "extension"))
181 .add_with(("title", title.clone()))
182 .add_with((
183 "body",
184 indoc::indoc! {r#"
185 This PR updates the CI workflow files from the main Zed repository
186 based on the commit zed-industries/zed@${{ github.sha }}
187 "#},
188 ))
189 .add_with(("commit-message", title))
190 .add_with(("branch", "update-workflows"))
191 .add_with((
192 "committer",
193 "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
194 ))
195 .add_with((
196 "author",
197 "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
198 ))
199 .add_with(("base", "main"))
200 .add_with(("delete-branch", true))
201 .add_with(("token", token.to_string()))
202 .add_with(("sign-commits", true))
203 .id("create-pr")
204 }
205
206 fn enable_auto_merge(token: &StepOutput) -> Step<gh_workflow::Run> {
207 named::bash(indoc::indoc! {r#"
208 if [ -n "$PR_NUMBER" ]; then
209 cd extension
210 gh pr merge "$PR_NUMBER" --auto --squash
211 fi
212 "#})
213 .add_env(("GH_TOKEN", token.to_string()))
214 .add_env((
215 "PR_NUMBER",
216 "${{ steps.create-pr.outputs.pull-request-number }}",
217 ))
218 }
219
220 let (authenticate, token) = generate_token(
221 vars::ZED_ZIPPY_APP_ID,
222 vars::ZED_ZIPPY_APP_PRIVATE_KEY,
223 Some(
224 RepositoryTarget::new("zed-extensions", &["${{ matrix.repo }}"]).permissions([
225 ("permission-pull-requests".to_owned(), Level::Write),
226 ("permission-contents".to_owned(), Level::Write),
227 ("permission-workflows".to_owned(), Level::Write),
228 ]),
229 ),
230 );
231 let (get_prev_tag, prev_commit) = get_previous_tag_commit();
232 let (calc_changes, removed_files) = get_removed_files(&prev_commit);
233 let (calculate_short_sha, short_sha) = get_short_sha();
234
235 let job = Job::default()
236 .needs([fetch_repos_job.name.clone()])
237 .cond(Expression::new(format!(
238 "needs.{}.outputs.repos != '[]'",
239 fetch_repos_job.name
240 )))
241 .runs_on(runners::LINUX_SMALL)
242 .timeout_minutes(10u32)
243 .strategy(
244 Strategy::default()
245 .fail_fast(false)
246 .max_parallel(10u32)
247 .matrix(json!({
248 "repo": format!("${{{{ fromJson(needs.{}.outputs.repos) }}}}", fetch_repos_job.name)
249 })),
250 )
251 .add_step(authenticate)
252 .add_step(checkout_zed_repo())
253 .add_step(checkout_extension_repo(&token))
254 .add_step(get_prev_tag)
255 .add_step(calc_changes)
256 .add_step(sync_workflow_files(&removed_files))
257 .add_step(calculate_short_sha)
258 .add_step(create_pull_request(&token, &short_sha))
259 .add_step(enable_auto_merge(&token));
260
261 named::job(job)
262}
263
264fn create_rollout_tag(rollout_job: &NamedJob) -> NamedJob {
265 fn checkout_zed_repo(token: &StepOutput) -> CheckoutStep {
266 steps::checkout_repo().with_full_history().with_token(token)
267 }
268
269 fn update_rollout_tag() -> Step<Run> {
270 named::bash(formatdoc! {r#"
271 if git rev-parse "{ROLLOUT_TAG_NAME}" >/dev/null 2>&1; then
272 git tag -d "{ROLLOUT_TAG_NAME}"
273 git push origin ":refs/tags/{ROLLOUT_TAG_NAME}" || true
274 fi
275
276 echo "Creating new tag '{ROLLOUT_TAG_NAME}' at $(git rev-parse --short HEAD)"
277 git tag "{ROLLOUT_TAG_NAME}"
278 git push origin "{ROLLOUT_TAG_NAME}"
279 "#})
280 }
281
282 fn configure_git() -> Step<Run> {
283 named::bash(indoc! {r#"
284 git config user.name "zed-zippy[bot]"
285 git config user.email "234243425+zed-zippy[bot]@users.noreply.github.com"
286 "#})
287 }
288
289 let (authenticate, token) = generate_token(
290 vars::ZED_ZIPPY_APP_ID,
291 vars::ZED_ZIPPY_APP_PRIVATE_KEY,
292 Some(
293 RepositoryTarget::current()
294 .permissions([("permission-contents".to_owned(), Level::Write)]),
295 ),
296 );
297
298 let job = Job::default()
299 .needs([rollout_job.name.clone()])
300 .runs_on(runners::LINUX_SMALL)
301 .timeout_minutes(1u32)
302 .add_step(authenticate)
303 .add_step(checkout_zed_repo(&token))
304 .add_step(configure_git())
305 .add_step(update_rollout_tag());
306
307 named::job(job)
308}