Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions .github/workflows/postgres-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: Postgres tests

# The full Python suite on Postgres (`make test-py-pg`), including the
# `@pytest.mark.postgres` tests that SQLite cannot run. Slow and serial (every
# test resets one database), so it is not part of the PR aggregate: it runs
# nightly, on demand, and on PRs that touch the database/tenancy layers (#508).

on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
pull_request:
branches: [main]
paths:
- "framework/db/**"
- "framework/testing/**"
- "modules/tenants/**"
- "host/migrations/**"
- ".github/workflows/postgres-tests.yml"

concurrency:
group: pg-tests-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

env:
UV_SYNC_FLAGS: --locked
UV_CACHE_GLOB: |
uv.lock
pyproject.toml
framework/*/pyproject.toml
modules/*/pyproject.toml
host/pyproject.toml

jobs:
python-tests-pg:
name: Python tests (Postgres)
runs-on: ubuntu-latest
timeout-minutes: 60
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: sm_test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 3s
--health-retries 10
# Same broker the python-tests job uses for the invalidation suite.
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_PG_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/sm_test
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-pg
39 changes: 27 additions & 12 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@ permissions:

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.
# Makefile `install-py` appends this: CI fails on a stale uv.lock instead of
# silently re-resolving.
UV_SYNC_FLAGS: --locked
# uv.lock is committed and CI syncs with --locked, so it is the primary cache
# key; the workspace pyproject.toml files are hashed too so a manifest edit
# that forgot to refresh the lock still busts the cache (and then fails
# `uv sync --locked`).
UV_CACHE_GLOB: |
uv.lock
pyproject.toml
framework/*/pyproject.toml
modules/*/pyproject.toml
Expand All @@ -37,19 +42,15 @@ jobs:
cache-dependency-glob: ${{ env.UV_CACHE_GLOB }}
- run: make install-py
- run: make ci-python-lint
# Package metadata, release matrix, and the hand-maintained ty
# Package metadata, READMEs, release matrix, and the hand-maintained ty
# extra-paths / pytest testpaths lists (#520) must match what is on disk.
- name: Package metadata and tooling lists
run: uv run python scripts/check_metadata.py
- run: make ci-check-metadata
# Fixed sleeps in tests are timing bets; poll with simple_module_test.wait_for.
- run: make ci-check-test-sleeps
# The checked-in typed-key files must match what the installed modules'
# catalogs emit. Catches a catalog edit committed without regenerating, and
# (with the namespace guard in gen_i18n.py) a regeneration that dropped keys.
- name: i18n generated files are up to date
run: |
uv run --project host python scripts/gen_i18n.py
git diff --exit-code packages/i18n/src/
- run: make ci-i18n-fresh

python-typecheck:
name: Python typecheck
Expand Down Expand Up @@ -149,7 +150,6 @@ jobs:
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:
Expand All @@ -176,7 +176,7 @@ jobs:
# 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
run: make ci-css-build

# Migrations are autogenerated on SQLite, which hides Postgres-only defects
# (integer defaults on boolean columns, undetected expression indexes, enum
Expand Down Expand Up @@ -209,6 +209,20 @@ jobs:
- run: make install-py
- run: make migrations-roundtrip-pg

# The SM0xx diagnostics fail a production boot, so run them against a freshly
# migrated throwaway SQLite DB on every PR.
doctor:
name: Module diagnostics (make doctor)
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-doctor

e2e-smoke:
name: E2E smoke (Playwright)
runs-on: ubuntu-latest
Expand Down Expand Up @@ -436,6 +450,7 @@ jobs:
- js-tests
- js-build
- migrations-roundtrip-pg
- doctor
- e2e-smoke
- perf-guards
- file-size-check
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
- name: Bump all package versions (working tree only)
run: uv run python scripts/bump_version.py "${{ needs.resolve.outputs.version }}"
# Workspace members are pinned in uv.lock (version + ==pins); PR CI runs
# `uv sync --locked`, so a bump that skips this breaks every PR after it.
- name: Regenerate uv lockfile
run: uv lock
- name: Regenerate npm lockfile
run: npm install --package-lock-only
- name: Build Python wheels + sdists
Expand Down Expand Up @@ -218,6 +222,10 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
- name: Re-apply version bump
run: uv run python scripts/bump_version.py "${{ needs.resolve.outputs.version }}"
# Workspace members are pinned in uv.lock (version + ==pins); PR CI runs
# `uv sync --locked`, so a bump that skips this breaks every PR after it.
- name: Regenerate uv lockfile
run: uv lock
- name: Regenerate npm lockfile
run: npm install --package-lock-only
- name: Commit, tag, push
Expand Down
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ build/
.venv/
*.egg

