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, checkout_repo, dependant_job, named,
 10    },
 11    vars::{
 12        JobOutput, StepOutput, WorkflowInput, WorkflowSecret, one_workflow_per_non_main_branch,
 13    },
 14};
 15
 16const VERSION_CHECK: &str =
 17    r#"sed -n 's/^version = \"\(.*\)\"/\1/p' < extension.toml | tr -d '[:space:]'"#;
 18
 19// This is used by various extensions repos in the zed-extensions org to bump extension versions.
 20pub(crate) fn extension_bump() -> Workflow {
 21    let bump_type = WorkflowInput::string("bump-type", Some("patch".to_owned()));
 22    // TODO: Ideally, this would have a default of `false`, but this is currently not
 23    // supported in gh-workflows
 24    let force_bump = WorkflowInput::bool("force-bump", None);
 25    let working_directory = WorkflowInput::string("working-directory", Some(".".to_owned()));
 26
 27    let (app_id, app_secret) = extension_workflow_secrets();
 28    let (check_version_changed, version_changed, current_version) = check_version_changed();
 29
 30    let version_changed = version_changed.as_job_output(&check_version_changed);
 31    let current_version = current_version.as_job_output(&check_version_changed);
 32
 33    let dependencies = [&check_version_changed];
 34    let bump_version = bump_extension_version(
 35        &dependencies,
 36        &current_version,
 37        &bump_type,
 38        &version_changed,
 39        &force_bump,
 40        &app_id,
 41        &app_secret,
 42    );
 43    let create_label = create_version_label(
 44        &dependencies,
 45        &version_changed,
 46        &current_version,
 47        &app_id,
 48        &app_secret,
 49    );
 50    let trigger_release = trigger_release(
 51        &[&check_version_changed, &create_label],
 52        current_version,
 53        &app_id,
 54        &app_secret,
 55    );
 56
 57    named::workflow()
 58        .add_event(
 59            Event::default().workflow_call(
 60                WorkflowCall::default()
 61                    .add_input(bump_type.name, bump_type.call_input())
 62                    .add_input(force_bump.name, force_bump.call_input())
 63                    .add_input(working_directory.name, working_directory.call_input())
 64                    .secrets([
 65                        (app_id.name.to_owned(), app_id.secret_configuration()),
 66                        (
 67                            app_secret.name.to_owned(),
 68                            app_secret.secret_configuration(),
 69                        ),
 70                    ]),
 71            ),
 72        )
 73        .concurrency(one_workflow_per_non_main_branch())
 74        .add_env(("CARGO_TERM_COLOR", "always"))
 75        .add_env(("RUST_BACKTRACE", 1))
 76        .add_env(("CARGO_INCREMENTAL", 0))
 77        .add_env((
 78            "ZED_EXTENSION_CLI_SHA",
 79            extension_tests::ZED_EXTENSION_CLI_SHA,
 80        ))
 81        .add_job(check_version_changed.name, check_version_changed.job)
 82        .add_job(bump_version.name, bump_version.job)
 83        .add_job(create_label.name, create_label.job)
 84        .add_job(trigger_release.name, trigger_release.job)
 85}
 86
 87fn extension_job_defaults() -> Defaults {
 88    Defaults::default().run(
 89        RunDefaults::default()
 90            .shell(BASH_SHELL)
 91            .working_directory("${{ inputs.working-directory }}"),
 92    )
 93}
 94
 95fn check_version_changed() -> (NamedJob, StepOutput, StepOutput) {
 96    let (compare_versions, version_changed, current_version) = compare_versions();
 97
 98    let job = Job::default()
 99        .defaults(extension_job_defaults())
100        .with_repository_owner_guard()
101        .outputs([
102            (version_changed.name.to_owned(), version_changed.to_string()),
103            (
104                current_version.name.to_string(),
105                current_version.to_string(),
106            ),
107        ])
108        .runs_on(runners::LINUX_SMALL)
109        .timeout_minutes(1u32)
110        .add_step(steps::checkout_repo().with_full_history())
111        .add_step(compare_versions);
112
113    (named::job(job), version_changed, current_version)
114}
115
116fn create_version_label(
117    dependencies: &[&NamedJob],
118    version_changed_output: &JobOutput,
119    current_version: &JobOutput,
120    app_id: &WorkflowSecret,
121    app_secret: &WorkflowSecret,
122) -> NamedJob {
123    let (generate_token, generated_token) =
124        generate_token(&app_id.to_string(), &app_secret.to_string(), None);
125    let job = steps::dependant_job(dependencies)
126        .defaults(extension_job_defaults())
127        .cond(Expression::new(format!(
128            "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.event_name == 'push' && \
129            github.ref == 'refs/heads/main' && {version_changed} == 'true'",
130            version_changed = version_changed_output.expr(),
131        )))
132        .runs_on(runners::LINUX_SMALL)
133        .timeout_minutes(1u32)
134        .add_step(generate_token)
135        .add_step(steps::checkout_repo())
136        .add_step(create_version_tag(current_version, generated_token));
137
138    named::job(job)
139}
140
141fn create_version_tag(current_version: &JobOutput, generated_token: StepOutput) -> Step<Use> {
142    named::uses("actions", "github-script", "v7").with(
143        Input::default()
144            .add(
145                "script",
146                formatdoc! {r#"
147                    github.rest.git.createRef({{
148                        owner: context.repo.owner,
149                        repo: context.repo.repo,
150                        ref: 'refs/tags/v{current_version}',
151                        sha: context.sha
152                    }})"#
153                },
154            )
155            .add("github-token", generated_token.to_string()),
156    )
157}
158
159/// Compares the current and previous commit and checks whether versions changed inbetween.
160pub(crate) fn compare_versions() -> (Step<Run>, StepOutput, StepOutput) {
161    let check_needs_bump = named::bash(formatdoc! {
162    r#"
163        CURRENT_VERSION="$({VERSION_CHECK})"
164
165        if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
166            PR_FORK_POINT="$(git merge-base origin/main HEAD)"
167            git checkout "$PR_FORK_POINT"
168        else
169            git checkout "$(git log -1 --format=%H)"~1
170        fi
171
172        PARENT_COMMIT_VERSION="$({VERSION_CHECK})"
173
174        [[ "$CURRENT_VERSION" == "$PARENT_COMMIT_VERSION" ]] && \
175            echo "version_changed=false" >> "$GITHUB_OUTPUT" || \
176            echo "version_changed=true" >> "$GITHUB_OUTPUT"
177
178        echo "current_version=${{CURRENT_VERSION}}" >> "$GITHUB_OUTPUT"
179        "#
180    })
181    .id("compare-versions-check");
182
183    let version_changed = StepOutput::new(&check_needs_bump, "version_changed");
184    let current_version = StepOutput::new(&check_needs_bump, "current_version");
185
186    (check_needs_bump, version_changed, current_version)
187}
188
189fn bump_extension_version(
190    dependencies: &[&NamedJob],
191    current_version: &JobOutput,
192    bump_type: &WorkflowInput,
193    version_changed_output: &JobOutput,
194    force_bump_output: &WorkflowInput,
195    app_id: &WorkflowSecret,
196    app_secret: &WorkflowSecret,
197) -> NamedJob {
198    let (generate_token, generated_token) =
199        generate_token(&app_id.to_string(), &app_secret.to_string(), None);
200    let (bump_version, _new_version, title, body, branch_name) =
201        bump_version(current_version, bump_type);
202
203    let job = steps::dependant_job(dependencies)
204        .defaults(extension_job_defaults())
205        .cond(Expression::new(format!(
206            "{DEFAULT_REPOSITORY_OWNER_GUARD} &&\n({force_bump} == true || {version_changed} == 'false')",
207            force_bump = force_bump_output.expr(),
208            version_changed = version_changed_output.expr(),
209        )))
210        .runs_on(runners::LINUX_SMALL)
211        .timeout_minutes(3u32)
212        .add_step(generate_token)
213        .add_step(steps::checkout_repo())
214        .add_step(install_bump_2_version())
215        .add_step(bump_version)
216        .add_step(create_pull_request(
217            title,
218            body,
219            generated_token,
220            branch_name,
221        ));
222
223    named::job(job)
224}
225
226pub(crate) fn generate_token(
227    app_id_source: &str,
228    app_secret_source: &str,
229    repository_target: Option<RepositoryTarget>,
230) -> (Step<Use>, StepOutput) {
231    let step = named::uses("actions", "create-github-app-token", "v2")
232        .id("generate-token")
233        .add_with(
234            Input::default()
235                .add("app-id", app_id_source)
236                .add("private-key", app_secret_source)
237                .when_some(
238                    repository_target,
239                    |input,
240                     RepositoryTarget {
241                         owner,
242                         repositories,
243                         permissions,
244                     }| {
245                        input
246                            .when_some(owner, |input, owner| input.add("owner", owner))
247                            .when_some(repositories, |input, repositories| {
248                                input.add("repositories", repositories)
249                            })
250                            .when_some(permissions, |input, permissions| {
251                                permissions
252                                    .into_iter()
253                                    .fold(input, |input, (permission, level)| {
254                                        input.add(
255                                            permission,
256                                            serde_json::to_value(&level).unwrap_or_default(),
257                                        )
258                                    })
259                            })
260                    },
261                ),
262        );
263
264    let generated_token = StepOutput::new(&step, "token");
265
266    (step, generated_token)
267}
268
269fn install_bump_2_version() -> Step<Run> {
270    named::run(
271        runners::Platform::Linux,
272        "pip install bump2version --break-system-packages",
273    )
274}
275
276fn bump_version(
277    current_version: &JobOutput,
278    bump_type: &WorkflowInput,
279) -> (Step<Run>, StepOutput, StepOutput, StepOutput, StepOutput) {
280    let step = named::bash(formatdoc! {r#"
281        BUMP_FILES=("extension.toml")
282        if [[ -f "Cargo.toml" ]]; then
283            BUMP_FILES+=("Cargo.toml")
284        fi
285
286        bump2version \
287            --search "version = \"{{current_version}}"\" \
288            --replace "version = \"{{new_version}}"\" \
289            --current-version "$OLD_VERSION" \
290            --no-configured-files "$BUMP_TYPE" "${{BUMP_FILES[@]}}"
291
292        if [[ -f "Cargo.toml" ]]; then
293            cargo update --workspace
294        fi
295
296        NEW_VERSION="$({VERSION_CHECK})"
297        EXTENSION_ID="$(sed -n 's/^id = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')"
298        EXTENSION_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')"
299
300        if [[ "$WORKING_DIR" == "." || -z "$WORKING_DIR" ]]; then
301            {{
302                echo "title=Bump version to ${{NEW_VERSION}}";
303                echo "body=This PR bumps the version of this extension to v${{NEW_VERSION}}";
304                echo "branch_name=zed-zippy-autobump";
305            }} >> "$GITHUB_OUTPUT"
306        else
307            {{
308                echo "title=${{EXTENSION_ID}}: Bump to v${{NEW_VERSION}}";
309                echo "body=This PR bumps the version of the ${{EXTENSION_NAME}} extension to v${{NEW_VERSION}}";
310                echo "branch_name=zed-zippy-${{EXTENSION_ID}}-autobump";
311            }} >> "$GITHUB_OUTPUT"
312        fi
313
314        echo "new_version=${{NEW_VERSION}}" >> "$GITHUB_OUTPUT"
315        "#
316    })
317    .id("bump-version")
318    .add_env(("OLD_VERSION", current_version.to_string()))
319    .add_env(("BUMP_TYPE", bump_type.to_string()))
320    .add_env(("WORKING_DIR", "${{ inputs.working-directory }}"));
321
322    let new_version = StepOutput::new(&step, "new_version");
323    let title = StepOutput::new(&step, "title");
324    let body = StepOutput::new(&step, "body");
325    let branch_name = StepOutput::new(&step, "branch_name");
326    (step, new_version, title, body, branch_name)
327}
328
329fn create_pull_request(
330    title: StepOutput,
331    body: StepOutput,
332    generated_token: StepOutput,
333    branch_name: StepOutput,
334) -> Step<Use> {
335    named::uses("peter-evans", "create-pull-request", "v7").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    version: 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) = generate_token(
361        &app_id.to_string(),
362        &app_secret.to_string(),
363        Some(extension_registry),
364    );
365    let (get_extension_id, extension_id) = get_extension_id();
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(extension_id, version, generated_token));
375
376    named::job(job)
377}
378
379fn get_extension_id() -> (Step<Run>, StepOutput) {
380    let step = named::bash(indoc! {
381    r#"
382        EXTENSION_ID="$(sed -n 's/id = \"\(.*\)\"/\1/p' < extension.toml)"
383
384        echo "extension_id=${EXTENSION_ID}" >> "$GITHUB_OUTPUT"
385    "#})
386    .id("get-extension-id");
387
388    let extension_id = StepOutput::new(&step, "extension_id");
389
390    (step, extension_id)
391}
392
393fn release_action(
394    extension_id: StepOutput,
395    version: JobOutput,
396    generated_token: StepOutput,
397) -> Step<Use> {
398    named::uses("huacnlee", "zed-extension-action", "v2")
399        .add_with(("extension-name", extension_id.to_string()))
400        .add_with(("push-to", "zed-industries/extensions"))
401        .add_with(("tag", format!("v{version}")))
402        .add_env(("COMMITTER_TOKEN", generated_token.to_string()))
403}
404
405fn extension_workflow_secrets() -> (WorkflowSecret, WorkflowSecret) {
406    let app_id = WorkflowSecret::new("app-id", "The app ID used to create the PR");
407    let app_secret =
408        WorkflowSecret::new("app-secret", "The app secret for the corresponding app ID");
409
410    (app_id, app_secret)
411}
412
413pub(crate) struct RepositoryTarget {
414    owner: Option<String>,
415    repositories: Option<String>,
416    permissions: Option<Vec<(String, Level)>>,
417}
418
419impl RepositoryTarget {
420    pub fn new<T: ToString>(owner: T, repositories: &[&str]) -> Self {
421        Self {
422            owner: Some(owner.to_string()),
423            repositories: Some(repositories.join("\n")),
424            permissions: None,
425        }
426    }
427
428    pub fn current() -> Self {
429        Self {
430            owner: None,
431            repositories: None,
432            permissions: None,
433        }
434    }
435
436    pub fn permissions(self, permissions: impl Into<Vec<(String, Level)>>) -> Self {
437        Self {
438            permissions: Some(permissions.into()),
439            ..self
440        }
441    }
442}