Skip to content

Commit c05c2c8

Browse files
authored
Merge pull request #5 from superfly/alex/directory-release-packaging
Preserve directory release metadata and automate ZIP packaging
2 parents da67ac3 + db73a43 commit c05c2c8

9 files changed

Lines changed: 382 additions & 5 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ node_modules/
33
dist/
44
tmp/
55
*.log
6+
__pycache__/

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,9 @@ If authentication fails during plugin install or first use, retry the plugin aut
127127
If a command inside a sprite cannot reach the network, ask Codex to inspect the sprite network policy before changing it.
128128

129129
If a web service is not reachable, ask Codex to inspect the sprite's services, logs, and configured HTTP port.
130+
131+
## Packaging a Directory Update
132+
133+
Run `python3 scripts/package_plugin.py` to build and validate the upload ZIP.
134+
See [directory release packaging](release/README.md) for the published-version
135+
baseline, upload-specific settings, and remaining portal checks.

‎plugins/sprites/.codex-plugin/plugin.json‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sprites",
3-
"version": "0.2.0",
3+
"version": "1.0.1",
44
"description": "Use Sprites from Codex to create, inspect, and operate remote isolated development environments.",
55
"author": {
66
"name": "Fly.io",
@@ -18,7 +18,7 @@
1818
"mcpServers": "./.mcp.json",
1919
"interface": {
2020
"displayName": "Sprites",
21-
"shortDescription": "Manage remote development sprites from Codex.",
21+
"shortDescription": "Give Codex its own computer",
2222
"longDescription": "Create, list, inspect, and operate remote Sprites environments from Codex. Select individual Sprites in supported desktop composers and open a read-only Sprite Inspector for services, checkpoints, and recent logs.",
2323
"developerName": "Fly.io",
2424
"category": "Developer Tools",
@@ -34,8 +34,9 @@
3434
"Use Sprites to create a new remote environment for this repo and run the tests.",
3535
"Use Sprites to inspect services and logs for a selected sprite."
3636
],
37-
"brandColor": "#8AE234",
38-
"composerIcon": "./assets/sprites-logo.svg",
39-
"logo": "./assets/sprites-logo.svg"
37+
"brandColor": "#16A34A",
38+
"composerIcon": "./assets/fly-logo.png",
39+
"logo": "./assets/fly-logo.png",
40+
"supportURL": "https://docs.fly.io/about/support"
4041
}
4142
}
46.8 KB
Loading

