Skip to content

Commit 59d68ef

Browse files
Guillaume Lessardclaude
andcommitted
fix(test): a live Stripe key must never route a unit test at production billing
test_flush_metered_usage_reports_missing_key_clearly branched on the mere presence of STRIPE_SECRET_KEY. On a box whose .env holds an sk_live_ secret that took the "configured" path and issued a real meter event against production Stripe for the dummy customer cus_test_flush_no_crash, failing with "Stripe rejected the meter event (HTTP 400)". The 400 was the benign outcome -- the dummy id is rejected. Had it been a real customer id, a unit test would have recorded actual billable usage against a paying account. Now gated on sk_test_, and a live key skips the test outright. CI behaviour is unchanged: with no key set, "" does not start with sk_test_, so the pytest.raises branch runs exactly as before. Also adds CLAUDE.md: the release-pipeline rules, so future sessions do not rewrite the workflow that ships the wheels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent e5e2c17 commit 59d68ef

2 files changed

Lines changed: 154 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# CLAUDE.md — release pipeline rules (STRICT)
2+
3+
Read this before touching **anything** under `.github/workflows/`, `scripts/pack_rust_core.py`,
4+
`src/*.rs`, `Cargo.toml`, `pyproject.toml`, or `rust_core.sha256`.
5+
6+
---
7+
8+
## 0. Prime directive
9+
10+
The pipeline in `.github/workflows/` is the workflow that actually shipped **v0.6.5 → v0.6.9**
11+
to PyPI. It is field-proven. **Do not modernize, refactor, tidy, restructure, or rewrite it.**
12+
13+
Every release outage in this project's history came from the same cause: someone replaced a
14+
working workflow with an untested rewrite that looked cleaner. Commit `3d332eb` did exactly
15+
this and introduced six separate release-breaking faults at once, none of which were visible
16+
until a tag was pushed.
17+
18+
If you believe the pipeline is wrong, **say so and stop**. Do not fix it unilaterally.
19+
20+
A publish to PyPI is **irreversible**. A version number can never be reused — only yanked.
21+
Treat every change that could reach `publish-to-pypi` as one-way.
22+
23+
---
24+
25+
## 1. Absolute prohibitions
26+
27+
Never do any of the following unless the human explicitly names the rule number and tells you
28+
to break it. "Make CI better", "clean this up", or "modernize the actions" is **not** permission.
29+
30+
| # | Never | Why — verified consequence |
31+
|---|---|---|
32+
| 1 | Publish, build, or restore an sdist job | `.gitignore` line 5 is `src/*` with `!src/lib.rs`. `maturin sdist` reads membership from git, so the tarball ships **no `.rs` sources** and cannot build. Any platform without a wheel then fails on a source build. PyPI currently holds **15 wheels, 0 sdists** — keep it exact. |
33+
| 2 | Reduce the Rust-core restore below 12 chunks, or inline `base64 -d \| tar -xzf` | The core is **46 `.rs` files** and packs to **exactly 12 chunks** at `MAX_CHUNK_BYTES = 30_000`. The pre-0.7.0 3-chunk inline restore (`${PART1}${PART2}${PART3}`) would silently truncate to a quarter of the source and fail the build. Verify with `python scripts/pack_rust_core.py pack --out <tmp>` and count. |
34+
| 3 | Add `\|\| inputs.publish` (or any `workflow_dispatch` input) to the publish gate | A dispatch input arrives as a **string**, and every non-empty string is truthy in GitHub expressions — so `publish=false` evaluated **TRUE**. This made publish live on runs explicitly asked not to publish. The gate must remain ref-based only. |
35+
| 4 | Reintroduce the `SKIP_BUILD` / "secret not set → warn and skip" pattern | The v0.6.9 file downgraded a missing secret to `::warning::` and skipped the wheel build. A release could then report **success while producing zero wheels**. A missing secret must fail loudly. |
36+
| 5 | Reference `get_decoder_info` | It does not exist. It appears only inside a docstring at `python/qector_decoder_v3/__init__.py:2819`. A smoke test calling it fails at import. |
37+
| 6 | Add `musllinux`, Linux `aarch64`, or `macos-13` / macos-intel targets | README documents 15 wheels. musllinux and Linux-aarch64 never built or smoke-tested cleanly; macos-intel **hangs** the runner. |
38+
| 7 | Change the wheel count, or weaken the `-lt 15` guard in `publish` | 3 platforms × 5 CPython (3.9–3.13) = **15**. The guard is the only thing that catches a partially-built matrix before upload. |
39+
| 8 | Make `actions/attest-build-provenance` gating | Provenance needs a public repo or GHAS; on a private repo the API returns "Feature not available". It must stay `continue-on-error: true` behind `if: github.event.repository.private == false`. See commit `49ba957`. |
40+
| 9 | Remove `skip-existing: true` from `pypa/gh-action-pypi-publish` | PyPI rejects re-uploading an existing file. Without this, a run that uploaded 9 of 15 wheels then failed can **never be retried** — the release is stuck half-published forever. |
41+
| 10 | Edit `src/*.rs` without repacking the secrets **and** committing `rust_core.sha256` | `src/*` is gitignored. Editing it and pushing does **nothing** to CI — the build keeps compiling whatever the secrets last held. A fix once sat local-only for four hours while every CI run reported green against stale source. |
42+
| 11 | Delete or disable the `python` job in `tests.yml` | It is the **only** CI that imports the built package. It runs pytest, ruff lint + format, the installed-vs-source `filecmp` gate, and the import smoke test. The wheel jobs only compile and upload; they never import anything. |
43+
| 12 | Remove `concurrency` from `tests.yml` | Without it, four quick pushes put ~20 test jobs in the pool at once, starving `release-build` of runners and making healthy runs look hung at 7%. Do **not** copy it onto the release build — cancelling a half-finished release is worse than paying for it to finish. |
44+
| 13 | Write unindented code inside a YAML `run: \|` block | This exact mistake made `tests.yml` unparseable and **silently disabled every trigger** — 0 jobs, `failure` conclusion, no error anyone noticed. Use `python - <<'PY' … PY`, indented to the block. |
45+
| 14 | Change `--no-default-features --features cuda` | `ocl` needs an OpenCL import library at link time, which CI runners lack. Both backends load drivers at **runtime**, so a CUDA wheel still installs and runs GPU-free — `cuda_is_available()` just returns `False`. |
46+
| 15 | Delete or force-push a branch or tag without archiving it first | Archive as `archive/<name>` and push the tag **before** deleting. `archive/*` matches none of the release triggers, so it fires no CI. |
47+
48+
---
49+
50+
## 2. Mandatory verification before any `.github/workflows/**` change
51+
52+
Run **all** of these. A change that skips them is not permitted.
53+
54+
```bash
55+
# 1. every workflow must parse — a ScannerError silently disables all triggers
56+
python -c "import yaml,glob,sys; [yaml.safe_load(open(f,encoding='utf-8')) for f in glob.glob('.github/workflows/*.yml')]; print('YAML OK')"
57+
58+
# 2. the packed core must still match the working tree
59+
python scripts/pack_rust_core.py check-manifest # must print OK
60+
61+
# 3. chunk count must not exceed the wired secrets (currently 1..12)
62+
python scripts/pack_rust_core.py pack --out /tmp/_chunks && ls /tmp/_chunks | grep -c RUST_SRC_B64_ && rm -rf /tmp/_chunks
63+
64+
# 4. versions must agree — a mismatch ships a wheel whose __version__ disagrees with its metadata
65+
grep -m1 '^version' Cargo.toml pyproject.toml
66+
```
67+
68+
Then prove it on CI **without** a tag:
69+
70+
```bash
71+
gh workflow run Build --ref main # builds all 15 wheels; cannot publish (publish needs refs/tags/v*)
72+
```
73+
74+
Never validate a workflow change by pushing a `v*` tag. Use `workflow_dispatch`, or a
75+
`test-*` / `ci-*` tag — both build everything and neither can reach PyPI.
76+
77+
---
78+
79+
## 3. Trigger semantics — memorize before editing `on:`
80+
81+
| Ref pushed | Build wheels | `dependency-gate` | `publish-to-pypi` |
82+
|---|---|---|---|
83+
| `main` / `master` | yes | skipped | skipped |
84+
| `test-*`, `ci-*` tag | yes | skipped | skipped |
85+
| **`v*` tag** | yes | **runs strict** | **PUBLISHES — irreversible** |
86+
| pull request | yes | skipped | skipped |
87+
| `workflow_dispatch` | yes | skipped | skipped |
88+
| `archive/*` tag | no | no | no |
89+
90+
`publish` requires **all** of: `startsWith(github.ref, 'refs/tags/v')`,
91+
`github.repository == 'GuillaumeLessard/qector-decoder'`, and
92+
`github.event_name != 'pull_request'`. It also `needs: [linux-x86_64, windows-x64, macos-arm,
93+
dependency-gate]`. Do not relax any term.
94+
95+
The `pypi` environment has **no protection rules** — nothing will pause a publish for human
96+
approval. The tag is the only gate.
97+
98+
---
99+
100+
## 4. The pipeline, as it must remain
101+
102+
- `release-build.yml``linux-x86_64`, `windows-x64`, `macos-arm` (5 CPython each) → `dependency-gate` (tag-only, `strict: true`) → `publish` (tag-only).
103+
- `tests.yml``rust` (cargo test/clippy, default + `full`) and `python` (5 versions).
104+
- `dependency-audit.yml` — advisories + licence policy. `workflow_call` declares `strict` (**boolean**, default `false`) and optional `SAFETY_API_KEY`. Strict mode caught **RUSTSEC-2026-0204** before it shipped.
105+
- `stale-secrets.yml` — restores the secrets and runs `check-manifest` against the committed `rust_core.sha256`, so forgetting to upload now fails CI instead of passing silently.
106+
107+
`SAFETY_API_KEY` is intentionally **not** configured; that step no-ops by design and `pip-audit`
108+
is the real gate. Do not "fix" its absence.
109+
110+
Do not "fix" a red `check-manifest` by regenerating the manifest alone — that just re-points the
111+
anchor at the drift. **Upload the secrets.**
112+
113+
---
114+
115+
## 5. Releasing
116+
117+
`docs/RELEASING.md` is authoritative; this file does not replace it. In particular:
118+
119+
- Bump the version in **both** `Cargo.toml` and `pyproject.toml`.
120+
- Move the `## [Unreleased]` / `UNRELEASED` block in `CHANGELOG.md` to the new version heading.
121+
- Refresh `RUST_SRC_B64_*` after **any** `src/*.rs` change and commit `rust_core.sha256` in the
122+
same change.
123+
- Run `python tools/check_token_compat.py` from the fulfilment-worker repo — expect
124+
`CROSS-COMPAT: PASS`. This is load-bearing: it proves a licence minted by the Cloudflare
125+
worker is byte-identical to `license.py::create_license_token_v2`. If the token format drifts,
126+
**every paying customer receives a token their installed package rejects**, and nothing else
127+
in the stack catches it.
128+
- `LICENSE_TOKEN_VERSION` in the worker stays `"legacy"` until a release containing
129+
`license._verify_v2` is on PyPI **and** customers have had time to upgrade.
130+
131+
Do not create a `v*` tag on the user's behalf without an explicit, unambiguous instruction to
132+
release. "It's ready", "v0.7.0 is there", or "ship it" is a cue to run the pre-flight and
133+
**report**, not to tag.
134+
135+
---
136+
137+
## 6. If you break something
138+
139+
Do not force-push over it. Report exactly what changed, cite the run URL, and restore from git
140+
(`git checkout HEAD -- .github/workflows/`). The committed state is the proven state.

