ci: Add workflow for bumping Zed versions (#54485)

Finn Evers created

Release Notes:

- N/A

Change summary

.github/workflows/bump_patch_version.yml                        |  41 
.github/workflows/bump_zed_version.yml                          | 226 ++
.github/workflows/extension_bump.yml                            |   3 
.github/workflows/extension_workflow_rollout.yml                |   5 
.github/workflows/publish_extension_cli.yml                     |   8 
.github/workflows/retag_release.yml                             |  88 +
script/bump-zed-minor-versions                                  | 123 -
script/bump-zed-patch-version                                   |  18 
script/bump-zed-version                                         |  50 
script/retag-release                                            |  38 
tooling/xtask/src/tasks/workflows.rs                            |   4 
tooling/xtask/src/tasks/workflows/autofix_pr.rs                 |   7 
tooling/xtask/src/tasks/workflows/bump_patch_version.rs         | 104 
tooling/xtask/src/tasks/workflows/bump_zed_version.rs           | 264 +++
tooling/xtask/src/tasks/workflows/extension_bump.rs             |  26 
tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs |  38 
tooling/xtask/src/tasks/workflows/publish_extension_cli.rs      |  64 
tooling/xtask/src/tasks/workflows/retag_release.rs              | 100 +
tooling/xtask/src/tasks/workflows/run_tests.rs                  |   7 
tooling/xtask/src/tasks/workflows/steps.rs                      | 252 ++
20 files changed, 1,142 insertions(+), 324 deletions(-)

Detailed changes

.github/workflows/bump_patch_version.yml 🔗

@@ -10,7 +10,7 @@ on:
         type: string
 jobs:
   run_bump_patch_version:
-    if: github.repository_owner == 'zed-industries'
+    if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
     runs-on: namespace-profile-16x32-ubuntu-2204
     steps:
     - id: generate-token
@@ -25,8 +25,8 @@ jobs:
         clean: false
         ref: ${{ inputs.branch }}
         token: ${{ steps.generate-token.outputs.token }}
-    - id: bump-version
-      name: bump_patch_version::run_bump_patch_version::bump_version
+    - id: channel
+      name: bump_patch_version::run_bump_patch_version::read_channel
       run: |
         channel="$(cat crates/zed/RELEASE_CHANNEL)"
 
@@ -38,30 +38,53 @@ jobs:
             tag_suffix="-pre"
             ;;
           *)
-            echo "this must be run on either of stable|preview release branches" >&2
+            echo "::error::must be run on a stable or preview release branch"
             exit 1
             ;;
         esac
-        which cargo-set-version > /dev/null || cargo install cargo-edit -f --no-default-features --features "set-version"
+
+        version=$(script/get-crate-version zed)
+
+        {
+            echo "channel=$channel"
+            echo "version=$version"
+            echo "tag_suffix=$tag_suffix"
+        } >> "$GITHUB_OUTPUT"
+    - name: bump_patch_version::run_bump_patch_version::verify_prior_release_exists
+      run: |
+        status=$(curl -s -o /dev/null -w '%{http_code}' "https://cloud.zed.dev/releases/$CHANNEL/$VERSION/asset?asset=zed&os=macos&arch=aarch64")
+        if [[ "$status" != "200" ]]; then
+            echo "::error::version $VERSION has not been released on $CHANNEL yet (HTTP $status) — bump the patch version only after the current version is released"
+            exit 1
+        fi
+      env:
+        CHANNEL: ${{ steps.channel.outputs.channel }}
+        VERSION: ${{ steps.channel.outputs.version }}
+    - name: steps::install_cargo_edit
+      uses: taiki-e/install-action@02cc5f8ca9f2301050c0c099055816a41ee05507
+      with:
+        tool: cargo-edit
+    - id: bump-version
+      name: bump_patch_version::run_bump_patch_version::bump_version
+      run: |
         version="$(cargo set-version -p zed --bump patch 2>&1 | sed 's/.* //')"
         echo "version=$version" >> "$GITHUB_OUTPUT"
-        echo "tag_suffix=$tag_suffix" >> "$GITHUB_OUTPUT"
     - id: commit
-      name: bump_patch_version::run_bump_patch_version::commit_changes
+      name: steps::bot_commit
       uses: IAreKyleW00t/verified-bot-commit@126a6a11889ab05bcff72ec2403c326cd249b84c
       with:
         message: Bump to ${{ steps.bump-version.outputs.version }} for @${{ github.actor }}
         ref: refs/heads/${{ inputs.branch }}
         files: '**'
         token: ${{ steps.generate-token.outputs.token }}
-    - name: bump_patch_version::run_bump_patch_version::create_version_tag
+    - name: steps::create_tag
       uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b
       with:
         script: |
           github.rest.git.createRef({
               owner: context.repo.owner,
               repo: context.repo.repo,
-              ref: 'refs/tags/v${{ steps.bump-version.outputs.version }}${{ steps.bump-version.outputs.tag_suffix }}',
+              ref: 'refs/tags/v${{ steps.bump-version.outputs.version }}${{ steps.channel.outputs.tag_suffix }}',
               sha: '${{ steps.commit.outputs.commit }}'
           })
         github-token: ${{ steps.generate-token.outputs.token }}

.github/workflows/bump_zed_version.yml 🔗

