# CI Workflow Patterns

> **Conformance:** L1 / L2 / L3 (see `README.md`)
> **Audience:** Maintainers setting up CI for an MGF-family project.
> **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).

> **Scope.** Paste-ready Woodpecker pipeline snippets that
> implement every rule in [`WORKFLOWS.md`](WORKFLOWS.md). Each
> pattern is annotated with the WF-* rule it satisfies and notes
> the per-project parameters you adjust (project name, version
> file path, coverage threshold, supported Python versions).
>
> When starting a new MGF-family library, the four-gate +
> publish recipe at the bottom of this document is the canonical
> starting point. Copy verbatim, adjust the marked parameters,
> commit.
>
> Adapting these snippets for **other CI platforms** (GitHub
> Actions, GitLab CI) is a translation exercise — the discipline
> is platform-agnostic; the syntax differs. The
> [§7 platform-translation table](#7-platform-translation-table)
> below maps Woodpecker concepts onto the major alternatives.

---

## How to use this document

1. **Read [`WORKFLOWS.md`](WORKFLOWS.md) first** for the *why*.
   This document is the *how* — it assumes the rules are accepted.
2. **Find the section matching your need:** four-gate baseline,
   build, publish, matrix testing, post-publish verification.
3. **Copy the snippet verbatim** into your project's
   `.woodpecker.yml` (or `.woodpecker/` directory for multi-pipeline
   projects).
4. **Adjust the marked parameters** — placeholders use the
   `<UPPERCASE>` convention (e.g., `<PACKAGE_NAME>`,
   `<COVERAGE_FLOOR>`).
5. **Verify against the WF-* rule** in the annotation at the top
   of each snippet — the rule is the contract; the snippet is
   one valid implementation.

---

## 1. The four-gate baseline (rules WF-01, WF-02, WF-10)

Every project starts here. Lint + typecheck + test + import-linter,
parallel by default, blocking on every push and PR.

### 1.1 Trigger block

```yaml
# .woodpecker.yml
when:
  - event: push
    branch: master           # adjust to your default branch
  - event: pull_request
  - event: tag
    ref: refs/tags/v*        # tag publishes (see §4)
  - event: manual            # for ad-hoc reruns from the UI
```

**Annotates:** WF-01 (every push + PR), WF-03 (tag-driven for
publish-capable projects).

### 1.2 The four gates

```yaml
steps:
  # ----- Gate 1: Lint (ruff) -----
  - name: lint
    image: python:3.12-slim         # WF-09: pinned minor
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet -e ".[dev]"
      - ruff check src tests
      - ruff format --check src tests
      - lint-imports                # rule WF-02: import-linter as gate

  # ----- Gate 2: Typecheck (mypy --strict) -----
  - name: typecheck
    image: python:3.12-slim
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet -e ".[dev,<EXTRAS_NEEDED_FOR_STUBS>]"
      - mypy --strict src/

  # ----- Gate 3: Test (pytest matrix; coverage on canonical version) -----
  - name: test
    image: python:${PYTHON_VERSION}-slim
    matrix:
      PYTHON_VERSION:
        - "3.11"            # adjust to your supported versions
        - "3.12"
        - "3.13"
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet -e ".[dev,<TEST_EXTRAS>]"
      - |
        if [ "${PYTHON_VERSION}" = "3.12" ]; then
          # WF-10: coverage on canonical version only — the test
          # suite is the same on every version; running coverage
          # three times is wasted CI minutes.
          coverage run --source=src/<PACKAGE_DIR> -m pytest --tb=short -q
          coverage report --show-missing --fail-under=<COVERAGE_FLOOR>
        else
          pytest --tb=short -q
        fi
```

**Annotates:** WF-02 (four gates), WF-09 (pinned versions),
WF-10 (parallel matrix).

**Per-project parameters:**
- `<EXTRAS_NEEDED_FOR_STUBS>` — extras that ship type stubs your
  source imports (e.g., `[observability]` for opentelemetry).
- `<TEST_EXTRAS>` — extras whose code paths your tests cover.
- `<PACKAGE_DIR>` — the directory under `src/` (e.g.,
  `mgf` for `src/mgf/`).
- `<COVERAGE_FLOOR>` — your project's threshold (mgf-common: 85;
  new projects: start at 80, raise as you stabilise).
