continuo-contracts 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 (44) hide show
  1. continuo_contracts-0.1.0/.claude/agents/ddd-architecture-auditor.md +282 -0
  2. continuo_contracts-0.1.0/.github/workflows/ci.yml +63 -0
  3. continuo_contracts-0.1.0/.github/workflows/dco.yml +49 -0
  4. continuo_contracts-0.1.0/.github/workflows/publish-pypi.yml +255 -0
  5. continuo_contracts-0.1.0/.github/workflows/release.yml +140 -0
  6. continuo_contracts-0.1.0/.gitignore +9 -0
  7. continuo_contracts-0.1.0/.python-version +1 -0
  8. continuo_contracts-0.1.0/CHANGELOG.md +60 -0
  9. continuo_contracts-0.1.0/CONTRIBUTING.md +124 -0
  10. continuo_contracts-0.1.0/DCO +34 -0
  11. continuo_contracts-0.1.0/LICENSE +201 -0
  12. continuo_contracts-0.1.0/NOTICE +11 -0
  13. continuo_contracts-0.1.0/PKG-INFO +450 -0
  14. continuo_contracts-0.1.0/README.md +430 -0
  15. continuo_contracts-0.1.0/SECURITY.md +45 -0
  16. continuo_contracts-0.1.0/continuo_contracts/__init__.py +10 -0
  17. continuo_contracts-0.1.0/continuo_contracts/canonical.py +183 -0
  18. continuo_contracts-0.1.0/continuo_contracts/errors.py +44 -0
  19. continuo_contracts-0.1.0/continuo_contracts/fingerprints.py +611 -0
  20. continuo_contracts-0.1.0/continuo_contracts/fingerprints.yaml +832 -0
  21. continuo_contracts-0.1.0/continuo_contracts/hashing.py +111 -0
  22. continuo_contracts-0.1.0/continuo_contracts/py.typed +0 -0
  23. continuo_contracts-0.1.0/continuo_contracts/python.py +450 -0
  24. continuo_contracts-0.1.0/continuo_contracts/vectors/canonical_json.json +55 -0
  25. continuo_contracts-0.1.0/continuo_contracts/vectors/hashing.json +32 -0
  26. continuo_contracts-0.1.0/continuo_contracts/vectors/projection.json +114 -0
  27. continuo_contracts-0.1.0/continuo_contracts/vectors/sql_types.json +20 -0
  28. continuo_contracts-0.1.0/docs/fingerprints.md +187 -0
  29. continuo_contracts-0.1.0/pyproject.toml +53 -0
  30. continuo_contracts-0.1.0/scripts/check_wheel.py +133 -0
  31. continuo_contracts-0.1.0/scripts/render_fingerprints.py +28 -0
  32. continuo_contracts-0.1.0/scripts/smoke_installed.py +198 -0
  33. continuo_contracts-0.1.0/tests/test_canonical.py +210 -0
  34. continuo_contracts-0.1.0/tests/test_canonical_vectors.py +106 -0
  35. continuo_contracts-0.1.0/tests/test_check_wheel.py +85 -0
  36. continuo_contracts-0.1.0/tests/test_fingerprints_guard.py +175 -0
  37. continuo_contracts-0.1.0/tests/test_fingerprints_reader.py +326 -0
  38. continuo_contracts-0.1.0/tests/test_hashing.py +100 -0
  39. continuo_contracts-0.1.0/tests/test_hashing_vectors.py +94 -0
  40. continuo_contracts-0.1.0/tests/test_package.py +19 -0
  41. continuo_contracts-0.1.0/tests/test_projection_vectors.py +71 -0
  42. continuo_contracts-0.1.0/tests/test_public_names.py +105 -0
  43. continuo_contracts-0.1.0/tests/test_python_projection.py +347 -0
  44. continuo_contracts-0.1.0/uv.lock +421 -0
