diff --git a/docs/superpowers/plans/2026-04-21-cf7stg-self-hosted-updates.md b/docs/superpowers/plans/2026-04-21-cf7stg-self-hosted-updates.md
new file mode 100644
index 0000000..285b233
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-21-cf7stg-self-hosted-updates.md
@@ -0,0 +1,1398 @@
+# CF7 Spam → Telegram — Self-Hosted Updates from Gitea (1.2.0) 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:** WordPress плагин `cf7-spam-to-telegram` получает self-hosted обновления из Gitea: v1.2.0 добавляет vendored Plugin Update Checker v5.6 + `wp-plugin-info.json` в корне репо + bash-скрипт `release.sh` для ручных релизов (bump → release commit → push → build zip → Gitea release → asset upload → regenerate JSON с real `download_url` → JSON commit → push).
+
+**Architecture:** PUC v5.6 (Generic JSON mode) ходит в raw URL `wp-plugin-info.json` на ветке `main` раз в 12ч и, при обнаружении новой версии, качает zip по `browser_download_url` (Gitea release asset). Релиз — два fast-forward коммита: первый с bump'ами (от него собран zip и взят tag), второй с обновлённым JSON содержащим реальный download_url.
+
+**Tech Stack:** PHP 7.4+ (плагин), PHPUnit 9.5 (shim-тесты), bash 4+ с `curl` / `jq` / `unzip` / `awk` / `sed` / `git archive` (релизный скрипт), Gitea API v1 (self-hosted на `git.netranking.ru`), Plugin Update Checker v5.6 (YahnisElsts).
+
+**Spec:** `docs/superpowers/specs/2026-04-21-cf7stg-self-hosted-updates-design.md` — читать до начала работы.
+
+---
+
+## File Structure
+
+### Create
+
+| Path | Responsibility |
+|---|---|
+| `includes/lib/plugin-update-checker/` (папка ~50 файлов) | Vendored PUC v5.6 целиком, без модификаций. Загрузчик: `plugin-update-checker.php`, register factory `YahnisElsts\PluginUpdateChecker\v5\PucFactory`. |
+| `wp-plugin-info.json` (корень) | Generic JSON metadata для PUC. Поля `version` / `download_url` / `last_updated` / `sections.changelog` регенерируются `release.sh`; остальные статичные. |
+| `release.sh` (корень, `chmod +x`) | Однокомандный релизный скрипт: bump → release commit → push → archive → Gitea release → asset upload → regenerate JSON → JSON commit → push. |
+| `tests/UpdateCheckerIntegrationTest.php` | Инварианты PUC + JSON: файл лоадера, класс фабрики, валидный JSON, version JSON == `CF7STG_VERSION`, download_url содержит asset name. |
+| `tests/ZipArchiveBuildTest.php` | `git archive HEAD --worktree-attributes` даёт zip с runtime-файлами, без dev-файлов. |
+
+### Modify
+
+| Path | Change |
+|---|---|
+| `includes/class-plugin.php` | Добавить `private static function register_update_checker()` + вызов `self::register_update_checker()` из `boot()`. |
+| `.gitattributes` | Дополнить список `export-ignore` до полного покрытия dev-файлов (см. Task 7). |
+| `.gitignore` | Добавить `dist/`. |
+| `readme.txt` | Добавить запись `= 1.2.0 =` в Changelog и bump `Stable tag` (bump делает `release.sh`, но changelog body пишется вручную до запуска). |
+
+---
+
+## Task 1: Vendor Plugin Update Checker v5.6
+
+**Files:**
+- Create: `includes/lib/plugin-update-checker/` (папка, ~50 файлов)
+
+- [ ] **Step 1: Download PUC v5.6 release archive**
+
+Run:
+```bash
+cd /tmp
+curl -fsSL https://github.com/YahnisElsts/plugin-update-checker/archive/refs/tags/v5.6.tar.gz -o puc-5.6.tar.gz
+tar -xzf puc-5.6.tar.gz
+ls plugin-update-checker-5.6/plugin-update-checker.php
+```
+
+Expected: файл существует (exit 0, вывод имени файла).
+
+- [ ] **Step 2: Copy PUC into plugin's vendored lib folder**
+
+Run (с корня проекта):
+```bash
+mkdir -p includes/lib
+cp -R /tmp/plugin-update-checker-5.6 includes/lib/plugin-update-checker
+ls includes/lib/plugin-update-checker/plugin-update-checker.php
+```
+
+Expected: файл виден по пути.
+
+- [ ] **Step 3: Verify loader path and namespace**
+
+Run:
+```bash
+grep -l "namespace YahnisElsts\\\\PluginUpdateChecker\\\\v5" includes/lib/plugin-update-checker/Puc/v5/PucFactory.php
+```
+
+Expected: печатает путь `includes/lib/plugin-update-checker/Puc/v5/PucFactory.php` — подтверждает что PucFactory в нужном namespace.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add includes/lib/plugin-update-checker
+git commit -m "chore(deps): vendor plugin-update-checker v5.6
+
+Source: https://github.com/YahnisElsts/plugin-update-checker/releases/tag/v5.6
+Used in 1.2.0 for self-hosted updates from Gitea (Generic JSON mode)."
+```
+
+---
+
+## Task 2: Test — PUC loader file exists + factory class loadable
+
+**Files:**
+- Create: `tests/UpdateCheckerIntegrationTest.php`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `tests/UpdateCheckerIntegrationTest.php`:
+
+```php
+1.1.0
- Silent drop: hard-rule/score threshold + dry-run mode, Settings block, Dashboard counter.
"
+ }
+}
+```
+
+- [ ] **Step 2: Verify JSON parses**
+
+Run:
+```bash
+php -r 'var_dump(json_decode(file_get_contents("wp-plugin-info.json"), true))' | head -5
+```
+
+Expected: вывод начинается с `array(11)` (11 ключей) — JSON валиден.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add wp-plugin-info.json
+git commit -m "feat(updates): add wp-plugin-info.json skeleton for PUC Generic JSON source"
+```
+
+---
+
+## Task 4: Tests — JSON valid, version matches plugin constant, download_url contains asset name
+
+**Files:**
+- Modify: `tests/UpdateCheckerIntegrationTest.php`
+
+- [ ] **Step 1: Append failing tests**
+
+Add to `tests/UpdateCheckerIntegrationTest.php` (inside the class):
+
+```php
+ private const WP_PLUGIN_INFO_JSON = self::PLUGIN_ROOT . '/wp-plugin-info.json';
+ private const PLUGIN_MAIN_FILE = self::PLUGIN_ROOT . '/cf7-spam-to-telegram.php';
+
+ public function test_wp_plugin_info_json_is_valid(): void
+ {
+ self::assertFileExists(self::WP_PLUGIN_INFO_JSON);
+ $data = json_decode(file_get_contents(self::WP_PLUGIN_INFO_JSON), true);
+ self::assertIsArray($data, 'wp-plugin-info.json must be valid JSON');
+ foreach (['name', 'slug', 'version', 'download_url', 'sections'] as $required) {
+ self::assertArrayHasKey($required, $data, "missing required key: $required");
+ }
+ self::assertSame('cf7-spam-to-telegram', $data['slug']);
+ self::assertIsArray($data['sections']);
+ self::assertArrayHasKey('description', $data['sections']);
+ self::assertArrayHasKey('changelog', $data['sections']);
+ }
+
+ public function test_wp_plugin_info_json_version_matches_plugin_constant(): void
+ {
+ $data = json_decode(file_get_contents(self::WP_PLUGIN_INFO_JSON), true);
+ $jsonVersion = $data['version'];
+
+ $phpSource = file_get_contents(self::PLUGIN_MAIN_FILE);
+ preg_match("/define\\('CF7STG_VERSION',\\s*'([^']+)'\\)/", $phpSource, $m);
+ self::assertNotEmpty($m[1] ?? null, 'CF7STG_VERSION constant must be defined in plugin main file');
+
+ self::assertSame(
+ $m[1],
+ $jsonVersion,
+ 'wp-plugin-info.json::version must match CF7STG_VERSION in cf7-spam-to-telegram.php'
+ );
+ }
+
+ public function test_wp_plugin_info_json_download_url_contains_expected_asset_name(): void
+ {
+ $data = json_decode(file_get_contents(self::WP_PLUGIN_INFO_JSON), true);
+ $expectedAssetName = 'cf7-spam-to-telegram-' . $data['version'] . '.zip';
+ self::assertStringContainsString(
+ $expectedAssetName,
+ $data['download_url'],
+ "download_url must contain expected asset name $expectedAssetName"
+ );
+ }
+```
+
+- [ ] **Step 2: Run the tests**
+
+Run:
+```bash
+./vendor/bin/phpunit --filter UpdateCheckerIntegrationTest
+```
+
+Expected: PASS (5 tests green) — skeleton JSON из Task 3 содержит `version: 1.1.0`, соответствует `CF7STG_VERSION`, и `download_url` содержит `cf7-spam-to-telegram-1.1.0.zip`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/UpdateCheckerIntegrationTest.php
+git commit -m "test(update-checker): wp-plugin-info.json validity + version/download_url invariants"
+```
+
+---
+
+## Task 5: Wire `register_update_checker()` into `Plugin::boot()`
+
+**Files:**
+- Modify: `includes/class-plugin.php` (add method, call from `boot()`)
+- Modify: `tests/UpdateCheckerIntegrationTest.php` (add reflection test)
+
+- [ ] **Step 1: Write failing reflection test**
+
+Append to `tests/UpdateCheckerIntegrationTest.php`:
+
+```php
+ public function test_plugin_class_has_register_update_checker_method(): void
+ {
+ self::assertTrue(
+ method_exists('\\Cf7stg\\Plugin', 'register_update_checker'),
+ 'Plugin class must expose register_update_checker()'
+ );
+ $ref = new \ReflectionMethod('\\Cf7stg\\Plugin', 'register_update_checker');
+ self::assertTrue($ref->isStatic(), 'register_update_checker must be static');
+ self::assertTrue($ref->isPrivate(), 'register_update_checker must be private');
+ }
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run:
+```bash
+./vendor/bin/phpunit --filter test_plugin_class_has_register_update_checker_method
+```
+
+Expected: FAIL — «Plugin class must expose register_update_checker()» (method doesn't exist yet).
+
+- [ ] **Step 3: Add method to `Plugin` class and call from `boot()`**
+
+Edit `includes/class-plugin.php`:
+
+Change the `boot()` method from:
+
+```php
+ public function boot(): void
+ {
+ // Inject silent-drop options into Classifier filter chain
+ Settings::register_classifier_filters();
+
+ // Register runtime hooks
+ Dispatcher::register_hooks();
+ (new CF7Source())->register();
+
+ // Admin
+ if (is_admin()) {
+ (new Settings_Page())->register();
+ (new Dashboard_Widget())->register();
+ }
+ }
+```
+
+To:
+
+```php
+ public function boot(): void
+ {
+ // Inject silent-drop options into Classifier filter chain
+ Settings::register_classifier_filters();
+
+ // Register runtime hooks
+ Dispatcher::register_hooks();
+ (new CF7Source())->register();
+
+ self::register_update_checker();
+
+ // Admin
+ if (is_admin()) {
+ (new Settings_Page())->register();
+ (new Dashboard_Widget())->register();
+ }
+ }
+
+ private static function register_update_checker(): void
+ {
+ $loader = CF7STG_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/cf7-spam-to-telegram/raw/branch/main/wp-plugin-info.json',
+ CF7STG_PLUGIN_FILE,
+ 'cf7-spam-to-telegram'
+ );
+ }
+```
+
+- [ ] **Step 4: Run the reflection test**
+
+Run:
+```bash
+./vendor/bin/phpunit --filter test_plugin_class_has_register_update_checker_method
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Run full test suite**
+
+Run:
+```bash
+./vendor/bin/phpunit
+```
+
+Expected: все тесты зелёные (существующие + 6 новых).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add includes/class-plugin.php tests/UpdateCheckerIntegrationTest.php
+git commit -m "feat(updates): wire PUC v5 registration into Plugin::boot()
+
+register_update_checker() guards on is_file + class_exists so the plugin
+degrades cleanly on installs that ship without the vendored lib (no update
+notices, but plugin core remains functional)."
+```
+
+---
+
+## Task 6: Add `dist/` to `.gitignore`
+
+**Files:**
+- Modify: `.gitignore`
+
+- [ ] **Step 1: Append `dist/` to .gitignore**
+
+Current `.gitignore` ends with:
+```
+.beads-credential-key
+```
+
+Add a new section:
+
+```
+/vendor/
+/node_modules/
+composer.lock
+.phpunit.result.cache
+.DS_Store
+*.log
+
+# Beads / Dolt files (added by bd init)
+.dolt/
+*.db
+.beads-credential-key
+
+# Release build artifact (release.sh creates dist/cf7-spam-to-telegram-.zip)
+dist/
+```
+
+- [ ] **Step 2: Verify git ignores dist/**
+
+Run:
+```bash
+mkdir -p dist && touch dist/probe.txt
+git status --porcelain | grep 'dist' || echo "ignored correctly"
+rm -rf dist
+```
+
+Expected: `ignored correctly` (git не видит новые файлы в dist/).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add .gitignore
+git commit -m "chore: ignore dist/ (release.sh build artifact)"
+```
+
+---
+
+## Task 7: `.gitattributes` — export-ignore for all dev files
+
+**Files:**
+- Modify: `.gitattributes`
+
+- [ ] **Step 1: Rewrite .gitattributes with full export-ignore coverage**
+
+Replace contents of `.gitattributes` with:
+
+```
+# Exclude dev files from `git archive` (used by release.sh to build release zip)
+/tests/ export-ignore
+/docs/ export-ignore
+/phpunit.xml export-ignore
+/composer.json export-ignore
+/composer.lock export-ignore
+/.phpunit.result.cache export-ignore
+/.beads/ export-ignore
+/.claude/ 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 —
+# it is required at runtime on the target WP site.
+```
+
+- [ ] **Step 2: Commit (archive verification happens in next task)**
+
+```bash
+git add .gitattributes
+git commit -m "chore: extend .gitattributes export-ignore to cover all dev files"
+```
+
+---
+
+## Task 8: Test — `git archive` includes runtime, excludes dev
+
+**Files:**
+- Create: `tests/ZipArchiveBuildTest.php`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `tests/ZipArchiveBuildTest.php`:
+
+```php
+&1',
+ escapeshellarg(self::PLUGIN_ROOT),
+ escapeshellarg($zip)
+ );
+ exec($cmd, $output, $rc);
+ if ($rc !== 0) {
+ self::fail("git archive failed: rc=$rc output=" . implode("\n", $output));
+ }
+ return $zip;
+ }
+
+ /** @return array */
+ private static function listZip(string $zipPath): array
+ {
+ exec('unzip -Z1 ' . escapeshellarg($zipPath), $list, $rc);
+ if ($rc !== 0) self::fail("unzip -Z1 failed: rc=$rc");
+ return $list;
+ }
+
+ public function test_git_archive_includes_runtime_files(): void
+ {
+ $zip = self::buildTestZip();
+ $entries = self::listZip($zip);
+ $joined = implode("\n", $entries);
+
+ self::assertStringContainsString('cf7-spam-to-telegram/cf7-spam-to-telegram.php', $joined);
+ self::assertStringContainsString('cf7-spam-to-telegram/includes/class-plugin.php', $joined);
+ self::assertStringContainsString('cf7-spam-to-telegram/includes/lib/plugin-update-checker/plugin-update-checker.php', $joined);
+ self::assertStringContainsString('cf7-spam-to-telegram/admin/', $joined);
+ self::assertStringContainsString('cf7-spam-to-telegram/wp-plugin-info.json', $joined);
+ self::assertStringContainsString('cf7-spam-to-telegram/readme.txt', $joined);
+
+ unlink($zip);
+ }
+
+ public function test_git_archive_excludes_dev_files(): void
+ {
+ $zip = self::buildTestZip();
+ $entries = self::listZip($zip);
+ $joined = implode("\n", $entries);
+
+ foreach (['tests/', 'docs/', 'composer.json', 'composer.lock', 'phpunit.xml', 'release.sh', '.gitattributes', '.gitignore', 'CLAUDE.md', 'AGENTS.md'] as $devPath) {
+ self::assertStringNotContainsString(
+ "cf7-spam-to-telegram/$devPath",
+ $joined,
+ "dev file must be excluded: $devPath"
+ );
+ }
+
+ unlink($zip);
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests**
+
+Run:
+```bash
+./vendor/bin/phpunit --filter ZipArchiveBuildTest
+```
+
+Expected: PASS. Note: `release.sh` ещё не существует — тест `test_git_archive_excludes_dev_files` проверяет его отсутствие, но ассерт `StringNotContainsString` пройдёт в обоих случаях (есть/нет). Цель тестов — защита от случайного добавления dev-файлов в archive в будущем.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/ZipArchiveBuildTest.php
+git commit -m "test(archive): git archive includes runtime files, excludes dev files"
+```
+
+---
+
+## Task 9: `release.sh` — skeleton + validate step
+
+**Files:**
+- Create: `release.sh`
+
+- [ ] **Step 1: Create release.sh with shebang, set -e, and validate step**
+
+Create `release.sh` (корень):
+
+```bash
+#!/usr/bin/env bash
+#
+# release.sh — Release helper for cf7-spam-to-telegram.
+#
+# Usage: ./release.sh
+# Example: ./release.sh 1.2.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-04-21-cf7stg-self-hosted-updates-design.md
+# for the full design rationale.
+
+set -euo pipefail
+
+REPO_OWNER="bryzgalov"
+REPO_NAME="cf7-spam-to-telegram"
+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.2.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)"
+
+[[ -z "$(git status --porcelain)" ]] \
+ || fail "working tree not clean (commit/stash first):\n$(git status --porcelain)"
+
+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
+
+# readme.txt must contain the changelog block for this version
+grep -qE "^=\\s*${VERSION}\\s*=\\s*$" 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"
+log "TODO: remaining steps implemented in subsequent tasks"
+exit 0
+```
+
+Make executable:
+
+```bash
+chmod +x release.sh
+```
+
+- [ ] **Step 2: Smoke-test validate with bad input**
+
+Run:
+```bash
+./release.sh # no arg
+./release.sh 1.2 # invalid semver
+./release.sh 1.2.0 # should pass validation (if readme.txt lacks section — fails on readme check)
+```
+
+Expected:
+- No arg → `FAIL: usage: ./release.sh ...` (exit 1)
+- `1.2` → `FAIL: invalid semver: '1.2'` (exit 1)
+- `1.2.0` → likely fails on «readme.txt does not contain '= 1.2.0 ='» (we haven't added it yet — that's Task 17). Validation step works, real bump won't proceed.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add release.sh
+git commit -m "feat(release): release.sh skeleton + validate step
+
+Checks semver, required CLI tools, ~/.gitea-token, branch=main,
+clean tree, sync with origin, tag-not-exists, readme.txt changelog entry."
+```
+
+---
+
+## Task 10: `release.sh` — bump step
+
+**Files:**
+- Modify: `release.sh`
+
+- [ ] **Step 1: Append bump step (after validation, before commit)**
+
+Replace the final `log "TODO: remaining steps ..."; exit 0` in `release.sh` with:
+
+```bash
+# ---------- STEP 2: BUMP VERSION ---------------------------------------------
+
+log "Bumping version to $VERSION in cf7-spam-to-telegram.php and readme.txt"
+
+# cf7-spam-to-telegram.php — bump header `Version:` and define CF7STG_VERSION
+sed -i.bak -E \
+ -e "s|^(\\s*\\*\\s*Version:\\s*).*$|\\1${VERSION}|" \
+ -e "s|define\\('CF7STG_VERSION',\\s*'[^']+'\\)|define('CF7STG_VERSION', '${VERSION}')|" \
+ cf7-spam-to-telegram.php
+rm cf7-spam-to-telegram.php.bak
+
+grep -q "^ \\* Version: ${VERSION}$" cf7-spam-to-telegram.php \
+ || fail "bump failed: header Version in cf7-spam-to-telegram.php"
+grep -q "define('CF7STG_VERSION', '${VERSION}')" cf7-spam-to-telegram.php \
+ || fail "bump failed: CF7STG_VERSION constant in cf7-spam-to-telegram.php"
+
+# readme.txt — bump `Stable tag:`
+sed -i.bak -E "s|^(Stable tag:\\s*).*$|\\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"
+log "TODO: remaining steps implemented in subsequent tasks"
+exit 0
+```
+
+- [ ] **Step 2: Smoke-test bump**
+
+Ensure readme.txt has `= 0.0.1 =` block first (dummy, will be removed after test):
+
+```bash
+# Temp workaround: add dummy changelog entry
+sed -i.bak '/^== Changelog ==$/a\
+\
+= 0.0.1 =\
+* Dummy smoke-test entry.' readme.txt
+rm readme.txt.bak
+
+./release.sh 0.0.1
+
+grep "^ \\* Version: 0.0.1$" cf7-spam-to-telegram.php && \
+grep "CF7STG_VERSION', '0.0.1'" cf7-spam-to-telegram.php && \
+grep "^Stable tag: 0.0.1$" readme.txt && echo "bump OK"
+
+# Rollback
+git checkout cf7-spam-to-telegram.php readme.txt
+```
+
+Expected: `bump OK` printed, then files restored.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add release.sh
+git commit -m "feat(release): bump step for cf7-spam-to-telegram.php and readme.txt"
+```
+
+---
+
+## Task 11: `release.sh` — release commit + push step
+
+**Files:**
+- Modify: `release.sh`
+
+- [ ] **Step 1: Append release-commit + push step**
+
+Replace final `log "TODO: remaining ..."; exit 0` with:
+
+```bash
+# ---------- STEP 3: RELEASE COMMIT + PUSH ------------------------------------
+
+log "Creating release commit"
+git add cf7-spam-to-telegram.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
+
+log "TODO: remaining steps implemented in subsequent tasks"
+exit 0
+```
+
+- [ ] **Step 2: Note on testing**
+
+We cannot safely smoke-test this step without actually pushing. Leave untested until Task 18 (real release run). The logic is trivial (3 git commands); regression protected by the final release verification.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add release.sh
+git commit -m "feat(release): release-commit + push step; capture RELEASE_SHA for Gitea API"
+```
+
+---
+
+## Task 12: `release.sh` — build zip + sanity checks
+
+**Files:**
+- Modify: `release.sh`
+
+- [ ] **Step 1: Append archive + sanity-check step**
+
+Replace final `log "TODO: remaining ..."; exit 0` with:
+
+```bash
+# ---------- STEP 4: BUILD ZIP + SANITY ---------------------------------------
+
+ZIP_NAME="cf7-spam-to-telegram-${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=cf7-spam-to-telegram/ \
+ -o "$ZIP_PATH"
+
+log "Running zip sanity checks"
+
+# Correct version embedded
+unzip -p "$ZIP_PATH" cf7-spam-to-telegram/cf7-spam-to-telegram.php \
+ | grep -q "^ \\* Version: ${VERSION}$" \
+ || fail "zip sanity: header Version mismatch inside $ZIP_PATH"
+
+unzip -p "$ZIP_PATH" cf7-spam-to-telegram/cf7-spam-to-telegram.php \
+ | grep -q "CF7STG_VERSION', '${VERSION}'" \
+ || fail "zip sanity: CF7STG_VERSION mismatch inside $ZIP_PATH"
+
+# Runtime dependencies present
+unzip -l "$ZIP_PATH" | grep -q "cf7-spam-to-telegram/includes/lib/plugin-update-checker/plugin-update-checker.php" \
+ || fail "zip sanity: PUC loader missing from $ZIP_PATH"
+
+unzip -l "$ZIP_PATH" | grep -q "cf7-spam-to-telegram/wp-plugin-info.json" \
+ || fail "zip sanity: wp-plugin-info.json missing from $ZIP_PATH"
+
+# Dev files absent
+for dev in tests/ docs/ composer.json composer.lock phpunit.xml release.sh CLAUDE.md AGENTS.md .gitattributes .gitignore; do
+ if unzip -l "$ZIP_PATH" | grep -q "cf7-spam-to-telegram/${dev}"; 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)"
+log "TODO: remaining steps implemented in subsequent tasks"
+exit 0
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add release.sh
+git commit -m "feat(release): build zip via git archive + sanity checks (version, runtime files, no dev files)"
+```
+
+---
+
+## Task 13: `release.sh` — Gitea API create release
+
+**Files:**
+- Modify: `release.sh`
+
+- [ ] **Step 1: Append create-release step**
+
+Replace final `log "TODO: remaining ..."; exit 0` with:
+
+```bash
+# ---------- STEP 5: GITEA CREATE RELEASE -------------------------------------
+
+log "Extracting changelog body for version $VERSION from readme.txt"
+
+# Extract lines between `= VERSION =` and the next `= ` line (or end of file)
+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"
+
+# jq builds the JSON payload with proper escaping
+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="$(echo "$CREATE_RESPONSE" | jq -r '.id')"
+[[ -n "$RELEASE_ID" && "$RELEASE_ID" != "null" ]] \
+ || fail "Gitea create-release response missing id: $CREATE_RESPONSE"
+
+log "Created Gitea release id=$RELEASE_ID"
+log "TODO: remaining steps implemented in subsequent tasks"
+exit 0
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add release.sh
+git commit -m "feat(release): Gitea API create-release with changelog body extracted from readme.txt"
+```
+
+---
+
+## Task 14: `release.sh` — upload asset
+
+**Files:**
+- Modify: `release.sh`
+
+- [ ] **Step 1: Append upload-asset step**
+
+Replace final `log "TODO: remaining ..."; exit 0` with:
+
+```bash
+# ---------- STEP 6: UPLOAD ASSET ---------------------------------------------
+
+log "Uploading asset $ZIP_NAME to release id=$RELEASE_ID"
+
+UPLOAD_RESPONSE="$(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")"
+
+DOWNLOAD_URL="$(echo "$UPLOAD_RESPONSE" | jq -r '.browser_download_url')"
+[[ -n "$DOWNLOAD_URL" && "$DOWNLOAD_URL" != "null" ]] \
+ || fail "Gitea asset upload response missing browser_download_url: $UPLOAD_RESPONSE"
+
+log "Asset uploaded: $DOWNLOAD_URL"
+log "TODO: remaining steps implemented in subsequent tasks"
+exit 0
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add release.sh
+git commit -m "feat(release): Gitea API upload asset; capture browser_download_url"
+```
+
+---
+
+## Task 15: `release.sh` — regenerate `wp-plugin-info.json` with changelog-to-HTML
+
+**Files:**
+- Modify: `release.sh`
+
+Note: самая сложная часть. Алгоритм конвертации блока `== Changelog ==` из `readme.txt` в HTML:
+- `= X.Y.Z =` (строка) → `X.Y.Z
` (и закрываем предыдущий `
` если был)
+- строка, начинающаяся с `* ` → `ESCAPE(text)`
+- прочие строки (продолжение li) → склеиваем пробелом к предыдущему li
+- пустая строка между версиями → закрыть ``
+
+- [ ] **Step 1: Append regenerate-JSON step**
+
+Replace final `log "TODO: remaining ..."; exit 0` with:
+
+```bash
+# ---------- 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
+
+# Sanity: check JSON still valid
+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)"
+log "TODO: remaining steps implemented in subsequent tasks"
+exit 0
+```
+
+- [ ] **Step 2: Unit-test the awk conversion in isolation**
+
+Create a tmp readme snippet and verify:
+
+```bash
+cat > /tmp/test-readme.txt <<'EOF'
+== Changelog ==
+
+= 1.2.0 =
+* New: Self-hosted updates.
+ Second line continues.
+* Dev: release.sh.
+
+= 1.1.0 =
+* New: Silent drop.
+
+= 1.0.0 =
+* First release.
+EOF
+
+# Extract the awk block from release.sh (lines with `awk '` … `' readme.txt`)
+# OR manually test with an inlined awk (copy from step 1 above, swap readme.txt → /tmp/test-readme.txt):
+
+bash -c '
+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
+ }
+'"'"' /tmp/test-readme.txt
+'
+```
+
+Expected output:
+```
+1.2.0
- New: Self-hosted updates. Second line continues.
- Dev: release.sh.
1.1.0
1.0.0
+```
+
+Cleanup: `rm /tmp/test-readme.txt`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add release.sh
+git commit -m "feat(release): regenerate wp-plugin-info.json with changelog-to-HTML conversion"
+```
+
+---
+
+## Task 16: `release.sh` — JSON commit + push + echo summary
+
+**Files:**
+- Modify: `release.sh`
+
+- [ ] **Step 1: Replace final `log "TODO ..."; exit 0` with JSON commit step and summary**
+
+```bash
+# ---------- 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)\n' "$TAG" "$GITEA_BASE/${REPO_OWNER}/${REPO_NAME}/src/tag/${TAG}"
+printf ' Release page : %s\n' "${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${TAG}"
+printf ' Asset : %s\n' "$DOWNLOAD_URL"
+printf ' PUC JSON : %s/${REPO_OWNER}/${REPO_NAME}/raw/branch/main/wp-plugin-info.json\n' "$GITEA_BASE"
+printf '\n'
+printf ' Next: in WordPress admin — «Плагины → Проверить обновления» (PUC re-checks hourly, force-check via transient delete if impatient).\n'
+printf '\n'
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add release.sh
+git commit -m "feat(release): JSON commit + push + summary echo
+
+release.sh is now feature-complete:
+ validate → bump → release commit + push → build zip → Gitea create release →
+ upload asset → regenerate wp-plugin-info.json → JSON commit + push → summary"
+```
+
+---
+
+## Task 17: Write `= 1.2.0 =` body in readme.txt
+
+**Files:**
+- Modify: `readme.txt` (changelog section only; Stable tag bump делает release.sh)
+
+- [ ] **Step 1: Insert new changelog entry at top of Changelog section**
+
+Edit `readme.txt`, find this line:
+```
+== Changelog ==
+
+= 1.1.0 =
+```
+
+Replace with:
+
+```
+== Changelog ==
+
+= 1.2.0 =
+* New: Self-hosted plugin updates from Gitea via Plugin Update Checker v5.6.
+ WordPress admin shows updates automatically (checked hourly). Source:
+ https://git.netranking.ru/bryzgalov/cf7-spam-to-telegram/raw/branch/main/wp-plugin-info.json
+* Dev: release.sh — one-shot release script (bump → release commit → push →
+ build clean zip via git archive --worktree-attributes → Gitea release + asset
+ upload → regenerate wp-plugin-info.json with actual download_url → JSON commit
+ + push). Two fast-forward commits per release, no force-push.
+* Dev: .gitattributes export-ignore covers tests/, docs/, composer.*, phpunit.xml,
+ .beads/, .claude/, CLAUDE.md, AGENTS.md, release.sh, dist/, .git* — git archive
+ produces clean zip without dev files.
+* Dev: .gitignore adds dist/ for local release build artifact.
+
+= 1.1.0 =
+```
+
+- [ ] **Step 2: Verify readme contains the new block**
+
+Run:
+```bash
+grep -c '^= 1.2.0 =$' readme.txt
+```
+
+Expected: `1`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add readme.txt
+git commit -m "docs(readme): add 1.2.0 changelog entry
+
+Release body that release.sh will extract and POST to Gitea /releases API."
+```
+
+---
+
+## Task 18: Run `./release.sh 1.2.0` — actual release
+
+**Files:** none (execution only)
+
+Pre-checks before running:
+- `bd ready` shows tasks complete
+- All tests pass (`./vendor/bin/phpunit` green)
+- `git status` clean
+- `git log --oneline -n 15` shows all prior tasks committed and on `main`
+- `~/.gitea-token` exists with `write:repository` scope
+
+- [ ] **Step 1: Confirm prereqs**
+
+Run:
+```bash
+./vendor/bin/phpunit
+git status --porcelain
+git log --oneline -n 5
+ls -la ~/.gitea-token
+```
+
+Expected:
+- PHPUnit: all green
+- Status: empty
+- Log: shows Task 17 commit on top
+- Token file: `-rw-------` (mode 600)
+
+- [ ] **Step 2: Run release**
+
+Run:
+```bash
+./release.sh 1.2.0
+```
+
+Expected: script runs through all 9 steps, final summary prints URLs.
+
+- [ ] **Step 3: Verify Gitea release**
+
+Run:
+```bash
+curl -fsSL "https://git.netranking.ru/api/v1/repos/bryzgalov/cf7-spam-to-telegram/releases/tags/v1.2.0" | jq '{tag: .tag_name, name: .name, assets: [.assets[].name]}'
+```
+
+Expected: JSON showing `tag: v1.2.0`, at least one asset matching `cf7-spam-to-telegram-1.2.0.zip`.
+
+- [ ] **Step 4: Verify raw JSON is fresh**
+
+Run:
+```bash
+curl -fsSL "https://git.netranking.ru/bryzgalov/cf7-spam-to-telegram/raw/branch/main/wp-plugin-info.json" | jq '{version, download_url, last_updated}'
+```
+
+Expected: `version: "1.2.0"`, `download_url` non-empty (points to uploaded asset), `last_updated` with today's timestamp.
+
+- [ ] **Step 5: Verify asset is downloadable anonymously**
+
+Run:
+```bash
+DOWNLOAD_URL="$(curl -fsSL https://git.netranking.ru/bryzgalov/cf7-spam-to-telegram/raw/branch/main/wp-plugin-info.json | jq -r .download_url)"
+curl -fsSLI "$DOWNLOAD_URL" | head -5
+```
+
+Expected: `HTTP/2 200`, content-type `application/zip`.
+
+- [ ] **Step 6: No separate commit — release.sh already pushed two commits**
+
+Confirm:
+```bash
+git log --oneline -n 3
+```
+
+Expected: top two commits are `wp-plugin-info.json: update for 1.2.0` and `release: 1.2.0`.
+
+---
+
+## Task 19: Deploy 1.2.0 to домработница.рус (one last time via rsync), verify PUC activation
+
+**Files:** none locally; deployment only.
+
+Из MEMORY / CLAUDE.md context: сайт домработница.рус развёрнут на Beget (SSH alias `sidelkin-ssh`), путь `domramotnica.rus/public_html`, WP 6.9.4, тема `domrabotnica`, WP CLI php8.3, wps-hide-login активен (WP admin URL нужно уточнить у пользователя).
+
+- [ ] **Step 1: Copy dist zip to server**
+
+Run (с локальной машины):
+```bash
+# build фактически уже есть от Task 18 run
+ls dist/cf7-spam-to-telegram-1.2.0.zip
+scp dist/cf7-spam-to-telegram-1.2.0.zip sidelkin-ssh:~/
+```
+
+Expected: file appears on remote home dir.
+
+- [ ] **Step 2: Backup current plugin, then unzip new version**
+
+On server (ask user to run or run via ssh):
+
+```bash
+ssh sidelkin-ssh bash -lc '
+ set -e
+ cd domramotnica.rus/public_html/wp-content/plugins
+ mv cf7-spam-to-telegram cf7-spam-to-telegram.bak-$(date +%s)
+ unzip ~/cf7-spam-to-telegram-1.2.0.zip
+ ls -la cf7-spam-to-telegram/cf7-spam-to-telegram.php cf7-spam-to-telegram/includes/lib/plugin-update-checker/plugin-update-checker.php
+'
+```
+
+Expected: both files listed; no errors.
+
+- [ ] **Step 3: Verify plugin version via WP-CLI**
+
+```bash
+ssh sidelkin-ssh 'cd domramotnica.rus/public_html && php8.3 $(which wp) plugin get cf7-spam-to-telegram --field=version'
+```
+
+Expected: `1.2.0`.
+
+- [ ] **Step 4: Force PUC to check immediately**
+
+```bash
+ssh sidelkin-ssh 'cd domramotnica.rus/public_html && php8.3 $(which wp) transient delete --all'
+ssh sidelkin-ssh 'cd domramotnica.rus/public_html && php8.3 $(which wp) plugin update --dry-run cf7-spam-to-telegram'
+```
+
+Expected: dry-run shows «No updates available» for 1.2.0 (since we just deployed 1.2.0 which matches remote JSON). This confirms PUC successfully reads the JSON.
+
+- [ ] **Step 5: Manual browser verification (Plugins page)**
+
+Ask user to:
+1. Login to домработница.рус WP admin.
+2. Plugins → "Check again" (link near the top).
+3. Verify: no update notice for CF7 Spam → Telegram (because we're on 1.2.0 and remote JSON = 1.2.0).
+4. Click «View version details» for the plugin — should show modal with our changelog HTML rendered.
+
+Expected: all steps pass; user confirms.
+
+- [ ] **Step 6: Cleanup backup (after 24h of operation)**
+
+Not part of this task; after a day of verifying 1.2.0 works, ask user if OK to remove the `.bak-` folder:
+
+```bash
+ssh sidelkin-ssh 'rm -rf domramotnica.rus/public_html/wp-content/plugins/cf7-spam-to-telegram.bak-*'
+```
+
+---
+
+## Completion checklist
+
+After all tasks:
+
+- [ ] `./vendor/bin/phpunit` all green locally
+- [ ] `git log --oneline` on `main` shows `release: 1.2.0` + `wp-plugin-info.json: update for 1.2.0` at top
+- [ ] `https://git.netranking.ru/bryzgalov/cf7-spam-to-telegram/releases/tag/v1.2.0` exists with a downloadable `.zip` asset
+- [ ] Raw `wp-plugin-info.json` on `main` has `version=1.2.0`, real `download_url`, and full HTML changelog
+- [ ] домработница.рус running 1.2.0, PUC dry-run reports «no updates» (i.e. PUC sees the remote JSON)
+- [ ] `bd close cf7stg-5wy.1 --reason "Spec finalized + plan written + implemented"` (via parent epic close)
+- [ ] `bd close cf7stg-5wy --reason "Self-hosted updates shipped in 1.2.0"`
+
+---
+
+## Notes for the implementer
+
+- **Do not skip TDD:** every code task has a test-first step. `release.sh` is the exception (bash is hard to unit-test) — smoke-tests between steps suffice.
+- **Commit frequently:** after each task. Don't batch.
+- **If a step fails:** do NOT add fallback / error-handling / retry logic beyond what's specified. The spec and plan are intentionally minimal; ask the user before expanding scope.
+- **If PUC tests fail because of unexpected WP function calls** during `require_once plugin-update-checker.php`: the tests only require the file + check factory class exists. They do NOT instantiate PUC. If the `require_once` itself triggers WP calls — investigate with `superpowers:systematic-debugging` before adding shims.
+- **Changelog HTML conversion (Task 15):** the awk script handles the current `readme.txt` format exactly. If someone later writes a non-standard changelog entry (e.g. nested lists with `**`), enhance the script in a separate commit, not during 1.2.0 release.
diff --git a/docs/superpowers/specs/2026-04-21-cf7stg-self-hosted-updates-design.md b/docs/superpowers/specs/2026-04-21-cf7stg-self-hosted-updates-design.md
index cdb8bfa..747fa5a 100644
--- a/docs/superpowers/specs/2026-04-21-cf7stg-self-hosted-updates-design.md
+++ b/docs/superpowers/specs/2026-04-21-cf7stg-self-hosted-updates-design.md
@@ -1,35 +1,34 @@
-# CF7 Spam → Telegram — Self-Hosted Updates from Gitea (1.2.0) — DRAFT
+# CF7 Spam → Telegram — Self-Hosted Updates from Gitea (1.2.0)
-**Статус:** DRAFT — дизайн обсуждён в брейнсторме, пользователь подтвердил разделы 1 и 2. Не финализировано (нет финального spec-документа без суффикса DRAFT, нет implementation plan, нет beads-подзадач).
-
-**Что нужно сделать в новой сессии:**
-
-1. Перечитать этот документ.
-2. Спросить пользователя: «По дизайну self-hosted updates (1.2.0) всё ок? Если да — финализирую spec (без DRAFT), пробегусь по self-review, попрошу глянуть, потом writing-plans».
-3. Если ок — `git mv ...DRAFT.md` в финальное имя `2026-04-21-cf7stg-self-hosted-updates-design.md`, повторить self-review, попросить пользователя глянуть финал, потом invoke `superpowers:writing-plans`.
-
----
+**Статус:** FINAL — дизайн подтверждён пользователем (в т.ч. схема release.sh с пост-upload bump `download_url`, changelog-to-HTML конвертация, PUC версия v5.6). Open questions закрыты:
+- PUC pinned to **v5.6** (GitHub latest на 2026-04-21).
+- Gitea репо `bryzgalov/cf7-spam-to-telegram` — публичный, raw URL отдаёт корректные `ETag` / `Last-Modified` / `Cache-Control: public, max-age=21600` → 304 поддерживается.
+- `download_url` в `wp-plugin-info.json` не угадывается — `release.sh` берёт точный `browser_download_url` из ответа Gitea API после upload asset, затем делает финальный commit + tag + push.
+- Changelog преобразуется из `readme.txt` блока `== Changelog ==` в HTML (`= 1.2.0 =` → ``, `* item` → `
`) внутри `release.sh`.
+- Минификация vendored PUC — не делаем в 1.2.0 (nit, откладываем до фактической необходимости).
## Контекст
**Trigger:** только что выпустили v1.1.0 (silent drop). Деплой 1.1.0 на домработница.рус сделан вручную через `rsync`. Этот же плагин будет деплоиться на другие сайты (washanyanya.ru, потенциально другие). Хочется, чтобы WP-сайты сами получали обновления через стандартный «Плагины → Обновления», без `rsync` и без знания внутренностей.
-**Решено в брейнсторме:**
-- Gitea репо `bryzgalov/cf7-spam-to-telegram` сделан **публичным** (пользователь подтвердил). В коде нет секретов.
-- Используем **Plugin Update Checker v5** от YahnisElsts, vendored в `includes/lib/plugin-update-checker/`.
+**Решено в брейнсторме и пост-брейнсторме:**
+- Gitea репо `bryzgalov/cf7-spam-to-telegram` — публичный (подтверждено через `GET /api/v1/repos/...` → `"visibility":"public"`). В коде нет секретов.
+- Используем **Plugin Update Checker v5.6** от YahnisElsts (https://github.com/YahnisElsts/plugin-update-checker/releases/tag/v5.6), vendored в `includes/lib/plugin-update-checker/`.
- В режиме «Generic JSON server»: PUC ходит в статичный `wp-plugin-info.json` в корне репо (через Gitea raw URL).
+- Raw URL отдаёт `ETag` / `Last-Modified` / `Cache-Control: public, max-age=21600` → PUC с conditional GET получает 304 на неизменный JSON.
- Zip-релизы хранятся как Gitea Release assets (создаются вручную через скрипт `release.sh`, чистые благодаря `.gitattributes export-ignore`).
+- `download_url` в JSON — не захардкожен, а берётся из `browser_download_url` ответа Gitea API после asset upload (см. раздел «release.sh» ниже).
## Scope
**В scope:**
-- Vendored PUC v5 в `includes/lib/plugin-update-checker/`.
+- Vendored PUC **v5.6** в `includes/lib/plugin-update-checker/`.
- Файл `wp-plugin-info.json` в корне репо (Generic JSON для PUC).
- Инициализация PUC в `Plugin::boot()` через `register_update_checker()`.
-- `.gitattributes` с `export-ignore` для всех dev-файлов.
-- Bash-скрипт `release.sh` (bump version → commit → tag → push → build clean zip → Gitea Release + asset upload).
-- Минимальные тесты на инвариант (PUC файл существует, JSON валидный, version в JSON совпадает с константой плагина).
-- Версия 1.2.0 + changelog.
+- `.gitattributes` с `export-ignore` для всех dev-файлов; `.gitignore` с `dist/`.
+- Bash-скрипт `release.sh` (bump → release-commit → push → archive → Gitea release → asset upload → regenerate JSON → json-commit → push → tag → push tag).
+- Минимальные тесты на инвариант (PUC файл существует, JSON валидный, version в JSON совпадает с константой плагина, `download_url` ссылается на ожидаемый asset).
+- Версия 1.2.0 + changelog в `readme.txt` и в `wp-plugin-info.json` (HTML).
**Вне scope:**
- Кастомный `GiteaApi extends Api` (не нужен — публичный JSON).
@@ -44,15 +43,26 @@
```
Локальная разработка
↓ ./release.sh 1.2.0
-git push origin main --tags
-git archive --worktree-attributes → cf7-spam-to-telegram-1.2.0.zip
-POST Gitea API /releases → создаёт Release объект
-POST Gitea API /releases/{id}/assets → uploads zip
+ 1. bump cf7-spam-to-telegram.php + readme.txt
+ 2. commit "release: 1.2.0" (tmp — без обновлённого JSON)
+ 3. git push origin main ← чтобы sha был доступен Gitea для target_commitish
+ 4. build zip: git archive HEAD --worktree-attributes → dist/cf7-spam-to-telegram-1.2.0.zip
+ (zip содержит bumped cf7-spam-to-telegram.php и old wp-plugin-info.json — это ок,
+ WP не использует JSON из zip, только live raw URL)
+ 5. POST Gitea /releases { tag_name: v1.2.0, target_commitish: HEAD sha } → release_id (Gitea создаёт тег на sha)
+ 6. POST Gitea /releases/{id}/assets (zip) → browser_download_url
+ 7. регенерировать wp-plugin-info.json (version + download_url + last_updated + changelog HTML)
+ 8. commit "wp-plugin-info.json: update for 1.2.0" + git push origin main
+ (второй коммит — содержит финальный JSON с реальным download_url.
+ ⚠ Тег v1.2.0 указывает на ПЕРВЫЙ коммит (release commit из step 2-3) —
+ на него же ссылается release entry и собран zip asset. Это корректно:
+ zip и tag согласованы; JSON на main живёт «рядом с тегом», обновлён на второй коммит.)
WP-сайт (плагин 1.2.x уже установлен)
↓ Plugin::boot() → register_update_checker()
- ↓ PUC раз в 12 часов (стандартный WP transient)
+ ↓ PUC раз в 12 часов (WP cron / transient)
GET https://git.netranking.ru/bryzgalov/cf7-spam-to-telegram/raw/branch/main/wp-plugin-info.json
+ (conditional: If-None-Match / If-Modified-Since → 304 если не менялся)
↓ парсит, сравнивает version с CF7STG_VERSION
↓ если новее → WP «Плагины → Обновления» показывает кнопку «Обновить»
WP качает zip по download_url (Gitea release asset, public, без auth)
@@ -60,12 +70,21 @@ WP качает zip по download_url (Gitea release asset, public, без auth)
Плагин обновлён.
```
+**Почему два коммита, а не один:** `git archive` читает из commit/tree, не из working dir. Чтобы zip содержал правильный `Version:` — bumps должны быть в коммите. После upload мы знаем `download_url` и добавляем его в JSON — это второй коммит. Альтернатива `git commit --amend --force-push` работает, но делает force-push на `main` — нежелательно. Два fast-forward коммита проще и безопаснее.
+
+**Инвариант (после step 8, на ветке `main`):**
+- `wp-plugin-info.json::version` == `cf7-spam-to-telegram.php::CF7STG_VERSION` == `1.2.0`
+- tag `v1.2.0` существует (указывает на release commit, в котором `cf7-spam-to-telegram.php` = 1.2.0)
+- `download_url` в JSON = реальный Gitea asset для 1.2.0 (проверяемо HEAD-запросом)
+
+Тест `test_wp_plugin_info_json_version_matches_plugin_constant` + проверка `download_url` ловят регрессию «release.sh упал между steps 3 и 8» (zip + tag уже запушены, JSON ещё не обновлён, локальный state рассогласован с remote).
+
## Изменяемые файлы
| Файл | Что |
|---|---|
-| `includes/lib/plugin-update-checker/` (новая папка, ~50 файлов) | Распакованный PUC v5 release с https://github.com/YahnisElsts/plugin-update-checker. Закоммичен целиком. |
-| `wp-plugin-info.json` (новый, корень репо) | Статичная metadata, формат Generic JSON для PUC. |
+| `includes/lib/plugin-update-checker/` (новая папка, ~50 файлов) | PUC v5.6, распакованный из https://github.com/YahnisElsts/plugin-update-checker/archive/refs/tags/v5.6.zip. Закоммичен целиком, без минификации. |
+| `wp-plugin-info.json` (новый, корень репо) | Metadata для PUC (Generic JSON format). Регенерируется `release.sh` — в git попадает каждый раз с актуальными `version`/`download_url`/`last_updated`/`sections.changelog`. |
| `includes/class-plugin.php` | В `boot()` добавляется `self::register_update_checker()`. Новый private method. |
| `.gitattributes` (изменяется) | `export-ignore` правила для всех dev-файлов. |
| `release.sh` (новый, корень, `chmod +x`) | Bash-скрипт релиза. |
@@ -75,12 +94,14 @@ WP качает zip по download_url (Gitea release asset, public, без auth)
## `wp-plugin-info.json` — структура
+Пример финального файла (значения иллюстративные — `download_url`, `last_updated`, `sections.changelog` регенерируются `release.sh`):
+
```json
{
"name": "CF7 Spam → Telegram",
"slug": "cf7-spam-to-telegram",
"version": "1.2.0",
- "download_url": "https://git.netranking.ru/bryzgalov/cf7-spam-to-telegram/releases/download/v1.2.0/cf7-spam-to-telegram-1.2.0.zip",
+ "download_url": "",
"homepage": "https://git.netranking.ru/bryzgalov/cf7-spam-to-telegram",
"requires": "6.0",
"tested": "6.9",
@@ -89,11 +110,13 @@ WP качает zip по download_url (Gitea release asset, public, без auth)
"author": "Vladimir Bryzgalov",
"sections": {
"description": "Forwards CF7 spam submissions to Telegram with heuristic classification (client/unclear/spam/drop labels).",
- "changelog": "<извлечённый из readme.txt блок == Changelog == ==>"
+ "changelog": "1.2.0
- New: Self-hosted updates from Gitea ...
1.1.0
..."
}
}
```
+Поля `name`, `slug`, `homepage`, `requires`, `tested`, `requires_php`, `author`, `sections.description` — статичные (правим только при реальном изменении, не при каждом релизе). Поля `version`, `download_url`, `last_updated`, `sections.changelog` — генерируются `release.sh`.
+
## `Plugin::boot()` изменение
```php
@@ -129,18 +152,83 @@ private static function register_update_checker(): void
```
release.sh 1.2.0 →
- 1. validate (semver, clean tree, на main, есть ~/.gitea-token)
- 2. bump version в cf7-spam-to-telegram.php (header + define) и readme.txt (Stable tag)
- 3. update wp-plugin-info.json (version, download_url, last_updated, sections.changelog ← из readme.txt)
- 4. git add + git commit -m "release: 1.2.0" + git tag v1.2.0 + git push origin main --tags
- 5. mkdir -p dist
- git archive --worktree-attributes --format=zip --prefix=cf7-spam-to-telegram/ -o dist/cf7-spam-to-telegram-1.2.0.zip v1.2.0
- 6. POST /api/v1/repos/.../releases (создать Release объект)
- 7. POST /api/v1/repos/.../releases/{id}/assets (загрузить zip как asset)
- 8. echo URLs (release page, asset URL, PUC JSON URL)
+ 1. validate:
+ - аргумент валиден как semver (regex ^[0-9]+\.[0-9]+\.[0-9]+$)
+ - git tree чистый: `git status --porcelain` пусто (gitignored файлы не считаются)
+ - текущая ветка = main
+ - remote main in sync (git fetch origin; локальный main ≡ origin/main)
+ - ~/.gitea-token существует и читается (chmod 600)
+ - тег v1.2.0 ещё не существует локально и на origin
+ - команды в PATH: git, curl, jq, unzip, awk, sed
+ - readme.txt содержит секцию `= 1.2.0 =` (пользователь написал тело changelog ДО запуска)
+
+ 2. bump version (files-only, в working tree, ещё НЕ commit):
+ - cf7-spam-to-telegram.php: header `Version:` и define `CF7STG_VERSION`
+ - readme.txt: `Stable tag: 1.2.0`
+ (wp-plugin-info.json на этом этапе НЕ трогаем — download_url ещё не известен)
+
+ 3. release commit + push:
+ - git add cf7-spam-to-telegram.php readme.txt
+ - git commit -m "release: 1.2.0"
+ - git push origin main
+ (sha этого коммита нужен Gitea на step 5 как target_commitish)
+
+ 4. build zip:
+ - mkdir -p dist
+ - git archive HEAD --worktree-attributes --format=zip \
+ --prefix=cf7-spam-to-telegram/ \
+ -o dist/cf7-spam-to-telegram-1.2.0.zip
+ - sanity-check: unzip -p dist/...zip cf7-spam-to-telegram/cf7-spam-to-telegram.php \
+ | grep -q "Version: 1.2.0" || fail
+ - sanity-check: unzip -l dist/...zip | grep -q "includes/lib/plugin-update-checker/" || fail
+ - sanity-check: ! unzip -l dist/...zip | grep -q "tests/\|docs/\|composer.json" || fail
+
+ 5. Gitea API: create release
+ POST /api/v1/repos/bryzgalov/cf7-spam-to-telegram/releases
+ headers: Authorization: token $(cat ~/.gitea-token)
+ body: {
+ "tag_name": "v1.2.0",
+ "name": "1.2.0",
+ "body": "",
+ "target_commitish": ""
+ }
+ → сохранить release_id (Gitea создаёт тег v1.2.0 на target_commitish sha)
+
+ 6. Gitea API: upload asset
+ POST /api/v1/repos/.../releases/{release_id}/assets?name=cf7-spam-to-telegram-1.2.0.zip
+ multipart: file=@dist/cf7-spam-to-telegram-1.2.0.zip
+ → JSON ответ, поле browser_download_url → DOWNLOAD_URL
+
+ 7. regenerate wp-plugin-info.json:
+ - version = 1.2.0
+ - download_url = DOWNLOAD_URL
+ - last_updated = UTC timestamp (YYYY-MM-DD HH:MM:SS, GMT)
+ - sections.changelog = HTML из блока `== Changelog ==` readme.txt:
+ `= X.Y.Z =` (строка) → закрыть открытый `` если есть, `X.Y.Z
`
+ `* item text` (список) → `- ESCAPE(item text)
`
+ `** sub item` (вложенный) → `- ESCAPE(sub item)
` (плоско в том же , nit)
+ пустая строка или конец блока → закрыть `
`
+ ESCAPE = htmlspecialchars-эквивалент (& < > → & < >)
+ (реализация: awk/sed над блоком + jq для embedding строки в JSON с escape)
+
+ 8. JSON commit + push:
+ - git add wp-plugin-info.json
+ - git commit -m "wp-plugin-info.json: update for 1.2.0"
+ - git push origin main
+
+ 9. echo:
+ - Release page URL
+ - browser_download_url
+ - raw URL wp-plugin-info.json
+ - tag на Gitea
+ - напоминание: «Проверь в WP: Плагины → Проверить обновления»
```
-Token хранится в `~/.gitea-token` (chmod 600), не в репо. Создание токена — единожды, в Gitea UI: `user/settings/applications` → scope `write:repository`.
+**Rollback/cleanup в случае failure:** если шаги 5/6 упали — release remote не создан, локальный state = release commit уже в origin. Пользователь может откатить (`git reset --hard HEAD^ && git push -f`, осознанно). В release.sh при ошибке шагов 5-8 — печатается гайд по откату, но скрипт не пытается откатывать сам (слишком рискованно).
+
+**Локальный tag:** Gitea создаёт тег `v1.2.0` на сервере (через API step 5), локально тег отсутствует до явного `git fetch --tags`. Это не блокер — release.sh показывает link на release page, а локальный tag не критичен для работы PUC.
+
+**Token:** хранится в `~/.gitea-token` (chmod 600), не в репо. Создаётся единожды в Gitea UI: `user/settings/applications` → scope **`write:repository`** (этого достаточно для releases и asset upload).
## `.gitattributes` правила
@@ -165,6 +253,14 @@ dist/ export-ignore
**ВАЖНО:** `includes/lib/plugin-update-checker/` НЕ исключаем — она нужна в zip!
+## `.gitignore` изменения
+
+```
+dist/
+```
+
+Без этого release.sh validation step «git tree чистый» не проходит (untracked `dist/`).
+
## Тесты
`tests/UpdateCheckerIntegrationTest.php` (extends `Cf7stgTestCase`):
@@ -173,7 +269,7 @@ dist/ export-ignore
- `test_puc_factory_class_loadable_after_require()` — после `require_once` лоадера класс `YahnisElsts\PluginUpdateChecker\v5\PucFactory` доступен.
- `test_wp_plugin_info_json_is_valid()` — `wp-plugin-info.json` парсится, содержит обязательные ключи `name, slug, version, download_url, sections`.
- `test_wp_plugin_info_json_version_matches_plugin_constant()` — `wp-plugin-info.json::version` совпадает с `CF7STG_VERSION` из `cf7-spam-to-telegram.php` (regex extract). Защита: release.sh забыл обновить JSON — тест поймает.
-- `test_wp_plugin_info_json_download_url_matches_version()` — `download_url` содержит `/releases/download/v{version}/`.
+- `test_wp_plugin_info_json_download_url_matches_version()` — `download_url` содержит подстроку `cf7-spam-to-telegram-{version}.zip` (не угадываем формат Gitea — asset URL может быть `/releases/download/v{v}/{name}` или `/attachments//{name}`, но имя asset'а в URL присутствует по любому). Защита: release.sh забыл обновить `download_url` после bump — тест поймает.
`tests/ZipArchiveBuildTest.php` (опционально, требует git в `$PATH`):
- `test_git_archive_includes_runtime_dirs()` — `git archive HEAD --worktree-attributes -o /tmp/test.zip` распаковывается, содержит `cf7-spam-to-telegram.php`, `includes/`, `admin/`, `includes/lib/plugin-update-checker/plugin-update-checker.php`.
@@ -188,11 +284,11 @@ dist/ export-ignore
## Rollout
-1. Локально: создать токен в Gitea (один раз), сохранить в `~/.gitea-token`.
-2. Локально: подготовить v1.2.0 (vendor PUC, добавить файлы, написать release.sh, тесты).
-3. PHPUnit зелёный → `./release.sh 1.2.0` → tag + push + Release + asset.
-4. На домработница.рус (где сейчас 1.1.0) — один последний раз через `rsync` обновить до 1.2.0. Активировать плагин (auto-seed уже идемпотентный). PUC начинает работать.
-5. Все будущие версии — `./release.sh 1.x.y`, и через 12ч (или после ручного «Проверить обновления») WP-сайты сами обновятся.
+1. Локально: создать токен в Gitea (один раз, scope `write:repository`), сохранить в `~/.gitea-token` (chmod 600).
+2. Локально: подготовить v1.2.0 (vendor PUC v5.6, добавить `wp-plugin-info.json` skeleton, `register_update_checker()` в `class-plugin.php`, `release.sh`, `.gitattributes`, `.gitignore`, тесты, changelog-entry в readme.txt).
+3. PHPUnit зелёный → `./release.sh 1.2.0` выполняет всю последовательность из секции «release.sh» выше → получаем Gitea release v1.2.0 + asset + `wp-plugin-info.json` на `main` с реальным `download_url`.
+4. На домработница.рус (где сейчас 1.1.0) — один последний раз через `rsync` обновить до 1.2.0. Активация плагина идемпотентная (auto-seed настроек ок). После активации PUC начинает чекать `main` раз в 12ч.
+5. Все будущие версии — `./release.sh 1.x.y`; через 12ч (или вручную «Проверить обновления» в WP-админке) сайты сами обновятся.
## Версионирование
@@ -200,22 +296,21 @@ dist/ export-ignore
- Changelog в `readme.txt`:
```
= 1.2.0 =
- * New: Self-hosted plugin updates from Gitea via plugin-update-checker (Yahnis Elsts v5).
+ * New: Self-hosted plugin updates from Gitea via plugin-update-checker (Yahnis Elsts v5.6).
WP «Плагины → Обновления» теперь показывает обновления автоматически (раз в 12ч),
скачивая чистый zip из Gitea Releases. Источник:
https://git.netranking.ru/bryzgalov/cf7-spam-to-telegram/raw/branch/main/wp-plugin-info.json.
- * Dev: release.sh — однострочный релиз (bump version → commit → tag → push →
- build clean zip via git archive --worktree-attributes → Gitea Release + asset upload).
+ * Dev: release.sh — релизный bash-скрипт (bump → release-commit → push → archive →
+ Gitea release + asset upload → regenerate wp-plugin-info.json с реальным download_url →
+ json-commit → push → tag). Два fast-forward коммита на релиз, без force-push.
* Dev: .gitattributes export-ignore для tests/, docs/, composer.*, phpunit.xml,
- .beads/, .claude/, CLAUDE.md, AGENTS.md, release.sh — git archive собирает
+ .beads/, .claude/, CLAUDE.md, AGENTS.md, release.sh, dist/ — git archive собирает
чистый zip без dev-файлов.
+ * Dev: .gitignore добавляет dist/ для сборочного артефакта.
```
-## Открытые вопросы для следующей сессии
+## Future epics (вне scope 1.2.0)
-Эти моменты НЕ обсуждены с пользователем явно — могут потребовать одного-двух уточнений перед finalizing spec:
-
-1. **Какую точно версию PUC v5 vendor'им?** Скачать последний release с https://github.com/YahnisElsts/plugin-update-checker/releases (на 2026-04-21 это видимо `5.x.x`). В spec зафиксировать конкретную версию для воспроизводимости.
-2. **Уважает ли Gitea raw URL HTTPS на git.netranking.ru cache headers?** PUC ставит `If-Modified-Since`, важно чтобы сервер не ломал. Проверить вручную через `curl -I https://git.netranking.ru/.../raw/branch/main/wp-plugin-info.json` — если есть `Last-Modified` / `ETag` — отлично.
-3. **Минификация PUC?** Vendored PUC ~50 файлов / ~300KB. Не критично, но можно опционально удалить `examples/`, `tests/`, `docs/` из vendored папки. Рассмотреть как nit.
-4. **Поддержка Gitea Actions** — пользователь не запрашивал, но если есть желание авто-релизов на push tag — это +1 эпик в будущем.
+1. **Gitea Actions**: авто-сборка release на push tag (`./release.sh` вручную заменяется на CI). +1 эпик после 1.2.0.
+2. **Минификация vendored PUC**: если zip в Gitea asset растёт некомфортно — добавить `includes/lib/plugin-update-checker/{tests,examples,docs}` в `.gitattributes` как `export-ignore`. Чисто cosmetic, nit.
+3. **Multi-site update broadcast**: сейчас каждый WP-сайт чекает раз в 12ч. Если будет много сайтов — рассмотреть webhook-push. Пока не актуально.