diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..d582714 --- /dev/null +++ b/release.sh @@ -0,0 +1,306 @@ +#!/usr/bin/env bash +# +# release.sh — Release helper for wp-multi-city. +# +# Usage: ./release.sh +# Example: ./release.sh 1.2.0 +# +# Prerequisites (validated below): +# - git, curl, jq, unzip, awk, sed in PATH +# - clean git tree on main, in sync with origin +# - ~/.gitea-token exists (chmod 600), scope: write:repository +# - readme.txt already contains `= =` section with changelog body +# +# See docs/superpowers/specs/2026-05-17-s7-release-puc-design.md +# for the full design rationale. + +set -euo pipefail + +REPO_OWNER="bryzgalov" +REPO_NAME="wp-multi-city" +GITEA_BASE="https://git.netranking.ru" +GITEA_API="${GITEA_BASE}/api/v1" +TOKEN_FILE="${HOME}/.gitea-token" + +log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m==> WARN:\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31m==> FAIL:\033[0m %s\n' "$*" >&2; exit 1; } + +# ---------- STEP 1: VALIDATE --------------------------------------------------- + +VERSION="${1:-}" +[[ -n "$VERSION" ]] || fail "usage: $0 (e.g. $0 1.2.0)" + +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ + || fail "invalid semver: '$VERSION'" + +for cmd in git curl jq unzip awk sed; do + command -v "$cmd" >/dev/null 2>&1 || fail "missing command in PATH: $cmd" +done + +[[ -f "$TOKEN_FILE" && -r "$TOKEN_FILE" ]] \ + || fail "$TOKEN_FILE missing or unreadable (chmod 600 and create token in Gitea UI)" +TOKEN="$(cat "$TOKEN_FILE")" +[[ -n "$TOKEN" ]] || fail "$TOKEN_FILE is empty" + +BRANCH="$(git rev-parse --abbrev-ref HEAD)" +[[ "$BRANCH" == "main" ]] || fail "must be on main branch (currently: $BRANCH)" + +if [[ -n "$(git status --porcelain)" ]]; then + fail "working tree not clean (commit/stash first): +$(git status --porcelain)" +fi + +log "Fetching origin to compare main..." +git fetch origin main --quiet +LOCAL="$(git rev-parse main)" +REMOTE="$(git rev-parse origin/main)" +[[ "$LOCAL" == "$REMOTE" ]] \ + || fail "main is not in sync with origin/main (local=$LOCAL remote=$REMOTE — pull/push first)" + +TAG="v${VERSION}" +if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + fail "tag $TAG already exists locally" +fi +if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then + fail "tag $TAG already exists on origin" +fi + +grep -qE "^=[[:space:]]*${VERSION}[[:space:]]*=[[:space:]]*$" readme.txt \ + || fail "readme.txt does not contain '= ${VERSION} =' changelog section — add it before running release" + +log "Validation passed: version=$VERSION, tag=$TAG, branch=$BRANCH" + +# ---------- STEP 2: BUMP VERSION --------------------------------------------- + +log "Bumping version to $VERSION in wp-multi-city.php and readme.txt" + +sed -i.bak -E \ + -e "s|^([[:space:]]*\\*[[:space:]]*Version:[[:space:]]*).*$|\\1${VERSION}|" \ + -e "s|define\\('WPMC_VERSION',[[:space:]]*'[^']+'\\)|define('WPMC_VERSION', '${VERSION}')|" \ + wp-multi-city.php +rm wp-multi-city.php.bak + +grep -q "^ \* Version: ${VERSION}$" wp-multi-city.php \ + || fail "bump failed: header Version in wp-multi-city.php" +grep -q "define('WPMC_VERSION', '${VERSION}')" wp-multi-city.php \ + || fail "bump failed: WPMC_VERSION constant in wp-multi-city.php" + +sed -i.bak -E "s|^(Stable tag:[[:space:]]*).*$|\\1${VERSION}|" readme.txt +rm readme.txt.bak + +grep -q "^Stable tag: ${VERSION}$" readme.txt \ + || fail "bump failed: Stable tag in readme.txt" + +log "Bumped to $VERSION" + +# ---------- STEP 3: RELEASE COMMIT + PUSH ------------------------------------ + +log "Creating release commit" +git add wp-multi-city.php readme.txt +git commit -m "release: ${VERSION}" +RELEASE_SHA="$(git rev-parse HEAD)" +log "Release commit: $RELEASE_SHA" + +log "Pushing main to origin" +git push origin main + +# ---------- STEP 4: BUILD ZIP + SANITY --------------------------------------- + +ZIP_NAME="wp-multi-city-${VERSION}.zip" +ZIP_PATH="dist/${ZIP_NAME}" + +log "Building release zip: $ZIP_PATH" +mkdir -p dist +rm -f "$ZIP_PATH" +git archive HEAD \ + --worktree-attributes \ + --format=zip \ + --prefix=wp-multi-city/ \ + -o "$ZIP_PATH" + +log "Running zip sanity checks" + +unzip -p "$ZIP_PATH" wp-multi-city/wp-multi-city.php \ + | grep -q "^ \* Version: ${VERSION}$" \ + || fail "zip sanity: header Version mismatch inside $ZIP_PATH" + +unzip -p "$ZIP_PATH" wp-multi-city/wp-multi-city.php \ + | grep -q "WPMC_VERSION', '${VERSION}'" \ + || fail "zip sanity: WPMC_VERSION mismatch inside $ZIP_PATH" + +# Cache zip listing once — `unzip -l | grep -q` triggers SIGPIPE to unzip with +# set -o pipefail, falsely failing the pipeline. Capture the list, then grep the string. +ZIP_LISTING="$(unzip -l "$ZIP_PATH")" + +grep -q "wp-multi-city/includes/lib/plugin-update-checker/plugin-update-checker.php" <<< "$ZIP_LISTING" \ + || fail "zip sanity: PUC loader missing from $ZIP_PATH" + +grep -q "wp-multi-city/wp-plugin-info.json" <<< "$ZIP_LISTING" \ + || fail "zip sanity: wp-plugin-info.json missing from $ZIP_PATH" + +for dev in tests/ docs/ composer.json composer.lock phpunit.xml.dist patchwork.json release.sh CLAUDE.md AGENTS.md .gitattributes .gitignore .gitea/; do + if grep -q "wp-multi-city/${dev}" <<< "$ZIP_LISTING"; then + fail "zip sanity: dev file must not be in zip: $dev" + fi +done + +ZIP_SIZE="$(wc -c < "$ZIP_PATH")" +log "Zip built OK: $ZIP_PATH ($ZIP_SIZE bytes)" + +# ---------- STEP 5: GITEA CREATE RELEASE ------------------------------------- + +log "Extracting changelog body for version $VERSION from readme.txt" + +CHANGELOG_BODY="$(awk -v ver="$VERSION" ' + BEGIN { in_block = 0 } + /^=[[:space:]]*[0-9]+\.[0-9]+\.[0-9]+[[:space:]]*=[[:space:]]*$/ { + if (in_block) exit + if ($0 ~ "^=[[:space:]]*" ver "[[:space:]]*=[[:space:]]*$") { + in_block = 1 + next + } + } + in_block { print } +' readme.txt | sed '/^[[:space:]]*$/d')" + +[[ -n "$CHANGELOG_BODY" ]] \ + || fail "could not extract changelog body for version $VERSION from readme.txt" + +log "Creating Gitea release $TAG" + +CREATE_PAYLOAD="$(jq -n \ + --arg tag "$TAG" \ + --arg name "$VERSION" \ + --arg body "$CHANGELOG_BODY" \ + --arg target "$RELEASE_SHA" \ + '{tag_name:$tag, name:$name, body:$body, target_commitish:$target, draft:false, prerelease:false}')" + +CREATE_RESPONSE="$(curl -fsSL \ + -X POST "${GITEA_API}/repos/${REPO_OWNER}/${REPO_NAME}/releases" \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$CREATE_PAYLOAD")" + +RELEASE_ID="$(jq -r '.id' <<< "$CREATE_RESPONSE")" +[[ -n "$RELEASE_ID" && "$RELEASE_ID" != "null" ]] \ + || fail "Gitea create-release response missing id: $CREATE_RESPONSE" + +log "Created Gitea release id=$RELEASE_ID" + +# ---------- STEP 6: UPLOAD ASSET --------------------------------------------- + +log "Uploading asset $ZIP_NAME to release id=$RELEASE_ID" + +curl -fsSL \ + -X POST "${GITEA_API}/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \ + -H "Authorization: token ${TOKEN}" \ + -F "attachment=@${ZIP_PATH};type=application/zip" \ + -o /dev/null + +# Gitea's asset upload response returns an internal /attachments/ URL, +# but the release detail endpoint returns the pretty /releases/download// +# URL for the same asset. Both resolve to the same bytes; the pretty URL is +# preferred for readability and WP-admin UX. +RELEASE_DETAIL="$(curl -fsSL \ + "${GITEA_API}/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}" \ + -H "Authorization: token ${TOKEN}")" + +# Use herestring instead of `echo | jq`: `echo` on macOS may interpret +# backslash-escapes in a multi-line release body, corrupting the JSON. +DOWNLOAD_URL="$(jq -r --arg name "$ZIP_NAME" \ + '.assets[] | select(.name == $name) | .browser_download_url' \ + <<< "$RELEASE_DETAIL" | head -n 1)" + +[[ -n "$DOWNLOAD_URL" && "$DOWNLOAD_URL" != "null" ]] \ + || fail "release $RELEASE_ID has no asset named $ZIP_NAME" + +log "Asset uploaded: $DOWNLOAD_URL" + +# ---------- STEP 7: REGENERATE wp-plugin-info.json --------------------------- + +log "Converting readme.txt Changelog to HTML" + +CHANGELOG_HTML="$(awk ' + function escape(s) { + gsub(/&/, "\\&", s) + gsub(//, "\\>", s) + return s + } + BEGIN { in_changelog = 0; ul_open = 0; html = ""; pending = "" } + /^== Changelog ==/ { in_changelog = 1; next } + /^==/ && in_changelog { exit } + !in_changelog { next } + /^=[[:space:]]*[0-9]+\.[0-9]+\.[0-9]+[[:space:]]*=[[:space:]]*$/ { + if (pending != "") { html = html "
  • " escape(pending) "
  • "; pending = "" } + if (ul_open) { html = html ""; ul_open = 0 } + match($0, /[0-9]+\.[0-9]+\.[0-9]+/) + ver = substr($0, RSTART, RLENGTH) + html = html "

    " ver "

      " + ul_open = 1 + next + } + /^\*[[:space:]]/ { + if (pending != "") { html = html "
    • " escape(pending) "
    • " } + sub(/^\*[[:space:]]+/, "") + pending = $0 + next + } + /^[[:space:]]+[^[:space:]]/ { + if (pending != "") { + line = $0 + sub(/^[[:space:]]+/, "", line) + pending = pending " " line + } + next + } + /^[[:space:]]*$/ { + if (pending != "") { html = html "
    • " escape(pending) "
    • "; pending = "" } + next + } + END { + if (pending != "") { html = html "
    • " escape(pending) "
    • " } + if (ul_open) { html = html "
    " } + print html + } +' readme.txt)" + +[[ -n "$CHANGELOG_HTML" ]] || fail "changelog HTML conversion produced empty result" + +LAST_UPDATED="$(date -u '+%Y-%m-%d %H:%M:%S')" + +log "Regenerating wp-plugin-info.json" +TMP_JSON="$(mktemp)" +jq \ + --arg version "$VERSION" \ + --arg download_url "$DOWNLOAD_URL" \ + --arg last_updated "$LAST_UPDATED" \ + --arg changelog "$CHANGELOG_HTML" \ + '.version = $version | .download_url = $download_url | .last_updated = $last_updated | .sections.changelog = $changelog' \ + wp-plugin-info.json > "$TMP_JSON" +mv "$TMP_JSON" wp-plugin-info.json + +jq empty wp-plugin-info.json || fail "regenerated wp-plugin-info.json is not valid JSON" + +log "Regenerated wp-plugin-info.json (version=$VERSION, download_url=$DOWNLOAD_URL)" + +# ---------- STEP 8: JSON COMMIT + PUSH --------------------------------------- + +log "Committing wp-plugin-info.json update" +git add wp-plugin-info.json +git commit -m "wp-plugin-info.json: update for ${VERSION}" +git push origin main + +# ---------- STEP 9: SUMMARY -------------------------------------------------- + +log "Release completed" +printf '\n' +printf ' Version : %s\n' "$VERSION" +printf ' Tag : %s (on %s/%s/%s/src/tag/%s)\n' "$TAG" "$GITEA_BASE" "$REPO_OWNER" "$REPO_NAME" "$TAG" +printf ' Release page : %s/%s/%s/releases/tag/%s\n' "$GITEA_BASE" "$REPO_OWNER" "$REPO_NAME" "$TAG" +printf ' Asset : %s\n' "$DOWNLOAD_URL" +printf ' PUC JSON : %s/%s/%s/raw/branch/main/wp-plugin-info.json\n' "$GITEA_BASE" "$REPO_OWNER" "$REPO_NAME" +printf '\n' +printf ' Next: in WordPress admin — «Плагины → Проверить обновления» (PUC re-checks hourly, force-check via transient delete if impatient).\n' +printf '\n'