Add extension CI rollout workflow

MrSubidubi created

Change summary

.github/workflows/extension_workflow_rollout.yml                | 101 +
tooling/xtask/src/tasks/workflows.rs                            |   2 
tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs | 174 +++
3 files changed, 277 insertions(+)

Detailed changes

.github/workflows/extension_workflow_rollout.yml 🔗

@@ -0,0 +1,101 @@
+# Generated from xtask::workflows::extension_workflow_rollout
+# Rebuild with `cargo xtask workflows`.
+name: extension_workflow_rollout
+env:
+  CARGO_TERM_COLOR: always
+on:
+  workflow_dispatch: {}
+jobs:
+  fetch_extension_repos:
+    runs-on: namespace-profile-2x4-ubuntu-2404
+    steps:
+    - id: list-repos
+      name: extension_workflow_rollout::fetch_extension_repos::get_repositories
+      uses: actions/github-script@v7
+      with:
+        script: |
+          const repos = await github.paginate(github.rest.repos.listForOrg, {
+              org: 'zed-extensions',
+              type: 'public',
+              per_page: 100,
+          });
+
+          const filteredRepos = repos
+              .filter(repo => !repo.archived)
+              .filter(repo => repo.name !== 'workflows' && repo.name !== 'material-icon-theme')
+              .map(repo => repo.name);
+
+          console.log(`Found ${filteredRepos.length} extension repos`);
+          return filteredRepos;
+        result-encoding: json
+    outputs:
+      repos: ${{ steps.list-repos.outputs.result }}
+    timeout-minutes: 5
+  rollout_workflows_to_extension:
+    needs:
+    - fetch_extension_repos
+    if: needs.fetch_extension_repos.outputs.repos != '[]'
+    runs-on: namespace-profile-2x4-ubuntu-2404
+    strategy:
+      matrix:
+        repo: ${{ fromJson(needs.fetch_extension_repos.outputs.repos) }}
+      fail-fast: false
+      max-parallel: 5
+    steps:
+    - id: get-app-token
+      name: steps::authenticate_as_zippy
+      uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1
+      with:
+        app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
+        private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
+    - name: checkout_zed_repo
+      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      with:
+        clean: false
+        path: zed
+    - name: steps::checkout_repo_with_token
+      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      with:
+        clean: false
+        token: ${{ steps.get-app-token.outputs.token }}
+        repository: zed-extensions/${{ matrix.repo }}
+        path: extension
+    - name: extension_workflow_rollout::rollout_workflows_to_extension::copy_workflow_files
+      run: |
+        mkdir -p extension/.github/workflows
+        cp zed/extensions/workflows/*.yml extension/.github/workflows/
+      shell: bash -euxo pipefail {0}
+    - id: short-sha
+      name: extension_workflow_rollout::rollout_workflows_to_extension::get_short_sha
+      run: |
+        echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
+      shell: bash -euxo pipefail {0}
+      working-directory: zed
+    - id: create-pr
+      name: extension_workflow_rollout::rollout_workflows_to_extension::create_pull_request
+      uses: peter-evans/create-pull-request@v7
+      with:
+        path: extension
+        title: Update CI workflows to zed@${{ steps.short-sha.outputs.sha_short }}
+        body: |
+          This PR updates the CI workflow files from the main Zed repository
+          based on the commit zed-industries/zed@${{ github.sha }}
+        commit-message: Update CI workflows to zed@${{ steps.short-sha.outputs.sha_short }}
+        branch: update-workflows
+        committer: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
+        author: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
+        base: main
+        delete-branch: true
+        token: ${{ steps.get-app-token.outputs.token }}
+        sign-commits: true
+    - name: extension_workflow_rollout::rollout_workflows_to_extension::enable_auto_merge
+      run: |
+        PR_NUMBER="${{ steps.create-pr.outputs.pull-request-number }}"
+        if [ -n "$PR_NUMBER" ]; then
+            cd extension
+            gh pr merge "$PR_NUMBER" --auto --squash
+        fi
+      shell: bash -euxo pipefail {0}
+      env:
+        GH_TOKEN: ${{ steps.get-app-token.outputs.token }}
+    timeout-minutes: 10

tooling/xtask/src/tasks/workflows.rs 🔗

@@ -12,6 +12,7 @@ mod danger;
 mod extension_bump;
 mod extension_release;
 mod extension_tests;
+mod extension_workflow_rollout;
 mod extensions;
 mod nix_build;
 mod release_nightly;
@@ -121,6 +122,7 @@ pub fn run_workflows(_: GenerateWorkflowArgs) -> Result<()> {
         WorkflowFile::zed(extension_tests::extension_tests),
         WorkflowFile::zed(extension_bump::extension_bump),
         WorkflowFile::zed(extension_release::extension_release),
+        WorkflowFile::zed(extension_workflow_rollout::extension_workflow_rollout),
         /* workflows used for CI/CD in extension repositories */
         WorkflowFile::extension(extensions::run_tests::run_tests),
         WorkflowFile::extension(extensions::bump_version::bump_version),

tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs 🔗

