Remove GitHub workflow configurations

This commit is contained in:
vickytechkey 2026-10-24 13:00:43 +05:30
parent 52cd0c1bb4
commit 6d7524bbe3
4 changed files with 0 additions and 912 deletions

View file

@ -1,75 +0,0 @@
# CodeQL static analysis. Results land in the Security tab under Code scanning.
#
# This was the stock 2020 starter template until 2026-08-12. It ran green for
# years, then failed 44 consecutive runs between 2026-08-01 and 2026-08-12,
# every push and PR, with "Resource not accessible by integration" on the
# code-scanning status endpoint.
#
# The cause was not the workflow changing, which is why it looked mysterious:
# this repository's default workflow permissions are set to `read` (see Settings
# -> Actions -> Workflow permissions). That is the right default, but the stock
# template declared no `permissions:` block at all, so the job's token could not
# write the SARIF it exists to produce. The block below is the fix.
#
# The v1 -> v3 action bump in the same change was overdue on its own (v1 was
# retired in January 2023) but was NOT what broke this. Do not read the bump as
# the repair; the `permissions:` block is the repair.
#
# The failure mode is worth remembering: the Security tab went on showing the
# results of the last upload that worked, dated 2026-07-31, with nothing
# marking them stale. A scanner failing loudly in Actions still reads as a
# scanner passing quietly if you only ever look at the Security tab.
name: "CodeQL"
on:
push:
branches: [master]
pull_request:
# The branches below must be a subset of the branches above
branches: [master]
schedule:
- cron: '41 18 * * 2'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
# Declaring this block at all drops every permission not listed to `none`,
# so these two are the whole set the job gets. `security-events: write` is
# the one that was missing and the only reason the job exists: it is what
# lets `analyze` upload the SARIF. `contents: read` is for the checkout.
# The upstream template also lists `packages: read` and `actions: read`,
# both needed only by private repositories. This one is public.
permissions:
security-events: write
contents: read
strategy:
fail-fast: false
matrix:
# Python only. The SvelteKit frontend is not scanned; adding
# `javascript-typescript` here is a real coverage gain and a real new
# alert backlog, so it is a decision rather than a fix.
language: ['python']
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
# No Autobuild step. The template shipped one, but it only does anything
# for compiled languages (C/C++, C#, Java, Go). Python needs no build, so
# the step was config that could never do anything.
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
# Keeps each matrix language's results a separate analysis. Without
# it a second language added above would overwrite this one's alerts
# rather than sit alongside them.
category: "/language:${{ matrix.language }}"

View file

