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.


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/repo in uses: resolves against github.com, not this instance — it must be the full https://gitea.mulligan.casa/…@ref form, 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: themestyle.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

  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:

    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.
S
Description
Generic release pipeline for a WordPress plugin or theme: validate, package, Gitea release, publish to a self-hosted update channel. Composite action + submodule.
Readme 53 KiB
Languages
Shell 57.4%
Python 42.6%