|
| 1 | +# RELEASE_PROCEDURE.md — QECTOR Decoder v3 |
| 2 | + |
| 3 | +**Audience: any LLM/agent (or human) doing dev work, version bumps, or |
| 4 | +releases in this repository.** Read this in full before touching version |
| 5 | +numbers, `src/`, GitHub secrets, or git tags. It exists because every |
| 6 | +mistake described below has actually happened in this repo at least once. |
| 7 | + |
| 8 | +Companion to `AGENTS.md` (general dev conventions, coding style, testing). |
| 9 | +This file is specifically about **the release/push/publish pipeline** — |
| 10 | +the part with irreversible consequences (PyPI publishes cannot be deleted |
| 11 | +or overwritten; GitHub secrets, once wrong, silently break every build). |
| 12 | + |
| 13 | +--- |
| 14 | + |
| 15 | +## 0. The one rule that matters most |
| 16 | + |
| 17 | +**`src/*.rs` in this working checkout is the real, proprietary Rust source. |
| 18 | +It must never be committed directly to git on `main`.** |
| 19 | + |
| 20 | +This repo's public GitHub history does NOT contain the real Rust source in |
| 21 | +plain form. Instead: |
| 22 | + |
| 23 | +- The real source lives only in this local checkout (and wherever else the |
| 24 | + operator keeps it) and as three base64-encoded, chunked GitHub Actions |
| 25 | + secrets: `RUST_SRC_B64_1`, `RUST_SRC_B64_2`, `RUST_SRC_B64_3`. |
| 26 | +- CI (`.github/workflows/CI.yml`) reassembles and decodes these secrets at |
| 27 | + build time (`Inject Rust source` step) into `src/` before compiling. |
| 28 | +- `git status` in this checkout will normally show `src/lib.rs` and other |
| 29 | + `src/*.rs` files as modified or untracked. **This is expected and correct.** |
| 30 | + Do not `git add src/` and do not include `src/` in release commits. |
| 31 | + |
| 32 | +If you ever need to update the source the secrets decode to, see §5 |
| 33 | +("Updating the injected Rust source"). Never treat a modified/untracked |
| 34 | +`src/` as something to silently stage in a routine commit. |
| 35 | + |
| 36 | +--- |
| 37 | + |
| 38 | +## 1. Repo / environment facts (verified, not assumed) |
| 39 | + |
| 40 | +- Local checkout root (Windows): `C:\Users\Admin\Desktop\qector-build-rc-0.6.2` |
| 41 | +- Remote: `https://github.com/GuillaumeLessard/qector-decoder.git`, default |
| 42 | + branch `main` (an earlier stray `master` branch existed and was deleted — |
| 43 | + if you ever see a `master` branch again, do not assume it's authoritative; |
| 44 | + verify against `main` first). |
| 45 | +- PyPI project: `qector-decoder-v3` (https://pypi.org/project/qector-decoder-v3/) |
| 46 | +- Publish mechanism: OIDC Trusted Publisher via `pypa/gh-action-pypi-publish` |
| 47 | + — **no API token is stored or needed.** Publishing is gated on: |
| 48 | + - tag matching `v*` |
| 49 | + - `github.repository == 'GuillaumeLessard/qector-decoder'` (forks can't publish) |
| 50 | + - not a `pull_request` event |
| 51 | + - all of `linux-x86_64`, `windows-x64`, `macos-arm` jobs succeeding first |
| 52 | + (`needs:` in CI.yml) — `macos-intel` is present but disabled |
| 53 | + (`if: false`, long queue times) and is NOT required for publish. |
| 54 | +- Build flags actually used in CI (`CI.yml` → `env.MATURIN_ARGS`): |
| 55 | + `--release --no-default-features --features cuda --out dist` |
| 56 | + — **OpenCL is NOT compiled into public wheels.** CUDA is present as a |
| 57 | + feature but loads at runtime via `libloading`/NVRTC, so no CUDA toolkit |
| 58 | + is needed on CI runners. Any code path that unconditionally assumes an |
| 59 | + OpenCL symbol exists on the compiled module will crash on import for |
| 60 | + every user (this exact bug shipped in v0.6.5 — see CHANGELOG). |
| 61 | +- Local dev builds (`maturin develop`) commonly use **default features**, |
| 62 | + which include both `opencl` and `cuda`. This means a bug gated behind |
| 63 | + "only present without the opencl feature" will not reproduce locally |
| 64 | + under default `maturin develop` — you must rebuild with the exact CI |
| 65 | + flags to catch it (§6). |
| 66 | + |
| 67 | +--- |
| 68 | + |
| 69 | +## 2. Version numbers — every location, no exceptions |
| 70 | + |
| 71 | +A version bump touches **all** of the following. Miss one and you ship an |
| 72 | +inconsistent release (this has happened — a stale `0.6.4` citation block |
| 73 | +survived two version bumps before being caught). |
| 74 | + |
| 75 | +| File | What to change | |
| 76 | +|---|---| |
| 77 | +| `pyproject.toml` | `[project] version = "X.Y.Z"` | |
| 78 | +| `Cargo.toml` | `[package] version = "X.Y.Z"` | |
| 79 | +| `Cargo.lock` | Auto-updates when you run `cargo check`/`cargo build` after bumping `Cargo.toml`. Verify the `qector_decoder_v3` package entry specifically — do NOT hand-edit; do not confuse it with unrelated third-party crate versions in the same lockfile (e.g. `rand_core 0.6.4` is unrelated and must not change). | |
| 80 | +| `python/qector_decoder_v3/__init__.py` | `__fallback_version__ = "X.Y.Z"` — **this is a fallback only**, used if the compiled extension's own `__version__` can't be read. Never make this overwrite the real compiled `__version__`; the code should keep reporting the built value until the wheel is actually rebuilt. | |
| 81 | +| `CITATION.cff` | `version: X.Y.Z` | |
| 82 | +| `codemeta.json` | `"version":"X.Y.Z"` (single-line minified JSON in this repo — use exact string match, don't reformat the whole file) | |
| 83 | +| `README.md` | Citation/BibTeX block (`version = {X.Y.Z}`) + add a new `## New in vX.Y.Z` section ahead of the previous one | |
| 84 | +| `PYPI_README.md` | Citation/BibTeX block (`version = {X.Y.Z}`) + add a new `## vX.Y.Z Highlights` section ahead of the previous one | |
| 85 | +| `CHANGELOG.md` | New `## [X.Y.Z] - YYYY-MM-DD` entry at the top (below `## [Unreleased]`), following Keep a Changelog format already in use | |
| 86 | + |
| 87 | +### Verifying you got them all |
| 88 | + |
| 89 | +Before committing, grep every version-bearing file for the OLD version |
| 90 | +string and confirm zero hits outside of historical changelog entries |
| 91 | +(which should of course still mention old versions in their own sections): |
| 92 | + |
| 93 | +```powershell |
| 94 | +Select-String -Path README.md,PYPI_README.md,CHANGELOG.md,CITATION.cff,codemeta.json,pyproject.toml,Cargo.toml,Cargo.lock -Pattern "OLD\.VERSION\.HERE" |
| 95 | +``` |
| 96 | + |
| 97 | +Then grep for the NEW version and confirm it appears in every file from |
| 98 | +the table above: |
| 99 | + |
| 100 | +```powershell |
| 101 | +Select-String -Path README.md,PYPI_README.md,CHANGELOG.md,CITATION.cff,codemeta.json,pyproject.toml,Cargo.toml,Cargo.lock -Pattern "NEW\.VERSION\.HERE" |
| 102 | +``` |
| 103 | + |
| 104 | +Read the actual diff before committing, not just the file list: |
| 105 | + |
| 106 | +```powershell |
| 107 | +git diff -- pyproject.toml Cargo.toml CITATION.cff codemeta.json Cargo.lock python/qector_decoder_v3/__init__.py README.md PYPI_README.md CHANGELOG.md |
| 108 | +``` |
| 109 | + |
| 110 | +--- |
| 111 | + |
| 112 | +## 3. Staging and committing — the safe pattern |
| 113 | + |
| 114 | +**Never `git add -A` or `git add .` in this repo.** The working tree |
| 115 | +routinely contains real proprietary `src/*.rs` files (modified or |
| 116 | +untracked) plus scratch/debug files that should never be committed |
| 117 | +(`*.ps1` one-off scripts, `mypy_*.txt`, `pytest_*.txt`, `ruff_*.txt`, |
| 118 | +`cargo_check_*.txt`, `.venv*/`, `benchmark_*.py` ad hoc scripts, etc.). |
| 119 | +Blind `-A` staging has caused real incidents in this repo's history. |
| 120 | + |
| 121 | +Correct pattern — stage exactly the files you intend to change, by name: |
| 122 | + |
| 123 | +```powershell |
| 124 | +cd "C:\Users\Admin\Desktop\qector-build-rc-0.6.2" |
| 125 | +git status --short # review everything first |
| 126 | +git add pyproject.toml Cargo.toml Cargo.lock CITATION.cff codemeta.json ` |
| 127 | + python/qector_decoder_v3/__init__.py README.md PYPI_README.md CHANGELOG.md |
| 128 | +git status --short # confirm ONLY intended files show staged (M in col 1) |
| 129 | + # confirm src/lib.rs is NOT staged |
| 130 | +``` |
| 131 | + |
| 132 | +If `src/lib.rs` or other `src/*.rs` files show as staged, unstage them |
| 133 | +immediately: `git restore --staged src/`. This should not normally happen |
| 134 | +if you only `git add` explicit filenames, but always verify. |
| 135 | + |
| 136 | +Write a commit message that documents **root cause and verification**, not |
| 137 | +just "bump version" — future sessions (including future you, and future |
| 138 | +LLM agents with no memory of this session) need to know *why*, not just |
| 139 | +*what*: |
| 140 | + |
| 141 | +```powershell |
| 142 | +git commit -m "vX.Y.Z: <one-line summary>" -m "<paragraph: root cause, what was verified, and how>" |
| 143 | +``` |
| 144 | + |
| 145 | +Push to `main` directly (this repo does not use a PR-gated release |
| 146 | +branch model for routine releases): |
| 147 | + |
| 148 | +```powershell |
| 149 | +git push origin main |
| 150 | +``` |
| 151 | + |
| 152 | +--- |
| 153 | + |
| 154 | +## 4. Verify CI is green on `main` BEFORE tagging |
| 155 | + |
| 156 | +Tagging triggers the real publish pipeline. Never tag on a red or unverified |
| 157 | +`main`. Use the GitHub CLI to watch the exact commit's runs: |
| 158 | + |
| 159 | +```powershell |
| 160 | +gh run list --branch main --limit 5 |
| 161 | +gh run watch <tests-run-id> --exit-status |
| 162 | +gh run watch <ci-run-id> --exit-status |
| 163 | +``` |
| 164 | + |
| 165 | +Confirm in the output: |
| 166 | +- `tests` workflow: `ruff-and-mypy`, `smoke-import-py3.9`...`py3.13` all pass |
| 167 | + (the `docker` job is informational only — `continue-on-error: true` — a |
| 168 | + red docker job is expected/harmless when `src/` in the checkout is a stub |
| 169 | + or doesn't match the Dockerfile's expectations; do not treat it as a |
| 170 | + release blocker) |
| 171 | +- `CI` workflow: all 15 real matrix jobs (5 Python versions × Linux/Windows, |
| 172 | + plus 5 × macOS ARM) show ✓ Complete job. `macos-x86_64-py${{ matrix.python-version }}` |
| 173 | + will show as **skipped**, not failed — this is the disabled Intel-macOS |
| 174 | + job (`if: false` in CI.yml, pre-existing, not something to fix as part |
| 175 | + of a release). A skip here is normal; a failure anywhere else is not. |
| 176 | + |
| 177 | +Do not proceed to tagging on partial/incomplete runs. Wait for full completion. |
| 178 | + |
| 179 | +--- |
| 180 | + |
| 181 | +## 5. Updating the injected Rust source (only when `src/` actually changed) |
| 182 | + |
| 183 | +If your release includes real Rust changes (new decoder, bugfix inside |
| 184 | +`src/*.rs`), the GitHub secrets must be regenerated from the verified-correct |
| 185 | +local source **before** the CI build that will use them: |
| 186 | + |
| 187 | +1. Confirm the local `src/` tree compiles and passes tests first: |
| 188 | + ```powershell |
| 189 | + cargo check --no-default-features --features cuda # match CI's exact flags |
| 190 | + cargo test |
| 191 | + ``` |
| 192 | +2. Round-trip-verify the encoding BEFORE uploading anything — encode, then |
| 193 | + decode back locally, and diff against the original byte-for-byte. Never |
| 194 | + upload a secret you haven't verified decodes to exactly the source you |
| 195 | + intended. A prior incident in this repo involved uploading a stub/wrong |
| 196 | + `src/lib.rs` this way, which silently broke every subsequent build. |
| 197 | +3. Chunk the base64 into three parts sized to stay under GitHub's per-secret |
| 198 | + size limit, and upload as `RUST_SRC_B64_1`, `RUST_SRC_B64_2`, `RUST_SRC_B64_3` |
| 199 | + via `gh secret set <name> < chunk_file` (or the GitHub UI). |
| 200 | +4. After uploading, trigger a fresh CI run (push a commit or re-run an |
| 201 | + existing workflow) and manually confirm the "Inject Rust source" step |
| 202 | + in the Actions log actually extracted the expected files — check the |
| 203 | + file count and spot-check one distinctive file (e.g. confirm a specific |
| 204 | + new function name grep-matches inside the extracted `src/lib.rs` in the |
| 205 | + Actions log, or that `cargo check` succeeds in that job). |
| 206 | +5. Only after confirming a full green CI run against the NEW secrets should |
| 207 | + you proceed to tagging. |
| 208 | + |
| 209 | +**Never assume a secret upload worked. Verify by observing the next real |
| 210 | +CI run use it successfully**, not by trusting the upload command's exit code. |
| 211 | + |
| 212 | +--- |
| 213 | + |
| 214 | +## 6. Tagging and publishing to PyPI |
| 215 | + |
| 216 | +**This step is irreversible.** PyPI does not allow re-uploading or deleting |
| 217 | +a version once published — a broken release stays broken forever at that |
| 218 | +version number; the only fix is shipping a new, higher version number |
| 219 | +(this happened with v0.6.5 → v0.6.6). Do not tag speculatively "to see |
| 220 | +what happens." |
| 221 | + |
| 222 | +Before tagging: |
| 223 | +- [ ] All version numbers updated and verified (§2) |
| 224 | +- [ ] Docs (`README.md`, `PYPI_README.md`, `CHANGELOG.md`) updated and re-read |
| 225 | + in full, not just diffed (read the actual rendered section — stale |
| 226 | + surrounding prose or broken markdown tables are easy to miss in a diff) |
| 227 | +- [ ] Committed and pushed to `main` |
| 228 | +- [ ] CI fully green on that exact commit (§4) |
| 229 | +- [ ] **Check whether a tag with this version already exists** — if you're |
| 230 | + re-doing a release (e.g. after discovering the version was already |
| 231 | + tagged against a stale/wrong commit), delete and recreate deliberately: |
| 232 | + ```powershell |
| 233 | + git tag -l "vX.Y.Z" # check local |
| 234 | + git ls-remote --tags origin | Select-String "vX.Y.Z" # check remote |
| 235 | + # if it exists and points at the wrong commit: |
| 236 | + git tag -d vX.Y.Z |
| 237 | + git push origin :refs/tags/vX.Y.Z # delete remote tag |
| 238 | + ``` |
| 239 | + |
| 240 | +Create and push the tag against the exact commit you verified in §4: |
| 241 | + |
| 242 | +```powershell |
| 243 | +git tag vX.Y.Z <commit-sha> # or omit sha to tag HEAD, but verify HEAD first |
| 244 | +git push origin vX.Y.Z |
| 245 | +``` |
| 246 | + |
| 247 | +Watch the tag-triggered run through to completion, including the publish job: |
| 248 | + |
| 249 | +```powershell |
| 250 | +gh run list --branch main --limit 3 |
| 251 | +gh run watch <tag-triggered-ci-run-id> --exit-status |
| 252 | +``` |
| 253 | + |
| 254 | +Confirm in the output that `publish-to-pypi` actually ran (not skipped) |
| 255 | +and succeeded. Then verify independently, from a fresh shell, that PyPI |
| 256 | +actually has it — don't trust the Actions log alone, and don't trust |
| 257 | +`pypi.org`'s JSON API's `info.version` field immediately after (it lags |
| 258 | +behind actual file availability due to CDN caching): |
| 259 | + |
| 260 | +```powershell |
| 261 | +pip index versions qector-decoder-v3 # or check the Files page directly: |
| 262 | +# https://pypi.org/project/qector-decoder-v3/#files |
| 263 | +``` |
| 264 | + |
| 265 | +--- |
| 266 | + |
| 267 | +## 7. Post-publish: install and test the REAL published artifact |
| 268 | + |
| 269 | +**Never consider a release verified based on local dev builds, editable |
| 270 | +installs, or `maturin develop` output.** You must install the actual thing |
| 271 | +a real user would get from `pip install`. |
| 272 | + |
| 273 | +1. Uninstall any local/editable install first, and confirm it's actually gone: |
| 274 | + ```powershell |
| 275 | + cd "C:\Users\Admin\Desktop\qector-build-rc-0.6.2" |
| 276 | + .\.venv\Scripts\Activate.ps1 |
| 277 | + pip uninstall qector-decoder-v3 -y |
| 278 | + python -c "import qector_decoder_v3" # MUST raise ModuleNotFoundError |
| 279 | + ``` |
| 280 | +2. Create a **fresh, separate, throwaway venv** — not `.venv` — with no |
| 281 | + cache, so nothing local leaks in: |
| 282 | + ```powershell |
| 283 | + python -m venv .venv_pypi_test |
| 284 | + .\.venv_pypi_test\Scripts\Activate.ps1 |
| 285 | + python -m pip install --upgrade pip --quiet |
| 286 | + pip install qector-decoder-v3==X.Y.Z --no-cache-dir |
| 287 | + ``` |
| 288 | +3. Confirm what actually got installed — look for the absence of an |
| 289 | + "Editable project location" line, and confirm the `Location` path |
| 290 | + points inside the throwaway venv, not this checkout: |
| 291 | + ```powershell |
| 292 | + pip show qector-decoder-v3 |
| 293 | + ``` |
| 294 | +4. Actually run code against it — at minimum: import (catches the exact |
| 295 | + class of bug that broke v0.6.5), GPU-availability checks (these run |
| 296 | + after import and were previously unreachable), and one real decode |
| 297 | + through the public API with real array shapes, not just `assert True`. |
| 298 | + Write this as a disposable script, run it, delete it after (see §8) — |
| 299 | + don't leave ad hoc verification scripts littering the repo root. |
| 300 | +5. Only after this passes is the release actually done. "CI is green" and |
| 301 | + "PyPI shows the version" are necessary but not sufficient — a release |
| 302 | + that imports-fails for every user (as v0.6.5 did) can still pass CI, |
| 303 | + because CI's own smoke-import test in `tests.yml` runs on Linux only |
| 304 | + and may not exercise every feature-flag combination a real user hits. |
| 305 | + |
| 306 | +--- |
| 307 | + |
| 308 | +## 8. Cleanup discipline |
| 309 | + |
| 310 | +- Delete throwaway venvs (`.venv_pypi_test`, `.venv_clean_test`, etc.) and |
| 311 | + ad hoc verification scripts once you're done with them. They are not |
| 312 | + tracked in git (see `.gitignore`), but they clutter the working directory |
| 313 | + and can confuse the next session (human or LLM) about what's real vs. |
| 314 | + scratch. |
| 315 | +- Never delete `todo.md`, `todo2.md`, `howto.md`, or other operator-authored |
| 316 | + planning docs at repo root unless explicitly asked — these are working |
| 317 | + notes, not scratch output, even though they sit alongside actual scratch |
| 318 | + files. |
| 319 | +- If you're unsure whether a root-level file is scratch or intentional, |
| 320 | + ask, or check `git log --follow -- <file>` / whether it's tracked at all |
| 321 | + (`git ls-files <file>`) before deleting. |
| 322 | + |
| 323 | +--- |
| 324 | + |
| 325 | +## 9. Quick reference — full release checklist |
| 326 | + |
| 327 | +``` |
| 328 | +[ ] Bump version in all 8 locations (§2), verify with grep |
| 329 | +[ ] Update README.md, PYPI_README.md, CHANGELOG.md — read full rendered sections |
| 330 | +[ ] git status --short — review everything, confirm no src/ surprises |
| 331 | +[ ] git add <explicit filenames only> -- never -A / . |
| 332 | +[ ] git status --short — confirm ONLY intended files staged |
| 333 | +[ ] git commit -m "vX.Y.Z: ..." -m "<root cause + verification detail>" |
| 334 | +[ ] git push origin main |
| 335 | +[ ] gh run watch <tests-run> --exit-status |
| 336 | +[ ] gh run watch <ci-run> --exit-status -- confirm all real jobs green |
| 337 | +[ ] (if src/ changed) regenerate + verify RUST_SRC_B64_1/2/3, confirm next |
| 338 | + CI run actually used the new secret successfully |
| 339 | +[ ] Check no stale/wrong tag already exists for this version |
| 340 | +[ ] git tag vX.Y.Z && git push origin vX.Y.Z |
| 341 | +[ ] gh run watch <tag-triggered-run> --exit-status -- confirm publish-to-pypi ran + succeeded |
| 342 | +[ ] pip index versions qector-decoder-v3 -- confirm live independent of Actions log |
| 343 | +[ ] Uninstall local install, confirm ModuleNotFoundError |
| 344 | +[ ] Fresh throwaway venv, pip install <pkg>==X.Y.Z --no-cache-dir |
| 345 | +[ ] pip show <pkg> -- confirm no "Editable project location", right venv path |
| 346 | +[ ] Run real code against it: import + GPU checks + one real decode |
| 347 | +[ ] Delete throwaway venv + ad hoc test script |
| 348 | +[ ] Done -- only now is the release actually verified |
| 349 | +``` |
| 350 | + |
| 351 | +--- |
| 352 | + |
| 353 | +*This document describes process, not code. If the actual workflow files |
| 354 | +(`.github/workflows/CI.yml`, `tests.yml`) change, re-verify this document |
| 355 | +against them rather than trusting it as historical truth — it was last |
| 356 | +verified against the live workflow files on 2026-07-12.* |
0 commit comments