‎release/README.md‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Directory release packaging
2+
3+
The last confirmed published directory version is **1.0.0**. The **1.0.1**
4+
package is prepared and pending publication; preparing or uploading it does not
5+
mean it has been approved or published. A previous upload labeled 0.2.0 entered
6+
review before the published version was checked.
7+
8+
Build the directory ZIP from the repository root with Python 3.12 or newer:
9+
10+
```sh
11+
python3 scripts/package_plugin.py
12+
```
13+
14+
The output is `dist/sprites-1.0.1.zip` for the current version. ZIP files and
15+
staging copies stay in ignored `dist/`; commit the source and release settings.
16+
No additional Python packages or network access are needed to build the ZIP.
17+
18+
The local manifest is the source for the version and listing. Before each
19+
release, confirm the latest published version in the directory and update
20+
`published_version` in [directory.json](directory.json). Increase the source
21+
manifest version above it, and update `release_notes` for the intended release.
22+
An explicit `--published-version 1.0.1` can override the recorded baseline for a
23+
single build. This check cannot discover remote publication changes itself.
24+
25+
The packager preserves the local plugin name `sprites` and local attribution
26+
headers. In the upload copy only, it uses the verified directory identity
27+
`app-6a8485b5beac8191954e37241acffe6d` in both manifests and the archive folder,
28+
generates portable `plugin.json` and `mcp.json`, and removes `headers` and
29+
`http_headers` from both MCP configurations. The directory rejected those
30+
headers during existing-connection setup. Authentication belongs in the portal's
31+
connection settings; no credentials belong in the package.
32+
33+
Local checks cover version progression, matching manifests, the directory ID,
34+
MCP endpoint and headers, subtitle length, default prompts, required HTTPS links,
35+
brand color contrast against white, icon paths and PNG dimensions, skill presence,
36+
and the final ZIP contents. The build uses fixed ZIP metadata so identical source
37+
produces identical bytes. These checks cover known upload constraints, not every
38+
portal rule. They do not verify live URLs, remote tool behavior, connection state,
39+
review materials, scans, attestations, or publication status.
40+
41+
After uploading to the existing entry, inspect its saved listing, connection,
42+
review materials, and publication settings. Review and publication are separate
43+
steps. Replacing an in-review submission requires cancelling review first; do
44+
not infer approval to cancel from a request to prepare a ZIP.
45+
46+
The logo is Fly.io's official color brandmark from
47+
[Fly.io's brand assets](https://docs.fly.io/about/brand). The committed 512 x 512
48+
PNG was rendered from the official SVG with transparent square padding. It is
49+
used for both listing and composer icons. The green brand color `#16A34A` has
50+
approximately 3.30:1 contrast against white, exceeding the directory's 2:1 minimum.

‎release/directory.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"plugin_id": "app-6a8485b5beac8191954e37241acffe6d",
3+
"published_version": "1.0.0",
4+
"release_notes": "Adds guidance for selecting individual Sprites in supported desktop composers and opening the Sprite Inspector to inspect services, checkpoints, and recent logs. Verifies selected Sprite identities before remote operations."
5+
}

