From d07eec493d979450fa87fe59dd3c8efdfe74f438 Mon Sep 17 00:00:00 2001 From: keertip Date: Tue, 3 Nov 2026 16:50:20 +0000 Subject: [PATCH 1/4] Add slim Codex plugin release script and workflow --- .github/workflows/release_codex_plugin.yaml | 78 +++ .gitignore | 3 + tool/build_codex_plugin.dart | 615 ++++++++++++++++++++ 3 files changed, 696 insertions(+) create mode 100644 .github/workflows/release_codex_plugin.yaml create mode 100644 tool/build_codex_plugin.dart diff --git a/.github/workflows/release_codex_plugin.yaml b/.github/workflows/release_codex_plugin.yaml new file mode 100644 index 00000000..2a2f34b9 --- /dev/null +++ b/.github/workflows/release_codex_plugin.yaml @@ -0,0 +1,78 @@ +name: release_codex_plugin + +# Builds the Codex plugin zip for the OpenAI plugin submission portal. +# - On a codex-plugin-v* tag: builds the zip from the tagged commit, runs sanity +# checks, and uploads it as a workflow artifact. +# - On pull requests: builds and checks 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/build_codex_plugin.dart' + - '.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 tag for notes. + fetch-depth: 0 + persist-credentials: false + + - uses: dart-lang/setup-dart@6afc89df92d6eb3834022f73cd65adc8cdfcb92d # v1.8.1 + with: + sdk: stable + + - name: Check formatting and analysis + run: | + dart format --output=none --set-exit-if-changed tool/build_codex_plugin.dart + dart analyze --fatal-infos tool/build_codex_plugin.dart + + - name: Build and verify zip + id: build + env: + TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }} + run: dart --no-pub tool/build_codex_plugin.dart ${TAG:+--tag="$TAG"} + + - 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 + 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/.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/ diff --git a/tool/build_codex_plugin.dart b/tool/build_codex_plugin.dart new file mode 100644 index 00000000..72caec49 --- /dev/null +++ b/tool/build_codex_plugin.dart @@ -0,0 +1,615 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io'; + +const String manifestPath = '.codex-plugin/plugin.json'; +const String tagPrefix = 'codex-plugin-v'; +const String releaseWorkflow = 'release_codex_plugin.yaml'; +const String defaultRepo = 'flutter/agent-plugins'; + +/// `description` for the skills-only release, without the MCP server. +const String 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 String releaseLongDescription = + 'Use official Dart and Flutter skills to develop apps, run workflows, and ' + 'manage code from Codex.'; + +const List _assetFields = [ + 'composerIcon', + 'composerIconDark', + 'logo', + 'logoDark', +]; + +final RegExp _semverPattern = RegExp(r'^(\d+)\.(\d+)\.(\d+)(-[^+]+)?(\+.*)?$'); + +/// Builds and optionally releases the Codex plugin zip for the OpenAI portal. +/// +/// Usage: +/// dart run tool/build_codex_plugin.dart [--tag=] [--output-dir=dist] +/// dart run tool/build_codex_plugin.dart --release [--dry-run] +Future main(List args) async { + var release = false; + var dryRun = false; + var outputDir = 'dist'; + var repo = defaultRepo; + var branch = 'main'; + String? tag; + String? remote; + String? releaseNotes; + + for (var i = 0; i < args.length; i++) { + final arg = args[i]; + String nextValue() { + if (i + 1 >= args.length) _fail('Missing value for $arg'); + return args[++i]; + } + + if (arg == '--help' || arg == '-h') { + _printUsage(); + return; + } else if (arg == '--release') { + release = true; + } else if (arg == '--dry-run') { + dryRun = true; + } else if (arg == '--tag') { + tag = nextValue(); + } else if (arg.startsWith('--tag=')) { + tag = arg.substring('--tag='.length); + } else if (arg == '--output-dir') { + outputDir = nextValue(); + } else if (arg.startsWith('--output-dir=')) { + outputDir = arg.substring('--output-dir='.length); + } else if (arg == '--release-notes') { + releaseNotes = nextValue(); + } else if (arg.startsWith('--release-notes=')) { + releaseNotes = arg.substring('--release-notes='.length); + } else if (arg == '--repo') { + repo = nextValue(); + } else if (arg.startsWith('--repo=')) { + repo = arg.substring('--repo='.length); + } else if (arg == '--remote') { + remote = nextValue(); + } else if (arg.startsWith('--remote=')) { + remote = arg.substring('--remote='.length); + } else if (arg == '--branch') { + branch = nextValue(); + } else if (arg.startsWith('--branch=')) { + branch = arg.substring('--branch='.length); + } else { + _fail('Unknown option: $arg\nRun with --help for usage.'); + } + } + + final root = _git(['rev-parse', '--show-toplevel']); + if (release) { + await _runRelease( + root: root, + repo: repo, + remote: remote, + branch: branch, + outputDir: outputDir, + dryRun: dryRun, + ); + } else { + _buildAndVerify( + root: root, + outputDir: outputDir, + tag: tag, + releaseNotesOverride: releaseNotes, + ); + } +} + +void _printUsage() { + stdout.writeln(''' +Builds and optionally releases the Dart and Flutter Codex plugin zip. + +Usage: + dart run tool/build_codex_plugin.dart [options] + +Options: + --tag= Verify the manifest version matches . + --output-dir= Directory to write the zip (default: dist). + --release-notes= Override the generated release notes. + --release Cut a release: check main, push tag, wait for CI, + and download the built artifact to --output-dir. + --dry-run With --release, run all preflight checks and build + locally without creating or pushing a tag. + --repo= GitHub repository for --release (default: $defaultRepo). + --remote= Git remote for --repo (auto-detected by default). + --branch= Release branch for --release (default: main). + -h, --help Show this usage information.'''); +} + +/// Builds the release zip from [root], verifies its contents, and writes it to +/// [outputDir]. Returns the path to the written zip file. +String _buildAndVerify({ + required String root, + required String outputDir, + String? tag, + String? releaseNotesOverride, +}) { + final repoManifest = _readManifest(root); + final name = repoManifest['name']; + final version = repoManifest['version']; + if (name is! String || name.isEmpty) { + _fail('$manifestPath "name" must be a non-empty string.'); + } + if (version is! String || !_semverPattern.hasMatch(version)) { + _fail('$manifestPath "version" must be a semantic version.'); + } + final expectedTag = '$tagPrefix$version'; + if (tag != null && tag.isNotEmpty && tag != expectedTag) { + _fail( + 'Tag $tag does not match $manifestPath version $version ' + '(expected $expectedTag).', + ); + } + + final notes = + releaseNotesOverride ?? _generateReleaseNotes(root, version: version); + final manifest = _createReleaseManifest(repoManifest, releaseNotes: notes); + final assetPaths = _collectAssetPaths(root, manifest); + final skillFiles = _gitLines(root, ['ls-files', '--', 'skills']); + if (skillFiles.isEmpty) { + _fail('No git-tracked files found under skills/.'); + } + + _verifyReleaseInputs(manifest: manifest, skillFiles: skillFiles); + + final outDir = Directory('$root/$outputDir')..createSync(recursive: true); + final zipName = '$name-codex-$version.zip'; + final zipPath = '${outDir.path}/$zipName'; + final existingZip = File(zipPath); + if (existingZip.existsSync()) { + existingZip.deleteSync(); + } + + final stagingDir = Directory.systemTemp.createTempSync('codex_plugin_'); + try { + final stagedManifest = File('${stagingDir.path}/$manifestPath') + ..parent.createSync(recursive: true); + stagedManifest.writeAsStringSync( + '${const JsonEncoder.withIndent(' ').convert(manifest)}\n', + ); + + for (final relPath in [...assetPaths, ...skillFiles]) { + final dest = File('${stagingDir.path}/$relPath') + ..parent.createSync(recursive: true); + File('$root/$relPath').copySync(dest.path); + } + + final zipResult = Process.runSync('zip', [ + '-r', + '-q', + '-X', + zipPath, + '.codex-plugin', + 'assets', + 'skills', + ], workingDirectory: stagingDir.path); + if (zipResult.exitCode != 0) { + _fail('zip failed: ${zipResult.stderr}'); + } + } finally { + stagingDir.deleteSync(recursive: true); + } + + final size = File(zipPath).lengthSync(); + stdout + ..writeln('Built $zipPath ($size bytes)') + ..writeln('Release notes:\n$notes'); + _writeGitHubOutput({ + 'zip_path': zipPath, + 'zip_name': zipName, + 'version': version, + }); + return zipPath; +} + +/// Creates a skills-only copy of [repoManifest] with MCP references removed +/// and `extensions.com.openai.publication.release_notes` set to [releaseNotes]. +Map _createReleaseManifest( + Map repoManifest, { + required String releaseNotes, +}) { + 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; +} + +List _collectAssetPaths(String root, Map manifest) { + final paths = {}; + final interface = manifest['interface']; + if (interface is Map) { + for (final field in _assetFields) { + final value = interface[field]; + if (value is! String || value.isEmpty) continue; + if (!value.startsWith('./')) { + _fail('interface.$field must start with "./" (got "$value").'); + } + final relPath = value.substring(2); + if (!File('$root/$relPath').existsSync()) { + _fail('interface.$field references $value, which does not exist.'); + } + paths.add(relPath); + } + } + if (paths.isEmpty) { + _fail('Manifest must reference at least one existing branding asset.'); + } + return paths.toList()..sort(); +} + +/// Essential sanity checks on the release manifest and skill layout. +void _verifyReleaseInputs({ + required Map manifest, + required List skillFiles, +}) { + if (manifest.containsKey('mcpServers')) { + _fail('Release manifest must not contain mcpServers.'); + } + final encoded = jsonEncode(manifest); + if (RegExp(r'\bmcp\b', caseSensitive: false).hasMatch(encoded)) { + _fail('Release manifest still contains an MCP reference.'); + } + + final interface = manifest['interface']; + if (interface is! Map) { + _fail('Manifest is missing "interface".'); + } + final shortDescription = interface['shortDescription']; + if (shortDescription is! String || + shortDescription.isEmpty || + shortDescription.length > 30) { + _fail('interface.shortDescription must be 1–30 characters.'); + } + + final skillDirs = {}; + for (final path in skillFiles) { + final parts = path.split('/'); + if (parts.length < 2) { + _fail('Unexpected file directly under skills/: $path'); + } + skillDirs.add(parts[1]); + } + for (final skill in skillDirs) { + if (!skillFiles.contains('skills/$skill/SKILL.md')) { + _fail('Skill directory skills/$skill is missing SKILL.md.'); + } + } +} + +/// Generates release notes summarizing skill and manifest changes since the +/// previous `codex-plugin-v*` release tag. +String _generateReleaseNotes(String root, {required String version}) { + final currentSkills = _skillDirsAtRef(root, 'HEAD'); + final previousTag = _findPreviousTag(root, version); + if (previousTag == null) { + return 'Dart and Flutter skills for Codex: ${currentSkills.length} skills ' + '(${currentSkills.join(', ')}).'; + } + + final previousSkills = _skillDirsAtRef(root, previousTag); + final changedPaths = _gitLines(root, [ + 'diff', + '--name-only', + previousTag, + 'HEAD', + '--', + 'skills', + '.codex-plugin', + 'assets', + ]); + + final changedSkills = { + for (final path in changedPaths) + if (path.startsWith('skills/') && path.split('/').length > 2) + path.split('/')[1], + }; + final added = currentSkills + .where((s) => !previousSkills.contains(s)) + .toList(); + final removed = previousSkills + .where((s) => !currentSkills.contains(s)) + .toList(); + final updated = currentSkills + .where((s) => previousSkills.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'); +} + +String? _findPreviousTag(String root, String currentVersion) { + final current = _parseSemver(currentVersion); + if (current == null) return null; + final tags = _gitLines(root, [ + 'tag', + '--list', + '$tagPrefix*', + '--merged', + 'HEAD', + ]); + String? bestTag; + List? bestVersion; + for (final tag in tags) { + if (!tag.startsWith(tagPrefix)) continue; + final verText = tag.substring(tagPrefix.length); + final match = _semverPattern.firstMatch(verText); + if (match == null || match[4] != null) continue; // Skip pre-release tags. + final candidate = [ + int.parse(match[1]!), + int.parse(match[2]!), + int.parse(match[3]!), + ]; + if (_compareSemver(candidate, current) >= 0) continue; + if (bestVersion == null || _compareSemver(candidate, bestVersion) > 0) { + bestTag = tag; + bestVersion = candidate; + } + } + return bestTag; +} + +List? _parseSemver(String version) { + final match = _semverPattern.firstMatch(version); + if (match == null) return null; + return [int.parse(match[1]!), int.parse(match[2]!), int.parse(match[3]!)]; +} + +int _compareSemver(List a, List b) { + for (var i = 0; i < 3; i++) { + if (a[i] != b[i]) return a[i].compareTo(b[i]); + } + return 0; +} + +List _skillDirsAtRef(String root, String ref) { + final lines = _gitLines(root, [ + 'ls-tree', + '-d', + '--name-only', + ref, + 'skills/', + ]); + return lines + .where((p) => p.startsWith('skills/')) + .map((p) => p.substring('skills/'.length)) + .toList() + ..sort(); +} + +/// Runs the maintainer release flow: checks `main`, creates and pushes the +/// release tag, waits for the GitHub Actions workflow, and downloads the zip. +Future _runRelease({ + required String root, + required String repo, + required String? remote, + required String branch, + required String outputDir, + required bool dryRun, +}) async { + final ghCheck = Process.runSync('gh', ['auth', 'status']); + if (ghCheck.exitCode != 0) { + _fail('GitHub CLI (gh) is not authenticated. Run `gh auth login`.'); + } + + final resolvedRemote = remote ?? _findRemote(root, repo); + stdout.writeln('Using remote "$resolvedRemote" for $repo.'); + _git(['fetch', resolvedRemote, branch, '--tags'], root: root); + + final status = _git([ + 'status', + '--porcelain', + '--untracked-files=no', + ], root: root); + if (status.isNotEmpty) { + _fail('Working tree has uncommitted changes:\n$status'); + } + final currentBranch = _git(['rev-parse', '--abbrev-ref', 'HEAD'], root: root); + if (currentBranch != branch) { + _fail( + 'Releases are cut from $branch, but current branch is $currentBranch.', + ); + } + final head = _git(['rev-parse', 'HEAD'], root: root); + final upstream = _git(['rev-parse', '$resolvedRemote/$branch'], root: root); + if (head != upstream) { + _fail( + '$branch is not up to date with $resolvedRemote/$branch. ' + 'Run `git pull $resolvedRemote $branch`.', + ); + } + + final manifest = _readManifest(root); + final version = manifest['version'] as String; + final name = manifest['name'] as String; + final tag = '$tagPrefix$version'; + final zipName = '$name-codex-$version.zip'; + + final localTag = Process.runSync('git', [ + 'rev-parse', + '--quiet', + '--verify', + 'refs/tags/$tag', + ], workingDirectory: root); + final remoteTag = _git([ + 'ls-remote', + '--tags', + resolvedRemote, + 'refs/tags/$tag', + ], root: root); + if (localTag.exitCode == 0 || remoteTag.isNotEmpty) { + _fail( + 'Tag $tag already exists. Bump "version" in $manifestPath in a PR first.', + ); + } + + if (dryRun) { + stdout.writeln('Dry run: building and verifying locally without tagging.'); + _buildAndVerify(root: root, outputDir: outputDir, tag: tag); + return; + } + + _git(['tag', '-a', tag, '-m', 'Codex plugin $version'], root: root); + _git(['push', resolvedRemote, 'refs/tags/$tag'], root: root); + stdout.writeln('Pushed tag $tag ($head). Waiting for workflow run...'); + + final run = await _waitForRun(root: root, repo: repo, sha: head, tag: tag); + stdout.writeln('Watching ${run.url}'); + final watchProcess = await Process.start( + 'gh', + ['run', 'watch', '${run.id}', '--repo', repo, '--exit-status'], + workingDirectory: root, + mode: ProcessStartMode.inheritStdio, + ); + if (await watchProcess.exitCode != 0) { + _fail('Release workflow failed: ${run.url}'); + } + + final destDir = '$root/$outputDir'; + final dl = Process.runSync('gh', [ + 'run', + 'download', + '${run.id}', + '--repo', + repo, + '--name', + zipName, + '--dir', + destDir, + ], workingDirectory: root); + if (dl.exitCode != 0) { + _fail('gh run download failed: ${dl.stderr}'); + } + stdout.writeln('\nDownloaded $destDir/$zipName'); +} + +Future<({int id, String url})> _waitForRun({ + required String root, + required String repo, + required String sha, + required String tag, +}) async { + final deadline = DateTime.now().add(const Duration(minutes: 2)); + while (DateTime.now().isBefore(deadline)) { + final result = Process.runSync('gh', [ + 'run', + 'list', + '--repo', + repo, + '--workflow', + releaseWorkflow, + '--commit', + sha, + '--event', + 'push', + '--json', + 'databaseId,url,headBranch', + ], workingDirectory: root); + if (result.exitCode == 0) { + final runs = (jsonDecode(result.stdout as String) as List) + .cast>(); + for (final run in runs) { + if (run['headBranch'] == tag) { + return (id: run['databaseId'] as int, url: run['url'] as String); + } + } + } + await Future.delayed(const Duration(seconds: 5)); + } + _fail('Timed out waiting for $releaseWorkflow run for $tag.'); +} + +String _findRemote(String root, String repo) { + for (final line in _gitLines(root, ['remote', '-v'])) { + final parts = line.split(RegExp(r'\s+')); + if (parts.length < 2) continue; + final url = parts[1].replaceFirst(RegExp(r'\.git$'), ''); + if (url.endsWith('/$repo') || url.endsWith(':$repo')) { + return parts[0]; + } + } + _fail('No git remote points at $repo. Pass --remote=.'); +} + +Map _readManifest(String root) { + final file = File('$root/$manifestPath'); + if (!file.existsSync()) { + _fail('$manifestPath does not exist.'); + } + try { + final decoded = jsonDecode(file.readAsStringSync()); + if (decoded is Map) return decoded; + } on FormatException catch (e) { + _fail('$manifestPath is not valid JSON: ${e.message}'); + } + _fail('$manifestPath must contain a JSON object.'); +} + +String _git(List args, {String? root}) { + final result = Process.runSync('git', args, workingDirectory: root); + if (result.exitCode != 0) { + _fail('git ${args.join(' ')} failed: ${(result.stderr as String).trim()}'); + } + return (result.stdout as String).trim(); +} + +List _gitLines(String root, List args) => + _git(args, root: root).split('\n').where((l) => l.isNotEmpty).toList(); + +void _writeGitHubOutput(Map outputs) { + final path = Platform.environment['GITHUB_OUTPUT']; + if (path == null || path.isEmpty) return; + File(path).writeAsStringSync( + outputs.entries.map((e) => '${e.key}=${e.value}\n').join(), + mode: FileMode.append, + ); +} + +Never _fail(String message) { + stderr.writeln('Error: $message'); + exit(1); +} From eebc1bf02a8484626d2e6ff31f84004ea9c53c8c Mon Sep 17 00:00:00 2001 From: keertip Date: Tue, 3 Nov 2026 17:56:30 +0000 Subject: [PATCH 2/4] Remove --repo, --remote, and --branch flags from build_codex_plugin.dart --- .github/workflows/release_codex_plugin.yaml | 2 +- tool/build_codex_plugin.dart | 110 +++++++++----------- 2 files changed, 50 insertions(+), 62 deletions(-) diff --git a/.github/workflows/release_codex_plugin.yaml b/.github/workflows/release_codex_plugin.yaml index 2a2f34b9..36e05956 100644 --- a/.github/workflows/release_codex_plugin.yaml +++ b/.github/workflows/release_codex_plugin.yaml @@ -43,7 +43,7 @@ jobs: id: build env: TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }} - run: dart --no-pub tool/build_codex_plugin.dart ${TAG:+--tag="$TAG"} + run: dart tool/build_codex_plugin.dart ${TAG:+--tag="$TAG"} - name: Upload zip if: github.ref_type == 'tag' diff --git a/tool/build_codex_plugin.dart b/tool/build_codex_plugin.dart index 72caec49..259ecdd8 100644 --- a/tool/build_codex_plugin.dart +++ b/tool/build_codex_plugin.dart @@ -8,7 +8,7 @@ import 'dart:io'; const String manifestPath = '.codex-plugin/plugin.json'; const String tagPrefix = 'codex-plugin-v'; const String releaseWorkflow = 'release_codex_plugin.yaml'; -const String defaultRepo = 'flutter/agent-plugins'; +const String releaseRepo = 'flutter/agent-plugins'; /// `description` for the skills-only release, without the MCP server. const String releaseDescription = @@ -33,16 +33,13 @@ final RegExp _semverPattern = RegExp(r'^(\d+)\.(\d+)\.(\d+)(-[^+]+)?(\+.*)?$'); /// Builds and optionally releases the Codex plugin zip for the OpenAI portal. /// /// Usage: -/// dart run tool/build_codex_plugin.dart [--tag=] [--output-dir=dist] -/// dart run tool/build_codex_plugin.dart --release [--dry-run] +/// dart tool/build_codex_plugin.dart [--tag=] [--output-dir=dist] +/// dart tool/build_codex_plugin.dart --release [--dry-run] Future main(List args) async { var release = false; var dryRun = false; var outputDir = 'dist'; - var repo = defaultRepo; - var branch = 'main'; String? tag; - String? remote; String? releaseNotes; for (var i = 0; i < args.length; i++) { @@ -71,18 +68,6 @@ Future main(List args) async { releaseNotes = nextValue(); } else if (arg.startsWith('--release-notes=')) { releaseNotes = arg.substring('--release-notes='.length); - } else if (arg == '--repo') { - repo = nextValue(); - } else if (arg.startsWith('--repo=')) { - repo = arg.substring('--repo='.length); - } else if (arg == '--remote') { - remote = nextValue(); - } else if (arg.startsWith('--remote=')) { - remote = arg.substring('--remote='.length); - } else if (arg == '--branch') { - branch = nextValue(); - } else if (arg.startsWith('--branch=')) { - branch = arg.substring('--branch='.length); } else { _fail('Unknown option: $arg\nRun with --help for usage.'); } @@ -90,14 +75,7 @@ Future main(List args) async { final root = _git(['rev-parse', '--show-toplevel']); if (release) { - await _runRelease( - root: root, - repo: repo, - remote: remote, - branch: branch, - outputDir: outputDir, - dryRun: dryRun, - ); + await _runRelease(root: root, outputDir: outputDir, dryRun: dryRun); } else { _buildAndVerify( root: root, @@ -113,7 +91,7 @@ void _printUsage() { Builds and optionally releases the Dart and Flutter Codex plugin zip. Usage: - dart run tool/build_codex_plugin.dart [options] + dart tool/build_codex_plugin.dart [options] Options: --tag= Verify the manifest version matches . @@ -123,9 +101,6 @@ Options: and download the built artifact to --output-dir. --dry-run With --release, run all preflight checks and build locally without creating or pushing a tag. - --repo= GitHub repository for --release (default: $defaultRepo). - --remote= Git remote for --repo (auto-detected by default). - --branch= Release branch for --release (default: main). -h, --help Show this usage information.'''); } @@ -425,9 +400,6 @@ List _skillDirsAtRef(String root, String ref) { /// release tag, waits for the GitHub Actions workflow, and downloads the zip. Future _runRelease({ required String root, - required String repo, - required String? remote, - required String branch, required String outputDir, required bool dryRun, }) async { @@ -436,9 +408,9 @@ Future _runRelease({ _fail('GitHub CLI (gh) is not authenticated. Run `gh auth login`.'); } - final resolvedRemote = remote ?? _findRemote(root, repo); - stdout.writeln('Using remote "$resolvedRemote" for $repo.'); - _git(['fetch', resolvedRemote, branch, '--tags'], root: root); + final target = _resolveTargetRemote(root); + stdout.writeln('Using remote "${target.remote}" (${target.repo}).'); + _git(['fetch', target.remote, 'main', '--tags'], root: root); final status = _git([ 'status', @@ -449,17 +421,15 @@ Future _runRelease({ _fail('Working tree has uncommitted changes:\n$status'); } final currentBranch = _git(['rev-parse', '--abbrev-ref', 'HEAD'], root: root); - if (currentBranch != branch) { - _fail( - 'Releases are cut from $branch, but current branch is $currentBranch.', - ); + if (!dryRun && currentBranch != 'main') { + _fail('Releases are cut from main, but current branch is $currentBranch.'); } final head = _git(['rev-parse', 'HEAD'], root: root); - final upstream = _git(['rev-parse', '$resolvedRemote/$branch'], root: root); - if (head != upstream) { + final upstream = _git(['rev-parse', '${target.remote}/main'], root: root); + if (!dryRun && head != upstream) { _fail( - '$branch is not up to date with $resolvedRemote/$branch. ' - 'Run `git pull $resolvedRemote $branch`.', + 'main is not up to date with ${target.remote}/main. ' + 'Run `git pull ${target.remote} main`.', ); } @@ -478,7 +448,7 @@ Future _runRelease({ final remoteTag = _git([ 'ls-remote', '--tags', - resolvedRemote, + target.remote, 'refs/tags/$tag', ], root: root); if (localTag.exitCode == 0 || remoteTag.isNotEmpty) { @@ -494,14 +464,19 @@ Future _runRelease({ } _git(['tag', '-a', tag, '-m', 'Codex plugin $version'], root: root); - _git(['push', resolvedRemote, 'refs/tags/$tag'], root: root); + _git(['push', target.remote, 'refs/tags/$tag'], root: root); stdout.writeln('Pushed tag $tag ($head). Waiting for workflow run...'); - final run = await _waitForRun(root: root, repo: repo, sha: head, tag: tag); + final run = await _waitForRun( + root: root, + repo: target.repo, + sha: head, + tag: tag, + ); stdout.writeln('Watching ${run.url}'); final watchProcess = await Process.start( 'gh', - ['run', 'watch', '${run.id}', '--repo', repo, '--exit-status'], + ['run', 'watch', '${run.id}', '--repo', target.repo, '--exit-status'], workingDirectory: root, mode: ProcessStartMode.inheritStdio, ); @@ -515,7 +490,7 @@ Future _runRelease({ 'download', '${run.id}', '--repo', - repo, + target.repo, '--name', zipName, '--dir', @@ -527,6 +502,31 @@ Future _runRelease({ stdout.writeln('\nDownloaded $destDir/$zipName'); } +/// Finds the git remote pointing to `flutter/agent-plugins`, falling back to +/// `origin` (for fork testing). +({String remote, String repo}) _resolveTargetRemote(String root) { + final remotes = {}; + for (final line in _gitLines(root, ['remote', '-v'])) { + final parts = line.split(RegExp(r'\s+')); + if (parts.length < 2) continue; + final match = RegExp( + r'github\.com[:/]([^/\s]+/[^/\s]+?)(?:\.git)?$', + ).firstMatch(parts[1]); + if (match != null) { + remotes[parts[0]] = match[1]!; + } + } + for (final entry in remotes.entries) { + if (entry.value == releaseRepo) { + return (remote: entry.key, repo: entry.value); + } + } + if (remotes.containsKey('origin')) { + return (remote: 'origin', repo: remotes['origin']!); + } + _fail('No GitHub remote found in git remotes.'); +} + Future<({int id, String url})> _waitForRun({ required String root, required String repo, @@ -563,18 +563,6 @@ Future<({int id, String url})> _waitForRun({ _fail('Timed out waiting for $releaseWorkflow run for $tag.'); } -String _findRemote(String root, String repo) { - for (final line in _gitLines(root, ['remote', '-v'])) { - final parts = line.split(RegExp(r'\s+')); - if (parts.length < 2) continue; - final url = parts[1].replaceFirst(RegExp(r'\.git$'), ''); - if (url.endsWith('/$repo') || url.endsWith(':$repo')) { - return parts[0]; - } - } - _fail('No git remote points at $repo. Pass --remote=.'); -} - Map _readManifest(String root) { final file = File('$root/$manifestPath'); if (!file.existsSync()) { From d7b8d5d88910f1f32ec0757fea4d82366544cfd9 Mon Sep 17 00:00:00 2001 From: Keerti Parthasarathy Date: Tue, 3 Nov 2026 11:20:48 -0800 Subject: [PATCH 3/4] Add try catch for process run --- tool/build_codex_plugin.dart | 75 ++++++++++++++++++++++++++++++------ 1 file changed, 63 insertions(+), 12 deletions(-) diff --git a/tool/build_codex_plugin.dart b/tool/build_codex_plugin.dart index 259ecdd8..3c61f9b6 100644 --- a/tool/build_codex_plugin.dart +++ b/tool/build_codex_plugin.dart @@ -162,7 +162,7 @@ String _buildAndVerify({ File('$root/$relPath').copySync(dest.path); } - final zipResult = Process.runSync('zip', [ + final zipResult = _runSync('zip', [ '-r', '-q', '-X', @@ -403,7 +403,7 @@ Future _runRelease({ required String outputDir, required bool dryRun, }) async { - final ghCheck = Process.runSync('gh', ['auth', 'status']); + final ghCheck = _runSync('gh', ['auth', 'status']); if (ghCheck.exitCode != 0) { _fail('GitHub CLI (gh) is not authenticated. Run `gh auth login`.'); } @@ -439,7 +439,7 @@ Future _runRelease({ final tag = '$tagPrefix$version'; final zipName = '$name-codex-$version.zip'; - final localTag = Process.runSync('git', [ + final localTag = _runSync('git', [ 'rev-parse', '--quiet', '--verify', @@ -474,18 +474,23 @@ Future _runRelease({ tag: tag, ); stdout.writeln('Watching ${run.url}'); - final watchProcess = await Process.start( - 'gh', - ['run', 'watch', '${run.id}', '--repo', target.repo, '--exit-status'], - workingDirectory: root, - mode: ProcessStartMode.inheritStdio, - ); + final Process watchProcess; + try { + watchProcess = await Process.start( + 'gh', + ['run', 'watch', '${run.id}', '--repo', target.repo, '--exit-status'], + workingDirectory: root, + mode: ProcessStartMode.inheritStdio, + ); + } on ProcessException catch (e) { + _failMissingTool('gh', e); + } if (await watchProcess.exitCode != 0) { _fail('Release workflow failed: ${run.url}'); } final destDir = '$root/$outputDir'; - final dl = Process.runSync('gh', [ + final dl = _runSync('gh', [ 'run', 'download', '${run.id}', @@ -535,7 +540,7 @@ Future<({int id, String url})> _waitForRun({ }) async { final deadline = DateTime.now().add(const Duration(minutes: 2)); while (DateTime.now().isBefore(deadline)) { - final result = Process.runSync('gh', [ + final result = await _run('gh', [ 'run', 'list', '--repo', @@ -578,7 +583,7 @@ Map _readManifest(String root) { } String _git(List args, {String? root}) { - final result = Process.runSync('git', args, workingDirectory: root); + final result = _runSync('git', args, workingDirectory: root); if (result.exitCode != 0) { _fail('git ${args.join(' ')} failed: ${(result.stderr as String).trim()}'); } @@ -597,6 +602,52 @@ void _writeGitHubOutput(Map outputs) { ); } +/// Runs [executable] synchronously, failing with a helpful message if it is +/// not installed or not on `PATH`. +ProcessResult _runSync( + String executable, + List args, { + String? workingDirectory, +}) { + try { + return Process.runSync( + executable, + args, + workingDirectory: workingDirectory, + ); + } on ProcessException catch (e) { + _failMissingTool(executable, e); + } +} + +/// Async variant of [_runSync] for use in polling loops so the event loop is +/// not blocked. +Future _run( + String executable, + List args, { + String? workingDirectory, +}) async { + try { + return await Process.run( + executable, + args, + workingDirectory: workingDirectory, + ); + } on ProcessException catch (e) { + _failMissingTool(executable, e); + } +} + +const _toolNames = {'gh': 'GitHub CLI (gh)', 'git': 'Git', 'zip': 'zip'}; + +Never _failMissingTool(String executable, ProcessException e) { + final name = _toolNames[executable] ?? executable; + _fail( + 'Failed to run "$executable". Please ensure $name is installed and ' + 'available in your PATH.\nDetails: ${e.message}', + ); +} + Never _fail(String message) { stderr.writeln('Error: $message'); exit(1); From cec2e40dd5157995bc6a17c6238e90cc39d1c182 Mon Sep 17 00:00:00 2001 From: Keerti Parthasarathy Date: Wed, 4 Nov 2026 11:18:58 -0800 Subject: [PATCH 4/4] Run dart pub get before format check in Codex release workflow --- .github/workflows/release_codex_plugin.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/release_codex_plugin.yaml b/.github/workflows/release_codex_plugin.yaml index 36e05956..c5d055de 100644 --- a/.github/workflows/release_codex_plugin.yaml +++ b/.github/workflows/release_codex_plugin.yaml @@ -34,6 +34,11 @@ jobs: with: sdk: stable + # Resolve the workspace so `dart format` uses the language version from + # pubspec.yaml, matching local formatting. + - name: Install dependencies + run: dart pub get + - name: Check formatting and analysis run: | dart format --output=none --set-exit-if-changed tool/build_codex_plugin.dart