# Releasing

> **Conformance:** L1 / L2 / L3 (see `README.md`)
> **Audience:** Human engineers AND AI coding agents acting as release maintainers.
> **Propagation (DOC-18):** changing a rule here ripples — walk DOCUMENTATION.md §10.4 and update its dependents in the same commit (README *Index of MUSTs*, the `mgf-fed standards` / `agents` CLI, and any parallel copies / pins the matrix names).

> **Package endpoint + auth live in [`PACKAGE_DISTRIBUTION.md`](PACKAGE_DISTRIBUTION.md) (rules PKG-01..PKG-12).** For `mgf-*` packages the distribution channel is the **self-hosted devpi index** (`magogi/prod` on `195.15.203.250`), not pypi.org. This doc owns the *release engineering* (tags, ordering, smoke, close-loop); the *where/how to publish* (endpoints, auth model, verify) is PKG-*. Where a REL-* rule below names PyPI for an `mgf-*` package, read "the devpi index" and follow the cited PKG-* rule.

> **Scope.** Cross-project release-engineering standard for any
> Python project that publishes to the **self-hosted devpi index**
> (`magogi/prod`, per [`PACKAGE_DISTRIBUTION.md`](PACKAGE_DISTRIBUTION.md) PKG-01)
> — or, for non-`mgf-*` projects, any PyPI-compatible index. The
> rules are written for the `mgf-common` shape but apply to every
> versioned, distributed package. They turn the "cut a release"
> ritual from tribal knowledge into a discipline that survives
> handoff between maintainers, sessions, and agents.
>
> Filed in response to two concrete incidents on 2026-05-02:
> (a) a maintainer-side false-positive unblock claim caused by
> reading a local `dist/` wheel as if it were the published
> registry artifact (resolved in commit `5bef66f`); (b) FEEDBACK
> PAPER-12 — a consumer reading `PUBLIC_API.md` from a checkout
> believed features were shipped that were only on master HEAD.
> Both failures share one root cause: **the published artifact is
> the contract; nothing else is.**

---

## Rules box (read this first)

