Repository: denoland/deno
Pull request: feat: `deno upgrade --rc`

Flag name open to bike-shedding.

Changed files:
filename: Cargo.lock
patch: @@ -1127,7 +1127,7 @@ dependencies = [
 
 [[package]]
 name = "deno"
-version = "1.45.5"
+version = "1.46.0-rc.0"
 dependencies = [
  "async-trait",
  "base32",

filename: cli/Cargo.toml
patch: @@ -2,7 +2,7 @@
 
 [package]
 name = "deno"
-version = "1.45.5"
+version = "1.46.0-rc.0"
 authors.workspace = true
 default-run = "deno"
 edition.workspace = true

filename: cli/args/flags.rs
patch: @@ -398,6 +398,7 @@ pub struct TestFlags {
 pub struct UpgradeFlags {
   pub dry_run: bool,
   pub force: bool,
+  pub release_candidate: bool,
   pub canary: bool,
   pub version: Option<String>,
   pub output: Option<String>,
@@ -2908,6 +2909,13 @@ update to a different location, use the --output flag:
             .help("Upgrade to canary builds")
             .action(ArgAction::SetTrue),
         )
+        .arg(
+          Arg::new("release-candidate")
+            .long("rc")
+            .help("Upgrade to a release candidate")
+            .conflicts_with_all(["canary", "version"])
+            .action(ArgAction::SetTrue),
+        )
         .arg(ca_file_arg())
     })
 }
@@ -4568,11 +4576,13 @@ fn upgrade_parse(flags: &mut Flags, matches: &mut ArgMatches) {
   let dry_run = matches.get_flag("dry-run");
   let force = matches.get_flag("force");
   let canary = matches.get_flag("canary");
+  let release_candidate = matches.get_flag("release-candidate");
   let version = matches.remove_one::<String>("version");
   let output = matches.remove_one::<String>("output");
   flags.subcommand = DenoSubcommand::Upgrade(UpgradeFlags {
     dry_run,
     force,
+    release_candidate,
     canary,
     version,
     output,
@@ -5057,6 +5067,7 @@ mod tests {
           force: true,
           dry_run: true,
           canary: false,
+          release_candidate: false,
           version: None,
           output: None,
         }),
@@ -5075,6 +5086,7 @@ mod tests {
           force: false,
           dry_run: false,
           canary: false,
+          release_candidate: false,
           version: None,
           output: Some(String::from("example.txt")),
         }),
@@ -9039,6 +9051,7 @@ mod tests {
           force: false,
           dry_run: false,
           canary: false,
+          release_candidate: false,
           version: None,
           output: None,
         }),
@@ -9048,6 +9061,31 @@ mod tests {
     );
   }
 
