due-work-harness 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 (153) hide show
  1. due_work_harness-0.1.0/.gitattributes +1 -0
  2. due_work_harness-0.1.0/.github/workflows/ci.yml +205 -0
  3. due_work_harness-0.1.0/.github/workflows/pr-title.yml +34 -0
  4. due_work_harness-0.1.0/.github/workflows/release.yml +193 -0
  5. due_work_harness-0.1.0/.gitignore +9 -0
  6. due_work_harness-0.1.0/.python-version +1 -0
  7. due_work_harness-0.1.0/.release-please-manifest.json +1 -0
  8. due_work_harness-0.1.0/ADOPTING.md +163 -0
  9. due_work_harness-0.1.0/ARCHITECTURE.md +144 -0
  10. due_work_harness-0.1.0/CHANGELOG.md +25 -0
  11. due_work_harness-0.1.0/CONTRIBUTING.md +59 -0
  12. due_work_harness-0.1.0/LICENSE +202 -0
  13. due_work_harness-0.1.0/PKG-INFO +209 -0
  14. due_work_harness-0.1.0/README.md +175 -0
  15. due_work_harness-0.1.0/RELEASING.md +70 -0
  16. due_work_harness-0.1.0/check/action.yml +88 -0
  17. due_work_harness-0.1.0/demos/README.md +127 -0
  18. due_work_harness-0.1.0/demos/__init__.py +0 -0
  19. due_work_harness-0.1.0/demos/dbos_transactional_outbox/__init__.py +0 -0
  20. due_work_harness-0.1.0/demos/dbos_transactional_outbox/conftest.py +4 -0
  21. due_work_harness-0.1.0/demos/dbos_transactional_outbox/pyproject.toml +9 -0
  22. due_work_harness-0.1.0/demos/dbos_transactional_outbox/run_demo_process.py +58 -0
  23. due_work_harness-0.1.0/demos/dbos_transactional_outbox/test_handoff_inventory.py +24 -0
  24. due_work_harness-0.1.0/demos/dbos_transactional_outbox/test_transactional_outbox.py +301 -0
  25. due_work_harness-0.1.0/demos/fetch_upstream.py +44 -0
  26. due_work_harness-0.1.0/demos/procrastinate_demo_django/__init__.py +0 -0
  27. due_work_harness-0.1.0/demos/procrastinate_demo_django/conftest.py +6 -0
  28. due_work_harness-0.1.0/demos/procrastinate_demo_django/pyproject.toml +9 -0
  29. due_work_harness-0.1.0/demos/procrastinate_demo_django/settings.py +16 -0
  30. due_work_harness-0.1.0/demos/procrastinate_demo_django/test_demo_django.py +368 -0
  31. due_work_harness-0.1.0/demos/procrastinate_demo_django/test_handoff_inventory.py +20 -0
  32. due_work_harness-0.1.0/demos/saleor_checkout/conftest.py +26 -0
  33. due_work_harness-0.1.0/demos/saleor_checkout/pyproject.toml +153 -0
  34. due_work_harness-0.1.0/demos/saleor_checkout/run.sh +30 -0
  35. due_work_harness-0.1.0/demos/saleor_checkout/test_handoff_inventory.py +23 -0
  36. due_work_harness-0.1.0/demos/saleor_checkout/test_order_confirmation.py +730 -0
  37. due_work_harness-0.1.0/docs/false-greens.md +65 -0
  38. due_work_harness-0.1.0/docs/what-a-green-result-means.md +202 -0
  39. due_work_harness-0.1.0/examples/adopter/adopter_app/__init__.py +0 -0
  40. due_work_harness-0.1.0/examples/adopter/adopter_app/summaries.py +26 -0
  41. due_work_harness-0.1.0/examples/adopter/pyproject.toml +22 -0
  42. due_work_harness-0.1.0/examples/adopter/tests/__init__.py +0 -0
  43. due_work_harness-0.1.0/examples/adopter/tests/conftest.py +4 -0
  44. due_work_harness-0.1.0/examples/adopter/tests/test_summaries_exemption.py +22 -0
  45. due_work_harness-0.1.0/examples/adopter/uv.lock +239 -0
  46. due_work_harness-0.1.0/pyproject.toml +89 -0
  47. due_work_harness-0.1.0/release-please-config.json +26 -0
  48. due_work_harness-0.1.0/scripts/check_core_is_framework_free.py +28 -0
  49. due_work_harness-0.1.0/src/due_work_harness/__init__.py +223 -0
  50. due_work_harness-0.1.0/src/due_work_harness/binding.py +531 -0
  51. due_work_harness-0.1.0/src/due_work_harness/coherence.py +118 -0
  52. due_work_harness-0.1.0/src/due_work_harness/contract.py +1673 -0
  53. due_work_harness-0.1.0/src/due_work_harness/coverage/__init__.py +53 -0
  54. due_work_harness-0.1.0/src/due_work_harness/coverage/cli.py +117 -0
  55. due_work_harness-0.1.0/src/due_work_harness/coverage/config.py +103 -0
  56. due_work_harness-0.1.0/src/due_work_harness/coverage/declarations.py +260 -0
  57. due_work_harness-0.1.0/src/due_work_harness/coverage/handoffs.py +374 -0
  58. due_work_harness-0.1.0/src/due_work_harness/coverage/project.py +261 -0
  59. due_work_harness-0.1.0/src/due_work_harness/coverage/report.py +43 -0
  60. due_work_harness-0.1.0/src/due_work_harness/coverage/scan.py +135 -0
  61. due_work_harness-0.1.0/src/due_work_harness/coverage/sites.py +147 -0
  62. due_work_harness-0.1.0/src/due_work_harness/crash_histories.py +477 -0
  63. due_work_harness-0.1.0/src/due_work_harness/evidence/__init__.py +13 -0
  64. due_work_harness-0.1.0/src/due_work_harness/evidence/observation.py +127 -0
  65. due_work_harness-0.1.0/src/due_work_harness/evidence/observation_report.py +76 -0
  66. due_work_harness-0.1.0/src/due_work_harness/exemptions.py +87 -0
  67. due_work_harness-0.1.0/src/due_work_harness/gap_probes.py +314 -0
  68. due_work_harness-0.1.0/src/due_work_harness/helpers.py +166 -0
  69. due_work_harness-0.1.0/src/due_work_harness/host.py +231 -0
  70. due_work_harness-0.1.0/src/due_work_harness/integrations/__init__.py +0 -0
  71. due_work_harness-0.1.0/src/due_work_harness/integrations/celery.py +193 -0
  72. due_work_harness-0.1.0/src/due_work_harness/integrations/dbos.py +100 -0
  73. due_work_harness-0.1.0/src/due_work_harness/integrations/django/__init__.py +103 -0
  74. due_work_harness-0.1.0/src/due_work_harness/integrations/django/callbacks.py +56 -0
  75. due_work_harness-0.1.0/src/due_work_harness/integrations/django/commits.py +90 -0
  76. due_work_harness-0.1.0/src/due_work_harness/integrations/django/lifecycle_references.py +514 -0
  77. due_work_harness-0.1.0/src/due_work_harness/integrations/django/lifecycle_states.py +630 -0
  78. due_work_harness-0.1.0/src/due_work_harness/integrations/django/references.py +35 -0
  79. due_work_harness-0.1.0/src/due_work_harness/integrations/django/selection.py +160 -0
  80. due_work_harness-0.1.0/src/due_work_harness/integrations/django/writes.py +107 -0
  81. due_work_harness-0.1.0/src/due_work_harness/integrations/postgres_plans.py +177 -0
  82. due_work_harness-0.1.0/src/due_work_harness/integrations/procrastinate.py +303 -0
  83. due_work_harness-0.1.0/src/due_work_harness/models.py +78 -0
  84. due_work_harness-0.1.0/src/due_work_harness/process_histories.py +101 -0
  85. due_work_harness-0.1.0/src/due_work_harness/profiles/__init__.py +27 -0
  86. due_work_harness-0.1.0/src/due_work_harness/profiles/automatic_recovery.py +1346 -0
  87. due_work_harness-0.1.0/src/due_work_harness/profiles/bounded_ownership.py +639 -0
  88. due_work_harness-0.1.0/src/due_work_harness/profiles/crash_ambiguity.py +291 -0
  89. due_work_harness-0.1.0/src/due_work_harness/profiles/durable_retention.py +119 -0
  90. due_work_harness-0.1.0/src/due_work_harness/profiles/eventual_convergence.py +308 -0
  91. due_work_harness-0.1.0/src/due_work_harness/profiles/fact_derived_obligations.py +389 -0
  92. due_work_harness-0.1.0/src/due_work_harness/py.typed +0 -0
  93. due_work_harness-0.1.0/src/due_work_harness/pytest_plugin.py +95 -0
  94. due_work_harness-0.1.0/src/due_work_harness/references/__init__.py +16 -0
  95. due_work_harness-0.1.0/src/due_work_harness/references/in_memory.py +632 -0
  96. due_work_harness-0.1.0/src/due_work_harness/references/in_memory_handoffs.py +290 -0
  97. due_work_harness-0.1.0/src/due_work_harness/safety/__init__.py +14 -0
  98. due_work_harness-0.1.0/src/due_work_harness/safety/bounded_retry.py +219 -0
  99. due_work_harness-0.1.0/src/due_work_harness/safety/replay_safe_execution.py +154 -0
  100. due_work_harness-0.1.0/src/due_work_harness/worker_death.py +13 -0
  101. due_work_harness-0.1.0/test/action.yml +63 -0
  102. due_work_harness-0.1.0/tests/__init__.py +0 -0
  103. due_work_harness-0.1.0/tests/core/__init__.py +0 -0
  104. due_work_harness-0.1.0/tests/core/automatic_recovery/__init__.py +0 -0
  105. due_work_harness-0.1.0/tests/core/automatic_recovery/test_plan_verdict.py +221 -0
  106. due_work_harness-0.1.0/tests/core/automatic_recovery/test_sweep.py +1132 -0
  107. due_work_harness-0.1.0/tests/core/conftest.py +15 -0
  108. due_work_harness-0.1.0/tests/core/contract/__init__.py +0 -0
  109. due_work_harness-0.1.0/tests/core/contract/conftest.py +33 -0
  110. due_work_harness-0.1.0/tests/core/contract/declarations.py +120 -0
  111. due_work_harness-0.1.0/tests/core/contract/reference_contract_cases.py +57 -0
  112. due_work_harness-0.1.0/tests/core/contract/test_binding.py +53 -0
  113. due_work_harness-0.1.0/tests/core/contract/test_contract.py +1092 -0
  114. due_work_harness-0.1.0/tests/core/contract/test_gap_probes.py +95 -0
  115. due_work_harness-0.1.0/tests/core/contract/test_generated_cases_are_marked.py +24 -0
  116. due_work_harness-0.1.0/tests/core/contract/test_safety_contract.py +176 -0
  117. due_work_harness-0.1.0/tests/core/coverage/__init__.py +0 -0
  118. due_work_harness-0.1.0/tests/core/coverage/builders.py +32 -0
  119. due_work_harness-0.1.0/tests/core/coverage/conftest.py +5 -0
  120. due_work_harness-0.1.0/tests/core/coverage/test_cli.py +105 -0
  121. due_work_harness-0.1.0/tests/core/coverage/test_due_work_verify.py +105 -0
  122. due_work_harness-0.1.0/tests/core/coverage/test_exempt_due_work_suite.py +86 -0
  123. due_work_harness-0.1.0/tests/core/coverage/test_scan_finds_sites.py +471 -0
  124. due_work_harness-0.1.0/tests/core/coverage/test_scan_requires_one_disposition.py +345 -0
  125. due_work_harness-0.1.0/tests/core/evidence/__init__.py +0 -0
  126. due_work_harness-0.1.0/tests/core/evidence/test_observation.py +62 -0
  127. due_work_harness-0.1.0/tests/core/profiles/__init__.py +0 -0
  128. due_work_harness-0.1.0/tests/core/profiles/test_bounded_ownership.py +451 -0
  129. due_work_harness-0.1.0/tests/core/profiles/test_crash_ambiguity.py +268 -0
  130. due_work_harness-0.1.0/tests/core/profiles/test_durable_retention.py +61 -0
  131. due_work_harness-0.1.0/tests/core/profiles/test_eventual_convergence.py +164 -0
  132. due_work_harness-0.1.0/tests/core/profiles/test_fact_derived_obligations.py +390 -0
  133. due_work_harness-0.1.0/tests/core/safety/__init__.py +0 -0
  134. due_work_harness-0.1.0/tests/core/safety/test_bounded_retry.py +170 -0
  135. due_work_harness-0.1.0/tests/core/safety/test_replay_safe_execution.py +109 -0
  136. due_work_harness-0.1.0/tests/core/test_crash_histories.py +121 -0
  137. due_work_harness-0.1.0/tests/core/test_models.py +28 -0
  138. due_work_harness-0.1.0/tests/core/test_process_histories.py +47 -0
  139. due_work_harness-0.1.0/tests/django/__init__.py +0 -0
  140. due_work_harness-0.1.0/tests/django/automatic_recovery/__init__.py +0 -0
  141. due_work_harness-0.1.0/tests/django/automatic_recovery/test_selection_inspector.py +261 -0
  142. due_work_harness-0.1.0/tests/django/automatic_recovery/test_terminal_states.py +536 -0
  143. due_work_harness-0.1.0/tests/django/conftest.py +26 -0
  144. due_work_harness-0.1.0/tests/django/contract/__init__.py +0 -0
  145. due_work_harness-0.1.0/tests/django/contract/test_contract_suites.py +123 -0
  146. due_work_harness-0.1.0/tests/django/settings.py +18 -0
  147. due_work_harness-0.1.0/tests/django/test_crash_histories.py +218 -0
  148. due_work_harness-0.1.0/tests/django/test_observed_selection.py +71 -0
  149. due_work_harness-0.1.0/tests_support/sample_production/__init__.py +11 -0
  150. due_work_harness-0.1.0/tests_support/sample_production/cache.py +11 -0
  151. due_work_harness-0.1.0/tests_support/sample_production/feed.py +25 -0
  152. due_work_harness-0.1.0/tests_support/sample_production/tasks.py +9 -0
  153. due_work_harness-0.1.0/uv.lock +1867 -0