- Python versions in the matrix — your `requires-python` minimum
  through the latest stable.

---

## 2. Build + packaging-regression check (rule WF-06)

For every project that publishes (or might one day publish), the
wheel + sdist build is a CI gate. A packaging regression that
breaks the wheel MUST fail loudly on PR, not silently after a
release ships.

```yaml
  # ----- Build: sdist + wheel + twine check -----
  - name: build
    image: python:3.12-slim
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet build twine
      - python -m build                       # → dist/<pkg>-<version>-*
      - twine check dist/*                    # METADATA validators (index-agnostic)
      # Sanity: the PEP 561 marker MUST ship in the wheel (rule AP-09)
      - unzip -l dist/<PACKAGE_DIST_NAME>-*.whl | grep py.typed
```

**Annotates:** WF-06 spirit (CI builds the artifact), and the
twine-check guards the index's accept-time METADATA validators
(the same PEP-standard checks whether the target is the devpi index
or public PyPI; `mgf-*` upload to devpi — PKG-01).

**Per-project parameters:**
- `<PACKAGE_DIST_NAME>` — the dashed dist name (e.g.,
  `mgf_common`; underscored per PEP 503 normalisation, as on any
  index).

---

## 3. Pre-publish smoke (rule WF-07)

The wheel that built and twine-checked might still ImportError
on a fresh consumer install. The smoke catches this.

```yaml
  # ----- Pre-publish smoke (runs in publish step; see §4) -----
  - name: smoke
    image: python:3.12-slim
    depends_on: [build]                       # WF-10: explicit dep
    commands:
      - python -m venv /tmp/smoke
      - /tmp/smoke/bin/pip install --no-cache-dir dist/<PACKAGE_DIST_NAME>-*.whl
      - |
        /tmp/smoke/bin/python -c "
        import <PACKAGE_IMPORT_NAME>
        expected = '${CI_COMMIT_TAG#v}'
        actual = <PACKAGE_IMPORT_NAME>.__version__
        assert actual == expected, f'wheel ships __version__={actual}, tag is v{expected}'
        print(f'OK: import + __version__ match ({actual})')
        "
      # If the project ships a console-script, smoke that too:
      - /tmp/smoke/bin/<CONSOLE_SCRIPT_NAME> --version
```

**Annotates:** WF-07 (smoke is mandatory before upload).

**Per-project parameters:**
- `<PACKAGE_DIST_NAME>` — dashed dist name.
- `<PACKAGE_IMPORT_NAME>` — Python import name (e.g.,
  `mgf.common` for the `mgf-common` PyPI package).
- `<CONSOLE_SCRIPT_NAME>` — only if `[project.scripts]` defines
  one; omit the line otherwise.

---

## 4. Tag-driven publish (rules WF-03, WF-04, WF-05, WF-08)

The full publish pipeline. Triggered by pushing a `v<X.Y.Z>` tag.

### 4.1 Pre-flight: tag-version coherence (rule WF-05)

