all issues fixed #274
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Smart Contract - Build & CI | |
| on: | |
| push: | |
| branches: [ main ] | |
| tags: [ 'v*' ] | |
| pull_request: | |
| branches: [ main ] | |
| jobs: | |
| ci: | |
| name: Contract CI | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| - name: Install Rust toolchain | |
| uses: actions-rust-lang/setup-rust-toolchain@v1 | |
| with: | |
| targets: wasm32-unknown-unknown | |
| - name: Install cargo-audit and cargo-deny | |
| run: | | |
| cargo install cargo-audit --locked | |
| cargo install cargo-deny --locked | |
| - name: Run cargo audit | |
| run: | | |
| cargo audit | |
| - name: Run cargo deny | |
| run: | | |
| cargo deny check | |
| - name: Install stellar-cli | |
| uses: stellar/stellar-cli@v25.0.0 | |
| - name: Build smart contracts | |
| run: | | |
| stellar contract build --release | |
| - name: Run recurring-payment tests | |
| run: cargo test -- recurring -- --nocapture | |
| - name: Generate wasm-manifest.json | |
| run: | | |
| python3 <<'PY' | |
| import glob | |
| import hashlib | |
| import json | |
| import os | |
| import re | |
| from datetime import datetime, timezone | |
| wasm_files = sorted(glob.glob("target/wasm32*/release/*.wasm")) | |
| if not wasm_files: | |
| raise SystemExit("No WASM files found after contract build") | |
| # Prefer the Accord contract artifact when multiple WASMs exist. | |
| wasm_path = next( | |
| (p for p in wasm_files if os.path.basename(p) == "accord.wasm"), | |
| wasm_files[0], | |
| ) | |
| with open(wasm_path, "rb") as f: | |
| wasm_hash = hashlib.sha256(f.read()).hexdigest() | |
| cargo_toml = open("Cargo.toml", encoding="utf-8").read() | |
| match = re.search( | |
| r'^soroban-sdk\s*=\s*"=?([^"\n]+)"', | |
| cargo_toml, | |
| flags=re.MULTILINE, | |
| ) | |
| if not match: | |
| raise SystemExit("Could not read soroban-sdk version from Cargo.toml") | |
| soroban_sdk_version = match.group(1) | |
| # Probed: True if contract source includes recurring-payment types/entrypoints | |
| try: | |
| lib_rs = open("contracts/accord/src/lib.rs", encoding="utf-8").read() | |
| supports_recurring_payments = "RecurringPayment" in lib_rs and "disburse_recurring" in lib_rs | |
| except FileNotFoundError: | |
| supports_recurring_payments = False | |
| manifest = { | |
| "wasm_file": os.path.basename(wasm_path), | |
| "wasm_hash": wasm_hash, | |
| "commit_sha": os.environ.get("GITHUB_SHA", ""), | |
| "build_timestamp": datetime.now(timezone.utc) | |
| .replace(microsecond=0) | |
| .isoformat() | |
| .replace("+00:00", "Z"), | |
| "soroban_sdk_version": soroban_sdk_version, | |
| "supports_recurring_payments": supports_recurring_payments, | |
| } | |
| with open("wasm-manifest.json", "w", encoding="utf-8") as out: | |
| json.dump(manifest, out, indent=2) | |
| out.write("\n") | |
| print(json.dumps(manifest, indent=2)) | |
| PY | |
| - name: Upload compiled WASM artifact | |
| if: github.ref == 'refs/heads/main' && github.event_name == 'push' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: accord-contract-wasm | |
| path: | | |
| target/wasm32-unknown-unknown/release/*.wasm | |
| wasm-manifest.json | |
| retention-days: 90 | |
| - name: Report WASM file sizes | |
| run: | | |
| python3 -c ' | |
| import os, glob | |
| files = glob.glob("target/wasm32*/release/*.wasm") | |
| if not files: | |
| print("No WASM files found in release target directories.") | |
| for f in files: | |
| if os.path.isfile(f): | |
| size_bytes = os.path.getsize(f) | |
| size_kb = size_bytes / 1024.0 | |
| print(f"Contract: {os.path.basename(f)} | Size: {size_kb:.2f} KB ({size_bytes} bytes)") | |
| ' | |
| # Runs only on push to main — never on pull requests. | |
| # Requires TESTNET_CONTRACT_ID to be set as a repository variable under | |
| # Settings → Secrets and variables → Actions → Variables. | |
| # When the variable is absent the job logs a message and exits cleanly so | |
| # forks and contributor PRs are unaffected. | |
| testnet-smoke-test: | |
| name: Testnet Smoke Test | |
| runs-on: ubuntu-latest | |
| needs: ci | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' | |
| steps: | |
| - name: Check TESTNET_CONTRACT_ID is configured | |
| id: guard | |
| run: | | |
| if [ -z "${{ vars.TESTNET_CONTRACT_ID }}" ]; then | |
| echo "TESTNET_CONTRACT_ID repository variable is not set — skipping smoke test." | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "skip=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Install Stellar CLI | |
| if: steps.guard.outputs.skip != 'true' | |
| uses: stellar/stellar-cli@v25.0.0 | |
| - name: Simulate get_total_proposals on deployed testnet contract | |
| if: steps.guard.outputs.skip != 'true' | |
| id: invoke | |
| run: | | |
| result=$(stellar contract invoke \ | |
| --network testnet \ | |
| --id "${{ vars.TESTNET_CONTRACT_ID }}" \ | |
| -- get_total_proposals) | |
| echo "result=$result" >> "$GITHUB_OUTPUT" | |
| - name: Report live proposal count | |
| if: steps.guard.outputs.skip != 'true' | |
| run: | | |
| echo "Deployed contract get_total_proposals returned: ${{ steps.invoke.outputs.result }}" | |
| # Pre-mainnet deployment gate (issue #420). | |
| # Runs on version tags (v*) that gate mainnet deployment. Queries the live | |
| # testnet contract and fails if sum(owner weights) != get_total_weight. | |
| # Skips cleanly when TESTNET_CONTRACT_ID is unset (same pattern as smoke test). | |
| weight-invariant-gate: | |
| name: Weight Invariant Gate | |
| runs-on: ubuntu-latest | |
| needs: ci | |
| if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') | |
| steps: | |
| - name: Check TESTNET_CONTRACT_ID is configured | |
| id: guard | |
| run: | | |
| if [ -z "${{ vars.TESTNET_CONTRACT_ID }}" ]; then | |
| echo "TESTNET_CONTRACT_ID repository variable is not set — skipping weight invariant gate." | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "skip=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Install Stellar CLI | |
| if: steps.guard.outputs.skip != 'true' | |
| uses: stellar/stellar-cli@v25.0.0 | |
| - name: Verify total-weight invariant on testnet | |
| if: steps.guard.outputs.skip != 'true' | |
| env: | |
| CONTRACT_ID: ${{ vars.TESTNET_CONTRACT_ID }} | |
| run: | | |
| set -euo pipefail | |
| weights_raw=$(stellar contract invoke \ | |
| --network testnet \ | |
| --id "$CONTRACT_ID" \ | |
| -- get_owner_weights) | |
| total_raw=$(stellar contract invoke \ | |
| --network testnet \ | |
| --id "$CONTRACT_ID" \ | |
| -- get_total_weight) | |
| export WEIGHTS_RAW="$weights_raw" | |
| export TOTAL_RAW="$total_raw" | |
| python3 <<'PY' | |
| import json, os, re, sys | |
| def parse_u32(value): | |
| if isinstance(value, bool): | |
| raise TypeError(value) | |
| if isinstance(value, int): | |
| return value | |
| if isinstance(value, str): | |
| return int(value, 10) | |
| raise TypeError(type(value)) | |
| def extract_weight(entry): | |
| if isinstance(entry, dict): | |
| for key, val in entry.items(): | |
| if str(key).lower() == "weight": | |
| return parse_u32(val) | |
| raise ValueError(f"Cannot read weight from entry: {entry!r}") | |
| weights_raw = os.environ["WEIGHTS_RAW"].strip() | |
| total_raw = os.environ["TOTAL_RAW"].strip() | |
| try: | |
| weights = json.loads(weights_raw) | |
| except json.JSONDecodeError: | |
| found = re.findall(r'"?weight"?\s*[:=]\s*"?(\d+)"?', weights_raw, flags=re.I) | |
| if not found: | |
| print("Failed to parse get_owner_weights output:", weights_raw, file=sys.stderr) | |
| sys.exit(1) | |
| summed = sum(int(w) for w in found) | |
| else: | |
| if not isinstance(weights, list): | |
| print("Expected a list from get_owner_weights, got:", type(weights), file=sys.stderr) | |
| sys.exit(1) | |
| summed = sum(extract_weight(entry) for entry in weights) | |
| try: | |
| reported = parse_u32(json.loads(total_raw)) | |
| except Exception: | |
| m = re.search(r"\d+", total_raw) | |
| if not m: | |
| print("Failed to parse get_total_weight output:", total_raw, file=sys.stderr) | |
| sys.exit(1) | |
| reported = int(m.group(0)) | |
| print(f"sum(owner weights) = {summed}") | |
| print(f"get_total_weight = {reported}") | |
| if summed != reported: | |
| print( | |
| "Weight invariant failed: sum of owner weights does not equal reported total weight.", | |
| file=sys.stderr, | |
| ) | |
| sys.exit(1) | |
| print("Weight invariant OK.") | |
| PY |