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