Version update history for plugins & themes — a proposal
Context
user/config/versions.yaml keeps an audit trail of core updates
(core/grav history, written during grav upgrades via
system/src/Grav/Installer/Install.php:330,350,442 → VersionUpdater → Versions::updateVersion()),
but plugins and themes are never recorded. Confirmed on a live site: after
years of GPM updates, the plugins: section is absent.
Origin. Reconstructing a site's upgrade history from its backup zip
(user/config/versions.yaml inside the archive) produced an intact
core.grav.history spanning several years, but no plugins: section at
all, despite all those GPM updates. Plugin update times had to be inferred
from each plugin's file modification time aligned to the nearest core-update
timestamp; a precise record of what changed and when did not exist. This
proposal closes that gap.
The underlying engine already supports this. Versions
(system/src/Grav/Installer/Versions.php) has full plugins/<slug> /
themes/<slug> support — getPlugins() (:81), getThemes() (:98),
getHistory() (:194), updateVersion() (:160), and updateHistory()
(:211) which appends ['version' => ..., 'date' => gmdate('Y-m-d H:i:s')],
on a store that already defaults to USER_DIR . 'config/versions.yaml'
(:35,37). It is simply never connected for plugins and themes: no call site
exists outside Install.php.
In short: one small recording hook in the installer engine, one API endpoint, and a "version history" section in Admin2. Everything else is existing infrastructure.
Design decisions
| Topic | Recommendation | Why |
|---|---|---|
| Recording point | Inside Grav\Common\GPM\Installer::install() — the single engine choke point |
All paths funnel here: CLI gpm install/update, gpm direct-install, and the Admin2 web install/update (api/.../GpmService.php:125). One hook covers all current and future install paths, mirroring how core history is recorded (inside the install flow, not per-command) |
| Code shape | One small named private static method on Installer |
Fits the class's existing style (loadInstaller :426, moveInstall :474, isValidDestination :677) and recent hardening helpers (isSafeArchiveEntry, archiveLimits, extractStreamed) |
| Which version to record | The freshly installed blueprints.yaml version: |
Authoritative and identical across all three paths; avoids the $package->available vs ->version ambiguity in InstallCommand |
| Expose to Admin2 | A new GET /gpm/versions endpoint |
One server-side read of versions.yaml, ETag-cacheable, doesn't bloat every PluginInfo payload; the UI reads a per-slug map |
| Where it shows up | A "Version history" section on the plugin/theme detail pages | It places the data where users look for update context. Themes reuse the shared ADMIN_NEXT.PLUGINS.* namespace already |
| History start | No backfill — history begins from the release that ships this | Same precedent as core.grav.history (started at 1.7.0-rc.20 when the feature shipped) |
| On uninstall | Keep the history | It's an audit store; removeHistory() (:224) serves skeleton building |
| Edge cases | Silent skip if a blueprint/version is missing | Never fail an otherwise good install because of metadata |
| Security | Read-only endpoint behind api.gpm.read, no trust boundary crossed |
Comparable to what CHANGELOG/README endpoints already serve |
Part 1 — Grav core (the engine)
Repo: getgrav/grav · File: system/src/Grav/Common/GPM/Installer.php
1a. Imports (Installer.php:12-20)
use Grav\Common\Filesystem\Folder;
use Grav\Common\Grav;
use Grav\Common\Utils;
+use Grav\Common\File\CompiledYamlFile;
+use Grav\Installer\Versions;
use RuntimeException;1b. Hook at the success exit (Installer.php:166)
self::$error = self::OK;
+ // Record version history for freshly installed / updated plugins and
+ // themes. Core payloads install under system/ so they never match the
+ // plugins|themes path below and stay recorded exclusively by
+ // Install::finalize().
+ self::recordVersionHistory($install_path);
return true;1c. New private static helper (right after loadInstaller(), before moveInstall() at :474)
/**
* Record a freshly installed / updated plugin or theme in
* user/config/versions.yaml (the same store core/grav history is written
* to by Install::finalize(), via VersionUpdater).
*
* @param string $install_path Fully resolved install destination.
*/
private static function recordVersionHistory(string $install_path): void
{
if (!preg_match('|/(plugins|themes)/([^/]+)$|u', $install_path, $m)) {
return; // System, binary, or root payload: core recording applies.
}
$type = $m[1];
$slug = $m[2];
$blueprints = $install_path . '/blueprints.yaml';
if (!is_file($blueprints) || !is_readable($blueprints)) {
return; // Best-effort metadata; never fail an otherwise good install.
}
$data = CompiledYamlFile::instance($blueprints)->content();
$version = is_array($data) ? $data['version'] ?? null : null;
if (!$version) {
return;
}
Versions::instance()->updateVersion("{$type}/{$slug}", (string)$version)->save();
}Notes. Type + slug are derived from the destination path, consistent with
the existing theme detection at Installer.php:141-143. Core self-upgrade
payloads install under system/, so they're excluded here and stay recorded
exclusively by Install::finalize() — no double-writes. Symlinked /
"already exists" / preflight-failure branches already return false before
:166, so only genuine installs and updates end up in history.
Part 2 — API plugin
Repo: getgrav/grav-plugin-api
2a. Route (classes/Api/ApiRouter.php — GPM block, after /gpm/upgrade at :826)
$r->addRoute('POST', '/gpm/upgrade', [GpmController::class, 'upgrade']);
+ $r->addRoute('GET', '/gpm/versions', [GpmController::class, 'versions']);2b. Controller (classes/Api/Controllers/GpmController.php)
Import — add to the use block (after use Grav\Common\GPM\Installer; at :8):
use Grav\Installer\Versions;Handler modeled on updates() (GpmController.php:189-230). Insert right
after it (~:231):
/**
* GET /gpm/versions - Installed versions and their recorded update history.
*
* Source: user/config/versions.yaml. History entries only exist for
* updates made after the recording feature was released; there is no
* backfill. Dates are UTC, Y-m-d H:i:s.
*/
public function versions(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$versions = Versions::instance();
$data = [
'grav' => [
'version' => $versions->getVersion('core/grav') ?? GRAV_VERSION,
'history' => $versions->getHistory('core/grav'),
],
'plugins' => [],
'themes' => [],
];
foreach ($versions->getPlugins() as $slug => $section) {
$data['plugins'][$slug] = [
'version' => $section['version'] ?? null,
'history' => $section['history'] ?? [],
];
}
foreach ($versions->getThemes() as $slug => $section) {
$data['themes'][$slug] = [
'version' => $section['version'] ?? null,
'history' => $section['history'] ?? [],
];
}
return $this->respondWithEtag($data);
}On sites without a versions.yaml, the store simply yields empty arrays (the
constructor already handles an absent file, Versions.php:318-326);
respondWithEtag handles ETag/304 exactly like plugin() does
(GpmController.php:116,183).
2c. OpenAPI (openapi.yaml)
Insert GET /gpm/versions after the /gpm/updates block (which ends just
before /gpm/install at openapi.yaml:3144):
/gpm/versions:
get:
operationId: getVersions
tags: [GPM]
summary: Installed versions and recorded update history
description: |
Installed version and recorded {version, date} history for Grav core,
all installed plugins and all installed themes, sourced from
user/config/versions.yaml. History starts accumulating at the release
that introduced recording; there is no backfill. Requires
`api.gpm.read` permission.
responses:
"200":
description: Version map.
content:
application/json:
schema:
$ref: "#/components/schemas/VersionsMap"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"429":
$ref: "#/components/responses/TooManyRequests"Schemas (under components.schemas):
VersionsMap:
type: object
properties:
grav:
$ref: "#/components/schemas/VersionedPackage"
plugins:
type: object
additionalProperties:
$ref: "#/components/schemas/VersionedPackage"
themes:
type: object
additionalProperties:
$ref: "#/components/schemas/VersionedPackage"
VersionedPackage:
type: object
properties:
version:
type: string
nullable: true
history:
$ref: "#/components/schemas/VersionHistory"
VersionHistory:
type: array
items:
type: object
properties:
version:
type: string
date:
type: string
description: UTC, Y-m-d H:i:s.Part 3 — Admin2 SPA
Repo: getgrav/grav-admin-next
3a. API client (src/lib/api/endpoints/gpm.ts)
Insert after checkUpdates() (gpm.ts:181-183):
export interface VersionEntry {
version: string;
date: string;
}
export interface VersionedPackage {
version: string | null;
history: VersionEntry[];
}
export interface VersionsResponse {
grav: VersionedPackage;
plugins: Record<string, VersionedPackage>;
themes: Record<string, VersionedPackage>;
}
/**
* Installed versions and their recorded update history (source:
* user/config/versions.yaml on the server).
*/
export async function getVersions(): Promise<VersionsResponse> {
return api.get<VersionsResponse>('/gpm/versions');
}3b. Plugin detail page (src/routes/plugins/[slug]/+page.svelte)
Load once next to the existing getPlugin call (:101 area):
const versions = await getVersions();
const versionHistory = versions.plugins[slug]?.history ?? [];Render a small section after the version header (<span>v{plugin.version}</span> at :461):
<section class="mt-6">
<h2 class="text-sm font-semibold text-foreground">{i18n.t('ADMIN_NEXT.PLUGINS.VERSION_HISTORY')}</h2>
{#if versionHistory.length > 0}
<ul class="mt-2 space-y-1 text-sm text-muted-foreground">
{#each versionHistory as entry}
<li>v{entry.version} — {entry.date} UTC</li>
{/each}
</ul>
{:else}
<p class="mt-2 text-sm text-muted-foreground">{i18n.t('ADMIN_NEXT.PLUGINS.NO_VERSION_HISTORY')}</p>
{/if}
</section>Themes get the same treatment: src/routes/themes/[slug]/+page.svelte,
anchored on v{theme.version} at :441, reading versions.themes[slug]?.history ?? [].
3c. Translations (grav-plugin-admin2/languages/<lang>.yaml)
Admin2 UI strings live in the Admin2 plugin languages (served through
/api/v1/translations/{lang}). In en-US.yaml after CHANGELOG at :1099:
CHANGELOG: Changelog
VERSION_HISTORY: Version history
NO_VERSION_HISTORY: No version history recorded yet.And the same two keys synced across the other 23 language files (translated per language).
Files touched
| Repo | File | Change |
|---|---|---|
| grav | system/src/Grav/Common/GPM/Installer.php |
2 imports, 1 call, 1 new private static method |
| grav-plugin-api | classes/Api/Controllers/GpmController.php |
1 import, 1 handler |
| grav-plugin-api | classes/Api/ApiRouter.php |
1 route |
| grav-plugin-api | openapi.yaml |
1 path + 3 schemas |
| grav-admin-next | src/lib/api/endpoints/gpm.ts |
3 interfaces + 1 fetch function |
| grav-admin-next | src/routes/plugins/[slug]/+page.svelte |
history section |
| grav-admin-next | src/routes/themes/[slug]/+page.svelte |
history section |
| grav-plugin-admin2 | languages/*.yaml |
2 keys × 24 files (en-US first) |
Resulting behavior
plugins.<slug>.version+.history[]andthemes.<slug>.version+.history[]start appearing inversions.yamlfrom the first recorded update onward (UTCY-m-d H:i:s, matching core's format).- CLI
gpm install,gpm update,gpm direct-installand the Admin2 web install/update all record — one hook, no per-command bookkeeping. - Core upgrade history is untouched (already recorded, and excluded by the plugins|themes guard).
- Upgraded sites simply begin accumulating history from the release that ships this — no migration and no backfill required.
Verification
- Core unit tests —
Versionshas no coverage today; a round-trip test (updateVersion→getHistory) plus one forrecordVersionHistory(path guard, missing blueprint, happy path). - Engine functional — on a scratch Grav installation:
gpm install,gpm update,gpm direct-install, then assertplugins:/themes:sections appear with UTC{version, date}— and that a core self-upgrade adds no duplicatecore/graventry. - API — seed a
versions.yaml,GET /gpm/versions: shape, ETag/304,api.gpm.readgating (401/403), and tolerance when the file is absent. - SPA —
svelte-check+ build; manually: detail pages show history or the empty-state string, and a package updated via the web UI shows up in history.
Impact assessment
Impact was evaluated relative to the project's security guidelines (whose
severity model is trust-boundary based). All layers are admin-gated read
paths: the engine hook runs only inside an admin-triggered install/update,
the new endpoint is read-only and requires api.gpm.read (and is blocked in
demo mode, api/.../Middleware/DemoModeMiddleware.php:38), and the Admin2 UI
renders Svelte-escaped values inside the admin session. No trust boundary is
crossed and nothing beyond what admins already see via GET /gpm/plugins is
exposed. This is intended to go through the normal feature review channels.
This report was generated with AI assistance.
Source: getgrav/grav