# GENERATED — do not hand-edit. Source: mgf-standard/templates/woodpecker/
# (base.yml + frag-*.yml), rendered by `mgf-fed` (mgf.fed._woodpecker).
# Regenerate after editing the template; never patch this file in place.
#
# Codeberg / Woodpecker CI for an MGF-family Python library. Implements the
# WF-* (WORKFLOWS), REL-* (RELEASING) and PKG-* (PACKAGE_DISTRIBUTION —
# publish to / resolve from the Codeberg PyPI registry, NOT pypi.org) rules
# from the mgf-standard corpus. The paste-source patterns are in
# WORKFLOWS_PATTERNS. Each step runs in a fresh python:X-slim container
# (rule WF-09 — pinned minor); the workspace is mounted across steps but
# each container does its own install so failure surfaces are per-step.

# The PR pipeline is the required check and runs for every pull request. The push
# pipeline runs ONLY on the default branch (post-merge verification) — NOT on feature
# branches, where it would run concurrently with the PR pipeline on the same commit:
# redundant, and the shared-workspace contention can kill a step (exit 126 —
# sooth-proven; PAPER-114, sooth-standard docs/recipes/ci-onboarding.md).
when:
  - event: push
    branch: main  # the fleet default branch (a repo on another default is a generator param, not a hand-edit — WF-13)
  - event: pull_request
  - event: tag
    ref: refs/tags/v*
  - event: manual
  - event: cron

# Full (non-partial) clone (WF-09 — image pinned). Woodpecker's default clone is
# a treeless partial clone (`git fetch --filter=tree:0`); the lazy promisor
# tree-fetch intermittently hits a Codeberg HTTP 504, failing the `clone` step
# before any real step runs (every later step then shows `skipped`). A single
# full fetch has no promisor round-trip, so it is not exposed to that 504.
clone:
  - name: clone
    image: woodpeckerci/plugin-git:2.9.2
    settings:
      partial: false