‎scripts/check_repository.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
".ruff_cache",
2323
".venv",
2424
"__pycache__",
25+
"dist",
2526
}
2627
TEXT_SUFFIXES = {
2728
".json",

‎scripts/package_plugin.py‎

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
#!/usr/bin/env python3
2+
"""Build and preflight the directory upload without changing local plugin configuration."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import copy
8+
import json
9+
import re
10+
import struct
11+
import zipfile
12+
from pathlib import Path, PurePosixPath
13+
from urllib.parse import urlparse
14+
15+
ROOT = Path(__file__).resolve().parents[1]
16+
17+
18+
def require(condition: bool, message: str) -> None:
19+
if not condition:
20+
raise ValueError(message)
21+
22+
23+
def stable_version(value: str) -> tuple[int, ...]:
24+
require(
25+
bool(re.fullmatch(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", value)),
26+
f"Expected a stable major.minor.patch version, got {value!r}",
27+
)
28+
return tuple(map(int, value.split(".")))
29+
30+
31+
def contrast_against_white(color: str) -> float:
32+
require(bool(re.fullmatch(r"#[0-9a-fA-F]{6}", color)), "brandColor must be #RRGGBB")
33+
channels = [int(color[i : i + 2], 16) / 255 for i in (1, 3, 5)]
34+
linear = [v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in channels]
35+
luminance = sum(v * weight for v, weight in zip(linear, (0.2126, 0.7152, 0.0722)))
36+
return 1.05 / (luminance + 0.05)
37+
38+
39+
def json_bytes(data: dict) -> bytes:
40+
return (json.dumps(data, indent=2) + "\n").encode()
41+
42+
43+
def asset_path(value: str) -> str:
44+
path = PurePosixPath(value)
45+
require(not path.is_absolute() and ".." not in path.parts, "Asset must stay inside package")
46+
require(path.parts[0] == "assets", "Icons must be in assets/")
47+
return path.as_posix()
48+
49+
50+
def validate_package(files: dict[str, bytes], release: dict) -> None:
51+
"""Check the effective manifests and known portal rules in the actual archive content."""
52+
manifest = json.loads(files["plugin.json"])
53+
legacy = json.loads(files[".codex-plugin/plugin.json"])
54+
for item in (manifest, legacy):
55+
require(item["name"] == release["plugin_id"], "Plugin name must match directory ID")
56+
require(
57+
stable_version(item["version"]) > stable_version(release["published_version"]),
58+
"Package version must exceed published_version",
59+
)
60+
require(not item.get("apps"), "Upload cannot declare app bindings")
61+
require(
62+
not item.get("extensions", {}).get("com.openai", {}).get("apps"),
63+
"Upload cannot declare app bindings",
64+
)
65+
require(manifest["version"] == legacy["version"], "Manifest versions must match")
66+
interface = manifest["extensions"]["com.openai"]["interface"]
67+
require(interface == legacy["interface"], "Manifest interfaces must match")
68+
for field, limit in (("displayName", 30), ("shortDescription", 30), ("longDescription", 4000)):
69+
require(0 < len(interface[field]) <= limit, f"{field} must be 1-{limit} characters")
70+
require(contrast_against_white(interface["brandColor"]) >= 2, "brandColor needs 2:1 contrast")
71+
prompts = interface["defaultPrompt"]
72+
prompts = [prompts] if isinstance(prompts, str) else prompts
73+
require(1 <= len(prompts) <= 3, "Expected 1-3 default prompts")
74+
require(
75+
all(p.strip() and len(p) <= 128 and "\n" not in p for p in prompts),
76+
"Prompts must be nonblank single lines of at most 128 characters",
77+
)
78+
require(len({" ".join(p.split()) for p in prompts}) == len(prompts), "Duplicate prompts")
79+
for field in ("websiteURL", "supportURL", "privacyPolicyURL", "termsOfServiceURL"):
80+
url = urlparse(interface[field])
81+
require(
82+
url.scheme == "https" and bool(url.hostname) and not url.username and not url.password,
83+
f"{field} must be an HTTPS URL without credentials",
84+
)
85+
for field in ("logo", "composerIcon"):
86+
path = asset_path(interface[field])
87+
require(path in files, f"Missing icon: {path}")
88+
data = files[path]
89+
require(
90+
len(data) >= 24 and data[:8] == b"\x89PNG\r\n\x1a\n" and data[12:16] == b"IHDR",
91+
"Icons must be PNG files",
92+
)
93+
width, height = struct.unpack(">II", data[16:24])
94+
minimum = 256 if field == "logo" else 48
95+
require(minimum <= width == height <= 4096, "Icons must be square and correctly sized")
96+
require(len(data) <= 5 * 1024 * 1024, "Icon exceeds 5 MiB")
97+
portable = json.loads(files["mcp.json"])["mcpServers"]
98+
compatibility = json.loads(files[".mcp.json"])["mcpServers"]
99+
require(set(portable) == set(compatibility) == {"sprites"}, "Unexpected MCP server inventory")
100+
for servers in (portable, compatibility):
101+
server = servers["sprites"]
102+
require("headers" not in server and "http_headers" not in server, "MCP headers unsupported")
103+
require(server["url"] == "https://sprites.dev/mcp", "Keep the existing MCP endpoint")
104+
require(
105+
set(portable["sprites"]) == {"type", "url"}
106+
and portable["sprites"]["type"] == "streamable-http",
107+
"Unsupported portable MCP settings",
108+
)
109+
require(".app.json" not in files, "Upload cannot include .app.json")
110+
require("skills/sprites/SKILL.md" in files, "Missing Sprites skill")
111+
require(legacy["skills"] == "./skills/", "Unexpected legacy skill path")
112+
require(legacy["mcpServers"] == "./.mcp.json", "Unexpected legacy MCP path")
113+
114+
115+
def build_package(root: Path, output: Path, published_version: str | None = None) -> Path:
116+
source = root / "plugins/sprites"
117+
release = json.loads((root / "release/directory.json").read_text())
118+
if published_version:
119+
release["published_version"] = published_version
120+
require(
121+
bool(re.fullmatch(r"app-[0-9a-f]{32}", release["plugin_id"])),
122+
"Expected the verified directory plugin ID",
123+
)
124+
legacy = json.loads((source / ".codex-plugin/plugin.json").read_text())
125+
legacy["name"] = release["plugin_id"]
126+
manifest = {
127+
k: copy.deepcopy(v)
128+
for k, v in legacy.items()
129+
if k not in ("skills", "mcpServers", "interface")
130+
}
131+
manifest["$schema"] = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
132+
manifest["extensions"] = {
133+
"com.openai": {
134+
"interface": copy.deepcopy(legacy["interface"]),
135+
"publication": {"release_notes": release["release_notes"]},
136+
}
137+
}
138+
config = json.loads((source / ".mcp.json").read_text())
139+
require(set(config["mcpServers"]) == {"sprites"}, "Unexpected source MCP server inventory")
140+
server = config["mcpServers"]["sprites"]
141+
require(
142+
set(server) <= {"url", "http_headers", "headers", "tool_timeout_sec"},
143+
"Unsupported source MCP setting; review before packaging",
144+
)
145+
server.pop("http_headers", None)
146+
server.pop("headers", None)
147+
portable = {
148+
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
149+
"mcpServers": {"sprites": {"type": "streamable-http", "url": server["url"]}},
150+
}
151+
files = {
152+
"plugin.json": json_bytes(manifest),
153+
".codex-plugin/plugin.json": json_bytes(legacy),
154+
"mcp.json": json_bytes(portable),
155+
".mcp.json": json_bytes(config),
156+
"LICENSE": (root / "LICENSE").read_bytes(),
157+
}
158+
paths = {asset_path(legacy["interface"][field]) for field in ("logo", "composerIcon")}
159+
paths.update(p.relative_to(source).as_posix() for p in (source / "skills").rglob("*.md"))
160+
for relative in sorted(paths):
161+
path = source / relative
162+
require(path.resolve().is_relative_to(source.resolve()), "File escapes plugin directory")
163+
require(not path.is_symlink(), "Package cannot contain symlinks")
164+
files[relative] = path.read_bytes()
165+
validate_package(files, release)
166+
output.mkdir(parents=True, exist_ok=True)
167+
archive = output / f"sprites-{manifest['version']}.zip"
168+
# Fixed metadata and sorted paths make repeated builds byte-for-byte reproducible.
169+
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as bundle:
170+
for name, data in sorted(files.items()):
171+
info = zipfile.ZipInfo(f"{release['plugin_id']}/{name}", (2020, 1, 1, 0, 0, 0))
172+
info.compress_type = zipfile.ZIP_DEFLATED
173+
info.external_attr = 0o100644 << 16
174+
bundle.writestr(info, data)
175+
with zipfile.ZipFile(archive) as bundle:
176+
require(bundle.testzip() is None, "ZIP integrity check failed")
177+
prefix = release["plugin_id"] + "/"
178+
actual = {name.removeprefix(prefix): bundle.read(name) for name in bundle.namelist()}
179+
require(actual == files, "ZIP content differs from prepared package")
180+
validate_package(actual, release)
181+
return archive
182+
183+
184+
def main() -> None:
185+
parser = argparse.ArgumentParser(description=__doc__)
186+
parser.add_argument("--output-dir", type=Path, default=ROOT / "dist")
187+
parser.add_argument("--published-version", help="Override the last confirmed published version")
188+
args = parser.parse_args()
189+
try:
190+
archive = build_package(ROOT, args.output_dir, args.published_version)
191+
except (ValueError, KeyError, OSError) as exc:
192+
parser.exit(1, f"Packaging failed: {exc}\n")
193+
print(f"Built and locally validated {archive}")
194+
print("Portal acceptance, review, and publication are not verified by these checks.")
195+
196+
197+
if __name__ == "__main__":
198+
main()

0 commit comments

Comments
 (0)