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