1use gh_workflow::{ctx::Context, *};
2use indoc::{formatdoc, indoc};
3
4use crate::tasks::workflows::{
5 extension_tests::{self},
6 runners,
7 steps::{
8 self, BASH_SHELL, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob,
9 RepositoryTarget, cache_rust_dependencies_namespace, checkout_repo, dependant_job,
10 generate_token, named,
11 },
12 vars::{
13 JobOutput, StepOutput, WorkflowInput, WorkflowSecret,
14 one_workflow_per_non_main_branch_and_token,
15 },
16};
17
18const VERSION_CHECK: &str =
19 r#"sed -n 's/^version = \"\(.*\)\"/\1/p' < extension.toml | tr -d '[:space:]'"#;
20
21// This is used by various extensions repos in the zed-extensions org to bump extension versions.
22pub(crate) fn extension_bump() -> Workflow {
23 let bump_type = WorkflowInput::string("bump-type", Some("patch".to_owned()));
24 // TODO: Ideally, this would have a default of `false`, but this is currently not
25 // supported in gh-workflows
26 let force_bump = WorkflowInput::bool("force-bump", None);
27 let working_directory = WorkflowInput::string("working-directory", Some(".".to_owned()));
28
29 let (app_id, app_secret) = extension_workflow_secrets();
30 let (check_version_changed, version_changed, current_version) = check_version_changed();
31
32 let version_changed = version_changed.as_job_output(&check_version_changed);
33 let current_version = current_version.as_job_output(&check_version_changed);
34
35 let dependencies = [&check_version_changed];
36 let bump_version = bump_extension_version(
37 &dependencies,
38 ¤t_version,
39 &bump_type,
40 &version_changed,
41 &force_bump,
42 &app_id,
43 &app_secret,
44 );
45 let (create_label, tag) = create_version_label(
46 &dependencies,
47 &version_changed,
48 ¤t_version,
49 &app_id,
50 &app_secret,
51 );
52 let tag = tag.as_job_output(&create_label);
53 let trigger_release = trigger_release(
54 &[&check_version_changed, &create_label],
55 tag,
56 &app_id,
57 &app_secret,
58 );
59
60 named::workflow()
61 .add_event(
62 Event::default().workflow_call(
63 WorkflowCall::default()
64 .add_input(bump_type.name, bump_type.call_input())
65 .add_input(force_bump.name, force_bump.call_input())
66 .add_input(working_directory.name, working_directory.call_input())
67 .secrets([
68 (app_id.name.to_owned(), app_id.secret_configuration()),
69 (
70 app_secret.name.to_owned(),
71 app_secret.secret_configuration(),
72 ),
73 ]),
74 ),
75 )
76 .concurrency(one_workflow_per_non_main_branch_and_token("extension-bump"))
77 .add_env(("CARGO_TERM_COLOR", "always"))
78 .add_env(("RUST_BACKTRACE", 1))
79 .add_env(("CARGO_INCREMENTAL", 0))
80 .add_env((
81 "ZED_EXTENSION_CLI_SHA",
82 extension_tests::ZED_EXTENSION_CLI_SHA,
83 ))
84 .add_job(check_version_changed.name, check_version_changed.job)
85 .add_job(bump_version.name, bump_version.job)
86 .add_job(create_label.name, create_label.job)
87 .add_job(trigger_release.name, trigger_release.job)
88}
89
90fn extension_job_defaults() -> Defaults {
91 Defaults::default().run(
92 RunDefaults::default()
93 .shell(BASH_SHELL)
94 .working_directory("${{ inputs.working-directory }}"),
95 )
96}
97
98fn check_version_changed() -> (NamedJob, StepOutput, StepOutput) {
99 let (compare_versions, version_changed, current_version) = compare_versions();
100
101 let job = Job::default()
102 .defaults(extension_job_defaults())
103 .with_repository_owner_guard()
104 .outputs([
105 (version_changed.name.to_owned(), version_changed.to_string()),
106 (
107 current_version.name.to_string(),
108 current_version.to_string(),
109 ),
110 ])
111 .runs_on(runners::LINUX_SMALL)
112 .timeout_minutes(1u32)
113 .add_step(steps::checkout_repo().with_full_history())
114 .add_step(compare_versions);
115
116 (named::job(job), version_changed, current_version)
117}
118
119fn create_version_label(
120 dependencies: &[&NamedJob],
121 version_changed_output: &JobOutput,
122 current_version: &JobOutput,
123 app_id: &WorkflowSecret,
124 app_secret: &WorkflowSecret,
125) -> (NamedJob, StepOutput) {
126 let (generate_token, generated_token) =
127 generate_token(&app_id.to_string(), &app_secret.to_string()).into();
128 let (determine_tag_step, tag) = determine_tag(current_version);
129 let job = steps::dependant_job(dependencies)
130 .defaults(extension_job_defaults())
131 .cond(Expression::new(format!(
132 "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.event_name == 'push' && \
133 github.ref == 'refs/heads/main' && {version_changed} == 'true'",
134 version_changed = version_changed_output.expr(),
135 )))
136 .outputs([(tag.name.to_owned(), tag.to_string())])
137 .runs_on(runners::LINUX_SMALL)
138 .timeout_minutes(1u32)
139 .add_step(generate_token)
140 .add_step(steps::checkout_repo())
141 .add_step(determine_tag_step)
142 .add_step(create_version_tag(&tag, generated_token));
143
144 (named::job(job), tag)
145}
146
147fn create_version_tag(tag: &StepOutput, generated_token: StepOutput) -> Step<Use> {
148 named::uses(
149 "actions",
150 "github-script",
151 "f28e40c7f34bde8b3046d885e986cb6290c5673b", // v7
152 )
153 .with(
154 Input::default()
155 .add(
156 "script",
157 formatdoc! {r#"
158 github.rest.git.createRef({{
159 owner: context.repo.owner,
160 repo: context.repo.repo,
161 ref: 'refs/tags/{tag}',
162 sha: context.sha
163 }})"#
164 },
165 )
166 .add("github-token", generated_token.to_string()),
167 )
168}
169
170fn determine_tag(current_version: &JobOutput) -> (Step<Run>, StepOutput) {
171 let step = named::bash(formatdoc! {r#"
172 EXTENSION_ID="$(sed -n 's/^id = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')"
173
174 if [[ "$WORKING_DIR" == "." || -z "$WORKING_DIR" ]]; then
175 TAG="v${{CURRENT_VERSION}}"
176 else
177 TAG="${{EXTENSION_ID}}-v${{CURRENT_VERSION}}"
178 fi
179
180 echo "tag=${{TAG}}" >> "$GITHUB_OUTPUT"
181 "#})
182 .id("determine-tag")
183 .add_env(("CURRENT_VERSION", current_version.to_string()))
184 .add_env(("WORKING_DIR", "${{ inputs.working-directory }}"));
185
186 let tag = StepOutput::new(&step, "tag");
187 (step, tag)
188}
189
190/// Compares the current and previous commit and checks whether versions changed inbetween.
191pub(crate) fn compare_versions() -> (Step<Run>, StepOutput, StepOutput) {
192 let check_needs_bump = named::bash(formatdoc! {
193 r#"
194 CURRENT_VERSION="$({VERSION_CHECK})"
195
196 if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
197 PR_FORK_POINT="$(git merge-base origin/main HEAD)"
198 git checkout "$PR_FORK_POINT"
199 else
200 git checkout "$(git log -1 --format=%H)"~1
201 fi
202
203 PARENT_COMMIT_VERSION="$({VERSION_CHECK})"
204
205 [[ "$CURRENT_VERSION" == "$PARENT_COMMIT_VERSION" ]] && \
206 echo "version_changed=false" >> "$GITHUB_OUTPUT" || \
207 echo "version_changed=true" >> "$GITHUB_OUTPUT"
208
209 echo "current_version=${{CURRENT_VERSION}}" >> "$GITHUB_OUTPUT"
210 "#
211 })
212 .id("compare-versions-check");
213
214 let version_changed = StepOutput::new(&check_needs_bump, "version_changed");
215 let current_version = StepOutput::new(&check_needs_bump, "current_version");
216
217 (check_needs_bump, version_changed, current_version)
218}
219
220fn bump_extension_version(
221 dependencies: &[&NamedJob],
222 current_version: &JobOutput,
223 bump_type: &WorkflowInput,
224 version_changed_output: &JobOutput,
225 force_bump_output: &WorkflowInput,
226 app_id: &WorkflowSecret,
227 app_secret: &WorkflowSecret,
228) -> NamedJob {
229 let (generate_token, generated_token) =
230 generate_token(&app_id.to_string(), &app_secret.to_string()).into();
231 let (bump_version, _new_version, title, body, branch_name) =
232 bump_version(current_version, bump_type);
233
234 let job = steps::dependant_job(dependencies)
235 .defaults(extension_job_defaults())
236 .cond(Expression::new(format!(
237 "{DEFAULT_REPOSITORY_OWNER_GUARD} &&\n({force_bump} == true || {version_changed} == 'false')",
238 force_bump = force_bump_output.expr(),
239 version_changed = version_changed_output.expr(),
240 )))
241 .runs_on(runners::LINUX_SMALL)
242 .timeout_minutes(5u32)
243 .add_step(generate_token)
244 .add_step(steps::checkout_repo())
245 .add_step(cache_rust_dependencies_namespace())
246 .add_step(install_bump_2_version())
247 .add_step(bump_version)
248 .add_step(create_pull_request(
249 title,
250 body,
251 generated_token,
252 branch_name,
253 ));
254
255 named::job(job)
256}
257
258fn install_bump_2_version() -> Step<Run> {
259 named::run(
260 runners::Platform::Linux,
261 "pip install bump2version --break-system-packages",
262 )
263}
264
265fn bump_version(
266 current_version: &JobOutput,
267 bump_type: &WorkflowInput,
268) -> (Step<Run>, StepOutput, StepOutput, StepOutput, StepOutput) {
269 let step = named::bash(formatdoc! {r#"
270 BUMP_FILES=("extension.toml")
271 if [[ -f "Cargo.toml" ]]; then
272 BUMP_FILES+=("Cargo.toml")
273 fi
274
275 bump2version \
276 --search "version = \"{{current_version}}"\" \
277 --replace "version = \"{{new_version}}"\" \
278 --current-version "$OLD_VERSION" \
279 --no-configured-files "$BUMP_TYPE" "${{BUMP_FILES[@]}}"
280
281 if [[ -f "Cargo.toml" ]]; then
282 cargo +stable update --workspace
283 fi
284
285 NEW_VERSION="$({VERSION_CHECK})"
286 EXTENSION_ID="$(sed -n 's/^id = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')"
287 EXTENSION_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')"
288
289 if [[ "$WORKING_DIR" == "." || -z "$WORKING_DIR" ]]; then
290 {{
291 echo "title=Bump version to ${{NEW_VERSION}}";
292 echo "body=This PR bumps the version of this extension to v${{NEW_VERSION}}";
293 echo "branch_name=zed-zippy-autobump";
294 }} >> "$GITHUB_OUTPUT"
295 else
296 {{
297 echo "title=${{EXTENSION_ID}}: Bump to v${{NEW_VERSION}}";
298 echo "body<<EOF";
299 echo "This PR bumps the version of the ${{EXTENSION_NAME}} extension to v${{NEW_VERSION}}.";
300 echo "";
301 echo "Release Notes:";
302 echo "";
303 echo "- N/A";
304 echo "EOF";
305 echo "branch_name=zed-zippy-${{EXTENSION_ID}}-autobump";
306 }} >> "$GITHUB_OUTPUT"
307 fi
308
309 echo "new_version=${{NEW_VERSION}}" >> "$GITHUB_OUTPUT"
310 "#
311 })
312 .id("bump-version")
313 .add_env(("OLD_VERSION", current_version.to_string()))
314 .add_env(("BUMP_TYPE", bump_type.to_string()))
315 .add_env(("WORKING_DIR", "${{ inputs.working-directory }}"));
316
317 let new_version = StepOutput::new(&step, "new_version");
318 let title = StepOutput::new(&step, "title");
319 let body = StepOutput::new(&step, "body");
320 let branch_name = StepOutput::new(&step, "branch_name");
321 (step, new_version, title, body, branch_name)
322}
323
324fn create_pull_request(
325 title: StepOutput,
326 body: StepOutput,
327 generated_token: StepOutput,
328 branch_name: StepOutput,
329) -> Step<Use> {
330 named::uses(
331 "peter-evans",
332 "create-pull-request",
333 "98357b18bf14b5342f975ff684046ec3b2a07725",
334 )
335 .with(
336 Input::default()
337 .add("title", title.to_string())
338 .add("body", body.to_string())
339 .add("commit-message", title.to_string())
340 .add("branch", branch_name.to_string())
341 .add(
342 "committer",
343 "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
344 )
345 .add("base", "main")
346 .add("delete-branch", true)
347 .add("token", generated_token.to_string())
348 .add("sign-commits", true)
349 .add("assignees", Context::github().actor().to_string()),
350 )
351}
352
353fn trigger_release(
354 dependencies: &[&NamedJob],
355 tag: JobOutput,
356 app_id: &WorkflowSecret,
357 app_secret: &WorkflowSecret,
358) -> NamedJob {
359 let extension_registry = RepositoryTarget::new("zed-industries", &["extensions"]);
360 let (generate_token, generated_token) =
361 generate_token(&app_id.to_string(), &app_secret.to_string())
362 .for_repository(extension_registry)
363 .into();
364 let (get_extension_id, extension_id) = get_extension_id();
365 let (release_action, pull_request_number) = release_action(extension_id, tag, &generated_token);
366
367 let job = dependant_job(dependencies)
368 .defaults(extension_job_defaults())
369 .with_repository_owner_guard()
370 .runs_on(runners::LINUX_SMALL)
371 .add_step(generate_token)
372 .add_step(checkout_repo())
373 .add_step(get_extension_id)
374 .add_step(release_action)
375 .add_step(enable_automerge_if_staff(
376 pull_request_number,
377 generated_token,
378 ));
379
380 named::job(job)
381}
382
383fn get_extension_id() -> (Step<Run>, StepOutput) {
384 let step = named::bash(indoc! {
385 r#"
386 EXTENSION_ID="$(sed -n 's/id = \"\(.*\)\"/\1/p' < extension.toml)"
387
388 echo "extension_id=${EXTENSION_ID}" >> "$GITHUB_OUTPUT"
389 "#})
390 .id("get-extension-id");
391
392 let extension_id = StepOutput::new(&step, "extension_id");
393
394 (step, extension_id)
395}
396
397fn release_action(
398 extension_id: StepOutput,
399 tag: JobOutput,
400 generated_token: &StepOutput,
401) -> (Step<Use>, StepOutput) {
402 let step = named::uses(
403 "huacnlee",
404 "zed-extension-action",
405 "82920ff0876879f65ffbcfa3403589114a8919c6",
406 )
407 .id("extension-update")
408 .add_with(("extension-name", extension_id.to_string()))
409 .add_with(("push-to", "zed-industries/extensions"))
410 .add_with(("tag", tag.to_string()))
411 .add_env(("COMMITTER_TOKEN", generated_token.to_string()));
412
413 let pull_request_number = StepOutput::new(&step, "pull-request-number");
414
415 (step, pull_request_number)
416}
417
418fn enable_automerge_if_staff(
419 pull_request_number: StepOutput,
420 generated_token: StepOutput,
421) -> Step<Use> {
422 named::uses(
423 "actions",
424 "github-script",
425 "f28e40c7f34bde8b3046d885e986cb6290c5673b", // v7
426 )
427 .add_with(("github-token", generated_token.to_string()))
428 .add_with((
429 "script",
430 indoc! {r#"
431 const prNumber = process.env.PR_NUMBER;
432 if (!prNumber) {
433 console.log('No pull request number set, skipping automerge.');
434 return;
435 }
436
437 const author = process.env.GITHUB_ACTOR;
438 let isStaff = false;
439 try {
440 const response = await github.rest.teams.getMembershipForUserInOrg({
441 org: 'zed-industries',
442 team_slug: 'staff',
443 username: author
444 });
445 isStaff = response.data.state === 'active';
446 } catch (error) {
447 if (error.status !== 404) {
448 throw error;
449 }
450 }
451
452 if (!isStaff) {
453 console.log(`Actor ${author} is not a staff member, skipping automerge.`);
454 return;
455 }
456
457 // Assign staff member responsible for the bump
458 const pullNumber = parseInt(prNumber);
459
460 await github.rest.issues.addAssignees({
461 owner: 'zed-industries',
462 repo: 'extensions',
463 issue_number: pullNumber,
464 assignees: [author]
465 });
466 console.log(`Assigned ${author} to PR #${prNumber} in zed-industries/extensions`);
467
468 // Get the GraphQL node ID
469 const { data: pr } = await github.rest.pulls.get({
470 owner: 'zed-industries',
471 repo: 'extensions',
472 pull_number: pullNumber
473 });
474
475 await github.graphql(`
476 mutation($pullRequestId: ID!) {
477 enablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId, mergeMethod: SQUASH }) {
478 pullRequest {
479 autoMergeRequest {
480 enabledAt
481 }
482 }
483 }
484 }
485 `, { pullRequestId: pr.node_id });
486
487 console.log(`Automerge enabled for PR #${prNumber} in zed-industries/extensions`);
488 "#},
489 ))
490 .add_env(("PR_NUMBER", pull_request_number.to_string()))
491}
492
493fn extension_workflow_secrets() -> (WorkflowSecret, WorkflowSecret) {
494 let app_id = WorkflowSecret::new("app-id", "The app ID used to create the PR");
495 let app_secret =
496 WorkflowSecret::new("app-secret", "The app secret for the corresponding app ID");
497
498 (app_id, app_secret)
499}