extension_bump.rs

  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        &current_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        &current_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}