Merge with upstream 2026-04-21 2/2

f105a548ca4 Roll recipe dependencies (trivial).
217f9752213 Roll recipe dependencies (trivial).
cfc72a29d52 infra: Add builder for uprev_refvm_image
69160fe72f7 infra: Add recipe for upreving reference VM image
a6f3039e046 devices: pci: stub: Implement setup_pci_config_mapping
9ec67f31091 Roll recipe dependencies (trivial).
c6beb2afd0e Roll recipe dependencies (trivial).
e4c638d5c4c devices: pci: vfio_pci: Don't use host IRQ as preferred guest IRQ
594ac95e04f cros_async: sys: linux: Handle malformed eventfd reads gracefully
02e79727307 Roll recipe dependencies (trivial).
38b12716bd3 Roll recipe dependencies (trivial).

https://chromium.googlesource.com/crosvm/crosvm/+log/2e1df462c1b95edef475dc8d7e210c672aca08cd..f105a548ca472f1d061879a7ddaa5e725b4e7b95

BUG=b:481167698

Change-Id: I2a6218920f00ece1fcf3302f3a172fb1fc446574
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/platform/crosvm/+/7780040
Commit-Queue: Vineeth Pillai <vineethrp@google.com>
Bot-Commit: crosvm LUCI CI <crosvm-luci-ci-builder@crosvm-infra.iam.gserviceaccount.com>
diff --git a/cros_async/src/sys/linux/event.rs b/cros_async/src/sys/linux/event.rs
index a937974..1cb8fc5 100644
--- a/cros_async/src/sys/linux/event.rs
+++ b/cros_async/src/sys/linux/event.rs
@@ -24,7 +24,17 @@
         if n != 8 {
             return Err(AsyncError::EventAsync(base::Error::new(libc::ENODATA)));
         }
-        Ok(u64::from_ne_bytes(v.try_into().unwrap()))
+        match v.try_into() {
+            Ok(bytes) => Ok(u64::from_ne_bytes(bytes)),
+            Err(e) => {
+                base::error!(
+                    "eventfd async read corrupted! n=8, Vec length is {}. Raw bytes: {:?}",
+                    e.len(),
+                    e
+                );
+                Err(AsyncError::EventAsync(base::Error::new(libc::EINVAL)))
+            }
+        }
     }
 }
 
diff --git a/devices/src/pci/stub.rs b/devices/src/pci/stub.rs
index 925d60d..3b7283b 100644
--- a/devices/src/pci/stub.rs
+++ b/devices/src/pci/stub.rs
@@ -13,6 +13,7 @@
 //! something to the guest on function 0.
 
 use base::RawDescriptor;
+use base::SharedMemory;
 use resources::SystemAllocator;
 use serde::Deserialize;
 use serde::Deserializer;
@@ -171,6 +172,18 @@
             .ok_or(PciDeviceError::PciAllocationFailed)
     }
 
+    fn setup_pci_config_mapping(
+        &mut self,
+        shmem: &SharedMemory,
+        base: usize,
+        len: usize,
+    ) -> Result<bool> {
+        self.config_regs
+            .setup_mapping(shmem, base, len)
+            .map(|_| true)
+            .map_err(PciDeviceError::MmioSetup)
+    }
+
     fn keep_rds(&self) -> Vec<RawDescriptor> {
         Vec::new()
     }
diff --git a/devices/src/pci/vfio_pci.rs b/devices/src/pci/vfio_pci.rs
index e294b24..5422b53 100644
--- a/devices/src/pci/vfio_pci.rs
+++ b/devices/src/pci/vfio_pci.rs
@@ -6,7 +6,6 @@
 use std::cmp::Reverse;
 use std::collections::BTreeMap;
 use std::collections::BTreeSet;
-use std::fs;
 use std::path::Path;
 use std::path::PathBuf;
 use std::str::FromStr;
