extension_workflow_rollout.rs

  1use gh_workflow::{
  2    Event, Expression, Job, Level, Run, Step, Strategy, Use, Workflow, WorkflowDispatch,
  3};
  4use indoc::formatdoc;
  5use indoc::indoc;
  6use serde_json::json;
  7
  8use crate::tasks::workflows::steps::CheckoutStep;
  9use crate::tasks::workflows::{
 10    extension_bump::{RepositoryTarget, generate_token},
 11    runners,
 12    steps::{self, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, named},
 13    vars::{self, StepOutput},
 14};
 15
 16const ROLLOUT_TAG_NAME: &str = "extension-workflows";
 17
 18pub(crate) fn extension_workflow_rollout() -> Workflow {
 19    let fetch_repos = fetch_extension_repos();
 20    let rollout_workflows = rollout_workflows_to_extension(&fetch_repos);
 21    let create_tag = create_rollout_tag(&rollout_workflows);
 22
 23    named::workflow()
 24        .on(Event::default().workflow_dispatch(WorkflowDispatch::default()))
 25        .add_env(("CARGO_TERM_COLOR", "always"))
 26        .add_job(fetch_repos.name, fetch_repos.job)
 27        .add_job(rollout_workflows.name, rollout_workflows.job)
 28        .add_job(create_tag.name, create_tag.job)
 29}
 30
 31fn fetch_extension_repos() -> NamedJob {
 32    fn get_repositories() -> (Step<Use>, StepOutput) {
 33        let step = named::uses("actions", "github-script", "v7")
 34            .id("list-repos")
 35            .add_with((
 36                "script",
 37                indoc::indoc! {r#"
 38                    const repos = await github.paginate(github.rest.repos.listForOrg, {
 39                        org: 'zed-extensions',
 40                        type: 'public',
 41                        per_page: 100,
 42                    });
 43
 44                    const filteredRepos = repos
 45                        .filter(repo => !repo.archived)
 46                        .map(repo => repo.name);
 47
 48                    console.log(`Found ${filteredRepos.length} extension repos`);
 49                    return filteredRepos;
 50                "#},
 51            ))
 52            .add_with(("result-encoding", "json"));
 53
 54        let filtered_repos = StepOutput::new(&step, "result");
 55
 56        (step, filtered_repos)
 57    }
 58
 59    let (get_org_repositories, list_repos_output) = get_repositories();
 60
 61    let job = Job::default()
 62        .cond(Expression::new(format!(
 63            "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.ref == 'refs/heads/main'"
 64        )))
 65        .runs_on(runners::LINUX_SMALL)
 66        .timeout_minutes(5u32)
 67        .outputs([("repos".to_owned(), list_repos_output.to_string())])
 68        .add_step(get_org_repositories);
 69
 70    named::job(job)
 71}
 72
 73fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
 74    fn checkout_zed_repo() -> CheckoutStep {
 75        steps::checkout_repo()
 76            .with_full_history()
 77            .with_path("zed")
 78            .with_custom_name("checkout_zed_repo")
 79    }
 80
 81    fn checkout_extension_repo(token: &StepOutput) -> CheckoutStep {
 82        steps::checkout_repo()
 83            .with_custom_name("checkout_extension_repo")
 84            .with_token(token)
 85            .with_repository("zed-extensions/${{ matrix.repo }}")
 86            .with_path("extension")
 87    }
 88
 89    fn get_previous_tag_commit() -> (Step<Run>, StepOutput) {
 90        let step = named::bash(formatdoc! {r#"
 91            PREV_COMMIT=$(git rev-parse "{ROLLOUT_TAG_NAME}^{{commit}}" 2>/dev/null || echo "")
 92            if [ -z "$PREV_COMMIT" ]; then
 93                echo "::error::No previous rollout tag '{ROLLOUT_TAG_NAME}' found. Cannot determine file changes."
 94                exit 1
 95            fi
 96            echo "Found previous rollout at commit: $PREV_COMMIT"
 97            echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT"
 98        "#})
 99        .id("prev-tag")
100        .working_directory("zed");
101
102        let step_output = StepOutput::new(&step, "prev_commit");
103
104        (step, step_output)
105    }
106
107    fn get_removed_files(prev_commit: &StepOutput) -> (Step<Run>, StepOutput) {
108        let step = named::bash(formatdoc! {r#"
109            PREV_COMMIT="{prev_commit}"
110
111            if [ "${{{{ matrix.repo }}}}" = "workflows" ]; then
112                WORKFLOW_DIR="extensions/workflows"
113            else
114                WORKFLOW_DIR="extensions/workflows/shared"
115            fi
116
117            echo "Calculating changes from $PREV_COMMIT to HEAD for $WORKFLOW_DIR"
118
119            # Get deleted files (status D) and renamed files (status R - old name needs removal)
120            # Using -M to detect renames, then extracting files that are gone from their original location
121            REMOVED_FILES=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \
122                awk '/^D/ {{ print $2 }} /^R/ {{ print $2 }}' | \
123                xargs -I{{}} basename {{}} 2>/dev/null | \
124                tr '\n' ' ' || echo "")
125
126            REMOVED_FILES=$(echo "$REMOVED_FILES" | xargs)
127
128            echo "Files to remove: $REMOVED_FILES"
129            echo "removed_files=$REMOVED_FILES" >> "$GITHUB_OUTPUT"
130        "#})
131        .id("calc-changes")
132        .working_directory("zed");
133
134        let removed_files = StepOutput::new(&step, "removed_files");
135
136        (step, removed_files)
137    }
138
139    fn sync_workflow_files(removed_files: &StepOutput) -> Step<Run> {
140        named::bash(formatdoc! {r#"
141            REMOVED_FILES="{removed_files}"
142
143            mkdir -p extension/.github/workflows
144            cd extension/.github/workflows
145
146            if [ -n "$REMOVED_FILES" ]; then
147                for file in $REMOVED_FILES; do
148                    if [ -f "$file" ]; then
149                        rm -f "$file"
150                    fi
151                done
152            fi
153
154            cd - > /dev/null
155
156            if [ "${{{{ matrix.repo }}}}" = "workflows" ]; then
157                cp zed/extensions/workflows/*.yml extension/.github/workflows/
158            else
159                cp zed/extensions/workflows/shared/*.yml extension/.github/workflows/
160            fi
161        "#})
162    }
163
164    fn get_short_sha() -> (Step<Run>, StepOutput) {
165        let step = named::bash(indoc::indoc! {r#"
166            echo "sha_short=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
167        "#})
168        .id("short-sha")
169        .working_directory("zed");
170
171        let step_output = StepOutput::new(&step, "sha_short");
172
173        (step, step_output)
174    }
175
176    fn create_pull_request(token: &StepOutput, short_sha: &StepOutput) -> Step<Use> {
177        let title = format!("Update CI workflows to `{short_sha}`");
178
179        named::uses("peter-evans", "create-pull-request", "v7")
180            .add_with(("path", "extension"))
181            .add_with(("title", title.clone()))
182            .add_with((
183                "body",
184                indoc::indoc! {r#"
185                    This PR updates the CI workflow files from the main Zed repository
186                    based on the commit zed-industries/zed@${{ github.sha }}
187                "#},
188            ))
189            .add_with(("commit-message", title))
190            .add_with(("branch", "update-workflows"))
191            .add_with((
192                "committer",
193                "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
194            ))
195            .add_with((
196                "author",
197                "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
198            ))
199            .add_with(("base", "main"))
200            .add_with(("delete-branch", true))
201            .add_with(("token", token.to_string()))
202            .add_with(("sign-commits", true))
203            .id("create-pr")
204    }
205
206    fn enable_auto_merge(token: &StepOutput) -> Step<gh_workflow::Run> {
207        named::bash(indoc::indoc! {r#"
208            PR_NUMBER="${{ steps.create-pr.outputs.pull-request-number }}"
209            if [ -n "$PR_NUMBER" ]; then
210                cd extension
211                gh pr merge "$PR_NUMBER" --auto --squash
212            fi
213        "#})
214        .add_env(("GH_TOKEN", token.to_string()))
215    }
216
217    let (authenticate, token) = generate_token(
218        vars::ZED_ZIPPY_APP_ID,
219        vars::ZED_ZIPPY_APP_PRIVATE_KEY,
220        Some(
221            RepositoryTarget::new("zed-extensions", &["${{ matrix.repo }}"]).permissions([
222                ("permission-pull-requests".to_owned(), Level::Write),
223                ("permission-contents".to_owned(), Level::Write),
224                ("permission-workflows".to_owned(), Level::Write),
225            ]),
226        ),
227    );
228    let (get_prev_tag, prev_commit) = get_previous_tag_commit();
229    let (calc_changes, removed_files) = get_removed_files(&prev_commit);
230    let (calculate_short_sha, short_sha) = get_short_sha();
231
232    let job = Job::default()
233        .needs([fetch_repos_job.name.clone()])
234        .cond(Expression::new(format!(
235            "needs.{}.outputs.repos != '[]'",
236            fetch_repos_job.name
237        )))
238        .runs_on(runners::LINUX_SMALL)
239        .timeout_minutes(10u32)
240        .strategy(
241            Strategy::default()
242                .fail_fast(false)
243                .max_parallel(10u32)
244                .matrix(json!({
245                    "repo": format!("${{{{ fromJson(needs.{}.outputs.repos) }}}}", fetch_repos_job.name)
246                })),
247        )
248        .add_step(authenticate)
249        .add_step(checkout_zed_repo())
250        .add_step(checkout_extension_repo(&token))
251        .add_step(get_prev_tag)
252        .add_step(calc_changes)
253        .add_step(sync_workflow_files(&removed_files))
254        .add_step(calculate_short_sha)
255        .add_step(create_pull_request(&token, &short_sha))
256        .add_step(enable_auto_merge(&token));
257
258    named::job(job)
259}
260
261fn create_rollout_tag(rollout_job: &NamedJob) -> NamedJob {
262    fn checkout_zed_repo(token: &StepOutput) -> CheckoutStep {
263        steps::checkout_repo().with_full_history().with_token(token)
264    }
265
266    fn update_rollout_tag() -> Step<Run> {
267        named::bash(formatdoc! {r#"
268            if git rev-parse "{ROLLOUT_TAG_NAME}" >/dev/null 2>&1; then
269                git tag -d "{ROLLOUT_TAG_NAME}"
270                git push origin ":refs/tags/{ROLLOUT_TAG_NAME}" || true
271            fi
272
273            echo "Creating new tag '{ROLLOUT_TAG_NAME}' at $(git rev-parse --short HEAD)"
274            git tag "{ROLLOUT_TAG_NAME}"
275            git push origin "{ROLLOUT_TAG_NAME}"
276        "#})
277    }
278
279    fn configure_git() -> Step<Run> {
280        named::bash(indoc! {r#"
281            git config user.name "zed-zippy[bot]"
282            git config user.email "234243425+zed-zippy[bot]@users.noreply.github.com"
283        "#})
284    }
285
286    let (authenticate, token) = generate_token(
287        vars::ZED_ZIPPY_APP_ID,
288        vars::ZED_ZIPPY_APP_PRIVATE_KEY,
289        Some(
290            RepositoryTarget::current()
291                .permissions([("permission-contents".to_owned(), Level::Write)]),
292        ),
293    );
294
295    let job = Job::default()
296        .needs([rollout_job.name.clone()])
297        .runs_on(runners::LINUX_SMALL)
298        .timeout_minutes(1u32)
299        .add_step(authenticate)
300        .add_step(checkout_zed_repo(&token))
301        .add_step(configure_git())
302        .add_step(update_rollout_tag());
303
304    named::job(job)
305}