django-query-contract 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_query_contract-0.1.0/.github/CODEOWNERS +1 -0
- django_query_contract-0.1.0/.github/SECURITY.md +9 -0
- django_query_contract-0.1.0/.github/dependabot.yml +26 -0
- django_query_contract-0.1.0/.github/workflows/release.yml +118 -0
- django_query_contract-0.1.0/.github/workflows/tests.yml +353 -0
- django_query_contract-0.1.0/.github/workflows/upstream-drift.yml +106 -0
- django_query_contract-0.1.0/.gitignore +636 -0
- django_query_contract-0.1.0/.pre-commit-config.yaml +67 -0
- django_query_contract-0.1.0/CHANGELOG.md +46 -0
- django_query_contract-0.1.0/CLAUDE.md +231 -0
- django_query_contract-0.1.0/LICENSE +21 -0
- django_query_contract-0.1.0/Makefile +90 -0
- django_query_contract-0.1.0/PKG-INFO +169 -0
- django_query_contract-0.1.0/README.md +131 -0
- django_query_contract-0.1.0/django_query_contract/__init__.py +23 -0
- django_query_contract-0.1.0/django_query_contract/capture_stack.py +64 -0
- django_query_contract-0.1.0/django_query_contract/format_capture_report.py +128 -0
- django_query_contract-0.1.0/django_query_contract/log_ceiling.py +73 -0
- django_query_contract-0.1.0/django_query_contract/normalise_sql.py +90 -0
- django_query_contract-0.1.0/django_query_contract/plugin.py +129 -0
- django_query_contract-0.1.0/django_query_contract/py.typed +0 -0
- django_query_contract-0.1.0/django_query_contract/query_capture.py +211 -0
- django_query_contract-0.1.0/django_query_contract/query_log_ceiling_warning.py +19 -0
- django_query_contract-0.1.0/django_query_contract/query_record.py +97 -0
- django_query_contract-0.1.0/django_query_contract/stack_frame.py +29 -0
- django_query_contract-0.1.0/django_query_contract/version.py +7 -0
- django_query_contract-0.1.0/docs/index.md +6 -0
- django_query_contract-0.1.0/docs/reference.md +25 -0
- django_query_contract-0.1.0/mkdocs.yml +73 -0
- django_query_contract-0.1.0/pyproject.toml +188 -0
- django_query_contract-0.1.0/scripts/release-publish.sh +246 -0
- django_query_contract-0.1.0/tests/__init__.py +0 -0
- django_query_contract-0.1.0/tests/conftest.py +88 -0
- django_query_contract-0.1.0/tests/conftest_settings.py +41 -0
- django_query_contract-0.1.0/tests/test_capture_stack.py +66 -0
- django_query_contract-0.1.0/tests/test_documentation.py +61 -0
- django_query_contract-0.1.0/tests/test_format_capture_report.py +184 -0
- django_query_contract-0.1.0/tests/test_log_ceiling.py +58 -0
- django_query_contract-0.1.0/tests/test_normalise_sql.py +115 -0
- django_query_contract-0.1.0/tests/test_plugin.py +286 -0
- django_query_contract-0.1.0/tests/test_plugin_hooks.py +238 -0
- django_query_contract-0.1.0/tests/test_query_capture.py +317 -0
- django_query_contract-0.1.0/tests/test_query_record.py +94 -0
- django_query_contract-0.1.0/tests/test_version.py +10 -0
- django_query_contract-0.1.0/tests/testapp/__init__.py +0 -0
- django_query_contract-0.1.0/tests/testapp/models.py +18 -0
- django_query_contract-0.1.0/uv.lock +1376 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* @Artui
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Reporting a Vulnerability
|
|
4
|
+
|
|
5
|
+
Please report security issues **privately** via GitHub's
|
|
6
|
+
[Report a vulnerability](../../security/advisories/new) button rather than opening a public issue.
|
|
7
|
+
I aim to acknowledge reports within 72 hours.
|
|
8
|
+
|
|
9
|
+
Supported: the latest released version on the default branch.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
- package-ecosystem: "github-actions"
|
|
4
|
+
directory: "/"
|
|
5
|
+
schedule:
|
|
6
|
+
interval: "weekly"
|
|
7
|
+
# Dependabot already holds a new version for 3 days before proposing it; a
|
|
8
|
+
# week is the more useful threshold. A compromised release is typically
|
|
9
|
+
# yanked within days, so the gap between "published" and "found" is exactly
|
|
10
|
+
# the window an automatic upgrade walks into. This costs nothing in
|
|
11
|
+
# responsiveness that matters: cooldown covers version updates only, and
|
|
12
|
+
# security advisories are proposed immediately regardless.
|
|
13
|
+
cooldown:
|
|
14
|
+
default-days: 7
|
|
15
|
+
groups:
|
|
16
|
+
actions:
|
|
17
|
+
patterns: ["*"]
|
|
18
|
+
- package-ecosystem: "uv"
|
|
19
|
+
directory: "/"
|
|
20
|
+
schedule:
|
|
21
|
+
interval: "weekly"
|
|
22
|
+
cooldown:
|
|
23
|
+
default-days: 7
|
|
24
|
+
groups:
|
|
25
|
+
python:
|
|
26
|
+
patterns: ["*"]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
# Main-triggered: every merge to `main` runs `release-publish-prepare`, which
|
|
4
|
+
# short-circuits to a no-op unless the version in source has been bumped past
|
|
5
|
+
# the most recent `vX.Y.Z` tag. The previous tag-trigger flow has been removed
|
|
6
|
+
# — `make release-bump` + merge to main is now the only path.
|
|
7
|
+
on:
|
|
8
|
+
push:
|
|
9
|
+
branches: [main]
|
|
10
|
+
|
|
11
|
+
concurrency:
|
|
12
|
+
group: release-main
|
|
13
|
+
cancel-in-progress: false
|
|
14
|
+
|
|
15
|
+
# Actions are pinned to a commit SHA rather than a tag: a tag is mutable, and an
|
|
16
|
+
# action owner (or anyone who takes over the account) can silently repoint one at
|
|
17
|
+
# new code that then runs with this workflow's token. The trailing version comment
|
|
18
|
+
# is not decoration -- Dependabot reads it to know which release the SHA is, and
|
|
19
|
+
# rewrites both together when a newer one ships.
|
|
20
|
+
jobs:
|
|
21
|
+
release:
|
|
22
|
+
name: release
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
environment:
|
|
25
|
+
name: pypi
|
|
26
|
+
url: https://pypi.org/p/django-query-contract
|
|
27
|
+
permissions:
|
|
28
|
+
id-token: write # PyPI OIDC trusted publishing
|
|
29
|
+
contents: write # tag push + gh release create
|
|
30
|
+
# No `services:` block, and that follows from the test settings rather than
|
|
31
|
+
# from the sibling repos: `release-publish-prepare` runs the suite before it
|
|
32
|
+
# builds anything, and this suite defaults to in-memory SQLite. A package
|
|
33
|
+
# whose default test backend needed a server would need one here too, and
|
|
34
|
+
# CI would give no warning -- its own database job would supply one while
|
|
35
|
+
# this job did not.
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
38
|
+
with:
|
|
39
|
+
fetch-depth: 0
|
|
40
|
+
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
41
|
+
with:
|
|
42
|
+
enable-cache: true
|
|
43
|
+
- name: Install
|
|
44
|
+
run: uv sync --all-groups
|
|
45
|
+
|
|
46
|
+
- name: Prepare release (version check, test, build dist)
|
|
47
|
+
id: prepare
|
|
48
|
+
run: make release-publish-prepare
|
|
49
|
+
|
|
50
|
+
- name: Publish to PyPI
|
|
51
|
+
if: steps.prepare.outputs.released == 'true'
|
|
52
|
+
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
|
53
|
+
with:
|
|
54
|
+
# Idempotent on purpose. What gates a release is the git tag, not the
|
|
55
|
+
# index (see `prepare`), so re-running this job after a failure in a
|
|
56
|
+
# later step arrives here having already uploaded these files. Without
|
|
57
|
+
# this the upload is the step that makes that re-run impossible, and
|
|
58
|
+
# every recovery becomes manual — which is how one release lost its tag,
|
|
59
|
+
# its GitHub Release and its docs deploy to a single transient push
|
|
60
|
+
# failure, with the package already published.
|
|
61
|
+
# PyPI never lets a file be replaced, so skipping one cannot overwrite
|
|
62
|
+
# a published artefact.
|
|
63
|
+
skip-existing: true
|
|
64
|
+
|
|
65
|
+
- name: Tag + GitHub Release
|
|
66
|
+
if: steps.prepare.outputs.released == 'true'
|
|
67
|
+
env:
|
|
68
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
69
|
+
run: make release-publish-finalize
|
|
70
|
+
|
|
71
|
+
# On release commits we own the single gh-pages push: embed the
|
|
72
|
+
# coverage badge JSON into the docs build so mkdocs gh-deploy carries
|
|
73
|
+
# it alongside the site, and tests.yml's coverage-badge job
|
|
74
|
+
# short-circuits to avoid a second push (and a second built-in
|
|
75
|
+
# "pages build and deployment" run).
|
|
76
|
+
- name: Render coverage badge for the docs site
|
|
77
|
+
if: steps.prepare.outputs.released == 'true' && hashFiles('mkdocs.yml') != ''
|
|
78
|
+
run: |
|
|
79
|
+
set -euo pipefail
|
|
80
|
+
# release-publish-prepare's pytest run wrote .coverage to the
|
|
81
|
+
# workspace root; convert to coverage.xml so the same parser
|
|
82
|
+
# we use in tests.yml can score it.
|
|
83
|
+
uv run coverage xml -o coverage.xml
|
|
84
|
+
python - <<'PY'
|
|
85
|
+
import json
|
|
86
|
+
import xml.etree.ElementTree as ET
|
|
87
|
+
|
|
88
|
+
tree = ET.parse("coverage.xml")
|
|
89
|
+
pct = round(float(tree.getroot().attrib["line-rate"]) * 100, 1)
|
|
90
|
+
if pct >= 95:
|
|
91
|
+
color = "brightgreen"
|
|
92
|
+
elif pct >= 85:
|
|
93
|
+
color = "green"
|
|
94
|
+
elif pct >= 70:
|
|
95
|
+
color = "yellow"
|
|
96
|
+
else:
|
|
97
|
+
color = "red"
|
|
98
|
+
payload = {
|
|
99
|
+
"schemaVersion": 1,
|
|
100
|
+
"label": "coverage",
|
|
101
|
+
"message": f"{pct}%",
|
|
102
|
+
"color": color,
|
|
103
|
+
}
|
|
104
|
+
# docs/ is mkdocs' source dir; non-.md files are copied verbatim
|
|
105
|
+
# into site/, which lands at the gh-pages root after gh-deploy.
|
|
106
|
+
with open("docs/coverage.json", "w") as fh:
|
|
107
|
+
json.dump(payload, fh)
|
|
108
|
+
fh.write("\n")
|
|
109
|
+
print(json.dumps(payload))
|
|
110
|
+
PY
|
|
111
|
+
|
|
112
|
+
- name: Deploy docs + coverage badge to gh-pages
|
|
113
|
+
if: steps.prepare.outputs.released == 'true' && hashFiles('mkdocs.yml') != ''
|
|
114
|
+
run: |
|
|
115
|
+
git config user.name 'github-actions[bot]'
|
|
116
|
+
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
|
117
|
+
DJANGO_SETTINGS_MODULE=tests.conftest_settings \
|
|
118
|
+
uv run mkdocs gh-deploy --force --clean --config-file mkdocs.yml
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: tests-${{ github.ref }}
|
|
10
|
+
cancel-in-progress: true
|
|
11
|
+
|
|
12
|
+
# Actions are pinned to a commit SHA rather than a tag: a tag is mutable, and an
|
|
13
|
+
# action owner (or anyone who takes over the account) can silently repoint one at
|
|
14
|
+
# new code that then runs with this workflow's token. The trailing version comment
|
|
15
|
+
# is not decoration -- Dependabot reads it to know which release the SHA is, and
|
|
16
|
+
# rewrites both together when a newer one ships.
|
|
17
|
+
jobs:
|
|
18
|
+
lint:
|
|
19
|
+
name: lint
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
23
|
+
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
24
|
+
with:
|
|
25
|
+
enable-cache: true
|
|
26
|
+
- name: Install
|
|
27
|
+
run: uv sync --all-groups
|
|
28
|
+
- name: ruff check
|
|
29
|
+
run: uv run ruff check .
|
|
30
|
+
- name: ruff format check
|
|
31
|
+
run: uv run ruff format --check --diff .
|
|
32
|
+
- name: ty
|
|
33
|
+
run: uv run ty check django_query_contract
|
|
34
|
+
|
|
35
|
+
docs:
|
|
36
|
+
name: docs build
|
|
37
|
+
runs-on: ubuntu-latest
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
40
|
+
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
41
|
+
with:
|
|
42
|
+
enable-cache: true
|
|
43
|
+
- name: Install with docs group
|
|
44
|
+
run: uv sync --all-groups
|
|
45
|
+
- name: mkdocs build --strict
|
|
46
|
+
# This package defines no models, so mkdocstrings can import it without
|
|
47
|
+
# django.setup(). The settings module is pointed at anyway because the
|
|
48
|
+
# reference pages document code that reads Django's connection API, and
|
|
49
|
+
# a future page importing the test models would otherwise fail here
|
|
50
|
+
# rather than in the docs build a contributor runs locally.
|
|
51
|
+
run: DJANGO_SETTINGS_MODULE=tests.conftest_settings uv run mkdocs build --strict
|
|
52
|
+
|
|
53
|
+
floor:
|
|
54
|
+
name: lowest declared versions
|
|
55
|
+
runs-on: ubuntu-latest
|
|
56
|
+
steps:
|
|
57
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
58
|
+
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
59
|
+
with:
|
|
60
|
+
enable-cache: true
|
|
61
|
+
# A floor is a claim about the *oldest* version that works, and a lockfile
|
|
62
|
+
# can only ever check the newest. Development and CI both resolve the
|
|
63
|
+
# newest in-range dependency, so every gate passes while a consumer whose
|
|
64
|
+
# own constraints pull an older one gets a package that does not import.
|
|
65
|
+
#
|
|
66
|
+
# `lowest-direct` pins every *declared* dependency to the bottom of its
|
|
67
|
+
# window while letting transitives resolve normally, so this asserts the
|
|
68
|
+
# floors mean what they say. A per-PR gate rather than a scheduled job,
|
|
69
|
+
# because a floor stops being true the moment someone imports a newer
|
|
70
|
+
# upstream symbol -- a code change, not the passage of time.
|
|
71
|
+
#
|
|
72
|
+
# Pinned to the oldest supported Python rather than whatever interpreter
|
|
73
|
+
# the runner exposes. The floor claim is about the oldest configuration
|
|
74
|
+
# we support, so that is the one to install, and leaving the choice to
|
|
75
|
+
# the image makes the answer drift when the image does.
|
|
76
|
+
- name: Install Python 3.10
|
|
77
|
+
run: uv python install 3.10
|
|
78
|
+
# `--refresh` on both resolutions below. This job's whole claim is "what a
|
|
79
|
+
# consumer resolving from scratch would get", and the runner's uv cache is
|
|
80
|
+
# shared across runs: without it the answer comes from whatever package
|
|
81
|
+
# list that cache happens to hold, which can be hours behind the index.
|
|
82
|
+
- name: Resolve every declared dependency at its floor
|
|
83
|
+
run: uv lock --resolution lowest-direct --refresh
|
|
84
|
+
# `--frozen` here and `--no-sync` below, not a bare `uv sync` / `uv run`.
|
|
85
|
+
# uv records the resolution mode in the lockfile and silently discards one
|
|
86
|
+
# resolved in a different mode, so a plain `uv sync` re-resolves at
|
|
87
|
+
# `highest` and undoes the step above -- the job would then measure the
|
|
88
|
+
# newest versions twice while reporting that it tested the oldest.
|
|
89
|
+
- name: Install
|
|
90
|
+
run: uv sync --frozen --all-groups --python 3.10
|
|
91
|
+
- name: Name the versions that produced
|
|
92
|
+
run: uv pip list
|
|
93
|
+
# No coverage gate here. This job's claim is that the declared floors
|
|
94
|
+
# resolve and the suite passes against them -- not that every line runs.
|
|
95
|
+
# One gate, on the matrix cell below, is the design.
|
|
96
|
+
- name: Test
|
|
97
|
+
run: uv run --no-sync pytest --cov-fail-under=0
|
|
98
|
+
|
|
99
|
+
# The dev group holds pytest and pytest-django, which the package itself
|
|
100
|
+
# does not depend on -- the plugin reaches pytest through a ``pytest11``
|
|
101
|
+
# entry point instead. So the floor claim is also checked on the install
|
|
102
|
+
# shape with the fewest constraints: the package alone, no dev group. That
|
|
103
|
+
# shape is what a consumer installing it beside a production Django gets,
|
|
104
|
+
# and it is the one where an accidental import of pytest would surface.
|
|
105
|
+
- name: Import the package installed alone, at its floors
|
|
106
|
+
run: |
|
|
107
|
+
uv venv --python 3.10 .venv-floor
|
|
108
|
+
uv pip install --python .venv-floor/bin/python --resolution lowest-direct --refresh -e .
|
|
109
|
+
uv pip list --python .venv-floor/bin/python
|
|
110
|
+
.venv-floor/bin/python - <<'PY'
|
|
111
|
+
import django
|
|
112
|
+
from django.conf import settings
|
|
113
|
+
|
|
114
|
+
# No models here, so the import itself needs no django.setup(). The
|
|
115
|
+
# settings are configured anyway so this asserts the shape a real
|
|
116
|
+
# consumer has: installed alongside Django, at the declared floors.
|
|
117
|
+
settings.configure(
|
|
118
|
+
SECRET_KEY="x",
|
|
119
|
+
INSTALLED_APPS=["django.contrib.contenttypes", "django.contrib.auth"],
|
|
120
|
+
DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
|
|
121
|
+
)
|
|
122
|
+
django.setup()
|
|
123
|
+
|
|
124
|
+
import sys
|
|
125
|
+
|
|
126
|
+
import django_query_contract
|
|
127
|
+
|
|
128
|
+
# The plugin module is the one that imports pytest, and it is only
|
|
129
|
+
# ever loaded through the entry point. Importing the package must not
|
|
130
|
+
# drag a test runner in with it.
|
|
131
|
+
assert "pytest" not in sys.modules, "importing the package pulled in pytest"
|
|
132
|
+
|
|
133
|
+
print("BASE IMPORT OK", django_query_contract.__version__, django.get_version())
|
|
134
|
+
PY
|
|
135
|
+
|
|
136
|
+
test:
|
|
137
|
+
name: ${{ matrix.python-version }} / Django ${{ matrix.django-version }}
|
|
138
|
+
runs-on: ubuntu-latest
|
|
139
|
+
strategy:
|
|
140
|
+
fail-fast: false
|
|
141
|
+
matrix:
|
|
142
|
+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
143
|
+
django-version: ["4.2", "5.0", "5.1", "5.2", "6.0", "6.1"]
|
|
144
|
+
exclude:
|
|
145
|
+
# Django 4.2 supports Python 3.8–3.12
|
|
146
|
+
- python-version: "3.13"
|
|
147
|
+
django-version: "4.2"
|
|
148
|
+
- python-version: "3.14"
|
|
149
|
+
django-version: "4.2"
|
|
150
|
+
# Django 5.0 supports Python 3.10–3.12
|
|
151
|
+
- python-version: "3.13"
|
|
152
|
+
django-version: "5.0"
|
|
153
|
+
- python-version: "3.14"
|
|
154
|
+
django-version: "5.0"
|
|
155
|
+
# Django 5.1 supports Python 3.10–3.13
|
|
156
|
+
- python-version: "3.14"
|
|
157
|
+
django-version: "5.1"
|
|
158
|
+
# Django 6.0 supports Python 3.12+
|
|
159
|
+
- python-version: "3.10"
|
|
160
|
+
django-version: "6.0"
|
|
161
|
+
- python-version: "3.11"
|
|
162
|
+
django-version: "6.0"
|
|
163
|
+
# Django 6.1 requires Python 3.12+
|
|
164
|
+
- python-version: "3.10"
|
|
165
|
+
django-version: "6.1"
|
|
166
|
+
- python-version: "3.11"
|
|
167
|
+
django-version: "6.1"
|
|
168
|
+
|
|
169
|
+
steps:
|
|
170
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
171
|
+
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
172
|
+
with:
|
|
173
|
+
enable-cache: true
|
|
174
|
+
- name: Install Python ${{ matrix.python-version }}
|
|
175
|
+
run: uv python install ${{ matrix.python-version }}
|
|
176
|
+
- name: Sync dev deps
|
|
177
|
+
run: uv sync --all-groups --python ${{ matrix.python-version }}
|
|
178
|
+
- name: Pin Django ${{ matrix.django-version }}
|
|
179
|
+
run: uv pip install --reinstall-package django "django~=${{ matrix.django-version }}.0"
|
|
180
|
+
# The coverage gate rides on the default addopts and therefore on every
|
|
181
|
+
# cell. There is no service container and no split matrix here on purpose:
|
|
182
|
+
# everything this package ships is backend-neutral, so SQLite reaches
|
|
183
|
+
# every line. When plan assertions arrive they will need PostgreSQL, and
|
|
184
|
+
# the rule that keeps the gate here rather than moving it is to branch
|
|
185
|
+
# their refusals on the connection's vendor -- a refusal coverable only on
|
|
186
|
+
# the backend it refuses is one the gating job cannot see.
|
|
187
|
+
- name: pytest
|
|
188
|
+
run: uv run --no-sync pytest --cov-report=xml:coverage.xml --cov-report=html:htmlcov
|
|
189
|
+
- name: Upload coverage artifact
|
|
190
|
+
if: always()
|
|
191
|
+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
192
|
+
with:
|
|
193
|
+
name: coverage-py${{ matrix.python-version }}-django${{ matrix.django-version }}
|
|
194
|
+
path: |
|
|
195
|
+
coverage.xml
|
|
196
|
+
htmlcov/
|
|
197
|
+
if-no-files-found: ignore
|
|
198
|
+
|
|
199
|
+
coverage-badge:
|
|
200
|
+
# Publishes a shields.io-compatible coverage.json to gh-pages so the README
|
|
201
|
+
# badge reflects reality. Runs only on push to main, after the matrix has
|
|
202
|
+
# produced an artifact we can read from.
|
|
203
|
+
name: coverage badge
|
|
204
|
+
needs: test
|
|
205
|
+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
206
|
+
runs-on: ubuntu-latest
|
|
207
|
+
permissions:
|
|
208
|
+
contents: write
|
|
209
|
+
steps:
|
|
210
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
211
|
+
with:
|
|
212
|
+
# Need the parent commit so the gate step below can diff
|
|
213
|
+
# version.py against ${{ github.event.before }}. The default
|
|
214
|
+
# shallow checkout (depth 1) would make `git show "$before:…"`
|
|
215
|
+
# fail with exit 128 and turn the whole job red.
|
|
216
|
+
fetch-depth: 2
|
|
217
|
+
# release.yml runs in parallel on the same push and owns gh-pages
|
|
218
|
+
# publishing on release commits (it bundles coverage.json into the
|
|
219
|
+
# mkdocs gh-deploy). If we also push here we get a second built-in
|
|
220
|
+
# "pages build and deployment" run that the first one cancels. The
|
|
221
|
+
# version-diff against ${{ github.event.before }} is the reliable
|
|
222
|
+
# signal — release.yml's tag isn't pushed until after PyPI publish,
|
|
223
|
+
# so a tag-existence check would race.
|
|
224
|
+
- name: Skip when release.yml owns this push
|
|
225
|
+
id: gate
|
|
226
|
+
run: |
|
|
227
|
+
set -euo pipefail
|
|
228
|
+
before="${{ github.event.before }}"
|
|
229
|
+
if [[ -z "$before" || "$before" =~ ^0+$ ]]; then
|
|
230
|
+
# First push to main on a brand-new repo — no prior state to diff.
|
|
231
|
+
echo "skip=false" >> "$GITHUB_OUTPUT"
|
|
232
|
+
exit 0
|
|
233
|
+
fi
|
|
234
|
+
# `before` may not be in the local object DB if the push
|
|
235
|
+
# contained more than one commit (fetch-depth: 2 only guarantees
|
|
236
|
+
# HEAD + 1 parent). Try to fetch just that commit; if it's not
|
|
237
|
+
# reachable, fall back to "don't skip" — better to double-push
|
|
238
|
+
# to gh-pages once than to turn the pipeline red.
|
|
239
|
+
if ! git cat-file -e "$before^{commit}" 2>/dev/null; then
|
|
240
|
+
git fetch --depth=1 origin "$before" 2>/dev/null || true
|
|
241
|
+
fi
|
|
242
|
+
old=""
|
|
243
|
+
if git cat-file -e "$before:django_query_contract/version.py" 2>/dev/null; then
|
|
244
|
+
old="$(git show "$before:django_query_contract/version.py" \
|
|
245
|
+
| awk -F'"' '/^__version__/ { print $2; exit }')"
|
|
246
|
+
fi
|
|
247
|
+
new="$(awk -F'"' '/^__version__/ { print $2; exit }' \
|
|
248
|
+
django_query_contract/version.py)"
|
|
249
|
+
if [[ -n "$old" && "$old" != "$new" ]]; then
|
|
250
|
+
echo "Version bumped $old → $new; release.yml will deploy gh-pages."
|
|
251
|
+
echo "skip=true" >> "$GITHUB_OUTPUT"
|
|
252
|
+
else
|
|
253
|
+
echo "skip=false" >> "$GITHUB_OUTPUT"
|
|
254
|
+
fi
|
|
255
|
+
- name: Download canonical coverage artifact
|
|
256
|
+
if: steps.gate.outputs.skip != 'true'
|
|
257
|
+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
258
|
+
with:
|
|
259
|
+
# Every cell reaches every line, so the number is the same across the
|
|
260
|
+
# matrix and the badge needs exactly one. Pinned to the newest
|
|
261
|
+
# supported pair rather than left to chance.
|
|
262
|
+
name: coverage-py3.14-django6.1
|
|
263
|
+
path: coverage-artifact/
|
|
264
|
+
- name: Render coverage.json
|
|
265
|
+
if: steps.gate.outputs.skip != 'true'
|
|
266
|
+
run: |
|
|
267
|
+
python - <<'PY'
|
|
268
|
+
import json
|
|
269
|
+
import xml.etree.ElementTree as ET
|
|
270
|
+
|
|
271
|
+
tree = ET.parse("coverage-artifact/coverage.xml")
|
|
272
|
+
pct = round(float(tree.getroot().attrib["line-rate"]) * 100, 1)
|
|
273
|
+
if pct >= 95:
|
|
274
|
+
color = "brightgreen"
|
|
275
|
+
elif pct >= 85:
|
|
276
|
+
color = "green"
|
|
277
|
+
elif pct >= 70:
|
|
278
|
+
color = "yellow"
|
|
279
|
+
else:
|
|
280
|
+
color = "red"
|
|
281
|
+
payload = {
|
|
282
|
+
"schemaVersion": 1,
|
|
283
|
+
"label": "coverage",
|
|
284
|
+
"message": f"{pct}%",
|
|
285
|
+
"color": color,
|
|
286
|
+
}
|
|
287
|
+
with open("coverage.json", "w") as fh:
|
|
288
|
+
json.dump(payload, fh)
|
|
289
|
+
fh.write("\n")
|
|
290
|
+
print(json.dumps(payload))
|
|
291
|
+
PY
|
|
292
|
+
- name: Publish coverage.json to gh-pages
|
|
293
|
+
if: steps.gate.outputs.skip != 'true'
|
|
294
|
+
env:
|
|
295
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
296
|
+
run: |
|
|
297
|
+
set -euo pipefail
|
|
298
|
+
git config user.name 'github-actions[bot]'
|
|
299
|
+
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
|
300
|
+
|
|
301
|
+
tmp="$(mktemp -d)"
|
|
302
|
+
cp coverage.json "$tmp/coverage.json"
|
|
303
|
+
|
|
304
|
+
git fetch origin gh-pages --depth=1 2>/dev/null || true
|
|
305
|
+
if git rev-parse --verify origin/gh-pages >/dev/null 2>&1; then
|
|
306
|
+
git worktree add gh-pages origin/gh-pages
|
|
307
|
+
cp "$tmp/coverage.json" gh-pages/coverage.json
|
|
308
|
+
cd gh-pages
|
|
309
|
+
if [[ -n "$(git status --porcelain coverage.json)" ]]; then
|
|
310
|
+
git add coverage.json
|
|
311
|
+
git commit -m "coverage badge: update from ${GITHUB_SHA::7}"
|
|
312
|
+
git push origin HEAD:gh-pages
|
|
313
|
+
else
|
|
314
|
+
echo "coverage.json unchanged, skipping push"
|
|
315
|
+
fi
|
|
316
|
+
else
|
|
317
|
+
# First run before docs deploy. Lay down an orphan gh-pages with
|
|
318
|
+
# just the badge file; the docs deploy will overwrite the rest.
|
|
319
|
+
git worktree add --detach gh-pages
|
|
320
|
+
cd gh-pages
|
|
321
|
+
git checkout --orphan gh-pages
|
|
322
|
+
git rm -rf . >/dev/null 2>&1 || true
|
|
323
|
+
cp "$tmp/coverage.json" coverage.json
|
|
324
|
+
git add coverage.json
|
|
325
|
+
git commit -m "coverage badge: initial publish from ${GITHUB_SHA::7}"
|
|
326
|
+
git push origin gh-pages
|
|
327
|
+
fi
|
|
328
|
+
|
|
329
|
+
secrets:
|
|
330
|
+
name: secret scan
|
|
331
|
+
runs-on: ubuntu-latest
|
|
332
|
+
steps:
|
|
333
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
334
|
+
with:
|
|
335
|
+
fetch-depth: 0
|
|
336
|
+
- name: Install gitleaks
|
|
337
|
+
run: |
|
|
338
|
+
curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz | tar -xz gitleaks
|
|
339
|
+
sudo mv gitleaks /usr/local/bin/
|
|
340
|
+
- name: Scan repo + history
|
|
341
|
+
run: gitleaks git --redact --no-banner .
|
|
342
|
+
|
|
343
|
+
tests-passed:
|
|
344
|
+
name: tests
|
|
345
|
+
if: always()
|
|
346
|
+
needs: [lint, docs, floor, test]
|
|
347
|
+
runs-on: ubuntu-latest
|
|
348
|
+
steps:
|
|
349
|
+
- name: Verify required jobs succeeded
|
|
350
|
+
run: |
|
|
351
|
+
if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
|
|
352
|
+
echo "a required job failed"; exit 1
|
|
353
|
+
fi
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# What a fresh install would get today, run on a schedule.
|
|
2
|
+
#
|
|
3
|
+
# ``uv.lock`` is what every other job resolves against, so a dependency can be
|
|
4
|
+
# unconstrained in ``pyproject.toml`` and still frozen everywhere it is actually
|
|
5
|
+
# tested. That gap is why the Django 6.1 break was invisible while the matrix
|
|
6
|
+
# was green: the cell that found it installed unpinned and resolved 6.1 by
|
|
7
|
+
# accident.
|
|
8
|
+
#
|
|
9
|
+
# This job closes it deliberately. It ignores the lock, resolves the newest
|
|
10
|
+
# versions ``pyproject.toml`` allows, and runs the suite — so an upstream
|
|
11
|
+
# release that breaks us is found by a scheduled run rather than by a consumer.
|
|
12
|
+
#
|
|
13
|
+
# It is also the control that makes dropping dependency ceilings safe. A
|
|
14
|
+
# ceiling is a guess about which future versions will break; this is a
|
|
15
|
+
# measurement of which ones do. Do not delete it to quieten a noisy week
|
|
16
|
+
# without replacing what it measures.
|
|
17
|
+
name: upstream drift
|
|
18
|
+
|
|
19
|
+
on:
|
|
20
|
+
schedule:
|
|
21
|
+
# Mondays, 06:00 UTC. Weekly rather than nightly: the failure this guards
|
|
22
|
+
# against is an upstream release, which is measured in weeks, and six repos
|
|
23
|
+
# running nightly is six chances a day to learn nothing.
|
|
24
|
+
- cron: "0 6 * * 1"
|
|
25
|
+
workflow_dispatch:
|
|
26
|
+
|
|
27
|
+
concurrency:
|
|
28
|
+
group: upstream-drift-${{ github.ref }}
|
|
29
|
+
cancel-in-progress: true
|
|
30
|
+
|
|
31
|
+
permissions:
|
|
32
|
+
contents: read
|
|
33
|
+
issues: write
|
|
34
|
+
|
|
35
|
+
# Actions are pinned to a commit SHA rather than a tag: a tag is mutable, and an
|
|
36
|
+
# action owner (or anyone who takes over the account) can silently repoint one at
|
|
37
|
+
# new code that then runs with this workflow's token. The trailing version comment
|
|
38
|
+
# is not decoration -- Dependabot reads it to know which release the SHA is, and
|
|
39
|
+
# rewrites both together when a newer one ships.
|
|
40
|
+
jobs:
|
|
41
|
+
resolve-latest:
|
|
42
|
+
name: resolve unpinned + test
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
46
|
+
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
47
|
+
with:
|
|
48
|
+
enable-cache: false
|
|
49
|
+
|
|
50
|
+
# One cell, not the matrix. The matrix answers "does this combination
|
|
51
|
+
# work"; this answers "has an upstream moved under us", and the newest
|
|
52
|
+
# supported Python is where a new release lands first.
|
|
53
|
+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
54
|
+
with:
|
|
55
|
+
python-version: "3.14"
|
|
56
|
+
|
|
57
|
+
- name: Resolve the newest versions pyproject allows
|
|
58
|
+
run: uv lock --upgrade
|
|
59
|
+
|
|
60
|
+
- name: Report what changed against the committed lock
|
|
61
|
+
# Informational, and the most useful line in the log when this fails:
|
|
62
|
+
# it names the upgrade that broke the build.
|
|
63
|
+
run: git --no-pager diff --stat uv.lock || true
|
|
64
|
+
|
|
65
|
+
- name: Install
|
|
66
|
+
run: uv sync --all-groups --all-extras
|
|
67
|
+
|
|
68
|
+
- name: Show the resolved versions
|
|
69
|
+
run: uv pip list
|
|
70
|
+
|
|
71
|
+
- name: Test
|
|
72
|
+
run: uv run pytest
|
|
73
|
+
|
|
74
|
+
- name: Open or update an issue on failure
|
|
75
|
+
if: failure()
|
|
76
|
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
77
|
+
with:
|
|
78
|
+
script: |
|
|
79
|
+
const title = "upstream drift: the unpinned resolve is failing";
|
|
80
|
+
const body = [
|
|
81
|
+
"The weekly unpinned resolve failed. A dependency released a version",
|
|
82
|
+
"that `pyproject.toml` admits and the suite does not survive.",
|
|
83
|
+
"",
|
|
84
|
+
`Run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
|
|
85
|
+
"",
|
|
86
|
+
"The `git diff --stat uv.lock` step in that run names what moved.",
|
|
87
|
+
"",
|
|
88
|
+
"Fix forward (adopt the new version) or add a floor/ceiling that says",
|
|
89
|
+
"why, but do not silence the job — it is the control that lets this",
|
|
90
|
+
"repo declare open-ended dependency windows.",
|
|
91
|
+
].join("\n");
|
|
92
|
+
const existing = await github.rest.issues.listForRepo({
|
|
93
|
+
owner: context.repo.owner, repo: context.repo.repo,
|
|
94
|
+
state: "open", labels: "upstream-drift",
|
|
95
|
+
});
|
|
96
|
+
if (existing.data.length) {
|
|
97
|
+
await github.rest.issues.createComment({
|
|
98
|
+
owner: context.repo.owner, repo: context.repo.repo,
|
|
99
|
+
issue_number: existing.data[0].number, body,
|
|
100
|
+
});
|
|
101
|
+
} else {
|
|
102
|
+
await github.rest.issues.create({
|
|
103
|
+
owner: context.repo.owner, repo: context.repo.repo,
|
|
104
|
+
title, body, labels: ["upstream-drift"],
|
|
105
|
+
});
|
|
106
|
+
}
|