python/tests/test_stripe_metered_billing.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,23 @@ def test_flush_metered_usage_reports_missing_key_clearly():
1818
``STRIPE_SECRET_KEY`` — which is every CI runner and every developer box
1919
that has not opted into billing (T7-01 is still blocked on key
2020
permissions). Both environments are now asserted explicitly.
21+
22+
The branch is gated on a **test-mode** key, not on the mere presence of
23+
``STRIPE_SECRET_KEY``. Keying off presence alone meant that on a box whose
24+
``.env`` holds an ``sk_live_`` secret, this test issued a real meter event
25+
against production Stripe and failed with
26+
``Stripe rejected the meter event (HTTP 400)``. That is the benign outcome:
27+
the dummy customer id is rejected. Had the id been a real customer, a unit
28+
test would have recorded actual billable usage against a paying account.
29+
A live key must never route a unit test at production billing.
2130
"""
2231
import os
2332

24-
configured = bool(os.environ.get("STRIPE_SECRET_KEY"))
33+
key = os.environ.get("STRIPE_SECRET_KEY", "")
34+
if key.startswith("sk_live_"):
35+
pytest.skip("live Stripe key in environment; refusing to emit real meter events from a unit test")
36+
37+
configured = key.startswith("sk_test_")
2538
if configured:
2639
result = flush_metered_usage(customer_id="cus_test_flush_no_crash")
2740
assert isinstance(result, dict)

0 commit comments

Comments
 (0)