|
| 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. |
0 commit comments