```yaml
  # ----- Tag-coherence check: tag MUST match pyproject + __version__ -----
  - name: tag-coherence
    image: python:3.12-slim
    when:
      event: tag
      ref: refs/tags/v*
    commands:
      - pip install --quiet -e .
      - |
        TAG_VERSION="${CI_COMMIT_TAG#v}"
        PYPROJECT_VERSION="$(grep -E '^version = ' pyproject.toml | head -1 | cut -d'"' -f2)"
        PACKAGE_VERSION="$(python -c 'import <PACKAGE_IMPORT_NAME>; print(<PACKAGE_IMPORT_NAME>.__version__)')"
        echo "tag=$TAG_VERSION pyproject=$PYPROJECT_VERSION package=$PACKAGE_VERSION"
        if [ "$TAG_VERSION" != "$PYPROJECT_VERSION" ]; then
            echo "ERROR: tag $CI_COMMIT_TAG does not match pyproject.toml::version ($PYPROJECT_VERSION)"
            echo "Did you forget to bump the version before tagging? Per RELEASING.md REL-06."
            exit 1
        fi
        if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then
            echo "ERROR: tag $CI_COMMIT_TAG does not match __version__ ($PACKAGE_VERSION)"
            echo "Did you forget to update src/<PACKAGE_DIR>/__init__.py? Per AP-13."
            exit 1
        fi
        echo "OK: tag $CI_COMMIT_TAG matches pyproject + __version__"
```

**Annotates:** WF-05 (tag-version coherence), with explicit
error messages pointing at the relevant standards.

### 4.2 Upload + post-publish verify (rules WF-04, WF-08)

> **Distribution target (PKG-01/PKG-02).** `mgf-*` packages publish
> to and resolve from the **self-hosted devpi index** (`magogi/prod`
> on `195.15.203.250`), not pypi.org. See
> [`PACKAGE_DISTRIBUTION.md`](PACKAGE_DISTRIBUTION.md) §5 (the
> ordered publish ritual) and §10 (the Woodpecker publish + verify
> steps). The upload below targets the devpi upload endpoint and
> authenticates as the `magogi` account with the `devpi_password`
> org secret (PKG-03, PKG-09); **install/read is anonymous** and
> there is **no OIDC / Trusted Publisher** on devpi.

```yaml
  # ----- Upload to the self-hosted devpi index (PKG-01/PKG-02) -----
  - name: publish
    image: python:3.12-slim
    when:
      event: tag
      ref: refs/tags/v*
    depends_on: [tag-coherence, smoke]        # WF-10: gate ordering
    environment:
      TWINE_USERNAME: magogi                  # the devpi index account (literal)
      TWINE_PASSWORD:
        from_secret: devpi_password           # WF-04 / PKG-03: tag-scoped Woodpecker org secret
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet twine
      # PKG-02: the only upload endpoint — no hand-typed variants.
      - twine upload --non-interactive
          --repository-url https://195.15.203.250.sslip.io/magogi/prod/
          dist/<PACKAGE_DIST_NAME>-${CI_COMMIT_TAG#v}*

  # ----- Post-publish verify -----
  # The verify-devpi step (poll the devpi `+simple/` index until the
  # version is listed — anonymous, WF-08 / PKG-05) is identical to the
  # one in the canonical recipe — see §8 below, the single source of
  # truth for the full `.woodpecker.yml`. Copy the `verify-devpi`
  # step from there; it is not duplicated here (DOC-18).
```

