Files
cf7-spam-to-telegram/docs/superpowers/specs/2026-04-21-cf7stg-self-hosted-updates-design.md
T
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

22 KiB
Raw Blame History

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-эквивалент (& < > → &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.01.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. Пока не актуально.