diff --git a/.github/actions/keynote-bench-setup/action.yml b/.github/actions/keynote-bench-setup/action.yml index 8fa03d744f7..424c1a55a7a 100644 --- a/.github/actions/keynote-bench-setup/action.yml +++ b/.github/actions/keynote-bench-setup/action.yml @@ -1,15 +1,6 @@ name: Keynote Bench Setup description: Shared setup for the keynote benchmark job -inputs: - public_root: - description: Path to the public SpacetimeDB checkout in the caller workspace - required: false - default: . - rust_cache_workspaces: - description: Workspace paths passed to Swatinem/rust-cache - required: true - runs: using: composite steps: @@ -18,41 +9,3 @@ runs: - name: Set default rust toolchain shell: bash run: rustup default $(rustup show active-toolchain | cut -d' ' -f1) - - - name: Set up sccache - uses: mozilla-actions/sccache-action@v0.0.10 - - - name: Enable sccache - shell: bash - run: | - echo "SCCACHE_GHA_ENABLED=true" >>"$GITHUB_ENV" - echo "RUSTC_WRAPPER=sccache" >>"$GITHUB_ENV" - - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 - with: - workspaces: ${{ inputs.rust_cache_workspaces }} - shared-key: spacetimedb - save-if: false - prefix-key: v1 - - - name: Check v8 outputs - shell: bash - run: | - find "${CARGO_TARGET_DIR}"/ -type f | grep '[/_]v8' || true - if ! [ -f "${CARGO_TARGET_DIR}"/release/gn_out/obj/librusty_v8.a ]; then - echo "Could not find v8 output file librusty_v8.a; rebuilding manually." - cargo clean --release -p v8 || true - cargo build --release -p v8 - fi - working-directory: ${{ inputs.public_root }} - - - name: Build public keynote benchmark binaries - shell: bash - run: cargo build --release -p spacetimedb-cli -p spacetimedb-standalone - working-directory: ${{ inputs.public_root }} - - - name: Build TypeScript SDK - shell: bash - run: pnpm build - working-directory: ${{ inputs.public_root }}/crates/bindings-typescript diff --git a/.github/actions/restore-smoketest-build/action.yml b/.github/actions/restore-smoketest-build/action.yml new file mode 100644 index 00000000000..f6fe717032c --- /dev/null +++ b/.github/actions/restore-smoketest-build/action.yml @@ -0,0 +1,46 @@ +name: Restore smoketest build +description: Restore prebuilt Linux smoketest support binaries from a build artifact. + +inputs: + artifact-name: + description: Name of the smoketest build artifact to download. + required: true + require-standalone: + description: Require the artifact to contain the standalone server. + required: false + default: "true" + schema-extractor-path: + description: Optional schema extractor executable used instead of standalone. + required: false + default: "" + +runs: + using: composite + steps: + - name: Download smoketest build + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: ${{ runner.temp }}/smoketest-build + + - name: Extract smoketest support files + shell: bash + run: tar -xzf "${RUNNER_TEMP}/smoketest-build/smoketest-support.tar.gz" + + - name: Configure prebuilt SpacetimeDB binaries + shell: bash + env: + REQUIRE_STANDALONE: ${{ inputs.require-standalone }} + SCHEMA_EXTRACTOR_PATH: ${{ inputs.schema-extractor-path }} + run: | + test -x "${CARGO_TARGET_DIR}/release/spacetimedb-cli" + if [[ "${REQUIRE_STANDALONE}" == "true" ]]; then + test -x "${CARGO_TARGET_DIR}/release/spacetimedb-standalone" + fi + if [[ -n "${SCHEMA_EXTRACTOR_PATH}" ]]; then + test -x "${SCHEMA_EXTRACTOR_PATH}" + echo "SPACETIMEDB_SCHEMA_EXTRACTOR=${SCHEMA_EXTRACTOR_PATH}" >>"$GITHUB_ENV" + fi + ln -sf spacetimedb-cli "${CARGO_TARGET_DIR}/release/spacetime" + echo "${CARGO_TARGET_DIR}/release" >>"$GITHUB_PATH" + echo "SPACETIME_PREBUILT_BINARIES=1" >>"$GITHUB_ENV" diff --git a/.github/actions/setup-pnpm/action.yml b/.github/actions/setup-pnpm/action.yml index 2bf9ecdbdec..f7155ca29a3 100644 --- a/.github/actions/setup-pnpm/action.yml +++ b/.github/actions/setup-pnpm/action.yml @@ -1,14 +1,30 @@ name: Set up pnpm description: Install pnpm and configure repository package-age policy before any install inputs: + version: + description: pnpm version to install. If omitted, package_json_file determines the version. + required: false + default: "" package_json_file: description: package.json file used to determine the pnpm version required: false default: package.json + node_version: + description: Node.js version to install with pnpm dependency caching; omitted when Node.js is already configured + required: false + default: "" + cache_dependency_path: + description: Lockfile used by setup-node's pnpm store cache + required: false + default: pnpm-lock.yaml run_install: description: Run pnpm install after configuring pnpm required: false default: "false" + install_args: + description: Additional arguments passed to pnpm install + required: false + default: "" working_directory: description: Directory to run pnpm install in required: false @@ -17,9 +33,24 @@ runs: using: composite steps: - uses: pnpm/action-setup@v4 + if: inputs.version != '' + with: + version: ${{ inputs.version }} + + - uses: pnpm/action-setup@v4 + if: inputs.version == '' with: package_json_file: ${{ inputs.package_json_file }} + # setup-node's pnpm cache requires the pnpm executable to exist first. + - name: Set up Node.js + if: inputs.node_version != '' + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node_version }} + cache: pnpm + cache-dependency-path: ${{ inputs.cache_dependency_path }} + - name: Configure pnpm package age policy shell: bash run: pnpm config set --global minimumReleaseAge 1440 @@ -28,4 +59,4 @@ runs: if: inputs.run_install == 'true' shell: bash working-directory: ${{ inputs.working_directory }} - run: pnpm install + run: pnpm install ${{ inputs.install_args }} diff --git a/.github/actions/smoketest-build/action.yml b/.github/actions/smoketest-build/action.yml new file mode 100644 index 00000000000..fde5d085b65 --- /dev/null +++ b/.github/actions/smoketest-build/action.yml @@ -0,0 +1,54 @@ +name: Build smoketest artifacts +description: Build and upload the nextest archive and smoketest runtime artifacts. + +inputs: + artifact-name: + description: Name of the workflow artifact to upload. + required: true + +runs: + using: composite + steps: + - name: Build smoketest dependencies and archive test binaries + shell: bash + run: | + cargo ci smoketests archive --archive-file smoketest-nextest.tar.zst + + if [[ "${RUNNER_OS}" == "Windows" ]]; then + executable_suffix=".exe" + else + executable_suffix="" + fi + + shopt -s nullglob + precompiled_modules=(target/wasm32-unknown-unknown/release/smoketest_module_*.wasm) + if (( ${#precompiled_modules[@]} == 0 )); then + echo "No precompiled smoketest modules were produced." + exit 1 + fi + + tar -czf smoketest-support.tar.gz \ + crates/bindings-typescript/dist \ + "target/debug/ci${executable_suffix}" \ + "target/release/spacetimedb-cli${executable_suffix}" \ + "target/release/spacetimedb-standalone${executable_suffix}" \ + "${precompiled_modules[@]}" + + - name: Upload Cargo timings + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact-name }}-cargo-timings + path: ${{ env.CARGO_TARGET_DIR }}/cargo-timings + if-no-files-found: warn + overwrite: true + + - name: Upload smoketest build + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: | + smoketest-nextest.tar.zst + smoketest-support.tar.gz + if-no-files-found: error + overwrite: true diff --git a/.github/actions/smoketest-run-partition/action.yml b/.github/actions/smoketest-run-partition/action.yml new file mode 100644 index 00000000000..47600945689 --- /dev/null +++ b/.github/actions/smoketest-run-partition/action.yml @@ -0,0 +1,53 @@ +name: Run smoketest partition +description: Download a smoketest build and run one nextest hash partition. + +inputs: + artifact-name: + description: Name of the workflow artifact to download. + required: true + partition: + description: One-based hash partition number. + required: true + partition-total: + description: Total number of hash partitions. + required: true + +runs: + using: composite + steps: + - name: Download smoketest build + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + + - name: Extract smoketest support files + shell: bash + run: | + tar -xzf smoketest-support.tar.gz + echo "SPACETIME_PREBUILT_TYPESCRIPT_SDK=1" >>"$GITHUB_ENV" + + - name: Run smoketest partition (Linux) + if: runner.os == 'Linux' + shell: bash + env: + SPACETIMEDB_WORKSPACE_ROOT: ${{ github.workspace }} + run: | + source ~/emsdk/emsdk_env.sh + ./target/debug/ci smoketests run-archive \ + --archive-file smoketest-nextest.tar.zst \ + -- \ + --test-threads=1 \ + --partition hash:${{ inputs.partition }}/${{ inputs.partition-total }} + + - name: Run smoketest partition (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + SPACETIMEDB_WORKSPACE_ROOT: ${{ github.workspace }} + run: | + & "$env:USERPROFILE\emsdk\emsdk_env.ps1" | Out-Null + & .\target\debug\ci.exe smoketests run-archive ` + --archive-file smoketest-nextest.tar.zst ` + -- ` + --test-threads=1 ` + --partition "hash:${{ inputs.partition }}/${{ inputs.partition-total }}" diff --git a/.github/actions/smoketest-setup/action.yml b/.github/actions/smoketest-setup/action.yml new file mode 100644 index 00000000000..02cebf29ede --- /dev/null +++ b/.github/actions/smoketest-setup/action.yml @@ -0,0 +1,102 @@ +name: Set up smoketests +description: Set up Rust and the language toolchains used by the smoketests. + +inputs: + runtime-dependencies: + description: Install dependencies needed while executing smoketests. + required: false + default: "true" + rust-cache-enabled: + description: Restore and save the Rust cache. + required: false + default: "true" + rust-cache-shared-key: + description: Shared key used for the Rust cache. + required: false + default: spacetimedb + +runs: + using: composite + steps: + - uses: dsherret/rust-toolchain-file@v1 + + - name: Set default Rust toolchain + shell: bash + run: rustup default "$(rustup show active-toolchain | cut -d' ' -f1)" + + - name: Cache Rust dependencies + if: inputs.rust-cache-enabled == 'true' + uses: Swatinem/rust-cache@v2 + with: + workspaces: ${{ github.workspace }} + shared-key: ${{ inputs.rust-cache-shared-key }} + cache-targets: true + cache-bin: false + cache-on-failure: false + cache-all-crates: false + cache-workspace-crates: false + prefix-key: v1 + + - name: Set up .NET + if: inputs.runtime-dependencies == 'true' + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + # Install Emscripten for C++ module compilation tests. + - name: Install Emscripten (Linux) + if: inputs.runtime-dependencies == 'true' && runner.os == 'Linux' + shell: bash + run: | + git clone https://github.com/emscripten-core/emsdk.git ~/emsdk + cd ~/emsdk + ./emsdk install 4.0.21 + ./emsdk activate 4.0.21 + + - name: Install Emscripten (Windows) + if: inputs.runtime-dependencies == 'true' && runner.os == 'Windows' + shell: pwsh + run: | + git clone https://github.com/emscripten-core/emsdk.git $env:USERPROFILE\emsdk + cd $env:USERPROFILE\emsdk + .\emsdk install 4.0.21 + .\emsdk activate 4.0.21 + + - name: Install psql (Windows) + if: inputs.runtime-dependencies == 'true' && runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + choco install psql -y --no-progress + # Chocolatey does not reliably fail the step when installation fails. + Get-Command psql + + - name: Update .NET workloads (Windows) + if: inputs.runtime-dependencies == 'true' && runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + + cd modules + # The sdk-manifests on windows-latest need to be updated before installing workloads. + dotnet workload config --update-mode manifests + dotnet workload update --from-previous-sdk + # Explicitly install wasi-experimental for .NET 8 SDK. + $sdk8Json = '{"sdk":{"version":"8.0.400","rollForward":"latestFeature"}}' + $sdk8Json | Out-File -FilePath global.json -Encoding utf8 + dotnet workload install wasi-experimental + Remove-Item global.json + + - name: Override NuGet packages + if: inputs.runtime-dependencies == 'true' + shell: bash + run: | + dotnet pack -c Release crates/bindings-csharp/BSATN.Runtime + dotnet pack -c Release crates/bindings-csharp/Runtime + cd sdks/csharp + ./tools~/write-nuget-config.sh ../.. + + - name: Install cargo-nextest + uses: taiki-e/install-action@nextest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 038334613ec..6a946fc75d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,7 @@ jobs: runs-on: ubuntu-latest outputs: skip: ${{ steps.compare.outputs.skip }} + source_sha: ${{ steps.source.outputs.sha }} steps: - name: Checkout merge queue commit uses: actions/checkout@v4 @@ -70,175 +71,260 @@ jobs: echo "Merge queue commit ${GITHUB_SHA} differs from PR #${pr_number} head ${pr_head_sha}; running CI normally." fi - smoketests: - needs: [merge_queue_noop, lints] - if: ${{ needs.merge_queue_noop.outputs.skip != 'true' }} - name: Smoketests (${{ matrix.name }}) - strategy: - matrix: - include: - - name: Linux - runner: spacetimedb-new-runner-2 - - name: Windows - runner: spacetimedb-windows-runner - runs-on: ${{ matrix.runner }} - timeout-minutes: 120 - env: - CARGO_TARGET_DIR: ${{ github.workspace }}/target - RUST_BACKTRACE: full - SPACETIMEDB_CPP_DIR: ${{ github.workspace }}/crates/bindings-cpp - steps: - - name: Find Git ref + - name: Resolve source revision + id: source env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | - PR_NUMBER="${{ github.event.inputs.pr_number || null }}" - if test -n "${PR_NUMBER}"; then - GIT_REF="$( gh pr view --repo clockworklabs/SpacetimeDB $PR_NUMBER --json headRefName --jq .headRefName )" + set -euo pipefail + + pr_number="${{ github.event.inputs.pr_number || null }}" + if [[ -n "${pr_number}" ]]; then + source_sha="$(gh pr view --repo clockworklabs/SpacetimeDB "${pr_number}" --json headRefOid --jq .headRefOid)" else - GIT_REF="${{ github.ref }}" + source_sha="${GITHUB_SHA}" fi - echo "GIT_REF=${GIT_REF}" >>"$GITHUB_ENV" + if [[ -z "${source_sha}" || "${source_sha}" == "null" ]]; then + echo "Could not resolve the source revision." + exit 1 + fi + + echo "sha=${source_sha}" >>"$GITHUB_OUTPUT" + + typescript_sdk_build: + needs: [merge_queue_noop] + if: ${{ needs.merge_queue_noop.outputs.skip != 'true' }} + name: Build TypeScript SDK + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: - name: Checkout sources uses: actions/checkout@v4 with: - ref: ${{ env.GIT_REF }} + ref: ${{ needs.merge_queue_noop.outputs.source_sha }} - - uses: dsherret/rust-toolchain-file@v1 - - name: Set default rust toolchain - run: rustup default $(rustup show active-toolchain | cut -d' ' -f1) + - name: Restore TypeScript SDK build + id: typescript-sdk-cache + uses: actions/cache@v4 + with: + path: crates/bindings-typescript/dist + key: typescript-sdk-v1-node22-pnpm10.16.0-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json', '.npmrc', 'crates/bindings-typescript/package.json', 'crates/bindings-typescript/.npmrc', 'crates/bindings-typescript/tsconfig*.json', 'crates/bindings-typescript/tsup.config.ts', 'crates/bindings-typescript/src/**') }} - - name: Set up sccache - uses: mozilla-actions/sccache-action@v0.0.10 + - name: Set up pnpm for TypeScript SDK build + if: steps.typescript-sdk-cache.outputs.cache-hit != 'true' + uses: ./.github/actions/setup-pnpm + with: + node_version: 22 + + - name: Build TypeScript SDK + if: steps.typescript-sdk-cache.outputs.cache-hit != 'true' + run: | + pnpm --filter spacetimedb... install --frozen-lockfile + pnpm --filter spacetimedb build - - name: Enable sccache + - name: Validate TypeScript SDK build shell: bash run: | - echo "SCCACHE_GHA_ENABLED=true" >>"$GITHUB_ENV" - echo "RUSTC_WRAPPER=sccache" >>"$GITHUB_ENV" + typescript_sdk_dist=crates/bindings-typescript/dist + for typescript_sdk_artifact in \ + "$typescript_sdk_dist/index.mjs" \ + "$typescript_sdk_dist/index.d.ts" \ + "$typescript_sdk_dist/server/index.mjs" \ + "$typescript_sdk_dist/server/index.d.ts" + do + if [[ ! -f "$typescript_sdk_artifact" ]]; then + echo "Prebuilt TypeScript SDK artifact is missing: $typescript_sdk_artifact" + exit 1 + fi + done - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 + - name: Upload TypeScript SDK build + uses: actions/upload-artifact@v4 with: - workspaces: ${{ github.workspace }} - shared-key: spacetimedb - cache-on-failure: false - cache-all-crates: true - cache-workspace-crates: true - prefix-key: v1 - - - uses: actions/setup-dotnet@v4 + name: typescript-sdk-dist + path: crates/bindings-typescript/dist + if-no-files-found: error + overwrite: true + + smoketest_build_linux: + needs: [merge_queue_noop, typescript_sdk_build] + if: ${{ needs.merge_queue_noop.outputs.skip != 'true' }} + name: Build smoketests (Linux) + runs-on: spacetimedb-new-runner-2 + timeout-minutes: 120 + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/target + RUST_BACKTRACE: full + SPACETIMEDB_CPP_DIR: ${{ github.workspace }}/crates/bindings-cpp + steps: + - name: Checkout sources + uses: actions/checkout@v4 with: - global-json-file: global.json + ref: ${{ needs.merge_queue_noop.outputs.source_sha }} - # nodejs and pnpm are required for the typescript quickstart smoketest - - name: Set up Node.js - uses: actions/setup-node@v4 + - name: Download TypeScript SDK build + uses: actions/download-artifact@v4 with: - node-version: 18 + name: typescript-sdk-dist + path: crates/bindings-typescript/dist - - uses: ./.github/actions/setup-pnpm + - uses: ./.github/actions/smoketest-setup with: - run_install: true + runtime-dependencies: "false" + rust-cache-enabled: "false" + + - uses: ./.github/actions/smoketest-build + with: + artifact-name: smoketest-build-Linux + + smoketest_build_windows: + needs: [merge_queue_noop, typescript_sdk_build] + if: ${{ needs.merge_queue_noop.outputs.skip != 'true' }} + name: Build smoketests (Windows) + runs-on: spacetimedb-windows-runner + timeout-minutes: 120 + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/target + # Keep cross-crate ThinLTO for the Linux build consumed by benchmarks, but + # skip it for Windows smoketest binaries where build latency matters more. + CARGO_PROFILE_RELEASE_LTO: "false" + RUST_BACKTRACE: full + SPACETIMEDB_CPP_DIR: ${{ github.workspace }}/crates/bindings-cpp + steps: + - name: Checkout sources + uses: actions/checkout@v4 + with: + ref: ${{ needs.merge_queue_noop.outputs.source_sha }} - # Install emscripten for C++ module compilation tests. - - name: Install emscripten (Linux) - if: runner.os == 'Linux' + - name: Download TypeScript SDK build + uses: actions/download-artifact@v4 + with: + name: typescript-sdk-dist + path: crates/bindings-typescript/dist + + - uses: ./.github/actions/smoketest-setup + with: + runtime-dependencies: "false" + rust-cache-enabled: "true" + rust-cache-shared-key: smoketest-build-dependencies + + - name: Resolve V8 version + id: v8-version shell: bash run: | - git clone https://github.com/emscripten-core/emsdk.git ~/emsdk - cd ~/emsdk - ./emsdk install 4.0.21 - ./emsdk activate 4.0.21 + v8_version="$(sed -n 's/^v8 = "=\([^"]*\)"$/\1/p' Cargo.toml)" + test -n "${v8_version}" + echo "v8=${v8_version}" >> "${GITHUB_OUTPUT}" - - name: Install emscripten (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - git clone https://github.com/emscripten-core/emsdk.git $env:USERPROFILE\emsdk - cd $env:USERPROFILE\emsdk - .\emsdk install 4.0.21 - .\emsdk activate 4.0.21 + - name: Restore native V8 library + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/spacetimedb-native-cache/rusty-v8/rusty_v8.lib.gz + key: native-v1-rusty-v8-${{ steps.v8-version.outputs.v8 }} - - name: Install psql (Windows) - if: runner.os == 'Windows' + - name: Prepare native V8 library shell: pwsh + env: + V8_VERSION: ${{ steps.v8-version.outputs.v8 }} run: | - # Fail properly if any individual command fails $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true - choco install psql -y --no-progress - # Check for existence, since `choco` doesn't seem to fail the step if it fails to install.. - # See https://github.com/clockworklabs/SpacetimeDB/pull/4399 for more background. - Get-Command psql + $cacheDir = Join-Path '${{ runner.temp }}' 'spacetimedb-native-cache/rusty-v8' + $archive = Join-Path $cacheDir 'rusty_v8.lib.gz' + New-Item -ItemType Directory -Force -Path $cacheDir | Out-Null + + if (-not (Test-Path $archive)) { + $url = "https://github.com/denoland/rusty_v8/releases/download/v$env:V8_VERSION/rusty_v8_release_x86_64-pc-windows-msvc.lib.gz" + $download = "$archive.tmp" + Remove-Item -Force -ErrorAction SilentlyContinue $download + & curl.exe --fail --location --retry 3 --output $download $url + Move-Item -Force $download $archive + } - - name: Update dotnet workloads - if: runner.os == 'Windows' - run: | - # Fail properly if any individual command fails - $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $true + - uses: ./.github/actions/smoketest-build + env: + # rust-cache removes V8's nonstandard target/release/gn_out directory. + RUSTY_V8_ARCHIVE: ${{ runner.temp }}/spacetimedb-native-cache/rusty-v8/rusty_v8.lib.gz + with: + artifact-name: smoketest-build-Windows - cd modules - # the sdk-manifests on windows-latest are messed up, so we need to update them - dotnet workload config --update-mode manifests - dotnet workload update --from-previous-sdk - # Explicitly install wasi-experimental for .NET 8 SDK (needed for test_build_csharp_module) - # Create temp global.json to target .NET 8 SDK for workload install - $sdk8Json = '{"sdk":{"version":"8.0.400","rollForward":"latestFeature"}}' - $sdk8Json | Out-File -FilePath global.json -Encoding utf8 - dotnet workload install wasi-experimental - Remove-Item global.json + smoketests_linux: + needs: [merge_queue_noop, smoketest_build_linux] + if: ${{ needs.merge_queue_noop.outputs.skip != 'true' }} + name: Smoketests (Linux, ${{ matrix.partition }}/2) + strategy: + fail-fast: false + matrix: + partition: [1, 2] + runs-on: spacetimedb-new-runner-2 + timeout-minutes: 120 + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/target + RUST_BACKTRACE: full + SPACETIMEDB_CPP_DIR: ${{ github.workspace }}/crates/bindings-cpp + steps: + - name: Checkout sources + uses: actions/checkout@v4 + with: + ref: ${{ needs.merge_queue_noop.outputs.source_sha }} - - name: Override NuGet packages - shell: bash - run: | - dotnet pack -c Release crates/bindings-csharp/BSATN.Runtime - dotnet pack -c Release crates/bindings-csharp/Runtime - cd sdks/csharp - ./tools~/write-nuget-config.sh ../.. + - uses: ./.github/actions/smoketest-setup + with: + rust-cache-enabled: "false" - # This step shouldn't be needed, but somehow we end up with caches that are missing librusty_v8.a. - # ChatGPT suspects that this could be due to different build invocations using the same target dir, - # and this makes sense to me because we only see it in this job where we mix `cargo build -p` with - # `cargo build --manifest-path` (which apparently build different dependency trees). - # However, we've been unable to fix it so... /shrug - - name: Check v8 outputs - shell: bash - run: | - find "${CARGO_TARGET_DIR}"/ -type f | grep '[/_]v8' || true - if ! [ -f "${CARGO_TARGET_DIR}"/release/gn_out/obj/librusty_v8.a ]; then - echo "Could not find v8 output file librusty_v8.a; rebuilding manually." - cargo clean --release -p v8 || true - cargo build --release -p v8 - fi + - name: Set up pnpm + uses: ./.github/actions/setup-pnpm + with: + node_version: 18 - - name: Install cargo-nextest - uses: taiki-e/install-action@nextest + - name: Install TypeScript SDK runtime dependencies + run: pnpm --filter spacetimedb install --prod --frozen-lockfile --prefer-offline - # --test-threads=1 eliminates contention in the C# tests where they fight over bindings - # build artifacts. - # It also seemed to improve performance a fair amount (11m -> 6m) - - name: Run smoketests (Linux) - if: runner.os == 'Linux' - shell: bash - run: | - if [ -f ~/emsdk/emsdk_env.sh ]; then - source ~/emsdk/emsdk_env.sh - fi - cargo ci smoketests -- --test-threads=1 + - uses: ./.github/actions/smoketest-run-partition + with: + artifact-name: smoketest-build-Linux + partition: ${{ matrix.partition }} + partition-total: 2 - # Due to Emscripten PATH issues this was separated to make sure OpenSSL still builds correctly - - name: Run smoketests (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - if (Test-Path "$env:USERPROFILE\emsdk\emsdk_env.ps1") { - & "$env:USERPROFILE\emsdk\emsdk_env.ps1" | Out-Null - } - cargo ci smoketests -- --test-threads=1 + smoketests_windows: + needs: [merge_queue_noop, smoketest_build_windows] + if: ${{ needs.merge_queue_noop.outputs.skip != 'true' }} + name: Smoketests (Windows, ${{ matrix.partition }}/4) + strategy: + fail-fast: false + matrix: + partition: [1, 2, 3, 4] + runs-on: spacetimedb-windows-runner + timeout-minutes: 120 + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/target + RUST_BACKTRACE: full + SPACETIMEDB_CPP_DIR: ${{ github.workspace }}/crates/bindings-cpp + steps: + - name: Checkout sources + uses: actions/checkout@v4 + with: + ref: ${{ needs.merge_queue_noop.outputs.source_sha }} + + - uses: ./.github/actions/smoketest-setup + with: + rust-cache-enabled: "false" + + - name: Set up pnpm + uses: ./.github/actions/setup-pnpm + with: + node_version: 18 + + - name: Install TypeScript SDK runtime dependencies + run: pnpm --filter spacetimedb install --prod --frozen-lockfile --prefer-offline + + - uses: ./.github/actions/smoketest-run-partition + with: + artifact-name: smoketest-build-Windows + partition: ${{ matrix.partition }} + partition-total: 4 # this is a no-op version of the above check with a trivially-passing body. # we can't just let the check be entirely skipped because each matrix target is a required check, @@ -246,12 +332,28 @@ jobs: smoketests_noop: needs: [merge_queue_noop] if: ${{ needs.merge_queue_noop.outputs.skip == 'true' }} - name: Smoketests (${{ matrix.name }}) + name: Smoketests (${{ matrix.name }}, ${{ matrix.partition }}/${{ matrix.partition-total }}) strategy: matrix: include: - name: Linux + partition: 1 + partition-total: 2 + - name: Linux + partition: 2 + partition-total: 2 + - name: Windows + partition: 1 + partition-total: 4 + - name: Windows + partition: 2 + partition-total: 4 - name: Windows + partition: 3 + partition-total: 4 + - name: Windows + partition: 4 + partition-total: 4 runs-on: ubuntu-latest steps: - name: Skip duplicate merge queue smoketest @@ -287,15 +389,6 @@ jobs: - uses: dsherret/rust-toolchain-file@v1 - name: Set default rust toolchain run: rustup default $(rustup show active-toolchain | cut -d' ' -f1) - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 - with: - workspaces: ${{ github.workspace }} - shared-key: spacetimedb - # Let the smoketests job save the cache since it builds the most things - save-if: false - prefix-key: v1 - - name: Check v8 outputs shell: bash run: | @@ -316,8 +409,9 @@ jobs: node-version: 22 - uses: ./.github/actions/setup-pnpm - with: - run_install: true + + - name: Install pnpm workspace dependencies + run: pnpm install --frozen-lockfile # Install cmake and emscripten for C++ module compilation tests. - name: Install cmake and emscripten @@ -360,7 +454,7 @@ jobs: cargo ci test keynote_bench: - needs: [merge_queue_noop, lints] + needs: [merge_queue_noop, lints, smoketest_build_linux] if: ${{ needs.merge_queue_noop.outputs.skip != 'true' }} name: Keynote Bench runs-on: spacetimedb-benchmark-runner @@ -372,22 +466,10 @@ jobs: CARGO_TARGET_DIR: ${{ github.workspace }}/target RUST_BACKTRACE: full steps: - - name: Find Git ref - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - PR_NUMBER="${{ github.event.inputs.pr_number || null }}" - if test -n "${PR_NUMBER}"; then - GIT_REF="$( gh pr view --repo clockworklabs/SpacetimeDB $PR_NUMBER --json headRefName --jq .headRefName )" - else - GIT_REF="${{ github.ref }}" - fi - echo "GIT_REF=${GIT_REF}" >>"$GITHUB_ENV" - - name: Checkout sources uses: actions/checkout@v4 with: - ref: ${{ env.GIT_REF }} + ref: ${{ needs.merge_queue_noop.outputs.source_sha }} # Node 24 is the current Active LTS line. - name: Set up Node.js @@ -396,15 +478,15 @@ jobs: node-version: 24 - uses: ./.github/actions/setup-pnpm - with: - package_json_file: package.json - working_directory: . - run_install: "true" + + - name: Install Keynote dependencies + run: pnpm --filter spacetime-simulations... install --frozen-lockfile - uses: ./.github/actions/keynote-bench-setup + + - uses: ./.github/actions/restore-smoketest-build with: - public_root: . - rust_cache_workspaces: ${{ github.workspace }} + artifact-name: smoketest-build-Linux - name: Run keynote-2 benchmark regression check run: cargo ci keynote-bench @@ -443,22 +525,6 @@ jobs: - name: Set default rust toolchain run: rustup default $(rustup show active-toolchain | cut -d' ' -f1) - - name: Set up sccache - uses: mozilla-actions/sccache-action@v0.0.10 - - - name: Enable sccache - run: | - echo "SCCACHE_GHA_ENABLED=true" >>"$GITHUB_ENV" - echo "RUSTC_WRAPPER=sccache" >>"$GITHUB_ENV" - - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 - with: - workspaces: ${{ github.workspace }} - shared-key: spacetimedb-index-scan - cache-on-failure: false - prefix-key: v1 - - name: Run index scan benchmark regression check run: cargo run --release -p spacetimedb-index-scan-gate @@ -479,15 +545,6 @@ jobs: run: rustup default $(rustup show active-toolchain | cut -d' ' -f1) - run: echo ::add-matcher::.github/workflows/rust_matcher.json - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 - with: - workspaces: ${{ github.workspace }} - shared-key: spacetimedb - # Let the smoketests job save the cache since it builds the most things - save-if: false - prefix-key: v1 - - uses: actions/setup-dotnet@v3 with: global-json-file: global.json @@ -520,15 +577,6 @@ jobs: run: rustup default $(rustup show active-toolchain | cut -d' ' -f1) - run: echo ::add-matcher::.github/workflows/rust_matcher.json - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 - with: - workspaces: ${{ github.workspace }} - shared-key: spacetimedb - # Let the smoketests job save the cache since it builds the most things - save-if: false - prefix-key: v1 - - name: Run bindgen tests run: cargo ci wasm-bindings @@ -758,8 +806,6 @@ jobs: node-version: 22 - uses: ./.github/actions/setup-pnpm - with: - run_install: true - name: Get pnpm store directory shell: bash @@ -778,21 +824,12 @@ jobs: - name: Set default rust toolchain run: rustup default $(rustup show active-toolchain | cut -d' ' -f1) - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 - with: - workspaces: ${{ github.workspace }} - shared-key: spacetimedb - # Let the smoketests job save the cache since it builds the most things - save-if: false - prefix-key: v1 - - name: Check for docs change run: | cargo ci cli-docs unity-testsuite: - needs: [merge_queue_noop, lints] + needs: [merge_queue_noop, lints, smoketest_build_linux] # Skip if this is an external contribution. # The license secrets will be empty, so the step would fail anyway. if: ${{ needs.merge_queue_noop.outputs.skip != 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) }} @@ -804,10 +841,24 @@ jobs: env: CARGO_TARGET_DIR: ${{ github.workspace }}/target RUST_BACKTRACE: full + UNITY_VERSION: 2022.3.32f1 steps: - name: Checkout repository id: checkout-stdb uses: actions/checkout@v4 + with: + ref: ${{ needs.merge_queue_noop.outputs.source_sha }} + + # Key the Library by the inputs Unity imports so unrelated PRs share the same + # cache. Keep this before generation/hydration so ignored build outputs do not + # affect hashFiles. + - name: Restore Unity Library + uses: actions/cache@v4 + with: + path: demo/Blackholio/client-unity/Library + key: Unity-v2-${{ runner.os }}-${{ env.UNITY_VERSION }}-${{ hashFiles('demo/Blackholio/client-unity/**', 'sdks/csharp/**', 'crates/bindings-csharp/BSATN.Runtime/**', 'crates/bindings-csharp/Runtime/**', 'crates/bindings-csharp/Directory.Build.props', 'tools/regen/src/csharp.rs', 'global.json') }} + restore-keys: | + Unity-v2-${{ runner.os }}-${{ env.UNITY_VERSION }}- # Run cheap .NET tests first. If those fail, no need to run expensive Unity tests. @@ -846,37 +897,9 @@ jobs: - name: Set default rust toolchain run: rustup default $(rustup show active-toolchain | cut -d' ' -f1) - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 + - uses: ./.github/actions/restore-smoketest-build with: - workspaces: ${{ github.workspace }} - shared-key: spacetimedb - # Let the main CI job save the cache since it builds the most things - save-if: false - prefix-key: v1 - - # This step shouldn't be needed, but somehow we end up with caches that are missing librusty_v8.a. - # ChatGPT suspects that this could be due to different build invocations using the same target dir, - # and this makes sense to me because we only see it in this job where we mix `cargo build -p` with - # `cargo build --manifest-path` (which apparently build different dependency trees). - # However, we've been unable to fix it so... /shrug - - name: Check v8 outputs - run: | - find "${CARGO_TARGET_DIR}"/ -type f | grep '[/_]v8' || true - if ! [ -f "${CARGO_TARGET_DIR}"/release/gn_out/obj/librusty_v8.a ]; then - echo "Could not find v8 output file librusty_v8.a; rebuilding manually." - cargo clean --release -p v8 || true - cargo build --release -p v8 - fi - - - name: Install SpacetimeDB CLI from the local checkout - run: | - export CARGO_HOME="$HOME/.cargo" - echo "$CARGO_HOME/bin" >> "$GITHUB_PATH" - cargo install --force --path crates/cli --locked --message-format=short - cargo install --force --path crates/standalone --locked --message-format=short - # Add a handy alias using the old binary name, so that we don't have to rewrite all scripts (incl. in submodules). - ln -sf $CARGO_HOME/bin/spacetimedb-cli $CARGO_HOME/bin/spacetime + artifact-name: smoketest-build-Linux - name: Generate client bindings working-directory: demo/Blackholio/server-rust @@ -917,16 +940,10 @@ jobs: yq e -i '.dependencies["com.clockworklabs.spacetimedbsdk"] = "file:../../../../sdks/csharp"' manifest.json cat manifest.json - - uses: actions/cache@v3 - with: - path: demo/Blackholio/client-unity/Library - key: Unity-${{ github.head_ref }} - restore-keys: Unity- - - name: Run Unity tests uses: game-ci/unity-test-runner@v4 with: - unityVersion: 2022.3.32f1 # Adjust Unity version to a valid tag + unityVersion: ${{ env.UNITY_VERSION }} projectPath: demo/Blackholio/client-unity # Path to the Unity project subdirectory githubToken: ${{ secrets.GITHUB_TOKEN }} testMode: playmode @@ -938,7 +955,7 @@ jobs: UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} godot-testsuite: - needs: [merge_queue_noop, lints] + needs: [merge_queue_noop, lints, smoketest_build_linux] if: ${{ needs.merge_queue_noop.outputs.skip != 'true' }} permissions: contents: read @@ -950,6 +967,8 @@ jobs: - name: Checkout repository id: checkout-stdb uses: actions/checkout@v4 + with: + ref: ${{ needs.merge_queue_noop.outputs.source_sha }} - name: Setup dotnet uses: actions/setup-dotnet@v3 @@ -986,37 +1005,9 @@ jobs: - name: Set default rust toolchain run: rustup default $(rustup show active-toolchain | cut -d' ' -f1) - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 + - uses: ./.github/actions/restore-smoketest-build with: - workspaces: ${{ github.workspace }} - shared-key: spacetimedb - # Let the main CI job save the cache since it builds the most things - save-if: false - prefix-key: v1 - - # This step shouldn't be needed, but somehow we end up with caches that are missing librusty_v8.a. - # ChatGPT suspects that this could be due to different build invocations using the same target dir, - # and this makes sense to me because we only see it in this job where we mix `cargo build -p` with - # `cargo build --manifest-path` (which apparently build different dependency trees). - # However, we've been unable to fix it so... /shrug - - name: Check v8 outputs - run: | - find "${CARGO_TARGET_DIR}"/ -type f | grep '[/_]v8' || true - if ! [ -f "${CARGO_TARGET_DIR}"/release/gn_out/obj/librusty_v8.a ]; then - echo "Could not find v8 output file librusty_v8.a; rebuilding manually." - cargo clean --release -p v8 || true - cargo build --release -p v8 - fi - - - name: Install SpacetimeDB CLI from the local checkout - run: | - export CARGO_HOME="$HOME/.cargo" - echo "$CARGO_HOME/bin" >> "$GITHUB_PATH" - cargo install --force --path crates/cli --locked --message-format=short - cargo install --force --path crates/standalone --locked --message-format=short - # Add a handy alias using the old binary name, so that we don't have to rewrite all scripts (incl. in submodules). - ln -sf $CARGO_HOME/bin/spacetimedb-cli $CARGO_HOME/bin/spacetime + artifact-name: smoketest-build-Linux - name: Generate client bindings working-directory: demo/Blackholio/server-rust @@ -1066,7 +1057,7 @@ jobs: run: godot --headless --scene res://tests/GodotPlayModeTests.tscn csharp-testsuite: - needs: [merge_queue_noop, lints] + needs: [merge_queue_noop, lints, smoketest_build_linux] if: ${{ needs.merge_queue_noop.outputs.skip != 'true' }} runs-on: spacetimedb-new-runner-2 timeout-minutes: 30 @@ -1077,6 +1068,8 @@ jobs: - name: Checkout repository id: checkout-stdb uses: actions/checkout@v4 + with: + ref: ${{ needs.merge_queue_noop.outputs.source_sha }} - name: Setup dotnet uses: actions/setup-dotnet@v3 @@ -1124,43 +1117,9 @@ jobs: - name: Set default rust toolchain run: rustup default $(rustup show active-toolchain | cut -d' ' -f1) - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 + - uses: ./.github/actions/restore-smoketest-build with: - workspaces: ${{ github.workspace }} - shared-key: spacetimedb - # Let the main CI job save the cache since it builds the most things - save-if: false - prefix-key: v1 - - # This step shouldn't be needed, but somehow we end up with caches that are missing librusty_v8.a. - # ChatGPT suspects that this could be due to different build invocations using the same target dir, - # and this makes sense to me because we only see it in this job where we mix `cargo build -p` with - # `cargo build --manifest-path` (which apparently build different dependency trees). - # However, we've been unable to fix it so... /shrug - - name: Check v8 outputs - run: | - find "${CARGO_TARGET_DIR}"/ -type f | grep '[/_]v8' || true - if ! [ -f "${CARGO_TARGET_DIR}"/debug/gn_out/obj/librusty_v8.a ]; then - echo "Could not find v8 output file librusty_v8.a; rebuilding manually." - cargo clean -p v8 || true - cargo build -p v8 - fi - find "${CARGO_TARGET_DIR}"/ -type f | grep '[/_]v8' || true - if ! [ -f "${CARGO_TARGET_DIR}"/release/gn_out/obj/librusty_v8.a ]; then - echo "Could not find v8 output file librusty_v8.a; rebuilding manually." - cargo clean --release -p v8 || true - cargo build --release -p v8 - fi - - - name: Install SpacetimeDB CLI from the local checkout - run: | - export CARGO_HOME="$HOME/.cargo" - echo "$CARGO_HOME/bin" >> "$GITHUB_PATH" - cargo install --force --path crates/cli --locked --message-format=short - cargo install --force --path crates/standalone --features allow_loopback_http_for_tests --locked --message-format=short - # Add a handy alias using the old binary name, so that we don't have to rewrite all scripts (incl. in submodules). - ln -sf $CARGO_HOME/bin/spacetimedb-cli $CARGO_HOME/bin/spacetime + artifact-name: smoketest-build-Linux - name: Check quickstart-chat bindings are up to date run: | @@ -1240,76 +1199,9 @@ jobs: - name: Set default rust toolchain run: rustup default $(rustup show active-toolchain | cut -d' ' -f1) - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 - with: - workspaces: ${{ github.workspace }} - shared-key: spacetimedb - save-if: false - prefix-key: v1 - - name: Check global.json policy run: cargo ci global-json-policy - smoketests_mod_rs_complete: - needs: [merge_queue_noop] - if: ${{ needs.merge_queue_noop.outputs.skip != 'true' }} - name: Check smoketests/mod.rs is complete - runs-on: ubuntu-latest - permissions: - contents: read - env: - CARGO_TARGET_DIR: ${{ github.workspace }}/target - RUST_BACKTRACE: full - steps: - - name: Find Git ref - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - PR_NUMBER="${{ github.event.inputs.pr_number || null }}" - if test -n "${PR_NUMBER}"; then - GIT_REF="$( gh pr view --repo clockworklabs/SpacetimeDB $PR_NUMBER --json headRefName --jq .headRefName )" - else - GIT_REF="${{ github.ref }}" - fi - echo "GIT_REF=${GIT_REF}" >>"$GITHUB_ENV" - - name: Checkout sources - uses: actions/checkout@v4 - with: - ref: ${{ env.GIT_REF }} - - uses: dsherret/rust-toolchain-file@v1 - - name: Set default rust toolchain - run: rustup default $(rustup show active-toolchain | cut -d' ' -f1) - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 - with: - workspaces: ${{ github.workspace }} - shared-key: spacetimedb - cache-on-failure: false - cache-all-crates: true - cache-workspace-crates: true - prefix-key: v1 - - # This step shouldn't be needed, but somehow we end up with caches that are missing librusty_v8.a. - # ChatGPT suspects that this could be due to different build invocations using the same target dir, - # and this makes sense to me because we only see it in this job where we mix `cargo build -p` with - # `cargo build --manifest-path` (which apparently build different dependency trees). - # However, we've been unable to fix it so... /shrug - - name: Check v8 outputs - shell: bash - run: | - find "${CARGO_TARGET_DIR}"/ -type f | grep '[/_]v8' || true - if ! [ -f "${CARGO_TARGET_DIR}"/debug/gn_out/obj/librusty_v8.a ]; then - echo "Could not find v8 output file librusty_v8.a; rebuilding manually." - cargo clean -p v8 || true - cargo build -p v8 - fi - - - name: Verify crates/smoketests/tests/smoketests/mod.rs lists all entries - run: | - cargo ci smoketests check-mod-list - docs-build: needs: [merge_queue_noop] if: ${{ needs.merge_queue_noop.outputs.skip != 'true' }} @@ -1327,8 +1219,6 @@ jobs: node-version: '22' - uses: ./.github/actions/setup-pnpm - with: - run_install: true - name: Get pnpm store directory working-directory: sdks/typescript @@ -1368,8 +1258,6 @@ jobs: node-version: 22 - uses: ./.github/actions/setup-pnpm - with: - run_install: true - name: Get pnpm store directory shell: bash @@ -1385,6 +1273,9 @@ jobs: restore-keys: | ${{ runner.os }}-pnpm-store- + - name: Install TypeScript test dependencies + run: pnpm install --frozen-lockfile + # - name: Extract SpacetimeDB branch name from file # id: extract-branch # run: | @@ -1415,15 +1306,6 @@ jobs: - name: Set default rust toolchain run: rustup default $(rustup show active-toolchain | cut -d' ' -f1) - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 - with: - workspaces: ${{ github.workspace }} - shared-key: spacetimedb - # Let the main CI job save the cache since it builds the most things - save-if: false - prefix-key: v1 - # # This step shouldn't be needed, but somehow we end up with caches that are missing librusty_v8.a. # # ChatGPT suspects that this could be due to different build invocations using the same target dir, # # and this makes sense to me because we only see it in this job where we mix `cargo build -p` with diff --git a/.github/workflows/docs-publish.yaml b/.github/workflows/docs-publish.yaml index 4cbf3b76a25..a5aac56113d 100644 --- a/.github/workflows/docs-publish.yaml +++ b/.github/workflows/docs-publish.yaml @@ -21,8 +21,6 @@ jobs: node-version: '22' - uses: ./.github/actions/setup-pnpm - with: - run_install: true - name: Get pnpm store directory working-directory: sdks/typescript @@ -39,8 +37,7 @@ jobs: ${{ runner.os }}-pnpm-store- - name: Install dependencies - working-directory: docs - run: pnpm install + run: pnpm --filter docs... install --frozen-lockfile - name: Docusaurus build working-directory: docs diff --git a/.github/workflows/docs-update-llms.yaml b/.github/workflows/docs-update-llms.yaml index a4928d55e49..341e1772c24 100644 --- a/.github/workflows/docs-update-llms.yaml +++ b/.github/workflows/docs-update-llms.yaml @@ -27,8 +27,6 @@ jobs: node-version: '22' - uses: ./.github/actions/setup-pnpm - with: - run_install: true - name: Get pnpm store directory working-directory: sdks/typescript @@ -45,8 +43,7 @@ jobs: ${{ runner.os }}-pnpm-store- - name: Install dependencies - working-directory: docs - run: pnpm install + run: pnpm --filter docs... install --frozen-lockfile - name: Docusaurus build working-directory: docs diff --git a/.github/workflows/internal-tests.yml b/.github/workflows/internal-tests.yml index cd100558a69..3425a011e32 100644 --- a/.github/workflows/internal-tests.yml +++ b/.github/workflows/internal-tests.yml @@ -53,15 +53,70 @@ jobs: github-token: ${{ secrets.SPACETIMEDB_PRIVATE_TOKEN }} script: | const workflowId = 'ci.yml'; - const targetRef = 'master'; const targetOwner = process.env.TARGET_OWNER; const targetRepo = process.env.TARGET_REPO; // Use the ref for pull requests because the head sha is brittle (github does some extra dance where it merges in master). const publicRef = (context.eventName === 'pull_request') ? context.payload.pull_request.head.ref : context.sha; const publicPrNumber = context.payload.pull_request?.number; const inputs = { public_ref: publicRef }; + let targetRef = 'master'; + if (publicPrNumber) { - inputs.public_pr_number = String(publicPrNumber); + // GitHub constructs the job graph from the dispatched ref before + // ci-setup can check out related sources. Dispatch the companion + // branch itself so workflow changes in that PR are exercised too. + const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: publicPrNumber, + per_page: 100, + }); + const privatePrNumbers = [ + ...new Set( + timeline + .filter(event => event.event === 'cross-referenced') + .map(event => event.source?.issue) + .filter(issue => + issue?.repository?.full_name === `${targetOwner}/${targetRepo}` + && issue.pull_request + && issue.state === 'open' + ) + .map(issue => issue.number) + ), + ]; + + if (privatePrNumbers.length > 1) { + core.setFailed( + `Multiple open ${targetOwner}/${targetRepo} PRs reference public PR #${publicPrNumber}: ` + + privatePrNumbers.map(number => `#${number}`).join(', ') + ); + return; + } + + if (privatePrNumbers.length === 1) { + const privatePrNumber = privatePrNumbers[0]; + const privatePr = await github.rest.pulls.get({ + owner: targetOwner, + repo: targetRepo, + pull_number: privatePrNumber, + }); + const privateHeadRepo = privatePr.data.head.repo?.full_name; + if (privateHeadRepo !== `${targetOwner}/${targetRepo}`) { + core.setFailed( + `Refusing to run a workflow from related private PR #${privatePrNumber}: ` + + `expected head repository ${targetOwner}/${targetRepo}, got ${privateHeadRepo || 'unknown'}` + ); + return; + } + + targetRef = privatePr.data.head.ref; + inputs.public_pr_number = String(publicPrNumber); + core.info(`Using related private PR #${privatePrNumber} workflow from ${targetRef}.`); + } else { + // No private companion exists, so run the private master workflow + // and let setup use the public PR with the private master sources. + inputs.public_pr_number = String(publicPrNumber); + } } // Dispatch the workflow in the target repository diff --git a/.github/workflows/llm-benchmark-periodic.yml b/.github/workflows/llm-benchmark-periodic.yml index 8e46d9ebc2b..91470592fa1 100644 --- a/.github/workflows/llm-benchmark-periodic.yml +++ b/.github/workflows/llm-benchmark-periodic.yml @@ -58,7 +58,6 @@ jobs: fetch-depth: 1 - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - name: Setup .NET SDK uses: actions/setup-dotnet@v4 @@ -88,8 +87,10 @@ jobs: - name: Install pnpm if: ${{ github.event_name != 'workflow_dispatch' || contains(inputs.languages || 'rust,csharp,typescript', 'typescript') }} uses: ./.github/actions/setup-pnpm - with: - run_install: true + + - name: Install TypeScript SDK dependencies + if: ${{ github.event_name != 'workflow_dispatch' || contains(inputs.languages || 'rust,csharp,typescript', 'typescript') }} + run: pnpm --filter spacetimedb... install --frozen-lockfile - name: Build TypeScript SDK if: ${{ github.event_name != 'workflow_dispatch' || contains(inputs.languages || 'rust,csharp,typescript', 'typescript') }} diff --git a/.github/workflows/llm-benchmark-validate-goldens.yml b/.github/workflows/llm-benchmark-validate-goldens.yml index a2d2ef87a3e..f89174ceb71 100644 --- a/.github/workflows/llm-benchmark-validate-goldens.yml +++ b/.github/workflows/llm-benchmark-validate-goldens.yml @@ -41,7 +41,6 @@ jobs: fetch-depth: 1 - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - name: Setup .NET SDK if: matrix.lang == 'csharp' @@ -73,8 +72,10 @@ jobs: - name: Install pnpm if: matrix.lang == 'typescript' uses: ./.github/actions/setup-pnpm - with: - run_install: true + + - name: Install TypeScript SDK dependencies + if: matrix.lang == 'typescript' + run: pnpm --filter spacetimedb... install --frozen-lockfile - name: Build TypeScript SDK if: matrix.lang == 'typescript' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 133c5e0d92f..3b32dfef988 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,9 +58,174 @@ concurrency: cancel-in-progress: true jobs: - # This job runs before all of our release jobs. If there is a release problem we should + release-dry-run: + name: release-dry-run + if: >- + ${{ + inputs.dry_run + && (inputs.release_crates + || inputs.release_csharp + || inputs.release_cpp + || inputs.release_npm + || inputs.release_docker) + }} + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + packages: write + env: + CARGO_TERM_COLOR: always + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + # Build the release tool from the dispatched ref, matching the live release path. + - name: Checkout release tooling + uses: actions/checkout@v4 + with: + submodules: recursive + + - 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: Install cargo-release + run: cargo install --path tools/release + + # Keep the release sources separate from the tooling checkout so each target + # can be reset without removing the cargo-release sources or executable. + - name: Checkout release sources + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.release_tag }} + path: release-source + submodules: recursive + + - name: Set up Node.js + if: inputs.release_npm + uses: actions/setup-node@v6 + with: + node-version: '22.x' + + - name: Install NPM release tools + if: inputs.release_npm + run: | + # This is the minimum npm version required to use trusted publishing. + npm install --global npm@11.5.1 + npm install --global pnpm + npm --version + pnpm --version + + - name: Set up .NET + if: inputs.release_csharp + uses: actions/setup-dotnet@v4 + with: + global-json-file: release-source/global.json + + - name: Install NuGet + if: inputs.release_csharp + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y mono-complete + + nuget_bin="$RUNNER_TEMP/release-tools" + mkdir -p "$nuget_bin" + wget -q https://dist.nuget.org/win-x86-commandline/latest/nuget.exe \ + -O "$nuget_bin/nuget-mono.exe" + cat >"$nuget_bin/nuget" <<'EOF' + #!/bin/bash + exec mono "$(dirname "$(readlink -f "$0")")/nuget-mono.exe" "$@" + EOF + chmod +x "$nuget_bin/nuget" + echo "$nuget_bin" >>"$GITHUB_PATH" + + - name: Set up SDK repository SSH keys + if: inputs.release_csharp || inputs.release_cpp + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: | + ${{ inputs.release_csharp && secrets.UNITY_SDK_DEPLOY_KEY || '' }} + ${{ inputs.release_cpp && secrets.CPP_SDK_DEPLOY_KEY || '' }} + + - name: Configure git + if: inputs.release_csharp || inputs.release_cpp + run: | + git config --global user.name "Release Bot" + git config --global user.email "no-reply@clockworklabs.io" + git config --global --unset-all url.https://github.com/.insteadOf || true + git config --global url."git@github.com:".insteadOf "https://github.com/" + + - name: Set up Docker Buildx + if: inputs.release_docker + uses: docker/setup-buildx-action@v3 + with: + driver-opts: network=host + + - name: Run release dry-runs + working-directory: release-source + env: + RELEASE_TAG: ${{ github.event.inputs.release_tag }} + RELEASE_CRATES: ${{ github.event.inputs.release_crates }} + RELEASE_CSHARP: ${{ github.event.inputs.release_csharp }} + RELEASE_CPP: ${{ github.event.inputs.release_cpp }} + RELEASE_NPM: ${{ github.event.inputs.release_npm }} + RELEASE_DOCKER: ${{ github.event.inputs.release_docker }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + set -uo pipefail + + failures=() + + reset_release_sources() { + git reset --hard "$RELEASE_TAG" + git clean -ffdx + git submodule sync --recursive + git submodule update --init --recursive --force + git submodule foreach --recursive 'git reset --hard HEAD && git clean -ffdx' + } + + run_dry_run() { + local enabled="$1" + local target="$2" + + if [[ "$enabled" != "true" ]]; then + return + fi + + echo "::group::$target dry-run" + if cargo-release release "$target" "$RELEASE_TAG" --dry-run; then + echo "$target: success" >>"$GITHUB_STEP_SUMMARY" + else + failures+=("$target") + echo "$target: failure" >>"$GITHUB_STEP_SUMMARY" + echo "::error title=$target release dry-run failed::See the $target log group for details." + fi + echo "::endgroup::" + + if ! reset_release_sources; then + failures+=("$target workspace reset") + echo "::error title=Release source reset failed::Could not clean the checkout after $target." + fi + } + + run_dry_run "$RELEASE_CRATES" crates + run_dry_run "$RELEASE_CSHARP" csharp + run_dry_run "$RELEASE_CPP" cpp + run_dry_run "$RELEASE_NPM" npm + run_dry_run "$RELEASE_DOCKER" docker + + if (( ${#failures[@]} > 0 )); then + printf 'Release dry-run failures: %s\n' "${failures[*]}" >&2 + exit 1 + fi + + # This job runs before all of our live release jobs. If there is a release problem we should # try to fail during this step to prevent partial releases. build-cargo-release: + if: ${{ !inputs.dry_run }} runs-on: spacetimedb-new-runner-2 steps: - name: Checkout @@ -96,7 +261,7 @@ jobs: release-crates: needs: build-cargo-release runs-on: spacetimedb-new-runner-2 - if: ${{ inputs.release_crates }} + if: ${{ !inputs.dry_run && inputs.release_crates }} env: CARGO_TERM_COLOR: always CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} @@ -125,20 +290,13 @@ jobs: 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 ${{ github.event.inputs.release_tag }} --dry-run - - name: Run release - if: ${{ !inputs.dry_run }} run: cargo-release release crates ${{ github.event.inputs.release_tag }} release-csharp: needs: build-cargo-release runs-on: spacetimedb-new-runner-2 - if: ${{ inputs.release_csharp }} + if: ${{ !inputs.dry_run && inputs.release_csharp }} env: CARGO_TERM_COLOR: always GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -170,7 +328,7 @@ jobs: - name: Set up .NET uses: actions/setup-dotnet@v4 with: - global-json-path: global.json + global-json-file: global.json - name: Install NuGet shell: bash @@ -206,18 +364,13 @@ jobs: 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 }} + if: ${{ !inputs.dry_run && inputs.release_cpp }} env: CARGO_TERM_COLOR: always GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -262,18 +415,13 @@ jobs: 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 }} + if: ${{ !inputs.dry_run && inputs.release_npm }} env: CARGO_TERM_COLOR: always GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -315,12 +463,7 @@ jobs: - 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: @@ -329,7 +472,7 @@ jobs: env: CARGO_TERM_COLOR: always GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - if: ${{ inputs.release_docker }} + if: ${{ !inputs.dry_run && inputs.release_docker }} steps: - name: Checkout specific tag @@ -362,7 +505,7 @@ jobs: # 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' || '' }} + run: cargo-release release docker ${{ github.event.inputs.release_tag }} update-mirror-latest-version: runs-on: ubuntu-latest diff --git a/crates/cli/src/subcommands/generate.rs b/crates/cli/src/subcommands/generate.rs index 69bab330cd2..a909a7f9fb6 100644 --- a/crates/cli/src/subcommands/generate.rs +++ b/crates/cli/src/subcommands/generate.rs @@ -750,7 +750,14 @@ impl Language { pub type ExtractDescriptions = fn(&Path) -> anyhow::Result; pub fn extract_descriptions(wasm_file: &Path) -> anyhow::Result { - let bin_path = resolve_sibling_binary("spacetimedb-standalone")?; + // Internal Tests execute public smoketests against private cloud binaries + // and intentionally do not package standalone. The private CI driver + // exposes the same `extract-schema` interface and provides this override. + // Normal CLI installations continue to use the sibling standalone binary. + let bin_path = std::env::var_os("SPACETIMEDB_SCHEMA_EXTRACTOR") + .map(PathBuf::from) + .map(Ok) + .unwrap_or_else(|| resolve_sibling_binary("spacetimedb-standalone"))?; let child = Command::new(&bin_path) .arg("extract-schema") .arg(wasm_file) diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index eead324c936..4d434b3e491 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -24,7 +24,16 @@ fn next_spawn_id() -> u64 { /// Returns the workspace root directory. // TODO: Should this use something like `git rev-parse --show-toplevel` to avoid being directory-relative? Or perhaps `CARGO_WORKSPACE_DIR` is set? fn workspace_root() -> PathBuf { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + // Archived binaries may execute in a different checkout from the build, so + // prefer the workspace root explicitly supplied by the runner. + if let Ok(workspace_root) = env::var("SPACETIMEDB_WORKSPACE_ROOT") { + return PathBuf::from(workspace_root); + } + + // Cargo and nextest set this at runtime, including nextest's remapped path. + let manifest_dir = PathBuf::from( + std::env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be set when running smoketests"), + ); manifest_dir .parent() // crates/ .and_then(|p| p.parent()) // workspace root @@ -34,10 +43,9 @@ fn workspace_root() -> PathBuf { /// Returns the target directory. fn target_dir() -> PathBuf { - let workspace_root = workspace_root(); env::var("CARGO_TARGET_DIR") .map(PathBuf::from) - .unwrap_or_else(|_| workspace_root.join("target")) + .unwrap_or_else(|_| workspace_root().join("target")) } /// Returns the expected CLI binary path. diff --git a/crates/smoketests/DEVELOP.md b/crates/smoketests/DEVELOP.md index 6acba49aea2..f0bfb33320b 100644 --- a/crates/smoketests/DEVELOP.md +++ b/crates/smoketests/DEVELOP.md @@ -18,6 +18,30 @@ cargo smoketest test_sql_format cargo smoketest "cli::" # Run all CLI tests ``` +### Archived and partitioned runs + +CI can build the CLI, standalone server, precompiled modules, and smoketest +binaries once: + +```bash +cargo ci smoketests archive --archive-file smoketest-nextest.tar.zst +``` + +After transferring the nextest archive and the required support binaries to a +runner, a partition can be executed without rebuilding the CLI, server, or test +binaries: + +```bash +target/debug/ci smoketests run-archive \ + --archive-file smoketest-nextest.tar.zst \ + -- \ + --partition hash:1/4 +``` + +The four hash partitions are deterministic and independently runnable. Tests +that intentionally verify module compilation still compile their temporary +module as part of the test itself. + ### Remote Servers Run against a standalone-compatible remote server with: diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 5b90de38a03..500e8232339 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -56,7 +56,6 @@ pub mod modules; use anyhow::{bail, Context, Result}; use regex::Regex; use spacetimedb_guard::{ensure_binaries_built, SpacetimeDbGuard}; -use std::env; use std::fs; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; @@ -181,7 +180,16 @@ macro_rules! timed { /// Returns the workspace root directory. pub fn workspace_root() -> PathBuf { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + // Archived tests may execute in a different checkout from the build, so + // prefer the workspace root explicitly supplied by the runner. + if let Ok(workspace_root) = std::env::var("SPACETIMEDB_WORKSPACE_ROOT") { + return PathBuf::from(workspace_root); + } + + // Cargo and nextest set this at runtime, including nextest's remapped path. + let manifest_dir = PathBuf::from( + std::env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be set when running smoketests"), + ); manifest_dir .parent() .and_then(|p| p.parent()) @@ -393,8 +401,29 @@ pub fn pnpm(args: &[&str], cwd: &Path) -> Result { pub fn build_typescript_sdk() -> Result<()> { let workspace = workspace_root(); let ts_bindings = workspace.join("crates/bindings-typescript"); - pnpm(&["install"], &ts_bindings)?; - pnpm(&["build"], &ts_bindings)?; + if std::env::var("SPACETIME_PREBUILT_TYPESCRIPT_SDK").ok().as_deref() == Some("1") { + for relative_path in [ + "dist/index.mjs", + "dist/index.d.ts", + "dist/server/index.mjs", + "dist/server/index.d.ts", + ] { + let artifact = ts_bindings.join(relative_path); + if !artifact.is_file() { + bail!( + "SPACETIME_PREBUILT_TYPESCRIPT_SDK is set, but the TypeScript SDK artifact is missing: {}", + artifact.display() + ); + } + } + return Ok(()); + } + + pnpm( + &["--filter", "spacetimedb...", "install", "--frozen-lockfile"], + &workspace, + )?; + pnpm(&["--filter", "spacetimedb", "build"], &workspace)?; Ok(()) } diff --git a/crates/smoketests/tests/smoketests/cli/server.rs b/crates/smoketests/tests/smoketests/cli/server.rs index 9aae2e41f83..1dfbdcf2405 100644 --- a/crates/smoketests/tests/smoketests/cli/server.rs +++ b/crates/smoketests/tests/smoketests/cli/server.rs @@ -1,6 +1,7 @@ //! CLI server command tests -use spacetimedb_guard::{ensure_binaries_built, SpacetimeDbGuard}; +use spacetimedb_guard::ensure_binaries_built; +use spacetimedb_smoketests::{require_local_server, Smoketest}; use std::fs; use std::io::Read; use std::net::TcpListener; @@ -98,10 +99,12 @@ fn stop_child(mut child: Child) { } #[test] -fn cli_can_ping_spacetimedb_on_disk() { - let spacetime = SpacetimeDbGuard::spawn_in_temp_data_dir(); +fn cli_can_ping_spacetimedb_server() { + // Internal Tests supply a private cloud URL through + // SPACETIME_REMOTE_SERVER. Normal public smoketests still start standalone. + let spacetime = Smoketest::builder().autopublish(false).build(); let output = cli_cmd() - .args(["server", "ping", &spacetime.host_url.to_string()]) + .args(["server", "ping", &spacetime.server_url]) .output() .expect("failed to execute"); assert!( @@ -113,6 +116,8 @@ fn cli_can_ping_spacetimedb_on_disk() { #[test] fn cli_start_uses_listen_addr_from_cli_toml() { + require_local_server!(); + let root = tempfile::tempdir().expect("failed to create tempdir"); let port = free_local_port(); let listen_addr = format!("127.0.0.1:{port}"); @@ -129,6 +134,8 @@ fn cli_start_uses_listen_addr_from_cli_toml() { #[test] fn cli_start_explicit_listen_addr_overrides_cli_toml() { + require_local_server!(); + let root = tempfile::tempdir().expect("failed to create tempdir"); let config_port = free_local_port(); let mut explicit_port = free_local_port(); diff --git a/crates/smoketests/tests/smoketests/default_module_clippy.rs b/crates/smoketests/tests/smoketests/default_module_clippy.rs index f32b0fd94e2..73e76f40357 100644 --- a/crates/smoketests/tests/smoketests/default_module_clippy.rs +++ b/crates/smoketests/tests/smoketests/default_module_clippy.rs @@ -1,17 +1,8 @@ //! These tests verify that the Rust module templates have no clippy warnings. -use std::path::PathBuf; +use spacetimedb_smoketests::workspace_root; use std::process::Command; -fn workspace_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .to_path_buf() -} - /// Run clippy on a template's spacetimedb module directory. /// Both templates use workspace dependencies, so they can be checked in place. fn check_template_clippy(template_name: &str) { diff --git a/crates/smoketests/tests/smoketests/namespaces.rs b/crates/smoketests/tests/smoketests/namespaces.rs index 3d632edd7e2..971de3ae027 100644 --- a/crates/smoketests/tests/smoketests/namespaces.rs +++ b/crates/smoketests/tests/smoketests/namespaces.rs @@ -1,15 +1,6 @@ -use spacetimedb_smoketests::Smoketest; +use spacetimedb_smoketests::{workspace_root, Smoketest}; use std::fs; -use std::path::{Path, PathBuf}; - -fn workspace_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .to_path_buf() -} +use std::path::Path; /// Count occurrences of a needle string in all .cs files under a directory fn count_matches(dir: &Path, needle: &str) -> usize { diff --git a/crates/smoketests/tests/smoketests/permissions.rs b/crates/smoketests/tests/smoketests/permissions.rs index d1c9c92b19f..f6cf93c0d3d 100644 --- a/crates/smoketests/tests/smoketests/permissions.rs +++ b/crates/smoketests/tests/smoketests/permissions.rs @@ -1,14 +1,4 @@ -use spacetimedb_smoketests::Smoketest; -use std::path::PathBuf; - -fn workspace_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .to_path_buf() -} +use spacetimedb_smoketests::{workspace_root, Smoketest}; /// Ensure that anyone has the permission to call any standard reducer #[test] diff --git a/crates/smoketests/tests/smoketests/templates.rs b/crates/smoketests/tests/smoketests/templates.rs index 37b23f1a51a..82fc679cf94 100644 --- a/crates/smoketests/tests/smoketests/templates.rs +++ b/crates/smoketests/tests/smoketests/templates.rs @@ -13,7 +13,7 @@ use anyhow::{bail, Context, Result}; use regex::Regex; use serde_json::Value; use spacetimedb_guard::ensure_binaries_built; -use spacetimedb_smoketests::{pnpm, random_string, workspace_root, Smoketest}; +use spacetimedb_smoketests::{build_typescript_sdk, pnpm, random_string, workspace_root, Smoketest}; use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -722,17 +722,6 @@ fn pin_csharp_client_sdk_package_version(project_path: &Path) -> Result<()> { Ok(()) } -/// Builds the TypeScript SDK (`crates/bindings-typescript`). -/// -/// Should be called once before testing any TypeScript templates. -fn build_typescript_sdk() -> Result<()> { - let sdk_path = workspace_root().join("crates/bindings-typescript"); - eprintln!("[TEMPLATES] Building TypeScript SDK at {:?}", sdk_path); - run_pnpm(&["install"], &sdk_path)?; - run_pnpm(&["build"], &sdk_path)?; - Ok(()) -} - /// Points the `spacetimedb` entry in `package.json` at the local TypeScript /// SDK and removes the template's lockfile so pnpm re-resolves dependencies. fn setup_typescript_sdk_in_package_json(package_json_path: &Path) -> Result<()> { @@ -1178,86 +1167,84 @@ fn test_basic_cs_init_default_dotnet_selection() -> Result<()> { Ok(()) } -/// Tests all templates discovered in the `templates/` directory. +/// Runs the init + publish + client-test cycle for one registered template. /// -/// For each template the test: -/// 1. Runs `spacetime init --template ` into a temp directory -/// 2. Wires local SDK dependencies so the template builds against the current source -/// 3. Publishes the server module and verifies it succeeds -/// 4. Type-checks / builds the client code -/// -/// All templates are exercised even if some fail; a summary is printed at the -/// end and the test fails if any template did. -#[test] -fn test_all_templates() { - let templates = get_templates(); - assert!(!templates.is_empty(), "No templates found in templates/"); +/// Each template is a separate Rust test so nextest can hash it independently +/// into a partition and report or rerun failures independently. +fn run_registered_template(template_id: &str) { + let template = get_templates() + .into_iter() + .find(|template| template.id == template_id) + .unwrap_or_else(|| panic!("Registered template not found: {template_id}")); - let has_typescript = templates.iter().any(|t| t.server_lang.as_deref() == Some("typescript")); - let has_csharp = templates.iter().any(|t| t.server_lang.as_deref() == Some("csharp")); - - // Guard checks - verify required tools are available before starting. - if has_typescript { - spacetimedb_smoketests::require_pnpm!(); - } - if has_csharp { - spacetimedb_smoketests::require_dotnet!(); - } - - // Build the TypeScript SDK once up-front if any TypeScript templates exist. - if has_typescript { - build_typescript_sdk().expect("Failed to build TypeScript SDK"); + match template.server_lang.as_deref() { + Some("typescript") => { + spacetimedb_smoketests::require_pnpm!(); + build_typescript_sdk().expect("Failed to build TypeScript SDK"); + } + Some("csharp") => { + spacetimedb_smoketests::require_dotnet!(); + } + _ => {} } - // One shared server for all templates. let test = Smoketest::builder().autopublish(false).build(); + test_template(&test, &template).unwrap_or_else(|error| panic!("Template {template_id} failed: {error:#}")); +} - let mut results: Vec<(String, Result<()>)> = Vec::new(); +macro_rules! template_tests { + ($($test_name:ident => $template_id:literal),+ $(,)?) => { + const REGISTERED_TEMPLATE_IDS: &[&str] = &[$($template_id),+]; - for template in &templates { - let result = test_template(&test, template); - let passed = result.is_ok(); - eprintln!( - "[TEMPLATES] {} {}", - if passed { "[PASS]" } else { "[FAIL]" }, - template.id - ); - results.push((template.id.clone(), result)); - } - - // Print summary. - eprintln!("\n{}", "=".repeat(60)); - eprintln!("TEMPLATE TEST SUMMARY"); - eprintln!("{}", "=".repeat(60)); - for (id, result) in &results { - eprintln!( - "{:40} {}", - id, - if result.is_ok() { - "[PASS]".to_string() - } else { - format!("[FAIL]: {:#}", result.as_ref().unwrap_err()) + $( + #[test] + fn $test_name() { + run_registered_template($template_id); } - ); - } - let passed = results.iter().filter(|(_, r)| r.is_ok()).count(); - let total = results.len(); - eprintln!("{}", "=".repeat(60)); - eprintln!("TOTAL: {}/{} passed", passed, total); + )+ + }; +} + +template_tests! { + test_template_angular_ts => "angular-ts", + test_template_astro_ts => "astro-ts", + test_template_basic_cpp => "basic-cpp", + test_template_basic_cs => "basic-cs", + test_template_basic_rs => "basic-rs", + test_template_basic_ts => "basic-ts", + test_template_browser_ts => "browser-ts", + test_template_bun_ts => "bun-ts", + test_template_chat_console_cs => "chat-console-cs", + test_template_chat_console_rs => "chat-console-rs", + test_template_chat_react_ts => "chat-react-ts", + test_template_deno_ts => "deno-ts", + test_template_hangman_react_ts => "hangman-react-ts", + test_template_llm_chat_ts => "llm-chat-ts", + test_template_money_exchange_react_ts => "money-exchange-react-ts", + test_template_nextjs_ts => "nextjs-ts", + test_template_nodejs_ts => "nodejs-ts", + test_template_nuxt_ts => "nuxt-ts", + test_template_react_ts => "react-ts", + test_template_remix_ts => "remix-ts", + test_template_solid_ts => "solid-ts", + test_template_svelte_ts => "svelte-ts", + test_template_tanstack_ts => "tanstack-ts", + test_template_vue_ts => "vue-ts", +} - // Fail if any template failed. - let failures: Vec<_> = results +#[test] +fn test_template_registry_matches_discovered_templates() { + let discovered = get_templates() + .into_iter() + .map(|template| template.id) + .collect::>(); + let registered = REGISTERED_TEMPLATE_IDS .iter() - .filter(|(_, r)| r.is_err()) - .map(|(id, r)| format!(" {}: {:#}", id, r.as_ref().unwrap_err())) - .collect(); - - if !failures.is_empty() { - panic!( - "{}/{} template(s) failed:\n{}", - failures.len(), - total, - failures.join("\n") - ); - } + .map(|template_id| (*template_id).to_owned()) + .collect::>(); + + assert_eq!( + discovered, registered, + "Every discovered template must have its own nextest-visible test" + ); } diff --git a/tools/ci/README.md b/tools/ci/README.md index 78147590f46..3cb0a382a2a 100644 --- a/tools/ci/README.md +++ b/tools/ci/README.md @@ -108,7 +108,7 @@ This is required for servers that reject direct server-issued logins for privile Optionally accepts an auth host to pass through to `spacetime login`, for example `--auth-host=https://spacetimedb.com`. -- `--dotnet `: +- `--dotnet `: Whether to run smoketests that require .NET - `args `: Additional arguments to pass to the test runner - `--help`: Print help (see a summary with '-h') @@ -127,6 +127,31 @@ Usage: prepare - `--help`: Print help (see a summary with '-h') +#### `archive` + +**Usage:** +```bash +Usage: archive --archive-file +``` + +**Options:** + +- `--archive-file `: Path to the nextest archive to create +- `--help`: Print help + +#### `run-archive` + +**Usage:** +```bash +Usage: run-archive --archive-file [ARGS]... +``` + +**Options:** + +- `--archive-file `: Path to the nextest archive to run +- `args `: Additional arguments to pass to nextest +- `--help`: Print help + #### `check-mod-list` **Usage:** diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index 4d506a4e5f4..cb9dab5e8a8 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -457,7 +457,7 @@ fn run_typescript_tests() -> Result<()> { } fn run_docs_build() -> Result<()> { - pnpm(["install"]).dir("docs").run()?; + pnpm(["--filter", "docs...", "install", "--frozen-lockfile"]).run()?; pnpm(["build"]).dir("docs").run()?; Ok(()) } @@ -747,7 +747,7 @@ fn main() -> Result<()> { ); } - pnpm(["install", "--recursive"]).run()?; + pnpm(["--filter", "docs...", "install", "--frozen-lockfile"]).run()?; pnpm(["generate-cli-docs"]).dir("docs").run()?; let out = cmd!("git", "status", "--porcelain", "--", "docs").read()?; if out.is_empty() { diff --git a/tools/ci/src/smoketest.rs b/tools/ci/src/smoketest.rs index 41bc8619841..44b33f3bad0 100644 --- a/tools/ci/src/smoketest.rs +++ b/tools/ci/src/smoketest.rs @@ -3,8 +3,7 @@ use anyhow::{bail, ensure, Context, Result}; use clap::{Args, Subcommand}; use duct::cmd; use spacetimedb_guard::ensure_binaries_built; -use std::ffi::OsStr; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::{env, fs}; use tempfile::TempDir; @@ -37,6 +36,7 @@ pub struct SmoketestsArgs { #[arg(long, num_args = 0..=1, require_equals = true, default_missing_value = "")] auth_host: Option, + /// Whether to run smoketests that require .NET. #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] dotnet: bool, @@ -51,22 +51,50 @@ enum SmoketestCmd { /// /// Use this before running `cargo test --all` to ensure binaries are built. Prepare, + /// Build the smoketest dependencies and create a nextest archive. + Archive { + /// Path to the nextest archive to create. + #[arg(long)] + archive_file: PathBuf, + }, + /// Run smoketests from an existing nextest archive without rebuilding. + RunArchive { + /// Path to the nextest archive to run. + #[arg(long)] + archive_file: PathBuf, + + /// Additional arguments to pass to nextest. + #[arg(trailing_var_arg = true)] + args: Vec, + }, CheckModList, } pub fn run(args: SmoketestsArgs) -> Result<()> { - match args.cmd { + let SmoketestsArgs { + cmd, + server, + auth_host, + dotnet, + args, + } = args; + + match cmd { Some(SmoketestCmd::Prepare) => { build_binaries()?; eprintln!("Binaries ready. You can now run `cargo test --all`."); Ok(()) } + Some(SmoketestCmd::Archive { archive_file }) => archive_smoketests(&archive_file), + Some(SmoketestCmd::RunArchive { archive_file, args }) => { + run_smoketest_archive(server, dotnet, auth_host.as_deref(), &archive_file, args) + } Some(SmoketestCmd::CheckModList) => { check_smoketests_mod_rs_complete()?; eprintln!("smoketests/mod.rs is up to date."); Ok(()) } - None => run_smoketest(args.server, args.dotnet, args.auth_host.as_deref(), args.args), + None => run_smoketest(server, dotnet, auth_host.as_deref(), args), } } @@ -76,6 +104,7 @@ fn build_binaries() -> Result<()> { let mut cmd = Command::new("cargo"); cmd.args([ "build", + "--timings", "--release", "-p", "spacetimedb-cli", @@ -86,10 +115,16 @@ fn build_binaries() -> Result<()> { ]); // Remove cargo/rust env vars that could cause fingerprint mismatches - // when the test later runs cargo build from a different environment + // when the test later runs cargo build from a different environment. + // CI intentionally overrides release LTO on Windows and supplies a cached + // V8 archive, so preserve those settings across every build performed by + // the smoketest build job. for (key, _) in env::vars() { - let should_remove = (key.starts_with("CARGO") && key != "CARGO_HOME" && key != "CARGO_TARGET_DIR") - || key.starts_with("RUST") + let should_remove = (key.starts_with("CARGO") + && key != "CARGO_HOME" + && key != "CARGO_TARGET_DIR" + && key != "CARGO_PROFILE_RELEASE_LTO") + || (key.starts_with("RUST") && key != "RUSTY_V8_ARCHIVE") // > The environment variable `__CARGO_FIX_YOLO` is an undocumented, internal-use-only feature // > for the Rust cargo fix command (and cargo clippy --fix) that forces the application of all // > available suggestions, including those that are marked as potentially incorrect or dangerous. @@ -137,6 +172,31 @@ fn build_precompiled_modules() -> Result<()> { /// 16 was found to be optimal - higher values cause OS scheduler overhead. const DEFAULT_PARALLELISM: &str = "16"; +fn archive_smoketests(archive_file: &Path) -> Result<()> { + // Validate the source module list before doing any expensive compilation. + check_smoketests_mod_rs_complete()?; + build_binaries()?; + build_precompiled_modules()?; + + eprintln!("Building and archiving smoketest binaries..."); + let status = Command::new("cargo") + .args([ + "nextest", + "archive", + "--release", + "-p", + "spacetimedb-smoketests", + "--archive-file", + ]) + .arg(archive_file) + .status() + .context("failed to run cargo nextest archive")?; + + ensure!(status.success(), "Failed to archive smoketest binaries"); + eprintln!("Smoketest archive written to {}.\n", archive_file.display()); + Ok(()) +} + fn run_smoketest(server: Option, dotnet: bool, auth_host: Option<&str>, args: Vec) -> Result<()> { // 1. Build binaries first (single process, no race) build_binaries()?; @@ -202,6 +262,53 @@ fn run_smoketest(server: Option, dotnet: bool, auth_host: Option<&str>, Ok(()) } +fn run_smoketest_archive( + server: Option, + dotnet: bool, + auth_host: Option<&str>, + archive_file: &Path, + args: Vec, +) -> Result<()> { + let cli_path = ensure_binaries_built(); + let base_config_dir = prepare_base_config(&cli_path, server.as_deref(), auth_host)?; + let base_config_path = base_config_dir.path().join("config.toml"); + let workspace_root = env::current_dir().context("failed to resolve workspace root")?; + + if let Some(ref server_url) = server { + eprintln!("Running archived smoketests against remote server {server_url}...\n"); + } else { + eprintln!("Running smoketests from archive {}...\n", archive_file.display()); + } + + let mut cmd = Command::new("cargo"); + set_env(&mut cmd, server, dotnet, auth_host.is_some(), &base_config_path); + cmd.args(["nextest", "run", "--archive-file"]) + .arg(archive_file) + .arg("--workspace-remap") + .arg(workspace_root) + .arg("--no-fail-fast"); + + if !args + .iter() + .any(|a| a.starts_with("-j") || a.starts_with("--jobs") || a.starts_with("--test-threads")) + { + cmd.args(["-j", DEFAULT_PARALLELISM]); + } + + let status = cmd + .args(&args) + .status() + .context("failed to run smoketests from nextest archive")?; + ensure!(status.success(), "Tests failed"); + + let diff_status = cmd!("bash", "tools/check-diff.sh", "crates/smoketests").run()?; + ensure!( + diff_status.status.success(), + "There is a diff in the smoketests directory." + ); + Ok(()) +} + fn prepare_base_config(cli_path: &Path, server: Option<&str>, auth_host: Option<&str>) -> Result { if server.is_none() && auth_host.is_some() { bail!("--auth-host requires --server"); @@ -296,27 +403,21 @@ fn check_smoketests_mod_rs_complete() -> Result<()> { let ft = entry.file_type()?; if ft.is_dir() { expected.insert(name.to_string()); - } else if ft.is_file() - && path.extension() == Some(OsStr::new("rs")) - && let Some(stem) = path.file_stem() - { + } else if ft.is_file() && path.extension().is_some_and(|extension| extension == "rs") { + let stem = path.file_stem().context("Rust smoketest module has no file stem")?; expected.insert(stem.to_string_lossy().to_string()); } } - let out = cmd!("cargo", "test", "-p", "spacetimedb-smoketests", "--", "--list",).read()?; - let mut present = std::collections::BTreeSet::::new(); - for line in out.lines() { - let line = line.trim(); - let parts: Vec<&str> = line.split("::").collect(); - if parts.len() < 2 { - continue; - } - if parts[0] != "smoketests" { - continue; + let mod_rs = fs::read_to_string(expected_dir.join("mod.rs"))?; + for line in mod_rs.lines() { + let line = line.split_once("//").map_or(line, |(code, _)| code).trim(); + let declaration = line.strip_prefix("mod ").or_else(|| line.strip_prefix("pub mod ")); + if let Some(module) = declaration.and_then(|declaration| declaration.strip_suffix(';')) { + let module = module.trim(); + present.insert(module.strip_prefix("r#").unwrap_or(module).to_string()); } - present.insert(parts[1].to_string()); } let missing = expected @@ -326,7 +427,7 @@ fn check_smoketests_mod_rs_complete() -> Result<()> { if !missing.is_empty() { bail!( - "crates/smoketests/tests/smoketests/mod.rs appears incomplete; missing modules (not present in `cargo test -- --list`):\n{}", + "crates/smoketests/tests/smoketests/mod.rs appears incomplete; missing modules:\n{}", missing .iter() .map(|m| format!("- mod {m};"))