Files
wp-multi-city/docs/superpowers/plans/2026-05-17-s7-release-puc.md
T
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

37 KiB
Raw Blame History

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
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
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
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
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:

{
  "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
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
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
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:

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

    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
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:

/**
 * 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
 */
defined('WPMC_VERSION')     || define('WPMC_VERSION', '1.0.0');

Apply via Edit tool (two replacements: Version: 0.1.0Version: 1.0.0 and add Tested up to: 6.9 line; '0.1.0''1.0.0' in constant).

  • Step 2: Verify with grep
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
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
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
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
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
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

# 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
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 со следующим содержимым:

# 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 темы:
    if (function_exists('wpmc_current_town')) {
        function current_town(): ?string { return wpmc_current_town(); }
    }
    
  5. Проверить: /tyumen/about/ отдаёт правильный page; меню префиксится; [shortcode_dmr] рендерит.

Testing

Automated (PHPUnit)

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

#!/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
chmod +x release.sh
  • Step 3: Sanity-test parse (without running — validate syntax)
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
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
find includes -name '*.php' -not -path '*/lib/*' -exec php -l {} \;

Expected: каждая строка вида No syntax errors detected in <file>.

  • Working tree clean
git status

Expected: nothing to commit, working tree clean.

  • Smoke git archive
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. Локально:
    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
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)