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
- Первый релиз. MVP-ядро S1–S7.
"
+ }
+}
+```
+
+- [ ] **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)
+ 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) |