diff --git a/.claude/skills/pr-description/SKILL.md b/.claude/skills/pr-description/SKILL.md
index bd7a072f40e..52a19f9dd0c 100644
--- a/.claude/skills/pr-description/SKILL.md
+++ b/.claude/skills/pr-description/SKILL.md
@@ -13,7 +13,8 @@ Generate a pull request title and description for the current branch using the p
1. Determine the base branch:
- Use the argument if provided
- Otherwise, auto-detect: `git remote set-head origin --auto >/dev/null 2>&1 && git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/||'`
- - Fall back to `v4.0-dev` if the command above fails
+ - If that fails, use the repository's default branch on GitHub: `gh repo view --json defaultBranchRef -q .defaultBranchRef.name`
+ - If both fail, ask the user rather than guessing a version branch
2. Gather context by running these git commands:
- `git log --oneline $(git merge-base HEAD )..HEAD` — all commits on this branch
@@ -25,19 +26,30 @@ Generate a pull request title and description for the current branch using the p
- What specific code changes were made
- Whether there are breaking changes
- What tests were added or modified
+ - What value the change adds, and for whom
+ - What could go wrong: consensus impact, behaviour users could notice, slow or flaky tests
4. Output a suggested PR title using conventional commits format:
- - Scopes: `sdk`, `drive`, `dpp`, `dapi`, `dashmate`, `wasm-dpp`, `wasm-sdk`, `platform`
- - Types: `feat`, `fix`, `refactor`, `chore`, `docs`, `test`, `build`
+ - Types and scopes: use only the `types:` and `scopes:` lists in `.github/workflows/pr.yml`. The PR title check rejects anything else, so read them from that file rather than from memory
+ - The scope is optional: leave it out (e.g. `chore: ...`) when the change spans several packages or no listed scope fits. Never invent a scope
+ - The subject must not start with an uppercase letter
- Add `!` after the type for breaking changes (e.g. `feat!:`)
- Format: **Suggested title:** `type(scope): description`
-5. Fill in this PR template (preserve all HTML comments exactly as shown):
+5. Fill in this PR template (preserve all HTML comments exactly as shown). The `## Basic explanation` section always comes first; the sections after it follow `.github/PULL_REQUEST_TEMPLATE.md`. If that file differs from the copy below, follow the file:
```markdown
+## Basic explanation
+
+**What this does:**
+
+**Value:**
+
+**Risks:**
+
## Issue being fixed or feature implemented
@@ -69,6 +81,7 @@ Generate a pull request title and description for the current branch using the p
- [ ] I have added or updated relevant unit/integration/functional/e2e tests
- [ ] I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
- [ ] I have made corresponding changes to the documentation if needed
+- [ ] If I added or changed GroveDB structure, I described it in the area's `structure.rs`, regenerated `grovedb-structure.json`, and checked the structure viewer link posted on this pull request
**For repository code-owners and collaborators only**
- [ ] I have assigned this pull request to a milestone
@@ -80,6 +93,7 @@ Output the entire PR description (title + body) as a single raw Markdown code bl
## Guidelines
+- Always open with `## Basic explanation`: three short paragraphs (what it does, value, risks) that someone outside this code can follow. For a security fix, keep it neutral: describe what the fix does, not how the bug could be exploited
- Keep the description **concise** — avoid walls of text. Prefer short bullet points over paragraphs
- Be specific — reference file paths, struct/function names, and types
- For "How Has This Been Tested?", check `git diff` for new `*test*`, `*spec*` files. Briefly describe what tests cover (1 line per test file), not every individual test case
diff --git a/.editorconfig b/.editorconfig
index 2e9dc07ba04..35057547424 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -11,6 +11,10 @@ end_of_line = lf
[*.rs]
indent_size = 4
+# Swift and Kotlin follow their languages' 4-space convention.
+[*.{swift,kt,kts}]
+indent_size = 4
+
# Preserve the existing indentation of the Swift SDK Python scripts.
[packages/swift-sdk/scripts/*.py]
indent_size = 4
diff --git a/.github/NPM_RUNNER.md b/.github/NPM_RUNNER.md
new file mode 100644
index 00000000000..893eb82ecfb
--- /dev/null
+++ b/.github/NPM_RUNNER.md
@@ -0,0 +1,103 @@
+# NPM release runners
+
+NPM release compilation uses a fresh single-job runner with a unique
+`platform-release---npm` label in the `platform-release-builds`
+runner group. Kotlin releases use the same lifecycle with a `-kotlin` label. Publishing
+continues on GitHub-hosted Ubuntu with OIDC; the builder receives no publishing
+credentials. The `npm-release-build` action is shared by releases and image
+validation so both compile and pack with the same setup.
+
+## Image contract
+
+`.github/runner-requirements.json` pins the complete Linux image requirements and
+recipe commit. The job runs `ci-image-contract verify` before compiling. The
+runner must have `/opt/client-codegen` matching `packages/dapi-grpc/codegen.json`.
+Its protobuf 3.18.1 compiler is intentionally separate from Rust's protoc 32.0.
+TypeScript generation comes from the workspace's pinned `ts-protoc-gen` dependency.
+
+Image-owned native dependencies are verified, never installed using sudo. Rust,
+Node and the pinned WASM tools use writable runner/user locations. Cargo targets
+remain in job-local HOME; each release starts with fresh runner, HOME and workspace state. The
+runner needs no Docker CLI/socket or KVM device.
+
+## Provisioning and promotion
+
+Use the reviewed `dashpay/dash-selfhosted-image` recipe and a tested immutable
+image digest, not a moving tag. Deploy the host-side disposable release controller
+only after the NPM validation workflow succeeds on that image. Do not add generic
+release labels to persistent CI registrations. See the
+[controller installation and cleanup runbook](https://github.com/dashpay/dash-selfhosted-image/blob/main/docs/disposable-releases.md).
+Old release tags
+still contain their original workflows and do not automatically gain this fix.
+
+Requirements-changing PRs select a candidate label bound to the complete PR head
+and image digest. The image controller must support the `npm` job kind and
+`.github/workflows/npm-runner-validation.yml`. Manifests requesting native client
+generation require successful Rust, Kotlin and NPM candidate jobs for promotion;
+skipped fork jobs do not qualify. Existing same-repository/trusted-fork guards
+remain in effect.
+
+The trusted `runner-image-candidate.yml` bootstrap, controller and Rust/Kotlin
+candidate routing must be installed on each consuming branch before candidate
+promotion can work. Platform PRs #4702 and #4912 establish those pieces; reconcile
+their requirements/selector files with this NPM extension when landing them. In
+particular, update both the bootstrap's reusable-workflow SHA and its
+`control_revision` to a reviewed image-repository revision supporting
+`client_codegen` and `npm`. Merely changing `recipe_revision` is insufficient.
+The default `v4.2-dev` and `v4.3-dev` branches must each use an explicit compatible
+manifest; this change does not alter an existing release tag or deploy a runner.
+
+## Verification
+
+`npm-runner-validation.yml` runs the real release build and packing action, DAPI
+unit tests, and a byte-for-byte check that packed Node/web clients match the
+freshly generated files. It uploads tarballs but never publishes them.
+`test-client-codegen.yml` also builds the native compilers on hosted Linux/macOS,
+checks committed generated output, tests failure recovery and validates packing.
+
+Local setup and generator test commands are in `packages/dapi-grpc/README.md`.
+
+After installing the controller, use the `release.yml` dispatch with
+`tag=npm-test:v` on a protected development branch for a non-publishing
+NPM build. For Kotlin, dispatch `release-kotlin-sdk.yml` from the protected branch
+with an existing published `tag` and `dry_run=true`: compilation/artifact upload
+run, but release attachment and Maven publication are both skipped. Check that
+the image contract matches the selected source. Neither controller unit tests
+nor an image smoke test establishes that these end-to-end jobs pass.
+
+## Separate PR and release state
+
+The `platform-release-builds` organization runner group selects only
+`dashpay/platform` and contains only controller-created one-job registrations.
+Each build requests:
+
+```yaml
+runs-on:
+ group: platform-release-builds
+ labels: [self-hosted, Linux, X64, 'platform-release-${{ github.run_id }}-${{ github.run_attempt }}-npm']
+```
+
+There is no fallback to `npm-build`, `rust-ci` or `kotlin-ci`. Without the
+controller, builds stay queued. Runtime markers reject accidental routing to an
+ordinary runner; they are not cryptographic attestation. The host controller
+independently checks repository, event, workflow, run, attempt and commit before
+creating fresh JIT capacity. Only one job can consume each registration; the host
+destroys its container/processes, HOME, registration and workspace afterward.
+
+**Ordinary PR caching is unchanged.** PR validation keeps its own persistent
+Cargo/Gradle/Yarn caches. Releases reuse the prebaked image/toolchains but never
+mount PR state or restore shared executable dependency caches. Yarn caching is
+opted out only for the release runtime; Kotlin release build/publication disable
+Gradle cache restores. A cold release compile is the intentional tradeoff; do not
+reintroduce shared caches to speed it up without reviewing their writer trust.
+
+`release.yml` calls its local reusable workflow, so the workflow travels with the
+release source. Port it and the matching image requirements to 4.3; the host
+controller needs no branch-specific allowlist. Branch protection, trusted tags,
+fork approvals and hosted publishing authorization remain necessary. Optional
+selected-workflow group restrictions are defense in depth, not the mechanism
+that erases prior-job state. Labels alone are not authorization.
+
+This assumes a trusted host and pinned image. A fresh container does not repair
+host compromise or retroactively secure old release tags/artifacts. Merge/deploy
+the controller before relying on this workflow change for a release.
diff --git a/.github/SELF_HOSTED_RUNNER.md b/.github/SELF_HOSTED_RUNNER.md
new file mode 100644
index 00000000000..6978bff1889
--- /dev/null
+++ b/.github/SELF_HOSTED_RUNNER.md
@@ -0,0 +1,182 @@
+# Self-hosted runner contract
+
+## Reproducible Linux image
+
+Source, locked dependencies, deployment examples and publishing workflow:
+**[dashpay/dash-selfhosted-image](https://github.com/dashpay/dash-selfhosted-image)**.
+Use its `linux/amd64` contract-1 image for persistent Linux `kotlin-ci` / `rust-ci`
+runners, or its native `linux/arm64` Rust-only image on Apple Silicon Linux VMs.
+The shared Rust action requires `/opt/ci/contract-version` to be `1` on
+self-hosted Linux and fails early if an old/native runner picks up the job.
+
+Platform's desired versions and checksums now live in
+[.github/runner-requirements.json](runner-requirements.json). This includes an immutable image-recipe
+commit. Persistent Linux jobs verify **both** the installed lock and recipe
+revision; a contract-1 marker by itself is not sufficient. The earlier published
+bootstrap image must be replaced by a matching candidate before these changes
+can be merged.
+
+ARM64 Rust jobs select [runner-requirements.arm64.json](runner-requirements.arm64.json)
+using the **actual** job's `RUNNER_OS=Linux` / `RUNNER_ARCH=ARM64`. They verify its
+exact recipe and ARM64 lock, not the AMD64 lock and not just tool version strings.
+The shared Rust action defaults to the native compilation target. Kotlin/Android
+remains AMD64-only; ARM64 does not export or pretend to provide Android tooling.
+
+The image locks Ubuntu 24.04 by digest, apt to a signed archive snapshot, and
+downloaded toolchains to exact URLs and SHA-256 hashes. It includes:
+
+| Toolchain | Contract |
+| --- | --- |
+| Native build | build-essential, clang/LLVM, Snappy, CMake, GMP, OpenSSL, pkg-config |
+| Rust | rustup plus the repository baseline; exact repo-selected toolchains may be installed user-locally |
+| Cargo helpers | llvm-cov **0.9.1**, nextest **0.9.144**, machete **0.9.2**, ndk **4.1.2** |
+| Protobuf / Java | protoc **32.0**, JDK **17** |
+| Android | API **35**, build-tools **35.0.0**, NDK **28.1.13356709**, pinned emulator/system image |
+| Other job tools | Git, GitHub CLI, jq, Python 3, gpg, zip/unzip |
+
+The SDK is image-owned. The persistent Kotlin workflow uses the image's
+`ci-android-emulator` wrapper to create user-writable AVDs, boot with KVM, and stop
+the emulator after testing. It does **not** run setup-android, sdkmanager, or an
+emulator action that upgrades image packages at job runtime. Lockscreen/PIN and
+unlocked-device checks remain in `.github/scripts/kotlin-instrumented-tests.sh`.
+
+## Runtime privileges
+
+- Non-root uid/gid **1001:1001**, no sudo, no Docker CLI or host Docker socket.
+- Drop **all** capabilities; enable **no-new-privileges**. Keep default Docker
+ seccomp/AppArmor policies, with no privileged mode or host namespaces.
+- Dedicated registration/work volumes only. Kotlin adds **`/dev/kvm:rw`** and its
+ numeric host group; Rust-only runners do not need that device.
+- The operator configures `/dev/kvm` as `root:kvm`, mode **0660**. Jobs only check
+ access; they never modify host udev rules, permissions or system packages.
+- Preserve runner-group selected-repository access and the existing fork guards.
+ Persistent job data is not isolation between mutually untrusted repositories.
+
+Building/publishing the image uses Docker on an ephemeral GitHub-hosted builder;
+that privilege is not passed into the resulting persistent runner.
+
+## Publish, prove, then roll out
+
+For Platform requirements changes, use the PR-first lifecycle below. The
+standalone image publishing workflow remains useful for recipe development,
+but its default lock is not a separate source of Platform requirements.
+
+1. Use a successful [image publishing run](https://github.com/dashpay/dash-selfhosted-image/actions/workflows/image.yml).
+ Publication requires non-root compiler/confinement checks, `KVM_CREATE_VM`, and
+ a real API 35 emulator boot. Retrieve `image-reference.txt` from the run.
+2. Set `RUNNER_IMAGE=dashpay/dash-selfhosted-image@sha256:` in
+ the operator's deployment. Do not use a floating image or a locally inherited
+ `github-runner-runner:latest` parent. The image repo's Compose files enforce the
+ runtime boundary above; the KVM overlay is optional.
+3. Drain the old runner before migration. Register a new name in the **existing
+ group**, retaining its selected repositories, with only the required labels.
+ Use a short-lived registration-token file, not a PAT stored in Compose.
+4. Prove a real Rust job and Kotlin job on that exact runner/digest before retiring
+ the old instance. Keep the previous registration/image for rollback. Rebuilding
+ or pushing source does not replace any live runner automatically.
+
+Deploy and prove the contract-1 image **before merging the consuming workflows**.
+Record the selected digest and real-job evidence with the deployment; do not infer
+runtime health from YAML validation or the image tag alone. Rebuild/repin when
+dependencies change, including runner updates required by GitHub's update policy.
+
+## Requirements changes: candidate before merge, promotion after
+
+1. Change .github/runner-requirements.json in the Platform PR, including exact download URLs,
+ checksums and package metadata. A change to this file is the automatic build
+ flag; no separate label is required. Change the pinned recipe commit only
+ when recipe/image code changes. Ordinary user-local Rust toolchain updates
+ still follow rust-toolchain.toml.
+2. The trusted base-branch publisher builds and smoke-tests a candidate on a
+ disposable VM. A separate VM publishes it without executing PR image/code
+ with Docker Hub credentials.
+3. The normal Rust and Kotlin workflows wait for the candidate, then request
+ temporary runners labelled for **this PR head, exact digest and job kind**.
+ A host-side controller creates one-job non-root containers, with KVM only
+ for Kotlin. Real application jobs must pass; skipped fork jobs do not count.
+4. After merge, the publisher verifies the merged/current requirements and both
+ real candidate jobs, then promotes **the same tested digest**, without a
+ rebuild. Each base branch gets a platform- channel; main advances only
+ for Platform's actual GitHub default branch.
+5. Promotion does not restart production runners. The operator drains and
+ switches ordinary runner capacity to the reviewed digest using the rollout
+ procedure above. Requirements checks fail explicitly until capacity matches.
+
+The trusted caller must land separately before a PR can use this flow.
+See the image repository's
+[bootstrap, GitHub App and candidate-controller setup](https://github.com/dashpay/dash-selfhosted-image/blob/feat/platform-pr-images/docs/platform-pr-images.md).
+No App key, Docker socket, registry credential or host workspace enters a job.
+The existing trusted-fork restrictions are unchanged; the controller's
+exact-head approvals do not override workflow-side guards.
+
+Run the routing checks with:
+
+~~~sh
+python3 -m unittest discover -s .github/scripts/tests -v
+~~~
+
+## Apple Silicon rollout and ARM64 requirements changes
+
+The Rust workspace and wallet jobs select `[self-hosted, Linux, rust-ci]`. This
+includes Linux containers on Macs; it does **not** remove Mac hardware from CI.
+Keep native macOS registrations for Swift, Xcode and simulator jobs. Never reuse
+their registration, HOME or workspaces inside a container. Rust and Swift may run
+concurrently, so reserve host resources rather than assigning both the whole Mac.
+Use Linux-owned named volumes for build/cache data, not macOS bind mounts.
+
+Initial ARM64 recipe: `772673c94f2c0b39e7e796198a6ea407c087dd72`, published by
+[image run 36419475060](https://github.com/dashpay/dash-selfhosted-image/actions/runs/36419475060).
+Its tested immutable reference is
+`dashpay/dash-selfhosted-image@sha256:2ef7934f6877b4b78bdc3d4b81c07ee260d1648c0338c86145cec02760390a24`.
+The existing AMD64 requirements and deployed images are unchanged.
+
+Before merging/routing ordinary CI, provision each Mac's ARM64 VM and validate
+the digest with the image's smoke test. Register it separately in the existing
+selected-repository group with `rust-ci-validation`, prove a real Platform
+workspace job on that exact runner, and verify unattended restart. Only validated
+instances get `rust-ci`; keep the validation label for future image qualification.
+Do not count image-build CI, local unit tests or skipped fork jobs as this proof.
+
+The existing automatic PR-candidate publisher/controller is **AMD64-only**.
+ARM64 requirements currently use explicit operator deployment, not that publisher:
+
+1. Build/publish the ARM64 recipe and pin its exact lock and recipe here.
+2. Deploy the tested digest to an idle validation runner, preserving rollback.
+3. `ARM64 runner image validation` runs the **full** Rust workspace on ARM64 when
+ this manifest changes. Exact lock/recipe mismatch fails before compilation.
+ Its selector compares the PR-head manifest with the checked-out merge tree;
+ an AMD64 candidate status cannot satisfy ARM64 validation.
+ That PR's ordinary Rust job stays on AMD64 so it cannot land on ARM64
+ production capacity still running the old image; unrelated PRs use both
+ architectures as usual.
+4. Require successful real ARM64 validation before merging the requirements and
+ rolling out other Mac-backed capacity. If AMD64 requirements also change,
+ their separate Rust/Kotlin candidate gates still apply.
+
+Keep shared Rust/helper versions aligned across both manifests. Automatic ARM64
+candidate creation/promotion is not implemented; never infer ARM64 validation or
+deployment from an AMD64 publisher result.
+
+## Hosted Linux and native macOS remain distinct
+
+The shared Rust action branches on `runner.environment`: persistent Linux verifies
+the image's native libraries and protoc, while GitHub-hosted consumers retain apt
+provisioning and the user-local protoc cache.
+Both select clang through `CC`/`CXX`, without mutating system alternatives.
+
+The Linux image does not provision macOS. Native macOS runners retain their
+existing Swift/Homebrew dependencies and registrations; generic Rust jobs now
+use the Linux image pool. Do not silently install tools or swallow failures in
+persistent jobs.
+
+Kotlin release builds use persistent `kotlin-ci` capacity and retain their separate
+release hardening: forcibly reinstall cargo-ndk 4.1.2 and verify a fresh protoc
+download by checksum. A version-only check of a binary left by a previous job is
+not a substitute for these release checks. Release attachment and Maven
+publication stay on separate GitHub-hosted jobs, keeping publishing credentials
+off the persistent build runner.
+
+Docker publication and Kotlin nightly jobs remain on ephemeral GitHub-hosted
+runners; the nightly job explicitly installs cargo-ndk 4.1.2. Any future self-hosted
+job that genuinely needs Docker must use separately isolated capacity; the
+persistent Rust/Kotlin runner must not regain the host Docker socket.
diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml
index 14ff10fba49..6527a051ce5 100644
--- a/.github/actionlint.yaml
+++ b/.github/actionlint.yaml
@@ -9,3 +9,5 @@ self-hosted-runner:
labels:
- rust-ci
- kotlin-ci
+ - npm-build
+ - npm-pr
diff --git a/.github/actions/librocksdb/action.yaml b/.github/actions/librocksdb/action.yaml
index 8bb32f9fae1..3edae56999e 100644
--- a/.github/actions/librocksdb/action.yaml
+++ b/.github/actions/librocksdb/action.yaml
@@ -21,7 +21,7 @@ runs:
uses: actions/cache@v5
id: librocksdb-cache
with:
- key: librocksdb/${{ inputs.version }}/${{ runner.os }}/${{ runner.arch }}
+ key: librocksdb/pic-v1/${{ inputs.version }}/${{ runner.os }}/${{ runner.arch }}
path: /opt/rocksdb
- if: ${{ steps.librocksdb-cache.outputs.cache-hit != 'true' || inputs.force == 'true' }}
@@ -29,6 +29,10 @@ runs:
name: Build librocksdb
env:
PORTABLE: 1
+ # This static archive is also linked into Rust cdylibs. In particular,
+ # RocksDB's thread-local objects must not use non-PIC relocations.
+ EXTRA_CFLAGS: -fPIC
+ EXTRA_CXXFLAGS: -fPIC
run: |
set -ex
WORKDIR=/tmp/rocksdb-build
diff --git a/.github/actions/nodejs/action.yaml b/.github/actions/nodejs/action.yaml
index 8c8066edf8e..96a834eb632 100644
--- a/.github/actions/nodejs/action.yaml
+++ b/.github/actions/nodejs/action.yaml
@@ -2,6 +2,10 @@
name: "Setup Node.JS"
description: "Setup Node.JS binaries, dependencies and cache"
inputs:
+ cache:
+ description: "Restore/save executable dependency build caches"
+ required: false
+ default: "true"
node-version:
description: "Node.js version to use"
required: false
@@ -29,6 +33,7 @@ runs:
run: npm config set audit false
- name: Cache NPM build artifacts
+ if: inputs.cache == 'true'
uses: actions/cache@v5
with:
# Cache the unplugged packages (unpacked native builds), yarn's
diff --git a/.github/actions/npm-release-build/action.yaml b/.github/actions/npm-release-build/action.yaml
new file mode 100644
index 00000000000..6250df11fa2
--- /dev/null
+++ b/.github/actions/npm-release-build/action.yaml
@@ -0,0 +1,132 @@
+name: Build release NPM packages
+description: Compile and pack on the provisioned unprivileged Linux image
+inputs:
+ cache-name:
+ description: Cargo target directory name (job-local on disposable release runners)
+ default: release-npm-target
+runs:
+ using: composite
+ steps:
+ - name: Verify provisioned runner dependencies
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ ci-image-contract verify .github/runner-requirements.json
+ python3 packages/dapi-grpc/scripts/setup-codegen.py
+ for pkg in build-essential cmake curl jq libgmp-dev libssl-dev pkg-config python3 unzip zip; do
+ dpkg-query -W -f='${Status}' "$pkg" | grep -Fx 'install ok installed'
+ done
+ test -x "$HOME/.cargo/bin/rustup"
+ echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
+
+ - name: Setup Rust
+ uses: ./.github/actions/rust
+ with:
+ target: wasm32-unknown-unknown
+ # Never restore a shared Rust build cache into a release.
+ cache: 'false'
+ system-dependencies: verify
+
+ # The Rust action above reuses a protoc left in ~/.local by earlier jobs.
+ # Override it with a fresh, checksum-verified copy for the release build;
+ # prost-build reads PROTOC first.
+ - name: Install protoc v32.0
+ env:
+ PROTOC_SHA256: 7ca037bfe5e5cabd4255ccd21dd265f79eb82d3c010117994f5dc81d2140ee88
+ shell: bash
+ run: |
+ set -euo pipefail
+ PROTOC_DIR="$RUNNER_TEMP/protoc-32.0"
+ curl -fsSL -o "$RUNNER_TEMP/protoc.zip" \
+ https://github.com/protocolbuffers/protobuf/releases/download/v32.0/protoc-32.0-linux-x86_64.zip
+ echo "$PROTOC_SHA256 $RUNNER_TEMP/protoc.zip" | sha256sum -c -
+ rm -rf "$PROTOC_DIR"
+ unzip -q "$RUNNER_TEMP/protoc.zip" -d "$PROTOC_DIR"
+ echo "PROTOC=$PROTOC_DIR/bin/protoc" >> "$GITHUB_ENV"
+ echo "$PROTOC_DIR/bin" >> "$GITHUB_PATH"
+ "$PROTOC_DIR/bin/protoc" --version
+
+ - name: Prepare release Cargo target cache
+ uses: ./.github/actions/release-cargo-target-cache
+ with:
+ name: ${{ inputs.cache-name }}
+
+ - name: Setup Node.JS
+ uses: ./.github/actions/nodejs
+ with:
+ # PR image validation may use its own caches; release jobs must not
+ # import executable state from the ordinary CI cache namespace.
+ cache: ${{ env.DASH_RELEASE_RUNNER != '1' }}
+
+ - name: Install Cargo binstall
+ uses: cargo-bins/cargo-binstall@v1.3.1
+
+ # Always reinstalled, never trusted from an earlier job: a binary left on
+ # this persistent runner can print the pinned version and still be
+ # something else.
+ - name: Install wasm-bindgen-cli
+ shell: bash
+ run: |
+ set -euo pipefail
+ cargo binstall wasm-bindgen-cli@0.2.108 --no-confirm --force
+ wasm-bindgen --version | grep -Fxq 'wasm-bindgen 0.2.108'
+
+ - name: Install wasm-pack
+ shell: bash
+ run: |
+ set -euo pipefail
+ cargo binstall wasm-pack@0.15.0 --no-confirm --force
+ wasm-pack --version | grep -Fxq 'wasm-pack 0.15.0'
+
+ # Fresh, checksum-verified copy in this job's temp directory rather than
+ # one left in ~/.local by an earlier job.
+ - name: Install Binaryen
+ env:
+ BINARYEN_SHA256: c90e0e295e8f8484ba5b47da92f26e5d1d18db6cd2fcc0c5cc265a5a73609f17
+ shell: bash
+ run: |
+ set -euo pipefail
+ ARCHIVE="$RUNNER_TEMP/binaryen-version_121-x86_64-linux.tar.gz"
+ curl -fsSL -o "$ARCHIVE" \
+ https://github.com/WebAssembly/binaryen/releases/download/version_121/binaryen-version_121-x86_64-linux.tar.gz
+ echo "$BINARYEN_SHA256 $ARCHIVE" | sha256sum -c -
+ rm -rf "$RUNNER_TEMP/binaryen-version_121"
+ tar -xzf "$ARCHIVE" -C "$RUNNER_TEMP"
+ echo "$RUNNER_TEMP/binaryen-version_121/bin" >> "$GITHUB_PATH"
+
+ # Binaryen otherwise sizes its thread pool from the host CPU count, which
+ # inside a Docker CPU quota oversubscribes and spins on futexes. Four
+ # threads measured ~3x faster with byte-identical output. Cargo's job
+ # budget is unaffected; a runner-provided BINARYEN_CORES still wins.
+ - name: Build packages
+ shell: bash
+ run: |
+ export BINARYEN_CORES="${BINARYEN_CORES:-4}"
+ echo "::notice::Binaryen threads: $BINARYEN_CORES"
+ time yarn build
+ env:
+ CARGO_BUILD_PROFILE: release
+
+ - name: Ignore only already cached artifacts
+ shell: bash
+ run: |
+ find . -name '.gitignore' -exec rm -f {} +
+ {
+ echo ".yarn"
+ echo "target"
+ echo "node_modules"
+ echo ".nyc_output"
+ echo ".idea"
+ echo ".ultra.cache.json"
+ echo "db/*"
+ echo "npm-packages"
+ } >> .gitignore
+
+ - name: Pack public workspaces
+ shell: bash
+ run: |
+ mkdir -p npm-packages
+ yarn workspaces foreach --all --no-private --parallel pack \
+ --out "$GITHUB_WORKSPACE/npm-packages/%s-%v.tgz"
+ test -n "$(find npm-packages -maxdepth 1 -type f -name '*.tgz' -print -quit)"
diff --git a/.github/actions/release-cargo-target-cache/action.yaml b/.github/actions/release-cargo-target-cache/action.yaml
new file mode 100644
index 00000000000..618117b86ac
--- /dev/null
+++ b/.github/actions/release-cargo-target-cache/action.yaml
@@ -0,0 +1,51 @@
+---
+name: "Release Cargo target cache"
+description: >-
+ Point CARGO_TARGET_DIR at a per-workflow directory outside the checkout.
+ On disposable release runners this is job-local HOME state, destroyed with
+ the container, never a cache shared with PRs. Reset it when it outgrows
+ max-gib or available space drops below min-free-gib.
+inputs:
+ name:
+ description: Cache directory name, unique per release workflow
+ required: true
+ max-gib:
+ description: Reset the cache when it grows past this many GiB
+ required: false
+ default: "60"
+ min-free-gib:
+ description: Reset the cache when the volume has less than this many GiB free
+ required: false
+ default: "40"
+runs:
+ using: composite
+ steps:
+ - name: Prepare release Cargo target cache
+ # Quoted: a self-hosted runner's temp directory can contain spaces.
+ shell: bash --noprofile --norc -e -o pipefail "{0}"
+ env:
+ CACHE_NAME: ${{ inputs.name }}
+ MAX_GIB: ${{ inputs.max-gib }}
+ MIN_FREE_GIB: ${{ inputs.min-free-gib }}
+ run: |
+ set -euo pipefail
+ case "$CACHE_NAME" in
+ ''|*/*|.*)
+ echo "::error::Invalid release target cache name '$CACHE_NAME'."
+ exit 1
+ ;;
+ esac
+ RELEASE_TARGET_DIR="$HOME/.cache/dash-platform/$CACHE_NAME"
+ MAX_CACHE_KIB=$((MAX_GIB * 1024 * 1024))
+ MIN_FREE_KIB=$((MIN_FREE_GIB * 1024 * 1024))
+ mkdir -p "$RELEASE_TARGET_DIR"
+ USED_KIB=$(du -sk "$RELEASE_TARGET_DIR" | cut -f1)
+ FREE_KIB=$(df -Pk "$RELEASE_TARGET_DIR" | awk 'NR == 2 { print $4 }')
+ if [ "$USED_KIB" -gt "$MAX_CACHE_KIB" ] || [ "$FREE_KIB" -lt "$MIN_FREE_KIB" ]; then
+ echo "::notice::Resetting the release target cache (${USED_KIB} KiB used, ${FREE_KIB} KiB free on the volume)."
+ rm -rf "${RELEASE_TARGET_DIR:?}"
+ mkdir -p "$RELEASE_TARGET_DIR"
+ fi
+ du -sh "$RELEASE_TARGET_DIR"
+ df -h "$RELEASE_TARGET_DIR"
+ echo "CARGO_TARGET_DIR=$RELEASE_TARGET_DIR" >> "$GITHUB_ENV"
diff --git a/.github/actions/rust/action.yaml b/.github/actions/rust/action.yaml
index 808f86cbe32..7dfed6fc848 100644
--- a/.github/actions/rust/action.yaml
+++ b/.github/actions/rust/action.yaml
@@ -6,12 +6,15 @@ inputs:
description: Rust toolchain to use, stable / nightly / beta, or exact version; uses rust-toolchain.toml if not specified
default: ""
target:
- description: Target Rust platform
+ description: Additional Rust target to install; defaults to the runner's native target
required: false
- default: x86_64-unknown-linux-gnu
+ default: ""
components:
description: List of additional Rust toolchain components to install
required: false
+ system-dependencies:
+ description: Install native packages on hosted runners, or verify a provisioned image
+ default: install
cache:
description: Enable Rust cache
required: false
@@ -21,6 +24,31 @@ inputs:
runs:
using: composite
steps:
+ - name: Validate native dependency mode
+ shell: bash
+ env:
+ DEPENDENCY_MODE: ${{ inputs.system-dependencies }}
+ run: |
+ case "$DEPENDENCY_MODE" in
+ install|verify) ;;
+ *) echo "::error::Unknown native dependency mode"; exit 1 ;;
+ esac
+
+ - name: Read shared runner tool requirements
+ shell: bash
+ run: python3 .github/scripts/runner-image.py env
+
+ - name: Verify persistent Linux runner image contract
+ if: runner.os == 'Linux' && runner.environment == 'self-hosted'
+ shell: bash
+ run: |
+ if [ "$(cat /opt/ci/contract-version 2>/dev/null)" != 1 ]; then
+ echo '::error::This runner needs the versioned dashpay/dash-selfhosted-image (contract 1); see .github/SELF_HOSTED_RUNNER.md.'
+ exit 1
+ fi
+ python3 .github/scripts/runner-image.py verify
+ command -v rustup
+
- name: Resolve HOME path for caching
id: resolved_home
shell: bash
@@ -47,7 +75,7 @@ runs:
components: ${{ inputs.components }}
- name: Get protoc arch
- if: runner.os == 'Linux'
+ if: runner.os == 'Linux' && runner.environment == 'github-hosted'
shell: bash
id: protoc_arch
run: |
@@ -66,40 +94,48 @@ runs:
;;
esac
- - name: Restore cached protoc (v32.0)
- if: runner.os == 'Linux'
+ - name: Restore cached repository-pinned protoc
+ if: runner.os == 'Linux' && runner.environment == 'github-hosted'
id: cache-protoc
uses: actions/cache@v5
with:
path: |
- ${{ steps.resolved_home.outputs.home }}/.local/protoc-32.0/bin
- ${{ steps.resolved_home.outputs.home }}/.local/protoc-32.0/include
- key: protoc/32.0/${{ runner.os }}/${{ steps.protoc_arch.outputs.arch }}
+ ${{ steps.resolved_home.outputs.home }}/.local/protoc-${{ env.CI_PROTOC_VERSION }}/bin
+ ${{ steps.resolved_home.outputs.home }}/.local/protoc-${{ env.CI_PROTOC_VERSION }}/include
+ key: protoc/${{ env.CI_PROTOC_VERSION }}/${{ runner.os }}/${{ steps.protoc_arch.outputs.arch }}
- - name: Install protoc (cached v32.0)
- if: runner.os == 'Linux'
+ - name: Install repository-pinned protoc
+ if: runner.os == 'Linux' && runner.environment == 'github-hosted'
id: deps-protoc
shell: bash
run: |
set -euxo pipefail
- PROTOC_DIR="${HOME}/.local/protoc-32.0"
+ PROTOC_DIR="${HOME}/.local/protoc-${{ env.CI_PROTOC_VERSION }}"
if [ ! -x "${PROTOC_DIR}/bin/protoc" ]; then
mkdir -p "${PROTOC_DIR}"
curl -fsSL -o /tmp/protoc.zip \
- "https://github.com/protocolbuffers/protobuf/releases/download/v32.0/protoc-32.0-linux-${{ steps.protoc_arch.outputs.arch }}.zip"
+ "https://github.com/protocolbuffers/protobuf/releases/download/v${CI_PROTOC_VERSION}/protoc-${{ env.CI_PROTOC_VERSION }}-linux-${{ steps.protoc_arch.outputs.arch }}.zip"
unzip -o /tmp/protoc.zip -d "${PROTOC_DIR}"
fi
echo "${PROTOC_DIR}/bin" >> "$GITHUB_PATH"
echo "PROTOC=${PROTOC_DIR}/bin/protoc" >> "$GITHUB_ENV"
- - name: Save cached protoc (v32.0)
- if: runner.os == 'Linux' && steps.cache-protoc.outputs.cache-hit != 'true'
+ - name: Save cached repository-pinned protoc
+ if: runner.os == 'Linux' && runner.environment == 'github-hosted' && steps.cache-protoc.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
with:
path: |
- ${{ steps.resolved_home.outputs.home }}/.local/protoc-32.0/bin
- ${{ steps.resolved_home.outputs.home }}/.local/protoc-32.0/include
- key: protoc/32.0/${{ runner.os }}/${{ steps.protoc_arch.outputs.arch }}
+ ${{ steps.resolved_home.outputs.home }}/.local/protoc-${{ env.CI_PROTOC_VERSION }}/bin
+ ${{ steps.resolved_home.outputs.home }}/.local/protoc-${{ env.CI_PROTOC_VERSION }}/include
+ key: protoc/${{ env.CI_PROTOC_VERSION }}/${{ runner.os }}/${{ steps.protoc_arch.outputs.arch }}
+
+ - name: Verify prebaked repository-pinned protoc
+ if: runner.os == 'Linux' && runner.environment == 'self-hosted'
+ shell: bash
+ run: |
+ set -euo pipefail
+ protoc --version | grep -Fx "libprotoc $CI_PROTOC_VERSION"
+ echo "PROTOC=$(command -v protoc)" >> "$GITHUB_ENV"
- name: Set HOME variable to github context
shell: bash
@@ -113,17 +149,42 @@ runs:
${{ steps.resolved_home.outputs.home }}/.cargo/registry/index
${{ steps.resolved_home.outputs.home }}/.cargo/registry/cache
${{ steps.resolved_home.outputs.home }}/.cargo/git
- key: ${{ runner.os }}/cargo/registry/${{ hashFiles('**/Cargo.lock') }}
+ key: ${{ runner.os }}/${{ runner.arch }}/cargo/registry/${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
- ${{ runner.os }}/cargo/registry/${{ hashFiles('**/Cargo.lock') }}
- ${{ runner.os }}/cargo/registry/
+ ${{ runner.os }}/${{ runner.arch }}/cargo/registry/${{ hashFiles('**/Cargo.lock') }}
+ ${{ runner.os }}/${{ runner.arch }}/cargo/registry/
- - name: Install clang
+ # This composite is also used by hosted release, nightly and book jobs.
+ # Keep their bootstrap path; only persistent runners require a prebaked image.
+ - name: Install native dependencies on ephemeral GitHub-hosted Linux
+ if: runner.os == 'Linux' && runner.environment == 'github-hosted' && inputs.system-dependencies == 'install'
+ shell: bash
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends clang llvm libsnappy-dev
+
+ # Linux self-hosted runners provide the compiler and native libraries in
+ # the pinned image. Do not install packages or mutate system alternatives
+ # from a job: the runner is intentionally non-root.
+ - name: Verify clang and native dependencies
id: deps-clang
shell: bash
if: runner.os == 'Linux'
run: |
- sudo apt update
- # snappy is required by rust rocksdb
- sudo apt install -qq --yes clang llvm libsnappy-dev
- sudo update-alternatives --set cc /usr/bin/clang
+ set -euo pipefail
+ missing=()
+ for tool in clang llvm-config; do
+ command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
+ done
+ for pkg in clang llvm libsnappy-dev; do
+ dpkg -s "$pkg" >/dev/null 2>&1 || missing+=("$pkg")
+ done
+ if [ ${#missing[@]} -gt 0 ]; then
+ echo "::error::Linux runner is missing: ${missing[*]}"
+ echo "::error::Persistent runners must provision these dependencies in the pinned image; hosted runners use the install step above."
+ exit 1
+ fi
+ # Use clang for native C/C++ build scripts without changing the
+ # system-wide cc alternative.
+ echo "CC=/usr/bin/clang" >> "$GITHUB_ENV"
+ echo "CXX=/usr/bin/clang++" >> "$GITHUB_ENV"
diff --git a/.github/runner-requirements.arm64.json b/.github/runner-requirements.arm64.json
new file mode 100644
index 00000000000..3c787c338e3
--- /dev/null
+++ b/.github/runner-requirements.arm64.json
@@ -0,0 +1,142 @@
+{
+ "schema": 1,
+ "recipe_revision": "772673c94f2c0b39e7e796198a6ea407c087dd72",
+ "requirements": {
+ "schema": 2,
+ "contract_version": "1",
+ "platform": "linux/arm64",
+ "ubuntu_image": "ubuntu:24.04@sha256:11dc1ccb427f0464a2369e645454c272bb0baece7357c892ba69d313b3a332cf",
+ "apt_snapshot": "20260920T000000Z",
+ "rust_version": "1.98.1",
+ "rust_manifest_sha256": "a7c8774a5fd8441c997d94c029776cbc5eb111e9d72ab5d256fa69866644347e",
+ "artifacts": [
+ {
+ "name": "runner",
+ "url": "https://github.com/actions/runner/releases/download/v2.337.0/actions-runner-linux-arm64-2.337.0.tar.gz",
+ "sha256": "9b1dc70626422526e3c94767cf024896beb15da5342a3f4819bf2feac13e0393",
+ "format": "tar",
+ "destination": "/opt/actions-runner"
+ },
+ {
+ "name": "cargo-llvm-cov",
+ "url": "https://github.com/taiki-e/cargo-llvm-cov/releases/download/v0.9.1/cargo-llvm-cov-aarch64-unknown-linux-gnu.tar.gz",
+ "sha256": "abf5f13c1520f8756d2192bfaaeadb0208f5b7eaa8cadc15bf847befa42b7360",
+ "format": "tar",
+ "destination": "/opt/ci/bin/cargo-llvm-cov",
+ "binary": "cargo-llvm-cov"
+ },
+ {
+ "name": "cargo-nextest",
+ "url": "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-0.9.144/cargo-nextest-0.9.144-aarch64-unknown-linux-gnu.tar.gz",
+ "sha256": "7fecfd431b810c05c589d800524286b8f80ce2fe9fb1fef05caf16d095cea407",
+ "format": "tar",
+ "destination": "/opt/ci/bin/cargo-nextest",
+ "binary": "cargo-nextest"
+ },
+ {
+ "name": "cargo-machete",
+ "url": "https://github.com/bnjbvr/cargo-machete/releases/download/v0.9.2/cargo-machete-v0.9.2-aarch64-unknown-linux-gnu.tar.gz",
+ "sha256": "6f96c3e6026a5bdd241b6ae600c6fb86c9197c6e189a894f91371baa01fd10f5",
+ "format": "tar",
+ "destination": "/opt/ci/bin/cargo-machete",
+ "binary": "cargo-machete"
+ },
+ {
+ "name": "protoc",
+ "url": "https://github.com/protocolbuffers/protobuf/releases/download/v32.0/protoc-32.0-linux-aarch_64.zip",
+ "sha256": "56af3fc2e43a0230802e6fadb621d890ba506c5c17a1ae1070f685fe79ba12d0",
+ "format": "zip",
+ "destination": "/opt/protoc"
+ },
+ {
+ "name": "rustup-init",
+ "url": "https://static.rust-lang.org/rustup/archive/1.28.2/aarch64-unknown-linux-gnu/rustup-init",
+ "sha256": "e3853c5a252fca15252d07cb23a1bdd9377a8c6f3efa01531109281ae47f841c",
+ "format": "file",
+ "destination": "/opt/ci/rustup-init",
+ "build_only": true
+ }
+ ],
+ "bootstrap_ca": {
+ "url": "https://snapshot.ubuntu.com/ubuntu/20260920T000000Z/pool/main/c/ca-certificates/ca-certificates_20240203_all.deb",
+ "sha256": "641de77d8f142cfd62a1a6f964ba67b20754d3337c480efb529d086075a06c9a",
+ "version": "20240203"
+ },
+ "apt_packages": [
+ "ca-certificates",
+ "curl",
+ "git",
+ "gh",
+ "jq",
+ "python3",
+ "unzip",
+ "zip",
+ "xz-utils",
+ "bzip2",
+ "gnupg",
+ "build-essential",
+ "clang",
+ "llvm",
+ "libsnappy-dev",
+ "cmake",
+ "libgmp-dev",
+ "libssl-dev",
+ "pkg-config",
+ "openjdk-17-jdk-headless",
+ "libicu74",
+ "libkrb5-3",
+ "zlib1g",
+ "libgcc-s1",
+ "libstdc++6",
+ "libcurl4t64",
+ "liblttng-ust1t64",
+ "libunwind8",
+ "libpulse0",
+ "libx11-xcb1",
+ "libnss3",
+ "libxcomposite1",
+ "libxcursor1",
+ "libxi6",
+ "libxrandr2",
+ "libxtst6",
+ "libasound2t64",
+ "libgl1",
+ "libegl1",
+ "libdbus-1-3",
+ "libxdamage1",
+ "libxfixes3"
+ ],
+ "java_major": 17,
+ "versions": {
+ "runner": "2.337.0",
+ "llvm_cov": "0.9.1",
+ "nextest": "0.9.144",
+ "machete": "0.9.2",
+ "protoc": "32.0",
+ "rustup": "1.28.2"
+ },
+ "client_codegen": {
+ "schema": 1,
+ "versions": {
+ "protobuf": "3.18.1",
+ "grpc": "1.46.3",
+ "grpc_java": "1.42.1"
+ },
+ "sources": [
+ {
+ "url": "https://github.com/protocolbuffers/protobuf/releases/download/v3.18.1/protobuf-cpp-3.18.1.tar.gz",
+ "sha256": "6ee35eda3f79e49608d2ace8d866313fdec539d8bb14c6c54e8d2a16fa4e6780"
+ },
+ {
+ "url": "https://github.com/grpc/grpc/archive/refs/tags/v1.46.3.tar.gz",
+ "sha256": "d6cbf22cb5007af71b61c6be316a79397469c58c82a942552a62e708bce60964"
+ },
+ {
+ "url": "https://github.com/grpc/grpc-java/archive/refs/tags/v1.42.1.tar.gz",
+ "sha256": "33775a1ad05974bbba6ff97801cd9b326485b21fab91b08a4e1bc53e500e6326"
+ }
+ ]
+ },
+ "profile": "rust"
+ }
+}
diff --git a/.github/runner-requirements.json b/.github/runner-requirements.json
new file mode 100644
index 00000000000..2987919fc83
--- /dev/null
+++ b/.github/runner-requirements.json
@@ -0,0 +1,228 @@
+{
+ "schema": 1,
+ "recipe_revision": "e49e8bc9977f5f961a76ba1d1f7673c72173679f",
+ "requirements": {
+ "schema": 2,
+ "contract_version": "1",
+ "platform": "linux/amd64",
+ "ubuntu_image": "ubuntu:24.04@sha256:496754492fb28b4d3049432f2ca787449331e23fb14f0dd3fffea86bf5a93eb4",
+ "apt_snapshot": "20260920T000000Z",
+ "rust_version": "1.98.1",
+ "rust_manifest_sha256": "a7c8774a5fd8441c997d94c029776cbc5eb111e9d72ab5d256fa69866644347e",
+ "artifacts": [
+ {
+ "name": "runner",
+ "url": "https://github.com/actions/runner/releases/download/v2.337.0/actions-runner-linux-x64-2.337.0.tar.gz",
+ "sha256": "70920811a4f8ad4328818682bca5c6469c1c942fab52448868071d0063816613",
+ "format": "tar",
+ "destination": "/opt/actions-runner"
+ },
+ {
+ "name": "cargo-llvm-cov",
+ "url": "https://github.com/taiki-e/cargo-llvm-cov/releases/download/v0.9.1/cargo-llvm-cov-x86_64-unknown-linux-gnu.tar.gz",
+ "sha256": "b3f68e625481fed9b16444174f3fa5ebcdbde4a1878803a35eabe2dcefcdc41a",
+ "format": "tar",
+ "destination": "/opt/ci/bin/cargo-llvm-cov",
+ "binary": "cargo-llvm-cov"
+ },
+ {
+ "name": "cargo-nextest",
+ "url": "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-0.9.144/cargo-nextest-0.9.144-x86_64-unknown-linux-gnu.tar.gz",
+ "sha256": "8a4f726272b0a1c499bd87ca3978bfbb1a8c20bb08ccf075b9996e2081bd1e1e",
+ "format": "tar",
+ "destination": "/opt/ci/bin/cargo-nextest",
+ "binary": "cargo-nextest"
+ },
+ {
+ "name": "cargo-machete",
+ "url": "https://github.com/bnjbvr/cargo-machete/releases/download/v0.9.2/cargo-machete-v0.9.2-x86_64-unknown-linux-musl.tar.gz",
+ "sha256": "48200087f54c55aabcd4db4af1e25742b49846c02a1b1bfa134711945b35b2e9",
+ "format": "tar",
+ "destination": "/opt/ci/bin/cargo-machete",
+ "binary": "cargo-machete"
+ },
+ {
+ "name": "cargo-ndk",
+ "url": "https://github.com/bbqsrc/cargo-ndk/releases/download/v4.1.2/cargo-ndk-x86_64-unknown-linux-gnu-v4.1.2.tgz",
+ "sha256": "9451622c4567e8abb2c8005001855e32901c0b716ccdd3a173a4375fd03426e1",
+ "format": "tar",
+ "destination": "/opt/ci/bin/cargo-ndk",
+ "binary": "cargo-ndk"
+ },
+ {
+ "name": "protoc",
+ "url": "https://github.com/protocolbuffers/protobuf/releases/download/v32.0/protoc-32.0-linux-x86_64.zip",
+ "sha256": "7ca037bfe5e5cabd4255ccd21dd265f79eb82d3c010117994f5dc81d2140ee88",
+ "format": "zip",
+ "destination": "/opt/protoc"
+ },
+ {
+ "name": "rustup-init",
+ "url": "https://static.rust-lang.org/rustup/archive/1.28.2/x86_64-unknown-linux-gnu/rustup-init",
+ "sha256": "20a06e644b0d9bd2fbdbfd52d42540bdde820ea7df86e92e533c073da0cdd43c",
+ "format": "file",
+ "destination": "/opt/ci/rustup-init",
+ "build_only": true
+ },
+ {
+ "name": "platforms;android-35",
+ "url": "https://dl.google.com/android/repository/platform-35_r02.zip",
+ "upstream_sha1": "0bb560a90a7a2cbd0dd8348224d518b638fe7949",
+ "format": "zip",
+ "destination": "/opt/android-sdk/platforms/android-35",
+ "archive_root": "android-35",
+ "package_xml": "\n \n 35\n 13\n true\n \n \n \n 2\n \n Android SDK Platform 35\n \n ",
+ "sha256": "0988cacad01b38a18a47bac14a0695f246bc76c1b06c0eeb8eb0dc825ab0c8e0"
+ },
+ {
+ "name": "ndk;28.1.13356709",
+ "url": "https://dl.google.com/android/repository/android-ndk-r28b-linux.zip",
+ "upstream_sha1": "f574d3165405bd59ffc5edaadac02689075a729f",
+ "format": "zip",
+ "destination": "/opt/android-sdk/ndk/28.1.13356709",
+ "archive_root": "android-ndk-r28b",
+ "package_xml": "\n \n \n 28\n 1\n 13356709\n \n NDK (Side by side) 28.1.13356709\n \n ",
+ "sha256": "e9f2759862cecfd48c20bbb7d8cfedbb020f4d91b5f78d9a2fc106f7db3c27ed"
+ },
+ {
+ "name": "build-tools;35.0.0",
+ "url": "https://dl.google.com/android/repository/build-tools_r35_linux.zip",
+ "upstream_sha1": "2cfaa0bbb2336e9ec18ed3ecea84fa2e2af607bc",
+ "format": "zip",
+ "destination": "/opt/android-sdk/build-tools/35.0.0",
+ "archive_root": "android-15",
+ "package_xml": "\n \n \n 35\n 0\n 0\n \n Android SDK Build-Tools 35\n \n ",
+ "sha256": "bd3a4966912eb8b30ed0d00b0cda6b6543b949d5ffe00bea54c04c81e1561d88"
+ },
+ {
+ "name": "cmdline-tools;19.0",
+ "url": "https://dl.google.com/android/repository/commandlinetools-linux-13114758_latest.zip",
+ "upstream_sha1": "5fdcc763663eefb86a5b8879697aa6088b041e70",
+ "format": "zip",
+ "destination": "/opt/android-sdk/cmdline-tools/19.0",
+ "archive_root": "cmdline-tools",
+ "package_xml": "\n \n \n 19\n 0\n \n Android SDK Command-line Tools\n \n ",
+ "sha256": "7ec965280a073311c339e571cd5de778b9975026cfcbe79f2b1cdcb1e15317ee"
+ },
+ {
+ "name": "platform-tools",
+ "url": "https://dl.google.com/android/repository/platform-tools_r37.0.1-linux.zip",
+ "upstream_sha1": "477254aa5f903c15cf51001717bdf347fb6b53e0",
+ "format": "zip",
+ "destination": "/opt/android-sdk/platform-tools",
+ "archive_root": "platform-tools",
+ "package_xml": "\n \n \n 37\n 0\n 1\n \n Android SDK Platform-Tools\n \n ",
+ "sha256": "d230f13842f60f782a8645f9c813f8f845bf36089ea7289f28c48f17979313f1"
+ },
+ {
+ "name": "emulator",
+ "url": "https://dl.google.com/android/repository/emulator-linux_x64-15917651.zip",
+ "upstream_sha1": "1b1f78891abf8ec268264356e1365c25519e8379",
+ "format": "zip",
+ "destination": "/opt/android-sdk/emulator",
+ "archive_root": "emulator",
+ "package_xml": "\n \n \n 37\n 1\n 11\n \n Android Emulator\n \n ",
+ "sha256": "95771e0ae431897b2a4bd2d97fa095f29a8b0624a7b216baf529f9306161c266"
+ },
+ {
+ "name": "system-images;android-35;default;x86_64",
+ "url": "https://dl.google.com/android/repository/sys-img/android/x86_64-35_r02.zip",
+ "upstream_sha1": "2d857d170c0d1b827149565da34b3383e5306f7f",
+ "format": "zip",
+ "destination": "/opt/android-sdk/system-images/android-35/default/x86_64",
+ "archive_root": "x86_64",
+ "package_xml": "\n \n 35\n 13\n true\n \n default\n Default Android System Image\n \n x86_64\n \n \n 2\n \n Intel x86_64 Atom System Image\n \n \n \n 29\n 1\n 11\n \n \n \n \n ",
+ "sha256": "6dd7de33e63ef105cf2fabea6badda1dbe7665c96d8908e5f6e1407e63ff4556"
+ }
+ ],
+ "bootstrap_ca": {
+ "url": "https://snapshot.ubuntu.com/ubuntu/20260920T000000Z/pool/main/c/ca-certificates/ca-certificates_20240203_all.deb",
+ "sha256": "641de77d8f142cfd62a1a6f964ba67b20754d3337c480efb529d086075a06c9a",
+ "version": "20240203"
+ },
+ "apt_packages": [
+ "ca-certificates",
+ "curl",
+ "git",
+ "gh",
+ "jq",
+ "python3",
+ "unzip",
+ "zip",
+ "xz-utils",
+ "bzip2",
+ "gnupg",
+ "build-essential",
+ "clang",
+ "llvm",
+ "libsnappy-dev",
+ "cmake",
+ "libgmp-dev",
+ "libssl-dev",
+ "pkg-config",
+ "openjdk-17-jdk-headless",
+ "libicu74",
+ "libkrb5-3",
+ "zlib1g",
+ "libgcc-s1",
+ "libstdc++6",
+ "libcurl4t64",
+ "liblttng-ust1t64",
+ "libunwind8",
+ "libpulse0",
+ "libx11-xcb1",
+ "libnss3",
+ "libxcomposite1",
+ "libxcursor1",
+ "libxi6",
+ "libxrandr2",
+ "libxtst6",
+ "libasound2t64",
+ "libgl1",
+ "libegl1",
+ "libdbus-1-3",
+ "libxdamage1",
+ "libxfixes3"
+ ],
+ "java_major": 17,
+ "versions": {
+ "runner": "2.337.0",
+ "llvm_cov": "0.9.1",
+ "nextest": "0.9.144",
+ "machete": "0.9.2",
+ "cargo_ndk": "4.1.2",
+ "protoc": "32.0",
+ "rustup": "1.28.2"
+ },
+ "android": {
+ "api": 35,
+ "build_tools": "35.0.0",
+ "ndk": "28.1.13356709",
+ "cmdline_tools": "19.0",
+ "system_image": "system-images;android-35;default;x86_64",
+ "abi": "x86_64"
+ },
+ "client_codegen": {
+ "schema": 1,
+ "versions": {
+ "protobuf": "3.18.1",
+ "grpc": "1.46.3",
+ "grpc_java": "1.42.1"
+ },
+ "sources": [
+ {
+ "url": "https://github.com/protocolbuffers/protobuf/releases/download/v3.18.1/protobuf-cpp-3.18.1.tar.gz",
+ "sha256": "6ee35eda3f79e49608d2ace8d866313fdec539d8bb14c6c54e8d2a16fa4e6780"
+ },
+ {
+ "url": "https://github.com/grpc/grpc/archive/refs/tags/v1.46.3.tar.gz",
+ "sha256": "d6cbf22cb5007af71b61c6be316a79397469c58c82a942552a62e708bce60964"
+ },
+ {
+ "url": "https://github.com/grpc/grpc-java/archive/refs/tags/v1.42.1.tar.gz",
+ "sha256": "33775a1ad05974bbba6ff97801cd9b326485b21fab91b08a4e1bc53e500e6326"
+ }
+ ]
+ }
+ }
+}
diff --git a/.github/scripts/kotlin-instrumented-tests.sh b/.github/scripts/kotlin-instrumented-tests.sh
new file mode 100644
index 00000000000..d0b239a64bc
--- /dev/null
+++ b/.github/scripts/kotlin-instrumented-tests.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+# Called from packages/kotlin-sdk by the image's ci-android-emulator wrapper.
+set -euo pipefail
+
+# Keystore's unlocked-device-required keys fail if the screen re-locks mid-test.
+adb shell settings put system screen_off_timeout 2147483647
+adb shell svc power stayon true
+
+# Auth-required identity keys need an enrolled secure lockscreen on the test AVD.
+adb shell locksettings set-pin 1234
+
+# Cold boot can race credential acceptance and keyguard dismissal. Preserve the
+# upstream retry and trust-state gate, now in one shell (not line-by-line sh -c).
+unlocked=false
+for attempt in 1 2 3; do
+ adb shell input keyevent KEYCODE_WAKEUP
+ adb shell wm dismiss-keyguard
+ adb shell input text 1234
+ adb shell input keyevent KEYCODE_ENTER
+ sleep 2
+ adb shell wm dismiss-keyguard
+ sleep 1
+ if adb shell dumpsys trust | grep -q 'deviceLocked=0'; then
+ unlocked=true
+ break
+ fi
+done
+if [ "$unlocked" != true ]; then
+ echo '::error::Emulator is still locked; Keystore-backed tests would fail spuriously.'
+ adb shell dumpsys trust
+ exit 1
+fi
+
+./gradlew :sdk:connectedDebugAndroidTest --stacktrace "$@"
diff --git a/.github/scripts/runner-image.py b/.github/scripts/runner-image.py
new file mode 100644
index 00000000000..6f55c45b158
--- /dev/null
+++ b/.github/scripts/runner-image.py
@@ -0,0 +1,218 @@
+#!/usr/bin/env python3
+"""Select an exact PR image and export the shared runner tool requirements."""
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import subprocess
+import sys
+import time
+import urllib.parse
+import urllib.request
+
+MANIFEST = ".github/runner-requirements.json"
+ARM64_MANIFEST = ".github/runner-requirements.arm64.json"
+REPO = "dashpay/platform"
+
+
+def require(condition, message):
+ if not condition:
+ raise ValueError(message)
+
+
+def read_manifest(path):
+ data = Path(path).read_bytes()
+ require(len(data) <= 256 * 1024, "Requirements manifest is too large")
+ manifest = json.loads(data)
+ require(set(manifest) == {"schema", "recipe_revision", "requirements"} and manifest["schema"] == 1,
+ "Unsupported requirements manifest")
+ require(re.fullmatch(r"[0-9a-f]{40}", manifest["recipe_revision"]), "Pin the image recipe to a full SHA")
+ require(manifest["requirements"]["platform"] in ("linux/amd64", "linux/arm64"),
+ "Unsupported image platform")
+ if manifest["requirements"]["platform"] == "linux/arm64":
+ require(manifest["requirements"].get("profile") == "rust", "ARM64 requires the Rust-only profile")
+ return manifest
+
+
+def runtime_manifest():
+ # Hosted selector jobs must not select a manifest for the eventual runner.
+ # Only env/verify use the actual job runner's OS and architecture.
+ return ARM64_MANIFEST if (os.environ.get("RUNNER_OS") == "Linux"
+ and os.environ.get("RUNNER_ARCH") == "ARM64") else MANIFEST
+
+
+def fingerprint(value):
+ return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
+
+
+def api(path):
+ token = os.environ.get("GH_TOKEN", "")
+ headers = {"Accept": "application/vnd.github+json", "User-Agent": "platform-runner-image"}
+ if token:
+ headers["Authorization"] = "Bearer " + token
+ request = urllib.request.Request("https://api.github.com/repos/" + REPO + "/" + path, headers=headers)
+ with urllib.request.urlopen(request, timeout=30) as response:
+ return json.load(response)
+
+
+def changed_requirements(pr, manifest_path=MANIFEST):
+ require(pr.get("changed_files", 0) <= 3000, "PR exceeds GitHub's file-list limit; requirements need explicit review")
+ for page in range(1, 31):
+ files = api(f"pulls/{pr['number']}/files?per_page=100&page={page}")
+ if any(f["filename"] == manifest_path or f.get("previous_filename") == manifest_path for f in files):
+ return True
+ if len(files) < 100:
+ return False
+ return False
+
+
+def latest_status(head, context):
+ # The combined /status endpoint omits creator. Full statuses are newest
+ # first: select before validating, never fall back to an older success.
+ page = 1
+ while True:
+ statuses = api(f"commits/{head}/statuses?per_page=100&page={page}")
+ # GitHub contexts are case-insensitive; a case variant must shadow
+ # older canonical statuses even though it cannot be trusted below.
+ candidate = next((s for s in statuses if s["context"].casefold() == context.casefold()), None)
+ if candidate is not None:
+ return candidate
+ if len(statuses) < 100:
+ return None
+ page += 1
+
+
+def export_environment(manifest, output):
+ lock = manifest["requirements"]
+ versions = lock["versions"]
+ values = {
+ "CI_CARGO_LLVM_COV_VERSION": versions["llvm_cov"],
+ "CI_CARGO_NEXTEST_VERSION": versions["nextest"],
+ "CI_CARGO_MACHETE_VERSION": versions["machete"],
+ "CI_PROTOC_VERSION": versions["protoc"], "CI_JAVA_MAJOR": str(lock["java_major"]),
+ }
+ if lock.get("profile", "full") == "full":
+ android = lock["android"]
+ values.update({
+ "CI_CARGO_NDK_VERSION": versions["cargo_ndk"],
+ "CI_ANDROID_API": str(android["api"]), "CI_ANDROID_NDK": android["ndk"],
+ "CI_ANDROID_BUILD_TOOLS": android["build_tools"],
+ })
+ require(all(isinstance(value, str) and re.fullmatch(r"[0-9]+(?:[.][0-9]+){0,3}(?:[-+][A-Za-z0-9.-]+)?", value)
+ for value in values.values()), "Versions must be version-pinned, newline-free values")
+ with open(output, "a") as handle:
+ for key, value in values.items():
+ handle.write(f"{key}={value}\n")
+
+
+def select(manifest, kind, output, wait_seconds, arch=None, validation=False):
+ require(arch in (None, "", "X64", "ARM64"), "Unsupported runner architecture")
+ require(not arch or kind == "rust", "Architecture selection is only supported for Rust")
+ require(not validation or (kind == "rust" and arch == "ARM64"),
+ "The validation-only pool is for explicitly selected ARM64 Rust jobs")
+ # Linux describes the runner process, not the physical host: ARM64 Linux
+ # containers on Macs remain in this pool; native macOS stays for Swift.
+ fallback = ["self-hosted"] + (["Linux"] if kind == "rust" else [])
+ if arch:
+ fallback.append(arch)
+ fallback.append("rust-ci-validation" if validation else
+ {"rust": "rust-ci", "kotlin": "kotlin-ci", "npm": "npm-pr"}[kind])
+ event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
+ requested = event.get("pull_request")
+ labels, changed = fallback, False
+ if requested:
+ pr = api(f"pulls/{requested['number']}")
+ head = requested["head"]["sha"]
+ require(pr["state"] == "open" and pr["head"]["sha"] == head, "This PR run has been superseded")
+ changed = changed_requirements(pr, ARM64_MANIFEST if arch == "ARM64" else MANIFEST)
+ if not arch and kind == "rust" and changed_requirements(pr, ARM64_MANIFEST):
+ # Only validation capacity has the new ARM64 image before rollout.
+ # Keep this PR's ordinary job on unchanged AMD64 capacity while its
+ # separate ARM64 job proves the new manifest on the validation pool.
+ labels = fallback = ["self-hosted", "Linux", "X64", "rust-ci"]
+ if arch == "ARM64":
+ # ARM64 is explicitly provisioned from a published immutable image.
+ # The AMD64/KVM candidate publisher is not ARM64 validation. The
+ # separate ARM64 job verifies its exact lock/recipe on real capacity.
+ if changed:
+ import base64
+ remote = api("contents/" + ARM64_MANIFEST + "?" + urllib.parse.urlencode({"ref": head}))
+ expected = json.loads(base64.b64decode(remote["content"]))
+ require(fingerprint(expected) == fingerprint(read_manifest(ARM64_MANIFEST)),
+ "Merge-tree ARM64 requirements differ from PR head; rebase before validation")
+ with open(output, "a") as handle:
+ handle.write("labels=" + json.dumps(fallback, separators=(",", ":")) + "\n")
+ handle.write("image_changed=" + str(changed).lower() + "\n")
+ print("Using explicitly provisioned ARM64 image capacity; exact runtime verification is required")
+ return
+ if changed:
+ # Use the exact PR requirement, not an accidental merge-tree mix
+ # after both branches edited this file. Rebase such a PR first.
+ import base64
+ remote = api("contents/" + MANIFEST + "?" + urllib.parse.urlencode({"ref": head}))
+ expected = json.loads(base64.b64decode(remote["content"]))
+ require(fingerprint(expected) == fingerprint(manifest),
+ "Merge-tree requirements differ from PR head; rebase before building a candidate")
+ deadline = time.monotonic() + wait_seconds
+ context = f"Runner image candidate / PR {pr['number']}"
+ while True:
+ candidate = latest_status(head, context)
+ if candidate and candidate["state"] == "success":
+ require(candidate["context"] == context, "Candidate status must use the exact publisher context")
+ creator = candidate.get("creator")
+ require(isinstance(creator, dict) and creator.get("login") == "github-actions[bot]",
+ "Candidate status must come from the trusted publisher")
+ require(re.fullmatch(r"sha256:[0-9a-f]{64}", candidate.get("description", "")),
+ "Publisher did not record an immutable digest")
+ match = re.fullmatch(r"https://github[.]com/dashpay/platform/actions/runs/([0-9]+)",
+ candidate.get("target_url", ""))
+ require(match, "Candidate status is not linked to its publishing workflow")
+ run = api(f"actions/runs/{match.group(1)}")
+ require(run["path"] == ".github/workflows/runner-image-candidate.yml"
+ and run["event"] == "pull_request_target", "Unexpected candidate publisher")
+ if run["conclusion"] == "success":
+ labels = ["self-hosted", "Linux", "X64",
+ f"platform-image-pr-{pr['number']}-{head}-{candidate['description'][7:]}-{kind}"]
+ break
+ require(time.monotonic() < deadline,
+ "Candidate image was not published in time. Check Runner image candidate CI and bootstrap setup.")
+ current = api(f"pulls/{pr['number']}")
+ require(current["state"] == "open" and current["head"]["sha"] == head, "PR changed while waiting")
+ time.sleep(20)
+ with open(output, "a") as handle:
+ handle.write("labels=" + json.dumps(labels, separators=(",", ":")) + "\n")
+ handle.write("image_changed=" + str(changed).lower() + "\n")
+ print("Candidate runner required" if changed else "Using the ordinary provisioned runner pool")
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("command", choices=["env", "verify", "select"])
+ parser.add_argument("--manifest")
+ parser.add_argument("--env", default=os.environ.get("GITHUB_ENV"))
+ parser.add_argument("--output", default=os.environ.get("GITHUB_OUTPUT"))
+ parser.add_argument("--kind", choices=["rust", "kotlin", "npm"])
+ parser.add_argument("--arch", choices=["", "X64", "ARM64"])
+ parser.add_argument("--validation", action="store_true")
+ parser.add_argument("--wait-seconds", type=int, default=7200)
+ args = parser.parse_args()
+ manifest_path = args.manifest or (MANIFEST if args.command == "select" else runtime_manifest())
+ manifest = read_manifest(manifest_path)
+ if args.command == "select":
+ require(args.kind and args.output, "Runner selection needs kind and output")
+ select(manifest, args.kind, args.output, args.wait_seconds, args.arch, args.validation)
+ return
+ if args.command == "verify":
+ subprocess.run(["ci-image-contract", "verify", manifest_path], check=True)
+ require(args.env, "Environment output file is required")
+ export_environment(manifest, args.env)
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except (ValueError, KeyError, TypeError, OSError) as error:
+ print(f"::error::{error}", file=sys.stderr)
+ sys.exit(1)
diff --git a/.github/scripts/tests/fixtures/candidate-status-pr5151.json b/.github/scripts/tests/fixtures/candidate-status-pr5151.json
new file mode 100644
index 00000000000..7cb66d5a787
--- /dev/null
+++ b/.github/scripts/tests/fixtures/candidate-status-pr5151.json
@@ -0,0 +1,40 @@
+{
+ "combined": {
+ "state": "pending",
+ "sha": "a02b1460736e18b6345bb4722c622e55e787371d",
+ "total_count": 4,
+ "statuses": [
+ {
+ "id": 55116170875,
+ "context": "Runner image candidate / PR 5151",
+ "state": "success",
+ "description": "sha256:e5ebd957d28d15976320023b76cffa8b982e1d131c572d92eb9c91814dbf807b",
+ "target_url": "https://github.com/dashpay/platform/actions/runs/36474975257",
+ "created_at": "2026-09-28T20:13:10Z",
+ "updated_at": "2026-09-28T20:13:10Z"
+ }
+ ]
+ },
+ "statuses": [
+ {
+ "id": 55116170875,
+ "context": "Runner image candidate / PR 5151",
+ "state": "success",
+ "description": "sha256:e5ebd957d28d15976320023b76cffa8b982e1d131c572d92eb9c91814dbf807b",
+ "target_url": "https://github.com/dashpay/platform/actions/runs/36474975257",
+ "created_at": "2026-09-28T20:13:10Z",
+ "updated_at": "2026-09-28T20:13:10Z",
+ "creator": {
+ "login": "github-actions[bot]",
+ "id": 41898282
+ }
+ }
+ ],
+ "publisher_run": {
+ "id": 36474975257,
+ "path": ".github/workflows/runner-image-candidate.yml",
+ "event": "pull_request_target",
+ "status": "completed",
+ "conclusion": "success"
+ }
+}
diff --git a/.github/scripts/tests/test_npm_release_boundary.py b/.github/scripts/tests/test_npm_release_boundary.py
new file mode 100644
index 00000000000..f457408204b
--- /dev/null
+++ b/.github/scripts/tests/test_npm_release_boundary.py
@@ -0,0 +1,100 @@
+"""Exercise caller/runtime guards and preserve the PR/release cache boundary."""
+import os
+from pathlib import Path
+import subprocess
+import textwrap
+import unittest
+
+ROOT = Path(__file__).resolve().parents[3]
+
+
+def step_script(filename, name):
+ workflow = (ROOT / '.github/workflows' / filename).read_text()
+ start = workflow.index(' - name: ' + name)
+ tail = workflow[start:].split(' run: |\n', 1)[1]
+ lines = []
+ for line in tail.splitlines():
+ if line.strip() and not line.startswith(' '):
+ break
+ lines.append(line)
+ return textwrap.dedent('\n'.join(lines))
+
+
+class ReleaseBoundaryTests(unittest.TestCase):
+ def run_guard(self, event, ref, repository='dashpay/platform', protected=False):
+ script = step_script('release-npm-build.yml', 'Reject untrusted release callers')
+ return subprocess.run(['bash', '-c', script], capture_output=True, text=True,
+ env=dict(os.environ, GITHUB_REPOSITORY=repository,
+ GITHUB_EVENT_NAME=event, GITHUB_REF=ref,
+ GITHUB_REF_PROTECTED=str(protected).lower())).returncode
+
+ def test_should_allow_release_tags_and_protected_branch_dry_runs(self):
+ for event, ref in [('release', 'refs/tags/v4.2.0-beta.5'),
+ ('workflow_dispatch', 'refs/tags/v4.2.0-beta.5'),
+ ('workflow_dispatch', 'refs/heads/v4.2-dev'),
+ ('workflow_dispatch', 'refs/heads/v4.3-dev')]:
+ with self.subTest(event=event, ref=ref):
+ self.assertEqual(self.run_guard(event, ref, protected=True), 0)
+
+ def test_should_reject_prs_forks_and_unprotected_dispatches(self):
+ for event, ref in [('pull_request', 'refs/pull/5068/merge'),
+ ('pull_request_target', 'refs/heads/v4.2-dev'),
+ ('workflow_dispatch', 'refs/heads/attacker'),
+ ('push', 'refs/heads/v4.2-dev'),
+ ('release', 'refs/heads/v4.2-dev')]:
+ with self.subTest(event=event, ref=ref):
+ self.assertNotEqual(self.run_guard(event, ref), 0)
+ self.assertNotEqual(self.run_guard('release', 'refs/tags/v4.2.0', 'unknown/platform'), 0)
+ self.assertNotEqual(self.run_guard('workflow_dispatch', 'refs/heads/v4.2-dev'), 0)
+
+ def test_should_reject_persistent_wrong_attempt_and_wrong_kind_runners(self):
+ for kind, filename in [('npm', 'release-npm-build.yml'), ('kotlin', 'release-kotlin-sdk.yml')]:
+ script = step_script(filename, 'Verify disposable release runner')
+ env = dict(os.environ, GITHUB_RUN_ID='123', GITHUB_RUN_ATTEMPT='2',
+ DASH_RELEASE_RUNNER='1', DASH_RELEASE_RUN_ID='123',
+ DASH_RELEASE_RUN_ATTEMPT='2', DASH_RELEASE_KIND=kind)
+ self.assertEqual(subprocess.run(['bash', '-c', script], env=env).returncode, 0)
+ for key, value in [('DASH_RELEASE_RUNNER', ''), ('DASH_RELEASE_RUN_ID', '124'),
+ ('DASH_RELEASE_RUN_ATTEMPT', '1'), ('DASH_RELEASE_KIND', 'rust')]:
+ with self.subTest(kind=kind, key=key):
+ self.assertNotEqual(subprocess.run(['bash', '-c', script],
+ env=dict(env, **{key: value})).returncode, 0)
+
+ def test_should_route_both_release_builds_away_from_persistent_ci(self):
+ for kind, filename in [('npm', 'release-npm-build.yml'), ('kotlin', 'release-kotlin-sdk.yml')]:
+ workflow = (ROOT / '.github/workflows' / filename).read_text()
+ build = workflow.split(' attach-release:', 1)[0]
+ self.assertIn('group: platform-release-builds', build)
+ self.assertIn('platform-release-${{ github.run_id }}-${{ github.run_attempt }}-' + kind, build)
+ self.assertNotIn('runs-on: [self-hosted, kotlin-ci]', build)
+ self.assertNotIn('Linux, X64, npm-build', build)
+ self.assertLess(build.index('Verify disposable release runner'), build.index('uses: actions/checkout'))
+ caller = (ROOT / '.github/workflows/release.yml').read_text()
+ self.assertIn('uses: ./.github/workflows/release-npm-build.yml', caller)
+ self.assertNotIn('release-npm-build.yml@v4.2-dev', caller)
+
+ def test_should_keep_pr_caching_enabled_but_exclude_it_from_releases(self):
+ node = (ROOT / '.github/actions/nodejs/action.yaml').read_text()
+ cache_input = node.split(' cache:\n', 1)[1].split(' node-version:', 1)[0]
+ self.assertIn('default: "true"', cache_input)
+ self.assertIn("if: inputs.cache == 'true'", node)
+ self.assertIn('uses: actions/cache@v5', node)
+ npm = (ROOT / '.github/actions/npm-release-build/action.yaml').read_text()
+ self.assertIn("cache: ${{ env.DASH_RELEASE_RUNNER != '1' }}", npm)
+ kotlin = (ROOT / '.github/workflows/release-kotlin-sdk.yml').read_text()
+ for gradle in kotlin.split('uses: gradle/actions/setup-gradle@v4')[1:]:
+ self.assertIn('cache-disabled: true', gradle.split('\n - ', 1)[0])
+ # The ordinary image-validation workflow retains its own cache name
+ # and does not opt into the release runtime marker.
+ validation = (ROOT / '.github/workflows/npm-runner-validation.yml').read_text()
+ self.assertIn('cache-name: npm-validation-target', validation)
+ self.assertNotIn('DASH_RELEASE_RUNNER:', validation)
+
+ def test_should_keep_kotlin_dry_runs_out_of_both_publishing_jobs(self):
+ kotlin = (ROOT / '.github/workflows/release-kotlin-sdk.yml').read_text()
+ attach = kotlin.split(' attach-release:', 1)[1].split(' steps:', 1)[0]
+ maven = kotlin.split(' maven-central-deploy:', 1)[1].split(' steps:', 1)[0]
+ self.assertIn('if: ${{ !inputs.dry_run }}', attach)
+ self.assertIn('if: ${{ !inputs.dry_run &&', maven)
+ self.assertIn("github.ref == format('refs/tags/{0}', inputs.tag)", maven)
+ self.assertIn('environment: maven-central', maven)
diff --git a/.github/scripts/tests/test_runner_image.py b/.github/scripts/tests/test_runner_image.py
new file mode 100644
index 00000000000..c811e406d35
--- /dev/null
+++ b/.github/scripts/tests/test_runner_image.py
@@ -0,0 +1,331 @@
+"""Exercise runner routing, stale candidates and safe environment exports."""
+import base64
+import copy
+import importlib.util
+import json
+import os
+import sys
+from pathlib import Path
+import tempfile
+import unittest
+from urllib.error import URLError
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[3]
+spec = importlib.util.spec_from_file_location("runner_image", ROOT / ".github/scripts/runner-image.py")
+runner = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(runner)
+# Minimal projections of public REST responses captured 2026-09-28 for PR 5151.
+# /status has Simple Commit Status objects (no creator); /statuses has creator.
+FIXTURE = json.loads((Path(__file__).parent / "fixtures/candidate-status-pr5151.json").read_text())
+HEAD = FIXTURE["combined"]["sha"]
+DIGEST = FIXTURE["statuses"][0]["description"]
+STATUS_PATH = f"commits/{HEAD}/statuses?per_page=100&page=1"
+RUN_PATH = f"actions/runs/{FIXTURE['publisher_run']['id']}"
+
+
+class SelectorTests(unittest.TestCase):
+ def setUp(self):
+ self.manifest = runner.read_manifest(ROOT / runner.MANIFEST)
+ self.temp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp.cleanup)
+ self.event = Path(self.temp.name) / "event.json"
+ self.output = Path(self.temp.name) / "output"
+ self.pr = {"number": 5151, "state": "open", "changed_files": 1,
+ "head": {"sha": HEAD}}
+ self.responses = {
+ "pulls/5151": self.pr,
+ "pulls/5151/files?per_page=100&page=1": [{"filename": runner.MANIFEST}],
+ f"contents/{runner.MANIFEST}?ref={HEAD}": {
+ "content": base64.b64encode(json.dumps(self.manifest).encode()).decode()},
+ f"commits/{HEAD}/status": copy.deepcopy(FIXTURE["combined"]),
+ STATUS_PATH: copy.deepcopy(FIXTURE["statuses"]),
+ RUN_PATH: copy.deepcopy(FIXTURE["publisher_run"]),
+ }
+
+ def api_response(self, path):
+ response = self.responses[path]
+ if isinstance(response, Exception):
+ raise response
+ return response
+
+ def status_calls(self):
+ return [call.args[0] for call in self.api.call_args_list
+ if call.args[0].startswith("commits/")]
+
+ def select(self, event=None, kind="rust", arch=None, validation=False, wait_seconds=0):
+ self.event.write_text(json.dumps(event if event is not None else {"pull_request": self.pr}))
+ with patch.dict(os.environ, {"GITHUB_EVENT_PATH": str(self.event)}), \
+ patch.object(runner, "api", side_effect=self.api_response) as api:
+ self.api = api
+ runner.select(self.manifest, kind, self.output, wait_seconds, arch, validation)
+ return dict(line.split("=", 1) for line in self.output.read_text().splitlines())
+
+ def test_non_pr_and_unchanged_pr_use_existing_pool(self):
+ self.assertEqual(json.loads(self.select({})["labels"]), ["self-hosted", "Linux", "rust-ci"])
+ self.responses["pulls/5151/files?per_page=100&page=1"] = [{"filename": "Cargo.lock"}]
+ self.assertEqual(self.select()["image_changed"], "false")
+
+ def test_exact_candidate_includes_head_digest_and_kind(self):
+ self.assertNotIn("creator", FIXTURE["combined"]["statuses"][0])
+ for kind in ("kotlin", "rust", "npm"):
+ with self.subTest(kind=kind):
+ output = self.select(kind=kind)
+ self.assertEqual(json.loads(output["labels"]), ["self-hosted", "Linux", "X64",
+ f"platform-image-pr-5151-{HEAD}-{DIGEST[7:]}-{kind}"])
+ self.assertEqual(output["image_changed"], "true")
+ self.assertEqual(self.status_calls(), [STATUS_PATH])
+
+ def test_should_keep_npm_ordinary_pool_labels(self):
+ self.assertEqual(json.loads(self.select({}, kind="npm")["labels"]), ["self-hosted", "npm-pr"])
+
+ def test_new_head_or_closed_pr_rejects_stale_run(self):
+ event = {"pull_request": copy.deepcopy(self.pr)}
+ self.pr["head"]["sha"] = "e" * 40
+ with self.assertRaisesRegex(ValueError, "superseded"):
+ self.select(event)
+ self.pr["head"]["sha"] = HEAD
+ self.pr["state"] = "closed"
+ with self.assertRaisesRegex(ValueError, "superseded"):
+ self.select(event)
+
+ def test_merge_tree_cannot_mix_requirements_from_both_branches(self):
+ self.manifest["recipe_revision"] = "e" * 40
+ with self.assertRaisesRegex(ValueError, "rebase"):
+ self.select()
+
+ def test_missing_or_incomplete_publisher_cannot_select_image(self):
+ for conclusion in (None, "failure", "cancelled"):
+ with self.subTest(conclusion=conclusion):
+ self.responses[RUN_PATH]["conclusion"] = conclusion
+ with self.assertRaisesRegex(ValueError, "not published"):
+ self.select()
+ self.assertFalse(self.output.exists())
+ self.responses[STATUS_PATH] = []
+ with self.assertRaisesRegex(ValueError, "not published"):
+ self.select()
+
+ def test_other_workflow_cannot_supply_candidate_status(self):
+ for field, value in (("path", ".github/workflows/tests.yml"), ("event", "pull_request")):
+ with self.subTest(field=field):
+ self.responses[RUN_PATH] = dict(FIXTURE["publisher_run"], **{field: value})
+ with self.assertRaisesRegex(ValueError, "Unexpected candidate"):
+ self.select()
+ self.assertFalse(self.output.exists())
+
+ def test_should_reject_missing_null_malformed_or_wrong_creator_without_fallback(self):
+ # The real combined response is also a regression case: no creator.
+ missing = FIXTURE["combined"]["statuses"][0]
+ good = FIXTURE["statuses"][0]
+ candidates = [missing] + [dict(good, creator=creator) for creator in (
+ None, {}, "github-actions[bot]", [], 42,
+ {"login": None}, {"login": "untrusted-user"},
+ )]
+ for candidate in candidates:
+ with self.subTest(creator=candidate.get("creator", "absent")):
+ self.responses[STATUS_PATH] = [candidate, good]
+ with self.assertRaisesRegex(ValueError, "trusted publisher"):
+ self.select()
+ self.assertEqual(self.status_calls(), [STATUS_PATH])
+ self.assertNotIn(RUN_PATH, [call.args[0] for call in self.api.call_args_list])
+ self.assertFalse(self.output.exists())
+
+ def test_should_reject_invalid_digest_or_publisher_url_without_fallback(self):
+ good = FIXTURE["statuses"][0]
+ for field, value, error in (
+ ("description", "sha256:abc", "immutable digest"),
+ ("description", "latest", "immutable digest"),
+ ("target_url", "https://github.com/other/platform/actions/runs/7", "publishing workflow"),
+ ("target_url", good["target_url"] + "/jobs/1", "publishing workflow"),
+ ):
+ with self.subTest(field=field, value=value):
+ self.responses[STATUS_PATH] = [dict(good, **{field: value}), good]
+ with self.assertRaisesRegex(ValueError, error):
+ self.select()
+ self.assertFalse(self.output.exists())
+
+ def test_should_block_older_success_when_newest_is_pending_failure_or_error(self):
+ good = FIXTURE["statuses"][0]
+ for state in ("pending", "failure", "error"):
+ with self.subTest(state=state):
+ self.responses[STATUS_PATH] = [dict(good, state=state), good]
+ with self.assertRaisesRegex(ValueError, "not published"):
+ self.select()
+ self.assertEqual(self.status_calls(), [STATUS_PATH])
+ self.assertNotIn(RUN_PATH, [call.args[0] for call in self.api.call_args_list])
+ self.assertFalse(self.output.exists())
+
+ def test_should_use_first_success_without_unnecessary_pagination(self):
+ good = FIXTURE["statuses"][0]
+ older = dict(good, description="sha256:" + "e" * 64)
+ self.responses[STATUS_PATH] = [good] + [older] * 99
+ labels = json.loads(self.select()["labels"])
+ self.assertEqual(labels[-1], f"platform-image-pr-5151-{HEAD}-{DIGEST[7:]}-rust")
+ self.assertEqual(self.status_calls(), [STATUS_PATH])
+
+ def test_should_shadow_canonical_success_with_newer_case_variant(self):
+ good = FIXTURE["statuses"][0]
+ for state in ("success", "pending", "failure", "error"):
+ with self.subTest(state=state):
+ newer = dict(good, context=good["context"].lower(), state=state)
+ self.responses[STATUS_PATH] = [newer] + [good] * 99
+ error = "exact publisher context" if state == "success" else "not published"
+ with self.assertRaisesRegex(ValueError, error):
+ self.select()
+ self.assertEqual(self.status_calls(), [STATUS_PATH])
+ self.assertNotIn(RUN_PATH, [call.args[0] for call in self.api.call_args_list])
+ self.assertFalse(self.output.exists())
+
+ def test_should_select_first_match_on_later_page_before_validation(self):
+ good = FIXTURE["statuses"][0]
+ self.responses[STATUS_PATH] = [dict(good, context="unrelated")] * 100
+ page2 = STATUS_PATH.replace("&page=1", "&page=2")
+ for state in ("pending", "success"):
+ with self.subTest(state=state):
+ self.responses[page2] = [dict(good, state=state)] + [good] * 99
+ if state == "pending":
+ with self.assertRaisesRegex(ValueError, "not published"):
+ self.select()
+ self.assertFalse(self.output.exists())
+ else:
+ self.assertEqual(self.select()["image_changed"], "true")
+ self.assertEqual(self.status_calls(), [STATUS_PATH, page2])
+
+ def test_should_require_exact_context_and_stop_at_page_exhaustion(self):
+ good = FIXTURE["statuses"][0]
+ unrelated = [dict(good, context=context) for context in (
+ "Runner image candidate / PR 51510", "Runner image candidate / PR 5151 suffix",
+ "Runner image candidate / PR 5151 ", "Runner image candidate / PR 515",
+ )]
+ page2 = STATUS_PATH.replace("&page=1", "&page=2")
+ for last_page in ([], unrelated):
+ with self.subTest(last_page_size=len(last_page)):
+ self.responses[STATUS_PATH] = unrelated * 25
+ self.responses[page2] = last_page
+ with self.assertRaisesRegex(ValueError, "not published"):
+ self.select()
+ self.assertEqual(self.status_calls(), [STATUS_PATH, page2])
+ self.assertFalse(self.output.exists())
+
+ def test_should_fail_closed_on_status_api_failure(self):
+ page2 = STATUS_PATH.replace("&page=1", "&page=2")
+ for failed_path in (STATUS_PATH, page2):
+ with self.subTest(failed_path=failed_path):
+ self.responses[STATUS_PATH] = [dict(FIXTURE["statuses"][0], context="other")] * 100
+ self.responses[failed_path] = URLError("status API unavailable")
+ with self.assertRaisesRegex(URLError, "status API unavailable"):
+ self.select()
+ self.assertFalse(self.output.exists())
+
+ def test_should_retry_pending_status_and_publisher_run(self):
+ good = FIXTURE["statuses"][0]
+ for pending in ("status", "run"):
+ with self.subTest(pending=pending):
+ self.responses[STATUS_PATH] = [dict(good, state="pending"), good] if pending == "status" else [good]
+ self.responses[RUN_PATH]["conclusion"] = None if pending == "run" else "success"
+
+ def publish(_seconds):
+ self.responses[STATUS_PATH] = [good]
+ self.responses[RUN_PATH]["conclusion"] = "success"
+
+ with patch.object(runner.time, "monotonic", return_value=0), \
+ patch.object(runner.time, "sleep", side_effect=publish) as sleep:
+ self.assertEqual(self.select(wait_seconds=60)["image_changed"], "true")
+ sleep.assert_called_once_with(20)
+ self.assertEqual(self.status_calls(), [STATUS_PATH, STATUS_PATH])
+ self.assertEqual([call.args[0] for call in self.api.call_args_list].count("pulls/5151"), 2)
+
+ def test_environment_export_rejects_multiline_values_before_writing(self):
+ self.manifest["requirements"]["versions"]["protoc"] = "32.0\nINJECTED=yes"
+ with self.assertRaisesRegex(ValueError, "newline-free"):
+ runner.export_environment(self.manifest, self.output)
+ self.assertFalse(self.output.exists())
+
+ def test_arm64_validation_never_consumes_amd64_candidate_status(self):
+ self.responses[STATUS_PATH] = []
+ output = self.select(arch="ARM64")
+ self.assertEqual(json.loads(output["labels"]), ["self-hosted", "Linux", "ARM64", "rust-ci"])
+
+ def test_arm64_manifest_change_does_not_use_stale_ordinary_arm64_runners(self):
+ self.responses["pulls/5151/files?per_page=100&page=1"] = [{"filename": runner.ARM64_MANIFEST}]
+ output = self.select()
+ self.assertEqual(json.loads(output["labels"]), ["self-hosted", "Linux", "X64", "rust-ci"])
+ self.assertEqual(output["image_changed"], "false")
+ # Mac-backed ARM64 capacity remains available to ordinary, unrelated PRs.
+ self.responses["pulls/5151/files?per_page=100&page=1"] = [{"filename": "Cargo.lock"}]
+ self.assertEqual(json.loads(self.select()["labels"]), ["self-hosted", "Linux", "rust-ci"])
+
+ def test_both_manifest_changes_still_require_exact_amd64_candidate(self):
+ self.responses["pulls/5151/files?per_page=100&page=1"] = [
+ {"filename": runner.MANIFEST}, {"filename": runner.ARM64_MANIFEST}]
+ output = self.select()
+ self.assertEqual(json.loads(output["labels"]), ["self-hosted", "Linux", "X64",
+ f"platform-image-pr-5151-{HEAD}-{DIGEST[7:]}-rust"])
+ self.assertEqual(output["image_changed"], "true")
+
+ def test_arm64_validation_rejects_merge_tree_drift(self):
+ arm = runner.read_manifest(ROOT / runner.ARM64_MANIFEST)
+ self.responses["pulls/5151/files?per_page=100&page=1"] = [{"filename": runner.ARM64_MANIFEST}]
+ remote = copy.deepcopy(arm)
+ remote["recipe_revision"] = "e" * 40
+ self.responses[f"contents/{runner.ARM64_MANIFEST}?ref={HEAD}"] = {
+ "content": base64.b64encode(json.dumps(remote).encode()).decode()}
+ with patch.object(runner, "read_manifest", return_value=arm):
+ with self.assertRaisesRegex(ValueError, "rebase"):
+ self.select(arch="ARM64")
+ self.responses[f"contents/{runner.ARM64_MANIFEST}?ref={HEAD}"] = {
+ "content": base64.b64encode(json.dumps(arm).encode()).decode()}
+ with patch.object(runner, "read_manifest", return_value=arm):
+ self.assertEqual(self.select(arch="ARM64")["image_changed"], "true")
+
+ def test_arm64_cannot_be_requested_for_android(self):
+ with self.assertRaisesRegex(ValueError, "only supported for Rust"):
+ self.select(kind="kotlin", arch="ARM64")
+
+ def test_arm64_validation_pool_is_not_ordinary_capacity(self):
+ labels = json.loads(self.select({}, arch="ARM64", validation=True)["labels"])
+ self.assertEqual(labels, ["self-hosted", "Linux", "ARM64", "rust-ci-validation"])
+ self.assertNotIn("rust-ci", labels)
+ with self.assertRaisesRegex(ValueError, "validation-only pool"):
+ self.select({}, validation=True)
+
+ def test_runtime_manifest_keeps_native_macos_and_amd64_separate(self):
+ for os_name, arch, expected in [
+ ("Linux", "ARM64", runner.ARM64_MANIFEST),
+ ("Linux", "X64", runner.MANIFEST),
+ ("macOS", "ARM64", runner.MANIFEST),
+ ]:
+ with self.subTest(os=os_name, arch=arch), \
+ patch.dict(os.environ, {"RUNNER_OS": os_name, "RUNNER_ARCH": arch}):
+ self.assertEqual(runner.runtime_manifest(), expected)
+
+ def test_arm64_verify_uses_exact_contract_without_android_exports(self):
+ with patch.dict(os.environ, {"RUNNER_OS": "Linux", "RUNNER_ARCH": "ARM64",
+ "GITHUB_ENV": str(self.output)}), \
+ patch.object(sys, "argv", ["runner-image.py", "verify"]), \
+ patch.object(runner.subprocess, "run") as verify:
+ runner.main()
+ verify.assert_called_once_with(["ci-image-contract", "verify", runner.ARM64_MANIFEST], check=True)
+ values = dict(line.split("=", 1) for line in self.output.read_text().splitlines())
+ self.assertEqual(values["CI_CARGO_NEXTEST_VERSION"], "0.9.144")
+ self.assertFalse(any("ANDROID" in name or "NDK" in name for name in values))
+
+ def test_shared_rust_toolchain_does_not_drift_between_architectures(self):
+ amd = self.manifest["requirements"]
+ arm = runner.read_manifest(ROOT / runner.ARM64_MANIFEST)["requirements"]
+ self.assertEqual(arm["versions"], {k: v for k, v in amd["versions"].items() if k != "cargo_ndk"})
+ for key in ("rust_version", "rust_manifest_sha256", "apt_snapshot", "java_major", "client_codegen"):
+ self.assertEqual(amd[key], arm[key], key)
+
+ def test_rejected_image_contract_stops_before_environment_export(self):
+ with patch.dict(os.environ, {"RUNNER_OS": "Linux", "RUNNER_ARCH": "ARM64",
+ "GITHUB_ENV": str(self.output)}), \
+ patch.object(sys, "argv", ["runner-image.py", "verify"]), \
+ patch.object(runner.subprocess, "run", side_effect=runner.subprocess.CalledProcessError(1, "ci-image-contract")):
+ with self.assertRaises(runner.subprocess.CalledProcessError):
+ runner.main()
+ self.assertFalse(self.output.exists())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.github/workflows/kotlin-sdk-build.yml b/.github/workflows/kotlin-sdk-build.yml
index 8919fd7b7dd..f1fc725e270 100644
--- a/.github/workflows/kotlin-sdk-build.yml
+++ b/.github/workflows/kotlin-sdk-build.yml
@@ -18,11 +18,14 @@ on:
- 'packages/*-contract/**'
- 'packages/simple-signer/**'
- 'docs/sdk/sdk-parity-manifest.json'
- - 'docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md'
- 'packages/kotlin-sdk/PARITY_SUMMARY.md'
- 'scripts/check_sdk_parity_manifest.py'
- 'scripts/tests/**'
- '.github/workflows/kotlin-sdk-build.yml'
+ - '.github/scripts/kotlin-instrumented-tests.sh'
+ - '.github/actions/rust/**'
+ - '.github/runner-requirements.json'
+ - '.github/scripts/runner-image.py'
permissions:
contents: read
@@ -32,9 +35,36 @@ concurrency:
cancel-in-progress: true
jobs:
+ select-runner:
+ name: Select compatible runner image
+ 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'
+ runs-on: ubuntu-24.04
+ # Allow the separate 90-minute build and publication/queue window to finish.
+ timeout-minutes: 135
+ permissions:
+ contents: read
+ pull-requests: read
+ statuses: read
+ actions: read
+ outputs:
+ labels: ${{ steps.select.outputs.labels }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - name: Select provisioned pool or wait for the exact PR candidate
+ id: select
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: python3 .github/scripts/runner-image.py select --kind kotlin
+
kotlin-sdk-build:
name: Kotlin SDK build + tests (x86_64 emulator)
- runs-on: [self-hosted, kotlin-ci]
+ needs: select-runner
+ runs-on: ${{ fromJSON(needs.select-runner.outputs.labels) }}
# Fork PRs must not execute on a persistent runner. Keep this guard in sync
# with tests-rs-workspace.yml.
if: >-
@@ -50,83 +80,90 @@ jobs:
# Preserve target/ and other untracked build outputs between runs.
clean: false
- - name: Ensure runner dependencies
+ # The persistent runner image is the versioned CI environment. Keep
+ # dependency installation out of the job: it required sudo and made a
+ # CI job capable of mutating its container. Rebuild the runner image when
+ # one of these requirements changes instead.
+ - name: Verify exact runner requirements and load versions
+ run: python3 .github/scripts/runner-image.py verify
+
+ - name: Verify runner image dependencies
run: |
set -euo pipefail
- MISSING=()
- for pkg in build-essential cmake curl jq libgmp-dev libpulse0 libssl-dev libx11-xcb1 pkg-config python3 unzip zip; do
- dpkg -s "$pkg" >/dev/null 2>&1 || MISSING+=("$pkg")
+ missing=()
+ for tool in cmake curl jq python3 rustup unzip zip; do
+ command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
done
- if [ ${#MISSING[@]} -gt 0 ]; then
- echo "Installing: ${MISSING[*]}"
- sudo apt-get update -qq
- sudo apt-get install -qq --yes "${MISSING[@]}"
- fi
-
- if [ ! -x "$HOME/.cargo/bin/rustup" ]; then
- curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
- | sh -s -- -y --no-modify-path --default-toolchain none
+ for pkg in build-essential libgmp-dev libpulse0 libssl-dev libx11-xcb1 pkg-config; do
+ dpkg -s "$pkg" >/dev/null 2>&1 || missing+=("$pkg")
+ done
+ if [ ${#missing[@]} -gt 0 ]; then
+ echo "::error::Self-hosted runner image is missing: ${missing[*]}"
+ echo "::error::Provision these in the pinned runner image; CI jobs must not install host packages."
+ exit 1
fi
- echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Validate executable SDK parity manifest
run: |
python3 scripts/check_sdk_parity_manifest.py
python3 -m unittest discover -s scripts/tests -p 'test_*.py'
- - name: Verify JDK 17
+ - name: Verify repository-pinned JDK
run: |
JAVA_HOME_RESOLVED=$(dirname "$(dirname "$(readlink -f "$(command -v java)")")")
JAVA_VERSION_OUTPUT=$("$JAVA_HOME_RESOLVED/bin/java" -version 2>&1)
printf '%s\n' "$JAVA_VERSION_OUTPUT"
- printf '%s\n' "$JAVA_VERSION_OUTPUT" | grep -Eq 'version "17([.]|\")'
+ printf '%s\n' "$JAVA_VERSION_OUTPUT" | grep -E "version \"${CI_JAVA_MAJOR}([.]|\")"
echo "JAVA_HOME=$JAVA_HOME_RESOLVED" >> "$GITHUB_ENV"
echo "$JAVA_HOME_RESOLVED/bin" >> "$GITHUB_PATH"
- - name: Set up Android SDK
- uses: android-actions/setup-android@v3
- with:
- packages: >-
- platforms;android-35
- build-tools;35.0.0
- ndk;28.1.13356709
- platform-tools
-
- - name: Set up Rust toolchain
- uses: dtolnay/rust-toolchain@stable
+ - name: Verify prebaked Android SDK
+ run: |
+ set -euo pipefail
+ test "${ANDROID_HOME:-}" = /opt/android-sdk
+ test -x "$ANDROID_HOME/ndk/$CI_ANDROID_NDK/ndk-build"
+ test -x "$ANDROID_HOME/build-tools/$CI_ANDROID_BUILD_TOOLS/aapt2"
+ test -f "$ANDROID_HOME/platforms/android-$CI_ANDROID_API/android.jar"
+ test -f "$ANDROID_HOME/system-images/android-$CI_ANDROID_API/default/x86_64/system.img"
+ command -v ci-android-emulator
+ command -v adb
+ # Do not run setup-android/sdkmanager: the SDK is pinned and root-owned.
+
+ - name: Set up repository-pinned Rust toolchain
+ uses: ./.github/actions/rust
with:
- targets: x86_64-linux-android
+ target: x86_64-linux-android
+ cache: false
- # Pinned: this runner is persistent, so an unpinned `cargo install`
- # leaves whatever version happened to be current on the day it first ran,
- # and every later job silently builds with it. Assert after installing so
- # a drifted host fails here instead of somewhere in the NDK build.
- - name: Ensure cargo-ndk v4.1.2 is installed
+ # Pinned: this runner is persistent, so cargo-ndk is provisioned in the
+ # image and checked here before the NDK build can start.
+ - name: Verify repository-pinned cargo-ndk
run: |
set -euo pipefail
- if ! cargo ndk --version 2>/dev/null | grep -qx 'cargo-ndk 4.1.2'; then
- cargo install cargo-ndk --version 4.1.2 --locked --force
- fi
cargo ndk --version
- cargo ndk --version | grep -qx 'cargo-ndk 4.1.2'
+ cargo ndk --version | grep -Fx "cargo-ndk $CI_CARGO_NDK_VERSION"
- - name: Ensure protoc v32.0 is installed (repo-standard; apt's 3.21 breaks tenderdash-proto)
+ - name: Verify repository-pinned protoc
run: |
set -euo pipefail
- if ! protoc --version 2>/dev/null | grep -qx 'libprotoc 32.0'; then
- curl -fsSL -o /tmp/protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v32.0/protoc-32.0-linux-x86_64.zip
- sudo unzip -o /tmp/protoc.zip -d /usr/local 'bin/protoc' 'include/*'
- fi
protoc --version
- # A stale protoc earlier on PATH would shadow the one just unpacked
- # into /usr/local; catch that here rather than in a codegen failure.
- protoc --version | grep -qx 'libprotoc 32.0'
+ # protoc is part of the runner image. Installing it into /usr/local
+ # from a job required sudo and made the CI container mutable.
+ protoc --version | grep -Fx "libprotoc $CI_PROTOC_VERSION"
+
+ # KVM is the only host device this job needs. The container receives it
+ # through the kvm group; no Docker socket or host package-management
+ # access is required.
+ - name: Verify KVM access
+ run: |
+ test -c /dev/kvm
+ test -r /dev/kvm
+ test -w /dev/kvm
+ ls -l /dev/kvm
- name: Build native library (x86_64, dev profile)
working-directory: packages/kotlin-sdk
- env:
- ANDROID_NDK_HOME: ${{ env.ANDROID_SDK_ROOT }}/ndk/28.1.13356709
run: ./build_android.sh --abi x86_64 --profile dev --verify
- name: Setup Gradle
@@ -144,79 +181,9 @@ jobs:
working-directory: packages/kotlin-sdk
run: ./gradlew :sdk:compileDebugAndroidTestKotlin --stacktrace
- - name: Verify KVM access
- run: |
- test -c /dev/kvm
- test -r /dev/kvm
- test -w /dev/kvm
- ls -l /dev/kvm
-
- - name: Align Android emulator configuration paths
- run: |
- # android-emulator-runner puts AVD data in $HOME/.android/avd.
- # An inherited user/emulator-home override can make avdmanager
- # write the .ini elsewhere, leaving the emulator unable to find it.
- mkdir -p "$HOME/.android/avd"
- {
- printf 'ANDROID_USER_HOME=%s/.android\n' "$HOME"
- printf 'ANDROID_EMULATOR_HOME=%s/.android\n' "$HOME"
- printf 'ANDROID_SDK_HOME=%s\n' "$HOME"
- } >> "$GITHUB_ENV"
-
- - name: Run instrumented FFI smoke test (API 35 emulator)
- uses: reactivecircus/android-emulator-runner@v2
- with:
- api-level: 35
- arch: x86_64
- profile: pixel_6
- # Avoid sharing the default "test" AVD with other jobs on this host.
- avd-name: platform-${{ github.run_id }}-${{ github.run_attempt }}
- disable-animations: true
- emulator-options: -no-snapshot -no-window -no-audio -no-boot-anim -camera-back none -dns-server 8.8.8.8,1.1.1.1
- working-directory: packages/kotlin-sdk
- script: |
- # NOTE: android-emulator-runner executes this script line by line —
- # each line is its own `sh -c` with no shared state — so every
- # command must be self-contained on a single line (multi-line loops
- # or cross-line variables silently fail).
-
- # Keep the screen on and never time out. THIS is the fix: the flake
- # was the emulator screen turning off mid-run and RE-LOCKING the
- # device. The mnemonic MASTER_ALIAS AES key is
- # setUnlockedDeviceRequired(true), so once the device re-locks even
- # an ENCRYPT throws `InvalidKeyException: Keystore operation failed`
- # (see WalletStorage.storeMnemonic). Unlocking alone is not enough —
- # a slower suite (more instrumented tests) crosses the screen-off
- # deadline before its wallet tests run, which is why this branch
- # failed where lighter ones passed. Prevent the re-lock outright.
- adb shell settings put system screen_off_timeout 2147483647
- adb shell svc power stayon true
-
- # Enroll a device-wide secure lock screen (PIN) — Android Keystore
- # refuses to generate an auth-required key
- # (setUserAuthenticationRequired(true), used for the identity-key
- # KEYS_ALIAS RSA pair) without one enrolled, even though nothing
- # here ever prompts for it (private-key ENCRYPT is never auth-gated;
- # only DECRYPT is).
- adb shell locksettings set-pin 1234
-
- # Unlock the keyguard. With the screen kept on above, the device now
- # stays unlocked for the whole run instead of re-locking.
- # Credential acceptance and keyguard dismissal can race during a
- # cold emulator boot. Retry the complete sequence atomically, then
- # fail loudly before tests if the device never reaches unlocked.
- for attempt in 1 2 3; do adb shell input keyevent KEYCODE_WAKEUP; adb shell wm dismiss-keyguard; adb shell input text 1234; adb shell input keyevent KEYCODE_ENTER; sleep 2; adb shell wm dismiss-keyguard; sleep 1; adb shell dumpsys trust | grep -q 'deviceLocked=0' && exit 0; done; echo "::error::Emulator is still locked (deviceLocked=1); Keystore-backed tests would fail spuriously."; adb shell dumpsys trust; exit 1
-
- ./gradlew :sdk:connectedDebugAndroidTest --stacktrace
-
- - name: Remove this run's emulator device
- if: always()
- env:
- CI_AVD_NAME: platform-${{ github.run_id }}-${{ github.run_attempt }}
- run: |
- if [ -f "$HOME/.android/avd/$CI_AVD_NAME.ini" ]; then
- avdmanager delete avd --name "$CI_AVD_NAME"
- fi
+ - name: Run instrumented FFI smoke test (prebaked emulator)
+ working-directory: packages/kotlin-sdk
+ run: ci-android-emulator bash ../../.github/scripts/kotlin-instrumented-tests.sh
- name: Upload test reports on failure
if: failure()
diff --git a/.github/workflows/kotlin-sdk-nightly.yml b/.github/workflows/kotlin-sdk-nightly.yml
index c53510ddc11..9be76995c46 100644
--- a/.github/workflows/kotlin-sdk-nightly.yml
+++ b/.github/workflows/kotlin-sdk-nightly.yml
@@ -15,8 +15,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
- with:
- ref: v4.1-dev
+ # Scheduled runs test the default branch; dispatches test their selected
+ # ref. Do not silently compile an old release branch with newer CI.
- name: Free disk space
run: |
@@ -54,8 +54,8 @@ jobs:
restore-keys: |
kotlin-sdk-cargo-
- - name: Install cargo-ndk
- run: cargo install cargo-ndk --locked
+ - name: Install pinned cargo-ndk v4.1.2 (ephemeral runner)
+ run: cargo install cargo-ndk --version 4.1.2 --locked
- name: Install protoc v32.0 (repo-standard; apt's 3.21 breaks tenderdash-proto)
run: |
@@ -90,7 +90,9 @@ jobs:
working-directory: packages/kotlin-sdk
# -Ptestnet=true lifts the TestnetGuard so the live-network
# queries (identity fetch, DPNS resolve, contract fetch) run.
- script: ./gradlew :sdk:connectedDebugAndroidTest -Ptestnet=true --stacktrace
+ # Use the same secure-lockscreen setup as daytime instrumented tests.
+ # Keystore authentication-required keys cannot be created otherwise.
+ script: bash ../../.github/scripts/kotlin-instrumented-tests.sh -Ptestnet=true
- name: Upload reports on failure
if: failure()
diff --git a/.github/workflows/npm-runner-validation.yml b/.github/workflows/npm-runner-validation.yml
new file mode 100644
index 00000000000..b21412df94a
--- /dev/null
+++ b/.github/workflows/npm-runner-validation.yml
@@ -0,0 +1,75 @@
+name: Validate NPM runner image
+on:
+ pull_request:
+ paths:
+ - '.github/runner-requirements.json'
+ - '.github/scripts/runner-image.py'
+ - '.github/scripts/tests/**'
+ - '.github/actions/npm-release-build/**'
+ - '.github/actions/rust/**'
+ - '.github/actions/nodejs/**'
+ - '.github/workflows/npm-runner-validation.yml'
+ - '.github/workflows/release.yml'
+ - '.github/workflows/release-npm-build.yml'
+ - '.github/workflows/release-kotlin-sdk.yml'
+ - 'packages/dapi-grpc/**'
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ runner-contract-tests:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - name: Test runner selection and release caller restrictions
+ run: python3 -m unittest discover -s .github/scripts/tests -v
+ select-runner:
+ 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'
+ runs-on: ubuntu-24.04
+ timeout-minutes: 135
+ permissions:
+ contents: read
+ pull-requests: read
+ statuses: read
+ actions: read
+ outputs:
+ labels: ${{ steps.select.outputs.labels }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - id: select
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: python3 .github/scripts/runner-image.py select --kind npm
+ build:
+ name: NPM release build validation
+ needs: select-runner
+ runs-on: ${{ fromJSON(needs.select-runner.outputs.labels) }}
+ timeout-minutes: 120
+ steps:
+ - name: Empty the previous job workspace
+ run: find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - name: Run the release build and pack without publishing
+ uses: ./.github/actions/npm-release-build
+ with:
+ cache-name: npm-validation-target
+ - name: Test the generated clients
+ run: yarn workspace @dashevo/dapi-grpc test:unit
+ - name: Verify the DAPI archive contains generated clients
+ run: python3 packages/dapi-grpc/scripts/check-packed-clients.py npm-packages
+ - uses: actions/upload-artifact@v4
+ with:
+ name: npm-validation-${{ github.sha }}
+ path: npm-packages/*.tgz
+ if-no-files-found: error
+ retention-days: 3
diff --git a/.github/workflows/release-kotlin-sdk.yml b/.github/workflows/release-kotlin-sdk.yml
index 8286d0b371e..7d7308f9fbf 100644
--- a/.github/workflows/release-kotlin-sdk.yml
+++ b/.github/workflows/release-kotlin-sdk.yml
@@ -19,12 +19,22 @@ name: Release Kotlin SDK
on:
workflow_call:
inputs:
+ dry_run:
+ description: 'Build and upload CI artifacts only; never attach or publish'
+ type: boolean
+ required: false
+ default: false
tag:
description: 'Platform release tag (vX.Y.Z[-pre.N])'
required: true
type: string
workflow_dispatch:
inputs:
+ dry_run:
+ description: 'Build and upload CI artifacts only; never attach or publish'
+ type: boolean
+ required: false
+ default: false
tag:
description: >-
Existing platform release tag to (re-)release the Kotlin SDK for.
@@ -37,10 +47,19 @@ on:
jobs:
build-and-release:
name: Build release AAR (arm64-v8a + x86_64)
- runs-on: ubuntu-24.04
+ # Fresh runner, registration, HOME and workspace for exactly one job.
+ # Publication remains hosted; no publishing credentials enter this pool.
+ if: >-
+ github.repository == 'dashpay/platform'
+ && (github.event_name == 'release'
+ || (github.event_name == 'workflow_dispatch'
+ && (github.ref_protected || startsWith(github.ref, 'refs/tags/'))))
+ runs-on:
+ group: platform-release-builds
+ labels: [self-hosted, Linux, X64, 'platform-release-${{ github.run_id }}-${{ github.run_attempt }}-kotlin']
timeout-minutes: 180
permissions:
- contents: write # attach the AAR to the platform release
+ contents: read # release attachment runs on an ephemeral hosted job
# Serialize same-tag runs (e.g. an emergency dispatch racing the
# release-triggered run) so the asset-exists guard cannot be bypassed by
# two concurrent builds. Never cancel a release build in progress.
@@ -58,6 +77,26 @@ jobs:
sha: ${{ steps.resolve-sha.outputs.sha }}
steps:
+ - name: Verify disposable release runner
+ shell: bash
+ run: |
+ set -euo pipefail
+ test "${DASH_RELEASE_RUNNER:-}" = 1
+ test "${DASH_RELEASE_RUN_ID:-}" = "$GITHUB_RUN_ID"
+ test "${DASH_RELEASE_RUN_ATTEMPT:-}" = "$GITHUB_RUN_ATTEMPT"
+ test "${DASH_RELEASE_KIND:-}" = kotlin
+
+ # Verify prebaked native tools. A release job has no sudo or Docker.
+ - name: Verify runner dependencies
+ run: |
+ set -euo pipefail
+
+ for pkg in build-essential cmake curl gh jq libgmp-dev libpulse0 libssl-dev libx11-xcb1 pkg-config python3 unzip zip; do
+ dpkg-query -W -f='${Status}' "$pkg" | grep -Fx 'install ok installed'
+ done
+ test -x "$HOME/.cargo/bin/rustup"
+ echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
+
# A workflow_dispatch `tag` input is free-form and actions/checkout would
# happily resolve it to a BRANCH (or any ref). Normalize and validate it
# here — reject anything that is not an existing platform release tag
@@ -117,6 +156,7 @@ jobs:
} >> "$GITHUB_OUTPUT"
echo "Releasing Kotlin SDK ${VERSION} for platform release ${TAG}"
+ # No previous job's Git configuration, hooks or build state exists here.
- name: Checkout repository
uses: actions/checkout@v4
with:
@@ -124,154 +164,175 @@ jobs:
# raw dispatch input — so the released AAR is built from the tag's
# commit and a manual run can never build from a branch.
ref: ${{ steps.release-ref.outputs.checkout_ref }}
+ persist-credentials: false
- name: Resolve built commit SHA
id: resolve-sha
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- - name: Free disk space
- run: |
- sudo rm -rf /usr/share/dotnet /usr/local/lib/android/sdk/ndk /opt/ghc
- df -h /
+ - name: Verify release image contract
+ run: ci-image-contract verify .github/runner-requirements.json
- - name: Set up JDK 17
- uses: actions/setup-java@v4
- with:
- distribution: temurin
- java-version: '17'
+ - name: Verify JDK 17
+ run: |
+ JAVA_HOME_RESOLVED=$(dirname "$(dirname "$(readlink -f "$(command -v java)")")")
+ JAVA_VERSION_OUTPUT=$("$JAVA_HOME_RESOLVED/bin/java" -version 2>&1)
+ printf '%s\n' "$JAVA_VERSION_OUTPUT"
+ printf '%s\n' "$JAVA_VERSION_OUTPUT" | grep -Eq 'version "17([.]|\")'
+ echo "JAVA_HOME=$JAVA_HOME_RESOLVED" >> "$GITHUB_ENV"
+ echo "$JAVA_HOME_RESOLVED/bin" >> "$GITHUB_PATH"
- - name: Set up Android SDK
- uses: android-actions/setup-android@v3
- with:
- packages: >-
- platforms;android-35
- build-tools;35.0.0
- ndk;28.1.13356709
+ - name: Verify prebaked Android SDK
+ run: |
+ test -f "$ANDROID_SDK_ROOT/platforms/android-35/android.jar"
+ test -x "$ANDROID_SDK_ROOT/build-tools/35.0.0/aapt2"
+ test -x "$ANDROID_SDK_ROOT/ndk/28.1.13356709/toolchains/llvm/prebuilt/linux-x86_64/bin/clang"
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android,x86_64-linux-android
- - name: Restore cargo cache
- uses: actions/cache@v4
+ # Job-local only: the host destroys HOME and this cache with the runner.
+ - name: Prepare release Cargo target cache
+ uses: ./.github/actions/release-cargo-target-cache
with:
- path: |
- ~/.cargo/registry
- ~/.cargo/git
- target
- key: kotlin-sdk-release-cargo-${{ hashFiles('**/Cargo.lock') }}
- restore-keys: |
- kotlin-sdk-release-cargo-
- kotlin-sdk-cargo-
-
- - name: Install cargo-ndk
- run: cargo install cargo-ndk --locked
-
- - name: Install protoc v32.0 (repo-standard; apt's 3.21 breaks tenderdash-proto)
+ name: release-kotlin-sdk-target
+
+ # Always reinstalled, never trusted from an earlier job: a binary left in
+ # ~/.cargo/bin can print the pinned version and still be something else.
+ # Cargo looks up `cargo ndk` in $CARGO_HOME/bin before PATH, so the
+ # install has to overwrite that copy rather than land elsewhere.
+ - name: Install cargo-ndk v4.1.2
run: |
- curl -fsSL -o /tmp/protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v32.0/protoc-32.0-linux-x86_64.zip
- sudo unzip -o /tmp/protoc.zip -d /usr/local 'bin/protoc' 'include/*'
- protoc --version
+ set -euo pipefail
+ cargo install cargo-ndk --version 4.1.2 --locked --force
+ cargo ndk --version | grep -qx 'cargo-ndk 4.1.2'
+
+ # Fresh, checksum-verified copy in this job's temp directory rather than
+ # whatever an earlier job left in /usr/local (repo-standard v32.0; apt's
+ # 3.21 breaks tenderdash-proto). prost-build reads PROTOC first.
+ - name: Install protoc v32.0
+ env:
+ PROTOC_SHA256: 7ca037bfe5e5cabd4255ccd21dd265f79eb82d3c010117994f5dc81d2140ee88
+ run: |
+ set -euo pipefail
+ PROTOC_DIR="$RUNNER_TEMP/protoc-32.0"
+ curl -fsSL -o "$RUNNER_TEMP/protoc.zip" \
+ https://github.com/protocolbuffers/protobuf/releases/download/v32.0/protoc-32.0-linux-x86_64.zip
+ echo "$PROTOC_SHA256 $RUNNER_TEMP/protoc.zip" | sha256sum -c -
+ rm -rf "$PROTOC_DIR"
+ unzip -q "$RUNNER_TEMP/protoc.zip" -d "$PROTOC_DIR"
+ echo "PROTOC=$PROTOC_DIR/bin/protoc" >> "$GITHUB_ENV"
+ echo "$PROTOC_DIR/bin" >> "$GITHUB_PATH"
+ "$PROTOC_DIR/bin/protoc" --version
- name: Build native library (both ABIs, release profile)
working-directory: packages/kotlin-sdk
- env:
- ANDROID_NDK_HOME: ${{ env.ANDROID_SDK_ROOT }}/ndk/28.1.13356709
- run: ./build_android.sh --abi all --profile release --verify
+ # Image-provided variables exist in the shell, not the Actions env context.
+ run: |
+ export ANDROID_NDK_HOME="${ANDROID_SDK_ROOT:?}/ndk/28.1.13356709"
+ ./build_android.sh --abi all --profile release --verify
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
+ with:
+ cache-disabled: true
- name: Build release AAR
working-directory: packages/kotlin-sdk
run: ./gradlew :sdk:assembleRelease --stacktrace
- # Defenses against a force-moved tag splitting the GitHub-release AAR
- # from the (immutable) Maven Central artifact of the same version:
- # 1. Hard-fail if the tag no longer resolves to the exact commit this
- # run built.
- # 2. The AAR is attached together with a .commit.txt recording
- # the commit it was built from. When both already exist, this run
- # may proceed to the Maven deploy ONLY if that recorded commit
- # equals the commit this run built — otherwise the existing GitHub
- # AAR and the Maven artifact this run would publish came from
- # different commits, and the run hard-fails instead of letting the
- # two channels drift. An attached AAR is never overwritten; a
- # deliberate re-release must delete both assets first.
- - name: Verify tag still resolves to the built commit
- id: tag-guard
+ - name: Prepare versioned assets (AAR + build provenance)
env:
- TAG: ${{ steps.release-ref.outputs.tag_name }}
- AAR_ASSET: dash-sdk-android-${{ steps.release-ref.outputs.version }}.aar
- COMMIT_ASSET: dash-sdk-android-${{ steps.release-ref.outputs.version }}.commit.txt
+ VERSION: ${{ steps.release-ref.outputs.version }}
BUILT_SHA: ${{ steps.resolve-sha.outputs.sha }}
+ run: |
+ cd packages/kotlin-sdk/sdk/build/outputs/aar
+ cp sdk-release.aar "dash-sdk-android-${VERSION}.aar"
+ printf '%s\n' "$BUILT_SHA" > "dash-sdk-android-${VERSION}.commit.txt"
+
+ # Release attachment runs on a fresh hosted runner so release-write
+ # credentials never reach the disposable build runner.
+ - name: Upload release assets
+ uses: actions/upload-artifact@v4
+ with:
+ name: kotlin-sdk-release-assets
+ path: |
+ packages/kotlin-sdk/sdk/build/outputs/aar/dash-sdk-android-${{ steps.release-ref.outputs.version }}.aar
+ packages/kotlin-sdk/sdk/build/outputs/aar/dash-sdk-android-${{ steps.release-ref.outputs.version }}.commit.txt
+ if-no-files-found: error
+ retention-days: 7
+
+ # Hand the freshly built native libraries to the deploy job so it
+ # re-stages the SAME .so (same commit, same tag) without repeating the
+ # multi-hour cargo/NDK build.
+ - name: Upload native libraries for the deploy job
+ uses: actions/upload-artifact@v4
+ with:
+ name: kotlin-sdk-jnilibs
+ path: packages/kotlin-sdk/sdk/src/main/jniLibs
+ if-no-files-found: error
+ retention-days: 7
+
+ attach-release:
+ name: Attach AAR to platform release
+ needs: build-and-release
+ if: ${{ !inputs.dry_run }}
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ permissions:
+ contents: write
+ concurrency:
+ group: release-kotlin-sdk-attach-${{ needs.build-and-release.outputs.tag_name }}
+ cancel-in-progress: false
+ steps:
+ - name: Download release assets
+ uses: actions/download-artifact@v4
+ with:
+ name: kotlin-sdk-release-assets
+ path: release-assets
+
+ - name: Verify tag and existing assets
+ id: verify
+ env:
+ TAG: ${{ needs.build-and-release.outputs.tag_name }}
+ AAR_ASSET: dash-sdk-android-${{ needs.build-and-release.outputs.version }}.aar
+ COMMIT_ASSET: dash-sdk-android-${{ needs.build-and-release.outputs.version }}.commit.txt
+ BUILT_SHA: ${{ needs.build-and-release.outputs.sha }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
run: |
- # Resolve the tag's current commit, dereferencing an annotated tag
- # to the commit it points at.
+ set -euo pipefail
OBJ_TYPE=$(gh api "repos/${REPO}/git/ref/tags/${TAG}" --jq '.object.type')
OBJ_SHA=$(gh api "repos/${REPO}/git/ref/tags/${TAG}" --jq '.object.sha')
if [ "$OBJ_TYPE" = "tag" ]; then
OBJ_SHA=$(gh api "repos/${REPO}/git/tags/${OBJ_SHA}" --jq '.object.sha')
fi
- if [ "$OBJ_SHA" != "$BUILT_SHA" ]; then
- echo "::error::Tag ${TAG} now points at ${OBJ_SHA} but this run built ${BUILT_SHA} — the tag was force-moved. Refusing to attach/deploy."
- exit 1
- fi
+ [ "$OBJ_SHA" = "$BUILT_SHA" ] || { echo "::error::Tag ${TAG} moved from built commit ${BUILT_SHA} to ${OBJ_SHA}."; exit 1; }
ASSETS=$(gh api "repos/${REPO}/releases/tags/${TAG}" --jq '.assets[].name')
HAVE_AAR=$(echo "$ASSETS" | grep -Fxc "$AAR_ASSET" || true)
HAVE_COMMIT=$(echo "$ASSETS" | grep -Fxc "$COMMIT_ASSET" || true)
- if [ "$HAVE_AAR" = "1" ] && [ "$HAVE_COMMIT" = "1" ]; then
+ if [ "$HAVE_AAR" = 1 ] && [ "$HAVE_COMMIT" = 1 ]; then
RECORDED_SHA=$(gh release download "$TAG" --repo "$REPO" --pattern "$COMMIT_ASSET" --output - | tr -d '[:space:]')
- if [ "$RECORDED_SHA" != "$BUILT_SHA" ]; then
- echo "::error::Release ${TAG} carries ${AAR_ASSET} built from ${RECORDED_SHA}, but this run built ${BUILT_SHA} — deploying would publish a different commit to Maven Central than the attached AAR. Delete both assets deliberately to re-release."
- exit 1
- fi
+ [ "$RECORDED_SHA" = "$BUILT_SHA" ] || { echo "::error::Existing AAR provenance does not match this build."; exit 1; }
echo "asset_exists=true" >> "$GITHUB_OUTPUT"
- echo "::notice::Release ${TAG} already has ${AAR_ASSET} built from this same commit — leaving it untouched; the Maven deploy may proceed."
- elif [ "$HAVE_AAR" = "1" ] || [ "$HAVE_COMMIT" = "1" ]; then
- echo "::error::Release ${TAG} has only one of ${AAR_ASSET} / ${COMMIT_ASSET} — an earlier attach was interrupted, so the existing asset's provenance cannot be verified. Delete the surviving asset and re-run."
+ elif [ "$HAVE_AAR" = 1 ] || [ "$HAVE_COMMIT" = 1 ]; then
+ echo "::error::Release has only one provenance asset; delete it before retrying."
exit 1
else
echo "asset_exists=false" >> "$GITHUB_OUTPUT"
fi
- - name: Prepare versioned assets (AAR + build provenance)
- env:
- VERSION: ${{ steps.release-ref.outputs.version }}
- BUILT_SHA: ${{ steps.resolve-sha.outputs.sha }}
- run: |
- cd packages/kotlin-sdk/sdk/build/outputs/aar
- cp sdk-release.aar "dash-sdk-android-${VERSION}.aar"
- printf '%s\n' "$BUILT_SHA" > "dash-sdk-android-${VERSION}.commit.txt"
-
- # Attach to the PLATFORM release: only tag_name + files. Passing name/
- # body/prerelease/generate_release_notes here would overwrite the
- # platform release's own notes, title or prerelease flag.
- # overwrite_files: false makes a same-name upload race fail loudly
- # instead of silently replacing an asset the guard above vouched for.
- name: Attach AAR to the platform release
- if: steps.tag-guard.outputs.asset_exists != 'true'
+ if: steps.verify.outputs.asset_exists != 'true'
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
- tag_name: ${{ steps.release-ref.outputs.tag_name }}
+ tag_name: ${{ needs.build-and-release.outputs.tag_name }}
overwrite_files: false
files: |
- packages/kotlin-sdk/sdk/build/outputs/aar/dash-sdk-android-${{ steps.release-ref.outputs.version }}.aar
- packages/kotlin-sdk/sdk/build/outputs/aar/dash-sdk-android-${{ steps.release-ref.outputs.version }}.commit.txt
-
- # Hand the freshly built native libraries to the deploy job so it
- # re-stages the SAME .so (same commit, same tag) without repeating the
- # multi-hour cargo/NDK build.
- - name: Upload native libraries for the deploy job
- uses: actions/upload-artifact@v4
- with:
- name: kotlin-sdk-jnilibs
- path: packages/kotlin-sdk/sdk/src/main/jniLibs
- if-no-files-found: error
- retention-days: 7
+ release-assets/dash-sdk-android-${{ needs.build-and-release.outputs.version }}.aar
+ release-assets/dash-sdk-android-${{ needs.build-and-release.outputs.version }}.commit.txt
# ---------------------------------------------------------------------------
# Maven Central deploy. Declares `environment: maven-central`, so the five
@@ -284,7 +345,7 @@ jobs:
# ---------------------------------------------------------------------------
maven-central-deploy:
name: Deploy to Maven Central (version from tag)
- needs: build-and-release
+ needs: [build-and-release, attach-release]
# Runs only when the RUN's ref is exactly the target tag — not merely
# some tag: binding the ref to inputs.tag stops a dispatch started at tag
# A from publishing input tag B under the environment authorization A's
@@ -294,7 +355,7 @@ jobs:
# selected ref) skips this job cleanly instead of failing at the
# environment policy; Maven for such tags goes through the manual
# runbook in packages/kotlin-sdk/PUBLISHING.md.
- if: ${{ github.ref == format('refs/tags/{0}', inputs.tag) }}
+ if: ${{ !inputs.dry_run && needs.build-and-release.result == 'success' && needs.attach-release.result == 'success' && github.ref == format('refs/tags/{0}', inputs.tag) }}
runs-on: ubuntu-24.04
timeout-minutes: 60
permissions:
@@ -401,6 +462,8 @@ jobs:
- name: Setup Gradle
if: steps.secrets-gate.outputs.proceed == 'true' && steps.maven-check.outputs.already_published != 'true'
uses: gradle/actions/setup-gradle@v4
+ with:
+ cache-disabled: true
# Stages the signed release AAR + sources/javadoc jars into
# sdk/build/staging-deploy. Task dependencies enforce the publish
diff --git a/.github/workflows/release-npm-build.yml b/.github/workflows/release-npm-build.yml
new file mode 100644
index 00000000000..174b160ec46
--- /dev/null
+++ b/.github/workflows/release-npm-build.yml
@@ -0,0 +1,81 @@
+name: Build release NPM artifacts
+
+# The host controller allocates a fresh one-job runner for this run/attempt.
+# No persistent CI runner carries this label or shares its writable state.
+on:
+ workflow_call:
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ name: Build NPM packages
+ if: >-
+ github.repository == 'dashpay/platform'
+ && (github.event_name == 'release'
+ || (github.event_name == 'workflow_dispatch'
+ && (github.ref_protected || startsWith(github.ref, 'refs/tags/'))))
+ runs-on:
+ group: platform-release-builds
+ labels: [self-hosted, Linux, X64, 'platform-release-${{ github.run_id }}-${{ github.run_attempt }}-npm']
+ timeout-minutes: 120
+ steps:
+ - name: Verify disposable release runner
+ shell: bash
+ run: |
+ set -euo pipefail
+ test "${DASH_RELEASE_RUNNER:-}" = 1
+ test "${DASH_RELEASE_RUN_ID:-}" = "$GITHUB_RUN_ID"
+ test "${DASH_RELEASE_RUN_ATTEMPT:-}" = "$GITHUB_RUN_ATTEMPT"
+ test "${DASH_RELEASE_KIND:-}" = npm
+
+ - name: Reject untrusted release callers
+ shell: bash
+ run: |
+ set -euo pipefail
+ test "$GITHUB_REPOSITORY" = dashpay/platform
+ case "$GITHUB_EVENT_NAME:$GITHUB_REF" in
+ release:refs/tags/*|workflow_dispatch:refs/tags/*) ;;
+ workflow_dispatch:refs/heads/*) test "${GITHUB_REF_PROTECTED:-}" = true ;;
+ *) echo '::error::NPM release runners accept only release tags or protected-branch dispatches'; exit 1 ;;
+ esac
+
+ - uses: softwareforgood/check-artifact-v4-existence@v0
+ id: check-artifact
+ with:
+ name: js-build-${{ github.sha }}
+
+ # The entire runner, including HOME and Git state, is new for this job.
+ - name: Check out repo
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ if: ${{ steps.check-artifact.outputs.exists != 'true' }}
+
+ - name: Build and pack NPM packages
+ uses: ./.github/actions/npm-release-build
+ if: ${{ steps.check-artifact.outputs.exists != 'true' }}
+
+ - name: Get modified files
+ id: diff
+ run: |
+ {
+ echo "files<> "$GITHUB_OUTPUT"
+ if: ${{ steps.check-artifact.outputs.exists != 'true' }}
+
+ - name: Upload the archive of built files
+ uses: actions/upload-artifact@v4
+ with:
+ name: js-build-${{ github.sha }}
+ path: |
+ ${{ steps.diff.outputs.files }}
+ npm-packages/*.tgz
+ # Keep the handoff alive long enough to re-run only a failed publish.
+ retention-days: 7
+ if-no-files-found: error
+ include-hidden-files: true
+ if: ${{ steps.check-artifact.outputs.exists != 'true' }}
diff --git a/.github/workflows/release-swift-sdk.yml b/.github/workflows/release-swift-sdk.yml
index 4d6669c244e..dcc62c29872 100644
--- a/.github/workflows/release-swift-sdk.yml
+++ b/.github/workflows/release-swift-sdk.yml
@@ -30,19 +30,44 @@ on:
jobs:
build-and-release:
name: Build and release DashSDKFFI
- runs-on: macos-15
- # Two cold release-profile builds (device + simulator) of ~25 min each.
+ outputs:
+ tag_name: ${{ steps.release-ref.outputs.tag_name }}
+ version: ${{ steps.release-ref.outputs.version }}
+ sha: ${{ steps.resolve-sha.outputs.sha }}
+ checksum: ${{ steps.zip.outputs.checksum }}
+ # Same persistent runner as swift-sdk-build.yml. Release builds keep their
+ # own Cargo target cache on it (see "Prepare release Cargo target cache"),
+ # so later releases only rebuild what changed since the previous one. No
+ # fork PR guard is needed here: the workflow only triggers on release/
+ # workflow_dispatch (via release.yml), never on pull_request.
+ runs-on: [self-hosted, macOS, ARM64]
+ # Three cold fat-LTO slices took over 45 minutes on a hosted macos-15
+ # runner. A cold run here (first release, or after the cache is reset)
+ # needs the same headroom; warm runs finish well inside it.
timeout-minutes: 90
permissions:
- contents: write # attach the xcframework to the platform release
+ contents: read # release attachment runs on an ephemeral hosted job
# Serialize same-tag runs (e.g. an emergency dispatch racing the
# release-triggered run) so the asset-exists guard cannot be bypassed by
# two concurrent builds. Never cancel a release build in progress.
concurrency:
group: release-swift-sdk-${{ inputs.tag }}
cancel-in-progress: false
+ defaults:
+ run:
+ # The runner's work/temp directory can be on a volume with spaces.
+ shell: bash --noprofile --norc -e -o pipefail "{0}"
steps:
+ # The tag validation below needs gh before checkout; hosted images ship
+ # it, the persistent runner may not.
+ - name: Ensure gh is installed
+ run: |
+ if ! command -v gh >/dev/null 2>&1; then
+ brew install gh
+ fi
+ gh --version
+
# Same guard as release-kotlin-sdk.yml: normalize/validate the tag,
# refuse anything that is not an existing platform release tag with a
# published GitHub release, and hand checkout an explicit refs/tags/
@@ -90,50 +115,107 @@ jobs:
} >> "$GITHUB_OUTPUT"
echo "Releasing DashSDKFFI ${TAG#v} for platform release ${TAG}"
+ # PR jobs run in this same workspace on this persistent runner. Git
+ # state they leave behind (.git/hooks, .git/config, .git/info/attributes)
+ # would run inside actions/checkout's own `git checkout`, before any
+ # later cleanup could remove it. Start from an empty directory and a
+ # fresh clone; the release build cache lives outside the workspace.
+ - name: Empty the workspace left by earlier jobs
+ run: |
+ find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf {} + \
+ || sudo -n find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
+
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ steps.release-ref.outputs.checkout_ref }}
+ persist-credentials: false
- name: Resolve built commit SHA
id: resolve-sha
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- - name: Select Xcode 16
- uses: maxim-lobanov/setup-xcode@v1
- with:
- xcode-version: '16.*'
-
+ # The runner's selected Xcode is the one every other Swift job on this
+ # machine builds with (setup-xcode only knows hosted image layouts).
- name: Show Xcode and Swift versions
run: |
xcodebuild -version
swift --version
- - name: Set up Rust toolchain (stable)
- uses: dtolnay/rust-toolchain@stable
+ # Composite actions choose their own shell and do not inherit the quoted
+ # script path above, so rustup is set up inline as in
+ # swift-sdk-build.yml. The build uses the rust-toolchain.toml channel.
+ - name: Set up Rust toolchain
+ env:
+ RUSTUP_PERMIT_COPY_RENAME: "1"
+ run: |
+ export CARGO_HOME="${CARGO_HOME:-$HOME/.cargo}"
+ export PATH="$CARGO_HOME/bin:$PATH"
+ if ! command -v rustup >/dev/null 2>&1; then
+ curl --proto '=https' --tlsv1.2 --retry 10 --retry-connrefused \
+ --location --silent --show-error --fail https://sh.rustup.rs \
+ | sh -s -- --default-toolchain none --no-modify-path -y
+ fi
+ {
+ echo "CARGO_HOME=$CARGO_HOME"
+ echo "CARGO_INCREMENTAL=${CARGO_INCREMENTAL-0}"
+ echo "CARGO_TERM_COLOR=${CARGO_TERM_COLOR-always}"
+ } >> "$GITHUB_ENV"
+ echo "$CARGO_HOME/bin" >> "$GITHUB_PATH"
- - name: Cache cargo registry
- uses: actions/cache@v5
+ # Restore-only, matching swift-sdk-build.yml: the persistent runner
+ # keeps ~/.cargo between runs, so saving it back would just re-upload
+ # gigabytes on every lockfile change.
+ - name: Restore cargo registry cache
+ uses: actions/cache/restore@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
key: cargo-registry-${{ hashFiles('**/Cargo.lock') }}
+ restore-keys: |
+ cargo-registry-
- name: Add iOS Rust targets
+ env:
+ RUSTUP_PERMIT_COPY_RENAME: "1"
run: |
rustup target add aarch64-apple-ios aarch64-apple-ios-sim
+ rustc --version --verbose
- - name: Install protoc (Protocol Buffers compiler)
- uses: arduino/setup-protoc@v3
+ # Fresh, checksum-verified copy in this job's temp directory. The runner's
+ # tool cache persists between jobs, so a protoc left there by an earlier
+ # job is not trusted for a release build.
+ - name: Install protoc v32.0 (Protocol Buffers compiler)
+ env:
+ PROTOC_SHA256: 09a2c729cc821215cc0d4c564b761760961fe338c52f24b302fd7e18e7b675d1
+ run: |
+ set -euo pipefail
+ PROTOC_DIR="$RUNNER_TEMP/protoc-32.0"
+ curl -fsSL -o "$RUNNER_TEMP/protoc.zip" \
+ https://github.com/protocolbuffers/protobuf/releases/download/v32.0/protoc-32.0-osx-aarch_64.zip
+ echo "$PROTOC_SHA256 $RUNNER_TEMP/protoc.zip" | shasum -a 256 -c -
+ rm -rf "$PROTOC_DIR"
+ unzip -q "$RUNNER_TEMP/protoc.zip" -d "$PROTOC_DIR"
+ echo "PROTOC=$PROTOC_DIR/bin/protoc" >> "$GITHUB_ENV"
+ echo "$PROTOC_DIR/bin" >> "$GITHUB_PATH"
+ "$PROTOC_DIR/bin/protoc" --version
+
+ # PR builds on this runner (swift-sdk-build.yml) set PRUNE_CARGO_TARGETS,
+ # which deletes every Apple target directory under the workspace
+ # target/ before and after each slice, and this job empties the
+ # workspace anyway. Releases keep their own release-ios cache outside
+ # it instead.
+ - name: Prepare release Cargo target cache
+ uses: ./.github/actions/release-cargo-target-cache
with:
- version: '32.x'
- # Without a token the action resolves the protoc release
- # unauthenticated, and the shared macOS-runner egress IP burns
- # through the 60 req/h anonymous limit.
- repo-token: ${{ secrets.GITHUB_TOKEN }}
+ name: release-swift-sdk-target
- name: Build DashSDKFFI.xcframework and install into Swift package
+ env:
+ # Keep all three slices' intermediates in the release cache above;
+ # pruning them would make every release a cold build.
+ PRUNE_CARGO_TARGETS: "0"
run: |
bash packages/swift-sdk/build_ios.sh --target all --profile release
@@ -149,88 +231,86 @@ jobs:
printf '%s\n' "$BUILT_SHA" > "DashSDKFFI-${VERSION}.commit.txt"
echo "checksum=$(cat "DashSDKFFI-${VERSION}.checksum.txt")" >> "$GITHUB_OUTPUT"
- # Same defenses as the Kotlin job: hard-fail if the tag was force-moved
- # away from the commit this run built, and never overwrite an
- # already-attached versioned zip — SwiftPM consumers pin the zip by
- # checksum, so replacing attached bytes would break them. The zip
- # attaches together with checksum + commit provenance; existing assets
- # are reused only when their recorded commit equals the commit this run
- # built, so a force-moved tag cannot leave a green run whose assets
- # came from a different commit. A deliberate re-release must delete all
- # three assets first.
- - name: Verify tag still resolves to the built commit
- id: tag-guard
+ # Release attachment runs on a fresh hosted runner so release-write
+ # credentials never reach the persistent build machine.
+ - name: Upload release assets
+ uses: actions/upload-artifact@v4
+ with:
+ name: swift-sdk-release-assets
+ path: |
+ packages/swift-sdk/DashSDKFFI-${{ steps.release-ref.outputs.version }}.xcframework.zip
+ packages/swift-sdk/DashSDKFFI-${{ steps.release-ref.outputs.version }}.checksum.txt
+ packages/swift-sdk/DashSDKFFI-${{ steps.release-ref.outputs.version }}.commit.txt
+ if-no-files-found: error
+ retention-days: 7
+
+ attach-release:
+ name: Attach XCFramework to platform release
+ needs: build-and-release
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ permissions:
+ contents: write
+ concurrency:
+ group: release-swift-sdk-attach-${{ needs.build-and-release.outputs.tag_name }}
+ cancel-in-progress: false
+ steps:
+ - name: Download release assets
+ uses: actions/download-artifact@v4
+ with:
+ name: swift-sdk-release-assets
+ path: release-assets
+
+ - name: Verify tag and existing assets
+ id: verify
env:
- TAG: ${{ steps.release-ref.outputs.tag_name }}
- ZIP_ASSET: DashSDKFFI-${{ steps.release-ref.outputs.version }}.xcframework.zip
- SUM_ASSET: DashSDKFFI-${{ steps.release-ref.outputs.version }}.checksum.txt
- COMMIT_ASSET: DashSDKFFI-${{ steps.release-ref.outputs.version }}.commit.txt
- BUILT_SHA: ${{ steps.resolve-sha.outputs.sha }}
+ TAG: ${{ needs.build-and-release.outputs.tag_name }}
+ ZIP_ASSET: DashSDKFFI-${{ needs.build-and-release.outputs.version }}.xcframework.zip
+ SUM_ASSET: DashSDKFFI-${{ needs.build-and-release.outputs.version }}.checksum.txt
+ COMMIT_ASSET: DashSDKFFI-${{ needs.build-and-release.outputs.version }}.commit.txt
+ BUILT_SHA: ${{ needs.build-and-release.outputs.sha }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
run: |
- # Resolve the tag's current commit, dereferencing an annotated tag
- # to the commit it points at.
+ set -euo pipefail
OBJ_TYPE=$(gh api "repos/${REPO}/git/ref/tags/${TAG}" --jq '.object.type')
OBJ_SHA=$(gh api "repos/${REPO}/git/ref/tags/${TAG}" --jq '.object.sha')
if [ "$OBJ_TYPE" = "tag" ]; then
OBJ_SHA=$(gh api "repos/${REPO}/git/tags/${OBJ_SHA}" --jq '.object.sha')
fi
- if [ "$OBJ_SHA" != "$BUILT_SHA" ]; then
- echo "::error::Tag ${TAG} now points at ${OBJ_SHA} but this run built ${BUILT_SHA} — the tag was force-moved. Refusing to attach."
- exit 1
- fi
- # The three assets are attached as one unit: reuse them only when
- # ALL are present AND the recorded provenance commit matches this
- # run's built commit. A partial set means an earlier run died
- # mid-attach (provenance unverifiable); a mismatched commit means
- # the tag was force-moved after the original attach. Both hard-fail
- # and require deliberate cleanup instead of a silently-green run.
+ [ "$OBJ_SHA" = "$BUILT_SHA" ] || { echo "::error::Tag ${TAG} moved from built commit ${BUILT_SHA} to ${OBJ_SHA}."; exit 1; }
ASSETS=$(gh api "repos/${REPO}/releases/tags/${TAG}" --jq '.assets[].name')
HAVE_ZIP=$(echo "$ASSETS" | grep -Fxc "$ZIP_ASSET" || true)
HAVE_SUM=$(echo "$ASSETS" | grep -Fxc "$SUM_ASSET" || true)
HAVE_COMMIT=$(echo "$ASSETS" | grep -Fxc "$COMMIT_ASSET" || true)
- if [ "$HAVE_ZIP" = "1" ] && [ "$HAVE_SUM" = "1" ] && [ "$HAVE_COMMIT" = "1" ]; then
+ if [ "$HAVE_ZIP" = 1 ] && [ "$HAVE_SUM" = 1 ] && [ "$HAVE_COMMIT" = 1 ]; then
RECORDED_SHA=$(gh release download "$TAG" --repo "$REPO" --pattern "$COMMIT_ASSET" --output - | tr -d '[:space:]')
- if [ "$RECORDED_SHA" != "$BUILT_SHA" ]; then
- echo "::error::Release ${TAG} carries ${ZIP_ASSET} built from ${RECORDED_SHA}, but this run built ${BUILT_SHA} — the existing assets came from a different commit. Delete all three assets deliberately to re-release."
- exit 1
- fi
+ [ "$RECORDED_SHA" = "$BUILT_SHA" ] || { echo "::error::Existing XCFramework provenance does not match this build."; exit 1; }
echo "asset_exists=true" >> "$GITHUB_OUTPUT"
- echo "::notice::Release ${TAG} already has ${ZIP_ASSET} (+ checksum/commit) built from this same commit — leaving them untouched."
- elif [ "$HAVE_ZIP" = "1" ] || [ "$HAVE_SUM" = "1" ] || [ "$HAVE_COMMIT" = "1" ]; then
- echo "::error::Release ${TAG} has only some of ${ZIP_ASSET} / ${SUM_ASSET} / ${COMMIT_ASSET} — an earlier attach was interrupted, so the existing assets' provenance cannot be verified. Delete the surviving assets and re-run."
+ elif [ "$HAVE_ZIP" = 1 ] || [ "$HAVE_SUM" = 1 ] || [ "$HAVE_COMMIT" = 1 ]; then
+ echo "::error::Release has only some XCFramework provenance assets; delete them before retrying."
exit 1
else
echo "asset_exists=false" >> "$GITHUB_OUTPUT"
fi
- # Attach to the PLATFORM release: only tag_name + files. Passing name/
- # body/prerelease/generate_release_notes here would overwrite the
- # platform release's own notes, title or prerelease flag.
- # overwrite_files: false makes a same-name upload race fail loudly
- # instead of silently replacing an asset the guard above vouched for.
- name: Attach XCFramework to the platform release
- if: steps.tag-guard.outputs.asset_exists != 'true'
+ if: steps.verify.outputs.asset_exists != 'true'
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
- tag_name: ${{ steps.release-ref.outputs.tag_name }}
+ tag_name: ${{ needs.build-and-release.outputs.tag_name }}
overwrite_files: false
files: |
- packages/swift-sdk/DashSDKFFI-${{ steps.release-ref.outputs.version }}.xcframework.zip
- packages/swift-sdk/DashSDKFFI-${{ steps.release-ref.outputs.version }}.checksum.txt
- packages/swift-sdk/DashSDKFFI-${{ steps.release-ref.outputs.version }}.commit.txt
+ release-assets/DashSDKFFI-${{ needs.build-and-release.outputs.version }}.xcframework.zip
+ release-assets/DashSDKFFI-${{ needs.build-and-release.outputs.version }}.checksum.txt
+ release-assets/DashSDKFFI-${{ needs.build-and-release.outputs.version }}.commit.txt
- # The durable home for the consumer checksum is the checksum.txt release
- # asset; this summary is a convenience copy. Skipped when the attach was
- # skipped: this run's freshly computed checksum would describe THIS
- # build, not the (not byte-reproducible) zip actually attached earlier.
- name: Write SwiftPM usage to the job summary
- if: steps.tag-guard.outputs.asset_exists != 'true'
+ if: steps.verify.outputs.asset_exists != 'true'
env:
- TAG: ${{ steps.release-ref.outputs.tag_name }}
- VERSION: ${{ steps.release-ref.outputs.version }}
- CHECKSUM: ${{ steps.zip.outputs.checksum }}
+ TAG: ${{ needs.build-and-release.outputs.tag_name }}
+ VERSION: ${{ needs.build-and-release.outputs.version }}
+ CHECKSUM: ${{ needs.build-and-release.outputs.checksum }}
REPO: ${{ github.repository }}
run: |
cat >> "$GITHUB_STEP_SUMMARY" <> "$GITHUB_OUTPUT"
+
+ # Only npm itself is needed here. Do not install dependencies or restore
+ # executable caches shared with the persistent builder in this OIDC job.
+ # No registry-url: with it, setup-node@v4 writes an _authToken line to
+ # .npmrc and exports a placeholder NODE_AUTH_TOKEN, so `npm publish` can
+ # attempt token auth instead of the trusted-publishing OIDC exchange.
+ # npm's default registry is already registry.npmjs.org.
+ - name: Setup Node.JS
+ uses: actions/setup-node@v4
with:
- target: wasm32-unknown-unknown
- if: ${{ steps.check-artifact.outputs.exists != 'true' }}
+ node-version: '24.14.1'
- - name: Setup sccache
- uses: ./.github/actions/sccache
+ - name: Download built package artifacts
+ uses: actions/download-artifact@v4
with:
- bucket: ${{ vars.CACHE_S3_BUCKET }}
- region: ${{ vars.AWS_REGION }}
- endpoint: ${{ vars.CACHE_S3_ENDPOINT }}
- access_key_id: ${{ secrets.CACHE_KEY_ID }}
- secret_access_key: ${{ secrets.CACHE_SECRET_KEY }}
-
- if: ${{ steps.check-artifact.outputs.exists != 'true' }}
-
- # Composite action defaults to Node 24+ which ships npm 11.5.1+;
- # required for trusted-publishers OIDC at the publish step below.
- # See https://docs.npmjs.com/trusted-publishers.
- - name: Setup Node.JS
- uses: ./.github/actions/nodejs
-
- - name: Install Cargo binstall
- uses: cargo-bins/cargo-binstall@v1.3.1
- if: ${{ steps.check-artifact.outputs.exists != 'true' }}
-
- - name: Install wasm-bindgen-cli
- run: cargo binstall wasm-bindgen-cli@0.2.108
- if: ${{ steps.check-artifact.outputs.exists != 'true' }}
-
- - name: Install wasm-pack
- run: cargo binstall wasm-pack
- if: ${{ steps.check-artifact.outputs.exists != 'true' }}
-
- - name: Install Binaryen
- run: |
- wget https://github.com/WebAssembly/binaryen/releases/download/version_121/binaryen-version_121-x86_64-linux.tar.gz -P /tmp
- tar -xzf /tmp/binaryen-version_121-x86_64-linux.tar.gz -C /tmp
- sudo cp -r /tmp/binaryen-version_121/* /usr/local/
- if: ${{ steps.check-artifact.outputs.exists != 'true' }}
+ name: js-build-${{ github.sha }}
+ path: release-artifacts
- - name: Build packages
- run: yarn build
+ # Verify every tarball against the trusted checkout metadata before the
+ # credentialed publish. Tarball contents are treated as data; no package
+ # script from the persistent builder is executed on this runner.
+ - name: Validate packed package identities
env:
- CARGO_BUILD_PROFILE: release
- if: ${{ steps.check-artifact.outputs.exists != 'true' }}
+ ARTIFACT_DIR: release-artifacts/npm-packages
+ run: |
+ set -euo pipefail
+ NPM_CLI_PATH="$(command -v npm)" node <<'NODE'
+ const fs = require('node:fs');
+ const path = require('node:path');
+ // Match npm publish's own manifest parser, including archive path aliases.
+ const npmBin = path.dirname(fs.realpathSync(process.env.NPM_CLI_PATH));
+ const pacote = require(path.join(npmBin, '../node_modules/pacote'));
+ (async () => {
+ const trusted = new Map();
+ for (const workspace of JSON.parse(fs.readFileSync('package.json')).workspaces) {
+ const manifest = JSON.parse(fs.readFileSync(path.join(workspace, 'package.json')));
+ if (!manifest.private) trusted.set(manifest.name, manifest.version);
+ }
+ const tarballs = fs.readdirSync(process.env.ARTIFACT_DIR).filter(file => file.endsWith('.tgz'));
+ if (!tarballs.length) throw new Error('No NPM tarballs were uploaded.');
+ const seen = new Set();
+ for (const file of tarballs) {
+ const tarball = path.resolve(process.env.ARTIFACT_DIR, file);
+ if (!fs.lstatSync(tarball).isFile()) throw new Error(`Not a regular tarball: ${file}`);
+ const manifest = await pacote.manifest(tarball, {
+ fullmetadata: true,
+ fullReadJson: true,
+ ignoreScripts: true,
+ cache: path.join(process.env.RUNNER_TEMP, 'npm-metadata-cache'),
+ });
+ if (!trusted.has(manifest.name) || trusted.get(manifest.name) !== manifest.version) {
+ throw new Error(`Untrusted package identity in ${file}`);
+ }
+ if (seen.has(manifest.name)) throw new Error(`Duplicate tarball for ${manifest.name}`);
+ seen.add(manifest.name);
+ // No workspace requires publishConfig. It can override npm's proxy,
+ // TLS and registry options even with --ignore-scripts, exposing OIDC.
+ if (manifest.publishConfig !== undefined && JSON.stringify(manifest.publishConfig) !== '{}') {
+ throw new Error(`Builder-supplied publishConfig is not allowed in ${file}`);
+ }
+ }
+ // A partial artifact would otherwise publish an incomplete release.
+ const missing = [...trusted.keys()].filter(name => !seen.has(name));
+ if (missing.length) throw new Error(`Missing NPM tarballs: ${missing.join(', ')}`);
+ })().catch(error => { console.error(error.message); process.exitCode = 1; });
+ NODE
- name: Set suffix
- uses: actions/github-script@v6
+ uses: actions/github-script@v8
id: suffix
with:
result-encoding: string
script: |
- const fullTag = "${{ inputs.tag }}" || context.payload.release.tag_name;
+ const fullTag = "${{ steps.test-tag.outputs.tag }}" || context.payload.release.tag_name;
if (fullTag.includes('-')) {
const [, fullSuffix] = fullTag.split('-');
const [suffix] = fullSuffix.split('.');
@@ -116,12 +148,12 @@ jobs:
}
- name: Set NPM release tag
- uses: actions/github-script@v6
+ uses: actions/github-script@v8
id: tag
with:
result-encoding: string
script: |
- const tag = "${{ inputs.tag }}" || context.payload.release.tag_name;
+ const tag = "${{ steps.test-tag.outputs.tag }}" || context.payload.release.tag_name;
const [, major, minor] = tag.match(/^v([0-9]+)\.([0-9]+)/);
return (tag.includes('-') ? `${major}.${minor}-${{steps.suffix.outputs.result}}` : 'latest');
@@ -130,41 +162,65 @@ jobs:
echo "NPM suffix: ${{ steps.suffix.outputs.result }}"
echo "NPM release tag: ${{ steps.tag.outputs.result }}"
+ # --provenance=false keeps what `yarn npm publish` did before. After a
+ # trusted-publishing token exchange from a public repository, npm turns
+ # provenance on unless it is set explicitly, and the registry then
+ # rejects every package whose repository.url does not name
+ # github.com/dashpay/platform (all but wasm-drive-verify today).
- name: Publish NPM packages
- run: yarn workspaces foreach --all --no-private --parallel npm publish --tolerate-republish --access public --tag ${{ steps.tag.outputs.result }}
-
- - name: Ignore only already cached artifacts
+ if: github.event_name == 'release'
+ env:
+ NPM_TAG: ${{ steps.tag.outputs.result }}
run: |
- find . -name '.gitignore' -exec rm -f {} +
- echo ".yarn" >> .gitignore
- echo "target" >> .gitignore
- echo "node_modules" >> .gitignore
- echo ".nyc_output" >> .gitignore
- echo ".idea" >> .gitignore
- echo ".ultra.cache.json" >> .gitignore
- echo "db/*" >> .gitignore
- if: ${{ steps.check-artifact.outputs.exists != 'true' }}
-
- - name: Get modified files
- id: diff
+ set -euo pipefail
+ REGISTRY="$(npm config get registry)"
+ REGISTRY="${REGISTRY%/}"
+ for tarball in release-artifacts/npm-packages/*.tgz; do
+ metadata=$(tar -xOf "$tarball" package/package.json)
+ name=$(jq -r '.name' <<<"$metadata")
+ version=$(jq -r '.version' <<<"$metadata")
+ # npm addresses scoped packages as @scope%2fname: keep the leading @,
+ # percent-encode only the scope separator.
+ encoded_name=${name//\//%2f}
+ status=$(curl --silent --show-error --location --output "$RUNNER_TEMP/npm-metadata.json" \
+ --write-out '%{http_code}' --connect-timeout 10 --max-time 30 \
+ "$REGISTRY/$encoded_name") || {
+ echo "::error::Could not query $REGISTRY for $name@$version."
+ exit 1
+ }
+ case "$status" in
+ 404)
+ ;;
+ 200)
+ jq -e --arg version "$version" '.versions[$version] != null' "$RUNNER_TEMP/npm-metadata.json" >/dev/null || {
+ jq -e . "$RUNNER_TEMP/npm-metadata.json" >/dev/null || { echo "::error::Invalid registry response for $name."; exit 1; }
+ npm publish "$tarball" --ignore-scripts --provenance=false --access public --tag "$NPM_TAG"
+ continue
+ }
+ echo "::notice::$name@$version already exists on $REGISTRY; skipping."
+ continue
+ ;;
+ *)
+ echo "::error::Registry query for $name@$version returned HTTP $status; refusing to publish blindly."
+ exit 1
+ ;;
+ esac
+ npm publish "$tarball" --ignore-scripts --provenance=false --access public --tag "$NPM_TAG"
+ done
+
+ - name: Dry-run NPM packages
+ if: github.event_name == 'workflow_dispatch'
+ env:
+ NPM_TAG: ${{ steps.tag.outputs.result }}
run: |
- echo "files<> $GITHUB_OUTPUT
- git ls-files --others --exclude-standard >> $GITHUB_OUTPUT
- echo "EOF" >> $GITHUB_OUTPUT
- if: ${{ steps.check-artifact.outputs.exists != 'true' }}
-
- - name: Upload the archive of built files
- uses: actions/upload-artifact@v4
- with:
- name: js-build-${{ github.sha }}
- path: ${{ steps.diff.outputs.files }}
- retention-days: 1
- if-no-files-found: error
- include-hidden-files: true
- if: ${{ steps.check-artifact.outputs.exists != 'true' }}
+ set -euo pipefail
+ for tarball in release-artifacts/npm-packages/*.tgz; do
+ npm publish "$tarball" --ignore-scripts --provenance=false --dry-run --access public --tag "$NPM_TAG"
+ done
release-drive-image:
name: Release Drive image
+ if: ${{ !startsWith(inputs.tag, 'npm-test:') }}
secrets: inherit
uses: ./.github/workflows/release-docker-image.yml
with:
@@ -176,6 +232,7 @@ jobs:
release-drive-image-debug:
name: Release Drive debug image
+ if: ${{ !startsWith(inputs.tag, 'npm-test:') }}
secrets: inherit
uses: ./.github/workflows/release-docker-image.yml
with:
@@ -189,7 +246,7 @@ jobs:
release-rs-dapi-image:
name: Release RS-DAPI image
- if: ${{ !inputs.only_drive }}
+ if: ${{ !inputs.only_drive && !startsWith(inputs.tag, 'npm-test:') }}
secrets: inherit
uses: ./.github/workflows/release-docker-image.yml
with:
@@ -201,7 +258,7 @@ jobs:
release-test-suite-image:
name: Release Test Suite image
- if: ${{ !inputs.only_drive }}
+ if: ${{ !inputs.only_drive && !startsWith(inputs.tag, 'npm-test:') }}
secrets: inherit
uses: ./.github/workflows/release-docker-image.yml
with:
@@ -214,7 +271,7 @@ jobs:
release-dashmate-helper-image:
name: Release Dashmate Helper image
secrets: inherit
- if: ${{ !inputs.only_drive }}
+ if: ${{ !inputs.only_drive && !startsWith(inputs.tag, 'npm-test:') }}
uses: ./.github/workflows/release-docker-image.yml
with:
name: Dashmate Helper
@@ -246,14 +303,13 @@ jobs:
with:
tag: ${{ github.event.release.tag_name }}
- release-dashmate-packages:
- name: Release Dashmate packages
+ build-dashmate-packages:
+ name: Build Dashmate packages
runs-on: ${{ matrix.os }}
- if: ${{ !inputs.only_drive }}
+ if: ${{ !inputs.only_drive && !startsWith(inputs.tag, 'npm-test:') }}
needs: release-npm
permissions:
- id-token: write # s3 cache
- contents: write # update release artifacts
+ contents: read
strategy:
fail-fast: false
matrix:
@@ -271,12 +327,15 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
+ persist-credentials: false
- name: Download JS build artifacts
uses: actions/download-artifact@v4
with:
name: js-build-${{ github.sha }}
- path: packages
+ # The mixed JS/NPM artifact is rooted at the repository, since its
+ # paths contain both packages/ and npm-packages/.
+ path: .
- name: Install macOS build deps
if: runner.os == 'macOS'
@@ -287,29 +346,6 @@ jobs:
if: runner.os == 'macOS'
uses: docker-practice/actions-setup-docker@master
- - name: Install the Apple certificate
- if: runner.os == 'macOS'
- env:
- BUILD_CERTIFICATE_BASE64: ${{ secrets.MACOS_BUILD_CERTIFICATE_BASE64 }}
- P12_PASSWORD: ${{ secrets.MACOS_P12_PASSWORD }}
- KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }}
- run: |
- # create variables
- CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
- KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
-
- # import certificate and provisioning profile from secrets
- echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
-
- # create temporary keychain
- security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
- security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
- security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
-
- # import certificate to keychain
- security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
- security list-keychain -d user -s $KEYCHAIN_PATH
-
- name: Install Linux build deps
if: runner.os == 'Linux'
run: sudo apt-get install -y nsis
@@ -324,23 +360,106 @@ jobs:
- name: Create package
env:
- OSX_KEYCHAIN: ${{ runner.temp }}/app-signing.keychain-db
- run: "${GITHUB_WORKSPACE}/scripts/pack_dashmate.sh ${{ matrix.package_type }}"
+ # Packaging executes dependency and lifecycle scripts, including
+ # output from the persistent builder. Signing happens in a new job.
+ DASHMATE_UNSIGNED: 'true'
+ run: |
+ "${GITHUB_WORKSPACE}/scripts/pack_dashmate.sh" "${{ matrix.package_type }}"
- - name: Upload artifacts to action summary
+ - name: Upload Dashmate packages
uses: actions/upload-artifact@v4
- if: github.event_name != 'release'
with:
- name: dashmate
+ name: dashmate-${{ matrix.package_type }}-${{ github.sha }}
path: packages/dashmate/dist/**
+ if-no-files-found: error
+ retention-days: 7
+
+ release-dashmate-packages:
+ name: Release Dashmate packages
+ needs: build-dashmate-packages
+ # `needs` on a matrix job waits for every leg and reports failure if any
+ # one leg failed, which by default would skip all four release legs. Each
+ # leg below needs only its own package type's artifact, so run whenever
+ # the build matrix ran at all: a leg whose build failed then fails at its
+ # own download step, and the other package types still ship.
+ if: >-
+ !cancelled()
+ && github.event_name == 'release'
+ && needs.build-dashmate-packages.result != 'skipped'
+ runs-on: ${{ matrix.os }}
+ permissions:
+ contents: write
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - package_type: tarballs
+ os: ubuntu-24.04
+ - package_type: win
+ os: ubuntu-24.04
+ - package_type: deb
+ os: ubuntu-24.04
+ - package_type: macos
+ os: macos-14
+ steps:
+ # Treat finished installers as data. This job never installs or runs
+ # package code and never restores caches from the build jobs.
+ - name: Download Dashmate packages
+ uses: actions/download-artifact@v4
+ with:
+ name: dashmate-${{ matrix.package_type }}-${{ github.sha }}
+ path: release-artifacts
+
+ - name: Install the Apple certificate
+ if: runner.os == 'macOS'
+ env:
+ BUILD_CERTIFICATE_BASE64: ${{ secrets.MACOS_BUILD_CERTIFICATE_BASE64 }}
+ P12_PASSWORD: ${{ secrets.MACOS_P12_PASSWORD }}
+ KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }}
+ run: |
+ set -euo pipefail
+ CERTIFICATE_PATH="$RUNNER_TEMP/build_certificate.p12"
+ KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db"
+ printf '%s' "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o "$CERTIFICATE_PATH"
+ security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+ security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
+ security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+ security import "$CERTIFICATE_PATH" -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
+ security list-keychain -d user -s "$KEYCHAIN_PATH"
+
+ - name: Sign macOS installers
+ if: runner.os == 'macOS'
+ run: |
+ set -euo pipefail
+ found=false
+ while IFS= read -r -d '' pkg; do
+ found=true
+ productsign --sign 'Developer ID Installer: The Dash Foundation, Inc.' \
+ --keychain "$RUNNER_TEMP/app-signing.keychain-db" \
+ "$pkg" "$RUNNER_TEMP/signed.pkg"
+ mv "$RUNNER_TEMP/signed.pkg" "$pkg"
+ done < <(find release-artifacts -type f -name '*.pkg' -print0)
+ "$found" || { echo '::error::No macOS installers were uploaded.'; exit 1; }
- name: Notarize MacOS Release Build
if: runner.os == 'macOS'
+ env:
+ APPLE_ID: ${{ secrets.MACOS_APPLE_ID }}
+ TEAM_ID: ${{ secrets.MACOS_TEAM_ID }}
+ NOTARIZING_PASSWORD: ${{ secrets.MACOS_NOTARIZING_PASSWORD }}
+ run: |
+ while IFS= read -r -d '' pkg; do
+ xcrun notarytool submit "$pkg" --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$NOTARIZING_PASSWORD" --wait
+ done < <(find release-artifacts -type f -name '*.pkg' -print0)
+
+ - name: Delete the Apple keychain
+ if: always() && runner.os == 'macOS'
run: |
- find packages/dashmate/dist/ -name '*.pkg' -exec sh -c 'xcrun notarytool submit "{}" --apple-id "${{ secrets.MACOS_APPLE_ID }}" --team-id "${{ secrets.MACOS_TEAM_ID }}" --password "${{ secrets.MACOS_NOTARIZING_PASSWORD }}" --wait;' \;
+ security delete-keychain "$RUNNER_TEMP/app-signing.keychain-db"
+ rm -f "$RUNNER_TEMP/build_certificate.p12"
- name: Upload artifacts to release
uses: softprops/action-gh-release@v0.1.15
if: github.event_name == 'release'
with:
- files: packages/dashmate/dist/**
+ files: release-artifacts/**
diff --git a/.github/workflows/runner-image-candidate.yml b/.github/workflows/runner-image-candidate.yml
new file mode 100644
index 00000000000..5e4708466fc
--- /dev/null
+++ b/.github/workflows/runner-image-candidate.yml
@@ -0,0 +1,33 @@
+name: Runner image candidate
+
+on:
+ pull_request_target:
+ types: [opened, synchronize, reopened, ready_for_review, closed]
+ branches: [master, 'v*-dev', 'ci/*']
+ paths: ['.github/runner-requirements.json']
+
+# This is trusted base-branch orchestration. Never check out PR code or select
+# the control revision from PR data. Build/publish execute on separate hosted VMs.
+permissions:
+ contents: read
+ pull-requests: read
+ actions: read
+ statuses: write
+
+concurrency:
+ group: ${{ github.event.action == 'closed' && format('runner-image-promote-{0}', github.event.pull_request.base.ref) || format('runner-image-pr-{0}', github.event.pull_request.number) }}
+ cancel-in-progress: ${{ github.event.action != 'closed' }}
+
+jobs:
+ image:
+ if: >-
+ (github.event.action != 'closed' && !github.event.pull_request.draft)
+ || (github.event.action == 'closed' && github.event.pull_request.merged)
+ uses: dashpay/dash-selfhosted-image/.github/workflows/platform-candidate.yml@7d901150bd3d0789d50c46f365b058f2f5f1f52d
+ with:
+ pull_request: ${{ github.event.pull_request.number }}
+ control_revision: 7d901150bd3d0789d50c46f365b058f2f5f1f52d
+ mode: ${{ github.event.action == 'closed' && 'promote' || 'candidate' }}
+ secrets:
+ DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
+ DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
diff --git a/.github/workflows/test-client-codegen.yml b/.github/workflows/test-client-codegen.yml
new file mode 100644
index 00000000000..1517760dacd
--- /dev/null
+++ b/.github/workflows/test-client-codegen.yml
@@ -0,0 +1,42 @@
+name: Native client generation
+on:
+ pull_request:
+ paths:
+ - 'packages/dapi-grpc/**'
+ - '.github/workflows/test-client-codegen.yml'
+ - 'yarn.lock'
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ generate:
+ strategy:
+ matrix:
+ os: [ubuntu-24.04, macos-15]
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - uses: actions/cache@v5
+ with:
+ path: ~/.cache/dash/client-codegen
+ key: client-codegen/${{ runner.os }}/${{ runner.arch }}/${{ hashFiles('packages/dapi-grpc/codegen.json') }}
+ - name: Build the locked native compilers
+ run: python3 packages/dapi-grpc/scripts/setup-codegen.py --install
+ - uses: ./.github/actions/nodejs
+ - name: Regenerate and test clients
+ run: |
+ yarn workspace @dashevo/dapi-grpc build
+ git diff --exit-code -- packages/dapi-grpc/clients
+ yarn workspace @dashevo/dapi-grpc test:unit
+ yarn workspace @dashevo/dapi-grpc exec python3 -m unittest discover -s tests/codegen -v
+ - name: Verify published client contents
+ run: |
+ mkdir -p npm-packages
+ yarn workspace @dashevo/dapi-grpc pack --out "$GITHUB_WORKSPACE/npm-packages/dapi-grpc.tgz"
+ python3 packages/dapi-grpc/scripts/check-packed-clients.py npm-packages
diff --git a/.github/workflows/tests-build-js.yml b/.github/workflows/tests-build-js.yml
index 75d1324ee40..05e7e642362 100644
--- a/.github/workflows/tests-build-js.yml
+++ b/.github/workflows/tests-build-js.yml
@@ -5,7 +5,8 @@ jobs:
build-js:
name: Build JS
runs-on: ubuntu-24.04
- timeout-minutes: 15
+ # Allow for a cold native compiler build and the final artifact upload.
+ timeout-minutes: 25
steps:
- uses: softwareforgood/check-artifact-v4-existence@v0
id: check-artifact
@@ -18,42 +19,16 @@ jobs:
fetch-depth: 0
if: ${{ steps.check-artifact.outputs.exists != 'true' }}
- - name: Check DockerHub credentials
- id: check-dockerhub
- if: ${{ steps.check-artifact.outputs.exists != 'true' }}
- run: |
- if [ -n "$DOCKERHUB_USERNAME" ]; then
- echo "available=true" >> "$GITHUB_OUTPUT"
- else
- echo "available=false" >> "$GITHUB_OUTPUT"
- fi
- env:
- DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
-
- - name: Login to DockerHub
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
- if: ${{ steps.check-artifact.outputs.exists != 'true' && steps.check-dockerhub.outputs.available == 'true' }}
-
- - name: Cache protoc Docker image
- id: cache-protoc
+ - name: Cache native client generators
uses: actions/cache@v5
with:
- path: /tmp/protoc-image.tar
- key: docker-rvolosatovs-protoc-4.0.0
+ path: ~/.cache/dash/client-codegen
+ key: client-codegen/${{ runner.os }}/${{ runner.arch }}/${{ hashFiles('packages/dapi-grpc/codegen.json') }}
if: ${{ steps.check-artifact.outputs.exists != 'true' }}
- - name: Load protoc image from cache
- run: docker load -i /tmp/protoc-image.tar
- if: ${{ steps.check-artifact.outputs.exists != 'true' && steps.cache-protoc.outputs.cache-hit == 'true' }}
-
- - name: Pull and cache protoc Docker image
- run: |
- docker pull rvolosatovs/protoc:4.0.0
- docker save rvolosatovs/protoc:4.0.0 -o /tmp/protoc-image.tar
- if: ${{ steps.check-artifact.outputs.exists != 'true' && steps.cache-protoc.outputs.cache-hit != 'true' }}
+ - name: Install pinned native client generators
+ run: python3 packages/dapi-grpc/scripts/setup-codegen.py --install
+ if: ${{ steps.check-artifact.outputs.exists != 'true' }}
- name: Setup Node.JS
uses: ./.github/actions/nodejs
diff --git a/.github/workflows/tests-rs-wallet.yml b/.github/workflows/tests-rs-wallet.yml
index 386d6bfe7be..b18700d9ba3 100644
--- a/.github/workflows/tests-rs-wallet.yml
+++ b/.github/workflows/tests-rs-wallet.yml
@@ -20,10 +20,8 @@
#
# No coverage here: code coverage is collected only by the nightly run of the
# full workspace workflow (tests-rs-workspace.yml, `coverage` input), which
-# also measures the wallet crates. Known trade-offs: this fast path stays
-# pinned to the macOS runners (unlike the full workspace job, which schedules
-# onto any `rust-ci` self-hosted runner), so wallet PRs depend on a mac
-# runner being online; and the scoped `-p` builds feature-unify shared deps
+# also measures the wallet crates. Both workflows use the same Linux image
+# pool, including ARM64 Linux VMs on Macs. The scoped `-p` builds feature-unify shared deps
# differently than `--workspace` builds, so the shared target/ carries an
# extra artifact flavor.
on:
@@ -35,9 +33,35 @@ on:
default: false
jobs:
+ select-runner:
+ name: Select compatible runner image
+ 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'
+ runs-on: ubuntu-24.04
+ timeout-minutes: 135
+ permissions:
+ contents: read
+ pull-requests: read
+ statuses: read
+ actions: read
+ outputs:
+ labels: ${{ steps.select.outputs.labels }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - name: Select provisioned pool or exact PR candidate
+ id: select
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: python3 .github/scripts/runner-image.py select --kind rust
+
test-mac:
- name: Wallet tests (macOS)
- runs-on: [self-hosted, macOS, ARM64]
+ name: Wallet tests (Linux image)
+ needs: select-runner
+ runs-on: ${{ fromJSON(needs.select-runner.outputs.labels) }}
if: >-
github.event_name != 'pull_request'
|| github.event.pull_request.head.repo.full_name == github.repository
@@ -58,7 +82,7 @@ jobs:
# shares the same runner workspace — wiping it on a wallet-only PR
# would force the next nightly into a ~2.5 min cold rebuild. The size
# guard below still removes the whole target/ if it outgrows the caps.
- - name: Prune macOS runner disk before tests
+ - name: Prune runner disk before tests
run: |
for path in ../target-backup-before-*-clean-*; do
if [ -e "$path" ]; then
@@ -77,6 +101,11 @@ jobs:
TARGET_MAX_MB=120000
MIN_FREE_MB=60000
+ TOTAL=$(df -m . | awk 'NR == 2 {print $2}')
+ if [ "${TOTAL:-0}" -gt 0 ]; then
+ [ $((TOTAL / 3)) -lt "$TARGET_MAX_MB" ] && TARGET_MAX_MB=$((TOTAL / 3))
+ [ $((TOTAL / 5)) -lt "$MIN_FREE_MB" ] && MIN_FREE_MB=$((TOTAL / 5))
+ fi
SIZE=$(du -sm target 2>/dev/null | awk '{print $1}' || echo 0)
FREE=$(df -m . | awk 'NR == 2 {print $4}')
SIZE=${SIZE:-0}
@@ -89,34 +118,15 @@ jobs:
rm -rf target
fi
- - name: Verify build dependencies (macOS)
- run: |
- # Persistent runners must be provisioned before accepting CI jobs.
- echo "/opt/homebrew/bin" >> "$GITHUB_PATH"
- echo "/opt/homebrew/opt/llvm/bin" >> "$GITHUB_PATH"
- export PATH="/opt/homebrew/opt/llvm/bin:/opt/homebrew/bin:$PATH"
-
- missing=0
- for tool in cmake llvm-config; do
- if ! "$tool" --version >/dev/null 2>&1; then
- echo "::error::Missing or unusable $tool. Provision this dependency on the runner before running CI."
- missing=1
- fi
- done
- for library in /opt/homebrew/opt/gmp/include/gmp.h /opt/homebrew/opt/gmp/lib/libgmp.dylib /opt/homebrew/opt/llvm/lib/libclang.dylib; do
- if [ ! -r "$library" ]; then
- echo "::error::Missing or unreadable $library. Provision this dependency on the runner before running CI."
- missing=1
- fi
- done
- exit "$missing"
-
- name: Setup Rust
uses: ./.github/actions/rust
with:
cache: false
components: rustfmt, clippy
+ - name: Verify repository-pinned cargo-nextest
+ run: test "$(cargo nextest --version | awk 'NR == 1 {print $2}')" = "$CI_CARGO_NEXTEST_VERSION"
+
# 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,
@@ -131,7 +141,7 @@ jobs:
- name: Find unused dependencies
run: |
- cargo install cargo-machete 2>/dev/null || true
+ test "$(cargo machete --version | awk '{print $NF}')" = "$CI_CARGO_MACHETE_VERSION"
cargo machete
- name: Detect immutable structure changes
diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml
index fb5632dc6da..19e21262574 100644
--- a/.github/workflows/tests-rs-workspace.yml
+++ b/.github/workflows/tests-rs-workspace.yml
@@ -1,6 +1,14 @@
on:
workflow_call:
inputs:
+ runner-architecture:
+ description: Optional native architecture for image validation (X64 or ARM64)
+ type: string
+ default: ''
+ validate-arm64-image:
+ description: Use validation-only ARM64 capacity before admission to the ordinary Rust pool
+ type: boolean
+ default: false
doctests-changed:
description: Whether doc comments with code examples have changed
type: boolean
@@ -26,14 +34,43 @@ on:
default: false
jobs:
+ select-runner:
+ name: Select compatible runner image
+ 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'
+ runs-on: ubuntu-24.04
+ # Allow the separate 90-minute build and publication/queue window to finish.
+ timeout-minutes: 135
+ permissions:
+ contents: read
+ pull-requests: read
+ statuses: read
+ actions: read
+ outputs:
+ labels: ${{ steps.select.outputs.labels }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - name: Select provisioned pool or wait for the exact PR candidate
+ id: select
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RUNNER_ARCHITECTURE: ${{ inputs.runner-architecture }}
+ VALIDATE_ARM64_IMAGE: ${{ inputs.validate-arm64-image }}
+ run: |
+ extra=()
+ if [ "$VALIDATE_ARM64_IMAGE" = true ]; then extra+=(--validation); fi
+ python3 .github/scripts/runner-image.py select --kind rust --arch "$RUNNER_ARCHITECTURE" "${extra[@]}"
+
test:
name: Tests
- # Scheduled onto whichever self-hosted runner is free — the macOS boxes or
- # the Linux one. `rust-ci` is a custom label applied to exactly those
- # runners; pairing it with `self-hosted` keeps the job off GitHub-hosted
- # runners entirely, so untrusted code never reaches a hosted Linux VM by
- # way of a label collision.
- runs-on: [self-hosted, rust-ci]
+ # Shared image pool: physical Linux hosts and ARM64 Linux VMs on Macs.
+ # Native macOS registrations remain available for Swift/Xcode.
+ needs: select-runner
+ runs-on: ${{ fromJSON(needs.select-runner.outputs.labels) }}
# Fork PRs must not execute on any persistent runner, macOS or Linux.
if: >-
github.event_name != 'pull_request'
@@ -130,43 +167,29 @@ jobs:
done
exit "$missing"
- # clang, llvm and libsnappy are installed by ./.github/actions/rust on
- # Linux; this covers what the rest of the job needs and what a bare
- # self-hosted image doesn't ship. Every branch is a no-op once the
- # persistent runner has been provisioned by the first run.
- - name: Install build dependencies (Linux)
+ # The persistent runner image is the versioned CI environment. Runtime
+ # apt/sudo would let a job mutate the runner and is unnecessary once the
+ # image contract is provisioned.
+ - name: Verify build dependencies (Linux)
if: runner.os == 'Linux'
run: |
set -euo pipefail
- MISSING=()
- for pkg in build-essential cmake libgmp-dev libssl-dev pkg-config jq zip; do
- dpkg -s "$pkg" >/dev/null 2>&1 || MISSING+=("$pkg")
+ missing=()
+ for tool in clang cmake gh jq llvm-config rustup zip; do
+ command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
done
- if [ ${#MISSING[@]} -gt 0 ]; then
- echo "Installing: ${MISSING[*]}"
- sudo apt-get update -qq
- sudo apt-get install -qq --yes "${MISSING[@]}"
- fi
-
- # Needed by the immutable-structure check below.
- if ! command -v gh >/dev/null 2>&1; then
- sudo apt-get install -qq --yes gh || {
- curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
- | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
- echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
- | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
- sudo apt-get update -qq
- sudo apt-get install -qq --yes gh
- }
+ for pkg in build-essential clang libgmp-dev libsnappy-dev libssl-dev llvm pkg-config; do
+ dpkg -s "$pkg" >/dev/null 2>&1 || missing+=("$pkg")
+ done
+ if [ ${#missing[@]} -gt 0 ]; then
+ echo "::error::Self-hosted runner image is missing: ${missing[*]}"
+ echo "::error::Provision these in the pinned runner image; CI jobs must not install host packages."
+ exit 1
fi
- # dtolnay/rust-toolchain drives rustup; it must already exist.
- if ! command -v rustup >/dev/null 2>&1; then
- curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
- | sh -s -- -y --no-modify-path --default-toolchain none
- echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- fi
+ # rustup is prebaked; Setup Rust may install the repository-selected
+ # toolchain under the runner user's home, without root.
- name: Setup Rust
uses: ./.github/actions/rust
@@ -174,26 +197,17 @@ jobs:
cache: false
components: llvm-tools, rustfmt, clippy
- # Install only when missing, and fail HERE, loudly, if the tool still
- # doesn't work afterwards — the previous `cargo install ... 2>/dev/null
- # || true` silently swallowed a failed nextest install on a freshly
- # provisioned runner, and the job then died 10 minutes later in the
- # test step with "no such command: nextest". cargo-llvm-cov is only
- # needed by the nightly coverage run.
- - name: Install cargo-llvm-cov
+ # These helpers are part of the pinned runner image. Version checks make
+ # image drift fail before the test phase instead of installing tools from
+ # a job or silently swallowing a failed installation. Coverage tooling
+ # is only needed when the caller requests an instrumented run.
+ - name: Verify repository-pinned cargo-llvm-cov
if: ${{ inputs.coverage }}
- run: |
- if ! cargo llvm-cov --version >/dev/null 2>&1; then
- cargo install cargo-llvm-cov --locked
- fi
- cargo llvm-cov --version
+ run: cargo llvm-cov --version | grep -Fx "cargo-llvm-cov $CI_CARGO_LLVM_COV_VERSION"
- - name: Install cargo-nextest
- run: |
- if ! cargo nextest --version >/dev/null 2>&1; then
- cargo install cargo-nextest --locked
- fi
- cargo nextest --version
+ - name: Verify repository-pinned cargo-nextest
+ # Release binaries include commit/host metadata after the version.
+ run: test "$(cargo nextest --version | awk 'NR == 1 {print $2}')" = "$CI_CARGO_NEXTEST_VERSION"
- name: Check formatting
run: cargo fmt --check --all
@@ -219,7 +233,7 @@ jobs:
- name: Find unused dependencies
run: |
- cargo install cargo-machete 2>/dev/null || true
+ test "$(cargo machete --version | awk '{print $NF}')" = "$CI_CARGO_MACHETE_VERSION"
cargo machete
# The transport-free cuts are how embedders with their own networking
@@ -623,11 +637,13 @@ jobs:
- name: Run doctests
if: ${{ inputs.doctests-changed }}
run: |
+ # Rustdoc compiles/links examples concurrently, independently of
+ # Cargo's build-job limit. Bound it too for memory-limited runners.
cargo test \
--workspace \
--all-features \
--locked \
- --doc
+ --doc -- --test-threads="${CARGO_BUILD_JOBS:-2}"
env:
CARGO_PROFILE_DEV_DEBUG: "0"
CARGO_PROFILE_DEV_CODEGEN_UNITS: "256"
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index f1ad788ecfc..0508af6eafb 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -61,6 +61,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 }}
+ arm64-image-changed: ${{ steps.filter-rs-workflows.outputs.arm64-image }}
rs-scope: ${{ steps.rs-scope.outputs.scope }}
shielded-changed: ${{ steps.override.outputs.shielded-changed || steps.filter-shielded.outputs.shielded-changed }}
doctests-changed: ${{ steps.override.outputs.doctests-changed || steps.filter-doctests.outputs.doctests-changed }}
@@ -76,6 +77,11 @@ jobs:
- name: Verify self-hosted Swift runner policy
run: python3 .github/scripts/check-swift-self-hosted-runner.py
+ - name: Verify candidate-image routing and manifest inputs
+ run: |
+ python3 .github/scripts/runner-image.py env
+ python3 -m unittest discover -s .github/scripts/tests -v
+
- uses: dorny/paths-filter@v4
id: filter-js
if: ${{ github.event_name != 'workflow_dispatch' }}
@@ -104,6 +110,12 @@ jobs:
- .github/workflows/tests-rs-wallet.yml
- .github/workflows/tests.yml
- .github/scripts/check-wallet-closure.py
+ - .github/runner-requirements.json
+ - .github/runner-requirements.arm64.json
+ - .github/scripts/runner-image.py
+ - .github/actions/rust/**
+ arm64-image:
+ - .github/runner-requirements.arm64.json
- uses: dorny/paths-filter@v4
id: filter-e2e
@@ -286,7 +298,7 @@ jobs:
exit 0
fi
- if echo "$CHANGED" | grep -qE '(^|/)Cargo\.(toml|lock)$|^rust-toolchain\.toml$|^\.github/actions/rust/|^\.github/workflows/tests\.yml$|^\.github/workflows/tests-rs-workspace\.yml$'; then
+ if echo "$CHANGED" | grep -qE '(^|/)Cargo\.(toml|lock)$|^rust-toolchain\.toml$|^\.github/runner-requirements(\.arm64)?\.json$|^\.github/scripts/runner-image\.py$|^\.github/actions/rust/|^\.github/workflows/tests\.yml$|^\.github/workflows/tests-rs-workspace\.yml$'; then
echo "shielded-changed=true" >> "$GITHUB_OUTPUT"
echo "Build configuration changed — shielded tests will run"
exit 0
@@ -469,6 +481,23 @@ jobs:
# test phases without instrumentation and upload nothing to Codecov.
coverage: ${{ github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.coverage == true) }}
+ # The AMD64 candidate publisher cannot prove an ARM64 image. Changes to
+ # its separate manifest need a real full workspace job on that architecture,
+ # in addition to any ordinary/AMD64 candidate job above. Provision the exact
+ # ARM64 image before merging; a hosted build or skipped fork job is not proof.
+ rs-arm64-image-tests:
+ name: ARM64 runner image validation
+ needs: changes
+ if: ${{ needs.changes.outputs.arm64-image-changed == 'true' }}
+ secrets: inherit
+ uses: ./.github/workflows/tests-rs-workspace.yml
+ with:
+ runner-architecture: ARM64
+ validate-arm64-image: true
+ doctests-changed: true
+ shielded-changed: true
+ coverage: false
+
# 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`).
diff --git a/.pnp.cjs b/.pnp.cjs
index ddb8eee1876..c1f40457d98 100755
--- a/.pnp.cjs
+++ b/.pnp.cjs
@@ -2664,7 +2664,8 @@ const RAW_RUNTIME_STATE =
["mocha", "npm:11.1.0"],\
["mocha-sinon", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:2.1.2"],\
["sinon", "npm:18.0.1"],\
- ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"]\
+ ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"],\
+ ["ts-protoc-gen", "npm:0.15.0"]\
],\
"linkType": "SOFT"\
}]\
@@ -12883,6 +12884,13 @@ const RAW_RUNTIME_STATE =
["google-protobuf", "npm:3.19.1"]\
],\
"linkType": "HARD"\
+ }],\
+ ["npm:3.21.4", {\
+ "packageLocation": "./.yarn/cache/google-protobuf-npm-3.21.4-48c47540d3-0d87fe8ef2.zip/node_modules/google-protobuf/",\
+ "packageDependencies": [\
+ ["google-protobuf", "npm:3.21.4"]\
+ ],\
+ "linkType": "HARD"\
}]\
]],\
["gopd", [\
@@ -21667,6 +21675,16 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["ts-protoc-gen", [\
+ ["npm:0.15.0", {\
+ "packageLocation": "./.yarn/cache/ts-protoc-gen-npm-0.15.0-4bb1076a19-de1d526b47.zip/node_modules/ts-protoc-gen/",\
+ "packageDependencies": [\
+ ["google-protobuf", "npm:3.21.4"],\
+ ["ts-protoc-gen", "npm:0.15.0"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["tsconfck", [\
["npm:3.0.0", {\
"packageLocation": "./.yarn/cache/tsconfck-npm-3.0.0-f54c83f135-25789acde6.zip/node_modules/tsconfck/",\
diff --git a/.yarn/cache/google-protobuf-npm-3.21.4-48c47540d3-0d87fe8ef2.zip b/.yarn/cache/google-protobuf-npm-3.21.4-48c47540d3-0d87fe8ef2.zip
new file mode 100644
index 00000000000..e65708da399
Binary files /dev/null and b/.yarn/cache/google-protobuf-npm-3.21.4-48c47540d3-0d87fe8ef2.zip differ
diff --git a/.yarn/cache/ts-protoc-gen-npm-0.15.0-4bb1076a19-de1d526b47.zip b/.yarn/cache/ts-protoc-gen-npm-0.15.0-4bb1076a19-de1d526b47.zip
new file mode 100644
index 00000000000..f7a25dfe1b8
Binary files /dev/null and b/.yarn/cache/ts-protoc-gen-npm-0.15.0-4bb1076a19-de1d526b47.zip differ
diff --git a/AGENTS.md b/AGENTS.md
index 316d53322ad..26ede667802 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -78,7 +78,8 @@ Platform uses data contracts to define application data schemas:
- Run linters: `yarn lint`
## Coding Style & Naming Conventions
-- Follow `.editorconfig`: 2-space indent by default; 4 spaces for `*.rs` and
+- Follow `.editorconfig`: 2-space indent by default; 4 spaces for `*.rs`,
+ Swift and Kotlin (`*.swift`, `*.kt`, `*.kts`), and
`packages/swift-sdk/scripts/*.py`, preserving the existing Python script style.
Use LF, UTF‑8, and a final newline.
- JS/TS: ESLint (Airbnb/TypeScript rules via package configs). Use camelCase for variables/functions, PascalCase for classes; prefer kebab-case filenames within JS packages.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d4ae8423857..72d965e81cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,217 @@
+## [4.2.0-beta.6](https://github.com/dashpay/platform/compare/v4.2.0-beta.5...v4.2.0-beta.6) (2026-09-28)
+
+
+### ⚠ BREAKING CHANGES
+
+* **platform:** a preallocated agreement source must fit a tree key (PV14) (#5123)
+* **sdk:** countOf and sumOf totals in the rule descriptors of the JS, Swift and Kotlin SDKs (#5121)
+* **platform:** refuse own-type totals on contested types and fail loudly on unread totals (PV14) (#5115)
+* **drive:** subscription filters match generated properties a transition leaves out (#5114)
+* **platform:** generatedFrom, string properties the platform generates with a system function (PV14) (#5099)
+* **platform:** countOf and sumOf totals from count and sum trees in propertyConstraints rules (PV14) (#5109)
+* **dpp:** propertyConstraints read empty objects as absent and follow $defs refs (PV14) (#5101)
+* **platform:** elected moderation windows may be 0 off mainnet, mainnet keeps one day (PV14) (#5108)
+* **platform:** ifThen, ifThenElse, notIn, min, max and abs in propertyConstraints rules (PV14) (#5100)
+* **swift-sdk:** new propertyConstraints read kinds and system reads in the Swift SDK and iOS example app (#5098)
+* **kotlin-sdk:** new propertyConstraints read kinds and system reads in the Kotlin SDK and Android example app (#5097)
+* **dpp:** size estimates of strings of 16384 or more characters no longer overflow (PV14) (#5086)
+* **platform:** startsWith and endsWith in propertyConstraints rules (PV14) (#5085)
+* **platform:** contains in propertyConstraints rules (PV14) (#5083)
+* **dpp:** report contracts refused by parser generation 3 as consensus errors (PV14) (#5076)
+* **platform:** creation, update and transfer times and heights in propertyConstraints rules (PV14) (#5078)
+* **platform:** string length, byte length and array count operands in propertyConstraints rules (PV14) (#5071)
+* **dpp:** propertyConstraints compare identifier properties that declare refersTo (PV14) (#5073)
+* **dpp:** accept a reordered entryPayload and refuse unruled keywords as incompatible (PV14) (#5074)
+* **dpp:** refuse token cost and unruled keyword changes on update with a consensus error (PV14) (#5069)
+* **platform:** $ownerId comparisons in propertyConstraints rules (PV14) (#5048)
+* **platform:** identifier comparisons in propertyConstraints rules (PV14) (#5047)
+* **platform:** string ifAbsent defaults in propertyConstraints rules (PV14) (#5046)
+* **platform:** compare two string properties in propertyConstraints rules (PV14) (#5045)
+
+### Features
+
+* **kotlin-sdk:** new propertyConstraints read kinds and system reads in the Kotlin SDK and Android example app ([#5097](https://github.com/dashpay/platform/issues/5097))
+* **platform:** $ownerId comparisons in propertyConstraints rules (PV14) ([#5048](https://github.com/dashpay/platform/issues/5048))
+* **platform:** compare two string properties in propertyConstraints rules (PV14) ([#5045](https://github.com/dashpay/platform/issues/5045))
+* **platform:** contains in propertyConstraints rules (PV14) ([#5083](https://github.com/dashpay/platform/issues/5083))
+* **platform:** countOf and sumOf totals from count and sum trees in propertyConstraints rules (PV14) ([#5109](https://github.com/dashpay/platform/issues/5109))
+* **platform:** creation, update and transfer times and heights in propertyConstraints rules (PV14) ([#5078](https://github.com/dashpay/platform/issues/5078))
+* **platform:** elected moderation windows may be 0 off mainnet, mainnet keeps one day (PV14) ([#5108](https://github.com/dashpay/platform/issues/5108))
+* **platform:** generatedFrom, string properties the platform generates with a system function (PV14) ([#5099](https://github.com/dashpay/platform/issues/5099))
+* **platform:** identifier comparisons in propertyConstraints rules (PV14) ([#5047](https://github.com/dashpay/platform/issues/5047))
+* **platform:** ifThen, ifThenElse, notIn, min, max and abs in propertyConstraints rules (PV14) ([#5100](https://github.com/dashpay/platform/issues/5100))
+* **platform:** startsWith and endsWith in propertyConstraints rules (PV14) ([#5085](https://github.com/dashpay/platform/issues/5085))
+* **platform:** string ifAbsent defaults in propertyConstraints rules (PV14) ([#5046](https://github.com/dashpay/platform/issues/5046))
+* **platform:** string length, byte length and array count operands in propertyConstraints rules (PV14) ([#5071](https://github.com/dashpay/platform/issues/5071))
+* **sdk:** countOf and sumOf totals in the rule descriptors of the JS, Swift and Kotlin SDKs ([#5121](https://github.com/dashpay/platform/issues/5121))
+* **sdk:** propertyConstraints discovery and pre-check in the JS SDK ([#5051](https://github.com/dashpay/platform/issues/5051))
+* **sdk:** propertyConstraints rules and pre-check in the Kotlin SDK and Android example app ([#5066](https://github.com/dashpay/platform/issues/5066))
+* **sdk:** propertyConstraints rules and pre-check in the Swift SDK and iOS example app ([#5064](https://github.com/dashpay/platform/issues/5064))
+* **swift-sdk:** new propertyConstraints read kinds and system reads in the Swift SDK and iOS example app ([#5098](https://github.com/dashpay/platform/issues/5098))
+
+
+### Bug Fixes
+
+* **ci:** build release clients natively on unprivileged runners
+* **ci:** isolate release runners from PR build state
+* **dpp:** accept a reordered entryPayload and refuse unruled keywords as incompatible (PV14) ([#5074](https://github.com/dashpay/platform/issues/5074))
+* **dpp:** parse nested required and transient entries by prefix ([#5050](https://github.com/dashpay/platform/issues/5050))
+* **dpp:** pass the contract's $defs to the countOf and sumOf key enum check ([#5110](https://github.com/dashpay/platform/issues/5110))
+* **dpp:** propertyConstraints compare identifier properties that declare refersTo (PV14) ([#5073](https://github.com/dashpay/platform/issues/5073))
+* **dpp:** propertyConstraints read empty objects as absent and follow $defs refs (PV14) ([#5101](https://github.com/dashpay/platform/issues/5101))
+* **dpp:** refuse token cost and unruled keyword changes on update with a consensus error (PV14) ([#5069](https://github.com/dashpay/platform/issues/5069))
+* **dpp:** report contracts refused by parser generation 3 as consensus errors (PV14) ([#5076](https://github.com/dashpay/platform/issues/5076))
+* **dpp:** restore the shipped order of basic consensus errors ([#5053](https://github.com/dashpay/platform/issues/5053))
+* **dpp:** size estimates of strings of 16384 or more characters no longer overflow (PV14) ([#5086](https://github.com/dashpay/platform/issues/5086))
+* **drive-abci:** finalize a block accepted in an earlier round after a later proposal was refused ([#5081](https://github.com/dashpay/platform/issues/5081))
+* **drive-abci:** sign a locked block when a later proposal left no execution context ([#5079](https://github.com/dashpay/platform/issues/5079))
+* **drive-abci:** sign vote extensions only for blocks this node accepted ([#5084](https://github.com/dashpay/platform/issues/5084))
+* **drive:** subscription filters match generated properties a transition leaves out ([#5114](https://github.com/dashpay/platform/issues/5114))
+* **platform:** a preallocated agreement source must fit a tree key (PV14) ([#5123](https://github.com/dashpay/platform/issues/5123))
+* **platform:** refuse own-type totals on contested types and fail loudly on unread totals (PV14) ([#5115](https://github.com/dashpay/platform/issues/5115))
+* **release:** require all generated clients in packed archives
+* **sdk:** bound each DAPI request attempt, including the response body ([#4973](https://github.com/dashpay/platform/issues/4973))
+* **sdk:** consensus errors reach JS with their code ([#5112](https://github.com/dashpay/platform/issues/5112))
+* **sdk:** consensus errors reach Swift and Kotlin apps with their code ([#5116](https://github.com/dashpay/platform/issues/5116))
+* **swift-sdk:** documentTransfer handles a missing document and signs once ([#5120](https://github.com/dashpay/platform/issues/5120))
+* **swift-sdk:** free FFI errors in state-transition wrappers ([#5117](https://github.com/dashpay/platform/issues/5117))
+* **swift-sdk:** take migration copies out of WAL mode
+* **wasm-sdk:** keep StateTransitionResult.ownerBalance exact in JSON ([#5059](https://github.com/dashpay/platform/issues/5059))
+* **wasm-sdk:** leave price tier validation to rs-dpp ([#5058](https://github.com/dashpay/platform/issues/5058))
+
+
+### Miscellaneous Chores
+
+* **swift-sdk:** freeze App Store schema 3.0.0
+
+
+### Continuous Integration
+
+* bootstrap PR-first runner images on v4.2-dev
+* reconcile rootless runner workflows with v4.2-dev, closes [#4702](https://github.com/dashpay/platform/issues/4702)
+
+
+### Code Refactoring
+
+* **dpp:** restore shipped registration_cost v1 index parsing ([#5055](https://github.com/dashpay/platform/issues/5055))
+* **drive:** create once-per-identity claim trees in insert_contract v2 ([#5056](https://github.com/dashpay/platform/issues/5056))
+* **platform:** fold DRIVE_ABCI_QUERY_VERSIONS_V3 into V2 ([#5057](https://github.com/dashpay/platform/issues/5057))
+* **platform:** import instead of inline crate paths in 4.1 and 4.2 code ([#5065](https://github.com/dashpay/platform/issues/5065))
+
+
+### Documentation
+
+* add a contract keywords reference page to the book ([#5067](https://github.com/dashpay/platform/issues/5067))
+* encrypt the 69-byte compact xpub in the contact-request guide ([#5087](https://github.com/dashpay/platform/issues/5087))
+* give every contract keyword its own chapter in the book ([#5075](https://github.com/dashpay/platform/issues/5075))
+* list the complete contract language in the keywords overview ([#5082](https://github.com/dashpay/platform/issues/5082))
+* **platform:** add Parameters and Returns sections to 4.1 and 4.2 dispatchers ([#5063](https://github.com/dashpay/platform/issues/5063))
+* **platform:** document the genesis protocol version exception and Swift/Kotlin indentation ([#5061](https://github.com/dashpay/platform/issues/5061))
+* **platform:** say why in-place edits to shipped generations are inert ([#5054](https://github.com/dashpay/platform/issues/5054))
+* remove committed working specs and plans ([#5060](https://github.com/dashpay/platform/issues/5060))
+* **swift-sdk:** say the migration copy is switched out of WAL mode
+
+
+### Tests
+
+* **drive:** pin that a cached contract read after an in-block update bills like a cold read ([#5052](https://github.com/dashpay/platform/issues/5052))
+* follow the test conventions in tests added in 4.1 and 4.2 ([#5062](https://github.com/dashpay/platform/issues/5062))
+* **sdk:** ifThen and ifThenElse rules through rs-sdk-ffi and the Swift and Kotlin SDKs ([#5106](https://github.com/dashpay/platform/issues/5106))
+
+## [4.2.0-beta.5](https://github.com/dashpay/platform/compare/v4.2.0-beta.4...v4.2.0-beta.5) (2026-09-27)
+
+
+### ⚠ BREAKING CHANGES
+
+* **platform:** string equality for enums in propertyConstraints rules (PV14) (#5042)
+* **platform:** pay document ttl storage fees to the epochs the documents live in (PV14) (#5033)
+* **platform:** contenders state the most they pay and are charged the join price (PV14) (#5039)
+* **platform:** boolean operands in propertyConstraints rules (PV14) (#5040)
+* **platform:** in, value membership in propertyConstraints rules (PV14) (#5038)
+* **platform:** present and absent tests in propertyConstraints rules (PV14) (#5037)
+* **platform:** anyOf, allOf and not in propertyConstraints rules (PV14) (#5036)
+* **platform:** a contender's fund doubles for every 50 contenders a contest holds past 250 (PV14) (#5034)
+* **drive-abci:** cap a contest at 1,000 contenders and tally every one (PV14) (#5029)
+* **platform:** documents with a time to live, deleted by the platform (PV14) (#5007)
+* **drive:** an evonode's token claim covers only the epochs it read (PV14) (#5015)
+* **drive-abci:** claw a storage refund back from the epochs it was priced for (PV14) (#5013)
+* **drive-abci:** refuse bytes after a state transition (PV14) (#5011)
+* **drive-abci:** refuse a masternode vote for an identity that is not a contender (PV14) (#5002)
+* **drive:** delete an ended vote poll end date only once none of its polls remain (PV14) (#4996)
+* **drive-abci:** refuse a token mint or direct purchase past the i64::MAX supply ceiling (PV14) (#5000)
+* **drive-abci:** allow contested documents before epoch 4 (#4995)
+* **drive:** merge repeated writes of one balance in a batch so an action fee no longer loses a purchase price (#4987)
+* **platform:** credit repaid identity debt to the processing fee pool (#4985)
+* **dpp:** refuse immutableAllowSetting on a deletableDocument reference (PV14) (#4983)
+* **drive-abci:** re-check a contract reference's owner requirement on every replace of a transferable document (PV14) (#4982)
+* **drive-abci:** refuse a $creatorId key reference on a document without a creator id (PV14) (#4984)
+
+### Features
+
+* **drive-abci:** allow contested documents before epoch 4 ([#4995](https://github.com/dashpay/platform/issues/4995))
+* **platform:** a contender's fund doubles for every 50 contenders a contest holds past 250 (PV14) ([#5034](https://github.com/dashpay/platform/issues/5034))
+* **platform:** anyOf, allOf and not in propertyConstraints rules (PV14) ([#5036](https://github.com/dashpay/platform/issues/5036))
+* **platform:** boolean operands in propertyConstraints rules (PV14) ([#5040](https://github.com/dashpay/platform/issues/5040))
+* **platform:** documents with a time to live, deleted by the platform (PV14) ([#5007](https://github.com/dashpay/platform/issues/5007))
+* **platform:** in, value membership in propertyConstraints rules (PV14) ([#5038](https://github.com/dashpay/platform/issues/5038))
+* **platform:** pay document ttl storage fees to the epochs the documents live in (PV14) ([#5033](https://github.com/dashpay/platform/issues/5033))
+* **platform:** present and absent tests in propertyConstraints rules (PV14) ([#5037](https://github.com/dashpay/platform/issues/5037))
+* **platform:** string equality for enums in propertyConstraints rules (PV14) ([#5042](https://github.com/dashpay/platform/issues/5042))
+* **rs-dapi:** refuse shielded broadcasts from addresses that keep sending invalid proofs ([#5001](https://github.com/dashpay/platform/issues/5001))
+
+
+### Bug Fixes
+
+* **dpp:** refuse immutableAllowSetting on a deletableDocument reference (PV14) ([#4983](https://github.com/dashpay/platform/issues/4983))
+* **drive-abci:** cap a contest at 1,000 contenders and tally every one (PV14) ([#5029](https://github.com/dashpay/platform/issues/5029))
+* **drive-abci:** check the address input limit before verifying witnesses ([#5005](https://github.com/dashpay/platform/issues/5005))
+* **drive-abci:** claw a storage refund back from the epochs it was priced for (PV14) ([#5013](https://github.com/dashpay/platform/issues/5013))
+* **drive-abci:** re-check a contract reference's owner requirement on every replace of a transferable document (PV14) ([#4982](https://github.com/dashpay/platform/issues/4982))
+* **drive-abci:** refuse a $creatorId key reference on a document without a creator id (PV14) ([#4984](https://github.com/dashpay/platform/issues/4984))
+* **drive-abci:** refuse a masternode vote for an identity that is not a contender (PV14) ([#5002](https://github.com/dashpay/platform/issues/5002))
+* **drive-abci:** refuse a token mint or direct purchase past the i64::MAX supply ceiling (PV14) ([#5000](https://github.com/dashpay/platform/issues/5000))
+* **drive-abci:** refuse bytes after a state transition (PV14) ([#5011](https://github.com/dashpay/platform/issues/5011))
+* **drive-abci:** sign and verify vote extensions of a block accepted in another round ([#5028](https://github.com/dashpay/platform/issues/5028))
+* **drive-abci:** verify vote extensions against the withdrawals of their own round ([#5010](https://github.com/dashpay/platform/issues/5010))
+* **drive:** an evonode's token claim covers only the epochs it read (PV14) ([#5015](https://github.com/dashpay/platform/issues/5015))
+* **drive:** delete an ended vote poll end date only once none of its polls remain (PV14) ([#4996](https://github.com/dashpay/platform/issues/4996))
+* **drive:** merge repeated writes of one balance in a batch so an action fee no longer loses a purchase price ([#4987](https://github.com/dashpay/platform/issues/4987))
+* **drive:** read stored group actions without the proof decoding budget ([#5006](https://github.com/dashpay/platform/issues/5006))
+* **platform:** contenders state the most they pay and are charged the join price (PV14) ([#5039](https://github.com/dashpay/platform/issues/5039))
+* **platform:** credit repaid identity debt to the processing fee pool ([#4985](https://github.com/dashpay/platform/issues/4985))
+* **rs-sdk-ffi:** stop probing google.com and testnet quorums on every SDK build ([#5008](https://github.com/dashpay/platform/issues/5008))
+* **sdk:** don't panic in DapiClient::new on an empty address list ([#4964](https://github.com/dashpay/platform/issues/4964))
+
+
+### Performance Improvements
+
+* **drive-abci:** read shielded encrypted notes in one chunk-aligned range read ([#5030](https://github.com/dashpay/platform/issues/5030))
+* **drive:** build batch deletes without copying the pending batch ([#5004](https://github.com/dashpay/platform/issues/5004))
+
+
+### Tests
+
+* **drive:** keep setup_drive's temp directory alive while the drive is open ([#5003](https://github.com/dashpay/platform/issues/5003))
+* **swift-sdk:** run SDKMethodTests against the offline mock SDK instead of live testnet ([#5009](https://github.com/dashpay/platform/issues/5009))
+
+
+### Miscellaneous Chores
+
+* open every pr-description output with a basic explanation section ([#5031](https://github.com/dashpay/platform/issues/5031))
+* sync the pr-description skill with the PR template and title check ([#5032](https://github.com/dashpay/platform/issues/5032))
+
+
+### Continuous Integration
+
+* build release SDKs and NPM packages on self-hosted runners ([#4562](https://github.com/dashpay/platform/issues/4562))
+* re-pin PR Hygiene ([#4975](https://github.com/dashpay/platform/issues/4975))
+* re-pin PR Hygiene ([#4979](https://github.com/dashpay/platform/issues/4979))
+* re-pin PR Hygiene ([#4989](https://github.com/dashpay/platform/issues/4989))
+* re-pin PR Hygiene ([#5016](https://github.com/dashpay/platform/issues/5016))
+* re-pin PR Hygiene ([#5023](https://github.com/dashpay/platform/issues/5023))
+* re-pin PR Hygiene and wake it on a hand-over ([#5020](https://github.com/dashpay/platform/issues/5020))
+* **release:** raise NPM and Swift SDK release job timeouts ([#4974](https://github.com/dashpay/platform/issues/4974))
+
## [4.2.0-beta.4](https://github.com/dashpay/platform/compare/v4.2.0-beta.3...v4.2.0-beta.4) (2026-09-24)
diff --git a/Cargo.lock b/Cargo.lock
index a7f35d1b352..e7d97484fc3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -159,7 +159,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "app-connect-contract"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"base58",
"platform-value",
@@ -1055,7 +1055,7 @@ dependencies = [
[[package]]
name = "check-features"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"toml 0.8.23",
]
@@ -1543,7 +1543,7 @@ dependencies = [
[[package]]
name = "dapi-grpc"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"dash-platform-macros",
"futures-core",
@@ -1630,7 +1630,7 @@ dependencies = [
[[package]]
name = "dash-async"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"futures",
"thiserror 2.0.18",
@@ -1641,7 +1641,7 @@ dependencies = [
[[package]]
name = "dash-context-provider"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"dash-async",
"dpp",
@@ -1672,7 +1672,7 @@ dependencies = [
[[package]]
name = "dash-platform-balance-checker"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"anyhow",
"clap",
@@ -1687,7 +1687,7 @@ dependencies = [
[[package]]
name = "dash-platform-macros"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"heck 0.5.0",
"quote",
@@ -1696,7 +1696,7 @@ dependencies = [
[[package]]
name = "dash-platform-queries"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"dapi-grpc",
"dash-context-provider",
@@ -1713,7 +1713,7 @@ dependencies = [
[[package]]
name = "dash-sdk"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"arc-swap",
"assert_matches",
@@ -1858,7 +1858,7 @@ dependencies = [
[[package]]
name = "dashpay-contract"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"platform-value",
"platform-version",
@@ -1868,7 +1868,7 @@ dependencies = [
[[package]]
name = "data-contracts"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"app-connect-contract",
"base58",
@@ -2073,7 +2073,7 @@ dependencies = [
[[package]]
name = "document-history-contract"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"platform-value",
"platform-version",
@@ -2095,7 +2095,7 @@ checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1"
[[package]]
name = "dpns-contract"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"platform-value",
"platform-version",
@@ -2105,7 +2105,7 @@ dependencies = [
[[package]]
name = "dpp"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"anyhow",
"assert_matches",
@@ -2164,7 +2164,7 @@ dependencies = [
[[package]]
name = "dpp-json-convertible-derive"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"proc-macro2",
"quote",
@@ -2173,7 +2173,7 @@ dependencies = [
[[package]]
name = "drive"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"arc-swap",
"assert_matches",
@@ -2216,7 +2216,7 @@ dependencies = [
[[package]]
name = "drive-abci"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"arc-swap",
"assert_matches",
@@ -2278,7 +2278,7 @@ dependencies = [
[[package]]
name = "drive-proof-verifier"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"dapi-grpc",
"dash-context-provider",
@@ -4080,7 +4080,7 @@ dependencies = [
[[package]]
name = "json-schema-compatibility-validator"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"assert_matches",
"json-patch",
@@ -4223,7 +4223,7 @@ dependencies = [
[[package]]
name = "keyword-search-contract"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"base58",
"platform-value",
@@ -4414,7 +4414,7 @@ dependencies = [
[[package]]
name = "masternode-reward-shares-contract"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"platform-value",
"platform-version",
@@ -4615,7 +4615,7 @@ dependencies = [
[[package]]
name = "moderation-charters-contract"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"base58",
"platform-value",
@@ -5198,7 +5198,7 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "platform-encryption"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"aes",
"cbc",
@@ -5210,7 +5210,7 @@ dependencies = [
[[package]]
name = "platform-serialization"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"grovedb-bincode",
"platform-version",
@@ -5218,7 +5218,7 @@ dependencies = [
[[package]]
name = "platform-serialization-derive"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"proc-macro2",
"quote",
@@ -5228,7 +5228,7 @@ dependencies = [
[[package]]
name = "platform-value"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"base64 0.22.1",
"bs58",
@@ -5247,7 +5247,7 @@ dependencies = [
[[package]]
name = "platform-value-convertible"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"quote",
"syn 2.0.117",
@@ -5255,7 +5255,7 @@ dependencies = [
[[package]]
name = "platform-version"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"grovedb-bincode",
"grovedb-version",
@@ -5265,7 +5265,7 @@ dependencies = [
[[package]]
name = "platform-versioning"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"proc-macro2",
"quote",
@@ -5274,7 +5274,7 @@ dependencies = [
[[package]]
name = "platform-wallet"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"arc-swap",
"async-trait",
@@ -5314,7 +5314,7 @@ dependencies = [
[[package]]
name = "platform-wallet-ffi"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"anyhow",
"async-trait",
@@ -5342,7 +5342,7 @@ dependencies = [
[[package]]
name = "platform-wallet-storage"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"apple-native-keyring-store",
"argon2",
@@ -6359,7 +6359,7 @@ dependencies = [
[[package]]
name = "rs-dapi"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"async-trait",
"axum 0.8.9",
@@ -6409,7 +6409,7 @@ dependencies = [
[[package]]
name = "rs-dapi-client"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"backon",
"chrono",
@@ -6417,9 +6417,11 @@ dependencies = [
"futures",
"getrandom 0.2.17",
"gloo-timers",
+ "h2",
"hex",
"http",
"http-serde",
+ "hyper-util",
"lru",
"rand 0.8.6",
"serde",
@@ -6428,13 +6430,14 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tonic-web-wasm-client",
+ "tower 0.5.3",
"tracing",
"wasm-bindgen-futures",
]
[[package]]
name = "rs-dash-event-bus"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"metrics",
"tokio",
@@ -6467,7 +6470,7 @@ dependencies = [
[[package]]
name = "rs-sdk-ffi"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"async-trait",
"bs58",
@@ -6501,7 +6504,7 @@ dependencies = [
[[package]]
name = "rs-sdk-trusted-context-provider"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"arc-swap",
"dash-async",
@@ -6521,7 +6524,7 @@ dependencies = [
[[package]]
name = "rs-unified-sdk-ffi"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"dash-network",
"key-wallet-ffi",
@@ -6531,7 +6534,7 @@ dependencies = [
[[package]]
name = "rs-unified-sdk-jni"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"android_logger",
"dash-network",
@@ -7273,7 +7276,7 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "simple-signer"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7410,7 +7413,7 @@ dependencies = [
[[package]]
name = "strategy-tests"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"dpp",
"drive",
@@ -7812,7 +7815,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "token-history-contract"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"platform-value",
"platform-version",
@@ -8655,7 +8658,7 @@ dependencies = [
[[package]]
name = "wallet-utils-contract"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"platform-value",
"platform-version",
@@ -8798,7 +8801,7 @@ checksum = "a8145dd1593bf0fb137dbfa85b8be79ec560a447298955877804640e40c2d6ea"
[[package]]
name = "wasm-dpp"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"anyhow",
"async-trait",
@@ -8822,7 +8825,7 @@ dependencies = [
[[package]]
name = "wasm-dpp2"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"anyhow",
"async-trait",
@@ -8841,7 +8844,7 @@ dependencies = [
[[package]]
name = "wasm-drive-verify"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"base64 0.22.1",
"bs58",
@@ -8896,7 +8899,7 @@ dependencies = [
[[package]]
name = "wasm-sdk"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"base64 0.22.1",
"bip39",
@@ -9407,7 +9410,7 @@ dependencies = [
[[package]]
name = "withdrawals-contract"
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
dependencies = [
"num_enum 0.5.11",
"platform-value",
diff --git a/Cargo.toml b/Cargo.toml
index e8727211b7b..030eaee0c49 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -149,5 +149,5 @@ opt-level = 3
[workspace.package]
-version = "4.2.0-beta.4"
+version = "4.2.0-beta.6"
rust-version = "1.98"
diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md
index c41f5122fa5..abc3286f5e0 100644
--- a/book/src/SUMMARY.md
+++ b/book/src/SUMMARY.md
@@ -34,6 +34,7 @@
- [Fee System Overview](fees/overview.md)
- [Platform Address Fees](fees/platform-address-fees.md)
- [Shielded Transaction Fees](fees/shielded-fees.md)
+- [What a Document Costs](fees/document-cost.md)
# Error Handling
@@ -57,10 +58,46 @@
- [Contract Groups](data-model/contract-groups.md)
- [Contract Moderation](data-model/contract-moderation.md)
- [Documents](data-model/documents.md)
+- [Document Time To Live](data-model/document-ttl.md)
- [Contested Documents](data-model/contested-documents.md)
- [Identities](data-model/identities.md)
- [Key Budgets and Expiry](data-model/key-limits.md)
+# Contract Keywords
+
+- [Overview](contract-keywords.md)
+- [Document Shape](contract-keywords/document-shape.md)
+- [Property Schemas](contract-keywords/property-schemas.md)
+- [Typed Arrays](contract-keywords/typed-arrays.md)
+- [System Properties](contract-keywords/system-properties.md)
+- [requiredSince](contract-keywords/required-since.md)
+- [transient](contract-keywords/transient.md)
+- [Mutability](contract-keywords/mutability.md)
+- [Deletion](contract-keywords/deletion.md)
+- [Time To Live (ttl)](contract-keywords/ttl.md)
+- [Creation, Transfers and Trading](contract-keywords/ownership-and-trading.md)
+- [History](contract-keywords/history.md)
+- [Signing and Keys](contract-keywords/signing-keys.md)
+- [References (refersTo)](contract-keywords/refers-to.md)
+ - [Lookups](contract-keywords/refers-to-lookup.md)
+ - [Expressions](contract-keywords/refers-to-expressions.md)
+ - [List Elements](contract-keywords/refers-to-list-element.md)
+ - [Writer and Creator References](contract-keywords/owner-refers-to.md)
+- [distinctFrom](contract-keywords/distinct-from.md)
+- [maxBytes](contract-keywords/max-bytes.md)
+- [generatedFrom](contract-keywords/generated-from.md)
+- [encryptedFor](contract-keywords/encrypted-for.md)
+- [propertyConstraints](contract-keywords/property-constraints.md)
+- [Token Costs (tokenCost)](contract-keywords/token-cost.md)
+- [Action Fees (actionFees)](contract-keywords/action-fees.md)
+- [Indexes (indices)](contract-keywords/indexes.md)
+ - [Contested Indexes](contract-keywords/contested.md)
+ - [Counts, Sums and Averages](contract-keywords/aggregates.md)
+ - [Ranked Indexes](contract-keywords/ranked.md)
+ - [Time-Range Indexes](contract-keywords/time-range.md)
+ - [Index-Only Types](contract-keywords/index-only.md)
+- [Contract-Level Keys and config](contract-keywords/contract-config.md)
+
# Drive
- [The GroveDB Structure](drive/grovedb-structure.md)
diff --git a/book/src/architecture/component-pipeline.md b/book/src/architecture/component-pipeline.md
index c292e1a908f..e7cc066216a 100644
--- a/book/src/architecture/component-pipeline.md
+++ b/book/src/architecture/component-pipeline.md
@@ -94,7 +94,9 @@ where
```
The `FullAbciApplication` struct wires everything together. It holds a reference
-to `Platform`, a GroveDB transaction, and the current block execution context:
+to `Platform`, a GroveDB transaction, the current block execution context, and
+the withdrawal transactions of every proposal accepted at the current height,
+which vote extensions are verified against:
```rust
// From packages/rs-drive-abci/src/abci/app/full.rs
@@ -102,6 +104,7 @@ pub struct FullAbciApplication<'a, C> {
pub platform: &'a Platform,
pub transaction: RwLock