# 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) |