extension_bump.rs

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