377 lines
14 KiB
YAML
377 lines
14 KiB
YAML
name: CI
|
|
|
|
on:
|
|
push:
|
|
# Every branch, not just master. `pull_request` below already gates the
|
|
# merge, so the branch filter here only decided whether work-in-progress
|
|
# got feedback before a PR existed, and the answer was no. `'**'` matches
|
|
# branch names containing slashes; naming `branches` at all is also what
|
|
# keeps tag pushes from triggering a second run of the same commit.
|
|
branches: ['**']
|
|
paths:
|
|
- 'backend/**'
|
|
- 'frontend/**'
|
|
- 'docs/**'
|
|
- 'mkdocs.yml'
|
|
- '.readthedocs.yaml'
|
|
- '.coveragerc'
|
|
- '.github/workflows/tests.yml'
|
|
pull_request:
|
|
branches: [master]
|
|
paths:
|
|
- 'backend/**'
|
|
- 'frontend/**'
|
|
- 'docs/**'
|
|
- 'mkdocs.yml'
|
|
- '.readthedocs.yaml'
|
|
- '.coveragerc'
|
|
- '.github/workflows/tests.yml'
|
|
|
|
# Running on every branch push means a branch with an open PR would otherwise
|
|
# bill two identical runs per push, and a rapid series of pushes would queue
|
|
# runs for commits nobody is waiting on any more. One group per ref, newest
|
|
# wins.
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
backend-tests:
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: write
|
|
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
|
|
- name: Install uv
|
|
uses: astral-sh/setup-uv@v10.0.1
|
|
with:
|
|
enable-cache: true
|
|
cache-dependency-glob: 'backend/uv.lock'
|
|
|
|
- name: Install system dependencies (WeasyPrint)
|
|
run: |
|
|
sudo apt-get update
|
|
sudo apt-get install -y \
|
|
libpango-1.0-0 \
|
|
libpangocairo-1.0-0 \
|
|
libgdk-pixbuf-2.0-0 \
|
|
libffi-dev \
|
|
shared-mime-info
|
|
|
|
- name: Install Python dependencies
|
|
working-directory: backend
|
|
run: uv sync --frozen
|
|
|
|
# Running the migrations cannot notice a model change that has no
|
|
# migration yet, so this checks for one explicitly.
|
|
- name: Check for missing migrations
|
|
working-directory: backend
|
|
env:
|
|
DJANGO_SETTINGS_MODULE: crm.test_settings
|
|
SECRET_KEY: test-secret-key-for-ci
|
|
ADMIN_EMAIL: admin@test.com
|
|
run: uv run python manage.py makemigrations --check --dry-run
|
|
|
|
- name: Run tests with coverage
|
|
working-directory: backend
|
|
env:
|
|
DJANGO_SETTINGS_MODULE: crm.test_settings
|
|
SECRET_KEY: test-secret-key-for-ci
|
|
ADMIN_EMAIL: admin@test.com
|
|
run: uv run pytest -m "not postgres_only" --tb=short -v
|
|
|
|
- name: Generate coverage badge
|
|
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
|
|
working-directory: backend
|
|
run: uvx --from "genbadge[coverage]" genbadge coverage -i coverage.xml -o ../coverage-badge.svg
|
|
|
|
- name: Commit coverage badge
|
|
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
|
|
run: |
|
|
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
|
git config --local user.name "github-actions[bot]"
|
|
git add coverage-badge.svg || true
|
|
git diff --staged --quiet || git commit -m "Update coverage badge [skip ci]"
|
|
git push || true
|
|
|
|
- name: Upload coverage report
|
|
if: always()
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: coverage-report
|
|
path: backend/htmlcov/
|
|
retention-days: 14
|
|
|
|
# The job above runs on SQLite, which is fast and blind to a whole class of
|
|
# defect: declared varchar lengths, RLS policies, and every test carrying the
|
|
# `postgres_only` marker (which the SQLite job deselects, so they had never
|
|
# run in CI at all). Two shipped bugs were invisible to a green suite for
|
|
# exactly this reason; see crm/test_settings_postgres.py.
|
|
backend-tests-postgres:
|
|
runs-on: ubuntu-latest
|
|
|
|
services:
|
|
postgres:
|
|
image: postgres:16
|
|
env:
|
|
POSTGRES_PASSWORD: postgres
|
|
ports:
|
|
- 5432:5432
|
|
options: >-
|
|
--health-cmd pg_isready
|
|
--health-interval 10s
|
|
--health-timeout 5s
|
|
--health-retries 5
|
|
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
|
|
- name: Install uv
|
|
uses: astral-sh/setup-uv@v10.0.1
|
|
with:
|
|
enable-cache: true
|
|
cache-dependency-glob: 'backend/uv.lock'
|
|
|
|
- name: Install system dependencies (WeasyPrint)
|
|
run: |
|
|
sudo apt-get update
|
|
sudo apt-get install -y \
|
|
libpango-1.0-0 \
|
|
libpangocairo-1.0-0 \
|
|
libgdk-pixbuf-2.0-0 \
|
|
libffi-dev \
|
|
shared-mime-info
|
|
|
|
# A superuser bypasses RLS, and the policies use FORCE ROW LEVEL
|
|
# SECURITY so even the owning role is subject to them. Running the
|
|
# RLS tests as `postgres` makes every isolation assertion vacuously
|
|
# true, the same trap RLS_SETUP.md warns about for local development.
|
|
# CREATEDB is what lets pytest-django build the test database.
|
|
- name: Create a non-superuser role for the RLS tests
|
|
env:
|
|
PGPASSWORD: postgres
|
|
run: |
|
|
psql -h localhost -U postgres -c \
|
|
"CREATE ROLE crm_ci WITH LOGIN CREATEDB PASSWORD 'crm_ci' NOSUPERUSER NOBYPASSRLS;"
|
|
|
|
- name: Install Python dependencies
|
|
working-directory: backend
|
|
run: uv sync --frozen
|
|
|
|
# Run 1: the whole suite, for what SQLite cannot see. Declared varchar
|
|
# lengths, integrity constraints, real type coercion. It runs as the
|
|
# superuser, so RLS is inert here; run 2 is where isolation is tested.
|
|
# The reason for the split is that the shared fixtures insert rows with
|
|
# no `app.current_org` set, which a policy-enforcing role refuses. That
|
|
# is a real gap in the fixtures rather than a CI setting, and closing it
|
|
# is a change to almost every test file, so it is deliberately not
|
|
# bundled into this one. Until then the split says plainly which run
|
|
# covers which.
|
|
- name: Run the full suite against PostgreSQL
|
|
working-directory: backend
|
|
env:
|
|
DJANGO_SETTINGS_MODULE: crm.test_settings_postgres
|
|
SECRET_KEY: test-secret-key-for-ci
|
|
ADMIN_EMAIL: admin@test.com
|
|
DBNAME: crm_db
|
|
DBUSER: postgres
|
|
DBPASSWORD: postgres
|
|
DBHOST: localhost
|
|
DBPORT: '5432'
|
|
# `postgres_only` is deselected here for the same reason it runs in
|
|
# the next step instead: those tests assert that RLS hides rows, and
|
|
# this run's superuser role is not subject to any policy, so they fail
|
|
# here for a reason that is about the role rather than the code.
|
|
# No coverage: the SQLite job produces the report and the badge.
|
|
run: uv run pytest --no-cov --tb=short -q -m "not postgres_only"
|
|
|
|
# Run 2: the `postgres_only` tests, as a role that RLS actually binds.
|
|
# The SQLite job deselects these, so before this step they had never
|
|
# executed in CI at all. They also have to run as `crm_ci` to mean
|
|
# anything: as a superuser they pass whether or not a single policy
|
|
# exists.
|
|
- name: Run the RLS tests as a policy-bound role
|
|
working-directory: backend
|
|
env:
|
|
DJANGO_SETTINGS_MODULE: crm.test_settings_postgres
|
|
SECRET_KEY: test-secret-key-for-ci
|
|
ADMIN_EMAIL: admin@test.com
|
|
DBNAME: crm_db
|
|
DBUSER: crm_ci
|
|
DBPASSWORD: crm_ci
|
|
DBHOST: localhost
|
|
DBPORT: '5432'
|
|
run: uv run pytest --no-cov --tb=short -q -m postgres_only
|
|
|
|
# Bare `ruff check .`, so the gate is whatever `backend/ruff.toml` selects
|
|
# (E, F, I with E501 ignored) rather than a CLI override that can silently
|
|
# drift from the file every editor and pre-commit hook reads.
|
|
#
|
|
# F is the half that catches defects rather than style: F811 is how
|
|
# `ActivitySerializer` came to be declared twice, with the second silently
|
|
# shadowing the first for both of its consumers, and F401 found three
|
|
# imports left behind by the timezone sweep plus two `# noqa` comments
|
|
# that suppressed nothing.
|
|
#
|
|
# `uv run`, not `uvx`: ruff is a locked dev dependency, so CI and a
|
|
# developer's machine check against the same version and a ruff release
|
|
# cannot turn this red overnight.
|
|
- name: Lint
|
|
working-directory: backend
|
|
run: uv run ruff check .
|
|
|
|
# `ruff format` rather than black: it is a reimplementation of black with
|
|
# the same output, `ruff.toml` already carried the `[format]` section, and
|
|
# one tool cannot disagree with itself the way black and ruff format can
|
|
# at the edges. `ruff check` above covers isort's job through the I rules.
|
|
# The tree was formatted in full when this step was added, so the gate
|
|
# starts green rather than as a warning nobody acts on.
|
|
- name: Format check
|
|
working-directory: backend
|
|
run: uv run ruff format --check .
|
|
|
|
# Nothing above this job exercises the built distribution. Development uses an
|
|
# editable install, which hatchling implements as a single .pth file adding all
|
|
# of backend/ to sys.path, so every app and template is reachable whether or not
|
|
# `[tool.hatch.build.targets.wheel]` in pyproject.toml ever lists it. That is how
|
|
# `business_hours` and `macros` came to be installed apps that no wheel shipped:
|
|
# a green suite, and `pip install django-crm` dead on ModuleNotFoundError.
|
|
package-build:
|
|
runs-on: ubuntu-latest
|
|
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
|
|
- name: Install uv
|
|
uses: astral-sh/setup-uv@v10.0.1
|
|
with:
|
|
enable-cache: true
|
|
cache-dependency-glob: 'backend/uv.lock'
|
|
|
|
# `check` walks the URLconf, which reaches the invoices views, which import
|
|
# WeasyPrint at module scope. Without these it fails on a missing .so and
|
|
# says nothing about packaging.
|
|
- name: Install system dependencies (WeasyPrint)
|
|
run: |
|
|
sudo apt-get update
|
|
sudo apt-get install -y \
|
|
libpango-1.0-0 \
|
|
libpangocairo-1.0-0 \
|
|
libgdk-pixbuf-2.0-0 \
|
|
libffi-dev \
|
|
shared-mime-info
|
|
|
|
- name: Build the wheel
|
|
working-directory: backend
|
|
run: uv build --wheel --out-dir "$RUNNER_TEMP/dist"
|
|
|
|
# Deliberately run from $RUNNER_TEMP rather than backend/. From inside the
|
|
# source tree `crm` and every app resolve from the working directory, which
|
|
# reproduces the exact blindness this job exists to remove. The venv gets
|
|
# the wheel and its declared dependencies, nothing else.
|
|
- name: Install the wheel into a clean environment
|
|
run: |
|
|
cd "$RUNNER_TEMP"
|
|
uv venv --python 3.12 pkgenv
|
|
uv pip install --python pkgenv/bin/python dist/*.whl
|
|
|
|
# Loads every app in INSTALLED_APPS and every view behind the URLconf, so a
|
|
# package missing from the wheel fails here. Warnings do not fail `check`,
|
|
# so keep this output empty: a standing warning is where the next real one
|
|
# hides.
|
|
- name: Start the installed package
|
|
env:
|
|
DJANGO_SETTINGS_MODULE: crm.settings
|
|
SECRET_KEY: startup-check-only-not-a-deployment-secret
|
|
run: |
|
|
mkdir -p "$RUNNER_TEMP/neutral"
|
|
cd "$RUNNER_TEMP/neutral"
|
|
"$RUNNER_TEMP/pkgenv/bin/django-admin" check
|
|
|
|
# `check` does not open a template, so it is blind to the second half of
|
|
# the same defect: these render the magic-link login emails, and they only
|
|
# ship because they sit in an app's templates/ directory. A project-level
|
|
# templates/ dir resolves to site-packages once installed.
|
|
- name: Resolve packaged templates
|
|
env:
|
|
DJANGO_SETTINGS_MODULE: crm.settings
|
|
SECRET_KEY: startup-check-only-not-a-deployment-secret
|
|
run: |
|
|
cd "$RUNNER_TEMP/neutral"
|
|
"$RUNNER_TEMP/pkgenv/bin/python" -c "import django; django.setup(); from django.template.loader import get_template; [get_template(n) for n in ('magic_link_email.html', 'magic_link_code_email.html', 'root_email_template_new.html', 'healthz.html', 'assigned_to/leads_assigned.html', 'opportunity/goal_milestone.html', 'csat/survey_email.html')]; print('templates resolve from the installed package')"
|
|
|
|
frontend-checks:
|
|
runs-on: ubuntu-latest
|
|
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
|
|
- name: Set up Node.js
|
|
uses: actions/setup-node@v7
|
|
with:
|
|
node-version: '24'
|
|
|
|
- name: Install pnpm
|
|
uses: pnpm/action-setup@v6
|
|
with:
|
|
version: 10
|
|
|
|
- name: Install dependencies
|
|
working-directory: frontend
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
- name: Prettier format check
|
|
working-directory: frontend
|
|
run: pnpm prettier --check . || echo "::warning::Prettier found formatting issues in $(pnpm prettier --check . 2>&1 | grep -c '^\[warn\]') files. Run 'pnpm format' to fix."
|
|
|
|
- name: ESLint
|
|
working-directory: frontend
|
|
run: pnpm eslint .
|
|
|
|
- name: Type check (svelte-check)
|
|
working-directory: frontend
|
|
run: pnpm check
|
|
|
|
- name: Run tests
|
|
working-directory: frontend
|
|
run: pnpm test
|
|
|
|
- name: Build
|
|
working-directory: frontend
|
|
run: pnpm build
|
|
|
|
docs-build:
|
|
runs-on: ubuntu-latest
|
|
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
|
|
- name: Install uv
|
|
uses: astral-sh/setup-uv@v10.0.1
|
|
with:
|
|
enable-cache: true
|
|
cache-dependency-glob: 'backend/uv.lock'
|
|
|
|
- name: Install docs dependencies
|
|
working-directory: backend
|
|
run: uv sync --frozen --group docs
|
|
|
|
# --strict fails on broken internal links and nav entries pointing at
|
|
# files that do not exist.
|
|
- name: Build docs
|
|
working-directory: backend
|
|
run: uv run mkdocs build --strict -f ../mkdocs.yml -d site
|
|
|
|
# These two tests skip when mkdocs is absent, so the backend-tests job
|
|
# (which installs no docs group) cannot run them. Without this step the
|
|
# "no internal artifacts published" guard would silently never execute
|
|
# in CI. A skip reads as a pass.
|
|
- name: Check docs build guards
|
|
working-directory: backend
|
|
env:
|
|
DJANGO_SETTINGS_MODULE: crm.test_settings
|
|
SECRET_KEY: test-secret-key-for-ci
|
|
ADMIN_EMAIL: admin@test.com
|
|
run: uv run pytest common/tests/test_docs_build_excludes_internal.py --no-cov -v
|