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 домработница.рус.
This commit is contained in:
Vladimir Bryzgalov
2026-04-21 20:59:52 +05:00
parent 995131371d
commit c0ff5a9227
2 changed files with 1547 additions and 54 deletions
File diff suppressed because it is too large Load Diff
@@ -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 =``<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` сделан **публичным** (пользователь подтвердил). В коде нет секретов.
- Используем **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": "<browser_download_url из Gitea API — формат определяется Gitea, не угадывается>",
"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": "<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
@@ -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": "<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: Плагины → Проверить обновления»
```
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/<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`.
@@ -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. Пока не актуально.