@@ -0,0 +1,226 @@
+# Generated from xtask::workflows::bump_zed_version
+# Rebuild with `cargo xtask workflows`.
+name: bump_zed_version
+on:
+  workflow_dispatch:
+    inputs:
+      target:
+        description: 'Which channels to bump: all, main, preview, or stable'
+        type: string
+        default: all
+jobs:
+  resolve_versions:
+    if: github.repository_owner == 'zed-industries'
+    runs-on: namespace-profile-16x32-ubuntu-2204
+    steps:
+    - id: generate-token
+      name: steps::authenticate_as_zippy
+      uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859
+      with:
+        app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
+        private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
+    - name: steps::checkout_repo
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
+      with:
+        clean: false
+        ref: main
+        token: ${{ steps.generate-token.outputs.token }}
+    - id: versions
+      name: bump_zed_version::resolve_versions::extract_versions
+      run: |
+        version=$(script/get-crate-version zed)
+        major=$(echo "$version" | cut -d. -f1)
+        minor=$(echo "$version" | cut -d. -f2)
+
+        channel=$(cat crates/zed/RELEASE_CHANNEL)
+        if [[ "$channel" != "dev" && "$channel" != "nightly" ]]; then
+            echo "::error::release channel on main should be dev or nightly, found: $channel"
+            exit 1
+        fi
+
+        # Next main version after bump
+        next_version="${major}.$((minor + 1)).0"
+        next_major=$(echo "$next_version" | cut -d. -f1)
+        next_minor=$(echo "$next_version" | cut -d. -f2)
+        pr_branch="bump-zed-to-v${next_major}.${next_minor}.0"
+
+        # New preview branch from current main
+        preview_branch="v${major}.${minor}.x"
+        preview_tag="v${version}-pre"
+
+        # Current preview to promote to stable — derive branch from released preview version
+        released_preview=$(script/get-released-version preview)
+        if [[ -z "$released_preview" ]]; then
+            echo "::error::could not determine released preview version"
+            exit 1
+        fi
+        stable_major=$(echo "$released_preview" | cut -d. -f1)
+        stable_minor=$(echo "$released_preview" | cut -d. -f2)
+        stable_branch="v${stable_major}.${stable_minor}.x"
+
+        # Final validation
+        for var in next_version pr_branch preview_branch preview_tag stable_branch; do
+            if [[ -z "${!var}" ]]; then
+                echo "::error::failed to compute $var"
+                exit 1
+            fi
+        done
+
+        {
+            echo "next_version=$next_version"
+            echo "pr_branch=$pr_branch"
+            echo "preview_branch=$preview_branch"
+            echo "preview_tag=$preview_tag"
+            echo "stable_branch=$stable_branch"
+        } >> "$GITHUB_OUTPUT"
+
+        echo "Resolved: next=$next_version preview=$preview_branch($preview_tag) stable=$stable_branch pr=$pr_branch"
+    outputs:
+      next_version: ${{ steps.versions.outputs.next_version }}
+      pr_branch: ${{ steps.versions.outputs.pr_branch }}
+      preview_branch: ${{ steps.versions.outputs.preview_branch }}
+      preview_tag: ${{ steps.versions.outputs.preview_tag }}
+      stable_branch: ${{ steps.versions.outputs.stable_branch }}
+  bump_main:
+    needs:
+    - resolve_versions
+    if: inputs.target == 'all' || inputs.target == 'main'
+    runs-on: namespace-profile-16x32-ubuntu-2204
+    steps:
+    - id: generate-token
+      name: steps::authenticate_as_zippy
+      uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859
+      with:
+        app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
+        private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
+    - name: steps::checkout_repo
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
+      with:
+        clean: false
+        ref: main
+        token: ${{ steps.generate-token.outputs.token }}
+    - name: steps::install_cargo_edit
+      uses: taiki-e/install-action@02cc5f8ca9f2301050c0c099055816a41ee05507
+      with:
+        tool: cargo-edit
+    - name: bump_zed_version::bump_main::bump_version
+      run: cargo set-version -p zed --bump minor
+    - name: steps::create_pull_request
+      uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725
+      with:
+        title: Bump Zed to v${{ needs.resolve_versions.outputs.next_version }}
+        body: |-
+          Release Notes:
+
+          - N/A
+        commit-message: Bump Zed to v${{ needs.resolve_versions.outputs.next_version }}
+        branch: ${{ needs.resolve_versions.outputs.pr_branch }}
+        committer: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
+        author: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
+        base: main
+        delete-branch: true
+        token: ${{ steps.generate-token.outputs.token }}
+        sign-commits: true
+        assignees: ${{ github.actor }}
+  create_preview_branch:
+    needs:
+    - resolve_versions
+    if: inputs.target == 'all' || inputs.target == 'preview'
+    runs-on: namespace-profile-16x32-ubuntu-2204
+    steps:
+    - id: generate-token
+      name: steps::authenticate_as_zippy
+      uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859
+      with:
+        app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
+        private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
+    - name: steps::checkout_repo
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
+      with:
+        clean: false
+        ref: main
+        token: ${{ steps.generate-token.outputs.token }}
+    - id: main-sha
+      name: bump_zed_version::create_preview_branch::get_main_sha
+      run: echo "main_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
+    - name: bump_zed_version::create_preview_branch::promote_to_preview
+      run: echo -n preview > crates/zed/RELEASE_CHANNEL
+    - name: steps::create_branch
+      uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b
+      with:
+        script: |
+          github.rest.git.createRef({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              ref: 'refs/heads/${{ needs.resolve_versions.outputs.preview_branch }}',
+              sha: '${{ steps.main-sha.outputs.main_sha }}'
+          })
+        github-token: ${{ steps.generate-token.outputs.token }}
+    - id: commit
+      name: steps::bot_commit
+      uses: IAreKyleW00t/verified-bot-commit@126a6a11889ab05bcff72ec2403c326cd249b84c
+      with:
+        message: ${{ needs.resolve_versions.outputs.preview_branch }} preview
+        ref: refs/heads/${{ needs.resolve_versions.outputs.preview_branch }}
+        files: crates/zed/RELEASE_CHANNEL
+        token: ${{ steps.generate-token.outputs.token }}
+    - name: steps::create_tag
+      uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b
+      with:
+        script: |
+          github.rest.git.createRef({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              ref: 'refs/tags/${{ needs.resolve_versions.outputs.preview_tag }}',
+              sha: '${{ steps.commit.outputs.commit }}'
+          })
+        github-token: ${{ steps.generate-token.outputs.token }}
+  promote_to_stable:
+    needs:
+    - resolve_versions
+    if: inputs.target == 'all' || inputs.target == 'stable'
+    runs-on: namespace-profile-16x32-ubuntu-2204
+    steps:
+    - id: generate-token
+      name: steps::authenticate_as_zippy
+      uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859
+      with:
+        app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
+        private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
+    - name: steps::checkout_repo
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
+      with:
+        clean: false
+        ref: ${{ needs.resolve_versions.outputs.stable_branch }}
+        token: ${{ steps.generate-token.outputs.token }}
+    - id: stable-info
+      name: bump_zed_version::promote_to_stable
+      run: |
+        stable_version=$(script/get-crate-version zed)
+        {
+            echo "stable_tag=v${stable_version}"
+        } >> "$GITHUB_OUTPUT"
+    - name: bump_zed_version::promote_to_stable
+      run: echo -n stable > crates/zed/RELEASE_CHANNEL
+    - id: commit
+      name: steps::bot_commit
+      uses: IAreKyleW00t/verified-bot-commit@126a6a11889ab05bcff72ec2403c326cd249b84c
+      with:
+        message: ${{ needs.resolve_versions.outputs.stable_branch }} stable
+        ref: refs/heads/${{ needs.resolve_versions.outputs.stable_branch }}
+        files: crates/zed/RELEASE_CHANNEL
+        token: ${{ steps.generate-token.outputs.token }}
+    - name: steps::create_tag
+      uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b
+      with:
+        script: |
+          github.rest.git.createRef({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              ref: 'refs/tags/${{ steps.stable-info.outputs.stable_tag }}',
+              sha: '${{ steps.commit.outputs.commit }}'
+          })
+        github-token: ${{ steps.generate-token.outputs.token }}
+defaults:
+  run:
+    shell: bash -euxo pipefail {0}

.github/workflows/extension_bump.yml 🔗

@@ -137,7 +137,7 @@ jobs:
         OLD_VERSION: ${{ needs.check_version_changed.outputs.current_version }}
         BUMP_TYPE: ${{ inputs.bump-type }}
         WORKING_DIR: ${{ inputs.working-directory }}
-    - name: extension_bump::create_pull_request
+    - name: steps::create_pull_request
       uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725
       with:
         title: ${{ steps.bump-version.outputs.title }}
@@ -145,6 +145,7 @@ jobs:
         commit-message: ${{ steps.bump-version.outputs.title }}
         branch: ${{ steps.bump-version.outputs.branch_name }}
         committer: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
+        author: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
         base: main
         delete-branch: true
         token: ${{ steps.generate-token.outputs.token }}

.github/workflows/extension_workflow_rollout.yml 🔗

@@ -172,10 +172,9 @@ jobs:
       run: |
         echo "sha_short=$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT"
     - id: create-pr
-      name: extension_workflow_rollout::rollout_workflows_to_extension::create_pull_request
+      name: steps::create_pull_request
       uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725
       with:
-        path: extension
         title: Update CI workflows to `${{ steps.short-sha.outputs.sha_short }}`
         body: |
           This PR updates the CI workflow files from the main Zed repository
@@ -190,6 +189,8 @@ jobs:
         delete-branch: true
         token: ${{ steps.generate-token.outputs.token }}
         sign-commits: true
+        assignees: ${{ inputs.filter-repos != '' && github.actor || '' }}
+        path: extension
     - name: extension_workflow_rollout::rollout_workflows_to_extension::enable_auto_merge
       run: |
         if [ -n "$PR_NUMBER" ]; then

.github/workflows/publish_extension_cli.yml 🔗

@@ -62,7 +62,7 @@ jobs:
             tooling/xtask/src/tasks/workflows/extension_tests.rs
     - name: publish_extension_cli::update_sha_in_zed::regenerate_workflows
       run: cargo xtask workflows
-    - name: publish_extension_cli::create_pull_request_zed
+    - name: steps::create_pull_request
       uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725
       with:
         title: 'extension_ci: Bump extension CLI version to `${{ steps.short-sha.outputs.sha_short }}`'
@@ -75,6 +75,7 @@ jobs:
         commit-message: 'extension_ci: Bump extension CLI version to `${{ steps.short-sha.outputs.sha_short }}`'
         branch: update-extension-cli-sha
         committer: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
+        author: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
         base: main
         delete-branch: true
         token: ${{ steps.generate-token.outputs.token }}
@@ -107,7 +108,7 @@ jobs:
       run: |
         sed -i "s/ZED_EXTENSION_CLI_SHA: [a-f0-9]*/ZED_EXTENSION_CLI_SHA: $GITHUB_SHA/" \
             .github/workflows/ci.yml
