# 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.