fix(api): keep open usage periods in step with their boxes #3400
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Run unit tests across all SDKs. | |
| # | |
| # This workflow runs unit tests only (no VM/integration tests). | |
| # GitHub runners do not support nested virtualization, so we cannot run | |
| # tests that require actual VMs. | |
| # | |
| # Test coverage: | |
| # - Rust: cargo-nextest (unit tests only, with llvm-cov coverage) | |
| # - Python: pytest with -m "not integration" | |
| # - Node.js: vitest (all tests are unit tests) | |
| name: Test | |
| on: | |
| merge_group: | |
| push: | |
| branches: [main] | |
| paths: | |
| - 'src/boxlite/**' | |
| - 'src/shared/**' | |
| - 'src/cli/**' | |
| - 'src/guest/**' | |
| - 'sdks/**' | |
| - 'apps/**' | |
| - '!apps/e2e/**' # Stack e2e runs in e2e-stack.yml / e2e-cloud.yml, not this unit matrix. | |
| - '**/Cargo.toml' | |
| - 'Cargo.lock' | |
| - '.github/workflows/test.yml' | |
| - '.github/workflows/config.yml' | |
| # No path filter on pull_request: the `Test (conclusion)` job is a required | |
| # status check, so this workflow must run on EVERY PR and report it. A | |
| # path-skipped workflow leaves the required check stuck "Pending" → PR blocked. | |
| # Per-suite filtering still happens in the `changes` job (jobs skip when | |
| # unaffected); the conclusion job reports success-or-skipped. | |
| pull_request: | |
| branches: [main] | |
| env: | |
| CARGO_TERM_COLOR: always | |
| CARGO_INCREMENTAL: '0' | |
| jobs: | |
| # Load shared configuration | |
| config: | |
| uses: ./.github/workflows/config.yml | |
| # Detect which files changed to conditionally run tests | |
| changes: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| pull-requests: read | |
| outputs: | |
| rust: ${{ steps.filter.outputs.rust }} | |
| cli: ${{ steps.filter.outputs.boxlite_cli }} | |
| python: ${{ steps.filter.outputs.python }} | |
| node: ${{ steps.filter.outputs.node }} | |
| go: ${{ steps.filter.outputs.go }} | |
| apps: ${{ steps.filter.outputs.apps }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - uses: dorny/paths-filter@v4 | |
| id: filter | |
| with: | |
| # In a merge queue, diff against the merge-group base/head (no PR base | |
| # exists). Empty on pull_request/push, where paths-filter ignores them. | |
| base: ${{ github.event_name == 'merge_group' && github.event.merge_group.base_sha || '' }} | |
| ref: ${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || '' }} | |
| filters: | | |
| rust: | |
| - 'src/boxlite/**' | |
| - 'src/shared/**' | |
| - 'src/guest/**' | |
| - '**/Cargo.toml' | |
| - 'Cargo.lock' | |
| cli: | |
| - 'src/cli/**' | |
| python: | |
| - 'sdks/python/**' | |
| node: | |
| - 'sdks/node/**' | |
| go: | |
| - 'sdks/go/**' | |
| apps: | |
| # paths-filter ORs its rules, so a '!apps/e2e/**' rule would make | |
| # this filter true for everything outside apps/e2e. Carve the stack | |
| # e2e suite out inside a single pattern instead; 'apps/*' keeps | |
| # top-level files (package.json, nx.json) in scope. Note !(e2e) | |
| # compiles to a segment-prefix negative lookahead, so a future | |
| # apps/e2e-<something> sibling would drop out of this filter too. | |
| - 'apps/*' | |
| - 'apps/!(e2e)/**' | |
| # Rust unit tests (boxlite-shared only - boxlite requires native libs not available in CI) | |
| rust: | |
| name: Rust Tests (${{ matrix.platform.target }}) | |
| needs: [config, changes] | |
| if: ${{ needs.changes.outputs.rust == 'true' }} | |
| runs-on: ${{ matrix.platform.os }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| platform: ${{ fromJson(needs.config.outputs.platforms) }} | |
| permissions: | |
| id-token: write | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v5 | |
| - name: Install Rust | |
| uses: actions-rust-lang/setup-rust-toolchain@v1 | |
| with: | |
| toolchain: ${{ needs.config.outputs.rust-toolchain }} | |
| - name: Install system dependencies (Linux) | |
| if: runner.os == 'Linux' | |
| run: sudo apt-get update && sudo apt-get install -y libseccomp-dev protobuf-compiler | |
| - name: Install system dependencies (macOS) | |
| if: runner.os == 'macOS' | |
| run: brew install protobuf | |
| - name: Install cargo-nextest | |
| uses: taiki-e/install-action@nextest | |
| - name: Install cargo-llvm-cov | |
| uses: taiki-e/install-action@cargo-llvm-cov | |
| - name: Run Rust unit tests with coverage | |
| # Only test boxlite-shared - boxlite requires libkrun/libgvproxy not available in CI | |
| run: cargo llvm-cov nextest -p boxlite-shared --lib --profile ci --lcov --output-path lcov.info | |
| - name: Run guest unit tests | |
| if: runner.os == 'Linux' | |
| run: make test:unit:guest | |
| - name: Upload coverage to Codecov | |
| uses: codecov/codecov-action@v5 | |
| with: | |
| file: lcov.info | |
| flags: rust-shared | |
| use_oidc: true | |
| fail_ci_if_error: false | |
| # cli unit tests (integration tests in tests/ are ignored - require VM) | |
| cli: | |
| name: CLI Unit Tests (${{ matrix.platform.target }}) | |
| needs: [config, changes] | |
| if: ${{ needs.changes.outputs.cli == 'true' }} | |
| runs-on: ${{ matrix.platform.os }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| platform: ${{ fromJson(needs.config.outputs.platforms) }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v5 | |
| - name: Install Rust | |
| uses: actions-rust-lang/setup-rust-toolchain@v1 | |
| with: | |
| toolchain: ${{ needs.config.outputs.rust-toolchain }} | |
| - name: Install system dependencies (Linux) | |
| if: runner.os == 'Linux' | |
| run: sudo apt-get update && sudo apt-get install -y protobuf-compiler | |
| - name: Install system dependencies (macOS) | |
| if: runner.os == 'macOS' | |
| run: brew install protobuf | |
| - name: Install cargo-nextest | |
| uses: taiki-e/install-action@nextest | |
| - name: Setup sccache | |
| uses: mozilla-actions/sccache-action@v0.0.9 | |
| continue-on-error: true | |
| - name: Export GHA cache env vars | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || ''); | |
| core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); | |
| core.exportVariable('ACTIONS_CACHE_SERVICE_V2', process.env.ACTIONS_CACHE_SERVICE_V2 || ''); | |
| - name: Run boxlite-cli unit tests | |
| # Only run tests in src/ (name contains "::tests::"); skip integration tests in tests/ (require VM) | |
| env: | |
| BOXLITE_DEPS_STUB: "1" | |
| run: cargo nextest run -p boxlite-cli --profile ci -E 'test(::tests::)' | |
| # Python SDK unit tests | |
| python: | |
| name: Python Tests (${{ matrix.platform.target }} / Python ${{ matrix.python-version }}) | |
| needs: [config, changes] | |
| if: ${{ needs.changes.outputs.python == 'true' }} | |
| runs-on: ${{ matrix.platform.os }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| platform: ${{ fromJson(needs.config.outputs.platforms) }} | |
| python-version: ${{ fromJson(needs.config.outputs.python-versions) }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v5 | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: ${{ matrix.python-version }} | |
| - name: Install test dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install pytest pytest-asyncio | |
| - name: Run Python unit tests | |
| working-directory: sdks/python | |
| run: python -m pytest tests/ -v -m "not integration" | |
| # Node.js SDK unit tests | |
| node: | |
| name: Node.js Tests (${{ matrix.platform.target }} / Node ${{ matrix.node-version }}) | |
| needs: [config, changes] | |
| if: ${{ needs.changes.outputs.node == 'true' }} | |
| runs-on: ${{ matrix.platform.os }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| platform: ${{ fromJson(needs.config.outputs.platforms) }} | |
| node-version: ${{ fromJson(needs.config.outputs.node-versions) }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v5 | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: ${{ matrix.node-version }} | |
| - name: Install dependencies | |
| working-directory: sdks/node | |
| run: npm install | |
| - name: Run Node.js unit tests | |
| working-directory: sdks/node | |
| run: npm run test | |
| # Go SDK unit tests | |
| go: | |
| name: Go Tests (${{ matrix.platform.target }}) | |
| needs: [config, changes] | |
| if: ${{ needs.changes.outputs.go == 'true' }} | |
| runs-on: ${{ matrix.platform.os }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| platform: ${{ fromJson(needs.config.outputs.platforms) }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v5 | |
| - name: Set up Go | |
| uses: actions/setup-go@v5 | |
| with: | |
| go-version: '1.24' | |
| - name: Install Rust | |
| uses: actions-rust-lang/setup-rust-toolchain@v1 | |
| with: | |
| toolchain: ${{ needs.config.outputs.rust-toolchain }} | |
| - name: Install build dependencies | |
| run: make setup:build | |
| - name: Setup sccache | |
| uses: mozilla-actions/sccache-action@v0.0.9 | |
| continue-on-error: true | |
| - name: Export GHA cache env vars | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || ''); | |
| core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); | |
| core.exportVariable('ACTIONS_CACHE_SERVICE_V2', process.env.ACTIONS_CACHE_SERVICE_V2 || ''); | |
| - name: Run Go unit tests | |
| run: make test:unit:go | |
| # apps/api (NestJS) unit tests — the nx `api` project's jest suite (org-quota, | |
| # box-usage, box.service, organization, etc.). Pure/mocked specs, no native libs. | |
| apps: | |
| name: Apps API Tests | |
| needs: [config, changes] | |
| if: ${{ needs.changes.outputs.apps == 'true' }} | |
| runs-on: ubuntu-latest | |
| # Read-only token: this job runs repository-controlled code (yarn install | |
| # build scripts, Nx/Jest), so it must not carry write scopes. | |
| permissions: | |
| contents: read | |
| # Redis lets the quota engine's real-Lua integration spec run and Postgres | |
| # lets the usage ledger's spec run (each self-skips without its env vars); | |
| # the rest of the api suite is pure/mocked. | |
| services: | |
| redis: | |
| image: redis:7 | |
| ports: | |
| - 6379:6379 | |
| # Wait for Redis to accept connections before the job runs, so the | |
| # real-Lua integration spec doesn't race container startup. | |
| options: >- | |
| --health-cmd "redis-cli ping" | |
| --health-interval 10s | |
| --health-timeout 5s | |
| --health-retries 5 | |
| postgres: | |
| image: postgres:16 | |
| env: | |
| POSTGRES_PASSWORD: postgres | |
| POSTGRES_DB: boxlite | |
| ports: | |
| - 5432:5432 | |
| options: >- | |
| --health-cmd "pg_isready -U postgres" | |
| --health-interval 10s | |
| --health-timeout 5s | |
| --health-retries 5 | |
| env: | |
| REDIS_HOST: localhost | |
| DB_HOST: localhost | |
| DB_PORT: 5432 | |
| DB_USERNAME: postgres | |
| DB_PASSWORD: postgres | |
| DB_DATABASE: boxlite | |
| defaults: | |
| run: | |
| working-directory: apps | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v5 | |
| with: | |
| submodules: recursive | |
| # Don't persist GITHUB_TOKEN in .git/config before untrusted code runs. | |
| persist-credentials: false | |
| # corepack must be enabled before setup-node's yarn cache probe runs | |
| - name: Enable corepack (yarn 4) | |
| run: corepack enable | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 22 | |
| cache: yarn | |
| cache-dependency-path: apps/yarn.lock | |
| - name: Install dependencies | |
| run: yarn install --immutable | |
| - name: Run apps/api unit tests | |
| run: yarn nx run api:test | |
| # Single required status check for branch protection / merge queue. | |
| # Passes only if every job above succeeded or was skipped (path-filtered). | |
| # `if: !cancelled()` is required: without it a failed dependency would skip | |
| # this job, and GitHub treats a skipped required check as success. | |
| # NOTE: keep `needs` in sync with the jobs above when adding/removing jobs. | |
| test-conclusion: | |
| name: Test (conclusion) | |
| needs: [config, changes, rust, cli, python, node, go, apps] | |
| if: ${{ !cancelled() }} | |
| runs-on: ubuntu-latest | |
| permissions: {} | |
| steps: | |
| - name: All test jobs passed or were skipped | |
| run: | | |
| jq -C <<< '${{ toJSON(needs) }}' | |
| jq --exit-status 'all(.result == "success" or .result == "skipped")' <<< '${{ toJSON(needs) }}' |