diff --git a/ARCHIVE-CUSTODY.md b/ARCHIVE-CUSTODY.md index b4a5211..8ed93cb 100644 --- a/ARCHIVE-CUSTODY.md +++ b/ARCHIVE-CUSTODY.md @@ -1,4 +1,4 @@ -# TestForge v1.1.1 archive custody +# TestForge archive custody TestForge is one two-skill Augment with several independently useful release objects. The canonical release keeps the complete product, its Codex plugin, and both standalone skills separately obtainable without pretending that a standalone skill is the whole product. @@ -11,7 +11,17 @@ TestForge is one two-skill Augment with several independently useful release obj | Claude.ai uploads | `claude-ai/software-verification-v1.1.1.zip` and `claude-ai/verification-reviewer-v1.1.1.zip` | Host-specific one-skill upload archives | | Source repository | Git tag `v1.1.1` and its GitHub source archives | Versioned source, documentation, tests, testbed, and provenance | -`release-assets/v1.1.1/archive-custody.json` records exact hashes, sizes, member counts, source-tree digests, and extraction-parity results for the governed archives. GitHub release assets and the latest-only convenience backup shelf must match those hashes. Canonical assets are copied, never moved, to the backup shelf. Older same-family convenience copies may be removed only after the new copies match; unrelated products are untouched. +## Plugin publication payloads -The two standalone skill archives and their Claude.ai counterparts intentionally carry the same skill content under channel-appropriate names. Static package equality does not establish live Claude activation, live Codex discovery, or directory approval. +TestForge plugin v1.1.2 preserves two deliberately different ZIPs: + +| Object | Canonical release artifact | Use | +|---|---|---| +| Installable Codex plugin | `release-assets/v1.1.2/Plugin-TestForge-v1.1.2.zip` | Normal Codex installation and marketplace distribution with the full interface manifest | +| OpenAI skills-only submission | `release-assets/v1.1.2/Plugin-TestForge-v1.1.2-OpenAI-Submission.zip` | Deterministic portal upload whose archived interface retains only `composerIcon` and `logo` | +`release-assets/v1.1.2/archive-custody.json` governs the installable plugin. `release-assets/v1.1.2/openai-submission-custody.json` separately records the portal derivative's archive hash, source and transformed manifest hashes, member count, and POSIX path requirement. The portal ZIP is not a replacement for the installable plugin. + +`release-assets/v1.1.1/archive-custody.json` records exact hashes, sizes, member counts, source-tree digests, and extraction-parity results for the unchanged v1.1.1 Augment and skills. GitHub release assets and the latest-only convenience backup shelf must match the applicable custody records. Canonical assets are copied, never moved, to the backup shelf. Older same-family convenience copies may be removed only after the new copies match; unrelated products are untouched. + +The two standalone skill archives and their Claude.ai counterparts intentionally carry the same skill content under channel-appropriate names. Static package equality does not establish live Claude activation, live Codex discovery, or directory approval. diff --git a/PLUGIN-DIRECTORY-SUBMISSION-v1.1.2.md b/PLUGIN-DIRECTORY-SUBMISSION-v1.1.2.md index 4fe0db9..9a01486 100644 --- a/PLUGIN-DIRECTORY-SUBMISSION-v1.1.2.md +++ b/PLUGIN-DIRECTORY-SUBMISSION-v1.1.2.md @@ -1,28 +1,42 @@ # TestForge Plugins Directory submission packet -This packet maps the released TestForge plugin to the current OpenAI Plugins Directory form. It prepares a **Skills only** submission; it does not assert that a publisher identity is verified, that policy attestations have been made, or that OpenAI has reviewed or published the plugin. +This packet maps the released TestForge plugin to the current OpenAI Plugins Directory form and records the created **Skills only** draft. Draft creation and publisher-identity selection are observed; owner policy attestations, submission for review, approval, publication, and discoverability remain separate states. ## Released object - Plugin version: `1.1.2` - Core Augment and standalone skill version: `1.1.1` -- Upload: `release-assets/v1.1.2/Plugin-TestForge-v1.1.2.zip` -- SHA-256: `ae04e5b090e48f94aa6316b38955bd82e38a75a202161db56f0d5fed539fdae7` +- Installable plugin: `release-assets/v1.1.2/Plugin-TestForge-v1.1.2.zip` +- Installable plugin SHA-256: `ae04e5b090e48f94aa6316b38955bd82e38a75a202161db56f0d5fed539fdae7` +- OpenAI submission upload: `release-assets/v1.1.2/Plugin-TestForge-v1.1.2-OpenAI-Submission.zip` +- Submission upload SHA-256: `5154636d71c9bb00fa8754071d843110fedd036e361036fd00dc2b42fcdb7db2` +- Submission custody: `release-assets/v1.1.2/openai-submission-custody.json` +- Draft-upload comparison: `release-assets/v1.1.2/portal-draft-upload-evidence.json` - Public release: - Submission type: **Skills only** +The installable plugin keeps its full local and marketplace `interface`. The governed portal upload is a deterministic channel derivative of the same 106-file plugin tree; only the archived manifest is transformed to retain `composerIcon` and `logo`. Every ZIP member path uses `/`; UTF-8 text is canonicalized to LF without a BOM; and members use stored ZIP entries so the archive is byte-identical across supported Python runtimes. The draft was created from a preflight ZIP with the same member names and raw member bytes; its ZIP container metadata was not deterministic. The live portal accepted this shape after rejecting the rich upload interface and its screenshot configuration. + +## Created draft + +- Plugin ID: `plugins_6a5e0f2981d48191a11548355b7ecbc5` +- Submission ID: `appsub_6a5e0f299d848191b1cd16ae162ef105` +- Draft URL: +- Observed draft state on 2026-07-20: listing, three prompts, two skills, and capability tags saved; both skills passed automated scanning; policy attestations unchecked; review submission not executed. + ## Info - Plugin name: **TestForge** -- Short description: **Risk-ranked verification with an independent skeptic.** +- Portal subtitle: **Risk-ranked code verification** (29 characters; the live form caps this field at 30) - Long description: **Turn software changes, repositories, defects, and release candidates into risk-ranked evidence, meaningful tests, captured execution, and a traceable release assessment, then challenge the result with an independent skeptical reviewer.** -- Developer identity: select the verified **Collaborative Dynamics** identity in the portal; the accountable owner must confirm the identity and organization match before submission. +- Developer identity: **Business — Collaborative Dynamics Inc** selected in the portal; the listing developer name is **Collaborative Dynamics Inc**. - Category: **Developer Tools** - Logo: `plugins/testforge/assets/testforge-icon-v1.1.1.png` - Website: - Support: - Privacy: - Terms: +- Capabilities: separate **Interactive**, **Read**, and **Write** tags. ## Starter prompts @@ -98,3 +112,5 @@ Initial public submission of the free TestForge skills-only plugin. TestForge co ## Accountable-owner gate Before selecting **Submit for Review**, the publisher must personally confirm the verified developer or business identity, organization and Apps Management authority, country availability, public URLs, and every policy attestation. Submission begins OpenAI review; approval, publisher release, and public discoverability are later observable states. + +The live 2026-07-20 skills-only form did not expose the documentation page's separate Testing, Global, or release-notes panels. The exact five positive cases, three negative cases, availability decision, and release notes remain preserved above for reviewer follow-up or a later portal revision; their repository presence does not mean they were transmitted in the current draft. diff --git a/README.md b/README.md index 621ca09..7585f7e 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ TestForge is advisory verification machinery. It does not prove defect freedom, - [`testforge/docs/QUICK-START.md`](testforge/docs/QUICK-START.md) - install and first-use guide. - [`RELEASE-NOTES-v1.1.1.md`](RELEASE-NOTES-v1.1.1.md) - plugin-publication changes and exact untested boundary. - [`ARCHIVE-CUSTODY.md`](ARCHIVE-CUSTODY.md) - canonical Augment, plugin, standalone-skill, Claude, GitHub, and backup custody. +- [`PLUGIN-DIRECTORY-SUBMISSION-v1.1.2.md`](PLUGIN-DIRECTORY-SUBMISSION-v1.1.2.md) - exact OpenAI draft listing, portal-specific upload custody, reviewer cases, and owner-only submission gate. - [`testforge/docs/SALES-DEMO.md`](testforge/docs/SALES-DEMO.md) - a compact proof-of-value scenario. - [`tools/augment-evals/`](tools/augment-evals/) - isolated Augment behavioral evaluation harness. - [`tools/augment-evals/README.md`](tools/augment-evals/README.md) - testbed setup, run, review, seal, promote and regression workflow. @@ -61,7 +62,7 @@ Start a new Codex task, then invoke `$software-verification` or `$verification-r Download the latest release, unzip it and keep the `testforge/` tree together. Expose both directories under `testforge/skills/` through your Agent host's skill mechanism. Host-specific notes are included for [Codex](testforge/adapters/codex.md), [Claude Code](testforge/adapters/claude-code.md), [GitHub](testforge/adapters/github.md), [local shell](testforge/adapters/local-shell.md) and [copy-paste chat](testforge/adapters/copy-paste-chat.md). -The GitHub release also preserves the complete Augment, the Codex plugin, and each bundled skill as separately named archives. This keeps `$software-verification` and `$verification-reviewer` independently recoverable without losing the complete two-skill product. +The GitHub release also preserves the complete Augment, the installable Codex plugin, its distinct OpenAI skills-only portal upload, and each bundled skill as separately named archives. This keeps `$software-verification` and `$verification-reviewer` independently recoverable without losing the complete two-skill product. The OpenAI draft exists and both bundled skills passed automated scanning; accountable-owner attestations and submission for review remain pending. Then start with: diff --git a/documentation-manifest.json b/documentation-manifest.json index 1333579..470fc9c 100644 --- a/documentation-manifest.json +++ b/documentation-manifest.json @@ -19,6 +19,7 @@ "testforge/docs/SUPPORT-AND-VERSIONING.md", "RELEASE-NOTES-v1.1.1.md", "ARCHIVE-CUSTODY.md", + "PLUGIN-DIRECTORY-SUBMISSION-v1.1.2.md", "testforge/PROVENANCE.md" ], "moments": { @@ -29,7 +30,7 @@ "recovery": ["README.md", "testforge/docs/TROUBLESHOOTING.md", "testforge/docs/SUPPORT-AND-VERSIONING.md"], "privacy_security": ["testforge/docs/DATA-AND-PRIVACY.md", "testforge/docs/TERMS-OF-USE.md", "testforge/SECURITY.md"], "evidence_limits": ["testforge/docs/VALIDATION.md", "testforge/docs/LIMITATIONS.md"], - "support_maintenance": ["testforge/docs/SUPPORT-AND-VERSIONING.md", "RELEASE-NOTES-v1.1.1.md", "ARCHIVE-CUSTODY.md"], + "support_maintenance": ["testforge/docs/SUPPORT-AND-VERSIONING.md", "RELEASE-NOTES-v1.1.1.md", "ARCHIVE-CUSTODY.md", "PLUGIN-DIRECTORY-SUBMISSION-v1.1.2.md"], "provenance": ["testforge/PROVENANCE.md"] } } diff --git a/release-assets/v1.1.2/Plugin-TestForge-v1.1.2-OpenAI-Submission.zip b/release-assets/v1.1.2/Plugin-TestForge-v1.1.2-OpenAI-Submission.zip new file mode 100644 index 0000000..9d8fbeb Binary files /dev/null and b/release-assets/v1.1.2/Plugin-TestForge-v1.1.2-OpenAI-Submission.zip differ diff --git a/release-assets/v1.1.2/openai-submission-custody.json b/release-assets/v1.1.2/openai-submission-custody.json new file mode 100644 index 0000000..5a2ba3f --- /dev/null +++ b/release-assets/v1.1.2/openai-submission-custody.json @@ -0,0 +1,39 @@ +{ + "schema_version": "cd-openai-plugin-submission-custody/v1", + "plugin": { + "name": "testforge", + "version": "1.1.2" + }, + "source_plugin_root": "testforge", + "top_level": "testforge-plugin", + "archive_name": "Plugin-TestForge-v1.1.2-OpenAI-Submission.zip", + "archive_sha256": "5154636d71c9bb00fa8754071d843110fedd036e361036fd00dc2b42fcdb7db2", + "bytes": 954071, + "member_count": 106, + "source_manifest_sha256": "5130b07286a89ec9c3e837b2432a3dbb02f7ea5a9a6b5ef2715470ecd40feba8", + "submission_manifest_sha256": "c520ced3671ee355ba228e55604d809d09af3de41c56b41102500ef95d39874e", + "text_canonicalization": "UTF-8 text is stored as LF without a BOM; binary and non-UTF-8 bytes are preserved", + "normalized_text_member_count": 0, + "zip_compression": "stored", + "manifest_transform": { + "kept_interface_fields": [ + "composerIcon", + "logo" + ], + "omitted_interface_fields": [ + "brandColor", + "capabilities", + "category", + "defaultPrompt", + "developerName", + "displayName", + "longDescription", + "privacyPolicyURL", + "screenshots", + "shortDescription", + "termsOfServiceURL", + "websiteURL" + ] + }, + "archive_paths_use_forward_slashes": true +} diff --git a/release-assets/v1.1.2/portal-draft-upload-evidence.json b/release-assets/v1.1.2/portal-draft-upload-evidence.json new file mode 100644 index 0000000..e3d58a5 --- /dev/null +++ b/release-assets/v1.1.2/portal-draft-upload-evidence.json @@ -0,0 +1,26 @@ +{ + "schema_version": "cd-openai-plugin-draft-upload-evidence/v1", + "observed_on": "2026-07-20", + "plugin_id": "plugins_6a5e0f2981d48191a11548355b7ecbc5", + "submission_id": "appsub_6a5e0f299d848191b1cd16ae162ef105", + "skill_scan_result": "software-verification passed; verification-reviewer passed", + "owner_gate": "four policy attestations unchecked; review submission not executed", + "accepted_preflight_archive": { + "name": "Plugin-TestForge-v1.1.2-icons-only.zip", + "sha256": "7938793968025cfab1a75eeb935bbbc3edecc5a51bff33d4baae4b408c63ba5b", + "bytes": 866006, + "member_count": 106 + }, + "governed_release_archive": { + "name": "Plugin-TestForge-v1.1.2-OpenAI-Submission.zip", + "sha256": "5154636d71c9bb00fa8754071d843110fedd036e361036fd00dc2b42fcdb7db2", + "bytes": 954071, + "member_count": 106 + }, + "comparison": { + "member_names_match": true, + "raw_member_hashes_match": true, + "archive_bytes_match": false, + "explanation": "The accepted preflight ZIP and governed release ZIP contain identical named file bytes. ZIP timestamps and container metadata differ; only the governed release ZIP is deterministic." + } +} diff --git a/release-manifest.json b/release-manifest.json index 8158f27..6592615 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -3,7 +3,7 @@ "package": "testforge-public-repository", "version": "1.1.1", "release_date": "2026-07-20", - "artifact_count": 423, + "artifact_count": 424, "artifacts": [ { "path": ".agents/plugins/marketplace.json", @@ -27,8 +27,8 @@ }, { "path": "ARCHIVE-CUSTODY.md", - "size": 1998, - "sha256": "bcdb3dc2ab15604c3ddd7e3a0cf0ca05fea994f84a862e54c13dcd87c92d316c" + "size": 2896, + "sha256": "425b0b6934970f7f08c0cfef076f22a8d5a19fb5e04e42a353c569119e731b1d" }, { "path": "archive-plan-v1.1.2.json", @@ -87,8 +87,8 @@ }, { "path": "documentation-manifest.json", - "size": 1591, - "sha256": "e18fc56494395b0ec7c00033f31ea5a022f71f34027902b60309d583934e0028" + "size": 1677, + "sha256": "e1a66509bec90a75b9e0ca8a7fe3734629eaeedd81eec72739c5875f089281e5" }, { "path": "documentation-review.json", @@ -127,8 +127,8 @@ }, { "path": "PLUGIN-DIRECTORY-SUBMISSION-v1.1.2.md", - "size": 8319, - "sha256": "4aac07a2b5f71dbc57d17facfcf977113dbf78b284b5c8fc592a41849aee4a9c" + "size": 10356, + "sha256": "80deaedd4a09d08a8c0f39a3fd9ce3d1a7ff3428212c9e2c8b067a535d53d601" }, { "path": "plugins/testforge/.codex-plugin/plugin.json", @@ -662,8 +662,8 @@ }, { "path": "README.md", - "size": 8321, - "sha256": "98caca5bba04d25a652d073e41d92b2fb130c8b8e433cb2e2f9980a5469cef91" + "size": 8721, + "sha256": "692953a48b51e18853fe19bc2626ce1bc9d24cf9a8d4c36c11176ccefd164a80" }, { "path": "RELEASE-NOTES-v1.1.0.md", @@ -1217,8 +1217,8 @@ }, { "path": "testforge/release-manifest.json", - "size": 41996, - "sha256": "2ceebfee9f8cfee217126091d2af6aed1bcc5d3ff0477061e739c6b685477226" + "size": 42133, + "sha256": "45903a62d228256652199523ff21594ee2acdc66f4d6f68f9dfffb9f7c72938f" }, { "path": "testforge/scripts/assemble_report.py", @@ -1818,12 +1818,12 @@ { "path": "tests/test_documentation.py", "size": 3729, - "sha256": "6966fa1b8b9da566f9c52097e04e5654a069bbce6cbb407e4e4b0f2e913b4ebb" + "sha256": "1b47b54c993d72d2bc8ebd5921794a3f83ab993fd25ad49d2ab53d97b2947091" }, { "path": "tests/test_public_distribution.py", - "size": 4517, - "sha256": "faa493e1c4d85e52ad4933e3c5ad28283e7c5246b77458e82f9df21251f162a4" + "size": 6286, + "sha256": "8027ec37dfb3c4381c4eb4c30ef3d5e264af886614dd287483cc5ba3350479b2" }, { "path": "tools/augment-evals/.gitignore", @@ -1925,10 +1925,15 @@ "size": 11261, "sha256": "44b5cf0276fe775cee1bb144666c4a3543690c610ff18073a6d958ff05e46920" }, + { + "path": "tools/build_openai_submission_archive.py", + "size": 7174, + "sha256": "af5dace6b88303b244233fc47191cce381cd250dc39b4806d348a3e9589bf3f7" + }, { "path": "tools/rebuild_public_release.py", - "size": 3869, - "sha256": "22cfb84d685bd30f16bfde49502d0a118fd2a501915f320fc143ce72ca185556" + "size": 4006, + "sha256": "1e55eb13ed19f519b5303997518f3ffef5602ccbb827b8d8e5dbd6954d5c8917" }, { "path": "tools/validate_release_manifests.py", @@ -2107,8 +2112,8 @@ }, { "path": "verification/release-summary-v1.1.2.json", - "size": 2310, - "sha256": "8dd00ab570d33033c2afcb4aafab6cd32b1434634b020354189a103c775f37a7" + "size": 3337, + "sha256": "f95e3d09d59878cd90c90912c65d87b717514895f95d74ec6f7f235a084f25e8" }, { "path": "verification/verification-report.json", @@ -2121,5 +2126,5 @@ "sha256": "3077f2273126e425873c1ac76206602a07007deb8843171127e9d80f71c1e73c" } ], - "note": "release-manifest.json and release-assets/ are excluded from this source-tree hash list; release-assets/v1.1.1/archive-custody.json governs release archives; UTF-8 text hashes use canonical LF line endings for cross-platform validation" + "note": "release-manifest.json and release-assets/ are excluded from this source-tree hash list; release-assets/v1.1.1/archive-custody.json governs the Augment and standalone skills, while release-assets/v1.1.2/archive-custody.json and openai-submission-custody.json govern plugin publication payloads; UTF-8 text hashes use canonical LF line endings for cross-platform validation" } diff --git a/testforge/release-manifest.json b/testforge/release-manifest.json index 019d92e..134f270 100644 --- a/testforge/release-manifest.json +++ b/testforge/release-manifest.json @@ -1131,5 +1131,5 @@ "sha256": "ce0c4eeed8c0be9e89180d8c761f9f254431ff1a05f78b7351506a69cf22b8a0" } ], - "note": "release-manifest.json and release-assets/ are excluded from this source-tree hash list; release-assets/v1.1.1/archive-custody.json governs release archives; UTF-8 text hashes use canonical LF line endings for cross-platform validation" + "note": "release-manifest.json and release-assets/ are excluded from this source-tree hash list; release-assets/v1.1.1/archive-custody.json governs the Augment and standalone skills, while release-assets/v1.1.2/archive-custody.json and openai-submission-custody.json govern plugin publication payloads; UTF-8 text hashes use canonical LF line endings for cross-platform validation" } diff --git a/tests/test_documentation.py b/tests/test_documentation.py index b632656..1ecf8a3 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -20,7 +20,7 @@ def setUp(self): def test_customer_document_manifest_is_complete_and_coherent(self): documents = self.manifest["customer_docs"] self.assertEqual(len(documents), len(set(documents))) - self.assertEqual(18, len(documents)) + self.assertEqual(19, len(documents)) self.assertIn("testforge/docs/TERMS-OF-USE.md", documents) self.assertIn("ARCHIVE-CUSTODY.md", documents) self.assertIn(f"RELEASE-NOTES-v{CURRENT_VERSION}.md", documents) diff --git a/tests/test_public_distribution.py b/tests/test_public_distribution.py index db070da..fd2b79b 100644 --- a/tests/test_public_distribution.py +++ b/tests/test_public_distribution.py @@ -1,7 +1,9 @@ import json import subprocess +import sys import tempfile import unittest +import zipfile from pathlib import Path from tools.validate_release_manifests import canonical_bytes @@ -16,6 +18,50 @@ class PublicDistributionTests(unittest.TestCase): + def test_openai_submission_archive_is_reproducible_and_portal_shaped(self): + tracked_archive = ( + ROOT + / "release-assets" + / "v1.1.2" + / "Plugin-TestForge-v1.1.2-OpenAI-Submission.zip" + ) + tracked_custody = ( + ROOT / "release-assets" / "v1.1.2" / "openai-submission-custody.json" + ) + with tempfile.TemporaryDirectory() as temporary: + output = Path(temporary) / tracked_archive.name + custody = Path(temporary) / "openai-submission-custody.json" + subprocess.run( + [ + sys.executable, + str(ROOT / "tools" / "build_openai_submission_archive.py"), + str(PLUGIN), + "--output", + str(output), + "--json-output", + str(custody), + ], + check=True, + capture_output=True, + text=True, + ) + self.assertEqual(tracked_archive.read_bytes(), output.read_bytes()) + self.assertEqual( + json.loads(tracked_custody.read_text(encoding="utf-8")), + json.loads(custody.read_text(encoding="utf-8")), + ) + + with zipfile.ZipFile(tracked_archive) as archive: + names = archive.namelist() + self.assertTrue(names) + self.assertTrue(all("\\" not in name for name in names)) + manifest = json.loads( + archive.read("testforge-plugin/.codex-plugin/plugin.json").decode( + "utf-8" + ) + ) + self.assertEqual({"composerIcon", "logo"}, set(manifest["interface"])) + def test_release_hashes_are_line_ending_portable(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) diff --git a/tools/build_openai_submission_archive.py b/tools/build_openai_submission_archive.py new file mode 100644 index 0000000..018b7a2 --- /dev/null +++ b/tools/build_openai_submission_archive.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Build a deterministic OpenAI skills-only submission ZIP from a Codex plugin.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path, PurePosixPath +import struct +import zipfile + + +FIXED_TIME = (2026, 1, 1, 0, 0, 0) +EXCLUDED_PARTS = {".git", "__pycache__", ".pytest_cache"} +MANIFEST_PATH = PurePosixPath(".codex-plugin/plugin.json") + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def canonical_archive_bytes(path: Path) -> tuple[bytes, bool]: + """Return cross-platform bytes while preserving non-UTF-8 and binary files.""" + data = path.read_bytes() + if b"\x00" in data: + return data, False + try: + text = data.decode("utf-8-sig") + except UnicodeDecodeError: + return data, False + canonical = text.replace("\r\n", "\n").replace("\r", "\n").encode("utf-8") + return canonical, canonical != data + + +def regular_files(root: Path) -> list[Path]: + return sorted( + ( + path + for path in root.rglob("*") + if path.is_file() and not EXCLUDED_PARTS.intersection(path.relative_to(root).parts) + ), + key=lambda path: ( + path.relative_to(root).as_posix().casefold(), + path.relative_to(root).as_posix(), + ), + ) + + +def resolve_asset(root: Path, value: object, field: str) -> Path: + if not isinstance(value, str) or not value.startswith("./"): + raise ValueError(f"interface.{field} must be a ./-relative path") + target = (root / value.removeprefix("./")).resolve() + if root.resolve() not in target.parents: + raise ValueError(f"interface.{field} escapes the plugin root") + if not target.is_file(): + raise ValueError(f"interface.{field} does not exist: {value}") + return target + + +def png_dimensions(path: Path) -> tuple[int, int]: + data = path.read_bytes() + if data[:8] != b"\x89PNG\r\n\x1a\n" or data[12:16] != b"IHDR": + raise ValueError(f"not a PNG: {path}") + return struct.unpack(">II", data[16:24]) + + +def submission_manifest(plugin_root: Path) -> tuple[dict[str, object], dict[str, object]]: + manifest_path = plugin_root / MANIFEST_PATH + manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig")) + if not isinstance(manifest, dict): + raise ValueError("plugin manifest must be an object") + interface = manifest.get("interface") + if not isinstance(interface, dict): + raise ValueError("plugin interface must be an object") + for field, minimum in (("composerIcon", 48), ("logo", 256)): + asset = resolve_asset(plugin_root, interface.get(field), field) + width, height = png_dimensions(asset) + if width != height or width < minimum: + raise ValueError( + f"interface.{field} must be a square PNG at least {minimum}x{minimum}; " + f"found {width}x{height}" + ) + + transformed = dict(manifest) + transformed["interface"] = { + "composerIcon": interface["composerIcon"], + "logo": interface["logo"], + } + metadata = { + "kept_interface_fields": ["composerIcon", "logo"], + "omitted_interface_fields": sorted(set(interface) - {"composerIcon", "logo"}), + } + return transformed, metadata + + +def build(plugin_root: Path, output: Path, top_level: str) -> dict[str, object]: + plugin_root = plugin_root.resolve() + if not plugin_root.is_dir(): + raise ValueError(f"plugin root does not exist: {plugin_root}") + manifest, transform = submission_manifest(plugin_root) + rendered_manifest = (json.dumps(manifest, indent=2, ensure_ascii=False) + "\n").encode("utf-8") + source_manifest, _ = canonical_archive_bytes(plugin_root / MANIFEST_PATH) + files = regular_files(plugin_root) + normalized_text_members: list[str] = [] + output = output.resolve() + output.parent.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile( + output, + "w", + compression=zipfile.ZIP_STORED, + ) as archive: + for path in files: + relative = PurePosixPath(path.relative_to(plugin_root).as_posix()) + name = PurePosixPath(top_level, relative).as_posix() + info = zipfile.ZipInfo(name, FIXED_TIME) + info.create_system = 0 + info.compress_type = zipfile.ZIP_STORED + info.external_attr = 0o644 << 16 + if relative == MANIFEST_PATH: + data = rendered_manifest + else: + data, normalized = canonical_archive_bytes(path) + if normalized: + normalized_text_members.append(relative.as_posix()) + archive.writestr(info, data) + + with zipfile.ZipFile(output) as archive: + names = archive.namelist() + if any("\\" in name for name in names): + raise RuntimeError("submission archive contains a backslash path") + archived_manifest = archive.read(f"{top_level}/{MANIFEST_PATH.as_posix()}") + observed = json.loads(archived_manifest.decode("utf-8")) + if observed.get("interface") != manifest["interface"]: + raise RuntimeError("submission manifest round trip failed") + + return { + "schema_version": "cd-openai-plugin-submission-custody/v1", + "plugin": {"name": manifest.get("name", ""), "version": manifest.get("version", "")}, + "source_plugin_root": plugin_root.name, + "top_level": top_level, + "archive_name": output.name, + "archive_sha256": sha256_file(output), + "bytes": output.stat().st_size, + "member_count": len(files), + "source_manifest_sha256": sha256_bytes(source_manifest), + "submission_manifest_sha256": sha256_bytes(rendered_manifest), + "text_canonicalization": "UTF-8 text is stored as LF without a BOM; binary and non-UTF-8 bytes are preserved", + "normalized_text_member_count": len(normalized_text_members), + "zip_compression": "stored", + "manifest_transform": transform, + "archive_paths_use_forward_slashes": True, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("plugin_root", type=Path) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--json-output", type=Path) + parser.add_argument("--top-level") + args = parser.parse_args() + + manifest = json.loads( + (args.plugin_root / MANIFEST_PATH).read_text(encoding="utf-8-sig") + ) + top_level = args.top_level or f"{manifest.get('name', args.plugin_root.name)}-plugin" + report = build(args.plugin_root, args.output, top_level) + rendered = json.dumps(report, indent=2) + "\n" + if args.json_output: + args.json_output.parent.mkdir(parents=True, exist_ok=True) + args.json_output.write_text(rendered, encoding="utf-8") + print(rendered, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/rebuild_public_release.py b/tools/rebuild_public_release.py index 7f95a4a..7476e0c 100644 --- a/tools/rebuild_public_release.py +++ b/tools/rebuild_public_release.py @@ -78,7 +78,7 @@ def write_manifest(root: Path, package_name: str) -> None: "release_date": RELEASE_DATE, "artifact_count": len(artifacts), "artifacts": artifacts, - "note": "release-manifest.json and release-assets/ are excluded from this source-tree hash list; release-assets/v1.1.1/archive-custody.json governs release archives; UTF-8 text hashes use canonical LF line endings for cross-platform validation", + "note": "release-manifest.json and release-assets/ are excluded from this source-tree hash list; release-assets/v1.1.1/archive-custody.json governs the Augment and standalone skills, while release-assets/v1.1.2/archive-custody.json and openai-submission-custody.json govern plugin publication payloads; UTF-8 text hashes use canonical LF line endings for cross-platform validation", } output.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") diff --git a/verification/release-summary-v1.1.2.json b/verification/release-summary-v1.1.2.json index 0687e71..88a6cd2 100644 --- a/verification/release-summary-v1.1.2.json +++ b/verification/release-summary-v1.1.2.json @@ -4,19 +4,28 @@ "version": "1.1.2", "core_augment_version": "1.1.1", "review_date": "2026-07-20", - "scope": "Plugin-only host-compatibility patch; the TestForge Augment, both standalone skills, and both Claude archives remain byte-identical to v1.1.1", + "scope": "Plugin host-compatibility and OpenAI draft publication custody; the TestForge Augment, both standalone skills, and both Claude archives remain byte-identical to v1.1.1", "trigger": "A fresh Codex v0.144.5 task discovered both installed skills and opened bundled resources, but the host ignored all three plugin listing prompts because each exceeded 128 characters.", "changes": [ "Shorten all three defaultPrompt entries to 128 characters or fewer while preserving their verification intent.", "Separate plugin version custody from the unchanged v1.1.1 Augment version in distribution tests.", - "Add a regression assertion for the current host prompt-length limit." + "Add a regression assertion for the current host prompt-length limit.", + "Build a deterministic OpenAI skills-only submission ZIP while preserving the full installable plugin manifest.", + "Create and populate the OpenAI plugin draft without checking owner attestations or submitting it for review." ], "evidence": { - "deterministic_tests": "66 passed; 0 failed", + "deterministic_tests": "67 passed; 0 failed", "plugin_audit": "verification/evidence/plugin-readiness-v1.1.2.json", "plugin_audit_result": "2 skills; zero errors and zero warnings", "prompt_lengths": [119, 126, 102], "archive_custody": "release-assets/v1.1.2/archive-custody.json", + "openai_submission_custody": "release-assets/v1.1.2/openai-submission-custody.json", + "portal_draft_upload_evidence": "release-assets/v1.1.2/portal-draft-upload-evidence.json", + "openai_draft": { + "plugin_id": "plugins_6a5e0f2981d48191a11548355b7ecbc5", + "submission_id": "appsub_6a5e0f299d848191b1cd16ae162ef105", + "state": "draft created and populated; both skills passed automated scanning; owner attestations unchecked; review submission not executed" + }, "prior_fresh_task_activation": { "plugin_version": "1.1.1", "software_verification_resource": "references/core/risk-based-testing.md", @@ -30,6 +39,12 @@ "sha256": "ae04e5b090e48f94aa6316b38955bd82e38a75a202161db56f0d5fed539fdae7", "bytes": 865770, "members": 106 + }, + { + "name": "Plugin-TestForge-v1.1.2-OpenAI-Submission.zip", + "sha256": "5154636d71c9bb00fa8754071d843110fedd036e361036fd00dc2b42fcdb7db2", + "bytes": 954071, + "members": 106 } ], "unchanged_release_objects": [ @@ -42,7 +57,7 @@ "material_findings": [], "conditions": [ "Install v1.1.2 from the refreshed marketplace and repeat fresh-task skill and bundled-resource activation.", - "Official OpenAI Plugins Directory submission, approval, publication, and discoverability remain separate states." + "The OpenAI draft exists and both skills passed automated scanning, but owner policy attestations, submission for review, approval, publication, and discoverability remain separate states." ], "disposition": "REVIEW_PASS_WITH_CONDITIONS", "release_decision": "READY_WITH_RESIDUAL_RISK"