| ID | Rule |
|---|---|
| <a id="REL-01"></a>**REL-01** | **Every release MUST have a corresponding annotated git tag** of the form `v<version>` on the exact commit that built the published wheel. (For `mgf-*` packages "release" means published to the devpi index — PKG-01.) Tags are how the project answers "what was master at when 0.X.Y was cut?" months later. |
| <a id="REL-02"></a>**REL-02** | **A version string MUST NOT be reused across different wheel contents.** Once `0.X.Y` is published, the local `dist/` artifact for that version is read-only. Never rebuild the same version string from a later commit — a rebuilt local wheel that *looks* like 0.X.Y but ships extra commits is the canonical maintainer-side footgun. Bump the version, build, ship. |
| <a id="REL-03"></a>**REL-03** | **`PUBLIC_API.md` and `PUBLIC_API.json` reflect master HEAD, not the published surface.** They MAY be ahead of the registry during a release window. Consumers verify what they actually have with `pip show mgf-common` + the `__version__` field in `PUBLIC_API.json`. The doc carries a banner stating this. (Closes PAPER-12.) |
| <a id="REL-04"></a>**REL-04** | **Publish credentials MUST NOT be passed via chat, shell history, or environment variables in unmanaged sessions.** For `mgf-*` the credential is the **`magogi` devpi account's upload password** — see [`PACKAGE_DISTRIBUTION.md`](PACKAGE_DISTRIBUTION.md) **PKG-03** for the canonical auth model. Local: a `chmod 600` file (e.g. `~/.pypirc`) holding the password. CI: a platform secret (`devpi_password`), tag-event-scoped. **Install/read is anonymous** — no consumer token. **There is no OIDC / Trusted Publisher on devpi** — do not reference one. |
| <a id="REL-05"></a>**REL-05** | **A published version is immutable.** Bug fixes ship as a new release, never as a content swap of an existing version. For `mgf-*` on devpi this is immutable **by the index, not merely by policy**: the `magogi/prod` stage is **non-volatile**, so devpi *rejects* re-uploading the same `name+version` (like pypi.org, unlike the old Codeberg registry). Botched-upload recovery is therefore a recorded **host-admin `devpi remove`** on the devpi host, not a routine op — see [`PACKAGE_DISTRIBUTION.md`](PACKAGE_DISTRIBUTION.md) **PKG-07**. Never silently replace a version a consumer may have resolved. |
| <a id="REL-06"></a>**REL-06** | **The release flow is strictly ordered.** Out-of-order is a footgun. The order is: bump version → regenerate `PUBLIC_API.json` → build → run full test suite (green) → twine check → commit → tag → push tag → **upload to the devpi index → verify via the `+simple/` index listing**. Do not invert. For `mgf-*` the upload target + post-publish verification are governed by [`PACKAGE_DISTRIBUTION.md`](PACKAGE_DISTRIBUTION.md) **PKG-04** (ordered flow) and **PKG-05** (`+simple/` verify, never pypi.org). |
| <a id="REL-07"></a>**REL-07** | **A release commit MUST close every FEEDBACK entry it ships.** Flip status from `OPEN` / `ACCEPTED` / `SCHEDULED` to `✅ SHIPPED` with a one-paragraph retrospective in the entry body. The CHANGELOG is the audit trail; the FEEDBACK entry is the design record. |
| <a id="REL-08"></a>**REL-08** | **Pre-release smoke MUST exercise the actually-built wheel.** Run `pip install dist/<package>-<version>-*.whl` into an ephemeral venv and import every public top-level name. A passing test suite against the source tree does NOT prove the wheel installs and imports cleanly. |
| <a id="REL-09"></a>**REL-09** | **Every FEEDBACK entry flipped to `✅ SHIPPED` (or to `DEFERRED` with a sharpened rationale, or to `❌ REJECTED`) MUST produce a consumer-facing notification message.** A short, copy-pasteable paragraph naming the version it ships in, the consumer's next move, and any caveats. The CHANGELOG's *Migration* section is the source content; the notification is the *delivery mechanism* — without this step, the consumer who filed the entry never learns it was addressed. The maintainer (human or AI agent) drafts the notification at close-loop time and hands it back to whoever filed the original entry. |
| <a id="REL-10"></a>**REL-10** | **A `mgf-common` minor bump that breaks any sibling's existing pin upper-bound MUST trigger a federation cohort-walk release wave** — every sibling that pins `mgf-common<X.Y` MUST publish a release widening its window to accept the new minor BEFORE the new mgf-common version is announced as available to consumers. The cohort-walk is the maintainer's discipline against PAPER-84-class breakage; running it ad-hoc per consumer complaint is the failure mode the rule prevents. The recipe at [`docs/recipes/cohort-release-wave.md`](../recipes/cohort-release-wave.md) is the canonical procedure. |
| <a id="REL-11"></a>**REL-11** | **Cohort-walk release waves MUST follow the Phase A / Phase B split** — Phase A is parallel mechanical edits (one sub-agent per sibling: pyproject pin walks + CHANGELOG entry + version bump + `uv.lock` refresh); Phase B is sequential verification + ship (`pytest` + `commit` + `tag` + `push` + `build` + `publish`, one sibling at a time, driven by the maintainer). The split makes the parallelism + sequential-verification boundary structural instead of accidental — Phase A parallelism reduces wall-clock; Phase B serialization keeps the publish-safety invariant intact even if a sibling's tests start failing mid-wave. Codified after PAPER-85 surfaced the gap; recipe at [`docs/recipes/cohort-release-wave.md`](../recipes/cohort-release-wave.md). |
| <a id="REL-12"></a>**REL-12** | **Phase A agents MUST carry a graceful pytest-denial fallback** — if pytest invocation is denied (Claude Code permission gate, sandbox restriction, or any other "cannot run tests" failure mode), the agent MUST stop at that step, report "edits ready for verification" with the file diff summary, and hand back WITHOUT committing/pushing/publishing. The maintainer then runs pytest in the parent session and proceeds with Phase B. Composes with REL-11; defense-in-depth against agent permission edge cases. The recipe's agent prompt template carries the standard fallback clause. |
| <a id="REL-13"></a>**REL-13** | **Every release MUST pass the federation consistency gate — releasing one or more libraries means the WHOLE federation is left consistent and verified.** Cutting a release of any `mgf-*` library is not done until the mgf-common supervisor has run the gate: `python scripts/gen_mgf_libraries.py` (in mgf-common). The gate (a) regenerates `MGF_LIBRARIES.md` — the always-current registry + high-level guide to every library (an at-a-glance index, a **release & compatibility matrix** showing which version of each library accepts the current cornerstone, and a per-library detail table), each name linked to its `DEPS.md` — and (b) checks that **every `mgf-*` → `mgf-*` dependency pin accepts the dependency's current version**. The supervisor MUST commit the regenerated registry; `python scripts/gen_mgf_libraries.py --check` is the pre-release gate that fails (exit 1) on **either** an inconsistent pin **or** a STALE registry (committed file ≠ fresh render), so "released a library but forgot to regenerate the registry" is a hard, catchable error, not silent drift. A RED gate (a dependent pinned to a window that excludes a current dependency, e.g. the pin lags a just-released version) MUST be resolved before the release is announced: the supervisor walks the lagging pin forward AND verifies the dependent's suite still passes (no source regression). This generalizes **REL-10** (which only fires on a `mgf-common` MINOR that breaks pins) to *any* library release, and it is the **mgf-common supervisor's standing duty** — only the supervisor edits/releases a sibling (see AGENTS **AG-13**); other projects file update requests in the target's `FEEDBACK.md`, never bump pins themselves. |

---

## Release modes — local-first, then remote promotion

A Magogi release has **two modes**, run in this order (the rules below are unchanged — this
just names the model the federation tooling implements):

1. **Local release** — build the wheel **once** (REL-02), validate it end-to-end against the
   **local index** (`~/.mgf/local-index/`), and publish it there. No network, no remote
   index. Always available. Recipe: [`docs/recipes/local-release.md`](../recipes/local-release.md).
2. **Remote release** — *promote the same wheel* to the devpi index: annotated tag (REL-01)
   → publish (PKG-04) → `+simple/`-verify (PKG-05) → consistency gate (REL-13). Recipe:
   [`docs/recipes/remote-release.md`](../recipes/remote-release.md).

The version number is **identical** in both modes — *build-once, promote* (REL-02): the artifact
uploaded to devpi is byte-identical to the one validated locally, never a rebuild from a later
commit. Local may lead in time; **remote is the eventual mirror**. A dependent resolves its mgf-*
deps **local-first** (the local index, then the devpi index), so a change can be validated across the
whole dependency chain — in wave order — before anything is published.

This is the gate the `mgf-fed` orchestrator runs (`mgf-fed release-local` / `release-remote
[--dry-run]`); the per-rule MUSTs it enforces are exactly REL-01/02/05/06/08/13 + PKG-04/05 above.
The local-release gate adds no new published-artifact MUSTs — it is the *pre-promotion validation
discipline* captured in the recipe.

---

## 1. Mental model — the registry is the contract

