Shipping Module Updates

4 views Markdown

Get a new version onto installations that already have it: one manifest, one version number, one operator decision.

Overview

The installation asks once a day what is published, and the publisher answers with a version, a changelog and a product card. A newer answer raises a notice; the update runs only on a click.

A directory becomes updatable by carrying manifest.json; without it the copy is never touched.

the contract you ship
{"type":"marketplace","id":42,"version":"1.2.0","last_updated":"2026-08-03"}

Prerequisites

A published listing Detection asks by identity: marketplace numeric id, store key name.
A version that only goes up PHP version comparison, so 2.10.0 is newer than 2.4.0. A published number cannot be reused.
ZipArchive on the customer's host Without it the extract step fails with zip_unavailable.
A writable temp directory Each job lives under temp/module-update: archive, payload and rollback copy.
Migrations that can run twice The update path runs no script of yours.

Structure

Detection, decision and installation are three separate things.

StageWho runs itWhat happens
Discoverya daily scheduled taskEvery manifest is collected and asked about in one call per source
Noticethe panelA bell notice snapshotting version, changelog and card, with Update Now and Later
Wizardthe operatorFour resumable requests: download, extract, apply, finish, addressed by a token

There is no automatic install step. The changelog you write is frozen the day it is discovered.

Editing a changelog does not reach a pending notice

Deduplication is on module plus version and looks at unread notices only, so one in the bell keeps its text; a later run raises a fresh row.

Walkthrough

Ship the manifest

Put manifest.json next to the class file, or beside theme.php for a theme.

where it sits
coremio/modules/Addons/AuroraBackup/AuroraBackup.php
coremio/modules/Addons/AuroraBackup/config.php
coremio/modules/Addons/AuroraBackup/manifest.json     <- makes the directory updatable

templates/website/Aurora/theme.php
templates/website/Aurora/manifest.json                <- beside theme.php, not instead of it

A manifest that cannot be understood counts as absent: unknown source, missing version, marketplace with no id, store with no name.

Bump the version

The manifest version is the only thing compared, not the one in config.php. The installation rewrites its copy after a successful apply.

how the comparison is made
// Both sides are normalised first: the first line only, and only the characters a
// version is made of, so a stray space or a comment cannot produce a version that
// compares equal to nothing.
public static function normalize(string $version): string;
public static function is_newer(string $remote, string $local): bool;

// is_newer('2.10.0', '2.4.0')  === true    version_compare, not string ordering
// is_newer('1.2.0',  '1.2.0')  === false   equal is not newer
// is_newer('1.2.0',  '')       === false   an unreadable side never triggers an update

Shape the archive

The archive is unpacked and searched; the first matching layout wins.

  1. A folder named exactly like the module key at the archive root.
  2. The full tree, coremio/modules/{Type}/{Key} or templates/website/{Key}.
  3. A single unnamed directory at the root, or the root itself.

Looking right means carrying {Key}.php or theme.php; otherwise the step stops with package_mismatch.

Migrate your own tables

The update copies files: no script of yours runs and the enable path is not called again.

the pattern that survives both paths
public function enable(): bool
{
    // The fresh install path.
    $this->check_database();

    return true;
}

public function adminArea(): array
{
    // The upgrade path: enable() already ran, years ago, on the previous version.
    $this->check_database();

    // ...
    return ['page_title' => 'Aurora Backup', 'content' => $this->view('index.php')];
}

/**
 * Creates what is missing and adds what was added later. Safe to call on every
 * request: the existence checks are the migration.
 */