@@ -0,0 +1 @@
1
+ * text=auto eol=lf
@@ -0,0 +1,205 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ concurrency:
12
+ group: ${{ github.workflow }}-${{ github.ref }}
13
+ cancel-in-progress: true
14
+
15
+ # Every job installs from uv.lock with --locked: a lock that no longer matches
16
+ # pyproject.toml fails CI instead of silently resolving something new.
17
+
18
+ jobs:
19
+ lint:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v7
23
+ - uses: astral-sh/setup-uv@v7
24
+ with:
25
+ enable-cache: true
26
+ - run: uv sync --locked --only-group lint
27
+ - run: uv run --no-sync ruff check
28
+ - run: uv run --no-sync ruff format --check
29
+ - name: actionlint
30
+ run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.7 -color
31
+
32
+ typecheck:
33
+ runs-on: ubuntu-latest
34
+ steps:
35
+ - uses: actions/checkout@v7
36
+ - uses: astral-sh/setup-uv@v7
37
+ with:
38
+ enable-cache: true
39
+ # The demos type-check against the upstream code they run (Saleor's in its own job).
40
+ - run: uv run --no-project python demos/fetch_upstream.py procrastinate dbos-demo-apps
41
+ - run: uv sync --locked --all-extras --group demos
42
+ - run: uv pip install -e demos/.upstream/procrastinate
43
+ - run: uv run --no-sync pyrefly check
44
+
45
+ core:
46
+ # The core must work with no framework installed: the package and pytest only.
47
+ runs-on: ubuntu-latest
48
+ strategy:
49
+ fail-fast: false
50
+ matrix:
51
+ python: ["3.12", "3.13", "3.14"]
52
+ steps:
53
+ - uses: actions/checkout@v7
54
+ - uses: astral-sh/setup-uv@v7
55
+ with:
56
+ enable-cache: true
57
+ python-version: ${{ matrix.python }}
58
+ - run: uv sync --locked --no-default-groups
59
+ - run: uv run --no-sync python scripts/check_core_is_framework_free.py
60
+ - run: uv run --no-sync pytest tests/core
61
+
62
+ django:
63
+ runs-on: ubuntu-latest
64
+ services:
65
+ postgres:
66
+ image: postgres:16
67
+ env:
68
+ POSTGRES_PASSWORD: postgres
69
+ ports: ["5432:5432"]
70
+ options: >-
71
+ --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
72
+ env:
73
+ PGHOST: localhost
74
+ PGUSER: postgres
75
+ PGPASSWORD: postgres
76
+ PGPORT: "5432"
77
+ PGDATABASE: due_work_harness
78
+ steps:
79
+ - uses: actions/checkout@v7
80
+ - uses: astral-sh/setup-uv@v7
81
+ with:
82
+ enable-cache: true
83
+ - run: uv sync --locked --no-default-groups --extra django --extra celery
84
+ - run: uv run --no-sync pytest tests/django --ds=tests.django.settings
85
+
86
+ demos:
87
+ runs-on: ubuntu-latest
88
+ services:
89
+ postgres:
90
+ image: postgres:16
91
+ env:
92
+ POSTGRES_PASSWORD: postgres
93
+ ports: ["5432:5432"]
94
+ options: >-
95
+ --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
96
+ env:
97
+ PGHOST: localhost
98
+ PGUSER: postgres
99
+ PGPASSWORD: postgres
100
+ PGPORT: "5432"
101
+ steps:
102
+ - uses: actions/checkout@v7
103
+ with:
104
+ # The check action builds the harness from this checkout; its version comes from git tags.
105
+ fetch-depth: 0
106
+ - uses: astral-sh/setup-uv@v7
107
+ with:
108
+ enable-cache: true
109
+ - run: uv run --no-project python demos/fetch_upstream.py procrastinate dbos-demo-apps
110
+ - run: uv sync --locked --no-default-groups --extra django --group demos
111
+ # procrastinate's demos ship only in its source tree, pinned by fetch_upstream.py.
112
+ - run: uv pip install -e demos/.upstream/procrastinate
113
+ - name: procrastinate demo_django
114
+ env:
115
+ PGDATABASE: procrastinate_demo
116
+ run: uv run --no-sync pytest demos/procrastinate_demo_django --ds=demos.procrastinate_demo_django.settings -rxX
117
+ - name: DBOS transactional-outbox
118
+ run: uv run --no-sync pytest demos/dbos_transactional_outbox -p no:django -rxX
119
+ # Each demo is an adopter: its handoffs are checked the way an adopter's CI checks them.
120
+ - name: procrastinate demo_django, every handoff accounted for
121
+ uses: ./check
122
+ with:
123
+ working-directory: demos/procrastinate_demo_django
124
+ package: ${{ github.workspace }}
125
+ - name: DBOS transactional-outbox, every handoff accounted for
126
+ uses: ./check
127
+ with:
128
+ working-directory: demos/dbos_transactional_outbox
129
+ package: ${{ github.workspace }}
130
+
131
+ saleor:
132
+ # Saleor's checkout, unmodified, in Saleor's own environment built from its uv.lock.
133
+ runs-on: ubuntu-latest
134
+ services:
135
+ postgres:
136
+ image: postgres:16
137
+ env:
138
+ POSTGRES_PASSWORD: postgres
139
+ ports: ["5432:5432"]
140
+ options: >-
141
+ --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
142
+ env:
143
+ DATABASE_URL: postgres://postgres:postgres@localhost:5432/saleor
144
+ steps:
145
+ - uses: actions/checkout@v7
146
+ with:
147
+ # The check action builds the harness from this checkout; its version comes from git tags.
148
+ fetch-depth: 0
149
+ - uses: astral-sh/setup-uv@v7
150
+ with:
151
+ enable-cache: true
152
+ # Saleor's pin lives in fetch_upstream.py; its lock is fetched after this step.
153
+ cache-dependency-glob: |
154
+ uv.lock
155
+ demos/fetch_upstream.py
156
+ - run: uv run --no-project python demos/fetch_upstream.py saleor
157
+ - name: Type-check the demo against Saleor
158
+ run: demos/saleor_checkout/run.sh --typecheck
159
+ - name: Saleor, every handoff accounted for
160
+ uses: ./check
161
+ with:
162
+ working-directory: demos/saleor_checkout
163
+ package: ${{ github.workspace }}
164
+ - name: Saleor checkout
165
+ run: demos/saleor_checkout/run.sh -rxX --due-work-verify
166
+
167
+ build:
168
+ # The published wheel alone: it installs without any framework, imports, and ships py.typed.
169
+ runs-on: ubuntu-latest
170
+ steps:
171
+ - uses: actions/checkout@v7
172
+ with:
173
+ # The version is derived from tags, so build with the history that has them.
174
+ fetch-depth: 0
175
+ - uses: astral-sh/setup-uv@v7
176
+ with:
177
+ enable-cache: true
178
+ - run: uv build
179
+ - run: unzip -l dist/*.whl | grep -q "due_work_harness/py.typed"
180
+ - run: uv venv /tmp/wheel && uv pip install --python /tmp/wheel dist/*.whl
181
+ - run: /tmp/wheel/bin/python scripts/check_core_is_framework_free.py
182
+
183
+ actions:
184
+ # The published GitHub Actions, run against the example adopter the way a project would use them.
185
+ runs-on: ubuntu-latest
186
+ steps:
187
+ - uses: actions/checkout@v7
188
+ with:
189
+ fetch-depth: 0
190
+ - name: check passes on a project whose every handoff is accounted for
191
+ uses: ./check
192
+ with:
193
+ working-directory: examples/adopter
194
+ package: ${{ github.workspace }}
195
+ - name: test runs the project's generated suites
196
+ uses: ./test
197
+ with:
198
+ working-directory: examples/adopter
199
+ - name: check fails once a handoff loses its disposition
200
+ run: |
201
+ rm examples/adopter/tests/test_summaries_exemption.py
202
+ if uvx --from "$GITHUB_WORKSPACE" due-work-harness check --root examples/adopter; then
203
+ echo "::error::the check passed a project with an unaccounted handoff" >&2
204
+ exit 1
205
+ fi
@@ -0,0 +1,34 @@
1
+ name: PR title
2
+
3
+ # Pull requests are squash-merged with their title as the commit message, and
4
+ # release-please reads those commits to choose the next version. The title must
5
+ # therefore be a Conventional Commit: see RELEASING.md.
6
+
7
+ on:
8
+ pull_request_target:
9
+ types: [opened, edited, synchronize, reopened]
10
+
11
+ permissions:
12
+ pull-requests: read
13
+
14
+ jobs:
15
+ conventional-commit:
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ # Reads only the title through the API; it never checks out the pull request's code.
19
+ - uses: amannn/action-semantic-pull-request@v5
20
+ env:
21
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
22
+ with:
23
+ types: |
24
+ feat
25
+ fix
26
+ perf
27
+ revert
28
+ docs
29
+ refactor
30
+ test
31
+ build
32
+ ci
33
+ chore
34
+ requireScope: false
@@ -0,0 +1,193 @@
1
+ name: Release
2
+
3
+ # On every push to main, release-please updates a release PR holding the next
4
+ # SemVer version and changelog, derived from Conventional Commit messages.
5
+ # Merging that PR tags vX.Y.Z and creates the GitHub release; this workflow
6
+ # then builds that exact tag and publishes it to PyPI with Trusted Publishing
7
+ # (OIDC, no stored token). See RELEASING.md.
8
+ #
9
+ # workflow_dispatch publishes an existing tag to TestPyPI or PyPI (a rehearsal,
10
+ # or a publish that failed after tagging), or, with no tag and TestPyPI, the
11
+ # dispatched commit's .devN version, which PyPI never receives.
12
+
13
+ on:
14
+ push:
15
+ branches: [main]
16
+ workflow_dispatch:
17
+ inputs:
18
+ tag:
19
+ description: "Release tag to publish, for example v0.1.0. Empty with testpypi: publish this commit's dev version"
20
+ required: false
21
+ default: ""
22
+ index:
23
+ description: "Where to publish"
24
+ type: choice
25
+ options: [testpypi, pypi]
26
+ default: testpypi
27
+
28
+ permissions: {}
29
+
30
+ concurrency:
31
+ group: release
32
+ cancel-in-progress: false
33
+
34
+ jobs:
35
+ release-please:
36
+ if: github.event_name == 'push'
37
+ runs-on: ubuntu-latest
38
+ permissions:
39
+ contents: write
40
+ pull-requests: write
41
+ outputs:
42
+ release_created: ${{ steps.release.outputs.release_created }}
43
+ tag_name: ${{ steps.release.outputs.tag_name }}
44
+ steps:
45
+ - id: release
46
+ uses: googleapis/release-please-action@v4
47
+ with:
48
+ config-file: release-please-config.json
49
+ manifest-file: .release-please-manifest.json
50
+
51
+ build:
52
+ needs: release-please
53
+ if: >-
54
+ always() && (
55
+ (github.event_name == 'push' && needs.release-please.result == 'success' && needs.release-please.outputs.release_created == 'true')
56
+ || github.event_name == 'workflow_dispatch'
57
+ )
58
+ runs-on: ubuntu-latest
59
+ permissions:
60
+ contents: read
61
+ outputs:
62
+ tag: ${{ steps.tag.outputs.tag }}
63
+ version: ${{ steps.built.outputs.version }}
64
+ steps:
65
+ - id: tag
66
+ env:
67
+ TAG: ${{ needs.release-please.outputs.tag_name || inputs.tag }}
68
+ INDEX: ${{ inputs.index || 'pypi' }}
69
+ run: |
70
+ if [[ -z "$TAG" ]]; then
71
+ # Only a TestPyPI rehearsal may publish an untagged commit, as its .devN version.
72
+ if [[ "$INDEX" != "testpypi" ]]; then
73
+ echo "::error::publishing to PyPI needs a release tag" >&2
74
+ exit 1
75
+ fi
76
+ echo "tag=" >> "$GITHUB_OUTPUT"
77
+ exit 0
78
+ fi
79
+ # A release is a plain SemVer core version: vMAJOR.MINOR.PATCH.
80
+ if [[ ! "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
81
+ echo "::error::'$TAG' is not a release tag of the form vMAJOR.MINOR.PATCH" >&2
82
+ exit 1
83
+ fi
84
+ echo "tag=$TAG" >> "$GITHUB_OUTPUT"
85
+ echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
86
+ - uses: actions/checkout@v7
87
+ with:
88
+ ref: ${{ steps.tag.outputs.tag || github.sha }}
89
+ # The version is read from the tag, so the tag must be present.
90
+ fetch-depth: 0
91
+ - uses: astral-sh/setup-uv@v7
92
+ - run: uv build
93
+ - name: The built version is the tag
94
+ if: steps.tag.outputs.tag != ''
95
+ env:
96
+ VERSION: ${{ steps.tag.outputs.version }}
97
+ run: |
98
+ ls dist
99
+ test -f "dist/due_work_harness-${VERSION}-py3-none-any.whl"
100
+ test -f "dist/due_work_harness-${VERSION}.tar.gz"
101
+ - name: An untagged build is a dev release
102
+ if: steps.tag.outputs.tag == ''
103
+ run: |
104
+ ls dist
105
+ wheel=$(basename dist/*.whl)
106
+ [[ "$wheel" =~ ^due_work_harness-[0-9]+\.[0-9]+(\.[0-9]+)?\.dev[0-9]+-py3-none-any\.whl$ ]]
107
+ - id: built
108
+ run: |
109
+ wheel=$(basename dist/*.whl)
110
+ version=${wheel#due_work_harness-}
111
+ echo "version=${version%-py3-none-any.whl}" >> "$GITHUB_OUTPUT"
112
+ - name: The wheel alone installs, imports without any framework, and is typed
113
+ run: |
114
+ unzip -l dist/*.whl | grep -q "due_work_harness/py.typed"
115
+ uv venv /tmp/wheel && uv pip install --python /tmp/wheel dist/*.whl
116
+ /tmp/wheel/bin/python scripts/check_core_is_framework_free.py
117
+ - uses: actions/upload-artifact@v7
118
+ with:
119
+ name: dist
120
+ path: dist/
121
+ if-no-files-found: error
122
+
123
+ publish-pypi:
124
+ needs: build
125
+ if: >-
126
+ always() && needs.build.result == 'success'
127
+ && (github.event_name == 'push' || inputs.index == 'pypi')
128
+ runs-on: ubuntu-latest
129
+ environment:
130
+ name: pypi
131
+ url: https://pypi.org/project/due-work-harness/${{ needs.build.outputs.version }}/
132
+ permissions:
133
+ id-token: write
134
+ steps:
135
+ - uses: actions/download-artifact@v8
136
+ with:
137
+ name: dist
138
+ path: dist/
139
+ - uses: pypa/gh-action-pypi-publish@release/v1
140
+
141
+ publish-testpypi:
142
+ needs: build
143
+ if: >-
144
+ always() && needs.build.result == 'success'
145
+ && github.event_name == 'workflow_dispatch' && inputs.index == 'testpypi'
146
+ runs-on: ubuntu-latest
147
+ environment:
148
+ name: testpypi
149
+ url: https://test.pypi.org/project/due-work-harness/${{ needs.build.outputs.version }}/
150
+ permissions:
151
+ id-token: write
152
+ steps:
153
+ - uses: actions/download-artifact@v8
154
+ with:
155
+ name: dist
156
+ path: dist/
157
+ - uses: pypa/gh-action-pypi-publish@release/v1
158
+ with:
159
+ repository-url: https://test.pypi.org/legacy/
160
+
161
+ attach-to-release:
162
+ needs: [build, publish-pypi]
163
+ if: always() && needs.publish-pypi.result == 'success' && needs.build.outputs.tag != ''
164
+ runs-on: ubuntu-latest
165
+ permissions:
166
+ contents: write
167
+ steps:
168
+ - uses: actions/download-artifact@v8
169
+ with:
170
+ name: dist
171
+ path: dist/
172
+ - env:
173
+ GH_TOKEN: ${{ github.token }}
174
+ TAG: ${{ needs.build.outputs.tag }}
175
+ run: gh release upload "$TAG" dist/* --clobber --repo "$GITHUB_REPOSITORY"
176
+
177
+ move-major-tag:
178
+ # `uses: gigaverse-app/due-work-harness/check@v0` follows the latest v0.x.y release.
179
+ needs: [build, publish-pypi]
180
+ if: always() && needs.publish-pypi.result == 'success' && needs.build.outputs.tag != ''
181
+ runs-on: ubuntu-latest
182
+ permissions:
183
+ contents: write
184
+ steps:
185
+ - uses: actions/checkout@v7
186
+ with:
187
+ ref: ${{ needs.build.outputs.tag }}
188
+ - env:
189
+ TAG: ${{ needs.build.outputs.tag }}
190
+ run: |
191
+ MAJOR="${TAG%%.*}"
192
+ git tag --force "$MAJOR" "$TAG"
193
+ git push --force origin "refs/tags/$MAJOR"
@@ -0,0 +1,9 @@
1
+ .venv*/
2
+ __pycache__/
3
+ *.pyc
4
+ .upstream_tests/
5
+ /dist/
6
+ /build/
7
+ *.egg-info/
8
+ demos/.upstream/
9
+ demos/dbos_transactional_outbox/.inbox.txt
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1 @@
1
+ {".":"0.1.0"}
@@ -0,0 +1,163 @@
1
+ # Adopting due-work-harness
2
+
3
+ This is the path from "we use Django/Celery, Procrastinate or DBOS" to "CI fails
4
+ when background work can be lost or repeated". A complete, minimal adopter lives
5
+ in [`examples/adopter/`](examples/adopter/); the harness's own CI runs it through
6
+ the GitHub Actions below.
7
+
8
+ ## 1. Install
9
+
10
+ ```bash
11
+ uv add --dev "due-work-harness[django]" # or [celery], [procrastinate], [dbos]; combine as needed
12
+ ```
13
+
14
+ The core needs only pytest and pydantic. An extra adds that framework's integration.
15
+
16
+ ## 2. Configure the scan
17
+
18
+ ```toml
19
+ # pyproject.toml
20
+ [tool.due-work-harness]
21
+ production-packages = ["myapp"] # the code that must be accounted for
22
+ ```
23
+
24
+ A kind is scanned whenever production code imports its framework, directly or
25
+ through another module of the project, so the result is the same wherever the
26
+ check runs, framework installed or not:
27
+
28
+ | Kind | A site is |
29
+ | --- | --- |
30
+ | `django` | `transaction.on_commit(...)`, including through `sync_to_async` |
31
+ | `celery` | `.delay(...)` / `.apply_async(...)` and their `_on_commit` variants on a `@shared_task` / `@app.task`, and `send_task(...)` |
32
+ | `procrastinate` | `.defer(...)` / `.defer_async(...)` on an `@app.task`, including after `.configure(...)` |
33
+ | `dbos` | `DBOS.start_workflow(...)`, and `queue.enqueue(workflow, ...)` for a `@DBOS.workflow` |
34
+ | `dramatiq` | `.send(...)` / `.send_with_options(...)` on an `@actor` |
35
+ | `rq` | `enqueue`, `enqueue_call`, `enqueue_at`, `enqueue_in` on `rq`/`django_rq` or a queue they returned; `.delay(...)` on an `@job` |
36
+ | `django-tasks` | `.enqueue(...)` / `.aenqueue(...)` on a `django.tasks` (or `django_tasks`) `@task` |
37
+
38
+ A site is any reference to the handoff, called or not, through any alias: a
39
+ local name, a parameter default, `self.hook`, a re-export, `sync_to_async(...)`
40
+ or `functools.partial(...)` all count, in the outermost function that contains
41
+ them. `sites = ["celery"]` adds a kind the scan cannot detect (a framework
42
+ reached only through a third-party wrapper); it never removes a detected one.
43
+ A project helper that hands work off for its callers (Zulip's
44
+ `send_event_on_commit`, say) is declared in `bridges` with its own site count, so
45
+ each call to it becomes a site in its caller. `exclude` adds name patterns to
46
+ skip, and may never hide production code. See
47
+ [`coverage/config.py`](src/due_work_harness/coverage/config.py) for every key.
48
+
49
+ ## 3. See what you have, and baseline the past
50
+
51
+ ```bash
52
+ uv run due-work-harness sites # every site, and its disposition
53
+ uv run due-work-harness baseline # a [tool.due-work-harness.baseline] table for today's sites
54
+ ```
55
+
56
+ Paste the baseline into `pyproject.toml` to adopt without fixing everything at
57
+ once. The baseline records work that predates adoption and **only shrinks**: in
58
+ CI, `check --base-ref <base>` refuses any entry a change adds.
59
+
60
+ ## 4. Configure the host for your tests
61
+
62
+ ```python
63
+ # conftest.py
64
+ from due_work_harness import configure
65
+ from due_work_harness.integrations.django import django_host
66
+
67
+ configure(django_host(production_packages={"myapp"}))
68
+ ```
69
+
70
+ The host tells the framework-free proofs what only your framework knows: how
71
+ to reach the database, count commits and freeze time.
72
+
73
+ If your code publishes through Celery, let crash histories refuse each publish
74
+ as a broker that is down would:
75
+
76
+ ```python
77
+ from due_work_harness.integrations.celery import celery_publication_breaker
78
+
79
+ configure(django_host(production_packages={"myapp"}, publication_breaker=celery_publication_breaker))
80
+ ```
81
+
82
+ ## 5. Give each site a disposition
83
+
84
+ **Cover it with a contract**: the proofs that it survives lost messages and
85
+ dead workers. Name the exact function the contract insures:
86
+
87
+ ```python
88
+ @due_work_contract_suite(ORDER_NOTIFICATIONS, covers=(DueWorkSource(OrderService.place),))
89
+ class TestOrderNotificationsDueWork:
90
+ pass
91
+ ```
92
+
93
+ Start small: a `HandoffHistory` with `assert_crash_at_every_commit_converges` is
94
+ often the first proof worth having. See [the README](README.md) and
95
+ [what a green result means](docs/what-a-green-result-means.md).
96
+
97
+ **Or exempt it, with proof**, when losing the handoff genuinely costs nothing:
98
+
99
+ ```python
100
+ @exempt_due_work_suite(
101
+ DueWorkSource(summaries.rename_order),
102
+ reason="the read path rebuilds a summary that disagrees with its order",
103
+ prove=LossIsAbsorbedElsewhere(strand=..., observe=..., absorb=summaries.summary),
104
+ )
105
+ class TestSummaryEvictionExemption:
106
+ pass
107
+ ```
108
+
109
+ The proof runs as a test. An exemption without one is refused, and so is a
110
+ proof written in the test itself (a lambda, a local function): it must be a
111
+ harness probe such as `LossIsAbsorbedElsewhere`, whose `absorb` is the
112
+ production path the reason names.
113
+
114
+ Each call then removes its function from the baseline.
115
+
116
+ ## 6. Enforce it in CI
117
+
118
+ ```yaml
119
+ jobs:
120
+ due-work-check:
121
+ # Static: no database, no framework, seconds. Pins the harness version from your uv.lock.
122
+ runs-on: ubuntu-latest
123
+ steps:
124
+ - uses: actions/checkout@v7
125
+ - uses: gigaverse-app/due-work-harness/check@v0
126
+
127
+ due-work-suites:
128
+ # The generated contract, safety and exemption suites, in your own environment.
129
+ runs-on: ubuntu-latest
130
+ services:
131
+ postgres:
132
+ image: postgres:16
133
+ env: { POSTGRES_PASSWORD: postgres }
134
+ ports: ["5432:5432"]
135
+ steps:
136
+ - uses: actions/checkout@v7
137
+ - uses: gigaverse-app/due-work-harness/test@v0
138
+ with:
139
+ sync-args: --all-extras
140
+ pytest-args: --ds=myproject.settings
141
+ ```
142
+
143
+ Without GitHub Actions, the same two steps are:
144
+
145
+ ```bash
146
+ uv run due-work-harness check --base-ref origin/main
147
+ uv run pytest -m due_work
148
+ ```
149
+
150
+ Every case the harness generates carries the `due_work` mark, so `-m due_work`
151
+ selects exactly the adoption suites. The `test` action also passes
152
+ `--due-work-verify`, which fails the session unless every declaration the check
153
+ counted ran a case: a suite skipped by an `importorskip`, deselected or never
154
+ collected cannot keep its function accounted for. Add it to the plain command
155
+ too:
156
+
157
+ ```bash
158
+ uv run pytest -m due_work --due-work-verify
159
+ ```
160
+
161
+ A declaration the check counts is one pytest will run as written: a
162
+ module-level `Test*` class, not rebound later in its module, with no skip or
163
+ xfail mark, using the harness's own decorator, `DueWorkSource` and contract.