> For `mgf-*` packages the index is the **self-hosted devpi
> index** (`magogi/prod`, PKG-01), not pypi.org. The principle
> below is index-agnostic; only the index name changes.

A published wheel is the *only* artifact every consumer can
observe. Master, `dist/`, `PUBLIC_API.md`, your reference
consumer's vendored copy — none of these are authoritative. They
can all drift from the registry, and when they drift they lie.

### 1.1 The asymmetry that makes the published artifact authoritative

When a consumer runs `pip install mgf-common==0.X.Y` (resolving
`mgf-*` from the devpi index, PKG-06):

- They get the bytes that the upload sent to the index on
  whatever date 0.X.Y was cut.
- They do NOT get whatever is on master right now.
- They do NOT get whatever is in the maintainer's `dist/`
  directory right now.
- They get whatever the index serves for 0.X.Y. On devpi a
  version is immutable *by the index* — the `magogi/prod` stage is
  non-volatile (PKG-07) — so "is this in 0.X.Y?" is still a
  question about the published artifact, never about `dist/`.

Every claim of the form "this is in 0.X.Y" must be verifiable
against that specific published artifact. The right verification
question is **"is this in the wheel the registry serves for
0.X.Y?"** — not "is this in master?", not "is this in
PUBLIC_API.md?".

### 1.2 The two anti-patterns this rule kills

**Anti-pattern A: trusting `dist/`.**
> "I unzipped `dist/mgf_common-0.X.Y-py3-none-any.whl` and the
> module is there, so it's published."

False. Local builds can reuse a version string; the local wheel
may contain commits that landed after the registry upload. The
maintainer-side false-positive on 2026-05-02 was exactly this
shape — local dist had VersionedFileStore, the published 0.12.0
(on pypi.org at the time, pre-cutover) didn't.

**Anti-pattern B: trusting `PUBLIC_API.md`.**
> "I'm reading `PUBLIC_API.md` from the maintainer's master and
> it lists `default_log_path`, so it's available."

False. `PUBLIC_API.md` reflects master HEAD. PAPER-12 documents
this consumer-side trap. The banner at the top of
`PUBLIC_API.md` (shipped post-PAPER-12) tells readers exactly
this.

### 1.3 How to verify "is X in version Y?"

For `mgf-*` packages the authoritative checks query the **devpi
index**, never `pypi.org/pypi/...` (PKG-05). All reads are
**anonymous** — no credential. Three increasingly authoritative checks:

```bash
# (1) The lightweight check — is the version even on the index?
#     (the +simple/ per-package listing; this is the CI verify-devpi check)
curl -fsS 'https://195.15.203.250.sslip.io/magogi/prod/+simple/<package>/' \
  | grep -q '<package>-<version>'

# (2) The canonical PKG-05 gate — the devpi JSON metadata (version listed = live):
curl -fsS -H 'Accept: application/json' \
  'https://195.15.203.250.sslip.io/magogi/prod/<package>'

# (3) Cross-check the upload time against the commit that ships X:
git log --pretty='%H %cI %s' -- <path/to/X> | head
```

If the upload time of `<version>` is BEFORE the commit that ships
X, then X is NOT in `<version>` — regardless of what's on master.

When this answer matters to a downstream decision, run all three.
A grep against a local `dist/` wheel is not a substitute. Endpoints
+ auth are defined once in [`PACKAGE_DISTRIBUTION.md`](PACKAGE_DISTRIBUTION.md) §3/§7.

> Third-party (non-`mgf-*`) packages still live on public PyPI, so
> the `curl https://pypi.org/pypi/<package>/json` shape remains
> valid for *those* — it is dropped only for `mgf-*` (PKG-12).

---

## 2. The release flow

The order is a discipline. Each step builds on the previous
step's invariants.

For `mgf-*` packages the upload + verify targets are the devpi
index, governed by [`PACKAGE_DISTRIBUTION.md`](PACKAGE_DISTRIBUTION.md)
**PKG-04** (ordered flow) / **PKG-05** (`+simple/` verify):

```
1. Bump version             — pyproject.toml::project.version + __version__
                              (CI lints they match — rule AP-13)
2. Regenerate PUBLIC_API.json — uv run python tools/generate_public_api_json.py
3. Build                    — uv build  (wheel + sdist into dist/)
4. Test                     — uv run pytest -x -q   (must be green)
5. twine check              — uv run --with twine twine check dist/<version>*
6. Commit                   — git add … && git commit
7. Tag                      — git tag -a v<version> -m "<description>"
8. Push tag                 — git push --tags
9. Upload to devpi          — TWINE_USERNAME=magogi TWINE_PASSWORD="$DEVPI_PASSWORD" \
                                uv run --with twine twine upload \
                                --repository-url \
                                https://195.15.203.250.sslip.io/magogi/prod/ \
                                dist/<version>*
                              (auth per PKG-03 — see §4)
10. Verify on devpi         — curl -fsS \
                                https://195.15.203.250.sslip.io/magogi/prod/+simple/<package>/ \
                                | grep -q '<package>-<version>'
                              (version listed = live — PKG-05; anonymous)
```

A release that lands at step 9 but fails any earlier step is a
broken release in disguise. Run them in order, fix at the step
that broke, restart from there if you have to. Note that the
`magogi/prod` stage is **non-volatile** — like pypi.org's one-shot
upload, devpi rejects re-uploading a `name+version`, so a botched
step 9 is recovered only by a host-admin `devpi remove` (PKG-07).
Get the build right *before* uploading; once step 10 confirms the
version, treat it as immutable.

### 2.1 Version-bump checklist

