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::steps::cache_rust_dependencies_namespace;
 10use crate::tasks::workflows::vars::JobOutput;
 11use crate::tasks::workflows::{
 12    extension_bump::{RepositoryTarget, generate_token},
 13    runners,
 14    steps::{self, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, named},
 15    vars::{self, StepOutput, WorkflowInput},
 16};
 17
 18const ROLLOUT_TAG_NAME: &str = "extension-workflows";
 19const WORKFLOW_ARTIFACT_NAME: &str = "extension-workflow-files";
 20
 21pub(crate) fn extension_workflow_rollout() -> Workflow {
 22    let filter_repos_input = WorkflowInput::string("filter-repos", Some(String::new()))
 23        .description(
 24            "Comma-separated list of repository names to rollout to. Leave empty for all repos.",
 25        );
 26    let extra_context_input = WorkflowInput::string("change-description", Some(String::new()))
 27        .description("Description for the changes to be expected with this rollout");
 28
 29    let (fetch_repos, removed_ci, removed_shared) = fetch_extension_repos(&filter_repos_input);
 30    let rollout_workflows = rollout_workflows_to_extension(
 31        &fetch_repos,
 32        removed_ci,
 33        removed_shared,
 34        &extra_context_input,
 35    );
 36    let create_tag = create_rollout_tag(&rollout_workflows, &filter_repos_input);
 37
 38    named::workflow()
 39        .on(Event::default().workflow_dispatch(
 40            WorkflowDispatch::default()
 41                .add_input(filter_repos_input.name, filter_repos_input.input())
 42                .add_input(extra_context_input.name, extra_context_input.input()),
 43        ))
 44        .add_env(("CARGO_TERM_COLOR", "always"))
 45        .add_job(fetch_repos.name, fetch_repos.job)
 46        .add_job(rollout_workflows.name, rollout_workflows.job)
 47        .add_job(create_tag.name, create_tag.job)
 48}
 49
 50fn fetch_extension_repos(filter_repos_input: &WorkflowInput) -> (NamedJob, JobOutput, JobOutput) {
 51    fn get_repositories(filter_repos_input: &WorkflowInput) -> (Step<Use>, StepOutput) {
 52        let step = named::uses("actions", "github-script", "v7")
 53            .id("list-repos")
 54            .add_with((
 55                "script",
 56                formatdoc! {r#"
 57                    const repos = await github.paginate(github.rest.repos.listForOrg, {{
 58                        org: 'zed-extensions',
 59                        type: 'public',
 60                        per_page: 100,
 61                    }});
 62
 63                    let filteredRepos = repos
 64                        .filter(repo => !repo.archived)
 65                        .map(repo => repo.name);
 66
 67                    const filterInput = `{filter_repos_input}`.trim();
 68                    if (filterInput.length > 0) {{
 69                        const allowedNames = filterInput.split(',').map(s => s.trim()).filter(s => s.length > 0);
 70                        filteredRepos = filteredRepos.filter(name => allowedNames.includes(name));
 71                        console.log(`Filter applied. Matched ${{filteredRepos.length}} repos from ${{allowedNames.length}} requested.`);
 72                    }}
 73
 74                    console.log(`Found ${{filteredRepos.length}} extension repos`);
 75                    return filteredRepos;
 76                "#},
 77            ))
 78            .add_with(("result-encoding", "json"));
 79
 80        let filtered_repos = StepOutput::new(&step, "result");
 81
 82        (step, filtered_repos)
 83    }
 84
 85    fn checkout_zed_repo() -> CheckoutStep {
 86        steps::checkout_repo()
 87            .with_full_history()
 88            .with_custom_name("checkout_zed_repo")
 89    }
 90
 91    fn get_previous_tag_commit() -> (Step<Run>, StepOutput) {
 92        let step = named::bash(formatdoc! {r#"
 93            PREV_COMMIT=$(git rev-parse "{ROLLOUT_TAG_NAME}^{{commit}}" 2>/dev/null || echo "")
 94            if [ -z "$PREV_COMMIT" ]; then
 95                echo "::error::No previous rollout tag '{ROLLOUT_TAG_NAME}' found. Cannot determine file changes."
 96                exit 1
 97            fi
 98            echo "Found previous rollout at commit: $PREV_COMMIT"
 99            echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT"
100        "#})
101        .id("prev-tag");
102
103        let step_output = StepOutput::new(&step, "prev_commit");
104
105        (step, step_output)
106    }
107
108    fn get_removed_files(prev_commit: &StepOutput) -> (Step<Run>, StepOutput, StepOutput) {
109        let step = named::bash(indoc! {r#"
110            for workflow_type in "ci" "shared"; do
111                if [ "$workflow_type" = "ci" ]; then
112                    WORKFLOW_DIR="extensions/workflows"
113                else
114                    WORKFLOW_DIR="extensions/workflows/shared"
115                fi
116
117                REMOVED=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \
118                    awk '/^D/ { print $2 } /^R/ { print $2 }' | \
119                    xargs -I{} basename {} 2>/dev/null | \
120                    tr '\n' ' ' || echo "")
121                REMOVED=$(echo "$REMOVED" | xargs)
122
123                echo "Removed files for $workflow_type: $REMOVED"
124                echo "removed_${workflow_type}=$REMOVED" >> "$GITHUB_OUTPUT"
125            done
126        "#})
127        .id("calc-changes")
128        .add_env(("PREV_COMMIT", prev_commit.to_string()));
129
130        let removed_ci = StepOutput::new(&step, "removed_ci");
131        let removed_shared = StepOutput::new(&step, "removed_shared");
132
133        (step, removed_ci, removed_shared)
134    }
135
136    fn generate_workflow_files() -> Step<Run> {
137        named::bash(indoc! {r#"
138            cargo xtask workflows "$COMMIT_SHA"
139        "#})
140        .add_env(("COMMIT_SHA", "${{ github.sha }}"))
141    }
142
143    fn upload_workflow_files() -> Step<Use> {
144        named::uses(
145            "actions",
146            "upload-artifact",
147            "330a01c490aca151604b8cf639adc76d48f6c5d4", // v5
148        )
149        .add_with(("name", WORKFLOW_ARTIFACT_NAME))
150        .add_with(("path", "extensions/workflows/**/*.yml"))
151        .add_with(("if-no-files-found", "error"))
152    }
153
154    let (get_org_repositories, list_repos_output) = get_repositories(filter_repos_input);
155    let (get_prev_tag, prev_commit) = get_previous_tag_commit();
156    let (calc_changes, removed_ci, removed_shared) = get_removed_files(&prev_commit);
157
158    let job = Job::default()
159        .cond(Expression::new(format!(
160            "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.ref == 'refs/heads/main'"
161        )))
162        .runs_on(runners::LINUX_SMALL)
163        .timeout_minutes(10u32)
164        .outputs([
165            ("repos".to_owned(), list_repos_output.to_string()),
166            ("prev_commit".to_owned(), prev_commit.to_string()),
167            ("removed_ci".to_owned(), removed_ci.to_string()),
168            ("removed_shared".to_owned(), removed_shared.to_string()),
169        ])
170        .add_step(checkout_zed_repo())
171        .add_step(get_prev_tag)
172        .add_step(calc_changes)
173        .add_step(get_org_repositories)
174        .add_step(cache_rust_dependencies_namespace())
175        .add_step(generate_workflow_files())
176        .add_step(upload_workflow_files());
177
178    let job = named::job(job);
179    let (removed_ci, removed_shared) = (
180        removed_ci.as_job_output(&job),
181        removed_shared.as_job_output(&job),
182    );
183
184    (job, removed_ci, removed_shared)
185}
186
187fn rollout_workflows_to_extension(
188    fetch_repos_job: &NamedJob,
189    removed_ci: JobOutput,
190    removed_shared: JobOutput,
191    extra_context_input: &WorkflowInput,
192) -> NamedJob {
193    fn checkout_extension_repo(token: &StepOutput) -> CheckoutStep {
194        steps::checkout_repo()
195            .with_custom_name("checkout_extension_repo")
196            .with_token(token)
197            .with_repository("zed-extensions/${{ matrix.repo }}")
198            .with_path("extension")
199    }
200
201    fn download_workflow_files() -> Step<Use> {
202        named::uses(
203            "actions",
204            "download-artifact",
205            "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0
206        )
207        .add_with(("name", WORKFLOW_ARTIFACT_NAME))
208        .add_with(("path", "workflow-files"))
209    }
210
211    fn sync_workflow_files(removed_ci: JobOutput, removed_shared: JobOutput) -> Step<Run> {
212        named::bash(indoc! {r#"
213            mkdir -p extension/.github/workflows
214
215            if [ "$MATRIX_REPO" = "workflows" ]; then
216                REMOVED_FILES="$REMOVED_CI"
217            else
218                REMOVED_FILES="$REMOVED_SHARED"
219            fi
220
221            cd extension/.github/workflows
222
223            if [ -n "$REMOVED_FILES" ]; then
224                for file in $REMOVED_FILES; do
225                    if [ -f "$file" ]; then
226                        rm -f "$file"
227                    fi
228                done
229            fi
230
231            cd - > /dev/null
232
233            if [ "$MATRIX_REPO" = "workflows" ]; then
234                cp workflow-files/*.yml extension/.github/workflows/
235            else
236                cp workflow-files/shared/*.yml extension/.github/workflows/
237            fi
238        "#})
239        .add_env(("REMOVED_CI", removed_ci))
240        .add_env(("REMOVED_SHARED", removed_shared))
241        .add_env(("MATRIX_REPO", "${{ matrix.repo }}"))
242    }
243
244    fn get_short_sha() -> (Step<Run>, StepOutput) {
245        let step = named::bash(indoc! {r#"
246            echo "sha_short=$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT"
247        "#})
248        .id("short-sha");
249
250        let step_output = StepOutput::new(&step, "sha_short");
251
252        (step, step_output)
253    }
254
255    fn create_pull_request(
256        token: &StepOutput,
257        short_sha: &StepOutput,
258        context_input: &WorkflowInput,
259    ) -> Step<Use> {
260        let title = format!("Update CI workflows to `{short_sha}`");
261
262        let body = formatdoc! {r#"
263            This PR updates the CI workflow files from the main Zed repository
264            based on the commit zed-industries/zed@${{{{ github.sha }}}}
265
266            {context_input}
267        "#,
268        };
269
270        named::uses("peter-evans", "create-pull-request", "v7")
271            .add_with(("path", "extension"))
272            .add_with(("title", title.clone()))
273            .add_with(("body", body))
274            .add_with(("commit-message", title))
275            .add_with(("branch", "update-workflows"))
276            .add_with((
277                "committer",
278                "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
279            ))
280            .add_with((
281                "author",
282                "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
283            ))
284            .add_with(("base", "main"))
285            .add_with(("delete-branch", true))
286            .add_with(("token", token.to_string()))
287            .add_with(("sign-commits", true))
288            .id("create-pr")
289    }
290
291    fn enable_auto_merge(token: &StepOutput) -> Step<gh_workflow::Run> {
292        named::bash(indoc! {r#"
293            if [ -n "$PR_NUMBER" ]; then
294                gh pr merge "$PR_NUMBER" --auto --squash
295            fi
296        "#})
297        .working_directory("extension")
298        .add_env(("GH_TOKEN", token.to_string()))
299        .add_env((
300            "PR_NUMBER",
301            "${{ steps.create-pr.outputs.pull-request-number }}",
302        ))
303    }
304
305    let (authenticate, token) = generate_token(
306        vars::ZED_ZIPPY_APP_ID,
307        vars::ZED_ZIPPY_APP_PRIVATE_KEY,
308        Some(
309            RepositoryTarget::new("zed-extensions", &["${{ matrix.repo }}"]).permissions([
310                ("permission-pull-requests".to_owned(), Level::Write),
311                ("permission-contents".to_owned(), Level::Write),
312                ("permission-workflows".to_owned(), Level::Write),
313            ]),
314        ),
315    );
316    let (calculate_short_sha, short_sha) = get_short_sha();
317
318    let job = Job::default()
319        .needs([fetch_repos_job.name.clone()])
320        .cond(Expression::new(format!(
321            "needs.{}.outputs.repos != '[]'",
322            fetch_repos_job.name
323        )))
324        .runs_on(runners::LINUX_SMALL)
325        .timeout_minutes(10u32)
326        .strategy(
327            Strategy::default()
328                .fail_fast(false)
329                .max_parallel(10u32)
330                .matrix(json!({
331                    "repo": format!("${{{{ fromJson(needs.{}.outputs.repos) }}}}", fetch_repos_job.name)
332                })),
333        )
334        .add_step(authenticate)
335        .add_step(checkout_extension_repo(&token))
336        .add_step(download_workflow_files())
337        .add_step(sync_workflow_files(removed_ci, removed_shared))
338        .add_step(calculate_short_sha)
339        .add_step(create_pull_request(&token, &short_sha, extra_context_input))
340        .add_step(enable_auto_merge(&token));
341
342    named::job(job)
343}
344
345fn create_rollout_tag(rollout_job: &NamedJob, filter_repos_input: &WorkflowInput) -> NamedJob {
346    fn checkout_zed_repo(token: &StepOutput) -> CheckoutStep {
347        steps::checkout_repo().with_full_history().with_token(token)
348    }
349
350    fn update_rollout_tag() -> Step<Run> {
351        named::bash(formatdoc! {r#"
352            if git rev-parse "{ROLLOUT_TAG_NAME}" >/dev/null 2>&1; then
353                git tag -d "{ROLLOUT_TAG_NAME}"
354                git push origin ":refs/tags/{ROLLOUT_TAG_NAME}" || true
355            fi
356
357            echo "Creating new tag '{ROLLOUT_TAG_NAME}' at $(git rev-parse --short HEAD)"
358            git tag "{ROLLOUT_TAG_NAME}"
359            git push origin "{ROLLOUT_TAG_NAME}"
360        "#})
361    }
362
363    fn configure_git() -> Step<Run> {
364        named::bash(indoc! {r#"
365            git config user.name "zed-zippy[bot]"
366            git config user.email "234243425+zed-zippy[bot]@users.noreply.github.com"
367        "#})
368    }
369
370    let (authenticate, token) = generate_token(
371        vars::ZED_ZIPPY_APP_ID,
372        vars::ZED_ZIPPY_APP_PRIVATE_KEY,
373        Some(
374            RepositoryTarget::current()
375                .permissions([("permission-contents".to_owned(), Level::Write)]),
376        ),
377    );
378
379    let job = Job::default()
380        .needs([rollout_job.name.clone()])
381        .cond(Expression::new(format!(
382            "{filter_repos} == ''",
383            filter_repos = filter_repos_input.expr(),
384        )))
385        .runs_on(runners::LINUX_SMALL)
386        .timeout_minutes(1u32)
387        .add_step(authenticate)
388        .add_step(checkout_zed_repo(&token))
389        .add_step(configure_git())
390        .add_step(update_rollout_tag());
391
392    named::job(job)
393}