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