Two locations, kept in sync by AP-13's CI lint:

```toml
# pyproject.toml
[project]
version = "0.X.Y"
```

```python
# src/<package>/__init__.py
__version__ = "0.X.Y"
```

The `tools/generate_public_api_json.py` script reads
`__version__` from the package — running it after the bump
embeds the new version into `PUBLIC_API.json`'s top-level
`__version__` field. If the field shows the OLD version after
regeneration, the bump didn't take. Re-check.

### 2.2 Pre-publish smoke (rule REL-08)

A green test suite proves the source tree works. It does not
prove the *built wheel* installs cleanly into a fresh venv —
packaging metadata bugs (missing `package_data`, broken
`requires-python`, wrong `[project.optional-dependencies]`) only
surface against the wheel itself.

```bash
# Cheap smoke that catches every packaging bug short of
# functional regression:
python -m venv /tmp/smoke && /tmp/smoke/bin/pip install \
  dist/<package>-<version>-py3-none-any.whl
/tmp/smoke/bin/python -c "import <package>; print(<package>.__version__)"
rm -rf /tmp/smoke
```

If this fails, the wheel is broken. Do NOT upload.

### 2.3 SBOM emission (rule SC-18)

Every release ships a CycloneDX SBOM (Software Bill of Materials)
alongside the wheel. The SBOM is a JSON manifest that names every
package in the runtime closure of `mgf-common[observability,vault]`
on the build platform, with version, license, and PURL for each.

**Why:** Supply-chain consumers (downstream SCA tools like
Dependency-Track, GitHub Advanced Security, OWASP
Dependency-Check) ingest SBOMs to map vulnerabilities, license
obligations, and transitive provenance back to a specific release.
Without an SBOM, a downstream consumer has to derive the closure
themselves — which drifts the moment a transitive dep updates.

**What ships:**

| Artifact | Location | Format |
|---|---|---|
| `mgf-common-<version>.cdx.json` | `dist/` (alongside `*.whl` and `*.tar.gz`) | CycloneDX JSON 1.x |

