From 835fc6c57daf07baa7ab134dcffe3ed804fb84ed Mon Sep 17 00:00:00 2001 From: Keerti Parthasarathy Date: Mon, 2 Nov 2026 09:09:03 -0800 Subject: [PATCH 1/4] Add tooling for build and release of the codex plugin --- .github/workflows/release_codex_plugin.yaml | 91 + pubspec.yaml | 1 + tool/codex_release/README.md | 60 + tool/codex_release/analysis_options.yaml | 14 + tool/codex_release/bin/build.dart | 93 + tool/codex_release/bin/release.dart | 81 + tool/codex_release/bin/validate.dart | 50 + tool/codex_release/lib/codex_release.dart | 12 + tool/codex_release/lib/src/finding.dart | 22 + tool/codex_release/lib/src/image_size.dart | 136 ++ tool/codex_release/lib/src/process.dart | 96 + tool/codex_release/lib/src/release.dart | 329 ++++ .../lib/src/release_manifest.dart | 62 + tool/codex_release/lib/src/release_notes.dart | 136 ++ tool/codex_release/lib/src/validator.dart | 1608 +++++++++++++++++ tool/codex_release/lib/src/zip_builder.dart | 134 ++ tool/codex_release/pubspec.yaml | 16 + tool/codex_release/test/fixtures.dart | 90 + tool/codex_release/test/image_size_test.dart | 44 + .../test/release_manifest_test.dart | 66 + tool/codex_release/test/release_test.dart | 237 +++ tool/codex_release/test/validator_test.dart | 492 +++++ tool/codex_release/test/zip_builder_test.dart | 140 ++ 23 files changed, 4010 insertions(+) create mode 100644 .github/workflows/release_codex_plugin.yaml create mode 100644 tool/codex_release/README.md create mode 100644 tool/codex_release/analysis_options.yaml create mode 100644 tool/codex_release/bin/build.dart create mode 100644 tool/codex_release/bin/release.dart create mode 100644 tool/codex_release/bin/validate.dart create mode 100644 tool/codex_release/lib/codex_release.dart create mode 100644 tool/codex_release/lib/src/finding.dart create mode 100644 tool/codex_release/lib/src/image_size.dart create mode 100644 tool/codex_release/lib/src/process.dart create mode 100644 tool/codex_release/lib/src/release.dart create mode 100644 tool/codex_release/lib/src/release_manifest.dart create mode 100644 tool/codex_release/lib/src/release_notes.dart create mode 100644 tool/codex_release/lib/src/validator.dart create mode 100644 tool/codex_release/lib/src/zip_builder.dart create mode 100644 tool/codex_release/pubspec.yaml create mode 100644 tool/codex_release/test/fixtures.dart create mode 100644 tool/codex_release/test/image_size_test.dart create mode 100644 tool/codex_release/test/release_manifest_test.dart create mode 100644 tool/codex_release/test/release_test.dart create mode 100644 tool/codex_release/test/validator_test.dart create mode 100644 tool/codex_release/test/zip_builder_test.dart diff --git a/.github/workflows/release_codex_plugin.yaml b/.github/workflows/release_codex_plugin.yaml new file mode 100644 index 00000000..4aa1d0f1 --- /dev/null +++ b/.github/workflows/release_codex_plugin.yaml @@ -0,0 +1,91 @@ +name: release_codex_plugin + +# Builds and validates the Codex plugin zip for the OpenAI plugin portal. +# - On a codex-plugin-v* tag: builds the zip from the tagged commit, validates +# it, and uploads it as a workflow artifact. tool/codex_release/bin/release.dart +# pushes the tag and downloads the artifact. +# - On pull requests: builds and validates the zip without uploading it, so +# problems are caught before they reach main. + +on: + push: + tags: ['codex-plugin-v*'] + pull_request: + paths: + - '.codex-plugin/**' + - 'assets/**' + - 'skills/**' + - 'tool/codex_release/**' + - 'pubspec.yaml' + - '.github/workflows/release_codex_plugin.yaml' + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Full history and tags, to find the previous release for the notes. + fetch-depth: 0 + persist-credentials: false + + - uses: dart-lang/setup-dart@6afc89df92d6eb3834022f73cd65adc8cdfcb92d # v1.8.1 + with: + sdk: stable + + - run: dart pub get + + - name: Analyze and test the release tool + working-directory: tool/codex_release + run: | + dart analyze --fatal-infos + dart format --output=none --set-exit-if-changed . + dart test + + - name: Build zip + id: build + env: + TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }} + run: dart run tool/codex_release/bin/build.dart ${TAG:+--tag "$TAG"} + + - name: Validate zip + env: + ZIP_PATH: ${{ steps.build.outputs.zip_path }} + VERSION: ${{ steps.build.outputs.version }} + run: dart run tool/codex_release/bin/validate.dart "$ZIP_PATH" --version "$VERSION" + + - name: Upload zip + if: github.ref_type == 'tag' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ steps.build.outputs.zip_name }} + path: ${{ steps.build.outputs.zip_path }} + retention-days: 30 + if-no-files-found: error + # The file is already a zip. + compression-level: 0 + + report_failure: + needs: build + if: failure() && github.ref_type == 'tag' + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Open an issue + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + gh label create codex-release --repo "$REPO" --color D93F0B \ + --description "Codex plugin release" 2>/dev/null || true + gh issue create --repo "$REPO" --label codex-release \ + --title "Codex plugin release failed: $TAG" \ + --body "The release workflow for \`$TAG\` failed: $RUN_URL + + Fix the problem on main, bump the version in \`.codex-plugin/plugin.json\`, and release again. Tags are never moved or reused." diff --git a/pubspec.yaml b/pubspec.yaml index f8c23f60..217cd0ed 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,4 +5,5 @@ environment: sdk: ^3.10.8 workspace: + - tool/codex_release - tool/generator diff --git a/tool/codex_release/README.md b/tool/codex_release/README.md new file mode 100644 index 00000000..44db6ad1 --- /dev/null +++ b/tool/codex_release/README.md @@ -0,0 +1,60 @@ +# codex_release + +Builds, validates, and releases the Dart and Flutter Codex plugin zip that's +uploaded to the [OpenAI plugin submission portal](https://platform.openai.com/plugins). + +The zip is a skills-only build of the plugin: the release copy of +`.codex-plugin/plugin.json` has every MCP reference removed and release notes +added. The repo manifest is unchanged. + +## Releasing + +From an up-to-date `main` with no uncommitted changes: + +```sh +dart run tool/codex_release/bin/release.dart +``` + +The script: + +1. checks that you're on `main`, it matches the remote, and the tree is clean; +2. reads `version` from `.codex-plugin/plugin.json` and stops if + `codex-plugin-v` already exists; +3. creates and pushes the tag; +4. waits for the `release_codex_plugin` workflow to build and validate the zip; +5. downloads the zip to `dist/`. + +Then upload the zip in the portal, resolve any findings, submit it for review, +and publish it after approval. + +If the version was already released, bump `version` in +`.codex-plugin/plugin.json` in a PR first. Tags are never moved or reused. + +The script needs the [GitHub CLI](https://cli.github.com) (`gh auth login`), +and push access to `flutter/agent-plugins`. It finds the git remote for +`flutter/agent-plugins` automatically; pass `--remote` to choose one. + +To check everything without tagging, build and validate the zip locally: + +```sh +dart run tool/codex_release/bin/release.dart --dry-run +``` + +## Building and validating by hand + +```sh +dart run tool/codex_release/bin/build.dart # writes dist/dart-flutter-codex-.zip +dart run tool/codex_release/bin/validate.dart dist/dart-flutter-codex-.zip +``` + +The validator checks the zip against OpenAI's documented +[package rules](https://developers.openai.com/plugins/deploy/submission-errors) +and reports OpenAI's error code for each problem. Checks specific to this repo +use codes starting with `release_`. The portal is still the final authority: +its skill safety scans and review can't be reproduced locally. + +## Release notes + +The build sets `extensions.com.openai.publication.release_notes` to the skills +added, updated, and removed since the previous `codex-plugin-v*` tag. Pass +`--release-notes` to `build.dart` to override them. diff --git a/tool/codex_release/analysis_options.yaml b/tool/codex_release/analysis_options.yaml new file mode 100644 index 00000000..1597615d --- /dev/null +++ b/tool/codex_release/analysis_options.yaml @@ -0,0 +1,14 @@ +include: package:lints/recommended.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + +linter: + rules: + - prefer_final_locals + - prefer_single_quotes + - require_trailing_commas + - directives_ordering diff --git a/tool/codex_release/bin/build.dart b/tool/codex_release/bin/build.dart new file mode 100644 index 00000000..33351f52 --- /dev/null +++ b/tool/codex_release/bin/build.dart @@ -0,0 +1,93 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:codex_release/codex_release.dart'; +import 'package:path/path.dart' as p; + +/// Builds the Codex plugin release zip from the current checkout. +/// +/// Usage: dart run tool/codex_release/bin/build.dart [--tag codex-plugin-v1.0.8] +Future main(List args) async { + final parser = ArgParser() + ..addOption( + 'tag', + help: + 'The release tag. The build fails if it does not match the ' + 'plugin.json version.', + ) + ..addOption( + 'output-dir', + defaultsTo: 'dist', + help: 'Where to write the zip.', + ) + ..addOption('release-notes', help: 'Overrides the generated release notes.') + ..addFlag('help', abbr: 'h', negatable: false); + final ArgResults options; + try { + options = parser.parse(args); + } on FormatException catch (e) { + stderr.writeln('${e.message}\n\n${parser.usage}'); + exit(64); + } + if (options.flag('help')) { + stdout.writeln('Builds the Codex plugin release zip.\n\n${parser.usage}'); + return; + } + + try { + final git = Git(await findRepoRoot()); + final tag = options.option('tag'); + final build = await buildZip( + git, + tag: tag == null || tag.isEmpty ? null : tag, + releaseNotes: options.option('release-notes'), + ); + final outputDir = Directory(p.join(git.root, options.option('output-dir')!)) + ..createSync(recursive: true); + final zipPath = p.join(outputDir.path, build.fileName); + File(zipPath).writeAsBytesSync(build.bytes); + stdout + ..writeln('Built $zipPath (${build.bytes.length} bytes)') + ..writeln('Release notes:') + ..writeln(releaseNotesOf(build.manifest)); + writeGitHubOutput({ + 'zip_path': zipPath, + 'zip_name': build.fileName, + 'version': build.version, + }); + } on BuildException catch (e) { + stderr.writeln('Error: $e'); + exit(1); + } on CommandException catch (e) { + stderr.writeln('Error: $e'); + exit(1); + } +} + +/// The repository root containing the current directory. +Future findRepoRoot() async { + final result = await Process.run('git', ['rev-parse', '--show-toplevel']); + if (result.exitCode != 0) { + stderr.writeln('Error: run this from inside the agent-plugins repository.'); + exit(1); + } + return (result.stdout as String).trim(); +} + +/// The release notes in a release manifest. +String releaseNotesOf(Map manifest) { + final extensions = manifest['extensions'] as Map; + final openai = extensions['com.openai'] as Map; + final publication = openai['publication'] as Map; + return publication['release_notes'] as String; +} + +/// Writes step outputs when running in GitHub Actions. +void writeGitHubOutput(Map outputs) { + final path = Platform.environment['GITHUB_OUTPUT']; + if (path == null) return; + File(path).writeAsStringSync( + outputs.entries.map((e) => '${e.key}=${e.value}\n').join(), + mode: FileMode.append, + ); +} diff --git a/tool/codex_release/bin/release.dart b/tool/codex_release/bin/release.dart new file mode 100644 index 00000000..67d803d7 --- /dev/null +++ b/tool/codex_release/bin/release.dart @@ -0,0 +1,81 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:codex_release/codex_release.dart'; + +/// Releases the Codex plugin: tags main, waits for the release workflow, and +/// downloads the zip to upload to the OpenAI plugin portal. +/// +/// Usage: dart run tool/codex_release/bin/release.dart [--dry-run] +Future main(List args) async { + final parser = ArgParser() + ..addFlag( + 'dry-run', + negatable: false, + help: + 'Check the checkout and build and validate the zip locally, ' + 'without creating a tag.', + ) + ..addOption( + 'repo', + defaultsTo: 'flutter/agent-plugins', + help: 'The GitHub repository to release from.', + ) + ..addOption( + 'remote', + help: 'The git remote for --repo. Detected from remote URLs by default.', + ) + ..addOption( + 'branch', + defaultsTo: 'main', + help: + 'The branch releases are cut from. Change it only for test runs ' + 'on a fork.', + ) + ..addOption( + 'output-dir', + defaultsTo: 'dist', + help: 'Where to save the zip.', + ) + ..addFlag('help', abbr: 'h', negatable: false); + final ArgResults options; + try { + options = parser.parse(args); + } on FormatException catch (e) { + stderr.writeln('${e.message}\n\n${parser.usage}'); + exit(64); + } + if (options.flag('help')) { + stdout.writeln('Releases the Codex plugin.\n\n${parser.usage}'); + return; + } + + final root = await Process.run('git', ['rev-parse', '--show-toplevel']); + if (root.exitCode != 0) { + stderr.writeln('Error: run this from inside the agent-plugins repository.'); + exit(1); + } + + final release = Release( + Git((root.stdout as String).trim()), + ReleaseOptions( + repo: options.option('repo')!, + remote: options.option('remote'), + branch: options.option('branch')!, + outputDirectory: options.option('output-dir')!, + dryRun: options.flag('dry-run'), + ), + ); + try { + await release.run(); + } on ReleaseException catch (e) { + stderr.writeln('Error: $e'); + exit(1); + } on BuildException catch (e) { + stderr.writeln('Error: $e'); + exit(1); + } on CommandException catch (e) { + stderr.writeln('Error: $e'); + exit(1); + } +} diff --git a/tool/codex_release/bin/validate.dart b/tool/codex_release/bin/validate.dart new file mode 100644 index 00000000..20df61cf --- /dev/null +++ b/tool/codex_release/bin/validate.dart @@ -0,0 +1,50 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:codex_release/codex_release.dart'; + +/// Validates a Codex plugin zip against OpenAI's documented package rules. +/// +/// Usage: `dart run tool/codex_release/bin/validate.dart ZIP [--version 1.0.8]` +Future main(List args) async { + final parser = ArgParser() + ..addOption('version', help: 'The version the manifest must have.') + ..addFlag('help', abbr: 'h', negatable: false); + final ArgResults options; + try { + options = parser.parse(args); + } on FormatException catch (e) { + stderr.writeln('${e.message}\n\n${parser.usage}'); + exit(64); + } + if (options.flag('help') || options.rest.length != 1) { + stdout.writeln( + 'Usage: validate.dart [--version ]\n\n${parser.usage}', + ); + exit(options.flag('help') ? 0 : 64); + } + + final file = File(options.rest.single); + if (!file.existsSync()) { + stderr.writeln('Error: ${file.path} does not exist.'); + exit(1); + } + final version = options.option('version'); + final findings = validateZip( + file.readAsBytesSync(), + expectedVersion: version == null || version.isEmpty ? null : version, + ); + if (findings.isEmpty) { + stdout.writeln('✓ ${file.path} passed all checks.'); + return; + } + + final inActions = Platform.environment['GITHUB_ACTIONS'] == 'true'; + stderr.writeln('✗ ${file.path} has ${findings.length} problem(s):'); + for (final finding in findings) { + stderr.writeln( + inActions ? '::error title=${finding.code}::$finding' : ' $finding', + ); + } + exit(1); +} diff --git a/tool/codex_release/lib/codex_release.dart b/tool/codex_release/lib/codex_release.dart new file mode 100644 index 00000000..9f4417db --- /dev/null +++ b/tool/codex_release/lib/codex_release.dart @@ -0,0 +1,12 @@ +/// Builds, validates, and releases the Codex plugin zip that's uploaded to the +/// OpenAI plugin submission portal. +library; + +export 'src/finding.dart'; +export 'src/image_size.dart'; +export 'src/process.dart'; +export 'src/release.dart'; +export 'src/release_manifest.dart'; +export 'src/release_notes.dart'; +export 'src/validator.dart'; +export 'src/zip_builder.dart'; diff --git a/tool/codex_release/lib/src/finding.dart b/tool/codex_release/lib/src/finding.dart new file mode 100644 index 00000000..cd35bac6 --- /dev/null +++ b/tool/codex_release/lib/src/finding.dart @@ -0,0 +1,22 @@ +/// A problem found while validating a plugin package. +/// +/// [code] matches the error name in OpenAI's plugin submission errors +/// reference (https://developers.openai.com/plugins/deploy/submission-errors) +/// where one exists. Checks specific to this repository's release process use +/// codes that start with `release_`. +class Finding { + const Finding(this.code, this.message, {this.path}); + + /// The OpenAI error name, or a `release_` code for repo-specific checks. + final String code; + + /// A human-readable description of the problem. + final String message; + + /// The archive path or manifest field the finding refers to, if any. + final String? path; + + @override + String toString() => + path == null ? '$code: $message' : '$code: $message ($path)'; +} diff --git a/tool/codex_release/lib/src/image_size.dart b/tool/codex_release/lib/src/image_size.dart new file mode 100644 index 00000000..835867c5 --- /dev/null +++ b/tool/codex_release/lib/src/image_size.dart @@ -0,0 +1,136 @@ +import 'dart:typed_data'; + +/// The pixel dimensions of a raster image. +class ImageSize { + const ImageSize(this.width, this.height); + + /// Width in pixels. + final int width; + + /// Height in pixels. + final int height; + + @override + String toString() => '$width×$height'; +} + +/// The raster formats OpenAI accepts for branding images. +enum RasterFormat { png, jpeg, webp } + +/// Detects the raster format of [bytes] from its signature, or returns null. +RasterFormat? detectRasterFormat(Uint8List bytes) { + if (_startsWith(bytes, const [ + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + ])) { + return RasterFormat.png; + } + if (_startsWith(bytes, const [0xFF, 0xD8, 0xFF])) { + return RasterFormat.jpeg; + } + if (bytes.length >= 12 && + _ascii(bytes, 0, 4) == 'RIFF' && + _ascii(bytes, 8, 4) == 'WEBP') { + return RasterFormat.webp; + } + return null; +} + +/// Reads the dimensions of a PNG, JPEG, or WebP image from its header. +/// +/// Returns null if the format isn't recognized or the header is malformed. +/// Only the header is read; the image data itself isn't decoded. +ImageSize? readRasterSize(Uint8List bytes) { + try { + return switch (detectRasterFormat(bytes)) { + RasterFormat.png => _pngSize(bytes), + RasterFormat.jpeg => _jpegSize(bytes), + RasterFormat.webp => _webpSize(bytes), + null => null, + }; + } on RangeError { + return null; + } +} + +ImageSize? _pngSize(Uint8List bytes) { + // The IHDR chunk always comes first: length (4), type (4), width, height. + if (_ascii(bytes, 12, 4) != 'IHDR') return null; + final data = ByteData.sublistView(bytes); + return ImageSize(data.getUint32(16), data.getUint32(20)); +} + +ImageSize? _jpegSize(Uint8List bytes) { + final data = ByteData.sublistView(bytes); + var offset = 2; + while (offset + 4 <= bytes.length) { + if (bytes[offset] != 0xFF) return null; + final marker = bytes[offset + 1]; + // Skip fill bytes. + if (marker == 0xFF) { + offset++; + continue; + } + // Markers without a length field. + if (marker == 0xD8 || + marker == 0x01 || + (marker >= 0xD0 && marker <= 0xD7)) { + offset += 2; + continue; + } + final length = data.getUint16(offset + 2); + final isStartOfFrame = + marker >= 0xC0 && + marker <= 0xCF && + marker != 0xC4 && + marker != 0xC8 && + marker != 0xCC; + if (isStartOfFrame) { + return ImageSize(data.getUint16(offset + 7), data.getUint16(offset + 5)); + } + offset += 2 + length; + } + return null; +} + +ImageSize? _webpSize(Uint8List bytes) { + final chunk = _ascii(bytes, 12, 4); + final data = ByteData.sublistView(bytes); + switch (chunk) { + case 'VP8 ': + // Lossy: frame tag (3 bytes) and start code (3 bytes) precede the size. + return ImageSize( + data.getUint16(26, Endian.little) & 0x3FFF, + data.getUint16(28, Endian.little) & 0x3FFF, + ); + case 'VP8L': + // Lossless: signature byte, then 14-bit width-1 and height-1. + final bits = data.getUint32(21, Endian.little); + return ImageSize((bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1); + case 'VP8X': + // Extended: 24-bit canvas width-1 and height-1. + int read24(int offset) => + bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16); + return ImageSize(read24(24) + 1, read24(27) + 1); + } + return null; +} + +bool _startsWith(Uint8List bytes, List prefix) { + if (bytes.length < prefix.length) return false; + for (var i = 0; i < prefix.length; i++) { + if (bytes[i] != prefix[i]) return false; + } + return true; +} + +String _ascii(Uint8List bytes, int start, int length) { + if (bytes.length < start + length) return ''; + return String.fromCharCodes(bytes.sublist(start, start + length)); +} diff --git a/tool/codex_release/lib/src/process.dart b/tool/codex_release/lib/src/process.dart new file mode 100644 index 00000000..499865a0 --- /dev/null +++ b/tool/codex_release/lib/src/process.dart @@ -0,0 +1,96 @@ +import 'dart:io'; + +/// Runs external commands. Tests substitute a fake. +abstract class ProcessRunner { + /// Runs [executable] with [arguments] and returns its result. + Future run( + String executable, + List arguments, { + String? workingDirectory, + }); + + /// Runs [executable] with its output streamed to the terminal, and returns + /// its exit code. + Future runInteractive( + String executable, + List arguments, { + String? workingDirectory, + }); +} + +/// Runs commands with `dart:io`. +class IoProcessRunner implements ProcessRunner { + const IoProcessRunner(); + + @override + Future run( + String executable, + List arguments, { + String? workingDirectory, + }) => Process.run(executable, arguments, workingDirectory: workingDirectory); + + @override + Future runInteractive( + String executable, + List arguments, { + String? workingDirectory, + }) async { + final process = await Process.start( + executable, + arguments, + workingDirectory: workingDirectory, + mode: ProcessStartMode.inheritStdio, + ); + return process.exitCode; + } +} + +/// Thrown when a command fails. +class CommandException implements Exception { + CommandException(this.command, this.result); + + /// The command line that failed. + final String command; + + /// The failed command's result. + final ProcessResult result; + + @override + String toString() { + final stderr = (result.stderr as String).trim(); + return '`$command` failed with exit code ${result.exitCode}' + '${stderr.isEmpty ? '' : ':\n$stderr'}'; + } +} + +/// Runs git commands in a repository. +class Git { + Git(this.root, {this.runner = const IoProcessRunner()}); + + /// The repository root. + final String root; + + /// Runs the commands. + final ProcessRunner runner; + + /// Runs `git ` and returns trimmed stdout, throwing on failure. + Future call(List args) async { + final result = await runner.run('git', args, workingDirectory: root); + if (result.exitCode != 0) { + throw CommandException('git ${args.join(' ')}', result); + } + return (result.stdout as String).trim(); + } + + /// Runs `git ` and returns whether it exited with 0. + Future succeeds(List args) async { + final result = await runner.run('git', args, workingDirectory: root); + return result.exitCode == 0; + } + + /// Files tracked by git under [path], relative to [root]. + Future> trackedFiles(String path) async { + final output = await call(['ls-files', '-z', '--', path]); + return output.split('\u0000').where((p) => p.isNotEmpty).toList()..sort(); + } +} diff --git a/tool/codex_release/lib/src/release.dart b/tool/codex_release/lib/src/release.dart new file mode 100644 index 00000000..9eeaed74 --- /dev/null +++ b/tool/codex_release/lib/src/release.dart @@ -0,0 +1,329 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'finding.dart'; +import 'process.dart'; +import 'release_notes.dart'; +import 'validator.dart'; +import 'zip_builder.dart'; + +/// The workflow that builds and validates the zip for a release tag. +const releaseWorkflow = 'release_codex_plugin.yaml'; + +/// Options for [Release.run]. +class ReleaseOptions { + const ReleaseOptions({ + required this.repo, + this.remote, + this.branch = 'main', + this.outputDirectory = 'dist', + this.dryRun = false, + this.pollInterval = const Duration(seconds: 5), + this.findRunTimeout = const Duration(minutes: 3), + }); + + /// The GitHub repository, e.g. `flutter/agent-plugins`. + final String repo; + + /// The git remote for [repo]. Detected from the remote URLs if null. + final String? remote; + + /// The branch releases are cut from. + final String branch; + + /// Where the downloaded zip is saved, relative to the repo root. + final String outputDirectory; + + /// If true, checks the checkout and builds and validates the zip locally, + /// without tagging or pushing. + final bool dryRun; + + /// How often to look for the workflow run after pushing the tag. + final Duration pollInterval; + + /// How long to wait for the workflow run to appear. + final Duration findRunTimeout; +} + +/// Thrown when the release stops before completing. +class ReleaseException implements Exception { + ReleaseException(this.message); + + /// Why the release stopped. + final String message; + + @override + String toString() => message; +} + +/// Cuts a release: checks the checkout, tags it, waits for CI, and downloads +/// the zip. +class Release { + Release( + this.git, + this.options, { + this.log = _print, + Future Function(Duration)? sleep, + }) : _sleep = sleep ?? Future.delayed; + + /// Git for the repository being released. + final Git git; + + /// Release settings. + final ReleaseOptions options; + + /// Where progress messages go. + final void Function(String) log; + + final Future Function(Duration) _sleep; + + ProcessRunner get _runner => git.runner; + + /// Runs the release and returns the path of the downloaded zip (or, for a + /// dry run, the locally built zip). + Future run() async { + await _checkGh(); + final remote = options.remote ?? await _findRemote(); + log('Using remote "$remote" for ${options.repo}.'); + + await git(['fetch', remote, options.branch, '--tags']); + await _checkCheckout(remote); + + final version = _readVersion(); + final tag = tagFor(version); + await _checkTagIsNew(remote, tag); + final sha = await git(['rev-parse', 'HEAD']); + log('Releasing version $version from $sha.'); + + if (options.dryRun) return _dryRun(tag); + + await git(['tag', '-a', tag, '-m', 'Codex plugin $version']); + await git(['push', remote, 'refs/tags/$tag']); + log('Pushed tag $tag.'); + + final run = await _findRun(sha, tag); + log('Waiting for ${run.url}'); + final exitCode = await _runner.runInteractive('gh', [ + 'run', + 'watch', + '${run.id}', + '--repo', + options.repo, + '--exit-status', + ], workingDirectory: git.root); + if (exitCode != 0) { + throw ReleaseException( + 'The release workflow failed: ${run.url}\n' + 'Fix the problem on ${options.branch}, bump the version, and release ' + 'again. Tags are never moved or reused.', + ); + } + + final zipName = '${_readName()}-codex-$version.zip'; + final outputDirectory = p.join(git.root, options.outputDirectory); + await _gh([ + 'run', + 'download', + '${run.id}', + '--repo', + options.repo, + '--name', + zipName, + '--dir', + outputDirectory, + ]); + final zipPath = p.join(outputDirectory, zipName); + if (!File(zipPath).existsSync()) { + throw ReleaseException( + 'Downloaded the artifact, but $zipPath is missing.', + ); + } + log(''); + log('Downloaded $zipPath'); + log( + 'Next: upload it in the OpenAI plugin portal ' + '(https://platform.openai.com/plugins), resolve any findings, submit ' + 'for review, and publish after approval.', + ); + return zipPath; + } + + Future _checkGh() async { + try { + final result = await _runner.run('gh', ['auth', 'status']); + if (result.exitCode != 0) { + throw ReleaseException( + 'The GitHub CLI is not signed in. Run `gh auth login`.', + ); + } + } on ProcessException { + throw ReleaseException( + 'The GitHub CLI (gh) is required: https://cli.github.com', + ); + } + } + + Future _findRemote() async { + final output = await git(['remote', '-v']); + for (final line in output.split('\n')) { + final parts = line.split(RegExp(r'\s+')); + if (parts.length < 2) continue; + final url = parts[1].replaceFirst(RegExp(r'\.git$'), ''); + if (url.endsWith('/${options.repo}') || + url.endsWith(':${options.repo}')) { + return parts[0]; + } + } + throw ReleaseException( + 'No git remote points at ${options.repo}. Add one, or pass --remote.', + ); + } + + Future _checkCheckout(String remote) async { + final status = await git(['status', '--porcelain', '--untracked-files=no']); + if (status.isNotEmpty) { + throw ReleaseException( + 'The working tree has uncommitted changes:\n$status', + ); + } + final branch = await git(['rev-parse', '--abbrev-ref', 'HEAD']); + if (branch != options.branch) { + throw ReleaseException( + 'Releases are cut from ${options.branch}, but the current branch is $branch.', + ); + } + final head = await git(['rev-parse', 'HEAD']); + final upstream = await git(['rev-parse', '$remote/${options.branch}']); + if (head != upstream) { + throw ReleaseException( + '${options.branch} is not up to date with $remote/${options.branch}. ' + 'Run `git pull $remote ${options.branch}`.', + ); + } + } + + Future _checkTagIsNew(String remote, String tag) async { + final local = await git.succeeds([ + 'rev-parse', + '--quiet', + '--verify', + 'refs/tags/$tag', + ]); + final onRemote = (await git([ + 'ls-remote', + '--tags', + remote, + 'refs/tags/$tag', + ])).isNotEmpty; + if (local || onRemote) { + throw ReleaseException( + '$tag already exists, so this version has already been released. ' + 'Bump "version" in .codex-plugin/plugin.json in a PR, merge it, and ' + 'release again.', + ); + } + } + + Future _dryRun(String tag) async { + log('Dry run: building and validating the zip locally. No tag is created.'); + final build = await buildZip(git, tag: tag); + final findings = validateZip(build.bytes, expectedVersion: build.version); + final outputDirectory = Directory(p.join(git.root, options.outputDirectory)) + ..createSync(recursive: true); + final zipPath = p.join(outputDirectory.path, build.fileName); + File(zipPath).writeAsBytesSync(build.bytes); + _reportFindings(findings); + log('Built $zipPath'); + log('Release notes:\n${_releaseNotes(build.manifest)}'); + if (findings.isNotEmpty) { + throw ReleaseException( + 'The zip has ${findings.length} validation problem(s).', + ); + } + return zipPath; + } + + Future<({int id, String url})> _findRun(String sha, String tag) async { + final deadline = DateTime.now().add(options.findRunTimeout); + log('Looking for the $releaseWorkflow run for $tag...'); + while (true) { + // Right after the tag is pushed, `gh run list --workflow` can fail + // until GitHub registers the workflow, so treat errors as "not yet". + final result = await _runner.run('gh', [ + 'run', + 'list', + '--repo', + options.repo, + '--workflow', + releaseWorkflow, + '--commit', + sha, + '--event', + 'push', + '--json', + 'databaseId,url,headBranch', + ], workingDirectory: git.root); + final runs = result.exitCode == 0 + ? (jsonDecode(result.stdout as String) as List) + .cast>() + : const >[]; + for (final run in runs) { + if (run['headBranch'] == tag) { + return (id: run['databaseId'] as int, url: run['url'] as String); + } + } + if (DateTime.now().isAfter(deadline)) { + throw ReleaseException( + 'No $releaseWorkflow run started for $tag. Check ' + 'https://github.com/${options.repo}/actions/workflows/$releaseWorkflow', + ); + } + await _sleep(options.pollInterval); + } + } + + Future _gh(List args) async { + final result = await _runner.run('gh', args, workingDirectory: git.root); + if (result.exitCode != 0) { + throw CommandException('gh ${args.join(' ')}', result); + } + return (result.stdout as String).trim(); + } + + Map _readManifest() => + jsonDecode( + File( + p.join(git.root, '.codex-plugin', 'plugin.json'), + ).readAsStringSync(), + ) + as Map; + + String _readVersion() { + final version = _readManifest()['version']; + if (version is! String || ReleaseVersion.tryParse(version) == null) { + throw ReleaseException( + '.codex-plugin/plugin.json "version" must be a semantic version.', + ); + } + return version; + } + + String _readName() => _readManifest()['name'] as String; + + void _reportFindings(List findings) { + for (final finding in findings) { + log(' ✗ $finding'); + } + } + + static String _releaseNotes(Map manifest) { + final extensions = manifest['extensions'] as Map; + final openai = extensions['com.openai'] as Map; + final publication = openai['publication'] as Map; + return publication['release_notes'] as String; + } +} + +void _print(String message) => stdout.writeln(message); diff --git a/tool/codex_release/lib/src/release_manifest.dart b/tool/codex_release/lib/src/release_manifest.dart new file mode 100644 index 00000000..33906b87 --- /dev/null +++ b/tool/codex_release/lib/src/release_manifest.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; + +/// `description` for the skills-only release, without the MCP server. +/// +/// The repo manifest describes the full plugin, including the Dart MCP server. +/// The zip for the OpenAI portal leaves the server out, so these replace the +/// repo text. Update them if the repo descriptions change. +const releaseDescription = + 'Official Codex plugin for Dart and Flutter that installs Flutter/Dart ' + 'Skills for building natively compiled, visually stunning applications ' + 'for mobile, web, desktop, and embedded devices from a single codebase.'; + +/// `interface.longDescription` for the skills-only release. +const releaseLongDescription = + 'Use official Dart and Flutter skills to develop apps, run workflows, and ' + 'manage code from Codex.'; + +/// Creates the release copy of the repo's `.codex-plugin/plugin.json`. +/// +/// Removes every MCP reference, replaces the descriptions with +/// [releaseDescription] and [releaseLongDescription], and sets +/// `extensions.com.openai.publication.release_notes` to [releaseNotes]. Every +/// other field is copied unchanged. [repoManifest] isn't modified. +Map createReleaseManifest( + Map repoManifest, { + required String releaseNotes, +}) { + // Deep copy so the caller's map is never changed. + final manifest = jsonDecode(jsonEncode(repoManifest)) as Map; + + manifest.remove('mcpServers'); + manifest['description'] = releaseDescription; + + final keywords = manifest['keywords']; + if (keywords is List) { + keywords.removeWhere((k) => k is String && k.toLowerCase() == 'mcp'); + } + + final interface = manifest['interface']; + if (interface is Map) { + interface['longDescription'] = releaseLongDescription; + final capabilities = interface['capabilities']; + if (capabilities is List) { + capabilities.removeWhere((c) => c is String && c.toLowerCase() == 'mcp'); + } + } + + final extensions = + (manifest['extensions'] ??= {}) as Map; + final openai = + (extensions['com.openai'] ??= {}) + as Map; + final publication = + (openai['publication'] ??= {}) as Map; + publication['release_notes'] = releaseNotes; + + return manifest; +} + +/// Encodes [manifest] the way it's written into the zip. +String encodeManifest(Map manifest) => + '${const JsonEncoder.withIndent(' ').convert(manifest)}\n'; diff --git a/tool/codex_release/lib/src/release_notes.dart b/tool/codex_release/lib/src/release_notes.dart new file mode 100644 index 00000000..abc8589e --- /dev/null +++ b/tool/codex_release/lib/src/release_notes.dart @@ -0,0 +1,136 @@ +import 'process.dart'; + +/// Prefix of the release tags, e.g. `codex-plugin-v1.0.8`. +const tagPrefix = 'codex-plugin-v'; + +/// The release tag for [version]. +String tagFor(String version) => '$tagPrefix$version'; + +/// A `major.minor.patch` version, ignoring pre-release and build suffixes. +class ReleaseVersion implements Comparable { + ReleaseVersion( + this.major, + this.minor, + this.patch, { + this.isPreRelease = false, + }); + + /// Major version. + final int major; + + /// Minor version. + final int minor; + + /// Patch version. + final int patch; + + /// Whether the version has a pre-release suffix such as `-rc1`. + final bool isPreRelease; + + static final _pattern = RegExp(r'^(\d+)\.(\d+)\.(\d+)(-[^+]+)?(\+.*)?$'); + + /// Parses [text], returning null if it isn't a semantic version. + static ReleaseVersion? tryParse(String text) { + final match = _pattern.firstMatch(text); + if (match == null) return null; + return ReleaseVersion( + int.parse(match[1]!), + int.parse(match[2]!), + int.parse(match[3]!), + isPreRelease: match[4] != null, + ); + } + + @override + int compareTo(ReleaseVersion other) { + if (major != other.major) return major.compareTo(other.major); + if (minor != other.minor) return minor.compareTo(other.minor); + return patch.compareTo(other.patch); + } + + @override + String toString() => '$major.$minor.$patch'; +} + +/// Finds the most recent release tag before [version] that's reachable from +/// HEAD, ignoring pre-release tags such as `codex-plugin-v1.0.7-rc1`. +Future findPreviousTag(Git git, String version) async { + final current = ReleaseVersion.tryParse(version); + if (current == null) return null; + final output = await git([ + 'tag', + '--list', + '$tagPrefix*', + '--merged', + 'HEAD', + ]); + String? best; + ReleaseVersion? bestVersion; + for (final tag in output.split('\n').map((t) => t.trim())) { + if (!tag.startsWith(tagPrefix)) continue; + final candidate = ReleaseVersion.tryParse(tag.substring(tagPrefix.length)); + if (candidate == null || candidate.isPreRelease) continue; + if (candidate.compareTo(current) >= 0) continue; + if (bestVersion == null || candidate.compareTo(bestVersion) > 0) { + best = tag; + bestVersion = candidate; + } + } + return best; +} + +/// Summarizes what changed since [previousTag] for the portal's release notes. +/// +/// Lists skills added, updated, and removed, and notes manifest or asset +/// changes. If there's no previous tag, describes the skills included. +Future generateReleaseNotes(Git git, {String? previousTag}) async { + final current = await _skillDirectories(git, 'HEAD'); + if (previousTag == null) { + return 'Dart and Flutter skills for Codex: ${current.length} skills ' + '(${current.join(', ')}).'; + } + + final previous = await _skillDirectories(git, previousTag); + final changedPaths = (await git([ + 'diff', + '--name-only', + previousTag, + 'HEAD', + '--', + 'skills', + '.codex-plugin', + 'assets', + ])).split('\n').where((p) => p.isNotEmpty).toList(); + + final changedSkills = { + for (final path in changedPaths) + if (path.startsWith('skills/') && path.split('/').length > 2) + path.split('/')[1], + }; + final added = current.where((s) => !previous.contains(s)).toList(); + final removed = previous.where((s) => !current.contains(s)).toList(); + final updated = current + .where((s) => previous.contains(s) && changedSkills.contains(s)) + .toList(); + final metadataChanged = changedPaths.any( + (p) => p.startsWith('.codex-plugin/') || p.startsWith('assets/'), + ); + + final lines = [ + if (added.isNotEmpty) 'Added skills: ${added.join(', ')}.', + if (updated.isNotEmpty) 'Updated skills: ${updated.join(', ')}.', + if (removed.isNotEmpty) 'Removed skills: ${removed.join(', ')}.', + if (metadataChanged) 'Updated plugin listing details.', + ]; + return lines.isEmpty ? 'Maintenance release.' : lines.join('\n'); +} + +Future> _skillDirectories(Git git, String ref) async { + final output = await git(['ls-tree', '-d', '--name-only', ref, 'skills/']); + return output + .split('\n') + .where((p) => p.startsWith('skills/')) + .map((p) => p.substring('skills/'.length)) + .toList() + ..sort(); +} diff --git a/tool/codex_release/lib/src/validator.dart b/tool/codex_release/lib/src/validator.dart new file mode 100644 index 00000000..f095e21f --- /dev/null +++ b/tool/codex_release/lib/src/validator.dart @@ -0,0 +1,1608 @@ +import 'dart:convert'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; +import 'package:xml/xml.dart'; +import 'package:yaml/yaml.dart'; + +import 'finding.dart'; +import 'image_size.dart'; + +/// Path of the Codex compatibility manifest inside the zip. +const manifestPath = '.codex-plugin/plugin.json'; + +/// Categories accepted by the OpenAI plugin directory. +const allowedCategories = { + 'Productivity', + 'Creativity', + 'Developer Tools', + 'Business & Operations', + 'Data & Analytics', + 'Communication', + 'Education & Research', + 'Security', + 'Finance', + 'Healthcare', + 'Travel', + 'Entertainment', + 'Other', +}; + +const _maxZipBytes = 100 * 1000 * 1000; +const _maxEntries = 5000; +const _maxUncompressedBytes = 512 * 1024 * 1024; +const _maxEntryBytes = 100 * 1024 * 1024; +const _maxPathSegments = 20; +const _maxImageBytes = 5 * 1024 * 1024; +const _minImagePixels = 48; +const _maxRasterPixels = 4096; + +final _semver = RegExp( + r'^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)' + r'(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?' + r'(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$', +); +final _pluginName = RegExp(r'^[A-Za-z0-9][A-Za-z0-9_-]*$'); +final _hexColor = RegExp(r'^#[0-9A-Fa-f]{6}$'); +final _mcpWord = RegExp(r'\bMCP\b', caseSensitive: false); +final _mention = RegExp(r'@\w'); +// Control characters, DEL, and Unicode line/paragraph separators. +final _unsupportedChars = RegExp( + '[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u2028\u2029]', +); +final _lineBreak = RegExp('[\r\n]'); + +/// Validates a Codex plugin zip against OpenAI's documented package rules. +/// +/// Uses the final directory submission limits where they're stricter than the +/// upload limits, and the skills-only submission path. If [expectedVersion] is +/// given, the manifest `version` must match it (e.g. the release tag). +/// +/// Returns every problem found; an empty list means the zip passed. +List validateZip(Uint8List zipBytes, {String? expectedVersion}) { + final findings = []; + if (zipBytes.length > _maxZipBytes) { + findings.add( + Finding( + 'archive_too_large', + 'Compressed ZIP is ${zipBytes.length} bytes; the limit is 100 MB.', + ), + ); + } + + // ZipDecoder returns an empty archive for non-zip input instead of + // throwing, so check for a local file header or end-of-central-directory + // signature first. + final isZip = + zipBytes.length >= 4 && + zipBytes[0] == 0x50 && + zipBytes[1] == 0x4B && + ((zipBytes[2] == 0x03 && zipBytes[3] == 0x04) || + (zipBytes[2] == 0x05 && zipBytes[3] == 0x06)); + if (!isZip) { + return findings..add( + const Finding( + 'archive_format_not_zip', + 'Archive must be a valid ZIP file.', + ), + ); + } + + final Archive archive; + try { + archive = ZipDecoder().decodeBytes(zipBytes); + } catch (e) { + return findings + ..add(Finding('archive_format_not_zip', 'Not a valid ZIP file: $e')); + } + + final package = _Package.fromArchive(archive, findings); + if (package == null) return findings; + + final manifest = _readManifest(package, findings); + if (manifest != null) { + _ManifestValidator(manifest, package, findings, expectedVersion).validate(); + } + _validateSkills(package, findings); + _validateExcludedFiles(package, findings); + return findings; +} + +/// The files in a zip, keyed by normalized path. +class _Package { + _Package(this.files, this.directories); + + final Map files; + final Set directories; + + static _Package? fromArchive(Archive archive, List findings) { + if (archive.isEmpty) { + findings.add( + const Finding('archive_empty', 'Archive must not be empty.'), + ); + return null; + } + if (archive.length > _maxEntries) { + findings.add( + Finding( + 'archive_too_many_entries', + 'Archive has ${archive.length} entries; the limit is 5,000.', + ), + ); + } + + final files = {}; + final directories = {}; + final normalized = {}; + var totalBytes = 0; + + for (final entry in archive) { + final rawName = entry.name; + final name = entry.isFile + ? rawName + : rawName.replaceFirst(RegExp(r'/$'), ''); + if (!_checkEntryPath(rawName, name, findings)) continue; + + if (entry.isSymbolicLink) { + findings.add( + Finding( + 'archive_member_type_unsupported', + 'Archive entries must be regular files or directories.', + path: name, + ), + ); + continue; + } + if (files.containsKey(name) || directories.contains(name)) { + findings.add( + Finding( + 'archive_member_path_duplicate', + 'Archive entry path must be unique.', + path: name, + ), + ); + continue; + } + final key = name.toLowerCase(); + final existing = normalized[key]; + if (existing != null) { + findings.add( + Finding( + 'archive_member_path_normalization_collision', + 'Paths must stay unique after case normalization (collides with $existing).', + path: name, + ), + ); + } + normalized[key] = name; + + if (entry.isFile) { + final bytes = entry.readBytes() ?? Uint8List(0); + if (bytes.length > _maxEntryBytes) { + findings.add( + Finding( + 'archive_member_too_large', + 'Archive entry must not exceed 100 MiB.', + path: name, + ), + ); + } + totalBytes += bytes.length; + files[name] = bytes; + } else { + directories.add(name); + } + } + + if (totalBytes > _maxUncompressedBytes) { + findings.add( + const Finding( + 'archive_uncompressed_too_large', + 'Extracted archive must not exceed 512 MiB.', + ), + ); + } + + // Implicit parent directories, and file paths that are also directories. + for (final path in files.keys) { + final segments = path.split('/'); + for (var i = 1; i < segments.length; i++) { + final parent = segments.sublist(0, i).join('/'); + if (files.containsKey(parent)) { + findings.add( + Finding( + 'archive_member_path_type_conflict', + 'A file path cannot also be a directory.', + path: parent, + ), + ); + } + directories.add(parent); + } + } + + // This repo builds the plugin root at the archive root. + if (!files.containsKey(manifestPath)) { + final topLevel = { + ...files.keys, + ...directories, + }.map((p) => p.split('/').first).toSet(); + final wrapped = + topLevel.length == 1 && + files.containsKey('${topLevel.single}/$manifestPath'); + findings.add( + Finding( + 'plugin_manifest_missing', + wrapped + ? 'The plugin is inside a top-level "${topLevel.single}/" directory; the release zip must have the plugin root at the archive root.' + : 'ZIP must contain $manifestPath at the archive root.', + ), + ); + return null; + } + return _Package(files, directories); + } + + /// Names of entries directly inside [directory]. + Iterable childrenOf(String directory) sync* { + final prefix = '$directory/'; + final seen = {}; + for (final path in [...files.keys, ...directories]) { + if (!path.startsWith(prefix)) continue; + final child = path.substring(prefix.length).split('/').first; + if (child.isNotEmpty && seen.add(child)) yield child; + } + } +} + +bool _checkEntryPath(String rawName, String name, List findings) { + void add(String code, String message) => + findings.add(Finding(code, message, path: rawName)); + + if (name.isEmpty) { + add('archive_member_path_empty', 'Archive entry path must not be empty.'); + return false; + } + if (name.trim() != name) { + add( + 'archive_member_path_has_outer_whitespace', + 'Archive entry path must not begin or end with whitespace.', + ); + } + if (name.contains(r'\')) { + add( + 'archive_member_path_has_backslash', + 'Archive entry path must use "/", not backslashes.', + ); + return false; + } + if (name.startsWith('/') || RegExp(r'^[A-Za-z]:').hasMatch(name)) { + add( + 'archive_member_path_absolute', + 'Archive entry path must be relative to the archive root.', + ); + return false; + } + final segments = name.split('/'); + if (segments.contains('')) { + add( + 'archive_member_path_has_empty_segment', + 'Archive entry path must not contain empty segments.', + ); + return false; + } + if (segments.contains('..')) { + add( + 'archive_member_path_has_parent_segment', + 'Archive entry path must not contain ".." segments.', + ); + return false; + } + if (segments.length > _maxPathSegments) { + add( + 'archive_member_path_too_deep', + 'Archive entry path must contain at most 20 segments.', + ); + } + return true; +} + +Map? _readManifest(_Package package, List findings) { + final String text; + try { + text = utf8.decode(package.files[manifestPath]!); + } on FormatException { + findings.add( + const Finding( + 'plugin_manifest_unreadable', + 'Plugin manifest must be readable UTF-8 text.', + path: manifestPath, + ), + ); + return null; + } + final Object? json; + try { + json = jsonDecode(text); + } on FormatException catch (e) { + findings.add( + Finding( + 'plugin_manifest_json_malformed', + 'Plugin manifest must contain valid JSON: ${e.message}', + path: manifestPath, + ), + ); + return null; + } + if (json is! Map) { + findings.add( + const Finding( + 'plugin_manifest_root_not_object', + 'Plugin manifest must contain a JSON object at the top level.', + path: manifestPath, + ), + ); + return null; + } + return json; +} + +class _ManifestValidator { + _ManifestValidator( + this.manifest, + this.package, + this.findings, + this.expectedVersion, + ); + + final Map manifest; + final _Package package; + final List findings; + final String? expectedVersion; + + void add(String code, String message, String field) => + findings.add(Finding(code, message, path: field)); + + void validate() { + _identity(); + _author(); + _url(manifest, 'homepage', 'plugin_homepage', maxLength: 2048); + _skillsPath(); + _excludedFields(); + + final interface = manifest['interface']; + if (interface == null) { + add( + 'plugin_interface_wrong_type', + 'interface is required in the Codex format.', + 'interface', + ); + } else if (interface is! Map) { + add( + 'plugin_interface_wrong_type', + 'interface must be a JSON object.', + 'interface', + ); + } else { + _listing(interface); + _assets(interface); + } + _releaseNotes(); + } + + void _identity() { + final name = _requiredString('name', 'plugin_name'); + if (name != null) { + if (name.length > 64) { + add( + 'plugin_name_too_long', + 'name must be 64 characters or fewer.', + 'name', + ); + } + if (!_pluginName.hasMatch(name)) { + add( + 'plugin_name_format', + 'name must start with an ASCII letter or digit and use only letters, digits, "_", or "-".', + 'name', + ); + } + } + + final version = _requiredString('version', 'plugin_version'); + if (version != null) { + if (version.length > 64) { + add( + 'plugin_version_too_long', + 'version must be 64 characters or fewer.', + 'version', + ); + } + if (!_semver.hasMatch(version)) { + add( + 'plugin_version_not_semver', + 'version must be a semantic version, such as 1.0.0.', + 'version', + ); + } + if (expectedVersion != null && version != expectedVersion) { + add( + 'release_version_mismatch', + 'version is $version, but the release tag is for $expectedVersion.', + 'version', + ); + } + } + + final description = _requiredString('description', 'plugin_description'); + if (description != null) { + if (description.length > 1024) { + add( + 'plugin_description_too_long', + 'description must be 1,024 characters or fewer.', + 'description', + ); + } + _text( + description, + 'plugin_description', + 'description', + singleLine: false, + ); + } + } + + void _author() { + final author = manifest['author']; + if (author == null) { + add('plugin_developer_missing', 'author.name is required.', 'author'); + return; + } + if (author is! Map) { + add('plugin_author_wrong_type', 'author must be an object.', 'author'); + return; + } + final name = author['name']; + if (name == null) { + add( + 'plugin_developer_missing', + 'author.name is required.', + 'author.name', + ); + } else if (name is! String) { + add( + 'plugin_author_name_wrong_type', + 'author.name must be a string.', + 'author.name', + ); + } else if (name.trim().isEmpty) { + add( + 'plugin_author_name_empty', + 'author.name must be non-empty.', + 'author.name', + ); + } else { + if (name.length > 120) { + add( + 'plugin_author_name_too_long', + 'author.name must be 120 characters or fewer.', + 'author.name', + ); + } + _text(name, 'plugin_author_name', 'author.name'); + } + final email = author['email']; + if (email != null) { + if (email is! String) { + add( + 'plugin_author_email_wrong_type', + 'author.email must be a string.', + 'author.email', + ); + } else if (email.trim().isEmpty) { + add( + 'plugin_author_email_empty', + 'author.email must be non-empty when provided.', + 'author.email', + ); + } else if (email.length > 320) { + add( + 'plugin_author_email_too_long', + 'author.email must be 320 characters or fewer.', + 'author.email', + ); + } + } + _url( + author, + 'url', + 'plugin_author_url', + maxLength: 2048, + fieldPrefix: 'author.', + ); + } + + void _skillsPath() { + final skills = manifest['skills']; + if (skills == null) return; + if (skills is! String) { + add( + 'plugin_skills_path_wrong_type', + 'skills must be a string path for the root skills/ directory.', + 'skills', + ); + } else if (skills.trim().isEmpty) { + add( + 'plugin_skills_path_empty', + 'skills must be a non-empty path.', + 'skills', + ); + } else if (skills != './skills/' && skills != './skills') { + add( + 'plugin_skills_path_unsupported', + 'skills must resolve to the root skills/ directory.', + 'skills', + ); + } else if (!package.directories.contains('skills')) { + add( + 'plugin_skills_directory_missing', + 'The declared skills/ directory must exist.', + 'skills', + ); + } + } + + void _excludedFields() { + if (manifest.containsKey('mcpServers')) { + add( + 'mcp_configuration_excluded', + 'The release zip must not declare MCP servers; local MCP is only accepted from allowlisted partners.', + 'mcpServers', + ); + } + if (manifest.containsKey('apps')) { + add( + 'app_configuration_excluded', + 'ZIP uploads must not include apps.', + 'apps', + ); + } + if (manifest.containsKey('hooks')) { + add( + 'release_hooks_excluded', + 'Packages with lifecycle hooks cannot be submitted to the public directory.', + 'hooks', + ); + } + final openai = _openAiExtension(); + if (openai != null) { + if (openai.containsKey('hooks')) { + add( + 'release_hooks_excluded', + 'Packages with lifecycle hooks cannot be submitted to the public directory.', + 'extensions.com.openai.hooks', + ); + } + if (openai.containsKey('apps')) { + add( + 'app_configuration_excluded', + 'ZIP uploads must not include apps.', + 'extensions.com.openai.apps', + ); + } + final review = openai['review']; + if (review is Map) { + for (final key in const ['test_credentials', 'reviewer_instructions']) { + if (review.containsKey(key)) { + add( + 'release_review_field_rejected', + 'ZIP metadata rejects $key; enter it in the portal instead.', + 'extensions.com.openai.review.$key', + ); + } + } + } + } + + final keywords = manifest['keywords']; + if (keywords is List && + keywords.any((k) => k is String && k.toLowerCase() == 'mcp')) { + add( + 'release_mcp_reference', + 'keywords must not include "mcp" in the skills-only release.', + 'keywords', + ); + } + final description = manifest['description']; + if (description is String && _mcpWord.hasMatch(description)) { + add( + 'release_mcp_reference', + 'description must not mention MCP in the skills-only release.', + 'description', + ); + } + } + + void _listing(Map interface) { + String? required( + String field, + String codePrefix, + int maxLength, { + bool singleLine = true, + }) { + final path = 'interface.$field'; + final value = interface[field]; + if (value == null) { + add('${codePrefix}_empty', '$path is required.', path); + return null; + } + if (value is! String) { + add('${codePrefix}_wrong_type', '$path must be a string.', path); + return null; + } + if (value.trim().isEmpty) { + add('${codePrefix}_empty', '$path must be non-empty.', path); + return null; + } + if (value.length > maxLength) { + add( + '${codePrefix}_too_long', + '$path must be $maxLength characters or fewer (it is ${value.length}).', + path, + ); + } + _text(value, codePrefix, path, singleLine: singleLine); + return value; + } + + required('displayName', 'plugin_display_name', 30); + required('shortDescription', 'plugin_short_description', 30); + final longDescription = required( + 'longDescription', + 'plugin_long_description', + 4000, + singleLine: false, + ); + required('developerName', 'plugin_developer_name', 80); + + if (longDescription != null && _mcpWord.hasMatch(longDescription)) { + add( + 'release_mcp_reference', + 'interface.longDescription must not mention MCP in the skills-only release.', + 'interface.longDescription', + ); + } + + final category = interface['category']; + if (category != null) { + if (category is! String) { + add( + 'plugin_category_wrong_type', + 'interface.category must be a string.', + 'interface.category', + ); + } else if (category.trim().isEmpty) { + add( + 'plugin_category_empty', + 'interface.category must be non-empty when provided.', + 'interface.category', + ); + } else if (!allowedCategories.contains(category)) { + add( + 'plugin_category_unknown', + 'interface.category must be one of: ${allowedCategories.join(', ')}.', + 'interface.category', + ); + } + } + + final capabilities = interface['capabilities']; + if (capabilities == null || capabilities is! List) { + add( + 'plugin_capabilities_wrong_type', + 'interface.capabilities must be a list of strings (use [] if there are none).', + 'interface.capabilities', + ); + } else { + if (capabilities.length > 20) { + add( + 'plugin_capabilities_too_many', + 'interface.capabilities must contain 20 entries or fewer.', + 'interface.capabilities', + ); + } + for (final capability in capabilities) { + if (capability is! String) { + add( + 'plugin_capability_wrong_type', + 'Each capability must be a string.', + 'interface.capabilities', + ); + } else if (capability.trim().isEmpty) { + add( + 'plugin_capability_empty', + 'Each capability must be non-empty.', + 'interface.capabilities', + ); + } else { + if (capability.length > 120) { + add( + 'plugin_capability_too_long', + 'Each capability must be 120 characters or fewer.', + 'interface.capabilities', + ); + } + _text(capability, 'plugin_capability', 'interface.capabilities'); + if (_mcpWord.hasMatch(capability)) { + add( + 'release_mcp_reference', + 'capabilities must not include "$capability" in the skills-only release.', + 'interface.capabilities', + ); + } + } + } + } + + _url( + interface, + 'websiteURL', + 'plugin_website_url', + fieldPrefix: 'interface.', + ); + _url( + interface, + 'supportURL', + 'plugin_support_url', + fieldPrefix: 'interface.', + ); + _url( + interface, + 'privacyPolicyURL', + 'plugin_privacy_policy_url', + fieldPrefix: 'interface.', + ); + _url( + interface, + 'termsOfServiceURL', + 'plugin_terms_of_service_url', + fieldPrefix: 'interface.', + ); + + _defaultPrompts(interface['defaultPrompt']); + _brandColor(interface, 'brandColor', 'plugin_brand_color', const [ + 0xFF, + 0xFF, + 0xFF, + ], 'white'); + _brandColor(interface, 'brandColorDark', 'plugin_brand_color_dark', const [ + 0x21, + 0x21, + 0x21, + ], '#212121'); + + if (interface.containsKey('screenshots')) { + add( + 'screenshot_configuration_excluded', + 'Skills-only ZIP uploads must not include interface.screenshots.', + 'interface.screenshots', + ); + } + } + + void _defaultPrompts(Object? value) { + const path = 'interface.defaultPrompt'; + if (value == null) return; + final List prompts; + if (value is String) { + prompts = [value]; + } else if (value is List) { + prompts = value; + } else { + add( + 'plugin_default_prompt_wrong_type', + '$path must be a string or list of strings.', + path, + ); + return; + } + if (prompts.length > 3) { + add( + 'plugin_default_prompt_too_many', + '$path must contain at most three prompts.', + path, + ); + } + final seen = {}; + for (final prompt in prompts) { + if (prompt is! String) { + add( + 'plugin_default_prompt_entry_wrong_type', + 'Each $path entry must be a string.', + path, + ); + continue; + } + if (prompt.trim().isEmpty) { + add( + 'plugin_default_prompt_empty', + 'Each $path entry must be non-empty.', + path, + ); + continue; + } + if (prompt.length > 128) { + add( + 'plugin_default_prompt_too_long', + 'Each $path entry must be 128 characters or fewer.', + path, + ); + } + _text(prompt, 'plugin_default_prompt', path); + if (_mention.hasMatch(prompt)) { + add( + 'plugin_default_prompt_mention', + 'Starter prompts must not contain @mentions.', + path, + ); + } + if (!seen.add( + prompt.trim().replaceAll(RegExp(r'\s+'), ' ').toLowerCase(), + )) { + add( + 'plugin_default_prompt_duplicate', + 'Starter prompts must be unique.', + path, + ); + } + } + } + + void _brandColor( + Map interface, + String field, + String codePrefix, + List background, + String backgroundName, + ) { + final path = 'interface.$field'; + final value = interface[field]; + if (value == null) return; + if (value is! String) { + add('${codePrefix}_wrong_type', '$path must be a string.', path); + } else if (value.isEmpty) { + add( + '${codePrefix}_empty', + '$path must be non-empty when provided.', + path, + ); + } else if (!_hexColor.hasMatch(value)) { + add( + '${codePrefix}_format', + '$path must be a six-digit hex color, such as #1ABCFE.', + path, + ); + } else { + final rgb = [ + for (var i = 1; i < 7; i += 2) + int.parse(value.substring(i, i + 2), radix: 16), + ]; + if (contrastRatio(rgb, background) < 2) { + add( + '${codePrefix}_contrast', + '$path must have at least 2:1 contrast against $backgroundName.', + path, + ); + } + } + } + + void _assets(Map interface) { + for (final field in const ['composerIcon', 'logo']) { + if (interface[field] == null) { + add( + field == 'logo' + ? 'plugin_logo_path_missing' + : 'plugin_composer_icon_path_missing', + 'interface.$field is required and must reference a square image.', + 'interface.$field', + ); + } + } + for (final field in const [ + 'composerIcon', + 'composerIconDark', + 'logo', + 'logoDark', + ]) { + final value = interface[field]; + if (value == null) continue; + final path = _assetPath(value, 'interface.$field'); + if (path != null) _image(path, 'interface.$field'); + } + } + + /// Checks an asset path field and returns the archive path it points to. + String? _assetPath(Object value, String field) { + if (value is! String) { + add( + 'declared_asset_path_wrong_type', + '$field must be a file path string.', + field, + ); + return null; + } + if (value.isEmpty) { + add('declared_asset_path_empty', '$field must not be empty.', field); + return null; + } + if (value.trim() != value) { + add( + 'declared_asset_path_has_outer_whitespace', + '$field must not begin or end with whitespace.', + field, + ); + return null; + } + if (RegExp('[\u0000-\u001F\u007F]').hasMatch(value)) { + add( + 'declared_asset_path_has_control_character', + '$field must not contain control characters.', + field, + ); + return null; + } + if (!value.startsWith('./')) { + add( + 'branding_asset_path_missing_root_prefix', + '$field must start with "./".', + field, + ); + return null; + } + final relative = value.substring(2); + final segments = relative.split('/'); + if (relative.startsWith('/') || + segments.contains('..') || + RegExp(r'^[A-Za-z]:').hasMatch(relative)) { + add( + 'declared_asset_path_unsafe', + '$field must be a relative path inside the plugin.', + field, + ); + return null; + } + if (package.directories.contains(relative)) { + add( + 'declared_asset_not_regular_file', + '$field must reference a file, not a directory.', + field, + ); + return null; + } + if (!package.files.containsKey(relative)) { + add( + 'declared_asset_file_missing', + '$field references $value, which is not in the zip.', + field, + ); + return null; + } + return relative; + } + + void _image(String path, String field) { + final bytes = package.files[path]!; + if (bytes.length > _maxImageBytes) { + add('image_file_too_large', 'Image must not exceed 5 MiB.', field); + } + final extension = path.contains('.') + ? path.split('.').last.toLowerCase() + : ''; + if (extension == 'svg') { + _svg(bytes, field); + return; + } + final expected = switch (extension) { + 'png' => RasterFormat.png, + 'jpg' || 'jpeg' => RasterFormat.jpeg, + 'webp' => RasterFormat.webp, + _ => null, + }; + if (expected == null) { + add( + 'image_file_format_unsupported', + 'Image filename must end in .png, .jpg, .jpeg, .webp, or .svg.', + field, + ); + return; + } + final actual = detectRasterFormat(bytes); + if (actual != null && actual != expected) { + add( + 'raster_image_extension_content_mismatch', + 'Image extension .$extension does not match its ${actual.name.toUpperCase()} content.', + field, + ); + } + final size = readRasterSize(bytes); + if (size == null) { + add( + 'raster_image_decode_failed', + 'Raster image must be a PNG, JPEG, or WebP file that can be decoded.', + field, + ); + return; + } + _dimensions(size.width, size.height, field, prefix: 'raster_image'); + if (size.width > _maxRasterPixels || size.height > _maxRasterPixels) { + add( + 'raster_image_dimensions_too_large', + 'Image is $size; the limit is 4,096×4,096.', + field, + ); + } + } + + void _svg(Uint8List bytes, String field) { + final XmlDocument document; + try { + document = XmlDocument.parse(utf8.decode(bytes)); + } catch (_) { + add('svg_xml_malformed', 'SVG must contain valid UTF-8 XML.', field); + return; + } + final root = document.rootElement; + if (root.name.local != 'svg') { + add('svg_root_element_invalid', 'SVG root element must be .', field); + return; + } + + num? parse(String? value) => + value == null ? null : num.tryParse(value.trim()); + final viewBox = root.getAttribute('viewBox'); + final width = root.getAttribute('width'); + final height = root.getAttribute('height'); + + num? w; + num? h; + if (viewBox != null) { + final parts = viewBox + .trim() + .split(RegExp(r'[\s,]+')) + .map(num.tryParse) + .toList(); + if (parts.length != 4 || parts.contains(null)) { + add( + 'svg_dimensions_not_numeric', + 'SVG viewBox must contain four numbers.', + field, + ); + return; + } + w = parts[2]; + h = parts[3]; + } else if (width != null && height != null) { + w = parse(width); + h = parse(height); + if (w == null || h == null) { + add( + 'svg_dimensions_not_numeric', + 'SVG width and height must be numeric, without units or percentages.', + field, + ); + return; + } + } else { + add( + 'svg_dimensions_missing', + 'SVG must define a numeric viewBox or numeric width and height.', + field, + ); + return; + } + if (w! <= 0 || h! <= 0 || !w.isFinite || !h.isFinite) { + add( + 'svg_dimensions_not_positive', + 'SVG width and height must be positive finite numbers.', + field, + ); + return; + } + _dimensions(w, h, field, prefix: 'svg'); + } + + void _dimensions( + num width, + num height, + String field, { + required String prefix, + }) { + if (width != height) { + add( + prefix == 'svg' + ? 'svg_dimensions_not_square' + : 'raster_image_not_square', + 'Image must be square (it is $width×$height).', + field, + ); + } + if (width < _minImagePixels || height < _minImagePixels) { + add( + '${prefix}_dimensions_too_small', + 'Image dimensions must be at least 48×48 (it is $width×$height).', + field, + ); + } + } + + void _releaseNotes() { + final publication = _openAiExtension()?['publication']; + final notes = publication is Map ? publication['release_notes'] : null; + if (notes is! String || notes.trim().isEmpty) { + add( + 'release_notes_missing', + 'extensions.com.openai.publication.release_notes must be set; otherwise the portal keeps the previous release notes.', + 'extensions.com.openai.publication.release_notes', + ); + } + } + + Map? _openAiExtension() { + final extensions = manifest['extensions']; + if (extensions is! Map) return null; + final openai = extensions['com.openai']; + return openai is Map ? openai : null; + } + + String? _requiredString(String field, String codePrefix) { + final value = manifest[field]; + if (value == null) { + add('${codePrefix}_missing', '$field is required.', field); + return null; + } + if (value is! String) { + add('${codePrefix}_wrong_type', '$field must be a string.', field); + return null; + } + if (value.trim().isEmpty) { + add('${codePrefix}_empty', '$field must be non-empty.', field); + return null; + } + return value; + } + + void _url( + Map object, + String key, + String codePrefix, { + int maxLength = 1024, + String fieldPrefix = '', + }) { + final field = '$fieldPrefix$key'; + final value = object[key]; + if (value == null) return; + if (value is! String) { + add('${codePrefix}_wrong_type', '$field must be a string.', field); + return; + } + if (value.trim().isEmpty) { + add( + '${codePrefix}_empty', + '$field must be non-empty when provided.', + field, + ); + return; + } + final uri = Uri.tryParse(value); + if (uri == null || uri.scheme != 'https' || uri.host.isEmpty) { + add( + '${codePrefix}_${codePrefix == 'plugin_author_url' ? 'not_https' : 'format'}', + '$field must be an HTTPS URL.', + field, + ); + } else if (uri.userInfo.isNotEmpty) { + add( + '${codePrefix}_has_credentials', + '$field must not contain credentials.', + field, + ); + } + if (value.length > maxLength) { + add( + '${codePrefix}_too_long', + '$field must be $maxLength characters or fewer.', + field, + ); + } + } + + void _text( + String value, + String codePrefix, + String field, { + bool singleLine = true, + }) { + if (_unsupportedChars.hasMatch(value) || + (singleLine && _lineBreak.hasMatch(value))) { + add( + '${codePrefix}_character_unsupported', + '$field must use supported text${singleLine ? ' and fit on one line' : ''}.', + field, + ); + } + } +} + +void _validateSkills(_Package package, List findings) { + if (!package.directories.contains('skills')) { + findings.add( + const Finding( + 'plugin_runtime_surface_missing', + 'A skills-only ZIP must contain at least one valid skill.', + ), + ); + return; + } + + // SKILL.md files deeper than skills//SKILL.md. + for (final path in package.files.keys) { + final segments = path.split('/'); + if (segments.first == 'skills' && + segments.last == 'SKILL.md' && + segments.length > 3) { + findings.add( + Finding( + 'skill_manifest_nested', + 'Each skill directory must be an immediate child of skills/.', + path: path, + ), + ); + } + } + + final names = {}; + var validSkills = 0; + final pluginName = _pluginNameFrom(package); + + for (final child in package.childrenOf('skills').toList()..sort()) { + final dir = 'skills/$child'; + if (package.files.containsKey(dir)) { + findings.add( + Finding( + 'skill_file_ignored', + 'Files directly under skills/ are not imported as skills.', + path: dir, + ), + ); + continue; + } + if (child.startsWith('.')) { + findings.add( + Finding( + 'skill_directory_hidden', + 'Skill directory names must not begin with ".".', + path: dir, + ), + ); + continue; + } + final skillMd = package.files['$dir/SKILL.md']; + if (skillMd == null) { + findings.add( + Finding( + 'skill_manifest_missing', + 'Skill must contain a SKILL.md file.', + path: dir, + ), + ); + continue; + } + final name = _validateSkillMd(skillMd, '$dir/SKILL.md', findings); + if (name != null) { + validSkills++; + final existing = names[name]; + if (existing != null) { + findings.add( + Finding( + 'skill_identity_duplicate', + 'Skill name "$name" is also used by $existing.', + path: dir, + ), + ); + } + names[name] = dir; + if (pluginName != null && '$pluginName:$name'.length > 64) { + findings.add( + Finding( + 'skill_identity_too_long', + '"$pluginName:$name" must be 64 characters or fewer.', + path: dir, + ), + ); + } + } + final agent = package.files['$dir/agents/openai.yaml']; + if (agent != null) { + _validateAgentYaml(agent, '$dir/agents/openai.yaml', findings); + } + } + + if (validSkills == 0) { + findings.add( + const Finding( + 'plugin_runtime_surface_missing', + 'A skills-only ZIP must contain at least one valid skill.', + ), + ); + } +} + +String? _pluginNameFrom(_Package package) { + try { + final json = jsonDecode(utf8.decode(package.files[manifestPath]!)); + return json is Map && json['name'] is String + ? json['name'] as String + : null; + } catch (_) { + return null; + } +} + +/// Validates a SKILL.md file and returns its name if the frontmatter is valid. +String? _validateSkillMd(Uint8List bytes, String path, List findings) { + void add(String code, String message) => + findings.add(Finding(code, message, path: path)); + + final String text; + try { + text = utf8.decode(bytes); + } on FormatException { + add('skill_manifest_invalid_utf8', 'SKILL.md must contain valid UTF-8.'); + return null; + } + final lines = const LineSplitter().convert(text.replaceFirst('\uFEFF', '')); + if (lines.isEmpty || lines.first.trim() != '---') { + add( + 'skill_frontmatter_missing', + 'SKILL.md must start with YAML front matter between "---" lines.', + ); + return null; + } + final end = lines.indexWhere((line) => line.trim() == '---', 1); + if (end == -1) { + add( + 'skill_frontmatter_unclosed', + 'SKILL.md YAML front matter must end with "---".', + ); + return null; + } + final Object? frontmatter; + try { + frontmatter = loadYaml(lines.sublist(1, end).join('\n')); + } on YamlException catch (e) { + add( + 'skill_frontmatter_yaml_malformed', + 'SKILL.md front matter must contain valid YAML: ${e.message}', + ); + return null; + } + if (frontmatter is! YamlMap) { + add( + 'skill_frontmatter_wrong_type', + 'SKILL.md front matter must contain a YAML mapping.', + ); + return null; + } + + final map = frontmatter; + String? field(String key, int? maxLength) { + final value = map[key]; + if (value == null) { + add('skill_${key}_missing', '$key is required and must not be empty.'); + return null; + } + if (value is! String) { + add('skill_${key}_wrong_type', '$key must be a string.'); + return null; + } + if (value.trim().isEmpty) { + add('skill_${key}_empty', '$key must be non-empty.'); + return null; + } + if (maxLength != null && value.trim().length > maxLength) { + add( + 'skill_${key}_too_long', + '$key must be $maxLength characters or fewer.', + ); + } + if (_unsupportedChars.hasMatch(value)) { + add( + 'skill_${key}_character_unsupported', + '$key must use supported text.', + ); + } + return value.trim(); + } + + final name = field('name', null); + field('description', 1024); + if (lines.sublist(end + 1).join('\n').trim().isEmpty) { + add('skill_body_empty', 'Skill instructions must not be empty.'); + } + return name; +} + +void _validateAgentYaml(Uint8List bytes, String path, List findings) { + void add(String code, String message) => + findings.add(Finding(code, message, path: path)); + + final Object? yaml; + try { + yaml = loadYaml(utf8.decode(bytes)); + } on YamlException { + add( + 'skill_agent_yaml_malformed', + 'agents/openai.yaml must contain valid YAML.', + ); + return; + } on FormatException { + add( + 'skill_agent_invalid_utf8', + 'agents/openai.yaml must contain valid UTF-8.', + ); + return; + } + if (yaml is! YamlMap) { + add( + 'skill_agent_top_level_wrong_type', + 'agents/openai.yaml must contain a YAML mapping.', + ); + return; + } + final interface = yaml['interface']; + if (interface == null) { + add( + 'skill_agent_interface_missing', + 'interface is required in agents/openai.yaml.', + ); + } else if (interface is! YamlMap) { + add( + 'skill_agent_interface_wrong_type', + 'interface must be a YAML mapping.', + ); + } else { + for (final key in const ['display_name', 'short_description']) { + final value = interface[key]; + if (value == null) { + add('skill_agent_${key}_missing', 'interface.$key is required.'); + } else if (value is! String) { + add( + 'skill_agent_${key}_wrong_type', + 'interface.$key must be a string.', + ); + } else if (value.trim().isEmpty) { + add('skill_agent_${key}_empty', 'interface.$key must not be empty.'); + } + } + for (final key in const ['icon_small', 'icon_large', 'default_prompt']) { + final value = interface[key]; + if (value == null) continue; + if (value is! String) { + add( + 'skill_agent_${key}_wrong_type', + 'interface.$key must be a string.', + ); + } else if (value.trim().isEmpty) { + add( + 'skill_agent_${key}_empty', + 'interface.$key must be non-empty when provided.', + ); + } + } + final color = interface['brand_color']; + if (color != null) { + if (color is! String) { + add( + 'skill_agent_brand_color_wrong_type', + 'interface.brand_color must be a string.', + ); + } else if (!_hexColor.hasMatch(color)) { + add( + 'skill_agent_brand_color_format', + 'interface.brand_color must be a six-digit hex color.', + ); + } + } + } + final policy = yaml['policy']; + if (policy != null) { + if (policy is! YamlMap) { + add('skill_agent_policy_wrong_type', 'policy must be a YAML mapping.'); + } else { + final products = policy['products']; + final implicit = policy['allow_implicit_invocation']; + final validKeys = policy.keys.every( + (k) => k == 'products' || k == 'allow_implicit_invocation', + ); + final validProducts = + products == null || + (products is YamlList && + products.every((p) => p == 'CHAT' || p == 'CODEX')); + if (!validKeys || + !validProducts || + (implicit != null && implicit is! bool)) { + add( + 'skill_agent_allow_implicit_invocation_wrong_type', + 'policy may contain only products (CHAT, CODEX) and allow_implicit_invocation (true or false).', + ); + } + } + } + final dependencies = yaml['dependencies']; + if (dependencies != null) { + if (dependencies is! YamlMap) { + add( + 'skill_agent_dependencies_wrong_type', + 'dependencies must be a YAML mapping.', + ); + } else if (dependencies.keys.any((k) => k != 'tools')) { + add( + 'skill_agent_dependency_unsupported', + 'Only dependencies.tools is supported.', + ); + } + } +} + +void _validateExcludedFiles(_Package package, List findings) { + if (package.files.containsKey('.mcp.json') || + package.files.containsKey('mcp.json')) { + findings.add( + const Finding( + 'mcp_configuration_excluded', + 'The release zip must not include MCP configuration.', + path: '.mcp.json', + ), + ); + } + if (package.files.containsKey('.app.json')) { + findings.add( + const Finding( + 'app_configuration_excluded', + 'ZIP uploads must not include .app.json.', + path: '.app.json', + ), + ); + } + if (package.directories.contains('hooks')) { + findings.add( + const Finding( + 'release_hooks_excluded', + 'Packages with lifecycle hooks cannot be submitted to the public directory.', + path: 'hooks/', + ), + ); + } +} + +/// The WCAG contrast ratio between two sRGB colors given as [r, g, b] bytes. +double contrastRatio(List a, List b) { + double luminance(List rgb) { + double channel(int value) { + final c = value / 255; + return c <= 0.03928 + ? c / 12.92 + : math.pow((c + 0.055) / 1.055, 2.4).toDouble(); + } + + return 0.2126 * channel(rgb[0]) + + 0.7152 * channel(rgb[1]) + + 0.0722 * channel(rgb[2]); + } + + final la = luminance(a); + final lb = luminance(b); + return (math.max(la, lb) + 0.05) / (math.min(la, lb) + 0.05); +} diff --git a/tool/codex_release/lib/src/zip_builder.dart b/tool/codex_release/lib/src/zip_builder.dart new file mode 100644 index 00000000..ce87702a --- /dev/null +++ b/tool/codex_release/lib/src/zip_builder.dart @@ -0,0 +1,134 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; +import 'package:path/path.dart' as p; + +import 'process.dart'; +import 'release_manifest.dart'; +import 'release_notes.dart'; +import 'validator.dart' show manifestPath; + +/// A fixed timestamp for every zip entry, so rebuilding the same commit +/// produces a byte-for-byte identical zip. +final _fixedTimestamp = DateTime(1980, 1, 1); + +/// The interface fields that reference branding images. +const _assetFields = ['composerIcon', 'composerIconDark', 'logo', 'logoDark']; + +/// The result of [buildZip]. +class BuildResult { + BuildResult(this.bytes, this.manifest, this.version); + + /// The zip contents. + final Uint8List bytes; + + /// The release copy of the manifest written into the zip. + final Map manifest; + + /// The plugin version. + final String version; + + /// The file name for the zip, e.g. `dart-flutter-codex-1.0.8.zip`. + String get fileName => '${manifest['name']}-codex-$version.zip'; +} + +/// Thrown when the zip can't be built. +class BuildException implements Exception { + BuildException(this.message); + + /// What went wrong. + final String message; + + @override + String toString() => message; +} + +/// Builds the release zip from the repository at [git]'s root. +/// +/// The zip contains the release copy of `.codex-plugin/plugin.json`, the +/// branding images it references, and every git-tracked file under `skills/`. +/// If [tag] is given, the manifest version must match it. [releaseNotes] +/// overrides the generated notes. +Future buildZip( + Git git, { + String? tag, + String? releaseNotes, +}) async { + final root = git.root; + final repoManifest = _readRepoManifest(root); + final version = repoManifest['version']; + if (version is! String) { + throw BuildException('$manifestPath has no string "version".'); + } + if (tag != null && tag != tagFor(version)) { + throw BuildException( + 'Tag $tag does not match $manifestPath version $version ' + '(expected ${tagFor(version)}).', + ); + } + + final notes = + releaseNotes ?? + await generateReleaseNotes( + git, + previousTag: await findPreviousTag(git, version), + ); + final manifest = createReleaseManifest(repoManifest, releaseNotes: notes); + + final files = >{ + manifestPath: utf8.encode(encodeManifest(manifest)), + }; + + final interface = manifest['interface']; + if (interface is Map) { + for (final field in _assetFields) { + final value = interface[field]; + if (value is! String || !value.startsWith('./')) continue; + final relative = value.substring(2); + final file = File(p.joinAll([root, ...relative.split('/')])); + if (!file.existsSync()) { + throw BuildException( + 'interface.$field references $value, which does not exist.', + ); + } + files[relative] = file.readAsBytesSync(); + } + } + + final skillFiles = await git.trackedFiles('skills'); + if (skillFiles.isEmpty) { + throw BuildException('No tracked files under skills/.'); + } + for (final path in skillFiles) { + files[path] = File(p.joinAll([root, ...path.split('/')])).readAsBytesSync(); + } + + return BuildResult(_encode(files), manifest, version); +} + +Map _readRepoManifest(String root) { + final file = File(p.join(root, '.codex-plugin', 'plugin.json')); + if (!file.existsSync()) { + throw BuildException('${file.path} does not exist.'); + } + final json = jsonDecode(file.readAsStringSync()); + if (json is! Map) { + throw BuildException('$manifestPath must contain a JSON object.'); + } + return json; +} + +Uint8List _encode(Map> files) { + final archive = Archive(); + for (final path in files.keys.toList()..sort()) { + archive.add( + ArchiveFile.bytes(path, files[path]!) + ..mode = + 0x1a4 // 0644 + ..lastModTime = _fixedTimestamp.millisecondsSinceEpoch ~/ 1000, + ); + } + return ZipEncoder().encodeBytes(archive, modified: _fixedTimestamp); +} diff --git a/tool/codex_release/pubspec.yaml b/tool/codex_release/pubspec.yaml new file mode 100644 index 00000000..ac56ce1d --- /dev/null +++ b/tool/codex_release/pubspec.yaml @@ -0,0 +1,16 @@ +name: codex_release +description: Builds, validates, and releases the Codex plugin zip for the OpenAI plugin submission portal. +publish_to: none +resolution: workspace + +environment: + sdk: ^3.10.8 +dependencies: + archive: ^4.5.2 + args: ^2.7.0 + path: ^1.9.1 + xml: ^7.1.0 + yaml: ^3.1.4 +dev_dependencies: + lints: ^6.1.0 + test: ^1.33.0 diff --git a/tool/codex_release/test/fixtures.dart b/tool/codex_release/test/fixtures.dart new file mode 100644 index 00000000..3f70346c --- /dev/null +++ b/tool/codex_release/test/fixtures.dart @@ -0,0 +1,90 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; +import 'package:codex_release/codex_release.dart'; + +/// A square SVG icon. +const squareSvg = + '' + ''; + +/// A valid release manifest. Tests copy and modify it. +Map validManifest() => { + 'name': 'dart-flutter', + 'version': '1.0.8', + 'description': 'Dart and Flutter skills.', + 'author': {'name': 'Google LLC', 'url': 'https://flutter.dev'}, + 'homepage': 'https://github.com/flutter/agent-plugins', + 'keywords': ['flutter', 'dart'], + 'skills': './skills/', + 'interface': { + 'displayName': 'Dart and Flutter', + 'shortDescription': 'Build Dart and Flutter apps', + 'longDescription': 'Use official Dart and Flutter skills.', + 'developerName': 'Google LLC', + 'category': 'Developer Tools', + 'capabilities': ['Skills'], + 'websiteURL': 'https://flutter.dev', + 'composerIcon': './assets/icon.svg', + 'logo': './assets/icon.svg', + }, + 'extensions': { + 'com.openai': { + 'publication': {'release_notes': 'Updated skills.'}, + }, + }, +}; + +/// A valid SKILL.md for a skill called [name]. +String skillMd(String name, {String description = 'Does a thing.'}) => + '---\nname: $name\ndescription: $description\n---\n\n# $name\n\nSteps.\n'; + +/// The files of a valid package. Tests copy and modify them. +Map validFiles({Map? manifest}) => { + manifestPath: manifest ?? validManifest(), + 'assets/icon.svg': squareSvg, + 'skills/dart-one/SKILL.md': skillMd('dart-one'), + 'skills/dart-two/SKILL.md': skillMd('dart-two'), + 'skills/dart-two/references/notes.md': '# Notes\n', +}; + +/// Zips [files]. Values can be a JSON map (encoded as the manifest), a +/// string, or bytes. +Uint8List zip(Map files) { + final archive = Archive(); + files.forEach((path, content) { + final List bytes = switch (content) { + final Map json => utf8.encode(jsonEncode(json)), + final String text => utf8.encode(text), + final List data => data, + _ => throw ArgumentError('Unsupported content for $path'), + }; + archive.add(ArchiveFile.bytes(path, bytes)); + }); + return ZipEncoder().encodeBytes(archive); +} + +/// Validates [files] and returns the finding codes. +List codesFor(Map files, {String? expectedVersion}) => + validateZip( + zip(files), + expectedVersion: expectedVersion, + ).map((f) => f.code).toList(); + +/// Minimal PNG header bytes for a [width]×[height] image. +Uint8List pngHeader(int width, int height) { + final data = ByteData(33); + const signature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + for (var i = 0; i < 8; i++) { + data.setUint8(i, signature[i]); + } + data.setUint32(8, 13); + 'IHDR'.codeUnits.asMap().forEach((i, c) => data.setUint8(12 + i, c)); + data + ..setUint32(16, width) + ..setUint32(20, height) + ..setUint8(24, 8) + ..setUint8(25, 6); + return data.buffer.asUint8List(); +} diff --git a/tool/codex_release/test/image_size_test.dart b/tool/codex_release/test/image_size_test.dart new file mode 100644 index 00000000..6498b092 --- /dev/null +++ b/tool/codex_release/test/image_size_test.dart @@ -0,0 +1,44 @@ +import 'dart:typed_data'; + +import 'package:codex_release/codex_release.dart'; +import 'package:test/test.dart'; + +import 'fixtures.dart'; + +void main() { + test('reads PNG dimensions', () { + final size = readRasterSize(pngHeader(953, 272))!; + expect((size.width, size.height), (953, 272)); + }); + + test('reads JPEG dimensions from the start-of-frame marker', () { + final bytes = Uint8List.fromList([ + 0xFF, 0xD8, // SOI + 0xFF, 0xE0, 0x00, 0x04, 0x00, 0x00, // APP0 with 2 bytes of data + 0xFF, 0xC0, 0x00, 0x0B, 0x08, // SOF0, length, precision + 0x00, 0x30, // height 48 + 0x00, 0x40, // width 64 + 0x03, 0x00, 0x00, 0x00, + ]); + expect(detectRasterFormat(bytes), RasterFormat.jpeg); + final size = readRasterSize(bytes)!; + expect((size.width, size.height), (64, 48)); + }); + + test('reads extended WebP dimensions', () { + final bytes = Uint8List(30); + 'RIFF'.codeUnits.asMap().forEach((i, c) => bytes[i] = c); + 'WEBP'.codeUnits.asMap().forEach((i, c) => bytes[8 + i] = c); + 'VP8X'.codeUnits.asMap().forEach((i, c) => bytes[12 + i] = c); + // Canvas width-1 = 99 and height-1 = 49, as 24-bit little endian. + bytes[24] = 99; + bytes[27] = 49; + final size = readRasterSize(bytes)!; + expect((size.width, size.height), (100, 50)); + }); + + test('returns null for unknown or truncated data', () { + expect(readRasterSize(Uint8List.fromList([1, 2, 3])), isNull); + expect(readRasterSize(pngHeader(1, 1).sublist(0, 18)), isNull); + }); +} diff --git a/tool/codex_release/test/release_manifest_test.dart b/tool/codex_release/test/release_manifest_test.dart new file mode 100644 index 00000000..a7506035 --- /dev/null +++ b/tool/codex_release/test/release_manifest_test.dart @@ -0,0 +1,66 @@ +import 'package:codex_release/codex_release.dart'; +import 'package:test/test.dart'; + +Map _repoManifest() => { + 'name': 'dart-flutter', + 'version': '1.0.8', + 'description': 'Installs skills and the Dart MCP server.', + 'keywords': ['flutter', 'mcp', 'MCP', 'skills'], + 'interface': { + 'longDescription': 'Skills and the MCP server.', + 'capabilities': ['Skills', 'MCP'], + 'logo': './assets/flutter-icon.svg', + }, + 'mcpServers': { + 'dart-mcp-server': {'command': 'dart'}, + }, +}; + +void main() { + test('removes every MCP reference and adds release notes', () { + final release = createReleaseManifest( + _repoManifest(), + releaseNotes: 'Notes.', + ); + expect(release.containsKey('mcpServers'), isFalse); + expect(release['description'], releaseDescription); + expect(release['keywords'], ['flutter', 'skills']); + final interface = release['interface'] as Map; + expect(interface['capabilities'], ['Skills']); + expect(interface['longDescription'], releaseLongDescription); + expect(interface['logo'], './assets/flutter-icon.svg'); + expect(release['extensions'], { + 'com.openai': { + 'publication': {'release_notes': 'Notes.'}, + }, + }); + }); + + test('copies other fields and leaves the input unchanged', () { + final repo = _repoManifest(); + final release = createReleaseManifest(repo, releaseNotes: 'Notes.'); + expect(release['name'], 'dart-flutter'); + expect(release['version'], '1.0.8'); + expect(repo, _repoManifest()); + }); + + test('keeps existing OpenAI extension fields', () { + final repo = _repoManifest() + ..['extensions'] = { + 'com.openai': { + 'publication': {'countries': [], 'release_notes': 'Old.'}, + }, + }; + final release = createReleaseManifest(repo, releaseNotes: 'New.'); + expect(release['extensions'], { + 'com.openai': { + 'publication': {'countries': [], 'release_notes': 'New.'}, + }, + }); + }); + + test('the release descriptions do not mention MCP', () { + expect(releaseDescription, isNot(contains('MCP'))); + expect(releaseLongDescription, isNot(contains('MCP'))); + }); +} diff --git a/tool/codex_release/test/release_test.dart b/tool/codex_release/test/release_test.dart new file mode 100644 index 00000000..77a66d3d --- /dev/null +++ b/tool/codex_release/test/release_test.dart @@ -0,0 +1,237 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:codex_release/codex_release.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +/// Answers commands from a table keyed by the command line, and records them. +class FakeRunner implements ProcessRunner { + FakeRunner(this.responses); + + /// Command line prefix → stdout, or an exit code if it's an int. + final Map responses; + + /// Every command run, in order. + final commands = []; + + /// Called for `gh run download`, to create the downloaded file. + void Function(List args)? onDownload; + + @override + Future run( + String executable, + List arguments, { + String? workingDirectory, + }) async { + final command = [executable, ...arguments].join(' '); + commands.add(command); + if (command.startsWith('gh run download')) onDownload?.call(arguments); + for (final entry in responses.entries) { + if (command.startsWith(entry.key)) { + final value = entry.value; + return value is int + ? ProcessResult(0, value, '', 'failed') + : ProcessResult(0, 0, value, ''); + } + } + return ProcessResult(0, 0, '', ''); + } + + @override + Future runInteractive( + String executable, + List arguments, { + String? workingDirectory, + }) async { + final command = [executable, ...arguments].join(' '); + commands.add(command); + final value = responses[command]; + return value is int ? value : 0; + } +} + +const _sha = 'abc123'; + +late Directory _repo; + +Map _healthyResponses() => { + 'git remote -v': + 'origin\tgit@github.com:me/agent-plugins.git (fetch)\n' + 'upstream\tgit@github.com:flutter/agent-plugins.git (fetch)', + 'git status --porcelain': '', + 'git rev-parse --abbrev-ref HEAD': 'main', + 'git rev-parse HEAD': _sha, + 'git rev-parse upstream/main': _sha, + 'git rev-parse --quiet --verify refs/tags/': 1, + 'git ls-remote --tags': '', + 'gh run list': jsonEncode([ + { + 'databaseId': 42, + 'url': 'https://github.com/flutter/agent-plugins/actions/runs/42', + 'headBranch': 'codex-plugin-v1.0.8', + }, + ]), +}; + +Future _release(FakeRunner runner) => Release( + Git(_repo.path, runner: runner), + const ReleaseOptions( + repo: 'flutter/agent-plugins', + pollInterval: Duration.zero, + ), + log: (_) {}, + sleep: (_) async {}, +).run(); + +void main() { + setUp(() { + _repo = Directory.systemTemp.createTempSync('codex_release_flow'); + File(p.join(_repo.path, '.codex-plugin', 'plugin.json')) + ..createSync(recursive: true) + ..writeAsStringSync( + jsonEncode({'name': 'dart-flutter', 'version': '1.0.8'}), + ); + }); + + tearDown(() => _repo.deleteSync(recursive: true)); + + test('tags, waits for the workflow, and downloads the zip', () async { + final runner = FakeRunner(_healthyResponses()) + ..onDownload = (args) { + final dir = args[args.indexOf('--dir') + 1]; + File(p.join(dir, 'dart-flutter-codex-1.0.8.zip')) + ..createSync(recursive: true) + ..writeAsStringSync('zip'); + }; + + final path = await _release(runner); + + expect(path, p.join(_repo.path, 'dist', 'dart-flutter-codex-1.0.8.zip')); + expect( + runner.commands, + containsAllInOrder([ + 'git fetch upstream main --tags', + 'git tag -a codex-plugin-v1.0.8 -m Codex plugin 1.0.8', + 'git push upstream refs/tags/codex-plugin-v1.0.8', + startsWith( + 'gh run list --repo flutter/agent-plugins ' + '--workflow release_codex_plugin.yaml --commit $_sha', + ), + 'gh run watch 42 --repo flutter/agent-plugins --exit-status', + startsWith( + 'gh run download 42 --repo flutter/agent-plugins ' + '--name dart-flutter-codex-1.0.8.zip', + ), + ]), + ); + }); + + group('stops before tagging when', () { + Future expectStop( + Map overrides, + String message, + ) async { + final runner = FakeRunner({..._healthyResponses(), ...overrides}); + await expectLater( + _release(runner), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains(message), + ), + ), + ); + expect(runner.commands, isNot(contains(startsWith('git tag')))); + expect(runner.commands, isNot(contains(startsWith('git push')))); + } + + test('the working tree is dirty', () { + return expectStop({ + 'git status --porcelain': ' M README.md', + }, 'uncommitted changes'); + }); + + test('the branch is not main', () { + return expectStop({ + 'git rev-parse --abbrev-ref HEAD': 'feature', + }, 'current branch is feature'); + }); + + test('main is behind the remote', () { + return expectStop({ + 'git rev-parse upstream/main': 'def456', + }, 'not up to date'); + }); + + test('the tag already exists on the remote', () { + return expectStop({ + 'git ls-remote --tags': '$_sha\trefs/tags/codex-plugin-v1.0.8', + }, 'already exists'); + }); + + test('no remote points at the repo', () { + return expectStop({ + 'git remote -v': 'origin\tgit@github.com:me/fork.git (fetch)', + }, 'No git remote'); + }); + + test('gh is not signed in', () { + return expectStop({'gh auth status': 1}, 'gh auth login'); + }); + }); + + test('keeps looking while gh run list fails', () async { + var calls = 0; + final runner = _FlakyListRunner(_healthyResponses(), () => ++calls < 3) + ..onDownload = (args) { + final dir = args[args.indexOf('--dir') + 1]; + File(p.join(dir, 'dart-flutter-codex-1.0.8.zip')) + ..createSync(recursive: true) + ..writeAsStringSync('zip'); + }; + await _release(runner); + expect(calls, 3); + }); + + test('reports the run URL when the workflow fails', () async { + final runner = FakeRunner({ + ..._healthyResponses(), + 'gh run watch 42 --repo flutter/agent-plugins --exit-status': 1, + }); + await expectLater( + _release(runner), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('actions/runs/42'), + ), + ), + ); + expect(runner.commands, isNot(contains(startsWith('gh run download')))); + }); +} + +/// Fails `gh run list` while [shouldFail] returns true. +class _FlakyListRunner extends FakeRunner { + _FlakyListRunner(super.responses, this.shouldFail); + + final bool Function() shouldFail; + + @override + Future run( + String executable, + List arguments, { + String? workingDirectory, + }) { + if (executable == 'gh' && + arguments.take(2).join(' ') == 'run list' && + shouldFail()) { + commands.add([executable, ...arguments].join(' ')); + return Future.value(ProcessResult(0, 1, '', 'workflow not found')); + } + return super.run(executable, arguments, workingDirectory: workingDirectory); + } +} diff --git a/tool/codex_release/test/validator_test.dart b/tool/codex_release/test/validator_test.dart new file mode 100644 index 00000000..0dda020f --- /dev/null +++ b/tool/codex_release/test/validator_test.dart @@ -0,0 +1,492 @@ +import 'dart:typed_data'; + +import 'package:codex_release/codex_release.dart'; +import 'package:test/test.dart'; + +import 'fixtures.dart'; + +Map _interface(Map manifest) => + manifest['interface'] as Map; + +/// Validates the valid package after applying [change] to its manifest. +List _withManifest(void Function(Map) change) { + final manifest = validManifest(); + change(manifest); + return codesFor(validFiles(manifest: manifest)); +} + +void main() { + test('a valid package has no findings', () { + expect(validateZip(zip(validFiles())), isEmpty); + }); + + group('archive', () { + test('rejects bytes that are not a zip', () { + final codes = validateZip(Uint8List.fromList([1, 2, 3])); + expect(codes.map((f) => f.code), ['archive_format_not_zip']); + }); + + test('rejects a .. path segment', () { + expect( + codesFor({...validFiles(), 'skills/../evil.md': 'x'}), + contains('archive_member_path_has_parent_segment'), + ); + }); + + test('rejects paths that collide after case normalization', () { + expect( + codesFor({...validFiles(), 'skills/dart-one/skill.md': 'x'}), + contains('archive_member_path_normalization_collision'), + ); + }); + + test('rejects paths deeper than 20 segments', () { + final deep = 'skills/dart-one/${List.filled(19, 'd').join('/')}/f.md'; + expect( + codesFor({...validFiles(), deep: 'x'}), + contains('archive_member_path_too_deep'), + ); + }); + + test('rejects a plugin wrapped in a top-level directory', () { + final wrapped = { + for (final e in validFiles().entries) 'plugin/${e.key}': e.value, + }; + expect(codesFor(wrapped), ['plugin_manifest_missing']); + }); + }); + + group('manifest identity', () { + test('rejects malformed JSON', () { + expect( + codesFor({...validFiles(), manifestPath: '{"name": '}), + contains('plugin_manifest_json_malformed'), + ); + }); + + test('rejects an invalid name', () { + expect( + _withManifest((m) => m['name'] = '-bad name'), + contains('plugin_name_format'), + ); + }); + + test('rejects a non-semver version', () { + expect( + _withManifest((m) => m['version'] = '1.0'), + contains('plugin_version_not_semver'), + ); + }); + + test('rejects a version that does not match the tag', () { + expect(codesFor(validFiles(), expectedVersion: '1.0.9'), [ + 'release_version_mismatch', + ]); + }); + + test('rejects a description over 1,024 characters', () { + expect( + _withManifest((m) => m['description'] = 'a' * 1025), + contains('plugin_description_too_long'), + ); + }); + + test('requires author.name', () { + expect( + _withManifest((m) => m['author'] = {}), + contains('plugin_developer_missing'), + ); + }); + + test('requires an HTTPS author URL', () { + expect( + _withManifest( + (m) => (m['author'] as Map)['url'] = 'http://a.dev', + ), + contains('plugin_author_url_not_https'), + ); + }); + }); + + group('listing', () { + test('rejects a 31-character short description', () { + expect( + _withManifest((m) => _interface(m)['shortDescription'] = 'a' * 31), + ['plugin_short_description_too_long'], + ); + }); + + test('rejects the old 50-character short description', () { + expect( + _withManifest( + (m) => _interface(m)['shortDescription'] = + 'Build and manage Dart and Flutter apps from Codex.', + ), + contains('plugin_short_description_too_long'), + ); + }); + + test('rejects a multi-line display name', () { + expect( + _withManifest((m) => _interface(m)['displayName'] = 'Dart\nFlutter'), + contains('plugin_display_name_character_unsupported'), + ); + }); + + test('rejects an unknown category', () { + expect( + _withManifest((m) => _interface(m)['category'] = 'Tools'), + contains('plugin_category_unknown'), + ); + }); + + test('requires capabilities', () { + expect( + _withManifest((m) => _interface(m).remove('capabilities')), + contains('plugin_capabilities_wrong_type'), + ); + }); + + test('rejects a non-HTTPS listing URL', () { + expect( + _withManifest((m) => _interface(m)['supportURL'] = 'http://a.dev'), + contains('plugin_support_url_format'), + ); + }); + + test('rejects more than three starter prompts', () { + expect( + _withManifest( + (m) => _interface(m)['defaultPrompt'] = ['a', 'b', 'c', 'd'], + ), + contains('plugin_default_prompt_too_many'), + ); + }); + + test('rejects duplicate starter prompts', () { + expect( + _withManifest( + (m) => _interface(m)['defaultPrompt'] = ['Add a test', 'add a TEST'], + ), + contains('plugin_default_prompt_duplicate'), + ); + }); + + test('rejects a low-contrast brand color', () { + expect( + _withManifest((m) => _interface(m)['brandColor'] = '#EEEEEE'), + contains('plugin_brand_color_contrast'), + ); + }); + + test('accepts the Flutter blue brand color', () { + expect( + _withManifest((m) => _interface(m)['brandColor'] = '#027DFD'), + isEmpty, + ); + }); + }); + + group('excluded content', () { + test('rejects inline mcpServers', () { + expect( + _withManifest( + (m) => m['mcpServers'] = { + 'dart-mcp-server': {'command': 'dart'}, + }, + ), + contains('mcp_configuration_excluded'), + ); + }); + + test('rejects a .mcp.json file', () { + expect( + codesFor({...validFiles(), '.mcp.json': '{}'}), + contains('mcp_configuration_excluded'), + ); + }); + + test('rejects MCP in capabilities, keywords, and descriptions', () { + final codes = _withManifest((m) { + _interface(m)['capabilities'] = ['Skills', 'MCP']; + _interface(m)['longDescription'] = 'Skills and the MCP server.'; + m['keywords'] = ['dart', 'mcp']; + m['description'] = 'Installs the Dart MCP server.'; + }); + expect(codes.where((c) => c == 'release_mcp_reference'), hasLength(4)); + }); + + test('rejects screenshots', () { + expect( + _withManifest((m) => _interface(m)['screenshots'] = ['./a.png']), + contains('screenshot_configuration_excluded'), + ); + }); + + test('rejects hooks', () { + expect( + codesFor({...validFiles(), 'hooks/hooks.json': '{}'}), + contains('release_hooks_excluded'), + ); + }); + + test('rejects reviewer credentials', () { + expect( + _withManifest((m) { + final openai = + (m['extensions'] as Map)['com.openai'] + as Map; + openai['review'] = {'test_credentials': 'secret'}; + }), + contains('release_review_field_rejected'), + ); + }); + }); + + group('assets', () { + test('rejects a non-square PNG logo', () { + final manifest = validManifest(); + _interface(manifest)['logo'] = './assets/logo.png'; + expect( + codesFor({ + ...validFiles(manifest: manifest), + 'assets/logo.png': pngHeader(953, 272), + }), + ['raster_image_not_square'], + ); + }); + + test('accepts a square PNG', () { + final manifest = validManifest(); + _interface(manifest)['logo'] = './assets/logo.png'; + expect( + codesFor({ + ...validFiles(manifest: manifest), + 'assets/logo.png': pngHeader(512, 512), + }), + isEmpty, + ); + }); + + test('rejects a PNG over 4096 pixels', () { + final manifest = validManifest(); + _interface(manifest)['logo'] = './assets/logo.png'; + expect( + codesFor({ + ...validFiles(manifest: manifest), + 'assets/logo.png': pngHeader(5000, 5000), + }), + ['raster_image_dimensions_too_large'], + ); + }); + + test('rejects a non-square SVG', () { + expect( + codesFor({ + ...validFiles(), + 'assets/icon.svg': + '', + }), + contains('svg_dimensions_not_square'), + ); + }); + + test('rejects an SVG smaller than 48×48', () { + expect( + codesFor({ + ...validFiles(), + 'assets/icon.svg': + '', + }), + contains('svg_dimensions_too_small'), + ); + }); + + test('rejects SVG dimensions with units', () { + expect( + codesFor({ + ...validFiles(), + 'assets/icon.svg': + '', + }), + contains('svg_dimensions_not_numeric'), + ); + }); + + test('rejects malformed SVG', () { + expect( + codesFor({...validFiles(), 'assets/icon.svg': ' _interface(m)['logo'] = 'assets/icon.svg'), + contains('branding_asset_path_missing_root_prefix'), + ); + }); + + test('requires a logo', () { + expect( + _withManifest((m) => _interface(m).remove('logo')), + contains('plugin_logo_path_missing'), + ); + }); + + test('rejects an extension that does not match the content', () { + final manifest = validManifest(); + _interface(manifest)['logo'] = './assets/logo.jpg'; + expect( + codesFor({ + ...validFiles(manifest: manifest), + 'assets/logo.jpg': pngHeader(64, 64), + }), + contains('raster_image_extension_content_mismatch'), + ); + }); + }); + + group('skills', () { + test('rejects a skill without frontmatter', () { + expect( + codesFor({...validFiles(), 'skills/dart-one/SKILL.md': '# No header'}), + contains('skill_frontmatter_missing'), + ); + }); + + test('rejects unclosed frontmatter', () { + expect( + codesFor({ + ...validFiles(), + 'skills/dart-one/SKILL.md': '---\nname: dart-one\n', + }), + contains('skill_frontmatter_unclosed'), + ); + }); + + test('rejects a missing description', () { + expect( + codesFor({ + ...validFiles(), + 'skills/dart-one/SKILL.md': '---\nname: dart-one\n---\nBody\n', + }), + contains('skill_description_missing'), + ); + }); + + test('accepts a folded multi-line description', () { + expect( + codesFor({ + ...validFiles(), + 'skills/dart-one/SKILL.md': + '---\nname: dart-one\ndescription: >-\n Line one\n line two.\n' + '---\nBody\n', + }), + isEmpty, + ); + }); + + test('rejects a description over 1,024 characters', () { + expect( + codesFor({ + ...validFiles(), + 'skills/dart-one/SKILL.md': skillMd( + 'dart-one', + description: 'a' * 1025, + ), + }), + contains('skill_description_too_long'), + ); + }); + + test('rejects an empty body', () { + expect( + codesFor({ + ...validFiles(), + 'skills/dart-one/SKILL.md': + '---\nname: dart-one\ndescription: x\n---\n', + }), + contains('skill_body_empty'), + ); + }); + + test('rejects duplicate skill names', () { + expect( + codesFor({ + ...validFiles(), + 'skills/dart-two/SKILL.md': skillMd('dart-one'), + }), + contains('skill_identity_duplicate'), + ); + }); + + test('rejects a plugin:skill identity over 64 characters', () { + final name = 'a' * 60; + expect( + codesFor({...validFiles(), 'skills/$name/SKILL.md': skillMd(name)}), + contains('skill_identity_too_long'), + ); + }); + + test('rejects a skill directory without SKILL.md', () { + expect( + codesFor({...validFiles(), 'skills/empty/README.md': 'x'}), + contains('skill_manifest_missing'), + ); + }); + + test('rejects a file directly under skills/', () { + expect( + codesFor({...validFiles(), 'skills/README.md': 'x'}), + contains('skill_file_ignored'), + ); + }); + + test('rejects a hidden skill directory', () { + expect( + codesFor({...validFiles(), 'skills/.hidden/SKILL.md': skillMd('h')}), + contains('skill_directory_hidden'), + ); + }); + + test('rejects a nested SKILL.md', () { + expect( + codesFor({ + ...validFiles(), + 'skills/dart-one/sub/SKILL.md': skillMd('sub'), + }), + contains('skill_manifest_nested'), + ); + }); + + test('rejects an agents/openai.yaml without interface', () { + expect( + codesFor({ + ...validFiles(), + 'skills/dart-one/agents/openai.yaml': 'policy: {}\n', + }), + contains('skill_agent_interface_missing'), + ); + }); + + test('requires at least one skill', () { + final files = { + manifestPath: validManifest(), + 'assets/icon.svg': squareSvg, + }; + expect(codesFor(files), contains('plugin_runtime_surface_missing')); + }); + }); + + test('requires release notes', () { + expect(_withManifest((m) => m.remove('extensions')), [ + 'release_notes_missing', + ]); + }); +} diff --git a/tool/codex_release/test/zip_builder_test.dart b/tool/codex_release/test/zip_builder_test.dart new file mode 100644 index 00000000..8b5bb28c --- /dev/null +++ b/tool/codex_release/test/zip_builder_test.dart @@ -0,0 +1,140 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:codex_release/codex_release.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import 'fixtures.dart'; + +late Directory _repo; + +void _write(String path, String content) { + File(p.join(_repo.path, path)) + ..createSync(recursive: true) + ..writeAsStringSync(content); +} + +Future _git(List args) async { + final result = await Process.run('git', args, workingDirectory: _repo.path); + if (result.exitCode != 0) fail('git ${args.join(' ')}: ${result.stderr}'); +} + +Future _commit(String message) async { + await _git(['add', '-A']); + await _git(['commit', '-q', '-m', message]); +} + +void _writeManifest(String version) { + final manifest = validManifest() + ..remove('extensions') + ..['version'] = version + ..['description'] = 'Installs skills and the Dart MCP server.' + ..['keywords'] = ['dart', 'mcp'] + ..['mcpServers'] = { + 'dart-mcp-server': {'command': 'dart'}, + }; + (manifest['interface'] as Map)['capabilities'] = [ + 'Skills', + 'MCP', + ]; + _write( + '.codex-plugin/plugin.json', + const JsonEncoder.withIndent(' ').convert(manifest), + ); +} + +void main() { + setUp(() async { + _repo = Directory.systemTemp.createTempSync('codex_release_test'); + await _git(['init', '-q', '-b', 'main']); + await _git(['config', 'user.email', 'test@example.com']); + await _git(['config', 'user.name', 'Test']); + _writeManifest('1.0.7'); + _write('assets/icon.svg', squareSvg); + _write('skills/dart-one/SKILL.md', skillMd('dart-one')); + _write('skills/dart-two/SKILL.md', skillMd('dart-two')); + await _commit('Initial'); + await _git(['tag', 'codex-plugin-v1.0.7']); + }); + + tearDown(() => _repo.deleteSync(recursive: true)); + + Future makeNextRelease() async { + _write('skills/dart-three/SKILL.md', skillMd('dart-three')); + _write( + 'skills/dart-one/SKILL.md', + skillMd('dart-one', description: 'New.'), + ); + File(p.join(_repo.path, 'skills/dart-two/SKILL.md')).deleteSync(); + _writeManifest('1.0.8'); + await _commit('Next'); + } + + test('builds a zip that passes validation', () async { + await makeNextRelease(); + final build = await buildZip(Git(_repo.path), tag: 'codex-plugin-v1.0.8'); + expect(build.fileName, 'dart-flutter-codex-1.0.8.zip'); + expect(validateZip(build.bytes, expectedVersion: '1.0.8'), isEmpty); + + final archive = ZipDecoder().decodeBytes(build.bytes); + expect(archive.map((f) => f.name), [ + '.codex-plugin/plugin.json', + 'assets/icon.svg', + 'skills/dart-one/SKILL.md', + 'skills/dart-three/SKILL.md', + ]); + }); + + test('generates release notes from changes since the previous tag', () async { + await makeNextRelease(); + final build = await buildZip(Git(_repo.path)); + final extensions = build.manifest['extensions'] as Map; + final openai = extensions['com.openai'] as Map; + final publication = openai['publication'] as Map; + expect( + publication['release_notes'], + 'Added skills: dart-three.\n' + 'Updated skills: dart-one.\n' + 'Removed skills: dart-two.\n' + 'Updated plugin listing details.', + ); + }); + + test('ignores pre-release tags when finding the previous release', () async { + await _git(['tag', 'codex-plugin-v1.0.8-rc1']); + await makeNextRelease(); + expect( + await findPreviousTag(Git(_repo.path), '1.0.8'), + 'codex-plugin-v1.0.7', + ); + }); + + test('describes every skill when there is no previous tag', () async { + await _git(['tag', '-d', 'codex-plugin-v1.0.7']); + final build = await buildZip(Git(_repo.path)); + final notes = jsonEncode(build.manifest['extensions']); + expect(notes, contains('2 skills (dart-one, dart-two)')); + }); + + test('fails when the tag does not match the version', () async { + expect( + () => buildZip(Git(_repo.path), tag: 'codex-plugin-v1.0.9'), + throwsA(isA()), + ); + }); + + test('leaves out untracked files', () async { + _write('skills/dart-one/scratch.md', 'not committed'); + final build = await buildZip(Git(_repo.path)); + final names = ZipDecoder().decodeBytes(build.bytes).map((f) => f.name); + expect(names, isNot(contains('skills/dart-one/scratch.md'))); + }); + + test('rebuilding the same commit produces an identical zip', () async { + final first = await buildZip(Git(_repo.path)); + final second = await buildZip(Git(_repo.path)); + expect(second.bytes, first.bytes); + }); +} From c81607a9d5f79fd30cdb3dcc36b28a3e4a4370ab Mon Sep 17 00:00:00 2001 From: Keerti Parthasarathy Date: Mon, 2 Nov 2026 09:17:58 -0800 Subject: [PATCH 2/4] Ignore dist/ release output --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 555a7794..6435685a 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ __pycache__/ state.json CLAUDE.md CLAUDE.local.md + +# Codex plugin release zips +dist/ From 5c736cf05403de403865d710d9932d0a100d7238 Mon Sep 17 00:00:00 2001 From: Keerti Parthasarathy Date: Mon, 2 Nov 2026 11:02:41 -0800 Subject: [PATCH 3/4] Use a UTC timestamp for zip entries --- tool/codex_release/lib/src/zip_builder.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tool/codex_release/lib/src/zip_builder.dart b/tool/codex_release/lib/src/zip_builder.dart index ce87702a..4bc993a4 100644 --- a/tool/codex_release/lib/src/zip_builder.dart +++ b/tool/codex_release/lib/src/zip_builder.dart @@ -12,7 +12,12 @@ import 'validator.dart' show manifestPath; /// A fixed timestamp for every zip entry, so rebuilding the same commit /// produces a byte-for-byte identical zip. -final _fixedTimestamp = DateTime(1980, 1, 1); +/// +/// The archive package also writes each entry's date and time converted to +/// the build machine's local time zone, so zips are identical only when built +/// in the same time zone. CI always builds in UTC. The date is a day after the +/// zip format's 1980 epoch so it never displays as 1979 in any time zone. +final _fixedTimestamp = DateTime.utc(1980, 1, 2); /// The interface fields that reference branding images. const _assetFields = ['composerIcon', 'composerIconDark', 'logo', 'logoDark']; From 34dc9f9dfe569d5024e5367b2358ac16ff34400e Mon Sep 17 00:00:00 2001 From: Keerti Parthasarathy Date: Mon, 2 Nov 2026 11:51:33 -0800 Subject: [PATCH 4/4] Handle a missing or invalid plugin.json without crashing Share one manifest reader between build and release that reports a missing file, invalid JSON, or a non-object as a BuildException, and parse the manifest once per release run. --- tool/codex_release/lib/src/release.dart | 27 ++++++++++--------- tool/codex_release/lib/src/zip_builder.dart | 15 ++++++++--- tool/codex_release/test/release_test.dart | 24 +++++++++++++++++ tool/codex_release/test/zip_builder_test.dart | 19 +++++++++++++ 4 files changed, 69 insertions(+), 16 deletions(-) diff --git a/tool/codex_release/lib/src/release.dart b/tool/codex_release/lib/src/release.dart index 9eeaed74..96da1475 100644 --- a/tool/codex_release/lib/src/release.dart +++ b/tool/codex_release/lib/src/release.dart @@ -91,7 +91,9 @@ class Release { await git(['fetch', remote, options.branch, '--tags']); await _checkCheckout(remote); - final version = _readVersion(); + final manifest = readRepoManifest(git.root); + final version = _versionOf(manifest); + final zipName = '${_nameOf(manifest)}-codex-$version.zip'; final tag = tagFor(version); await _checkTagIsNew(remote, tag); final sha = await git(['rev-parse', 'HEAD']); @@ -121,7 +123,6 @@ class Release { ); } - final zipName = '${_readName()}-codex-$version.zip'; final outputDirectory = p.join(git.root, options.outputDirectory); await _gh([ 'run', @@ -292,16 +293,8 @@ class Release { return (result.stdout as String).trim(); } - Map _readManifest() => - jsonDecode( - File( - p.join(git.root, '.codex-plugin', 'plugin.json'), - ).readAsStringSync(), - ) - as Map; - - String _readVersion() { - final version = _readManifest()['version']; + static String _versionOf(Map manifest) { + final version = manifest['version']; if (version is! String || ReleaseVersion.tryParse(version) == null) { throw ReleaseException( '.codex-plugin/plugin.json "version" must be a semantic version.', @@ -310,7 +303,15 @@ class Release { return version; } - String _readName() => _readManifest()['name'] as String; + static String _nameOf(Map manifest) { + final name = manifest['name']; + if (name is! String || name.isEmpty) { + throw ReleaseException( + '.codex-plugin/plugin.json "name" must be a non-empty string.', + ); + } + return name; + } void _reportFindings(List findings) { for (final finding in findings) { diff --git a/tool/codex_release/lib/src/zip_builder.dart b/tool/codex_release/lib/src/zip_builder.dart index 4bc993a4..930b5694 100644 --- a/tool/codex_release/lib/src/zip_builder.dart +++ b/tool/codex_release/lib/src/zip_builder.dart @@ -62,7 +62,7 @@ Future buildZip( String? releaseNotes, }) async { final root = git.root; - final repoManifest = _readRepoManifest(root); + final repoManifest = readRepoManifest(root); final version = repoManifest['version']; if (version is! String) { throw BuildException('$manifestPath has no string "version".'); @@ -113,12 +113,21 @@ Future buildZip( return BuildResult(_encode(files), manifest, version); } -Map _readRepoManifest(String root) { +/// Reads and parses the repo's `.codex-plugin/plugin.json` under [root]. +/// +/// Throws a [BuildException] if the file is missing, isn't valid JSON, or +/// doesn't contain a JSON object. +Map readRepoManifest(String root) { final file = File(p.join(root, '.codex-plugin', 'plugin.json')); if (!file.existsSync()) { throw BuildException('${file.path} does not exist.'); } - final json = jsonDecode(file.readAsStringSync()); + final Object? json; + try { + json = jsonDecode(file.readAsStringSync()); + } on FormatException catch (e) { + throw BuildException('$manifestPath is not valid JSON: ${e.message}'); + } if (json is! Map) { throw BuildException('$manifestPath must contain a JSON object.'); } diff --git a/tool/codex_release/test/release_test.dart b/tool/codex_release/test/release_test.dart index 77a66d3d..294523b5 100644 --- a/tool/codex_release/test/release_test.dart +++ b/tool/codex_release/test/release_test.dart @@ -128,6 +128,16 @@ void main() { }); group('stops before tagging when', () { + Future expectStopWith(String message) async { + final runner = FakeRunner(_healthyResponses()); + await expectLater( + _release(runner), + throwsA(isA().having((e) => '$e', 'message', contains(message))), + ); + expect(runner.commands, isNot(contains(startsWith('git tag')))); + expect(runner.commands, isNot(contains(startsWith('git push')))); + } + Future expectStop( Map overrides, String message, @@ -177,6 +187,20 @@ void main() { }, 'No git remote'); }); + test('plugin.json is not valid JSON', () { + File( + p.join(_repo.path, '.codex-plugin', 'plugin.json'), + ).writeAsStringSync('{"name": '); + return expectStopWith('not valid JSON'); + }); + + test('plugin.json has no name', () { + File( + p.join(_repo.path, '.codex-plugin', 'plugin.json'), + ).writeAsStringSync(jsonEncode({'version': '1.0.8'})); + return expectStop({}, '"name" must be a non-empty string'); + }); + test('gh is not signed in', () { return expectStop({'gh auth status': 1}, 'gh auth login'); }); diff --git a/tool/codex_release/test/zip_builder_test.dart b/tool/codex_release/test/zip_builder_test.dart index 8b5bb28c..32e550ec 100644 --- a/tool/codex_release/test/zip_builder_test.dart +++ b/tool/codex_release/test/zip_builder_test.dart @@ -125,6 +125,25 @@ void main() { ); }); + test('fails cleanly when plugin.json is not valid JSON', () async { + _write('.codex-plugin/plugin.json', '{"name": '); + expect( + () => buildZip(Git(_repo.path)), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('not valid JSON'), + ), + ), + ); + }); + + test('fails cleanly when plugin.json is missing', () async { + File(p.join(_repo.path, '.codex-plugin', 'plugin.json')).deleteSync(); + expect(() => buildZip(Git(_repo.path)), throwsA(isA())); + }); + test('leaves out untracked files', () async { _write('skills/dart-one/scratch.md', 'not committed'); final build = await buildZip(Git(_repo.path));