diff --git a/.github/workflows/release-zip.yml b/.github/workflows/release-zip.yml index a59ec926..b80502f0 100644 --- a/.github/workflows/release-zip.yml +++ b/.github/workflows/release-zip.yml @@ -16,8 +16,26 @@ permissions: contents: write jobs: + # A release tag MUST be v. Tiger_Update_Core::resolveRelease() fetches + # /releases/tags/v, so a bare "1.6.3" tag publishes a ZIP nothing can find — the admin + # "Update core" button and the headless upgrade verb both report "no release with a vendored + # bundle" while the asset sits right there. (It happened; two releases had to be re-tagged.) + tag-format: + runs-on: ubuntu-latest + steps: + - name: Assert the release tag is v + env: + TAG: ${{ github.event.release.tag_name }} + run: | + if ! printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$'; then + echo "::error::Release tag '$TAG' must be v.. (e.g. v1.6.4) — the updater resolves releases by that exact form." + exit 1 + fi + echo "✓ tag $TAG" + # The gate: run the full smoke matrix against the released tag before anything is attached. smoke: + needs: tag-format permissions: contents: read uses: ./.github/workflows/smoke.yml @@ -25,7 +43,7 @@ jobs: ref: ${{ github.event.release.tag_name }} vendored-zip: - needs: smoke # skipped (→ no ZIP attached) if smoke fails + needs: smoke # skipped (→ no ZIP attached) if smoke fails or the tag is malformed runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fac286f..ca501199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows ## [Unreleased] +## [1.7.0] — 2026-09-14 + +**One theme-activation authority, and the headless install path documented.** + +### Added + +- **`Tiger_Theme::activate($slug)` / `deactivate($slug)`** (TIGER-124). Making a theme active — the + `tiger.theme` config write plus the `public/` link with the copy fallback — lived only + inside the Modules admin service, so a non-interactive caller had to re-implement it. It is now a + library seam the service and the headless installer both call. `ThemeActivateTest` covers the link, + idempotent re-activation over a stale link, and that deactivating a theme that is not active changes + nothing. +- **INSTALL.md — Headless install.** The third install path: `tiger-headless` (WebTigers/TigerHeadless), + the non-interactive authority behind the WHM plugin, Softaculous and any provisioning script. + +### Fixed + +- **Release tags must be `v`.** `Tiger_Update_Core::resolveRelease()` fetches + `/releases/tags/v`, so a bare `1.6.3` tag published a vendored ZIP that neither the admin + "Update core" button nor the headless `upgrade` verb could find. 1.6.3 and 1.6.4 were re-tagged, and + the release workflow now refuses a malformed tag before building anything. + ## [1.6.4] — 2026-09-14 ### Fixed diff --git a/INSTALL.md b/INSTALL.md index 3f157d7b..8e543e46 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -6,10 +6,13 @@ runs anywhere. **Check these *before* you install:** most are one-time cPanel/`p and the [web installer's pre-flight](#pre-flight) will verify them for you and tell you exactly what to change if something's short. -Two install paths, same requirements: +Three install paths, same requirements: -- **Composer** (shell hosts / dev): `composer create-project webtigers/tiger my-app --stability=beta` -- **No-shell / cPanel** (roadmap): a pre-built vendored ZIP + a browser web installer. +- **Composer** (shell hosts / dev): `composer create-project webtigers/tiger my-app` +- **No-shell / cPanel**: the pre-built vendored ZIP + the browser [web installer](https://github.com/WebTigers/TigerInstall). +- **Headless** (packaging front-ends — a WHM plugin, Softaculous, a provisioning script): the + [headless installer](https://github.com/WebTigers/TigerHeadless) — one JSON spec in, one JSON result + out, no human at a browser. See [Headless install](#headless) below. --- @@ -119,5 +122,37 @@ the source of truth those checks are generated from. --- +## Headless install + +The web installer is for a person; **`tiger-headless`** ([WebTigers/TigerHeadless](https://github.com/WebTigers/TigerHeadless)) +is for a program. A WHM plugin, a Softaculous package, or a provisioning script has the database +credentials and the admin details in hand and wants a result code — it does not want to drive a +wizard. There is **one install**, not several: the headless installer calls the same Tiger authorities +the web installer and `bin/tiger` do (`Tiger_Install`, `Tiger_Db_Migrator` over +`Tiger_Module_Installer::migrationPaths()`, `Tiger_Module_Installer`, `Tiger_Theme::activate()`), so a +front-end never re-implements a migration, an owner record, or an asset link. + +``` +tiger-headless install --spec=spec.json # or --spec=- to read it from stdin +``` + +The spec names the pre-created database, the app root and docroot, the site URL, the admin, and +optionally a locale, Directory modules and a theme. The result is JSON on stdout — every step named +with its outcome, the admin URL on success, the failing step on failure — and the exit code is honest +(`0` ok · `1` a step failed · `2` invalid spec). A re-run after a failure resumes from the failed step; +a re-run after success is a no-op that says so. The docroot front controller is written **last**, so a +half-finished install is never web-reachable. + +**Layout is not negotiable by accident.** The default keeps the app **above the docroot** (this +document's security story against `wp-config.php`); a caller that genuinely cannot express that opts +in to the `docroot` layout explicitly, and the result says which layout was used. + +`upgrade`, `backup`, `restore` and `status` verbs wrap `Tiger_Update_Core` and `Tiger_Backup` the same +way, so a packaging front-end gets every lifecycle verb from one binary. The requirements on this +page apply unchanged — the headless `requirements` step checks the same things the web pre-flight does, +plus that the database accepts the supplied credentials, before anything is extracted. + +--- + *Requirements are also declared in `composer.json` (`require`) for the shell path; this document is the human + no-shell reference. Keep the two in sync.* diff --git a/library/Tiger/Theme.php b/library/Tiger/Theme.php index 5114516c..a94dcfe4 100644 --- a/library/Tiger/Theme.php +++ b/library/Tiger/Theme.php @@ -221,6 +221,77 @@ public static function active() ]; } + /** + * Make an installed theme the active one (THEMES.md §5a): write `tiger.theme` (global scope — one + * active theme per scope) and link its assets to its `assetBase` (copied where symlink() is blocked). + * No module.active flag, no build, no deploy. The ONE authority — the Modules admin and the headless + * installer both call this rather than each writing the config key their own way. + * + * @param string $slug the theme's module slug (e.g. `theme-grey-mist`) + * @return array{slug:string,key:string,asset_base:string} + * @throws RuntimeException when no theme with that slug is on disk + */ + public static function activate($slug) + { + $d = self::_discovered($slug); + $key = (string) ($d['key'] ?? preg_replace('/^theme-/', '', $slug)); + $base = ((string) ($d['asset_base'] ?? '')) !== '' ? (string) $d['asset_base'] : '/_' . $key; + (new Tiger_Model_Config())->set(Tiger_Model_Config::SCOPE_GLOBAL, '', 'tiger.theme', $key); + self::_linkAssets($slug, $base, (string) ($d['area'] ?? 'app')); + return ['slug' => $slug, 'key' => $key, 'asset_base' => $base]; + } + + /** + * Deactivate a theme: clear `tiger.theme` back to the platform base theme — but only if THIS theme + * is the active one, so deactivating a theme that is not active changes nothing. + * + * @param string $slug the theme's module slug + * @return bool whether the config was cleared + */ + public static function deactivate($slug) + { + $d = self::_discovered($slug); + $key = (string) ($d['key'] ?? preg_replace('/^theme-/', '', $slug)); + $cfg = new Tiger_Model_Config(); + if ($cfg->get(Tiger_Model_Config::SCOPE_GLOBAL, '', 'tiger.theme') === $key) { + $cfg->set(Tiger_Model_Config::SCOPE_GLOBAL, '', 'tiger.theme', ''); + return true; + } + return false; + } + + /** The discovery row for a theme slug, or throw. */ + protected static function _discovered($slug) + { + $rows = Tiger_Module_Discovery::all(); + $d = $rows[$slug] ?? null; + if (!$d || (string) ($d['type'] ?? '') !== 'theme') { + throw new RuntimeException("No installed theme with slug \"{$slug}\"."); + } + return $d; + } + + /** The docroot-facing public dir — PUBLIC_PATH when booted, else derived from the app root (CLI/tests). */ + protected static function publicDir() + { + if (defined('PUBLIC_PATH')) { return rtrim(PUBLIC_PATH, '/'); } + $base = defined('APPLICATION_ROOT') ? rtrim(APPLICATION_ROOT, '/') : rtrim(getcwd(), '/'); + return $base . '/public'; + } + + /** Symlink a theme's assets/ to public/ (copy fallback where symlinks are blocked). */ + protected static function _linkAssets($slug, $base, $area) + { + $root = ($area === 'app' && defined('APPLICATION_PATH')) ? APPLICATION_PATH : TIGER_CORE_PATH; + $assets = $root . '/modules/' . $slug . '/assets'; + if (!is_dir($assets)) { return; } + $link = self::publicDir() . '/' . ltrim((string) $base, '/'); + if (is_link($link)) { @unlink($link); } + if (!(function_exists('symlink') && @symlink($assets, $link)) && !is_dir($link)) { + Tiger_Module_Installer::publishAssets($slug); // best-effort; symlink is the norm on cPanel + } + } + /** * The active theme's SCOPE (THEMES.md §5d): `'site'` (default — the theme provides the chrome for * the whole public site, CMS pages included, the WordPress model) or `'content'` (the theme styles diff --git a/library/Tiger/Version.php b/library/Tiger/Version.php index 36abe230..3c0c7b28 100644 --- a/library/Tiger/Version.php +++ b/library/Tiger/Version.php @@ -9,5 +9,5 @@ class Tiger_Version { /** Current Tiger Core version. Keep in lockstep with the git tag cut for a release. */ - const VERSION = '1.6.4'; + const VERSION = '1.7.0'; } diff --git a/modules/system/services/Modules.php b/modules/system/services/Modules.php index 65212b39..301f86f5 100644 --- a/modules/system/services/Modules.php +++ b/modules/system/services/Modules.php @@ -193,26 +193,19 @@ protected function _toggle(array $params, $on): void } /** - * Activate/deactivate a THEME (THEMES.md §5a). Activation writes `tiger.theme` (global scope — - * one active theme per scope) and symlinks the theme's assets to its `assetBase`; deactivation - * clears the config back to the platform base theme. No module.active flag, no build, no deploy. + * Activate/deactivate a THEME (THEMES.md §5a) — through the one authority, Tiger_Theme::activate() + * / deactivate(), which the headless installer calls too. Activation writes `tiger.theme` and links + * the theme's assets; deactivation clears the config back to the platform base theme. * * @param string $slug the theme slug - * @param array $d its discovery row (type/asset_base/area) + * @param array $d its discovery row (unused here; kept for the caller's signature) * @param bool $on activate (true) or deactivate (false) * @return void */ protected function _toggleTheme($slug, array $d, $on): void { - $key = (string) ($d['key'] ?? preg_replace('/^theme-/', '', $slug)); // tiger.theme stores the KEY - $cfg = new Tiger_Model_Config(); - if ($on) { - $cfg->set(Tiger_Model_Config::SCOPE_GLOBAL, '', 'tiger.theme', $key); // one active per scope - $base = ((string) ($d['asset_base'] ?? '')) !== '' ? $d['asset_base'] : '/_' . $key; - $this->_linkThemeAssets($slug, $base, (string) ($d['area'] ?? 'app')); - } elseif ($cfg->get(Tiger_Model_Config::SCOPE_GLOBAL, '', 'tiger.theme') === $key) { - $cfg->set(Tiger_Model_Config::SCOPE_GLOBAL, '', 'tiger.theme', ''); // -> platform base theme - } + if ($on) { Tiger_Theme::activate($slug); } + else { Tiger_Theme::deactivate($slug); } $this->_success( ['slug' => $slug, 'theme' => true, 'active' => (bool) $on], $on ? 'system.theme.activated' : 'system.theme.deactivated', @@ -220,19 +213,6 @@ protected function _toggleTheme($slug, array $d, $on): void ); } - /** Symlink a theme's assets/ to public/ (copy fallback where symlinks are blocked). */ - protected function _linkThemeAssets($slug, $base, $area): void - { - $root = ($area === 'app' && defined('APPLICATION_PATH')) ? APPLICATION_PATH : TIGER_CORE_PATH; - $assets = $root . '/modules/' . $slug . '/assets'; - if (!is_dir($assets)) { return; } - $link = PUBLIC_PATH . '/' . ltrim((string) $base, '/'); - if (is_link($link)) { @unlink($link); } - if (!@symlink($assets, $link) && !is_dir($link)) { - Tiger_Module_Installer::publishAssets($slug); // best-effort; symlink is the norm on cPanel - } - } - /** * Search the Vendor Registry (empty + available=false when the registry isn't reachable). Each result * is annotated with its Add-screen `availability` (free|freemium|pass|paid) so the client renders the diff --git a/tests/Integration/Theme/ThemeActivateTest.php b/tests/Integration/Theme/ThemeActivateTest.php new file mode 100644 index 00000000..fe1f0c9e --- /dev/null +++ b/tests/Integration/Theme/ThemeActivateTest.php @@ -0,0 +1,101 @@ +moduleDir = \APPLICATION_PATH . '/modules/' . self::SLUG; + @mkdir($this->moduleDir . '/assets/css', 0777, true); + file_put_contents($this->moduleDir . '/theme.json', json_encode(['key' => self::KEY, 'name' => 'Q9 Theme', 'version' => '0.1.0'])); + file_put_contents($this->moduleDir . '/assets/css/q9z.css', 'body{}'); + $this->link = (\defined('PUBLIC_PATH') ? \PUBLIC_PATH : \APPLICATION_ROOT . '/public') . '/_' . self::KEY; + $this->cleanLink(); + } + + protected function tearDown(): void + { + $this->cleanLink(); + @unlink($this->moduleDir . '/assets/css/q9z.css'); + @rmdir($this->moduleDir . '/assets/css'); + @rmdir($this->moduleDir . '/assets'); + @unlink($this->moduleDir . '/theme.json'); + @rmdir($this->moduleDir); + parent::tearDown(); + } + + private function cleanLink(): void + { + if (is_link($this->link)) { @unlink($this->link); } + elseif (is_dir($this->link)) { @unlink($this->link . '/css/q9z.css'); @rmdir($this->link . '/css'); @rmdir($this->link); } + } + + private function activeTheme(): string + { + return (string) (new Tiger_Model_Config())->get(Tiger_Model_Config::SCOPE_GLOBAL, '', 'tiger.theme'); + } + + #[Test] + public function activate_writes_the_config_key_and_links_the_assets(): void + { + $out = Tiger_Theme::activate(self::SLUG); + $this->assertSame(['slug' => self::SLUG, 'key' => self::KEY, 'asset_base' => '/_' . self::KEY], $out); + $this->assertSame(self::KEY, $this->activeTheme()); + $this->assertTrue(is_dir($this->link), 'assets reachable under public/_'); + $this->assertFileExists($this->link . '/css/q9z.css'); + } + + #[Test] + public function activate_is_idempotent_and_refreshes_a_stale_link(): void + { + Tiger_Theme::activate(self::SLUG); + // Point the link somewhere wrong, as a moved install would; activate() must re-point it. + if (is_link($this->link)) { @unlink($this->link); @symlink(sys_get_temp_dir(), $this->link); } + Tiger_Theme::activate(self::SLUG); + $this->assertSame(self::KEY, $this->activeTheme()); + $this->assertFileExists($this->link . '/css/q9z.css'); + } + + #[Test] + public function deactivate_clears_the_key_only_when_this_theme_is_active(): void + { + Tiger_Theme::activate(self::SLUG); + $this->assertTrue(Tiger_Theme::deactivate(self::SLUG)); + $this->assertSame('', $this->activeTheme(), 'back to the platform base theme'); + + (new Tiger_Model_Config())->set(Tiger_Model_Config::SCOPE_GLOBAL, '', 'tiger.theme', 'someother'); + $this->assertFalse(Tiger_Theme::deactivate(self::SLUG), 'not active → nothing to clear'); + $this->assertSame('someother', $this->activeTheme(), 'another theme\'s activation is left alone'); + } + + #[Test] + public function an_unknown_or_non_theme_slug_is_refused(): void + { + $this->expectException(RuntimeException::class); + Tiger_Theme::activate('system'); // a module, not a theme + } +}