← Files Plugin AutopilotARCHIVED FILE

skills/chatgpt-codex-plugin-autopilot/scripts/package_plugin.py

5.83 KB · Oct 4, 2026 · 12:33 UTC

↓ Download file

#!/usr/bin/env python3
"""Build a deterministic archive-root ZIP after generic plugin validation."""
from __future__ import annotations

import argparse
import hashlib
import json
import os
import stat
import sys
import zipfile
from pathlib import Path

# Prevent transient bytecode inside the plugin root from entering deterministic archives.
sys.dont_write_bytecode = True
sys.path.insert(0, str(Path(__file__).resolve().parent))
from validate_plugin import (  # noqa: E402
    MAX_MEMBER_PATH,
    archive_member_path_within_limit,
    validate_plugin,
)

FIXED_TIME = (1980, 1, 1, 0, 0, 0)
MAX_ARCHIVE = 100 * 1000 * 1000


def archive_size_within_limit(size: int) -> bool:
    return 0 <= size <= MAX_ARCHIVE


def _collect(root: Path) -> tuple[list[str], list[str]]:
    files: list[str] = []
    directories: set[str] = set()
    for current, dirs, names in os.walk(root, topdown=True, followlinks=False):
        current_path = Path(current)
        for name in sorted(dirs):
            path = current_path / name
            dir_rel = path.relative_to(root).as_posix()
            dir_entry = dir_rel if dir_rel.endswith("/") else dir_rel + "/"
            if "\\" in dir_rel:
                raise ValueError(f"archive_member_path_has_backslash: archive member path must use /, not backslashes: {dir_rel}")
            if not archive_member_path_within_limit(dir_entry):
                raise ValueError(f"archive_member_path_too_long: archive member path exceeds {MAX_MEMBER_PATH} characters: {dir_entry}")
            if path.is_symlink():
                raise ValueError(f"symlink is not packageable: {dir_rel}")
        for name in sorted(names):
            path = current_path / name
            rel = path.relative_to(root).as_posix()
            if "\\" in rel:
                raise ValueError(f"archive_member_path_has_backslash: archive member path must use /, not backslashes: {rel}")
            if not archive_member_path_within_limit(rel):
                raise ValueError(f"archive_member_path_too_long: archive member path exceeds {MAX_MEMBER_PATH} characters: {rel}")
            if path.is_symlink() or not path.is_file():
                raise ValueError(f"unsupported package member: {rel}")
            files.append(rel)
            parts = rel.split("/")[:-1]
            for index in range(1, len(parts) + 1):
                directories.add("/".join(parts[:index]) + "/")
    return sorted(directories), sorted(files)


def build_archive(plugin_root: str, output_path: str, exclusions: list[str] | None = None) -> dict:
    root = Path(plugin_root).expanduser().resolve()
    output = Path(output_path).expanduser().resolve()
    try:
        output.relative_to(root)
    except ValueError:
        pass
    else:
        raise ValueError("output ZIP must live outside the plugin root")

    report = validate_plugin(str(root), exclusions or [])
    if not report["ok"]:
        raise ValueError("plugin validation failed: " + "; ".join(report["errors"]))
    directories, files = _collect(root)
    if report["architecture"] == "skills-only":
        for excluded in (".mcp.json", ".app.json"):
            if excluded in files:
                code = "mcp_configuration_excluded" if excluded == ".mcp.json" else "app_configuration_excluded"
                raise ValueError(f"{code}: Skills-only ZIP must not include {excluded}")
    output.parent.mkdir(parents=True, exist_ok=True)
    temporary = output.with_name(output.name + ".tmp")
    try:
        with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
            for rel in directories:
                info = zipfile.ZipInfo(rel, FIXED_TIME)
                info.create_system = 3
                info.external_attr = (stat.S_IFDIR | 0o755) << 16
                archive.writestr(info, b"")
            for rel in files:
                path = root.joinpath(*rel.split("/"))
                info = zipfile.ZipInfo(rel, FIXED_TIME)
                info.create_system = 3
                info.compress_type = zipfile.ZIP_DEFLATED
                executable = bool(path.stat().st_mode & stat.S_IXUSR)
                mode = 0o755 if executable else 0o644
                info.external_attr = (stat.S_IFREG | mode) << 16
                archive.writestr(info, path.read_bytes(), compress_type=zipfile.ZIP_DEFLATED, compresslevel=9)
        if not archive_size_within_limit(temporary.stat().st_size):
            raise ValueError(f"compressed ZIP exceeds 100 MB: {temporary.stat().st_size}")
        temporary.replace(output)
    finally:
        if temporary.exists():
            temporary.unlink()
    digest = hashlib.sha256(output.read_bytes()).hexdigest()
    return {
        "ok": True,
        "outputPath": str(output),
        "sha256": digest,
        "version": report["version"],
        "architecture": report["architecture"],
        "skills": len(report["skills"]),
        "entries": len(directories) + len(files),
        "bytes": output.stat().st_size,
        "exclusions": report["exclusions"],
    }


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("plugin_root")
    parser.add_argument("output_zip")
    parser.add_argument("--json", action="store_true", dest="as_json")
    parser.add_argument("--exclude", action="append", default=[])
    args = parser.parse_args(argv)
    try:
        result = build_archive(args.plugin_root, args.output_zip, args.exclude)
    except Exception as exc:
        if args.as_json:
            print(json.dumps({"ok": False, "errors": [str(exc)]}, indent=2, sort_keys=True))
        else:
            print(f"error: {exc}", file=sys.stderr)
        return 1
    if args.as_json:
        print(json.dumps(result, indent=2, sort_keys=True))
    else:
        print(f"built {result['outputPath']} sha256={result['sha256']}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 17e7d0f9adf5e056eb2958bed60efb13d28968f4f3d19adedb4eed91f897b23e