**How it is generated:** the `sbom` step in `.woodpecker.yml`
runs `cyclonedx-py environment` against a fresh
`pip install .[observability,vault]` environment. The `dev`
extra is deliberately excluded — `pytest` / `mypy` / `ruff` are
not runtime dependencies and would pollute the consumer-facing
SBOM. The `--output-reproducible` flag strips timestamps so
re-running on the same commit produces a byte-identical SBOM
(supports REL-02's wheel-content immutability).

**SCA-tool integration**: a downstream consumer can ingest the
SBOM into Dependency-Track via:

```bash
curl -X POST "${DT_URL}/api/v1/bom" \
    -H "X-Api-Key: ${DT_API_KEY}" \
    -F "project=${PROJECT_UUID}" \
    -F "bom=@mgf-common-<version>.cdx.json"
```

The devpi index does not host CycloneDX SBOMs as a
first-class artifact today (and neither did pypi.org — its
attestations are sigstore in-toto attestations, a different
shape). For now the SBOM is a CI artifact + a Codeberg
release-notes link (Codeberg remains the git forge); the
Woodpecker UI exposes the `dist/` directory as a downloadable
artifact on every green run. Index-hosted SBOM is a follow-up if
and when devpi's package API standardises one.

**Anti-patterns:**

- **Generating the SBOM from the dev environment** — pollutes
  the consumer-facing manifest with `pytest`, `mypy`, etc.
  Always install only runtime-relevant extras before SBOM
  generation.
- **Pinning cyclonedx-py at `latest`** — a rule-pack change in
  cyclonedx-py can shift component naming and break SCA-tool
  diffing. Pin the major version (`cyclonedx-bom>=7,<8` today);
  bump deliberately in a PR with a CI green/red review.
- **Skipping the SBOM on local builds** — `dist/` published from
  a local build (rather than CI) lacks the SBOM. Either publish
  via the CI tag flow, OR run the SBOM step manually before
  upload. The `sbom` step in `.woodpecker.yml` is the
  authoritative recipe; local maintainers can copy-paste the
  three commands to reproduce.

---

## 3. Tag discipline (rule REL-01)

Git tags are how the project answers "what was master at when
0.X.Y was cut?" three months later, when memory is gone and the
maintainer has switched contexts twice.

### 3.1 The tag format

Annotated tags only. Lightweight tags don't carry author /
date / message metadata and are easy to lose to garbage
collection. Use:

```bash
git tag -a v0.X.Y -m "v0.X.Y — <one-line summary>

Index: https://195.15.203.250.sslip.io/magogi/prod/<package>
SHA256 (wheel): <prefix>...
Uploaded: <UTC timestamp>

Closes FEEDBACK PAPER-NN."
git push --tags
```

The tag message embeds the published-artifact metadata so future
inspection (`git show v0.X.Y`) answers "what published?" without
hitting the index. (For `mgf-*` the canonical "where it lives"
URL is the devpi package page, not `pypi.org/project/<name>` —
PKG-10.)

### 3.2 What the tag enables

| Question | One-liner |
|---|---|
| What's in master that isn't in the latest published version? | `git diff v<latest>..HEAD --stat` |
| What did 0.X.Y change relative to its predecessor? | `git log --oneline v<prev>..v<latest>` |
| Was symbol X published in 0.X.Y or a later release? | `git log --all -- <path/to/X.py>` then check which tag's commit is an ancestor |
| Reproduce the build exactly | `git checkout v0.X.Y && uv build` |

Without tags, every one of these is a manual cross-reference
against the registry's upload times.

### 3.3 Retroactive tagging

If a release shipped without a tag (the case for early
`mgf-common` releases), tag retroactively against the commit
that built the wheel. Identify the commit by:

1. `curl -fsS -H 'Accept: application/json'
   https://195.15.203.250.sslip.io/magogi/prod/<package>`
   — the devpi JSON metadata lists the release + its files (for
   `mgf-*`; PKG-05; anonymous). (Third-party packages still use
   `curl https://pypi.org/pypi/<package>/<version>/json`.)
2. `git log --pretty='%H %cI'` — find the commit immediately
   *before* the upload time.
3. Verify that commit's tree contains the version bump in
   `pyproject.toml` and `__version__`.
4. `git tag -a v<version> <commit-sha> -m "..."`.

A retroactive tag is a small lie if the published wheel was
built from a commit that drifted further than expected — but in
practice, the bump-build-upload sequence happens within minutes,
so the tag is accurate.

---

## 4. Credential storage (rule REL-04)

> **Canonical auth model: [`PACKAGE_DISTRIBUTION.md`](PACKAGE_DISTRIBUTION.md) PKG-03.**
> For `mgf-*` the credential is the **`magogi` devpi account's
> upload password** (HTTP basic; there is no scoped-token model to
> mint) — *not* a pypi.org token. **Install/read is anonymous** —
> no consumer credential — and **there is no OIDC / Trusted
> Publisher on devpi.** This section is the REL-04 storage
> discipline; PKG-03 is the source of truth for the endpoints.

Index authentication is the place where shortcuts compound into
breaches. The password authorizes uploads to the `magogi/prod`
index; if it leaks, anyone with it can publish a malicious
0.X.(Y+1) that every consumer's `pip install -U` will pull.

### 4.1 Local: a `chmod 600` credential file (the minimum)

Keep the devpi upload password in a `chmod 600` file outside the
repo — a `~/.pypirc` stanza for the devpi index, or a file read
into `TWINE_PASSWORD` at call time:

```ini
# ~/.pypirc
[distutils]
  index-servers = devpi

[devpi]
  repository = https://195.15.203.250.sslip.io/magogi/prod/
  username = magogi
  password = <the devpi upload password>
```

Then:

```bash
chmod 600 ~/.pypirc
twine upload --repository devpi dist/*
```

No env vars on a shared shell, no command-line arguments, no shell
history exposure.

**One credential, not a scope matrix.** devpi authenticates uploads
with the single `magogi` account username + password (HTTP basic) —
there is no `write:`/`read:` split to mint. **Read is anonymous**, so
consumers need no credential at all; the password is the *publish*
credential and nothing else (PKG-03).

### 4.2 CI: a tag-scoped platform secret (the standing answer)

devpi has **no PyPI-style Trusted Publisher / OIDC** — the
password-in-CI-secret model is the standing answer, not a fallback
(PKG-03, PKG-09). Store the `magogi` upload password as the
Woodpecker **org** secret named `devpi_password`, exposed only on
tag events:

```yaml
# .woodpecker.yml (publish step — tag-only)
  - name: publish
    when: [ { event: tag, ref: refs/tags/v* } ]
    environment:
      TWINE_USERNAME: magogi
      TWINE_PASSWORD: { from_secret: devpi_password }   # org secret, tag-scoped
    commands:
      - twine upload --non-interactive
          --repository-url https://195.15.203.250.sslip.io/magogi/prod/ dist/*
```

No secret is ever echoed; no publish runs on non-tag events. The
exact YAML (plus the `verify-devpi` step) lives in
[`PACKAGE_DISTRIBUTION.md`](PACKAGE_DISTRIBUTION.md) §10.

### 4.3 The anti-patterns to avoid

| Anti-pattern | Why it's wrong | What to do instead |
|---|---|---|
| Password pasted in chat / Slack / email | Lands in transcripts, search indexes, model context | Store in a `chmod 600` file and reference by file |
| Password in environment variable on shared shell | History captures it; `ps` may surface it | `chmod 600` credential file (e.g. `~/.pypirc`) |
| Password committed to a `.env` file | Even a "private" repo is one accidental fork from public | Credential file outside the repo |
| Distributing the publish password for install/read | Read is anonymous — a shared read credential is needless attack surface | Install anonymously (`--index-url …/+simple/`); the password is publish-only |
| Re-using the password after a leak | The leaked password authenticates until rotated | Rotate immediately (host-admin), update the CI secret, never reuse |

If the password leaks (was pasted, sent over an unencrypted channel,
appeared in a model session), rotate the `magogi` account password
on the devpi host within minutes and update the `devpi_password`
org secret. (The 2026-06-02 pypi.org token revocation that blocked a
release is the incident that motivated the move off pypi.org — see
PACKAGE_DISTRIBUTION.md §1.)

---

## 5. Local `dist/` hygiene (rule REL-02)

The `dist/` directory is a build cache. Treat it like one.

### 5.1 The reuse-version footgun

```bash
$ ls dist/
mgf_common-0.12.0-py3-none-any.whl   # ← published to the index on 2026-05-01
mgf_common-0.12.0.tar.gz              # ← published to the index on 2026-05-01

# Maintainer merges another feature, runs uv build...
$ uv build
$ ls dist/
mgf_common-0.12.0-py3-none-any.whl   # ← OVERWRITTEN with a different artifact
                                     #   that contradicts the published 0.12.0
```

The local wheel now claims to be 0.12.0 but its contents differ
from the index's 0.12.0. Anyone (including future-you) who
treats the local wheel as authoritative will be wrong. (The
`magogi/prod` stage is **non-volatile** — devpi *rejects* a
re-upload of the same `name+version` outright; the only recovery
is a host-admin `devpi remove`, PKG-07, never to paper over this
footgun.)

### 5.2 The discipline

Before each release build, decide which version is being built
and bump first. After publish, treat the published-version
wheels in `dist/` as artifacts of a frozen release — don't
overwrite them.

For local development, build with a `+dev` suffix or to a
separate directory:

```bash
# Local-only dev build, doesn't collide with release artifacts:
uv build --out-dir dist-dev/

# Or use a +dev local version:
sed -i 's/^version = "0.X.Y"$/version = "0.X.Y+dev"/' pyproject.toml
uv build
```

Either way, the rule is: **once a version is on the index, the
matching `dist/` artifact is read-only.** If you need to inspect
"what shipped?", `pip download mgf-common==0.X.Y` from the
devpi index (§6 of PACKAGE_DISTRIBUTION.md) into a clean
directory pulls the authoritative artifact.

### 5.3 Cleaning between releases

```bash
rm -rf dist/   # before each release build
uv build
```

The `dist/` directory is regenerated by every build. Wiping it
before a release ensures only the artifacts of the current
version are present, eliminating the "which 0.X.Y is this?"
ambiguity.

---

## 6. Recovery

> For `mgf-*` on devpi the immutability + recovery model is
> [`PACKAGE_DISTRIBUTION.md`](PACKAGE_DISTRIBUTION.md) **PKG-07**:
> the `magogi/prod` stage is **non-volatile**, so a version is
> immutable **by the index** (like pypi.org, unlike the old
> Codeberg registry); recovery is a recorded host-admin
> `devpi remove`.

A published version is immutable **by the index** — once step 10 of
the flow (§2) confirms it and any consumer could have resolved it,
treat the contents as frozen. Both indices reject re-upload:

- **devpi (the `mgf-*` channel).** The `magogi/prod` stage is
  non-volatile: devpi rejects re-uploading an existing
  `name+version`. Botched-upload recovery is a **host-admin op on
  the devpi host** (temporarily toggle volatile → `devpi remove` →
  re-upload → un-toggle, PKG-07), reserved for a version **no
  consumer has resolved**, and MUST be recorded (CHANGELOG note or
  release log). It is **never** a silent content swap of a version
  others may hold.
- **pypi.org (third-party / historical).** Forever-immutable —
  rejects re-upload of the same version, no delete-and-republish.

What you should do, regardless of index:

- **Recover a fresh botch (devpi — host-admin).** If an upload
  landed wrong and **no consumer has resolved it yet**, a host
  admin removes the version on the devpi host (`devpi remove
  <name>==<version>` with the index toggled volatile, PKG-07) and
  re-runs the upload. Record it. This is **not** a routine token
  DELETE — it needs access to the devpi host.
- **Ship a new version.** The general fix for anything a consumer
  may already hold: bump the patch / minor / major appropriately,
  fix the issue, ship `0.X.(Y+1)`. The superseded version stays
  resolvable for explicit pins; new installs land on the new one.

### 6.1 What "broken release" warrants a pull / supersede

A "pull" means: on devpi, a host-admin `devpi remove` if no
consumer has resolved it yet (PKG-07) and otherwise ship a
superseding `0.X.(Y+1)`; on pypi.org (third-party / historical),
yank (`twine yank` or the web UI) since delete isn't available.

| Failure | Pull / supersede? |
|---|---|
| Broken wheel — won't install / won't import | Yes (security-class — silent breakage) |
| Wrong contents — published earlier than intended | Yes |
| Critical functional regression | Yes |
| Minor functional bug | Usually no — ship a fix in the next release |
| Test-only or doc-only mistake | No |

### 6.2 Credential leak

For `mgf-*` the leaked credential is the `magogi` devpi account's
upload password (PKG-03). The recovery sequence:

1. Rotate the `magogi` account password on the devpi host
   (host-admin op) — the old password stops authenticating uploads.
2. There is no scope to re-pick — the single account password *is*
   the publish credential; read stays anonymous.
3. Update the credential store with the new value — the local
   `chmod 600` file (§4.1) and the `devpi_password` Woodpecker
   org secret (§4.2).
4. Audit recent uploads against the devpi index (the anonymous
   `+simple/<name>/` listing / JSON metadata, §1.3) — verify every
   release is intentional. If you see one you didn't make, that's
   an active compromise; rotate the `magogi` password again, audit
   devpi-host access, and coordinate with the host admin
   (`MGF_INFRA.md`).

A leaked token in the model context window or transcript counts
as leaked. Revoke. The leaked token is the credential, not the
bytes-on-disk.

---

## 7. Consumer notification on closure (rule REL-09)

The CHANGELOG is the audit trail. The FEEDBACK retrospective is
the design record. The cutover guide (added v0.18.0; closes
PAPER-21) is the consumer-action artifact. Neither of the first
two *delivers* the news to the consumer who filed the entry;
REL-09 closes that gap by producing a notification paragraph
that points at the cutover guide.

### 7.0 Cutover-guide pattern (since v0.18.0)

Each release that closes ≥1 FEEDBACK entry produces an in-tree
markdown file at `docs/cutover/v<X.Y.Z>.md`. The cutover guide
contains one § per closed entry with:

- What landed (one-line summary).
- The consumer's migration steps (concrete commands, code
  diffs where relevant).
