# CF7 Spam → Telegram — Self-Hosted Updates from Gitea (1.2.0) **Статус:** 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` — публичный (подтверждено через `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.6** в `includes/lib/plugin-update-checker/`. - Файл `wp-plugin-info.json` в корне репо (Generic JSON для PUC). - Инициализация PUC в `Plugin::boot()` через `register_update_checker()`. - `.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). - Update-сервер / прокси (не нужен — статичный JSON через raw URL). - CI/Gitea Actions для авто-сборки релизов (вручную через release.sh достаточно). - License key management. - Авто-rollback на старую версию. - Уведомления о доступном обновлении куда-либо кроме стандартной WP-админки. ## Архитектура ``` Локальная разработка ↓ ./release.sh 1.2.0 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 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) ↓ распаковывает в wp-content/plugins/cf7-spam-to-telegram/ Плагин обновлён. ``` **Почему два коммита, а не один:** `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.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-скрипт релиза. | | `cf7-spam-to-telegram.php` | Bump до `1.2.0` (header `Version:` + константа `CF7STG_VERSION`). | | `readme.txt` | Bump `Stable tag: 1.2.0` + Changelog `= 1.2.0 =`. | | `.gitignore` | Добавляется `dist/`. | ## `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": "", "homepage": "https://git.netranking.ru/bryzgalov/cf7-spam-to-telegram", "requires": "6.0", "tested": "6.9", "requires_php": "7.4", "last_updated": "2026-04-21 17:00:00", "author": "Vladimir Bryzgalov", "sections": { "description": "Forwards CF7 spam submissions to Telegram with heuristic classification (client/unclear/spam/drop labels).", "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 public function boot(): void { Settings::register_classifier_filters(); Dispatcher::register_hooks(); (new CF7Source())->register(); self::register_update_checker(); // ← новый 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' ); } ``` ## `release.sh` (главные шаги) ``` release.sh 1.2.0 → 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: Плагины → Проверить обновления» ``` **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` правила ``` # Исключаем dev-файлы из git archive (release zip) tests/ export-ignore docs/ export-ignore composer.json export-ignore composer.lock export-ignore phpunit.xml 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 ``` **ВАЖНО:** `includes/lib/plugin-update-checker/` НЕ исключаем — она нужна в zip! ## `.gitignore` изменения ``` dist/ ``` Без этого release.sh validation step «git tree чистый» не проходит (untracked `dist/`). ## Тесты `tests/UpdateCheckerIntegrationTest.php` (extends `Cf7stgTestCase`): - `test_puc_loader_file_exists()` — `includes/lib/plugin-update-checker/plugin-update-checker.php` существует. - `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` содержит подстроку `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`. - `test_git_archive_excludes_dev_dirs()` — НЕТ `tests/`, `docs/`, `composer.json`, `release.sh`. ## Error handling - Отсутствие PUC папки на сайте: `register_update_checker` делает `is_file` + `class_exists` → плагин не падает, обновлений просто нет (как до 1.2.0). - Недоступность Gitea (network/down/4xx): PUC сам логирует через `error_log`, ничего не показывает в админке. Следующая итерация (12ч) повторит. Юзер не видит «обновлений» — всё. - Bad JSON в `wp-plugin-info.json`: PUC скипает, error_log. - Версия в JSON совпадает с локальной → no update notice. Норм. ## Rollout 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-админке) сайты сами обновятся. ## Версионирование - `1.1.0` → `1.2.0` (semver minor: новая фича, обратная совместимость). - Changelog в `readme.txt`: ``` = 1.2.0 = * 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 — релизный 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, dist/ — git archive собирает чистый zip без dev-файлов. * Dev: .gitignore добавляет dist/ для сборочного артефакта. ``` ## Future epics (вне scope 1.2.0) 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. Пока не актуально.