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 домработница.рус.
45 KiB
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:
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 (с корня проекта):
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:
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
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
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:
./vendor/bin/phpunit --filter UpdateCheckerIntegrationTest
Expected: PASS (both tests green) — vendoring был сделан в Task 1.
- Step 3: Commit
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:
{
"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:
php -r 'var_dump(json_decode(file_get_contents("wp-plugin-info.json"), true))' | head -5
Expected: вывод начинается с array(11) (11 ключей) — JSON валиден.
- Step 3: Commit
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):
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:
./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
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 fromboot()) -
Modify:
tests/UpdateCheckerIntegrationTest.php(add reflection test) -
Step 1: Write failing reflection test
Append to tests/UpdateCheckerIntegrationTest.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:
./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
Pluginclass and call fromboot()
Edit includes/class-plugin.php:
Change the boot() method from:
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:
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:
./vendor/bin/phpunit --filter test_plugin_class_has_register_update_checker_method
Expected: PASS.
- Step 5: Run full test suite
Run:
./vendor/bin/phpunit
Expected: все тесты зелёные (существующие + 6 новых).
- Step 6: Commit
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:
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
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)
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
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:
./vendor/bin/phpunit --filter ZipArchiveBuildTest
Expected: PASS. Note: release.sh ещё не существует — тест test_git_archive_excludes_dev_files проверяет его отсутствие, но ассерт StringNotContainsString пройдёт в обоих случаях (есть/нет). Цель тестов — защита от случайного добавления dev-файлов в archive в будущем.
- Step 3: Commit
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 (корень):
#!/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:
chmod +x release.sh
- Step 2: Smoke-test validate with bad input
Run:
./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
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:
# ---------- 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):
# 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
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:
# ---------- 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
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:
# ---------- 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
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:
# ---------- 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
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:
# ---------- 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
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:
# ---------- STEP 7: REGENERATE wp-plugin-info.json ---------------------------
log "Converting readme.txt Changelog to HTML"
CHANGELOG_HTML="$(awk '
function escape(s) {
gsub(/&/, "\\&", 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 "<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:
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); 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 "<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
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 0with JSON commit step and summary
# ---------- 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
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:
grep -c '^= 1.2.0 =$' readme.txt
Expected: 1.
- Step 3: Commit
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 readyshows tasks complete -
All tests pass (
./vendor/bin/phpunitgreen) -
git statusclean -
git log --oneline -n 15shows all prior tasks committed and onmain -
~/.gitea-tokenexists withwrite:repositoryscope -
Step 1: Confirm prereqs
Run:
./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:
./release.sh 1.2.0
Expected: script runs through all 9 steps, final summary prints URLs.
- Step 3: Verify Gitea release
Run:
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:
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:
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:
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 (с локальной машины):
# 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):
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
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
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:
- Login to домработница.рус WP admin.
- Plugins → "Check again" (link near the top).
- Verify: no update notice for CF7 Spam → Telegram (because we're on 1.2.0 and remote JSON = 1.2.0).
- 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:
ssh sidelkin-ssh 'rm -rf domramotnica.rus/public_html/wp-content/plugins/cf7-spam-to-telegram.bak-*'
Completion checklist
After all tasks:
./vendor/bin/phpunitall green locallygit log --onelineonmainshowsrelease: 1.2.0+wp-plugin-info.json: update for 1.2.0at tophttps://git.netranking.ru/bryzgalov/cf7-spam-to-telegram/releases/tag/v1.2.0exists with a downloadable.zipasset- Raw
wp-plugin-info.jsononmainhasversion=1.2.0, realdownload_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.shis 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 therequire_onceitself triggers WP calls — investigate withsuperpowers:systematic-debuggingbefore adding shims. - Changelog HTML conversion (Task 15): the awk script handles the current
readme.txtformat 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.