# Shipping Module Updates

https://dev.wisecp.com/es/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**: The update path runs no script of yours.

## Structure

Detection, decision and installation are three separate things.

| 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 |
| Wizard | the operator | Four 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.

```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`. The installation rewrites its copy after a successful apply.

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

### Migrate your own tables

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

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

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

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

| 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 |
| `package_mismatch` | extract, apply | No layout held the expected class file |
| `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, 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
```

```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

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

## 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)
- [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)
