extension_rollout: Add support for renaming and deleting files (#47329)

Finn Evers created

This is in preparation for removing one of the files in favor of having
just one larger one (and perhaps renaming that in the future).

Release Notes:

- N/A

Change summary

.github/workflows/extension_workflow_rollout.yml                |  90 +
tooling/xtask/src/tasks/workflows/extension_bump.rs             |  22 
tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs | 140 ++
3 files changed, 235 insertions(+), 17 deletions(-)

Detailed changes

.github/workflows/extension_workflow_rollout.yml 🔗

@@ -57,6 +57,7 @@ jobs:
       with:
         clean: false
         path: zed
+        fetch-depth: '0'
     - name: steps::checkout_repo_with_token
       uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
       with:
@@ -64,9 +65,61 @@ jobs:
         token: ${{ steps.generate-token.outputs.token }}
         repository: zed-extensions/${{ matrix.repo }}
         path: extension
-    - name: extension_workflow_rollout::rollout_workflows_to_extension::copy_workflow_files
+    - id: prev-tag
+      name: extension_workflow_rollout::rollout_workflows_to_extension::get_previous_tag_commit
       run: |
+        PREV_COMMIT=$(git rev-parse "extension-workflows^{commit}" 2>/dev/null || echo "")
+        if [ -z "$PREV_COMMIT" ]; then
+            echo "::error::No previous rollout tag 'extension-workflows' found. Cannot determine file changes."
+            exit 1
+        fi
+        echo "Found previous rollout at commit: $PREV_COMMIT"
+        echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT"
+      shell: bash -euxo pipefail {0}
+      working-directory: zed
+    - id: calc-changes
+      name: extension_workflow_rollout::rollout_workflows_to_extension::get_removed_files
+      run: |
+        PREV_COMMIT="${{ steps.prev-tag.outputs.prev_commit }}"
+
+        if [ "${{ matrix.repo }}" = "workflows" ]; then
+            WORKFLOW_DIR="extensions/workflows"
+        else
+            WORKFLOW_DIR="extensions/workflows/shared"
+        fi
+
+        echo "Calculating changes from $PREV_COMMIT to HEAD for $WORKFLOW_DIR"
+
+        # Get deleted files (status D) and renamed files (status R - old name needs removal)
+        # Using -M to detect renames, then extracting files that are gone from their original location
+        REMOVED_FILES=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \
+            awk '/^D/ { print $2 } /^R/ { print $2 }' | \
+            xargs -I{} basename {} 2>/dev/null | \
+            tr '\n' ' ' || echo "")
+
+        REMOVED_FILES=$(echo "$REMOVED_FILES" | xargs)
+
+        echo "Files to remove: $REMOVED_FILES"
+        echo "removed_files=$REMOVED_FILES" >> "$GITHUB_OUTPUT"
+      shell: bash -euxo pipefail {0}
+      working-directory: zed
+    - name: extension_workflow_rollout::rollout_workflows_to_extension::sync_workflow_files
+      run: |
+        REMOVED_FILES="${{ steps.calc-changes.outputs.removed_files }}"
+
         mkdir -p extension/.github/workflows
