django-outbound-webhooks 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 (112) hide show
  1. django_outbound_webhooks-0.1.0/.github/CODEOWNERS +1 -0
  2. django_outbound_webhooks-0.1.0/.github/SECURITY.md +9 -0
  3. django_outbound_webhooks-0.1.0/.github/dependabot.yml +26 -0
  4. django_outbound_webhooks-0.1.0/.github/workflows/release.yml +112 -0
  5. django_outbound_webhooks-0.1.0/.github/workflows/tests.yml +342 -0
  6. django_outbound_webhooks-0.1.0/.github/workflows/upstream-drift.yml +106 -0
  7. django_outbound_webhooks-0.1.0/.gitignore +639 -0
  8. django_outbound_webhooks-0.1.0/.pre-commit-config.yaml +90 -0
  9. django_outbound_webhooks-0.1.0/CHANGELOG.md +146 -0
  10. django_outbound_webhooks-0.1.0/CLAUDE.md +254 -0
  11. django_outbound_webhooks-0.1.0/LICENSE +21 -0
  12. django_outbound_webhooks-0.1.0/Makefile +90 -0
  13. django_outbound_webhooks-0.1.0/PKG-INFO +67 -0
  14. django_outbound_webhooks-0.1.0/README.md +28 -0
  15. django_outbound_webhooks-0.1.0/django_outbound_webhooks/__init__.py +41 -0
  16. django_outbound_webhooks-0.1.0/django_outbound_webhooks/apps.py +55 -0
  17. django_outbound_webhooks-0.1.0/django_outbound_webhooks/checks.py +58 -0
  18. django_outbound_webhooks-0.1.0/django_outbound_webhooks/delivery/__init__.py +4 -0
  19. django_outbound_webhooks-0.1.0/django_outbound_webhooks/delivery/check_address.py +72 -0
  20. django_outbound_webhooks-0.1.0/django_outbound_webhooks/delivery/classify_response.py +32 -0
  21. django_outbound_webhooks-0.1.0/django_outbound_webhooks/delivery/deliver_due.py +108 -0
  22. django_outbound_webhooks-0.1.0/django_outbound_webhooks/delivery/fan_out.py +44 -0
  23. django_outbound_webhooks-0.1.0/django_outbound_webhooks/delivery/lease_deadline.py +37 -0
  24. django_outbound_webhooks-0.1.0/django_outbound_webhooks/delivery/pending_attempts.py +39 -0
  25. django_outbound_webhooks-0.1.0/django_outbound_webhooks/delivery/pinning_transport.py +64 -0
  26. django_outbound_webhooks-0.1.0/django_outbound_webhooks/delivery/record_attempts.py +42 -0
  27. django_outbound_webhooks-0.1.0/django_outbound_webhooks/delivery/record_failed_attempts.py +40 -0
  28. django_outbound_webhooks-0.1.0/django_outbound_webhooks/delivery/register_fan_out.py +45 -0
  29. django_outbound_webhooks-0.1.0/django_outbound_webhooks/delivery/resolve_destination.py +45 -0
  30. django_outbound_webhooks-0.1.0/django_outbound_webhooks/delivery/retry_policy.py +59 -0
  31. django_outbound_webhooks-0.1.0/django_outbound_webhooks/delivery/send_once.py +75 -0
  32. django_outbound_webhooks-0.1.0/django_outbound_webhooks/delivery/send_webhook.py +53 -0
  33. django_outbound_webhooks-0.1.0/django_outbound_webhooks/delivery/unsafe_destination.py +13 -0
  34. django_outbound_webhooks-0.1.0/django_outbound_webhooks/delivery/webhook_client.py +34 -0
  35. django_outbound_webhooks-0.1.0/django_outbound_webhooks/delivery/webhook_delivery_due.py +48 -0
  36. django_outbound_webhooks-0.1.0/django_outbound_webhooks/endpoints/__init__.py +13 -0
  37. django_outbound_webhooks-0.1.0/django_outbound_webhooks/endpoints/endpoints_for.py +60 -0
  38. django_outbound_webhooks-0.1.0/django_outbound_webhooks/endpoints/pinned_format.py +20 -0
  39. django_outbound_webhooks-0.1.0/django_outbound_webhooks/endpoints/register_endpoint.py +95 -0
  40. django_outbound_webhooks-0.1.0/django_outbound_webhooks/endpoints/validate_webhook_url.py +24 -0
  41. django_outbound_webhooks-0.1.0/django_outbound_webhooks/formats/__init__.py +7 -0
  42. django_outbound_webhooks-0.1.0/django_outbound_webhooks/formats/body_format.py +66 -0
  43. django_outbound_webhooks-0.1.0/django_outbound_webhooks/formats/envelope_v1.py +49 -0
  44. django_outbound_webhooks-0.1.0/django_outbound_webhooks/formats/format_registry.py +144 -0
  45. django_outbound_webhooks-0.1.0/django_outbound_webhooks/migrations/0001_initial.py +96 -0
  46. django_outbound_webhooks-0.1.0/django_outbound_webhooks/migrations/0002_deliveryattempt.py +64 -0
  47. django_outbound_webhooks-0.1.0/django_outbound_webhooks/migrations/__init__.py +0 -0
  48. django_outbound_webhooks-0.1.0/django_outbound_webhooks/models/__init__.py +7 -0
  49. django_outbound_webhooks-0.1.0/django_outbound_webhooks/models/delivery_attempt.py +66 -0
  50. django_outbound_webhooks-0.1.0/django_outbound_webhooks/models/endpoint.py +60 -0
  51. django_outbound_webhooks-0.1.0/django_outbound_webhooks/models/subscription.py +50 -0
  52. django_outbound_webhooks-0.1.0/django_outbound_webhooks/py.typed +0 -0
  53. django_outbound_webhooks-0.1.0/django_outbound_webhooks/settings.py +75 -0
  54. django_outbound_webhooks-0.1.0/django_outbound_webhooks/signing/__init__.py +13 -0
  55. django_outbound_webhooks-0.1.0/django_outbound_webhooks/signing/sign_request.py +75 -0
  56. django_outbound_webhooks-0.1.0/django_outbound_webhooks/signing/signing_secrets.py +23 -0
  57. django_outbound_webhooks-0.1.0/django_outbound_webhooks/signing/validate_signing_secret.py +51 -0
  58. django_outbound_webhooks-0.1.0/django_outbound_webhooks/types/__init__.py +6 -0
  59. django_outbound_webhooks-0.1.0/django_outbound_webhooks/types/attempt_outcome.py +31 -0
  60. django_outbound_webhooks-0.1.0/django_outbound_webhooks/types/delivery_verdict.py +27 -0
  61. django_outbound_webhooks-0.1.0/django_outbound_webhooks/types/format_id.py +33 -0
  62. django_outbound_webhooks-0.1.0/django_outbound_webhooks/types/rendered_body.py +24 -0
  63. django_outbound_webhooks-0.1.0/django_outbound_webhooks/version.py +7 -0
  64. django_outbound_webhooks-0.1.0/docs/index.md +7 -0
  65. django_outbound_webhooks-0.1.0/mkdocs.yml +72 -0
  66. django_outbound_webhooks-0.1.0/pyproject.toml +174 -0
  67. django_outbound_webhooks-0.1.0/scripts/check-changelog +148 -0
  68. django_outbound_webhooks-0.1.0/scripts/generate-format-fixtures +66 -0
  69. django_outbound_webhooks-0.1.0/scripts/release-publish.sh +246 -0
  70. django_outbound_webhooks-0.1.0/tests/__init__.py +0 -0
  71. django_outbound_webhooks-0.1.0/tests/conftest.py +60 -0
  72. django_outbound_webhooks-0.1.0/tests/conftest_settings.py +36 -0
  73. django_outbound_webhooks-0.1.0/tests/delivery/__init__.py +0 -0
  74. django_outbound_webhooks-0.1.0/tests/delivery/test_check_address.py +93 -0
  75. django_outbound_webhooks-0.1.0/tests/delivery/test_classify_response.py +48 -0
  76. django_outbound_webhooks-0.1.0/tests/delivery/test_deliver_due.py +154 -0
  77. django_outbound_webhooks-0.1.0/tests/delivery/test_delivery_log.py +288 -0
  78. django_outbound_webhooks-0.1.0/tests/delivery/test_fan_out.py +144 -0
  79. django_outbound_webhooks-0.1.0/tests/delivery/test_lease_deadline.py +45 -0
  80. django_outbound_webhooks-0.1.0/tests/delivery/test_pinning_transport.py +145 -0
  81. django_outbound_webhooks-0.1.0/tests/delivery/test_register_fan_out.py +44 -0
  82. django_outbound_webhooks-0.1.0/tests/delivery/test_resolve_destination.py +55 -0
  83. django_outbound_webhooks-0.1.0/tests/delivery/test_retry_policy.py +59 -0
  84. django_outbound_webhooks-0.1.0/tests/delivery/test_send_webhook.py +203 -0
  85. django_outbound_webhooks-0.1.0/tests/delivery/test_webhook_client.py +20 -0
  86. django_outbound_webhooks-0.1.0/tests/endpoints/__init__.py +0 -0
  87. django_outbound_webhooks-0.1.0/tests/endpoints/test_endpoints_for.py +128 -0
  88. django_outbound_webhooks-0.1.0/tests/endpoints/test_pinned_format.py +14 -0
  89. django_outbound_webhooks-0.1.0/tests/endpoints/test_register_endpoint.py +131 -0
  90. django_outbound_webhooks-0.1.0/tests/endpoints/test_validate_webhook_url.py +34 -0
  91. django_outbound_webhooks-0.1.0/tests/fixtures/formats/envelope-v1.json +1 -0
  92. django_outbound_webhooks-0.1.0/tests/format_samples.py +39 -0
  93. django_outbound_webhooks-0.1.0/tests/formats/__init__.py +0 -0
  94. django_outbound_webhooks-0.1.0/tests/formats/test_envelope_v1.py +76 -0
  95. django_outbound_webhooks-0.1.0/tests/formats/test_format_registry.py +237 -0
  96. django_outbound_webhooks-0.1.0/tests/models/__init__.py +0 -0
  97. django_outbound_webhooks-0.1.0/tests/models/test_endpoint.py +55 -0
  98. django_outbound_webhooks-0.1.0/tests/models/test_subscription.py +67 -0
  99. django_outbound_webhooks-0.1.0/tests/signing/__init__.py +0 -0
  100. django_outbound_webhooks-0.1.0/tests/signing/test_sign_request.py +104 -0
  101. django_outbound_webhooks-0.1.0/tests/signing/test_signing_secrets.py +12 -0
  102. django_outbound_webhooks-0.1.0/tests/signing/test_validate_signing_secret.py +48 -0
  103. django_outbound_webhooks-0.1.0/tests/test_checks.py +73 -0
  104. django_outbound_webhooks-0.1.0/tests/test_migrations.py +41 -0
  105. django_outbound_webhooks-0.1.0/tests/test_settings.py +32 -0
  106. django_outbound_webhooks-0.1.0/tests/test_version.py +29 -0
  107. django_outbound_webhooks-0.1.0/tests/testapp/__init__.py +0 -0
  108. django_outbound_webhooks-0.1.0/tests/testapp/events.py +20 -0
  109. django_outbound_webhooks-0.1.0/tests/types/__init__.py +0 -0
  110. django_outbound_webhooks-0.1.0/tests/types/test_format_id.py +31 -0
  111. django_outbound_webhooks-0.1.0/tests/types/test_rendered_body.py +24 -0
  112. django_outbound_webhooks-0.1.0/uv.lock +1489 -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,112 @@
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-outbound-webhooks
27
+ permissions:
28
+ id-token: write # PyPI OIDC trusted publishing
29
+ contents: write # tag push + gh release create
30
+ steps:
31
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
32
+ with:
33
+ fetch-depth: 0
34
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
35
+ with:
36
+ enable-cache: true
37
+ - name: Install
38
+ run: uv sync --all-groups
39
+
40
+ - name: Prepare release (version check, test, build dist)
41
+ id: prepare
42
+ run: make release-publish-prepare
43
+
44
+ - name: Publish to PyPI
45
+ if: steps.prepare.outputs.released == 'true'
46
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
47
+ with:
48
+ # Idempotent on purpose. What gates a release is the git tag, not the
49
+ # index (see `prepare`), so re-running this job after a failure in a
50
+ # later step arrives here having already uploaded these files. Without
51
+ # this the upload is the step that makes that re-run impossible, and
52
+ # every recovery becomes manual — which is how one release lost its tag,
53
+ # its GitHub Release and its docs deploy to a single transient push
54
+ # failure, with the package already published.
55
+ # PyPI never lets a file be replaced, so skipping one cannot overwrite
56
+ # a published artefact.
57
+ skip-existing: true
58
+
59
+ - name: Tag + GitHub Release
60
+ if: steps.prepare.outputs.released == 'true'
61
+ env:
62
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
63
+ run: make release-publish-finalize
64
+
65
+ # On release commits we own the single gh-pages push: embed the
66
+ # coverage badge JSON into the docs build so mkdocs gh-deploy carries
67
+ # it alongside the site, and tests.yml's coverage-badge job
68
+ # short-circuits to avoid a second push (and a second built-in
69
+ # "pages build and deployment" run).
70
+ - name: Render coverage badge for the docs site
71
+ if: steps.prepare.outputs.released == 'true' && hashFiles('mkdocs.yml') != ''
72
+ run: |
73
+ set -euo pipefail
74
+ # release-publish-prepare's pytest run wrote .coverage to the
75
+ # workspace root; convert to coverage.xml so the same parser
76
+ # we use in tests.yml can score it.
77
+ uv run coverage xml -o coverage.xml
78
+ python - <<'PY'
79
+ import json
80
+ import xml.etree.ElementTree as ET
81
+
82
+ tree = ET.parse("coverage.xml")
83
+ pct = round(float(tree.getroot().attrib["line-rate"]) * 100, 1)
84
+ if pct >= 95:
85
+ color = "brightgreen"
86
+ elif pct >= 85:
87
+ color = "green"
88
+ elif pct >= 70:
89
+ color = "yellow"
90
+ else:
91
+ color = "red"
92
+ payload = {
93
+ "schemaVersion": 1,
94
+ "label": "coverage",
95
+ "message": f"{pct}%",
96
+ "color": color,
97
+ }
98
+ # docs/ is mkdocs' source dir; non-.md files are copied verbatim
99
+ # into site/, which lands at the gh-pages root after gh-deploy.
100
+ with open("docs/coverage.json", "w") as fh:
101
+ json.dump(payload, fh)
102
+ fh.write("\n")
103
+ print(json.dumps(payload))
104
+ PY
105
+
106
+ - name: Deploy docs + coverage badge to gh-pages
107
+ if: steps.prepare.outputs.released == 'true' && hashFiles('mkdocs.yml') != ''
108
+ run: |
109
+ git config user.name 'github-actions[bot]'
110
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
111
+ DJANGO_SETTINGS_MODULE=tests.conftest_settings \
112
+ uv run mkdocs gh-deploy --force --clean --config-file mkdocs.yml
@@ -0,0 +1,342 @@
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_outbound_webhooks
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
+ # mkdocstrings imports the package, which defines Django models. Those
47
+ # cannot be imported before ``django.setup()``, so we point at the test
48
+ # settings module to keep the build hermetic.
49
+ run: DJANGO_SETTINGS_MODULE=tests.conftest_settings uv run mkdocs build --strict
50
+
51
+ floor:
52
+ name: lowest declared versions
53
+ runs-on: ubuntu-latest
54
+ steps:
55
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
56
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
57
+ with:
58
+ enable-cache: true
59
+ # A floor is a claim about the *oldest* version that works, and a lockfile
60
+ # can only ever check the newest. Development and CI both resolve the
61
+ # newest in-range dependency, so every gate passes while a consumer whose
62
+ # own constraints pull an older one gets a package that does not import.
63
+ #
64
+ # `lowest-direct` pins every *declared* dependency to the bottom of its
65
+ # window while letting transitives resolve normally, so this asserts the
66
+ # floors mean what they say. A per-PR gate rather than a scheduled job,
67
+ # because a floor stops being true the moment someone imports a newer
68
+ # upstream symbol -- a code change, not the passage of time.
69
+ #
70
+ # Pinned to the oldest supported Python rather than whatever interpreter
71
+ # the runner exposes. The floor claim is about the oldest configuration
72
+ # we support, so that is the one to install, and leaving the choice to
73
+ # the image makes the answer drift when the image does.
74
+ - name: Install Python 3.10
75
+ run: uv python install 3.10
76
+ # `--refresh` on both resolutions below. This job's whole claim is "what a
77
+ # consumer resolving from scratch would get", and the runner's uv cache is
78
+ # shared across runs: without it the answer comes from whatever package
79
+ # list that cache happens to hold, which can be hours behind the index.
80
+ - name: Resolve every declared dependency at its floor
81
+ run: uv lock --resolution lowest-direct --refresh
82
+ # `--frozen` here and `--no-sync` below, not a bare `uv sync` / `uv run`.
83
+ # uv records the resolution mode in the lockfile and silently discards one
84
+ # resolved in a different mode, so a plain `uv sync` re-resolves at
85
+ # `highest` and undoes the step above -- the job would then measure the
86
+ # newest versions twice while reporting that it tested the oldest.
87
+ - name: Install
88
+ run: uv sync --frozen --all-groups --all-extras --python 3.10
89
+ - name: Name the versions that produced
90
+ run: uv pip list
91
+ - name: Test
92
+ run: uv run --no-sync pytest
93
+
94
+ # The step above installs every extra, and an extra can hold a shared
95
+ # dependency *above* the floor being claimed -- masking the lie rather
96
+ # than exposing it. So the floor claim is also checked on the install
97
+ # shape with the fewest constraints: the package alone, no extras, no dev
98
+ # group. That shape is what a consumer who wants only the default codec
99
+ # actually gets.
100
+ - name: Import the package installed alone, at its floors
101
+ run: |
102
+ uv venv --python 3.10 .venv-floor
103
+ uv pip install --python .venv-floor/bin/python --resolution lowest-direct --refresh -e .
104
+ uv pip list --python .venv-floor/bin/python
105
+ .venv-floor/bin/python - <<'PY'
106
+ import django
107
+ from django.conf import settings
108
+
109
+ # This package defines models, so a bare import cannot run before
110
+ # django.setup() against configured settings.
111
+ settings.configure(
112
+ SECRET_KEY="x",
113
+ INSTALLED_APPS=[
114
+ "django.contrib.contenttypes",
115
+ "django.contrib.auth",
116
+ # The substrate ships models this package's own models point
117
+ # at, so its app has to be installed for either to load.
118
+ "django_domain_events",
119
+ "django_outbound_webhooks",
120
+ ],
121
+ DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
122
+ )
123
+ django.setup()
124
+
125
+ import django_outbound_webhooks
126
+
127
+ print("BASE IMPORT OK", django_outbound_webhooks.__version__, django.get_version())
128
+ PY
129
+
130
+ test:
131
+ name: ${{ matrix.python-version }} / Django ${{ matrix.django-version }}
132
+ runs-on: ubuntu-latest
133
+ strategy:
134
+ fail-fast: false
135
+ matrix:
136
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
137
+ django-version: ["4.2", "5.0", "5.1", "5.2", "6.0", "6.1"]
138
+ exclude:
139
+ # Django 4.2 supports Python 3.8–3.12
140
+ - python-version: "3.13"
141
+ django-version: "4.2"
142
+ - python-version: "3.14"
143
+ django-version: "4.2"
144
+ # Django 5.0 supports Python 3.10–3.12
145
+ - python-version: "3.13"
146
+ django-version: "5.0"
147
+ - python-version: "3.14"
148
+ django-version: "5.0"
149
+ # Django 5.1 supports Python 3.10–3.13
150
+ - python-version: "3.14"
151
+ django-version: "5.1"
152
+ # Django 6.0 supports Python 3.12+
153
+ - python-version: "3.10"
154
+ django-version: "6.0"
155
+ - python-version: "3.11"
156
+ django-version: "6.0"
157
+ # Django 6.1 requires Python 3.12+
158
+ - python-version: "3.10"
159
+ django-version: "6.1"
160
+ - python-version: "3.11"
161
+ django-version: "6.1"
162
+
163
+ steps:
164
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
165
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
166
+ with:
167
+ enable-cache: true
168
+ - name: Install Python ${{ matrix.python-version }}
169
+ run: uv python install ${{ matrix.python-version }}
170
+ - name: Sync dev deps
171
+ run: uv sync --all-groups --python ${{ matrix.python-version }}
172
+ - name: Pin Django ${{ matrix.django-version }}
173
+ run: uv pip install --reinstall-package django "django~=${{ matrix.django-version }}.0"
174
+ - name: pytest
175
+ run: |
176
+ uv run --no-sync pytest \
177
+ --cov-report=xml:coverage.xml \
178
+ --cov-report=html:htmlcov
179
+ - name: Upload coverage artifact
180
+ if: always()
181
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
182
+ with:
183
+ name: coverage-py${{ matrix.python-version }}-django${{ matrix.django-version }}
184
+ path: |
185
+ coverage.xml
186
+ htmlcov/
187
+ if-no-files-found: ignore
188
+
189
+ coverage-badge:
190
+ # Publishes a shields.io-compatible coverage.json to gh-pages so the README
191
+ # badge reflects reality. Runs only on push to main, after the matrix has
192
+ # produced an artifact we can read from.
193
+ name: coverage badge
194
+ needs: test
195
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
196
+ runs-on: ubuntu-latest
197
+ permissions:
198
+ contents: write
199
+ steps:
200
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
201
+ with:
202
+ # Need the parent commit so the gate step below can diff
203
+ # version.py against ${{ github.event.before }}. The default
204
+ # shallow checkout (depth 1) would make `git show "$before:…"`
205
+ # fail with exit 128 and turn the whole job red.
206
+ fetch-depth: 2
207
+ # release.yml runs in parallel on the same push and owns gh-pages
208
+ # publishing on release commits (it bundles coverage.json into the
209
+ # mkdocs gh-deploy). If we also push here we get a second built-in
210
+ # "pages build and deployment" run that the first one cancels. The
211
+ # version-diff against ${{ github.event.before }} is the reliable
212
+ # signal — release.yml's tag isn't pushed until after PyPI publish,
213
+ # so a tag-existence check would race.
214
+ - name: Skip when release.yml owns this push
215
+ id: gate
216
+ run: |
217
+ set -euo pipefail
218
+ before="${{ github.event.before }}"
219
+ if [[ -z "$before" || "$before" =~ ^0+$ ]]; then
220
+ # First push to main on a brand-new repo — no prior state to diff.
221
+ echo "skip=false" >> "$GITHUB_OUTPUT"
222
+ exit 0
223
+ fi
224
+ # `before` may not be in the local object DB if the push
225
+ # contained more than one commit (fetch-depth: 2 only guarantees
226
+ # HEAD + 1 parent). Try to fetch just that commit; if it's not
227
+ # reachable, fall back to "don't skip" — better to double-push
228
+ # to gh-pages once than to turn the pipeline red.
229
+ if ! git cat-file -e "$before^{commit}" 2>/dev/null; then
230
+ git fetch --depth=1 origin "$before" 2>/dev/null || true
231
+ fi
232
+ old=""
233
+ if git cat-file -e "$before:django_outbound_webhooks/version.py" 2>/dev/null; then
234
+ old="$(git show "$before:django_outbound_webhooks/version.py" \
235
+ | awk -F'"' '/^__version__/ { print $2; exit }')"
236
+ fi
237
+ new="$(awk -F'"' '/^__version__/ { print $2; exit }' \
238
+ django_outbound_webhooks/version.py)"
239
+ if [[ -n "$old" && "$old" != "$new" ]]; then
240
+ echo "Version bumped $old → $new; release.yml will deploy gh-pages."
241
+ echo "skip=true" >> "$GITHUB_OUTPUT"
242
+ else
243
+ echo "skip=false" >> "$GITHUB_OUTPUT"
244
+ fi
245
+ - name: Download canonical coverage artifact
246
+ if: steps.gate.outputs.skip != 'true'
247
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
248
+ with:
249
+ # Pin to one cell of the matrix — coverage is the same shape across
250
+ # all cells and we only need one number.
251
+ name: coverage-py3.13-django5.2
252
+ path: coverage-artifact/
253
+ - name: Render coverage.json
254
+ if: steps.gate.outputs.skip != 'true'
255
+ run: |
256
+ python - <<'PY'
257
+ import json
258
+ import xml.etree.ElementTree as ET
259
+
260
+ tree = ET.parse("coverage-artifact/coverage.xml")
261
+ pct = round(float(tree.getroot().attrib["line-rate"]) * 100, 1)
262
+ if pct >= 95:
263
+ color = "brightgreen"
264
+ elif pct >= 85:
265
+ color = "green"
266
+ elif pct >= 70:
267
+ color = "yellow"
268
+ else:
269
+ color = "red"
270
+ payload = {
271
+ "schemaVersion": 1,
272
+ "label": "coverage",
273
+ "message": f"{pct}%",
274
+ "color": color,
275
+ }
276
+ with open("coverage.json", "w") as fh:
277
+ json.dump(payload, fh)
278
+ fh.write("\n")
279
+ print(json.dumps(payload))
280
+ PY
281
+ - name: Publish coverage.json to gh-pages
282
+ if: steps.gate.outputs.skip != 'true'
283
+ env:
284
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
285
+ run: |
286
+ set -euo pipefail
287
+ git config user.name 'github-actions[bot]'
288
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
289
+
290
+ tmp="$(mktemp -d)"
291
+ cp coverage.json "$tmp/coverage.json"
292
+
293
+ git fetch origin gh-pages --depth=1 2>/dev/null || true
294
+ if git rev-parse --verify origin/gh-pages >/dev/null 2>&1; then
295
+ git worktree add gh-pages origin/gh-pages
296
+ cp "$tmp/coverage.json" gh-pages/coverage.json
297
+ cd gh-pages
298
+ if [[ -n "$(git status --porcelain coverage.json)" ]]; then
299
+ git add coverage.json
300
+ git commit -m "coverage badge: update from ${GITHUB_SHA::7}"
301
+ git push origin HEAD:gh-pages
302
+ else
303
+ echo "coverage.json unchanged, skipping push"
304
+ fi
305
+ else
306
+ # First run before docs deploy. Lay down an orphan gh-pages with
307
+ # just the badge file; the docs deploy will overwrite the rest.
308
+ git worktree add --detach gh-pages
309
+ cd gh-pages
310
+ git checkout --orphan gh-pages
311
+ git rm -rf . >/dev/null 2>&1 || true
312
+ cp "$tmp/coverage.json" coverage.json
313
+ git add coverage.json
314
+ git commit -m "coverage badge: initial publish from ${GITHUB_SHA::7}"
315
+ git push origin gh-pages
316
+ fi
317
+
318
+ secrets:
319
+ name: secret scan
320
+ runs-on: ubuntu-latest
321
+ steps:
322
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
323
+ with:
324
+ fetch-depth: 0
325
+ - name: Install gitleaks
326
+ run: |
327
+ curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz | tar -xz gitleaks
328
+ sudo mv gitleaks /usr/local/bin/
329
+ - name: Scan repo + history
330
+ run: gitleaks git --redact --no-banner .
331
+
332
+ tests-passed:
333
+ name: tests
334
+ if: always()
335
+ needs: [lint, docs, floor, test]
336
+ runs-on: ubuntu-latest
337
+ steps:
338
+ - name: Verify required jobs succeeded
339
+ run: |
340
+ if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
341
+ echo "a required job failed"; exit 1
342
+ 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
+ }