diff --git a/release.sh b/release.sh new file mode 100644 index 0000000..4f03424 --- /dev/null +++ b/release.sh @@ -0,0 +1,245 @@ +#!/usr/bin/env bash +# Generic release pipeline for a WordPress plugin OR theme. +# +# Given a checkout of a WordPress plugin/theme repo at a tagged commit, this: +# 1. validates the version header (+ readme.txt, for plugins) matches the tag, +# 2. optionally builds a bundled Vite app and gates on its staleness, +# 3. scans the archive for stray/dev files, +# 4. packages a distributable zip (git archive, honouring .gitattributes export-ignore), +# 5. cuts a Gitea release on the SOURCE repo and attaches the zip, +# 6. publishes the zip + a computed update manifest to a self-hosted "channel" repo. +# +# Run from a plugin/theme checkout (CWD = the source repo at the tagged commit). +# Expects env: GITHUB_REPOSITORY, GITHUB_REF_NAME, GITHUB_SERVER_URL, GITHUB_TOKEN. +# Optional env (step 6 — channel publish; skipped entirely if the key is unset): +# CHANNEL_DEPLOY_KEY SSH private key with WRITE access to the channel repo. +# CHANNEL_SSH_PORT SSH port for the Gitea host (default 30009). +# CHANNEL_BRANCH branch of the channel repo to push (default main). +# CHANNEL_GIT_NAME git committer name for the push (default "release bot"). +# +# Per-project knobs live in .mu-release.yaml (read from CWD): +# channel: "aa/maineaa-updates" # REQUIRED — target channel repo (owner/name). +# type: plugin | theme # default plugin. +# main_file: style.css # optional; defaults per type (see below). +# app_build: false # build app/ (Vite) + gate on assets/app staleness. +# sections: { autogen: true } # manifest section sourcing (see build-manifest.py). +set -euo pipefail + +# Locate build-manifest.py alongside THIS script (works whether release.sh is run +# from a composite action's path or from a submodule checked out at .ci/). +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +SLUG="$(basename "${GITHUB_REPOSITORY}")" +VER="${GITHUB_REF_NAME#v}" +HOST="${GITHUB_SERVER_URL}" +CFG=".mu-release.yaml" + +# --- config helpers (yaml via python; pure-convention defaults if absent) ----- +cfg() { python3 - "$1" "$2" <<'PY' 2>/dev/null || true +import sys, os +key, default = sys.argv[1], sys.argv[2] +val = default +if os.path.isfile(".mu-release.yaml"): + import yaml + data = yaml.safe_load(open(".mu-release.yaml")) or {} + cur = data + for part in key.split("."): + cur = cur.get(part) if isinstance(cur, dict) else None + if cur is None: + cur = default; break + val = cur +print("true" if val is True else "false" if val is False else val) +PY +} + +# Builder/config deps up front: cfg() parses YAML and build-manifest needs +# markdown + pyyaml. Installing here (not mid-pipeline) so config reads and every +# later YAML read resolve correctly. +command -v python3 >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq python3; } +python3 -m pip --version >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq python3-pip; } +python3 -m pip install --quiet markdown pyyaml 2>/dev/null \ + || python3 -m pip install --quiet --break-system-packages markdown pyyaml + +# channel is REQUIRED — never default it. A wrong-or-missing channel must fail the +# release loudly rather than silently publishing to the wrong place (or nowhere). +CHANNEL="$(cfg channel '')" +if [ -z "${CHANNEL}" ]; then + echo "::error::.mu-release.yaml is missing the required 'channel:' key (target channel repo, e.g. 'aa/maineaa-updates'). Refusing to run without an explicit publish target." + exit 1 +fi +APP_BUILD="$(cfg app_build 'false')" +TYPE="$(cfg type 'plugin')" +MAIN="$(cfg main_file '')" +if [ -z "${MAIN}" ]; then + if [ "${TYPE}" = "theme" ]; then MAIN="style.css"; else MAIN="${SLUG}.php"; fi +fi + +log() { printf '\n\033[1m▸ %s\033[0m\n' "$*"; } + +# --- 1. validate header/const/changelog/readme all match the tag -------------- +log "Validate ${MAIN} Version (type: ${TYPE}) vs tag v${VER}" +[ -f "${MAIN}" ] || { echo "::error::main file ${MAIN} not found"; exit 1; } +HVER="$(grep -iE '^[[:space:]]*\*?[[:space:]]*Version:' "${MAIN}" | head -1 | sed -E 's/.*[Vv]ersion:[[:space:]]*//' | tr -d '[:space:]\r')" +[ "${HVER}" = "${VER}" ] || { echo "::error::header Version (${HVER}) != tag (${VER})"; exit 1; } +# *_VERSION const check is plugin-shaped; if-guarded so it silently skips for themes. +if grep -qE "_VERSION'" "${MAIN}"; then + grep -qF "_VERSION', '${VER}'" "${MAIN}" || { echo "::error::*_VERSION const != ${VER}"; exit 1; } +fi +# CHANGELOG.md is documentation, not correctness — warn, never block a release. +if [ -f CHANGELOG.md ] && ! grep -qF "[${VER}]" CHANGELOG.md; then + echo "::warning::CHANGELOG.md has no [${VER}] entry (documentation only — not blocking)." +fi +if [ "${TYPE}" = "theme" ]; then + echo "Theme release — skipping readme.txt Stable tag / License / '== Changelog ==' validation." +else + [ -f readme.txt ] || { echo "::error::no readme.txt — required for packaging"; exit 1; } + RSTABLE="$(grep -iE '^Stable tag:' readme.txt | head -1 | sed -E 's/.*:[[:space:]]*//' | tr -d '[:space:]\r')" + [ "${RSTABLE}" = "${VER}" ] || { echo "::error::readme.txt Stable tag (${RSTABLE}) != tag (${VER})"; exit 1; } + grep -qiE '^License:' readme.txt || { echo "::error::readme.txt missing 'License:' header"; exit 1; } + grep -qE '^== Changelog ==' readme.txt || { echo "::error::readme.txt missing '== Changelog ==' section"; exit 1; } +fi +echo "OK: version/changelog/readme all agree on ${VER}." + +# --- 2. optional Vite app build + staleness gate ------------------------------ +if [ "${APP_BUILD}" = "true" ] && [ -f app/package.json ]; then + log "Build app/ (Vite) + gate on assets/app staleness" + ( cd app && npm ci && npm run build ) + if ! git diff --quiet -- assets/app; then + echo "::error::assets/app is stale — rebuild, commit, re-tag." + git --no-pager diff --stat -- assets/app; exit 1 + fi +else + echo "No app build (app_build=${APP_BUILD})." +fi + +# --- 3. package-hygiene scan: nothing stray may ship -------------------------- +log "Package hygiene scan" +rm -rf _scan && mkdir _scan +git archive "${GITHUB_REF_NAME}" | tar -x -C _scan +BAD="$(cd _scan && find . \( -iname '*claude*' -o -name '.git' -o -name '.gitea' \ + -o -name '.github' -o -name 'node_modules' -o -name '.DS_Store' -o -name 'Thumbs.db' \ + -o -name '.build-notes-*' -o -name '*.scratch' -o -name 'dev' -o -name 'docs' \) -print)" +[ -z "${BAD}" ] || { echo "::error::disallowed files in package:"; echo "${BAD}"; exit 1; } +if grep -rIilE 'claude' _scan; then + echo "::error::forbidden token in a shipped file"; exit 1 +fi +rm -rf _scan +echo "Package hygiene OK." + +# --- 4. build the zip (git archive honours .gitattributes export-ignore) ------ +log "Package ${SLUG}-${VER}.zip" +ZIP="${SLUG}-${VER}.zip" +git archive --format=zip --prefix="${SLUG}/" -o "${ZIP}" "${GITHUB_REF_NAME}" +ls -l "${ZIP}" + +# --- 5. cut the Gitea release + attach the zip -------------------------------- +log "Publish Gitea release" +API="${HOST}/api/v1/repos/${GITHUB_REPOSITORY}" +AUTH="Authorization: token ${GITHUB_TOKEN}" +# Idempotent: reuse the release if this tag was already released (re-run/re-tag). +REL="$(curl -fsS -H "${AUTH}" "${API}/releases/tags/${GITHUB_REF_NAME}" 2>/dev/null || true)" +REL_ID="$(printf '%s' "${REL}" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*' || true)" +if [ -z "${REL_ID}" ]; then + BODY="$(python3 -c 'import json,sys; print(json.dumps({"tag_name":sys.argv[1],"name":sys.argv[2],"body":sys.argv[3]}))' \ + "${GITHUB_REF_NAME}" "${SLUG} ${VER}" "Automated build of ${GITHUB_REF_NAME}.")" + REL="$(curl -fsS -X POST "${API}/releases" -H "${AUTH}" -H "Content-Type: application/json" --data-raw "${BODY}")" + REL_ID="$(printf '%s' "${REL}" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*' || true)" +fi +[ -n "${REL_ID}" ] || { echo "::error::could not create/get the release"; echo "${REL}"; exit 1; } +# Replace a same-named asset on a re-run rather than 409-ing. +OLD="$(curl -fsS -H "${AUTH}" "${API}/releases/${REL_ID}/assets" 2>/dev/null \ + | python3 -c "import json,sys;[print(a['id']) for a in (json.load(sys.stdin) or []) if a.get('name')==sys.argv[1]]" "${ZIP}" 2>/dev/null || true)" +[ -z "${OLD}" ] || curl -fsS -X DELETE -H "${AUTH}" "${API}/releases/${REL_ID}/assets/${OLD}" >/dev/null 2>&1 || true +curl -fsS -X POST "${API}/releases/${REL_ID}/assets?name=${ZIP}" -H "${AUTH}" \ + -F "attachment=@${ZIP};type=application/zip" >/dev/null +echo "Released ${GITHUB_REF_NAME}." + +# --- 6. publish to the self-hosted update channel (skipped without deploy key) - +if [ -z "${CHANNEL_DEPLOY_KEY:-}" ]; then + echo "CHANNEL_DEPLOY_KEY not set — skipping channel publish."; exit 0 +fi +log "Publish to channel ${CHANNEL}" +SSH_HOST="${HOST#https://}"; SSH_HOST="${SSH_HOST#http://}" +SSH_PORT="${CHANNEL_SSH_PORT:-30009}" +CHANNEL_BRANCH="${CHANNEL_BRANCH:-main}" +CHANNEL_GIT_NAME="${CHANNEL_GIT_NAME:-release bot}" +DL="${HOST}/${CHANNEL}/raw/branch/${CHANNEL_BRANCH}/${SLUG}/${ZIP}" +RAW="${HOST}/${CHANNEL}/raw/branch/${CHANNEL_BRANCH}/${SLUG}" + +install -d -m 700 "${HOME}/.ssh" +KEY="${HOME}/.ssh/channel_deploy"; KH="${HOME}/.ssh/known_hosts" +printf '%s\n' "${CHANNEL_DEPLOY_KEY}" > "${KEY}"; chmod 600 "${KEY}" +ssh-keyscan -p "${SSH_PORT}" "${SSH_HOST}" >> "${KH}" 2>/dev/null +export GIT_SSH_COMMAND="ssh -i ${KEY} -o IdentitiesOnly=yes -o UserKnownHostsFile=${KH}" + +git clone --depth 1 --branch "${CHANNEL_BRANCH}" "ssh://git@${SSH_HOST}:${SSH_PORT}/${CHANNEL}.git" _upd + +# computed (release-derived) fields → build-manifest merges them with .mu-manifest.yaml + sections. +if [ "${TYPE}" = "theme" ]; then + NAME="$(grep -iE '^[[:space:]]*\*?[[:space:]]*Theme Name:' "${MAIN}" | head -1 | sed -E 's/.*Theme Name:[[:space:]]*//' | tr -d '\r')" +else + NAME="$(grep -iE '^[[:space:]]*\*?[[:space:]]*Plugin Name:' "${MAIN}" | head -1 | sed -E 's/.*Plugin Name:[[:space:]]*//' | tr -d '\r')" +fi +REQ="$(grep -iE '^Requires at least:' readme.txt 2>/dev/null | head -1 | sed -E 's/.*:[[:space:]]*//' | tr -d '[:space:]\r' || true)" +TESTED="$(grep -iE '^Tested up to:' readme.txt 2>/dev/null | head -1 | sed -E 's/.*:[[:space:]]*//' | tr -d '[:space:]\r' || true)" +RPHP="$(grep -iE '^Requires PHP:' readme.txt 2>/dev/null | head -1 | sed -E 's/.*:[[:space:]]*//' | tr -d '[:space:]\r' || true)" +NOW="$(date -u +%Y-%m-%d' '%H:%M:%S)" + +# Plugins carry an `icons` map; themes carry a single `screenshot_url` (from +# screenshot.png at the repo root). Each is emitted only when the asset exists. +ICONS='{}' +SCREENSHOT_URL='' +if [ "${TYPE}" = "theme" ]; then + [ -f screenshot.png ] && SCREENSHOT_URL="${RAW}/screenshot.png" +elif [ -f icons/icon-256x256.png ]; then + ICONS="$(printf '{"1x":"%s/icon-128x128.png","2x":"%s/icon-256x256.png"}' "${RAW}" "${RAW}")" +fi +# screenshot images (screenshots/screenshot-N.ext) -> {N: filename}; build-manifest +# pairs these with the readme == Screenshots == captions into the `screenshots` field. +SHOTS='{}' +if [ -d screenshots ]; then + SHOTS="$(ls screenshots 2>/dev/null | python3 -c 'import sys,re,json;print(json.dumps({m.group(1):m.group(0) for m in (re.match(r"screenshot-(\d+)\.(?:png|jpe?g|gif|webp)$",f,re.I) for f in sys.stdin.read().split()) if m}))' || echo '{}')" +fi +python3 - "$NAME" "$SLUG" "$VER" "$DL" "$HOST/${CHANNEL%/*}/$SLUG" "$REQ" "$TESTED" "$RPHP" "$NOW" "$ICONS" "$SHOTS" "$RAW" "$SCREENSHOT_URL" > computed.json <<'PY' +import json, sys +k = ["name","slug","version","download_url","homepage","requires","tested","requires_php","last_updated"] +d = dict(zip(k, sys.argv[1:10])) +icons = json.loads(sys.argv[10]) +if icons: + d["icons"] = icons +shots = json.loads(sys.argv[11]) +if shots: + raw = sys.argv[12] + d["_screenshots"] = {n: f"{raw}/{fn}" for n, fn in shots.items()} +screenshot_url = sys.argv[13] +if screenshot_url: + d["screenshot_url"] = screenshot_url +json.dump(d, sys.stdout) +PY + +BUILDER="${SCRIPT_DIR}/build-manifest.py" +[ -f "${BUILDER}" ] || { echo "::error::build-manifest.py not found next to release.sh (${BUILDER})"; exit 1; } +python3 "${BUILDER}" --repo-root . --computed computed.json --config "${CFG}" --out "_upd/${SLUG}.json" + +mkdir -p "_upd/${SLUG}" +rm -f "_upd/${SLUG}"/*.zip +cp "${ZIP}" "_upd/${SLUG}/" +[ -d icons ] && cp icons/icon-128x128.png icons/icon-256x256.png "_upd/${SLUG}/" 2>/dev/null || true +[ -f screenshot.png ] && cp screenshot.png "_upd/${SLUG}/" 2>/dev/null || true +[ -d screenshots ] && cp screenshots/screenshot-*.* "_upd/${SLUG}/" 2>/dev/null || true + +( cd _upd + git config user.email "ci@${SSH_HOST}" + git config user.name "${CHANNEL_GIT_NAME}" + git add -A + if git diff --cached --quiet; then echo "channel already current — nothing to publish."; exit 0; fi + git commit -m "release ${SLUG} ${VER}" + pushed=0 + for i in 1 2 3 4 5; do + if git push; then pushed=1; break; fi + echo "channel push raced; backing off ${i}/5" + sleep $(( (RANDOM % 20) + 1 )) # large random jitter to de-sync concurrent releases + git pull --rebase --no-edit + done + [ "${pushed}" = 1 ] || { echo "::error::channel push failed after 5 retries"; exit 1; } + echo "Published ${SLUG} ${VER} to ${CHANNEL}: ${DL}" )