feat(settings): tenant self-service settings, validate TENANT scope_id (#382) [stack 10/11] #437
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PR | |
| on: | |
| pull_request: | |
| branches: [main] | |
| # Cancel in-progress runs when new commits are pushed to the same PR | |
| # so we never waste minutes on outdated code. On main (push), github.ref is | |
| # refs/heads/main — separate concurrency key from PR runs. | |
| concurrency: | |
| group: pr-${{ github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| env: | |
| NODE_VERSION: "24" | |
| # Hash all workspace pyproject.toml files so cache invalidates when any | |
| # member's deps change. uv.lock is gitignored, so this is the best key | |
| # available; commit uv.lock to get true lockfile-based caching. | |
| UV_CACHE_GLOB: | | |
| pyproject.toml | |
| framework/*/pyproject.toml | |
| modules/*/pyproject.toml | |
| host/pyproject.toml | |
| jobs: | |
| python-lint: | |
| name: Python lint & format | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: astral-sh/setup-uv@v9.0.0 | |
| with: | |
| enable-cache: true | |
| cache-dependency-glob: ${{ env.UV_CACHE_GLOB }} | |
| - run: make install-py | |
| - run: make ci-python-lint | |
| python-typecheck: | |
| name: Python typecheck | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: astral-sh/setup-uv@v9.0.0 | |
| with: | |
| enable-cache: true | |
| cache-dependency-glob: ${{ env.UV_CACHE_GLOB }} | |
| - run: make install-py | |
| - run: make ci-python-typecheck | |
| python-tests: | |
| name: Python tests | |
| runs-on: ubuntu-latest | |
| # The invalidation suite covers what a fake Redis client cannot — a real | |
| # subscription, and two worker processes talking through one broker. The | |
| # runner image does not ship redis-server, so the broker comes from a service | |
| # container; SM_TEST_REDIS_URL points the ``redis_server`` fixture at it | |
| # instead of having it start its own. Without this the fixture fails the job | |
| # by design rather than skipping those tests silently. | |
| services: | |
| redis: | |
| image: redis:7-alpine | |
| ports: ["6379:6379"] | |
| options: >- | |
| --health-cmd "redis-cli ping" | |
| --health-interval 5s | |
| --health-timeout 3s | |
| --health-retries 10 | |
| env: | |
| SM_TEST_REDIS_URL: redis://127.0.0.1:6379/0 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: astral-sh/setup-uv@v9.0.0 | |
| with: | |
| enable-cache: true | |
| cache-dependency-glob: ${{ env.UV_CACHE_GLOB }} | |
| - run: make install-py | |
| - run: make test-py | |
| js-tests: | |
| name: JS tests (Vitest) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: "npm" | |
| - run: make install-js | |
| - run: make test-js | |
| js-lint: | |
| name: JS lint & format | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: "npm" | |
| - run: make install-js | |
| - run: make ci-js-lint | |
| # Catches user-visible text rendered as a literal instead of t(keys.…). | |
| # Shipping a locales/en.json never proved a page actually read it. | |
| - run: make ci-check-untranslated | |
| js-typecheck: | |
| name: JS typecheck | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: "npm" | |
| # Need uv too — pages.ts imports ./modules.generated, which is | |
| # produced by `smpy gen-pages` from the installed Python modules. | |
| - uses: astral-sh/setup-uv@v9.0.0 | |
| with: | |
| enable-cache: true | |
| cache-dependency-glob: ${{ env.UV_CACHE_GLOB }} | |
| - run: make install-py | |
| - run: make install-js | |
| - run: make gen-pages | |
| - run: make ci-js-typecheck | |
| js-build: | |
| name: JS build (Vite) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: "npm" | |
| # gen-pages needs the Python modules installed so Vite's import.meta.glob | |
| # sees every module's pages/ dir — which is how dep-scan failures surface. | |
| - uses: astral-sh/setup-uv@v9.0.0 | |
| with: | |
| enable-cache: true | |
| cache-dependency-glob: ${{ env.UV_CACHE_GLOB }} | |
| - run: make install-py | |
| - run: make install-js | |
| - run: make gen-pages | |
| - run: make build | |
| # These drive a real gen-pages + vite build to prove module-shipped CSS | |
| # and cross-module npm-name imports actually resolve. They self-skip | |
| # without node_modules, so the `Python tests` job (make install-py only) | |
| # silently skips them — this is the one job that can really run them. | |
| - name: Module asset build guards | |
| run: uv run pytest framework/cli/tests/test_module_css_build.py | |
| e2e-smoke: | |
| name: E2E smoke (Playwright) | |
| runs-on: ubuntu-latest | |
| services: | |
| redis: | |
| image: redis:7-alpine | |
| ports: ["6379:6379"] | |
| options: >- | |
| --health-cmd "redis-cli ping" | |
| --health-interval 5s | |
| --health-timeout 3s | |
| --health-retries 10 | |
| env: | |
| # SQLite keeps the job self-contained — no postgres service needed. | |
| # The bootstrap vars create the admin that tests/e2e/test_smoke.py | |
| # logs in as (admin@example.com / admin). | |
| SM_DATABASE_URL: sqlite+aiosqlite:///./app.db | |
| SM_ENVIRONMENT: development | |
| SM_SECRET_KEY: ci-test-key | |
| SM_USERS_BOOTSTRAP_EMAIL: admin@example.com | |
| SM_USERS_BOOTSTRAP_PASSWORD: admin | |
| # Exclude Keycloak module — SM020 prevents both users and keycloak | |
| # from running simultaneously. E2E tests use the users module. | |
| # Branding is included so the admin section's "edited on its own page, | |
| # never in the generic module editor" rule is actually exercised — the | |
| # server-side half of that is a 409 guard, which is worth CI coverage. | |
| SM_MODULES_ENABLED: '["Auth","Users","Dashboard","Permissions","Settings","BackgroundTasks","FileStorage","FeatureFlags","AuditLog","Branding"]' | |
| E2E_BASE_URL: http://localhost:8000 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: astral-sh/setup-uv@v9.0.0 | |
| with: | |
| enable-cache: true | |
| cache-dependency-glob: ${{ env.UV_CACHE_GLOB }} | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: "npm" | |
| - run: make install | |
| # Playwright's chromium download is ~150MB — cache it keyed on uv.lock so | |
| # we only re-download when the pinned playwright version moves. OS deps | |
| # (apt packages) aren't cacheable across runs but install-deps is fast on | |
| # the GH runner image since most libs are preinstalled. | |
| - name: Cache Playwright browsers | |
| id: playwright-cache | |
| uses: actions/cache@v6 | |
| with: | |
| path: ~/.cache/ms-playwright | |
| key: playwright-${{ runner.os }}-${{ hashFiles('uv.lock') }} | |
| - name: Install Playwright chromium | |
| run: | | |
| if [ "${{ steps.playwright-cache.outputs.cache-hit }}" = "true" ]; then | |
| uv run --project host playwright install-deps chromium | |
| else | |
| uv run --project host playwright install --with-deps chromium | |
| fi | |
| - run: make gen-pages | |
| - run: uv run --project host alembic -c host/alembic.ini upgrade heads | |
| - name: Start API + Vite | |
| run: | | |
| uv run --project host uvicorn host.main:app --port 8000 > api.log 2>&1 & | |
| echo $! > api.pid | |
| npm run --workspace host/client_app dev > vite.log 2>&1 & | |
| echo $! > vite.pid | |
| - name: Wait for API + Vite | |
| run: | | |
| for i in $(seq 1 60); do | |
| curl -sf http://localhost:8000/health > /dev/null && \ | |
| curl -sf http://localhost:5050/@vite/client > /dev/null && \ | |
| echo "services ready" && exit 0 | |
| sleep 1 | |
| done | |
| echo "services did not come up in time" | |
| echo "--- api.log ---"; cat api.log || true | |
| echo "--- vite.log ---"; cat vite.log || true | |
| exit 1 | |
| - run: make test-e2e | |
| - name: Upload server logs on failure | |
| if: failure() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: e2e-server-logs | |
| path: | | |
| api.log | |
| vite.log | |
| # Guards the asset-delivery wins from docs/perf/2026-08-02-baseline.md: | |
| # response compression (~70% of transfer) and chunk grouping (55 -> 13 | |
| # requests on cold load). Both are invisible to every other job — the app | |
| # renders identically either way, just slower, so only a browser measuring | |
| # the built bundle catches a regression. | |
| # | |
| # Runs against the PRODUCTION build deliberately: chunk groups only apply to | |
| # `vite build`, and non-dev environments are what reference the built | |
| # manifest. Asserts on structure (request count, compression ratio), never on | |
| # milliseconds, so shared-runner noise cannot make it flaky. | |
| # | |
| # The other three guards (compression headers, canonical menu URLs, dialect | |
| # branch selection) are plain pytest and already run in `python-tests`. | |
| perf-guards: | |
| name: Perf guards (Playwright) | |
| runs-on: ubuntu-latest | |
| env: | |
| # SQLite keeps the job self-contained — these guards measure asset | |
| # delivery, which does not depend on row volumes. | |
| SM_DATABASE_URL: sqlite+aiosqlite:///./app.db | |
| PERF_BASE_URL: http://localhost:8000 | |
| PERF_BUILD: ci-prod | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: astral-sh/setup-uv@v9.0.0 | |
| with: | |
| enable-cache: true | |
| cache-dependency-glob: ${{ env.UV_CACHE_GLOB }} | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: "npm" | |
| - run: make install | |
| - name: Cache Playwright browsers | |
| id: playwright-cache | |
| uses: actions/cache@v6 | |
| with: | |
| path: ~/.cache/ms-playwright | |
| key: playwright-${{ runner.os }}-${{ hashFiles('uv.lock') }} | |
| - name: Install Playwright chromium | |
| run: | | |
| if [ "${{ steps.playwright-cache.outputs.cache-hit }}" = "true" ]; then | |
| uv run --project host playwright install-deps chromium | |
| else | |
| uv run --project host playwright install --with-deps chromium | |
| fi | |
| - run: make gen-pages | |
| - run: make build | |
| - run: uv run --project host alembic -c host/alembic.ini upgrade heads | |
| # The production config lives on THIS step only, never job-wide. Under | |
| # SM_ENVIRONMENT=production the simple_module_test pytest plugin fails to | |
| # import — it builds BackgroundTasksSettings() eagerly and those reject a | |
| # localhost broker — so exporting it job-wide stops pytest from starting | |
| # at all. The server needs it; the test process must not see it. | |
| - name: Start API | |
| env: | |
| # A non-dev environment is what makes the host serve the built | |
| # manifest instead of pointing at the Vite dev server. | |
| SM_ENVIRONMENT: production | |
| SM_SECRET_KEY: ci-perf-secret-key-not-a-real-secret-000000000000 | |
| SM_USERS_RESET_PASSWORD_TOKEN_SECRET: ci-perf-reset-secret-000000000000000000 | |
| SM_USERS_VERIFICATION_TOKEN_SECRET: ci-perf-verify-secret-00000000000000000 | |
| SM_USERS_BOOTSTRAP_EMAIL: admin@example.com | |
| SM_USERS_BOOTSTRAP_PASSWORD: admin | |
| # Keycloak excluded (SM020: one auth provider). BackgroundTasks | |
| # excluded because its DB-hydrated settings reject a localhost broker | |
| # under SM_ENVIRONMENT=production. | |
| SM_MODULES_ENABLED: '["Auth","Users","Dashboard","Permissions","Settings","FileStorage","FeatureFlags","AuditLog","Branding"]' | |
| run: | | |
| uv run --project host uvicorn host.main:app --port 8000 > api.log 2>&1 & | |
| echo $! > api.pid | |
| - name: Wait for API | |
| run: | | |
| for i in $(seq 1 60); do | |
| curl -sf http://localhost:8000/health > /dev/null && echo "api ready" && exit 0 | |
| sleep 1 | |
| done | |
| echo "api did not come up in time"; cat api.log || true; exit 1 | |
| - name: Verify built assets are being served | |
| run: | | |
| # If this regresses to the Vite dev path the guards would measure the | |
| # wrong bundle and pass vacuously. | |
| curl -sf http://localhost:8000/users/login | grep -q '/static/dist/assets/' \ | |
| || { echo "server is not serving built assets"; exit 1; } | |
| - run: uv run pytest -m "perf and e2e" tests/perf/test_page_load.py tests/perf/test_asset_integrity.py -v -s | |
| - name: Upload server log on failure | |
| if: failure() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: perf-guards-api-log | |
| path: api.log | |
| file-size-check: | |
| name: File size (300-line cap) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: astral-sh/setup-uv@v9.0.0 | |
| with: | |
| enable-cache: true | |
| cache-dependency-glob: ${{ env.UV_CACHE_GLOB }} | |
| - run: make install-py | |
| - run: make ci-check-file-size | |
| # Dry-run the release build on every PR — same `uv build --all-packages` | |
| # invocation the release workflow runs, so any packaging regression | |
| # (force-include going outside the package, missing template, broken sdist→ | |
| # wheel rebuild, etc.) fails here instead of only when someone clicks | |
| # "Run workflow" on Releases. No deps install needed: build isolation | |
| # provisions each package's build backend on its own. | |
| package-build: | |
| name: Package build (release dry-run) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: astral-sh/setup-uv@v9.0.0 | |
| with: | |
| enable-cache: true | |
| cache-dependency-glob: ${{ env.UV_CACHE_GLOB }} | |
| - run: make ci-build-packages | |
| # Single required status check for branch protection. | |
| # Protect `main` with this one check and every leaf job is required transitively. | |
| pr-checks: | |
| name: PR checks | |
| runs-on: ubuntu-latest | |
| needs: | |
| - python-lint | |
| - python-typecheck | |
| - python-tests | |
| - js-lint | |
| - js-typecheck | |
| - js-tests | |
| - js-build | |
| - e2e-smoke | |
| - perf-guards | |
| - file-size-check | |
| - package-build | |
| if: always() | |
| steps: | |
| - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') | |
| run: exit 1 |