# Running the CI/CD pipeline locally recipe

> **Audience:** maintainer (human or AI agent) who wants to run `.woodpecker.yml` on their own machine — to validate before pushing, to debug a red step, or to drive a release — without a Woodpecker server.
> **Tool:** [`scripts/ci-local.sh`](../../scripts/ci-local.sh), a thin wrapper over `woodpecker-cli exec`.
> **History:** Codified 2026-05-30 after the per-version test matrix was found to have never actually run on the server (a step-level `matrix:` Woodpecker silently ignores) — caught only by running the pipeline locally. Local execution is now the first line of CI defence.

---

## TL;DR

```bash
scripts/ci-local.sh ci                  # full validation pipeline (default)
scripts/ci-local.sh lint                # one step (shorthand for `step lint`)
scripts/ci-local.sh step test-3.12      # one step, explicit
scripts/ci-local.sh perf                # the nightly perf sweep
scripts/ci-local.sh release v0.43.0     # dry-run the release flow (NOTHING uploaded)
scripts/ci-local.sh release v0.43.0 --publish   # REAL release → uploads to PyPI
```

Each step runs in the **same Docker image** and runs the **same commands** the
Woodpecker server would, against your **local working tree** (uncommitted changes
included). No server, no remote, no push.

## Requirements

- `woodpecker-cli` ≥ 3.x on `PATH` (`~/.local/bin/woodpecker-cli`; install by
  downloading the `woodpecker-cli_linux_amd64.tar.gz` release asset).
- Docker running and reachable.
- `.venv` with `pyyaml` (used only to slice single steps / strip the publish step).

## How it works (and why it's safe)

`woodpecker-cli exec` evaluates each step's `when:` gate against a **simulated
event**, exactly like the server. The wrapper sets that event:

| Command | Simulated event | Steps that run |
|---|---|---|
| `ci` (default) | `push` | lint, typecheck, test-3.11/3.12/3.13, supervisor-self-check, supervisor-tier-0, build, compat-matrix-example-app, vuln-scan, secret-scan, sbom |
| `perf` | `cron` | the always-on steps **+** `perf-nightly` |
| `release vX.Y.Z` | `tag` (`refs/tags/vX.Y.Z`) | always-on steps **+** tag-coherence, smoke, build — **publish/verify-pypi are stripped** |
| `release vX.Y.Z --publish` | `tag` | the full tag flow **including** `publish` (twine upload) + `verify-pypi` |

**The publish safety property:** `publish` is gated on `event: tag` +
`ref: refs/tags/v*`. A `ci` run is a `push` event, so `publish` **physically
cannot run** — there is no way for the default pipeline to upload to PyPI. The
only path that uploads is the explicit `release … --publish`, which is the only
mode that ever reads the PyPI token.

## Driving a sibling's pipeline

Set `CI_LOCAL_REPO` to run another repo's `.woodpecker.yml` from here:

```bash
CI_LOCAL_REPO=/home/bassam/PycharmProjects/mgf-hrb scripts/ci-local.sh ci
CI_LOCAL_REPO=/home/bassam/PycharmProjects/mgf-vm  scripts/ci-local.sh lint
```

Only `mgf-hrb` and `mgf-vm` currently ship a `.woodpecker.yml` (the other
siblings have no CI). Both had the same step-level `matrix:` bug mgf-common had
— worth a `woodpecker-cli lint` sweep when adding CI to a sibling.

## Privileged steps (full capabilities)

Some steps exercise Linux capability / namespace ops — e.g. `mgf-hrb`'s Zig
privilege-separation tests need `CAP_SETPCAP` (for `PR_CAPBSET_DROP`), which the
default Docker cap set lacks. Set `CI_LOCAL_PRIVILEGED=1` to run every step with
`privileged: true` + `--repo-trusted-security` (full caps + host device access):

```bash
CI_LOCAL_PRIVILEGED=1 CI_LOCAL_REPO=…/mgf-hrb scripts/ci-local.sh step zig-build-and-test
```

This grants containers host-level access — use only on a repo you trust. And it
isn't a silver bullet: a residue of tests need an exact host cap set or real
hardware (USB) that no container provides (mgf-hrb's bounding-set + USB-contract
tests are genuinely bare-metal-only — privileged takes its Zig suite to 364/365,
not 365/365).

The `--local --repo-path` flags bind-mount the repo (including `.git`, so
`gitleaks` scans real history) into the workspace, instead of the server's
git-clone step.

## The release flow as a local tool

A real release, end to end, from a clean tree on `main`:

```bash
# 1. bump version in pyproject.toml + src/mgf/common/__init__.py, update CHANGELOG
# 2. commit, tag, push the tag (tag-coherence checks CI_COMMIT_TAG == pyproject)
git tag -a v0.43.0 -m "…" && git push origin v0.43.0

# 3. dry-run first — runs tag-coherence + smoke + the full test/build/scan set,
#    but strips publish + verify-pypi, so nothing is uploaded:
scripts/ci-local.sh release v0.43.0

# 4. when green, do the real upload:
scripts/ci-local.sh release v0.43.0 --publish
```

`--publish` resolves the PyPI token from `my_stuff/keys/pypi_tokens.txt`
(prefers the `Project mfg-common scope:` token, falls back to the all-projects
token), writes it to a `umask 077` temp secrets file, hands it to the `publish`
step via `from_secret: pypi_token`, and deletes it on exit. The token value is
never printed.

> Note: this is **not** a substitute for the cohort discipline in
> [cohort-release-wave](https://codeberg.org/magogi-admin/mgf-standard/src/branch/main/docs/recipes/cohort-release-wave.md)
> — a MINOR bump that trips sibling pins still needs the pin-walk.
> `ci-local.sh release` is the single-package mechanism for *running*
> the release pipeline.

## Common situations

- **A step is red on the server and you want to reproduce it:** run just that
  step — `scripts/ci-local.sh step <name>` — instead of waiting on a full run.
- **Editing `.woodpecker.yml`:** `woodpecker-cli lint .woodpecker.yml` first
  (catches step-level `matrix:` and other silently-ignored keys), then
  `scripts/ci-local.sh step <changed-step>`.
- **PyPI propagation lag:** right after a publish, PyPI's *simple* index (what
  `pip` reads) can lag the JSON API by ~1–3 min via the Fastly CDN; a fresh
  install may briefly resolve the previous version. Re-run after a minute.
- **Speed:** a full `ci` run installs deps fresh per step (no host venv reuse),
  so it takes minutes. For a fast inner loop, run individual steps; reserve the
  full `ci` run for pre-push / pre-release validation.
