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.
Files changed (47) hide show
  1. django_query_contract-0.1.0/.github/CODEOWNERS +1 -0
  2. django_query_contract-0.1.0/.github/SECURITY.md +9 -0
  3. django_query_contract-0.1.0/.github/dependabot.yml +26 -0
  4. django_query_contract-0.1.0/.github/workflows/release.yml +118 -0
  5. django_query_contract-0.1.0/.github/workflows/tests.yml +353 -0
  6. django_query_contract-0.1.0/.github/workflows/upstream-drift.yml +106 -0
  7. django_query_contract-0.1.0/.gitignore +636 -0
  8. django_query_contract-0.1.0/.pre-commit-config.yaml +67 -0
  9. django_query_contract-0.1.0/CHANGELOG.md +46 -0
  10. django_query_contract-0.1.0/CLAUDE.md +231 -0
  11. django_query_contract-0.1.0/LICENSE +21 -0
  12. django_query_contract-0.1.0/Makefile +90 -0
  13. django_query_contract-0.1.0/PKG-INFO +169 -0
  14. django_query_contract-0.1.0/README.md +131 -0
  15. django_query_contract-0.1.0/django_query_contract/__init__.py +23 -0
  16. django_query_contract-0.1.0/django_query_contract/capture_stack.py +64 -0
  17. django_query_contract-0.1.0/django_query_contract/format_capture_report.py +128 -0
  18. django_query_contract-0.1.0/django_query_contract/log_ceiling.py +73 -0
  19. django_query_contract-0.1.0/django_query_contract/normalise_sql.py +90 -0
  20. django_query_contract-0.1.0/django_query_contract/plugin.py +129 -0
  21. django_query_contract-0.1.0/django_query_contract/py.typed +0 -0
  22. django_query_contract-0.1.0/django_query_contract/query_capture.py +211 -0
  23. django_query_contract-0.1.0/django_query_contract/query_log_ceiling_warning.py +19 -0
  24. django_query_contract-0.1.0/django_query_contract/query_record.py +97 -0
  25. django_query_contract-0.1.0/django_query_contract/stack_frame.py +29 -0
  26. django_query_contract-0.1.0/django_query_contract/version.py +7 -0
  27. django_query_contract-0.1.0/docs/index.md +6 -0
  28. django_query_contract-0.1.0/docs/reference.md +25 -0
  29. django_query_contract-0.1.0/mkdocs.yml +73 -0
  30. django_query_contract-0.1.0/pyproject.toml +188 -0
  31. django_query_contract-0.1.0/scripts/release-publish.sh +246 -0
  32. django_query_contract-0.1.0/tests/__init__.py +0 -0
  33. django_query_contract-0.1.0/tests/conftest.py +88 -0
  34. django_query_contract-0.1.0/tests/conftest_settings.py +41 -0
  35. django_query_contract-0.1.0/tests/test_capture_stack.py +66 -0
  36. django_query_contract-0.1.0/tests/test_documentation.py +61 -0
  37. django_query_contract-0.1.0/tests/test_format_capture_report.py +184 -0
  38. django_query_contract-0.1.0/tests/test_log_ceiling.py +58 -0
  39. django_query_contract-0.1.0/tests/test_normalise_sql.py +115 -0
  40. django_query_contract-0.1.0/tests/test_plugin.py +286 -0
  41. django_query_contract-0.1.0/tests/test_plugin_hooks.py +238 -0
  42. django_query_contract-0.1.0/tests/test_query_capture.py +317 -0
  43. django_query_contract-0.1.0/tests/test_query_record.py +94 -0
  44. django_query_contract-0.1.0/tests/test_version.py +10 -0
  45. django_query_contract-0.1.0/tests/testapp/__init__.py +0 -0
  46. django_query_contract-0.1.0/tests/testapp/models.py +18 -0
  47. 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
+ }