Files
wp-multi-city/docs/superpowers/plans/2026-05-17-s7-release-puc.md
Vladimir Bryzgalov 011ccb8dbf docs(wpmc S7): implementation plan for release flow + PUC
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 12:00:47 +05:00

1153 lines
37 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<?php
declare(strict_types=1);
namespace WPMultiCity\Tests;
use PHPUnit\Framework\TestCase as BaseTestCase;
final class UpdateCheckerIntegrationTest extends BaseTestCase
{
private const PUC_LOADER = __DIR__ . '/../includes/lib/plugin-update-checker/plugin-update-checker.php';
public function test_puc_loader_exists(): void
{
self::assertFileExists(self::PUC_LOADER);
}
public function test_puc_factory_loadable(): void
{
require_once self::PUC_LOADER;
self::assertTrue(
class_exists('YahnisElsts\\PluginUpdateChecker\\v5\\PucFactory'),
'PucFactory must be loadable after requiring the PUC loader'
);
}
}
```
- [ ] **Step 4: Run test to verify both pass**
Run: `vendor/bin/phpunit --filter UpdateCheckerIntegrationTest`
Expected: OK (2 tests, 2 assertions).
- [ ] **Step 5: Commit**
```bash
git add includes/lib/plugin-update-checker tests/UpdateCheckerIntegrationTest.php
git commit -m "feat(wpmc S7): vendor Plugin Update Checker v5.6"
```
---
## Task 2: Create wp-plugin-info.json
**Files:**
- Create: `wp-plugin-info.json`
- [ ] **Step 1: Create initial JSON**
Create `wp-plugin-info.json`:
```json
{
"name": "WP Multi City",
"slug": "wp-multi-city",
"version": "1.0.0",
"download_url": "https://git.netranking.ru/bryzgalov/wp-multi-city/releases/download/v1.0.0/wp-multi-city-1.0.0.zip",
"homepage": "https://git.netranking.ru/bryzgalov/wp-multi-city",
"requires": "6.0",
"tested": "6.9",
"requires_php": "8.0",
"last_updated": "2026-05-17 00:00:00",
"author": "Vladimir Bryzgalov",
"sections": {
"description": "Мультигородская подсистема: taxonomy town, [shortcode_dmr] для подстановок, URL-роутер /{city}/{path}/, ACF-driven page clones.",
"changelog": "<h4>1.0.0</h4><ul><li>Первый релиз. MVP-ядро S1S7.</li></ul>"
}
}
```
- [ ] **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
<?php
declare(strict_types=1);
namespace WPMultiCity\Tests;
use Brain\Monkey\Functions;
use ReflectionClass;
use WPMultiCity\Plugin;
final class PluginBootS7Test extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$reflection = new ReflectionClass(Plugin::class);
$prop = $reflection->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 последнего релиза:
<https://git.netranking.ru/bryzgalov/wp-multi-city/releases>
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/<slug-страницы>/` → увидеть «Тюменский тариф».
Открыть `/<slug-страницы>/` без префикса → пусто (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
<?php
declare(strict_types=1);
namespace WPMultiCity\Tests;
use PHPUnit\Framework\TestCase as BaseTestCase;
final class ZipArchiveBuildTest extends BaseTestCase
{
private const PLUGIN_ROOT = __DIR__ . '/..';
private const ARCHIVE_PREFIX = 'wp-multi-city/';
public function test_git_archive_includes_runtime_files_excludes_dev_files(): void
{
$tmp = tempnam(sys_get_temp_dir(), 'wpmc-zip-');
unlink($tmp);
$zipPath = $tmp . '.zip';
$cmd = sprintf(
'cd %s && git archive HEAD --worktree-attributes --format=zip --prefix=%s -o %s 2>&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 <semver>
# 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 `= <version> =` 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 <semver> (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(/&/, "\\&amp;", s)
gsub(/</, "\\&lt;", s)
gsub(/>/, "\\&gt;", 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 "<li>" escape(pending) "</li>"; pending = "" }
if (ul_open) { html = html "</ul>"; ul_open = 0 }
match($0, /[0-9]+\.[0-9]+\.[0-9]+/)
ver = substr($0, RSTART, RLENGTH)
html = html "<h4>" ver "</h4><ul>"
ul_open = 1
next
}
/^\*[[:space:]]/ {
if (pending != "") { html = html "<li>" escape(pending) "</li>" }
sub(/^\*[[:space:]]+/, "")
pending = $0
next
}
/^[[:space:]]+[^[:space:]]/ {
if (pending != "") {
line = $0
sub(/^[[:space:]]+/, "", line)
pending = pending " " line
}
next
}
/^[[:space:]]*$/ {
if (pending != "") { html = html "<li>" escape(pending) "</li>"; pending = "" }
next
}
END {
if (pending != "") { html = html "<li>" escape(pending) "</li>" }
if (ul_open) { html = html "</ul>" }
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 <file>`.
- [ ] **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) |