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