diff --git a/.github/package-filters/rs-packages-direct.yml b/.github/package-filters/rs-packages-direct.yml index 52d9bcbd5b5..27c918e0400 100644 --- a/.github/package-filters/rs-packages-direct.yml +++ b/.github/package-filters/rs-packages-direct.yml @@ -123,6 +123,9 @@ wasm-sdk: platform-wallet: - packages/rs-platform-wallet/** +platform-wallet-storage: + - packages/rs-platform-wallet-storage/** + platform-wallet-ffi: - packages/rs-platform-wallet-ffi/** diff --git a/.github/package-filters/rs-packages-no-workflows.yml b/.github/package-filters/rs-packages-no-workflows.yml index f83f17ca471..4ba416f3192 100644 --- a/.github/package-filters/rs-packages-no-workflows.yml +++ b/.github/package-filters/rs-packages-no-workflows.yml @@ -147,6 +147,10 @@ platform-wallet: &platform_wallet - *platform_encryption - *sdk +platform-wallet-storage: + - packages/rs-platform-wallet-storage/** + - *platform_wallet + platform-wallet-ffi: &platform_wallet_ffi - packages/rs-platform-wallet-ffi/** - *platform_wallet diff --git a/.github/package-filters/rs-packages.yml b/.github/package-filters/rs-packages.yml index 1b1be8f1abc..9c9e8c51ea4 100644 --- a/.github/package-filters/rs-packages.yml +++ b/.github/package-filters/rs-packages.yml @@ -174,6 +174,11 @@ platform-wallet: &platform_wallet - *platform_encryption - *sdk +platform-wallet-storage: + - .github/workflows/tests* + - packages/rs-platform-wallet-storage/** + - *platform_wallet + platform-wallet-ffi: &platform_wallet_ffi - .github/workflows/tests* - packages/rs-platform-wallet-ffi/** diff --git a/.github/scripts/check-wallet-closure.py b/.github/scripts/check-wallet-closure.py new file mode 100644 index 00000000000..aa5509139ec --- /dev/null +++ b/.github/scripts/check-wallet-closure.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Verify the wallet reverse-dependency closure. + +The wallet-only CI fast path (.github/workflows/tests-rs-wallet.yml) compiles +and tests only the wallet crates plus their known dependents. That is sound +only while the set of workspace crates depending (transitively) on the wallet +crates is exactly EXPECTED_DEPENDENTS below. + +This script runs on BOTH Rust CI paths: on the full workspace path it fails +the PR that introduces a new dependent (the moment the invariant breaks), and +on the wallet fast path it protects against a stale scoped package list. + +On failure: extend the scoped --package lists in +.github/workflows/tests-rs-wallet.yml, add the new dependent to +EXPECTED_DEPENDENTS here, and reconsider whether the fast path is still sound. +""" + +import json +import subprocess +import sys + +WALLET_CRATES = {"platform-wallet", "platform-wallet-ffi", "platform-wallet-storage"} +EXPECTED_DEPENDENTS = {"rs-unified-sdk-ffi"} + + +def main() -> int: + meta = json.loads( + subprocess.check_output( + ["cargo", "metadata", "--format-version", "1", "--no-deps", "--locked"] + ) + ) + deps = {p["name"]: {d["name"] for d in p["dependencies"]} for p in meta["packages"]} + + # Transitive closure of workspace crates that depend on a wallet crate + reaches_wallet = set(WALLET_CRATES) + changed = True + while changed: + changed = False + for name, d in deps.items(): + if name not in reaches_wallet and d & reaches_wallet: + reaches_wallet.add(name) + changed = True + + dependents = reaches_wallet - WALLET_CRATES + if dependents != EXPECTED_DEPENDENTS: + print( + f"Wallet reverse-dependency closure changed: " + f"expected {sorted(EXPECTED_DEPENDENTS)}, got {sorted(dependents)}" + ) + print( + "Update the scoped --package lists in " + ".github/workflows/tests-rs-wallet.yml and EXPECTED_DEPENDENTS in " + ".github/scripts/check-wallet-closure.py to cover the new dependents." + ) + return 1 + + print(f"Closure OK: only {sorted(EXPECTED_DEPENDENTS)} depends on the wallet crates") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/tests-rs-wallet.yml b/.github/workflows/tests-rs-wallet.yml new file mode 100644 index 00000000000..20cf5375660 --- /dev/null +++ b/.github/workflows/tests-rs-wallet.yml @@ -0,0 +1,213 @@ +# Fast path for same-repo PRs that touch ONLY the platform-wallet crates. +# +# `tests.yml`'s `changes` job sets `rs-scope=wallet` when the run is a +# same-repo pull request and every changed file lives under one of the wallet +# crate directories (Swift sources are ignored — they are not part of the +# Rust workspace). Fork PRs and push/schedule runs always take the full +# `tests-rs-workspace.yml` path. In the wallet case this workflow runs in +# place of the full one, compiling and testing only the wallet crates plus +# their sole in-workspace dependent (`rs-unified-sdk-ffi`) instead of the +# whole workspace. +# +# Safety: nothing in the workspace depends on the wallet crates except +# `rs-unified-sdk-ffi` — an invariant enforced by the "Verify wallet +# reverse-dependency closure" step below, so a new dependent added elsewhere +# fails this job until the scoped package lists are updated. Scoped clippy +# compiles that dependent, preserving the downstream compile check the full +# `clippy --workspace` provides. Formatting, unused-dep, immutable-structure +# checks, and wallet-scoped doctests mirror the full job so nothing merges +# through the fast path that the full path would have rejected. +# +# Coverage upload is intentionally omitted (codecov never gates the merge — +# `fail_ci_if_error: false`). Known trade-offs: no Ubuntu backup jobs exist +# here (if `UBUNTU_BACKUP_ENABLED` is turned on, wallet PRs still depend on +# the mac runner being online), and the scoped `-p` builds feature-unify +# shared deps differently than `--workspace` builds, so the shared target/ +# carries an extra artifact flavor. +on: + workflow_call: + inputs: + doctests-changed: + description: Whether doc comments with code examples have changed + type: boolean + default: false + +jobs: + test-mac: + name: Wallet tests (macOS) + runs-on: [self-hosted, macOS, ARM64] + if: >- + github.event_name != 'pull_request' + || github.event.pull_request.head.repo.full_name == github.repository + || github.event.pull_request.head.repo.owner.login == 'thepastaclaw' + # Matches the full workspace job: after a disk prune deletes target/, the + # scoped build still cold-compiles ~77% of the workspace dependency graph + # (dpp, dash-sdk, drive, dashcore, the orchard/halo2 stack) in two profiles. + timeout-minutes: 30 + steps: + - name: Check out repo + uses: actions/checkout@v4 + with: + clean: false + + - name: Prune macOS runner disk before tests + run: | + for path in ../target-backup-before-*-clean-* target/llvm-cov-target; do + if [ -e "$path" ]; then + echo "Removing stale runner artifact: $path" + rm -rf "$path" + fi + done + + TARGET_MAX_MB=120000 + MIN_FREE_MB=60000 + SIZE=$(du -sm target 2>/dev/null | awk '{print $1}' || echo 0) + FREE=$(df -m . | awk 'NR == 2 {print $4}') + SIZE=${SIZE:-0} + FREE=${FREE:-0} + + echo "target/ size: ${SIZE}MB" + echo "available disk: ${FREE}MB" + if [ "$SIZE" -gt "$TARGET_MAX_MB" ] || [ "$FREE" -lt "$MIN_FREE_MB" ]; then + echo "target/ exceeds ${TARGET_MAX_MB}MB or disk is below ${MIN_FREE_MB}MB free; removing target/" + rm -rf target + fi + + - name: Install build dependencies + run: | + echo "/opt/homebrew/bin" >> $GITHUB_PATH + echo "/opt/homebrew/opt/llvm/bin" >> $GITHUB_PATH + brew list cmake &>/dev/null || brew install cmake + brew list gmp &>/dev/null || brew install gmp + brew list llvm &>/dev/null || brew install llvm + + - name: Setup Rust + uses: ./.github/actions/rust + with: + cache: false + components: rustfmt, clippy + + # Enforce the invariant the scoped --package lists below rely on: the + # only workspace crate depending (transitively) on the wallet crates is + # rs-unified-sdk-ffi. The same check runs on the full workspace path, + # which is what catches the PR that introduces a new dependent (that PR + # necessarily touches a non-wallet crate and takes the full path); this + # copy protects against a stale scoped package list. + - name: Verify wallet reverse-dependency closure + run: python3 .github/scripts/check-wallet-closure.py + + - name: Check formatting + run: cargo fmt --check --all + + - name: Find unused dependencies + run: | + cargo install cargo-machete 2>/dev/null || true + cargo machete + + - 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$"))) | .[]') + if [ -z "$CHANGED_RS" ]; then + echo "No .rs files changed — skipping" + exit 0 + fi + + # Brace-aware extractor: captures from @tag through the matching closing brace + extract_tagged_block() { + local tag="$1" + awk -v tag="$tag" ' + $0 ~ tag { found=1; depth=0 } + found { + for (i=1; i<=length($0); i++) { + c = substr($0,i,1) + if (c == "{") depth++ + if (c == "}") depth-- + } + print + if (found && depth <= 0 && index($0, "}")) { found=0 } + } + ' + } + + 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) + if [ -z "$BASE_CONTENT" ]; then continue; fi + + BASE_APPEND=$(echo "$BASE_CONTENT" | extract_tagged_block "@append_only") + PR_APPEND=$(extract_tagged_block "@append_only" < "$file") + BASE_IMMUTABLE=$(echo "$BASE_CONTENT" | extract_tagged_block "@immutable") + PR_IMMUTABLE=$(extract_tagged_block "@immutable" < "$file") + + if [ -n "$BASE_APPEND" ]; then + # Check for deletions, ignoring comment-only lines and the tag itself + DELETIONS=$(diff <(echo "$BASE_APPEND") <(echo "$PR_APPEND") | grep "^<" | grep -v "@append_only" | grep -v "^< *$" | grep -v "^< *///" | grep -v "^< *//" || true) + if [ -n "$DELETIONS" ]; then + echo "Deletions detected in @append_only structures in $file" + echo "$DELETIONS" + exit 1 + fi + fi + if [ -n "$BASE_IMMUTABLE" ]; then + CHANGES=$(diff <(echo "$BASE_IMMUTABLE") <(echo "$PR_IMMUTABLE") | grep -E "^[<>]" | grep -v "^[<>] *$" | grep -v "^[<>] *///" | grep -v "^[<>] *//" | grep -v "@immutable" || true) + if [ -n "$CHANGES" ]; then + echo "Code changes detected in @immutable structures in $file" + echo "$CHANGES" + exit 1 + fi + fi + done + echo "No immutable/append_only structure violations found" + + # Scoped clippy over the wallet crates plus `rs-unified-sdk-ffi`, the only + # in-workspace crate that depends on them — this preserves the downstream + # compile check the full `clippy --workspace` would otherwise provide. + - name: Clippy lints (wallet crates + dependents) + run: | + cargo clippy \ + --package platform-wallet \ + --package platform-wallet-storage \ + --package platform-wallet-ffi \ + --package rs-unified-sdk-ffi \ + --all-features \ + --locked \ + -- --no-deps -D warnings + + # Mirrors the workspace job's non-shielded step for the wallet crates: + # same package subset, same `not test(~shield)` filter (shielded wallet + # tests are not run there either). + - name: Run wallet tests (non-shielded) + run: | + cargo nextest run \ + --package platform-wallet \ + --package platform-wallet-storage \ + --package platform-wallet-ffi \ + --all-features \ + --locked \ + -E 'not test(~shield)' + env: + RUST_MIN_STACK: 4194304 + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_DEV_CODEGEN_UNITS: "256" + + # Wallet-only PRs can only change doc examples in the wallet crates, so + # scoped doctests are equivalent to the full job's `--workspace --doc` + # run for this scope. (The standalone rs-doctests job in tests.yml is + # only an Ubuntu backup gated on UBUNTU_BACKUP_ENABLED.) + - name: Run doctests (wallet crates) + if: ${{ inputs.doctests-changed }} + run: | + cargo test --doc \ + --package platform-wallet \ + --package platform-wallet-storage \ + --package platform-wallet-ffi \ + --all-features \ + --locked + env: + RUST_MIN_STACK: 4194304 + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_DEV_CODEGEN_UNITS: "256" diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index b62f70f986a..29bd4839d00 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -65,6 +65,13 @@ jobs: - name: Check formatting run: cargo fmt --check --all + # Fails the PR that introduces a new workspace dependent of the wallet + # crates, keeping the wallet-only fast path (tests-rs-wallet.yml) sound: + # such a PR always takes this full path, so the check must live here to + # fire at the moment the invariant breaks. + - name: Verify wallet reverse-dependency closure + run: python3 .github/scripts/check-wallet-closure.py + - name: Clippy lints run: | cargo clippy \ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b5971dcfbd8..ea9465ef8f5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -46,6 +46,7 @@ jobs: js-packages-direct: ${{ steps.override.outputs.js-packages-direct || steps.filter-js-direct.outputs.changes }} rs-packages: ${{ steps.override.outputs.rs-packages || steps.filter-rs.outputs.changes }} rs-workflows-changed: ${{ steps.filter-rs-workflows.outputs.rs-workflows }} + rs-scope: ${{ steps.rs-scope.outputs.scope }} doctests-changed: ${{ steps.override.outputs.doctests-changed || steps.filter-doctests.outputs.doctests-changed }} swift-sdk-changed: ${{ steps.override.outputs.swift-sdk-changed || steps.filter-swift-sdk.outputs.swift-sdk-changed }} version-changed: ${{ steps.override.outputs.version-changed || steps.filter-version.outputs.version-changed }} @@ -80,8 +81,10 @@ jobs: filters: | rs-workflows: - .github/workflows/tests-rs-workspace.yml + - .github/workflows/tests-rs-wallet.yml - .github/workflows/tests-rs-doctests.yml - .github/workflows/tests.yml + - .github/scripts/check-wallet-closure.py - name: Check for platform version change id: filter-version @@ -113,6 +116,67 @@ jobs: echo "doctests-changed=false" >> "$GITHUB_OUTPUT" echo "No doctest changes — skipping doctests" + - name: Determine Rust test scope (wallet-only fast path) + id: rs-scope + if: ${{ github.event_name != 'workflow_dispatch' }} + run: | + # The fast path applies only to same-repo pull requests. Fork PRs + # must take the full workspace path (whose Ubuntu backup jobs cover + # them when UBUNTU_BACKUP_ENABLED is set, while the wallet + # workflow's only job skips fork PRs). Push and schedule runs have + # no reliable base SHA — scope computed from the last commit alone + # could silently downgrade the nightly / post-merge full runs. + if [ "${{ github.event_name }}" != "pull_request" ]; then + echo "scope=full" >> "$GITHUB_OUTPUT" + echo "Not a pull request — using full Rust workspace tests" + exit 0 + fi + if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ] \ + && [ "${{ github.event.pull_request.head.repo.owner.login }}" != "thepastaclaw" ]; then + echo "scope=full" >> "$GITHUB_OUTPUT" + echo "Fork pull request — using full Rust workspace tests" + exit 0 + fi + + # --no-renames: a file moved out of another crate into a wallet + # crate must still count as a change to the source crate. + if ! CHANGED=$(git diff --no-renames --name-only "${{ github.event.pull_request.base.sha }}"...HEAD); then + echo "scope=full" >> "$GITHUB_OUTPUT" + echo "Could not diff against the PR base — using full Rust workspace tests" + exit 0 + fi + if [ -z "$CHANGED" ]; then + echo "scope=full" >> "$GITHUB_OUTPUT" + echo "No changed files detected — using full Rust workspace tests" + exit 0 + fi + + has_wallet=false + non_wallet=false + while IFS= read -r f; do + [ -z "$f" ] && continue + case "$f" in + packages/rs-platform-wallet/*|packages/rs-platform-wallet-ffi/*|packages/rs-platform-wallet-storage/*) + has_wallet=true ;; + packages/swift-sdk/*) + # Swift sources are not part of the Rust workspace and cannot + # affect `cargo test`, so they don't force the full suite. + : ;; + *) + # Anything else — another Rust crate, Cargo.lock, root config, + # docs, workflows — conservatively forces the full workspace run. + non_wallet=true ;; + esac + done <<< "$CHANGED" + + if [ "$has_wallet" = true ] && [ "$non_wallet" = false ]; then + echo "scope=wallet" >> "$GITHUB_OUTPUT" + echo "Only wallet crates changed — using scoped Rust wallet tests" + else + echo "scope=full" >> "$GITHUB_OUTPUT" + echo "Non-wallet Rust changes present — using full Rust workspace tests" + fi + - name: Check for Swift SDK changes id: filter-swift-sdk if: ${{ github.event_name != 'workflow_dispatch' }} @@ -145,6 +209,7 @@ jobs: - packages/rs-platform-versioning/** - packages/rs-platform-wallet/** - packages/rs-platform-wallet-ffi/** + - packages/rs-platform-wallet-storage/** - packages/rs-sdk/** - packages/rs-sdk-ffi/** - packages/rs-sdk-trusted-context-provider/** @@ -241,12 +306,25 @@ jobs: name: Rust workspace tests needs: - changes - if: ${{ needs.changes.outputs.rs-packages != '[]' || needs.changes.outputs.rs-workflows-changed == 'true' }} + if: ${{ (needs.changes.outputs.rs-packages != '[]' || needs.changes.outputs.rs-workflows-changed == 'true') && needs.changes.outputs.rs-scope != 'wallet' }} secrets: inherit uses: ./.github/workflows/tests-rs-workspace.yml with: doctests-changed: ${{ needs.changes.outputs.doctests-changed == 'true' }} + # Fast path: only wallet crates changed, so run the scoped wallet suite + # instead of the full workspace job above (the two are mutually exclusive + # via `rs-scope`). + rs-wallet-tests: + name: Rust wallet tests + needs: + - changes + if: ${{ needs.changes.outputs.rs-scope == 'wallet' }} + secrets: inherit + uses: ./.github/workflows/tests-rs-wallet.yml + with: + doctests-changed: ${{ needs.changes.outputs.doctests-changed == 'true' }} + rs-doctests: name: Rust doctests needs: @@ -260,7 +338,11 @@ jobs: needs: - changes - rs-workspace-tests - if: ${{ always() && needs.changes.outputs.swift-sdk-changed == 'true' && needs.changes.result != 'failure' && needs.rs-workspace-tests.result != 'failure' }} + - rs-wallet-tests + # At most one of the two Rust jobs runs (none for e.g. Swift-only PRs); + # skipped jobs report result 'skipped', not 'failure', so this only + # blocks on a Rust job that actually ran and failed. + if: ${{ always() && needs.changes.outputs.swift-sdk-changed == 'true' && needs.changes.result != 'failure' && needs.rs-workspace-tests.result != 'failure' && needs.rs-wallet-tests.result != 'failure' }} secrets: inherit uses: ./.github/workflows/swift-sdk-build.yml