Files
cf7-spam-to-telegram/docs/superpowers/specs/2026-04-21-cf7stg-self-hosted-updates-design.md
Vladimir Bryzgalov c0ff5a9227 docs: finalize self-hosted updates spec + implementation plan (1.2.0)
Spec finalized after brainstorm + pre-impl verification: PUC v5.6 pinned,
Gitea raw URL cache headers verified, release.sh uses browser_download_url
from API response (not guessed format), changelog-to-HTML conversion via awk,
two fast-forward commits per release (no force-push).

Plan decomposes into 19 tasks: vendor PUC → tests → JSON skeleton → boot
wiring → gitignore/gitattributes → release.sh incrementally (validate, bump,
commit+push, zip, Gitea release, upload, regenerate JSON, JSON commit) →
changelog body → actual release run → deploy to домработница.рус.
2026-04-21 20:59:52 +05:00

317 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 =``<h4>`, `* item``<li>`) внутри `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": "<browser_download_url из Gitea API — формат определяется Gitea, не угадывается>",
"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": "<h4>1.2.0</h4><ul><li>New: Self-hosted updates from Gitea ...</li></ul><h4>1.1.0</h4>..."
}
}
```
Поля `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": "<changelog entry text>",
"target_commitish": "<sha HEAD из step 3>"
}
→ сохранить 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 =` (строка) → закрыть открытый `<ul>` если есть, `<h4>X.Y.Z</h4><ul>`
`* item text` (список) → `<li>ESCAPE(item text)</li>`
`** sub item` (вложенный) → `<li>ESCAPE(sub item)</li>` (плоско в том же <ul>, nit)
пустая строка или конец блока → закрыть `</ul>`
ESCAPE = htmlspecialchars-эквивалент (& < > → &amp; &lt; &gt;)
(реализация: 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/<uuid>/{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. Пока не актуально.