diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index dcbbeb48..0ef22775 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -3,210 +3,153 @@ name: Release Artifacts on: push: tags: - - '*' + - 'v*' workflow_dispatch: inputs: tag: - description: 'Tag name to attach artifacts to (e.g., v1.0.0). The tag must exist in the repository.' + description: 'Tag name to attach artifacts to (e.g., v0.1.0). The tag must exist in the repository.' required: true type: string jobs: build-and-upload: + name: Build & Upload Soroban Artifacts runs-on: ubuntu-latest permissions: contents: write - + steps: - - name: Determine tag name and workflow branch + - name: Determine release tag id: tag env: INPUT_TAG: ${{ github.event.inputs.tag }} run: | - if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then TAG="$INPUT_TAG" - # For workflow_dispatch, use the branch where workflow is running - WORKFLOW_REF="${{ github.ref }}" else - TAG="${GITHUB_REF#refs/tags/}" - # For tag pushes, use default branch (main) to get script - # The script must exist in the default branch for workflow_dispatch to work anyway - WORKFLOW_REF="main" + TAG="${GITHUB_REF_NAME}" fi - echo "tag=$TAG" >> $GITHUB_OUTPUT - echo "workflow_ref=$WORKFLOW_REF" >> $GITHUB_OUTPUT - echo "Determined tag: $TAG" - echo "Workflow ref for script: $WORKFLOW_REF" - - name: Checkout workflow branch (to get script) + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "Using release tag: $TAG" + + - name: Checkout code at tag uses: actions/checkout@v4 with: - submodules: recursive fetch-depth: 0 - ref: ${{ steps.tag.outputs.workflow_ref }} + submodules: recursive + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', github.event.inputs.tag) || github.ref }} - - name: Save extraction script + - name: Stage extract-docs script from workflow ref + env: + WORKFLOW_SHA: ${{ github.sha }} run: | - # Save the script to a temp location that won't be affected by tag checkout - mkdir -p /tmp/workflow-scripts - if [ -f "scripts/extract-artifacts.js" ]; then - cp scripts/extract-artifacts.js /tmp/workflow-scripts/extract-artifacts.js - echo "✓ Saved script from workflow branch" - else - echo "Warning: scripts/extract-artifacts.js not found in workflow branch" - fi + git cat-file -p "${WORKFLOW_SHA}:soroban/scripts/extract-docs.js" \ + > /tmp/extract-docs.js + chmod +x /tmp/extract-docs.js + echo "Staged extract-docs.js from ${WORKFLOW_SHA}" - - name: Checkout code at tag - uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable with: - submodules: recursive - fetch-depth: 0 - ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', github.event.inputs.tag) || github.ref }} + targets: wasm32v1-none,wasm32-unknown-unknown - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 + - name: Install Stellar CLI + uses: stellar/stellar-cli@v26.0.0 - - name: Build contracts - run: forge build + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + soroban/target + key: ${{ runner.os }}-cargo-soroban-${{ hashFiles('soroban/**/Cargo.toml', 'soroban/**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-soroban- - - name: Extract contract artifacts - id: extract + - name: Discover Soroban contracts + working-directory: soroban run: | - # Use the script from the workflow branch (saved earlier) - if [ -f "/tmp/workflow-scripts/extract-artifacts.js" ]; then - echo "Using script from workflow branch" - node /tmp/workflow-scripts/extract-artifacts.js - else - echo "Error: Extraction script not found. Make sure scripts/extract-artifacts.js exists in your workflow branch." + cargo metadata --format-version 1 --no-deps > /tmp/soroban-metadata.json + jq -r '.packages[] as $pkg | $pkg.targets[] | select(.kind | index("cdylib")) | [$pkg.name, .name] | @tsv' \ + /tmp/soroban-metadata.json > /tmp/soroban-contracts.tsv + + if [ ! -s /tmp/soroban-contracts.tsv ]; then + echo "Error: no Soroban cdylib contracts found in the workspace" exit 1 fi - - name: Create release if it doesn't exist - id: create-release + echo "Contracts selected for release:" + cat /tmp/soroban-contracts.tsv + + - name: Build Soroban contracts + working-directory: soroban + run: | + while IFS="$(printf '\t')" read -r package target; do + echo "Building $package ($target)" + stellar contract build --package "$package" --optimize + done < /tmp/soroban-contracts.tsv + + - name: Package release assets + working-directory: soroban + run: | + asset_dir="$GITHUB_WORKSPACE/release-assets" + mkdir -p "$asset_dir" + + while IFS="$(printf '\t')" read -r package target; do + release_wasm="" + for target_dir in \ + target/wasm32v1-none/release \ + target/wasm32-unknown-unknown/release; do + if [ -f "$target_dir/${target}.optimized.wasm" ]; then + release_wasm="$target_dir/${target}.optimized.wasm" + break + elif [ -f "$target_dir/${target}.wasm" ]; then + release_wasm="$target_dir/${target}.wasm" + break + fi + done + + if [ -z "$release_wasm" ]; then + echo "Error: no WASM found for ${target} under target/wasm32v1-none or target/wasm32-unknown-unknown" + exit 1 + fi + + echo "Using $release_wasm" + + cp "$release_wasm" "$asset_dir/${package}.wasm" + stellar contract info interface \ + --wasm "$asset_dir/${package}.wasm" \ + --output json-formatted > "$asset_dir/${package}.contractspec.json" + node /tmp/extract-docs.js \ + "$asset_dir/${package}.contractspec.json" \ + "$asset_dir/${package}.docs.json" + done < /tmp/soroban-contracts.tsv + + echo "Release assets:" + ls -lh "$asset_dir" + + - name: Create release if it does not exist env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.tag.outputs.tag }} run: | - TAG="${{ steps.tag.outputs.tag }}" - - # Check if release already exists if gh release view "$TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then - echo "Release $TAG already exists - will add artifacts to existing release" - echo "exists=true" >> $GITHUB_OUTPUT - echo "release_action=update" >> $GITHUB_OUTPUT + echo "Release $TAG already exists; artifacts will be uploaded to it." else - echo "Creating new release $TAG" - # Verify the tag exists in the repository - if ! git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then - echo "Error: Tag $TAG does not exist in the repository" - echo "Available tags:" - git tag | head -10 - exit 1 - fi - gh release create "$TAG" \ --repo "${{ github.repository }}" \ + --verify-tag \ --title "Release $TAG" \ - --notes "Contract artifacts for $TAG" \ - --draft=false \ - --prerelease=false - - if [ $? -eq 0 ]; then - echo "Successfully created release $TAG" - echo "exists=false" >> $GITHUB_OUTPUT - echo "release_action=create" >> $GITHUB_OUTPUT - else - echo "Warning: Failed to create release (may have been created concurrently)" - echo "exists=true" >> $GITHUB_OUTPUT - echo "release_action=update" >> $GITHUB_OUTPUT - fi + --notes "Soroban contract artifacts for $TAG" fi - name: Upload artifacts to release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.tag.outputs.tag }} run: | - TAG="${{ steps.tag.outputs.tag }}" - RELEASE_ACTION="${{ steps.create-release.outputs.release_action }}" - - # Verify release exists - if ! gh release view "$TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then - echo "Error: Release $TAG does not exist and could not be created" - exit 1 - fi - - echo "Release action: $RELEASE_ACTION" - echo "Tag: $TAG" - echo "" - - # Upload each contract's artifacts - if [ -d "artifacts" ] && [ "$(ls -A artifacts)" ]; then - # Create a list of files to upload - find artifacts -type f > /tmp/artifacts_list.txt - - # Create temporary directory for renamed files - mkdir -p /tmp/release-assets - - upload_count=0 - failed_count=0 - - # Process each file - while IFS= read -r artifact_file; do - filename=$(basename "$artifact_file") - contract_name=$(basename $(dirname "$artifact_file")) - - # Create a descriptive name for the asset - # Copy file to temp location with desired name for upload - asset_name="${contract_name}/${filename}" - temp_asset_path="/tmp/release-assets/${asset_name}" - - # Create subdirectory structure in temp location - mkdir -p "$(dirname "$temp_asset_path")" - - # Copy file to temp location with desired name - cp "$artifact_file" "$temp_asset_path" - - echo "Uploading $artifact_file as $asset_name" - - # Use --clobber to overwrite existing assets if they exist - upload_output=$(gh release upload "$TAG" "$temp_asset_path" \ - --repo "${{ github.repository }}" \ - --clobber 2>&1) - upload_exit_code=$? - - if [ $upload_exit_code -eq 0 ]; then - echo "✓ Successfully uploaded $asset_name" - upload_count=$((upload_count + 1)) - else - echo "✗ Failed to upload $asset_name" - echo " Error: $upload_output" - failed_count=$((failed_count + 1)) - fi - done < /tmp/artifacts_list.txt - - # Cleanup - rm -f /tmp/artifacts_list.txt - rm -rf /tmp/release-assets - - echo "" - echo "==========================================" - echo "Upload Summary:" - echo " Tag: $TAG" - echo " Release action: $RELEASE_ACTION" - echo " Files uploaded: $upload_count" - echo " Files failed: $failed_count" - echo " Contracts processed: $(find artifacts -mindepth 1 -maxdepth 1 -type d | wc -l)" - echo "==========================================" - - if [ $failed_count -gt 0 ]; then - echo "Error: Some files failed to upload" - exit 1 - fi - - if [ $upload_count -eq 0 ]; then - echo "Warning: No files were uploaded" - exit 1 - fi - else - echo "Error: No artifacts found to upload" - exit 1 - fi + gh release upload "$TAG" release-assets/* \ + --repo "${{ github.repository }}" \ + --clobber diff --git a/soroban/Cargo.toml b/soroban/Cargo.toml index 11ee7399..c7a5af09 100644 --- a/soroban/Cargo.toml +++ b/soroban/Cargo.toml @@ -6,3 +6,13 @@ members = [ "predicate-client", "example-compliant-token", ] + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true diff --git a/soroban/predicate-registry/src/lib.rs b/soroban/predicate-registry/src/lib.rs index bc2b111c..ce1f0f5a 100644 --- a/soroban/predicate-registry/src/lib.rs +++ b/soroban/predicate-registry/src/lib.rs @@ -21,6 +21,12 @@ pub struct PredicateRegistryContract; #[contractimpl] impl PredicateRegistryContract { /// Initialize the registry with an owner address. + /// + /// # Arguments + /// + /// * `owner` - Address with administrative privileges. Can register and + /// deregister attesters, and propose a new owner via the two-step + /// `transfer_ownership` / `accept_ownership` flow. pub fn __constructor(e: &Env, owner: Address) { e.storage().instance().set(&OWNER, &owner); } diff --git a/soroban/scripts/extract-docs.js b/soroban/scripts/extract-docs.js new file mode 100755 index 00000000..b69c7a44 --- /dev/null +++ b/soroban/scripts/extract-docs.js @@ -0,0 +1,149 @@ +#!/usr/bin/env node +/** + * extract-docs.js + * + * Reads a Soroban contract spec JSON (as produced by + * `stellar contract info interface --wasm --output json-formatted`) + * and emits a structured docs file the consuming app can render without + * parsing Markdown at runtime. + * + * Output shape: + * + * { + * "": { + * "summary": "First paragraph of the docstring.", + * "args": [{ "name": "admin", "description": "..." }], + * "errors": [{ "description": "Reverts if ..." }], + * "events": [{ "description": "Emits ..." }] + * }, + * ... + * } + * + * Conventions expected in source docstrings (enforced by convention, not + * by this script): + * + * - `# Arguments`, `# Errors`, `# Events` as section headers + * - Bullets formatted as `* `name` - description` (backtick-wrapped name) + * - Continuation lines indented with 2 spaces + */ + +"use strict"; + +const fs = require("node:fs"); + +function usage() { + console.error("usage: extract-docs.js "); + process.exit(2); +} + +function readEntries(rawText) { + const trimmed = rawText.trim(); + if (!trimmed) return []; + + try { + const parsed = JSON.parse(trimmed); + return Array.isArray(parsed) ? parsed : [parsed]; + } catch (_) { + return trimmed + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line)); + } +} + +const SECTION_HEADERS = { + "# Arguments": "args", + "# Errors": "errors", + "# Events": "events", +}; + +function parseDoc(doc) { + if (!doc || typeof doc !== "string") return null; + + const lines = doc + .replace(/\r\n/g, "\n") + .replace(/\\n/g, "\n") + .split("\n"); + const result = { summary: "", args: [], errors: [], events: [] }; + const summaryLines = []; + + let section = "summary"; + let currentBullet = null; + + const flush = () => { + if (!currentBullet) return; + if (section === "args" || section === "errors" || section === "events") { + result[section].push(currentBullet); + } + currentBullet = null; + }; + + for (const rawLine of lines) { + const trimmed = rawLine.trim(); + const headerKey = SECTION_HEADERS[trimmed]; + + if (headerKey) { + flush(); + section = headerKey; + continue; + } + if (trimmed.startsWith("# ")) { + flush(); + section = "other"; + continue; + } + + if (section === "summary") { + if (trimmed) summaryLines.push(trimmed); + continue; + } + if (section === "other") continue; + + const namedBullet = trimmed.match(/^[*-]\s+`([^`]+)`\s*-\s*(.*)$/); + if (namedBullet) { + flush(); + currentBullet = { name: namedBullet[1], description: namedBullet[2].trim() }; + continue; + } + + const plainBullet = trimmed.match(/^[*-]\s+(.*)$/); + if (plainBullet) { + flush(); + currentBullet = { description: plainBullet[1].trim() }; + continue; + } + + if (currentBullet && trimmed) { + currentBullet.description = `${currentBullet.description} ${trimmed}`.trim(); + } + } + flush(); + + result.summary = summaryLines.join(" ").trim(); + + const hasContent = + result.summary || result.args.length || result.errors.length || result.events.length; + return hasContent ? result : null; +} + +function main() { + const [, , inFile, outFile] = process.argv; + if (!inFile || !outFile) usage(); + + const entries = readEntries(fs.readFileSync(inFile, "utf8")); + const docs = {}; + + for (const entry of entries) { + const fn = entry && entry.function_v0; + if (!fn || typeof fn.name !== "string") continue; + + const parsed = parseDoc(fn.doc); + if (parsed) docs[fn.name] = parsed; + } + + fs.writeFileSync(outFile, `${JSON.stringify(docs, null, 2)}\n`); + console.log(`Wrote ${Object.keys(docs).length} function doc entries to ${outFile}`); +} + +main(); diff --git a/soroban/test-stablecoin/src/lib.rs b/soroban/test-stablecoin/src/lib.rs index b3191a37..13065d3b 100644 --- a/soroban/test-stablecoin/src/lib.rs +++ b/soroban/test-stablecoin/src/lib.rs @@ -24,6 +24,20 @@ pub enum TestStablecoinError { #[contractimpl] impl TestStablecoinContract { + /// Deploy the stablecoin and mint the initial supply to `admin`. + /// + /// # Arguments + /// + /// * `name` - Token display name (e.g. "USD Stablecoin"). + /// * `symbol` - Ticker symbol (e.g. "USDC"). 3-7 characters recommended. + /// * `admin` - Address with role-admin privileges. Holds the initial + /// supply and can grant or revoke any role. + /// * `manager` - Address granted the `manager` role at deploy time. Can + /// be the same as `admin` for simple deployments. + /// * `blocker` - Address granted the `blocker` role at deploy time. + /// Required to call `block_user` and `unblock_user`. + /// * `initial_supply` - Tokens minted to `admin` at deploy, in base + /// units. The token uses 6 decimals, so 1 token = 1_000_000. pub fn __constructor( e: &Env, name: String,