-    - name: publish_extension_cli::create_pull_request_extensions
+    - name: steps::create_pull_request
       uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725
       with:
         title: Bump extension CLI version to `${{ steps.short-sha.outputs.sha_short }}`
@@ -116,12 +117,13 @@ jobs:
         commit-message: Bump extension CLI version to `${{ steps.short-sha.outputs.sha_short }}`
         branch: update-extension-cli-sha
         committer: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
+        author: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
         base: main
         delete-branch: true
         token: ${{ steps.generate-token.outputs.token }}
         sign-commits: true
-        labels: allow-no-extension
         assignees: ${{ github.actor }}
+        labels: allow-no-extension
 defaults:
   run:
     shell: bash -euxo pipefail {0}

.github/workflows/retag_release.yml 🔗

@@ -0,0 +1,88 @@
+# Generated from xtask::workflows::retag_release
+# Rebuild with `cargo xtask workflows`.
+name: retag_release
+on:
+  workflow_dispatch:
+    inputs:
+      branch:
+        description: Release branch to re-tag (e.g. v0.180.x)
+        required: true
+        type: string
+jobs:
+  run_retag_release:
+    if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
+    runs-on: namespace-profile-16x32-ubuntu-2204
+    steps:
+    - id: generate-token
+      name: steps::authenticate_as_zippy
+      uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859
+      with:
+        app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
+        private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
+    - name: steps::checkout_repo
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
+      with:
+        clean: false
+        ref: ${{ inputs.branch }}
+        token: ${{ steps.generate-token.outputs.token }}
+    - id: info
+      name: retag_release::run_retag_release::resolve_tag
+      run: |
+        if [[ ! "$BRANCH" =~ ^v[0-9]+\.[0-9]{1,3}\.x$ ]]; then
+            echo "::error::branch '$BRANCH' does not match the release branch pattern v[N].[N].x"
+            exit 1
+        fi
+
+        channel="$(cat crates/zed/RELEASE_CHANNEL)"
+
+        tag_suffix=""
+        case $channel in
+          stable)
+            ;;
+          preview)
+            tag_suffix="-pre"
+            ;;
+          *)
+            echo "::error::must be run on a stable or preview release branch"
+            exit 1
+            ;;
+        esac
+
+        version=$(script/get-crate-version zed)
+
+        {
+            echo "channel=$channel"
+            echo "version=$version"
+            echo "tag_suffix=$tag_suffix"
+            echo "head_sha=$(git rev-parse HEAD)"
+        } >> "$GITHUB_OUTPUT"
+      env:
+        BRANCH: ${{ inputs.branch }}
+    - name: retag_release::run_retag_release::verify_no_existing_release
+      run: |
+        status=$(curl -s -o /dev/null -w '%{http_code}' "https://cloud.zed.dev/releases/$CHANNEL/$VERSION/asset?asset=zed&os=macos&arch=aarch64")
+        if [[ "$status" == "200" ]]; then
+            echo "::error::version $VERSION is already released on $CHANNEL — cannot re-tag a released version"
+            exit 1
+        fi
+      env:
+        CHANNEL: ${{ steps.info.outputs.channel }}
+        VERSION: ${{ steps.info.outputs.version }}
+    - name: steps::update_tag
+      uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b
+      with:
+        script: |
+          github.rest.git.updateRef({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              ref: 'tags/v${{ steps.info.outputs.version }}${{ steps.info.outputs.tag_suffix }}',
+              sha: '${{ steps.info.outputs.head_sha }}',
+              force: true
+          })
+        github-token: ${{ steps.generate-token.outputs.token }}
+concurrency:
+  group: ${{ github.workflow }}-${{ inputs.branch }}
+  cancel-in-progress: true
+defaults:
+  run:
+    shell: bash -euxo pipefail {0}

script/bump-zed-minor-versions 🔗

@@ -1,123 +0,0 @@
-#!/usr/bin/env bash
-
-set -eu
-
-# Ensure cargo-edit is installed
-which cargo-set-version > /dev/null || cargo install cargo-edit
-
-# Ensure we're in a clean state on an up-to-date `main` branch.
-if [[ -n $(git status --short --untracked-files=no) ]]; then
-  echo "can't bump versions with uncommitted changes"
-  exit 1
-fi
-if [[ $(git rev-parse --abbrev-ref HEAD) != "main" ]]; then
-  echo "this command must be run on main"
-  exit 1
-fi
-git pull -q --ff-only origin main
-
-# Parse the current version
-version=$(script/get-crate-version zed)
-major=$(echo $version | cut -d. -f1)
-minor=$(echo $version | cut -d. -f2)
-patch=$(echo $version | cut -d. -f3)
-prev_minor=$(expr $minor - 1)
-next_minor=$(expr $minor + 1)
-
-minor_branch_name="v${major}.${minor}.x"
-prev_minor_branch_name="v${major}.${prev_minor}.x"
-next_minor_branch_name="v${major}.${next_minor}.x"
-preview_tag_name="v${major}.${minor}.${patch}-pre"
-bump_main_branch_name="set-minor-version-to-${major}.${next_minor}"
-
-git fetch origin ${prev_minor_branch_name}:${prev_minor_branch_name}
-git fetch origin --tags
-cargo check -q
-
-function cleanup {
-  git checkout -q main
-}
-trap cleanup EXIT
-
-echo "Checking invariants before taking any actions..."
-if [[ $(cat crates/zed/RELEASE_CHANNEL) != dev && $(cat crates/zed/RELEASE_CHANNEL) != nightly ]]; then
-  echo "release channel on main should be dev or nightly"
-  exit 1
-fi
-if git show-ref --quiet refs/tags/${preview_tag_name}; then
-  echo "tag ${preview_tag_name} already exists"
-  exit 1
-fi
-if git show-ref --quiet refs/heads/${minor_branch_name}; then
-  echo "branch ${minor_branch_name} already exists"
-  exit 1
-fi
-if ! git show-ref --quiet refs/heads/${prev_minor_branch_name}; then
-  echo "previous branch ${minor_branch_name} doesn't exist"
-  exit 1
-fi
-if [[ $(git show ${prev_minor_branch_name}:crates/zed/RELEASE_CHANNEL) != preview ]]; then
-  echo "release channel on branch ${prev_minor_branch_name} should be preview"
-  exit 1
-fi
-
-echo "Promoting existing branch ${prev_minor_branch_name} to stable..."
-git checkout -q ${prev_minor_branch_name}
-git clean -q -dff
-stable_tag_name="v$(script/get-crate-version zed)"
-if git show-ref --quiet refs/tags/${stable_tag_name}; then
-  echo "tag ${stable_tag_name} already exists"
-  exit 1
-fi
-old_prev_minor_sha=$(git rev-parse HEAD)
-echo -n stable > crates/zed/RELEASE_CHANNEL
-git commit -q --all --message "${prev_minor_branch_name} stable"
-git tag ${stable_tag_name}
-
-echo "Creating new preview branch ${minor_branch_name}..."
-git checkout -q main
-git checkout -q -b ${minor_branch_name}
-echo -n preview > crates/zed/RELEASE_CHANNEL
-git commit -q --all --message "${minor_branch_name} preview"
-git tag ${preview_tag_name}
-
-echo "Preparing main for version ${next_minor_branch_name}..."
-git checkout -q main
-git clean -q -dff
-git checkout -q -b ${bump_main_branch_name}
-cargo set-version --package zed --bump minor
-cargo check -q
-
-git commit -q --all --message "${next_minor_branch_name} dev"
-
-git checkout -q main
-
-cat <<MESSAGE
-Prepared new Zed versions locally. You will need to push the branches and open a PR for the change to main.
-
-# To push and open a PR to update main:
-
-    git push -u origin \\
-      ${preview_tag_name} \\
-      ${stable_tag_name} \\
-      ${minor_branch_name} \\
-      ${prev_minor_branch_name} \\
-      ${bump_main_branch_name}
-
-    echo -e "Release Notes:\n\n- N/A" | gh pr create \\
-      --title "Bump Zed to v${major}.${next_minor}" \\
-      --body-file "-" \\
-      --base main \\
-      --head ${bump_main_branch_name} \\
-      --web
-
-# To undo this push:
-
-    git push -f . \\
-      :${preview_tag_name} \\
-      :${stable_tag_name} \\
-      :${minor_branch_name} \\
-      :${bump_main_branch_name} \\
-      ${old_prev_minor_sha}:${prev_minor_branch_name}
-
-MESSAGE

