Document composite-action + submodule usage, config schemas, release ritual

This commit is contained in:
2026-07-15 15:48:10 -04:00
parent 711a46d3e8
commit 1b84f688b7
+202 -1
View File
@@ -1,3 +1,204 @@
# wp-release-ci # wp-release-ci
Generic release pipeline for a WordPress plugin or theme: validate, package, Gitea release, publish to a self-hosted update channel. Composite action + submodule. 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.