From 011ccb8dbfdde893ba73ef6a65a552811304f716 Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov Date: Sun, 17 May 2026 12:00:47 +0500 Subject: [PATCH] docs(wpmc S7): implementation plan for release flow + PUC Co-Authored-By: Claude Opus 4.7 --- .../plans/2026-05-17-s7-release-puc.md | 1152 +++++++++++++++++ 1 file changed, 1152 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-17-s7-release-puc.md diff --git a/docs/superpowers/plans/2026-05-17-s7-release-puc.md b/docs/superpowers/plans/2026-05-17-s7-release-puc.md new file mode 100644 index 0000000..d9a2336 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-s7-release-puc.md @@ -0,0 +1,1152 @@ +# S7 — Release flow + PUC Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Закрыть эпик cp-cw4 self-hosted поставкой wp-multi-city в Gitea: vendored PUC v5.6, `release.sh` (адаптация cf7stg), расширенный README, version bump 0.1.0 → 1.0.0. + +**Architecture:** Infrastructure + docs only. Один новый приватный метод `Plugin::register_update_checker()` для PUC bootstrap, всё остальное — файлы. Эталон — `cf7-spam-to-telegram` (то же owner, то же Gitea, тот же flow). + +**Tech Stack:** PHP 8.0+, PHPUnit 9.6 + Brain Monkey 2.7, Plugin Update Checker v5.6 (Yahnis Elsts), Gitea Actions, git archive + bash + jq + curl для release.sh. + +**Spec:** `docs/superpowers/specs/2026-05-17-s7-release-puc-design.md` + +**Beads:** parent `cp-7cf` (epic `cp-cw4`). + +**Critical Path:** +1. Vendor PUC (без него нет updates) +2. PUC bootstrap в Plugin (без него PUC mертв) +3. `wp-plugin-info.json` (метаданные для PUC) +4. Version bump (1.0.0) +5. `.gitattributes` (без него dev-файлы попадут в zip) +6. `readme.txt` (источник правды для release.sh) +7. `CHANGELOG.md` (human-readable mirror) +8. `README.md` (Quick Start + Hook API + Migration guide) +9. `ZipArchiveBuildTest` (проверяет всё вместе) +10. `release.sh` (использует всё вышеперечисленное) + +--- + +## Task 1: Vendor Plugin Update Checker v5.6 + +**Files:** +- Create: `includes/lib/plugin-update-checker/` (целая папка из cf7stg) +- Test: `tests/UpdateCheckerIntegrationTest.php` + +**Source:** `/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/cf7-spam-to-telegram/includes/lib/plugin-update-checker/` + +- [ ] **Step 1: Copy PUC vendor directory** + +```bash +cp -R "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/cf7-spam-to-telegram/includes/lib/plugin-update-checker" \ + "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city/includes/lib/" +``` + +- [ ] **Step 2: Verify copy succeeded** + +```bash +ls includes/lib/plugin-update-checker/plugin-update-checker.php +ls includes/lib/plugin-update-checker/load-v5p6.php +``` + +Expected: both files present, no errors. + +- [ ] **Step 3: Write the failing test** + +Create `tests/UpdateCheckerIntegrationTest.php`: + +```php +1.0.0" + } +} +``` + +- [ ] **Step 2: Validate JSON** + +Run: `jq empty wp-plugin-info.json && echo OK` +Expected: `OK` + +- [ ] **Step 3: Commit** + +```bash +git add wp-plugin-info.json +git commit -m "feat(wpmc S7): wp-plugin-info.json metadata for PUC" +``` + +--- + +## Task 3: Plugin::register_update_checker() + boot integration + +**Files:** +- Modify: `includes/class-plugin.php` (add private static method + call in boot()) +- Create: `tests/PluginBootS7Test.php` + +- [ ] **Step 1: Write the failing test** + +Create `tests/PluginBootS7Test.php`: + +```php +getProperty('booted'); + $prop->setAccessible(true); + $prop->setValue(null, false); + } + + public function test_boot_loads_puc_factory(): void + { + Functions\stubs([ + 'register_taxonomy' => null, + 'register_post_type' => null, + 'add_action' => null, + 'add_filter' => null, + 'is_admin' => false, + 'plugin_basename' => 'wp-multi-city/wp-multi-city.php', + ]); + + Plugin::instance()->boot(); + + self::assertTrue( + class_exists('YahnisElsts\\PluginUpdateChecker\\v5\\PucFactory'), + 'boot() should require PUC loader and make PucFactory available' + ); + } + + public function test_boot_double_call_is_idempotent(): void + { + Functions\stubs([ + 'register_taxonomy' => null, + 'register_post_type' => null, + 'add_action' => null, + 'add_filter' => null, + 'is_admin' => false, + 'plugin_basename' => 'wp-multi-city/wp-multi-city.php', + ]); + + Plugin::instance()->boot(); + Plugin::instance()->boot(); + + self::assertTrue(true, 'Double boot must not throw'); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `vendor/bin/phpunit --filter PluginBootS7Test` +Expected: FAIL — PUC not registered by Plugin::boot() yet. + +- [ ] **Step 3: Read current class-plugin.php to find insertion point** + +```bash +grep -n "RestUrlFilter::register" includes/class-plugin.php +grep -n "private bool \$booted" includes/class-plugin.php +``` + +Expected: `RestUrlFilter::register();` is the last register call inside `boot()`. `$booted` flag exists. + +- [ ] **Step 4: Add register_update_checker method and call** + +In `includes/class-plugin.php`, after the existing `RestUrlFilter::register();` line inside `boot()`, add a new line: + +```php + self::register_update_checker(); +``` + +Then add the new private method right after `boot()` (or wherever other private static methods sit; if none, add at the end of the class before the closing brace): + +```php + private static function register_update_checker(): void + { + $loader = WPMC_PLUGIN_DIR . 'includes/lib/plugin-update-checker/plugin-update-checker.php'; + if (!\is_file($loader)) { + return; + } + require_once $loader; + if (!\class_exists('YahnisElsts\\PluginUpdateChecker\\v5\\PucFactory')) { + return; + } + \YahnisElsts\PluginUpdateChecker\v5\PucFactory::buildUpdateChecker( + 'https://git.netranking.ru/bryzgalov/wp-multi-city/raw/branch/main/wp-plugin-info.json', + WPMC_PLUGIN_FILE, + 'wp-multi-city' + ); + } +``` + +- [ ] **Step 5: Run S7 tests** + +Run: `vendor/bin/phpunit --filter PluginBootS7Test` +Expected: OK (2 tests, 2 assertions). + +- [ ] **Step 6: Run full suite to verify nothing broke** + +Run: `vendor/bin/phpunit` +Expected: OK (102 tests, 179 assertions) — 98 prior + 2 from Task 1 + 2 from this task. + +- [ ] **Step 7: Commit** + +```bash +git add includes/class-plugin.php tests/PluginBootS7Test.php +git commit -m "feat(wpmc S7): Plugin::register_update_checker wires PUC v5.6" +``` + +--- + +## Task 4: Bump version 0.1.0 → 1.0.0 + +**Files:** +- Modify: `wp-multi-city.php` (header Version, WPMC_VERSION constant, add Tested up to) + +- [ ] **Step 1: Edit wp-multi-city.php** + +Replace plugin header and version constant. Final state of relevant lines: + +```php +/** + * Plugin Name: WP Multi City + * Description: Мультигородская подсистема: taxonomy town, шорткоды, URL-роутер /{city}/{path}/, ACF page-clones. + * Version: 1.0.0 + * Author: Vladimir Bryzgalov + * Requires PHP: 8.0 + * Requires at least: 6.0 + * Tested up to: 6.9 + * License: GPLv2 or later + * Text Domain: wp-multi-city + */ +``` + +```php +defined('WPMC_VERSION') || define('WPMC_VERSION', '1.0.0'); +``` + +Apply via Edit tool (two replacements: `Version: 0.1.0` → `Version: 1.0.0` and add `Tested up to: 6.9` line; `'0.1.0'` → `'1.0.0'` in constant). + +- [ ] **Step 2: Verify with grep** + +```bash +grep -E "^ \* Version: 1\.0\.0$" wp-multi-city.php +grep -E "^ \* Tested up to: 6\.9$" wp-multi-city.php +grep "WPMC_VERSION', '1.0.0'" wp-multi-city.php +``` + +Expected: all three lines printed (each is a match). + +- [ ] **Step 3: Run full test suite (no test changes, but verify nothing broke)** + +Run: `vendor/bin/phpunit` +Expected: OK (102 tests, 179 assertions). + +- [ ] **Step 4: Commit** + +```bash +git add wp-multi-city.php +git commit -m "feat(wpmc S7): bump version to 1.0.0 + Tested up to 6.9" +``` + +--- + +## Task 5: .gitattributes for git archive + +**Files:** +- Create: `.gitattributes` + +- [ ] **Step 1: Create .gitattributes** + +``` +# Exclude dev files from `git archive` (release.sh build) +/tests/ export-ignore +/docs/ export-ignore +/phpunit.xml.dist export-ignore +/patchwork.json export-ignore +/composer.json export-ignore +/composer.lock export-ignore +/.phpunit.result.cache export-ignore +/.beads/ export-ignore +/.claude/ export-ignore +/.gitea/ export-ignore +/CLAUDE.md export-ignore +/AGENTS.md export-ignore +/release.sh export-ignore +/dist/ export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore +/.DS_Store export-ignore + +# NOTE: includes/lib/plugin-update-checker/ MUST NOT be excluded — +# required at runtime on target WP site. +``` + +- [ ] **Step 2: Smoke test git archive** + +```bash +git add .gitattributes +git stash +git archive HEAD --worktree-attributes --format=zip --prefix=wp-multi-city/ -o /tmp/wpmc-smoke.zip +unzip -l /tmp/wpmc-smoke.zip | grep -E "(tests/|docs/|composer\.json|release\.sh)" || echo "OK: no dev files in archive" +unzip -l /tmp/wpmc-smoke.zip | grep "includes/lib/plugin-update-checker/plugin-update-checker.php" +git stash pop +rm /tmp/wpmc-smoke.zip +``` + +Expected: `OK: no dev files in archive` printed; PUC loader path printed. + +- [ ] **Step 3: Commit** + +```bash +git commit -m "feat(wpmc S7): .gitattributes export-ignore for git archive build" +``` + +--- + +## Task 6: readme.txt (WordPress.org format) + +**Files:** +- Create: `readme.txt` + +- [ ] **Step 1: Create readme.txt** + +``` +=== WP Multi City === +Contributors: vbryzgalov +Tags: multisite, multi-city, taxonomy, acf, shortcode +Requires at least: 6.0 +Tested up to: 6.9 +Requires PHP: 8.0 +Stable tag: 1.0.0 +License: GPLv2 or later + +Мультигородская подсистема: одна WP-инсталляция обслуживает несколько городов по схеме /{city}/{path}/ с подстановками контента и URL. + +== Description == + +Решает задачу мультигородского сайта без WordPress Multisite. Один WP, единая БД, города представлены термами `town` таксономии. URL формы `/{city}/{path}/` патчат `$wp_query`, чтобы загрузить нужную страницу. Контент по городу подставляется через `[shortcode_dmr id=N]` (ACF repeater `list_shortcode`). + +Требует Advanced Custom Fields Pro. + +== Installation == + +1. Загрузите архив wp-multi-city в `/wp-content/plugins/`. +2. Активируйте плагин в WP admin → Plugins. +3. Если ACF Pro не активен — увидите admin notice об ошибке зависимости. + +== Changelog == + += 1.0.0 = +* Первый релиз. MVP-ядро завершено (S1–S7 эпика cp-cw4). +* taxonomy town + CPT shortcode-town + ACF list_shortcode repeater. +* [shortcode_dmr id=N] для подстановок по городу. +* URL-роутер /{city}/{path}/ с поддержкой page_unic / main_page_alt / page_no_rep. +* Глобальные фильтры the_title, home_url, post_link, acf/load_value, rest_url. +* Self-hosted updates через Gitea + plugin-update-checker v5.6. +``` + +- [ ] **Step 2: Verify Stable tag + changelog section** + +```bash +grep -E "^Stable tag: 1\.0\.0$" readme.txt +grep -E "^=[[:space:]]*1\.0\.0[[:space:]]*=[[:space:]]*$" readme.txt +``` + +Expected: both lines printed. + +- [ ] **Step 3: Commit** + +```bash +git add readme.txt +git commit -m "docs(wpmc S7): readme.txt WP.org format with 1.0.0 changelog" +``` + +--- + +## Task 7: CHANGELOG.md + +**Files:** +- Create: `CHANGELOG.md` + +- [ ] **Step 1: Create CHANGELOG.md** + +```markdown +# Changelog + +All notable changes to wp-multi-city. Format mirrors `readme.txt`. + +## [1.0.0] — 2026-05-17 + +### Added +- Первый релиз. MVP-ядро завершено (S1–S7 эпика cp-cw4). +- `taxonomy town` + CPT `shortcode-town` + ACF `list_shortcode` repeater. +- `[shortcode_dmr id=N]` для подстановок по городу. +- URL-роутер `/{city}/{path}/` (page_unic, main_page_alt, page_no_rep). +- Глобальные фильтры (`the_title`, `home_url`, `post_link`, `acf/load_value`, `rest_url`). +- Self-hosted updates через Gitea + `plugin-update-checker` v5.6. +``` + +- [ ] **Step 2: Commit** + +```bash +git add CHANGELOG.md +git commit -m "docs(wpmc S7): CHANGELOG.md mirror of readme.txt changelog" +``` + +--- + +## Task 8: Расширение README.md + +**Files:** +- Modify: `README.md` (полная переработка с добавлением Quick Start, Hook API, Migration guide; roadmap S7 → done) + +- [ ] **Step 1: Replace README.md content** + +Полностью переписать `README.md` со следующим содержимым: + +```markdown +# WP Multi City + +Мультигородская подсистема для WordPress: один сайт обслуживает несколько городов +по схеме `/{city}/{path}/` с подстановкой контента, ссылок и шорткодов. + +**Версия:** 1.0.0 — MVP завершён. + +## Требования + +- WordPress 6.0+ +- PHP 8.0+ +- Advanced Custom Fields Pro + +## Установка + +1. Скачать zip последнего релиза: + +2. WP admin → Plugins → Add new → Upload (или распаковать в `wp-content/plugins/wp-multi-city/`). +3. Активировать. Если ACF Pro не активен — увидите admin notice об ошибке зависимости. + +Обновления приходят автоматически: плагин-update-checker опрашивает Gitea hourly. +Force-check: WP admin → Plugins → «Check again». + +## Quick Start (5 минут) + +1. WP admin → **Шорткоды → Города** → создать term `tyumen`, `spb`. +2. WP admin → **WP Multi City** → выставить `main_town_slug` = `spb`. +3. WP admin → **Шорткоды → Добавить** пост: + - title: `Тарифы` + - ACF repeater `list_shortcode`: row town=tyumen, text="Тюменский тариф". + - Запомнить ID поста (например 42). +4. Создать страницу с контентом `[shortcode_dmr id=42]`. +5. Открыть `/tyumen//` → увидеть «Тюменский тариф». + Открыть `//` без префикса → пусто (main_town=spb, не tyumen). + +## Hook API + +### Процедурные функции + +| Функция | Возвращает | Назначение | +|---|---|---| +| `wpmc_current_town()` | `?string` | town slug из текущего URL или null | +| `wpmc_get_term_by_slug($slug)` | `?\WP_Term` | `town`-term по slug | +| `wpmc_alt_get_field($key, $post_id)` | `mixed` | `get_field` с fallback на term meta | +| `wpmc_remove_domain_link($url)` | `string` | strip `home_url` prefix | +| `wpmc_main_town_slug()` | `?string` | slug из options page | + +### WP-фильтры от плагина + +- `the_title` / `single_cat_title` → `do_shortcode` (priority 9999, после Yoast SEO). +- `home_url` / `post_link` / `acf/load_value` → `LinkFilter::add_town_prefix` (auto-prefix URL текущим town). +- `rest_url` → `RestUrlFilter::preserve_host` (форсирует HTTPS + `HTTP_HOST`). + +### Bypass URL-фильтров + +Когда нужно построить «канонический» URL без городского префикса: + +```php +$canonical = \WPMultiCity\Helpers::without_url_filters( + fn() => get_permalink($post_id) +); +``` + +Counter-based, поддерживает вложенные вызовы. + +## Migration guide (из темы washanyanya) + +1. В `wp-content/themes/washanyanya/functions.php` закомментировать + `require_once get_template_directory() . '/inc/town-functions.php';`. +2. Активировать wp-multi-city. +3. ACF Pro field group из темы → re-import в новую location (CPT `shortcode-town`). +4. Заменить вызовы `current_town()` на `wpmc_current_town()`. Либо прописать + алиас в functions.php темы: + ```php + if (function_exists('wpmc_current_town')) { + function current_town(): ?string { return wpmc_current_town(); } + } + ``` +5. Проверить: `/tyumen/about/` отдаёт правильный page; меню префиксится; + `[shortcode_dmr]` рендерит. + +## Testing + +### Automated (PHPUnit) + +```bash +composer install +vendor/bin/phpunit +``` + +Expected: 104 tests, ~185 assertions green. Unit-level coverage via Brain Monkey ++ Mockery + Patchwork — no real WordPress required; полный suite за <1 секунду. + +### Manual QA + +Для end-to-end проверки против реального WordPress см. `docs/QA-CHECKLIST.md` — +13 секций (активация, admin UI, helpers, shortcode, router, page clones, +URL filters, REST URL). + +## CI + +`.gitea/workflows/test.yml` запускает PHPUnit на каждый push в `main` и каждый PR. +Активируется автоматически после push в Gitea. + +## Roadmap MVP + +| Этап | Содержание | Beads | Статус | +|---|---|---|---| +| S1 | Скелет (header, autoload, bootstrap) | cp-8ci | done | +| S2 | Taxonomy `town` + CPT `shortcode-town` + ACF `list_shortcode` | cp-q7g | done | +| S3 | `[shortcode_dmr]` + хелперы | cp-arq | done | +| S4 | URL-роутер `/{city}/{path}/` | cp-83m | done | +| S5 | Глобальные фильтры (`the_title`/URL/`rest_url`) | cp-i17 | done | +| S6 | CI workflow + manual QA checklist | cp-la7 | done | +| S7 | Gitea release + plugin-update-checker | cp-7cf | done | + +Эпик: `cp-cw4` — MVP CLOSED. + +## Источник + +Плагин — переработка кастомной мультигородской подсистемы темы `washanyanya` +(1555 строк `inc/town-functions.php`) в переиспользуемый плагин для других проектов. + +## Лицензия + +GPLv2 или новее. +``` + +- [ ] **Step 2: Verify content** + +```bash +grep "Quick Start (5 минут)" README.md +grep "Hook API" README.md +grep "Migration guide" README.md +grep "S7 | Gitea release" README.md +``` + +Expected: all four lines printed. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs(wpmc S7): README expand with Quick Start + Hook API + Migration guide" +``` + +--- + +## Task 9: ZipArchiveBuildTest — verify git archive output + +**Files:** +- Create: `tests/ZipArchiveBuildTest.php` + +- [ ] **Step 1: Write the failing test** + +Create `tests/ZipArchiveBuildTest.php`: + +```php +&1', + escapeshellarg(self::PLUGIN_ROOT), + escapeshellarg(self::ARCHIVE_PREFIX), + escapeshellarg($zipPath) + ); + exec($cmd, $out, $rc); + self::assertSame(0, $rc, 'git archive failed: ' . implode("\n", $out)); + + exec('unzip -l ' . escapeshellarg($zipPath), $listing); + $joined = implode("\n", $listing); + + // MUST be in archive + $required = [ + 'wp-multi-city/wp-multi-city.php', + 'wp-multi-city/includes/lib/plugin-update-checker/plugin-update-checker.php', + 'wp-multi-city/wp-plugin-info.json', + 'wp-multi-city/readme.txt', + 'wp-multi-city/README.md', + ]; + foreach ($required as $path) { + self::assertStringContainsString($path, $joined, "missing in archive: {$path}"); + } + + // MUST NOT be in archive + $forbidden = [ + 'tests/', + 'docs/', + 'composer.json', + 'phpunit.xml.dist', + 'patchwork.json', + 'release.sh', + 'CLAUDE.md', + '.gitattributes', + '.gitea/', + ]; + foreach ($forbidden as $path) { + self::assertStringNotContainsString( + 'wp-multi-city/' . $path, + $joined, + "dev file leaked into archive: {$path}" + ); + } + + unlink($zipPath); + } + + public function test_wp_plugin_info_json_is_valid(): void + { + $path = self::PLUGIN_ROOT . '/wp-plugin-info.json'; + self::assertFileExists($path); + + $json = file_get_contents($path); + self::assertIsString($json); + + $data = json_decode($json, true); + self::assertIsArray($data, 'wp-plugin-info.json must be valid JSON'); + self::assertSame('wp-multi-city', $data['slug']); + self::assertArrayHasKey('version', $data); + self::assertArrayHasKey('download_url', $data); + self::assertStringContainsString( + 'git.netranking.ru/bryzgalov/wp-multi-city', + $data['download_url'] + ); + } +} +``` + +- [ ] **Step 2: Run test (note: requires .gitattributes, readme.txt, wp-plugin-info.json уже в HEAD)** + +Run: `vendor/bin/phpunit --filter ZipArchiveBuildTest` +Expected: OK (2 tests, ~15 assertions). + +Если тест падает с "git archive failed" — убедиться, что все предыдущие задачи закоммичены: `git log --oneline -10`. + +- [ ] **Step 3: Run full test suite** + +Run: `vendor/bin/phpunit` +Expected: OK (104 tests, ~185 assertions). + +- [ ] **Step 4: Commit** + +```bash +git add tests/ZipArchiveBuildTest.php +git commit -m "test(wpmc S7): ZipArchiveBuildTest verifies git archive integrity" +``` + +--- + +## Task 10: release.sh — Gitea release helper + +**Files:** +- Create: `release.sh` (executable) + +- [ ] **Step 1: Create release.sh based on cf7stg pattern** + +Create `release.sh` (содержимое — адаптация `cf7-spam-to-telegram/release.sh`): + +```bash +#!/usr/bin/env bash +# +# release.sh — Release helper for wp-multi-city. +# +# Usage: ./release.sh +# Example: ./release.sh 1.0.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.0.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" + +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 + +RELEASE_DETAIL="$(curl -fsSL \ + "${GITEA_API}/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}" \ + -H "Authorization: token ${TOKEN}")" + +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 — «Plugins → Check again» (PUC re-checks hourly, force-check via transient delete if impatient).\n' +printf '\n' +``` + +- [ ] **Step 2: Make executable** + +```bash +chmod +x release.sh +``` + +- [ ] **Step 3: Sanity-test parse (without running — validate syntax)** + +```bash +bash -n release.sh && echo "OK: release.sh syntactically valid" +``` + +Expected: `OK: release.sh syntactically valid`. + +- [ ] **Step 4: Run full PHPUnit suite (no test changes but verify nothing broke)** + +Run: `vendor/bin/phpunit` +Expected: OK (104 tests, ~185 assertions). + +- [ ] **Step 5: Commit** + +```bash +git add release.sh +git commit -m "feat(wpmc S7): release.sh Gitea release helper (mirror cf7stg flow)" +``` + +--- + +## Final verification + +After all 10 tasks complete: + +- [ ] **Run full PHPUnit suite** + +Run: `vendor/bin/phpunit` +Expected: OK (104 tests, ~185 assertions). + +- [ ] **PHP lint clean** + +```bash +find includes -name '*.php' -not -path '*/lib/*' -exec php -l {} \; +``` + +Expected: каждая строка вида `No syntax errors detected in `. + +- [ ] **Working tree clean** + +```bash +git status +``` + +Expected: `nothing to commit, working tree clean`. + +- [ ] **Smoke git archive** + +```bash +git archive HEAD --worktree-attributes --format=zip --prefix=wp-multi-city/ -o /tmp/wpmc-final.zip +unzip -l /tmp/wpmc-final.zip | head -40 +rm /tmp/wpmc-final.zip +``` + +Expected: видны `wp-multi-city.php`, `README.md`, `readme.txt`, `wp-plugin-info.json`, `includes/lib/plugin-update-checker/`; НЕ видны `tests/`, `docs/`, `composer.json`, `release.sh`. + +- [ ] **Manual Gitea steps (вне плана — выполняются пользователем после merge)** + +1. В Gitea UI (`https://git.netranking.ru/bryzgalov`) → New Repository → name `wp-multi-city`. +2. Локально: + ```bash + git remote add origin https://git.netranking.ru/bryzgalov/wp-multi-city.git + git push -u origin main + ``` +3. Запустить: `./release.sh 1.0.0`. +4. Проверить: `https://git.netranking.ru/bryzgalov/wp-multi-city/releases/tag/v1.0.0` отдаёт zip. + +- [ ] **Close epic** + +```bash +bd close cp-7cf --reason "S7 done: vendored PUC v5.6 + release.sh + extended README + version 1.0.0. 104 tests green." +bd close cp-cw4 --reason "MVP-ядро завершено: S1-S7 done. Self-hosted updates через Gitea + PUC." +``` + +--- + +## Spec coverage check + +Все требования спека покрыты: + +| Spec section | Tasks | +|---|---| +| Артефакты (10 файлов) | T1 (PUC vendor), T2 (JSON), T3 (Plugin), T4 (version bump), T5 (.gitattributes), T6 (readme.txt), T7 (CHANGELOG), T8 (README), T10 (release.sh) | +| PUC integration | T1 (vendor) + T3 (bootstrap) | +| release.sh | T10 | +| .gitattributes | T5 | +| Документация (README/readme.txt/CHANGELOG) | T6, T7, T8 | +| wp-multi-city.php изменения | T4 | +| 6 новых тестов | T1 (2), T3 (2), T9 (2) — итого 6 | +| Acceptance verification (1-6) | Final verification block | +| Manual Gitea (7) | Final verification block (manual steps) |