script/bump-zed-patch-version 🔗

@@ -1,18 +0,0 @@
-#!/usr/bin/env bash
-
-channel=$(cat crates/zed/RELEASE_CHANNEL)
-
-tag_suffix=""
-case $channel in
-  stable)
-    ;;
-  preview)
-    tag_suffix="-pre"
-    ;;
-  *)
-    echo "this must be run on either of stable|preview release branches" >&2
-    exit 1
-    ;;
-esac
-
-exec script/lib/bump-version.sh zed v "$tag_suffix" patch

script/bump-zed-version 🔗

@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+
+set -eu
+
+usage() {
+  echo "Usage: $0 [target]"
+  echo ""
+  echo "Triggers the bump_zed_version workflow to perform a minor release version bump "
+  echo "and update the stable and preview versions."
+  echo ""
+  echo "Arguments:"
+  echo "  target      Which channels to bump: all (default), main, preview, or stable"
+  exit 1
+}
+
+target="${1:-all}"
+
+if [[ "$target" != "all" && "$target" != "main" && "$target" != "preview" && "$target" != "stable" ]]; then
+  echo "error: invalid target '$target'" >&2
+  echo "Valid targets: all, main, preview, stable" >&2
+  exit 1
+fi
+
+day_of_week=$(date +%u)
+if [[ $day_of_week -ne 3 ]]; then
+  day_name=$(date +%A)
+  echo "Warning: Today is $day_name. Release version bumps are typically only done on Zednesdays."
+  read -r -p "Continue anyway? (y/N) " confirm
+  if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
+    echo "Aborted."
+    exit 0
+  fi
+fi
+
+which gh > /dev/null 2>&1 || {
+  echo "error: GitHub CLI (gh) is required but not installed." >&2
+  echo "Install it with: brew install gh" >&2
+  exit 1
+}
+
+echo "Triggering bump_zed_version workflow:"
+echo "  target:    $target"
+echo ""
+
+gh workflow run bump_zed_version.yml \
+  -f target="$target"
+
+echo ""
+echo "Workflow triggered. Monitor progress at:"
+echo "  https://github.com/zed-industries/zed/actions/workflows/bump_zed_version.yml"

script/retag-release 🔗

@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+
+set -eu
+
+usage() {
+  echo "Usage: $0 <branch>"
+  echo ""
+  echo "Re-tags the HEAD of a release branch by force-updating the tag."
+  echo "This is useful when commits were added to a release branch after"
+  echo "tagging but before the release was published."
+  echo ""
+  echo "Arguments:"
+  echo "  branch    Release branch name (e.g. v0.180.x)"
+  exit 1
+}
+
+branch="${1:-}"
+
+if [[ -z "$branch" ]]; then
+  usage
+fi
+
+which gh > /dev/null 2>&1 || {
+  echo "error: GitHub CLI (gh) is required but not installed." >&2
+  echo "Install it with: brew install gh" >&2
+  exit 1
+}
+
+echo "Triggering retag_release workflow:"
+echo "  branch: $branch"
+echo ""
+
+gh workflow run retag_release.yml \
+  -f branch="$branch"
+
+echo ""
+echo "Workflow triggered. Monitor progress at:"
+echo "  https://github.com/zed-industries/zed/actions/workflows/retag_release.yml"

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

@@ -9,6 +9,7 @@ use crate::tasks::workflow_checks::{self};
 mod after_release;
 mod autofix_pr;
 mod bump_patch_version;
+mod bump_zed_version;
 mod cherry_pick;
 mod compare_perf;
 mod compliance_check;
@@ -22,6 +23,7 @@ mod extensions;
 mod nix_build;
 mod publish_extension_cli;
 mod release_nightly;
+mod retag_release;
 mod run_bundling;
 
 mod release;
