HomeDeveloper docsHow to version, sign and distribute a module
Implementation tutorials 11 min read

How to version, sign and distribute a module

Prepare immutable ZIP releases, verify Ed25519 catalog records and understand installation, activation and revocation.

From a working local package to an immutable release

This guide prepares a publisher’s own distribution for Gravewright 0.3.0. It does not publish anything on your behalf. Start with a complete working first module, sheet action or language package, and test it on a disposable host. Keep original or authorized code/assets and their licenses together. Exclude .env, databases, campaign media, installed packages, logs, private keys and operator files.

1. Prepare a new version and validate the final ZIP

For changed bytes, increment the package manifest version, for example 1.0.0 to 1.0.1. Keep the ID stable, entry path correct and sdk.requires/tested honest for the target host. A release ID/version cannot be replaced with different bytes. Save previous archives and catalog records before publishing an update. Existing users remain on their explicitly selected campaign version until it is changed.

Build the ZIP with manifest.json directly at its root. List the archive and test CRC before signing. The native installer enforces compressed/expanded quotas, file count/type rules, manifest schema, SDK compatibility, entry presence, traversal, duplicate paths and executable-content checks. The signer below adds basic offline checks; it is not a substitute for native installation and activation tests.

powershell
& 'E:/Your disposable host/.venv/Scripts/python.exe' -m zipfile -l ./example.sheet-name-1.0.1.zip
& 'E:/Your disposable host/.venv/Scripts/python.exe' -m zipfile -t ./example.sheet-name-1.0.1.zip

2. Keep installation and activation distinct

Local testing: owner uses Inside → Modules → Install module → Install from ZIP. A system uses Systems → Install system. Installation validates and stores a release. A GM then opens the table’s Gravewright → Extensions → Table modules and selects Activate for the exact browser-module version. A campaign system is selected as the system. A language uses the global Activate languages control; a Django app requires owner activation of trusted server code and a managed restart. Do not treat any of these as the same operation.

3. Choose the signed catalog type

ManifesttypeActivation
entry, without system/djangomoduleCampaign; locales are global
systemsystemCampaign system
django, without entry/system/localesdjangoOwner, server restart

type participates in the signature and must match the ZIP manifest. A language package is catalog type module with locales metadata, not a fourth catalog type. Publish complete RPG systems as one installable system package using the requested host APIs; do not create a separately installed rules companion to work around missing host support.

4. Download the complete offline signer

sign_catalog.py

Save this original MIT helper outside the module source folder. Run it with the host’s already prepared virtual environment; cryptography is an existing host dependency. It imports no private host API, creates no production key and never overwrites an output record. Use the path of a publisher-managed Ed25519 private PEM that you already control. Keep that private key outside the ZIP, site, catalog and repository. For an encrypted PEM, --password-env names an environment variable containing its password; the helper does not print it.

