Skip to content

feat(settings): tenant self-service settings, validate TENANT scope_id (#382) [stack 10/11] #437

feat(settings): tenant self-service settings, validate TENANT scope_id (#382) [stack 10/11]

feat(settings): tenant self-service settings, validate TENANT scope_id (#382) [stack 10/11] #437

Workflow file for this run

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