> The full `verify-devpi` step lives in
> [§8 Full canonical recipe](#8-full-canonical-recipe-paste-ready)
> — the doc's single paste-ready source. This section explains the
> rules it satisfies; copy the step itself from §8. The devpi
> `+simple/` verify is specified in
> [`PACKAGE_DISTRIBUTION.md`](PACKAGE_DISTRIBUTION.md) §5 step 6 /
> §7 (PKG-05).

**Annotates:** WF-04 (credential from secret store), WF-08 (verify the
version is live on the index; PKG-05).

**Per-project parameters:**
- `devpi_password` — the Woodpecker **org** secret holding the
  `magogi` account upload password (PKG-03); it is set once at the
  org level and applies to every repo, so there is **no per-project
  secret wiring**. `TWINE_USERNAME` is the literal `magogi`. See §4.3.
- `<PACKAGE_DIST_NAME>` — dashed dist name, e.g., `mgf_common`.
- `<PACKAGE_IMPORT_NAME>` — import name, e.g., `mgf.common`.
- `<PACKAGE_DIR>` — directory under `src/`, e.g., `mgf`.

### 4.3 One-time setup: the `devpi_password` org secret

`mgf-*` publish authenticates as the `magogi` devpi account. The
upload password lives in a single Woodpecker **org** secret
`devpi_password` (org id 1), tag-event-scoped — it is configured
**once at the org level** and applies to every repo, so a new
project needs **no per-project publish secret**. **Install/read is
anonymous** — no read token to mint. See
[`PACKAGE_DISTRIBUTION.md`](PACKAGE_DISTRIBUTION.md) §4 (auth)
and §10 (the Woodpecker wiring) for the authoritative model.

0. **(Codeberg-hosted Woodpecker only — one-time per user account.)**
   Request CI access via the Woodpecker CI Access template at
   [`codeberg.org/Codeberg-e.V./requests/issues/new?template=Woodpecker-CI.yaml`](https://codeberg.org/Codeberg-e.V./requests/issues/new?template=Woodpecker-CI.yaml).
   Title MUST start with `[CI]` (approval-bot requirement). Pick
   `minimal` for Expected Resource Usage. Codeberg gates
   `ci.codeberg.org` per user account; without this every step
   below errors with "access denied". Approval is account-wide
   (one issue covers all your future projects); turnaround is
   usually 1–2 days. After approval, log in to `ci.codeberg.org`
   and activate the repo. See
   [`docs/release/ci.md`](https://codeberg.org/magogi-admin/mgf-common/src/branch/main/docs/release/ci.md)
   "I want to set up CI for a new project" Step 0 for
   field-by-field guidance.
1. **Org secret (org admin — one-time for the whole federation, NOT
   per project).** In Woodpecker, at the **organization** level →
   **Secrets** → **Add secret**:
   - Name: `devpi_password`, Value: the `magogi` account's devpi
     upload password.
   - **Events:** check `tag` only. Uncheck `push`, `pull_request`,
     `manual`. This binds the secret to tag-event runs only — a push
     to master that tries to read it gets nothing.
   - Because it is an **org** secret, every repo in the org inherits
     it; you do **not** repeat this per project. (`TWINE_USERNAME` is
     the literal `magogi` in the workflow, not a secret.)
2. **Install/read needs no secret.** CI install steps and consumers
   resolve anonymously from `…/magogi/prod/+simple/` — there is
   nothing to add for the read side.
3. Trigger a release with `git push --follow-tags`.

After this, the `from_secret: devpi_password` reference in the
workflow resolves at run time. The password never appears in the
workflow file.

---

## 5. Vulnerability scan (rule SC-10)

Per [`SECURITY_STANDARD.md`](SECURITY_STANDARD.md) **SC-10**:
dependencies pinned + scanned for CVEs.

```yaml
  # ----- Vulnerability scan (rule SC-10) -----
  - name: vuln-scan
    image: python:3.12-slim
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet pip-audit -e ".[dev,<RUNTIME_EXTRAS>]"
      # --strict exits non-zero on any finding so PRs cannot merge
      # until the finding is triaged: fix (bump dep), justify (add to
      # ignore-vulns with rationale), or embargo (with expiry date).
      # Retry once on transient OSV-database fetch errors.
      - pip-audit --strict --desc || (sleep 30 && pip-audit --strict --desc)
```

**Per-project parameters:**
- `<RUNTIME_EXTRAS>` — extras whose deps you want scanned
  (typically the same as your test extras + service-shape extras).

---

## 5.1 Secret scan (rule SC-01)

Per [`SECURITY_STANDARD.md`](SECURITY_STANDARD.md) **SC-01**: a
secret-scanner gates commits + history so a leaked token / key
fails CI red instead of landing in the published artifact.

```yaml
  # ----- Secret scan (rule SC-01) -----
  - name: secret-scan
    image: zricethezav/gitleaks:v8.30.1
    commands:
      - gitleaks detect --source . --config .gitleaks.toml --no-banner --redact --verbose
```

**Per-project parameters:**
- The image tag is the gitleaks release version. Exact pin per
  **WF-09** — the registry does NOT ship a rolling `v8` tag, so
  a moving target isn't an option. Bump via a deliberate PR
  when the rule-pack changes; the bundled rules occasionally
  tighten / loosen pattern matching, so a major-or-minor bump
  deserves a review of CI red/green.

**Required: `.gitleaks.toml` at repo root.** The default rule
pack flags canonical example tokens (`AKIAIOSFODNN7EXAMPLE` etc.)
that appear in redactor test fixtures and pedagogical examples.
A surgical `[allowlist] paths = [...]` is the right shape:

```toml
title = "<project> gitleaks config"

[extend]
useDefault = true

[allowlist]
description = "Files whose stated purpose is to embed secret-shaped strings."
paths = [
    '''tests/.*/test_redaction\.py''',
    '''tests/.*/test_redaction_value_patterns\.py''',
    '''tests/.*/test_crash_reporter\.py''',
    '''src/.*/observability/redaction\.py''',
    '''docs/standards/LOGGING\.md''',
    '''examples/.*/03_advanced\.py''',
]
```

Allowlist a whole FILE only when its stated purpose is to embed
secret-shaped strings (test fixtures, pattern literals,
pedagogical placeholders). Avoid line-level regex allowlists —
they rot when the file is reformatted.

**Release-gate wiring.** The publish step's `depends_on:` MUST
include `secret-scan` so a release tag cannot ship without it
green.

---

## 5.2 SBOM emission (rule SC-18)

Per [`SECURITY_STANDARD.md`](SECURITY_STANDARD.md) **SC-18**:
releases are reproducible from source, and the dependency
manifest is published alongside the artifact so downstream SCA
tooling (Dependency-Track, GitHub Advanced Security, OWASP
Dependency-Check) has a single authoritative input.

```yaml
  # ----- SBOM (rule SC-18) -----
  - name: sbom
    image: python:3.12-slim
    depends_on: [build]
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet 'cyclonedx-bom>=7,<8'
      # Install the OPTIONAL surface a consumer might install —
      # NOT [dev] (pytest/mypy/ruff are dev-only, would pollute
      # the consumer-facing SBOM).
      - pip install --quiet ".[<OPTIONAL_EXTRAS>]"
      - |
        VERSION="$(grep -E '^version = ' pyproject.toml | head -1 | cut -d'"' -f2)"
        SBOM_PATH="dist/<PACKAGE_NAME>-${VERSION}.cdx.json"
        mkdir -p dist
        cyclonedx-py environment \
            --pyproject pyproject.toml \
            --mc-type library \
            --of JSON \
            --output-reproducible \
            -o "${SBOM_PATH}" \
            "$(which python)"
        # Sanity: well-formed CycloneDX JSON naming this project.
        python - "${SBOM_PATH}" "${VERSION}" <<'PYEOF'
        import json, sys
        sbom_path, expected_version = sys.argv[1], sys.argv[2]
        d = json.load(open(sbom_path))
        assert d.get("bomFormat") == "CycloneDX"
        assert d.get("specVersion", "").startswith("1.")
        main = d.get("metadata", {}).get("component", {})
        assert main.get("name") == "<PACKAGE_NAME>"
        assert main.get("version") == expected_version
        assert len(d.get("components", [])) > 0
        print(f"OK: {sbom_path}")
        PYEOF
```

**Per-project parameters:**
- `<OPTIONAL_EXTRAS>` — the optional-deps surface a consumer
  might install (e.g., `observability,vault` for mgf-common).
  Do NOT include `dev`.
- `<PACKAGE_NAME>` — the canonical project name as it appears
  in `pyproject.toml`'s `[project] name = "..."`.

**Pin cyclonedx-bom to a major range** (`>=7,<8`) per WF-09. A
major-version bump in cyclonedx-py can shift component-naming
conventions, breaking diff-based SCA workflows downstream;
bump deliberately via a PR with a green/red review.

**`--output-reproducible`** strips timestamps so re-running the
SBOM step on the same commit produces a byte-identical file.
Supports **REL-02** (version → wheel-content is one-to-one;
SBOM follows the same property).

**Where the SBOM lives.** PyPI does NOT host CycloneDX SBOMs
(PyPI attestations are sigstore in-toto, a different shape).
For now the artifact ships via the CI run's `dist/` directory
(Woodpecker preserves it across steps; the run UI exposes it
as a downloadable). Consumers' SCA tooling pulls from the
release-notes link or the CI artifact directly.

**Release-gate wiring.** The publish step's `depends_on:` MUST
include `sbom` so a release tag cannot ship without a valid
SBOM emitted.

---

## 6. Compat matrix — second-consumer regression check

For libraries with at least one in-repo consumer (e.g.,
`mgf-common`'s `example_app/`, or any project shipping a
"downstream consumer" demo as smoke-test fixtures):

```yaml
  # ----- Compat matrix: install in-repo consumer + run its tests -----
  - name: compat-matrix-<CONSUMER_NAME>
    image: python:3.12-slim
    commands:
      - pip install --quiet --upgrade pip
      # Library installed editable from the in-repo source first.
      - pip install --quiet -e ".[dev,<EXTRAS>]"
      # Consumer installed editable on top — its pyproject path-source
      # already points at the in-repo library.
      - pip install --quiet -e "./<CONSUMER_DIR>[dev]"
      - cd <CONSUMER_DIR> && pytest --tb=short -q
```

**Per-project parameters:**
- `<CONSUMER_NAME>` — name suffix for the step (visible in
  Woodpecker UI).
- `<CONSUMER_DIR>` — directory holding the consumer
  (`example_app`, `examples/integration`, etc.).

---

## 7. Platform-translation table

The discipline is platform-agnostic; the syntax differs. Map
Woodpecker concepts onto your platform:

| Concept | Woodpecker | GitHub Actions | GitLab CI |
|---|---|---|---|
| Trigger filter | `when: event: tag` + `ref: refs/tags/v*` | `on: push: tags: ['v*']` | `rules: - if: $CI_COMMIT_TAG =~ /^v/` |
| Step image | `image: python:3.12-slim` | `runs-on: ubuntu-latest` + `uses: actions/setup-python@v5 with: python-version: '3.12'` | `image: python:3.12-slim` |
| Matrix | `matrix: PYTHON_VERSION: ["3.11", "3.12"]` | `strategy: matrix: python: ["3.11", "3.12"]` | `parallel: matrix: PYTHON_VERSION: ["3.11", "3.12"]` |
| Secret | `from_secret: devpi_password` | `secrets.DEVPI_PASSWORD` | `$DEVPI_PASSWORD` (masked variable) |
| Step dependency | `depends_on: [build]` | `needs: [build]` | `needs: [build]` |
| Tag variable | `${CI_COMMIT_TAG}` | `${{ github.ref_name }}` | `${CI_COMMIT_TAG}` |
| Publish auth | `magogi` devpi account password in a CI secret (PKG-03; **no OIDC / Trusted Publisher on devpi**) | OIDC available for pypi.org third-party publishing, but `mgf-*` publish to devpi → password-in-secret | same — `mgf-*` publish to devpi via password |

---

## 8. Full canonical recipe (paste-ready)

The minimal `.woodpecker.yml` for a publish-capable MGF library.
Copy verbatim; replace the `<PLACEHOLDER>` values; commit.

```yaml
# .woodpecker.yml — canonical CI for an MGF-family library that
# publishes to the self-hosted devpi index (magogi/prod), NOT pypi.org.
# Implements every rule in docs/standards/WORKFLOWS.md and the
# distribution contract in docs/standards/PACKAGE_DISTRIBUTION.md
# (PKG-01..PKG-12).
#
# One-time setup before the first release (PACKAGE_DISTRIBUTION.md
# §4/§10):
#   - Woodpecker: the `magogi` upload password is the tag-scoped
#     org secret `devpi_password` (org id 1) — set once at the org
#     level, inherited by every repo (PKG-03/PKG-09). No per-project
#     publish secret; `TWINE_USERNAME` is the literal `magogi`.
#   - Install/read is anonymous — no read token to configure.

when:
  - event: push
    branch: master                            # WF-01
  - event: pull_request                       # WF-01
  - event: tag
    ref: refs/tags/v*                         # WF-03
  - event: manual

steps:
  # ===== Four-gate baseline (WF-02) =====

  - name: lint
    image: python:3.12-slim                   # WF-09
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet -e ".[dev]"
      - ruff check src tests
      - ruff format --check src tests
      - lint-imports

  - name: typecheck
    image: python:3.12-slim
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet -e ".[dev,<EXTRAS_FOR_STUBS>]"
      - mypy --strict src/

  - name: test
    image: python:${PYTHON_VERSION}-slim
    matrix:
      PYTHON_VERSION: ["3.11", "3.12", "3.13"]
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet -e ".[dev,<TEST_EXTRAS>]"
      - |
        if [ "${PYTHON_VERSION}" = "3.12" ]; then
          coverage run --source=src/<PACKAGE_DIR> -m pytest --tb=short -q
          coverage report --show-missing --fail-under=<COVERAGE_FLOOR>
        else
          pytest --tb=short -q
        fi

  - name: vuln-scan
    image: python:3.12-slim
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet pip-audit -e ".[dev,<RUNTIME_EXTRAS>]"
      - pip-audit --strict --desc || (sleep 30 && pip-audit --strict --desc)

  # ===== Build (every push, gates packaging regressions) (WF-06) =====

  - name: build
    image: python:3.12-slim
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet build twine
      - python -m build
      - twine check dist/*
      - unzip -l dist/<PACKAGE_DIST_NAME>-*.whl | grep py.typed

  # ===== Tag-coherence check (WF-05) — only on tag push =====

  - name: tag-coherence
    image: python:3.12-slim
    when:
      event: tag
      ref: refs/tags/v*
    depends_on: [build]
    commands:
      - pip install --quiet -e .
      - |
        TAG_VERSION="${CI_COMMIT_TAG#v}"
        PYPROJECT_VERSION="$(grep -E '^version = ' pyproject.toml | head -1 | cut -d'"' -f2)"
        PACKAGE_VERSION="$(python -c 'import <PACKAGE_IMPORT_NAME>; print(<PACKAGE_IMPORT_NAME>.__version__)')"
        echo "tag=$TAG_VERSION pyproject=$PYPROJECT_VERSION package=$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
        echo "OK: tag $CI_COMMIT_TAG coherent with pyproject + __version__"

  # ===== Pre-publish smoke (WF-07) — only on tag push =====

  - name: smoke
    image: python:3.12-slim
    when:
      event: tag
      ref: refs/tags/v*
    depends_on: [tag-coherence]
    commands:
      - python -m venv /tmp/smoke
      - /tmp/smoke/bin/pip install --no-cache-dir dist/<PACKAGE_DIST_NAME>-${CI_COMMIT_TAG#v}-*.whl
      - |
        /tmp/smoke/bin/python -c "
        import <PACKAGE_IMPORT_NAME>
        expected = '${CI_COMMIT_TAG#v}'
        actual = <PACKAGE_IMPORT_NAME>.__version__
        assert actual == expected, f'wheel __version__={actual} vs tag v{expected}'
        print(f'OK: smoke import + version match ({actual})')
        "

  # ===== Publish to the self-hosted devpi index (WF-04 / PKG-01/02/09) — only on tag push =====

  - name: publish
    image: python:3.12-slim
    when:
      event: tag
      ref: refs/tags/v*
    depends_on: [smoke, lint, typecheck, test, vuln-scan]
    environment:
      TWINE_USERNAME: magogi                       # the devpi index account (literal)
      TWINE_PASSWORD:
        from_secret: devpi_password                # tag-scoped Woodpecker org secret (PKG-03)
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet twine
      # PKG-02: the canonical upload endpoint — no hand-typed variants.
      - twine upload --non-interactive
          --repository-url https://195.15.203.250.sslip.io/magogi/prod/
          dist/<PACKAGE_DIST_NAME>-${CI_COMMIT_TAG#v}*

  # ===== Post-publish verify (WF-08 / PKG-05) — only on tag push =====
  # Authoritative "is it live?" = the devpi `+simple/` index lists the
  # version, never pypi.org / Codeberg (PACKAGE_DISTRIBUTION.md §5 step 6
  # / §7). Anonymous read — no secret on the verify side.

  - name: verify-devpi
    image: python:3.12-slim
    when:
      event: tag
      ref: refs/tags/v*
    depends_on: [publish]
    commands:
      - |
        VERSION="${CI_COMMIT_TAG#v}"
        for attempt in 1 2 3 4 5 6; do
            # -L follows devpi's PEP 503 name-normalisation redirect; the
            # listing's anchors are the real wheel/sdist filenames.
            if curl -fsSL \
              "https://195.15.203.250.sslip.io/magogi/prod/+simple/<PACKAGE_DIST_NAME>/" \
              | grep -q "<PACKAGE_DIST_NAME>-${VERSION}"; then
                echo "OK: <PACKAGE_DIST_NAME> ${VERSION} live on the devpi index."
                exit 0
            fi
            echo "devpi hasn't surfaced ${VERSION} yet (attempt $attempt of 6)..."
            sleep 15
        done
        echo "ERROR: devpi did not surface ${VERSION} within 90s"
        exit 1
```

---

## 9. Maintenance + evolution

### 9.1 When to update this document

- A new pattern emerges in any MGF project's CI that's worth
  lifting (similar to `lift_checklist.md` for code).
- A WF-* rule changes — the patterns must follow.
- A new CI platform joins the supported list (add a §7
  translation row). Note: devpi has **no** PyPI-style Trusted
  Publisher / OIDC — the password-in-CI-secret model
  (`devpi_password`, PKG-03/PKG-09) is the standing
  answer, not a placeholder for a future OIDC pattern.

### 9.2 When to NOT add a pattern

- Project-specific one-offs (a hypervisor smoke test, a
  hardware-pin test, a domain-specific integration). These
  belong in the project's own `.woodpecker.yml`, not here.
- Patterns that work on one platform but not another. Either
  generalise (and add a §7 translation row) or leave out.

### 9.3 Cross-references

- [`WORKFLOWS.md`](WORKFLOWS.md) — the standard. WF-* rules
  this document implements.
- [`PACKAGE_DISTRIBUTION.md`](PACKAGE_DISTRIBUTION.md) — the
  `mgf-*` distribution contract (PKG-01..PKG-12): devpi
  index endpoints (§3), auth (§4), the publish ritual (§5),
  consumer config (§6), and the `+simple/` verify (§7). The publish +
  verify steps here are its Woodpecker realization (§10).
- [`RELEASING.md`](RELEASING.md) — REL-* rules around release
  semantics; the publish pipeline here is the mechanical
  enforcement.
- [`SECURITY_STANDARD.md`](SECURITY_STANDARD.md) — SC-10
  (dependency scanning) implemented in §5.
- [Woodpecker docs](https://woodpecker-ci.org/docs/intro) —
  platform reference.
