From 9f89377860f66094a51c1b2a6709cd6d6e733061 Mon Sep 17 00:00:00 2001 From: Similoluwa Abidoye Date: Wed, 29 Jul 2026 10:35:51 +0100 Subject: [PATCH] feat: add deploy dry-run, mainnet CI target, rollback procedure, and versioning policy Implements four mainnet-readiness issues: - scripts/deploy.sh: add --dry-run mode (simulate instead of broadcast), --confirm-mainnet guard, and --wasm path option (#1135) - .github/workflows/soroban-deploy.yml: add mainnet deployment target gated behind GitHub Environment with manual approval (#1136) - ROLLBACK_PROCEDURE.md: document 5 failure scenarios with circuit-breaker, proxy redeploy, and timelock mitigations (#1137) - VERSIONING_POLICY.md: define semver policy for CLI, output schemas, rule sets, exit codes, and library API (#1138) Closes #1135 Closes #1136 Closes #1137 Closes #1138 --- .github/workflows/soroban-deploy.yml | 278 +++++++++++++++++---------- CHANGELOG.md | 1 + README.md | 2 + ROLLBACK_PROCEDURE.md | 168 ++++++++++++++++ VERSIONING_POLICY.md | 180 +++++++++++++++++ scripts/deploy.sh | 229 +++++++++++++++++++--- 6 files changed, 726 insertions(+), 132 deletions(-) create mode 100644 ROLLBACK_PROCEDURE.md create mode 100644 VERSIONING_POLICY.md diff --git a/.github/workflows/soroban-deploy.yml b/.github/workflows/soroban-deploy.yml index 8805c9df..bb08115d 100644 --- a/.github/workflows/soroban-deploy.yml +++ b/.github/workflows/soroban-deploy.yml @@ -1,26 +1,48 @@ name: Soroban Runtime Guard Deployment on: - # Pruned to manual-only: this workflow was persistently red on push/PR/schedule. - # Run on demand from the Actions tab. - workflow_dispatch: {} + workflow_dispatch: + inputs: + target: + description: 'Deployment target network' + required: true + default: 'testnet' + type: choice + options: + - testnet + - mainnet + dry_run: + description: 'Perform a dry run without broadcasting' + required: true + default: 'true' + type: choice + options: + - 'true' + - 'false' + ref: + description: 'Git ref to deploy (branch, tag, or SHA)' + required: false + default: '' + type: string + env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: - build-and-deploy: - name: Build & Deploy Runtime Guard Wrapper + # ── Shared build — produces the WASM artifact ────────────────────────────── + build: + name: Build Runtime Guard Wrapper runs-on: ubuntu-latest permissions: contents: read - deployments: write - checks: write steps: - name: Checkout repository uses: actions/checkout@v6 + with: + ref: ${{ github.event.inputs.ref || github.ref }} - name: Install stable Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -70,6 +92,43 @@ jobs: fi echo "WASM size: $(du -h "$WASM_PATH" | cut -f1)" echo "WASM_PATH=$WASM_PATH" >> $GITHUB_ENV + echo "WASM_HASH=$(sha256sum "$WASM_PATH" | awk '{print $1}')" >> $GITHUB_ENV + + - name: Upload WASM artifact + uses: actions/upload-artifact@v6 + with: + name: runtime-guard-wrapper-wasm + path: target/wasm32-unknown-unknown/release/runtime_guard_wrapper.wasm + retention-days: 7 + + # ── Testnet deploy (existing path) ───────────────────────────────────────── + testnet-deploy: + name: Deploy to Soroban Testnet + runs-on: ubuntu-latest + needs: build + if: github.event.inputs.target == 'testnet' + permissions: + contents: read + deployments: write + checks: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install Soroban CLI + run: | + sudo apt-get update + sudo apt-get install -y libdbus-1-dev libudev-dev pkg-config + export PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig:$PKG_CONFIG_PATH + echo "PKG_CONFIG_PATH=$PKG_CONFIG_PATH" >> $GITHUB_ENV + cargo install --locked soroban-cli || true + + - name: Download WASM artifact + uses: actions/download-artifact@v6 + with: + name: runtime-guard-wrapper-wasm + path: target/wasm32-unknown-unknown/release/ - name: Show Soroban network info run: | @@ -83,7 +142,7 @@ jobs: echo "present=true" >> "$GITHUB_OUTPUT" else echo "present=false" >> "$GITHUB_OUTPUT" - echo "::warning::SOROBAN_SECRET_KEY secret is not configured — deployment steps will be skipped. Add the secret to the repository to enable live deployments." + echo "::warning::SOROBAN_SECRET_KEY secret is not configured — deployment steps will be skipped." fi env: SOROBAN_SECRET_KEY: ${{ secrets.SOROBAN_SECRET_KEY }} @@ -91,73 +150,69 @@ jobs: - name: Deploy to Soroban testnet (Dry Run) if: github.event.inputs.dry_run == 'true' && steps.check-key.outputs.present == 'true' run: | - bash scripts/deploy-soroban-testnet.sh \ + bash scripts/deploy.sh \ --network testnet \ --dry-run \ - --debug + --wasm target/wasm32-unknown-unknown/release/runtime_guard_wrapper.wasm env: SOROBAN_SECRET_KEY: ${{ secrets.SOROBAN_SECRET_KEY }} - SOROBAN_ACCOUNT_ID: ${{ secrets.SOROBAN_ACCOUNT_ID }} - name: Deploy to Soroban testnet if: github.event.inputs.dry_run != 'true' && steps.check-key.outputs.present == 'true' run: | - bash scripts/deploy-soroban-testnet.sh \ + bash scripts/deploy.sh \ --network testnet \ - --interval 300 \ - --no-continuous \ - --debug + --wasm target/wasm32-unknown-unknown/release/runtime_guard_wrapper.wasm env: SOROBAN_SECRET_KEY: ${{ secrets.SOROBAN_SECRET_KEY }} - SOROBAN_ACCOUNT_ID: ${{ secrets.SOROBAN_ACCOUNT_ID }} timeout-minutes: 30 + - name: Continuous validation + if: github.event.inputs.dry_run != 'true' && steps.check-key.outputs.present == 'true' + run: | + echo "Running continuous validation checks..." + if [ -f ".deployment-manifest.json" ]; then + CONTRACTS=$(jq -r '.deployments[].contract_id' .deployment-manifest.json) + for CID in $CONTRACTS; do + echo "Validating: $CID" + if soroban contract invoke --id "$CID" --network testnet -- health_check; then + echo "✓ Health check passed" + else + echo "✗ Health check failed" + exit 1 + fi + done + fi + env: + SOROBAN_SECRET_KEY: ${{ secrets.SOROBAN_SECRET_KEY }} + SOROBAN_ACCOUNT_ID: ${{ secrets.SOROBAN_ACCOUNT_ID }} + - name: Upload deployment manifest if: always() uses: actions/upload-artifact@v6 with: - name: deployment-manifest-${{ github.run_id }} + name: deployment-manifest-testnet-${{ github.run_id }} path: .deployment-manifest.json retention-days: 30 - - name: Upload deployment log - if: always() - uses: actions/upload-artifact@v6 - with: - name: deployment-log-${{ github.run_id }} - path: .deployment.log - retention-days: 30 - - - name: Parse deployment results - if: always() - run: | - if [ -f ".deployment-manifest.json" ]; then - echo "## Deployment Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - jq '.deployments[] | "- **\(.name)**: `\(.contract_id)` (\(.status))"' \ - .deployment-manifest.json >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Last updated: $(jq -r '.last_updated' .deployment-manifest.json)" >> $GITHUB_STEP_SUMMARY - fi - - continuous-validation: - name: Continuous Validation + # ── Mainnet deploy (gated by GitHub Environment + manual approval) ─────── + mainnet-deploy: + name: Deploy to Soroban Mainnet runs-on: ubuntu-latest - needs: build-and-deploy - if: success() && github.ref == 'refs/heads/main' + needs: build + if: github.event.inputs.target == 'mainnet' + environment: + name: mainnet + url: https://stellar.expert/explorer permissions: contents: read deployments: write + checks: write steps: - name: Checkout repository uses: actions/checkout@v6 - - name: Download deployment manifest - uses: actions/download-artifact@v6 - with: - name: deployment-manifest-${{ github.run_id }} - - name: Install Soroban CLI run: | sudo apt-get update @@ -166,95 +221,106 @@ jobs: echo "PKG_CONFIG_PATH=$PKG_CONFIG_PATH" >> $GITHUB_ENV cargo install --locked soroban-cli || true - - name: Run continuous validation checks + - name: Download WASM artifact + uses: actions/download-artifact@v6 + with: + name: runtime-guard-wrapper-wasm + path: target/wasm32-unknown-unknown/release/ + + - name: Show Soroban network info run: | - echo "Running continuous validation checks..." - + soroban network ls + soroban network info --network mainnet + + - name: Check mainnet deployment secrets + id: check-key + run: | + if [[ -n "${SOROBAN_MAINNET_SECRET_KEY:-}" ]]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::error::SOROBAN_MAINNET_SECRET_KEY is not configured — aborting." + exit 1 + fi + env: + SOROBAN_MAINNET_SECRET_KEY: ${{ secrets.SOROBAN_MAINNET_SECRET_KEY }} + + - name: Dry-run first (preflight simulation) + run: | + bash scripts/deploy.sh \ + --network mainnet \ + --confirm-mainnet \ + --dry-run \ + --wasm target/wasm32-unknown-unknown/release/runtime_guard_wrapper.wasm + env: + SOROBAN_SECRET_KEY: ${{ secrets.SOROBAN_MAINNET_SECRET_KEY }} + + - name: Deploy to Soroban mainnet + run: | + bash scripts/deploy.sh \ + --network mainnet \ + --confirm-mainnet \ + --wasm target/wasm32-unknown-unknown/release/runtime_guard_wrapper.wasm + env: + SOROBAN_SECRET_KEY: ${{ secrets.SOROBAN_MAINNET_SECRET_KEY }} + timeout-minutes: 30 + + - name: Continuous validation + run: | + echo "Running mainnet validation checks..." if [ -f ".deployment-manifest.json" ]; then CONTRACTS=$(jq -r '.deployments[].contract_id' .deployment-manifest.json) - - for CONTRACT_ID in $CONTRACTS; do - echo "" - echo "Validating contract: $CONTRACT_ID" - - # Health check - if soroban contract invoke \ - --id "$CONTRACT_ID" \ - --network testnet \ - -- health_check; then - echo "✓ Health check passed for $CONTRACT_ID" + for CID in $CONTRACTS; do + echo "Validating: $CID" + if soroban contract invoke --id "$CID" --network mainnet -- health_check; then + echo "✓ Health check passed" else - echo "✗ Health check failed for $CONTRACT_ID" + echo "✗ Health check failed" exit 1 fi - - # Get stats - if soroban contract invoke \ - --id "$CONTRACT_ID" \ - --network testnet \ - -- get_stats 2>/dev/null; then - echo "✓ Stats retrieved for $CONTRACT_ID" - fi done fi env: - SOROBAN_SECRET_KEY: ${{ secrets.SOROBAN_SECRET_KEY }} - SOROBAN_ACCOUNT_ID: ${{ secrets.SOROBAN_ACCOUNT_ID }} + SOROBAN_SECRET_KEY: ${{ secrets.SOROBAN_MAINNET_SECRET_KEY }} - - name: Generate validation report + - name: Upload deployment manifest if: always() - run: | - echo "## Continuous Validation Report" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "- Timestamp: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_STEP_SUMMARY - echo "- Network: testnet" >> $GITHUB_STEP_SUMMARY - echo "- Status: Success" >> $GITHUB_STEP_SUMMARY + uses: actions/upload-artifact@v6 + with: + name: deployment-manifest-mainnet-${{ github.run_id }} + path: .deployment-manifest.json + retention-days: 90 - notification: - name: Send Deployment Notification + # ── Summary ────────────────────────────────────────────────────────────── + summary: + name: Deployment Summary runs-on: ubuntu-latest - needs: [build-and-deploy, continuous-validation] + needs: [build, testnet-deploy, mainnet-deploy] if: always() permissions: checks: write steps: - - name: Determine status + - name: Determine overall status id: status run: | - if [ "${{ needs.build-and-deploy.result }}" = "success" ]; then + if [[ '${{ needs.testnet-deploy.result }}' == 'success' || '${{ needs.mainnet-deploy.result }}' == 'success' ]]; then echo "DEPLOYMENT_STATUS=✅ Success" >> $GITHUB_OUTPUT echo "DEPLOYMENT_COLOR=0x28a745" >> $GITHUB_OUTPUT - else + elif [[ '${{ needs.testnet-deploy.result }}' == 'failure' || '${{ needs.mainnet-deploy.result }}' == 'failure' ]]; then echo "DEPLOYMENT_STATUS=❌ Failed" >> $GITHUB_OUTPUT echo "DEPLOYMENT_COLOR=0xdc3545" >> $GITHUB_OUTPUT + else + echo "DEPLOYMENT_STATUS=⏭️ Skipped" >> $GITHUB_OUTPUT + echo "DEPLOYMENT_COLOR=0x6c757d" >> $GITHUB_OUTPUT fi - - name: Create deployment status check - uses: actions/github-script@v8 - with: - script: | - github.rest.checks.create({ - owner: context.repo.owner, - repo: context.repo.repo, - name: 'Soroban Runtime Guard Deployment', - head_sha: context.sha, - status: '${{ needs.build-and-deploy.result }}' === 'success' ? 'completed' : 'completed', - conclusion: '${{ needs.build-and-deploy.result }}' === 'success' ? 'success' : 'failure', - output: { - title: 'Deployment ${{ steps.status.outputs.DEPLOYMENT_STATUS }}', - summary: 'Runtime guard wrapper contract has been deployed to Soroban testnet', - text: 'Check the deployment artifacts for detailed logs and manifests.' - } - }); - - name: Post deployment summary run: | - echo "## 🚀 Soroban Runtime Guard Deployment Complete" >> $GITHUB_STEP_SUMMARY + echo "## 🚀 Soroban Runtime Guard Deployment" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Status**: ${{ steps.status.outputs.DEPLOYMENT_STATUS }}" >> $GITHUB_STEP_SUMMARY - echo "**Network**: testnet" >> $GITHUB_STEP_SUMMARY + echo "**Target**: ${{ github.event.inputs.target || 'testnet' }}" >> $GITHUB_STEP_SUMMARY + echo "**Dry Run**: ${{ github.event.inputs.dry_run || 'true' }}" >> $GITHUB_STEP_SUMMARY echo "**Timestamp**: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_STEP_SUMMARY echo "**Commit**: ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "[View Artifacts](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" >> $GITHUB_STEP_SUMMARY diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a286658..bd888ca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +See [VERSIONING_POLICY.md](./VERSIONING_POLICY.md) for the detailed policy on what constitutes a breaking change for CLI flags, output schemas, and rule sets. ## Format Guidelines diff --git a/README.md b/README.md index dbf370b7..7a067d99 100644 --- a/README.md +++ b/README.md @@ -353,6 +353,8 @@ in a single terminal session. | Write your own rule | [docs/rule-authoring-guide.md](docs/rule-authoring-guide.md) | | See it benchmarked | [docs/case-studies/soroban-examples.md](docs/case-studies/soroban-examples.md) | | Review the threat model | [docs/security-threat-model.md](docs/security-threat-model.md) | +| Rollback procedures for mainnet | [ROLLBACK_PROCEDURE.md](./ROLLBACK_PROCEDURE.md) | +| Understand versioning policy | [VERSIONING_POLICY.md](./VERSIONING_POLICY.md) | | Browse design decisions | [docs/adr/](docs/adr/) | --- diff --git a/ROLLBACK_PROCEDURE.md b/ROLLBACK_PROCEDURE.md new file mode 100644 index 00000000..3b85b094 --- /dev/null +++ b/ROLLBACK_PROCEDURE.md @@ -0,0 +1,168 @@ +# Rollback Procedure — Mainnet Contract Deployment + +**Covers realistic failure modes during or after a mainnet Soroban contract deployment and the concrete mitigation paths available, separate from the general deployment runbook.** + +--- + +## 1. Incident Declaration & Authority + +| Role | Authority | Contact | +|------|-----------|---------| +| **On-call engineer** | Declare incident, invoke circuit breaker (`pause`) | PagerDuty / Slack | +| **Lead maintainer** | Authorise proxy redeploy or timelocked upgrade | GitHub issue `/cc` | +| **Project admin (multisig)** | Emergency key rotation, on-chain admin actions | Defined in key-ceremony doc | + +**Escalation path:** On-call → Lead maintainer → Project admin multisig. + +A declared incident is tracked via a GitHub issue tagged `incident` and `P0`, with a post-mortem required within 5 business days. + +--- + +## 2. Failure Scenarios & Mitigations + +### Scenario A — Bad WASM Upload + +**What happens:** Deploy transaction succeeds but the uploaded WASM is the wrong bytecode (wrong hash, stale build, or a non-production artifact). + +**Detection:** +- WASM hash printed in deploy log differs from the CI-signed hash +- `soroban contract invoke --id -- version` returns unexpected value +- On-chain `IMPL_HASH` does not match the reference hash in the release manifest + +**Mitigation path:** + +1. **Pause via circuit breaker** (#1126) + - Invoke `runtime-guard-wrapper.pause()` with the multisig admin key + - This halts all guarded executions; state is frozen but readable + - **Expected duration:** ~2 minutes + +2. **Redeploy corrected WASM behind the same proxy** (contracts/proxy) + - Build the correct WASM, obtain its hash from CI + - Invoke `UupsProxy.upgrade(new_wasm_hash)` with the admin key + - Verify new `IMPL_HASH` matches the expected release hash + +3. **Verify & unpause** + - Run `health_check` on the redeployed contract + - Invoke `runtime-guard-wrapper.unpause()` + - Confirm normal operation via `get_stats` + +**If proxy is not available:** Deploy a fresh contract with corrected WASM, update all downstream consumers to point at the new contract ID. + +### Scenario B — Failed / Partial Initialisation + +**What happens:** The `initialize` call (or constructor-equivalent) fails mid-transaction, or succeeds but leaves storage in an inconsistent state. Soroban contract init is atomic, but if the init logic writes to multiple storage keys and one write fails silently (e.g. storage limit), the contract may be in a partially-configured state. + +**Detection:** +- `health_check` returns `false` immediately after deploy +- `get_stats` returns zero invariants checked despite calls +- `get_wrapped_contract` returns an unexpected address or fails + +**Mitigation path:** + +1. **Freeze interactions** — the runtime guard (#1126) should already reject calls if `INITIALISED` is not set. Verify: + - Invoke `runtime-guard-wrapper.pause()` if not already paused + - All external calls return "Contract not initialised" error + +2. **Diagnose** — inspect persistent storage keys via `soroban contract read`: + - Check `WRAPPED_CONTRACT_ADDRESS`, `guard_config`, `CALL_LOG` + - Compare against expected initial state from the deployment manifest + +3. **Redeploy corrected contract** — if the proxy pattern is in use: + - Deploy a corrected implementation WASM (with idempotent or fixed `initialize`) + - Invoke `UupsProxy.upgrade(new_wasm_hash)` + - Invoke the corrected `initialize` path + +4. **If proxy is not in use** — the broken contract is irrecoverable. Deploy an entirely new contract, update all registries/downstream consumers, and deprecate the old contract ID via an on-chain announcement. + +### Scenario C — Post-Deploy Vulnerability Discovered + +**What happens:** Hours or days after a successful deployment, a security vulnerability is reported (internally or via bug bounty) that affects the live contract. + +**Detection:** +- Bug report filed via SECURITY.md disclosure path +- Internal audit or fuzzing run discovers a finding applicable to the deployed WASM +- On-chain events show anomalous guard failures (`guard_failure` events spiking) + +**Mitigation path:** + +1. **Emergency pause** — invoke `runtime-guard-wrapper.pause()` immediately + - **Authority:** On-call engineer can invoke pause without further approval + - This freezes all guarded state transitions + +2. **Assess severity & impact** + - Determine if funds/protected state are at immediate risk + - If yes: keep paused, proceed to step 3 + - If no: consider timelocked upgrade path (#1127) for a more measured fix + +3. **Deploy fix via timelock** (#1127) — the timelocked upgrade path requires a minimum delay (e.g. 24-48 hours) between proposal and execution: + - Push a patched implementation WASM + - Submit upgrade proposal to the `Timelock` contract + - Wait for the delay period (allows watchers to review) + - Execute the upgrade + +4. **Emergency bypass** — if the vulnerability is actively exploited and timelock delay is unacceptably risky: + - **Authority required:** Lead maintainer + 1 additional admin (2-of-3 multisig) + - Invoke `UupsProxy.upgrade()` directly (bypassing timelock) + - Document the emergency bypass in an incident report within 24 hours + +5. **Unpause** after fix is verified, resume normal operations + +### Scenario D — Storage Exhaustion / Fee Spike + +**What happens:** The contract's storage grows beyond the Soroban per-contract storage limit, or the transaction fee spikes unexpectedly due to mainnet congestion, causing writes to fail mid-operation. + +**Detection:** +- `soroban contract invoke` returns a storage limit error +- Deployment log shows fee-estimation warnings +- `health_check` fails due to `HEALTHY_STORAGE_LIMIT` being exceeded + +**Mitigation path:** + +1. **Pause contract** to prevent further storage writes +2. **Analyze storage usage** via `soroban contract read` and `get_stats` +3. **Prune stale data** if the contract supports data cleanup functions +4. **Redeploy with increased limits** or storage-optimised logic via proxy upgrade +5. **Unpause** after verification + +### Scenario E — Admin Key Compromise + +**What happens:** The admin key (deployer or upgrade authority) is leaked or compromised. + +**Detection:** +- Unexpected `upgrade` or `transfer_admin` events in the event log +- Unauthorised `pause` / `unpause` transitions +- GitHub secret `SOROBAN_MAINNET_SECRET_KEY` is found in logs or external sources + +**Mitigation path:** + +1. **Rotate the admin key** — generate a new key pair via Soroban key generation +2. **Transfer admin** on all deployed contracts to the new key: + - `UupsProxy.transfer_admin(new_admin_address)` + - `new_admin_address` invokes `UupsProxy.accept_admin()` +3. **Revoke old key** — remove the compromised secret from GitHub Secrets +4. **Audit** event history for any unauthorised transactions made with the compromised key +5. **Incident report** with timeline and remediation steps + +--- + +## 3. Post-Rollback Verification Checklist + +After any rollback mitigation is applied: + +- [ ] `health_check` returns `true` for all affected contracts +- [ ] `get_stats` shows expected invariants-checked count +- [ ] Guard events emit normally (test with a known-good transaction) +- [ ] Deployment manifest is updated with new contract IDs / WASM hashes +- [ ] GitHub Secrets are rotated if keys were exposed +- [ ] Incident post-mortem is drafted with root cause and preventive measures + +--- + +## 4. Related Documents + +- [Mainnet Deployment Runbook](./MAINNET_DEPLOYMENT_RUNBOOK.md) (#1134) — happy-path deployment +- `contracts/proxy/` — UUPS proxy pattern for WASM upgrades +- `contracts/runtime-guard-wrapper/` — on-chain circuit breaker (#1126) +- `contracts/timelock/` — timelocked upgrade governance (#1127) +- [RELEASE_CHECKLIST.md](./RELEASE_CHECKLIST.md) — pre-release verification +- [BRANCH_PROTECTION.md](./BRANCH_PROTECTION.md) — branch and key protection policy diff --git a/VERSIONING_POLICY.md b/VERSIONING_POLICY.md new file mode 100644 index 00000000..63a6910a --- /dev/null +++ b/VERSIONING_POLICY.md @@ -0,0 +1,180 @@ +# Versioning Policy + +**Applies to:** `sanctifier-cli`, `sanctifier-core`, and all published artifacts once they reach mainnet-stable `v1.0.0`. + +This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). Once `v1.0.0` is released, the public API is defined as: + +1. **CLI flags and exit codes** — every flag, subcommand, and exit code documented by `sanctifier --help` +2. **Output schemas** — `schemas/analysis-output.json` and `schemas/sarif-2.1.0.json` +3. **Rule identifiers and severities** — the `S001..S012` (and `Z001..Z014`) rule codes and their default severities +4. **Library API** — public items exported by `sanctifier-core` (types, traits, functions) that are `#[doc(hidden)]`-free + +Anything not listed above (internal modules, private types, test helpers, example code) is **not** part of the public API and may change at any minor/patch version. + +--- + +## 1. Major version (`X.0.0`) + +A major release signals **breaking changes**. Consumers must expect to update their integration. + +### What requires a major bump + +| Area | Breaking change | +|------|----------------| +| **CLI flags** | Removing or renaming an existing flag or subcommand | +| **CLI flags** | Changing the default value of a flag in a way that alters behaviour | +| **CLI flags** | Changing the type or format of a flag's argument | +| **Exit codes** | Removing, renaming, or changing the meaning of an exit code | +| **Output schema** | Removing a required field from `schemas/analysis-output.json` | +| **Output schema** | Changing the type of an existing field | +| **Output schema** | Making an optional field required | +| **SARIF output** | Removing or renaming a property that Sanctifier populates in `schemas/sarif-2.1.0.json` | +| **Rules** | Removing a rule (`S001`, `S002`, etc.) entirely | +| **Rules** | Changing a rule's default severity from the existing classification (e.g. `error` → `warning`) | +| **Rules** | Renaming a rule identifier code | +| **Library API** | Removing or renaming any public function, type, trait, or constant | +| **Library API** | Changing function signatures (adding required params, changing param types, restricting trait bounds) | +| **Library API** | Changing the behaviour of a public function in a way that breaks existing callers | + +### Examples + +- Removing `--format json` and making JSON the implicit default → **major** (default behaviour changes) +- Renaming `S001` to `S100` → **major** (suppression files and dashboards break) +- Deleting the `findings` array from `analysis-output.json` → **major** (all consumers break) + +--- + +## 2. Minor version (`X.Y.0`) + +A minor release adds functionality **without breaking** any existing public API. + +### What requires a minor bump + +| Area | Non-breaking addition | +|------|----------------------| +| **CLI flags** | Adding a new flag or subcommand | +| **CLI flags** | Adding a new optional parameter to an existing subcommand | +| **Output schema** | Adding a new optional field to `analysis-output.json` | +| **Output schema** | Adding a new optional property to `sarif-2.1.0.json` output | +| **Rules** | Adding a new rule (`S013`, `S014`, etc.) | +| **Rules** | Adding a new severity level to the taxonomy | +| **Rules** | Adding new optional configuration for an existing rule | +| **Library API** | Adding new public functions, types, traits, or constants | +| **Library API** | Adding new default trait implementations | +| **Library API** | Widening accepted input types (e.g. `&[u8]` to `impl AsRef<[u8]>`) | + +### Examples + +- Adding `--timeout 30` flag → **minor** +- Adding `S013` rule for a new vulnerability class → **minor** +- Adding an `optional_warnings` array to `analysis-output.json` → **minor** + +--- + +## 3. Patch version (`X.Y.Z`) + +A patch release ships **bug fixes and performance improvements** that do not change the public API. + +### What requires a patch bump + +| Area | Bug fix / internal change | +|------|--------------------------| +| **CLI** | Fixing a crash, hang, or incorrect exit code (without changing the documented meaning of the code) | +| **Output** | Fixing a bug in finding detection that changes results without changing the schema | +| **Output** | Correcting a SARIF property value format that violates the spec | +| **Rules** | Narrowing false positives or expanding true positive coverage within the existing rule scope | +| **Rules** | Performance improvements to rule detection | +| **Library API** | Internal refactors, documentation improvements, dependency updates (no API surface change) | +| **Build** | CI/CD fixes, toolchain version bumps, packaging fixes | + +### Examples + +- Fixing a panic when analysing empty files → **patch** +- Reducing false-positive rate for `S003` unchecked arithmetic → **patch** +- Updating `serde_json` dependency → **patch** + +--- + +## 4. Schema versioning (`schema_version`) + +The `schema_version` field in `analysis-output.json` follows its **own** semver, independent of the tool version. + +| Schema change | `schema_version` bump | +|---------------|-----------------------| +| Adding an optional field in a backward-compatible way | Minor | +| Removing / renaming a field, or changing a type | Major | +| Fixing a field description or example (no shape change) | Patch | + +The `sarif-2.1.0.json` schema is the upstream SARIF 2.1.0 standard. Sanctifier populates a subset of its properties. Adding a **new** SARIF property that Sanctifier now populates is a minor change; stopping population of a previously-populated property is a major change. + +--- + +## 5. Pre-1.0.0 exceptions + +Before `v1.0.0` (current state), the project follows **zero-version** SemVer conventions: + +- Minor version bumps (`0.1.0` → `0.2.0`) may include breaking changes without notice +- Patch version bumps (`0.1.0` → `0.1.1`) are bug fixes only +- Rule codes (`S001`–`S012`) are considered stable even before `v1.0.0` and will not be removed or renamed without a deprecation notice + +--- + +## 6. Deprecation process + +Before a breaking change is shipped in a major version: + +1. The deprecated API is marked with a `#[deprecated]` attribute (library) or a `--deprecated` notice in `--help` output (CLI) +2. The deprecation notice specifies the planned removal version (e.g. "removed in v3.0.0") +3. At least **one minor version** passes between the deprecation notice and the removal +4. The deprecation is documented in `CHANGELOG.md` under a `### Deprecated` section + +--- + +## 7. Exit code stability + +Exit codes are part of the public API and follow the same major/minor rules: + +| Exit code | Meaning | Stability | +|-----------|---------|-----------| +| `0` | Success, no findings | Stable | +| `1` | Analysis completed with findings | Stable | +| `2` | Analysis error (timeout, parse error, I/O error) | Stable | +| `3` | Configuration error (invalid flags, missing file) | Stable | +| `4` | Internal error (bug, invariant violation) | Stable | +| `5`–`127` | Reserved for future use | Adding new codes = minor | + +Exit codes `128+` are reserved for OS signal handling and must not be used by Sanctifier. + +--- + +## 8. Version alignment + +All published artifacts that share the `sanctifier` name **must** carry the same version number at any given release: + +- `sanctifier-cli` (crates.io / npm / Homebrew) +- `sanctifier-core` (crates.io) +- `sanctifier-detector` (crates.io) +- `sanctifier-wasm` (crates.io) +- `vscode-extension` (VS Code Marketplace) + +The version is bumped atomically by `scripts/release.sh` across all manifests. No artifact is published individually without the others being updated. + +--- + +## 9. Enforcement & automation + +- CI checks that the `schema_version` in `schemas/analysis-output.json` is bumped appropriately for the diff +- `scripts/validate_release_artifacts.js` verifies version consistency across all manifests +- The release-gate Action (#1189) blocks tag creation if any version string is inconsistent +- `CHANGELOG.md` must have an entry for the new version before a release tag is created + +--- + +## References + +- [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html) +- `schemas/analysis-output.json` — Sanctifier analysis output schema +- `schemas/sarif-2.1.0.json` — SARIF output schema +- `CHANGELOG.md` — release history +- `scripts/release.sh` — version bump script +- `scripts/validate_release_artifacts.js` — pre-release validation diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 1f0398f6..585fb33f 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,35 +1,212 @@ #!/bin/bash +set -euo pipefail -# Load environment variables from .env.local -if [ -f .env.local ]; then - export $(cat .env.local | xargs) -fi +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" -if [ -z "$SOROBAN_SECRET_KEY" ]; then - echo "Error: SOROBAN_SECRET_KEY not found in .env.local" - echo "Please create .env.local with SOROBAN_SECRET_KEY=S..." - exit 1 -fi +# ── Colours ────────────────────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' + +log_info() { echo -e "${BLUE}[INFO]${NC} $*"; } +log_success() { echo -e "${GREEN}[OK]${NC} $*"; } +log_warning() { echo -e "${YELLOW}[WARN]${NC} $*"; } +log_error() { echo -e "${RED}[ERR]${NC} $*" >&2; } + +# ── Defaults ───────────────────────────────────────────────────────────────────── +NETWORK="testnet" +CONFIRM_MAINNET=false +DRY_RUN=false +WASM_PATH="" + +# ── Help ───────────────────────────────────────────────────────────────────────── +print_help() { + cat << 'HELP' +Usage: deploy.sh [OPTIONS] + +Options: + --network Target network (testnet, futurenet, mainnet) + Default: testnet + --confirm-mainnet Acknowledge mainnet target (required for mainnet) + --dry-run Simulate deployment without broadcasting + --wasm Path to WASM file (auto-detected if omitted) + --help Show this help + +Environment: + SOROBAN_SECRET_KEY Secret key for signing (required) +HELP +} + +# ── Parse args ─────────────────────────────────────────────────────────────────── +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --network) NETWORK="$2"; shift 2 ;; + --confirm-mainnet) CONFIRM_MAINNET=true; shift ;; + --dry-run) DRY_RUN=true; shift ;; + --wasm) WASM_PATH="$2"; shift 2 ;; + --help) print_help; exit 0 ;; + *) log_error "Unknown: $1"; print_help; exit 1 ;; + esac + done +} + +# ── Validate environment ───────────────────────────────────────────────────────── +validate_env() { + if [[ -f "${PROJECT_ROOT}/.env.local" ]]; then + set -a; source "${PROJECT_ROOT}/.env.local"; set +a + fi + + if [[ -z "${SOROBAN_SECRET_KEY:-}" ]]; then + log_error "SOROBAN_SECRET_KEY not set (use .env.local or export)" + exit 1 + fi + + for cmd in soroban cargo jq; do + if ! command -v "$cmd" &>/dev/null; then + log_error "Required tool missing: $cmd" + exit 1 + fi + done +} + +# ── Network guard (#1124, #1133) ───────────────────────────────────────────────── +check_network() { + log_info "Target network: $NETWORK" + + if [[ "$NETWORK" == "mainnet" ]]; then + if [[ "$CONFIRM_MAINNET" != "true" ]]; then + log_error "Mainnet requires --confirm-mainnet flag" + exit 1 + fi + log_warning "Mainnet deployment confirmed. Proceeding with caution." + fi -echo "Building contracts..." -cargo build --target wasm32-unknown-unknown --release + if ! soroban network info --network "$NETWORK" &>/dev/null; then + log_error "Network not reachable: $NETWORK" + exit 1 + fi + log_success "Network reachable: $NETWORK" +} -echo "Deploying vulnerable-contract..." -# Adjust the path to the WASM file as needed based on your package name in Cargo.toml -WASM_PATH="target/wasm32-unknown-unknown/release/vulnerable_contract.wasm" +# ── Resolve WASM path ──────────────────────────────────────────────────────────── +resolve_wasm() { + if [[ -n "$WASM_PATH" ]]; then + if [[ ! -f "$WASM_PATH" ]]; then + log_error "WASM not found: $WASM_PATH" + exit 1 + fi + echo "$WASM_PATH" + return + fi -if [ ! -f "$WASM_PATH" ]; then - echo "Error: WASM file not found at $WASM_PATH" - echo "Make sure the contract package name matches the expected WASM filename." + local default="target/wasm32-unknown-unknown/release/vulnerable_contract.wasm" + if [[ -f "$default" ]]; then + echo "$default" + return + fi + + local found + found=$(find target/wasm32-unknown-unknown/release -name "*.wasm" 2>/dev/null | head -1) + if [[ -n "$found" ]]; then + echo "$found" + return + fi + + log_error "No WASM file found. Build first or pass --wasm." exit 1 -fi +} + +# ── Preflight / simulation ─────────────────────────────────────────────────────── +dry_run_deploy() { + local wasm="$1" + local wasm_hash + wasm_hash=$(sha256sum "$wasm" | awk '{print $1}') + + echo "" + echo "╔═══════════════════════════════════════════════════════════════╗" + echo "║ DRY RUN — Transaction Summary ║" + echo "╚═══════════════════════════════════════════════════════════════╝" + echo "" + echo " Network: $NETWORK" + echo " WASM file: $wasm" + echo " WASM size: $(du -h "$wasm" | cut -f1)" + echo " WASM SHA-256: $wasm_hash" + echo " Signer: ${SOROBAN_SECRET_KEY:0:6}...${SOROBAN_SECRET_KEY: -4}" + echo "" + + # Preflight via simulate — this validates the deploy would succeed + log_info "Running simulation (soroban contract simulate)..." + local sim_output + if sim_output=$(soroban contract simulate \ + --wasm "$wasm" \ + --source "$SOROBAN_SECRET_KEY" \ + --network "$NETWORK" 2>&1); then + log_success "Simulation succeeded" + echo "" + echo " ── Simulated transaction ──" + echo "$sim_output" | head -20 + echo "" + echo " ✅ Dry-run PASSED — no broadcast made." + echo "" + return 0 + else + log_error "Simulation FAILED:" + echo "$sim_output" + echo "" + echo " ❌ Dry-run FAILED — the deploy would not succeed." + echo "" + return 1 + fi +} + +live_deploy() { + local wasm="$1" + log_info "Deploying contract to $NETWORK ..." + + local output + output=$(soroban contract deploy \ + --wasm "$wasm" \ + --source "$SOROBAN_SECRET_KEY" \ + --network "$NETWORK" 2>&1) + + if echo "$output" | grep -qE "^[A-Z0-9]{56}$"; then + local cid + cid=$(echo "$output" | grep -oE "^[A-Z0-9]{56}$" | head -1) + log_success "Contract deployed: $cid" + else + log_error "Deploy failed or unexpected output:" + echo "$output" + exit 1 + fi +} + +# ── Main ───────────────────────────────────────────────────────────────────────── +main() { + parse_args "$@" + validate_env + check_network + + echo "" + echo "==============================================" + echo " Sanctifier — Contract Deployment" + echo " Network: $NETWORK" + echo " Mode: $( $DRY_RUN && echo 'DRY RUN (no broadcast)' || echo 'LIVE' )" + echo "==============================================" + echo "" + + local wasm + wasm=$(resolve_wasm) -# Deploy to Testnet (default) -echo "Deploying to Testnet using provided key..." -CONTRACT_ID=$(soroban contract deploy \ - --wasm "$WASM_PATH" \ - --source "$SOROBAN_SECRET_KEY" \ - --network testnet) + if [[ "$DRY_RUN" == "true" ]]; then + dry_run_deploy "$wasm" + else + live_deploy "$wasm" + fi +} -echo "Contract deployed successfully!" -echo "Contract ID: $CONTRACT_ID" +main "$@"