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