# wp-release-ci A generic, project-agnostic release pipeline for a **WordPress plugin _or_ theme** hosted on Gitea. On a `vX.Y.Z` tag it validates the version, packages a distributable zip, cuts a Gitea release on the source repo, and publishes the zip plus a computed update manifest to a self-hosted **update channel** repo (the JSON a self-hosted updater polls). It ships two pieces: - **`release.sh`** — the pipeline. Runs from a checkout of your plugin/theme at the tagged commit. - **`build-manifest.py`** — builds the published `${slug}.json` manifest from your curated inputs (`.mu-manifest.yaml`, `readme.txt` sections, `CHANGELOG.md`). It is packaged as a **Gitea Actions composite action** (`action.yml`), and can also be vendored as a **git submodule**. --- ## Consuming it — composite action (recommended) Add a thin release workflow to your plugin/theme repo. The action assumes you have **already checked out the source repo with full history** (`fetch-depth: 0`). `.gitea/workflows/release.yml`: ```yaml name: Release on tag on: push: tags: - 'v*' jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # full history + the tag ref (git archive needs this) - uses: wordpress/wp-release-ci@v1 # PIN to a tag or SHA — never a moving branch with: channel_deploy_key: ${{ secrets.CHANNEL_DEPLOY_KEY }} ``` > **Pin the ref.** Reference the action by a release tag (`@v1`) or a commit SHA, not by > `@main`. A composite action runs shell from its own checkout inside your repo's job; pinning > is what stops an upstream change from silently altering what runs against your secrets. If your project bundles a Vite app (`app_build: true`), also add a `actions/setup-node@v4` step before the release step. ### Action inputs | Input | Required | Default | Maps to env | |----------------------|----------|----------------|---------------------| | `channel_deploy_key` | yes | — | `CHANNEL_DEPLOY_KEY`| | `channel_ssh_port` | no | `30009` | `CHANNEL_SSH_PORT` | | `channel_branch` | no | `main` | `CHANNEL_BRANCH` | | `channel_git_name` | no | `release bot` | `CHANNEL_GIT_NAME` | `GITHUB_REPOSITORY`, `GITHUB_REF_NAME`, `GITHUB_SERVER_URL`, and `GITHUB_TOKEN` are provided by the runner and consumed directly. --- ## Consuming it — git submodule (fallback) Vendor this repo into your plugin/theme (submodules are directory-level — the whole repo lands at one path, conventionally `.ci/`): ```bash git submodule add https://gitea.mulligan.casa/wordpress/wp-release-ci .ci git commit -m "Add wp-release-ci as .ci submodule" ``` Then check out submodules in your workflow and run `release.sh` directly: ```yaml - uses: actions/checkout@v4 with: fetch-depth: 0 submodules: true # required — pulls the .ci/ submodule contents - name: Release env: CHANNEL_DEPLOY_KEY: ${{ secrets.CHANNEL_DEPLOY_KEY }} CHANNEL_SSH_PORT: ${{ vars.CHANNEL_SSH_PORT }} CHANNEL_BRANCH: ${{ vars.CHANNEL_BRANCH }} CHANNEL_GIT_NAME: ${{ vars.CHANNEL_GIT_NAME }} run: bash .ci/release.sh ``` `release.sh` locates `build-manifest.py` next to itself, so this works from the submodule path unchanged. To update the vendored copy later: `git submodule update --remote .ci`. --- ## `.mu-release.yaml` (in your plugin/theme repo root) Per-project pipeline config. Read from the repo root; export-ignored from the shipped zip. | Key | Required | Default | Purpose | |-------------|----------|-----------------------|----------------------------------------------------------------------| | `channel` | **yes** | — (hard-fails if unset) | Target channel repo `owner/name` the manifest + zip publish to. | | `type` | no | `plugin` | `plugin` or `theme` — the master switch for validation & manifest. | | `main_file` | no | per `type` (below) | Override the file the `Version:` header is read from. | | `app_build` | no | `false` | Build `app/` (Vite) and gate on `assets/app` staleness before release.| | `sections` | no | `{ autogen: true }` | How modal sections are sourced (see `build-manifest.py`). | `main_file` defaults: `theme` ⟹ `style.css`, `plugin` ⟹ `${slug}.php` (slug = the repo name). There is **no default `channel`** — this is deliberate. A missing `channel:` prints a `::error::` and exits non-zero, so a misconfigured repo can never silently publish to the wrong place. **Theme example** (`aa/maineaa`, publishing to `aa/maineaa-updates`): ```yaml channel: aa/maineaa-updates type: theme ``` **Plugin example:** ```yaml channel: aa/maineaa-updates type: plugin # optional (default) app_build: true # only if the plugin bundles a Vite app in assets/app sections: autogen: true # default modal sections from readme.txt == Section == blocks strip_title: true # drop a leading H1 from parsed markdown ``` --- ## `.mu-manifest.yaml` (optional, repo root) Curated base fields merged into the published manifest. Computed/release-derived keys always win (`version`, `slug`, `download_url`, `last_updated`, `icons`, `screenshot_url`, `requires`, `tested`, `requires_php`); everything else here overrides. Section bodies default to `readme.txt`'s `== Section ==` blocks unless you override them here. ```yaml homepage: https://gitea.mulligan.casa/aa/maineaa author: Mike Mulligan author_homepage: https://gitea.mulligan.casa/mmulligan tags: - theme - events # Optional per-section overrides (inline HTML, or `@path.(md|html|txt)` includes): # sections: # description: "@docs/description.md" ``` **Icons vs screenshot.** A plugin's manifest gets an `icons` map built from `icons/icon-128x128.png` + `icons/icon-256x256.png` when present. A theme's manifest gets a single `screenshot_url` built from `screenshot.png` at the repo root when present. --- ## Required secrets / vars on the consuming repo - **`CHANNEL_DEPLOY_KEY`** (secret) — an SSH private key whose **public** half is registered as a **deploy key with WRITE access on the channel repo** (e.g. `aa/maineaa-updates`). This is what lets the pipeline push the new zip + manifest. Without it, the pipeline still cuts the Gitea release on the source repo but **skips** the channel publish. - Optionally `CHANNEL_SSH_PORT`, `CHANNEL_BRANCH`, `CHANNEL_GIT_NAME` (repo variables) if you need non-default values. --- ## The release ritual 1. Bump the version **everywhere** it must agree with the tag: - the `Version:` header — in `style.css` (theme) or your main plugin file (plugin); - for **plugins**: the `Stable tag:` in `readme.txt`, and (if you keep one) the `*_VERSION` PHP constant; - add the release entry to your changelog (`readme.txt` `== Changelog ==` and/or `CHANGELOG.md`). 2. Commit. 3. Tag and push: ```bash git tag v1.4.0 git push origin v1.4.0 ``` The pipeline hard-fails if the header version, `Stable tag:` (plugins), and tag disagree. For themes, the `readme.txt` `Stable tag:` / `License:` / `== Changelog ==` checks are skipped and the version comes from `style.css`'s `Version:` header. --- ## What the pipeline does, in order 1. **Validate** the `Version:` header (and, for plugins, `readme.txt` `Stable tag:` / `License:` / `== Changelog ==`) all match the tag. A `*_VERSION` const is checked when present. 2. **Optional Vite build** (`app_build: true`) with a staleness gate on `assets/app`. 3. **Package-hygiene scan** — refuse to ship stray dev/scratch files. 4. **Package** `${slug}-${version}.zip` via `git archive` (honours `.gitattributes export-ignore`). 5. **Cut a Gitea release** on the source repo and attach the zip (idempotent on re-run/re-tag). 6. **Publish** the zip + computed `${slug}.json` manifest to the channel repo over SSH, with retry + jitter for concurrent releases.