Merge pull request #1813 from zed-industries/preview-channel

Max Brunsfeld created

Create preview channel

Change summary

.github/workflows/ci.yml                     |  30 +++++
.github/workflows/publish_collab_image.yml   |  13 +-
Cargo.lock                                   |   1 
crates/auto_update/src/auto_update.rs        |  23 +++-
crates/client/Cargo.toml                     |   2 
crates/client/src/client.rs                  |  18 ++
crates/collab/k8s/environments/preview.sh    |   3 
crates/collab/k8s/manifest.template.yml      |   2 
crates/settings/src/settings.rs              |   8 +
crates/zed/Cargo.toml                        |  16 ++
crates/zed/RELEASE_CHANNEL                   |   1 
crates/zed/build.rs                          |  11 +
crates/zed/resources/app-icon-preview.png    |   0 
crates/zed/resources/app-icon-preview@2x.png |   0 
crates/zed/resources/app-icon.png            |   0 
crates/zed/resources/app-icon@2x.png         |   0 
crates/zed/src/main.rs                       |   5 
crates/zed/src/zed.rs                        |  22 ++++
script/bump-app-version                      |  17 +++
script/bump-collab-version                   |   2 
script/bundle                                |  26 +++-
script/get-crate-version                     |  17 +++
script/lib/bump-version.sh                   |  18 ++-
script/railcar                               | 109 ++++++++++++++++++++++
script/validate-version                      |  17 ---
25 files changed, 292 insertions(+), 69 deletions(-)

Detailed changes

.github/workflows/ci.yml 🔗

@@ -79,9 +79,30 @@ jobs:
           clean: false
           submodules: 'recursive'
 
-      - name: Validate version
+      - name: Determine version and release channel
         if: ${{ startsWith(github.ref, 'refs/tags/v') }}
-        run: script/validate-version
+        run: |
+          set -eu
+
+          version=$(script/get-crate-version zed)
+          channel=$(cat crates/zed/RELEASE_CHANNEL)
+          echo "Publishing version: ${version} on release channel ${channel}"
+          echo "RELEASE_CHANNEL=${channel}" >> $GITHUB_ENV
+
+          expected_tag_name=""
+          case ${channel} in
+            stable)
+              expected_tag_name="v${version}";;
+            preview)
+              expected_tag_name="v${version}-pre";;
+            *)
+              echo "can't publish a release on channel ${channel}"
+              exit 1;;
+          esac
+          if [[ $GITHUB_REFNAME != $expected_tag_name ]]; then
+            echo "invalid release tag ${GITHUB_REFNAME}. expected ${expected_tag_name}"
+            exit 1
+          fi
 
       - name: Create app bundle
         run: script/bundle
@@ -94,10 +115,11 @@ jobs:
           path: target/release/Zed.dmg
 
       - uses: softprops/action-gh-release@v1
-        name: Upload app bundle to release if release tag
-        if: ${{ startsWith(github.ref, 'refs/tags/v') }}
+        name: Upload app bundle to release
+        if: ${{ github.env.RELEASE_CHANNEL }}
         with:
           draft: true
+          prerelease: ${{ github.env.RELEASE_CHANNEL == 'preview' }}
           files: target/release/Zed.dmg
           overwrite: true
           body: ""

.github/workflows/publish_collab_image.yml 🔗

@@ -28,17 +28,16 @@ jobs:
           clean: false
           submodules: 'recursive'
 
-      - name: Check that tag version matches package version
+      - name: Determine version
         run: |
           set -eu
-          package_version=$(cargo metadata --no-deps --format-version=1 | jq --raw-output '.packages[] | select(.name == "collab") | .version')
-          tag_version=$(echo $GITHUB_REF_NAME | sed -e 's/collab-v//')
-          if [[ $tag_version != $package_version ]]; then
-            echo "collab package version $package_version does not match git tag version $tag_version"
+          version=$(script/get-crate-version collab)
+          if [[ $GITHUB_REF_NAME != "collab-v${version}" ]]; then
+            echo "release tag ${GITHUB_REF_NAME} does not match version ${version}"
             exit 1
           fi