@@ -0,0 +1,282 @@
1
+ ---
2
+ name: ddd-architecture-auditor
3
+ description: >-
4
+ Use to audit continuo-contracts for architecture and hash-stability
5
+ compliance: module dependency direction, library purity, the single runtime
6
+ dependency, pinned byte formats and vectors, error conventions, the
7
+ fingerprint declaration chain, public-text rules (no design ids), packaging
8
+ and release-workflow safety. Dispatch before merging a branch or whenever
9
+ asked to review architecture. Read-only: it writes a findings document the
10
+ main agent can act on; it never edits source.
11
+ tools: Read, Grep, Glob, Bash, Write
12
+ model: inherit
13
+ ---
14
+
15
+ # Role
16
+
17
+ You are a Domain-Driven Design and Clean Architecture auditor for the
18
+ `continuo-contracts` repository. It is a small, pure-Python library
19
+ (`continuo_contracts`) that defines bytes other systems must reproduce exactly:
20
+ canonical JSON, the text normaliser, the `content_hash` folds, the Python code
21
+ projection and the fingerprint field declaration. A defect here moves the hash
22
+ of every node in every install, so hash stability is an architecture concern,
23
+ not only a test concern. You inspect code, find violations, and write a
24
+ structured report so the main agent can fix them. You do not modify source
25
+ code. Your only output artifact is the report.
26
+
27
+ The rules below come from `README.md` (the Stability paragraph and each module
28
+ section), `CONTRIBUTING.md`, `pyproject.toml` and the workflows in
29
+ `.github/workflows/`. Read `README.md` and `CONTRIBUTING.md` first, every time:
30
+ if they and this file disagree, they win; say so in the report.
31
+
32
+ # Scope
33
+
34
+ Default to the current branch's diff against `main` (use `origin/main` when no
35
+ local `main` exists):
36
+
37
+ ```
38
+ git diff main...HEAD --name-only
39
+ git diff main...HEAD
40
+ ```
41
+
42
+ Audit the whole tree only when the caller asks for a full sweep, or when the
43
+ branch diff is the whole repository (a first release branch); say which at the
44
+ top of the report. State the scope you chose. The tree is `continuo_contracts/`
45
+ (the library and its data files), `scripts/`, `tests/`, `docs/` and
46
+ `.github/workflows/`.
47
+
48
+ You may run read-only probes through
49
+ `uv --directory <repo> run --locked python -B -c "..."` (`<repo>` is the
50
+ repository root) or through throwaway scripts kept outside the repository, such
51
+ as under `/tmp`. You must write nothing in the repository except the report; the
52
+ caches that git ignores, which `uv` and the tools of rule 11 write, do not count.
53
+
54
+ # What to check
55
+
56
+ Report a finding only when you can point at a real `file:line` you have opened
57
+ and confirmed. Do not flag hypotheticals. A violation that was already present
58
+ before the branch is still a finding; mark it **(pre-existing)** so the caller
59
+ can tell new drift from known debt.
60
+
61
+ ## Layer 1: generic DDD / Clean Architecture, applied to a library
62
+
63
+ This repository is a functional core. It has no application layer, no ports and
64
+ no adapters; do not flag their absence. Flag an abstraction layer added without
65
+ a second implementation that needs it. An abstraction layer (a base class, a
66
+ protocol, a registry, a factory or a plugin seam) needs at least two
67
+ implementations or an extension point that `README.md` documents. Data-driven
68
+ dispatch, such as a table of rule functions keyed by field name, is not a new
69
+ layer; do not flag it.
70
+
71
+ - **Dependency direction.** The arrow runs `scripts/` and `tests/` ->
72
+ `continuo_contracts`, and inside the package from the specialised modules to
73
+ the shared ones. Flag any import that points the other way, and any cycle.
74
+ - **Purity.** Library modules compute; they do no I/O, read no environment,
75
+ start no process, read no clock, and write no log lines or output.
76
+ - **Cohesion (SOLID).** One reason to change per module. Flag a module that mixes
77
+ a byte format with parsing, I/O or rendering it does not own, a second
78
+ implementation of a rule another module already owns (for example a second
79
+ text normaliser), and a public function with a parameter list that grows by
80
+ flags instead of splitting.
81
+ - **Public surface.** Public names are documented in `README.md`; helpers are
82
+ private (leading underscore). Flag a third-party type (PyYAML) in a public
83
+ signature or return value, and a private helper imported across modules.
84
+
85
+ ## Layer 2: continuo-contracts rules
86
+
87
+ Package root: `continuo_contracts/`.
88
+
89
+ 1. **Layering by import.** Verify with grep over each module, for example
90
+ `grep -nE "^\s*(from|import) " continuo_contracts/*.py`.
91
+ - `errors.py` imports nothing from the package.
92
+ - `canonical.py` and `hashing.py` import only the standard library and
93
+ `continuo_contracts.errors`.
94
+ - `fingerprints.py` imports only the standard library, `yaml` and
95
+ `continuo_contracts.errors`.
96
+ - `python.py` may import `canonical`, `hashing`, `fingerprints` and `errors`.
97
+ `fingerprints.py` must not import `python.py`, `canonical.py` or
98
+ `hashing.py`.
99
+ - `__init__.py` imports only `importlib.metadata` (the version).
100
+ - No package module imports `scripts`, `tests` or `docs`. `scripts/check_wheel.py`
101
+ is standard library only and must not import the package (it runs before
102
+ the package is installed).
103
+ 2. **Purity beyond imports.** In `continuo_contracts/`, flag `open(`,
104
+ `Path(`-based reads or writes, `os.environ`, `os.getenv`, `subprocess`,
105
+ `socket`, `datetime.now`, `time.time`, `random`, `logging` (any use),
106
+ `print(` and `sys.stdout`. The one allowed I/O is `fingerprints.load()`
107
+ reading the packaged `fingerprints.yaml` through `importlib.resources`,
108
+ cached once.
109
+ 3. **One runtime dependency.** `pyproject.toml` lists exactly `PyYAML>=6.0` as
110
+ a dependency, the wheel stays pure (`py3-none-any`), and `yaml` is imported
111
+ only in `fingerprints.py`. Flag any other third-party import in the package
112
+ and any added dependency.
113
+ 4. **Python 3.12 and later.** Every module and script must import and pass on
114
+ 3.12. Flag syntax or standard-library names added in 3.13 or 3.14 (for
115
+ example `typing.ReadOnly`, `typing.TypeIs`, `warnings.deprecated`,
116
+ `copy.replace`, `annotationlib`, template strings, an unparenthesised
117
+ multi-exception `except`). `requires-python` stays `>=3.12`, and the CI
118
+ matrix keeps 3.12, 3.13 and 3.14.
119
+ 5. **Hash stability.** The byte formats are pinned by tests and by every file
120
+ in `continuo_contracts/vectors/`: the RFC 8785 serialisation, the text
121
+ normaliser, `fold3`/`fold4`, the shared fold's line format and sort order, the projection keys and their order, the
122
+ SQL type canonicaliser, the denylist and the classification fields of
123
+ `fingerprints.yaml` (`id`, `fingerprint`, `parts`, `when`, `rule`,
124
+ `inputs`). Flag as **BLOCKER** any change in the diff to a pinned expected
125
+ value, a vector, a projection key or its order, a fold, the denylist or one of
126
+ those classification fields, unless `CHANGELOG.md` under `## [Unreleased]`
127
+ says the change ships with a re-baseline. Flag new byte-affecting behaviour
128
+ that no test or vector pins.
129
+ When there is nothing earlier to compare against (a first release branch,
130
+ where `main` holds no package), the diff has no changed value to flag, so
131
+ check instead that every pinned value is reproducible. Recompute a sample of
132
+ the cases and pinned hashes of each file in `continuo_contracts/vectors/` (at
133
+ least ten per vector file) with plain `hashlib` and `json` code that does not
134
+ import the package, compare each result with the pinned value, and report the
135
+ sample size.
136
+ 6. **Cross-language rules are written down.** Every rule a consumer in another
137
+ language must reproduce (sort orders, whitespace rules, number formats,
138
+ what is normalised and what is written as given) is stated in `README.md`
139
+ or a docstring and matches the code. Flag a behaviour change without the
140
+ matching README or docstring change, and a documented rule the code does not
141
+ follow.
142
+ To judge whether a documented rule is precise enough to reproduce, probe
143
+ every list, range or count it states against the code over its whole domain:
144
+ every code point (`range(0x110000)`) for a whitespace or character claim,
145
+ the values just below, at and just above a limit, and both ends of a
146
+ documented range. A loose phrase such as "Unicode whitespace" is a finding if
147
+ the code strips more or fewer characters than the text names: the text must
148
+ name the exact set or count. When a rule depends on interpreter tables
149
+ (`str.strip`, `isspace`, `re` classes), run the probe on the oldest and the
150
+ newest Python that the CI matrix names and report both, for example
151
+ `uv --directory <repo> run --isolated --python 3.14 --locked python -B -c "..."`
152
+ and the same with `--python 3.12`.
153
+ 7. **Error conventions.** Every error class the package defines derives from
154
+ `ContractsError`. A refusal names where it happened: the JSON path in
155
+ `canonical`, `<schema>.<table>: ` in `python`, the YAML location in
156
+ `fingerprints`. An unsupported value is refused, never turned into a string.
157
+ Flag a bare `except`, a swallowed exception, and an error message that
158
+ drops its location. Statements that every raised error is a
159
+ `ContractsError` are true only if no function raises a plain `ValueError`,
160
+ `TypeError` or similar for its own checks: check before relying on them.
161
+ These failures show only when code runs, so run these probes (the read-only
162
+ forms of Scope) and record the exception type and message of each:
163
+ - nesting just inside `MAX_DEPTH` (100), just beyond it (101) and 5000 deep,
164
+ for a list and for a dict, through `canonical_json` and through
165
+ `projection_hash` (as `config`, which may nest at most 99 levels);
166
+ - a lone surrogate (such as `"\ud800"`) in a string, in a mapping key and in a
167
+ part passed to `fold3`, `fold4` and `shared_fold`;
168
+ - an argument of the wrong type (a set to `canonical_json`, an `int` part to
169
+ `fold3`, a plain `dict` to `projection_hash`, `None` to
170
+ `canonical_sql_type`);
171
+ - an unknown family name (`classify`, `unclassified`) and an unknown section
172
+ name (`Fingerprints.entries`);
173
+ - text passed to `parse_fingerprints` that is invalid YAML, that repeats a
174
+ key, or that is not a mapping.
175
+
176
+ Some exceptions are documented as not being a `ContractsError`: the
177
+ `ContractsError` docstring in `errors.py` and the errors section of
178
+ `README.md` name them (if the two differ, say so). They are the plain
179
+ `ValueError` for a family outside `FAMILIES` and a section outside
180
+ `SECTIONS`, the `UnicodeEncodeError` of `fold3` and `fold4` for a part that
181
+ UTF-8 cannot encode, and a `TypeError` or `AttributeError` for an argument of
182
+ the wrong type. Flag only an undocumented one (a `RecursionError` on deep
183
+ nesting, a `UnicodeEncodeError` from any other function, a `KeyError`, or a
184
+ documented-as-refused input that is accepted), and any message that drops its
185
+ location.
186
+ 8. **Declaration chain.** `continuo_contracts/fingerprints.yaml`,
187
+ `continuo_contracts/fingerprints.py`, `docs/fingerprints.md`, the renderer
188
+ text and the guard tests in `tests/test_fingerprints_guard.py` stay in step:
189
+ `docs/fingerprints.md` equals `render_markdown(load())` (compute it
190
+ read-only), each `inputs` name is in the closed vocabulary, the per-section
191
+ entry counts are pinned, and a new `content_hash` input is added to the yaml.
192
+ 9. **Public text.** The repository is public and the private design is not.
193
+ Flag, in code, comments, docstrings, tests, workflows and every Markdown
194
+ file: design ids (a capital letter or two followed by digits, such as
195
+ `C14`, `N2`, `K9`, `OD2b`, `D14`), design section numbers, pull request
196
+ references, citations of files in other repositories, the words "Review
197
+ Focus", and sentences that are no longer true or that describe history
198
+ ("previously", "after the cutover") instead of the present. All tracked text
199
+ is ASCII. `print(` appears nowhere; diagnostics use `logging` to stderr,
200
+ and only in `scripts/`.
201
+ 10. **Packaging and release.**
202
+ - The wheel ships `fingerprints.yaml`, every file in
203
+ `continuo_contracts/vectors/` and `py.typed`;
204
+ `scripts/check_wheel.py` `REQUIRED_MEMBERS` matches the package files.
205
+ - `.github/workflows/ci.yml`: jobs `test` (3.12, 3.13, 3.14), `wheel` and
206
+ `ci`, with `ci` needing both and failing on a skipped or cancelled job.
207
+ - `.github/workflows/publish-pypi.yml`: `id-token: write` only on the upload
208
+ job; that job runs no repository code and uploads only the artifact the
209
+ `prepare` job built and checked; a real upload happens only for an exact
210
+ `vX.Y.Z` tag and a matching package version, a `vX.Y.Z-test<anything>` tag
211
+ rehearses on TestPyPI, and any other `v*` tag fails before any upload; no
212
+ `skip-existing` on the PyPI step.
213
+ - `.github/workflows/release.yml` creates a GitHub Release only after the
214
+ publish run succeeded, never for a `-test` tag, with `contents: write`
215
+ on its one job only.
216
+ - All four workflows (`ci.yml`, `dco.yml`, `publish-pypi.yml` and
217
+ `release.yml`): no `${{ }}` expression inside any `run:` body (values pass
218
+ through `env:`). List every expression with
219
+ `grep -nF '${{' .github/workflows/*.yml` and open each hit to see which
220
+ key holds it.
221
+ - Action pinning: every action in the job that holds `id-token: write` is
222
+ pinned by commit SHA with a version comment (`uses: owner/name@<sha> # v1`);
223
+ the other jobs may pin by version tag. Flag a tag-pinned action in the
224
+ id-token job. List the actions with
225
+ `grep -n "uses:" .github/workflows/*.yml`.
226
+ - The failure table in `CONTRIBUTING.md` matches the jobs.
227
+ - Run `actionlint .github/workflows/*.yml` and report its output.
228
+ 11. **Conventions.** Every behaviour change has a line under `## [Unreleased]` in
229
+ `CHANGELOG.md`; commits carry `Signed-off-by`; test files assert behaviour
230
+ and are not skipped; pytest runs with `filterwarnings = ["error"]`. Run
231
+ `uv run --locked ruff check .`, `uv run --locked mypy continuo_contracts scripts`
232
+ and `uv run --locked pytest -q`, and report their summary lines; a run with
233
+ skipped tests is a finding. These commands write only caches that git
234
+ ignores. `ruff check` runs with the rules that `pyproject.toml` selects, and
235
+ E501 is one of them, so flag any line over the configured `line-length`.
236
+ `ruff format --check` is not run unless CI runs it (read
237
+ `.github/workflows/ci.yml`). A commit that is already on `main` before the
238
+ branch (such as the initial commit) and lacks `Signed-off-by` is outside the
239
+ DCO range and is not a finding.
240
+
241
+ # Report
242
+
243
+ Write to `docs/ddd-violations/<YYYY-MM-DD-HHMM>-ddd-violations.md` (create the
244
+ directory; timestamp from `date +%Y-%m-%d-%H%M`). The directory is for local
245
+ reports and is not committed.
246
+
247
+ ```markdown
248
+ # DDD / Clean Architecture Audit: <YYYY-MM-DD HH:MM>
249
+
250
+ **Scope:** <branch diff vs main | full tree>
251
+ **Branch / commit:** <branch> @ <short-sha>
252
+ **Summary:** <N> blockers, <N> should-fix, <N> nits (<N> pre-existing)
253
+
254
+ ## Findings
255
+
256
+ ### [BLOCKER] <short title>
257
+ - **Where:** `path/to/file.py:123`
258
+ - **Rule:** <Layer 1 principle, or Layer 2 rule number>
259
+ - **Problem:** <what is wrong and why>
260
+ - **Suggested fix:** <concrete change>
261
+ - **Pre-existing:** <yes | no>
262
+
263
+ ### [SHOULD-FIX] ...
264
+ ### [NIT] ...
265
+
266
+ ## Clean
267
+ - <checks that passed, so their absence above is explicit>
268
+ ```
269
+
270
+ Severity: **BLOCKER** = breaks the dependency arrow, library purity, hash
271
+ stability, the one-dependency rule, release safety, or a CI-enforced check;
272
+ **SHOULD-FIX** = boundary erosion, an undocumented consumer rule, or convention
273
+ drift that will bite later; **NIT** = stylistic.
274
+
275
+ # Hard constraints
276
+
277
+ - Never edit, create, or scaffold source code. The report is your only write.
278
+ - Never claim a violation is fixed; you only diagnose.
279
+ - Every finding cites a real `file:line` you have opened and confirmed.
280
+ - If you find nothing, still write the report with an empty Findings section
281
+ and a populated Clean list.
282
+ - End your reply with the report's path and the summary counts.
@@ -0,0 +1,63 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ name: test (${{ matrix.python }})
15
+ runs-on: ubuntu-latest
16
+ strategy:
17
+ fail-fast: false
18
+ matrix:
19
+ python: ["3.12", "3.13", "3.14"]
20
+ env:
21
+ # Overrides .python-version, so each leg runs its own interpreter.
22
+ UV_PYTHON: ${{ matrix.python }}
23
+ steps:
24
+ - uses: actions/checkout@v7
25
+ - uses: astral-sh/setup-uv@v7
26
+ - run: uv sync --locked
27
+ - name: Interpreter is ${{ matrix.python }}
28
+ env:
29
+ EXPECTED_PYTHON: ${{ matrix.python }}
30
+ run: uv run --locked python -c "import sys; assert sys.version.startswith(sys.argv[1] + '.'), sys.version" "$EXPECTED_PYTHON"
31
+ - run: uv run --locked ruff check .
32
+ - run: uv run --locked mypy continuo_contracts scripts
33
+ - run: uv run --locked pytest
34
+
35
+ # Builds the distributions, refuses a wheel that lacks a module or a data file, and runs the
36
+ # installed wheel from outside the checkout on the oldest and the newest supported Python.
37
+ wheel:
38
+ runs-on: ubuntu-latest
39
+ steps:
40
+ - uses: actions/checkout@v7
41
+ - uses: astral-sh/setup-uv@v7
42
+ - run: uv build -o dist
43
+ - run: uv run --no-project --python 3.12 scripts/check_wheel.py dist
44
+ - name: Install the wheel in clean environments and run the smoke test
45
+ run: |
46
+ set -euo pipefail
47
+ for py in 3.12 3.14; do
48
+ uv venv -q --python "$py" "$RUNNER_TEMP/venv-$py"
49
+ uv pip install -q --python "$RUNNER_TEMP/venv-$py/bin/python" dist/*.whl
50
+ (cd "$RUNNER_TEMP" && "$RUNNER_TEMP/venv-$py/bin/python" -I "$GITHUB_WORKSPACE/scripts/smoke_installed.py")
51
+ done
52
+
53
+ # The one check a ruleset requires, with dco. always() makes a failed test or wheel job fail
54
+ # this job instead of skipping it, because a skipped check reads as passing.
55
+ ci:
56
+ needs: [test, wheel]
57
+ if: always()
58
+ runs-on: ubuntu-latest
59
+ steps:
60
+ - env:
61
+ TEST_RESULT: ${{ needs.test.result }}
62
+ WHEEL_RESULT: ${{ needs.wheel.result }}
63
+ run: test "$TEST_RESULT" = success && test "$WHEEL_RESULT" = success
@@ -0,0 +1,49 @@
1
+ name: dco
2
+
3
+ on:
4
+ pull_request:
5
+ branches:
6
+ - main
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ # Job name becomes the check-run name, which is what a branch ruleset requires
13
+ # by string. Keep it "dco" so the required check is unambiguous.
14
+ dco:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v7
18
+ with:
19
+ # The whole PR commit range has to be walkable, not just the merge commit.
20
+ fetch-depth: 0
21
+
22
+ - name: Verify every commit is signed off
23
+ env:
24
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
25
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
26
+ run: |
27
+ set -euo pipefail
28
+ missing=0
29
+ while read -r sha; do
30
+ [ -z "$sha" ] && continue
31
+ # Merge commits are generated by GitHub and carry no sign-off.
32
+ if [ "$(git rev-list --no-walk --count --merges "$sha")" -eq 1 ]; then
33
+ continue
34
+ fi
35
+ if ! git show -s --format='%B' "$sha" | grep -qiE '^Signed-off-by: .+ <.+@.+>$'; then
36
+ echo "::error::Commit $sha ($(git show -s --format='%an' "$sha")) has no Signed-off-by line."
37
+ missing=1
38
+ fi
39
+ done < <(git rev-list "$BASE_SHA".."$HEAD_SHA")
40
+
41
+ if [ "$missing" -ne 0 ]; then
42
+ echo ""
43
+ echo "Every commit needs a Developer Certificate of Origin sign-off (see DCO)."
44
+ echo "Fix an existing branch with:"
45
+ echo " git rebase --signoff origin/main && git push --force-with-lease"
46
+ echo "Sign off future commits automatically with 'git commit -s'."
47
+ exit 1
48
+ fi
49
+ echo "All commits signed off."
@@ -0,0 +1,255 @@
1
+ name: publish-pypi
2
+
3
+ # The release pipeline for one `v*` tag: the `continuo-contracts` distribution, an sdist and a
4
+ # wheel, on PyPI. The filename stays `publish-pypi.yml` because the PyPI and TestPyPI trusted
5
+ # publishers are registered against it, and against the `pypi` and `testpypi` environments, so
6
+ # renaming the file or either environment breaks publishing. PyPI does not honour a reusable
7
+ # workflow there, so the upload job must live in this file.
8
+ #
9
+ # PRINCIPLE: build once, test the exact artifact, publish the same bytes.
10
+ #
11
+ # prepare refuses a tag that is neither `vX.Y.Z` nor `vX.Y.Z-test<anything>`, refuses a
12
+ # tag that names another version, runs the tests, builds the sdist and the
13
+ # wheel ONCE into `dist`, refuses a wheel that lacks a module or a data file,
14
+ # and runs the installed wheel's smoke test on the oldest and the newest
15
+ # supported Python
16
+ # publish uploads THE SAME `dist` files. Irreversible. Needs `prepare` to have passed.
17
+ # verify-index downloads the published wheel from the index, checks that it is byte for
18
+ # byte the wheel `prepare` built, installs it and runs the same smoke test
19
+ #
20
+ # release.yml creates the GitHub Release only if this whole workflow succeeds.
21
+ #
22
+ # THREE MODES, decided in `prepare`:
23
+ # real tag `vX.Y.Z` -> PyPI
24
+ # test tag `vX.Y.Z-test<anything>` -> TestPyPI. A real dress rehearsal of every step. The
25
+ # version is rewritten to a unique `<version>.dev<N>`
26
+ # first: TestPyPI never replaces a file, so a second
27
+ # rehearsal at the same version would be refused.
28
+ # verify-index compares the file the index serves byte
29
+ # for byte with the one this run built.
30
+ # dry workflow_dispatch -> the same graph, but the upload is skipped and
31
+ # verify-index does not run. Start with:
32
+ # gh workflow run publish-pypi.yml \
33
+ # --ref <branch> -f dry_run=true
34
+ #
35
+ # `X`, `Y` and `Z` are digits only. `-test` is read in any case. A tag of any other shape, such as
36
+ # `v1.2.3rc1`, `v1.2.3-rc1` or `v1.2`, fails the first step of `prepare`.
37
+ #
38
+ # RECOVERY. PyPI uploads are immutable: if anything fails AFTER `publish` the files are on the
39
+ # index for good. Yank them on pypi.org (a yank keeps the file but stops resolvers choosing it)
40
+ # and FIX FORWARD with a new version. A failure BEFORE `publish` leaves nothing behind except the
41
+ # git tag: delete it, fix, re-tag. A failure IN `publish` can leave some of the files on the
42
+ # index, and a file already there fails the upload, so look at the index before re-running. No
43
+ # GitHub Release exists for a failed run. See CONTRIBUTING.md for the full failure table.
44
+ #
45
+ # Tag glob: `v*` is the only pattern this workflow listens to. A tag that matches it but has
46
+ # neither shape above fails the first step of `prepare`, before anything is built.
47
+ on:
48
+ push:
49
+ tags:
50
+ - "v*"
51
+ workflow_dispatch:
52
+ inputs:
53
+ dry_run:
54
+ description: Build and check the package; publish nothing
55
+ type: boolean
56
+ default: true
57
+
58
+ concurrency:
59
+ group: release-pipeline-${{ github.ref }}
60
+ cancel-in-progress: false
61
+
62
+ permissions: {}
63
+
64
+ jobs:
65
+ prepare:
66
+ runs-on: ubuntu-latest
67
+ permissions:
68
+ contents: read
69
+ outputs:
70
+ mode: ${{ steps.mode.outputs.mode }}
71
+ version: ${{ steps.build.outputs.version }}
72
+ steps:
73
+ - name: Decide the mode
74
+ id: mode
75
+ env:
76
+ EVENT: ${{ github.event_name }}
77
+ REF: ${{ github.ref }}
78
+ REF_NAME: ${{ github.ref_name }}
79
+ DRY_RUN: ${{ inputs.dry_run }}
80
+ run: |
81
+ set -eu
82
+ if [ "$EVENT" = "workflow_dispatch" ]; then
83
+ # A manual run can never publish: there is no tag to publish under.
84
+ [ "$DRY_RUN" = "true" ] || { echo "::error::workflow_dispatch only supports dry_run=true"; exit 1; }
85
+ mode="dry"
86
+ else
87
+ case "$REF" in refs/tags/v*) ;; *) echo "::error::not a v* tag: $REF"; exit 1;; esac
88
+ # The only two shapes that may reach an index: `vX.Y.Z` (digits only) and
89
+ # `vX.Y.Z-test` followed by anything, with `-test` in any case.
90
+ pattern='^v[0-9]+\.[0-9]+\.[0-9]+(-[Tt][Ee][Ss][Tt].*)?$'
91
+ if ! [[ "$REF_NAME" =~ $pattern ]]; then
92
+ echo "::error::tag $REF_NAME is not an allowed tag: use vX.Y.Z (digits only) to release or vX.Y.Z-test<anything> to rehearse"
93
+ exit 1
94
+ fi
95
+ # release.yml's `contains(..., '-test')` is case-insensitive, so this
96
+ # must be too: `v1.2.3-Test1` is a rehearsal, never a real release.
97
+ lower="$(printf '%s' "$REF_NAME" | tr '[:upper:]' '[:lower:]')"
98
+ case "$lower" in
99
+ *-test*) mode="test";;
100
+ *) mode="real";;
101
+ esac
102
+ fi
103
+ echo "mode=$mode" >> "$GITHUB_OUTPUT"
104
+ echo "mode: $mode" >> "$GITHUB_STEP_SUMMARY"
105
+ - uses: actions/checkout@v7
106
+ - uses: astral-sh/setup-uv@v7
107
+ - name: Refuse a tag that names another version
108
+ if: github.event_name == 'push'
109
+ env:
110
+ REF_NAME: ${{ github.ref_name }}
111
+ run: |
112
+ set -euo pipefail
113
+ # `vX.Y.Z` and `vX.Y.Z-test<anything>` (in any case) both name version X.Y.Z.
114
+ named="${REF_NAME#v}"
115
+ named="${named%%-[Tt][Ee][Ss][Tt]*}"
116
+ declared="$(uv version --short)"
117
+ if [ "$named" != "$declared" ]; then
118
+ echo "::error::tag $REF_NAME names version $named but pyproject.toml declares $declared"
119
+ exit 1
120
+ fi
121
+ - name: Test before publishing
122
+ # The gate in front of the immutable upload. `--locked` fails when uv.lock does not
123
+ # match pyproject.toml.
124
+ run: uv sync --locked && uv run --locked pytest
125
+ - name: Rewrite the version to a unique dev release (rehearsal only)
126
+ if: steps.mode.outputs.mode != 'real'
127
+ # TestPyPI never replaces a file, so each rehearsal needs a version of its own. `--frozen`
128
+ # leaves uv.lock alone: the build does not read it.
129
+ run: uv version "$(uv version --short).dev$((GITHUB_RUN_NUMBER * 100 + GITHUB_RUN_ATTEMPT))" --frozen
130
+ - name: Build once
131
+ id: build
132
+ run: |
133
+ set -euo pipefail
134
+ uv build -o dist
135
+ test "$(find dist -name '*.whl' | wc -l)" -eq 1
136
+ test "$(find dist -name '*.tar.gz' | wc -l)" -eq 1
137
+ echo "version=$(uv version --short)" >> "$GITHUB_OUTPUT"
138
+ (cd dist && sha256sum -- *) >> "$GITHUB_STEP_SUMMARY"
139
+ - name: Refuse a wheel that lacks a module or a data file
140
+ run: uv run --no-project --python 3.12 scripts/check_wheel.py dist
141
+ - name: Install the wheel in clean environments and run the smoke test
142
+ run: |
143
+ set -euo pipefail
144
+ for py in 3.12 3.14; do
145
+ uv venv -q --python "$py" "$RUNNER_TEMP/venv-$py"
146
+ uv pip install -q --python "$RUNNER_TEMP/venv-$py/bin/python" dist/*.whl
147
+ (cd "$RUNNER_TEMP" && "$RUNNER_TEMP/venv-$py/bin/python" -I "$GITHUB_WORKSPACE/scripts/smoke_installed.py")
148
+ done
149
+ # Everything below, including the upload, uses THIS artifact: never rebuild.
150
+ - uses: actions/upload-artifact@v7
151
+ with:
152
+ name: dist
153
+ path: dist/
154
+ retention-days: 7
155
+ overwrite: true
156
+
157
+ # ---- the irreversible step ------------------------------------------------
158
+
159
+ publish:
160
+ needs: prepare
161
+ runs-on: ubuntu-latest
162
+ # `dry-run` is an unprotected environment with no PyPI trust: a dry run
163
+ # still starts this job (so the graph is the same in every mode) but every
164
+ # upload step below is skipped.
165
+ environment: ${{ (needs.prepare.outputs.mode == 'real' && 'pypi') || (needs.prepare.outputs.mode == 'test' && 'testpypi') || 'dry-run' }}
166
+ permissions:
167
+ id-token: write # OIDC token for Trusted Publishing
168
+ contents: read
169
+ steps:
170
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
171
+ with:
172
+ name: dist
173
+ path: dist
174
+ - name: Dry run, nothing is uploaded
175
+ if: needs.prepare.outputs.mode == 'dry'
176
+ run: |
177
+ echo "dry run: would upload $(find dist -type f | wc -l) files" >> "$GITHUB_STEP_SUMMARY"
178
+ # Rehearsal versions are unique per run, so there is nothing to skip: an
179
+ # existing file means something is wrong, and the upload must say so.
180
+ - name: Publish to TestPyPI
181
+ if: needs.prepare.outputs.mode == 'test'
182
+ # Every action in this job is pinned to a commit SHA. This job holds
183
+ # id-token: write for PyPI Trusted Publishing, so a compromised revision
184
+ # of a tag or of a moving branch such as release/v1 could mint a token
185
+ # and publish under this project's name.
186
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
187
+ with:
188
+ repository-url: https://test.pypi.org/legacy/
189
+ packages-dir: dist
190
+ skip-existing: false
191
+ # There is one distribution, so a file already on the index means this
192
+ # version was published before: the upload must fail, not skip it.
193
+ # Skipping would report success for bytes this run never uploaded.
194
+ - name: Publish to PyPI
195
+ if: needs.prepare.outputs.mode == 'real'
196
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
197
+ with:
198
+ packages-dir: dist
199
+
200
+ # ---- the published file, read back ----------------------------------------
201
+
202
+ verify-index:
203
+ needs: [prepare, publish]
204
+ if: needs.prepare.outputs.mode != 'dry'
205
+ runs-on: ubuntu-latest
206
+ strategy:
207
+ fail-fast: false
208
+ matrix:
209
+ python: ["3.12", "3.14"]
210
+ permissions:
211
+ contents: read
212
+ env:
213
+ VERSION: ${{ needs.prepare.outputs.version }}
214
+ INDEX: ${{ needs.prepare.outputs.mode == 'test' && 'https://test.pypi.org/simple/' || 'https://pypi.org/simple/' }}
215
+ PY_VERSION: ${{ matrix.python }}
216
+ steps:
217
+ - uses: actions/checkout@v7
218
+ - uses: astral-sh/setup-uv@v7
219
+ - uses: actions/download-artifact@v8
220
+ with:
221
+ name: dist
222
+ path: dist
223
+ - name: Install the published wheel and run the smoke test
224
+ run: |
225
+ set -euo pipefail
226
+ # The index can lag the upload, so ask every 20 s, up to 30 times. pip's own
227
+ # cache is off: it would keep serving the index page it read before the upload.
228
+ downloaded=false
229
+ for attempt in $(seq 1 30); do
230
+ if python3 -m pip download --no-cache-dir --no-deps --only-binary :all: \
231
+ --index-url "$INDEX" "continuo-contracts==$VERSION" -d "$RUNNER_TEMP/dl"; then
232
+ downloaded=true
233
+ break
234
+ fi
235
+ echo "attempt $attempt of 30: continuo-contracts==$VERSION is not served by $INDEX yet"
236
+ if [ "$attempt" -lt 30 ]; then
237
+ sleep 20
238
+ fi
239
+ done
240
+ if [ "$downloaded" != true ]; then
241
+ echo "::error::continuo-contracts==$VERSION was not served by $INDEX after 30 attempts"
242
+ exit 1
243
+ fi
244
+ fetched=("$RUNNER_TEMP"/dl/*.whl)
245
+ built=(dist/*.whl)
246
+ test "${#fetched[@]}" -eq 1
247
+ test "${#built[@]}" -eq 1
248
+ # The index must serve the very file this run built.
249
+ cmp "${fetched[0]}" "${built[0]}"
250
+ uv venv -q --python "$PY_VERSION" "$RUNNER_TEMP/venv"
251
+ # PyYAML comes from PyPI first: TestPyPI holds copies of real project names,
252
+ # so it must not supply a dependency.
253
+ uv pip install -q --python "$RUNNER_TEMP/venv/bin/python" "PyYAML>=6.0"
254
+ uv pip install -q --python "$RUNNER_TEMP/venv/bin/python" --no-deps "${fetched[0]}"
255
+ (cd "$RUNNER_TEMP" && "$RUNNER_TEMP/venv/bin/python" -I "$GITHUB_WORKSPACE/scripts/smoke_installed.py")