From 1b84f688b789ae0d1919de7a9df9730a44fb956a Mon Sep 17 00:00:00 2001 From: Mike Mulligan <2+mmulligan@noreply.gitea.mulligan.casa> Date: Wed, 15 Jul 2026 15:48:10 -0400 Subject: [PATCH] Document composite-action + submodule usage, config schemas, release ritual --- README.md | 203 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 202 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b4f1f74..558f583 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,204 @@ # 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. \ No newline at end of file +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.