Repository: denoland/deno
Pull request: ci: add script to promote to RC release

This commits add a CI script that allows to promote a certain
canary build to a "Release Candidate" release.

This is done using `libsui` and `patchver` utilities.

Changed files:
filename: .github/workflows/promote_to_rc.yml
patch: @@ -0,0 +1,47 @@
+name: promote_to_rc
+
+on:
+  workflow_dispatch:
+    inputs:
+      commitHash:
+        description: 'Commit to promote to the Release Candidate'
+        required: true
+
+jobs:
+  promote-to-rc:
+    name: Promote to Release Candidate
+    runs-on: ubuntu-latest
+    if: github.repository == 'denoland/deno'
+    steps:
+      - name: Clone repository
+        uses: actions/checkout@v4
+        with:
+          token: ${{ secrets.DENOBOT_PAT }}
+          submodules: recursive
+
+      - name: Authenticate with Google Cloud
+        uses: google-github-actions/auth@v1
+        with:
+          project_id: denoland
+          credentials_json: ${{ secrets.GCP_SA_KEY }}
+          export_environment_variables: true
+          create_credentials_file: true
+
+      - name: Setup gcloud
+        uses: google-github-actions/setup-gcloud@v1
+        with:
+          project_id: denoland
+
+      - name: Install deno
+        uses: denoland/setup-deno@v1
+        with:
+          deno-version: v1.x
+
+      - name: Promote to RC
+        run: |
+          deno run -A ./tools/release/promote_to_rc.ts ${{github.event.inputs.releaseKind}}
+
+      - name: Upload archives to dl.deno.land
+        run: |
+          gsutil -h "Cache-Control: public, max-age=3600" cp ./*.zip gs://dl.deno.land/release/$(echo release-rc-latest.txt)/
+          gsutil -h "Cache-Control: no-cache" cp release-rc-latest.txt gs://dl.deno.land/release-rc-latest.txt

filename: cli/tools/upgrade.rs
patch: @@ -35,6 +35,7 @@ use std::time::Duration;
 
 const RELEASE_URL: &str = "https://github.com/denoland/deno/releases";
 const CANARY_URL: &str = "https://dl.deno.land/canary";
+const RC_URL: &str = "https://dl.deno.land/release";
 
 pub static ARCHIVE_NAME: Lazy<String> =
   Lazy::new(|| format!("deno-{}.zip", env!("TARGET")));
@@ -474,7 +475,7 @@ pub async fn upgrade(
 
   let download_url = get_download_url(
     &selected_version_to_upgrade.version_or_hash,
-    requested_version.is_canary(),
+    requested_version.release_channel(),
   )?;
   log::info!("{}", colors::gray(format!("Downloading {}", &download_url)));
   let Some(archive_data) = download_package(&client, download_url).await?
@@ -505,7 +506,7 @@ pub async fn upgrade(
   if upgrade_flags.dry_run {
     fs::remove_file(&new_exe_path)?;
     log::info!("Upgraded successfully (dry run)");
-    if !requested_version.is_canary() {
+    if requested_version.release_channel() == ReleaseChannel::Stable {
       print_release_notes(
         version::DENO_VERSION_INFO.deno,
         &selected_version_to_upgrade.version_or_hash,
@@ -529,7 +530,7 @@ pub async fn upgrade(
     "\nUpgraded successfully to Deno {}\n",
     colors::green(selected_version_to_upgrade.display())
   );
-  if !requested_version.is_canary() {
+  if requested_version.release_channel() == ReleaseChannel::Stable {
     print_release_notes(
       version::DENO_VERSION_INFO.deno,
       &selected_version_to_upgrade.display,
@@ -583,14 +584,10 @@ impl RequestedVersion {
   }
 
   /// Channels that use Git hashes as versions are considered canary.
-  pub fn is_canary(&self) -> bool {
+  pub fn release_channel(&self) -> ReleaseChannel {
     match self {
-      Self::Latest(channel) => {
-        matches!(channel, ReleaseChannel::Canary | ReleaseChannel::Rc)
-      }
-      Self::SpecificVersion(channel, _) => {
-        matches!(channel, ReleaseChannel::Canary | ReleaseChannel::Rc)
-      }
+      Self::Latest(channel) => *channel,
+      Self::SpecificVersion(channel, _) => *channel,
     }
   }
 }
@@ -663,7 +660,7 @@ async fn find_latest_version_to_upgrade(
       .await?;
 
   let (maybe_newer_latest_version, current_version) = match release_channel {
-    ReleaseChannel::Stable => {
+    ReleaseChannel::Stable | ReleaseChannel::Rc => {
       let current_version = version::DENO_VERSION_INFO.deno;
       let current_is_most_recent = if version::DENO_VERSION_INFO.release_channel
         != ReleaseChannel::Canary
@@ -694,17 +691,6 @@ async fn find_latest_version_to_upgrade(
         (Some(latest_version_found), current_version)
       }
     }
-    ReleaseChannel::Rc => {
-      let current_version = version::DENO_VERSION_INFO.git_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::Lts => unreachable!(),
   };
@@ -789,7 +775,7 @@ fn get_latest_version_url(
     ReleaseChannel::Canary => {
       Cow::Owned(format!("canary-{target_tuple}-latest.txt"))
     }
-    ReleaseChannel::Rc => Cow::Borrowed("release-rc.txt"),
+    ReleaseChannel::Rc => Cow::Borrowed("release-rc-latest.txt"),
     _ => unreachable!(),
   };
   let query_param = match check_kind {
@@ -808,11 +794,21 @@ fn base_upgrade_url() -> Cow<'static, str> {
   }
 }
 
-fn get_download_url(version: &str, is_canary: bool) -> Result<Url, AnyError> {
-  let download_url = if is_canary {
-    format!("{}/{}/{}", CANARY_URL, version, *ARCHIVE_NAME)
-  } else {
-    format!("{}/download/v{}/{}", RELEASE_URL, version, *ARCHIVE_NAME)
+fn get_download_url(
+  version: &str,
+  release_channel: ReleaseChannel,
+) -> Result<Url, AnyError> {
+  let download_url = match release_channel {
+    ReleaseChannel::Stable => {
+      format!("{}/download/v{}/{}", RELEASE_URL, version, *ARCHIVE_NAME)
+    }
+    ReleaseChannel::Rc => {
+      format!("{}/v{}/{}", RC_URL, version, *ARCHIVE_NAME)
+    }
+    ReleaseChannel::Canary => {
+      format!("{}/{}/{}", CANARY_URL, version, *ARCHIVE_NAME)
+    }
+    ReleaseChannel::Lts => unreachable!(),
   };
 
   Url::parse(&download_url).with_context(|| {
@@ -1373,39 +1369,39 @@ mod test {
         "x86_64-pc-windows-msvc",
         UpgradeCheckKind::Lsp
       ),
-      "https://dl.deno.land/release-rc.txt?lsp"
+      "https://dl.deno.land/release-rc-latest.txt?lsp"
     );
     assert_eq!(
       get_latest_version_url(
         ReleaseChannel::Rc,
         "aarch64-apple-darwin",
         UpgradeCheckKind::Execution
       ),
-      "https://dl.deno.land/release-rc.txt"
+      "https://dl.deno.land/release-rc-latest.txt"
     );
     assert_eq!(
       get_latest_version_url(
         ReleaseChannel::Rc,
         "aarch64-apple-darwin",
         UpgradeCheckKind::Lsp
       ),
-      "https://dl.deno.land/release-rc.txt?lsp"
+      "https://dl.deno.land/release-rc-latest.txt?lsp"
     );
     assert_eq!(
       get_latest_version_url(
         ReleaseChannel::Rc,
         "x86_64-pc-windows-msvc",
         UpgradeCheckKind::Execution
       ),
-      "https://dl.deno.land/release-rc.txt"
+      "https://dl.deno.land/release-rc-latest.txt"
     );
     assert_eq!(
       get_latest_version_url(
         ReleaseChannel::Rc,
         "x86_64-pc-windows-msvc",
         UpgradeCheckKind::Lsp
       ),
-      "https://dl.deno.land/release-rc.txt?lsp"
+      "https://dl.deno.land/release-rc-latest.txt?lsp"
     );
   }
 

filename: tools/deno.lock.json
patch: @@ -2,9 +2,77 @@
   "version": "3",
   "packages": {
     "specifiers": {
+      "jsr:@david/dax@0.41.0": "jsr:@david/dax@0.41.0",
+      "jsr:@david/which@^0.4.1": "jsr:@david/which@0.4.1",
+      "jsr:@deno/patchver@0.1.0": "jsr:@deno/patchver@0.1.0",
+      "jsr:@std/assert@^0.221.0": "jsr:@std/assert@0.221.0",
+      "jsr:@std/bytes@^0.221.0": "jsr:@std/bytes@0.221.0",
+      "jsr:@std/fmt@1": "jsr:@std/fmt@1.0.0",
+      "jsr:@std/fmt@^0.221.0": "jsr:@std/fmt@0.221.0",
+      "jsr:@std/fs@0.221.0": "jsr:@std/fs@0.221.0",
+      "jsr:@std/io@0.221.0": "jsr:@std/io@0.221.0",
+      "jsr:@std/io@^0.221.0": "jsr:@std/io@0.221.0",
+      "jsr:@std/path@0.221.0": "jsr:@std/path@0.221.0",
+      "jsr:@std/path@^0.221.0": "jsr:@std/path@0.221.0",
+      "jsr:@std/streams@0.221.0": "jsr:@std/streams@0.221.0",
       "jsr:@std/yaml@^0.221": "jsr:@std/yaml@0.221.0"
     },
     "jsr": {
+      "@david/dax@0.41.0": {
+        "integrity": "9e1ecf66a0415962cc8ad3ba4e3fa93ce0f1a1cc797dd95c36fdfb6977dc7fc8",
+        "dependencies": [
+          "jsr:@david/which@^0.4.1",
+          "jsr:@std/fmt@^0.221.0",
+          "jsr:@std/fs@0.221.0",
+          "jsr:@std/io@0.221.0",
+          "jsr:@std/path@0.221.0",
+          "jsr:@std/streams@0.221.0"
+        ]
+      },
+      "@david/which@0.4.1": {
+        "integrity": "896a682b111f92ab866cc70c5b4afab2f5899d2f9bde31ed00203b9c250f225e"
+      },
+      "@deno/patchver@0.1.0": {
+        "integrity": "3102aa1b751a9fb85ef6cf7d4c0a1ec6624c85a77facc140c5748d82126d66a6"
+      },
+      "@std/assert@0.221.0": {
+        "integrity": "a5f1aa6e7909dbea271754fd4ab3f4e687aeff4873b4cef9a320af813adb489a"
+      },
+      "@std/bytes@0.221.0": {
+        "integrity": "64a047011cf833890a4a2ab7293ac55a1b4f5a050624ebc6a0159c357de91966"
+      },
+      "@std/fmt@0.221.0": {
+        "integrity": "379fed69bdd9731110f26b9085aeb740606b20428ce6af31ef6bd45ef8efa62a"
+      },
+      "@std/fmt@1.0.0": {
+        "integrity": "8a95c9fdbb61559418ccbc0f536080cf43341655e1444f9d375a66886ceaaa3d"
+      },
+      "@std/fs@0.221.0": {
+        "integrity": "028044450299de8ed5a716ade4e6d524399f035513b85913794f4e81f07da286",
+        "dependencies": [
+          "jsr:@std/assert@^0.221.0",
+          "jsr:@std/path@^0.221.0"
+        ]
+      },
+      "@std/io@0.221.0": {
+        "integrity": "faf7f8700d46ab527fa05cc6167f4b97701a06c413024431c6b4d207caa010da",
+        "dependencies": [
+          "jsr:@std/assert@^0.221.0",
+          "jsr:@std/bytes@^0.221.0"
+        ]
+      },
+      "@std/path@0.221.0": {
+        "integrity": "0a36f6b17314ef653a3a1649740cc8db51b25a133ecfe838f20b79a56ebe0095",
+        "dependencies": [
+          "jsr:@std/assert@^0.221.0"
+        ]
+      },
+      "@std/streams@0.221.0": {
+        "integrity": "47f2f74634b47449277c0ee79fe878da4424b66bd8975c032e3afdca88986e61",
+        "dependencies": [
+          "jsr:@std/io@^0.221.0"
+        ]
+      },
       "@std/yaml@0.221.0": {
         "integrity": "bac8913ee4f6fc600d4b92cc020f755070e22687ad242341f31d123ff690ae98"
       }

filename: tools/release/promote_to_rc.ts
patch: @@ -0,0 +1,212 @@
+#!/usr/bin/env -S deno run -A --lock=tools/deno.lock.json
+// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
+
+import { $ } from "jsr:@david/dax@0.41.0";
+import { gray } from "jsr:@std/fmt@1/colors";
+import { patchver } from "jsr:@deno/patchver@0.1.0";
+
+const SUPPORTED_TARGETS = [
+  "aarch64-apple-darwin",
+  "aarch64-unknown-linux-gnu",
+  "x86_64-apple-darwin",
+  "x86_64-pc-windows-msvc",
+  "x86_64-unknown-linux-gnu",
+];
+
+const DENO_BINARIES = [
+  "deno",
+  "denort",
+];
+
+const CHANNEL = "rc";
+
+const CANARY_URL = "https://dl.deno.land";
+
+function getCanaryBinaryUrl(
+  version: string,
+  binary: string,
+  target: string,
+): string {
+  return `${CANARY_URL}/canary/${version}/${binary}-${target}.zip`;
+}
+
+function getUnzippedFilename(binary: string, target: string) {
+  if (target.includes("windows")) {
+    return `${binary}.exe`;
+  } else {
+    return binary;
+  }
+}
+
+function getRcBinaryName(binary: string, target: string): string {
+  let ext = "";
+  if (target.includes("windows")) {
+    ext = ".exe";
+  }
+  return `${binary}-${target}-rc${ext}`;
+}
+
+function getArchiveName(binary: string, target: string): string {
+  return `${binary}-${target}.zip`;
+}
+
+interface CanaryVersion {
+  target: string;
+  version: string;
+}
+
+async function remove(filePath: string) {
+  try {
+    await Deno.remove(filePath);
+  } catch {
+    // pass
+  }
+}
+
+async function fetchLatestCanaryBinary(
+  version: string,
+  binary: string,
+  target: string,
+) {
+  const url = getCanaryBinaryUrl(version, binary, target);
+  await $.request(url).showProgress().pipeToPath();
+}
+
+async function fetchLatestCanaryBinaries(canaryVersion: string) {
+  for (const binary of DENO_BINARIES) {
+    for (const target of SUPPORTED_TARGETS) {
+      $.logStep("Download", binary, gray("target:"), target);
+      await fetchLatestCanaryBinary(canaryVersion, binary, target);
+    }
+  }
+}
+
+async function unzipArchive(archiveName: string, unzippedName: string) {
+  await remove(unzippedName);
+  const output = await $`unzip ./${archiveName}`;
+  if (output.code !== 0) {
+    $.logError(`Failed to unzip ${archiveName} (error code ${output.code})`);
+    Deno.exit(1);
+  }
+}
+
+async function createArchive(rcBinaryName: string, archiveName: string) {
+  const output = await $`zip -r ./${archiveName} ./${rcBinaryName}`;
+
+  if (output.code !== 0) {
+    $.logError(
+      `Failed to create archive ${archiveName} (error code ${output.code})`,
+    );
+    Deno.exit(1);
+  }
+}
+
+async function runPatchver(
+  binary: string,
+  target: string,
+  rcBinaryName: string,
+) {
+  const input = await Deno.readFile(binary);
+  const output = patchver(input, CHANNEL);
+
+  try {
+    await Deno.writeFile(rcBinaryName, output);
+  } catch (e) {
+    $.logError(
+      `Failed to promote to RC ${binary} (${target}), error:`,
+      e,
+    );
+    Deno.exit(1);
+  }
+}
+
+async function promoteBinaryToRc(binary: string, target: string) {
+  const unzippedName = getUnzippedFilename(binary, target);
+  const rcBinaryName = getRcBinaryName(binary, target);
+  const archiveName = getArchiveName(binary, target);
+  await remove(unzippedName);
+  await remove(rcBinaryName);
+  $.logStep(
+    "Unzip",
+    archiveName,
+    gray("binary"),
+    binary,
+    gray("rcBinaryName"),
+    rcBinaryName,
+  );
+
+  await unzipArchive(archiveName, unzippedName);
+  await remove(archiveName);
+
+  $.logStep(
+    "Patchver",
+    unzippedName,
+    `(${target})`,
+    gray("output to"),
+    rcBinaryName,
+  );
+  await runPatchver(unzippedName, target, rcBinaryName);
+  // Remove the unpatched binary and rename patched one.
+  await remove(unzippedName);
+  await Deno.rename(rcBinaryName, unzippedName);
+  // Set executable permission
+  if (!target.includes("windows")) {
+    Deno.chmod(unzippedName, 0o777);
+  }
+
+  await createArchive(unzippedName, archiveName);
+  await remove(unzippedName);
+}
+
+async function promoteBinariesToRc() {
+  const totalCanaries = SUPPORTED_TARGETS.length * DENO_BINARIES.length;
+
+  for (let targetIdx = 0; targetIdx < SUPPORTED_TARGETS.length; targetIdx++) {
+    const target = SUPPORTED_TARGETS[targetIdx];
+    for (let binaryIdx = 0; binaryIdx < DENO_BINARIES.length; binaryIdx++) {
+      const binaryName = DENO_BINARIES[binaryIdx];
+      const currentIdx = (targetIdx * 2) + binaryIdx + 1;
+      $.logLight(
+        `[${currentIdx}/${totalCanaries}]`,
+        "Promote",
+        binaryName,
+        target,
+        "to RC...",
+      );
+      await promoteBinaryToRc(binaryName, target);
+      $.logLight(
+        `[${currentIdx}/${totalCanaries}]`,
+        "Promoted",
+        binaryName,
+        target,
+        "to RC!",
+      );
+    }
+  }
+}
+
+async function dumpRcVersion() {
+  $.logStep("Compute version");
+  await unzipArchive(getArchiveName("deno", Deno.build.target), "deno");
+  const output = await $`./deno -V`.stdout("piped");
+  const denoVersion = output.stdout.slice(5).split("+")[0];
+  $.logStep("Computed version", denoVersion);
+  await Deno.writeTextFile("./release-rc-latest.txt", denoVersion);
+}
+
+async function main() {
+  const commitHash = Deno.args[0];
+  if (!commitHash) {
+    throw new Error("Commit hash needs to be provided as an argument");
+  }
+  $.logStep("Download canary binaries...");
+  await fetchLatestCanaryBinaries(commitHash);
+  console.log("All canary binaries ready");
+  $.logStep("Promote canary binaries to RC...");
+  await promoteBinariesToRc();
+
+  // Finally dump the version name to a `release.txt` file for uploading to GCP
+  await dumpRcVersion();
+}
+
+await main();