diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 2c70415b..00000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -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 }}" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index dacb095c..00000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -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" diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml deleted file mode 100644 index a230694a..00000000 --- a/.github/workflows/tag-release.yml +++ /dev/null @@ -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, 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}" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index ebb49c81..00000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -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