# Shipping Module Updates

https://dev.wisecp.com/fr/shipping-module-updates

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.

```json
{"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**: From 5.0-beta.4 a finished update calls `action:module.updated`; older cores call nothing of yours, so the same checks also run on a path of your own.

## Structure

Detection, decision, installation and the announcement that follows are separate stages.

| Stage | Who runs it | What happens |
| --- | --- | --- |
| Discovery | a daily scheduled task | Every manifest is collected and asked about in one call per source |
| Notice | the panel | A bell notice snapshotting version, changelog and card, with Update Now and Later. A marketplace listing with several directories raises one notice naming all of them (5.0-beta.4 and later; older cores update one directory) |
| Wizard | the operator | Four resumable requests: download, extract, apply, finish, addressed by a token |
| Announcement | the finish step | `action:module.updated` reaches your listener with the previous and the new version, once per directory (5.0-beta.4 and later) |

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.

```bash
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`. After a successful apply the installation writes the new version into its manifest. It also moves `meta.version` in `config.php` on to the same release (5.0-beta.2 and later). The rest of that file stays as the operator saved it.

```php
// 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`.

From 5.0-beta.4 each directory of a listing is searched on its own, and only the full tree adds a new one.

### Migrate your own tables

The update copies files and does not call the enable path again. From 5.0-beta.4 it announces the finished update with `action:module.updated`, and that is where the migration belongs. A customer still on an older core never receives that call, so the same checks also run from a page of yours.

```php
public function enable(): bool
{
    // The fresh install path.
    $this->check_database();

    return true;
}

public function adminArea(): array
{
    // Cores older than 5.0-beta.4 send no update hook: enable() ran long 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.
 */
public 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.

```php
Hook::add('action:module.updated', 10, function (array $update) {
    if ($update['type'] !== 'Addons' || $update['key'] !== 'AuroraBackup') return;

    // This request loaded the new release's files, so the new checks run here.
    $module = Modules::getInstance('Addons', 'AuroraBackup');
    $module->check_database();

    // Work that depends on the release left behind is keyed to from.
    if (version_compare($update['from'], '1.2.0', '<')) $module->link_old_jobs();
});
```

Every step of the wizard is its own request. The finish request registers the listener from the files apply put in place. A listener that throws does not undo the update: the exception goes to the error log and the wizard still reports success.

### Write the changelog

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

```json
[
  {"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

| Field | Required | Rule |
| --- | --- | --- |
| `type` | always | `wstore` or `marketplace`; anything else means not managed |
| `id` | marketplace only | The numeric listing id; directory names are not unique |
| `name` | store only | The product key; blank rejects the whole manifest |
| `version` | always | The installed version, and the only thing compared |
| `last_updated` | no | Shown to the operator, never part of a decision |

### The updater surface

```php
// 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.
// apply then moves meta.version in config.php on; finish fires action:module.updated.
```

- **ModuleUpdater::installed()**: Every managed directory: kind, type, key, dir, active and `ident`. Here `type` is the manifest's source (`wstore` or `marketplace`); the module type is `module_type`. The update hook's own `type` is the module type.
- **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**: Every target directory is copied aside before any file is written. If one directory fails, all of them are put back (5.0-beta.4 and later).
- **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 and only `meta.version` is moved on to the new release; 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.

The finish step then calls `action:module.updated` and removes the job folder.

### Failure codes

Every step throws a bare code so the panel can translate it; a code about one directory names it after a colon.

| Code | Raised by | What it usually means |
| --- | --- | --- |
| `not_managed` | begin, apply | No readable manifest: hand installed, or malformed |
| `already_current` | begin | Not newer; usually a reused number |
| `download_failed` | download | The signed address returned nothing |
| `zip_unavailable` | extract | The host has no ZipArchive |
| `archive_unreadable` | extract | The archive would not open; often a nested one |
| `archive_unsafe` | extract | An entry would land outside the job folder; nothing was unpacked |
| `package_mismatch` | extract, apply | No layout held the expected class file, or a directory the listing installed is missing: `package_mismatch:Servers/AcmeCloud` |
| `target_conflict` | extract | A directory this release adds is already used by something else; nothing was changed (5.0-beta.4 and later) |
| `install_failed` | apply | A copy failed; the rollback has been put back |

## Example

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

```json
// 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"}
```

```bash
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; where one exists only its meta.version moves on
     ├─ hooks.php                 # registers the action:module.updated listener
     ├─ 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
```

```php
<?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";
```

```json
{
    "type": "marketplace",
    "id": 42,
    "version": "1.2.0",
    "last_updated": "2026-08-03"
}
```

The identity fields are carried over rather than derived again.

## Pitfalls

> **The version in config.php is not the update signal**
> 
> The manifest decides what is offered, and an update moves the number in config.php on to the new release. A migration that compares it with the version in your code never sees the gap; key it to the hook's `from`.

> **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. Run it from the update hook and from a path that runs anyway.

> **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.

## Related Articles

- [Shipping a Release](https://dev.wisecp.com/en/shipping-a-marketplace-release)
- [Packaging a Module for Distribution](https://dev.wisecp.com/en/packaging-a-module-for-distribution)
- [Licensing a Module](https://dev.wisecp.com/en/licensing-a-module)
- [Module Lifecycle](https://dev.wisecp.com/en/module-lifecycle)
- [Module Installation Hooks](https://dev.wisecp.com/en/module-installation-hooks)
- [Changing the Database Schema](https://dev.wisecp.com/en/changing-the-database-schema)
- [Surviving a Core Upgrade](https://dev.wisecp.com/en/surviving-a-core-upgrade)
