# CI Workflows

> **Conformance:** L0 / L1 / L2 (see `README.md`)
> **Audience:** Human engineers AND AI coding agents responsible for project CI configuration.

> **Scope.** Cross-project CI / continuous-integration standard for
> any project in the MGF family — libraries that publish to PyPI,
> services with deployment pipelines, internal tools with no
> publish step. The rules are written for [Woodpecker
> CI](https://woodpecker-ci.org/) on Codeberg / Forgejo (the MGF
> family's primary CI host) but apply to every CI platform: GitHub
> Actions, GitLab CI, Jenkins, Buildkite. The mechanics differ;
> the discipline doesn't.
>
> Filed in response to a maintainer-side incident on 2026-05-02:
> three releases were cut by hand from a developer machine,
> reusing wheel artifacts that drifted from the published PyPI
> versions and leaking project-wide PyPI tokens through chat.
> [`RELEASING.md`](RELEASING.md) is the *what* and *why* of a
> release; this document is the *how* — how CI enforces the
> release rules so a human (or agent) can't accidentally bypass
> them.

---

## Rules box (read this first)

| ID | Rule |
|---|---|
| **WF-01** | **Every project MUST have CI that runs on every push to a long-lived branch and every pull request.** No "ship from local laptop" paths. The CI run is the canonical proof that the code passes the project's gates; a developer machine's pytest run is not. |
| **WF-02** | **The four green gates (lint / typecheck / test / import-linter) MUST be CI-enforced** and MUST block merge on red. The gates are the contract; CI is the enforcement. Branch-protection rules on the platform side make the block automatic. |
| **WF-03** | **A library that publishes to PyPI MUST have a tag-driven publish workflow.** Pushing a `v<X.Y.Z>` tag is the trigger; pushes without a tag MUST NOT publish. The tag is the maintainer's signed declaration "this is the release point" (rule **REL-01**); CI consumes that declaration. |
| **WF-04** | **Publish credentials MUST be stored in the CI platform's secret store**, scoped per-project, and (where supported) restricted to tag-event runs only. Tokens in the workflow file, in environment variables passed at trigger time, or in chat are forbidden. Per **REL-04**: project-scoped tokens; PyPI Trusted Publisher (OIDC) when the platform supports it. |
| **WF-05** | **The publish workflow MUST verify tag-version coherence before uploading.** The `v<X.Y.Z>` tag name MUST match `pyproject.toml::project.version` AND `__version__`. Any mismatch aborts the run with a loud error. This catches the "I tagged before I bumped" footgun and the "I bumped after I tagged" footgun. |
| **WF-06** | **The wheel uploaded to PyPI MUST be the one built in the CI run that's about to publish.** Never re-use a wheel built earlier — in a prior CI run, on a developer machine, sitting in `dist/`. Per **REL-02** local `dist/` artifacts can drift from the version string they claim; the only safe wheel is one built fresh in the publish run. |
| **WF-07** | **Pre-publish smoke MUST install the freshly-built wheel into an ephemeral venv** and import the top-level package + `__version__`. A test suite passing against the source tree does not prove the wheel installs and imports cleanly — packaging-metadata bugs (missing `package_data`, broken `requires-python`, wrong extras) only surface against the wheel itself. Per **REL-08**. |
| **WF-08** | **Post-publish verification MUST confirm the artifact appeared on PyPI** with the expected version + SHA256 within a polling window. Failure to verify = treat as failed publish, not silent success. The PyPI upload step returning 0 is necessary but not sufficient — the index might 5xx, the CDN might not propagate, the package might be quarantined. |
| **WF-09** | **Workflow files MUST pin every external dependency to a specific version.** Python interpreter image (`python:3.12-slim`, not `python:slim`); plugin actions (`@v1.2.3`, not `@v1` or `@latest`); base images by digest where the platform supports it. Pinning eliminates "the build worked yesterday but breaks today" surprises. |
| **WF-10** | **Independent CI steps MUST run in parallel.** Sequential ordering is reserved for genuine dependencies (build before publish; smoke before upload; tag-coherence-check before build). The matrix axes (Python versions, optional extras) MUST fan out, never serialise. |
| **WF-11** | **A CI run MUST be reproducible from its inputs alone.** No environment variables outside what the workflow declares; no host-machine state; no implicit network resources beyond what's pinned. Re-running the same workflow against the same commit MUST produce the same outcome (modulo external-flake retries, which MUST be explicit). |
| **WF-12** | **Every CI step MUST surface its exit code, the exact command that ran, and enough log context** (last 50+ lines of stdout/stderr) in the platform UI on failure. Silent failures — green ticks for skipped or no-op steps, hidden errors swallowed by `|| true` — are forbidden. A red CI run MUST tell the on-call exactly what to look at. |

---

## 1. Mental model — CI as the contract enforcement layer

The standards in this folder declare what good engineering looks
like. CI is what makes the standards mechanical instead of
aspirational.

### 1.1 The asymmetry between human discipline and machine discipline

A human maintainer who reads the standards once a quarter and
"tries to follow them" will, on a long enough timeline, miss one.
Tired evening; "just this once" exception; a dependency upgrade
that surfaces a new violation no one was watching for. The
maintainer's intent doesn't fail; their attention does.

CI doesn't get tired. The four-gate baseline (rule WF-02) runs
the same way on every push, every PR, every release. A
maintainer who skips a gate locally still gets caught at PR time.
A maintainer who skips the publish smoke locally cannot even
trigger a publish — the workflow is the only path.

This is the load-bearing reason to push manual discipline INTO
the workflow file. Every rule that survives only if a human
remembers it is one bad day from being broken; every rule
encoded in CI survives until someone explicitly removes it.

### 1.2 The two populations of CI failures

Like API consumers (cf. [`API_DESIGN.md`](API_DESIGN.md) §1.1),
CI runs have two populations:

- **Visible failures** — the PR you're working on, the release
  you're cutting. You see the red tick and fix it.
- **Invisible failures** — the cron job that scans for new CVEs
  on your dependency tree, the matrix axis you don't routinely
  watch, the post-publish verification that flagged a CDN
  propagation issue at 3am. Easy to miss, easy to silently
  ignore, easy to disable.

The standards-grade discipline: every CI failure surfaces
loudly, and disabling a check requires a written justification
in the workflow file. Drift in the silent direction (more
exemptions, more `|| true`s, more suppressed gates) is how the
discipline rots.

---

## 2. The four-gate baseline (rule WF-02)

Every MGF project's CI runs four gates on every push and PR.
Red on any gate blocks merge. Order in the workflow file is
parallelism-first; the dependency graph is below.

### 2.1 The four gates

| Gate | Tool | What it catches |
|---|---|---|
| **Lint** | `ruff check src tests` | Style + correctness lints (unused imports, mutable defaults, broad `except`, etc.). Format-check via `ruff format --check src tests`. |
| **Typecheck** | `mypy --strict src/` | Type errors, missing annotations, unsafe `Any` propagation. The `--strict` flag is non-negotiable; consumers expect typed surfaces (rule **AP-09**). |
| **Test** | `pytest -q` (with coverage on the canonical Python version) | Unit + integration tests. Coverage gate (per project: 80% / 85% / 90%) blocks regression. |
| **Import-linter** | `lint-imports` | Layering invariants from `.importlinter` — closed-box rules, federation-leaf rules, no-cycle rules. The discipline that keeps the architecture from degrading. |

### 2.2 Parallelisation (rule WF-10)

The four gates have no dependencies on each other. They MUST
run in parallel — Woodpecker step graph, GitHub Actions matrix,
or equivalent. Sequential gates burn CI minutes for no benefit.

The Python-version matrix axis is independent of the gate axis:
on a 3-version matrix × 4 gates, you have 12 parallel jobs
(though typecheck + lint + import-linter only need to run on
the canonical version — they're version-agnostic). The
canonical pattern: gates × 1 version + test × N versions.

### 2.3 What "block merge on red" means concretely

Codeberg/Forgejo: branch-protection rule on `master` requires
the CI status check to be green before the merge button is
enabled. GitHub: same shape via "Require status checks to
pass". The rule is enforced by the platform, not by the
maintainer's good intentions.

A CI failure in a non-blocking branch (a feature branch) is the
maintainer's signal to fix before opening the PR. A CI failure
on the PR itself MUST be fixed before merge — no override
without recorded justification.

---

## 3. Build + smoke before publish (rules WF-06, WF-07)

The bridge between "tests passed" and "a wheel that consumers
can `pip install`" is wider than it looks. A green test suite
proves the source tree works; the wheel might still be broken.

### 3.1 What can break in the wheel that tests don't catch

- **Missing package data.** A YAML / JSON / standards doc that
  the source-tree test reads via a relative path is missing
  from the built wheel because `pyproject.toml`'s
  `package_data` / `force-include` rules don't cover it.
- **`requires-python` mismatch.** The source tree runs fine on
  3.13; the wheel claims `>=3.11` but uses a 3.13-only stdlib
  feature. Source-tree tests on 3.13 pass; consumers on 3.11
  ImportError.
- **Optional-extras misconfiguration.** `pip install
  mypackage[fastapi]` doesn't pull in the right deps because
  `[project.optional-dependencies]` has a typo.
- **Console-script entry-point broken.** The `[project.scripts]`
  entry references a function that no longer exists; `pip
  install` succeeds but `mypackage --version` ImportErrors.
- **`py.typed` marker missing.** Wheel ships without the PEP 561
  marker; consumers' `mypy --strict` can't read your annotations.

### 3.2 The pre-publish smoke recipe

```bash
# Build the wheel (must be the only wheel in dist/ for this version)
uv build

# Create a clean, ephemeral venv and install the wheel ONLY (no editable, no source tree)
python -m venv /tmp/smoke-venv
/tmp/smoke-venv/bin/pip install --no-cache-dir dist/<package>-<version>-*.whl

# Smoke: import + version check
/tmp/smoke-venv/bin/python -c "
import <package>
assert <package>.__version__ == '<version>', f'expected <version>, got {<package>.__version__}'
print(f'OK: {<package>.__version__}')
"

# Cleanup
rm -rf /tmp/smoke-venv
```

If the smoke fails, the wheel is broken — abort the publish.
This catches every packaging-metadata bug short of functional
regression.

For projects with a console-script entry point, add an
invocation smoke too:

```bash
/tmp/smoke-venv/bin/<package> --version
```

### 3.3 What pre-publish smoke is NOT

It is NOT a substitute for the test suite. It is a fast
sanity check that the artifact installs and imports. The test
suite proves the code works; the smoke proves the wheel ships.

---

## 4. Tag-driven publish (rule WF-03)

The canonical publish trigger is a `v<X.Y.Z>` tag pushed to the
remote. Pushes to master without a tag MUST NOT publish.

### 4.1 Why tag-driven

The tag is the maintainer's signal "this commit is the release
point" (rule **REL-01**). It's intentional, durable, and
inspectable (`git show v0.X.Y` answers "what did 0.X.Y
contain?"). Push-to-master triggers are wrong: a merged PR is
not a release intent.

The trigger is the tag arriving on the server, not the commit
the tag points at. A maintainer can push commits freely; the
release happens only when they push a tag.

### 4.2 The full publish flow (operationalises REL-06)

```
1. Tag-coherence check  — tag name v<X.Y.Z> matches
                          pyproject.toml::version AND __version__.
                          Mismatch aborts.
2. Build                — uv build (sdist + wheel, fresh in CI).
3. twine check          — metadata validates against PyPI's rules.
4. Pre-publish smoke    — install wheel into /tmp venv, import + assert
                          __version__.
5. Upload               — twine upload, TWINE_PASSWORD from secret store.
6. Post-publish verify  — poll pypi.org/pypi/<pkg>/<version>/json until
                          the artifact appears with expected SHA256;
                          fail if the polling window expires.
```

Each step's failure aborts the run. The `latest` PyPI pointer
flips only after step 5 succeeds; step 6 is the watchdog that
the upload actually landed.

### 4.3 Tag-version coherence (rule WF-05)

The check is mechanical. In the workflow:

```bash
TAG_VERSION="${CI_COMMIT_TAG#v}"   # strip leading 'v'
PYPROJECT_VERSION="$(grep -E '^version = ' pyproject.toml | cut -d'"' -f2)"
PACKAGE_VERSION="$(python -c 'import <package>; print(<package>.__version__)')"

if [ "$TAG_VERSION" != "$PYPROJECT_VERSION" ] || [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then
    echo "::error::Tag $CI_COMMIT_TAG does not match pyproject ($PYPROJECT_VERSION) or __version__ ($PACKAGE_VERSION)"
    exit 1
fi
```

This catches:
- Tagged before bumping the version files (tag = `v0.16.0`,
  pyproject still `0.15.0`).
- Bumped pyproject but not `__version__` (rule **AP-13** forbids
  this; the AP-13 sync test catches it locally; this catches
  it at CI time).
- Tagged the wrong commit (the maintainer ran `git tag v0.16.0
  HEAD~3` against a commit that didn't have the bump yet).

---

## 5. Token storage (rule WF-04)

PyPI authentication is where shortcuts compound into breaches.
A leaked publish token authorises an attacker to ship a malicious
patch release that every consumer's `pip install -U` will pull.

### 5.1 The right answer: PyPI Trusted Publisher (OIDC)

[PyPI Trusted Publishers](https://docs.pypi.org/trusted-publishers/)
authenticate the CI run directly via OIDC — no token stored
anywhere. The CI platform (GitHub, GitLab, Google, ActiveState
as of 2026-05) presents an OIDC identity that PyPI recognizes
as authorised for the project; the publish step needs no
secret.

GitHub Actions example:

```yaml
jobs:
  publish:
    permissions:
      id-token: write   # required for OIDC
    steps:
      - uses: pypa/gh-action-pypi-publish@release/v1
```

PyPI side: project → "Manage" → "Publishing" → add the GitHub
repo + workflow filename. After that, no token is needed.

### 5.2 The interim answer: project-scoped token in the CI secret store

When the CI platform isn't in PyPI's Trusted-Publisher
allowlist (Codeberg / Forgejo / Woodpecker, as of 2026-05),
the publish step uses a stored token.

**The discipline:**
- Token is **project-scoped** (limited to one PyPI project),
  not account-wide. Issued at
  [pypi.org/manage/account/token/](https://pypi.org/manage/account/token/).
- Token lives in the **CI platform's secret store**, never in
  the workflow file, never in environment variables passed at
  trigger time, never in chat.
- The secret is **restricted to tag events** where the platform
  supports it (Woodpecker: `events: [tag]` on the secret
  definition). A push to master cannot use the token even if
  the workflow tries.
- Token **rotation is part of the release-engineering rhythm**.
  Annual rotation minimum; immediate rotation on any leak signal
  (token appeared in a chat transcript, in a commit, in a
  screenshot — any of these counts as leaked).

### 5.3 Anti-patterns

| Anti-pattern | Why it's wrong | Fix |
|---|---|---|
| Token in workflow file | Anyone with read access to the repo can publish | Move to platform secret store |
| Token passed via env var at workflow trigger | Lands in CI logs; visible to anyone with run-history access | Use `from_secret` / equivalent |
| Account-wide token | One project's compromise affects every project on the account | Project-scoped token |
| Token shared across maintainers via Slack / email / chat | Auditability gone; the leaked-token blast radius includes everyone in the channel | Each maintainer issues their own token via PyPI UI |
| Token reused after a leak | The leaked token authenticates forever until revoked | Revoke immediately; issue fresh; never reuse |

---

## 6. Matrix testing

Two matrix axes are in scope for most projects: **Python
versions** and **optional extras**.

### 6.1 Python-version matrix

A project that declares `requires-python = ">=3.11"` MUST test
on every supported version. A regression that only manifests on
3.11 (you used a 3.12-only stdlib feature) MUST be caught at CI
time, not by a downstream consumer's `pip install` failing.

The canonical version (the one that runs the coverage gate +
the gates that aren't version-sensitive — lint, typecheck) is
the project's lead version: typically the second-newest stable
Python at any moment.

### 6.2 Optional-extras matrix

A project that ships `[project.optional-dependencies]` MUST
test each extra in isolation AND in combination with the others
that consumers commonly request together. The base install
(no extras) MUST also be tested — a consumer who runs `pip
install <package>` and gets an `ImportError` because the
default code path imported an opt-in dep is the worst-case
break.

For `mgf-common` specifically: base + `[observability]` + each
service-shape extra (`[fastapi]`, `[db]`, `[alembic]`,
`[django]`, `[http]`) + `[dev]` for the test runners.

### 6.3 Matrix size and CI minutes

Matrix size is unbounded if you're not careful; 3 versions × 8
extra combinations × 4 gates = 96 jobs. Trim by:

- Running the typecheck / lint / import-linter gates on the
  canonical version only (they're version-agnostic).
- Running the optional-extras matrix on the canonical version
  only (they're extras-related, not version-related).
- Running the Python-version matrix with the *base* extras
  (not all combinations).

Result: lean matrix that catches the failure modes that matter
without burning a CI hour per push.

---

## 7. Reproducibility + pinning (rules WF-09, WF-11)

A CI run that produces different results on different days is
worse than no CI run — it teaches the team to ignore failures.

### 7.1 What MUST be pinned

| Component | How to pin |
|---|---|
| Python interpreter | `python:3.12.7-slim` (specific patch), not `python:3.12-slim` (rolling minor) when reproducibility-critical. Most projects accept rolling-minor for the security-update benefits. |
| Plugin actions | `pypa/gh-action-pypi-publish@release/v1.8.10`, not `@v1` or `@latest`. |
| Base images (where supported) | By digest: `python:3.12-slim@sha256:abc123...`. Enforces byte-identical environments. |
| Dependencies | Lockfile (`uv.lock`, `poetry.lock`, `pip-compile` output) checked into the repo; CI installs from lock, not from `pyproject.toml` directly. |

### 7.2 What CANNOT be pinned

- **Time.** Tests that depend on `datetime.now()` MUST mock the
  clock or use frozen time (`freezegun`).
- **Network state.** Tests that hit external APIs MUST mock the
  network or use VCR-style cassettes.
- **CI-platform behavior.** The platform itself updates;
  workflow files survive across platform updates only if they
  use the documented stable interfaces.

### 7.3 The "works on my machine" anti-pattern

Every CI failure that "doesn't reproduce locally" is a signal
that the project's reproducibility is leaking. The right
response is not "rerun the failed job until green" — it's
"identify what's different and pin it". The fix is in the
workflow file, the lockfile, or the test code.

---

## 8. Failure modes + recovery

CI runs fail. The standard is about how loudly they fail and
how cleanly the team recovers.

### 8.1 The publish-step failure matrix

| Where it failed | What it means | Recovery |
|---|---|---|
| Tag-coherence check | Tag points at a commit that doesn't have the version bump (or vice versa) | Delete the tag, fix the version files, re-tag, re-push. The publish never happened — no PyPI side-effects. |
| Build | Packaging metadata broken (typed extras, py.typed missing, hatchling config) | Fix `pyproject.toml`; re-run. The publish never happened. |
| `twine check` | Metadata won't pass PyPI's validators | Fix the metadata; re-run. |
| Pre-publish smoke | The built wheel doesn't install or import cleanly | Fix the packaging; re-run. The publish never happened. |
| `twine upload` | Network error, PyPI 5xx, or — rare — token rejected | Re-run. PyPI is idempotent on the same artifact bytes; if the upload partially completed and PyPI accepted it, the re-run will fail with "version already exists" and you treat it as success. |
| Post-publish verify | Upload succeeded but PyPI didn't surface the artifact in the polling window | Wait + retry once. Persistent failure = PyPI incident; the artifact may be stuck in a quarantine. Contact PyPI support. |

### 8.2 The cardinal recovery rule: never re-upload a version

PyPI does not allow re-upload of an existing version. If the
artifact you uploaded is wrong (broken wheel, leaked secret in
the source distribution, wrong contents), the recovery is:

1. **Yank** the bad version (PyPI keeps it for explicit pins
   but hides from default `pip install -U`).
2. **Bump** to the next patch / minor (`0.X.(Y+1)` or
   `0.(X+1).0`).
3. **Ship** the fixed version.

There is no path that overwrites the published bytes. Per
**REL-05**.

### 8.3 The CHANGELOG-of-CI

When a CI step changes meaningfully (new gate added, gate
removed, threshold tightened), the workflow file MUST carry an
inline comment with the reason and the date. Future-you (or
your replacement) reading the workflow MUST be able to answer
"why is this step here?" without git-blame archaeology.

Example, from `mgf-common`'s `.woodpecker.yml`:

```yaml
# The 80% floor — versus VManager's 85% — reflects that mgf.common
# has a higher proportion of conditional sink code (Sentry, OTLP,
# syslog, journald, HTTP webhook) that can only be meaningfully
# exercised against real backends. ...
```

The comment carries the rationale; the value carries the rule.

---

## 9. Mechanical-enforcement matrix

| Rule | Enforcement mechanism | Locus |
|---|---|---|
| WF-01 (CI on every push/PR) | `when: [event: push, event: pull_request]` in the workflow file; branch-protection rule on the platform side | `.woodpecker.yml` + Codeberg/Forgejo settings |
| WF-02 (four gates block merge) | Branch-protection rule requires the CI status check; workflow file enforces the four steps | Platform settings + `.woodpecker.yml` |
| WF-03 (tag-driven publish) | `when: event: tag` on the publish step; ref filter `refs/tags/v*` | `.woodpecker.yml` |
| WF-04 (token in secret store) | Token never appears in workflow file; `from_secret` references; secret restricted to tag events | Platform secret store + `.woodpecker.yml` |
| WF-05 (tag-version coherence) | Pre-build script in the workflow that compares tag to pyproject + `__version__`; aborts on mismatch | `.woodpecker.yml` |
| WF-06 (CI-built wheel only) | Wheel built in the publish run's own `build` step; the upload step references `dist/<pkg>-${CI_COMMIT_TAG#v}*` from this run only | `.woodpecker.yml` |
| WF-07 (pre-publish smoke) | Smoke step between build and upload; ephemeral venv; aborts on import failure | `.woodpecker.yml` |
| WF-08 (post-publish verify) | Verify step after upload; polls PyPI JSON API; aborts on missing artifact | `.woodpecker.yml` |
| WF-09 (pinned versions) | Code review of workflow file; enforced by the absence of `latest` / `*` / unversioned image refs | Code review |
| WF-10 (parallel by default) | Workflow-graph design; sequential `depends_on` only where necessary | `.woodpecker.yml` |
| WF-11 (reproducibility) | Lockfile checked in; CI installs from lock; clock + network mocked in tests | Code review + test design |
| WF-12 (no silent failures) | Code review of workflow file; absence of `|| true`; explicit retry loops with logged context | Code review |

---

## 10. Cross-references

- [`RELEASING.md`](RELEASING.md) — the *what* and *why* of a
  release. This doc operationalises **REL-01** (tag discipline),
  **REL-04** (token storage), **REL-05** (release immutability),
  **REL-06** (release flow ordering), **REL-08** (pre-publish
  smoke) into mechanical CI steps.
- [`API_DESIGN.md`](API_DESIGN.md) — **AP-13** (`__version__` ↔
  `pyproject.toml::version` sync) is the static-side invariant
  this doc enforces at CI time via WF-05.
- [`SECURITY_STANDARD.md`](SECURITY_STANDARD.md) — **SC-10**
  (dependencies pinned + scanned in CI) maps directly onto a
  CI step. **SC-12** (vulnerability-reporting policy) names the
  contact path used when a CI run surfaces a CVE.
- [`TESTING.md`](TESTING.md) — defines the test suite that the
  WF-02 test gate runs. The test gate's coverage threshold is
  set per-project per the **TS-** rules.
- [`WORKFLOWS_PATTERNS.md`](WORKFLOWS_PATTERNS.md) — paste-ready
  Woodpecker snippets implementing every rule in this document.
  When starting a new project, copy from there; when designing
  a new pattern, file it back here.
- [`../internal/CI_STARTHERE.md`](../internal/CI_STARTHERE.md) —
  developer-facing runbook: cut a release, set up CI for a new
  project, diagnose a red run. Action-oriented; this standard
  is the rationale, that doc is the recipe.

---

## 11. Self-review checklist

Before merging a workflow-file change, walk this list:

- [ ] **WF-01** — every long-lived branch (`master`, release
  branches) is in the trigger list.
- [ ] **WF-02** — the four gates are present and parallel.
- [ ] **WF-03** — if this project publishes, the publish step is
  tag-gated (`when: event: tag` AND `ref: refs/tags/v*`).
- [ ] **WF-04** — no tokens / passwords / API keys appear in the
  workflow file. Every secret is `from_secret` (or platform
  equivalent).
- [ ] **WF-05** — the publish step runs the tag-coherence check
  before the build.
- [ ] **WF-06** — the wheel uploaded is the one built in this
  run. Build step is in the publish workflow, not a separate run.
- [ ] **WF-07** — pre-publish smoke installs the wheel into an
  ephemeral venv and asserts `__version__`.
- [ ] **WF-08** — post-publish verify polls PyPI for the artifact.
- [ ] **WF-09** — every external dep is pinned (image tag, action
  version, lockfile reference).
- [ ] **WF-10** — independent steps run in parallel; serial
  dependencies are explicit.
- [ ] **WF-11** — no host-machine state, no implicit network
  resources.
- [ ] **WF-12** — every step's failure mode is loud; no `|| true`
  without a comment justifying it.
