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