c0ff5a9227
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 домработница.рус.
1399 lines
45 KiB
Markdown
1399 lines
45 KiB
Markdown
# 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(/&/, "\\&", 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:
|
||
|
||
```bash
|
||
cat > /tmp/test-readme.txt <<'EOF'
|
||
== Changelog ==
|
||
|
||
= 1.2.0 =
|
||
* New: Self-hosted updates.
|
||
Second line continues.
|
||
* Dev: release.sh.
|
||
|
||
= 1.1.0 =
|
||
* New: Silent drop.
|
||
|
||
= 1.0.0 =
|
||
* First release.
|
||
EOF
|
||
|
||
# Extract the awk block from release.sh (lines with `awk '` … `' readme.txt`)
|
||
# OR manually test with an inlined awk (copy from step 1 above, swap readme.txt → /tmp/test-readme.txt):
|
||
|
||
bash -c '
|
||
awk '"'"'
|
||
function escape(s) { gsub(/&/, "\\&", s); gsub(/</, "\\<", s); 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**
|
||
|
||
```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.
|