python
"""Offline catalog signer for Gravewright 0.3.0. License: MIT.

Uses cryptography already installed in the host virtual environment. It imports
no host internals, executes no archive code, generates no production key and
overwrites no output. Native installation remains the authoritative ZIP check.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import hmac
import io
import json
import os
from pathlib import Path, PurePosixPath
import re
import stat
import sys
from urllib.parse import urlsplit
import zipfile
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey


def canonical(record):
    # Exact wire representation used by packages.canonical in the target host.
    return json.dumps(
        {k: v for k, v in record.items() if k != "signature"},
        sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False,
    ).encode("ascii")


def archive_info(path):
    path = Path(path)
    if path.stat().st_size > 64 * 1024 * 1024:
        raise ValueError("Archive exceeds 64 MiB: " + str(path))
    data = path.read_bytes()
    with zipfile.ZipFile(io.BytesIO(data)) as archive:
        entries = archive.infolist()
        if len(entries) > 4096 or sum(item.file_size for item in entries) > 256 * 1024 * 1024:
            raise ValueError("Archive quota exceeded")
        seen = set()
        for item in entries:
            name = item.filename.rstrip("/") if item.is_dir() else item.filename
            parts = name.split("/")
            mode = item.external_attr >> 16
            if (not name or PurePosixPath(name).is_absolute()
                    or any(part in ("", ".", "..") for part in parts)
                    or any(char in name for char in ("\\", "\x00", ":", "%", "?", "#"))
                    or stat.S_ISLNK(mode) or stat.S_IFMT(mode) not in (0, stat.S_IFREG, stat.S_IFDIR)
                    or item.flag_bits & 1 or name.casefold() in seen):
                raise ValueError("Unsupported archive path or entry: " + name)
            seen.add(name.casefold())
        info = archive.getinfo("manifest.json")
        if info.file_size > 1024 * 1024:
            raise ValueError("Manifest exceeds 1 MiB")
        manifest = json.loads(archive.read(info))
        if not isinstance(manifest, dict) or not isinstance(manifest.get("sdk"), dict):
            raise ValueError("Manifest and sdk must be objects")
        bad_crc = archive.testzip()
        if bad_crc:
            raise ValueError("Archive CRC failure: " + bad_crc)
        if "entry" in manifest and manifest["entry"] not in archive.namelist():
            raise ValueError("Manifest entry is missing from the ZIP")
    if not re.fullmatch(r"[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+", manifest.get("id", "")):
        raise ValueError("Invalid manifest ID")
    if not re.fullmatch(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", manifest.get("version", "")):
        raise ValueError("Invalid manifest version")
    if not isinstance(manifest.get("sdk", {}).get("requires"), str):
        raise ValueError("Missing sdk.requires")
    if "system" in manifest and "locales" in manifest:
        raise ValueError("A system cannot also declare locales")
    if "django" in manifest and any(key in manifest for key in ("entry", "system", "locales")):
        raise ValueError("A Django package cannot also declare entry, system or locales")
    kind = "django" if "django" in manifest else "system" if "system" in manifest else "module"
    return data, manifest, kind


def valid_download(value):
    parsed = urlsplit(value)
    if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.fragment:
        raise ValueError("Use an HTTPS download URL without credentials or a fragment")
    parsed.port


def record_shape(record):
    required = {"id", "version", "sdk", "download", "sha256", "keyId", "signature"}
    allowed = required | {"status", "name", "description", "type", "tags"}
    if not isinstance(record, dict) or not required <= set(record) or not set(record) <= allowed:
        raise ValueError("Invalid catalog record fields")
    if any(not isinstance(value, str) or not value.isascii() for key, value in record.items() if key != "tags"):
        raise ValueError("Record scalar fields must be ASCII strings")
    tags = record.get("tags", [])
    if (not isinstance(tags, list) or len(tags) > 24
            or any(not isinstance(tag, str) or not tag.strip() or tag != tag.strip()
                   or len(tag) > 64 or any(ord(char) < 32 for char in tag) for tag in tags)
            or len({tag.casefold() for tag in tags}) != len(tags)):
        raise ValueError("Invalid tags")
    if record.get("type", "module") not in ("module", "system", "django"):
        raise ValueError("Invalid package type")
    if record.get("status", "active") not in ("active", "revoked"):
        raise ValueError("Invalid release status")
    if not re.fullmatch(r"[a-f0-9]{64}", record["sha256"]):
        raise ValueError("Invalid SHA-256")
    valid_download(record["download"])


def sign(args):
    data, manifest, kind = archive_info(args.archive)
    record = {"id":manifest["id"], "version":manifest["version"],
              "sdk":manifest["sdk"]["requires"], "type":kind,
              "download":args.download, "sha256":hashlib.sha256(data).hexdigest(),
              "keyId":args.key_id, "status":args.status, "signature":""}
    if args.tag:
        record["tags"] = args.tag
    record_shape(record)
    password = None
    if args.password_env:
        if args.password_env not in os.environ:
            raise ValueError("The requested password environment variable is not set")
        password = os.environ[args.password_env].encode("utf-8")
    private = serialization.load_pem_private_key(Path(args.private_key).read_bytes(), password=password)
    if not isinstance(private, Ed25519PrivateKey):
        raise ValueError("The private PEM must contain an Ed25519 key")
    record["signature"] = base64.b64encode(private.sign(canonical(record))).decode("ascii")
    # Exclusive creation protects an existing reviewed record from replacement.
    with Path(args.output).open("x", encoding="utf-8", newline="\n") as stream:
        json.dump(record, stream, ensure_ascii=True, indent=2)
        stream.write("\n")
    print("Signed " + record["id"] + "@" + record["version"] + " -> " + args.output)


def verify(args):
    record = json.loads(Path(args.record).read_text(encoding="utf-8"))
    record_shape(record)
    data, manifest, kind = archive_info(args.archive)
    if (record["id"] != manifest["id"] or record["version"] != manifest["version"]
            or record["sdk"] != manifest["sdk"]["requires"] or record.get("type", "module") != kind
            or not hmac.compare_digest(record["sha256"], hashlib.sha256(data).hexdigest())):
        raise ValueError("The final ZIP does not match the signed identity, SDK, type or digest")
    keys = json.loads(Path(args.keys).read_text(encoding="utf-8"))
    raw = base64.b64decode(keys[record["keyId"]], validate=True)
    Ed25519PublicKey.from_public_bytes(raw).verify(base64.b64decode(record["signature"], validate=True), canonical(record))
    print("Verified " + record["id"] + "@" + record["version"] + " (" + record.get("status", "active") + ")")


def self_test(_args):
    # Public, deterministic test material. Never use these bytes as a publisher key.
    private = Ed25519PrivateKey.from_private_bytes(bytes(range(32)))
    record = {"id":"example.synthetic", "version":"1.0.0", "sdk":">=1.0.0 <2.0.0",
              "type":"module", "download":"https://publisher.example/synthetic.zip",
              "sha256":"0" * 64, "keyId":"synthetic-test", "status":"active"}
    record["signature"] = base64.b64encode(private.sign(canonical(record))).decode("ascii")
    record_shape(record)
    signature = base64.b64decode(record["signature"])
    private.public_key().verify(signature, canonical(record))
    record["status"] = "revoked"
    try:
        private.public_key().verify(signature, canonical(record))
    except InvalidSignature:
        pass
    else:
        raise AssertionError("Changing signed status must invalidate the signature")
    assert canonical({"z":"a", "signature":"ignored", "a":"b"}) == b'{"a":"b","z":"a"}'
    print("Synthetic Ed25519 and canonicalization tests passed")


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    sub = parser.add_subparsers(dest="command", required=True)
    signing = sub.add_parser("sign", help="Write one signed record, not a complete catalog")
    signing.add_argument("--archive", required=True)
    signing.add_argument("--download", required=True)
    signing.add_argument("--key-id", required=True)
    signing.add_argument("--tag", action="append", default=[], help="Optional signed catalog tag, repeat for multiple tags")
    signing.add_argument("--private-key", required=True)
    signing.add_argument("--password-env", help="Name of an optional PEM-password environment variable")
    signing.add_argument("--status", choices=("active", "revoked"), default="active")
    signing.add_argument("--output", required=True)
    signing.set_defaults(run=sign)
    verifying = sub.add_parser("verify")
    verifying.add_argument("--archive", required=True)
    verifying.add_argument("--record", required=True)
    verifying.add_argument("--keys", required=True)
    verifying.set_defaults(run=verify)
    sub.add_parser("self-test").set_defaults(run=self_test)
    args = parser.parse_args()
    try:
        args.run(args)
    except (OSError, ValueError, TypeError, KeyError, zipfile.BadZipFile, InvalidSignature) as error:
        parser.exit(2, "Error: " + (str(error) or type(error).__name__) + "\n")


if __name__ == "__main__":
    main()

5. Sign the final bytes and verify them

The self-test uses public deterministic synthetic material in memory, never a real publishing key. Run it first. Then replace all example paths, URL and key ID with the publisher’s own values. On POSIX use the existing host .venv/bin/python path in place of python.exe. No dependency install, global Python registration or permanent PATH change is required.

powershell
& 'E:/Your disposable host/.venv/Scripts/python.exe' ./sign_catalog.py self-test
& 'E:/Your disposable host/.venv/Scripts/python.exe' ./sign_catalog.py sign --archive ./example.sheet-name-1.0.1.zip --download https://publisher.example/releases/example.sheet-name-1.0.1.zip --key-id publisher-2026 --private-key 'E:/Publisher private material/publisher-ed25519.pem' --output ./release-record-1.0.1.json
& 'E:/Your disposable host/.venv/Scripts/python.exe' ./sign_catalog.py verify --archive ./example.sheet-name-1.0.1.zip --record ./release-record-1.0.1.json --keys ./publisher-public-keys.json

publisher-public-keys.json maps key IDs to base64-encoded raw 32-byte Ed25519 public keys. It contains no private key. Export the matching public key through your publisher key-management process. A renamed keyId with the same signature does not verify, because keyId is signed too.

The SHA-256 covers the final ZIP bytes, including archive metadata. The signature covers every catalog field except signature, using the exact packages.canonical format: sorted keys, compact comma/colon separators, ensure_ascii true and allow_nan false. Scalar record values are ASCII strings; supported tags are the explicit exception and remain covered by canonicalization. Use --tag Exploration --tag Original when signing if you want catalog categories; there may be at most 24 unique nonblank tags of up to 64 characters. Recompressing, altering a URL, adding a type/status/tag or changing any signed field requires re-signing. Do not sign a directory and later rebuild its ZIP.

6. Assemble and host the catalog

The helper writes one record object. The Marketplace feed is an array. To create a new one-release draft catalog without overwriting an existing feed, save and run this small script beside the reviewed record. For an established multi-package catalog, merge the new record into the publisher’s existing reviewed feed, preserve unrelated records and coordinate concurrent publishers.

python
import json
from pathlib import Path

record = json.loads(Path("release-record-1.0.1.json").read_text(encoding="utf-8"))
with Path("catalog-draft.json").open("x", encoding="utf-8") as output:
    json.dump([record], output, ensure_ascii=True, indent=2)
    output.write("\n")

Host the immutable ZIP at its signed HTTPS download URL and the reviewed array at an HTTPS catalog URL. Publish the matching public key separately through a trusted channel; do not treat a key obtained only from an untrusted catalog as trust. Confirm a fresh download has the exact signed digest before making the catalog discoverable. Keep backup artifacts and do not replace an already distributed ID/version. This guide supplies no hosting credentials and performs no upload.

7. Configure a disposable consumer and diagnose failures

On a disposable consumer only, explicitly configure GRAVEWRIGHT_MARKETPLACE_URL with the catalog HTTPS URL and GRAVEWRIGHT_MARKETPLACE_KEYS_FILE with its trusted public-key JSON. The key file defaults and relative paths remain inside that installation when you choose installation-relative paths. Restart after changing environment configuration. Do not edit a real operator .env just to run this tutorial. The owner can read GET /api/marketplace/status without contacting the remote publisher.

DiagnosticWhat to inspect
marketplace_catalog_missing / invalidCatalog URL, HTTPS, credentials and fragment
marketplace_keys_missing / unreadable / invalid / emptyTrusted public-key path, JSON and raw key length
permission_deniedUnknown key ID, signature, revocation or authorization
invalid_dataManifest/catalog shape, digest, type, paths and quotas
unavailableSDK compatibility, HTTPS transport and publisher availability
conflictRefresh activation state and review its new revision

Open the appropriate Modules or Systems installation modal to fetch that catalog. Selecting a release installs its verified archive; activate it separately. Configuration failure must not disable signature checks. Test one altered ZIP, one changed signed field, a missing public key and a mismatching type on the disposable consumer, and verify that each is rejected.

8. Revoke or roll back without promising data rollback

To revoke, sign a new record for the same existing ID/version with status revoked, keeping its immutable archive identity. Use the helper sign command with --status revoked and a new output filename; changing status without re-signing invalidates the old signature. Replace that version’s catalog record with the signed revoked record while preserving other releases. When consumers fetch that catalog, the host marks installed releases revoked, disables global activation, removes affected table modules, clears replacement preferences and changes table revisions. This is catalog-fetch-driven, not an immediate push to offline users.

Selecting an older installed release changes code selection only. Module storage and campaign documents do not revert automatically; deactivating a Django app does not generally undo its migrations. Version and migrate your own storage explicitly and keep compatible code/data recovery backups. Re-enable only a reviewed compatible version.

How to use AI to prepare a release

text
Prepare a reviewable distribution for this exact working package on Gravewright 0.3.0. Inspect the manifest and packages.py before choosing catalog type or SDK range. Preserve private data and earlier work. Provide a new version, exact file tree, final ZIP checksum, canonicalization-compatible Ed25519 signing steps using an existing private key outside the repo, public-key trust setup, HTTPS catalog draft and native disposable-host validation. Do not generate or publish a real key, upload artifacts or mutate an operator environment without authorization. Explain installation versus activation, signed revocation, immutable releases and storage/data rollback limits.

SDK · All tutorials