CI/CD Integration
This page is a reference for integrating soroban-budget-assert into a GitHub Actions CI/CD pipeline. It covers the complete example workflow, explains each step, and provides guidance on customization and troubleshooting.
For a step-by-step tutorial that walks through the full setup from scratch, see the End-to-End CI Tutorial.
Purpose
Why budget assertions should run in CI
A contract that passes tests locally can still exceed resource limits on the network. The gap between local Soroban WASM estimates and real network costs means that a cost regression can go unnoticed until a transaction fails on testnet or, worse, on mainnet. Running budget assertions in CI catches regressions before they reach the network.
Benefits of automated budget regression detection
- Early feedback: a pull request that pushes a function past its budget fails the CI check immediately, with the exact metric and limit in the log.
- Audit trail: the measured costs are captured as artifacts or step summaries, so the review history includes the budget data.
- Consistent baselines: Tier A macro assertions (
#[budget_cpu_lt],#[budget_mem_lt]) pin a specific limit intocargo test, making the pass/fail boundary reviewable in the same diff as the contract change.
When to include budget validation
Include budget validation whenever the contract source, the release profile, or the Soroban SDK version changes. The usual pipeline rules apply:
- Every push to
main: record the budget report for the cost-over-time dashboard. - Every pull request targeting
main: run Tier A assertions as a required status check. - Periodically (or on SDK bumps): re-derive Tier A limits from a fresh Tier B network report.
GitHub Actions Example
The following workflow runs both tiers of budget validation in a single CI pipeline. It implements the same pattern this repository's own .github/workflows/budget.yml is built around: Tier A runs everywhere and needs no secrets; Tier B runs only where secrets exist. Our shipped file is currently a deliberately reduced case of that pattern — its Tier B step emits placeholder JSON so the job stays green without testnet configuration (see the fork-safe section and the tutorial for why). The example below shows the full destination pattern.
The fork-PR constraint this structure works around: GitHub withholds repository secrets from pull_request runs opened from forks, which is where every external contribution arrives. Any ungated step that consumes secrets.* fails on exactly those PRs while staying green on maintainer pushes.
Example Workflow File
Save this as .github/workflows/budget.yml in your repository:
name: Soroban Budget Check
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
contents: read
jobs:
budget-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.93.0
targets: wasm32v1-none
- name: Cache Rust Dependencies
uses: Swatinem/rust-cache@v2
- name: Install System Dependencies
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config libudev-dev
# Everything below that touches the network or a secret is gated on
# push events: secrets are withheld from pull_request runs opened from
# forks, where every external contribution comes from.
- name: Install Stellar CLI
if: github.event_name == 'push'
run: |
curl -sL https://github.com/stellar/stellar-cli/releases/download/v21.5.3/stellar-cli-21.5.3-x86_64-unknown-linux-gnu.tar.gz | tar -xz
mv stellar ~/.cargo/bin/
- name: Configure Stellar Identity
if: github.event_name == 'push'
env:
ALICE_SECRET_KEY: $\{\{ secrets.ALICE_SECRET_KEY \}\}
run: stellar keys add alice --secret-key "$ALICE_SECRET_KEY"
- name: Check formatting
run: cargo fmt --all -- --check
- name: Run Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Build Contracts
run: cargo build -p amm-pool-contract --release --target wasm32v1-none
- name: Run Budget Macros Test (Tier A)
run: cargo test --workspace
- name: Run Budget Report (Tier B)
if: github.event_name == 'push'
run: cargo run --bin cargo-budget-report -- budget-report --json --validate > current_report.json
- name: Write Placeholder Report (fork PR)
if: github.event_name != 'push'
run: |
# Fork PRs have no testnet identity; emit placeholder rows so the
# summary/artifact steps below still find a file. Synthetic values —
# never feed these into history or enforcement.
echo '[{"package":"amm-pool-contract","function":"do_expensive_work","metric":"CPU Instructions","value":1000000},{"package":"amm-pool-contract","function":"do_expensive_work","metric":"Read Bytes","value":4096}]' > current_report.json
- name: Publish Step Summary
if: hashFiles('current_report.json') != ''
run: |
{
echo "# Workspace Budget Report (${{ github.sha }})"
echo ""
echo "| Package | Function | Metric | Value |"
echo "|---------|----------|--------|-------|"
jq -r '.[] | "| \(.package) | \(.function) | \(.metric) | \(.value) |"' current_report.json
echo ""
echo "---"
echo "_Simulated resource amounts, not fees._"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload Budget Report
uses: actions/upload-artifact@v7
with:
name: budget-report
path: current_report.jsonExplanation of Each Step
actions/checkout@v7
Checks out the repository so subsequent steps have access to the source code. fetch-depth: 0 ensures full Git history is available for operations like commit comparison and record-history.
dtolnay/rust-toolchain
Installs the Rust toolchain pinned to the version you measure with locally. The targets argument installs the project's wasm32v1-none measurement target. Keep the pin aligned with the channel in your rust-toolchain.toml, or local and CI numbers will drift apart.
Swatinem/rust-cache
Caches compiled Rust dependencies between runs. Speeds up subsequent workflow executions significantly. The cache key is derived from Cargo.lock and the toolchain version, so it invalidates automatically when dependencies change.
Install System Dependencies
Installs libdbus-1-dev, pkg-config, and libudev-dev. These are required by the Soroban SDK's system dependencies on Linux. Without them, cargo build fails with linker errors.
Install Stellar CLI
Downloads and installs the prebuilt stellar CLI binary. The workflow uses a tarball rather than cargo install to avoid a multi-minute Rust build. The step is gated on github.event_name == 'push': fork PRs cannot reach testnet anyway (no secrets), so there is no reason to pay for the download — or to fail it when the release tarball moves — on contributor runs. Bump the pinned version when you upgrade the SDK.
Configure Stellar Identity
Imports the testnet secret key as a Stellar CLI identity named alice. This identity is used by cargo budget-report to deploy and invoke contracts on testnet. The identity name must match the source field in your budget.toml. The secret key arrives through an env: mapping from the GitHub Actions secret (ALICE_SECRET_KEY) and is referenced as "$ALICE_SECRET_KEY" inside the script — do not interpolate $\{\{ secrets.* \}\} directly into run: lines or echo the variable. Like every secret-consuming step, this one carries the push-event gate: fork PRs receive no secrets and would fail here otherwise.
Check formatting
Runs cargo fmt --all -- --check to verify code formatting. Fails the build if the code does not match the repository's rustfmt.toml configuration. This enforces consistent code style across all contributors.
Run Clippy
Runs cargo clippy --workspace --all-targets -- -D warnings to lint the entire workspace. Every warning is treated as an error (-D warnings), so no lint slips through. This enforces the project's code quality standards.
Build Contracts
Builds the contract WASM with the release profile. This step compiles the Soroban contract(s) to WASM so that:
- The Tier A macro tests can load and execute the WASM (not raw Rust).
- The Tier B budget report can simulate deployment on testnet.
Use cargo build -p <your-package> --release --target wasm32v1-none for a single package, or cargo build --workspace --release --target wasm32v1-none for multiple.
Run Budget Macros Test (Tier A)
Runs cargo test --workspace to execute all tests, including those annotated with #[budget_cpu_lt] and #[budget_mem_lt]. This is the fast, local, CI-blocking gate: if a function's measured CPU or memory exceeds its pinned limit, the test fails with the exact metric and limit in the output.
Run Budget Report (Tier B)
Runs cargo run --bin cargo-budget-report -- budget-report --json --validate to produce a network-verified resource cost report. The JSON output is saved to current_report.json. This step deploys the contract WASM to testnet and simulates each configured function, returning the real resource costs. The step is gated on github.event_name == 'push', so only runs that actually have the testnet identity available pay the network cost.
The --json flag produces machine-readable output suitable for artifact upload, step-summary rendering, and further processing. --validate cross-checks reported metrics against the Stellar CLI's own XDR decoder. Use --check to enforce limits configured in budget.toml.
On fork PRs — where this step is skipped — a sibling step writes placeholder rows to the same path so the summary and artifact steps still find a file. Placeholder values are synthetic: never let them feed cost history or limit enforcement.
Publish Step Summary
Appends a GitHub-flavored Markdown table built from current_report.json to $GITHUB_STEP_SUMMARY. The table appears inline on the workflow run page and is surfaced with the check on pull requests, making the budget data visible without downloading an artifact. The jq filter emits one pipe-table row per JSON entry (package, function, metric, value). The step is guarded by hashFiles('current_report.json') != '' so it no-ops cleanly if Tier B was skipped and no fallback ran.
Note: A native Markdown output mode for
cargo budget-reportitself is planned but not shipped yet; piping the tool's plain-text table into the summary does not render correctly (GitHub expects Markdown). Build the pipe table withjqas shown.
Upload Budget Report
Uploads current_report.json as a workflow artifact named budget-report. The artifact can be downloaded from the run page for manual inspection or downstream processing (e.g., the cost-over-time dashboard's record-history job).
Customization
Running on multiple Rust versions
If your project supports multiple Rust toolchains, use a build matrix:
strategy:
matrix:
toolchain: ["1.93.0", "stable"]
steps:
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ matrix.toolchain }}
targets: wasm32v1-noneNote that changing the Rust version may produce different WASM and therefore different budget numbers. The pinned version in rust-toolchain.toml is what this project's measurements are based on.
Limiting execution to pull requests
To run budget checks only on pull requests (not on every push to main), remove the push trigger:
on:
pull_request:
branches: ["main"]Running only on selected branches
To limit execution to specific branches:
on:
push:
branches: ["main", "develop"]
pull_request:
branches: ["main", "develop"]Integrating with existing CI pipelines
You can merge the budget check into an existing workflow file by copying the relevant steps. The minimum required steps for Tier A (local, CI-blocking) are:
- name: Build Contracts
run: cargo build -p your-contract --release --target wasm32v1-none
- name: Run Budget Macros Test
run: cargo test --workspaceAdd the Tier B steps when you need network-verified cost measurements and have configured a testnet identity with the ALICE_SECRET_KEY secret.
Fork-safe fallback for Tier B
Pull requests from forks do not have access to repository secrets — GitHub withholds them by design, because the PR author is untrusted. This is why every secret-consuming step in the example above (CLI install, identity import, Tier B report) is gated on github.event_name == 'push', and why a placeholder-writing sibling covers fork PRs so downstream steps still find current_report.json.
The minimal shape of the split:
- name: Run Budget Report (push)
if: github.event_name == 'push'
run: cargo run --bin cargo-budget-report -- budget-report --json --validate > current_report.json
- name: Run Budget Report (fork / pull request)
if: github.event_name != 'push'
run: echo '[{"package":"your-contract","function":"your_function","metric":"CPU Instructions","value":0}]' > current_report.jsonRules for the placeholder path:
- Label it loudly (step name and comment) so nobody mistakes synthetic rows for measurements.
- Never feed placeholder output into cost history (
record-history-style jobs) or--checkenforcement; gate anything durable on the push event regardless. - The simpler alternative is to skip Tier B on fork PRs entirely (no sibling step) and guard consumers with
if: hashFiles('current_report.json') != ''. Use that when you do not need an artifact from every run.
This repo's own workflow hit the failure mode before it was documented: gating the job on a secret the job did not need made every contributor PR red, and the fix (dropping the testnet steps entirely until they are reinstated behind event gates) is recorded in a comment in .github/workflows/budget.yml. The End-to-End CI Tutorial covers the constraint in depth.
Best Practices
Fail builds on budget regressions
Make the budget-check job (or its Tier A equivalent) a required status check in your branch protection rules. This prevents merging any pull request that would push a function past its budget.
Keep budget baselines current
Re-run cargo budget-report --json and re-derive Tier A limits whenever:
- The contract source changes.
- The release profile in
Cargo.tomlchanges. - The Soroban SDK version changes.
- The
[margin]block inbudget.tomlchanges.
Avoid unnecessary workflow duplication
If you already have a CI workflow that builds and tests your contracts, add the budget steps to that existing workflow rather than creating a separate one. The Tier A steps (cargo build + cargo test) integrate naturally into any Rust CI pipeline.
Validate changes before merging
Require the budget-check job to pass before merging. The branch protection rule for main in this repository already requires the Quality Checks status check. Add budget-check to that list so a budget regression blocks the merge alongside formatting, Clippy, and test failures.
Use the same release profile
Always build WASM with the same [profile.release] settings locally and in CI. The published measurements in this repository use the size-optimized profile (opt-level = "z", lto = true, etc.). Numbers from a different profile are not comparable. Copy the profile from Cargo.toml into your workspace before recording or comparing budget figures.
Exit codes
cargo-budget-report exits with a distinct code per outcome so a CI job can tell a real regression apart from a flaky network. Every code other than 0 means the run failed, so scripts that only care about pass/fail ($? -ne 0) keep working unchanged.
| Code | Constant | Meaning | CI should… |
|---|---|---|---|
0 | EXIT_SUCCESS | The run succeeded; all measurements are within limits and tolerances. | Continue. |
1 | EXIT_GENERIC_FAILURE | An unexpected failure that is neither a budget/regression result nor a network fault (e.g. the contract WASM failed to build). | Fail the job; inspect the log. |
3 | EXIT_CONFIG_ERROR | budget.toml is malformed, a required configuration value is missing, or another configuration-level mistake was detected. | Fail the job; fix the config. Do not retry. |
4 | EXIT_BUDGET_EXCEEDED | A measured resource breached a configured --check limit. | Fail the job; treat as a budget regression. |
5 | EXIT_REGRESSION | A regression was detected beyond tolerance when comparing against a baseline (--check-baseline). | Fail the job; treat as a code regression. |
6 | EXIT_NETWORK_FAILURE | A network or infrastructure failure (RPC error, the stellar CLI could not be spawned, a simulation failed to produce metrics, or --validate decode failure). | Retry; results from this run are unreliable. |
The codes are chosen to avoid the values with reserved meaning in POSIX shells (126, 127, and 128 + N for fatal signals) and to stay clear of clap's own exit code 2, which cargo-budget-report uses for argument-parse errors before main runs. When more than one outcome occurs in a single run, the most actionable wins: regression beats budget-exceeded beats network-failure. A regression is a real signal that should block a PR, whereas a network fault is safe to retry, so surfacing the regression takes priority.
Example workflow that branches on the exit code
The following snippet maps each outcome to a different action. It assumes the report step has already produced current_report.json.
- name: Run Budget Report
if: github.event_name == 'push'
id: report
continue-on-error: true
run: |
cargo run --bin cargo-budget-report -- budget-report --json --check --check-baseline budget-baseline.toml > current_report.json
echo "code=$?" >> "$GITHUB_OUTPUT"
- name: Classify outcome
if: github.event_name == 'push'
env:
REPORT_CODE: ${{ steps.report.outputs.code }}
run: |
case "${REPORT_CODE:-0}" in
0) echo "✅ Budget check passed." ;;
1) echo "💥 Unexpected failure — see logs."; exit 1 ;;
3) echo "⚙️ Configuration error — fix budget.toml, do not retry."; exit 3 ;;
4) echo "🚨 Budget exceeded — a limit in budget.toml was breached."; exit 4 ;;
5) echo "📉 Regression beyond tolerance — block the PR."; exit 5 ;;
6) echo "🌐 Network/infrastructure failure — retrying."; exit 6 ;;
*) echo "Unknown exit code ${REPORT_CODE}"; exit 1 ;;
esacNote:
continue-on-error: truelets the step record$?without aborting the job before the classifier runs. The classifier re-emits the same code, so branch protection still sees the real result. A network failure (6) is the one case where you might preferexit 0plus aretry:annotation rather than failing, depending on how aggressively your pipeline retries transient testnet errors.
Troubleshooting
Missing toolchain
Symptom: The dtolnay/rust-toolchain step fails with error: toolchain 'X.Y.Z' is not installed.
Fix: Verify that the toolchain: version in the workflow matches the channel in your rust-toolchain.toml. If they differ, rustup will try to use two different toolchains and may fail. Pin both to the same version.
Dependency caching problems
Symptom: The Swatinem/rust-cache step takes a long time or produces a cache miss on every run.
Fix: Ensure Cargo.lock is checked into the repository. The cache key is derived from Cargo.lock contents. Without it, the cache cannot detect dependency changes efficiently. If cache entries grow stale, clear the cache from the GitHub Actions UI (Settings → Actions → Caches).
Failing budget assertions
Symptom: The cargo test step fails with a message like:
CPU instruction cost 5,400,123 exceeded limit 5,000,000 - local estimate,
real network cost may differ significantly in either directionFix: Re-measure the function's cost with cargo budget-report and update the limit in your budget.toml or macro annotation. If the increase is expected (e.g., you added a feature), raise the limit consciously. If it is a regression, optimize the function.
Formatting failures
Symptom: The cargo fmt --all -- --check step exits non-zero with a diff of formatting changes.
Fix: Run cargo fmt --all locally, commit the formatting changes, and push again. To catch this before CI, install the pre-commit hook from scripts/install-hooks.sh.
Clippy failures
Symptom: The cargo clippy step exits non-zero with warnings promoted to errors.
Fix: Read the Clippy output to identify the lint violation. Run cargo clippy --workspace --all-targets locally to reproduce, fix the issue, and commit. If the lint is a false positive, add an #[allow(...)] attribute with a brief justification.
Test failures
Symptom: cargo test --workspace fails with test errors unrelated to budget assertions.
Fix: Check whether the WASM was built before running tests (cargo build -p <contract> --release --target wasm32v1-none). Tests that load contract WASM will fail if the WASM artifact is missing or stale. Rebuild and re-run. If the failure is in a non-budget test, it is a real test break — fix the test or the code it exercises.
Unfunded or reset testnet accounts
Symptom: cargo budget-report exits non-zero with source account may be unfunded or txInsufficientBalance in the error chain.
Fix: Re-fund the testnet identity:
stellar keys fund alice --network testnetFriendbot-funded accounts are reset periodically. If the workflow has been idle for a week, re-fund before debugging further. This is the most common cause of Tier B failures unrelated to the contract code.
stellar CLI missing on the runner
Symptom: The Install Stellar CLI step fails with a download error, or stellar is not found later in the workflow.
Fix: The workflow installs Stellar CLI from a prebuilt tarball. Verify the URL in the curl command points to a valid release. Bump the version number when you upgrade the SDK. The tarballs are published under each GitHub release at https://github.com/stellar/stellar-cli/releases.
See also
- End-to-End CI Tutorial — step-by-step guide for setting up the full pipeline.
- End-User Guide — installing the tool, configuring
budget.toml, and writing gated tests. - Protocol Mechanics — why local estimates differ from network costs.
- Tool Reference — every CLI flag and macro signature.
- Developer Guide — building and extending the tool itself.
- Measurements — the measured gap between local and network costs. This page has been merged into the End-to-End CI Tutorial. Please update your bookmarks.