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