@@ -196,6 +198,7 @@ pub fn run_workflows(args: GenerateWorkflowArgs) -> Result<()> {
         WorkflowFile::zed(after_release::after_release),
         WorkflowFile::zed(autofix_pr::autofix_pr),
         WorkflowFile::zed(bump_patch_version::bump_patch_version),
+        WorkflowFile::zed(bump_zed_version::bump_zed_version),
         WorkflowFile::zed(cherry_pick::cherry_pick),
         WorkflowFile::zed(compare_perf::compare_perf),
         WorkflowFile::zed(compliance_check::compliance_check),
@@ -208,6 +211,7 @@ pub fn run_workflows(args: GenerateWorkflowArgs) -> Result<()> {
         WorkflowFile::zed(publish_extension_cli::publish_extension_cli),
         WorkflowFile::zed(release::release),
         WorkflowFile::zed(release_nightly::release_nightly),
+        WorkflowFile::zed(retag_release::retag_release),
         WorkflowFile::zed(run_agent_evals::run_cron_unit_evals),
         WorkflowFile::zed(run_agent_evals::run_unit_evals),
         WorkflowFile::zed(run_bundling::run_bundling),

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

@@ -61,12 +61,7 @@ fn run_autofix(pr_number: &WorkflowInput, run_clippy: &WorkflowInput) -> NamedJo
     }
 
     fn install_cargo_machete() -> Step<Use> {
-        named::uses(
-            "taiki-e",
-            "install-action",
-            "02cc5f8ca9f2301050c0c099055816a41ee05507",
-        )
-        .add_with(("tool", "cargo-machete@0.7.0"))
+        steps::taiki_install_action("cargo-machete@0.7.0")
     }
 
     fn run_cargo_fmt() -> Step<Run> {

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

@@ -2,7 +2,7 @@ use gh_workflow::*;
 
 use crate::tasks::workflows::{
     runners,
-    steps::{self, CheckoutStep, named},
+    steps::{self, CheckoutStep, CommonJobConditions, named},
     vars::{StepOutput, WorkflowInput},
 };
 
@@ -28,7 +28,7 @@ fn run_bump_patch_version(branch: &WorkflowInput) -> steps::NamedJob {
             .with_ref(branch.to_string())
     }
 
-    fn bump_version() -> Step<Run> {
+    fn read_channel() -> Step<Run> {
         named::bash(indoc::indoc! {r#"
             channel="$(cat crates/zed/RELEASE_CHANNEL)"
 
@@ -40,86 +40,68 @@ fn run_bump_patch_version(branch: &WorkflowInput) -> steps::NamedJob {
                 tag_suffix="-pre"
                 ;;
               *)
-                echo "this must be run on either of stable|preview release branches" >&2
+                echo "::error::must be run on a stable or preview release branch"
                 exit 1
                 ;;
             esac
-            which cargo-set-version > /dev/null || cargo install cargo-edit -f --no-default-features --features "set-version"
-            version="$(cargo set-version -p zed --bump patch 2>&1 | sed 's/.* //')"
-            echo "version=$version" >> "$GITHUB_OUTPUT"
-            echo "tag_suffix=$tag_suffix" >> "$GITHUB_OUTPUT"
+
+            version=$(script/get-crate-version zed)
+
+            {
+                echo "channel=$channel"
+                echo "version=$version"
+                echo "tag_suffix=$tag_suffix"
+            } >> "$GITHUB_OUTPUT"
         "#})
-        .id("bump-version")
+        .id("channel")
     }
 
-    fn commit_changes(
-        version: &StepOutput,
-        token: &StepOutput,
-        branch: &WorkflowInput,
-    ) -> Step<Use> {
-        named::uses(
-            "IAreKyleW00t",
-            "verified-bot-commit",
-            "126a6a11889ab05bcff72ec2403c326cd249b84c", // v2.3.0
-        )
-        .id("commit")
-        .add_with((
-            "message",
-            format!("Bump to {version} for @${{{{ github.actor }}}}"),
-        ))
-        .add_with(("ref", format!("refs/heads/{branch}")))
-        .add_with(("files", "**"))
-        .add_with(("token", token.to_string()))
+    fn verify_prior_release_exists() -> Step<Run> {
+        named::bash(indoc::indoc! {r#"
+            status=$(curl -s -o /dev/null -w '%{http_code}' "https://cloud.zed.dev/releases/$CHANNEL/$VERSION/asset?asset=zed&os=macos&arch=aarch64")
+            if [[ "$status" != "200" ]]; then
+                echo "::error::version $VERSION has not been released on $CHANNEL yet (HTTP $status) — bump the patch version only after the current version is released"
+                exit 1
+            fi
+        "#})
+        .add_env(("CHANNEL", "${{ steps.channel.outputs.channel }}"))
+        .add_env(("VERSION", "${{ steps.channel.outputs.version }}"))
     }
 
-    fn create_version_tag(
-        version: &StepOutput,
-        tag_suffix: &StepOutput,
-        commit_sha: &StepOutput,
-        token: &StepOutput,
-    ) -> Step<Use> {
-        named::uses(
-            "actions",
-            "github-script",
-            "f28e40c7f34bde8b3046d885e986cb6290c5673b", // v7
-        )
-        .with(
-            Input::default()
-                .add(
-                    "script",
-                    indoc::formatdoc! {r#"
-                        github.rest.git.createRef({{
-                            owner: context.repo.owner,
-                            repo: context.repo.repo,
-                            ref: 'refs/tags/v{version}{tag_suffix}',
-                            sha: '{commit_sha}'
-                        }})
-                    "#},
-                )
-                .add("github-token", token.to_string()),
-        )
+    fn bump_version() -> Step<Run> {
+        named::bash(indoc::indoc! {r#"
+            version="$(cargo set-version -p zed --bump patch 2>&1 | sed 's/.* //')"
+            echo "version=$version" >> "$GITHUB_OUTPUT"
+        "#})
+        .id("bump-version")
     }
 
     let (authenticate, token) = steps::authenticate_as_zippy().into();
+    let channel_step = read_channel();
+    let tag_suffix = StepOutput::new(&channel_step, "tag_suffix");
     let bump_version_step = bump_version();
     let version = StepOutput::new(&bump_version_step, "version");
-    let tag_suffix = StepOutput::new(&bump_version_step, "tag_suffix");
-    let commit_step = commit_changes(&version, &token, branch);
+    let commit_step: Step<Use> = steps::BotCommitStep::new(
+        format!("Bump to {version} for @${{{{ github.actor }}}}"),
+        branch,
+        &token,
+    )
+    .into();
     let commit_sha = StepOutput::new_unchecked(&commit_step, "commit");
 
     named::job(
         Job::default()
-            .cond(Expression::new(
-                "github.repository_owner == 'zed-industries'",
-            ))
-            .runs_on(runners::LINUX_XL)
+            .with_repository_owner_guard()
+            .runs_on(runners::LINUX_DEFAULT)
             .add_step(authenticate)
             .add_step(checkout_branch(branch, &token))
+            .add_step(channel_step)
+            .add_step(verify_prior_release_exists())
+            .add_step(steps::install_cargo_edit())
             .add_step(bump_version_step)
             .add_step(commit_step)
-            .add_step(create_version_tag(
-                &version,
-                &tag_suffix,
+            .add_step(steps::create_ref(
+                steps::GitRef::tag(format!("v{version}{tag_suffix}")),
                 &commit_sha,
                 &token,
             )),

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

@@ -0,0 +1,264 @@
+use gh_workflow::*;
+
+use crate::tasks::workflows::{
+    runners,
+    steps::{self, named},
+    vars::{self, StepOutput, WorkflowInput},
+};
+
+pub fn bump_zed_version() -> Workflow {
+    let target = WorkflowInput::string("target", Some("all".to_string()))
+        .description("Which channels to bump: all, main, preview, or stable");
+
+    let (versions_job, outputs) = resolve_versions();
+
+    let bump_main_job = bump_main(&target, &versions_job, &outputs);
+    let preview_job = create_preview_branch(&target, &versions_job, &outputs);
+    let stable_job = promote_to_stable(&target, &versions_job, &outputs);
+
+    named::workflow()
+        .on(Event::default()
+            .workflow_dispatch(WorkflowDispatch::default().add_input(target.name, target.input())))
+        .add_job(versions_job.name, versions_job.job)
+        .add_job(bump_main_job.name, bump_main_job.job)
+        .add_job(preview_job.name, preview_job.job)
+        .add_job(stable_job.name, stable_job.job)
+}
+
+struct ResolvedOutputs {
+    next_version: vars::JobOutput,
+    pr_branch: vars::JobOutput,
+    preview_branch: vars::JobOutput,
+    preview_tag: vars::JobOutput,
+    stable_branch: vars::JobOutput,
+}
+
+fn resolve_versions() -> (steps::NamedJob, ResolvedOutputs) {
+    fn extract_versions() -> Step<Run> {
+        named::bash(indoc::indoc! {r#"
+            version=$(script/get-crate-version zed)
+            major=$(echo "$version" | cut -d. -f1)
+            minor=$(echo "$version" | cut -d. -f2)
+
+            channel=$(cat crates/zed/RELEASE_CHANNEL)
+            if [[ "$channel" != "dev" && "$channel" != "nightly" ]]; then
+                echo "::error::release channel on main should be dev or nightly, found: $channel"
+                exit 1
+            fi
+
+            # Next main version after bump
+            next_version="${major}.$((minor + 1)).0"
+            next_major=$(echo "$next_version" | cut -d. -f1)
+            next_minor=$(echo "$next_version" | cut -d. -f2)
+            pr_branch="bump-zed-to-v${next_major}.${next_minor}.0"
+
+            # New preview branch from current main
+            preview_branch="v${major}.${minor}.x"
+            preview_tag="v${version}-pre"
+
+            # Current preview to promote to stable — derive branch from released preview version
+            released_preview=$(script/get-released-version preview)
+            if [[ -z "$released_preview" ]]; then
+                echo "::error::could not determine released preview version"
+                exit 1
+            fi
+            stable_major=$(echo "$released_preview" | cut -d. -f1)
+            stable_minor=$(echo "$released_preview" | cut -d. -f2)
+            stable_branch="v${stable_major}.${stable_minor}.x"
+
+            # Final validation
+            for var in next_version pr_branch preview_branch preview_tag stable_branch; do
+                if [[ -z "${!var}" ]]; then
+                    echo "::error::failed to compute $var"
+                    exit 1
+                fi
+            done
+
+            {
+                echo "next_version=$next_version"
+                echo "pr_branch=$pr_branch"
+                echo "preview_branch=$preview_branch"
+                echo "preview_tag=$preview_tag"
+                echo "stable_branch=$stable_branch"
+            } >> "$GITHUB_OUTPUT"
+
+            echo "Resolved: next=$next_version preview=$preview_branch($preview_tag) stable=$stable_branch pr=$pr_branch"
+        "#})
+        .id("versions")
+    }
+
+    let (authenticate, token) = steps::authenticate_as_zippy().into();
+    let versions_step = extract_versions();
+    let next_version = StepOutput::new(&versions_step, "next_version");
+    let pr_branch = StepOutput::new(&versions_step, "pr_branch");
+    let preview_branch = StepOutput::new(&versions_step, "preview_branch");
+    let preview_tag = StepOutput::new(&versions_step, "preview_tag");
+    let stable_branch = StepOutput::new(&versions_step, "stable_branch");
+
+    let job = named::job(
+        Job::default()
+            .cond(Expression::new(
+                "github.repository_owner == 'zed-industries'",
+            ))
+            .runs_on(runners::LINUX_XL)
+            .add_step(authenticate)
+            .add_step(steps::checkout_repo().with_token(&token).with_ref("main"))
+            .add_step(versions_step)
+            .outputs([
+                (next_version.name.to_owned(), next_version.to_string()),
+                (pr_branch.name.to_owned(), pr_branch.to_string()),
+                (preview_branch.name.to_owned(), preview_branch.to_string()),
+                (preview_tag.name.to_owned(), preview_tag.to_string()),
+                (stable_branch.name.to_owned(), stable_branch.to_string()),
+            ]),
+    );
+
+    let outputs = ResolvedOutputs {
+        next_version: next_version.as_job_output(&job),
+        pr_branch: pr_branch.as_job_output(&job),
+        preview_branch: preview_branch.as_job_output(&job),
+        preview_tag: preview_tag.as_job_output(&job),
+        stable_branch: stable_branch.as_job_output(&job),
+    };
+
+    (job, outputs)
+}
+
+fn bump_main(
+    target: &WorkflowInput,
+    versions_job: &steps::NamedJob,
+    outputs: &ResolvedOutputs,
+) -> steps::NamedJob {
+    fn bump_version() -> Step<Run> {
+        named::bash("cargo set-version -p zed --bump minor")
+    }
+
+    let (authenticate, token) = steps::authenticate_as_zippy().into();
+
+    named::job(
+        Job::default()
+            .cond(Expression::new(format!(
+                "{} == 'all' || {} == 'main'",
+                target.expr(),
+                target.expr(),
+            )))
+            .needs(vec![versions_job.name.clone()])
+            .runs_on(runners::LINUX_DEFAULT)
+            .add_step(authenticate)
+            .add_step(steps::checkout_repo().with_token(&token).with_ref("main"))
+            .add_step(steps::install_cargo_edit())
+            .add_step(bump_version())
+            .add_step(steps::CreatePrStep::new(
+                format!("Bump Zed to v{}", outputs.next_version),
+                &outputs.pr_branch,
+                &token,
+            )),
+    )
+}
+
+fn create_preview_branch(
+    target: &WorkflowInput,
+    versions_job: &steps::NamedJob,
+    outputs: &ResolvedOutputs,
+) -> steps::NamedJob {
+    fn promote_to_preview() -> Step<Run> {
+        named::bash("echo -n preview > crates/zed/RELEASE_CHANNEL")
+    }
+
+    fn get_main_sha() -> Step<Run> {
+        named::bash("echo \"main_sha=$(git rev-parse HEAD)\" >> \"$GITHUB_OUTPUT\"").id("main-sha")
+    }
+
+    let (authenticate, token) = steps::authenticate_as_zippy().into();
+
+    let main_sha_step = get_main_sha();
+    let main_sha = StepOutput::new(&main_sha_step, "main_sha");
+
+    let commit_step: Step<Use> = steps::BotCommitStep::new(
+        format!("{} preview", outputs.preview_branch),
+        &outputs.preview_branch,
+        &token,
+    )
+    .with_files("crates/zed/RELEASE_CHANNEL")
+    .into();
+    let commit_sha = StepOutput::new_unchecked(&commit_step, "commit");
+
+    named::job(
+        Job::default()
+            .cond(Expression::new(format!(
+                "{} == 'all' || {} == 'preview'",
+                target.expr(),
+                target.expr(),
+            )))
+            .needs(vec![versions_job.name.clone()])
+            .runs_on(runners::LINUX_DEFAULT)
+            .add_step(authenticate)
+            .add_step(steps::checkout_repo().with_token(&token).with_ref("main"))
+            .add_step(main_sha_step)
+            .add_step(promote_to_preview())
+            .add_step(steps::create_ref(
+                steps::GitRef::branch(&outputs.preview_branch),
+                &main_sha,
+                &token,
+            ))
+            .add_step(commit_step)
+            .add_step(steps::create_ref(
+                steps::GitRef::tag(&outputs.preview_tag),
+                &commit_sha,
+                &token,
+            )),
+    )
+}
+
+fn promote_to_stable(
+    target: &WorkflowInput,
+    versions_job: &steps::NamedJob,
+    outputs: &ResolvedOutputs,
+) -> steps::NamedJob {
+    let (authenticate, token) = steps::authenticate_as_zippy().into();
+
+    let read_version_step = named::bash(indoc::indoc! {r#"
+            stable_version=$(script/get-crate-version zed)
+            {
+                echo "stable_tag=v${stable_version}"
+            } >> "$GITHUB_OUTPUT"
+        "#})
+    .id("stable-info");
+    let stable_tag = StepOutput::new(&read_version_step, "stable_tag");
+
+    let write_channel = named::bash("echo -n stable > crates/zed/RELEASE_CHANNEL");
+
+    let commit_step: Step<Use> = steps::BotCommitStep::new(
+        format!("{} stable", outputs.stable_branch),
+        &outputs.stable_branch,
+        &token,
+    )
+    .with_files("crates/zed/RELEASE_CHANNEL")
+    .into();
+    let commit_sha = StepOutput::new_unchecked(&commit_step, "commit");
+
+    named::job(
+        Job::default()
+            .cond(Expression::new(format!(
+                "{} == 'all' || {} == 'stable'",
+                target.expr(),
+                target.expr(),
+            )))
+            .needs(vec![versions_job.name.clone()])
+            .runs_on(runners::LINUX_DEFAULT)
+            .add_step(authenticate)
+            .add_step(
+                steps::checkout_repo()
+                    .with_token(&token)
+                    .with_ref(outputs.stable_branch.to_string()),
+            )
+            .add_step(read_version_step)
+            .add_step(write_channel)
+            .add_step(commit_step)
+            .add_step(steps::create_ref(
+                steps::GitRef::tag(&stable_tag),
+                &commit_sha,
+                &token,
+            )),
+    )
+}

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

@@ -1,4 +1,4 @@
-use gh_workflow::{ctx::Context, *};
+use gh_workflow::*;
 use indoc::{formatdoc, indoc};
 
 use crate::tasks::workflows::{
@@ -327,27 +327,9 @@ fn create_pull_request(
     generated_token: StepOutput,
     branch_name: StepOutput,
 ) -> Step<Use> {
-    named::uses(
-        "peter-evans",
-        "create-pull-request",
-        "98357b18bf14b5342f975ff684046ec3b2a07725",
-    )
-    .with(
-        Input::default()
-            .add("title", title.to_string())
-            .add("body", body.to_string())
-            .add("commit-message", title.to_string())
-            .add("branch", branch_name.to_string())
-            .add(
-                "committer",
-                "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
-            )
-            .add("base", "main")
-            .add("delete-branch", true)
-            .add("token", generated_token.to_string())
-            .add("sign-commits", true)
-            .add("assignees", Context::github().actor().to_string()),
-    )
+    steps::CreatePrStep::new(title.to_string(), branch_name, &generated_token)
+        .with_body(body)
+        .into()
 }
 
 fn trigger_release(

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

@@ -34,6 +34,7 @@ pub(crate) fn extension_workflow_rollout() -> Workflow {
         removed_ci,
         removed_shared,
         &extra_context_input,
+        &filter_repos_input,
     );
     let create_tag = create_rollout_tag(&rollout_workflows, &filter_repos_input);
 
@@ -192,6 +193,7 @@ fn rollout_workflows_to_extension(
     removed_ci: JobOutput,
     removed_shared: JobOutput,
     extra_context_input: &WorkflowInput,
+    filter_repos_input: &WorkflowInput,
 ) -> NamedJob {
     fn checkout_extension_repo(token: &StepOutput) -> CheckoutStep {
         steps::checkout_repo()
@@ -259,6 +261,7 @@ fn rollout_workflows_to_extension(
         token: &StepOutput,
         short_sha: &StepOutput,
         context_input: &WorkflowInput,
+        filter_repos_input: &WorkflowInput,
     ) -> Step<Use> {
         let title = format!("Update CI workflows to `{short_sha}`");
 
@@ -270,29 +273,16 @@ fn rollout_workflows_to_extension(
         "#,
         };
 
-        named::uses(
-            "peter-evans",
-            "create-pull-request",
-            "98357b18bf14b5342f975ff684046ec3b2a07725",
-        )
-        .add_with(("path", "extension"))
-        .add_with(("title", title.clone()))
-        .add_with(("body", body))
-        .add_with(("commit-message", title))
-        .add_with(("branch", "update-workflows"))
-        .add_with((
-            "committer",
-            "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
-        ))
-        .add_with((
-            "author",
-            "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
-        ))
-        .add_with(("base", "main"))
-        .add_with(("delete-branch", true))
-        .add_with(("token", token.to_string()))
-        .add_with(("sign-commits", true))
-        .id("create-pr")
+        let pr_step: Step<Use> = steps::CreatePrStep::new(title, "update-workflows", token)
+            .with_body(body)
+            .with_path("extension")
+            // Save my inbox from exploding on rollout
+            .with_assignee(format!(
+                "${{{{ {repos_expr} != '' && github.actor || '' }}}}",
+                repos_expr = filter_repos_input.expr()
+            ))
+            .into();
+        pr_step.id("create-pr")
     }
 
     fn enable_auto_merge(token: &StepOutput) -> Step<gh_workflow::Run> {
@@ -345,7 +335,7 @@ fn rollout_workflows_to_extension(
         .add_step(download_workflow_files())
         .add_step(sync_workflow_files(removed_ci, removed_shared))
         .add_step(calculate_short_sha)
-        .add_step(create_pull_request(&token, &short_sha, extra_context_input))
+        .add_step(create_pull_request(&token, &short_sha, extra_context_input, filter_repos_input))
         .add_step(enable_auto_merge(&token));
 
     named::job(job)

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

@@ -1,4 +1,4 @@
-use gh_workflow::{ctx::Context, *};
+use gh_workflow::*;
 use indoc::indoc;
 
 use crate::tasks::workflows::{
@@ -88,31 +88,15 @@ fn create_pull_request_zed(generated_token: &StepOutput, short_sha: &StepOutput)
         short_sha
     );
 
-    named::uses("peter-evans", "create-pull-request", "98357b18bf14b5342f975ff684046ec3b2a07725").with(
-        Input::default()
-            .add("title", title.clone())
-            .add(
-                "body",
-                indoc! {r#"
-                    This PR bumps the extension CLI version used in the extension workflows to `${{ github.sha }}`.
-
-                    Release Notes:
-
-                    - N/A
-                "#},
-            )
-            .add("commit-message", title)
-            .add("branch", "update-extension-cli-sha")
-            .add(
-                "committer",
-                "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
-            )
-            .add("base", "main")
-            .add("delete-branch", true)
-            .add("token", generated_token.to_string())
-            .add("sign-commits", true)
-            .add("assignees", Context::github().actor().to_string()),
-    )
+    steps::CreatePrStep::new(title, "update-extension-cli-sha", generated_token)
+        .with_body(indoc::indoc! {r#"
+            This PR bumps the extension CLI version used in the extension workflows to `${{ github.sha }}`.
+
+            Release Notes:
+
+            - N/A
+        "#})
+        .into()
 }
 
 fn update_sha_in_extensions(publish_job: &NamedJob) -> NamedJob {
@@ -160,28 +144,12 @@ fn create_pull_request_extensions(
 ) -> Step<Use> {
     let title = format!("Bump extension CLI version to `{}`", short_sha);
 
-    named::uses("peter-evans", "create-pull-request", "98357b18bf14b5342f975ff684046ec3b2a07725").with(
-        Input::default()
-            .add("title", title.clone())
-            .add(
-                "body",
-                indoc! {r#"
-                    This PR bumps the extension CLI version to https://github.com/zed-industries/zed/commit/${{ github.sha }}.
-                "#},
-            )
-            .add("commit-message", title)
-            .add("branch", "update-extension-cli-sha")
-            .add(
-                "committer",
-                "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
-            )
-            .add("base", "main")
-            .add("delete-branch", true)
-            .add("token", generated_token.to_string())
-            .add("sign-commits", true)
-            .add("labels", "allow-no-extension")
-            .add("assignees", Context::github().actor().to_string()),
-    )
+    steps::CreatePrStep::new(title, "update-extension-cli-sha", generated_token)
+        .with_body(indoc::indoc! {r#"
+            This PR bumps the extension CLI version to https://github.com/zed-industries/zed/commit/${{ github.sha }}.
+        "#})
+        .with_labels("allow-no-extension")
+        .into()
 }
 
 fn get_short_sha() -> (Step<Run>, StepOutput) {

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

@@ -0,0 +1,100 @@
+use gh_workflow::*;
+
+use crate::tasks::workflows::{
+    runners,
+    steps::{self, CheckoutStep, CommonJobConditions, named},
+    vars::{StepOutput, WorkflowInput},
+};
+
+pub fn retag_release() -> Workflow {
+    let branch = WorkflowInput::string("branch", None)
+        .description("Release branch to re-tag (e.g. v0.180.x)");
+    let retag_job = run_retag_release(&branch);
+    named::workflow()
+        .on(Event::default()
+            .workflow_dispatch(WorkflowDispatch::default().add_input(branch.name, branch.input())))
+        .concurrency(
+            Concurrency::new(Expression::new(format!(
+                "${{{{ github.workflow }}}}-{branch}"
+            )))
+            .cancel_in_progress(true),
+        )
+        .add_job(retag_job.name, retag_job.job)
+}
+
+fn run_retag_release(branch: &WorkflowInput) -> steps::NamedJob {
+    fn checkout_branch(branch: &WorkflowInput, token: &StepOutput) -> CheckoutStep {
+        steps::checkout_repo()
+            .with_token(token)
+            .with_ref(branch.to_string())
+    }
+
+    fn resolve_tag(branch: &WorkflowInput) -> Step<Run> {
+        named::bash(indoc::indoc! {r#"
+            if [[ ! "$BRANCH" =~ ^v[0-9]+\.[0-9]{1,3}\.x$ ]]; then
+                echo "::error::branch '$BRANCH' does not match the release branch pattern v[N].[N].x"
+                exit 1
+            fi
+
+            channel="$(cat crates/zed/RELEASE_CHANNEL)"
+
+            tag_suffix=""
+            case $channel in
+              stable)
+                ;;
+              preview)
+                tag_suffix="-pre"
+                ;;
+              *)
+                echo "::error::must be run on a stable or preview release branch"
+                exit 1
+                ;;
+            esac
+
+            version=$(script/get-crate-version zed)
+
+            {
+                echo "channel=$channel"
+                echo "version=$version"
+                echo "tag_suffix=$tag_suffix"
+                echo "head_sha=$(git rev-parse HEAD)"
+            } >> "$GITHUB_OUTPUT"
+        "#})
+        .id("info")
+        .add_env(("BRANCH", branch.to_string()))
+    }
+
+    fn verify_no_existing_release() -> Step<Run> {
+        named::bash(indoc::indoc! {r#"
+            status=$(curl -s -o /dev/null -w '%{http_code}' "https://cloud.zed.dev/releases/$CHANNEL/$VERSION/asset?asset=zed&os=macos&arch=aarch64")
+            if [[ "$status" == "200" ]]; then
+                echo "::error::version $VERSION is already released on $CHANNEL — cannot re-tag a released version"
+                exit 1
+            fi
+        "#})
+        .add_env(("CHANNEL", "${{ steps.info.outputs.channel }}"))
+        .add_env(("VERSION", "${{ steps.info.outputs.version }}"))
+    }
+
+    let (authenticate, token) = steps::authenticate_as_zippy().into();
+    let resolve_step = resolve_tag(branch);
+    let version = StepOutput::new(&resolve_step, "version");
+    let tag_suffix = StepOutput::new(&resolve_step, "tag_suffix");
+    let head_sha = StepOutput::new(&resolve_step, "head_sha");
+
+    named::job(
+        Job::default()
+            .with_repository_owner_guard()
+            .runs_on(runners::LINUX_XL)
+            .add_step(authenticate)
+            .add_step(checkout_branch(branch, &token))
+            .add_step(resolve_step)
+            .add_step(verify_no_existing_release())
+            .add_step(steps::update_ref(
+                steps::GitRef::tag(format!("v{version}{tag_suffix}")),
+                &head_sha,
+                &token,
+                true,
+            )),
+    )
+}

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

@@ -430,12 +430,7 @@ fn check_style() -> NamedJob {
 
 fn check_dependencies() -> NamedJob {
     fn install_cargo_machete() -> Step<Use> {
-        named::uses(
-            "taiki-e",
-            "install-action",
-            "02cc5f8ca9f2301050c0c099055816a41ee05507",
-        )
-        .add_with(("tool", "cargo-machete@0.7.0"))
+        steps::taiki_install_action("cargo-machete@0.7.0")
     }
 
     fn run_cargo_machete() -> Step<Run> {

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

@@ -1,4 +1,4 @@
-use gh_workflow::*;
+use gh_workflow::{ctx::Context, *};
 use serde_json::Value;
 
 use crate::tasks::workflows::{
@@ -176,6 +176,20 @@ pub fn cargo_fmt() -> Step<Run> {
     named::bash("cargo fmt --all -- --check")
 }
 
+pub fn install_cargo_edit() -> Step<Use> {
+    taiki_install_action("cargo-edit")
+}
+
+pub fn taiki_install_action(tool: &str) -> Step<Use> {
+    Step::new(named::function_name(1))
+        .uses(
+            "taiki-e",
+            "install-action",
+            "02cc5f8ca9f2301050c0c099055816a41ee05507", // v2
+        )
+        .add_with(("tool", tool))
+}
+
 pub fn cargo_install_nextest() -> Step<Use> {
     named::uses(
         "taiki-e",
@@ -648,3 +662,239 @@ fn generate_token_with_job_name<'a>(
         permissions: None,
     }
 }
+
+pub(crate) struct BotCommitStep {
+    message: String,
+    branch: String,
+    files: String,
+    token: String,
+}
+
+impl BotCommitStep {
+    pub fn new(message: impl ToString, branch: impl ToString, token: &StepOutput) -> Self {
+        Self {
+            message: message.to_string(),
+            branch: branch.to_string(),
+            files: "**".to_string(),
+            token: token.to_string(),
+        }
+    }
+
+    pub fn with_files(self, files: impl ToString) -> Self {
+        Self {
+            files: files.to_string(),
+            ..self
+        }
+    }
+}
+
+impl From<BotCommitStep> for Step<Use> {
+    fn from(step: BotCommitStep) -> Self {
+        Step::new("steps::bot_commit")
+            .uses(
+                "IAreKyleW00t",
+                "verified-bot-commit",
+                "126a6a11889ab05bcff72ec2403c326cd249b84c", // v2.3.0
+            )
+            .id("commit")
+            .add_with(("message", step.message))
+            .add_with(("ref", format!("refs/heads/{}", step.branch)))
+            .add_with(("files", step.files))
+            .add_with(("token", step.token))
+    }
+}
+
+pub(crate) enum GitRef {
+    Tag(String),
+    Branch(String),
+}
+
+impl GitRef {
+    pub fn tag(name: impl ToString) -> Self {
+        Self::Tag(name.to_string())
+    }
+
+    pub fn branch(name: impl ToString) -> Self {
+        Self::Branch(name.to_string())
+    }
+
+    fn create_ref_path(&self) -> String {
+        match self {
+            Self::Tag(name) => format!("refs/tags/{name}"),
+            Self::Branch(name) => format!("refs/heads/{name}"),
+        }
+    }
+
+    fn update_ref_path(&self) -> String {
+        match self {
+            Self::Tag(name) => format!("tags/{name}"),
+            Self::Branch(name) => format!("heads/{name}"),
+        }
+    }
+
+    fn kind(&self) -> &'static str {
+        match self {
+            Self::Tag(_) => "tag",
+            Self::Branch(_) => "branch",
+        }
+    }
+}
+
+#[allow(unused)]
+enum RefOperation {
+    Create,
+    Update { force: bool },
+}
+
+struct RefOp {
+    git_ref: GitRef,
+    operation: RefOperation,
+    sha: String,
+    token: String,
+}
+
+impl From<RefOp> for Step<Use> {
+    fn from(op: RefOp) -> Self {
+        let (api_method, ref_path, force_line) = match &op.operation {
+            RefOperation::Create => ("createRef", op.git_ref.create_ref_path(), String::new()),
+            RefOperation::Update { force } => (
+                "updateRef",
+                op.git_ref.update_ref_path(),
+                format!(",\n    force: {force}"),
+            ),
+        };
+        let step_name = match &op.operation {
+            RefOperation::Create => format!("steps::create_{}", op.git_ref.kind()),
+            RefOperation::Update { .. } => format!("steps::update_{}", op.git_ref.kind()),
+        };
+        let sha = &op.sha;
+        let script = indoc::formatdoc! {r#"
+            github.rest.git.{api_method}({{
+                owner: context.repo.owner,
+                repo: context.repo.repo,
+                ref: '{ref_path}',
+                sha: '{sha}'{force_line}
+            }})
+        "#};
+        Step::new(step_name)
+            .uses(
+                "actions",
+                "github-script",
+                "f28e40c7f34bde8b3046d885e986cb6290c5673b", // v7
+            )
+            .with(
+                Input::default()
+                    .add("script", script)
+                    .add("github-token", op.token),
+            )
+    }
+}
+
+pub(crate) fn create_ref(
+    git_ref: GitRef,
+    sha: impl ToString,
+    token: &StepOutput,
+) -> impl Into<Step<Use>> {
+    RefOp {
+        git_ref,
+        operation: RefOperation::Create,
+        sha: sha.to_string(),
+        token: token.to_string(),
+    }
+}
+
+#[allow(unused)]
+pub(crate) fn update_ref(
+    git_ref: GitRef,
+    sha: impl ToString,
+    token: &StepOutput,
+    force: bool,
+) -> impl Into<Step<Use>> {
+    RefOp {
+        git_ref,
+        operation: RefOperation::Update { force },
+        sha: sha.to_string(),
+        token: token.to_string(),
+    }
+}
+
+const ZED_ZIPPY_COMMITTER: &str =
+    "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>";
+
+pub(crate) struct CreatePrStep {
+    title: String,
+    body: String,
+    branch: String,
+    base: String,
+    token: String,
+    assignees: Option<String>,
+    labels: Option<String>,
+    path: Option<String>,
+}
+
+impl CreatePrStep {
+    pub fn new(title: impl ToString, branch: impl ToString, token: &StepOutput) -> Self {
+        Self {
+            title: title.to_string(),
+            body: "Release Notes:\n\n- N/A".to_string(),
+            branch: branch.to_string(),
+            base: "main".to_string(),
+            token: token.to_string(),
+            assignees: Some(Context::github().actor().to_string()),
+            labels: None,
+            path: None,
+        }
+    }
+
+    pub fn with_body(self, body: impl ToString) -> Self {
+        Self {
+            body: body.to_string(),
+            ..self
+        }
+    }
+
+    pub fn with_assignee(self, assignee: impl ToString) -> Self {
+        Self {
+            assignees: Some(assignee.to_string()),
+            ..self
+        }
+    }
+
+    pub fn with_labels(self, labels: impl ToString) -> Self {
+        Self {
+            labels: Some(labels.to_string()),
+            ..self
+        }
+    }
+
+    pub fn with_path(self, path: impl ToString) -> Self {
+        Self {
+            path: Some(path.to_string()),
+            ..self
+        }
+    }
+}
+
+impl From<CreatePrStep> for Step<Use> {
+    fn from(step: CreatePrStep) -> Self {
+        Step::new("steps::create_pull_request")
+            .uses(
+                "peter-evans",
+                "create-pull-request",
+                "98357b18bf14b5342f975ff684046ec3b2a07725", // v7
+            )
+            .add_with(("title", step.title.clone()))
+            .add_with(("body", step.body))
+            .add_with(("commit-message", step.title))
+            .add_with(("branch", step.branch))
+            .add_with(("committer", ZED_ZIPPY_COMMITTER))
+            .add_with(("author", ZED_ZIPPY_COMMITTER))
+            .add_with(("base", step.base))
+            .add_with(("delete-branch", true))
+            .add_with(("token", step.token))
+            .add_with(("sign-commits", true))
+            .when_some(step.assignees, |s, v| s.add_with(("assignees", v)))
+            .when_some(step.labels, |s, v| s.add_with(("labels", v)))
+            .when_some(step.path, |s, v| s.add_with(("path", v)))
+    }
+}