+  #[test]
+  fn upgrade_release_candidate() {
+    let r = flags_from_vec(svec!["deno", "upgrade", "--rc"]);
+    assert_eq!(
+      r.unwrap(),
+      Flags {
+        subcommand: DenoSubcommand::Upgrade(UpgradeFlags {
+          force: false,
+          dry_run: false,
+          canary: false,
+          release_candidate: true,
+          version: None,
+          output: None,
+        }),
+        ..Flags::default()
+      }
+    );
+
+    let r = flags_from_vec(svec!["deno", "upgrade", "--rc", "--canary"]);
+    assert!(r.is_err());
+
+    let r = flags_from_vec(svec!["deno", "upgrade", "--rc", "--version"]);
+    assert!(r.is_err());
+  }
+
   #[test]
   fn cache_with_cafile() {
     let r = flags_from_vec(svec![

filename: cli/tools/upgrade.rs
patch: @@ -93,12 +93,14 @@ trait VersionProvider: Clone {
   async fn latest_version(
     &self,
     release_channel: ReleaseChannel,
-  ) -> Result<String, AnyError>;
+  ) -> Result<AvailableVersion, AnyError>;
 
-  // TODO(bartlomieju): what this one actually returns?
+  /// Returns either a semver or git hash. It's up to implementor to
+  /// decide which one is appropriate, but in general only "stable"
+  /// and "lts" versions use semver.
   fn current_version(&self) -> Cow<str>;
 
-  // TODO(bartlomieju): update to handle `Lts` and `Rc` channels
+  // TODO(bartlomieju): update to handle `Lts` channel
   async fn get_current_exe_release_channel(
     &self,
   ) -> Result<ReleaseChannel, AnyError>;
@@ -127,7 +129,7 @@ impl VersionProvider for RealVersionProvider {
   async fn latest_version(
     &self,
     release_channel: ReleaseChannel,
-  ) -> Result<String, AnyError> {
+  ) -> Result<AvailableVersion, AnyError> {
     fetch_latest_version(
       &self.http_client_provider.get_or_create()?,
       release_channel,
@@ -140,12 +142,26 @@ impl VersionProvider for RealVersionProvider {
     Cow::Borrowed(version::release_version_or_canary_commit_hash())
   }
 
-  // TODO(bartlomieju): update to handle `Lts` and `Rc` channels
+  // TODO(bartlomieju): update to handle `Lts` channel
   async fn get_current_exe_release_channel(
     &self,
   ) -> Result<ReleaseChannel, AnyError> {
     if version::is_canary() {
-      Ok(ReleaseChannel::Canary)
+      let rc_versions = get_rc_versions(
+        &self.http_client_provider.get_or_create()?,
+        self.check_kind,
+      )
+      .await?;
+
+      let is_current_exe_an_rc = rc_versions
+        .iter()
+        .any(|(hash, _)| hash == version::GIT_COMMIT_HASH);
+
+      if is_current_exe_an_rc {
+        Ok(ReleaseChannel::Rc)
+      } else {
+        Ok(ReleaseChannel::Canary)
+      }
     } else {
       Ok(ReleaseChannel::Stable)
     }
@@ -176,20 +192,19 @@ impl<
   }
 
   pub fn should_check_for_new_version(&self) -> bool {
-    match &self.maybe_file {
-      Some(file) => {
-        let last_check_age = self
-          .env
-          .current_time()
-          .signed_duration_since(file.last_checked);
-        last_check_age > chrono::Duration::hours(UPGRADE_CHECK_INTERVAL)
-      }
-      None => true,
-    }
+    let Some(file) = &self.maybe_file else {
+      return true;
+    };
+
+    let last_check_age = self
+      .env
+      .current_time()
+      .signed_duration_since(file.last_checked);
+    last_check_age > chrono::Duration::hours(UPGRADE_CHECK_INTERVAL)
   }
 
-  /// Returns the version if a new one is available and it should be prompted about.
-  pub fn should_prompt(&self) -> Option<String> {
+  /// Returns the current exe release channel and a version if a new one is available and it should be prompted about.
+  pub fn should_prompt(&self) -> Option<(ReleaseChannel, String)> {
     let file = self.maybe_file.as_ref()?;
     // If the current version saved is not the actually current version of the binary
     // It means
@@ -217,7 +232,7 @@ impl<
       .current_time()
       .signed_duration_since(file.last_prompt);
     if last_prompt_age > chrono::Duration::hours(UPGRADE_CHECK_INTERVAL) {
-      Some(file.latest_version.clone())
+      Some((file.current_release_channel, file.latest_version.clone()))
     } else {
       None
     }
@@ -238,6 +253,9 @@ fn get_minor_version(version: &str) -> &str {
 }
 
 fn print_release_notes(current_version: &str, new_version: &str) {
+  // TODO(bartlomieju): we might want to reconsider this one for RC releases.
+  // TODO(bartlomieju): also maybe just parse using `Version::standard` instead
+  // of using `get_minor_version`?
   if get_minor_version(current_version) == get_minor_version(new_version) {
     return;
   }
@@ -274,8 +292,10 @@ pub fn check_for_upgrades(
   }
 
   let env = RealUpdateCheckerEnvironment::new(cache_file_path);
-  let version_provider =
-    RealVersionProvider::new(http_client_provider, UpgradeCheckKind::Execution);
+  let version_provider = RealVersionProvider::new(
+    http_client_provider.clone(),
+    UpgradeCheckKind::Execution,
+  );
   let update_checker = UpdateChecker::new(env, version_provider);
 
   if update_checker.should_check_for_new_version() {
@@ -294,16 +314,20 @@ pub fn check_for_upgrades(
     });
   }
 
+  // Don't bother doing any more computation if we're not in TTY environment.
+  let should_prompt =
+    log::log_enabled!(log::Level::Info) && std::io::stderr().is_terminal();
+
+  if !should_prompt {
+    return;
+  }
+
   // Print a message if an update is available
-  if let Some(upgrade_version) = update_checker.should_prompt() {
-    if log::log_enabled!(log::Level::Info) && std::io::stderr().is_terminal() {
-      if version::is_canary() {
-        log::info!(
-          "{} {}",
-          colors::green("A new canary release of Deno is available."),
-          colors::italic_gray("Run `deno upgrade --canary` to install it.")
-        );
-      } else {
+  if let Some((release_channel, upgrade_version)) =
+    update_checker.should_prompt()
+  {
+    match release_channel {
+      ReleaseChannel::Stable => {
         log::info!(
           "{} {} → {} {}",
           colors::green("A new release of Deno is available:"),
@@ -312,16 +336,31 @@ pub fn check_for_upgrades(
           colors::italic_gray("Run `deno upgrade` to install it.")
         );
       }
-
-      update_checker.store_prompted();
+      ReleaseChannel::Canary => {
+        log::info!(
+          "{} {}",
+          colors::green("A new canary release of Deno is available."),
+          colors::italic_gray("Run `deno upgrade --canary` to install it.")
+        );
+      }
+      ReleaseChannel::Rc => {
+        log::info!(
+          "{} {}",
+          colors::green("A new release candidate of Deno is available."),
+          colors::italic_gray("Run `deno upgrade --rc` to install it.")
+        );
+      }
+      // TODO(bartlomieju)
+      ReleaseChannel::Lts => unreachable!(),
     }
+
+    update_checker.store_prompted();
   }
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct LspVersionUpgradeInfo {
   pub latest_version: String,
-  // TODO(bartlomieju): use `ReleaseChannel` instead
   pub is_canary: bool,
 }
 
@@ -346,34 +385,39 @@ async fn check_for_upgrades_for_lsp_with_provider(
   let current_version = version_provider.current_version();
 
   // Nothing to upgrade
-  if current_version == latest_version {
+  if current_version == latest_version.version_or_hash {
     return Ok(None);
   }
 
   match release_channel {
     ReleaseChannel::Stable => {
       if let Ok(current) = Version::parse_standard(&current_version) {
-        if let Ok(latest) = Version::parse_standard(&latest_version) {
+        if let Ok(latest) =
+          Version::parse_standard(&latest_version.version_or_hash)
+        {
           if current >= latest {
             return Ok(None); // nothing to upgrade
           }
         }
       }
       Ok(Some(LspVersionUpgradeInfo {
-        latest_version,
+        latest_version: latest_version.display,
         is_canary: false,
       }))
     }
 
     ReleaseChannel::Canary => Ok(Some(LspVersionUpgradeInfo {
-      latest_version,
+      latest_version: latest_version.display,
+      is_canary: true,
+    })),
+
+    ReleaseChannel::Rc => Ok(Some(LspVersionUpgradeInfo {
+      latest_version: latest_version.display,
       is_canary: true,
     })),
 
     // TODO(bartlomieju)
     ReleaseChannel::Lts => unreachable!(),
-    // TODO(bartlomieju)
-    ReleaseChannel::Rc => unreachable!(),
   }
 }
 
@@ -396,18 +440,18 @@ async fn fetch_and_store_latest_version<
     return;
   };
 
-  env.write_check_file(
-    &CheckVersionFile {
-      // put a date in the past here so that prompt can be shown on next run
-      last_prompt: env
-        .current_time()
-        .sub(chrono::Duration::hours(UPGRADE_CHECK_INTERVAL + 1)),
-      last_checked: env.current_time(),
-      current_version: version_provider.current_version().to_string(),
-      latest_version,
-    }
-    .serialize(),
-  );
+  let version_file = CheckVersionFile {
+    // put a date in the past here so that prompt can be shown on next run
+    last_prompt: env
+      .current_time()
+      .sub(chrono::Duration::hours(UPGRADE_CHECK_INTERVAL + 1)),
+    last_checked: env.current_time(),
+    current_version: version_provider.current_version().to_string(),
+    latest_version: latest_version.version_or_hash,
+    current_release_channel: release_channel,
+  };
+
+  env.write_check_file(&version_file.serialize());
 }
 
 pub async fn upgrade(
@@ -437,29 +481,33 @@ pub async fn upgrade(
   let requested_version =
     RequestedVersion::from_upgrade_flags(upgrade_flags.clone())?;
 
-  let maybe_install_version = match requested_version {
+  let maybe_selected_version_to_upgrade = match &requested_version {
     RequestedVersion::Latest(channel) => {
       find_latest_version_to_upgrade(
         http_client_provider.clone(),
-        channel,
+        *channel,
         force_selection_of_new_version,
       )
       .await?
     }
     RequestedVersion::SpecificVersion(channel, version) => {
       select_specific_version_for_upgrade(
-        channel,
-        version,
+        *channel,
+        version.clone(),
         force_selection_of_new_version,
       )?
     }
   };
 
-  let Some(install_version) = maybe_install_version else {
+  let Some(selected_version_to_upgrade) = maybe_selected_version_to_upgrade
+  else {
     return Ok(());
   };
 
-  let download_url = get_download_url(&install_version, upgrade_flags.canary)?;
+  let download_url = get_download_url(
+    &selected_version_to_upgrade.version_or_hash,
+    requested_version.is_canary(),
+  )?;
   log::info!("{}", colors::gray(format!("Downloading {}", &download_url)));
   let Some(archive_data) = download_package(&client, download_url).await?
   else {
@@ -469,7 +517,10 @@ pub async fn upgrade(
 
   log::info!(
     "{}",
-    colors::gray(format!("Deno is upgrading to version {}", &install_version))
+    colors::gray(format!(
+      "Deno is upgrading to version {}",
+      &selected_version_to_upgrade.display
+    ))
   );
 
   let temp_dir = tempfile::TempDir::new()?;
@@ -486,8 +537,11 @@ pub async fn upgrade(
   if upgrade_flags.dry_run {
     fs::remove_file(&new_exe_path)?;
     log::info!("Upgraded successfully (dry run)");
-    if !upgrade_flags.canary {
-      print_release_notes(version::deno(), &install_version);
+    if !requested_version.is_canary() {
+      print_release_notes(
+        version::deno(),
+        &selected_version_to_upgrade.version_or_hash,
+      );
     }
     drop(temp_dir);
     return Ok(());
@@ -507,11 +561,11 @@ pub async fn upgrade(
     "{}",
     colors::green(format!(
       "\nUpgraded successfully to Deno v{}\n",
-      install_version
+      selected_version_to_upgrade.display
     ))
   );
-  if !upgrade_flags.canary {
-    print_release_notes(version::deno(), &install_version);
+  if !requested_version.is_canary() {
+    print_release_notes(version::deno(), &selected_version_to_upgrade.display);
   }
 
   drop(temp_dir); // delete the temp dir
@@ -526,10 +580,13 @@ enum RequestedVersion {
 impl RequestedVersion {
   fn from_upgrade_flags(upgrade_flags: UpgradeFlags) -> Result<Self, AnyError> {
     let is_canary = upgrade_flags.canary;
+    let is_release_candidate = upgrade_flags.release_candidate;
 
     let Some(passed_version) = upgrade_flags.version else {
       let channel = if is_canary {
         ReleaseChannel::Canary
+      } else if is_release_candidate {
+        ReleaseChannel::Rc
       } else {
         ReleaseChannel::Stable
       };
@@ -556,13 +613,25 @@ impl RequestedVersion {
 
     Ok(RequestedVersion::SpecificVersion(channel, passed_version))
   }
+
+  /// Channels that use Git hashes as versions are considered canary.
+  pub fn is_canary(&self) -> bool {
+    match self {
+      Self::Latest(channel) => {
+        matches!(channel, ReleaseChannel::Canary | ReleaseChannel::Rc)
+      }
+      Self::SpecificVersion(channel, _) => {
+        matches!(channel, ReleaseChannel::Canary | ReleaseChannel::Rc)
+      }
+    }
+  }
 }
 
 fn select_specific_version_for_upgrade(
   release_channel: ReleaseChannel,
   version: String,
   force: bool,
-) -> Result<Option<String>, AnyError> {
+) -> Result<Option<AvailableVersion>, AnyError> {
   match release_channel {
     ReleaseChannel::Stable => {
       let current_is_passed = if !version::is_canary() {
@@ -576,7 +645,10 @@ fn select_specific_version_for_upgrade(
         return Ok(None);
       }
 
-      Ok(Some(version))
+      Ok(Some(AvailableVersion {
+        version_or_hash: version.to_string(),
+        display: version,
+      }))
     }
     ReleaseChannel::Canary => {
       let current_is_passed = version::GIT_COMMIT_HASH == version;
@@ -585,7 +657,10 @@ fn select_specific_version_for_upgrade(
         return Ok(None);
       }
 
-      Ok(Some(version))
+      Ok(Some(AvailableVersion {
+        version_or_hash: version.to_string(),
+        display: version,
+      }))
     }
     // TODO(bartlomieju)
     ReleaseChannel::Rc => unreachable!(),
@@ -598,14 +673,14 @@ async fn find_latest_version_to_upgrade(
   http_client_provider: Arc<HttpClientProvider>,
   release_channel: ReleaseChannel,
   force: bool,
-) -> Result<Option<String>, AnyError> {
+) -> Result<Option<AvailableVersion>, AnyError> {
   log::info!(
     "{}",
     colors::gray(&format!("Looking up {} version", release_channel.name()))
   );
 
   let client = http_client_provider.get_or_create()?;
-  let latest_version =
+  let latest_version_found =
     fetch_latest_version(&client, release_channel, UpgradeCheckKind::Execution)
       .await?;
 
@@ -614,7 +689,9 @@ async fn find_latest_version_to_upgrade(
       let current_version = version::deno();
       let current_is_most_recent = if !version::is_canary() {
         let current = Version::parse_standard(current_version).unwrap();
-        let latest = Version::parse_standard(&latest_version).unwrap();
+        let latest =
+          Version::parse_standard(&latest_version_found.version_or_hash)
+            .unwrap();
         current >= latest
       } else {
         false
@@ -623,21 +700,31 @@ async fn find_latest_version_to_upgrade(
       if !force && current_is_most_recent {
         (None, current_version)
       } else {
-        (Some(latest_version), current_version)
+        (Some(latest_version_found), current_version)
       }
     }
     ReleaseChannel::Canary => {
       let current_version = version::GIT_COMMIT_HASH;
-      let current_is_most_recent = current_version == latest_version;
+      let current_is_most_recent =
+        current_version == latest_version_found.version_or_hash;
 
       if !force && current_is_most_recent {
         (None, current_version)
       } else {
-        (Some(latest_version), current_version)
+        (Some(latest_version_found), current_version)
+      }
+    }
+    ReleaseChannel::Rc => {
+      let current_version = version::GIT_COMMIT_HASH;
+      let current_is_most_recent =
+        current_version == latest_version_found.version_or_hash;
+
+      if !force && current_is_most_recent {
+        (None, current_version)
+      } else {
+        (Some(latest_version_found), current_version)
       }
     }
-    // TODO(bartlomieju)
-    ReleaseChannel::Rc => unreachable!(),
     // TODO(bartlomieju)
     ReleaseChannel::Lts => unreachable!(),
   };
@@ -648,7 +735,7 @@ async fn find_latest_version_to_upgrade(
       "{}",
       color_print::cformat!(
         "<g>Found latest version {}</>",
-        newer_latest_version
+        newer_latest_version.display
       )
     );
   } else {
@@ -665,7 +752,7 @@ async fn find_latest_version_to_upgrade(
   Ok(maybe_newer_latest_version)
 }
 
-#[derive(Debug, Clone, Copy)]
+#[derive(Debug, Clone, Copy, PartialEq)]
 enum ReleaseChannel {
   /// Stable version, eg. 1.45.4, 2.0.0, 2.1.0
   Stable,
@@ -677,8 +764,7 @@ enum ReleaseChannel {
   #[allow(unused)]
   Lts,
 
-  /// Release candidate
-  #[allow(unused)]
+  /// Release candidate, poiting to a git hash
   Rc,
 }
 
@@ -691,26 +777,106 @@ impl ReleaseChannel {
       Self::Lts => "LTS (long term support)",
     }
   }
+
+  fn serialize(&self) -> String {
+    match self {
+      Self::Stable => "stable",
+      Self::Canary => "canary",
+      Self::Rc => "rc",
+      Self::Lts => "lts",
+    }
+    .to_string()
+  }
+
+  fn deserialize(str_: &str) -> Result<Self, AnyError> {
+    Ok(match str_ {
+      "stable" => Self::Stable,
+      "canary" => Self::Canary,
+      "rc" => Self::Rc,
+      "lts" => Self::Lts,
+      unknown => bail!("Unrecognized release channel: {}", unknown),
+    })
+  }
+}
+
+// Return a list of available RC release in format of (<commit_hash>, <version_name>)
+fn parse_rc_versions_text(
+  text: &str,
+) -> Result<Vec<(String, String)>, AnyError> {
+  let lines: Vec<_> = text
+    .split("\n")
+    .map(|s| s.to_string())
+    .filter(|s| !s.is_empty())
+    .collect();
+  if lines.is_empty() {
+    bail!("No release candidates available");
+  }
+
+  let mut parsed = Vec::with_capacity(lines.len());
+
+  for line in lines {
+    let v = line.split(' ').collect::<Vec<_>>();
+    if v.len() != 2 {
+      bail!("Malformed list of RC releases");
+    }
+    parsed.push((v[0].to_string(), v[1].to_string()));
+  }
+
+  Ok(parsed)
+}
+
+async fn get_rc_versions(
+  client: &HttpClient,
+  check_kind: UpgradeCheckKind,
+) -> Result<Vec<(String, String)>, AnyError> {
+  let url =
+    get_latest_version_url(ReleaseChannel::Rc, env!("TARGET"), check_kind);
+  let text = client.download_text(url.parse()?).await?;
+  parse_rc_versions_text(&text)
+}
+
+#[derive(Debug, Clone, PartialEq)]
+struct AvailableVersion {
+  version_or_hash: String,
+  display: String,
 }
 
 async fn fetch_latest_version(
   client: &HttpClient,
   release_channel: ReleaseChannel,
   check_kind: UpgradeCheckKind,
-) -> Result<String, AnyError> {
+) -> Result<AvailableVersion, AnyError> {
   let url = get_latest_version_url(release_channel, env!("TARGET"), check_kind);
   let text = client.download_text(url.parse()?).await?;
-  Ok(normalize_version_from_server(release_channel, &text))
+  let version = normalize_version_from_server(release_channel, &text)?;
+  Ok(version)
 }
 
 fn normalize_version_from_server(
   release_channel: ReleaseChannel,
   text: &str,
-) -> String {
+) -> Result<AvailableVersion, AnyError> {
   let text = text.trim();
   match release_channel {
-    ReleaseChannel::Stable => text.trim_start_matches('v').to_string(),
-    ReleaseChannel::Canary => text.to_string(),
+    ReleaseChannel::Stable => {
+      let v = text.trim_start_matches('v').to_string();
+      Ok(AvailableVersion {
+        version_or_hash: v.to_string(),
+        display: v.to_string(),
+      })
+    }
+    ReleaseChannel::Canary => Ok(AvailableVersion {
+      version_or_hash: text.to_string(),
+      display: text.to_string(),
+    }),
+    ReleaseChannel::Rc => {
+      let lines = parse_rc_versions_text(text)?;
+      let latest = lines.last().unwrap();
+      Ok(AvailableVersion {
+        version_or_hash: latest.0.to_string(),
+        display: latest.1.to_string(),
+      })
+    }
     _ => unreachable!(),
   }
 }
@@ -725,6 +891,7 @@ fn get_latest_version_url(
     ReleaseChannel::Canary => {
       Cow::Owned(format!("canary-{target_tuple}-latest.txt"))
     }
+    ReleaseChannel::Rc => Cow::Borrowed("release-rc.txt"),
     _ => unreachable!(),
   };
   let query_param = match check_kind {
@@ -864,13 +1031,14 @@ struct CheckVersionFile {
   pub last_checked: chrono::DateTime<chrono::Utc>,
   pub current_version: String,
   pub latest_version: String,
+  pub current_release_channel: ReleaseChannel,
 }
 
 impl CheckVersionFile {
   pub fn parse(content: String) -> Option<Self> {
     let split_content = content.split('!').collect::<Vec<_>>();
 
-    if split_content.len() != 4 {
+    if split_content.len() != 5 {
       return None;
     }
 
@@ -882,6 +1050,15 @@ impl CheckVersionFile {
     if current_version.is_empty() {
       return None;
     }
+    let current_release_channel = split_content[4].trim().to_owned();
+    if current_release_channel.is_empty() {
+      return None;
+    }
+    let Ok(current_release_channel) =
+      ReleaseChannel::deserialize(&current_release_channel)
+    else {
+      return None;
+    };
 
     let last_prompt = chrono::DateTime::parse_from_rfc3339(split_content[0])
       .map(|dt| dt.with_timezone(&chrono::Utc))
@@ -895,16 +1072,18 @@ impl CheckVersionFile {
       last_checked,
       current_version,
       latest_version,
+      current_release_channel,
     })
   }
 
   fn serialize(&self) -> String {
     format!(
-      "{}!{}!{}!{}",
+      "{}!{}!{}!{}!{}",
       self.last_prompt.to_rfc3339(),
       self.last_checked.to_rfc3339(),
       self.latest_version,
       self.current_version,
+      self.current_release_channel.serialize(),
     )
   }
 
@@ -925,9 +1104,16 @@ mod test {
 
   #[test]
   fn test_parse_upgrade_check_file() {
-    let file = CheckVersionFile::parse(
+    // NOTE(bartlomieju): pre-1.46 format
+    let maybe_file = CheckVersionFile::parse(
       "2020-01-01T00:00:00+00:00!2020-01-01T00:00:00+00:00!1.2.3!1.2.2"
         .to_string(),
+    );
+    assert!(maybe_file.is_none());
+    // NOTE(bartlomieju): post-1.46 format
+    let file = CheckVersionFile::parse(
+      "2020-01-01T00:00:00+00:00!2020-01-01T00:00:00+00:00!1.2.3!1.2.2!stable"
+        .to_string(),
     )
     .unwrap();
     assert_eq!(
@@ -940,6 +1126,7 @@ mod test {
     );
     assert_eq!(file.latest_version, "1.2.3".to_string());
     assert_eq!(file.current_version, "1.2.2".to_string());
+    assert_eq!(file.current_release_channel, ReleaseChannel::Stable);
 
     let result =
       CheckVersionFile::parse("2020-01-01T00:00:00+00:00!".to_string());
@@ -952,9 +1139,46 @@ mod test {
     assert!(result.is_none());
   }
 
+  #[test]
+  fn test_parse_rc_versions_text() {
+    let rc_versions = parse_rc_versions_text(
+      r#"qwerw3452gbxcvbarwett234 v1.46.0-rc.0
+cvbnfhuertt23523452345 v1.46.0-rc.1
+sdfq3452345egasdfgsdgf v2.0.0-rc.0
+asdf456yegfncbvjwe4523 v2.0.0-rc.1
+hjr6562w34rgzcvh56734a v2.0.0-rc.2
+bdfgtd6wergsdg3243234v v2.0.0-rc.3
+"#,
+    )
+    .unwrap();
+
+    assert_eq!(rc_versions.len(), 6);
+    assert_eq!(rc_versions[3].0, "asdf456yegfncbvjwe4523");
+    assert_eq!(rc_versions[3].1, "v2.0.0-rc.1");
+
+    let rc_versions = parse_rc_versions_text(
+      r#"qwerw3452gbxcvbarwett234 v1.46.0-rc.0
+
+cvbnfhuertt23523452345 v1.46.0-rc.1
+"#,
+    )
+    .unwrap();
+
+    assert_eq!(rc_versions.len(), 2);
+    assert_eq!(rc_versions[1].0, "cvbnfhuertt23523452345");
+    assert_eq!(rc_versions[1].1, "v1.46.0-rc.1");
+
+    let err =
+      parse_rc_versions_text(r#"qwerw3452gbxcvbarwett234     v1.46.0-rc.0"#)
+        .unwrap_err();
+    assert_eq!(err.to_string(), "Malformed list of RC releases");
+    let err = parse_rc_versions_text("").unwrap_err();
+    assert_eq!(err.to_string(), "No release candidates available");
+  }
+
   #[test]
   fn test_serialize_upgrade_check_file() {
-    let file = CheckVersionFile {
+    let mut file = CheckVersionFile {
       last_prompt: chrono::DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z")
         .unwrap()
         .with_timezone(&chrono::Utc),
@@ -965,19 +1189,35 @@ mod test {
       .with_timezone(&chrono::Utc),
       latest_version: "1.2.3".to_string(),
       current_version: "1.2.2".to_string(),
+      current_release_channel: ReleaseChannel::Stable,
     };
     assert_eq!(
       file.serialize(),
-      "2020-01-01T00:00:00+00:00!2020-01-01T00:00:00+00:00!1.2.3!1.2.2"
+      "2020-01-01T00:00:00+00:00!2020-01-01T00:00:00+00:00!1.2.3!1.2.2!stable"
+    );
+    file.current_release_channel = ReleaseChannel::Canary;
+    assert_eq!(
+      file.serialize(),
+      "2020-01-01T00:00:00+00:00!2020-01-01T00:00:00+00:00!1.2.3!1.2.2!canary"
+    );
+    file.current_release_channel = ReleaseChannel::Rc;
+    assert_eq!(
+      file.serialize(),
+      "2020-01-01T00:00:00+00:00!2020-01-01T00:00:00+00:00!1.2.3!1.2.2!rc"
+    );
+    file.current_release_channel = ReleaseChannel::Lts;
+    assert_eq!(
+      file.serialize(),
+      "2020-01-01T00:00:00+00:00!2020-01-01T00:00:00+00:00!1.2.3!1.2.2!lts"
     );
   }
 
   #[derive(Clone)]
   struct TestUpdateCheckerEnvironment {
     file_text: Rc<RefCell<String>>,
-    is_canary: Rc<RefCell<bool>>,
+    release_channel: Rc<RefCell<ReleaseChannel>>,
     current_version: Rc<RefCell<String>>,
-    latest_version: Rc<RefCell<Result<String, String>>>,
+    latest_version: Rc<RefCell<Result<AvailableVersion, String>>>,
     time: Rc<RefCell<chrono::DateTime<chrono::Utc>>>,
   }
 
@@ -986,8 +1226,11 @@ mod test {
       Self {
         file_text: Default::default(),
         current_version: Default::default(),
-        is_canary: Default::default(),
-        latest_version: Rc::new(RefCell::new(Ok("".to_string()))),
+        release_channel: Rc::new(RefCell::new(ReleaseChannel::Stable)),
+        latest_version: Rc::new(RefCell::new(Ok(AvailableVersion {
+          version_or_hash: "".to_string(),
+          display: "".to_string(),
+        }))),
         time: Rc::new(RefCell::new(chrono::Utc::now())),
       }
     }
@@ -1008,15 +1251,18 @@ mod test {
     }
 
     pub fn set_latest_version(&self, version: &str) {
-      *self.latest_version.borrow_mut() = Ok(version.to_string());
+      *self.latest_version.borrow_mut() = Ok(AvailableVersion {
+        version_or_hash: version.to_string(),
+        display: version.to_string(),
+      });
     }
 
     pub fn set_latest_version_err(&self, err: &str) {
       *self.latest_version.borrow_mut() = Err(err.to_string());
     }
 
-    pub fn set_is_canary(&self, is_canary: bool) {
-      *self.is_canary.borrow_mut() = is_canary;
+    pub fn set_release_channel(&self, channel: ReleaseChannel) {
+      *self.release_channel.borrow_mut() = channel;
     }
   }
 
@@ -1026,7 +1272,7 @@ mod test {
     async fn latest_version(
       &self,
       _release_channel: ReleaseChannel,
-    ) -> Result<String, AnyError> {
+    ) -> Result<AvailableVersion, AnyError> {
       match self.latest_version.borrow().clone() {
         Ok(result) => Ok(result),
         Err(err) => bail!("{}", err),
@@ -1037,15 +1283,10 @@ mod test {
       Cow::Owned(self.current_version.borrow().clone())
     }
 
-    // TODO(bartlomieju): update to handle `Lts` and `Rc` channels
     async fn get_current_exe_release_channel(
       &self,
     ) -> Result<ReleaseChannel, AnyError> {
-      if *self.is_canary.borrow() {
-        Ok(ReleaseChannel::Canary)
-      } else {
-        Ok(ReleaseChannel::Stable)
-      }
+      Ok(*self.release_channel.borrow())
     }
   }
 
@@ -1083,25 +1324,37 @@ mod test {
     // should not check for latest version because we just did
     assert!(!checker.should_check_for_new_version());
     // but should prompt
-    assert_eq!(checker.should_prompt(), Some("1.1.0".to_string()));
+    assert_eq!(
+      checker.should_prompt(),
+      Some((ReleaseChannel::Stable, "1.1.0".to_string()))
+    );
 
     // fast forward an hour and bump the latest version
     env.add_hours(1);
     env.set_latest_version("1.2.0");
     assert!(!checker.should_check_for_new_version());
-    assert_eq!(checker.should_prompt(), Some("1.1.0".to_string()));
+    assert_eq!(
+      checker.should_prompt(),
+      Some((ReleaseChannel::Stable, "1.1.0".to_string()))
+    );
 
     // fast forward again and it should check for a newer version
     env.add_hours(UPGRADE_CHECK_INTERVAL);
     assert!(checker.should_check_for_new_version());
-    assert_eq!(checker.should_prompt(), Some("1.1.0".to_string()));
+    assert_eq!(
+      checker.should_prompt(),
+      Some((ReleaseChannel::Stable, "1.1.0".to_string()))
+    );
 
     fetch_and_store_latest_version(&env, &env).await;
 
     // reload and store that we prompted
     let checker = UpdateChecker::new(env.clone(), env.clone());
     assert!(!checker.should_check_for_new_version());
-    assert_eq!(checker.should_prompt(), Some("1.2.0".to_string()));
+    assert_eq!(
+      checker.should_prompt(),
+      Some((ReleaseChannel::Stable, "1.2.0".to_string()))
+    );
     checker.store_prompted();
 
     // reload and it should now say not to prompt
@@ -1112,7 +1365,10 @@ mod test {
     // but if we fast forward past the upgrade interval it should prompt again
     env.add_hours(UPGRADE_CHECK_INTERVAL + 1);
     assert!(checker.should_check_for_new_version());
-    assert_eq!(checker.should_prompt(), Some("1.2.0".to_string()));
+    assert_eq!(
+      checker.should_prompt(),
+      Some((ReleaseChannel::Stable, "1.2.0".to_string()))
+    );
 
     // upgrade the version and it should stop prompting
     env.set_current_version("1.2.0");
@@ -1128,6 +1384,21 @@ mod test {
     fetch_and_store_latest_version(&env, &env).await;
     assert!(checker.should_check_for_new_version());
     assert_eq!(checker.should_prompt(), None);
+
+    // now switch to RC release
+    env.set_release_channel(ReleaseChannel::Rc);
+    env.set_current_version("1.46.0-rc.0");
+    env.set_latest_version("1.46.0-rc.1");
+    fetch_and_store_latest_version(&env, &env).await;
+    env.add_hours(UPGRADE_CHECK_INTERVAL + 1);
+
+    // We should check for new version and prompt
+    let checker = UpdateChecker::new(env.clone(), env.clone());
+    assert!(checker.should_check_for_new_version());
+    assert_eq!(
+      checker.should_prompt(),
+      Some((ReleaseChannel::Rc, "1.46.0-rc.1".to_string()))
+    );
   }
 
   #[tokio::test]
@@ -1140,6 +1411,7 @@ mod test {
       last_checked: env.current_time(),
       latest_version: "1.26.2".to_string(),
       current_version: "1.27.0".to_string(),
+      current_release_channel: ReleaseChannel::Stable,
     }
     .serialize();
     env.write_check_file(&file_content);
@@ -1162,6 +1434,7 @@ mod test {
       last_checked: env.current_time(),
       latest_version: "1.26.2".to_string(),
       current_version: "1.25.0".to_string(),
+      current_release_channel: ReleaseChannel::Stable,
     }
     .serialize();
     env.write_check_file(&file_content);
@@ -1231,36 +1504,90 @@ mod test {
     );
     assert_eq!(
       get_latest_version_url(
-        ReleaseChannel::Stable,
+        ReleaseChannel::Rc,
         "x86_64-pc-windows-msvc",
         UpgradeCheckKind::Lsp
       ),
-      "https://dl.deno.land/release-latest.txt?lsp"
+      "https://dl.deno.land/release-rc.txt?lsp"
+    );
+    assert_eq!(
+      get_latest_version_url(
+        ReleaseChannel::Rc,
+        "aarch64-apple-darwin",
+        UpgradeCheckKind::Execution
+      ),
+      "https://dl.deno.land/release-rc.txt"
+    );
+    assert_eq!(
+      get_latest_version_url(
+        ReleaseChannel::Rc,
+        "aarch64-apple-darwin",
+        UpgradeCheckKind::Lsp
+      ),
+      "https://dl.deno.land/release-rc.txt?lsp"
+    );
+    assert_eq!(
+      get_latest_version_url(
+        ReleaseChannel::Rc,
+        "x86_64-pc-windows-msvc",
+        UpgradeCheckKind::Execution
+      ),
+      "https://dl.deno.land/release-rc.txt"
+    );
+    assert_eq!(
+      get_latest_version_url(
+        ReleaseChannel::Rc,
+        "x86_64-pc-windows-msvc",
+        UpgradeCheckKind::Lsp
+      ),
+      "https://dl.deno.land/release-rc.txt?lsp"
     );
   }
 
   #[test]
   fn test_normalize_version_server() {
     // should strip v for stable
     assert_eq!(
-      normalize_version_from_server(ReleaseChannel::Stable, "v1.0.0"),
-      "1.0.0"
+      normalize_version_from_server(ReleaseChannel::Stable, "v1.0.0").unwrap(),
+      AvailableVersion {
+        version_or_hash: "1.0.0".to_string(),
+        display: "1.0.0".to_string()
+      },
     );
     // should not replace v after start
     assert_eq!(
       normalize_version_from_server(
         ReleaseChannel::Stable,
         "  v1.0.0-test-v\n\n  "
-      ),
-      "1.0.0-test-v"
+      )
+      .unwrap(),
+      AvailableVersion {
+        version_or_hash: "1.0.0-test-v".to_string(),
+        display: "1.0.0-test-v".to_string()
+      }
     );
     // should not strip v for canary
     assert_eq!(
       normalize_version_from_server(
         ReleaseChannel::Canary,
         "  v1452345asdf   \n\n   "
-      ),
-      "v1452345asdf"
+      )
+      .unwrap(),
+      AvailableVersion {
+        version_or_hash: "v1452345asdf".to_string(),
+        display: "v1452345asdf".to_string()
+      }
+    );
+    assert_eq!(
+      normalize_version_from_server(
+        ReleaseChannel::Rc,
+        "asdfq345wdfasdfasdf v1.46.0-rc.0\nasdfq345wdfasdfasdf v1.46.0-rc.1\n"
+      )
+      .unwrap(),
+      AvailableVersion {
+        version_or_hash: "asdfq345wdfasdfasdf".to_string(),
+        display: "v1.46.0-rc.1".to_string(),
+      },
     );
   }
 
@@ -1303,7 +1630,7 @@ mod test {
     {
       env.set_current_version("123");
       env.set_latest_version("123");
-      env.set_is_canary(true);
+      env.set_release_channel(ReleaseChannel::Canary);
       let maybe_info = check_for_upgrades_for_lsp_with_provider(&env)
         .await
         .unwrap();
@@ -1323,5 +1650,30 @@ mod test {
         })
       );
     }
+    // rc equal
+    {
+      env.set_release_channel(ReleaseChannel::Rc);
+      env.set_current_version("1.2.3-rc.0");
+      env.set_latest_version("1.2.3-rc.0");
+      let maybe_info = check_for_upgrades_for_lsp_with_provider(&env)
+        .await
+        .unwrap();
+      assert_eq!(maybe_info, None);
+    }
+    // canary different
+    {
+      env.set_latest_version("1.2.3-rc.0");
+      env.set_latest_version("1.2.3-rc.1");
+      let maybe_info = check_for_upgrades_for_lsp_with_provider(&env)
+        .await
+        .unwrap();
+      assert_eq!(
+        maybe_info,
+        Some(LspVersionUpgradeInfo {
+          latest_version: "1.2.3-rc.1".to_string(),
+          is_canary: true,
+        })
+      );
+    }
   }
 }