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