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}.jsonmanifest from your curated inputs (.mu-manifest.yaml,readme.txtsections,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:
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: https://gitea.mulligan.casa/wordpress/wp-release-ci@v1 # full Gitea URL; PIN the ref
with:
channel_deploy_key: ${{ secrets.CHANNEL_DEPLOY_KEY }}
Use the full Gitea URL. On Gitea Actions a bare
owner/repoinuses:resolves against github.com, not this instance — it must be the fullhttps://gitea.mulligan.casa/…@refform, or the runner tries to check the action out from GitHub and the job fails.Pin the ref to a release tag (
@v1) or commit SHA, never@main. A composite action runs shell from its own checkout inside your repo's job; pinning 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/):
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:
- 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):
channel: aa/maineaa-updates
type: theme
Plugin example:
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.
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. It is required — the publish step hard-fails if it's unset (channel:is a required target, so reaching it is required too).- Optionally
CHANNEL_SSH_PORT,CHANNEL_BRANCH,CHANNEL_GIT_NAME(repo variables) if you need non-default values.
The release ritual
-
Bump the version everywhere it must agree with the tag:
- the
Version:header — instyle.css(theme) or your main plugin file (plugin); - for plugins: the
Stable tag:inreadme.txt, and (if you keep one) the*_VERSIONPHP constant; - add the release entry to your changelog (
readme.txt== Changelog ==and/orCHANGELOG.md).
- the
-
Commit.
-
Tag and push:
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
- Validate the
Version:header (and, for plugins,readme.txtStable tag:/License:/== Changelog ==) all match the tag. A*_VERSIONconst is checked when present. - Optional Vite build (
app_build: true) with a staleness gate onassets/app. - Package-hygiene scan — refuse to ship stray dev/scratch files.
- Package
${slug}-${version}.zipviagit archive(honours.gitattributes export-ignore). - Cut a Gitea release on the source repo and attach the zip (idempotent on re-run/re-tag).
- Publish the zip + computed
${slug}.jsonmanifest to the channel repo over SSH, with retry + jitter for concurrent releases.