Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion .github/workflows/release-zip.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,34 @@ permissions:
contents: write

jobs:
# A release tag MUST be v<semver>. Tiger_Update_Core::resolveRelease() fetches
# /releases/tags/v<version>, 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<semver>
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<major>.<minor>.<patch> (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
with:
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
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<assetBase>` 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<semver>`.** `Tiger_Update_Core::resolveRelease()` fetches
`/releases/tags/v<version>`, 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
Expand Down
41 changes: 38 additions & 3 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -119,5 +122,37 @@ the source of truth those checks are generated from.

---

## Headless install <a id="headless"></a>

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.*
71 changes: 71 additions & 0 deletions library/Tiger/Theme.php
Original file line number Diff line number Diff line change
Expand Up @@ -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/<assetBase> (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
Expand Down
2 changes: 1 addition & 1 deletion library/Tiger/Version.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
32 changes: 6 additions & 26 deletions modules/system/services/Modules.php
Original file line number Diff line number Diff line change
Expand Up @@ -193,46 +193,26 @@ 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',
'/system/modules'
);
}

/** Symlink a theme's assets/ to public/<assetBase> (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
Expand Down
101 changes: 101 additions & 0 deletions tests/Integration/Theme/ThemeActivateTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php
/**
* SPDX-License-Identifier: BSD-3-Clause
* Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
*
* Tiger_Theme::activate()/deactivate() — the ONE authority for making a theme active (THEMES.md §5a),
* shared by the Modules admin service and the headless installer (TIGER-124). Before this seam the
* logic lived only inside System_Service_Modules, so a non-interactive caller had to re-implement it.
*/

namespace Tiger\Tests\Integration\Theme;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use RuntimeException;
use Tiger\Tests\Support\IntegrationTestCase;
use Tiger_Model_Config;
use Tiger_Theme;

#[CoversClass(Tiger_Theme::class)]
final class ThemeActivateTest extends IntegrationTestCase
{
private const SLUG = 'theme-q9z';
private const KEY = 'q9z';

private string $moduleDir;
private string $link;

protected function setUp(): void
{
parent::setUp();
$this->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/_<key>');
$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
}
}
Loading