diff --git a/.github/workflows/rust-checks.yaml b/.github/workflows/rust-checks.yaml
index 6fb26152264..6c36abec0bb 100644
--- a/.github/workflows/rust-checks.yaml
+++ b/.github/workflows/rust-checks.yaml
@@ -10,32 +10,61 @@ concurrency:
   group: ${{ github.ref }}-${{ github.workflow }}
   cancel-in-progress: true
 
+env:
+  RUST_BACKTRACE: 1
+  # pin nightly to avoid constantly throwing out cache
+  TOOLCHAIN_LINT: nightly-2023-11-13
+
 jobs:
-  rust-checks:
-    runs-on: ubuntu-20.04
+  lint:
+    runs-on: ubuntu-latest
     steps:
-      - name: Checkout source code
-        uses: actions/checkout@v4
+      - uses: actions/checkout@v4
+      - name: Install Rust ${{ env.TOOLCHAIN_LINT }}
+        uses: dtolnay/rust-toolchain@stable
+        with:
+          toolchain: ${{ env.TOOLCHAIN_LINT }}
+          targets: wasm32-unknown-unknown
+          components: rustfmt, clippy
+      - uses: Swatinem/rust-cache@v2
+        with:
+          key: lint-v0
+      - name: cargo fmt
+        run: cargo +${{ env.TOOLCHAIN_LINT }} fmt --all -- --check
+      - name: Install deps for musl build
+        run: |
+          sudo apt-get update
+          sudo apt-get install -y protobuf-compiler musl-tools clang build-essential curl llvm-dev libclang-dev linux-headers-generic libsnappy-dev liblz4-dev libzstd-dev libgflags-dev zlib1g-dev libbz2-dev
+          sudo ln -s /usr/bin/g++ /usr/bin/musl-g++
+      - name: cargo clippy
+        uses: actions-rs-plus/clippy-check@v2
+        with:
+          toolchain: ${{ env.TOOLCHAIN_LINT }}
+          args: --all-targets --all-features --locked --no-deps -- --deny warnings
 
+  test:
+    strategy:
+      matrix:
+        os: [ubuntu-latest]
+        toolchain: [stable]
+    runs-on: ${{ matrix.os }}
+    steps:
+      - uses: actions/checkout@v4
+      - name: Install Rust ${{ matrix.toolchain }}
+        uses: dtolnay/rust-toolchain@stable
+        with:
+          toolchain: ${{ matrix.toolchain }}
+          targets: wasm32-unknown-unknown
+      - uses: Swatinem/rust-cache@v2
       - name: Install Protoc
         uses: arduino/setup-protoc@v1
         with:
           version: "3.6.1"
-
-      - name: Install clippy and fmt
-        run: rustup component add clippy rustfmt
-
-      - name: Add wasm32-unknown-unknown target
-        run: rustup target add wasm32-unknown-unknown
-
-      - name: Run Format Checks
-        uses: actions-rs/cargo@v1
-        with:
-          command: fmt
-          args: --all
-
-      - name: Clippy
-        uses: actions-rs/cargo@v1
-        with:
-          command: clippy
-          args: --all-targets -- --no-deps -D warnings
+      - name: build try-runtime-cli
+        # this is required for testing
+        # build --release or the execution time of the test is too long
+        run: cargo build --release -p try-runtime-cli
+      - name: cargo test
+        run: cargo test --release
+      - name: Check disk space
+        run: df . -h
diff --git a/Cargo.lock b/Cargo.lock
index 2a4d02efc31..cfd788c4a6c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11262,7 +11262,7 @@ checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed"
 
 [[package]]
 name = "try-runtime-cli"
