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 домработница.рус.
22 KiB
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):
{
"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() изменение
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-эквивалент (& < > → & < >)
(реализация: 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
- Локально: создать токен в Gitea (один раз, scope
write:repository), сохранить в~/.gitea-token(chmod 600). - Локально: подготовить v1.2.0 (vendor PUC v5.6, добавить
wp-plugin-info.jsonskeleton,register_update_checker()вclass-plugin.php,release.sh,.gitattributes,.gitignore, тесты, changelog-entry в readme.txt). - PHPUnit зелёный →
./release.sh 1.2.0выполняет всю последовательность из секции «release.sh» выше → получаем Gitea release v1.2.0 + asset +wp-plugin-info.jsonнаmainс реальнымdownload_url. - На домработница.рус (где сейчас 1.1.0) — один последний раз через
rsyncобновить до 1.2.0. Активация плагина идемпотентная (auto-seed настроек ок). После активации PUC начинает чекатьmainраз в 12ч. - Все будущие версии —
./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)
- Gitea Actions: авто-сборка release на push tag (
./release.shвручную заменяется на CI). +1 эпик после 1.2.0. - Минификация vendored PUC: если zip в Gitea asset растёт некомфортно — добавить
includes/lib/plugin-update-checker/{tests,examples,docs}в.gitattributesкакexport-ignore. Чисто cosmetic, nit. - Multi-site update broadcast: сейчас каждый WP-сайт чекает раз в 12ч. Если будет много сайтов — рассмотреть webhook-push. Пока не актуально.