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