steps:
  # Lint — ruff + import-linter (DP-01 / WF-02) + OP-03 placeholder-URL guard.
  - name: lint
    image: python:3.12-slim
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet -e ".[dev,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/ || (sleep 15 && pip install --quiet -e ".[dev,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/)
      - ruff check src tests
      # OP-03 — project-metadata URLs MUST NOT be placeholders (the live-HEAD
      # resolve check is N/A: magogi-admin is private, an unauthenticated HEAD
      # 404s; the no-placeholder grep is the mechanizable part).
      - "! grep -rEn 'example[.]com|<org>|<your-|placeholder-|TODO-URL|FIXME-URL' pyproject.toml README.md"
      # Layering enforcement (rule DP-01) — see ``.importlinter`` / [tool.importlinter].
      - lint-imports

  # no-pypi-surface (PKG-12) — fail if any mgf-* publish/index/verify surface
  # targets pypi.org (the Codeberg cutover). Stdlib-only, no install. Allowlists
  # comments / "not pypi.org" negations / --extra-index-url third-party fallbacks.
  - name: no-pypi-surface
    image: python:3.12-slim
    commands:
      - python scripts/check_no_pypi_surface.py

  # commit-trailers (AG-03) — fail if any non-merge commit on this branch lacks
  # BOTH Signed-off-by and Co-Authored-By. The authoritative server-side half of
  # AG-03 (the .gitmessage template + prepare-commit-msg hook are the local half);
  # independent of any contributor's local config. The reference checker is
  # stdlib-only and fail-open on shallow-clone range quirks. git is apt-installed
  # because the python:slim image omits it; safe.directory clears git's
  # dubious-ownership check on the mounted workspace.
  - name: commit-trailers
    image: python:3.12-slim
    commands:
      - apt-get update -qq && apt-get install -y -qq --no-install-recommends git
      - git config --global --add safe.directory "$CI_WORKSPACE"
      - git fetch -q --depth=100 origin "${CI_COMMIT_TARGET_BRANCH:-main}" || true
      - python scripts/check_commit_trailers.py

  # Typecheck — mypy --strict over the package tree.
  - name: typecheck
    image: python:3.12-slim
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet -e ".[dev,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/ || (sleep 15 && pip install --quiet -e ".[dev,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/)
      - mypy src

  # Per-version test steps — explicit, NOT a step-level `matrix:` (Woodpecker
  # silently ignores that → ${PYTHON_VERSION} never expands → python:-slim).
  # Coverage gate on the 3.12 job only (WF-10) — re-running coverage on every
  # version wastes CI minutes and the result is identical across versions.
  - name: test-3.11
    image: python:3.11-slim
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet -e ".[dev,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/ || (sleep 15 && pip install --quiet -e ".[dev,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/)
      - pytest --tb=short -q

  - name: test-3.12
    image: python:3.12-slim
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet -e ".[dev,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/ || (sleep 15 && pip install --quiet -e ".[dev,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/)
      # Coverage GATE (WF-10, RA-08/TS-06): drive coverage from the process top
      # via `coverage run -m pytest` (pytest-cov starts collection after pytest's
      # own collection phase, by which point test modules have already imported
      # the package — module-level statements run on import are then counted as
      # missed, depressing the number ~10pp). `--fail-under` reds CI on any
      # regression below the declared floor; clear a drop by adding tests, never
      # by lowering the gate.
      - coverage run --source=src/mgf -m pytest --tb=short -q
      - coverage report --show-missing --fail-under=85

  - name: test-3.13
    image: python:3.13-slim
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet -e ".[dev,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/ || (sleep 15 && pip install --quiet -e ".[dev,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/)
      - pytest --tb=short -q

  # Dependency CVE 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,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/ || (sleep 15 && pip install --quiet pip-audit -e ".[dev,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/)
      # --strict exits non-zero on any finding so the PR cannot merge until the
      # finding is triaged (fix / justify / embargo). Retry once on transient
      # OSV-database fetch errors. Per-repo carve-outs (--skip-editable /
      # --ignore-vuln) are declared in [tool.mgf-fed] and rendered here.
      - pip-audit --skip-editable --desc --ignore-vuln CVE-2025-71176 --ignore-vuln GHSA-537c-gmf6-5ccf || (sleep 30 && pip-audit --skip-editable --desc --ignore-vuln CVE-2025-71176 --ignore-vuln GHSA-537c-gmf6-5ccf)

  # Secret scan (SH-05, rule SC-01) — gitleaks over the working tree + history,
  # with the project allowlist (.gitleaks.toml). Image pinned exactly (WF-09).
  - name: secret-scan
    image: zricethezav/gitleaks:v8.30.1
    commands:
      - gitleaks detect --source . --config .gitleaks.toml --no-banner --redact

  # app-doctor --standards (RA runtime gate / DOC-15) — audit the repo against
  # the curated AP/DP/TS/DOC MUST subset + the per-MUST conformance ledger. The
  # standing guard against doc-drift (stale pins/levels, ledger rot) and the L3
  # runtime gate that was universally missing fleet-wide (Item 1b §2.A.1).
  # Install the repo FIRST (so metadata-derived __version__ resolves for AP-13),
  # then mgf-fed[cli] (the tool; CI-time only — not a pyproject dep, so no
  # cornerstone circular dependency).
  - name: app-doctor
    image: python:3.12-slim
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet -e ".[dev,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/ || (sleep 15 && pip install --quiet -e ".[dev,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/)
      # The devpi index comes from $DEVPI_URL — a Woodpecker SERVER-level variable
      # (set once by founder/infra, live since 2026-07-04; value = the MGF_INFRA.md §1
      # endpoint https://…/magogi/prod/+simple/) — never a hardcoded IP in a generated
      # pipeline (PAPER-113; variable name coordinated with sooth-standard). Each
      # consuming step fails fast with the reason when the variable is unset.
      - '[ -n "$DEVPI_URL" ] || { echo "DEVPI_URL is unset -- set the Woodpecker server-level variable (MGF_INFRA.md §2; PAPER-113)"; exit 1; }'
      - pip install --quiet "mgf-fed[cli]" --index-url "$DEVPI_URL"
      - mgf-fed app-doctor --standards

  # fedres check (D-GEN freshness gate) — regenerate fed-resources/generated/ into
  # a temp dir and byte-compare against the committed tree; nonzero on ANY drift
  # (the regenerate→byte-identical discipline). Spliced in only when the repo
  # carries fed-resources/source/project.toml. The Class-P projectors read the
  # registry from the mgf.standard package data, so this runs in a single-repo
  # checkout (no sibling mgf-standard on disk needed); mgf-fedres ships `fedres`.
  - name: fedres-check
    image: python:3.12-slim
    commands:
      - pip install --quiet --upgrade pip
      # The index is $DEVPI_URL — the Woodpecker server-level variable (PAPER-113;
      # MGF_INFRA.md §2), never a hardcoded IP; the guard fails fast when unset.
      - '[ -n "$DEVPI_URL" ] || { echo "DEVPI_URL is unset -- set the Woodpecker server-level variable (MGF_INFRA.md §2; PAPER-113)"; exit 1; }'
      - pip install --quiet mgf-fedres "mgf-common[standards]" --index-url "$DEVPI_URL" || (sleep 15 && pip install --quiet mgf-fedres "mgf-common[standards]" --index-url "$DEVPI_URL")
      - fedres check

  # mgf-test-supervisor (TS-23/24/25/27) — drive the suite through the
  # federation supervisor (`mtest`, shipped by the `[supervisor]` extra) so CI
  # produces the structured JSONL run record (TS-24), exercises auto-quarantine
  # (TS-25), and writes a single-file failure bundle on any failure (TS-27) —
  # not the bare-pytest, exit-code-only path. `mtest` is the supervisor's own
  # console script (`mgf-fed test` is a thin frontend to the same entry point);
  # CI calls `mtest` directly so it needs no mgf-fed[cli] install (and the
  # cornerstone mgf-common avoids a circular dep on mgf-fed). 3.12 only (the
  # supervisor is stdlib, version-agnostic).
  - name: supervisor-self-check
    image: python:3.12-slim
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet -e ".[dev,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/ || (sleep 15 && pip install --quiet -e ".[dev,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/)
      - mtest --self-check

  # depends_on serializes the two supervisor steps: both `mtest` runs write the
  # shared workspace `.gates/` (guarded by `.gates/.lock`), so running them in
  # parallel corrupts it / fails the lock. Self-check first, then tier-0.
  - name: supervisor-tier-0
    image: python:3.12-slim
    depends_on: [supervisor-self-check]
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet -e ".[dev,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/ || (sleep 15 && pip install --quiet -e ".[dev,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/)
      - mtest --tier 0

  # build (WF-06) — verify pyproject + hatchling wheel target still produce a
  # valid sdist + wheel on every push; twine check validates the metadata the
  # registry would reject. The fresh wheel is what the tag-driven publish flow
  # below uploads (built in THIS run, not a stale local dist/).
  - name: build
    image: python:3.12-slim
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet build twine
      - python -m build
      - twine check dist/*.whl dist/*.tar.gz
      # The PEP 561 marker MUST ship in the wheel (AP-09). stdlib zipfile —
      # `unzip` is not in the slim image.
      - python -m zipfile -l dist/mgf_common-*.whl | grep py.typed

  # sbom (SC-18) — CycloneDX provenance for the published wheel, next to it in
  # dist/. Install the OPTIONAL consumer surface (NOT [dev] — dev tools are not
  # runtime deps and would pollute the SBOM). Reproducible (timestamps
  # stripped) per REL-02. cyclonedx-bom pinned to a major range (WF-09).
  - name: sbom
    image: python:3.12-slim
    depends_on: [build]
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet 'cyclonedx-bom>=7,<8'
      - pip install --quiet ".[observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/ || (sleep 15 && pip install --quiet ".[observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/)
      - cyclonedx-py environment --output-reproducible -o dist/mgf-common.cdx.json

  # compat-matrix (Phase G4) — second-consumer regression check. Installs the
  # in-repo library editable, then the in-repo example_app on top (its path
  # source already points at ..), and runs example_app's tests. A public-API
  # regression surfaces here BEFORE a release lands, not after. 3.12 only — the
  # version fan-out is covered by test-3.11/3.12/3.13.
  - name: compat-matrix-example-app
    image: python:3.12-slim
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet -e ".[dev,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/ || (sleep 15 && pip install --quiet -e ".[dev,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/)
      # Steered like every package-consuming install (PKG-06 single index; the
      # retry re-runs the SAME steered command, never a bare index-less one). The
      # index is $DEVPI_URL — the Woodpecker server-level variable (PAPER-113;
      # MGF_INFRA.md §2), never a hardcoded IP; the guard fails fast when unset.
      - '[ -n "$DEVPI_URL" ] || { echo "DEVPI_URL is unset -- set the Woodpecker server-level variable (MGF_INFRA.md §2; PAPER-113)"; exit 1; }'
      - pip install --quiet -e "./example_app[dev]" --index-url "$DEVPI_URL" || (sleep 15 && pip install --quiet -e "./example_app[dev]" --index-url "$DEVPI_URL")
      - cd example_app && pytest --tb=short -q

  # perf-nightly (LG-12 + RA-11 + PC-01 budgets) — runs only on the `cron`
  # event. The per-PR default suite excludes perf via `-m "not perf and not
  # slow"` in addopts; this is the nightly counterpart that exercises the
  # perf-marked tests so a budget regression fires instead of sliding through.
  # Schedule lives in the platform UI (Settings → Crons), not in this file.
  - name: perf-nightly
    image: python:3.12-slim
    when:
      event: cron
    commands:
      - pip install --quiet --upgrade pip
      - pip install --quiet -e ".[dev,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/ || (sleep 15 && pip install --quiet -e ".[dev,observability,vault]" --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/)
      - pytest -m perf -v --override-ini "addopts="

  # =======================================================================
  # PUBLISH FLOW — runs only on `v*` tag push (WF-03..08, PKG-04/05/09,
  # REL-06/08): tag-coherence → smoke → publish → verify-devpi. Index
  # side-effects only at `publish` (Phase-2 B2: the self-hosted devpi index,
  # not Codeberg). The tag-scoped Woodpecker secret `devpi_password` is the
  # standing answer (no OIDC / Trusted Publisher on the self-hosted index).
  # =======================================================================

  # tag-coherence (WF-05) — tag MUST match pyproject::version AND __version__.
  - name: tag-coherence
    image: python:3.12-slim
    when:
      event: tag
      ref: refs/tags/v*
    depends_on: [build]
    commands:
      - pip install --quiet -e . --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/ || (sleep 15 && pip install --quiet -e . --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/)
      - |
        TAG_VERSION="${CI_COMMIT_TAG#v}"
        PYPROJECT_VERSION="$(grep -E '^version = ' pyproject.toml | head -1 | cut -d'"' -f2)"
        PACKAGE_VERSION="$(python -c 'import mgf.common; print(mgf.common.__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 update pyproject.toml but not the package __init__.py? Per AP-13."
            exit 1
        fi
        echo "OK: tag $CI_COMMIT_TAG coherent with pyproject + __version__"

  # smoke (WF-07 · REL-08) — install the freshly-built wheel into an ephemeral
  # venv, import the package, assert __version__ matches the tag. Catches
  # packaging-metadata bugs the source-tree pytest can't see.
  - 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 --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/ dist/mgf_common-${CI_COMMIT_TAG#v}-py3-none-any.whl
      - |
        /tmp/smoke/bin/python -c "
        import mgf.common
        expected = '${CI_COMMIT_TAG#v}'
        actual = mgf.common.__version__
        assert actual == expected, f'wheel ships __version__={actual}, tag is v{expected}'
        print(f'OK: smoke import + version match ({actual})')
        "

  # publish (PKG-04/09 · WF-04) — twine upload to the self-hosted devpi index
  # (magogi/prod), tag-event-scoped. Phase-2 B2 cutover: releases now land on
  # devpi (the same index CI resolves from), NOT Codeberg/pypi.org. The devpi
  # upload password is a tag-scoped Woodpecker secret; user is the `magogi`
  # index account. Depends on every gate — if anything red, no upload.
  - name: publish
    image: python:3.12-slim
    when:
      event: tag
      ref: refs/tags/v*
    depends_on:
      - smoke
      - lint
      - typecheck
      - test-3.11
      - test-3.12
      - test-3.13
      - vuln-scan
      - secret-scan
      - sbom
      - compat-matrix-example-app
    environment:
      TWINE_USERNAME: magogi
      TWINE_PASSWORD:
        from_secret: devpi_password
    commands:
      - '[ -n "$DEVPI_URL" ] || { echo "DEVPI_URL is unset -- set the Woodpecker server-level variable (MGF_INFRA.md §2; PAPER-113)"; exit 1; }'
      - pip install --quiet --upgrade pip
      - pip install --quiet twine
      # Upload target = the index root: $DEVPI_URL minus its trailing `+simple/`
      # (twine posts to /magogi/prod/, pip resolves from /magogi/prod/+simple/).
      - twine upload --non-interactive --repository-url "${DEVPI_URL%%+simple*}" dist/mgf_common-${CI_COMMIT_TAG#v}*

  # verify-devpi (PKG-05 · WF-08) — a release is "live" only when devpi's simple
  # index serves this name+version (anonymous read; no public PyPI, no Codeberg).
  - name: verify-devpi
    image: python:3.12-slim
    when:
      event: tag
      ref: refs/tags/v*
    depends_on: [publish]
    commands:
      - '[ -n "$DEVPI_URL" ] || { echo "DEVPI_URL is unset -- set the Woodpecker server-level variable (MGF_INFRA.md §2; PAPER-113)"; exit 1; }'
      - |
        python -c "
        import urllib.request, sys
        v = '${CI_COMMIT_TAG#v}'
        url = '${DEVPI_URL%/}/mgf-common/'
        html = urllib.request.urlopen(url, timeout=30).read().decode('utf-8', 'replace')
        ok = v in html
        print(('OK' if ok else 'MISSING') + f': mgf-common {v} on devpi')
        sys.exit(0 if ok else 1)
        "

  # AP-16 B-lite ping (cornerstone only) — after a cornerstone release is verified
  # live on devpi, fan out to every mgf-common-consuming sibling's CI so its
  # compat-matrix runs against the just-published cornerstone. Surfaces a break
  # before a consumer pulls the untested combo — the forcing function behind the wide
  # `mgf-common>=<floor>,<1.0` pin (API_DESIGN §4.4). Tag-gated; the woodpecker_token
  # org secret authorises the triggers, WOODPECKER_URL is the CI server itself. A red
  # here is post-publish + advisory (the release already shipped): it means "some
  # siblings weren't pinged — re-run `mgf-fed ping-siblings`". Spliced only into the
  # cornerstone (mgf-common) pipeline.
  - name: ping-siblings
    image: python:3.12-slim
    when:
      event: tag
      ref: refs/tags/v*
    depends_on: [verify-devpi]
    environment:
      WOODPECKER_TOKEN:
        from_secret: woodpecker_token
    commands:
      # The index is $DEVPI_URL — the Woodpecker server-level variable (PAPER-113;
      # MGF_INFRA.md §2), never a hardcoded IP; the guard fails fast when unset.
      - '[ -n "$DEVPI_URL" ] || { echo "DEVPI_URL is unset -- set the Woodpecker server-level variable (MGF_INFRA.md §2; PAPER-113)"; exit 1; }'
      - pip install --quiet "mgf-fed[cli]" --index-url "$DEVPI_URL"
      - WOODPECKER_URL="$CI_SYSTEM_URL" mgf-fed ping-siblings
