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.
- continuo_contracts-0.1.0/.claude/agents/ddd-architecture-auditor.md +282 -0
- continuo_contracts-0.1.0/.github/workflows/ci.yml +63 -0
- continuo_contracts-0.1.0/.github/workflows/dco.yml +49 -0
- continuo_contracts-0.1.0/.github/workflows/publish-pypi.yml +255 -0
- continuo_contracts-0.1.0/.github/workflows/release.yml +140 -0
- continuo_contracts-0.1.0/.gitignore +9 -0
- continuo_contracts-0.1.0/.python-version +1 -0
- continuo_contracts-0.1.0/CHANGELOG.md +60 -0
- continuo_contracts-0.1.0/CONTRIBUTING.md +124 -0
- continuo_contracts-0.1.0/DCO +34 -0
- continuo_contracts-0.1.0/LICENSE +201 -0
- continuo_contracts-0.1.0/NOTICE +11 -0
- continuo_contracts-0.1.0/PKG-INFO +450 -0
- continuo_contracts-0.1.0/README.md +430 -0
- continuo_contracts-0.1.0/SECURITY.md +45 -0
- continuo_contracts-0.1.0/continuo_contracts/__init__.py +10 -0
- continuo_contracts-0.1.0/continuo_contracts/canonical.py +183 -0
- continuo_contracts-0.1.0/continuo_contracts/errors.py +44 -0
- continuo_contracts-0.1.0/continuo_contracts/fingerprints.py +611 -0
- continuo_contracts-0.1.0/continuo_contracts/fingerprints.yaml +832 -0
- continuo_contracts-0.1.0/continuo_contracts/hashing.py +111 -0
- continuo_contracts-0.1.0/continuo_contracts/py.typed +0 -0
- continuo_contracts-0.1.0/continuo_contracts/python.py +450 -0
- continuo_contracts-0.1.0/continuo_contracts/vectors/canonical_json.json +55 -0
- continuo_contracts-0.1.0/continuo_contracts/vectors/hashing.json +32 -0
- continuo_contracts-0.1.0/continuo_contracts/vectors/projection.json +114 -0
- continuo_contracts-0.1.0/continuo_contracts/vectors/sql_types.json +20 -0
- continuo_contracts-0.1.0/docs/fingerprints.md +187 -0
- continuo_contracts-0.1.0/pyproject.toml +53 -0
- continuo_contracts-0.1.0/scripts/check_wheel.py +133 -0
- continuo_contracts-0.1.0/scripts/render_fingerprints.py +28 -0
- continuo_contracts-0.1.0/scripts/smoke_installed.py +198 -0
- continuo_contracts-0.1.0/tests/test_canonical.py +210 -0
- continuo_contracts-0.1.0/tests/test_canonical_vectors.py +106 -0
- continuo_contracts-0.1.0/tests/test_check_wheel.py +85 -0
- continuo_contracts-0.1.0/tests/test_fingerprints_guard.py +175 -0
- continuo_contracts-0.1.0/tests/test_fingerprints_reader.py +326 -0
- continuo_contracts-0.1.0/tests/test_hashing.py +100 -0
- continuo_contracts-0.1.0/tests/test_hashing_vectors.py +94 -0
- continuo_contracts-0.1.0/tests/test_package.py +19 -0
- continuo_contracts-0.1.0/tests/test_projection_vectors.py +71 -0
- continuo_contracts-0.1.0/tests/test_public_names.py +105 -0
- continuo_contracts-0.1.0/tests/test_python_projection.py +347 -0
- 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")
|