private function check_database(): void
{
    if (!WDB::hasTable('AuroraBackup_jobs')) {
        WDB::exec('CREATE TABLE `AuroraBackup_jobs` (
            `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
            `user_id` INT UNSIGNED NOT NULL DEFAULT 0,
            `created_at` DATETIME NOT NULL,
            PRIMARY KEY (`id`),
            KEY `owner` (`user_id`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci');

        // Created with every column already present: nothing below has to run.
        return;
    }

    // Added in 1.2: jobs written before it could not be traced back to a schedule.
    $col = WDB::query("SHOW COLUMNS FROM `AuroraBackup_jobs` LIKE 'schedule_id'");
    if (!$col || !WDB::getAssoc($col))
        WDB::exec('ALTER TABLE `AuroraBackup_jobs` ADD `schedule_id` INT UNSIGNED NOT NULL DEFAULT 0 AFTER `user_id`');
}

Keep creation and column repair on separate branches; a seed in the creation branch dies on an older table.

Write the changelog

The dialog groups notes under headings, so each line carries one of four types; an unknown type prints last.

the changelog format
[
  {"type": "features",     "text": "Scheduled jobs can now target a second storage account."},
  {"type": "improvements", "text": "Listing a bucket with many objects no longer times out."},
  {"type": "security",     "text": "Restore tokens are now single use."},
  {"type": "fixes",        "text": "A job cancelled during upload left a partial archive behind."}
]

Reference

The manifest fields

FieldRequiredRule
typealwayswstore or marketplace; anything else means not managed
idmarketplace onlyThe numeric listing id; directory names are not unique
namestore onlyThe product key; blank rejects the whole manifest
versionalwaysThe installed version, and the only thing compared
last_updatednoShown to the operator, never part of a decision

The updater surface

signatures
// reading what is on disk
public static function manifest(string $dir): array;
public static function version(string $type, string $key): string;
public static function normalize(string $version): string;
public static function installed(bool $activeOnly = true): array;
public static function dir(array $item): string;

// asking the publisher
public static function releases(array $items): array;
public static function pending(bool $activeOnly = true): array;
public static function issue(string $source, string $ident): array;
public static function notes(array $changelog): array;
public static function is_newer(string $remote, string $local): bool;

// running one update
public static function token(string $source, string $ident, string $version): string;
public static function begin(string $source, string $ident, string $version): array;
public static function job(string $token): array;
public static function step(string $token, string $step): array;
public static function abandon(string $token): void;
public static function write_manifest(string $dir, array $manifest): bool;

// STEPS is the fixed order: download, extract, apply, finish.
// PRESERVED is the list of filenames apply refuses to overwrite: config.php.
ModuleUpdater::installed() Every managed directory: kind, type, key, dir, active and ident.
ModuleUpdater::pending() The installed entry plus release, product, developer, changelog, available.
ModuleUpdater::manifest() Empty for anything it cannot make sense of; use it to check your package.
ModuleUpdater::step() Runs one named step, and can be called again for it.
ModuleUpdater::token() Derived from source, identity and version, so a retry resumes the job.

What apply does

A rollback copy is taken first The whole target directory is copied aside before any file is written.
Files are merged, not replaced The package tree is copied over the target; missing directories are created.
config.php is skipped when it already exists Operator settings survive; a new component's own config still installs.
Nothing is ever deleted A file you removed stays on disk: there is no delete list.
The manifest is written last A run that dies halfway does not look like a finished one.

Failure codes

Every step throws a bare code so the panel can translate it.

CodeRaised byWhat it usually means
not_managedbegin, applyNo readable manifest: hand installed, or malformed
already_currentbeginNot newer; usually a reused number
download_faileddownloadThe signed address returned nothing
zip_unavailableextractThe host has no ZipArchive
archive_unreadableextractThe archive would not open; often a nested one
package_mismatchextract, applyNo layout held the expected class file
install_failedapplyA copy failed; the rollback has been put back

Example

One release, from the two files that change to what the installation reads back.

the manifest, before and after
// what the customer has on disk
{"type": "marketplace", "id": 42, "version": "1.1.0", "last_updated": "2026-05-14"}

// what you put in the 1.2.0 package
{"type": "marketplace", "id": 42, "version": "1.2.0", "last_updated": "2026-08-03"}
the archive
AuroraBackup-1.2.0.zip
 └─ AuroraBackup/                 # the module key, exactly
     ├─ AuroraBackup.php          # the file the extractor looks for
     ├─ manifest.json             # version 1.2.0
     ├─ config.php                # shipped, but skipped where one already exists
     ├─ lang/
     │   └─ en.php                # a directory, never a flat lang.php
     └─ views/
         ├─ index.php
         └─ schedules.php         # new in 1.2.0, arrives as a plain copy
checking your own package
<?php
// temp/_manifest-probe.php  :  run against an unpacked copy of the package
require dirname(__DIR__) . '/bootstrap.php';

$dir = MODULE_DIR . 'Addons' . DS . 'AuroraBackup';

$manifest = ModuleUpdater::manifest($dir);

// Empty means the directory would never be offered an update at all: unknown type,
// missing version, or a marketplace record with no id.
if (!$manifest) exit("not managed: fix manifest.json\n");

$installed = (string) ($manifest['version'] ?? '');

echo 'reads as: ', $manifest['type'], ' / ', $installed, "\n";
echo ModuleUpdater::is_newer('1.2.0', $installed) ? "1.2.0 would be offered\n" : "1.2.0 would NOT be offered\n";
what the installation reads back
{
    "type": "marketplace",
    "id": 42,
    "version": "1.2.0",
    "last_updated": "2026-08-03"
}

The identity fields are carried over rather than derived again.

Pitfalls

Bumping the version in config.php changes nothing

An update never writes that file, so the number there freezes at the first install.

Zipping the wrong level

A root holding the class file works, and so does one holding the module folder. A version-named folder or a nested archive stops at package_mismatch.

A migration only in enable() misses upgrades

Enabling happens once, and every upgrading customer did it long ago, so a change wired only there misses every existing install.

Removing a file does not remove it

The old file stays on disk and stays loadable, so the replacement has to neutralise it.

A published version number cannot be corrected

A wrong number is fixed by deleting the release and republishing, never by editing it in place.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.