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