-          echo "Publishing image version: $package_version"
-          echo "COLLAB_VERSION=$package_version" >> $GITHUB_ENV
+          echo "Publishing collab version: ${version}"
+          echo "COLLAB_VERSION=${version}" >> $GITHUB_ENV
 
       - name: Build docker image
         run: docker build . --tag registry.digitalocean.com/zed/collab:v${COLLAB_VERSION}

Cargo.lock 🔗

@@ -959,6 +959,7 @@ dependencies = [
  "rand 0.8.5",
  "rpc",
  "serde",
+ "settings",
  "smol",
  "sum_tree",
  "tempfile",

crates/auto_update/src/auto_update.rs 🔗

@@ -1,13 +1,14 @@
 mod update_notification;
 
 use anyhow::{anyhow, Context, Result};
-use client::{http::HttpClient, ZED_SECRET_CLIENT_TOKEN};
+use client::{http::HttpClient, ZED_SECRET_CLIENT_TOKEN, ZED_SERVER_URL};
 use gpui::{
     actions, platform::AppVersion, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle,
     MutableAppContext, Task, WeakViewHandle,
 };
 use lazy_static::lazy_static;
 use serde::Deserialize;
+use settings::ReleaseChannel;
 use smol::{fs::File, io::AsyncReadExt, process::Command};
 use std::{env, ffi::OsString, path::PathBuf, sync::Arc, time::Duration};
 use update_notification::UpdateNotification;
@@ -54,13 +55,9 @@ impl Entity for AutoUpdater {
     type Event = ();
 }
 
-pub fn init(
-    db: project::Db,
-    http_client: Arc<dyn HttpClient>,
-    server_url: String,
-    cx: &mut MutableAppContext,
-) {
+pub fn init(db: project::Db, http_client: Arc<dyn HttpClient>, cx: &mut MutableAppContext) {
     if let Some(version) = (*ZED_APP_VERSION).or_else(|| cx.platform().app_version().ok()) {
+        let server_url = ZED_SERVER_URL.to_string();
         let auto_updater = cx.add_model(|cx| {
             let updater = AutoUpdater::new(version, db.clone(), http_client, server_url.clone());
             updater.start_polling(cx).detach();
@@ -177,9 +174,19 @@ impl AutoUpdater {
                 this.current_version,
             )
         });
+
+        let preview_param = cx.read(|cx| {
+            if cx.has_global::<ReleaseChannel>() {
+                if *cx.global::<ReleaseChannel>() == ReleaseChannel::Preview {
+                    return "&preview=1";
+                }
+            }
+            ""
+        });
+
         let mut response = client
             .get(
-                &format!("{server_url}/api/releases/latest?token={ZED_SECRET_CLIENT_TOKEN}&asset=Zed.dmg"),
+                &format!("{server_url}/api/releases/latest?token={ZED_SECRET_CLIENT_TOKEN}&asset=Zed.dmg{preview_param}"),
                 Default::default(),
                 true,
             )

crates/client/Cargo.toml 🔗

@@ -35,9 +35,11 @@ tiny_http = "0.8"
 uuid = { version = "1.1.2", features = ["v4"] }
 url = "2.2"
 serde = { version = "*", features = ["derive"] }
+settings = { path = "../settings" }
 tempfile = "3"
 
 [dev-dependencies]
 collections = { path = "../collections", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 rpc = { path = "../rpc", features = ["test-support"] }
+settings = { path = "../settings", features = ["test-support"] }

crates/client/src/client.rs 🔗

@@ -30,6 +30,7 @@ use postage::watch;
 use rand::prelude::*;
 use rpc::proto::{AnyTypedEnvelope, EntityMessage, EnvelopedMessage, RequestMessage};
 use serde::Deserialize;
+use settings::ReleaseChannel;
 use std::{
     any::TypeId,
     collections::HashMap,
@@ -931,8 +932,9 @@ impl Client {
         self.establish_websocket_connection(credentials, cx)
     }
 
-    async fn get_rpc_url(http: Arc<dyn HttpClient>) -> Result<Url> {
-        let url = format!("{}/rpc", *ZED_SERVER_URL);
+    async fn get_rpc_url(http: Arc<dyn HttpClient>, is_preview: bool) -> Result<Url> {
+        let preview_param = if is_preview { "?preview=1" } else { "" };
+        let url = format!("{}/rpc{preview_param}", *ZED_SERVER_URL);
         let response = http.get(&url, Default::default(), false).await?;
 
         // Normally, ZED_SERVER_URL is set to the URL of zed.dev website.
@@ -967,6 +969,14 @@ impl Client {
         credentials: &Credentials,
         cx: &AsyncAppContext,
     ) -> Task<Result<Connection, EstablishConnectionError>> {
+        let is_preview = cx.read(|cx| {
+            if cx.has_global::<ReleaseChannel>() {
+                *cx.global::<ReleaseChannel>() == ReleaseChannel::Preview
+            } else {
+                false
+            }
+        });
+
         let request = Request::builder()
             .header(
                 "Authorization",
@@ -976,7 +986,7 @@ impl Client {
 
         let http = self.http.clone();
         cx.background().spawn(async move {
-            let mut rpc_url = Self::get_rpc_url(http).await?;
+            let mut rpc_url = Self::get_rpc_url(http, is_preview).await?;
             let rpc_host = rpc_url
                 .host_str()
                 .zip(rpc_url.port_or_known_default())
@@ -1130,7 +1140,7 @@ impl Client {
 
         // Use the collab server's admin API to retrieve the id
         // of the impersonated user.
-        let mut url = Self::get_rpc_url(http.clone()).await?;
+        let mut url = Self::get_rpc_url(http.clone(), false).await?;
         url.set_path("/user");
         url.set_query(Some(&format!("github_login={login}")));
         let request = Request::get(url.as_str())

crates/collab/k8s/manifest.template.yml 🔗

@@ -11,7 +11,7 @@ metadata:
   name: collab
   annotations:
     service.beta.kubernetes.io/do-loadbalancer-tls-ports: "443"
-    service.beta.kubernetes.io/do-loadbalancer-certificate-id: "40879815-9a6b-4bbb-8207-8f2c7c0218f9"
+    service.beta.kubernetes.io/do-loadbalancer-certificate-id: "08d9d8ce-761f-4ab3-bc78-4923ab5b0e33"
 spec:
   type: LoadBalancer
   selector:

crates/settings/src/settings.rs 🔗

@@ -54,6 +54,14 @@ pub struct FeatureFlags {
     pub experimental_themes: bool,
 }
 
+#[derive(Copy, Clone, PartialEq, Eq, Default)]
+pub enum ReleaseChannel {
+    #[default]
+    Dev,
+    Preview,
+    Stable,
+}
+
 impl FeatureFlags {
     pub fn keymap_files(&self) -> Vec<&'static str> {
         vec![]

crates/zed/Cargo.toml 🔗

@@ -123,8 +123,20 @@ env_logger = "0.9"
 serde_json = { version = "1.0", features = ["preserve_order"] }
 unindent = "0.1.7"
 
-[package.metadata.bundle]
-icon = ["app-icon@2x.png", "app-icon.png"]
+[package.metadata.bundle-dev]
+icon = ["resources/app-icon-preview@2x.png", "resources/app-icon-preview.png"]
+identifier = "dev.zed.Zed-Dev"
+name = "Zed Dev"
+osx_minimum_system_version = "10.15.7"
+
+[package.metadata.bundle-preview]
+icon = ["resources/app-icon-preview@2x.png", "resources/app-icon-preview.png"]
+identifier = "dev.zed.Zed-Preview"
+name = "Zed Preview"
+osx_minimum_system_version = "10.15.7"
+
+[package.metadata.bundle-stable]
+icon = ["resources/app-icon@2x.png", "resources/app-icon.png"]
 identifier = "dev.zed.Zed"
 name = "Zed"
 osx_minimum_system_version = "10.15.7"

crates/zed/build.rs 🔗

@@ -3,11 +3,14 @@ use std::process::Command;
 fn main() {
     println!("cargo:rustc-env=MACOSX_DEPLOYMENT_TARGET=10.15.7");
 
-    if let Ok(api_key) = std::env::var("ZED_MIXPANEL_TOKEN") {
-        println!("cargo:rustc-env=ZED_MIXPANEL_TOKEN={api_key}");
+    if let Ok(value) = std::env::var("ZED_MIXPANEL_TOKEN") {
+        println!("cargo:rustc-env=ZED_MIXPANEL_TOKEN={value}");
     }
-    if let Ok(api_key) = std::env::var("ZED_AMPLITUDE_API_KEY") {
-        println!("cargo:rustc-env=ZED_AMPLITUDE_API_KEY={api_key}");
+    if let Ok(value) = std::env::var("ZED_AMPLITUDE_API_KEY") {
+        println!("cargo:rustc-env=ZED_AMPLITUDE_API_KEY={value}");
+    }
+    if let Ok(value) = std::env::var("ZED_PREVIEW_CHANNEL") {
+        println!("cargo:rustc-env=ZED_PREVIEW_CHANNEL={value}");
     }
 
     if std::env::var("ZED_BUNDLE").ok().as_deref() == Some("true") {

crates/zed/src/main.rs 🔗

@@ -39,7 +39,7 @@ use settings::watched_json::{watch_keymap_file, watch_settings_file, WatchedJson
 use theme::ThemeRegistry;
 use util::{ResultExt, TryFutureExt};
 use workspace::{self, AppState, ItemHandle, NewFile, OpenPaths, Workspace};
-use zed::{self, build_window_options, initialize_workspace, languages, menus};
+use zed::{self, build_window_options, initialize_workspace, languages, menus, RELEASE_CHANNEL};
 
 fn main() {
     let http = http::client();
@@ -97,6 +97,7 @@ fn main() {
 
         let (settings_file_content, keymap_file) = cx.background().block(config_files).unwrap();
 
+        cx.set_global(*RELEASE_CHANNEL);
         cx.set_global(HomeDir(zed::paths::HOME.to_path_buf()));
 
         //Setup settings global before binding actions
@@ -158,7 +159,7 @@ fn main() {
             initialize_workspace,
             default_item_factory,
         });
-        auto_update::init(db, http, client::ZED_SERVER_URL.clone(), cx);
+        auto_update::init(db, http, cx);
         workspace::init(app_state.clone(), cx);
         journal::init(app_state.clone(), cx);
         theme_selector::init(app_state.clone(), cx);

crates/zed/src/zed.rs 🔗

@@ -13,6 +13,7 @@ use collab_ui::{CollabTitlebarItem, ToggleCollaborationMenu};
 use collections::VecDeque;
 pub use editor;
 use editor::{Editor, MultiBuffer};
+use lazy_static::lazy_static;
 
 use gpui::{
     actions,
@@ -28,7 +29,7 @@ use project_panel::ProjectPanel;
 use search::{BufferSearchBar, ProjectSearchBar};
 use serde::Deserialize;
 use serde_json::to_string_pretty;
-use settings::{keymap_file_json_schema, settings_file_json_schema, Settings};
+use settings::{keymap_file_json_schema, settings_file_json_schema, ReleaseChannel, Settings};
 use std::{env, path::Path, str, sync::Arc};
 use util::ResultExt;
 pub use workspace;
@@ -69,6 +70,17 @@ actions!(
 
 const MIN_FONT_SIZE: f32 = 6.0;
 
+lazy_static! {
+    static ref RELEASE_CHANNEL_NAME: String =
+        env::var("ZED_RELEASE_CHANNEL").unwrap_or(include_str!("../RELEASE_CHANNEL").to_string());
+    pub static ref RELEASE_CHANNEL: ReleaseChannel = match RELEASE_CHANNEL_NAME.as_str() {
+        "dev" => ReleaseChannel::Dev,
+        "preview" => ReleaseChannel::Preview,
+        "stable" => ReleaseChannel::Preview,
+        _ => panic!("invalid release channel {}", *RELEASE_CHANNEL_NAME),
+    };
+}
+
 pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
     cx.add_action(about);
     cx.add_global_action(|_: &Hide, cx: &mut gpui::MutableAppContext| {
@@ -377,9 +389,15 @@ fn quit(_: &Quit, cx: &mut gpui::MutableAppContext) {
 }
 
 fn about(_: &mut Workspace, _: &About, cx: &mut gpui::ViewContext<Workspace>) {
+    let app_name = match *cx.global::<ReleaseChannel>() {
+        ReleaseChannel::Dev => "Zed Dev",
+        ReleaseChannel::Preview => "Zed Preview",
+        ReleaseChannel::Stable => "Zed",
+    };
+    let version = env!("CARGO_PKG_VERSION");
     cx.prompt(
         gpui::PromptLevel::Info,
-        &format!("Zed {}", env!("CARGO_PKG_VERSION")),
+        &format!("{app_name} {version}"),
         &["OK"],
     );
 }

script/bump-app-version 🔗

@@ -1,3 +1,18 @@
 #!/bin/bash
 
-exec script/lib/bump-version.sh zed v $@
+channel=$(cat crates/zed/RELEASE_CHANNEL)
+
+tag_suffix=""
+case $channel; in
+  stable)
+    ;;
+  preview)
+    tag_suffix="-pre"
+    ;;
+  *)
+    echo "do this on a release branch where RELEASE_CHANNEL is either 'preview' or 'stable'" >&2
+    exit 1
+    ;;
+esac
+
+exec script/lib/bump-version.sh zed v $tag_suffix $@

script/bump-collab-version 🔗

@@ -1,3 +1,3 @@
 #!/bin/bash
 
-exec script/lib/bump-version.sh collab collab-v $@
+exec script/lib/bump-version.sh collab collab-v '' $@

script/bundle 🔗

@@ -5,8 +5,7 @@ set -e
 export ZED_BUNDLE=true
 export MACOSX_DEPLOYMENT_TARGET=10.15.7
 
-echo "Installing cargo bundle"
-cargo install cargo-bundle --version 0.5.0
+which cargo-bundle > /dev/null || cargo install cargo-bundle --version 0.5.0
 rustup target add wasm32-wasi
 
 # Deal with versions of macOS that don't include libstdc++ headers
@@ -22,23 +21,32 @@ echo "Compiling cli binary for x86_64-apple-darwin"
 cargo build --release --package cli --target x86_64-apple-darwin
 
 echo "Creating application bundle"
-(cd crates/zed && cargo bundle --release --target x86_64-apple-darwin)
+pushd crates/zed
+channel=$(cat RELEASE_CHANNEL)
+cp Cargo.toml Cargo.toml.backup
+sed \
+    -i .backup \
+    "s/package.metadata.bundle-${channel}/package.metadata.bundle/" \
+    Cargo.toml
+app_path=$(cargo bundle --release --target x86_64-apple-darwin | xargs)
+mv Cargo.toml.backup Cargo.toml
+popd
 
 echo "Creating fat binaries"
 lipo \
     -create \
     target/{x86_64-apple-darwin,aarch64-apple-darwin}/release/Zed \
     -output \
-    target/x86_64-apple-darwin/release/bundle/osx/Zed.app/Contents/MacOS/zed
+    "${app_path}/Contents/MacOS/zed"
 lipo \
     -create \
     target/{x86_64-apple-darwin,aarch64-apple-darwin}/release/cli \
     -output \
-    target/x86_64-apple-darwin/release/bundle/osx/Zed.app/Contents/MacOS/cli
+    "${app_path}/Contents/MacOS/cli"
 
 echo "Copying WebRTC.framework into the frameworks folder"
-mkdir target/x86_64-apple-darwin/release/bundle/osx/Zed.app/Contents/Frameworks
-cp -R target/x86_64-apple-darwin/release/WebRTC.framework target/x86_64-apple-darwin/release/bundle/osx/Zed.app/Contents/Frameworks/
+mkdir "${app_path}/Contents/Frameworks"
+cp -R target/x86_64-apple-darwin/release/WebRTC.framework "${app_path}/Contents/Frameworks/"
 
 if [[ -n $MACOS_CERTIFICATE && -n $MACOS_CERTIFICATE_PASSWORD && -n $APPLE_NOTARIZATION_USERNAME && -n $APPLE_NOTARIZATION_PASSWORD ]]; then
     echo "Signing bundle with Apple-issued certificate"
@@ -49,12 +57,12 @@ if [[ -n $MACOS_CERTIFICATE && -n $MACOS_CERTIFICATE_PASSWORD && -n $APPLE_NOTAR
     security import /tmp/zed-certificate.p12 -k zed.keychain -P $MACOS_CERTIFICATE_PASSWORD -T /usr/bin/codesign
     rm /tmp/zed-certificate.p12
     security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $MACOS_CERTIFICATE_PASSWORD zed.keychain
-    /usr/bin/codesign --force --deep --timestamp --options runtime --sign "Zed Industries, Inc." target/x86_64-apple-darwin/release/bundle/osx/Zed.app -v
+    /usr/bin/codesign --force --deep --timestamp --options runtime --sign "Zed Industries, Inc." "${app_path}" -v
     security default-keychain -s login.keychain
 else
     echo "One or more of the following variables are missing: MACOS_CERTIFICATE, MACOS_CERTIFICATE_PASSWORD, APPLE_NOTARIZATION_USERNAME, APPLE_NOTARIZATION_PASSWORD"
     echo "Performing an ad-hoc signature, but this bundle should not be distributed"
-    codesign --force --deep --sign - target/x86_64-apple-darwin/release/bundle/osx/Zed.app -v
+    codesign --force --deep --sign - "${app_path}" -v
 fi
 
 echo "Creating DMG"

script/get-crate-version 🔗

@@ -0,0 +1,17 @@
+#!/bin/bash
+
+set -eu
+
+if [[ $# < 1 ]]; then
+  echo "Usage: $0 <crate_name>" >&2
+  exit 1
+fi
+
+CRATE_NAME=$1
+
+cargo metadata \
+    --no-deps \
+    --format-version=1 \
+    | jq \
+        --raw-output \
+        ".packages[] | select(.name == \"${CRATE_NAME}\") | .version"

script/lib/bump-version.sh 🔗

@@ -2,14 +2,15 @@
 
 set -eu
 
-if [[ $# < 3 ]]; then
+if [[ $# < 4 ]]; then
   echo "Missing version increment (major, minor, or patch)" >&2
   exit 1
 fi
 
 package=$1
 tag_prefix=$2
-version_increment=$3
+tag_suffix=$3
+version_increment=$4
 
 if [[ -n $(git status --short --untracked-files=no) ]]; then
   echo "Can't push a new version with uncommitted changes"
@@ -20,20 +21,23 @@ which cargo-set-version > /dev/null || cargo install cargo-edit
 cargo set-version --package $package --bump $version_increment
 cargo check --quiet
 
-new_version=$(cargo metadata --no-deps --format-version=1 | jq --raw-output ".packages[] | select(.name == \"${package}\") | .version")
+new_version=$(script/get-crate-version $package)
 branch_name=$(git rev-parse --abbrev-ref HEAD)
 old_sha=$(git rev-parse HEAD)
-tag_name=${tag_prefix}${new_version}
+tag_name=${tag_prefix}${new_version}${tag_suffix}
 
 git commit --quiet --all --message "${package} ${new_version}"
 git tag ${tag_name}
 
 cat <<MESSAGE
-Committed and tagged ${package} version ${new_version}
+Locally committed and tagged ${package} version ${new_version}
 
 To push this:
-    git push origin ${tag_name} ${branch_name}
+    git push origin \
+      ${tag_name} \
+      ${branch_name}
 
 To undo this:
-    git tag -d ${tag_name} && git reset --hard $old_sha
+    git tag -d ${tag_name} && \
+      git reset --hard ${old_sha}
 MESSAGE

script/railcar 🔗

@@ -0,0 +1,109 @@
+#!/bin/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 roll the railcars with uncommitted changes"
+  exit 1
+fi
+if [[ $(git rev-parse --abbrev-ref HEAD) != "main" ]]; then
+  echo "Run this command on the main branch"
+  exit 1
+fi
+git pull -q --ff-only origin main
+git fetch --tags
+
+# 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"
+
+function cleanup {
+  git checkout -q main
+}
+trap cleanup EXIT
+
+echo "Checking invariants before taking any actions..."
+if [[ $patch != 0 ]]; then
+  echo "patch version on main should be zero"
+  exit 1
+fi
+if [[ $(cat crates/zed/RELEASE_CHANNEL) != dev ]]; then
+  echo "release channel on main should be dev"
+  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 ${preview_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 "Stable ${prev_minor_branch_name}"
+git tag ${stable_tag_name}
+
+echo "Creating new preview branch ${minor_branch_name}..."
+git checkout -q -b ${minor_branch_name}
+echo -n preview > crates/zed/RELEASE_CHANNEL
+git commit -q --all --message "Preview ${minor_branch_name}"
+git tag ${preview_tag_name}
+
+echo "Preparing main for version ${next_minor_branch_name}..."
+git checkout -q main
+git clean -q -dff
+old_main_sha=$(git rev-parse HEAD)
+cargo set-version --package zed --bump minor
+cargo check -q
+git commit -q --all --message "Dev ${next_minor_branch_name}"
+
+cat <<MESSAGE
+Locally rolled the railcars.
+
+To push this:
+    git push origin \\
+      ${preview_tag_name} \\
+      ${stable_tag_name} \\
+      ${minor_branch_name} \\
+      ${prev_minor_branch_name} \\
+      main
+
+To undo this:
+    git push -f . \\
+      :${preview_tag_name} \\
+      :${stable_tag_name} \\
+      :${minor_branch_name} \\
+      ${old_prev_minor_sha}:${prev_minor_branch_name} \\
+      ${old_main_sha}:main
+MESSAGE

script/validate-version 🔗

@@ -1,17 +0,0 @@
-#!/bin/bash
-
-set -e
-
-mkdir -p vendor/bin
-if [[ ! -f vendor/bin/jq ]]; then
-    curl -L https://github.com/stedolan/jq/releases/download/jq-1.6/jq-osx-amd64 > vendor/bin/jq
-    chmod +x vendor/bin/jq
-fi
-
-package_version="v$(cargo metadata --format-version=1 | vendor/bin/jq --raw-output '.packages[] | select(.name == "zed") | .version')"
-git_tag=$(git tag --points-at HEAD)
-
-if [[ $package_version != $git_tag ]]; then
-    echo "Version $package_version of zed package does not match git tag $git_tag"
-    exit 1
-fi