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

45 KiB
Raw Permalink Blame History

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 from boot())

  • 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 Plugin class and call from boot()

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.2FAIL: 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(/&/, "\\&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:

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

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

./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:

  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:

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.