+        cd extension/.github/workflows
+
+        if [ -n "$REMOVED_FILES" ]; then
+            for file in $REMOVED_FILES; do
+                if [ -f "$file" ]; then
+                    rm -f "$file"
+                fi
+            done
+        fi
+
+        cd - > /dev/null
+
         if [ "${{ matrix.repo }}" = "workflows" ]; then
             cp zed/extensions/workflows/*.yml extension/.github/workflows/
         else
@@ -107,3 +160,38 @@ jobs:
       env:
         GH_TOKEN: ${{ steps.generate-token.outputs.token }}
     timeout-minutes: 10
+  create_rollout_tag:
+    needs:
+    - rollout_workflows_to_extension
+    runs-on: namespace-profile-2x4-ubuntu-2404
+    steps:
+    - id: generate-token
+      name: extension_bump::generate_token
+      uses: actions/create-github-app-token@v2
+      with:
+        app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
+        private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
+        permission-contents: write
+    - name: steps::checkout_repo_with_token
+      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      with:
+        clean: false
+        token: ${{ steps.generate-token.outputs.token }}
+        fetch-depth: '0'
+    - name: extension_workflow_rollout::create_rollout_tag::configure_git
+      run: |
+        git config user.name "zed-zippy[bot]"
+        git config user.email "234243425+zed-zippy[bot]@users.noreply.github.com"
+      shell: bash -euxo pipefail {0}
+    - name: extension_workflow_rollout::create_rollout_tag::update_rollout_tag
+      run: |
+        if git rev-parse "extension-workflows" >/dev/null 2>&1; then
+            git tag -d "extension-workflows"
+            git push origin ":refs/tags/extension-workflows" || true
+        fi
+
+        echo "Creating new tag 'extension-workflows' at $(git rev-parse --short HEAD)"
+        git tag "extension-workflows"
+        git push origin "extension-workflows"
+      shell: bash -euxo pipefail {0}
+    timeout-minutes: 1

tooling/xtask/src/tasks/workflows/extension_bump.rs 🔗

@@ -223,8 +223,10 @@ pub(crate) fn generate_token(
                          permissions,
                      }| {
                         input
-                            .add("owner", owner)
-                            .add("repositories", repositories)
+                            .when_some(owner, |input, owner| input.add("owner", owner))
+                            .when_some(repositories, |input, repositories| {
+                                input.add("repositories", repositories)
+                            })
                             .when_some(permissions, |input, permissions| {
                                 permissions
                                     .into_iter()
@@ -315,16 +317,24 @@ fn create_pull_request(new_version: StepOutput, generated_token: StepOutput) ->
 }
 
 pub(crate) struct RepositoryTarget {
-    owner: String,
-    repositories: String,
+    owner: Option<String>,
+    repositories: Option<String>,
     permissions: Option<Vec<(String, Level)>>,
 }
 
 impl RepositoryTarget {
     pub fn new<T: ToString>(owner: T, repositories: &[&str]) -> Self {
         Self {
-            owner: owner.to_string(),
-            repositories: repositories.join("\n"),
+            owner: Some(owner.to_string()),
+            repositories: Some(repositories.join("\n")),
+            permissions: None,
+        }
+    }
+
+    pub fn current() -> Self {
+        Self {
+            owner: None,
+            repositories: None,
             permissions: None,
         }
     }

tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs 🔗

@@ -1,6 +1,7 @@
 use gh_workflow::{
     Event, Expression, Job, Level, Run, Step, Strategy, Use, Workflow, WorkflowDispatch,
 };
+use indoc::formatdoc;
 use indoc::indoc;
 use serde_json::json;
 
@@ -11,15 +12,19 @@ use crate::tasks::workflows::{
     vars::{self, StepOutput},
 };
 
+const ROLLOUT_TAG_NAME: &str = "extension-workflows";
+
 pub(crate) fn extension_workflow_rollout() -> Workflow {
     let fetch_repos = fetch_extension_repos();
     let rollout_workflows = rollout_workflows_to_extension(&fetch_repos);
+    let create_tag = create_rollout_tag(&rollout_workflows);
 
     named::workflow()
         .on(Event::default().workflow_dispatch(WorkflowDispatch::default()))
         .add_env(("CARGO_TERM_COLOR", "always"))
         .add_job(fetch_repos.name, fetch_repos.job)
         .add_job(rollout_workflows.name, rollout_workflows.job)
+        .add_job(create_tag.name, create_tag.job)
 }
 
 fn fetch_extension_repos() -> NamedJob {
@@ -28,7 +33,7 @@ fn fetch_extension_repos() -> NamedJob {
             .id("list-repos")
             .add_with((
                 "script",
-                indoc! {r#"
+                indoc::indoc! {r#"
                     const repos = await github.paginate(github.rest.repos.listForOrg, {
                         org: 'zed-extensions',
                         type: 'public',
@@ -66,6 +71,7 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
         steps::checkout_repo()
             .name("checkout_zed_repo")
             .add_with(("path", "zed"))
+            .add_with(("fetch-depth", "0"))
     }
 
     fn checkout_extension_repo(token: &StepOutput) -> Step<Use> {
@@ -74,10 +80,74 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
             .add_with(("path", "extension"))
     }
 
-    fn copy_workflow_files() -> Step<Run> {
-        named::bash(indoc! {r#"
+    fn get_previous_tag_commit() -> (Step<Run>, StepOutput) {
+        let step = named::bash(formatdoc! {r#"
+            PREV_COMMIT=$(git rev-parse "{ROLLOUT_TAG_NAME}^{{commit}}" 2>/dev/null || echo "")
+            if [ -z "$PREV_COMMIT" ]; then
+                echo "::error::No previous rollout tag '{ROLLOUT_TAG_NAME}' found. Cannot determine file changes."
+                exit 1
+            fi
+            echo "Found previous rollout at commit: $PREV_COMMIT"
+            echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT"
+        "#})
+        .id("prev-tag")
+        .working_directory("zed");
+
+        let step_output = StepOutput::new(&step, "prev_commit");
+
+        (step, step_output)
+    }
+
+    fn get_removed_files(prev_commit: &StepOutput) -> (Step<Run>, StepOutput) {
+        let step = named::bash(formatdoc! {r#"
+            PREV_COMMIT="{prev_commit}"
+
+            if [ "${{{{ matrix.repo }}}}" = "workflows" ]; then
+                WORKFLOW_DIR="extensions/workflows"
+            else
+                WORKFLOW_DIR="extensions/workflows/shared"
+            fi
+
+            echo "Calculating changes from $PREV_COMMIT to HEAD for $WORKFLOW_DIR"
+
+            # Get deleted files (status D) and renamed files (status R - old name needs removal)
+            # Using -M to detect renames, then extracting files that are gone from their original location
+            REMOVED_FILES=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \
+                awk '/^D/ {{ print $2 }} /^R/ {{ print $2 }}' | \
+                xargs -I{{}} basename {{}} 2>/dev/null | \
+                tr '\n' ' ' || echo "")
+
+            REMOVED_FILES=$(echo "$REMOVED_FILES" | xargs)
+
+            echo "Files to remove: $REMOVED_FILES"
+            echo "removed_files=$REMOVED_FILES" >> "$GITHUB_OUTPUT"
+        "#})
+        .id("calc-changes")
+        .working_directory("zed");
+
+        let removed_files = StepOutput::new(&step, "removed_files");
+
+        (step, removed_files)
+    }
+
+    fn sync_workflow_files(removed_files: &StepOutput) -> Step<Run> {
+        named::bash(formatdoc! {r#"
+            REMOVED_FILES="{removed_files}"
+
             mkdir -p extension/.github/workflows
-            if [ "${{ matrix.repo }}" = "workflows" ]; then
+            cd extension/.github/workflows
+
+            if [ -n "$REMOVED_FILES" ]; then
+                for file in $REMOVED_FILES; do
+                    if [ -f "$file" ]; then
+                        rm -f "$file"
+                    fi
+                done
+            fi
+
+            cd - > /dev/null
+
+            if [ "${{{{ matrix.repo }}}}" = "workflows" ]; then
                 cp zed/extensions/workflows/*.yml extension/.github/workflows/
             else
                 cp zed/extensions/workflows/shared/*.yml extension/.github/workflows/
@@ -86,7 +156,7 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
     }
 
     fn get_short_sha() -> (Step<Run>, StepOutput) {
-        let step = named::bash(indoc! {r#"
+        let step = named::bash(indoc::indoc! {r#"
             echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
         "#})
         .id("short-sha")
@@ -97,7 +167,7 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
         (step, step_output)
     }
 
-    fn create_pull_request(token: &StepOutput, short_sha: StepOutput) -> Step<Use> {
+    fn create_pull_request(token: &StepOutput, short_sha: &StepOutput) -> Step<Use> {
         let title = format!("Update CI workflows to `zed@{}`", short_sha);
 
         named::uses("peter-evans", "create-pull-request", "v7")
@@ -105,7 +175,7 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
             .add_with(("title", title.clone()))
             .add_with((
                 "body",
-                indoc! {r#"
+                indoc::indoc! {r#"
                     This PR updates the CI workflow files from the main Zed repository
                     based on the commit zed-industries/zed@${{ github.sha }}
                 "#},
@@ -128,7 +198,7 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
     }
 
     fn enable_auto_merge(token: &StepOutput) -> Step<gh_workflow::Run> {
-        named::bash(indoc! {r#"
+        named::bash(indoc::indoc! {r#"
             PR_NUMBER="${{ steps.create-pr.outputs.pull-request-number }}"
             if [ -n "$PR_NUMBER" ]; then
                 cd extension
@@ -149,6 +219,8 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
             ]),
         ),
     );
+    let (get_prev_tag, prev_commit) = get_previous_tag_commit();
+    let (calc_changes, removed_files) = get_removed_files(&prev_commit);
     let (calculate_short_sha, short_sha) = get_short_sha();
 
     let job = Job::default()
@@ -170,10 +242,58 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
         .add_step(authenticate)
         .add_step(checkout_zed_repo())
         .add_step(checkout_extension_repo(&token))
-        .add_step(copy_workflow_files())
+        .add_step(get_prev_tag)
+        .add_step(calc_changes)
+        .add_step(sync_workflow_files(&removed_files))
         .add_step(calculate_short_sha)
-        .add_step(create_pull_request(&token, short_sha))
+        .add_step(create_pull_request(&token, &short_sha))
         .add_step(enable_auto_merge(&token));
 
     named::job(job)
 }
+
+fn create_rollout_tag(rollout_job: &NamedJob) -> NamedJob {
+    fn checkout_zed_repo(token: &StepOutput) -> Step<Use> {
+        steps::checkout_repo_with_token(token).add_with(("fetch-depth", "0"))
+    }
+
+    fn update_rollout_tag() -> Step<Run> {
+        named::bash(formatdoc! {r#"
+            if git rev-parse "{ROLLOUT_TAG_NAME}" >/dev/null 2>&1; then
+                git tag -d "{ROLLOUT_TAG_NAME}"
+                git push origin ":refs/tags/{ROLLOUT_TAG_NAME}" || true
+            fi
+
+            echo "Creating new tag '{ROLLOUT_TAG_NAME}' at $(git rev-parse --short HEAD)"
+            git tag "{ROLLOUT_TAG_NAME}"
+            git push origin "{ROLLOUT_TAG_NAME}"
+        "#})
+    }
+
+    fn configure_git() -> Step<Run> {
+        named::bash(indoc! {r#"
+            git config user.name "zed-zippy[bot]"
+            git config user.email "234243425+zed-zippy[bot]@users.noreply.github.com"
+        "#})
+    }
+
+    let (authenticate, token) = generate_token(
+        vars::ZED_ZIPPY_APP_ID,
+        vars::ZED_ZIPPY_APP_PRIVATE_KEY,
+        Some(
+            RepositoryTarget::current()
+                .permissions([("permission-contents".to_owned(), Level::Write)]),
+        ),
+    );
+
+    let job = Job::default()
+        .needs([rollout_job.name.clone()])
+        .runs_on(runners::LINUX_SMALL)
+        .timeout_minutes(1u32)
+        .add_step(authenticate)
+        .add_step(checkout_zed_repo(&token))
+        .add_step(configure_git())
+        .add_step(update_rollout_tag());
+
+    named::job(job)
+}