-version = "0.3.5"
+version = "0.4.0"
 dependencies = [
  "clap",
  "env_logger",
@@ -11314,7 +11314,7 @@ dependencies = [
 
 [[package]]
 name = "try-runtime-core"
-version = "0.3.5"
+version = "0.4.0"
 dependencies = [
  "assert_cmd",
  "async-trait",
diff --git a/Cargo.toml b/Cargo.toml
index 70d1f1bc9c1..772eccdd395 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,7 +7,7 @@ members = [
 ]
 
 [workspace.package]
-version = "0.3.5"
+version = "0.4.0"
 authors = ["Parity Technologies <admin@parity.io>"]
 description = "Substrate's programmatic testing framework."
 edition = "2021"
diff --git a/core/src/commands/execute_block.rs b/core/src/commands/execute_block.rs
index 6d5f40a2eb1..f06aad82ffa 100644
--- a/core/src/commands/execute_block.rs
+++ b/core/src/commands/execute_block.rs
@@ -19,7 +19,6 @@ use std::{fmt::Debug, str::FromStr};
 
 use parity_scale_codec::Encode;
 use sc_executor::sp_wasm_interface::HostFunctions;
-use sp_rpc::{list::ListOrValue, number::NumberOrHex};
 use sp_runtime::{
     generic::SignedBlock,
     traits::{Block as BlockT, Header as HeaderT, NumberFor},
@@ -108,18 +107,37 @@ where
     // get the block number associated with this block.
     let block_ws_uri = command.block_ws_uri();
     let rpc = ws_client(&block_ws_uri).await?;
-    let next_hash = next_hash_of::<Block>(&rpc, ext.block_hash).await?;
 
-    log::info!(target: LOG_TARGET, "fetching next block: {:?} ", next_hash);
+    let live_state = match command.state {
+        State::Live(live_state) => live_state,
+        _ => {
+            unreachable!("execute block currently only supports Live state")
+        }
+    };
 
-    let block = ChainApi::<(), Block::Hash, Block::Header, SignedBlock<Block>>::block(
-        &rpc,
-        Some(next_hash),
-    )
-    .await
-    .map_err(rpc_err_handler)?
-    .expect("header exists, block should also exist; qed")
-    .block;
+    // The block we want to *execute* at is the block passed by the user
+    let execute_at = live_state.at::<Block>()?;
+
+    let prev_block_live_state = live_state.to_prev_block_live_state::<Block>().await?;
+
+    // Get state for the prev block
+    let ext = State::Live(prev_block_live_state)
+        .to_ext::<Block, HostFns>(
+            &shared,
+            &executor,
+            None,
+            TryRuntimeFeatureCheck::Check,
+            SpecVersionCheck::Skip,
+        )
+        .await?;
+
+    // Execute the desired block on top of it
+    let block =
+        ChainApi::<(), Block::Hash, Block::Header, SignedBlock<Block>>::block(&rpc, execute_at)
+            .await
+            .map_err(rpc_err_handler)?
+            .expect("header exists, block should also exist; qed")
+            .block;
 
     // A digest item gets added when the runtime is processing the block, so we need to pop
     // the last one to be consistent with what a gossiped block would contain.
@@ -140,6 +158,7 @@ where
 
     let _ = state_machine_call_with_proof::<Block, HostFns>(
         &ext,
+        &mut Default::default(),
         &executor,
         "TryRuntime_execute_block",
         &payload,
@@ -149,35 +168,3 @@ where
 
     Ok(())
 }
-
-pub(crate) async fn next_hash_of<Block: BlockT>(
-    rpc: &substrate_rpc_client::WsClient,
-    hash: Block::Hash,
-) -> sc_cli::Result<Block::Hash>
-where
-    Block: BlockT + serde::de::DeserializeOwned,
-    Block::Header: serde::de::DeserializeOwned,
-{
-    let number = ChainApi::<(), Block::Hash, Block::Header, ()>::header(rpc, Some(hash))
-        .await
-        .map_err(rpc_err_handler)
-        .and_then(|maybe_header| maybe_header.ok_or("header_not_found").map(|h| *h.number()))?;
-
-    let next = number + sp_runtime::traits::One::one();
-
-    let next_hash = match ChainApi::<(), Block::Hash, Block::Header, ()>::block_hash(
-        rpc,
-        Some(ListOrValue::Value(NumberOrHex::Number(
-            next.try_into()
-                .map_err(|_| "failed to convert number to block number")?,
-        ))),
-    )
-    .await
-    .map_err(rpc_err_handler)?
-    {
-        ListOrValue::Value(t) => t.expect("value passed in; value comes out; qed"),
-        _ => unreachable!(),
-    };
-
-    Ok(next_hash)
-}
diff --git a/core/src/commands/fast_forward.rs b/core/src/commands/fast_forward.rs
index 6076ab03121..84f711a154f 100644
--- a/core/src/commands/fast_forward.rs
+++ b/core/src/commands/fast_forward.rs
@@ -246,6 +246,7 @@ where
         log::info!("Running migrations...");
         state_machine_call_with_proof::<Block, HostFns>(
             &ext,
+            &mut Default::default(),
             &executor,
             "TryRuntime_on_runtime_upgrade",
             command.try_state.encode().as_ref(),
diff --git a/core/src/commands/follow_chain.rs b/core/src/commands/follow_chain.rs
index 0047e064410..367760f251a 100644
--- a/core/src/commands/follow_chain.rs
+++ b/core/src/commands/follow_chain.rs
@@ -157,8 +157,10 @@ where
             .as_mut()
             .expect("state_ext either existed or was just created");
 
+        let mut overlayed_changes = Default::default();
         let result = state_machine_call_with_proof::<Block, HostFns>(
             state_ext,
+            &mut overlayed_changes,
             &executor,
             "TryRuntime_execute_block",
             (
@@ -186,9 +188,7 @@ where
             continue;
         }
 
-        let (mut changes, _, _) = result.expect("checked to be Ok; qed");
-
-        let storage_changes = changes
+        let storage_changes = overlayed_changes
             .drain_storage_changes(
                 &state_ext.backend,
                 // Note that in case a block contains a runtime upgrade, state version could
diff --git a/core/src/commands/offchain_worker.rs b/core/src/commands/offchain_worker.rs
index d2c64b4f5f7..a6853935994 100644
--- a/core/src/commands/offchain_worker.rs
+++ b/core/src/commands/offchain_worker.rs
@@ -26,7 +26,7 @@ use crate::{
     build_executor,
     commands::execute_block::next_hash_of,
     full_extensions, parse, rpc_err_handler,
-    state::{LiveState, RuntimeChecks, State},
+    state::{LiveState, RuntimeChecks, SpecVersionCheck, State, TryRuntimeFeatureCheck},
     state_machine_call, SharedParams, LOG_TARGET,
 };
 
@@ -85,14 +85,20 @@ where
         .state
         .to_ext::<Block, HostFns>(&shared, &executor, None, runtime_checks)
         .await?;
+    let block_ws_uri = command.header_ws_uri();
+    let rpc = ws_client(&block_ws_uri).await?;
 
-    let header_ws_uri = command.header_ws_uri();
+    // The block we want to *execute* at is the block passed by the user
+    let execute_at = live_state.at::<Block>()?;
 
-    let rpc = ws_client(&header_ws_uri).await?;
-    let next_hash = next_hash_of::<Block>(&rpc, ext.block_hash).await?;
-    log::info!(target: LOG_TARGET, "fetching next header: {:?} ", next_hash);
+    let prev_block_live_state = live_state.to_prev_block_live_state::<Block>().await?;
 
-    let header = ChainApi::<(), Block::Hash, Block::Header, ()>::header(&rpc, Some(next_hash))
+    // Get state for the prev block
+    let ext = State::Live(prev_block_live_state)
+        .to_ext::<Block, HostFns>(&shared, &executor, None, runtime_checks)
+        .await?;
+
+    let header = ChainApi::<(), Block::Hash, Block::Header, ()>::header(&rpc, execute_at)
         .await
         .map_err(rpc_err_handler)
         .map(|maybe_header| maybe_header.ok_or("Header does not exist"))??;
diff --git a/core/src/commands/on_runtime_upgrade.rs b/core/src/commands/on_runtime_upgrade.rs
index 21b52555c62..3d6a2de2084 100644
--- a/core/src/commands/on_runtime_upgrade.rs
+++ b/core/src/commands/on_runtime_upgrade.rs
@@ -57,7 +57,7 @@ pub struct Command {
     )]
     pub checks: UpgradeCheckSelect,
 
-    /// Whether to assume that the runtime is a relay chain runtime.
+    /// Whether to disable weight warnings, useful if the runtime is for a relay chain.
     ///
     /// This is used to adjust the behavior of weight measurement warnings.
     #[clap(long, default_value = "false", default_missing_value = "true")]
@@ -66,7 +66,11 @@ pub struct Command {
     /// Whether to enforce the new runtime `spec_version` is greater or equal to the existing
     /// `spec_version`.
     #[clap(long, default_value = "true", default_missing_value = "true")]
+
     pub check_spec_version: bool,
+    /// Whether to disable migration idempotency checks
+    #[clap(long, default_value = "false", default_missing_value = "true")]
+    pub no_idempotency_checks: bool,
 }
 
 // Runs the `on-runtime-upgrade` command.
@@ -103,15 +107,18 @@ where
         "🔬 Running TryRuntime_on_runtime_upgrade with checks: {:?}",
         command.checks
     );
-    let (_, proof, encoded_result) = state_machine_call_with_proof::<Block, HostFns>(
+    // Save the overlayed changes from the first run, so we can use them later for idempotency
+    // checks.
+    let mut overlayed_changes = Default::default();
+    let (proof, encoded_result) = state_machine_call_with_proof::<Block, HostFns>(
         &ext,
+        &mut overlayed_changes,
         &executor,
         "TryRuntime_on_runtime_upgrade",
         command.checks.encode().as_ref(),
         Default::default(), // we don't really need any extensions here.
         shared.export_proof.clone(),
     )?;
-
     let ref_time_results = encoded_result.try_into()?;
 
     // If the above call ran with checks then we need to run the call again without checks to
@@ -124,43 +131,93 @@ where
             log::info!(
                 "🔬 TryRuntime_on_runtime_upgrade succeeded! Running it again without checks for weight measurements."
             );
-            let (_, proof, encoded_result) = state_machine_call_with_proof::<Block, HostFns>(
+            let (proof, encoded_result) = state_machine_call_with_proof::<Block, HostFns>(
                 &ext,
+                &mut Default::default(),
                 &executor,
                 "TryRuntime_on_runtime_upgrade",
                 UpgradeCheckSelect::None.encode().as_ref(),
                 Default::default(), // we don't really need any extensions here.
-                shared.export_proof,
+                shared.export_proof.clone(),
             )?;
             let ref_time_results = encoded_result.try_into()?;
             (proof, ref_time_results)
         }
     };
 
+    // Check idempotency
+    let idempotency_ok = match command.no_idempotency_checks {
+        true => {
+            log::info!("ℹ Skipping idempotency check");
+            true
+        }
+        false => {
+            log::info!(
+                "🔬 Running TryRuntime_on_runtime_upgrade again to check idempotency: {:?}",
+                command.checks
+            );
+            let (oc_pre_root, _) = overlayed_changes.storage_root(&ext.backend, ext.state_version);
+            match state_machine_call_with_proof::<Block, HostFns>(
+                &ext,
+                &mut overlayed_changes,
+                &executor,
+                "TryRuntime_on_runtime_upgrade",
+                command.checks.encode().as_ref(),
+                Default::default(), // we don't really need any extensions here.
+                shared.export_proof.clone(),
+            ) {
+                Ok(_) => {
+                    // Execution was OK, check if the storage root changed.
+                    let (oc_post_root, _) =
+                        overlayed_changes.storage_root(&ext.backend, ext.state_version);
+                    if oc_pre_root != oc_post_root {
+                        log::error!("❌ Migrations are not idempotent. Selectively remove migrations from Executive until you find the culprit.");
+                        false
+                    } else {
+                        // Exeuction was OK and state root didn't change
+                        true
+                    }
+                }
+                Err(e) => {
+                    log::error!(
+                        "❌ Migrations are not idempotent, they failed during the second execution.",
+                    );
+                    log::debug!("{:?}", e);
+                    false
+                }
+            }
+        }
+    };
+
+    // Check weight safety
     let pre_root = ext.backend.root();
     let pov_safety = analyse_pov::<HashingFor<Block>>(proof, *pre_root, command.no_weight_warnings);
     let ref_time_safety = analyse_ref_time(ref_time_results, command.no_weight_warnings);
-
-    match (pov_safety, ref_time_safety, command.no_weight_warnings) {
+    let weight_ok = match (pov_safety, ref_time_safety, command.no_weight_warnings) {
         (_, _, true) => {
-            log::info!("✅ TryRuntime_on_runtime_upgrade executed without errors")
+            log::info!("ℹ Skipped checking weight safety");
+            true
         }
         (WeightSafety::ProbablySafe, WeightSafety::ProbablySafe, _) => {
             log::info!(
                 target: LOG_TARGET,
-                "✅ TryRuntime_on_runtime_upgrade executed without errors or weight safety \
-                warnings. Please note this does not guarantee a successful runtime upgrade. \
+                "✅ No weight safety issues detected. \
+                Please note this does not guarantee a successful runtime upgrade. \
                 Always test your runtime upgrade with recent state, and ensure that the weight usage \
                 of your migrations will not drastically differ between testing and actual on-chain \
                 execution."
             );
+            true
         }
         _ => {
-            log::warn!(target: LOG_TARGET, "⚠️  TryRuntime_on_runtime_upgrade executed \
-            successfully but with weight safety warnings.");
-            // Exit with a non-zero exit code to indicate that the runtime upgrade may not be safe.
-            std::process::exit(1);
+            log::error!(target: LOG_TARGET, "❌ Weight safety issues detected.");
+            false
         }
+    };
+
+    if !weight_ok || !idempotency_ok {
+        log::error!("❌ Issues detected, exiting non-zero. See logs.");
+        std::process::exit(1);
     }
 
     Ok(())
diff --git a/core/src/lib.rs b/core/src/lib.rs
index d1ba19735c3..06e7db0816b 100644
--- a/core/src/lib.rs
+++ b/core/src/lib.rs
@@ -152,23 +152,22 @@ impl TryFrom<Vec<u8>> for RefTimeInfo {
 /// Make sure [`LOG_TARGET`] is enabled in logging.
 pub(crate) fn state_machine_call_with_proof<Block: BlockT, HostFns: HostFunctions>(
     ext: &TestExternalities<HashingFor<Block>>,
+    storage_overlay: &mut OverlayedChanges<HashingFor<Block>>,
     executor: &WasmExecutor<HostFns>,
     method: &'static str,
     data: &[u8],
     mut extensions: Extensions,
     maybe_export_proof: Option<PathBuf>,
-) -> sc_cli::Result<(OverlayedChanges<HashingFor<Block>>, StorageProof, Vec<u8>)> {
-    let mut changes = Default::default();
-    let backend = ext.backend.clone();
-    let runtime_code_backend = sp_state_machine::backend::BackendRuntimeCode::new(&backend);
-    let proving_backend = TrieBackendBuilder::wrap(&backend)
+) -> sc_cli::Result<(StorageProof, Vec<u8>)> {
+    let runtime_code_backend = sp_state_machine::backend::BackendRuntimeCode::new(&ext.backend);
+    let proving_backend = TrieBackendBuilder::wrap(&ext.backend)
         .with_recorder(Default::default())
         .build();
     let runtime_code = runtime_code_backend.runtime_code()?;
 
     let encoded_result = StateMachine::new(
         &proving_backend,
-        &mut changes,
+        storage_overlay,
         executor,
         method,
         data,
@@ -210,7 +209,7 @@ pub(crate) fn state_machine_call_with_proof<Block: BlockT, HostFns: HostFunction
             })?;
     }
 
-    Ok((changes, proof, encoded_result))
+    Ok((proof, encoded_result))
 }
 
 /// Converts a [`sp_state_machine::StorageProof`] into a JSON string.
diff --git a/core/src/state.rs b/core/src/state.rs
index 449da1148ac..e01af37bc55 100644
--- a/core/src/state.rs
+++ b/core/src/state.rs
@@ -23,7 +23,7 @@ use frame_remote_externalities::{
 use parity_scale_codec::Decode;
 use sc_cli::RuntimeVersion;
 use sc_executor::{sp_wasm_interface::HostFunctions, WasmExecutor};
-use sp_api::HashT;
+use sp_api::{HashT, HeaderT};
 use sp_core::{
     hexdisplay::HexDisplay, storage::well_known_keys, traits::ReadRuntimeVersion, twox_128,
 };
@@ -31,9 +31,10 @@ use sp_runtime::{
     traits::{BlakeTwo256, Block as BlockT},
     DeserializeOwned,
 };
+use substrate_rpc_client::{ws_client, ChainApi};
 
 use crate::{
-    ensure_try_runtime, hash_of, parse,
+    ensure_try_runtime, hash_of, parse, rpc_err_handler,
     shared_parameters::{Runtime, SharedParams},
     LOG_TARGET,
 };
@@ -80,6 +81,48 @@ pub struct LiveState {
     pub child_tree: bool,
 }
 
+impl LiveState {
+    /// Return the `at` block hash as a `Hash`, if it exists.
+    pub fn at<Block: BlockT>(&self) -> sc_cli::Result<Option<<Block>::Hash>>
+    where
+        <Block::Hash as FromStr>::Err: Debug,
+    {
+        self.at
+            .clone()
+            .map(|s| hash_of::<Block>(s.as_str()))
+            .transpose()
+    }
+
+    /// Converts this `LiveState` into a `LiveState` for the previous block.
+    ///
+    /// Useful for opertations like when you want to execute a block, but also need the state of the
+    /// block *before* it.
+    pub async fn to_prev_block_live_state<Block: BlockT>(self) -> sc_cli::Result<LiveState>
+    where
+        <Block::Hash as FromStr>::Err: Debug,
+    {
+        // We want to execute the block `at`, therefore need the state of the block *before* it.
+        let at = self.at::<Block>()?;
+
+        // Get the block number requested by the user, or the current block number if they
+        // didn't specify one.
+        let rpc = ws_client(&self.uri).await?;
+        let previous_hash = ChainApi::<(), Block::Hash, Block::Header, ()>::header(&rpc, at)
+            .await
+            .map_err(rpc_err_handler)
+            .and_then(|maybe_header| {
+                maybe_header
+                    .ok_or("header_not_found")
+                    .map(|h| *h.parent_hash())
+            })?;
+
+        Ok(LiveState {
+            at: Some(hex::encode(previous_hash)),
+            ..self
+        })
+    }
+}
+
 /// The source of runtime *state* to use.
 #[derive(Debug, Clone, clap::Subcommand)]
 pub enum State {
diff --git a/core/tests/create_snapshot.rs b/core/tests/create_snapshot.rs
index a6985165af5..03534b5f128 100644
--- a/core/tests/create_snapshot.rs
+++ b/core/tests/create_snapshot.rs
@@ -25,7 +25,6 @@ use std::{
 use assert_cmd::cargo::cargo_bin;
 use frame_remote_externalities::{Builder, Mode, OfflineConfig, SnapshotConfig};
 use node_primitives::{Block, Hash};
-use regex::Regex;
 use substrate_cli_test_utils as common;
 use tokio::process::Command;
 
@@ -74,15 +73,10 @@ async fn create_snapshot_works() {
         let block_hash = common::block_hash(block_number, &ws_url).await.unwrap();
 
         // Try to create a snapshot.
-        let mut snapshot_creation = create_snapshot(&ws_url, &snap_file_path, block_hash);
+        let child = create_snapshot(&ws_url, &snap_file_path, block_hash);
+        let out = child.wait_with_output().await.unwrap();
 
-        let re = Regex::new(r".*writing snapshot of (\d+) bytes to .*").unwrap();
-        let matched =
-            common::wait_for_stream_pattern_match(snapshot_creation.stderr.take().unwrap(), re)
-                .await;
-
-        // Assert that the snapshot creation succeded.
-        assert!(matched.is_ok(), "Failed to create snapshot");
+        assert!(out.status.success());
 
         let snapshot_is_on_disk = Path::new(&snap_file_path).exists();
         assert!(snapshot_is_on_disk, "Snapshot was not written to disk");
diff --git a/core/tests/execute_block.rs b/core/tests/execute_block.rs
index 1a3173db32f..93d9b85143d 100644
--- a/core/tests/execute_block.rs
+++ b/core/tests/execute_block.rs
@@ -60,23 +60,28 @@ async fn execute_block_works() {
                 .unwrap()
         }
 
-        let block_number = 1;
+        let block_number = 3;
         let block_hash = common::block_hash(block_number, &ws_url).await.unwrap();
 
         // Try to execute the block.
         let mut block_execution = execute_block(&ws_url, block_hash);
 
         // The execute-block command is actually executing the next block.
-        let expected_output = format!(
-            r#".*Block #{} successfully executed"#,
-            block_number.saturating_add(1)
-        );
+        let expected_output = format!(r#".*Block #{} successfully executed"#, block_number);
         let re = Regex::new(expected_output.as_str()).unwrap();
         let matched =
             common::wait_for_stream_pattern_match(block_execution.stderr.take().unwrap(), re).await;
 
-        // Assert that the block-execution process has executed a block.
+        // Assert that the block-execution process has executed the expected block.
         assert!(matched.is_ok());
+
+        // Assert that the block-execution exited succesfully
+        assert!(block_execution
+            .wait_with_output()
+            .await
+            .unwrap()
+            .status
+            .success());
     })
     .await
 }
diff --git a/core/tests/on_runtime_upgrade.rs b/core/tests/on_runtime_upgrade.rs
new file mode 100644
index 00000000000..642824b91c9
--- /dev/null
+++ b/core/tests/on_runtime_upgrade.rs
@@ -0,0 +1,173 @@
+// This file is part of try-runtime-cli.
+
+// Copyright (C) Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: Apache-2.0
+
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 	http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#![cfg(unix)]
+
+mod on_runtime_upgrade {
+    use std::time::Duration;
+
+    use assert_cmd::cargo::cargo_bin;
+    use substrate_cli_test_utils as common;
+    use tokio::process::Command;
+
+    fn on_runtime_upgrade(
+        snap_path: &str,
+        runtime_path: &str,
+        extra_args: &[&str],
+    ) -> tokio::process::Child {
+        Command::new(cargo_bin("try-runtime"))
+            .stdout(std::process::Stdio::piped())
+            .stderr(std::process::Stdio::piped())
+            .arg(format!("--runtime={}", runtime_path))
+            .arg("on-runtime-upgrade")
+            .args(extra_args)
+            .args(["snap", format!("--path={}", snap_path).as_str()])
+            .kill_on_drop(true)
+            .spawn()
+            .unwrap()
+    }
+
+    #[tokio::test]
+    async fn ok_works() {
+        common::run_with_timeout(Duration::from_secs(300), async move {
+            let project_root = env!("CARGO_MANIFEST_DIR");
+            let snap_path = format!("{}/tests/snaps/rococo-bridge-hub.snap", project_root);
+            let runtime_path = format!(
+                "{}/tests/runtimes/bridge_hub_rococo_runtime_ok.compact.compressed.wasm",
+                project_root
+            );
+            dbg!(&runtime_path, &snap_path);
+            let child = on_runtime_upgrade(snap_path.as_str(), runtime_path.as_str(), &[]);
+            let out = child.wait_with_output().await.unwrap();
+            dbg!(&out);
+            assert!(out.status.success());
+        })
+        .await;
+    }
+
+    #[tokio::test]
+    async fn weight_issue_fails() {
+        common::run_with_timeout(Duration::from_secs(300), async move {
+            let project_root = env!("CARGO_MANIFEST_DIR");
+            let snap_path = format!("{}/tests/snaps/rococo-bridge-hub.snap", project_root);
+            let runtime_path = format!(
+                "{}/tests/runtimes/bridge_hub_rococo_runtime_WEIGHT_ISSUE.compact.compressed.wasm",
+                project_root
+            );
+            let child = on_runtime_upgrade(snap_path.as_str(), runtime_path.as_str(), &[]);
+            let out = child.wait_with_output().await.unwrap();
+            assert!(!out.status.success());
+        })
+        .await;
+    }
+
+    #[tokio::test]
+    async fn weight_issue_can_be_ignored() {
+        common::run_with_timeout(Duration::from_secs(300), async move {
+            let project_root = env!("CARGO_MANIFEST_DIR");
+            let snap_path = format!("{}/tests/snaps/rococo-bridge-hub.snap", project_root);
+            let runtime_path = format!(
+                "{}/tests/runtimes/bridge_hub_rococo_runtime_WEIGHT_ISSUE.compact.compressed.wasm",
+                project_root
+            );
+
+            let child = on_runtime_upgrade(
+                snap_path.as_str(),
+                runtime_path.as_str(),
+                &["--no-weight-warnings"],
+            );
+            let out = child.wait_with_output().await.unwrap();
+            assert!(out.status.success());
+        })
+        .await;
+    }
+
+    #[tokio::test]
+    async fn not_idempotent_execution_fails() {
+        common::run_with_timeout(Duration::from_secs(300), async move {
+        let project_root = env!("CARGO_MANIFEST_DIR");
+        let snap_path = format!("{}/tests/snaps/rococo-bridge-hub.snap", project_root);
+        let runtime_path = format!(
+            "{}/tests/runtimes/bridge_hub_rococo_runtime_not_idempotent_execution.compact.compressed.wasm",
+            project_root
+        );
+
+        let child = on_runtime_upgrade(snap_path.as_str(), runtime_path.as_str(), &[]);
+        let out = child.wait_with_output().await.unwrap();
+        assert!(!out.status.success());
+    })
+    .await;
+    }
+
+    #[tokio::test]
+    async fn not_idempotent_execution_issue_can_be_ignored() {
+        common::run_with_timeout(Duration::from_secs(300), async move {
+        let project_root = env!("CARGO_MANIFEST_DIR");
+        let snap_path = format!("{}/tests/snaps/rococo-bridge-hub.snap", project_root);
+        let runtime_path = format!(
+            "{}/tests/runtimes/bridge_hub_rococo_runtime_not_idempotent_execution.compact.compressed.wasm",
+            project_root
+        );
+
+        let child = on_runtime_upgrade(
+            snap_path.as_str(),
+            runtime_path.as_str(),
+            &["--no-idempotency-checks"],
+        );
+        let out = child.wait_with_output().await.unwrap();
+        assert!(out.status.success());
+    })
+    .await;
+    }
+
+    #[tokio::test]
+    async fn not_idempotent_state_root_fails() {
+        common::run_with_timeout(Duration::from_secs(300), async move {
+        let project_root = env!("CARGO_MANIFEST_DIR");
+        let snap_path = format!("{}/tests/snaps/rococo-bridge-hub.snap", project_root);
+        let runtime_path = format!(
+            "{}/tests/runtimes/bridge_hub_rococo_runtime_not_idempotent_state_root.compact.compressed.wasm",
+            project_root
+        );
+
+        let child = on_runtime_upgrade(snap_path.as_str(), runtime_path.as_str(), &[]);
+        let out = child.wait_with_output().await.unwrap();
+        assert!(!out.status.success());
+    })
+    .await;
+    }
+
+    #[tokio::test]
+    async fn not_idempotent_state_root_issue_can_be_ignored() {
+        common::run_with_timeout(Duration::from_secs(300), async move {
+        let project_root = env!("CARGO_MANIFEST_DIR");
+        let snap_path = format!("{}/tests/snaps/rococo-bridge-hub.snap", project_root);
+        let runtime_path = format!(
+            "{}/tests/runtimes/bridge_hub_rococo_runtime_not_idempotent_state_root.compact.compressed.wasm",
+            project_root
+        );
+        let child = on_runtime_upgrade(
+            snap_path.as_str(),
+            runtime_path.as_str(),
+            &["--no-idempotency-checks"],
+        );
+        let out = child.wait_with_output().await.unwrap();
+        assert!(out.status.success());
+    })
+    .await;
+    }
+}
diff --git a/core/tests/readme.md b/core/tests/readme.md
new file mode 100644
index 00000000000..3377d0ae2a1
--- /dev/null
+++ b/core/tests/readme.md
@@ -0,0 +1,11 @@
+
+# tests
+
+## ./runtimes and ./snaps
+
+A state snapshot is included in ./snaps, and some runtimes in ./runtimes for use in tests.
+
+- `bridge_hub_rococo_runtime_OK.compact.compressed.wasm` a runtime with correctly configured migrations
+- `bridge_hub_rococo_runtime_WEIGHT_ISSUE.compact.compressed.wasm` a runtime with migrations that would exceed sensible values for a parachain
+- `bridge_hub_rococo_runtime_NOT_IDEMPOTENT_EXECUTION.compact.compressed.wasm` a runtime where `try_on_runtime_upgrade` if migrations are executed for a second time
+- `bridge_hub_rococo_runtime_NOT_IDEMPOTENT_STATE_ROOT.compact.compressed.wasm` a runtime which will succeed when migrations are executed for a second time, but the state changes are not idempotent
diff --git a/core/tests/runtimes/bridge_hub_rococo_runtime_not_idempotent_execution.compact.compressed.wasm b/core/tests/runtimes/bridge_hub_rococo_runtime_not_idempotent_execution.compact.compressed.wasm
new file mode 100644
index 00000000000..835cccc0701
Binary files /dev/null and b/core/tests/runtimes/bridge_hub_rococo_runtime_not_idempotent_execution.compact.compressed.wasm differ
diff --git a/core/tests/runtimes/bridge_hub_rococo_runtime_not_idempotent_state_root.compact.compressed.wasm b/core/tests/runtimes/bridge_hub_rococo_runtime_not_idempotent_state_root.compact.compressed.wasm
new file mode 100644
index 00000000000..c965be9b033
Binary files /dev/null and b/core/tests/runtimes/bridge_hub_rococo_runtime_not_idempotent_state_root.compact.compressed.wasm differ
diff --git a/core/tests/runtimes/bridge_hub_rococo_runtime_ok.compact.compressed.wasm b/core/tests/runtimes/bridge_hub_rococo_runtime_ok.compact.compressed.wasm
new file mode 100644
index 00000000000..4ff6c81fdb0
Binary files /dev/null and b/core/tests/runtimes/bridge_hub_rococo_runtime_ok.compact.compressed.wasm differ
diff --git a/core/tests/runtimes/bridge_hub_rococo_runtime_weight_issue.compact.compressed.wasm b/core/tests/runtimes/bridge_hub_rococo_runtime_weight_issue.compact.compressed.wasm
new file mode 100644
index 00000000000..fc03a10bb52
Binary files /dev/null and b/core/tests/runtimes/bridge_hub_rococo_runtime_weight_issue.compact.compressed.wasm differ
diff --git a/core/tests/snaps/rococo-bridge-hub.snap b/core/tests/snaps/rococo-bridge-hub.snap
new file mode 100644
index 00000000000..bb49d8f068b
Binary files /dev/null and b/core/tests/snaps/rococo-bridge-hub.snap differ