From c0ff5a9227349dd398c41216275f9879166e64ae Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov Date: Tue, 21 Apr 2026 20:59:52 +0500 Subject: [PATCH] docs: finalize self-hosted updates spec + implementation plan (1.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-cf7stg-self-hosted-updates.md | 1398 +++++++++++++++++ ...04-21-cf7stg-self-hosted-updates-design.md | 203 ++- 2 files changed, 1547 insertions(+), 54 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-21-cf7stg-self-hosted-updates.md diff --git a/docs/superpowers/plans/2026-04-21-cf7stg-self-hosted-updates.md b/docs/superpowers/plans/2026-04-21-cf7stg-self-hosted-updates.md new file mode 100644 index 0000000..285b233 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-cf7stg-self-hosted-updates.md @@ -0,0 +1,1398 @@ +# CF7 Spam → Telegram — Self-Hosted Updates from Gitea (1.2.0) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** WordPress плагин `cf7-spam-to-telegram` получает self-hosted обновления из Gitea: v1.2.0 добавляет vendored Plugin Update Checker v5.6 + `wp-plugin-info.json` в корне репо + bash-скрипт `release.sh` для ручных релизов (bump → release commit → push → build zip → Gitea release → asset upload → regenerate JSON с real `download_url` → JSON commit → push). + +**Architecture:** PUC v5.6 (Generic JSON mode) ходит в raw URL `wp-plugin-info.json` на ветке `main` раз в 12ч и, при обнаружении новой версии, качает zip по `browser_download_url` (Gitea release asset). Релиз — два fast-forward коммита: первый с bump'ами (от него собран zip и взят tag), второй с обновлённым JSON содержащим реальный download_url. + +**Tech Stack:** PHP 7.4+ (плагин), PHPUnit 9.5 (shim-тесты), bash 4+ с `curl` / `jq` / `unzip` / `awk` / `sed` / `git archive` (релизный скрипт), Gitea API v1 (self-hosted на `git.netranking.ru`), Plugin Update Checker v5.6 (YahnisElsts). + +**Spec:** `docs/superpowers/specs/2026-04-21-cf7stg-self-hosted-updates-design.md` — читать до начала работы. + +--- + +## File Structure + +### Create + +| Path | Responsibility | +|---|---| +| `includes/lib/plugin-update-checker/` (папка ~50 файлов) | Vendored PUC v5.6 целиком, без модификаций. Загрузчик: `plugin-update-checker.php`, register factory `YahnisElsts\PluginUpdateChecker\v5\PucFactory`. | +| `wp-plugin-info.json` (корень) | Generic JSON metadata для PUC. Поля `version` / `download_url` / `last_updated` / `sections.changelog` регенерируются `release.sh`; остальные статичные. | +| `release.sh` (корень, `chmod +x`) | Однокомандный релизный скрипт: bump → release commit → push → archive → Gitea release → asset upload → regenerate JSON → JSON commit → push. | +| `tests/UpdateCheckerIntegrationTest.php` | Инварианты PUC + JSON: файл лоадера, класс фабрики, валидный JSON, version JSON == `CF7STG_VERSION`, download_url содержит asset name. | +| `tests/ZipArchiveBuildTest.php` | `git archive HEAD --worktree-attributes` даёт zip с runtime-файлами, без dev-файлов. | + +### Modify + +| Path | Change | +|---|---| +| `includes/class-plugin.php` | Добавить `private static function register_update_checker()` + вызов `self::register_update_checker()` из `boot()`. | +| `.gitattributes` | Дополнить список `export-ignore` до полного покрытия dev-файлов (см. Task 7). | +| `.gitignore` | Добавить `dist/`. | +| `readme.txt` | Добавить запись `= 1.2.0 =` в Changelog и bump `Stable tag` (bump делает `release.sh`, но changelog body пишется вручную до запуска). | + +--- + +## Task 1: Vendor Plugin Update Checker v5.6 + +**Files:** +- Create: `includes/lib/plugin-update-checker/` (папка, ~50 файлов) + +- [ ] **Step 1: Download PUC v5.6 release archive** + +Run: +```bash +cd /tmp +curl -fsSL https://github.com/YahnisElsts/plugin-update-checker/archive/refs/tags/v5.6.tar.gz -o puc-5.6.tar.gz +tar -xzf puc-5.6.tar.gz +ls plugin-update-checker-5.6/plugin-update-checker.php +``` + +Expected: файл существует (exit 0, вывод имени файла). + +- [ ] **Step 2: Copy PUC into plugin's vendored lib folder** + +Run (с корня проекта): +```bash +mkdir -p includes/lib +cp -R /tmp/plugin-update-checker-5.6 includes/lib/plugin-update-checker +ls includes/lib/plugin-update-checker/plugin-update-checker.php +``` + +Expected: файл виден по пути. + +- [ ] **Step 3: Verify loader path and namespace** + +Run: +```bash +grep -l "namespace YahnisElsts\\\\PluginUpdateChecker\\\\v5" includes/lib/plugin-update-checker/Puc/v5/PucFactory.php +``` + +Expected: печатает путь `includes/lib/plugin-update-checker/Puc/v5/PucFactory.php` — подтверждает что PucFactory в нужном namespace. + +- [ ] **Step 4: Commit** + +```bash +git add includes/lib/plugin-update-checker +git commit -m "chore(deps): vendor plugin-update-checker v5.6 + +Source: https://github.com/YahnisElsts/plugin-update-checker/releases/tag/v5.6 +Used in 1.2.0 for self-hosted updates from Gitea (Generic JSON mode)." +``` + +--- + +## Task 2: Test — PUC loader file exists + factory class loadable + +**Files:** +- Create: `tests/UpdateCheckerIntegrationTest.php` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/UpdateCheckerIntegrationTest.php`: + +```php +1.1.0" + } +} +``` + +- [ ] **Step 2: Verify JSON parses** + +Run: +```bash +php -r 'var_dump(json_decode(file_get_contents("wp-plugin-info.json"), true))' | head -5 +``` + +Expected: вывод начинается с `array(11)` (11 ключей) — JSON валиден. + +- [ ] **Step 3: Commit** + +```bash +git add wp-plugin-info.json +git commit -m "feat(updates): add wp-plugin-info.json skeleton for PUC Generic JSON source" +``` + +--- + +## Task 4: Tests — JSON valid, version matches plugin constant, download_url contains asset name + +**Files:** +- Modify: `tests/UpdateCheckerIntegrationTest.php` + +- [ ] **Step 1: Append failing tests** + +Add to `tests/UpdateCheckerIntegrationTest.php` (inside the class): + +```php + private const WP_PLUGIN_INFO_JSON = self::PLUGIN_ROOT . '/wp-plugin-info.json'; + private const PLUGIN_MAIN_FILE = self::PLUGIN_ROOT . '/cf7-spam-to-telegram.php'; + + public function test_wp_plugin_info_json_is_valid(): void + { + self::assertFileExists(self::WP_PLUGIN_INFO_JSON); + $data = json_decode(file_get_contents(self::WP_PLUGIN_INFO_JSON), true); + self::assertIsArray($data, 'wp-plugin-info.json must be valid JSON'); + foreach (['name', 'slug', 'version', 'download_url', 'sections'] as $required) { + self::assertArrayHasKey($required, $data, "missing required key: $required"); + } + self::assertSame('cf7-spam-to-telegram', $data['slug']); + self::assertIsArray($data['sections']); + self::assertArrayHasKey('description', $data['sections']); + self::assertArrayHasKey('changelog', $data['sections']); + } + + public function test_wp_plugin_info_json_version_matches_plugin_constant(): void + { + $data = json_decode(file_get_contents(self::WP_PLUGIN_INFO_JSON), true); + $jsonVersion = $data['version']; + + $phpSource = file_get_contents(self::PLUGIN_MAIN_FILE); + preg_match("/define\\('CF7STG_VERSION',\\s*'([^']+)'\\)/", $phpSource, $m); + self::assertNotEmpty($m[1] ?? null, 'CF7STG_VERSION constant must be defined in plugin main file'); + + self::assertSame( + $m[1], + $jsonVersion, + 'wp-plugin-info.json::version must match CF7STG_VERSION in cf7-spam-to-telegram.php' + ); + } + + public function test_wp_plugin_info_json_download_url_contains_expected_asset_name(): void + { + $data = json_decode(file_get_contents(self::WP_PLUGIN_INFO_JSON), true); + $expectedAssetName = 'cf7-spam-to-telegram-' . $data['version'] . '.zip'; + self::assertStringContainsString( + $expectedAssetName, + $data['download_url'], + "download_url must contain expected asset name $expectedAssetName" + ); + } +``` + +- [ ] **Step 2: Run the tests** + +Run: +```bash +./vendor/bin/phpunit --filter UpdateCheckerIntegrationTest +``` + +Expected: PASS (5 tests green) — skeleton JSON из Task 3 содержит `version: 1.1.0`, соответствует `CF7STG_VERSION`, и `download_url` содержит `cf7-spam-to-telegram-1.1.0.zip`. + +- [ ] **Step 3: Commit** + +```bash +git add tests/UpdateCheckerIntegrationTest.php +git commit -m "test(update-checker): wp-plugin-info.json validity + version/download_url invariants" +``` + +--- + +## Task 5: Wire `register_update_checker()` into `Plugin::boot()` + +**Files:** +- Modify: `includes/class-plugin.php` (add method, call from `boot()`) +- Modify: `tests/UpdateCheckerIntegrationTest.php` (add reflection test) + +- [ ] **Step 1: Write failing reflection test** + +Append to `tests/UpdateCheckerIntegrationTest.php`: + +```php + public function test_plugin_class_has_register_update_checker_method(): void + { + self::assertTrue( + method_exists('\\Cf7stg\\Plugin', 'register_update_checker'), + 'Plugin class must expose register_update_checker()' + ); + $ref = new \ReflectionMethod('\\Cf7stg\\Plugin', 'register_update_checker'); + self::assertTrue($ref->isStatic(), 'register_update_checker must be static'); + self::assertTrue($ref->isPrivate(), 'register_update_checker must be private'); + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: +```bash +./vendor/bin/phpunit --filter test_plugin_class_has_register_update_checker_method +``` + +Expected: FAIL — «Plugin class must expose register_update_checker()» (method doesn't exist yet). + +- [ ] **Step 3: Add method to `Plugin` class and call from `boot()`** + +Edit `includes/class-plugin.php`: + +Change the `boot()` method from: + +```php + public function boot(): void + { + // Inject silent-drop options into Classifier filter chain + Settings::register_classifier_filters(); + + // Register runtime hooks + Dispatcher::register_hooks(); + (new CF7Source())->register(); + + // Admin + if (is_admin()) { + (new Settings_Page())->register(); + (new Dashboard_Widget())->register(); + } + } +``` + +To: + +```php + public function boot(): void + { + // Inject silent-drop options into Classifier filter chain + Settings::register_classifier_filters(); + + // Register runtime hooks + Dispatcher::register_hooks(); + (new CF7Source())->register(); + + self::register_update_checker(); + + // Admin + 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' + ); + } +``` + +- [ ] **Step 4: Run the reflection test** + +Run: +```bash +./vendor/bin/phpunit --filter test_plugin_class_has_register_update_checker_method +``` + +Expected: PASS. + +- [ ] **Step 5: Run full test suite** + +Run: +```bash +./vendor/bin/phpunit +``` + +Expected: все тесты зелёные (существующие + 6 новых). + +- [ ] **Step 6: Commit** + +```bash +git add includes/class-plugin.php tests/UpdateCheckerIntegrationTest.php +git commit -m "feat(updates): wire PUC v5 registration into Plugin::boot() + +register_update_checker() guards on is_file + class_exists so the plugin +degrades cleanly on installs that ship without the vendored lib (no update +notices, but plugin core remains functional)." +``` + +--- + +## Task 6: Add `dist/` to `.gitignore` + +**Files:** +- Modify: `.gitignore` + +- [ ] **Step 1: Append `dist/` to .gitignore** + +Current `.gitignore` ends with: +``` +.beads-credential-key +``` + +Add a new section: + +``` +/vendor/ +/node_modules/ +composer.lock +.phpunit.result.cache +.DS_Store +*.log + +# Beads / Dolt files (added by bd init) +.dolt/ +*.db +.beads-credential-key + +# Release build artifact (release.sh creates dist/cf7-spam-to-telegram-.zip) +dist/ +``` + +- [ ] **Step 2: Verify git ignores dist/** + +Run: +```bash +mkdir -p dist && touch dist/probe.txt +git status --porcelain | grep 'dist' || echo "ignored correctly" +rm -rf dist +``` + +Expected: `ignored correctly` (git не видит новые файлы в dist/). + +- [ ] **Step 3: Commit** + +```bash +git add .gitignore +git commit -m "chore: ignore dist/ (release.sh build artifact)" +``` + +--- + +## Task 7: `.gitattributes` — export-ignore for all dev files + +**Files:** +- Modify: `.gitattributes` + +- [ ] **Step 1: Rewrite .gitattributes with full export-ignore coverage** + +Replace contents of `.gitattributes` with: + +``` +# Exclude dev files from `git archive` (used by release.sh to build release zip) +/tests/ export-ignore +/docs/ export-ignore +/phpunit.xml export-ignore +/composer.json export-ignore +/composer.lock 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 + +# NOTE: includes/lib/plugin-update-checker/ MUST NOT be excluded — +# it is required at runtime on the target WP site. +``` + +- [ ] **Step 2: Commit (archive verification happens in next task)** + +```bash +git add .gitattributes +git commit -m "chore: extend .gitattributes export-ignore to cover all dev files" +``` + +--- + +## Task 8: Test — `git archive` includes runtime, excludes dev + +**Files:** +- Create: `tests/ZipArchiveBuildTest.php` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/ZipArchiveBuildTest.php`: + +```php +&1', + escapeshellarg(self::PLUGIN_ROOT), + escapeshellarg($zip) + ); + exec($cmd, $output, $rc); + if ($rc !== 0) { + self::fail("git archive failed: rc=$rc output=" . implode("\n", $output)); + } + return $zip; + } + + /** @return array */ + private static function listZip(string $zipPath): array + { + exec('unzip -Z1 ' . escapeshellarg($zipPath), $list, $rc); + if ($rc !== 0) self::fail("unzip -Z1 failed: rc=$rc"); + return $list; + } + + public function test_git_archive_includes_runtime_files(): void + { + $zip = self::buildTestZip(); + $entries = self::listZip($zip); + $joined = implode("\n", $entries); + + self::assertStringContainsString('cf7-spam-to-telegram/cf7-spam-to-telegram.php', $joined); + self::assertStringContainsString('cf7-spam-to-telegram/includes/class-plugin.php', $joined); + self::assertStringContainsString('cf7-spam-to-telegram/includes/lib/plugin-update-checker/plugin-update-checker.php', $joined); + self::assertStringContainsString('cf7-spam-to-telegram/admin/', $joined); + self::assertStringContainsString('cf7-spam-to-telegram/wp-plugin-info.json', $joined); + self::assertStringContainsString('cf7-spam-to-telegram/readme.txt', $joined); + + unlink($zip); + } + + public function test_git_archive_excludes_dev_files(): void + { + $zip = self::buildTestZip(); + $entries = self::listZip($zip); + $joined = implode("\n", $entries); + + foreach (['tests/', 'docs/', 'composer.json', 'composer.lock', 'phpunit.xml', 'release.sh', '.gitattributes', '.gitignore', 'CLAUDE.md', 'AGENTS.md'] as $devPath) { + self::assertStringNotContainsString( + "cf7-spam-to-telegram/$devPath", + $joined, + "dev file must be excluded: $devPath" + ); + } + + unlink($zip); + } +} +``` + +- [ ] **Step 2: Run the tests** + +Run: +```bash +./vendor/bin/phpunit --filter ZipArchiveBuildTest +``` + +Expected: PASS. Note: `release.sh` ещё не существует — тест `test_git_archive_excludes_dev_files` проверяет его отсутствие, но ассерт `StringNotContainsString` пройдёт в обоих случаях (есть/нет). Цель тестов — защита от случайного добавления dev-файлов в archive в будущем. + +- [ ] **Step 3: Commit** + +```bash +git add tests/ZipArchiveBuildTest.php +git commit -m "test(archive): git archive includes runtime files, excludes dev files" +``` + +--- + +## Task 9: `release.sh` — skeleton + validate step + +**Files:** +- Create: `release.sh` + +- [ ] **Step 1: Create release.sh with shebang, set -e, and validate step** + +Create `release.sh` (корень): + +```bash +#!/usr/bin/env bash +# +# release.sh — Release helper for cf7-spam-to-telegram. +# +# Usage: ./release.sh +# Example: ./release.sh 1.2.0 +# +# Prerequisites (validated below): +# - git, curl, jq, unzip, awk, sed in PATH +# - clean git tree on main, in sync with origin +# - ~/.gitea-token exists (chmod 600), scope: write:repository +# - readme.txt already contains `= =` section with changelog body +# +# See docs/superpowers/specs/2026-04-21-cf7stg-self-hosted-updates-design.md +# for the full design rationale. + +set -euo pipefail + +REPO_OWNER="bryzgalov" +REPO_NAME="cf7-spam-to-telegram" +GITEA_BASE="https://git.netranking.ru" +GITEA_API="${GITEA_BASE}/api/v1" +TOKEN_FILE="${HOME}/.gitea-token" + +log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m==> WARN:\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31m==> FAIL:\033[0m %s\n' "$*" >&2; exit 1; } + +# ---------- STEP 1: VALIDATE --------------------------------------------------- + +VERSION="${1:-}" +[[ -n "$VERSION" ]] || fail "usage: $0 (e.g. $0 1.2.0)" + +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ + || fail "invalid semver: '$VERSION'" + +for cmd in git curl jq unzip awk sed; do + command -v "$cmd" >/dev/null 2>&1 || fail "missing command in PATH: $cmd" +done + +[[ -f "$TOKEN_FILE" && -r "$TOKEN_FILE" ]] \ + || fail "$TOKEN_FILE missing or unreadable (chmod 600 and create token in Gitea UI)" +TOKEN="$(cat "$TOKEN_FILE")" +[[ -n "$TOKEN" ]] || fail "$TOKEN_FILE is empty" + +BRANCH="$(git rev-parse --abbrev-ref HEAD)" +[[ "$BRANCH" == "main" ]] || fail "must be on main branch (currently: $BRANCH)" + +[[ -z "$(git status --porcelain)" ]] \ + || fail "working tree not clean (commit/stash first):\n$(git status --porcelain)" + +log "Fetching origin to compare main..." +git fetch origin main --quiet +LOCAL="$(git rev-parse main)" +REMOTE="$(git rev-parse origin/main)" +[[ "$LOCAL" == "$REMOTE" ]] \ + || fail "main is not in sync with origin/main (local=$LOCAL remote=$REMOTE — pull/push first)" + +TAG="v${VERSION}" +if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + fail "tag $TAG already exists locally" +fi +if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then + fail "tag $TAG already exists on origin" +fi + +# readme.txt must contain the changelog block for this version +grep -qE "^=\\s*${VERSION}\\s*=\\s*$" readme.txt \ + || fail "readme.txt does not contain '= ${VERSION} =' changelog section — add it before running release" + +log "Validation passed: version=$VERSION, tag=$TAG, branch=$BRANCH" +log "TODO: remaining steps implemented in subsequent tasks" +exit 0 +``` + +Make executable: + +```bash +chmod +x release.sh +``` + +- [ ] **Step 2: Smoke-test validate with bad input** + +Run: +```bash +./release.sh # no arg +./release.sh 1.2 # invalid semver +./release.sh 1.2.0 # should pass validation (if readme.txt lacks section — fails on readme check) +``` + +Expected: +- No arg → `FAIL: usage: ./release.sh ...` (exit 1) +- `1.2` → `FAIL: invalid semver: '1.2'` (exit 1) +- `1.2.0` → likely fails on «readme.txt does not contain '= 1.2.0 ='» (we haven't added it yet — that's Task 17). Validation step works, real bump won't proceed. + +- [ ] **Step 3: Commit** + +```bash +git add release.sh +git commit -m "feat(release): release.sh skeleton + validate step + +Checks semver, required CLI tools, ~/.gitea-token, branch=main, +clean tree, sync with origin, tag-not-exists, readme.txt changelog entry." +``` + +--- + +## Task 10: `release.sh` — bump step + +**Files:** +- Modify: `release.sh` + +- [ ] **Step 1: Append bump step (after validation, before commit)** + +Replace the final `log "TODO: remaining steps ..."; exit 0` in `release.sh` with: + +```bash +# ---------- STEP 2: BUMP VERSION --------------------------------------------- + +log "Bumping version to $VERSION in cf7-spam-to-telegram.php and readme.txt" + +# cf7-spam-to-telegram.php — bump header `Version:` and define CF7STG_VERSION +sed -i.bak -E \ + -e "s|^(\\s*\\*\\s*Version:\\s*).*$|\\1${VERSION}|" \ + -e "s|define\\('CF7STG_VERSION',\\s*'[^']+'\\)|define('CF7STG_VERSION', '${VERSION}')|" \ + cf7-spam-to-telegram.php +rm cf7-spam-to-telegram.php.bak + +grep -q "^ \\* Version: ${VERSION}$" cf7-spam-to-telegram.php \ + || fail "bump failed: header Version in cf7-spam-to-telegram.php" +grep -q "define('CF7STG_VERSION', '${VERSION}')" cf7-spam-to-telegram.php \ + || fail "bump failed: CF7STG_VERSION constant in cf7-spam-to-telegram.php" + +# readme.txt — bump `Stable tag:` +sed -i.bak -E "s|^(Stable tag:\\s*).*$|\\1${VERSION}|" readme.txt +rm readme.txt.bak + +grep -q "^Stable tag: ${VERSION}$" readme.txt \ + || fail "bump failed: Stable tag in readme.txt" + +log "Bumped to $VERSION" +log "TODO: remaining steps implemented in subsequent tasks" +exit 0 +``` + +- [ ] **Step 2: Smoke-test bump** + +Ensure readme.txt has `= 0.0.1 =` block first (dummy, will be removed after test): + +```bash +# Temp workaround: add dummy changelog entry +sed -i.bak '/^== Changelog ==$/a\ +\ += 0.0.1 =\ +* Dummy smoke-test entry.' readme.txt +rm readme.txt.bak + +./release.sh 0.0.1 + +grep "^ \\* Version: 0.0.1$" cf7-spam-to-telegram.php && \ +grep "CF7STG_VERSION', '0.0.1'" cf7-spam-to-telegram.php && \ +grep "^Stable tag: 0.0.1$" readme.txt && echo "bump OK" + +# Rollback +git checkout cf7-spam-to-telegram.php readme.txt +``` + +Expected: `bump OK` printed, then files restored. + +- [ ] **Step 3: Commit** + +```bash +git add release.sh +git commit -m "feat(release): bump step for cf7-spam-to-telegram.php and readme.txt" +``` + +--- + +## Task 11: `release.sh` — release commit + push step + +**Files:** +- Modify: `release.sh` + +- [ ] **Step 1: Append release-commit + push step** + +Replace final `log "TODO: remaining ..."; exit 0` with: + +```bash +# ---------- STEP 3: RELEASE COMMIT + PUSH ------------------------------------ + +log "Creating release commit" +git add cf7-spam-to-telegram.php readme.txt +git commit -m "release: ${VERSION}" +RELEASE_SHA="$(git rev-parse HEAD)" +log "Release commit: $RELEASE_SHA" + +log "Pushing main to origin" +git push origin main + +log "TODO: remaining steps implemented in subsequent tasks" +exit 0 +``` + +- [ ] **Step 2: Note on testing** + +We cannot safely smoke-test this step without actually pushing. Leave untested until Task 18 (real release run). The logic is trivial (3 git commands); regression protected by the final release verification. + +- [ ] **Step 3: Commit** + +```bash +git add release.sh +git commit -m "feat(release): release-commit + push step; capture RELEASE_SHA for Gitea API" +``` + +--- + +## Task 12: `release.sh` — build zip + sanity checks + +**Files:** +- Modify: `release.sh` + +- [ ] **Step 1: Append archive + sanity-check step** + +Replace final `log "TODO: remaining ..."; exit 0` with: + +```bash +# ---------- STEP 4: BUILD ZIP + SANITY --------------------------------------- + +ZIP_NAME="cf7-spam-to-telegram-${VERSION}.zip" +ZIP_PATH="dist/${ZIP_NAME}" + +log "Building release zip: $ZIP_PATH" +mkdir -p dist +rm -f "$ZIP_PATH" +git archive HEAD \ + --worktree-attributes \ + --format=zip \ + --prefix=cf7-spam-to-telegram/ \ + -o "$ZIP_PATH" + +log "Running zip sanity checks" + +# Correct version embedded +unzip -p "$ZIP_PATH" cf7-spam-to-telegram/cf7-spam-to-telegram.php \ + | grep -q "^ \\* Version: ${VERSION}$" \ + || fail "zip sanity: header Version mismatch inside $ZIP_PATH" + +unzip -p "$ZIP_PATH" cf7-spam-to-telegram/cf7-spam-to-telegram.php \ + | grep -q "CF7STG_VERSION', '${VERSION}'" \ + || fail "zip sanity: CF7STG_VERSION mismatch inside $ZIP_PATH" + +# Runtime dependencies present +unzip -l "$ZIP_PATH" | grep -q "cf7-spam-to-telegram/includes/lib/plugin-update-checker/plugin-update-checker.php" \ + || fail "zip sanity: PUC loader missing from $ZIP_PATH" + +unzip -l "$ZIP_PATH" | grep -q "cf7-spam-to-telegram/wp-plugin-info.json" \ + || fail "zip sanity: wp-plugin-info.json missing from $ZIP_PATH" + +# Dev files absent +for dev in tests/ docs/ composer.json composer.lock phpunit.xml release.sh CLAUDE.md AGENTS.md .gitattributes .gitignore; do + if unzip -l "$ZIP_PATH" | grep -q "cf7-spam-to-telegram/${dev}"; then + fail "zip sanity: dev file must not be in zip: $dev" + fi +done + +ZIP_SIZE="$(wc -c < "$ZIP_PATH")" +log "Zip built OK: $ZIP_PATH ($ZIP_SIZE bytes)" +log "TODO: remaining steps implemented in subsequent tasks" +exit 0 +``` + +- [ ] **Step 2: Commit** + +```bash +git add release.sh +git commit -m "feat(release): build zip via git archive + sanity checks (version, runtime files, no dev files)" +``` + +--- + +## Task 13: `release.sh` — Gitea API create release + +**Files:** +- Modify: `release.sh` + +- [ ] **Step 1: Append create-release step** + +Replace final `log "TODO: remaining ..."; exit 0` with: + +```bash +# ---------- STEP 5: GITEA CREATE RELEASE ------------------------------------- + +log "Extracting changelog body for version $VERSION from readme.txt" + +# Extract lines between `= VERSION =` and the next `= ` line (or end of file) +CHANGELOG_BODY="$(awk -v ver="$VERSION" ' + BEGIN { in_block = 0 } + /^=[[:space:]]*[0-9]+\.[0-9]+\.[0-9]+[[:space:]]*=[[:space:]]*$/ { + if (in_block) exit + if ($0 ~ "^=[[:space:]]*" ver "[[:space:]]*=[[:space:]]*$") { + in_block = 1 + next + } + } + in_block { print } +' readme.txt | sed '/^[[:space:]]*$/d')" + +[[ -n "$CHANGELOG_BODY" ]] \ + || fail "could not extract changelog body for version $VERSION from readme.txt" + +log "Creating Gitea release $TAG" + +# jq builds the JSON payload with proper escaping +CREATE_PAYLOAD="$(jq -n \ + --arg tag "$TAG" \ + --arg name "$VERSION" \ + --arg body "$CHANGELOG_BODY" \ + --arg target "$RELEASE_SHA" \ + '{tag_name:$tag, name:$name, body:$body, target_commitish:$target, draft:false, prerelease:false}')" + +CREATE_RESPONSE="$(curl -fsSL \ + -X POST "${GITEA_API}/repos/${REPO_OWNER}/${REPO_NAME}/releases" \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$CREATE_PAYLOAD")" + +RELEASE_ID="$(echo "$CREATE_RESPONSE" | jq -r '.id')" +[[ -n "$RELEASE_ID" && "$RELEASE_ID" != "null" ]] \ + || fail "Gitea create-release response missing id: $CREATE_RESPONSE" + +log "Created Gitea release id=$RELEASE_ID" +log "TODO: remaining steps implemented in subsequent tasks" +exit 0 +``` + +- [ ] **Step 2: Commit** + +```bash +git add release.sh +git commit -m "feat(release): Gitea API create-release with changelog body extracted from readme.txt" +``` + +--- + +## Task 14: `release.sh` — upload asset + +**Files:** +- Modify: `release.sh` + +- [ ] **Step 1: Append upload-asset step** + +Replace final `log "TODO: remaining ..."; exit 0` with: + +```bash +# ---------- STEP 6: UPLOAD ASSET --------------------------------------------- + +log "Uploading asset $ZIP_NAME to release id=$RELEASE_ID" + +UPLOAD_RESPONSE="$(curl -fsSL \ + -X POST "${GITEA_API}/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \ + -H "Authorization: token ${TOKEN}" \ + -F "attachment=@${ZIP_PATH};type=application/zip")" + +DOWNLOAD_URL="$(echo "$UPLOAD_RESPONSE" | jq -r '.browser_download_url')" +[[ -n "$DOWNLOAD_URL" && "$DOWNLOAD_URL" != "null" ]] \ + || fail "Gitea asset upload response missing browser_download_url: $UPLOAD_RESPONSE" + +log "Asset uploaded: $DOWNLOAD_URL" +log "TODO: remaining steps implemented in subsequent tasks" +exit 0 +``` + +- [ ] **Step 2: Commit** + +```bash +git add release.sh +git commit -m "feat(release): Gitea API upload asset; capture browser_download_url" +``` + +--- + +## Task 15: `release.sh` — regenerate `wp-plugin-info.json` with changelog-to-HTML + +**Files:** +- Modify: `release.sh` + +Note: самая сложная часть. Алгоритм конвертации блока `== Changelog ==` из `readme.txt` в HTML: +- `= X.Y.Z =` (строка) → `

X.Y.Z

    ` (и закрываем предыдущий `
` если был) +- строка, начинающаяся с `* ` → `
  • ESCAPE(text)
  • ` +- прочие строки (продолжение li) → склеиваем пробелом к предыдущему li +- пустая строка между версиями → закрыть `` + +- [ ] **Step 1: Append regenerate-JSON step** + +Replace final `log "TODO: remaining ..."; exit 0` with: + +```bash +# ---------- STEP 7: REGENERATE wp-plugin-info.json --------------------------- + +log "Converting readme.txt Changelog to HTML" + +CHANGELOG_HTML="$(awk ' + function escape(s) { + gsub(/&/, "\\&", s) + gsub(//, "\\>", s) + return s + } + BEGIN { in_changelog = 0; ul_open = 0; html = ""; pending = "" } + /^== Changelog ==/ { in_changelog = 1; next } + /^==/ && in_changelog { exit } + !in_changelog { next } + /^=[[:space:]]*[0-9]+\.[0-9]+\.[0-9]+[[:space:]]*=[[:space:]]*$/ { + if (pending != "") { html = html "
  • " escape(pending) "
  • "; pending = "" } + if (ul_open) { html = html ""; ul_open = 0 } + match($0, /[0-9]+\.[0-9]+\.[0-9]+/) + ver = substr($0, RSTART, RLENGTH) + html = html "

    " ver "

      " + ul_open = 1 + next + } + /^\*[[:space:]]/ { + if (pending != "") { html = html "
    • " escape(pending) "
    • " } + sub(/^\*[[:space:]]+/, "") + pending = $0 + next + } + /^[[:space:]]+[^[:space:]]/ { + if (pending != "") { + line = $0 + sub(/^[[:space:]]+/, "", line) + pending = pending " " line + } + next + } + /^[[:space:]]*$/ { + if (pending != "") { html = html "
    • " escape(pending) "
    • "; pending = "" } + next + } + END { + if (pending != "") { html = html "
    • " escape(pending) "
    • " } + if (ul_open) { html = html "
    " } + print html + } +' readme.txt)" + +[[ -n "$CHANGELOG_HTML" ]] || fail "changelog HTML conversion produced empty result" + +LAST_UPDATED="$(date -u '+%Y-%m-%d %H:%M:%S')" + +log "Regenerating wp-plugin-info.json" +TMP_JSON="$(mktemp)" +jq \ + --arg version "$VERSION" \ + --arg download_url "$DOWNLOAD_URL" \ + --arg last_updated "$LAST_UPDATED" \ + --arg changelog "$CHANGELOG_HTML" \ + '.version = $version | .download_url = $download_url | .last_updated = $last_updated | .sections.changelog = $changelog' \ + wp-plugin-info.json > "$TMP_JSON" +mv "$TMP_JSON" wp-plugin-info.json + +# Sanity: check JSON still valid +jq empty wp-plugin-info.json || fail "regenerated wp-plugin-info.json is not valid JSON" + +log "Regenerated wp-plugin-info.json (version=$VERSION, download_url=$DOWNLOAD_URL)" +log "TODO: remaining steps implemented in subsequent tasks" +exit 0 +``` + +- [ ] **Step 2: Unit-test the awk conversion in isolation** + +Create a tmp readme snippet and verify: + +```bash +cat > /tmp/test-readme.txt <<'EOF' +== Changelog == + += 1.2.0 = +* New: Self-hosted updates. + Second line continues. +* Dev: release.sh. + += 1.1.0 = +* New: Silent drop. + += 1.0.0 = +* First release. +EOF + +# Extract the awk block from release.sh (lines with `awk '` … `' readme.txt`) +# OR manually test with an inlined awk (copy from step 1 above, swap readme.txt → /tmp/test-readme.txt): + +bash -c ' +awk '"'"' + function escape(s) { gsub(/&/, "\\&", s); gsub(//, "\\>", s); return s } + BEGIN { in_changelog = 0; ul_open = 0; html = ""; pending = "" } + /^== Changelog ==/ { in_changelog = 1; next } + /^==/ && in_changelog { exit } + !in_changelog { next } + /^=[[:space:]]*[0-9]+\.[0-9]+\.[0-9]+[[:space:]]*=[[:space:]]*$/ { + if (pending != "") { html = html "
  • " escape(pending) "
  • "; pending = "" } + if (ul_open) { html = html ""; ul_open = 0 } + match($0, /[0-9]+\.[0-9]+\.[0-9]+/) + ver = substr($0, RSTART, RLENGTH) + html = html "

    " ver "

      " + ul_open = 1 + next + } + /^\*[[:space:]]/ { + if (pending != "") { html = html "
    • " escape(pending) "
    • " } + sub(/^\*[[:space:]]+/, "") + pending = $0 + next + } + /^[[:space:]]+[^[:space:]]/ { + if (pending != "") { + line = $0 + sub(/^[[:space:]]+/, "", line) + pending = pending " " line + } + next + } + /^[[:space:]]*$/ { + if (pending != "") { html = html "
    • " escape(pending) "
    • "; pending = "" } + next + } + END { + if (pending != "") { html = html "
    • " escape(pending) "
    • " } + if (ul_open) { html = html "
    " } + print html + } +'"'"' /tmp/test-readme.txt +' +``` + +Expected output: +``` +

    1.2.0

    • New: Self-hosted updates. Second line continues.
    • Dev: release.sh.

    1.1.0

    • New: Silent drop.

    1.0.0

    • First release.
    +``` + +Cleanup: `rm /tmp/test-readme.txt`. + +- [ ] **Step 3: Commit** + +```bash +git add release.sh +git commit -m "feat(release): regenerate wp-plugin-info.json with changelog-to-HTML conversion" +``` + +--- + +## Task 16: `release.sh` — JSON commit + push + echo summary + +**Files:** +- Modify: `release.sh` + +- [ ] **Step 1: Replace final `log "TODO ..."; exit 0` with JSON commit step and summary** + +```bash +# ---------- STEP 8: JSON COMMIT + PUSH --------------------------------------- + +log "Committing wp-plugin-info.json update" +git add wp-plugin-info.json +git commit -m "wp-plugin-info.json: update for ${VERSION}" +git push origin main + +# ---------- STEP 9: SUMMARY -------------------------------------------------- + +log "Release completed" +printf '\n' +printf ' Version : %s\n' "$VERSION" +printf ' Tag : %s (on %s)\n' "$TAG" "$GITEA_BASE/${REPO_OWNER}/${REPO_NAME}/src/tag/${TAG}" +printf ' Release page : %s\n' "${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${TAG}" +printf ' Asset : %s\n' "$DOWNLOAD_URL" +printf ' PUC JSON : %s/${REPO_OWNER}/${REPO_NAME}/raw/branch/main/wp-plugin-info.json\n' "$GITEA_BASE" +printf '\n' +printf ' Next: in WordPress admin — «Плагины → Проверить обновления» (PUC re-checks hourly, force-check via transient delete if impatient).\n' +printf '\n' +``` + +- [ ] **Step 2: Commit** + +```bash +git add release.sh +git commit -m "feat(release): JSON commit + push + summary echo + +release.sh is now feature-complete: + validate → bump → release commit + push → build zip → Gitea create release → + upload asset → regenerate wp-plugin-info.json → JSON commit + push → summary" +``` + +--- + +## Task 17: Write `= 1.2.0 =` body in readme.txt + +**Files:** +- Modify: `readme.txt` (changelog section only; Stable tag bump делает release.sh) + +- [ ] **Step 1: Insert new changelog entry at top of Changelog section** + +Edit `readme.txt`, find this line: +``` +== Changelog == + += 1.1.0 = +``` + +Replace with: + +``` +== Changelog == + += 1.2.0 = +* New: Self-hosted plugin updates from Gitea via Plugin Update Checker v5.6. + WordPress admin shows updates automatically (checked hourly). Source: + https://git.netranking.ru/bryzgalov/cf7-spam-to-telegram/raw/branch/main/wp-plugin-info.json +* Dev: release.sh — one-shot release script (bump → release commit → push → + build clean zip via git archive --worktree-attributes → Gitea release + asset + upload → regenerate wp-plugin-info.json with actual download_url → JSON commit + + push). Two fast-forward commits per release, no force-push. +* Dev: .gitattributes export-ignore covers tests/, docs/, composer.*, phpunit.xml, + .beads/, .claude/, CLAUDE.md, AGENTS.md, release.sh, dist/, .git* — git archive + produces clean zip without dev files. +* Dev: .gitignore adds dist/ for local release build artifact. + += 1.1.0 = +``` + +- [ ] **Step 2: Verify readme contains the new block** + +Run: +```bash +grep -c '^= 1.2.0 =$' readme.txt +``` + +Expected: `1`. + +- [ ] **Step 3: Commit** + +```bash +git add readme.txt +git commit -m "docs(readme): add 1.2.0 changelog entry + +Release body that release.sh will extract and POST to Gitea /releases API." +``` + +--- + +## Task 18: Run `./release.sh 1.2.0` — actual release + +**Files:** none (execution only) + +Pre-checks before running: +- `bd ready` shows tasks complete +- All tests pass (`./vendor/bin/phpunit` green) +- `git status` clean +- `git log --oneline -n 15` shows all prior tasks committed and on `main` +- `~/.gitea-token` exists with `write:repository` scope + +- [ ] **Step 1: Confirm prereqs** + +Run: +```bash +./vendor/bin/phpunit +git status --porcelain +git log --oneline -n 5 +ls -la ~/.gitea-token +``` + +Expected: +- PHPUnit: all green +- Status: empty +- Log: shows Task 17 commit on top +- Token file: `-rw-------` (mode 600) + +- [ ] **Step 2: Run release** + +Run: +```bash +./release.sh 1.2.0 +``` + +Expected: script runs through all 9 steps, final summary prints URLs. + +- [ ] **Step 3: Verify Gitea release** + +Run: +```bash +curl -fsSL "https://git.netranking.ru/api/v1/repos/bryzgalov/cf7-spam-to-telegram/releases/tags/v1.2.0" | jq '{tag: .tag_name, name: .name, assets: [.assets[].name]}' +``` + +Expected: JSON showing `tag: v1.2.0`, at least one asset matching `cf7-spam-to-telegram-1.2.0.zip`. + +- [ ] **Step 4: Verify raw JSON is fresh** + +Run: +```bash +curl -fsSL "https://git.netranking.ru/bryzgalov/cf7-spam-to-telegram/raw/branch/main/wp-plugin-info.json" | jq '{version, download_url, last_updated}' +``` + +Expected: `version: "1.2.0"`, `download_url` non-empty (points to uploaded asset), `last_updated` with today's timestamp. + +- [ ] **Step 5: Verify asset is downloadable anonymously** + +Run: +```bash +DOWNLOAD_URL="$(curl -fsSL https://git.netranking.ru/bryzgalov/cf7-spam-to-telegram/raw/branch/main/wp-plugin-info.json | jq -r .download_url)" +curl -fsSLI "$DOWNLOAD_URL" | head -5 +``` + +Expected: `HTTP/2 200`, content-type `application/zip`. + +- [ ] **Step 6: No separate commit — release.sh already pushed two commits** + +Confirm: +```bash +git log --oneline -n 3 +``` + +Expected: top two commits are `wp-plugin-info.json: update for 1.2.0` and `release: 1.2.0`. + +--- + +## Task 19: Deploy 1.2.0 to домработница.рус (one last time via rsync), verify PUC activation + +**Files:** none locally; deployment only. + +Из MEMORY / CLAUDE.md context: сайт домработница.рус развёрнут на Beget (SSH alias `sidelkin-ssh`), путь `domramotnica.rus/public_html`, WP 6.9.4, тема `domrabotnica`, WP CLI php8.3, wps-hide-login активен (WP admin URL нужно уточнить у пользователя). + +- [ ] **Step 1: Copy dist zip to server** + +Run (с локальной машины): +```bash +# build фактически уже есть от Task 18 run +ls dist/cf7-spam-to-telegram-1.2.0.zip +scp dist/cf7-spam-to-telegram-1.2.0.zip sidelkin-ssh:~/ +``` + +Expected: file appears on remote home dir. + +- [ ] **Step 2: Backup current plugin, then unzip new version** + +On server (ask user to run or run via ssh): + +```bash +ssh sidelkin-ssh bash -lc ' + set -e + cd domramotnica.rus/public_html/wp-content/plugins + mv cf7-spam-to-telegram cf7-spam-to-telegram.bak-$(date +%s) + unzip ~/cf7-spam-to-telegram-1.2.0.zip + ls -la cf7-spam-to-telegram/cf7-spam-to-telegram.php cf7-spam-to-telegram/includes/lib/plugin-update-checker/plugin-update-checker.php +' +``` + +Expected: both files listed; no errors. + +- [ ] **Step 3: Verify plugin version via WP-CLI** + +```bash +ssh sidelkin-ssh 'cd domramotnica.rus/public_html && php8.3 $(which wp) plugin get cf7-spam-to-telegram --field=version' +``` + +Expected: `1.2.0`. + +- [ ] **Step 4: Force PUC to check immediately** + +```bash +ssh sidelkin-ssh 'cd domramotnica.rus/public_html && php8.3 $(which wp) transient delete --all' +ssh sidelkin-ssh 'cd domramotnica.rus/public_html && php8.3 $(which wp) plugin update --dry-run cf7-spam-to-telegram' +``` + +Expected: dry-run shows «No updates available» for 1.2.0 (since we just deployed 1.2.0 which matches remote JSON). This confirms PUC successfully reads the JSON. + +- [ ] **Step 5: Manual browser verification (Plugins page)** + +Ask user to: +1. Login to домработница.рус WP admin. +2. Plugins → "Check again" (link near the top). +3. Verify: no update notice for CF7 Spam → Telegram (because we're on 1.2.0 and remote JSON = 1.2.0). +4. Click «View version details» for the plugin — should show modal with our changelog HTML rendered. + +Expected: all steps pass; user confirms. + +- [ ] **Step 6: Cleanup backup (after 24h of operation)** + +Not part of this task; after a day of verifying 1.2.0 works, ask user if OK to remove the `.bak-` folder: + +```bash +ssh sidelkin-ssh 'rm -rf domramotnica.rus/public_html/wp-content/plugins/cf7-spam-to-telegram.bak-*' +``` + +--- + +## Completion checklist + +After all tasks: + +- [ ] `./vendor/bin/phpunit` all green locally +- [ ] `git log --oneline` on `main` shows `release: 1.2.0` + `wp-plugin-info.json: update for 1.2.0` at top +- [ ] `https://git.netranking.ru/bryzgalov/cf7-spam-to-telegram/releases/tag/v1.2.0` exists with a downloadable `.zip` asset +- [ ] Raw `wp-plugin-info.json` on `main` has `version=1.2.0`, real `download_url`, and full HTML changelog +- [ ] домработница.рус running 1.2.0, PUC dry-run reports «no updates» (i.e. PUC sees the remote JSON) +- [ ] `bd close cf7stg-5wy.1 --reason "Spec finalized + plan written + implemented"` (via parent epic close) +- [ ] `bd close cf7stg-5wy --reason "Self-hosted updates shipped in 1.2.0"` + +--- + +## Notes for the implementer + +- **Do not skip TDD:** every code task has a test-first step. `release.sh` is the exception (bash is hard to unit-test) — smoke-tests between steps suffice. +- **Commit frequently:** after each task. Don't batch. +- **If a step fails:** do NOT add fallback / error-handling / retry logic beyond what's specified. The spec and plan are intentionally minimal; ask the user before expanding scope. +- **If PUC tests fail because of unexpected WP function calls** during `require_once plugin-update-checker.php`: the tests only require the file + check factory class exists. They do NOT instantiate PUC. If the `require_once` itself triggers WP calls — investigate with `superpowers:systematic-debugging` before adding shims. +- **Changelog HTML conversion (Task 15):** the awk script handles the current `readme.txt` format exactly. If someone later writes a non-standard changelog entry (e.g. nested lists with `**`), enhance the script in a separate commit, not during 1.2.0 release. diff --git a/docs/superpowers/specs/2026-04-21-cf7stg-self-hosted-updates-design.md b/docs/superpowers/specs/2026-04-21-cf7stg-self-hosted-updates-design.md index cdb8bfa..747fa5a 100644 --- a/docs/superpowers/specs/2026-04-21-cf7stg-self-hosted-updates-design.md +++ b/docs/superpowers/specs/2026-04-21-cf7stg-self-hosted-updates-design.md @@ -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 =` → `

    `, `* 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` сделан **публичным** (пользователь подтвердил). В коде нет секретов. -- Используем **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": "", "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": "

    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 @@ -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": "", + "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: Плагины → Проверить обновления» ``` -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//{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. Пока не актуально.