@@ -1681,23 +1680,11 @@
     }
 
     fn preferred_irq(&self) -> PreferredIrq {
-        // Is INTx configured?
-        let pin = match self.config.read_config::<u8>(PCI_INTERRUPT_PIN) {
-            1 => PciInterruptPin::IntA,
-            2 => PciInterruptPin::IntB,
-            3 => PciInterruptPin::IntC,
-            4 => PciInterruptPin::IntD,
-            _ => return PreferredIrq::None,
-        };
-
-        // TODO: replace sysfs/irq value parsing with vfio interface
-        //       reporting host allocated interrupt number and type.
-        let path = self.sysfs_path.join("irq");
-        let gsi = fs::read_to_string(path)
-            .map(|v| v.trim().parse::<u32>().unwrap_or(0))
-            .unwrap_or(0);
-
-        PreferredIrq::Fixed { pin, gsi }
+        // Do not use a fixed IRQ for VFIO devices. The sysfs "irq" file reports a host-assigned IRQ
+        // number that is not meaningful for the guest and can exceed the u8 range required by the
+        // MP table. Let the VMM allocate a guest IRQ instead; VFIO handles host/guest
+        // interrupt mapping regardless of the guest IRQ number chosen.
+        PreferredIrq::Any
     }
 
     fn assign_irq(&mut self, irq_evt: IrqLevelEvent, pin: PciInterruptPin, irq_num: u32) {
diff --git a/infra/README.recipes.md b/infra/README.recipes.md
index 99875b3..35d9b65 100644
--- a/infra/README.recipes.md
+++ b/infra/README.recipes.md
@@ -20,6 +20,7 @@
   * [push_to_github](#recipes-push_to_github)
   * [update_chromeos_merges](#recipes-update_chromeos_merges)
   * [uprev_baguette_image](#recipes-uprev_baguette_image) &mdash; Recipe for uploading uprevs of the baguette image.
+  * [uprev_refvm_image](#recipes-uprev_refvm_image) &mdash; Recipe for uploading uprevs of the refvm image.
 ## Recipe Modules
 
 ### *recipe_modules* / [crosvm](/infra/recipe_modules/crosvm)
@@ -198,21 +199,29 @@
 Recipe for uploading uprevs of the baguette image.
 
 &mdash; **def [RunSteps](/infra/recipes/uprev_baguette_image.py#37)(api: RecipeApi, properties: UprevBaguetteImageProperties):**
+### *recipes* / [uprev\_refvm\_image](/infra/recipes/uprev_refvm_image.py)
 
-[depot_tools/recipe_modules/bot_update]: https://chromium.googlesource.com/chromium/tools/depot_tools.git/+/442cbd6d584d2c992ca9bcc19ecbd2235bea772e/recipes/README.recipes.md#recipe_modules-bot_update
-[depot_tools/recipe_modules/depot_tools]: https://chromium.googlesource.com/chromium/tools/depot_tools.git/+/442cbd6d584d2c992ca9bcc19ecbd2235bea772e/recipes/README.recipes.md#recipe_modules-depot_tools
-[depot_tools/recipe_modules/gclient]: https://chromium.googlesource.com/chromium/tools/depot_tools.git/+/442cbd6d584d2c992ca9bcc19ecbd2235bea772e/recipes/README.recipes.md#recipe_modules-gclient
-[depot_tools/recipe_modules/git]: https://chromium.googlesource.com/chromium/tools/depot_tools.git/+/442cbd6d584d2c992ca9bcc19ecbd2235bea772e/recipes/README.recipes.md#recipe_modules-git
-[depot_tools/recipe_modules/gsutil]: https://chromium.googlesource.com/chromium/tools/depot_tools.git/+/442cbd6d584d2c992ca9bcc19ecbd2235bea772e/recipes/README.recipes.md#recipe_modules-gsutil
-[recipe_engine/recipe_modules/buildbucket]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/363e865839c69e49ce2f6b6857bed6a66eae0dca/README.recipes.md#recipe_modules-buildbucket
-[recipe_engine/recipe_modules/cipd]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/363e865839c69e49ce2f6b6857bed6a66eae0dca/README.recipes.md#recipe_modules-cipd
-[recipe_engine/recipe_modules/context]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/363e865839c69e49ce2f6b6857bed6a66eae0dca/README.recipes.md#recipe_modules-context
-[recipe_engine/recipe_modules/file]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/363e865839c69e49ce2f6b6857bed6a66eae0dca/README.recipes.md#recipe_modules-file
-[recipe_engine/recipe_modules/json]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/363e865839c69e49ce2f6b6857bed6a66eae0dca/README.recipes.md#recipe_modules-json
-[recipe_engine/recipe_modules/path]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/363e865839c69e49ce2f6b6857bed6a66eae0dca/README.recipes.md#recipe_modules-path
-[recipe_engine/recipe_modules/platform]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/363e865839c69e49ce2f6b6857bed6a66eae0dca/README.recipes.md#recipe_modules-platform
-[recipe_engine/recipe_modules/properties]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/363e865839c69e49ce2f6b6857bed6a66eae0dca/README.recipes.md#recipe_modules-properties
-[recipe_engine/recipe_modules/raw_io]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/363e865839c69e49ce2f6b6857bed6a66eae0dca/README.recipes.md#recipe_modules-raw_io
-[recipe_engine/recipe_modules/step]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/363e865839c69e49ce2f6b6857bed6a66eae0dca/README.recipes.md#recipe_modules-step
-[recipe_engine/recipe_modules/time]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/363e865839c69e49ce2f6b6857bed6a66eae0dca/README.recipes.md#recipe_modules-time
-[recipe_engine/wkt/RecipeApi]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/363e865839c69e49ce2f6b6857bed6a66eae0dca/recipe_engine/recipe_api.py#439
+[DEPS](/infra/recipes/uprev_refvm_image.py#18): [depot\_tools/git][depot_tools/recipe_modules/git], [recipe\_engine/buildbucket][recipe_engine/recipe_modules/buildbucket], [recipe\_engine/cipd][recipe_engine/recipe_modules/cipd], [recipe\_engine/context][recipe_engine/recipe_modules/context], [recipe\_engine/file][recipe_engine/recipe_modules/file], [recipe\_engine/path][recipe_engine/recipe_modules/path], [recipe\_engine/properties][recipe_engine/recipe_modules/properties], [recipe\_engine/raw\_io][recipe_engine/recipe_modules/raw_io], [recipe\_engine/step][recipe_engine/recipe_modules/step]
+
+
+Recipe for uploading uprevs of the refvm image.
+
+&mdash; **def [RunSteps](/infra/recipes/uprev_refvm_image.py#36)(api: RecipeApi, properties: UprevRefvmImageProperties):**
+
+[depot_tools/recipe_modules/bot_update]: https://chromium.googlesource.com/chromium/tools/depot_tools.git/+/e16ce5a52dbf64646a1de370f562672bbeb9d9f4/recipes/README.recipes.md#recipe_modules-bot_update
+[depot_tools/recipe_modules/depot_tools]: https://chromium.googlesource.com/chromium/tools/depot_tools.git/+/e16ce5a52dbf64646a1de370f562672bbeb9d9f4/recipes/README.recipes.md#recipe_modules-depot_tools
+[depot_tools/recipe_modules/gclient]: https://chromium.googlesource.com/chromium/tools/depot_tools.git/+/e16ce5a52dbf64646a1de370f562672bbeb9d9f4/recipes/README.recipes.md#recipe_modules-gclient
+[depot_tools/recipe_modules/git]: https://chromium.googlesource.com/chromium/tools/depot_tools.git/+/e16ce5a52dbf64646a1de370f562672bbeb9d9f4/recipes/README.recipes.md#recipe_modules-git
+[depot_tools/recipe_modules/gsutil]: https://chromium.googlesource.com/chromium/tools/depot_tools.git/+/e16ce5a52dbf64646a1de370f562672bbeb9d9f4/recipes/README.recipes.md#recipe_modules-gsutil
+[recipe_engine/recipe_modules/buildbucket]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/36229065a1e627aa00341564303ac1336c51d925/README.recipes.md#recipe_modules-buildbucket
+[recipe_engine/recipe_modules/cipd]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/36229065a1e627aa00341564303ac1336c51d925/README.recipes.md#recipe_modules-cipd
+[recipe_engine/recipe_modules/context]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/36229065a1e627aa00341564303ac1336c51d925/README.recipes.md#recipe_modules-context
+[recipe_engine/recipe_modules/file]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/36229065a1e627aa00341564303ac1336c51d925/README.recipes.md#recipe_modules-file
+[recipe_engine/recipe_modules/json]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/36229065a1e627aa00341564303ac1336c51d925/README.recipes.md#recipe_modules-json
+[recipe_engine/recipe_modules/path]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/36229065a1e627aa00341564303ac1336c51d925/README.recipes.md#recipe_modules-path
+[recipe_engine/recipe_modules/platform]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/36229065a1e627aa00341564303ac1336c51d925/README.recipes.md#recipe_modules-platform
+[recipe_engine/recipe_modules/properties]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/36229065a1e627aa00341564303ac1336c51d925/README.recipes.md#recipe_modules-properties
+[recipe_engine/recipe_modules/raw_io]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/36229065a1e627aa00341564303ac1336c51d925/README.recipes.md#recipe_modules-raw_io
+[recipe_engine/recipe_modules/step]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/36229065a1e627aa00341564303ac1336c51d925/README.recipes.md#recipe_modules-step
+[recipe_engine/recipe_modules/time]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/36229065a1e627aa00341564303ac1336c51d925/README.recipes.md#recipe_modules-time
+[recipe_engine/wkt/RecipeApi]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/36229065a1e627aa00341564303ac1336c51d925/recipe_engine/recipe_api.py#439
diff --git a/infra/config/generated/cr-buildbucket.cfg b/infra/config/generated/cr-buildbucket.cfg
index 92770ed..88259e0 100644
--- a/infra/config/generated/cr-buildbucket.cfg
+++ b/infra/config/generated/cr-buildbucket.cfg
@@ -194,6 +194,21 @@
       service_account: "crosvm-luci-ci-builder@crosvm-infra.iam.gserviceaccount.com"
     }
     builders {
+      name: "refvm_uprev"
+      swarming_host: "chromium-swarm.appspot.com"
+      dimensions: "cpu:x86-64"
+      dimensions: "os:Ubuntu"
+      dimensions: "pool:luci.crosvm.ci"
+      recipe {
+        name: "uprev_refvm_image"
+        cipd_package: "infra/recipe_bundles/chromium.googlesource.com/crosvm/crosvm"
+        cipd_version: "refs/heads/main"
+        properties_j: "bot:true"
+        properties_j: "push:true"
+      }
+      service_account: "crosvm-luci-ci-builder@crosvm-infra.iam.gserviceaccount.com"
+    }
+    builders {
       name: "update_chromeos_merges"
       swarming_host: "chromium-swarm.appspot.com"
       dimensions: "cpu:x86-64"
diff --git a/infra/config/generated/luci-milo.cfg b/infra/config/generated/luci-milo.cfg
index e0751a3..c01b2bb 100644
--- a/infra/config/generated/luci-milo.cfg
+++ b/infra/config/generated/luci-milo.cfg
@@ -77,5 +77,8 @@
   builders {
     name: "buildbucket/luci.crosvm.ci/baguette_uprev"
   }
+  builders {
+    name: "buildbucket/luci.crosvm.ci/refvm_uprev"
+  }
   builder_view_only: true
 }
diff --git a/infra/config/generated/luci-notify.cfg b/infra/config/generated/luci-notify.cfg
index 4855300..8ef3c06 100644
--- a/infra/config/generated/luci-notify.cfg
+++ b/infra/config/generated/luci-notify.cfg
@@ -164,6 +164,20 @@
   }
   builders {
     bucket: "ci"
+    name: "refvm_uprev"
+  }
+}
+notifiers {
+  notifications {
+    on_change: true
+    email {
+      recipients: "crosvm-uprev@grotations.appspotmail.com"
+      recipients: "keiichiw@google.com"
+      recipients: "zihanchen@google.com"
+    }
+  }
+  builders {
+    bucket: "ci"
     name: "update_chromeos_merges"
   }
 }
diff --git a/infra/config/generated/luci-scheduler.cfg b/infra/config/generated/luci-scheduler.cfg
index 192c245..c851c6a 100644
--- a/infra/config/generated/luci-scheduler.cfg
+++ b/infra/config/generated/luci-scheduler.cfg
@@ -117,6 +117,17 @@
   }
 }
 job {
+  id: "refvm_uprev"
+  realm: "ci"
+  schedule: "0 12 * * *"
+  acl_sets: "ci"
+  buildbucket {
+    server: "cr-buildbucket.appspot.com"
+    bucket: "ci"
+    builder: "refvm_uprev"
+  }
+}
+job {
   id: "update_chromeos_merges"
   realm: "ci"
   schedule: "0,30 * * * *"
diff --git a/infra/config/main.star b/infra/config/main.star
index 9240946..e15ea4a 100755
--- a/infra/config/main.star
+++ b/infra/config/main.star
@@ -459,3 +459,16 @@
     schedule = "0 12 * * *",  # Check for uprevs daily
     postsubmit = False,
 )
+
+infra_builder(
+    name = "refvm_uprev",
+    executable = luci.recipe(
+        name = "uprev_refvm_image",
+    ),
+    properties = {
+        "push": True,
+        "bot": True,
+    },
+    schedule = "0 12 * * *",  # Check for uprevs daily
+    postsubmit = False,
+)
diff --git a/infra/config/recipes.cfg b/infra/config/recipes.cfg
index d6117b6..80df6fa 100644
--- a/infra/config/recipes.cfg
+++ b/infra/config/recipes.cfg
@@ -18,12 +18,12 @@
   "deps": {
     "depot_tools": {
       "branch": "refs/heads/main",
-      "revision": "442cbd6d584d2c992ca9bcc19ecbd2235bea772e",
+      "revision": "e16ce5a52dbf64646a1de370f562672bbeb9d9f4",
       "url": "https://chromium.googlesource.com/chromium/tools/depot_tools.git"
     },
     "recipe_engine": {
       "branch": "refs/heads/main",
-      "revision": "363e865839c69e49ce2f6b6857bed6a66eae0dca",
+      "revision": "36229065a1e627aa00341564303ac1336c51d925",
       "url": "https://chromium.googlesource.com/infra/luci/recipes-py.git"
     }
   },
diff --git a/infra/recipes/uprev_refvm_image.expected/Invalid size output.json b/infra/recipes/uprev_refvm_image.expected/Invalid size output.json
new file mode 100644
index 0000000..b7c64aa
--- /dev/null
+++ b/infra/recipes/uprev_refvm_image.expected/Invalid size output.json
@@ -0,0 +1,146 @@
+[
+  {
+    "cmd": [
+      "python3",
+      "-u",
+      "RECIPE_MODULE[depot_tools::git]/resources/git_setup.py",
+      "--path",
+      "[CLEANUP]/tmp_tmp_1",
+      "--url",
+      "https://chromium.googlesource.com/chromiumos/platform/tast-tests"
+    ],
+    "name": "git setup"
+  },
+  {
+    "cmd": [
+      "git",
+      "fetch",
+      "origin",
+      "main",
+      "--progress",
+      "--depth",
+      "1"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "env": {
+      "PATH": "RECIPE_REPO[depot_tools]:<PATH>"
+    },
+    "infra_step": true,
+    "name": "git fetch"
+  },
+  {
+    "cmd": [
+      "git",
+      "checkout",
+      "-f",
+      "FETCH_HEAD"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git checkout"
+  },
+  {
+    "cmd": [
+      "git",
+      "rev-parse",
+      "HEAD"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "read revision",
+    "~followup_annotations": [
+      "@@@STEP_TEXT@<br/>checked out 'deadbeef'<br/>@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "clean",
+      "-f",
+      "-d",
+      "-x"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git clean"
+  },
+  {
+    "cmd": [
+      "git",
+      "submodule",
+      "sync"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "submodule sync"
+  },
+  {
+    "cmd": [
+      "git",
+      "submodule",
+      "update",
+      "--init",
+      "--recursive"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "submodule update"
+  },
+  {
+    "cmd": [
+      "curl",
+      "-Lo",
+      ".git/hooks/commit-msg",
+      "https://chromium-review.googlesource.com/tools/hooks/commit-msg"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Install commit hook"
+  },
+  {
+    "cmd": [
+      "chmod",
+      "+x",
+      ".git/hooks/commit-msg"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Make commit hook executable"
+  },
+  {
+    "cmd": [
+      "gcloud",
+      "storage",
+      "ls",
+      "gs://refvm-images/[0-9][0-9][0-9][0-9]-[0-9][0-9]/refvm-*.qcow2"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Find latest image"
+  },
+  {
+    "cmd": [
+      "gcloud",
+      "storage",
+      "cp",
+      "gs://refvm-images/2026-03/refvm-20260316_000339.qcow2",
+      "[CLEANUP]/tmp_tmp_2/image.br"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Download image"
+  },
+  {
+    "cmd": [
+      "stat",
+      "-c",
+      "%s",
+      "[CLEANUP]/tmp_tmp_2/image.br"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Get compressed size"
+  },
+  {
+    "failure": {
+      "failure": {},
+      "humanReason": "Failed to parse compressed size"
+    },
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/infra/recipes/uprev_refvm_image.expected/No images.json b/infra/recipes/uprev_refvm_image.expected/No images.json
new file mode 100644
index 0000000..407ec0d
--- /dev/null
+++ b/infra/recipes/uprev_refvm_image.expected/No images.json
@@ -0,0 +1,125 @@
+[
+  {
+    "cmd": [
+      "python3",
+      "-u",
+      "RECIPE_MODULE[depot_tools::git]/resources/git_setup.py",
+      "--path",
+      "[CLEANUP]/tmp_tmp_1",
+      "--url",
+      "https://chromium.googlesource.com/chromiumos/platform/tast-tests"
+    ],
+    "name": "git setup"
+  },
+  {
+    "cmd": [
+      "git",
+      "fetch",
+      "origin",
+      "main",
+      "--progress",
+      "--depth",
+      "1"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "env": {
+      "PATH": "RECIPE_REPO[depot_tools]:<PATH>"
+    },
+    "infra_step": true,
+    "name": "git fetch"
+  },
+  {
+    "cmd": [
+      "git",
+      "checkout",
+      "-f",
+      "FETCH_HEAD"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git checkout"
+  },
+  {
+    "cmd": [
+      "git",
+      "rev-parse",
+      "HEAD"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "read revision",
+    "~followup_annotations": [
+      "@@@STEP_TEXT@<br/>checked out 'deadbeef'<br/>@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "clean",
+      "-f",
+      "-d",
+      "-x"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git clean"
+  },
+  {
+    "cmd": [
+      "git",
+      "submodule",
+      "sync"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "submodule sync"
+  },
+  {
+    "cmd": [
+      "git",
+      "submodule",
+      "update",
+      "--init",
+      "--recursive"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "submodule update"
+  },
+  {
+    "cmd": [
+      "curl",
+      "-Lo",
+      ".git/hooks/commit-msg",
+      "https://chromium-review.googlesource.com/tools/hooks/commit-msg"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Install commit hook"
+  },
+  {
+    "cmd": [
+      "chmod",
+      "+x",
+      ".git/hooks/commit-msg"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Make commit hook executable"
+  },
+  {
+    "cmd": [
+      "gcloud",
+      "storage",
+      "ls",
+      "gs://refvm-images/[0-9][0-9][0-9][0-9]-[0-9][0-9]/refvm-*.qcow2"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Find latest image"
+  },
+  {
+    "failure": {
+      "failure": {},
+      "humanReason": "No images found in gs://refvm-images/"
+    },
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/infra/recipes/uprev_refvm_image.expected/Nothing to submit.json b/infra/recipes/uprev_refvm_image.expected/Nothing to submit.json
new file mode 100644
index 0000000..741377d
--- /dev/null
+++ b/infra/recipes/uprev_refvm_image.expected/Nothing to submit.json
@@ -0,0 +1,246 @@
+[
+  {
+    "cmd": [
+      "python3",
+      "-u",
+      "RECIPE_MODULE[depot_tools::git]/resources/git_setup.py",
+      "--path",
+      "[CLEANUP]/tmp_tmp_1",
+      "--url",
+      "https://chromium.googlesource.com/chromiumos/platform/tast-tests"
+    ],
+    "name": "git setup"
+  },
+  {
+    "cmd": [
+      "git",
+      "fetch",
+      "origin",
+      "main",
+      "--progress",
+      "--depth",
+      "1"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "env": {
+      "PATH": "RECIPE_REPO[depot_tools]:<PATH>"
+    },
+    "infra_step": true,
+    "name": "git fetch"
+  },
+  {
+    "cmd": [
+      "git",
+      "checkout",
+      "-f",
+      "FETCH_HEAD"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git checkout"
+  },
+  {
+    "cmd": [
+      "git",
+      "rev-parse",
+      "HEAD"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "read revision",
+    "~followup_annotations": [
+      "@@@STEP_TEXT@<br/>checked out 'deadbeef'<br/>@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "clean",
+      "-f",
+      "-d",
+      "-x"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git clean"
+  },
+  {
+    "cmd": [
+      "git",
+      "submodule",
+      "sync"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "submodule sync"
+  },
+  {
+    "cmd": [
+      "git",
+      "submodule",
+      "update",
+      "--init",
+      "--recursive"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "submodule update"
+  },
+  {
+    "cmd": [
+      "curl",
+      "-Lo",
+      ".git/hooks/commit-msg",
+      "https://chromium-review.googlesource.com/tools/hooks/commit-msg"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Install commit hook"
+  },
+  {
+    "cmd": [
+      "chmod",
+      "+x",
+      ".git/hooks/commit-msg"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Make commit hook executable"
+  },
+  {
+    "cmd": [
+      "gcloud",
+      "storage",
+      "ls",
+      "gs://refvm-images/[0-9][0-9][0-9][0-9]-[0-9][0-9]/refvm-*.qcow2"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Find latest image"
+  },
+  {
+    "cmd": [
+      "gcloud",
+      "storage",
+      "cp",
+      "gs://refvm-images/2026-03/refvm-20260316_000339.qcow2",
+      "[CLEANUP]/tmp_tmp_2/image.br"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Download image"
+  },
+  {
+    "cmd": [
+      "stat",
+      "-c",
+      "%s",
+      "[CLEANUP]/tmp_tmp_2/image.br"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Get compressed size"
+  },
+  {
+    "cmd": [
+      "sha256sum",
+      "[CLEANUP]/tmp_tmp_2/image.br"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Get compressed sha256"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-vpython-spec",
+      "RECIPE[crosvm::uprev_refvm_image].resources/brotli_decompress.vpython3",
+      "RECIPE[crosvm::uprev_refvm_image].resources/brotli_decompress.py",
+      "[CLEANUP]/tmp_tmp_2/image.br",
+      "[CLEANUP]/tmp_tmp_2/image"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Decompress image"
+  },
+  {
+    "cmd": [
+      "sha256sum",
+      "[CLEANUP]/tmp_tmp_2/image"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Get decompressed sha256"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "{\n    \"sha256sum\": \"aaaa\",\n    \"size\": 1024,\n    \"url\": \"gs://refvm-images/2026-03/refvm-20260316_000339.qcow2\"\n}\n",
+      "[CLEANUP]/tmp_tmp_1/src/go.chromium.org/tast-tests/cros/local/bruschetta/data/refvm.qcow2.external"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "Update external file",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@refvm.qcow2.external@{@@@",
+      "@@@STEP_LOG_LINE@refvm.qcow2.external@    \"sha256sum\": \"aaaa\",@@@",
+      "@@@STEP_LOG_LINE@refvm.qcow2.external@    \"size\": 1024,@@@",
+      "@@@STEP_LOG_LINE@refvm.qcow2.external@    \"url\": \"gs://refvm-images/2026-03/refvm-20260316_000339.qcow2\"@@@",
+      "@@@STEP_LOG_LINE@refvm.qcow2.external@}@@@",
+      "@@@STEP_LOG_END@refvm.qcow2.external@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "bbbb\n",
+      "[CLEANUP]/tmp_tmp_1/src/go.chromium.org/tast-tests/cros/local/bruschetta/data/refvm.qcow2.SHA256"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "Update SHA256 file",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@refvm.qcow2.SHA256@bbbb@@@",
+      "@@@STEP_LOG_END@refvm.qcow2.SHA256@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "add",
+      "[CLEANUP]/tmp_tmp_1/src/go.chromium.org/tast-tests/cros/local/bruschetta/data/refvm.qcow2.external"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git add"
+  },
+  {
+    "cmd": [
+      "git",
+      "add",
+      "[CLEANUP]/tmp_tmp_1/src/go.chromium.org/tast-tests/cros/local/bruschetta/data/refvm.qcow2.SHA256"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git add (2)"
+  },
+  {
+    "cmd": [
+      "git",
+      "diff",
+      "--cached",
+      "--exit-code"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git diff",
+    "~followup_annotations": [
+      "@@@STEP_TEXT@Nothing to submit@@@"
+    ]
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/infra/recipes/uprev_refvm_image.expected/Submit bot uprev.json b/infra/recipes/uprev_refvm_image.expected/Submit bot uprev.json
new file mode 100644
index 0000000..8334340
--- /dev/null
+++ b/infra/recipes/uprev_refvm_image.expected/Submit bot uprev.json
@@ -0,0 +1,265 @@
+[
+  {
+    "cmd": [
+      "python3",
+      "-u",
+      "RECIPE_MODULE[depot_tools::git]/resources/git_setup.py",
+      "--path",
+      "[CLEANUP]/tmp_tmp_1",
+      "--url",
+      "https://chromium.googlesource.com/chromiumos/platform/tast-tests"
+    ],
+    "name": "git setup"
+  },
+  {
+    "cmd": [
+      "git",
+      "fetch",
+      "origin",
+      "main",
+      "--progress",
+      "--depth",
+      "1"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "env": {
+      "PATH": "RECIPE_REPO[depot_tools]:<PATH>"
+    },
+    "infra_step": true,
+    "name": "git fetch"
+  },
+  {
+    "cmd": [
+      "git",
+      "checkout",
+      "-f",
+      "FETCH_HEAD"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git checkout"
+  },
+  {
+    "cmd": [
+      "git",
+      "rev-parse",
+      "HEAD"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "read revision",
+    "~followup_annotations": [
+      "@@@STEP_TEXT@<br/>checked out 'deadbeef'<br/>@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "clean",
+      "-f",
+      "-d",
+      "-x"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git clean"
+  },
+  {
+    "cmd": [
+      "git",
+      "submodule",
+      "sync"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "submodule sync"
+  },
+  {
+    "cmd": [
+      "git",
+      "submodule",
+      "update",
+      "--init",
+      "--recursive"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "submodule update"
+  },
+  {
+    "cmd": [
+      "curl",
+      "-Lo",
+      ".git/hooks/commit-msg",
+      "https://chromium-review.googlesource.com/tools/hooks/commit-msg"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Install commit hook"
+  },
+  {
+    "cmd": [
+      "chmod",
+      "+x",
+      ".git/hooks/commit-msg"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Make commit hook executable"
+  },
+  {
+    "cmd": [
+      "gcloud",
+      "storage",
+      "ls",
+      "gs://refvm-images/[0-9][0-9][0-9][0-9]-[0-9][0-9]/refvm-*.qcow2"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Find latest image"
+  },
+  {
+    "cmd": [
+      "gcloud",
+      "storage",
+      "cp",
+      "gs://refvm-images/2026-03/refvm-20260316_000339.qcow2",
+      "[CLEANUP]/tmp_tmp_2/image.br"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Download image"
+  },
+  {
+    "cmd": [
+      "stat",
+      "-c",
+      "%s",
+      "[CLEANUP]/tmp_tmp_2/image.br"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Get compressed size"
+  },
+  {
+    "cmd": [
+      "sha256sum",
+      "[CLEANUP]/tmp_tmp_2/image.br"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Get compressed sha256"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-vpython-spec",
+      "RECIPE[crosvm::uprev_refvm_image].resources/brotli_decompress.vpython3",
+      "RECIPE[crosvm::uprev_refvm_image].resources/brotli_decompress.py",
+      "[CLEANUP]/tmp_tmp_2/image.br",
+      "[CLEANUP]/tmp_tmp_2/image"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Decompress image"
+  },
+  {
+    "cmd": [
+      "sha256sum",
+      "[CLEANUP]/tmp_tmp_2/image"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Get decompressed sha256"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "{\n    \"sha256sum\": \"aaaa\",\n    \"size\": 1024,\n    \"url\": \"gs://refvm-images/2026-03/refvm-20260316_000339.qcow2\"\n}\n",
+      "[CLEANUP]/tmp_tmp_1/src/go.chromium.org/tast-tests/cros/local/bruschetta/data/refvm.qcow2.external"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "Update external file",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@refvm.qcow2.external@{@@@",
+      "@@@STEP_LOG_LINE@refvm.qcow2.external@    \"sha256sum\": \"aaaa\",@@@",
+      "@@@STEP_LOG_LINE@refvm.qcow2.external@    \"size\": 1024,@@@",
+      "@@@STEP_LOG_LINE@refvm.qcow2.external@    \"url\": \"gs://refvm-images/2026-03/refvm-20260316_000339.qcow2\"@@@",
+      "@@@STEP_LOG_LINE@refvm.qcow2.external@}@@@",
+      "@@@STEP_LOG_END@refvm.qcow2.external@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "bbbb\n",
+      "[CLEANUP]/tmp_tmp_1/src/go.chromium.org/tast-tests/cros/local/bruschetta/data/refvm.qcow2.SHA256"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "Update SHA256 file",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@refvm.qcow2.SHA256@bbbb@@@",
+      "@@@STEP_LOG_END@refvm.qcow2.SHA256@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "add",
+      "[CLEANUP]/tmp_tmp_1/src/go.chromium.org/tast-tests/cros/local/bruschetta/data/refvm.qcow2.external"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git add"
+  },
+  {
+    "cmd": [
+      "git",
+      "add",
+      "[CLEANUP]/tmp_tmp_1/src/go.chromium.org/tast-tests/cros/local/bruschetta/data/refvm.qcow2.SHA256"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git add (2)"
+  },
+  {
+    "cmd": [
+      "git",
+      "diff",
+      "--cached",
+      "--exit-code"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git diff"
+  },
+  {
+    "cmd": [
+      "git",
+      "commit",
+      "-m",
+      "bruschetta: Uprev refvm image to 20260316_000339.\n\nGenerated by https://cr-buildbucket.appspot.com/build/0."
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git commit"
+  },
+  {
+    "cmd": [
+      "git",
+      "push",
+      "origin",
+      "HEAD:refs/for/main%r=crosvm-uprev@google.com,l=Bot-Commit+1,l=Commit-Queue+2"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git push"
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/infra/recipes/uprev_refvm_image.expected/Submit test uprev.json b/infra/recipes/uprev_refvm_image.expected/Submit test uprev.json
new file mode 100644
index 0000000..ef6489d
--- /dev/null
+++ b/infra/recipes/uprev_refvm_image.expected/Submit test uprev.json
@@ -0,0 +1,265 @@
+[
+  {
+    "cmd": [
+      "python3",
+      "-u",
+      "RECIPE_MODULE[depot_tools::git]/resources/git_setup.py",
+      "--path",
+      "[CLEANUP]/tmp_tmp_1",
+      "--url",
+      "https://chromium.googlesource.com/chromiumos/platform/tast-tests"
+    ],
+    "name": "git setup"
+  },
+  {
+    "cmd": [
+      "git",
+      "fetch",
+      "origin",
+      "main",
+      "--progress",
+      "--depth",
+      "1"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "env": {
+      "PATH": "RECIPE_REPO[depot_tools]:<PATH>"
+    },
+    "infra_step": true,
+    "name": "git fetch"
+  },
+  {
+    "cmd": [
+      "git",
+      "checkout",
+      "-f",
+      "FETCH_HEAD"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git checkout"
+  },
+  {
+    "cmd": [
+      "git",
+      "rev-parse",
+      "HEAD"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "read revision",
+    "~followup_annotations": [
+      "@@@STEP_TEXT@<br/>checked out 'deadbeef'<br/>@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "clean",
+      "-f",
+      "-d",
+      "-x"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git clean"
+  },
+  {
+    "cmd": [
+      "git",
+      "submodule",
+      "sync"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "submodule sync"
+  },
+  {
+    "cmd": [
+      "git",
+      "submodule",
+      "update",
+      "--init",
+      "--recursive"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "submodule update"
+  },
+  {
+    "cmd": [
+      "curl",
+      "-Lo",
+      ".git/hooks/commit-msg",
+      "https://chromium-review.googlesource.com/tools/hooks/commit-msg"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Install commit hook"
+  },
+  {
+    "cmd": [
+      "chmod",
+      "+x",
+      ".git/hooks/commit-msg"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Make commit hook executable"
+  },
+  {
+    "cmd": [
+      "gcloud",
+      "storage",
+      "ls",
+      "gs://refvm-images/[0-9][0-9][0-9][0-9]-[0-9][0-9]/refvm-*.qcow2"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Find latest image"
+  },
+  {
+    "cmd": [
+      "gcloud",
+      "storage",
+      "cp",
+      "gs://refvm-images/2026-03/refvm-20260316_000339.qcow2",
+      "[CLEANUP]/tmp_tmp_2/image.br"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Download image"
+  },
+  {
+    "cmd": [
+      "stat",
+      "-c",
+      "%s",
+      "[CLEANUP]/tmp_tmp_2/image.br"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Get compressed size"
+  },
+  {
+    "cmd": [
+      "sha256sum",
+      "[CLEANUP]/tmp_tmp_2/image.br"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Get compressed sha256"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-vpython-spec",
+      "RECIPE[crosvm::uprev_refvm_image].resources/brotli_decompress.vpython3",
+      "RECIPE[crosvm::uprev_refvm_image].resources/brotli_decompress.py",
+      "[CLEANUP]/tmp_tmp_2/image.br",
+      "[CLEANUP]/tmp_tmp_2/image"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Decompress image"
+  },
+  {
+    "cmd": [
+      "sha256sum",
+      "[CLEANUP]/tmp_tmp_2/image"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "name": "Get decompressed sha256"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "{\n    \"sha256sum\": \"aaaa\",\n    \"size\": 1024,\n    \"url\": \"gs://refvm-images/2026-03/refvm-20260316_000339.qcow2\"\n}\n",
+      "[CLEANUP]/tmp_tmp_1/src/go.chromium.org/tast-tests/cros/local/bruschetta/data/refvm.qcow2.external"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "Update external file",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@refvm.qcow2.external@{@@@",
+      "@@@STEP_LOG_LINE@refvm.qcow2.external@    \"sha256sum\": \"aaaa\",@@@",
+      "@@@STEP_LOG_LINE@refvm.qcow2.external@    \"size\": 1024,@@@",
+      "@@@STEP_LOG_LINE@refvm.qcow2.external@    \"url\": \"gs://refvm-images/2026-03/refvm-20260316_000339.qcow2\"@@@",
+      "@@@STEP_LOG_LINE@refvm.qcow2.external@}@@@",
+      "@@@STEP_LOG_END@refvm.qcow2.external@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "bbbb\n",
+      "[CLEANUP]/tmp_tmp_1/src/go.chromium.org/tast-tests/cros/local/bruschetta/data/refvm.qcow2.SHA256"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "Update SHA256 file",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@refvm.qcow2.SHA256@bbbb@@@",
+      "@@@STEP_LOG_END@refvm.qcow2.SHA256@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "add",
+      "[CLEANUP]/tmp_tmp_1/src/go.chromium.org/tast-tests/cros/local/bruschetta/data/refvm.qcow2.external"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git add"
+  },
+  {
+    "cmd": [
+      "git",
+      "add",
+      "[CLEANUP]/tmp_tmp_1/src/go.chromium.org/tast-tests/cros/local/bruschetta/data/refvm.qcow2.SHA256"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git add (2)"
+  },
+  {
+    "cmd": [
+      "git",
+      "diff",
+      "--cached",
+      "--exit-code"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git diff"
+  },
+  {
+    "cmd": [
+      "git",
+      "commit",
+      "-m",
+      "bruschetta: Uprev refvm image to 20260316_000339.\n\nGenerated by https://cr-buildbucket.appspot.com/build/0."
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git commit"
+  },
+  {
+    "cmd": [
+      "git",
+      "push",
+      "origin",
+      "HEAD:refs/for/main%r=crosvm-uprev@google.com,l=Commit-Queue+1"
+    ],
+    "cwd": "[CLEANUP]/tmp_tmp_1",
+    "infra_step": true,
+    "name": "git push"
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/infra/recipes/uprev_refvm_image.proto b/infra/recipes/uprev_refvm_image.proto
new file mode 100644
index 0000000..6045608
--- /dev/null
+++ b/infra/recipes/uprev_refvm_image.proto
@@ -0,0 +1,14 @@
+// Copyright 2026 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+syntax = "proto3";
+
+package recipes.crosvm.uprev_refvm_image;
+
+message UprevRefvmImageProperties {
+  // Set to true to enable pushing to gerrit
+  bool push = 1;
+  // Set to true if the recipe is allowed to set Bot-Commit+1
+  bool bot = 2;
+}
diff --git a/infra/recipes/uprev_refvm_image.py b/infra/recipes/uprev_refvm_image.py
new file mode 100644
index 0000000..fe86660
--- /dev/null
+++ b/infra/recipes/uprev_refvm_image.py
@@ -0,0 +1,247 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 The ChromiumOS Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Recipe for uploading uprevs of the refvm image."""
+
+import json
+import re
+from typing import Generator
+
+from PB.recipes.crosvm.uprev_refvm_image import UprevRefvmImageProperties
+from recipe_engine.recipe_api import RecipeApi
+from recipe_engine.recipe_api import StepFailure
+from recipe_engine.recipe_test_api import RecipeTestApi
+from recipe_engine.recipe_test_api import TestData
+
+DEPS = [
+    "recipe_engine/buildbucket",
+    "recipe_engine/cipd",
+    "recipe_engine/context",
+    "recipe_engine/file",
+    "recipe_engine/path",
+    "recipe_engine/properties",
+    "recipe_engine/raw_io",
+    "recipe_engine/step",
+    "depot_tools/git",
+]
+
+PROPERTIES = UprevRefvmImageProperties
+
+_TAST_TESTS_REPO_URL = "https://chromium.googlesource.com/chromiumos/platform/tast-tests"
+_DATA_DIR_PATH = "src/go.chromium.org/tast-tests/cros/local/bruschetta/data"
+
+
+def RunSteps(api: RecipeApi, properties: UprevRefvmImageProperties) -> None:
+    # Clone the tast-tests repo in a temp dir
+    checkout = api.path.mkdtemp()
+    api.git.checkout(_TAST_TESTS_REPO_URL, dir_path=checkout, depth=1)
+
+    with api.context(cwd=checkout):
+        # Ensure the commit hook is configured
+        api.step(
+            "Install commit hook",
+            [
+                "curl",
+                "-Lo",
+                ".git/hooks/commit-msg",
+                "https://chromium-review.googlesource.com/tools/hooks/commit-msg",
+            ],
+        )
+        api.step("Make commit hook executable", ["chmod", "+x", ".git/hooks/commit-msg"])
+
+        # Step 1. Find latest image
+        res_dir = api.step(
+            "Find latest image",
+            [
+                "gcloud",
+                "storage",
+                "ls",
+                "gs://refvm-images/[0-9][0-9][0-9][0-9]-[0-9][0-9]/refvm-*.qcow2",
+            ],
+            stdout=api.raw_io.output_text(),
+        )
+        objs = res_dir.stdout.strip().splitlines()
+        if not objs:
+            raise StepFailure("No images found in gs://refvm-images/")
+        last_obj = objs[-1].strip()
+
+        match = re.search(r"refvm-([\d_]+)\.qcow2", last_obj)
+        version = match.group(1) if match else "unknown"
+
+        # Step 2. Download image to temp dir
+        tmp_dir = api.path.mkdtemp()
+        downloaded_file = tmp_dir / "image.br"
+
+        api.step(
+            "Download image",
+            ["gcloud", "storage", "cp", last_obj, downloaded_file],
+        )
+
+        # Step 3. Calculate hashes
+        decompressed_file = tmp_dir / "image"
+
+        res_size = api.step(
+            "Get compressed size",
+            ["stat", "-c", "%s", downloaded_file],
+            stdout=api.raw_io.output_text(),
+        )
+        try:
+            compressed_size = int(res_size.stdout.strip())
+        except ValueError:
+            raise StepFailure("Failed to parse compressed size")
+
+        res_c_sha = api.step(
+            "Get compressed sha256",
+            ["sha256sum", downloaded_file],
+            stdout=api.raw_io.output_text(),
+        )
+        compressed_sha256 = res_c_sha.stdout.strip().split()[0]
+
+        api.step(
+            "Decompress image",
+            [
+                "vpython3",
+                "-vpython-spec",
+                api.resource("brotli_decompress.vpython3"),
+                api.resource("brotli_decompress.py"),
+                downloaded_file,
+                decompressed_file,
+            ],
+        )
+
+        res_d_sha = api.step(
+            "Get decompressed sha256",
+            ["sha256sum", decompressed_file],
+            stdout=api.raw_io.output_text(),
+        )
+        decompressed_sha256 = res_d_sha.stdout.strip().split()[0]
+
+        datadir = checkout / _DATA_DIR_PATH
+        external_file = datadir / "refvm.qcow2.external"
+        sha256_file = datadir / "refvm.qcow2.SHA256"
+
+        external_content = {
+            "sha256sum": compressed_sha256,
+            "size": compressed_size,
+            "url": last_obj,
+        }
+
+        api.file.write_text(
+            "Update external file",
+            external_file,
+            json.dumps(external_content, indent=4) + "\n",
+        )
+
+        api.file.write_text(
+            "Update SHA256 file",
+            sha256_file,
+            decompressed_sha256 + "\n",
+        )
+
+        # Step 4. Create commit if there are changes
+        api.git("add", str(external_file))
+        api.git("add", str(sha256_file))
+
+        diff_result = api.git("diff", "--cached", "--exit-code", ok_ret="any")
+        if diff_result.retcode == 0:
+            diff_result.presentation.step_text = "Nothing to submit"
+            return
+
+        commit_lines = [
+            f"bruschetta: Uprev refvm image to {version}.",
+            "",
+            f"Generated by {api.buildbucket.build_url()}.",
+        ]
+
+        api.git("commit", "-m", "\n".join(commit_lines))
+
+        # Push the change to gerrit if requested
+        if properties.push:
+            gerrit_params = ["r=crosvm-uprev@google.com"]
+            if properties.bot:
+                gerrit_params += ["l=Bot-Commit+1", "l=Commit-Queue+2"]
+            else:
+                gerrit_params += ["l=Commit-Queue+1"]
+            api.git("push", "origin", f"HEAD:refs/for/main%{','.join(gerrit_params)}")
+
+
+def GenTests(api: RecipeTestApi) -> Generator[TestData, None, None]:
+    def mock_decompress_output(fail_size=False):
+        steps = []
+        if fail_size:
+            steps.append(
+                api.step_data("Get compressed size", stdout=api.raw_io.output_text("not an int"))
+            )
+            return tuple(steps)
+        else:
+            steps.append(
+                api.step_data("Get compressed size", stdout=api.raw_io.output_text("1024"))
+            )
+
+        steps.append(
+            api.step_data(
+                "Get compressed sha256", stdout=api.raw_io.output_text("aaaa  /tmp/image.br")
+            )
+        )
+        steps.append(api.step_data("Decompress image", retcode=0))
+        steps.append(
+            api.step_data(
+                "Get decompressed sha256", stdout=api.raw_io.output_text("bbbb  /tmp/image")
+            )
+        )
+        return tuple(steps)
+
+    yield api.test(
+        "Submit test uprev",
+        api.properties(push=True, bot=False),
+        api.step_data(
+            "Find latest image",
+            stdout=api.raw_io.output_text("gs://refvm-images/2026-03/refvm-20260316_000339.qcow2"),
+        ),
+        *mock_decompress_output(),
+        api.step_data("git diff", retcode=1),
+    )
+
+    yield api.test(
+        "Submit bot uprev",
+        api.properties(push=True, bot=True),
+        api.step_data(
+            "Find latest image",
+            stdout=api.raw_io.output_text("gs://refvm-images/2026-03/refvm-20260316_000339.qcow2"),
+        ),
+        *mock_decompress_output(),
+        api.step_data("git diff", retcode=1),
+    )
+
+    yield api.test(
+        "Nothing to submit",
+        api.properties(push=True),
+        api.step_data(
+            "Find latest image",
+            stdout=api.raw_io.output_text("gs://refvm-images/2026-03/refvm-20260316_000339.qcow2"),
+        ),
+        *mock_decompress_output(),
+        api.step_data("git diff", retcode=0),
+    )
+
+    yield api.test(
+        "No images",
+        api.step_data(
+            "Find latest image",
+            stdout=api.raw_io.output_text("gs://refvm-images/2026-03/refvm-20260316_000339.qcow2"),
+        ),
+        api.step_data("Find latest image", stdout=api.raw_io.output_text("")),
+        status="FAILURE",
+    )
+
+    yield api.test(
+        "Invalid size output",
+        api.step_data(
+            "Find latest image",
+            stdout=api.raw_io.output_text("gs://refvm-images/2026-03/refvm-20260316_000339.qcow2"),
+        ),
+        *mock_decompress_output(fail_size=True),
+        status="FAILURE",
+    )
diff --git a/infra/recipes/uprev_refvm_image.resources/brotli_decompress.py b/infra/recipes/uprev_refvm_image.resources/brotli_decompress.py
new file mode 100644
index 0000000..50e1f88
--- /dev/null
+++ b/infra/recipes/uprev_refvm_image.resources/brotli_decompress.py
@@ -0,0 +1,28 @@
+# Copyright 2026 The ChromiumOS Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Decompresses a Brotli-compressed file."""
+
+import brotli
+import sys
+
+
+def main():
+    if len(sys.argv) != 3:
+        print("Usage: brotli_decompress.py <input> <output>")
+        sys.exit(1)
+    input_file = sys.argv[1]
+    output_file = sys.argv[2]
+
+    with open(input_file, "rb") as f:
+        compressed_data = f.read()
+
+    decompressed_data = brotli.decompress(compressed_data)
+
+    with open(output_file, "wb") as f:
+        f.write(decompressed_data)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/infra/recipes/uprev_refvm_image.resources/brotli_decompress.vpython3 b/infra/recipes/uprev_refvm_image.resources/brotli_decompress.vpython3
new file mode 100644
index 0000000..1c5dc2d
--- /dev/null
+++ b/infra/recipes/uprev_refvm_image.resources/brotli_decompress.vpython3
@@ -0,0 +1,6 @@
+python_version: "3.8"
+
+wheel: <
+  name: "infra/python/wheels/brotli/${vpython_platform}"
+  version: "version:1.0.9"
+>