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