Files
cf7-spam-to-telegram/docs/superpowers/plans/2026-04-21-cf7stg-self-hosted-updates.md
T
Vladimir Bryzgalov c0ff5a9227 docs: finalize self-hosted updates spec + implementation plan (1.2.0)
Spec finalized after brainstorm + pre-impl verification: PUC v5.6 pinned,
Gitea raw URL cache headers verified, release.sh uses browser_download_url
from API response (not guessed format), changelog-to-HTML conversion via awk,
two fast-forward commits per release (no force-push).

Plan decomposes into 19 tasks: vendor PUC → tests → JSON skeleton → boot
wiring → gitignore/gitattributes → release.sh incrementally (validate, bump,
commit+push, zip, Gitea release, upload, regenerate JSON, JSON commit) →
changelog body → actual release run → deploy to домработница.рус.
2026-04-21 20:59:52 +05:00

1399 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<?php
declare(strict_types=1);
namespace Cf7stg\Tests;
final class UpdateCheckerIntegrationTest extends Cf7stgTestCase
{
private const PLUGIN_ROOT = __DIR__ . '/..';
private const PUC_LOADER = self::PLUGIN_ROOT . '/includes/lib/plugin-update-checker/plugin-update-checker.php';
public function test_puc_loader_file_exists(): void
{
self::assertFileExists(self::PUC_LOADER);
}
public function test_puc_factory_class_loadable_after_require(): void
{
require_once self::PUC_LOADER;
self::assertTrue(
class_exists('YahnisElsts\\PluginUpdateChecker\\v5\\PucFactory'),
'PucFactory must be loadable after requiring the PUC loader'
);
}
}
```
- [ ] **Step 2: Run the tests**
Run:
```bash
./vendor/bin/phpunit --filter UpdateCheckerIntegrationTest
```
Expected: PASS (both tests green) — vendoring был сделан в Task 1.
- [ ] **Step 3: Commit**
```bash
git add tests/UpdateCheckerIntegrationTest.php
git commit -m "test(update-checker): invariants for vendored PUC loader and factory class"
```
---
## Task 3: Create `wp-plugin-info.json` skeleton
**Files:**
- Create: `wp-plugin-info.json` (корень)
Note: на этом этапе `version` = `1.1.0` (текущая в плагине), `download_url` = placeholder с именем asset'а под текущую версию. `release.sh` в Task 15 будет регенерировать JSON с реальными значениями после upload.
- [ ] **Step 1: Write the file**
Create `wp-plugin-info.json`:
```json
{
"name": "CF7 Spam → Telegram",
"slug": "cf7-spam-to-telegram",
"version": "1.1.0",
"download_url": "https://git.netranking.ru/bryzgalov/cf7-spam-to-telegram/releases/download/v1.1.0/cf7-spam-to-telegram-1.1.0.zip",
"homepage": "https://git.netranking.ru/bryzgalov/cf7-spam-to-telegram",
"requires": "6.0",
"tested": "6.9",
"requires_php": "7.4",
"last_updated": "2026-04-21 00:00:00",
"author": "Vladimir Bryzgalov",
"sections": {
"description": "Forwards CF7 spam submissions to Telegram with heuristic classification (client/unclear/spam/drop labels).",
"changelog": "<h4>1.1.0</h4><ul><li>Silent drop: hard-rule/score threshold + dry-run mode, Settings block, Dashboard counter.</li></ul>"
}
}
```
- [ ] **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-<ver>.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
<?php
declare(strict_types=1);
namespace Cf7stg\Tests;
final class ZipArchiveBuildTest extends Cf7stgTestCase
{
private const PLUGIN_ROOT = __DIR__ . '/..';
private static function buildTestZip(): string
{
$zip = tempnam(sys_get_temp_dir(), 'cf7stg-archive-') . '.zip';
$cmd = sprintf(
'cd %s && git archive HEAD --worktree-attributes --format=zip --prefix=cf7-spam-to-telegram/ -o %s 2>&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<string> */
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 <semver>
# 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 `= <version> =` 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 <semver> (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 <semver> ...` (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 =` (строка) → `<h4>X.Y.Z</h4><ul>` (и закрываем предыдущий `</ul>` если был)
- строка, начинающаяся с `* ``<li>ESCAPE(text)</li>`
- прочие строки (продолжение li) → склеиваем пробелом к предыдущему li
- пустая строка между версиями → закрыть `</ul>`
- [ ] **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(/&/, "\\&amp;", s)
gsub(/</, "\\&lt;", s)
gsub(/>/, "\\&gt;", 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 "<li>" escape(pending) "</li>"; pending = "" }
if (ul_open) { html = html "</ul>"; ul_open = 0 }
match($0, /[0-9]+\.[0-9]+\.[0-9]+/)
ver = substr($0, RSTART, RLENGTH)
html = html "<h4>" ver "</h4><ul>"
ul_open = 1
next
}
/^\*[[:space:]]/ {
if (pending != "") { html = html "<li>" escape(pending) "</li>" }
sub(/^\*[[:space:]]+/, "")
pending = $0
next
}
/^[[:space:]]+[^[:space:]]/ {
if (pending != "") {
line = $0
sub(/^[[:space:]]+/, "", line)
pending = pending " " line
}
next
}
/^[[:space:]]*$/ {
if (pending != "") { html = html "<li>" escape(pending) "</li>"; pending = "" }
next
}
END {
if (pending != "") { html = html "<li>" escape(pending) "</li>" }
if (ul_open) { html = html "</ul>" }
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(/&/, "\\&amp;", s); gsub(/</, "\\&lt;", s); gsub(/>/, "\\&gt;", 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 "<li>" escape(pending) "</li>"; pending = "" }
if (ul_open) { html = html "</ul>"; ul_open = 0 }
match($0, /[0-9]+\.[0-9]+\.[0-9]+/)
ver = substr($0, RSTART, RLENGTH)
html = html "<h4>" ver "</h4><ul>"
ul_open = 1
next
}
/^\*[[:space:]]/ {
if (pending != "") { html = html "<li>" escape(pending) "</li>" }
sub(/^\*[[:space:]]+/, "")
pending = $0
next
}
/^[[:space:]]+[^[:space:]]/ {
if (pending != "") {
line = $0
sub(/^[[:space:]]+/, "", line)
pending = pending " " line
}
next
}
/^[[:space:]]*$/ {
if (pending != "") { html = html "<li>" escape(pending) "</li>"; pending = "" }
next
}
END {
if (pending != "") { html = html "<li>" escape(pending) "</li>" }
if (ul_open) { html = html "</ul>" }
print html
}
'"'"' /tmp/test-readme.txt
'
```
Expected output:
```
<h4>1.2.0</h4><ul><li>New: Self-hosted updates. Second line continues.</li><li>Dev: release.sh.</li></ul><h4>1.1.0</h4><ul><li>New: Silent drop.</li></ul><h4>1.0.0</h4><ul><li>First release.</li></ul>
```
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.