- What the rule does NOT require (drop-it-if-N/A clarifications).
- Caveats (things that *didn't* change, follow-ups).

REL-09 paragraphs become **pointers into the cutover guide**
("see v0.X.Y cutover guide §<entry>") rather than re-stating
the migration text. Single source of truth per release;
eliminates per-paragraph drift; composes cleanly with REL-07
("a release commit MUST close every FEEDBACK entry it ships")
— the cutover guide is the artifact REL-07 produces; REL-09
paragraphs are its delivery mechanism.

The first cutover guide is
[`docs/cutover/archive/v0.18.0.md`](https://codeberg.org/magogi-admin/mgf-common/src/branch/main/docs/cutover/archive/v0.18.0.md). Its shape
is the canonical pattern; subsequent releases follow it.

Approach A (in-tree markdown per release) is what's shipping.
Approach B (auto-generate from FEEDBACK.md frontmatter at
release time) is the eventual evolution; deferred until 2-3
manually-authored cutover guides surface the right shape.

### 7.1 What the rule asks for

After every status flip on a FEEDBACK entry —
`OPEN`/`ACCEPTED`/`SCHEDULED` → `✅ SHIPPED`, `OPEN` →
`DEFERRED` with sharpened rationale, or `OPEN` → `WONTFIX`
— produce a short paragraph in chat (or in whatever channel
relays back to the filer) that is **copy-pasteable into the
filer's next session**. The paragraph names:

  - The PAPER-NN (or VAULT-NN, ASPIRE-NN) identifier.
  - The new status.
  - The version it ships in (if SHIPPED) or the trigger that
    would unblock it (if DEFERRED).
  - The consumer's concrete next move — bump pin, run audit,
    rewrite imports, etc.
  - Any caveats: things that *didn't* change, things still
    deferred, follow-up entries to file if X surfaces.

This is *distinct from* the maintainer-facing recap that every
release commit already produces. Maintainer recap → "what
shipped"; consumer notification → "what you do about it".

### 7.2 Template (cutover-guide era — since v0.18.0)

When a cutover guide exists (any release that closes ≥1
entry), the notification paragraph is short and points into it:

```
PAPER-NN <STATUS> in mgf-common v<X.Y.Z>. See the cutover guide
§<PAPER-NN> for the migration steps:
https://codeberg.org/magogi-admin/mgf-common/src/branch/main/docs/cutover/v<X.Y.Z>.md#<paper-nn>

Pin bump: mgf-common = ">=0.X,<0.(X+1)".

Caveats: <one-line caveat or "none">.
```

For DEFERRED entries (no cutover-guide entry):

```
PAPER-NN DEFERRED. Reopen criteria: <trigger>. <reference to
gating evidence — strategic-review priority, second-consumer
signal, etc.>.
```

### 7.2.1 Template (pre-cutover-guide — for historical reference)

Before v0.18.0, REL-09 paragraphs carried the full migration
content inline. This shape is still valid for releases without
a cutover guide (e.g., a single-entry close where a separate
guide would be overkill):

```
PAPER-NN <STATUS> in mgf-common v<X.Y.Z>. <one-line summary of what changed>.

What to do <once 0.X.Y is on the devpi index>:

  1. <concrete step 1>
  2. <concrete step 2>
  3. <concrete step 3>

Notes:
  - <caveat 1>
  - <follow-up to file if N surfaces>
```

For DEFERRED entries, drop steps 1–3 and replace with:

```
What to do: <nothing actionable / continue with X / file evidence
if Y surfaces>. <reference to the gating evidence — a
strategic-review priority, a second-consumer signal, etc.>
```

### 7.3 Worked example — PAPER-08, closed in v0.17.0

```
PAPER-08 SHIPPED in mgf-common v0.17.0. The maintainer-side gap
that let your `_default_log_path` import slip through pre-PAPER-07
is now mechanical: `mgf-fed catch-up` grows a sixth audit row
that AST-walks src/ and flags any `from mgf.common._x import …`,
`from mgf.common.X import _y`, or `import mgf.common._x`.

What to do once 0.17.0 is on the devpi index:

  1. Bump your pin: `mgf-common>=0.17,<0.18` in pyproject.toml.
  2. Re-run `mgf-fed catch-up` in your project root. Expect a
     new "closed-box leaks (private mgf.common imports)" row.
  3. If clean (✅): you're done. If anything surfaces (⚠️): the
     warning names file:line and, when the symbol's known, the
     public replacement. Migrate by hand — `catch-up --apply`
     does NOT auto-rewrite imports.

Notes:
  - tests/ is exempt — `_internal_test_helpers/` style imports
    are legitimate and won't surface.
  - The `_PRIVATE_TO_PUBLIC` mapping in catch-up is currently
    `_default_log_path` and `_crash_dir`. If you find any other
    private imports without a public replacement listed, file a
    PAPER-NN — it means the underlying private symbol may need
    its own PAPER-07-shaped promotion.
```

### 7.4 Why this is a release-engineering rule, not a soft norm

The notification step rots the moment it depends on maintainer
memory. Codified as REL-09:

- It surfaces in the **§8 self-review checklist** so the
  maintainer cannot mark a release "done" with the close-loop
  open.
- AI agents acting as maintainers (the `mgf-common` /
  `mgf-apiprobe` / consumer-side relay shape) read this
  document and produce the notification automatically.
- The next maintainer (or future you) doesn't have to invent
  the practice from scratch.

### 7.5 What the rule does NOT require

- A specific delivery channel. Email, chat, GitHub PR
  comment, Codeberg issue note — whatever the filer used to
  submit. The rule is "produce the paragraph"; routing is
  context-dependent.
- A separate document or audit log. The CHANGELOG carries
  the migration content; FEEDBACK carries the design record;
  the notification is just the message that *unbundles them
  for the consumer*.
- Re-stating everything. The notification is short — 5–10
  lines. Anyone who wants the full picture follows the
  CHANGELOG link.

---

## 8. Self-review checklist

Walk this list before pushing a tag (= triggering the publish
flow).

**Release gate (mechanical — run first):** from the mgf-common repo,
`python scripts/gen_mgf_libraries.py --check` MUST exit **GREEN**
(**REL-13**) — it verifies every `mgf-*` → `mgf-*` dependency pin
accepts the current version and regenerates `MGF_LIBRARIES.md`. A RED
gate blocks the release until resolved (walk the lagging pin forward +
verify that dependent). Then walk the per-rule boxes below.

- [ ] **REL-01** — annotated `v<version>` tag points at the
  exact commit that built the wheel.
- [ ] **REL-02** — version was bumped before the build; no
  reused version string in `dist/`.
- [ ] **REL-03** — `PUBLIC_API.md` banner still present;
  `PUBLIC_API.json::__version__` matches the tag.
- [ ] **REL-04** — devpi upload password in a `chmod 600` file
  (local) or the `devpi_password` CI org secret (tag-scoped) — PKG-03.
  Not in chat, not in env vars on a shared shell, not in the
  workflow file. Install/read is anonymous. (No Trusted Publisher /
  OIDC on devpi.)
- [ ] **REL-05** — version isn't already on the index.
  (The `+simple/<name>/` listing — PKG-05; the non-volatile stage
  rejects a re-upload, so never a content swap of a resolved
  version, PKG-07.)
- [ ] **REL-06** — flow ran in order: bump → regen JSON →
  build → test → twine check → commit → tag → push → upload to
  devpi → `+simple/` verify (PKG-04/PKG-05).
- [ ] **REL-07** — every FEEDBACK entry shipped in this release
  has its status flipped to `✅ SHIPPED` with a retrospective.
- [ ] **REL-08** — the freshly-built wheel installed into an
  ephemeral venv and imported cleanly with `__version__`
  matching the tag.
- [ ] **CI extended gates green** — the publish step's
  `depends_on:` includes `vuln-scan` (SC-10, `pip-audit`),
  `secret-scan` (SC-01, `gitleaks`), and `sbom` (SC-18,
  `cyclonedx-py`). Each MUST be green for the tag to upload.
  For a local-machine release (when CI is unavailable),
  reproduce the recipes in `WORKFLOWS_PATTERNS.md` §5 + §5.1 +
  §5.2 before pushing.
- [ ] **SBOM exists in `dist/`** (rule SC-18) —
  `dist/<package>-<version>.cdx.json` is present, parses as
  CycloneDX 1.x JSON, names the project as the main component,
  and lists >0 components. CI's `sbom` step pins this; local
  builds run the same recipe — see §2.3.
- [ ] **REL-09** — for every closed FEEDBACK entry, a
  consumer-facing notification paragraph is drafted and ready
  to relay. CHANGELOG migration content + delivery message.
- [ ] **REL-10 / REL-11 / REL-12** — *if this release is a
  `mgf-common` MINOR that breaks any sibling's pin upper-bound,*
  the cohort-walk wave ran: Phase A parallel pin-walks → Phase B
  sequential ship, per
  [`cohort-release-wave.md`](../recipes/cohort-release-wave.md),
  with the pytest-denial fallback. (N/A for a release that breaks
  no pins.)
- [ ] **REL-13** — the **federation consistency gate is GREEN**:
  `python scripts/gen_mgf_libraries.py` confirms every dependent's
  pin accepts this release's version, any pin walked forward had its
  dependent's suite **verified** (no source regression), and
  `MGF_LIBRARIES.md` was regenerated + committed. Releasing one or
  more libraries leaves the WHOLE federation consistent + verified.
- [ ] **AG-13** — only the mgf-common supervisor made the library
  change; any consumer-requested change traces to a paper in the
  target library's `FEEDBACK.md` (no uncoordinated cross-project edits).

If any box is unchecked, fix before tagging — not after.

---

## Cross-references

- [`WORKFLOWS.md`](WORKFLOWS.md) — the *how* of release
  discipline. Every REL-* rule in this document is enforced
  mechanically by a corresponding WF-* rule + CI step. When CI
  is wired per WORKFLOWS, a maintainer literally cannot skip a
  REL-* rule by accident — the workflow is the only path.
- [`WORKFLOWS_PATTERNS.md`](WORKFLOWS_PATTERNS.md) — paste-ready
  Woodpecker snippets implementing the publish flow.
- [`API_DESIGN.md`](API_DESIGN.md) — the public-surface contract
  the release ships. AP-13 (`__version__` ↔ pyproject sync) and
  AP-10 (`PUBLIC_API.md` contract list) are the static-side
  invariants this doc operationalises.
- [`../public/PUBLIC_API.md`](https://codeberg.org/magogi-admin/mgf-common/src/branch/main/PUBLIC_API.md) — carries the
  banner from REL-03 and PAPER-12.
- [`../../FEEDBACK.md`](../../FEEDBACK.md) — PAPER-12 is the
  consumer-side incident this standard addresses.
- [`PACKAGE_DISTRIBUTION.md`](PACKAGE_DISTRIBUTION.md) — the
  `mgf-*` distribution channel (PKG-01..PKG-12): the devpi
  index endpoints (§3), the auth model (PKG-03, the REL-04
  CI path — password-in-secret, **not** Trusted Publisher/OIDC), the
  ordered publish flow (PKG-04) + `+simple/` verify (PKG-05), and
  non-volatile immutability / host-admin `devpi remove` recovery (PKG-07).
- [SemVer 0.x clause](https://semver.org/#spec-item-4) — the
  versioning regime this doc presumes.
