Repository: denoland/deno
Pull request: ci: split integration and spec tests on PRs


Changed files:
filename: .github/workflows/ci.generate.ts
patch: @@ -11,7 +11,7 @@ import {
   type ExpressionValue,
   job,
   step,
-} from "jsr:@david/gagen@0.2.16";
+} from "jsr:@david/gagen@0.2.18";
 
 // Bump this number when you want to purge the cache.
 // Note: the tools/release/01_bump_crate_versions.ts script will update this version
@@ -955,11 +955,19 @@ const buildJobs = buildItems.map((rawBuildItem) => {
   const additionalJobs = [];
 
   {
+    const shardedCrates = new Set(["specs", "integration"]);
+    const shardCount = 2;
     const testMatrix = defineMatrix({
-      include: testCrates.map((tc) => ({
-        test_crate: tc.name,
-        test_package: tc.package,
-      })),
+      include: testCrates.flatMap((tc) => {
+        const total = shardedCrates.has(tc.name) ? shardCount : 1;
+        return Array.from({ length: total }, (_, i) => ({
+          test_crate: tc.name,
+          test_package: tc.package,
+          shard_index: i,
+          shard_total: total,
+          shard_label: total > 1 ? `(${i + 1}/${total}) ` : "",
+        }));
+      }),
     });
     const testCrateNameExpr = testMatrix.test_crate;
     const {
@@ -969,11 +977,14 @@ const buildJobs = buildItems.map((rawBuildItem) => {
       ...buildItem,
       cachePrefix: `test-${testCrateNameExpr}`,
     });
+    // shard_index > 0 jobs only run on PRs (main runs unsharded)
+    const isShardZero = testMatrix.shard_index.equals(0);
+    const shouldRunShard = isShardZero.or(isPr);
     additionalJobs.push(job(
       jobIdForJob("test"),
       {
         name:
-          `test ${testMatrix.test_crate} ${buildItem.profile} ${buildItem.os}-${buildItem.arch}`,
+          `test ${testMatrix.test_crate} ${testMatrix.shard_label}${buildItem.profile} ${buildItem.os}-${buildItem.arch}`,
         needs: [buildJob],
         runsOn: buildItem.testRunner ?? buildItem.runner,
         timeoutMinutes: 240,
@@ -983,7 +994,7 @@ const buildJobs = buildItems.map((rawBuildItem) => {
           matrix: testMatrix,
           failFast: false,
         },
-        steps: step.if(isNotTag.and(buildItem.skip.not()))(
+        steps: step.if(isNotTag.and(buildItem.skip.not()).and(shouldRunShard))(
           cloneRepoStep,
           cloneSubmodule("./tests/node_compat/runner/suite")
             .if(testCrateNameExpr.equals("node_compat")),
@@ -1034,7 +1045,11 @@ const buildJobs = buildItems.map((rawBuildItem) => {
             if: isDebug,
             run:
               `cargo test -p ${testMatrix.test_package} --test ${testMatrix.test_crate}`,
-            env: { CARGO_PROFILE_DEV_DEBUG: 0 },
+            env: {
+              CARGO_PROFILE_DEV_DEBUG: 0,
+              CI_SHARD_INDEX: isPr.then(testMatrix.shard_index).else(""),
+              CI_SHARD_TOTAL: isPr.then(testMatrix.shard_total).else(""),
+            },
           },
           {
             name: "Test (release)",
@@ -1043,6 +1058,10 @@ const buildJobs = buildItems.map((rawBuildItem) => {
             ),
             run:
               `cargo test -p ${testMatrix.test_package} --test ${testMatrix.test_crate} --release`,
+            env: {
+              CI_SHARD_INDEX: isPr.then(testMatrix.shard_index).else(""),
+              CI_SHARD_TOTAL: isPr.then(testMatrix.shard_total).else(""),
+            },
           },
           {
             name: "Ensure no git changes",
@@ -1058,16 +1077,6 @@ const buildJobs = buildItems.map((rawBuildItem) => {
               "fi",
             ],
           },
-          step.dependsOn(installDenoStep)({
-            name: "Upload test results",
-            uses: "actions/upload-artifact@v6",
-            if: conditions.status.always().and(isNotTag),
-            with: {
-              name:
-                `test-results-${buildItem.os}-${buildItem.arch}-${buildItem.profile}-${testMatrix.test_crate}.json`,
-              path: `target/test_results_${testMatrix.test_crate}.json`,
-            },
-          }),
           saveCacheStep.if(buildItem.save_cache),
         ),
       },

filename: tests/integration/mod.rs
patch: @@ -96,6 +96,14 @@ pub fn main() {
       children: Default::default(),
     };
   test_util::collect_and_filter_tests(&mut main_category);
+
+  let main_category =
+    if let Some(shard) = test_util::test_runner::ShardConfig::from_env() {
+      test_util::test_runner::filter_to_shard(main_category, &shard)
+    } else {
+      main_category
+    };
+
   if main_category.is_empty() {
     return; // no tests to run for the filter
   }

filename: tests/specs/mod.rs
patch: @@ -273,6 +273,13 @@ pub fn main() {
     })
     .into_flat_category();
 
+  let root_category =
+    if let Some(shard) = test_util::test_runner::ShardConfig::from_env() {
+      test_util::test_runner::filter_to_shard(root_category, &shard)
+    } else {
+      root_category
+    };
+
   if root_category.is_empty() {
     return; // all tests filtered out
   }

filename: tests/util/lib/test_runner.rs
patch: @@ -1,6 +1,7 @@
 // Copyright 2018-2026 the Deno authors. MIT license.
 
 use std::collections::HashMap;
+use std::collections::HashSet;
 use std::io::IsTerminal;
 use std::io::Write;
 use std::sync::Arc;
@@ -11,6 +12,8 @@ use std::time::Instant;
 
 use console_static_text::ConsoleStaticText;
 use file_test_runner::TestResult;
+use file_test_runner::collection::CollectedCategoryOrTest;
+use file_test_runner::collection::CollectedTestCategory;
 use file_test_runner::reporter::LogReporter;
 use parking_lot::Mutex;
 use serde::Serialize;
@@ -19,6 +22,75 @@ use crate::IS_CI;
 use crate::colors;
 use crate::semaphore::Semaphore;
 
+pub struct ShardConfig {
+  pub index: usize,
+  pub total: usize,
+}
+
+impl ShardConfig {
+  /// Reads CI_SHARD_INDEX and CI_SHARD_TOTAL from env.
+  /// Returns None if not set or total <= 1.
+  pub fn from_env() -> Option<Self> {
+    let total: usize = std::env::var("CI_SHARD_TOTAL").ok()?.parse().ok()?;
+    let index: usize = std::env::var("CI_SHARD_INDEX").ok()?.parse().ok()?;
+    if total <= 1 {
+      return None;
+    }
+    Some(Self { index, total })
+  }
+}
+
+/// Filter a collected test category to only include tests assigned to this shard.
+/// Uses round-robin distribution by sorted test name.
+pub fn filter_to_shard<T>(
+  category: CollectedTestCategory<T>,
+  shard: &ShardConfig,
+) -> CollectedTestCategory<T> {
+  let all_names = collect_test_names(&category);
+
+  let my_tests = assign_shard_tests(&all_names, shard);
+
+  let total_count = all_names.len();
+  let shard_count = my_tests.len();
+  crate::eprintln!(
+    "shard {}/{}: running {shard_count}/{total_count} tests",
+    shard.index + 1,
+    shard.total,
+  );
+
+  let (mine, _) = category.partition(|test| my_tests.contains(&test.name));
+  mine
+}
+
+fn collect_test_names<T>(category: &CollectedTestCategory<T>) -> Vec<String> {
+  let mut names = Vec::new();
+  fn walk<T>(children: &[CollectedCategoryOrTest<T>], names: &mut Vec<String>) {
+    for child in children {
+      match child {
+        CollectedCategoryOrTest::Test(t) => names.push(t.name.clone()),
+        CollectedCategoryOrTest::Category(c) => walk(&c.children, names),
+      }
+    }
+  }
+  walk(&category.children, &mut names);
+  names
+}
+
+fn assign_shard_tests(
+  all_names: &[String],
+  shard: &ShardConfig,
+) -> HashSet<String> {
+  // round-robin: distribute by sorted name index
+  let mut sorted: Vec<_> = all_names.to_vec();
+  sorted.sort();
+  sorted
+    .into_iter()
+    .enumerate()
+    .filter(|(i, _)| i % shard.total == shard.index)
+    .map(|(_, name)| name)
+    .collect()
+}
+
 /// Tracks the number of times each test has been flaky
 pub struct FlakyTestTracker {
   flaky_counts: Mutex<HashMap<String, usize>>,
@@ -362,9 +434,14 @@ impl JsonReporter {
       let data = self.data.lock();
       serde_json::to_string(&*data).unwrap()
     };
-    let file_path = crate::root_path()
-      .join("target")
-      .join(format!("test_results_{}.json", self.test_module_name));
+    let shard_suffix = match ShardConfig::from_env() {
+      Some(shard) => format!("_shard-{}", shard.index),
+      None => String::new(),
+    };
+    let file_path = crate::root_path().join("target").join(format!(
+      "test_results_{}{shard_suffix}.json",
+      self.test_module_name
+    ));
 
     file_path.write(json);
   }

filename: tools/deno.lock.json
patch: @@ -3,7 +3,7 @@
   "specifiers": {
     "jsr:@david/console-static-text@0.3": "0.3.0",
     "jsr:@david/dax@~0.43.2": "0.43.2",
-    "jsr:@david/gagen@0.2.16": "0.2.16",
+    "jsr:@david/gagen@0.2.18": "0.2.18",
     "jsr:@david/path@0.2": "0.2.0",
     "jsr:@david/which@~0.4.1": "0.4.1",
     "jsr:@deno/rust-automation@0.22.2": "0.22.2",
@@ -39,8 +39,8 @@
         "jsr:@std/path@1"
       ]
     },
-    "@david/gagen@0.2.16": {
-      "integrity": "98063b491ba436f02deac8a290e60052cfdc666671fcd6cb579eb3e7743730dc",
+    "@david/gagen@0.2.18": {
+      "integrity": "1bc27d996d183ecb6cce9c89042ae5c872b07a7fa83bfee0876223bd1a6f8bde",
       "dependencies": [
         "jsr:@std/yaml@^1.0.11"
       ]