@ -1,318 +0,0 @@
name: Publish to PyPI
# Publishing is the one irreversible thing this repo does. PyPI refuses a second
# upload of a version that already exists, so a wrong or broken artifact cannot
# be replaced, only yanked and superseded. Every gate below exists because of
# that: verify the built distribution before it leaves, not after.
#
# `django-crm` on PyPI is an existing project (0.2.0 in 2019 through 0.9.0 in
# 2021, then dormant). This resumes it rather than claiming a new name. Note
# that PyPI stopped at 0.9.0 while git carried on to 1.0, 1.1 and 1.2, so those
# three tags have no release behind them.
#
# Three ways in. The first is the normal one; the other two exist because they
# were already possible and going silent on them would be worse:
#
# 1. workflow_dispatch on a TAG ref, from tag-release.yml, against the tag it
# just derived from backend/pyproject.toml. Nobody types a version here.
# This path exists because a tag pushed by tag-release.yml's GITHUB_TOKEN
# does NOT fire the `push` trigger below: GitHub suppresses workflow runs
# from events its own token caused, so workflows cannot loop.
# 2. push of a tag a HUMAN created. A person's token is not suppressed, so
# this fires normally. Prefer path 1; this one can still disagree with
# backend/pyproject.toml, which is what the version guard is for.
# 3. workflow_dispatch on a BRANCH ref, which rehearses a release end to end
# without uploading anything: `publish` and `frontend-image` are gated on
# a tag ref, so both skip. Read the version-guard step's output, which
# says which checks a rehearsal did not run.
#
# Exactly one of 1 and 2 fires per release, so they do not double-publish.
#
# See docs/contributing/releasing.md.
on:
push:
tags:
# Both spellings on purpose. Every tag in this repo's history before
# v1.3.0 is unprefixed (1.2, 1.1, 0.9.0), so matching only 'v*' would
# mean someone tagging 1.4.0 out of habit gets silence instead of a
# release, and silence is the worst possible failure for a publish
# trigger. The version check below strips a leading v when there is one.
# Tags that tag-release.yml creates are always v-prefixed.
- 'v[0-9]*'
- '[0-9]*'
workflow_dispatch:
# Read-only by default. Only `publish` gets id-token: write and only
# `frontend-image` gets packages: write, so no other job holds a token that can
# publish anything. Without this line every job inherits the repository default,
# which on many repos is write-all.
permissions:
contents: read
concurrency:
group: publish-${{ github.ref }}
cancel-in-progress: false
jobs:
# tests.yml is branch-scoped (`push: branches: ['**']`) AND path-filtered, so
# a tag push runs no tests of its own and a commit that touched only, say,
# README.md (the PyPI long description) or this file ran none either. A
# release is exactly the moment nobody is watching CI, so the suite runs here
# against the commit actually being shipped.
#
# This is the SQLite run only. The PostgreSQL corner in tests.yml (a
# non-superuser role, the RLS isolation pass) is not repeated here: it is
# ~40 lines of service container and role setup that would drift from the
# copy that matters. So this gate proves the suite passes, not that RLS
# isolation does. Merge to master with that job green before releasing.
test:
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 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
# The suite runs with --no-migrations, so nothing else here would catch a
# model change shipped without its migration. That reaches a user as a
# broken `migrate` on upgrade, which is worse than a failed release.
- 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 the test suite
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" --no-cov --tb=short
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v10.0.1
with:
# Deliberately off here, and on in every other job. This is the job
# whose output is uploaded to PyPI under the project's name, and the
# GitHub Actions cache is writable from less-trusted contexts: a
# `pull_request` run from a fork can poison an entry that a later run
# on master restores. A cache is therefore an input to the artifact,
# and this artifact is the one thing here that cannot be recalled.
#
# setup-uv v10 made exactly this its headline change, and it is still
# not enough on its own. Under `enable-cache: auto` v10 skips the
# cache for `pull_request_target`, `workflow_run` and `release`. This
# workflow's normal entry point is `workflow_dispatch` on a tag,
# which is in none of those, so `auto` would happily cache here. An
# explicit `true` is worse than useless: setup-uv reads it before any
# event test, so it opts out of the protection entirely. Only an
# explicit `false` is actually off.
enable-cache: false
# The tag is what people will `pip install ==`, and the version inside the
# wheel is what they actually get. Nothing else compares them, and a
# mismatch is only discoverable after the upload it cannot be undone from.
#
# Skipped on a non-tag ref, which is the workflow_dispatch rehearsal
# described above. That skip is announced rather than silent: a skipped
# guard and a passing one look identical in the Actions UI, and the whole
# point of a rehearsal is knowing which checks it actually ran.
- name: Tag matches the packaged version
working-directory: backend
run: |
packaged="$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")"
case "$GITHUB_REF" in
refs/tags/*) ;;
*)
echo "::warning::Not a tag ref ($GITHUB_REF): skipping the tag/version check. This is a rehearsal, and it will NOT publish."
echo "would package version $packaged"
exit 0
;;
esac
tag="${GITHUB_REF_NAME#v}"
if [ "$tag" != "$packaged" ]; then
echo "::error::tag $GITHUB_REF_NAME does not match pyproject.toml version $packaged"
exit 1
fi
echo "publishing version $packaged"
- name: Build the wheel and sdist
working-directory: backend
run: uv build --out-dir "$RUNNER_TEMP/dist"
# README.md is the long description, so a construct PyPI will not render
# fails here rather than landing as a broken project page on a version
# that cannot be replaced. The publish action verifies metadata too, but
# only on the tag path: this is what makes a workflow_dispatch dry run
# tell you anything about the description.
#
# `--strict` because twine's default turns a rendering WARNING into a
# green tick, and PyPI publishes a description it cannot render as an
# empty project page rather than as an error. The warning is the only
# notice you get, and only if someone reads the log. `readme-renderer[md]`
# is what teaches twine to check Markdown at all; without it a Markdown
# long description is waved through unexamined.
- name: Check the distribution metadata
run: uvx --with "readme-renderer[md]" twine check --strict "$RUNNER_TEMP"/dist/*
# `check` walks the URLconf into the invoices views, which import
# WeasyPrint at module scope.
- 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
# The same gate as the package-build job in tests.yml, repeated here
# because that job runs on the branch and this runs on the tag. Run from
# $RUNNER_TEMP: inside the source tree every app imports from the working
# directory, which is what let two installed apps ship missing from the
# wheel with a green suite.
- name: Install the built wheel into a clean environment and start it
env:
DJANGO_SETTINGS_MODULE: crm.settings
SECRET_KEY: startup-check-only-not-a-deployment-secret
run: |
cd "$RUNNER_TEMP"
uv venv --python 3.12 pkgenv
uv pip install --python pkgenv/bin/python dist/*.whl
mkdir -p neutral && cd neutral
"$RUNNER_TEMP/pkgenv/bin/django-admin" check
"$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')"
- name: Upload the distributions
uses: actions/upload-artifact@v7
with:
name: dist
path: ${{ runner.temp }}/dist/
if-no-files-found: error
publish:
needs: build
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
# Trusted Publishing (OIDC). No API token is stored in this repo, so there
# is no long-lived PyPI credential to leak or rotate, and publish rights are
# bound to this repository, this workflow file and this environment.
#
# Configured once on pypi.org under the project's Publishing settings, with
# GitHub's own casing for the first two, which is what lands in the OIDC
# token claims:
# owner: Django-CRM <- the org the repo was TRANSFERRED to. The
# PyPI project page still shows
# MicroPyramid from the 2021 release, and
# MicroPyramid/Django-CRM now redirects
# here. The publisher must name where the
# code lives, not where it used to.
# repository: Django-CRM
# workflow: publish.yml <- this file, matched by filename
# environment: pypi <- must equal `environment.name` below
environment:
name: pypi
url: https://pypi.org/project/django-crm/
permissions:
# The only permission the OIDC exchange needs. Deliberately no
# `contents: write`: publishing does not modify the repository.
id-token: write
steps:
- name: Download the distributions
uses: actions/download-artifact@v8
with:
name: dist
path: dist/
# Pinned to a commit SHA rather than the `v1` tag used elsewhere in this
# repo. A moving tag means whatever the upstream owner repoints it to runs
# inside the job holding the OIDC token that can publish as us. tests.yml
# can afford tags; this job cannot.
# dc37677b2e1c63e2034f94d8a5b11f265b73ba33 is pypa/gh-action-pypi-publish
# v1.14.2. Bump both together, never the comment alone.
- name: Publish
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33
# The other half of the release. PyPI carries the API; this carries the UI,
# which is a Node server (adapter-node, 81 server load functions and 64 form
# actions) and cannot be a Python package. Built from the SAME tag as the
# wheel above so "enterprise runs community 1.4.2" names one commit rather
# than an API version and whatever the UI happened to be.
frontend-image:
# Gated on `publish`, not just `build`. The two artifacts are one release
# under one tag, so an image tagged 1.3.0 must never exist without the
# matching PyPI release. Gating on `build` alone was not enough and said so
# while being wrong: v1.3.0 built fine, failed the PyPI OIDC exchange, and
# still pushed ghcr.io/django-crm/bottlecrm-frontend:1.3.0 and :latest with
# nothing on PyPI to match. `latest` is the damaging half, since it moves.
# Both jobs are already tag-only, so depending on a job that is skipped on
# workflow_dispatch skips this one too, which is what a dry run wants.
needs: [build, publish]
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
# Plain docker CLI rather than docker/login-action + build-push-action.
# This job can push an image under the org's namespace, so the fewer
# third-party actions holding that token the better. The CLI is already
# on the runner and the whole recipe is four lines.
- name: Build and push
env:
REGISTRY: ghcr.io
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
version="${GITHUB_REF_NAME#v}"
owner="$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')"
image="${REGISTRY}/${owner}/bottlecrm-frontend"
echo "${GHCR_TOKEN}" | docker login "${REGISTRY}" -u "${GITHUB_ACTOR}" --password-stdin
# The default (last) stage is the production server. Naming it is not
# optional: `target: dev` in docker-compose.yml means the stage order
# is load-bearing, and a silent change to it would ship a Vite dev
# server to everyone who pulls this tag.
docker build --target runtime \
-t "${image}:${version}" \
-t "${image}:latest" \
frontend/
docker push "${image}:${version}"
docker push "${image}:latest"

View file

@ -1,142 +0,0 @@
name: Tag release
# The tag is DERIVED from backend/pyproject.toml. Nobody types it.
#
# publish.yml already refuses a tag whose version disagrees with the packaged
# one, which is the right guard and does not go far enough: it turns the
# mistake into a failed release rather than into no mistake. Recovering still
# means deleting a tag, and a leftover tag is itself a trap (see the "already
# exists" branch below). backend/pyproject.toml is the only place this version
# is written, so a hand-typed tag was the single remaining thing that could
# disagree with it. Now nothing types it.
#
# The whole release process:
#
# bump `version` in backend/pyproject.toml, open a PR, merge it
#
# That is the end of the list. This workflow creates tag v<version>, publishes
# a GitHub Release with generated notes, and starts publish.yml, which builds
# the wheel, uploads it to PyPI and pushes the frontend image to GHCR.
#
# See docs/contributing/releasing.md.
on:
push:
branches: [master]
# pyproject.toml changes for plenty of reasons that are not a version bump:
# a dependency, a classifier, a ruff setting. This only narrows what wakes
# the workflow up. The real decision is "does that tag already exist",
# below, which is what makes re-running this harmless.
paths: ['backend/pyproject.toml']
# Lets a version already on master be released without touching the file,
# including the first run, when the current version predates this workflow
# and no push will ever mention it.
workflow_dispatch:
permissions:
contents: write # create the tag and the GitHub Release
actions: write # dispatch publish.yml
concurrency:
group: tag-release
cancel-in-progress: false
jobs:
tag:
name: Tag and release if the version changed
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
# Existing tags are how this decides whether a version was already
# released, and a shallow clone fetches none of them. It would
# re-release every version, every time.
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v10.0.1
- name: Read the version and decide whether it needs releasing
id: v
run: |
version=$(python3 -c "import tomllib, pathlib; print(tomllib.loads(pathlib.Path('backend/pyproject.toml').read_text())['project']['version'])")
echo "version=$version" >> "$GITHUB_OUTPUT"
# BOTH spellings, and that is not defensive padding. Every tag in
# this repo before v1.3.0 is unprefixed (0.9.0, 1.0, 1.1, 1.2) and
# publish.yml still triggers on both, so a version released under a
# bare tag must not be re-released under a v-prefixed one. New tags
# are always v-prefixed; this only reads the old ones.
if git rev-parse -q --verify "refs/tags/v${version}" >/dev/null ||
git rev-parse -q --verify "refs/tags/${version}" >/dev/null; then
echo "release=false" >> "$GITHUB_OUTPUT"
# Say so where a human will actually see it. A no-op run is green
# and its logs are collapsed, so "did nothing" and "released it"
# look identical from the Actions list. This matters most in the
# case that bites: a LEFTOVER tag from an abandoned release means
# bumping to that same version later silently publishes nothing.
echo "::warning::No release: a tag for ${version} already exists. If you meant to release ${version}, delete that tag and its GitHub Release first."
{
echo "### No release"
echo "\`backend/pyproject.toml\` says **${version}**, and a tag for it already exists."
echo "Nothing to do. If this was meant to be a release, delete that tag and its GitHub Release, then re-run this workflow."
} >> "$GITHUB_STEP_SUMMARY"
else
echo "release=true" >> "$GITHUB_OUTPUT"
{
echo "### Releasing ${version}"
echo "Creating tag \`v${version}\` on \`${GITHUB_SHA}\` and starting \`publish.yml\`."
} >> "$GITHUB_STEP_SUMMARY"
fi
# PEP 440 decides this, not a substring search: 1.4.0rc1 is a
# pre-release and 1.4.0.post1 is not. GitHub labels the Release from
# the answer, and getting it wrong shows a release candidate to
# everyone watching the repo as though it were final.
# --no-project because this runs from the repo root, which has no
# pyproject.toml of its own (the package lives in backend/). uv would
# cope today by finding no project at all, but it searches UPWARD, so
# adding one at the root later would silently make this step install
# and build the whole application to read one version string.
prerelease=$(uv run --no-project --with packaging python -c "from packaging.version import Version; print(str(Version('${version}').is_prerelease).lower())")
echo "prerelease=${prerelease}" >> "$GITHUB_OUTPUT"
echo "django-crm ${version} (prerelease: ${prerelease})"
- name: Create the tag and the GitHub Release
if: steps.v.outputs.release == 'true'
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.v.outputs.version }}
PRERELEASE: ${{ steps.v.outputs.prerelease }}
run: |
flags=""
if [ "$PRERELEASE" = "true" ]; then
flags="--prerelease"
fi
# --target pins the tag to the exact commit that carried the bump,
# not to whatever master has drifted to since this run started.
gh release create "v${VERSION}" \
--target "$GITHUB_SHA" \
--title "v${VERSION}" \
--generate-notes \
$flags
- name: Start the publish workflow
if: steps.v.outputs.release == 'true'
# Creating that tag and Release with GITHUB_TOKEN fires NEITHER of
# publish.yml's own triggers. GitHub suppresses workflow runs caused by
# its own token so workflows cannot loop, and that covers both the tag
# push and the release event. Relying on either would fail silently:
# the release appears, nothing publishes, and no run exists anywhere to
# explain why. workflow_dispatch is one of the two documented
# exceptions, which is the only reason this chain works.
#
# This is also why publish.yml keeps its `push: tags` trigger: that one
# is for a tag a human pushes by hand. Exactly one of the two fires per
# release, so they do not double-publish.
#
# --ref puts github.ref on refs/tags/v*, which is what publish.yml's
# version guard and its publish/frontend-image jobs all key off.
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.v.outputs.version }}
run: gh workflow run publish.yml --ref "v${VERSION}"

View file

@ -1,377 +0,0 @@
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