# UV
uv.lock

# Node
node_modules/
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ All notable changes to this project are documented in this file. The format is b
- `captured_mail` pytest fixture in `simple_module_test` records sent mail.

### Changed
- CI and local lint now share one definition (#508). `make ci-js-typecheck`
depends on `gen-pages`, so `make lint` works on a fresh clone. New targets
`ci-check-metadata` (now also runs `check_readmes.py` in CI), `ci-i18n-fresh`
(in `make lint`), `ci-css-build` and `ci-doctor`; `pr.yml` calls them. CI gains
a `doctor` job (`make doctor` against a freshly migrated SQLite DB) and a
separate `postgres-tests.yml` workflow (`make test-py-pg`; nightly, manual and
path-filtered PRs, not part of `pr-checks`).
- Biome runs with `--error-on-warnings`; the seven unused `type React` imports
and the useless fragment in `ErrorPage.test.tsx` are removed (#508).
- `uv.lock` is now committed (the ignore rule is gone), keys the CI caches, and
CI installs with `uv sync --locked` (`UV_SYNC_FLAGS`); `make install` is
unchanged (#508). The release workflow runs `uv lock` after
the version bump so the lock follows the lockstep versions.
- **Behaviour change:** the `users` mailer settings (`mailer`, `smtp_*`) moved to
the `mail` module as `backend` and `smtp_*`; a data migration (`d7b3a9e5c1f2`)
moves stored values (#496). `simple_module_users` now depends on `simple_module_mail`, and
Expand Down
25 changes: 17 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,21 @@ Run what CI runs, in this order; a red push costs a review cycle.
changes). Every feature/fix PR in recent history does this.

CI (`.github/workflows/pr.yml`, aggregate job `pr-checks`): python-lint (includes
the i18n up-to-date diff), python-typecheck, python-tests (Redis service),
js-tests, js-lint (includes the untranslated check), js-typecheck, js-build
(module CSS build guards), migrations-roundtrip-pg, e2e-smoke, perf-guards,
file-size-check, package-build. **Local-only checks** (in `make lint`, not in
CI, reviewers still reject on them): hardcoded strings, `check_metadata.py`,
`check_readmes.py`. e2e-smoke and perf-guards enable a hardcoded
`SM_MODULES_ENABLED` JSON list; a new module is not exercised there until added.
`make ci-check-metadata` and the `make ci-i18n-fresh` diff), python-typecheck,
python-tests (Redis service), js-tests, js-lint (`biome ci --error-on-warnings`,
plus the untranslated check), js-typecheck, js-build (`make ci-css-build`),
migrations-roundtrip-pg, doctor (`make ci-doctor`: migrate a throwaway SQLite DB,
then `make doctor`), e2e-smoke, perf-guards, file-size-check, package-build. CI
calls the same `make ci-*` targets as `make lint`, with `UV_SYNC_FLAGS=--locked`
so a stale `uv.lock` fails (commit `uv.lock` whenever a `pyproject.toml` changes;
`make install` stays unlocked). `.github/workflows/postgres-tests.yml` runs
`make test-py-pg` nightly, on demand and on PRs touching `framework/db`,
`framework/testing`, `modules/tenants` or `host/migrations`; it is not in
`pr-checks`. **Local-only checks** (in `make lint`, not in CI, reviewers still
reject on them): hardcoded strings. `make ci-css-build` is in CI but not in
`make lint` (slow, rewrites generated files). e2e-smoke and perf-guards enable a
hardcoded `SM_MODULES_ENABLED` JSON list; a new module is not exercised there
until added.

## Architecture

Expand Down Expand Up @@ -394,7 +402,8 @@ without constants or `label_key`, and sets `app.state.<pkg>` directly instead
of calling `register_module_settings`. Fix those first. It also does **not**:

1. Run `npm install`, so regenerate and commit `package-lock.json` (CI uses
`npm ci`).
`npm ci`). Commit the `uv.lock` change the scaffold's
`uv sync` makes too (CI uses `uv sync --locked`).
2. Add `simple_module_<name>` to the `publish-pypi` matrix in
`.github/workflows/release.yml`; a test and `check_metadata.py` fail until
you do. `pyproject.toml` needs description, `readme`, `license = "MIT"`,
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
&& rm -rf /var/lib/apt/lists/*

# Dependency layer: every workspace member's manifest, resolved before the
# full source arrives. `uv.lock` is gitignored in this repo, so it's an
# optional glob and the sync deliberately isn't `--frozen`.
# full source arrives. `uv.lock` is committed, but stays an optional glob
# (and the sync is not `--frozen`) so the build tolerates a stale lock.
COPY pyproject.toml uv.lock* ./
COPY framework/ framework/
COPY modules/ modules/
Expand Down
51 changes: 43 additions & 8 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-py-pg test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history migrations-roundtrip-pg docker-up docker-down kill new-module gen-pages openapi gen-i18n docker-build docker-app docker-compose-app sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-test-sleeps ci-check-hardcoded-strings ci-check-untranslated ci-build-packages worker beat worker-docker
.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-py-pg test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history migrations-roundtrip-pg docker-up docker-down kill new-module gen-pages openapi gen-i18n docker-build docker-app docker-compose-app sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-test-sleeps ci-check-hardcoded-strings ci-check-untranslated ci-check-metadata ci-i18n-fresh ci-css-build ci-doctor ci-build-packages worker beat worker-docker

# UV_SYNC_FLAGS is empty locally; CI passes --locked so a stale uv.lock fails the build.
UV_SYNC_FLAGS ?=

# Install
install:
uv sync --all-packages
uv sync $(UV_SYNC_FLAGS) --all-packages
npm install

# Install (granular — used by CI so Python jobs don't pull npm and vice versa)
install-py:
uv sync --all-packages
uv sync $(UV_SYNC_FLAGS) --all-packages

install-js:
npm ci
Expand Down Expand Up @@ -121,9 +124,7 @@ loadtest: ## Run locust against a server already on $(LOCUST_H
loadtest-memray: ## Start uvicorn under memray, load-test, emit flamegraph
scripts/loadtest_memray.sh $(LOCUST_ARGS)

lint: ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-test-sleeps ci-check-hardcoded-strings ci-check-untranslated
uv run python scripts/check_metadata.py
uv run python scripts/check_readmes.py
lint: ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-metadata ci-i18n-fresh ci-check-file-size ci-check-test-sleeps ci-check-hardcoded-strings ci-check-untranslated

# Kept granular so pr.yml can run them in parallel.
ci-python-lint:
Expand All @@ -134,9 +135,9 @@ ci-python-typecheck:
uv run ty check

ci-js-lint:
npx biome ci .
npx biome ci --error-on-warnings .

ci-js-typecheck:
ci-js-typecheck: gen-pages
npx tsc --noEmit -p host/client_app/tsconfig.json
@# Fail if a module ships .tsx files but has no tsconfig.json — otherwise
@# the type-check would silently skip the module. See SM017 in `make doctor`.
Expand All @@ -153,6 +154,31 @@ ci-js-typecheck:
npx tsc --noEmit -p "$$cfg" || exit 1; \
done

# Package metadata, release matrix, ty extra-paths / pytest testpaths (#520), and READMEs.
ci-check-metadata:
uv run python scripts/check_metadata.py
uv run python scripts/check_readmes.py

# The checked-in typed-key files must match what the installed modules' catalogs
# emit. Catches a catalog edit committed without regenerating, and (with the
# namespace guard in gen_i18n.py) a regeneration that dropped keys.
# Compares content before/after regeneration rather than `git diff`, so a locally
# regenerated-but-unstaged file passes and a stale one fails (CI checks out clean).
ci-i18n-fresh:
@before=$$(find packages/i18n/src -type f | sort | xargs cat | sha256sum); \
uv run --project host python scripts/gen_i18n.py || exit 1; \
after=$$(find packages/i18n/src -type f | sort | xargs cat | sha256sum); \
if [ "$$before" != "$$after" ]; then \
echo "packages/i18n/src was stale and has been regenerated: run 'make gen-i18n' and commit the result"; \
exit 1; \
fi

# Drives a real gen-pages + vite build to prove module CSS and cross-module npm
# imports resolve. Needs node_modules; slow and rewrites generated files, so it
# is not part of `lint`.
ci-css-build:
uv run pytest framework/cli/tests/test_module_css_build.py

# Enforce a max of 300 lines per .py/.ts/.tsx file.
# Exempts vendored shadcn components under packages/ui/src/components/ui/**.
ci-check-file-size:
Expand Down Expand Up @@ -188,6 +214,15 @@ ci-build-packages:
doctor:
uv run python -m simple_module_core

# Throwaway SQLite DB: migrate to heads, then run the SM0xx suite against it
# (the same checks that fail a production boot). Not in `lint`: it needs a DB.
CI_DOCTOR_DB ?= ci-doctor.db
ci-doctor:
rm -f $(CI_DOCTOR_DB)
SM_DATABASE_URL=sqlite+aiosqlite:///./$(CI_DOCTOR_DB) $(MAKE) migrate \
&& SM_DATABASE_URL=sqlite+aiosqlite:///./$(CI_DOCTOR_DB) $(MAKE) doctor; \
rc=$$?; rm -f $(CI_DOCTOR_DB)*; exit $$rc

# Database migrations
# All targets run from the repo root so alembic and `make dev-api` share the
# same cwd (and therefore the same .env, SM_DATABASE_URL, and SQLite path).
Expand Down
1 change: 0 additions & 1 deletion host/client_app/pages/Admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { NavIcon } from '@simple-module-py/ui/components/NavIcon';
import { PageShell } from '@simple-module-py/ui/components/PageShell';
import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout';
import type { MenuItem, SharedProps } from '@simple-module-py/ui/types';
import type React from 'react';

/**
* Admin section landing page.
Expand Down
1 change: 0 additions & 1 deletion modules/audit_log/audit_log/pages/Browse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { Button } from '@simple-module-py/ui/components/ui/button';
import { Card } from '@simple-module-py/ui/components/ui/card';
import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout';
import { Download } from 'lucide-react';
import type React from 'react';
import { useState } from 'react';
import { BrowseEmpty } from './components/BrowseEmpty';
import { CorrelationBanner } from './components/Correlation';
Expand Down
1 change: 0 additions & 1 deletion modules/dashboard/dashboard/pages/Doctor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { PageShell } from '@simple-module-py/ui/components/PageShell';
import { Button } from '@simple-module-py/ui/components/ui/button';
import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout';
import { RefreshCw } from 'lucide-react';
import type React from 'react';
import { toast } from 'sonner';
import { ChecksCard } from './components/doctor/ChecksCard';
import { useCheckLabels } from './components/doctor/check-copy';
Expand Down
1 change: 0 additions & 1 deletion modules/settings/settings/pages/Browse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { Card } from '@simple-module-py/ui/components/ui/card';
import { Input } from '@simple-module-py/ui/components/ui/input';
import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout';
import { Plus, Search, Settings as SettingsIcon, Trash2 } from 'lucide-react';
import type React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { type ScopeCounts, type ScopeFilter, ScopeTabs } from './components/ScopeTabs';
Expand Down
1 change: 0 additions & 1 deletion modules/settings/settings/pages/ModulesEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { Card } from '@simple-module-py/ui/components/ui/card';
import { Input } from '@simple-module-py/ui/components/ui/input';
import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout';
import { ArrowRight, Box, Search } from 'lucide-react';
import type React from 'react';
import { useMemo, useState } from 'react';
import { ModuleForm, type ModuleView } from './components/ModuleForm';
import { ROUTES } from './routes';
Expand Down
Loading
Loading