@@ -0,0 +1,174 @@
+use gh_workflow::{Event, Expression, Job, Run, Step, Strategy, Use, Workflow, WorkflowDispatch};
+use indoc::indoc;
+use serde_json::json;
+
+use crate::tasks::workflows::{
+    runners,
+    steps::{self, NamedJob, named},
+    vars::StepOutput,
+};
+
+const EXCLUDED_REPOS: &[&str] = &["workflows", "material-icon-theme"];
+
+pub(crate) fn extension_workflow_rollout() -> Workflow {
+    let fetch_repos = fetch_extension_repos();
+    let rollout_workflows = rollout_workflows_to_extension(&fetch_repos);
+
+    named::workflow()
+        .on(Event::default().workflow_dispatch(WorkflowDispatch::default()))
+        .add_env(("CARGO_TERM_COLOR", "always"))
+        .add_job(fetch_repos.name, fetch_repos.job)
+        .add_job(rollout_workflows.name, rollout_workflows.job)
+}
+
+fn fetch_extension_repos() -> NamedJob {
+    fn get_repositories() -> (Step<Use>, StepOutput) {
+        let exclusion_filter = EXCLUDED_REPOS
+            .iter()
+            .map(|repo| format!("repo.name !== '{}'", repo))
+            .collect::<Vec<_>>()
+            .join(" && ");
+
+        let step = named::uses("actions", "github-script", "v7")
+            .id("list-repos")
+            .add_with((
+                "script",
+                format!(
+                    indoc! {r#"
+                        const repos = await github.paginate(github.rest.repos.listForOrg, {{
+                            org: 'zed-extensions',
+                            type: 'public',
+                            per_page: 100,
+                        }});
+
+                        const filteredRepos = repos
+                            .filter(repo => !repo.archived)
+                            .filter(repo => {})
+                            .map(repo => repo.name);
+
+                        console.log(`Found ${{filteredRepos.length}} extension repos`);
+                        return filteredRepos;
+                    "#},
+                    exclusion_filter
+                ),
+            ))
+            .add_with(("result-encoding", "json"));
+
+        let filtered_repos = StepOutput::new(&step, "result");
+
+        (step, filtered_repos)
+    }
+
+    let (get_org_repositories, list_repos_output) = get_repositories();
+
+    let job = Job::default()
+        .runs_on(runners::LINUX_SMALL)
+        .timeout_minutes(5u32)
+        .outputs([("repos".to_owned(), list_repos_output.to_string())])
+        .add_step(get_org_repositories);
+
+    named::job(job)
+}
+
+fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
+    fn checkout_zed_repo() -> Step<Use> {
+        steps::checkout_repo()
+            .name("checkout_zed_repo")
+            .add_with(("path", "zed"))
+    }
+
+    fn checkout_extension_repo(token: &StepOutput) -> Step<Use> {
+        steps::checkout_repo_with_token(token)
+            .add_with(("repository", "zed-extensions/${{ matrix.repo }}"))
+            .add_with(("path", "extension"))
+    }
+
+    fn copy_workflow_files() -> Step<Run> {
+        named::bash(indoc! {r#"
+            mkdir -p extension/.github/workflows
+            cp zed/extensions/workflows/*.yml extension/.github/workflows/
+        "#})
+    }
+
+    fn get_short_sha() -> (Step<Run>, StepOutput) {
+        let step = named::bash(indoc! {r#"
+            echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
+        "#})
+        .id("short-sha")
+        .working_directory("zed");
+
+        let step_output = StepOutput::new(&step, "sha_short");
+
+        (step, step_output)
+    }
+
+    fn create_pull_request(token: &StepOutput, short_sha: StepOutput) -> Step<Use> {
+        let title = format!("Update CI workflows to zed@{}", short_sha);
+
+        named::uses("peter-evans", "create-pull-request", "v7")
+            .add_with(("path", "extension"))
+            .add_with(("title", title.clone()))
+            .add_with((
+                "body",
+                indoc! {r#"
+                    This PR updates the CI workflow files from the main Zed repository
+                    based on the commit zed-industries/zed@${{ github.sha }}
+                "#},
+            ))
+            .add_with(("commit-message", title))
+            .add_with(("branch", "update-workflows"))
+            .add_with((
+                "committer",
+                "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
+            ))
+            .add_with((
+                "author",
+                "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
+            ))
+            .add_with(("base", "main"))
+            .add_with(("delete-branch", true))
+            .add_with(("token", token.to_string()))
+            .add_with(("sign-commits", true))
+            .id("create-pr")
+    }
+
+    fn enable_auto_merge(token: &StepOutput) -> Step<gh_workflow::Run> {
+        named::bash(indoc! {r#"
+            PR_NUMBER="${{ steps.create-pr.outputs.pull-request-number }}"
+            if [ -n "$PR_NUMBER" ]; then
+                cd extension
+                gh pr merge "$PR_NUMBER" --auto --squash
+            fi
+        "#})
+        .add_env(("GH_TOKEN", token.to_string()))
+    }
+
+    let (authenticate, token) = steps::authenticate_as_zippy();
+    let (calculate_short_sha, short_sha) = get_short_sha();
+
+    let job = Job::default()
+        .needs([fetch_repos_job.name.clone()])
+        .cond(Expression::new(format!(
+            "needs.{}.outputs.repos != '[]'",
+            fetch_repos_job.name
+        )))
+        .runs_on(runners::LINUX_SMALL)
+        .timeout_minutes(10u32)
+        .strategy(
+            Strategy::default()
+                .fail_fast(false)
+                .max_parallel(5u32)
+                .matrix(json!({
+                    "repo": format!("${{{{ fromJson(needs.{}.outputs.repos) }}}}", fetch_repos_job.name)
+                })),
+        )
+        .add_step(authenticate)
+        .add_step(checkout_zed_repo())
+        .add_step(checkout_extension_repo(&token))
+        .add_step(copy_workflow_files())
+        .add_step(calculate_short_sha)
+        .add_step(create_pull_request(&token, short_sha))
+        .add_step(enable_auto_merge(&token));
+
+    named::job(job)
+}