From 0cd38690459c019442bbf8a20edc73a80b4a760f Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Mon, 15 Jun 2026 11:47:02 -0400 Subject: [PATCH 1/2] Add release tooling --- .github/workflows/release.yml | 384 +++++++++++++++++++++++++++ Cargo.lock | 11 + Cargo.toml | 1 + tools/release/Cargo.toml | 17 ++ tools/release/README.md | 229 ++++++++++++++++ tools/release/src/crates_resolver.rs | 152 +++++++++++ tools/release/src/main.rs | 167 ++++++++++++ tools/release/src/targets/cpp.rs | 226 ++++++++++++++++ tools/release/src/targets/crates.rs | 141 ++++++++++ tools/release/src/targets/csharp.rs | 381 ++++++++++++++++++++++++++ tools/release/src/targets/docker.rs | 211 +++++++++++++++ tools/release/src/targets/mod.rs | 12 + tools/release/src/targets/npm.rs | 112 ++++++++ tools/release/src/targets/util.rs | 19 ++ 14 files changed, 2063 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 tools/release/Cargo.toml create mode 100644 tools/release/README.md create mode 100644 tools/release/src/crates_resolver.rs create mode 100644 tools/release/src/main.rs create mode 100644 tools/release/src/targets/cpp.rs create mode 100644 tools/release/src/targets/crates.rs create mode 100644 tools/release/src/targets/csharp.rs create mode 100644 tools/release/src/targets/docker.rs create mode 100644 tools/release/src/targets/mod.rs create mode 100644 tools/release/src/targets/npm.rs create mode 100644 tools/release/src/targets/util.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000000..066e02deeb5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,384 @@ +name: Release + +run-name: ${{ format('Release {0} [{1}]', github.event.inputs.release_tag, github.event.inputs.dry_run == 'true' && 'dry-run' || 'live') }} + +on: + workflow_dispatch: + inputs: + release_tag: + description: "Release tag (e.g. v1.9.0)" + required: true + dry_run: + description: "Run in dry-run mode" + required: true + type: boolean + default: true + release_crates: + description: "Release crates.io packages" + required: true + type: boolean + default: false + release_csharp: + description: "Release C# SDK" + required: true + type: boolean + default: false + release_cpp: + description: "Release C++ bindings" + required: true + type: boolean + default: false + release_npm: + description: "Release NPM package" + required: true + type: boolean + default: false + release_docker: + description: "Release Docker container" + required: true + type: boolean + default: false + update_mirror_latest_version: + description: "Update S3 mirror latest-version marker" + required: true + type: boolean + default: false + +permissions: + contents: write + packages: write + +concurrency: + group: manual-release + cancel-in-progress: true + +jobs: + # This job runs before all of our release jobs. If there is a release problem we should + # try to fail during this step to prevent partial releases. + build-cargo-release: + runs-on: spacetimedb-new-runner-2 + steps: + - name: Checkout release tag + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.release_tag }} + + - name: Set up Rust + uses: dsherret/rust-toolchain-file@v1 + - name: Set default rust toolchain + run: rustup default $(rustup show active-toolchain | cut -d' ' -f1) + + - name: Cache cargo + uses: swatinem/rust-cache@v2 + with: + workspaces: ./ + + - name: Install cargo-release + run: | + cargo install --path tools/release + # ensure the binary exists + which cargo-release + # copy it into a sharable directory + mkdir -p shared-bin + cp "$(which cargo-release)" shared-bin/ + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: cargo-release-bin + path: shared-bin/ + + release-crates: + needs: build-cargo-release + runs-on: spacetimedb-new-runner-2 + if: ${{ inputs.release_crates }} + env: + CARGO_TERM_COLOR: always + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + - name: Checkout specific tag + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.release_tag }} + + - name: Set up Rust + uses: dsherret/rust-toolchain-file@v1 + - name: Set default rust toolchain + run: rustup default $(rustup show active-toolchain | cut -d' ' -f1) + + - name: Download cargo-release + uses: actions/download-artifact@v4 + with: + name: cargo-release-bin + path: ./shared-bin + + - name: Make binary executable and on PATH + run: | + chmod +x ./shared-bin/cargo-release + echo "$PWD/shared-bin" >> "$GITHUB_PATH" + + # TODO: dry-run via publishing to a local registry: https://doc.rust-lang.org/cargo/reference/registries.html + - name: Run release (dry-run) + if: ${{ inputs.dry_run }} + # NOTE: This will print a warning that `cargo-release release crates` dry runs are not supported + run: cargo-release release crates --dry-run + + - name: Run release + if: ${{ !inputs.dry_run }} + run: cargo-release release crates + + release-csharp: + needs: build-cargo-release + runs-on: spacetimedb-new-runner-2 + if: ${{ inputs.release_csharp }} + env: + CARGO_TERM_COLOR: always + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + + steps: + - name: Checkout specific tag + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.release_tag }} + + - name: Set up Rust + uses: dsherret/rust-toolchain-file@v1 + - name: Set default rust toolchain + run: rustup default $(rustup show active-toolchain | cut -d' ' -f1) + + - name: Download cargo-release + uses: actions/download-artifact@v4 + with: + name: cargo-release-bin + path: ./shared-bin + + - name: Make binary executable and on PATH + run: | + chmod +x ./shared-bin/cargo-release + echo "$PWD/shared-bin" >> "$GITHUB_PATH" + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + global-json-path: global.json + + - name: Install NuGet + shell: bash + run: | + sudo apt install -y mono-complete + mkdir bin + cd bin + wget https://dist.nuget.org/win-x86-commandline/latest/nuget.exe + # We're on linux, so we need to do a mono dance to make NuGet invocations look the same as they would on e.g. windows + mv nuget.exe nuget-mono.exe + cat <<'EOF' | sed 's/^ *//' >nuget + #!/bin/bash + DIR="$(dirname "$(readlink -f "$0")")" + mono "$DIR/nuget-mono.exe" "$@" + EOF + chmod +x nuget + echo "$PWD" >> $GITHUB_PATH + + - name: Setup SSH key for Unity SDK repo + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.UNITY_SDK_DEPLOY_KEY }} + + - name: Configure git + run: | + git config --global user.name "Release Bot" + git config --global user.email "no-reply@clockworklabs.io" + # Remove any HTTPS to SSH conversion set by checkout action + git config --global --unset-all url.https://github.com/.insteadOf || true + # Ensure SSH is used instead of HTTPS for GitHub + git config --global url."git@github.com:".insteadOf "https://github.com/" + # Verify configuration + echo "Git URL rewrite config:" + git config --global --get-regexp url + + - name: Run C# SDK release (dry-run) + if: ${{ inputs.dry_run }} + run: cargo-release release csharp ${{ github.event.inputs.release_tag }} --dry-run + + - name: Run C# SDK release + if: ${{ !inputs.dry_run }} + run: cargo-release release csharp ${{ github.event.inputs.release_tag }} + + release-cpp: + needs: build-cargo-release + runs-on: spacetimedb-new-runner-2 + if: ${{ inputs.release_cpp }} + env: + CARGO_TERM_COLOR: always + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + - name: Checkout specific tag + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.release_tag }} + + - name: Set up Rust + uses: dsherret/rust-toolchain-file@v1 + - name: Set default rust toolchain + run: rustup default $(rustup show active-toolchain | cut -d' ' -f1) + + - name: Download cargo-release + uses: actions/download-artifact@v4 + with: + name: cargo-release-bin + path: ./shared-bin + + - name: Make binary executable and on PATH + run: | + chmod +x ./shared-bin/cargo-release + echo "$PWD/shared-bin" >> "$GITHUB_PATH" + + - name: Setup SSH key for C++ SDK repo + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.CPP_SDK_DEPLOY_KEY }} + + - name: Configure git + run: | + git config --global user.name "Release Bot" + git config --global user.email "no-reply@clockworklabs.io" + # Remove any HTTPS to SSH conversion set by checkout action + git config --global --unset-all url.https://github.com/.insteadOf || true + # Ensure SSH is used instead of HTTPS for GitHub + git config --global url."git@github.com:".insteadOf "https://github.com/" + # Verify configuration + echo "Git URL rewrite config:" + git config --global --get-regexp url + + - name: Run C++ SDK release (dry-run) + if: ${{ inputs.dry_run }} + run: cargo-release release cpp ${{ github.event.inputs.release_tag }} --dry-run + + - name: Run C++ SDK release + if: ${{ !inputs.dry_run }} + run: cargo-release release cpp ${{ github.event.inputs.release_tag }} + + release-npm: + needs: build-cargo-release + runs-on: ubuntu-latest + if: ${{ inputs.release_npm }} + env: + CARGO_TERM_COLOR: always + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + permissions: + id-token: write + contents: write + packages: write + + steps: + - name: Checkout specific tag + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.release_tag }} + + - name: Download cargo-release + uses: actions/download-artifact@v4 + with: + name: cargo-release-bin + path: ./shared-bin + + - name: Make binary executable and on PATH + run: | + chmod +x ./shared-bin/cargo-release + echo "$PWD/shared-bin" >> "$GITHUB_PATH" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.x' + registry-url: 'https://registry.npmjs.org' + + - name: Install pnpm + run: npm install -g pnpm + + - name: Run NPM release (dry-run) + if: ${{ inputs.dry_run }} + run: cargo-release release npm ${{ github.event.inputs.release_tag }} --dry-run + + - name: Run NPM release + if: ${{ !inputs.dry_run }} + run: cargo-release release npm ${{ github.event.inputs.release_tag }} + + release-docker: + needs: build-cargo-release + runs-on: spacetimedb-new-runner-2 + env: + CARGO_TERM_COLOR: always + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + if: ${{ inputs.release_docker }} + + steps: + - name: Checkout specific tag + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.release_tag }} + + - name: Download cargo-release + uses: actions/download-artifact@v4 + with: + name: cargo-release-bin + path: ./shared-bin + + - name: Make binary executable and on PATH + run: | + chmod +x ./shared-bin/cargo-release + echo "$PWD/shared-bin" >> "$GITHUB_PATH" + + # We need network=host during dry-runs but it's fine during non dry-runs as well + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver-opts: network=host + - name: Login to DockerHub + uses: docker/login-action@v3 + if: ${{ !inputs.dry_run }} + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + # Docker Hub access tokens are passed to docker/login-action via the password input. + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Run Docker release + run: cargo-release release docker ${{ github.event.inputs.release_tag }} ${{ inputs.dry_run && '--dry-run' || '' }} + + update-mirror-latest-version: + runs-on: ubuntu-latest + if: ${{ !inputs.dry_run && inputs.update_mirror_latest_version }} + + steps: + - name: Verify mirror tag prefix exists + env: + RELEASE_TAG: ${{ github.event.inputs.release_tag }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: us-east-1 + run: | + set -uo pipefail + + PREFIX="refs/tags/$RELEASE_TAG/" + COUNT="$(aws s3api list-objects-v2 \ + --bucket spacetimedb-client-binaries \ + --prefix "$PREFIX" \ + --max-keys 1 \ + --query 'length(not_null(Contents, `[]`))')" + test "$COUNT" -gt 0 + + - name: Generate latest-version marker + env: + RELEASE_TAG: ${{ github.event.inputs.release_tag }} + run: printf '%s\n' "$RELEASE_TAG" > latest-version + + - name: Upload latest-version to S3 mirror + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: us-east-1 + run: aws s3 cp latest-version s3://spacetimedb-client-binaries/latest-version --acl public-read diff --git a/Cargo.lock b/Cargo.lock index d9f5b5d97aa..b70948bb8f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8453,6 +8453,17 @@ dependencies = [ "spacetimedb-lib", ] +[[package]] +name = "spacetimedb-release" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap 4.5.50", + "serde", + "serde_json", + "toml 0.8.23", +] + [[package]] name = "spacetimedb-runtime" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index ede22ce89ed..121cd30ffc0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,7 @@ members = [ "tools/replace-spacetimedb", "tools/generate-client-api", "tools/gen-bindings", + "tools/release", "tools/regen", "tools/xtask-llm-benchmark", "crates/bindings-typescript/test-app/server", diff --git a/tools/release/Cargo.toml b/tools/release/Cargo.toml new file mode 100644 index 00000000000..625fc485a56 --- /dev/null +++ b/tools/release/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "spacetimedb-release" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +publish = false + +[[bin]] +name = "cargo-release" +path = "src/main.rs" + +[dependencies] +clap = { version = "4.4", features = ["derive"] } +toml = "0.8.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +anyhow = "1.0" diff --git a/tools/release/README.md b/tools/release/README.md new file mode 100644 index 00000000000..103f7ed4c2e --- /dev/null +++ b/tools/release/README.md @@ -0,0 +1,229 @@ +# SpacetimeDB Release CLI + +A command-line tool for managing SpacetimeDB releases and deployments. + +Note: The permissions related to this tool are very complex. For publishing a package you will need to be a member of the clockwork labs org for that package and in the case of crates.io you will need to be added to each package individually. Generally it is recommended to not attempt to run this locally unless you know what you're doing. We recommend using our Github workflow which is already setup with the correct permissions/tokens. This allows anyone to publish a release: https://github.com/clockworklabs/SpacetimeDB/actions/workflows/release.yml + +## Key Objectives + +1. **Platform Independence**: This tool is designed to use minimal shell scripting and platform-specific commands, making it as platform-independent as possible. + +2. **CI/CD Integration**: While the tool can be executed locally, it's primarily designed to run within GitHub workflows. This approach eliminates the need for local tool installations and special permissions or secret keys to perform releases. + +3. **Configurability**: The tool provides fine-grained control over which components are released, allowing you to choose exactly what gets released and what doesn't. + +## Installation + +This tool is part of the SpacetimeDB repository. To install it as a cargo subcommand: + +```bash +cd tools/release +cargo install --path . +``` + +This will install the `cargo-release` binary to your `~/.cargo/bin` directory, allowing you to run it as `cargo release` from anywhere. + +To verify the installation: + +```bash +cargo release --help +``` + +## Usage + +The release CLI provides commands for releasing various components of the SpacetimeDB ecosystem: + +### Crates.io Packages + +Release the following packages to crates.io: +- memory-usage +- primitives +- metrics +- bindings-macro +- bindings-sys +- bindings +- data-structures +- client-api-messages +- sats +- lib +- sdk + +```bash +cargo release crates +``` + +You can also perform a dry run to see what would be published without actually publishing: + +```bash +cargo release crates --dry-run +``` + +### NPM Package + +Release the TypeScript SDK to npm. This will: +1. Run `pnpm publish` which automatically triggers the `prepublishOnly` script +2. The `prepublishOnly` script will build, test, and size up the package +3. Publish the package to npm as `@clockworklabs/spacetimedb-sdk` +4. Set the dist-tag to `latest` +5. Verify the dist-tags + +```bash +cargo release npm 1.2.0 +``` + +You can also perform a dry run to test the build and publish process without actually publishing: + +```bash +cargo release npm 1.2.0 --dry-run +``` + +**Note:** In dry-run mode, `pnpm publish --dry-run` will be executed to verify the build and packaging process works correctly, but the package will NOT be published to npm. + +**Prerequisites:** +- Node.js and npm must be installed +- pnpm must be installed (`npm install -g pnpm`) +- You must be logged in to npm (`npm login`) +- You must have publish access to the `@clockworklabs/spacetimedb-sdk` package + +### C# SDK (NuGet + Unity SDK) + +Release the C# SDK to both NuGet and the Unity SDK repository. This unified release process: +1. Builds the C# DLLs once using `dotnet pack` +2. Publishes NuGet packages to nuget.org: + - SpacetimeDB.BSATN.Runtime + - SpacetimeDB.Runtime + - SpacetimeDB.ClientSDK + - SpacetimeDB.ClientSDK.Godot +3. Updates the Unity project with new DLLs: + - Removes existing `sdks/csharp/packages/spacetimedb.bsatn.runtime` directory + - Runs `dotnet restore` to populate DLLs from NuGet cache (creates `{version}/` directory) + - Copies `.meta` files from `sdks/csharp/release~/spacetimedb.bsatn.runtime/unversioned/` to packages + - Commits the changes +4. Publishes to the Unity SDK repository: + - Fetches the `release/mirror/csharp` branch + - Creates a git subtree split from `sdks/csharp` + - Pushes to `clockworklabs/com.clockworklabs.spacetimedbsdk` as `release/latest` + - Tags the Unity SDK repository with the version + +```bash +cargo release csharp 1.2.0 +``` + +You can also perform a dry run to test the build process without publishing: + +```bash +cargo release csharp 1.2.0 --dry-run +``` + +**Note:** In dry-run mode, the DLLs will be built to verify the build process works correctly, but packages will NOT be pushed to NuGet or the Unity SDK repository. + +**Why Combined?** The same DLLs are used for both NuGet packages and the Unity SDK. Building them once ensures consistency and avoids potential version mismatches. + +**Prerequisites:** +- .NET SDK must be installed +- NuGet CLI must be installed + - On Linux: `sudo apt-get install nuget mono-complete` + - On macOS: `brew install nuget` + - On Windows: Download from https://www.nuget.org/downloads +- Git must be installed +- You must have SSH access to `git@github.com:clockworklabs/com.clockworklabs.spacetimedbsdk.git` +- You must have a NuGet API key configured (set via environment variable or NuGet config) + +### Docker Container + +Release the SpacetimeDB public Docker container to DockerHub. This will: +1. Build a multi-platform image (linux/amd64, linux/arm64) +2. Push the versioned image (e.g., clockworklabs/spacetime:v1.2.0) +3. Tag the image as :latest + +```bash +cargo release docker v1.2.0 +``` + +You can also perform a dry run to test the build process without pushing to DockerHub: + +```bash +cargo release docker v1.2.0 --dry-run +``` + +**Note:** In dry-run mode, the containers will be built locally to verify the build process works correctly, but they will NOT be pushed to DockerHub. + +**Prerequisites:** +- Docker must be installed and running +- You must be logged in to DockerHub (`docker login`) +- You must have push access to the clockworklabs/spacetime repository + +## Full Release + +To perform a full release of all components: + +```bash +cargo release --all +``` + +You can also skip specific targets: + +```bash +cargo release --all --skip docker +``` + +Or skip multiple targets: + +```bash +cargo release --all --skip docker --skip nuget +``` + +## GitHub Workflow Integration + +The release tool is integrated with GitHub Actions via the `.github/workflows/release.yml` workflow. + +### Workflow Behavior + +- **Manual Trigger**: Can be run in either dry-run or actual release mode via workflow_dispatch + +### Docker Release Workflow + +The Docker release job automatically: +1. Extracts the version from `Cargo.toml` +2. Sets up Docker Buildx for multi-platform builds +3. Builds the Docker images (dry-run mode) or builds and pushes (release mode) +4. Tags the image as `:latest` (release mode only) + +### Required GitHub Secrets and Variables + +To run the Docker release workflow in non-dry-run mode, you need to configure the following in your GitHub repository: + +**Variables** (Settings → Secrets and variables → Actions → Variables): +- `DOCKERHUB_USERNAME`: Your DockerHub username + +**Secrets** (Settings → Secrets and variables → Actions → Secrets): +- `DOCKERHUB_TOKEN`: Your DockerHub access token (create one at https://hub.docker.com/settings/security) + +For the crates.io release workflow: + +**Secrets**: +- `CARGO_REGISTRY_TOKEN`: Your crates.io API token (create one at https://crates.io/settings/tokens) + - Permissions: The token must be able to publish crates and add crate owners. You must be an owner of all crates that you are publishing otherwise you will get an error when you go to publish. + +For the C# SDK release workflow (NuGet + Unity): + +**Secrets**: +- `NUGET_API_KEY`: Your NuGet API key (create one at https://www.nuget.org/account/apikeys) + - Permissions: Just the `push` permission. It is recommended that you scope your token to just the required packages. You can also use wildcards here like `SpacetimeDB.*`. + +For the NPM release workflow: + +Configure npm trusted publishing for this workflow in the npm package settings. + +### Running the Workflow Manually + +1. Go to the "Actions" tab in your GitHub repository +2. Select the "Release" workflow +3. Click "Run workflow" +4. Enter the tag you are releasing. Example: `v1.1.1` +5. Choose whether to run in dry-run mode (default: true) +6. Click "Run workflow" + +## Development + +To add a new release target, implement the `ReleaseTarget` trait and add it to the appropriate modules. diff --git a/tools/release/src/crates_resolver.rs b/tools/release/src/crates_resolver.rs new file mode 100644 index 00000000000..3412ecf93da --- /dev/null +++ b/tools/release/src/crates_resolver.rs @@ -0,0 +1,152 @@ +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Debug, Deserialize)] +struct CargoToml { + dependencies: Option, +} + +#[derive(Debug, Deserialize)] +struct CargoMetadata { + packages: Vec, +} + +#[derive(Debug, Deserialize)] +struct Package { + name: String, + manifest_path: PathBuf, +} + +/// Gets cargo metadata and returns a mapping of crate names to their manifest paths +pub fn get_crate_manifest_map(workspace_root: &Path) -> Result> { + let output = Command::new("cargo") + .arg("metadata") + .arg("--format-version=1") + .arg("--no-deps") + .current_dir(workspace_root) + .output() + .context("Failed to execute cargo metadata")?; + + if !output.status.success() { + anyhow::bail!("cargo metadata failed: {}", String::from_utf8_lossy(&output.stderr)); + } + + let metadata: CargoMetadata = + serde_json::from_slice(&output.stdout).context("Failed to parse cargo metadata output")?; + + let mut map = HashMap::new(); + for package in metadata.packages { + map.insert(package.name, package.manifest_path); + } + + Ok(map) +} + +/// Finds SpacetimeDB dependencies in a Cargo.toml file +pub fn find_spacetimedb_dependencies(cargo_toml_path: &Path) -> Result> { + let content = fs::read_to_string(cargo_toml_path) + .with_context(|| format!("Failed to read Cargo.toml at {}", cargo_toml_path.display()))?; + + // NOTE(bfops): I prefer what find-publish-list.py does; it reads the deps from the cargo metadata output. + let cargo_data: CargoToml = toml::from_str(&content) + .with_context(|| format!("Failed to parse Cargo.toml at {}", cargo_toml_path.display()))?; + + let mut deps = Vec::new(); + if let Some(dependencies) = cargo_data.dependencies { + for (dep_name, _) in dependencies { + if dep_name.starts_with("spacetimedb-") { + deps.push(dep_name); + } + } + } + + Ok(deps) +} + +/// Processes a crate to find its SpacetimeDB dependencies +pub fn get_crate_deps(crate_name: &String, manifest_map: &HashMap) -> Result> { + // Look up the crate in the manifest map + let cargo_toml_path = manifest_map + .get(crate_name) + .with_context(|| format!("Crate '{}' not found in cargo metadata", &crate_name))?; + + println!("\nChecking crate '{}'...", &crate_name); + + let deps = find_spacetimedb_dependencies(cargo_toml_path)?; + if !deps.is_empty() { + for name in &deps { + println!(" {}", name); + } + } else { + println!(" No spacetimedb-* dependencies found."); + } + + let mut all_deps = deps.clone(); + + for dep_name in deps { + let sub_deps = get_crate_deps(&dep_name, manifest_map)?; + all_deps.extend(sub_deps); + } + + Ok(all_deps) +} + +/// Finds the workspace root (public directory) by searching up the directory tree +pub fn find_workspace_root() -> Result { + // TODO(bfops): We can simplify this by doing `git rev-parse --show-toplevel`. + // TODO(jdetter): We can probably just remove this. `cargo release` is almost exclusively going + // to be ran in the CI so it's fine to make an assumption about the directory + // where we're running `cargo release` from. + std::env::current_dir() + .context("Failed to get current directory")? + .ancestors() + .find_map(|path| { + if path.join("Cargo.toml").exists() { + Some(path.to_path_buf()) + } else { + None + } + }) + .context("Failed to find workspace root. Make sure you're running from within the SpacetimeDB repository.") +} + +/// Returns a list of crates to publish in the correct order +pub fn get_crates_to_publish() -> Result> { + // Find the workspace root (public directory) + let workspace_root = find_workspace_root()?; + + // Get the manifest map from cargo metadata + let manifest_map = get_crate_manifest_map(&workspace_root)?; + + // We must publish the bindings + sdk at a minimum + // Note: "bindings" corresponds to the "spacetimedb" crate + // and "sdk" corresponds to the "spacetimedb-sdk" crate + let root_crates = vec!["spacetimedb".to_string(), "spacetimedb-sdk".to_string()]; + let mut all_crates = Vec::new(); + all_crates.extend(root_crates.iter().cloned()); + + // Add all dependencies of the root crates + for crate_name in &root_crates { + all_crates.extend(get_crate_deps(crate_name, &manifest_map)?); + } + + // It takes a bit of reasoning to conclude that this is, in fact, going to be a legitimate + // dependency-order of all of these crates. Because of how the list is constructed, once it's reversed, + // every crate will be mentioned before any of the crates that use it. Because of that, it's safe to + // deduplicate the list in a way that preserves the _first_ occurrence of every crate name, without + // violating the "mentioned before it's used" property of the list. + let mut seen = HashSet::new(); + let mut publish_order = Vec::new(); + + for crate_name in all_crates.into_iter().rev() { + if seen.insert(crate_name.clone()) { + publish_order.push(crate_name); + } + } + + Ok(publish_order) +} diff --git a/tools/release/src/main.rs b/tools/release/src/main.rs new file mode 100644 index 00000000000..f27dc2898f0 --- /dev/null +++ b/tools/release/src/main.rs @@ -0,0 +1,167 @@ +use clap::{Parser, Subcommand}; +mod crates_resolver; + +mod targets; +use targets::{ + cpp::CppRelease, crates::CratesRelease, csharp::CSharpRelease, docker::DockerRelease, npm::NpmRelease, + ReleaseTarget, +}; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +#[command(propagate_version = true)] +#[command(bin_name = "cargo")] +struct Cli { + #[command(subcommand)] + command: CargoCli, +} + +#[derive(Subcommand)] +enum CargoCli { + Release(ReleaseArgs), +} + +#[derive(Parser)] +struct ReleaseArgs { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Release crates.io packages + Crates { + #[arg(long)] + dry_run: bool, + }, + /// Release NPM package + Npm { + release_version: String, + #[arg(long)] + dry_run: bool, + }, + + /// Release C# SDK (NuGet + Unity SDK) + Csharp { + release_version: String, + #[arg(long)] + dry_run: bool, + }, + + /// Release C++ bindings (subtree mirror + git tags) + Cpp { + release_version: String, + #[arg(long)] + dry_run: bool, + }, + + /// Release Docker container + Docker { + release_version: String, + #[arg(long)] + dry_run: bool, + }, + /// Perform a release for all targets + #[command(name = "--all")] + All { + release_version: String, + /// Skip specified targets + #[arg(long)] + skip: Option>, + /// Perform a dry run without actually publishing + #[arg(long)] + dry_run: bool, + }, +} + +fn main() { + let cli = Cli::parse(); + + let CargoCli::Release(release_args) = cli.command; + + let result = match &release_args.command { + Commands::Crates { dry_run } => { + let target = CratesRelease::new(*dry_run); + target.release() + } + Commands::Csharp { + release_version: version, + dry_run, + } => { + let target = CSharpRelease::new(version.clone(), *dry_run); + target.release() + } + Commands::Cpp { + release_version: version, + dry_run, + } => { + let target = CppRelease::new(version.clone(), *dry_run); + target.release() + } + Commands::Npm { + release_version: version, + dry_run, + } => { + let target = NpmRelease::new(version.clone(), *dry_run); + target.release() + } + Commands::Docker { + release_version: version, + dry_run, + } => { + let target = DockerRelease::new(version.clone(), *dry_run); + target.release() + } + Commands::All { + release_version: version, + skip, + dry_run, + } => release_all(version.clone(), skip.clone(), *dry_run), + }; + + if let Err(err) = result { + eprintln!("Error: {}", err); + std::process::exit(1); + } +} + +fn release_all(version: String, skip: Option>, dry_run: bool) -> Result<(), String> { + let skip_targets = skip.unwrap_or_default(); + + let targets: Vec> = vec![ + Box::new(CratesRelease::new(dry_run)), + Box::new(NpmRelease::new(version.clone(), dry_run)), + Box::new(CSharpRelease::new(version.clone(), dry_run)), + Box::new(CppRelease::new(version.clone(), dry_run)), + Box::new(DockerRelease::new(version, dry_run)), + ]; + + println!("Performing a full release..."); + + if !skip_targets.is_empty() { + println!("Skipping targets: {:?}", skip_targets); + } + + if dry_run { + println!("DRY RUN: No changes will be published"); + } + + // Make sure all skip targets are valid + for skip in &skip_targets { + if targets.iter().all(|t| t.name() != skip) { + return Err(format!("Invalid skip target: {}", skip)); + } + } + + for target in targets { + if skip_targets.contains(&target.name().to_string()) { + println!("Skipping {}", target.name()); + continue; + } + + println!("Releasing {}...", target.name()); + target.release()?; + } + + Ok(()) +} diff --git a/tools/release/src/targets/cpp.rs b/tools/release/src/targets/cpp.rs new file mode 100644 index 00000000000..88c690bac5e --- /dev/null +++ b/tools/release/src/targets/cpp.rs @@ -0,0 +1,226 @@ +use crate::targets::util::print_command; +use crate::targets::ReleaseTarget; +use std::process::Command; + +pub struct CppRelease { + pub version: String, + pub dry_run: bool, +} + +impl CppRelease { + pub fn new(version: String, dry_run: bool) -> Self { + Self { version, dry_run } + } +} + +const CPP_REPO_URL: &str = "git@github.com:clockworklabs/spacetimedb-bindings-cpp.git"; +const MIRROR_BRANCH: &str = "release/mirror/bindings-cpp"; +const CPP_PREFIX: &str = "crates/bindings-cpp"; + +/// Configure the local git repo to use SSH instead of HTTPS for GitHub. +/// The checkout action installs a local URL rewrite that converts git@github.com: -> https://, +/// which overrides global config and breaks SSH-based pushes to external repos. +fn configure_git_ssh() { + println!("Configuring git to use SSH..."); + + // Remove the checkout action's SSH -> HTTPS rewrite + let mut cmd = Command::new("git"); + cmd.args(["config", "--local", "--unset-all", "url.https://github.com/.insteadOf"]); + print_command(&cmd); + let _ = cmd.output(); + + // Set HTTPS -> SSH so any https:// GitHub URL also uses SSH + let mut cmd = Command::new("git"); + cmd.args([ + "config", + "--local", + "url.git@github.com:.insteadOf", + "https://github.com/", + ]); + print_command(&cmd); + let _ = cmd.output(); +} + +/// Fetch the release/mirror/bindings-cpp branch from origin. +/// Returns true if the branch exists on origin, false if it doesn't exist yet. +fn fetch_mirror_branch() -> Result { + println!("\n=== Fetching C++ mirror branch ==="); + configure_git_ssh(); + println!("Fetching {} branch...", MIRROR_BRANCH); + + let mut cmd = Command::new("git"); + cmd.args(["fetch", "origin", MIRROR_BRANCH]); + print_command(&cmd); + let output = cmd + .output() + .map_err(|e| format!("Failed to execute git fetch: {}", e))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("couldn't find remote ref") { + println!( + "Branch {} doesn't exist yet - will create it on first push", + MIRROR_BRANCH + ); + return Ok(false); + } else { + return Err(format!("Failed to fetch {} branch: {}", MIRROR_BRANCH, stderr)); + } + } + + println!("Successfully fetched {}", MIRROR_BRANCH); + Ok(true) +} + +/// Create a subtree split for the C++ bindings crate. +/// If `mirror_branch_exists` is true, passes `--onto origin/` to speed up the split. +fn create_subtree_split(mirror_branch_exists: bool) -> Result<(), String> { + println!("Creating subtree split for {}...", CPP_PREFIX); + + // Delete local branch if it exists + let mut cmd = Command::new("git"); + cmd.args(["branch", "-D", MIRROR_BRANCH]); + print_command(&cmd); + let _ = cmd.output(); + + let mut args = vec!["subtree", "split", "--prefix", CPP_PREFIX]; + let onto = format!("origin/{}", MIRROR_BRANCH); + if mirror_branch_exists { + args.extend(["--onto", &onto]); + } + args.extend(["-b", MIRROR_BRANCH]); + + let mut cmd = Command::new("git"); + cmd.args(&args); + print_command(&cmd); + let status = cmd + .status() + .map_err(|e| format!("Failed to execute git subtree split: {}", e))?; + if !status.success() { + return Err("Failed to create subtree split".to_string()); + } + + println!("Successfully created subtree split"); + Ok(()) +} + +/// Push to the C++ SDK repository as release/MAJOR.MINOR +fn push_to_cpp_repo_major_minor(version: &str, dry_run: bool) -> Result<(), String> { + let parts: Vec<&str> = version.splitn(3, '.').collect(); + if parts.len() < 2 { + return Err(format!( + "Invalid version format (expected MAJOR.MINOR.PATCH): {}", + version + )); + } + let branch = format!("release/{}.{}", parts[0], parts[1]); + println!("Pushing to C++ SDK repository ({})...", branch); + + if dry_run { + println!("DRY RUN: Would execute:"); + println!(" git push -f {} {}:{}", CPP_REPO_URL, MIRROR_BRANCH, branch); + return Ok(()); + } + + let mut cmd = Command::new("git"); + cmd.args(["push", "-f", CPP_REPO_URL, &format!("{}:{}", MIRROR_BRANCH, branch)]); + print_command(&cmd); + let status = cmd + .status() + .map_err(|e| format!("Failed to execute git push to C++ repo: {}", e))?; + if !status.success() { + return Err(format!("Failed to push to C++ SDK repository as {}", branch)); + } + + println!("Successfully pushed to C++ SDK repository as {}", branch); + Ok(()) +} + +/// Push to the C++ SDK repository as release/latest +fn push_to_cpp_repo_latest(dry_run: bool) -> Result<(), String> { + println!("Pushing to C++ SDK repository (release/latest)..."); + + if dry_run { + println!("DRY RUN: Would execute:"); + println!(" git push -f {} {}:release/latest", CPP_REPO_URL, MIRROR_BRANCH); + return Ok(()); + } + + let mut cmd = Command::new("git"); + cmd.args(["push", "-f", CPP_REPO_URL, &format!("{}:release/latest", MIRROR_BRANCH)]); + print_command(&cmd); + let status = cmd + .status() + .map_err(|e| format!("Failed to execute git push to C++ repo: {}", e))?; + if !status.success() { + return Err("Failed to push to C++ SDK repository as release/latest".to_string()); + } + + println!("Successfully pushed to C++ SDK repository as release/latest"); + Ok(()) +} + +/// Push a version tag to the C++ SDK repository +fn push_to_cpp_repo_tag(version: &str, dry_run: bool) -> Result<(), String> { + let tag_name = format!("v{}", version); + println!("Pushing to C++ SDK repository (tag: {})...", tag_name); + + if dry_run { + println!("DRY RUN: Would execute:"); + println!(" git push {} {}:refs/tags/{}", CPP_REPO_URL, MIRROR_BRANCH, tag_name); + return Ok(()); + } + + let mut cmd = Command::new("git"); + cmd.args([ + "push", + CPP_REPO_URL, + &format!("{}:refs/tags/{}", MIRROR_BRANCH, tag_name), + ]); + print_command(&cmd); + let status = cmd + .status() + .map_err(|e| format!("Failed to execute git push tag to C++ repo: {}", e))?; + if !status.success() { + return Err(format!("Failed to push tag {} to C++ SDK repository", tag_name)); + } + + println!("Successfully pushed tag {} to C++ SDK repository", tag_name); + Ok(()) +} + +impl ReleaseTarget for CppRelease { + fn release(&self) -> Result<(), String> { + println!("=== Releasing C++ Bindings ==="); + println!("Version: {}", self.version); + + if self.dry_run { + println!("\n*** DRY RUN MODE ***\n"); + } + + // Strip the leading 'v' from the version string + let version = self.version.strip_prefix('v').unwrap_or(&self.version); + + let mirror_branch_exists = fetch_mirror_branch()?; + create_subtree_split(mirror_branch_exists)?; + push_to_cpp_repo_major_minor(version, self.dry_run)?; + push_to_cpp_repo_latest(self.dry_run)?; + push_to_cpp_repo_tag(version, self.dry_run)?; + + println!("\n=== C++ Bindings Release Complete ==="); + if !self.dry_run { + let parts: Vec<&str> = version.splitn(3, '.').collect(); + println!("C++ bindings published to clockworklabs/spacetimedb-bindings-cpp:"); + if parts.len() >= 2 { + println!(" - Branch: release/{}.{}", parts[0], parts[1]); + } + println!(" - Branch: release/latest"); + println!(" - Tag: v{}", version); + } + + Ok(()) + } + + fn name(&self) -> &'static str { + "cpp" + } +} diff --git a/tools/release/src/targets/crates.rs b/tools/release/src/targets/crates.rs new file mode 100644 index 00000000000..758993f9b78 --- /dev/null +++ b/tools/release/src/targets/crates.rs @@ -0,0 +1,141 @@ +use crate::targets::{util, ReleaseTarget}; +use anyhow::Result; +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Command; + +const CRATE_OWNERS: &[&str] = &["cloutiertyler", "jdetter", "bfops", "rekhoff", "spacetimedb-devops"]; + +pub struct CratesRelease { + pub dry_run: bool, +} + +impl CratesRelease { + pub fn new(dry_run: bool) -> Self { + Self { dry_run } + } + + /// Publishes a single crate to crates.io + fn publish_crate(&self, crate_name: &str, manifest_map: &HashMap) -> Result<(), String> { + println!("Publishing crate: {}", crate_name); + + let manifest_path = manifest_map + .get(crate_name) + .ok_or_else(|| format!("Crate '{}' not found in cargo metadata", crate_name))?; + + let crate_dir = manifest_path + .parent() + .ok_or_else(|| format!("Failed to get parent directory of {}", manifest_path.display()))?; + + let mut cmd_args = vec!["publish", "--allow-dirty"]; + if self.dry_run { + cmd_args.push("--dry-run"); + cmd_args.push("--no-verify"); + } + + let mut cmd = Command::new("cargo"); + cmd.args(&cmd_args).current_dir(crate_dir); + util::print_command(&cmd); + + let output = cmd + .output() + .map_err(|e| format!("Failed to execute cargo publish: {}", e))?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + if stderr.contains("already exists on crates.io index") { + println!( + "Crate {} already published on crates.io; skipping (treating as success).\n{}{}", + crate_name, stdout, stderr + ); + return Ok(()); + } + + Err(format!( + "Failed to publish crate: {}\n--- stdout ---\n{}\n--- stderr ---\n{}", + crate_name, stdout, stderr + )) + } + + fn add_crate_owners(&self, crate_name: &str) -> Result<(), String> { + println!("Adding owners for crate: {}", crate_name); + + for owner in CRATE_OWNERS { + let mut cmd = Command::new("cargo"); + cmd.args(["owner", "--add", owner, crate_name]); + util::print_command(&cmd); + + let output = cmd + .output() + .map_err(|e| format!("Failed to execute cargo owner --add {} {}: {}", owner, crate_name, e))?; + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + + if output.status.success() { + println!("Added {} as an owner of {}.", owner, crate_name); + continue; + } + + if stderr.contains("already") || stdout.contains("already") { + println!("{} is already an owner of {}.", owner, crate_name); + continue; + } + + return Err(format!( + "Failed to add owner {} to crate {}\n--- stdout ---\n{}\n--- stderr ---\n{}", + owner, crate_name, stdout, stderr + )); + } + + Ok(()) + } +} + +impl ReleaseTarget for CratesRelease { + fn release(&self) -> Result<(), String> { + if self.dry_run { + println!("cargo release crates --dry-run is currently not supported. See TODO in workflow file."); + return Ok(()); + } + + println!("Finding crates to publish to crates.io..."); + + // Get the workspace root and manifest map + let workspace_root = crate::crates_resolver::find_workspace_root() + .map_err(|e| format!("Failed to find workspace root: {}", e))?; + let manifest_map = crate::crates_resolver::get_crate_manifest_map(&workspace_root) + .map_err(|e| format!("Failed to get crate manifest map: {}", e))?; + + // TODO(bfops): we could pass manifest_map into get_crates_to_publish and then it wouldn't need to compute workspace_root or manifest_map again + // alternatively, it could return richer data (e.g. including the crate path) and then we wouldn't need to compute manifest_map here + let crates = crate::crates_resolver::get_crates_to_publish() + .map_err(|e| format!("Failed to find crates to publish: {}", e))?; + + println!("\nCrates to publish in order:"); + for crate_name in &crates { + println!(" - {}", crate_name); + } + + if self.dry_run { + println!("\nDRY RUN: No crates will be published"); + } + + println!("\nStarting publish process..."); + for crate_name in &crates { + self.publish_crate(crate_name, &manifest_map)?; + self.add_crate_owners(crate_name)?; + } + + println!("\nAll crates published successfully!"); + Ok(()) + } + + fn name(&self) -> &'static str { + "crates" + } +} diff --git a/tools/release/src/targets/csharp.rs b/tools/release/src/targets/csharp.rs new file mode 100644 index 00000000000..cd2ad121d55 --- /dev/null +++ b/tools/release/src/targets/csharp.rs @@ -0,0 +1,381 @@ +use crate::targets::util::print_command; +use crate::targets::ReleaseTarget; +use std::path::Path; +use std::process::Command; + +pub struct CSharpRelease { + pub version: String, + pub dry_run: bool, +} + +impl CSharpRelease { + pub fn new(version: String, dry_run: bool) -> Self { + Self { version, dry_run } + } +} + +/// Build the NuGet packages using dotnet pack (builds DLLs) +fn run_cargo_ci_dlls() -> Result<(), String> { + println!("\n=== Running `cargo ci dlls` ==="); + + let mut cmd = Command::new("cargo"); + cmd.args(["ci", "dlls"]); + print_command(&cmd); + let status = cmd + .status() + .map_err(|e| format!("Failed to run cargo ci dlls: {}", e))?; + if !status.success() { + return Err("Failed to run cargo ci dlls".to_string()); + } + Ok(()) +} + +/// Push all NuGet packages +fn push_nuget_packages(version: &str, dry_run: bool) -> Result<(), String> { + println!("\n=== Publishing NuGet Packages ==="); + + let packages = vec![ + format!( + "crates/bindings-csharp/BSATN.Runtime/bin/Release/SpacetimeDB.BSATN.Runtime.{}.nupkg", + version + ), + format!( + "crates/bindings-csharp/Runtime/bin/Release/SpacetimeDB.Runtime.{}.nupkg", + version + ), + format!("sdks/csharp/bin~/Release/SpacetimeDB.ClientSDK.{}.nupkg", version), + format!("sdks/csharp/bin~/Release/SpacetimeDB.ClientSDK.Godot.{}.nupkg", version), + ]; + + for package in &packages { + if !dry_run && !Path::new(package).exists() { + return Err(format!("Package not found: {}. Did dotnet pack succeed?", package)); + } + println!("Pushing package: {}", package); + push_nuget_package(package, dry_run)?; + } + + println!("Successfully pushed all NuGet packages"); + Ok(()) +} + +/// Push a single NuGet package to the registry +fn push_nuget_package(package_path: &str, dry_run: bool) -> Result<(), String> { + if dry_run { + println!("DRY RUN: Would push package: {}", package_path); + return Ok(()); + } + + // Get the NuGet API key from environment variable + let api_key = + std::env::var("NUGET_API_KEY").map_err(|_| "NUGET_API_KEY environment variable not set".to_string())?; + let mut cmd = Command::new("nuget"); + cmd.args([ + "push", + package_path, + "-Source", + "https://api.nuget.org/v3/index.json", + "-ApiKey", + &api_key, + "-SkipDuplicate", + ]); + print_command(&cmd); + let status = cmd + .status() + .map_err(|e| format!("Failed to execute nuget push: {}", e))?; + if !status.success() { + return Err("Failed to push NuGet package".to_string()); + } + Ok(()) +} + +/// Update Unity project with new DLLs +fn commit_csharp_dlls_for_unity(version: &str) -> Result<(), String> { + pub fn git_force_add(add_path: &str) -> Result<(), String> { + let prefix = if add_path.contains("*") { ":(glob)" } else { "" }; + let add_path = format!("{}{}", prefix, add_path); + let mut cmd = Command::new("git"); + cmd.args(["add", "-f", &add_path]); + print_command(&cmd); + let status = cmd + .status() + .map_err(|e| format!("Failed to force add git path: {}", e))?; + if !status.success() { + return Err("Failed to force add git path".to_string()); + } + Ok(()) + } + pub fn git_force_remove(path: &str) -> Result<(), String> { + let prefix = if path.contains("*") { ":(glob)" } else { "" }; + let path = format!("{}{}", prefix, path); + let mut cmd = Command::new("git"); + cmd.args(["rm", "-f", &path]); + print_command(&cmd); + let status = cmd + .status() + .map_err(|e| format!("Failed to force rm git path: {}", e))?; + if !status.success() { + return Err("Failed to force rm git path".to_string()); + } + Ok(()) + } + + println!("\n=== Updating Unity Project with New DLLs ==="); + + // Any meta files copied over by `cargo ci dlls` is a file we should keep. + git_force_add("sdks/csharp/packages/**/*.meta")?; + git_force_add("sdks/csharp/packages.meta")?; + git_force_add( + "sdks/csharp/packages/spacetimedb.bsatn.runtime/*/analyzers/dotnet/cs/SpacetimeDB.BSATN.Codegen.dll", + )?; + git_force_add("sdks/csharp/packages/spacetimedb.bsatn.runtime/*/lib/netstandard2.1/SpacetimeDB.BSATN.Runtime.dll")?; + + // Remove the gitignore file - Unity 6 will force-delete files that are listed in the gitignore + git_force_remove("sdks/csharp/packages/.gitignore")?; + + // Remove the net8.0 files for now because we don't need them + git_force_remove("sdks/csharp/packages/spacetimedb.bsatn.runtime/*/lib/net8.0.meta")?; + git_force_remove("sdks/csharp/packages/spacetimedb.bsatn.runtime/*/lib/net8.0/SpacetimeDB.BSATN.Runtime.dll.meta")?; + + // Materialize the LICENSE.txt symlink + let license_path = Path::new("sdks/csharp/LICENSE.txt"); + let target = std::fs::read_link(license_path).map_err(|e| format!("Failed to read LICENSE.txt symlink: {}", e))?; + // resolve the path relative to the link's directory, since it basically has to be a relative path + let target_path = license_path.parent().unwrap().join(&target); + let contents = std::fs::read(&target_path).map_err(|e| format!("Failed to read LICENSE.txt target file: {}", e))?; + std::fs::remove_file(license_path).map_err(|e| format!("Failed to remove LICENSE.txt symlink: {}", e))?; + std::fs::write(license_path, contents).map_err(|e| format!("Failed to write LICENSE.txt file: {}", e))?; + git_force_add("sdks/csharp/LICENSE.txt")?; + + // Print git status so we have an account of what has gone into this commit in the actions log + let mut cmd = Command::new("git"); + cmd.args(["status"]); + print_command(&cmd); + let status = cmd.status().map_err(|e| format!("Failed to run git status: {}", e))?; + if !status.success() { + return Err("Failed to run git status".to_string()); + } + + let mut cmd = Command::new("git"); + cmd.args(["commit", "-m", &format!("Update Unity SDK to version v{}", version)]); + print_command(&cmd); + let status = cmd.status().map_err(|e| format!("Failed to run git commit: {}", e))?; + if !status.success() { + return Err("Failed to run git commit".to_string()); + } + + println!("\nSuccessfully updated Unity project"); + Ok(()) +} + +/// Fetch the release/mirror/csharp branch (or create it if it doesn't exist) +fn fetch_mirror_branch(dry_run: bool) -> Result<(), String> { + println!("\n=== Publishing Unity SDK ==="); + + // Ensure git uses SSH instead of HTTPS (in case global config doesn't apply to submodule) + println!("Configuring git to use SSH..."); + + // Remove the checkout action's HTTPS URL rewriting (SSH -> HTTPS conversion) + let mut cmd = Command::new("git"); + cmd.args(["config", "--local", "--unset-all", "url.https://github.com/.insteadOf"]); + print_command(&cmd); + let _ = cmd.output(); + + // Set up SSH preference (HTTPS -> SSH conversion) + let mut cmd = Command::new("git"); + cmd.args([ + "config", + "--local", + "url.git@github.com:.insteadOf", + "https://github.com/", + ]); + print_command(&cmd); + let _ = cmd.output(); + + // Debug: show URL rewrite config + println!(" Git URL rewrite configuration:"); + let mut cmd = Command::new("git"); + cmd.args(["config", "--local", "--get-regexp", "url"]); + print_command(&cmd); + if let Ok(output) = cmd.output() { + let config = String::from_utf8_lossy(&output.stdout); + for line in config.lines() { + println!(" {}", line); + } + } + + println!("Fetching release/mirror/csharp branch..."); + + if dry_run { + println!("DRY RUN: Would execute:"); + println!(" git fetch origin release/mirror/csharp"); + return Ok(()); + } + + // Try to fetch the branch - it's okay if it doesn't exist yet + let mut cmd = Command::new("git"); + cmd.args(["fetch", "origin", "release/mirror/csharp"]); + print_command(&cmd); + let output = cmd + .output() + .map_err(|e| format!("Failed to execute git fetch: {}", e))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + + // Check if the error is because the branch doesn't exist + if stderr.contains("couldn't find remote ref") { + println!("Branch release/mirror/csharp doesn't exist yet - will create it on first push"); + return Ok(()); + } else { + return Err(format!("Failed to fetch release/mirror/csharp branch: {}", stderr)); + } + } + + println!("Successfully fetched release/mirror/csharp"); + Ok(()) +} + +/// Create subtree split for the C# SDK +fn create_subtree_split(dry_run: bool) -> Result<(), String> { + println!("Creating subtree split for sdks/csharp..."); + + if dry_run { + println!("DRY RUN: Would execute:"); + println!(" git subtree split --prefix=sdks/csharp -b release/mirror/csharp"); + return Ok(()); + } + + // Delete local branch if it exists + let mut cmd = Command::new("git"); + cmd.args(["branch", "-D", "release/mirror/csharp"]); + print_command(&cmd); + let _ = cmd.output(); + + // Build the git subtree split command + let mut cmd = Command::new("git"); + cmd.args([ + "subtree", + "split", + "--prefix=sdks/csharp", + "--onto", + "origin/release/mirror/csharp", + "-b", + "release/mirror/csharp", + ]); + print_command(&cmd); + let status = cmd + .status() + .map_err(|e| format!("Failed to execute git subtree split: {}", e))?; + if !status.success() { + return Err("Failed to create subtree split".to_string()); + } + + println!("Successfully created subtree split"); + Ok(()) +} + +/// Push to the Unity SDK repository as release/latest +fn push_to_unity_repo_latest(dry_run: bool) -> Result<(), String> { + println!("Pushing to Unity SDK repository (release/latest)..."); + + if dry_run { + println!("DRY RUN: Would execute:"); + println!(" git push -f git@github.com:clockworklabs/com.clockworklabs.spacetimedbsdk.git release/mirror/csharp:release/latest"); + return Ok(()); + } + + let mut cmd = Command::new("git"); + cmd.args([ + "push", + "-f", + "git@github.com:clockworklabs/com.clockworklabs.spacetimedbsdk.git", + "release/mirror/csharp:release/latest", + ]); + print_command(&cmd); + let status = cmd + .status() + .map_err(|e| format!("Failed to execute git push to Unity repo: {}", e))?; + + if !status.success() { + return Err("Failed to push to Unity SDK repository as release/latest".to_string()); + } + + println!("Successfully pushed to Unity SDK repository as release/latest"); + Ok(()) +} + +/// Push to the Unity SDK repository as a version tag +fn push_to_unity_repo_tag(version: &str, dry_run: bool) -> Result<(), String> { + let tag_name = format!("v{}", version); + println!("Pushing to Unity SDK repository (tag: {})...", tag_name); + + if dry_run { + println!("DRY RUN: Would execute:"); + println!(" git push git@github.com:clockworklabs/com.clockworklabs.spacetimedbsdk.git release/mirror/csharp:refs/tags/{}", tag_name); + return Ok(()); + } + + let mut cmd = Command::new("git"); + cmd.args([ + "push", + "git@github.com:clockworklabs/com.clockworklabs.spacetimedbsdk.git", + &format!("release/mirror/csharp:refs/tags/{}", tag_name), + ]); + print_command(&cmd); + let status = cmd + .status() + .map_err(|e| format!("Failed to execute git push tag to Unity repo: {}", e))?; + + if !status.success() { + return Err(format!("Failed to push tag {} to Unity SDK repository", tag_name)); + } + + println!("Successfully pushed tag {} to Unity SDK repository", tag_name); + Ok(()) +} + +impl ReleaseTarget for CSharpRelease { + fn release(&self) -> Result<(), String> { + println!("=== Releasing C# SDK (NuGet + Unity) ==="); + println!("Version: {}", self.version); + + if self.dry_run { + println!("\n*** DRY RUN MODE ***\n"); + } + + // Strip the leading 'v' from the version string + let version = self.version.strip_prefix('v').unwrap_or(&self.version); + + run_cargo_ci_dlls()?; + push_nuget_packages(version, self.dry_run)?; + + // TODO: It might be worth combining these 3 function calls (e.g. commit_unity_dlls) + commit_csharp_dlls_for_unity(version)?; + fetch_mirror_branch(self.dry_run)?; + create_subtree_split(self.dry_run)?; + + // Note: We skip pushing to the public repo's origin since GitHub Actions + // doesn't have permission. We push directly to the Unity SDK repo instead. + push_to_unity_repo_latest(self.dry_run)?; + push_to_unity_repo_tag(version, self.dry_run)?; + + println!("\n=== C# SDK Release Complete ==="); + if !self.dry_run { + println!("NuGet packages published:"); + println!(" - SpacetimeDB.BSATN.Runtime.{}", version); + println!(" - SpacetimeDB.Runtime.{}", version); + println!(" - SpacetimeDB.ClientSDK.{}", version); + println!(" - SpacetimeDB.ClientSDK.Godot.{}", version); + println!("\nUnity SDK published:"); + println!(" - Branch: release/latest"); + println!(" - Tag: v{}", version); + println!(" - Repository: clockworklabs/com.clockworklabs.spacetimedbsdk"); + } + + Ok(()) + } + + fn name(&self) -> &'static str { + "csharp" + } +} diff --git a/tools/release/src/targets/docker.rs b/tools/release/src/targets/docker.rs new file mode 100644 index 00000000000..2028f33624e --- /dev/null +++ b/tools/release/src/targets/docker.rs @@ -0,0 +1,211 @@ +use crate::targets::{util, ReleaseTarget}; +use std::net::{SocketAddr, TcpStream}; +use std::process::Command; +use std::thread; +use std::time::{Duration, Instant}; + +pub struct DockerRelease { + pub version: String, + pub dry_run: bool, +} + +struct LocalRegistryGuard { + container_name: String, +} + +impl LocalRegistryGuard { + fn start(container_name: &str, host_port: u16) -> Result { + println!("Starting local Docker registry for dry-run..."); + + // Remove the container before creating, just in case there's a lingering copy from a previous run + let mut cmd = Command::new("docker"); + cmd.args(["rm", "-f", container_name]); + util::print_command(&cmd); + let _ = cmd.status(); + + // This registry:2 bit below just means that we're pulling version 2 from the official + // docker "registry" image + let mut cmd = Command::new("docker"); + cmd.args([ + "run", + "-d", + "--name", + container_name, + "-p", + &format!("{}:5000", host_port), + "registry:2", + ]); + util::print_command(&cmd); + let status = cmd + .status() + .map_err(|e| format!("Failed to start local docker registry: {}", e))?; + + if !status.success() { + return Err("Failed to start local docker registry".to_string()); + } + + Self::wait_until_ready(host_port)?; + + Ok(Self { + container_name: container_name.to_string(), + }) + } + + fn wait_until_ready(host_port: u16) -> Result<(), String> { + let addr: SocketAddr = format!("127.0.0.1:{}", host_port) + .parse() + .map_err(|e| format!("Failed to parse registry address: {}", e))?; + + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() { + return Ok(()); + } + thread::sleep(Duration::from_millis(200)); + } + + Err(format!( + "Timed out waiting for local docker registry to start at {}", + addr + )) + } +} + +impl Drop for LocalRegistryGuard { + fn drop(&mut self) { + println!("Stopping local Docker registry..."); + let mut cmd = Command::new("docker"); + cmd.args(["rm", "-f", &self.container_name]); + util::print_command(&cmd); + let _ = cmd.status(); + } +} + +impl DockerRelease { + pub fn new(version: String, dry_run: bool) -> Self { + Self { version, dry_run } + } + + /// Verify that docker is installed and user is logged in + fn verify_docker_login(&self) -> Result<(), String> { + println!("Verifying Docker login..."); + + let mut cmd = Command::new("docker"); + cmd.arg("info"); + util::print_command(&cmd); + let output = cmd + .output() + .map_err(|e| format!("Failed to run docker command. Is Docker installed? Error: {}", e))?; + + if !output.status.success() { + return Err("Docker is not running or you are not logged in. Please run 'docker login' first.".to_string()); + } + + println!("Docker is available and running."); + Ok(()) + } + + /// Build the Docker image for multiple platforms and push to DockerHub + fn build_and_push_image(&self, image_repo: &String) -> Result<(), String> { + println!("\nBuilding Docker image for version: {}", self.version); + println!("Building for platforms: linux/amd64, linux/arm64"); + + let version_tag = format!("{}:{}", image_repo, self.version); + + // Build and push the multi-platform image + let mut cmd = Command::new("docker"); + cmd.args([ + "buildx", + "build", + "--platform", + "linux/amd64,linux/arm64", + "-t", + &version_tag, + "--push", + ".", + ]); + util::print_command(&cmd); + let status = cmd + .status() + .map_err(|e| format!("Failed to execute docker buildx build: {}", e))?; + + if !status.success() { + return Err(format!( + "Failed to build and push Docker image for version {}", + self.version + )); + } + + println!("Successfully built and pushed {}", version_tag); + Ok(()) + } + + /// Tag the version as :latest + fn tag_as_latest(&self, image_repo: &String) -> Result<(), String> { + println!("\nTagging version {} as :latest", self.version); + + let mut cmd = Command::new("docker"); + cmd.args([ + "buildx", + "imagetools", + "create", + "-t", + &format!("{}:latest", image_repo), + &format!("{}:{}", image_repo, self.version), + ]); + util::print_command(&cmd); + let status = cmd + .status() + .map_err(|e| format!("Failed to execute docker buildx imagetools: {}", e))?; + + if !status.success() { + return Err("Failed to tag image as :latest".to_string()); + } + + println!("Successfully tagged {}:latest", image_repo); + Ok(()) + } +} + +impl ReleaseTarget for DockerRelease { + fn release(&self) -> Result<(), String> { + let docker_repo_url = if self.dry_run { + "localhost:5000/clockworklabs/spacetime" + } else { + "clockworklabs/spacetime" + } + .to_string(); + + println!("=== Releasing Docker Container ==="); + println!("Version: {}", self.version); + println!("Target: {}", &docker_repo_url); + + let _local_registry_guard = if self.dry_run { + let container_name = format!("spacetimedb-release-local-registry-{}", std::process::id()); + Some(LocalRegistryGuard::start(&container_name, 5000)?) + } else { + None + }; + + if self.dry_run { + println!("\n*** DRY RUN MODE - Skipping docker login ***\n"); + } else { + // If we're pushing to a real repository, verify that we're logged in + self.verify_docker_login()?; + } + + self.build_and_push_image(&docker_repo_url)?; + self.tag_as_latest(&docker_repo_url)?; + + println!("\n=== Docker Release Complete ==="); + println!("Images published:"); + println!(" - {}:{}", docker_repo_url, self.version); + println!(" - {}:latest", docker_repo_url); + + Ok(()) + } + + fn name(&self) -> &'static str { + "docker" + } +} diff --git a/tools/release/src/targets/mod.rs b/tools/release/src/targets/mod.rs new file mode 100644 index 00000000000..2dc299ae357 --- /dev/null +++ b/tools/release/src/targets/mod.rs @@ -0,0 +1,12 @@ +pub mod cpp; +pub mod crates; +pub mod csharp; +pub mod docker; +pub mod npm; +pub mod util; + +/// Common trait for all release targets +pub trait ReleaseTarget { + fn release(&self) -> Result<(), String>; + fn name(&self) -> &'static str; +} diff --git a/tools/release/src/targets/npm.rs b/tools/release/src/targets/npm.rs new file mode 100644 index 00000000000..aa1dd45ebaa --- /dev/null +++ b/tools/release/src/targets/npm.rs @@ -0,0 +1,112 @@ +use crate::targets::{util, ReleaseTarget}; +use std::path::Path; +use std::process::Command; + +pub struct NpmRelease { + pub version: String, + pub dry_run: bool, +} + +impl NpmRelease { + pub fn new(version: String, dry_run: bool) -> Self { + Self { version, dry_run } + } + + /// Verify that pnpm is installed + fn verify_pnpm(&self) -> Result<(), String> { + println!("Verifying pnpm is installed..."); + + let mut cmd = Command::new("pnpm"); + cmd.arg("--version"); + util::print_command(&cmd); + let output = cmd + .output() + .map_err(|e| format!("Failed to run pnpm command. Is pnpm installed? Error: {}", e))?; + if !output.status.success() { + return Err("pnpm is not available. Please install pnpm (npm install -g pnpm).".to_string()); + } + + let version = String::from_utf8_lossy(&output.stdout); + println!("pnpm version: {}", version.trim()); + Ok(()) + } + + /// Publish the package using pnpm + fn publish_package(&self) -> Result<(), String> { + println!("\nPublishing TypeScript SDK to npm..."); + + let sdk_dir = Path::new("sdks/typescript"); + + println!("Running pnpm publish in {}...", sdk_dir.display()); + println!("Note: prepublishOnly script will build, test, and size up the package"); + + // pnpm install first + let mut cmd = Command::new("pnpm"); + cmd.args(["install"]); + cmd.current_dir(sdk_dir); + util::print_command(&cmd); + let status = cmd + .status() + .map_err(|e| format!("Failed to execute pnpm install: {}", e))?; + if !status.success() { + return Err("Failed to run pnpm install".to_string()); + } + + let mut cmd = Command::new("pnpm"); + // The publish step here runs from a directory that doesn't have a clean git worktree + // so we disable pnpm's default git cleanliness/branch checks otherwise this will fail. + // We need --no-git-checks because otherwise the workflow will complain that we're not + // on the main/master branch (we're in a detached HEAD state at this point). + // ERR_PNPM_GIT_UNKNOWN_BRANCH The Git HEAD may not attached to any branch, but your "publish-branch" is set to "master|main". + if self.dry_run { + cmd.args(["publish", "--dry-run", "--no-git-checks"]); + } else { + cmd.args(["publish", "--no-git-checks"]); + } + cmd.current_dir(sdk_dir); + util::print_command(&cmd); + let status = cmd + .status() + .map_err(|e| format!("Failed to execute pnpm publish: {}", e))?; + + if !status.success() { + return Err("Failed to publish package to npm".to_string()); + } + + println!( + "Successfully{} published @clockworklabs/spacetimedb-sdk@{}", + if self.dry_run { " dry-run" } else { "" }, + self.version + ); + println!("See our package here: https://www.npmjs.com/package/spacetimedb"); + + Ok(()) + } +} + +impl ReleaseTarget for NpmRelease { + fn release(&self) -> Result<(), String> { + println!("=== Releasing TypeScript SDK to NPM ==="); + println!("Version: {}", self.version); + println!("Package: @clockworklabs/spacetimedb-sdk"); + + if self.dry_run { + println!("\n*** DRY RUN MODE - Package will be built but NOT published ***\n"); + } + + self.verify_pnpm()?; + self.publish_package()?; + + println!("\n=== NPM Release Complete ==="); + if !self.dry_run { + println!("Package published: @clockworklabs/spacetimedb-sdk@{}", self.version); + println!("Dist-tag 'latest' set to version {}", self.version); + } + + Ok(()) + } + + fn name(&self) -> &'static str { + "npm" + } +} diff --git a/tools/release/src/targets/util.rs b/tools/release/src/targets/util.rs new file mode 100644 index 00000000000..f86d446682e --- /dev/null +++ b/tools/release/src/targets/util.rs @@ -0,0 +1,19 @@ +use std::process::Command; + +// Note: This isn't perfectly correct, and may look a little wrong if the args have spaces, quotes, +// or other characters that need escaping, but it's a decent representation. +pub fn print_command(cmd: &Command) { + // program() is Option, args() is an iterator of OsStr + let program = cmd.get_program().to_string_lossy(); + let args = cmd + .get_args() + .map(|a| a.to_string_lossy()) + .collect::>() + .join(" "); + let dir = cmd.get_current_dir().map(|d| d.to_string_lossy().into_owned()); + + match dir { + Some(d) => println!("$> {} {} (cwd = {})", program, args, d), + None => println!("$> {} {}", program, args), + } +} From 8376b35f1394fbb3197876cffbc37a6b234811d2 Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Mon, 15 Jun 2026 17:08:23 -0400 Subject: [PATCH 2/2] Allow release CLI progress output --- tools/release/src/main.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/release/src/main.rs b/tools/release/src/main.rs index f27dc2898f0..024b330d36c 100644 --- a/tools/release/src/main.rs +++ b/tools/release/src/main.rs @@ -1,3 +1,5 @@ +#![allow(clippy::disallowed_macros)] + use clap::{Parser, Subcommand}; mod crates_resolver;