From 7e0137a33a81a5196ce92e831deca5b63a619ce9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 5 Aug 2026 16:52:28 +0700 Subject: [PATCH 1/3] test(drive-abci): gate PR runs to one comprehensive chain simulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drive-abci strategy suite (92 whole-chain simulations, 261s of test CPU — 18% of the entire workspace suite) runs on every Rust PR while its multi-second simulations set the wall-clock floor of the test phase. Replace it on the PR path with a single comprehensive simulation and keep the full suite on push and nightly runs, the same safety-net pattern as the shielded phase. The new simulation packs the subsystems that compose deterministically into one 30-block run (~6s): per-block identity creation, top-ups, key additions, credit withdrawals, credit transfers, document create/replace/delete on dashpay, random contract creation, token minting, address funding from core asset locks, address transfers, identity top-ups from address balances, random core height increases with validator quorum rotation, and epoch changes with masternode payouts — with per-transition proof verification and sum-tree verification enabled. A fixed seed keeps it deterministic; a single hard-coded start identity forces contract ownership so the token contract id (and the token id the mint op signs against) is precomputable. Also fixes a latent panic in strategy-tests: IdentityTransfer(None) drew a recipient from an empty range when exactly one identity existed. Co-Authored-By: Claude Fable 5 --- .github/workflows/tests-rs-workspace.yml | 14 +- .../test_cases/comprehensive_tests.rs | 463 ++++++++++++++++++ .../tests/strategy_tests/test_cases/mod.rs | 1 + packages/strategy-tests/src/lib.rs | 5 +- 4 files changed, 481 insertions(+), 2 deletions(-) create mode 100644 packages/rs-drive-abci/tests/strategy_tests/test_cases/comprehensive_tests.rs diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index 497b1e67225..b8bc4aa35a1 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -290,9 +290,21 @@ jobs: path: lcov-shielded.info key: rs-shielded-lcov-v1-${{ steps.shielded-hash.outputs.hash }} + # On pull requests the drive-abci chain-simulation suite + # (strategy_tests, ~90 multi-second whole-chain simulations that + # dominate the test phase's wall time) is reduced to the single + # comprehensive simulation — see the doc comment on + # run_chain_comprehensive_mixed_operations_with_epoch_change_and_quorum_rotation + # for what it covers. Push and nightly runs execute the full suite, so + # a regression in an excluded simulation is caught minutes after merge + # — the same safety-net pattern as the shielded phase above. - name: Run non-shielded tests with nextest (parallel, compiles all packages) if: steps.coverage-cache.outputs.reuse == 'false' run: | + FILTER='not test(~shield)' + if [ "${{ github.event_name }}" = "pull_request" ]; then + FILTER="$FILTER and (not binary_id(=drive-abci::strategy_tests) or test(~comprehensive_mixed_operations))" + fi cargo llvm-cov nextest --no-report \ --package drive \ --package dpp \ @@ -318,7 +330,7 @@ jobs: --package keyword-search-contract \ --all-features \ --locked \ - -E 'not test(~shield)' + -E "$FILTER" env: RUST_MIN_STACK: 4194304 CARGO_PROFILE_DEV_DEBUG: "0" diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/comprehensive_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/comprehensive_tests.rs new file mode 100644 index 00000000000..54140c26377 --- /dev/null +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/comprehensive_tests.rs @@ -0,0 +1,463 @@ +#[cfg(test)] +mod tests { + use crate::execution::run_chain_for_strategy; + use crate::strategy::CoreHeightIncrease::RandomCoreHeightIncrease; + use crate::strategy::NetworkStrategy; + use dash_platform_macros::stack_size; + use dpp::dash_to_credits; + use dpp::dash_to_duffs; + use dpp::dashcore::hashes::Hash; + use dpp::dashcore::Txid; + use dpp::dashcore_rpc::dashcore_rpc_json::{ + AssetUnlockStatus, AssetUnlockStatusResult, QuorumType, + }; + use dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}; + use dpp::data_contract::accessors::v1::DataContractV1Getters; + use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; + use dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Setters; + use dpp::data_contract::document_type::random_document::{ + DocumentFieldFillSize, DocumentFieldFillType, + }; + use dpp::data_contract::document_type::v0::random_document_type::{ + FieldMinMaxBounds, FieldTypeWeights, RandomDocumentTypeParameters, + }; + use dpp::data_contract::DataContract; + use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; + use dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use dpp::state_transition::StateTransition; + use dpp::tests::json_document::json_document_to_created_contract; + use dpp::tokens::token_event::TokenEvent; + use drive_abci::config::{ + ChainLockConfig, ExecutionConfig, InstantLockConfig, PlatformConfig, PlatformTestConfig, + ValidatorSetConfig, + }; + use drive_abci::test::helpers::setup::TestPlatformBuilder; + use platform_version::version::PlatformVersion; + use rand::prelude::StdRng; + use rand::SeedableRng; + use simple_signer::signer::SimpleSigner; + use strategy_tests::frequency::Frequency; + use strategy_tests::operations::DocumentAction::DocumentActionReplaceRandom; + use strategy_tests::operations::{ + DocumentAction, DocumentOp, IdentityUpdateOp, Operation, OperationType, TokenOp, + }; + use strategy_tests::transitions::create_state_transitions_for_identities; + use strategy_tests::{IdentityInsertInfo, StartAddresses, StartIdentities, Strategy}; + + /// The one chain simulation that runs on every pull request. + /// + /// The rest of the strategy suite is excluded from the PR path by the + /// nextest filter in `.github/workflows/tests-rs-workspace.yml` (it + /// selects this test by name — keep `comprehensive_mixed_operations` in + /// the name if renaming) and runs on push and nightly instead. This test + /// therefore packs as many subsystems as compose deterministically into + /// one run: identity creation per block, top-ups, key additions, credit + /// withdrawals, credit transfers, document create/replace/delete on + /// dashpay, random contract creation, token minting, address funding + /// from core asset locks, address-to-address transfers, identity top-ups + /// from address balances, random core height increases with validator + /// quorum rotation, and two epoch changes with masternode payouts — + /// with per-transition proof verification and sum-tree verification on. + /// + /// Not covered here (nightly/push strategy tests still cover them): + /// shielded operations (separate CI phase), contested-resource voting, + /// protocol upgrades, failure injection, and masternode list mutations. + #[stack_size(4 * 1024 * 1024)] + #[test] + async fn run_chain_comprehensive_mixed_operations_with_epoch_change_and_quorum_rotation() { + let platform_version = PlatformVersion::latest(); + + // A single hard-coded start identity: contracts deploy at block 2, + // when the only identities present are the start identities, so both + // contracts below deterministically get this identity as owner. That + // is what lets the token contract's final id (and therefore the + // token id the mint operation targets) be precomputed. + let mut rng = StdRng::seed_from_u64(792); + let mut simple_signer = SimpleSigner::default(); + let (mut identity, keys) = Identity::random_identity_with_main_keys_with_private_key::< + Vec<_>, + >(3, &mut rng, platform_version) + .expect("expected a random identity"); + simple_signer.add_identity_public_keys(keys); + + // The generated main keys are all authentication-purpose; withdrawal + // and credit-transfer operations sign with a critical TRANSFER key, + // so add one explicitly (inserted identities get theirs via + // `extra_keys` below). + let (transfer_key, transfer_private_key) = + IdentityPublicKey::random_key_with_known_attributes( + 3, + &mut rng, + Purpose::TRANSFER, + SecurityLevel::CRITICAL, + KeyType::ECDSA_SECP256K1, + None, + platform_version, + ) + .expect("expected a transfer key"); + identity.add_public_key(transfer_key.clone()); + simple_signer.add_identity_public_key(transfer_key, transfer_private_key); + + let start_identities: Vec<(Identity, Option)> = + create_state_transitions_for_identities( + vec![&mut identity], + &(dash_to_duffs!(10)..=dash_to_duffs!(10)), + &simple_signer, + &mut rng, + platform_version, + ) + .await + .into_iter() + .map(|(identity, transition)| (identity, Some(transition))) + .collect(); + + let dashpay_created_contract = json_document_to_created_contract( + "tests/supporting_files/contract/dashpay/dashpay-contract-all-mutable.json", + 1, + true, + platform_version, + ) + .expect("expected to get dashpay contract from a json document"); + let dashpay_contract = dashpay_created_contract.data_contract().clone(); + + let mut token_created_contract = json_document_to_created_contract( + "tests/supporting_files/contract/basic-token/basic-token.json", + 1, + true, + platform_version, + ) + .expect("expected to get token contract from a json document"); + + let token_contract = token_created_contract.data_contract_mut(); + token_contract + .token_configuration_mut(0) + .expect("expected to get token configuration") + .distribution_rules_mut() + .set_minting_allow_choosing_destination(true); + token_contract.set_owner_id(identity.id()); + // Contracts deploy in `start_contracts` order, each bumping the + // owner's identity nonce: the dashpay contract takes nonce 1, this + // one nonce 2. With a single start identity the id set here is + // exactly the id deployment will assign, so the token op below stays + // bound to the deployed token. + token_contract.set_id(DataContract::generate_data_contract_id_v0(identity.id(), 2)); + let token_id = token_contract + .token_id(0) + .expect("expected to get token id"); + let token_op_contract = token_contract.clone(); + + let contact_request_document_type = dashpay_contract + .document_type_for_name("contactRequest") + .expect("expected a contactRequest document type") + .to_owned_document_type(); + + let operations = vec![ + Operation { + op_type: OperationType::Document(DocumentOp { + contract: dashpay_contract.clone(), + action: DocumentAction::DocumentActionInsertRandom( + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + ), + document_type: contact_request_document_type.clone(), + }), + frequency: Frequency { + times_per_block_range: 1..10, + chance_per_block: None, + }, + }, + Operation { + op_type: OperationType::Document(DocumentOp { + contract: dashpay_contract.clone(), + action: DocumentActionReplaceRandom, + document_type: contact_request_document_type.clone(), + }), + frequency: Frequency { + times_per_block_range: 1..4, + chance_per_block: Some(0.7), + }, + }, + Operation { + op_type: OperationType::Document(DocumentOp { + contract: dashpay_contract.clone(), + action: DocumentAction::DocumentActionDelete, + document_type: contact_request_document_type, + }), + frequency: Frequency { + times_per_block_range: 1..3, + chance_per_block: Some(0.7), + }, + }, + Operation { + op_type: OperationType::IdentityTopUp(dash_to_duffs!(1)..=dash_to_duffs!(1)), + frequency: Frequency { + times_per_block_range: 1..3, + chance_per_block: None, + }, + }, + Operation { + op_type: OperationType::IdentityUpdate(IdentityUpdateOp::IdentityUpdateAddKeys(2)), + frequency: Frequency { + times_per_block_range: 1..2, + chance_per_block: Some(0.5), + }, + }, + Operation { + op_type: OperationType::IdentityWithdrawal( + dash_to_credits!(0.1)..=dash_to_credits!(0.1), + ), + frequency: Frequency { + times_per_block_range: 1..2, + chance_per_block: Some(0.5), + }, + }, + Operation { + op_type: OperationType::IdentityTransfer(None), + frequency: Frequency { + times_per_block_range: 1..2, + chance_per_block: Some(0.5), + }, + }, + Operation { + op_type: OperationType::Token(TokenOp { + contract: token_op_contract, + token_id, + token_pos: 0, + use_identity_with_id: Some(identity.id()), + action: TokenEvent::Mint(1000, identity.id(), None), + }), + frequency: Frequency { + times_per_block_range: 1..2, + chance_per_block: None, + }, + }, + Operation { + op_type: OperationType::ContractCreate( + RandomDocumentTypeParameters { + new_fields_optional_count_range: 1..5, + new_fields_required_count_range: 1..5, + new_indexes_count_range: 1..3, + field_weights: FieldTypeWeights { + string_weight: 50, + float_weight: 50, + integer_weight: 50, + date_weight: 50, + boolean_weight: 20, + byte_array_weight: 70, + }, + field_bounds: FieldMinMaxBounds { + string_min_len: 1..10, + string_has_min_len_chance: 0.5, + string_max_len: 10..63, + string_has_max_len_chance: 0.5, + integer_min: 1..10, + integer_has_min_chance: 0.5, + integer_max: 10..10000, + integer_has_max_chance: 0.5, + float_min: 0.1..10.0, + float_has_min_chance: 0.5, + float_max: 10.0..1000.0, + float_has_max_chance: 0.5, + date_min: 0, + date_max: 0, + byte_array_min_len: 1..10, + byte_array_has_min_len_chance: 0.0, + byte_array_max_len: 10..255, + byte_array_has_max_len_chance: 0.0, + }, + keep_history_chance: 0.5, + documents_mutable_chance: 0.5, + documents_can_be_deleted_chance: 0.5, + }, + 1..3, + ), + frequency: Frequency { + times_per_block_range: 1..2, + chance_per_block: Some(0.3), + }, + }, + Operation { + op_type: OperationType::AddressFundingFromCoreAssetLock( + dash_to_credits!(20)..=dash_to_credits!(20), + ), + frequency: Frequency { + times_per_block_range: 1..3, + chance_per_block: None, + }, + }, + Operation { + op_type: OperationType::AddressTransfer( + dash_to_credits!(5)..=dash_to_credits!(5), + 1..=4, + Some(0.2), + None, + ), + frequency: Frequency { + times_per_block_range: 0..2, + chance_per_block: Some(0.5), + }, + }, + Operation { + op_type: OperationType::IdentityTopUpFromAddresses( + dash_to_credits!(1)..=dash_to_credits!(3), + ), + frequency: Frequency { + times_per_block_range: 0..2, + chance_per_block: Some(0.4), + }, + }, + ]; + + let strategy = NetworkStrategy { + strategy: Strategy { + start_contracts: vec![ + (dashpay_created_contract, None), + (token_created_contract, None), + ], + operations, + start_identities: StartIdentities { + hard_coded: start_identities, + ..Default::default() + }, + start_addresses: StartAddresses::default(), + identity_inserts: IdentityInsertInfo { + frequency: Frequency { + times_per_block_range: 1..6, + chance_per_block: None, + }, + start_keys: 5, + extra_keys: [( + Purpose::TRANSFER, + [(SecurityLevel::CRITICAL, vec![KeyType::ECDSA_SECP256K1])].into(), + )] + .into(), + start_balance_range: dash_to_duffs!(1)..=dash_to_duffs!(1), + }, + identity_contract_nonce_gaps: None, + signer: Some(simple_signer), + }, + total_hpmns: 120, + extra_normal_mns: 0, + validator_quorum_count: 24, + chain_lock_quorum_count: 24, + upgrading_info: None, + core_height_increase: RandomCoreHeightIncrease(Frequency { + times_per_block_range: 1..3, + chance_per_block: Some(0.5), + }), + proposer_strategy: Default::default(), + rotate_quorums: true, + failure_testing: None, + query_testing: None, + verify_state_transition_results: true, + sign_instant_locks: true, + ..Default::default() + }; + + let two_days_in_ms = 1000 * 60 * 60 * 24 * 2; + let config = PlatformConfig { + validator_set: ValidatorSetConfig { + quorum_type: QuorumType::Llmq100_67, + quorum_size: 10, + ..Default::default() + }, + chain_lock: ChainLockConfig { + quorum_type: QuorumType::Llmq100_67, + quorum_size: 10, + quorum_window: 24, + quorum_active_signers: 24, + quorum_rotation: false, + }, + instant_lock: InstantLockConfig { + quorum_type: QuorumType::Llmq100_67, + quorum_size: 10, + quorum_window: 24, + quorum_active_signers: 24, + quorum_rotation: false, + }, + execution: ExecutionConfig { + verify_sum_trees: true, + epoch_time_length_s: 1576800, + ..Default::default() + }, + block_spacing_ms: two_days_in_ms, + testing_configs: PlatformTestConfig::default_minimal_verifications(), + ..Default::default() + }; + let block_count = 30; + let mut platform = TestPlatformBuilder::new() + .with_config(config.clone()) + .build_with_mock_rpc(); + + // The withdrawal queue broadcasts asset-unlock transactions to core + // and later polls their status. Accept every broadcast and report + // every status as still unknown — that keeps the queue, broadcast, + // and rebroadcast paths exercised without simulating core-side + // confirmation (the withdrawal strategy tests cover the full + // lifecycle). + platform + .core_rpc + .expect_send_raw_transaction() + .returning(move |_| Ok(Txid::all_zeros())); + platform + .core_rpc + .expect_get_asset_unlock_statuses() + .returning(move |indices, _| { + Ok(indices + .iter() + .map(|index| AssetUnlockStatusResult { + index: *index, + status: AssetUnlockStatus::Unknown, + }) + .collect()) + }); + + let outcome = run_chain_for_strategy( + &mut platform, + block_count, + strategy, + config, + 40, + &mut None, + &mut None, + ) + .await; + + // The exact counts are deterministic for the fixed seeds above; a + // change here means block execution behavior changed and should be + // understood, not just re-pinned. + assert!( + outcome.identities.len() > 20, + "expected the simulation to create identities, got {}", + outcome.identities.len() + ); + assert_eq!(outcome.masternode_identity_balances.len(), 120); + let paid_masternodes = outcome + .masternode_identity_balances + .iter() + .filter(|(_, balance)| **balance != 0) + .count(); + assert!( + paid_masternodes > 0, + "expected epoch changes to pay proposers" + ); + + let issues = outcome + .abci_app + .platform + .drive + .grove + .visualize_verify_grovedb(None, true, false, &platform_version.drive.grove_version) + .expect("expected to be able to verify grovedb"); + assert_eq!( + issues.len(), + 0, + "issues are {}", + issues + .iter() + .map(|(hash, (a, b, c))| format!("{}: {} {} {}", hash, a, b, c)) + .collect::>() + .join(" | ") + ); + } +} diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs index 8f0537b8c14..e24e19439e0 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs @@ -1,6 +1,7 @@ mod address_tests; mod basic_tests; mod chain_lock_update_tests; +mod comprehensive_tests; mod core_height_increase; mod core_update_tests; mod data_contract_history_tests; diff --git a/packages/strategy-tests/src/lib.rs b/packages/strategy-tests/src/lib.rs index fb3f104c226..07ebfd2d0bf 100644 --- a/packages/strategy-tests/src/lib.rs +++ b/packages/strategy-tests/src/lib.rs @@ -1627,8 +1627,11 @@ impl Strategy { } else if current_identities.len() > 1 { // Handle the case where no sender, recipient, and amount are provided + // A transfer needs a distinct sender and recipient; with + // fewer than 2 identities the recipient draw below would + // panic on an empty range. let identities_count = current_identities.len(); - if identities_count == 0 { + if identities_count < 2 { break; } From 839d6c99ff7a104a8c23a38f87765ef30a3dcca8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 5 Aug 2026 17:16:18 +0700 Subject: [PATCH 2/3] ci: drop api.github.com dependency from immutable-structure check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check listed changed files via `gh pr view`, and a runner with broken connectivity to api.github.com failed the whole Rust job three times in a row before any test ran — while git fetches to github.com kept working from the same box. The pull_request checkout is the PR merge commit, whose first parent is the base-branch tip the merge was built on, so the changed-file list and base file contents now come from `git diff`/`git show` against that parent, entirely over git transport. Verified against a live merge ref that the first parent equals the base tip and the diff yields exactly the PR's files. Co-Authored-By: Claude Fable 5 --- .github/workflows/tests-rs-wallet.yml | 16 +++++++++++----- .github/workflows/tests-rs-workspace.yml | 20 +++++++++++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tests-rs-wallet.yml b/.github/workflows/tests-rs-wallet.yml index de451d633ea..683fb329ea3 100644 --- a/.github/workflows/tests-rs-wallet.yml +++ b/.github/workflows/tests-rs-wallet.yml @@ -107,10 +107,17 @@ jobs: - name: Detect immutable structure changes if: github.event_name == 'pull_request' - env: - GH_TOKEN: ${{ github.token }} run: | - CHANGED_RS=$(gh pr view ${{ github.event.pull_request.number }} --json files --jq '[.files[].path] | map(select(test("\\.rs$"))) | .[]') + # Work entirely over git transport — see the same step in + # tests-rs-workspace.yml for why `gh pr view` (api.github.com) is + # avoided here. + BASE_PARENT=$(git cat-file commit HEAD | awk '/^parent /{print $2; exit}') + if [ -z "$BASE_PARENT" ]; then + echo "::error::Could not determine the merge commit's base parent" + exit 1 + fi + git fetch --depth=1 origin "$BASE_PARENT" + CHANGED_RS=$(git diff --no-renames --name-only "$BASE_PARENT" HEAD -- '*.rs') if [ -z "$CHANGED_RS" ]; then echo "No .rs files changed — skipping" exit 0 @@ -133,10 +140,9 @@ jobs: ' } - git fetch origin ${{ github.event.pull_request.base.ref }} --depth=1 for file in $CHANGED_RS; do if [ ! -f "$file" ]; then continue; fi - BASE_CONTENT=$(git show origin/${{ github.event.pull_request.base.ref }}:"$file" 2>/dev/null || true) + BASE_CONTENT=$(git show "$BASE_PARENT":"$file" 2>/dev/null || true) if [ -z "$BASE_CONTENT" ]; then continue; fi BASE_APPEND=$(echo "$BASE_CONTENT" | extract_tagged_block "@append_only") diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index b8bc4aa35a1..a3b6fd0041d 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -159,10 +159,21 @@ jobs: - name: Detect immutable structure changes if: github.event_name == 'pull_request' - env: - GH_TOKEN: ${{ github.token }} run: | - CHANGED_RS=$(gh pr view ${{ github.event.pull_request.number }} --json files --jq '[.files[].path] | map(select(test("\\.rs$"))) | .[]') + # Work entirely over git transport: the pull_request checkout is + # the PR merge commit, whose first parent is the base-branch tip + # the merge was built on, so diffing against it yields exactly the + # PR's changes. The previous `gh pr view` call needed + # api.github.com, which has repeatedly been unreachable from a + # runner while git fetches to github.com kept working — and it + # failed the whole job before any test ran. + BASE_PARENT=$(git cat-file commit HEAD | awk '/^parent /{print $2; exit}') + if [ -z "$BASE_PARENT" ]; then + echo "::error::Could not determine the merge commit's base parent" + exit 1 + fi + git fetch --depth=1 origin "$BASE_PARENT" + CHANGED_RS=$(git diff --no-renames --name-only "$BASE_PARENT" HEAD -- '*.rs') if [ -z "$CHANGED_RS" ]; then echo "No .rs files changed — skipping" exit 0 @@ -185,10 +196,9 @@ jobs: ' } - git fetch origin ${{ github.event.pull_request.base.ref }} --depth=1 for file in $CHANGED_RS; do if [ ! -f "$file" ]; then continue; fi - BASE_CONTENT=$(git show origin/${{ github.event.pull_request.base.ref }}:"$file" 2>/dev/null || true) + BASE_CONTENT=$(git show "$BASE_PARENT":"$file" 2>/dev/null || true) if [ -z "$BASE_CONTENT" ]; then continue; fi BASE_APPEND=$(echo "$BASE_CONTENT" | extract_tagged_block "@append_only") From c1e9a00e40316600401f22f6c85c4dc8bd7241f1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 5 Aug 2026 19:32:33 +0700 Subject: [PATCH 3/3] ci: carryforward codecov flags for skipped test phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Excluding the chain-simulation suite on PRs made every PR report a spurious ~0.57% project-coverage drop (drive-abci -1.33%): base coverage comes from full-suite push runs while PRs upload gated coverage. Split coverage into three codecov flags — rust (always uploaded), rust-strategy (chain simulations, push/nightly only), and rust-shielded (shielded phase, when it runs) — with carryforward enabled, so codecov reuses the base commit's coverage for any flag a PR run doesn't upload and PR reports stay comparable. The chain simulations now run as their own phase (push/nightly only) with their own lcov, and the nextest phase uses the same gated filter on every event so the rust flag measures the same population everywhere. Carryforward also replaces the shielded coverage cache from #4293 entirely — instead of restoring a content-verified cached lcov on skip runs, the flag is simply not uploaded and codecov carries the base's forward, which deletes the content-hash, cache-restore, and cache-save steps. The complete-suite tree-hash marker now additionally requires the chain-simulation phase to have succeeded, so a PR fast-path run cannot mark its tree as fully tested for a same-tree post-merge push run. Co-Authored-By: Claude Fable 5 --- .codecov.yml | 9 ++ .github/workflows/tests-rs-workspace.yml | 186 ++++++++++------------- 2 files changed, 91 insertions(+), 104 deletions(-) diff --git a/.codecov.yml b/.codecov.yml index 2f3cfb79b6a..d1b3dee57bd 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,9 +1,18 @@ codecov: require_ci_to_pass: true +# The rust-strategy and rust-shielded suites run only on push/nightly (PRs +# skip them for speed); carryforward makes codecov reuse the base commit's +# coverage for a flag when a PR run doesn't upload it, so PR reports don't +# show a spurious project-coverage drop for tests that intentionally didn't +# run. flags: rust: carryforward: true + rust-strategy: + carryforward: true + rust-shielded: + carryforward: true ignore: - "**/test_utils.rs" diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index 361496035de..a97ef49d7c7 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -9,9 +9,10 @@ on: # Computed by the `changes` job in tests.yml (see the # "Check for shielded-relevant changes" step there for the heuristic # and its safety net). When false, the shielded test phase is skipped - # and its coverage is restored from the cache populated by runs that - # did execute it. Defaults to true so any caller that doesn't compute - # the heuristic gets the full suite. + # and no rust-shielded coverage is uploaded — codecov carries the + # base commit's rust-shielded flag forward (see .codecov.yml). + # Defaults to true so any caller that doesn't compute the heuristic + # gets the full suite. description: Whether shielded code (or anything affecting the shielded suite) changed type: boolean default: true @@ -255,7 +256,7 @@ jobs: run: | CURRENT_TREE=$(git rev-parse HEAD^{tree}) CACHED_TREE=$(cat target/lcov-tree-hash 2>/dev/null || echo "none") - if [ "$CURRENT_TREE" = "$CACHED_TREE" ] && [ -f lcov-nonshielded.info ] && [ -f lcov-shielded.info ]; then + if [ "$CURRENT_TREE" = "$CACHED_TREE" ] && [ -f lcov-nonshielded.info ]; then echo "reuse=true" >> "$GITHUB_OUTPUT" echo "Tree hash matches ($CURRENT_TREE) — reusing coverage from previous run" else @@ -267,80 +268,21 @@ jobs: # "Prune runner disk before tests", unconditionally for every job. - name: Remove stale coverage data if: steps.coverage-cache.outputs.reuse == 'false' - run: rm -f lcov.info lcov-nonshielded.info lcov-shielded.info - - # Content-address the shielded coverage cache: the key is a hash over - # the blobs of the union of every input the shielded-change detector in - # tests.yml treats as shielded-relevant (keep the keyword regex and the - # build-input list in sync with it): - # - tracked files whose PATH mentions a shielded keyword (shielded - # module files don't all mention a keyword in their content, e.g. - # error types under a shielded/ directory); - # - tracked .rs/.proto files whose CONTENT mentions a keyword - # (shielded match arms and test names in shared files); - # - the build inputs that select or compile the suite: every Cargo - # manifest and lockfile, rust-toolchain.toml, the rust setup - # action, and the Rust test workflows. - # An exact-key hit therefore proves the cached lcov was produced from - # byte-identical shielded sources and build configuration — a PR can - # never reuse coverage from a tree whose shielded inputs differ from - # its own, and any change that forces a fresh shielded run also moves - # the key (Actions cache entries are immutable, so a stale entry must - # never stay reachable under a current key). `git ls-files`/`git grep` - # only consider tracked files, so runner-local artifacts (target/) - # cannot perturb the hash. - - name: Compute shielded source content hash - id: shielded-hash - if: steps.coverage-cache.outputs.reuse == 'false' - run: | - set -eu - HASH=$( - { - git ls-files | grep -iE 'shield|orchard|halo2' || true - git grep -ilE 'shield|orchard|halo2' -- '*.rs' '*.proto' || true - git ls-files | grep -E '(^|/)Cargo\.(toml|lock)$' || true - echo rust-toolchain.toml - git ls-files -- .github/actions/rust .github/workflows/tests.yml .github/workflows/tests-rs-workspace.yml - } \ - | LC_ALL=C sort -u \ - | tr '\n' '\0' \ - | xargs -0 git ls-files -s -- \ - | git hash-object --stdin - ) - echo "hash=$HASH" >> "$GITHUB_OUTPUT" - echo "Shielded source content hash: $HASH" - - # When the PR didn't touch anything shielded-relevant, reuse the - # shielded coverage produced by an earlier run of identical shielded - # sources instead of paying ~4-5 minutes of halo2 proving per PR. In - # practice the hit comes from the entry saved by a post-merge push run - # on the base branch. Exact key match only — no restore-keys prefix - # fallback — so a hit is a content-verified reuse. A miss (evicted - # cache, shielded sources newer than any prior full run) simply falls - # back to running the shielded suite, which then repopulates the cache. - - name: Restore shielded coverage from cache - id: shielded-cache - if: steps.coverage-cache.outputs.reuse == 'false' && inputs.shielded-changed == false - uses: actions/cache/restore@v4 - with: - path: lcov-shielded.info - key: rs-shielded-lcov-v1-${{ steps.shielded-hash.outputs.hash }} + run: rm -f lcov.info lcov-nonshielded.info lcov-strategy.info lcov-shielded.info - # On pull requests the drive-abci chain-simulation suite - # (strategy_tests, ~90 multi-second whole-chain simulations that - # dominate the test phase's wall time) is reduced to the single - # comprehensive simulation — see the doc comment on + # The drive-abci chain-simulation suite (strategy_tests, ~90 + # multi-second whole-chain simulations that dominate the test phase's + # wall time) is reduced here to the single comprehensive simulation — + # see the doc comment on # run_chain_comprehensive_mixed_operations_with_epoch_change_and_quorum_rotation - # for what it covers. Push and nightly runs execute the full suite, so - # a regression in an excluded simulation is caught minutes after merge - # — the same safety-net pattern as the shielded phase above. + # for what it covers. The remaining simulations run in their own phase + # below on push and nightly only, so a regression in one of them is + # caught minutes after merge — the same safety-net pattern as the + # shielded phase. Keeping this phase's filter identical across events + # keeps the `rust` codecov flag comparable between PR and push runs. - name: Run non-shielded tests with nextest (parallel, compiles all packages) if: steps.coverage-cache.outputs.reuse == 'false' run: | - FILTER='not test(~shield)' - if [ "${{ github.event_name }}" = "pull_request" ]; then - FILTER="$FILTER and (not binary_id(=drive-abci::strategy_tests) or test(~comprehensive_mixed_operations))" - fi cargo llvm-cov nextest --no-report \ --package drive \ --package dpp \ @@ -366,32 +308,54 @@ jobs: --package keyword-search-contract \ --all-features \ --locked \ - -E "$FILTER" + -E 'not test(~shield) and (not binary_id(=drive-abci::strategy_tests) or test(~comprehensive_mixed_operations))' env: RUST_MIN_STACK: 4194304 CARGO_PROFILE_DEV_DEBUG: "0" CARGO_PROFILE_DEV_CODEGEN_UNITS: "256" - # Report the non-shielded phase on its own, then drop its profraw files - # so the shielded report below contains only shielded execution. The - # two lcov files are uploaded together and codecov merges them, so the - # combined coverage is the same as the old single-report flow — but the - # shielded half is now separately cacheable. + # Each phase gets its own lcov file (upload steps below tag each with + # its codecov flag), so profraw files are dropped between phases to + # keep the reports disjoint. - name: Generate non-shielded coverage report if: steps.coverage-cache.outputs.reuse == 'false' run: | cargo llvm-cov report --lcov --output-path lcov-nonshielded.info cargo llvm-cov clean --profraw-only - # Runs when shielded-relevant code changed, and also when nothing - # shielded changed but no content-verified cached coverage could be - # restored. (A skipped restore step yields an empty cache-hit output, - # which correctly reads as "no cache" here.) + # The chain simulations excluded from the phase above. PR runs skip + # them (and upload no rust-strategy coverage — codecov carries the base + # commit's forward); push, nightly, and dispatch runs execute them all. + - name: Run full chain-simulation suite (push and nightly only) + id: strategy-tests + if: >- + steps.coverage-cache.outputs.reuse == 'false' + && github.event_name != 'pull_request' + run: | + cargo llvm-cov nextest --no-report \ + --package drive-abci \ + --all-features \ + --locked \ + -E 'binary_id(=drive-abci::strategy_tests) and not test(~comprehensive_mixed_operations) and not test(~shield)' + env: + RUST_MIN_STACK: 4194304 + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_DEV_CODEGEN_UNITS: "256" + + - name: Generate chain-simulation coverage report + if: steps.strategy-tests.outcome == 'success' + run: | + cargo llvm-cov report --lcov --output-path lcov-strategy.info + cargo llvm-cov clean --profraw-only + + # When nothing shielded-relevant changed, the phase is skipped and no + # rust-shielded coverage is uploaded — codecov carries the base + # commit's forward (see .codecov.yml). - name: Run shielded tests with cargo test (shared process for VK reuse) id: shielded-tests if: >- steps.coverage-cache.outputs.reuse == 'false' - && (inputs.shielded-changed || steps.shielded-cache.outputs.cache-hit != 'true') + && inputs.shielded-changed run: | cargo llvm-cov test --no-report \ --package dpp \ @@ -410,25 +374,17 @@ jobs: if: steps.shielded-tests.outcome == 'success' run: cargo llvm-cov report --lcov --output-path lcov-shielded.info - # Saving under the content hash means a duplicate save (same shielded - # sources already cached) is a harmless no-op warning. - - name: Save shielded coverage to cache - if: steps.shielded-tests.outcome == 'success' - uses: actions/cache/save@v4 - with: - path: lcov-shielded.info - key: rs-shielded-lcov-v1-${{ steps.shielded-hash.outputs.hash }} - # The marker means "this exact tree passed the COMPLETE suite", so it - # is written only when the shielded phase actually ran and succeeded. - # A fast-path run (cache-restored shielded coverage) must not write it: - # the post-merge push commonly has the same tree as the PR merge ref, - # and on the same runner the marker would let the reuse check skip the - # push run's shielded suite — the safety net for shared-code changes - # the detector heuristic misses. + # is written only when both skippable phases (chain simulations and + # shielded) actually ran and succeeded. A PR fast-path run must not + # write it: the post-merge push commonly has the same tree as the PR + # merge ref, and on the same runner the marker would let the reuse + # check skip the push run's full phases — the safety net for changes + # the PR-path gating misses. - name: Record coverage tree hash if: >- steps.coverage-cache.outputs.reuse == 'false' + && steps.strategy-tests.outcome == 'success' && steps.shielded-tests.outcome == 'success' run: git rev-parse HEAD^{tree} > target/lcov-tree-hash @@ -439,18 +395,40 @@ jobs: rm -rf ~/.gnupg/public-keys.d/*.lock 2>/dev/null || true rm -rf ~/.gnupg/.#* 2>/dev/null || true - # Codecov merges the two report files into one combined coverage view; - # on runs that skipped the shielded phase, lcov-shielded.info is the - # cache-restored report from the last run that executed it. + # One upload per codecov flag. A phase that didn't run produces no file + # and uploads nothing — its flag is carried forward from the base + # commit by codecov (see .codecov.yml), so PR reports don't show a + # spurious coverage drop for suites that intentionally didn't run. + # File-existence gating (rather than step-outcome gating) also covers + # the tree-hash reuse path, where the files persist from the previous + # run of the identical tree. - name: Upload coverage - if: always() + if: always() && hashFiles('lcov-nonshielded.info') != '' uses: codecov/codecov-action@v6 with: - files: lcov-nonshielded.info,lcov-shielded.info + files: lcov-nonshielded.info flags: rust token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: false + - name: Upload chain-simulation coverage + if: always() && hashFiles('lcov-strategy.info') != '' + uses: codecov/codecov-action@v6 + with: + files: lcov-strategy.info + flags: rust-strategy + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + + - name: Upload shielded coverage + if: always() && hashFiles('lcov-shielded.info') != '' + uses: codecov/codecov-action@v6 + with: + files: lcov-shielded.info + flags: rust-shielded + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + # Keep the coverage-instrumented build (target/llvm-cov-target) between # runs so the test step's compile stays incremental — a full wipe forces # a ~2.5 min cold rebuild of ~700 crates every job. Only per-run profile