From 3434a3bd3b89599d614189c15ef132de3310e42d Mon Sep 17 00:00:00 2001 From: Arenukvern Date: Mon, 29 Jun 2026 16:00:49 +0300 Subject: [PATCH] chore: automate release train metadata sync --- .github/workflows/ci.yml | 3 + .github/workflows/release-pr-sync-train.yml | 44 +++ justfile | 12 + tool/intentcall/bin/intentcall.dart | 37 ++ tool/intentcall/bin/release_train.dart | 343 ++++++++++++++++++ .../test/publish_preflight_test.dart | 151 ++++++++ 6 files changed, 590 insertions(+) create mode 100644 .github/workflows/release-pr-sync-train.yml create mode 100644 tool/intentcall/bin/release_train.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c14d619..d762945 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,9 @@ jobs: channel: stable cache: true + - name: release train metadata check + run: dart tool/intentcall/bin/release_train.dart check + - name: dart pub get run: dart pub get diff --git a/.github/workflows/release-pr-sync-train.yml b/.github/workflows/release-pr-sync-train.yml new file mode 100644 index 0000000..ca822e3 --- /dev/null +++ b/.github/workflows/release-pr-sync-train.yml @@ -0,0 +1,44 @@ +name: Release PR Sync Train + +on: + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + +permissions: + contents: write + pull-requests: write + +jobs: + sync-release-train: + if: github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'release-please--branches--main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.head_ref }} + token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + + - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 + with: + channel: stable + cache: true + + - name: sync release train metadata + run: dart tool/intentcall/bin/release_train.dart sync + + - name: commit release train metadata + run: | + if git diff --quiet; then + echo "Release train metadata already synchronized." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add packages/*/pubspec.yaml packages/intentcall_platform/ios/intentcall_platform.podspec packages/intentcall_platform/macos/intentcall_platform.podspec + git commit -m "chore: sync release train metadata" + git push origin HEAD:${{ github.head_ref }} diff --git a/justfile b/justfile index 5391f60..0761195 100644 --- a/justfile +++ b/justfile @@ -45,6 +45,18 @@ publish-tag-execute tag: check-path-deps: dart run tool/intentcall/bin/intentcall.dart check-path-deps +# Check release train metadata without requiring pub resolution +check-release-train: + dart tool/intentcall/bin/release_train.dart check + +# Synchronize release train versions, internal floors, and native podspecs +sync-release-train: + dart run tool/intentcall/bin/intentcall.dart sync-release-train + +# Synchronize release train metadata to a specific version +sync-release-train-version version: + dart run tool/intentcall/bin/intentcall.dart sync-release-train --version {{version}} + # Print hosted dependencies block for the synchronized package train print-hosted-deps: dart run tool/intentcall/bin/intentcall.dart print-hosted-deps diff --git a/tool/intentcall/bin/intentcall.dart b/tool/intentcall/bin/intentcall.dart index 08c7f25..fbc29eb 100644 --- a/tool/intentcall/bin/intentcall.dart +++ b/tool/intentcall/bin/intentcall.dart @@ -4,6 +4,8 @@ import 'dart:io'; import 'package:args/args.dart'; import 'package:path/path.dart' as p; +import 'release_train.dart' as release_train; + const publishOrder = [ 'intentcall_schema', 'intentcall_core', @@ -21,6 +23,22 @@ void main(List arguments) async { final parser = ArgParser() ..addCommand('doctor') ..addCommand('validate') + ..addCommand('check-release-train') + ..addCommand( + 'sync-release-train', + ArgParser() + ..addOption( + 'version', + abbr: 'v', + help: + 'Train version to sync. Defaults to .release-please-manifest.json or package versions.', + ) + ..addFlag( + 'check', + negatable: false, + help: 'Report whether sync would edit files without writing them.', + ), + ) ..addCommand('check-path-deps') ..addCommand('check-doc-versions') ..addCommand( @@ -105,6 +123,19 @@ void main(List arguments) async { final code = await runValidate(repoRoot); exit(code); + case 'check-release-train': + final code = await release_train.runReleaseTrainCheck(repoRoot); + exit(code); + + case 'sync-release-train': + final cmdResults = results.command!; + final code = await release_train.runReleaseTrainSync( + repoRoot, + version: cmdResults['version'] as String?, + checkOnly: cmdResults['check'] as bool? ?? false, + ); + exit(code); + case 'check-path-deps': final code = await runCheckPathDeps(repoRoot); exit(code); @@ -193,6 +224,12 @@ void printUsage(ArgParser parser) { print( ' validate Validate path dependencies and version consistency.', ); + print( + ' check-release-train Verify train versions, internal floors, and podspecs.', + ); + print( + ' sync-release-train Rewrite train versions, internal floors, and podspecs.', + ); print( ' check-path-deps Scan workspace for invalid path dependencies.', ); diff --git a/tool/intentcall/bin/release_train.dart b/tool/intentcall/bin/release_train.dart new file mode 100644 index 0000000..89ca293 --- /dev/null +++ b/tool/intentcall/bin/release_train.dart @@ -0,0 +1,343 @@ +import 'dart:convert'; +import 'dart:io'; + +const publishablePackages = [ + 'intentcall_schema', + 'intentcall_core', + 'intentcall_session', + 'intentcall_mcp', + 'intentcall_webmcp', + 'intentcall_apple', + 'intentcall_android', + 'intentcall_codegen', + 'intentcall_platform', + 'intentcall_testing', +]; + +const workspaceOnlyPackages = ['intentcall_gemma']; + +const allInternalPackages = [...publishablePackages, ...workspaceOnlyPackages]; + +Future main(List arguments) async { + final repoRoot = findRepoRoot(); + final command = arguments.isEmpty ? 'check' : arguments.first; + final args = arguments.skip(1).toList(); + + final code = switch (command) { + 'check' => await runReleaseTrainCheck(repoRoot), + 'sync' => await runReleaseTrainSync( + repoRoot, + version: _option(args, '--version'), + checkOnly: args.contains('--check'), + ), + _ => _usage(), + }; + + exit(code); +} + +Directory findRepoRoot() { + var dir = Directory.current; + while (dir.path != dir.parent.path) { + final pubspec = File(joinPath([dir.path, 'pubspec.yaml'])); + if (pubspec.existsSync() && + pubspec.readAsStringSync().contains('name: intentcall_workspace')) { + return dir; + } + dir = dir.parent; + } + return Directory.current; +} + +Future runReleaseTrainCheck(Directory repoRoot) async { + final version = await resolveReleaseTrainVersion(repoRoot); + final findings = await releaseTrainFindings(repoRoot, version: version); + if (findings.isNotEmpty) { + stderr.writeln('FAIL: IntentCall release train metadata is stale.'); + stderr.writeln('Run: dart tool/intentcall/bin/release_train.dart sync'); + for (final finding in findings) { + stderr.writeln(' - $finding'); + } + return 1; + } + print('OK: IntentCall release train metadata is synchronized at $version.'); + return 0; +} + +Future runReleaseTrainSync( + Directory repoRoot, { + String? version, + bool checkOnly = false, +}) async { + final targetVersion = version ?? await resolveReleaseTrainVersion(repoRoot); + final edits = await syncReleaseTrainMetadata( + repoRoot, + version: targetVersion, + write: !checkOnly, + ); + + if (checkOnly) { + if (edits.isNotEmpty) { + stderr.writeln('FAIL: IntentCall release train metadata needs sync.'); + for (final edit in edits) { + stderr.writeln(' - $edit'); + } + return 1; + } + print( + 'OK: IntentCall release train metadata is synchronized at $targetVersion.', + ); + return 0; + } + + if (edits.isEmpty) { + print('OK: IntentCall release train metadata already at $targetVersion.'); + } else { + print('Updated IntentCall release train metadata to $targetVersion:'); + for (final edit in edits) { + print(' - $edit'); + } + } + return 0; +} + +Future resolveReleaseTrainVersion(Directory repoRoot) async { + final manifest = File( + joinPath([repoRoot.path, '.release-please-manifest.json']), + ); + if (manifest.existsSync()) { + final decoded = jsonDecode(await manifest.readAsString()); + if (decoded is Map) { + final values = {}; + for (final packageName in publishablePackages) { + final value = decoded['packages/$packageName']; + if (value is String && value.isNotEmpty) { + values.add(value); + } + } + if (values.length == 1) { + return values.single; + } + if (values.length > 1) { + throw StateError( + 'Release manifest contains multiple train versions: ${values.join(", ")}', + ); + } + } + } + + String? version; + for (final packageName in publishablePackages) { + final pubspec = await readPubspec(repoRoot, packageName); + final packageVersion = pubspecVersion(pubspec); + if (packageVersion == null) { + throw StateError('$packageName pubspec.yaml has no version.'); + } + version ??= packageVersion; + if (version != packageVersion) { + throw StateError( + '$packageName is $packageVersion, expected train version $version.', + ); + } + } + return version!; +} + +Future> releaseTrainFindings( + Directory repoRoot, { + required String version, +}) async { + final findings = []; + + for (final packageName in publishablePackages) { + final pubspec = await readPubspec(repoRoot, packageName); + final actual = pubspecVersion(pubspec); + if (actual != version) { + findings.add('$packageName version is $actual, expected $version'); + } + } + + for (final packageName in allInternalPackages) { + final pubspec = await readPubspec(repoRoot, packageName); + for (final dependency in publishablePackages) { + if (dependency == packageName) { + continue; + } + final actual = dependencyFloor(pubspec, dependency); + if (actual != null && actual != version) { + findings.add( + '$packageName depends on $dependency ^$actual, expected ^$version', + ); + } + } + } + + for (final relativePath in [ + 'packages/intentcall_platform/ios/intentcall_platform.podspec', + 'packages/intentcall_platform/macos/intentcall_platform.podspec', + ]) { + final file = File(joinPath([repoRoot.path, relativePath])); + final actual = podspecVersion(await file.readAsString()); + if (actual != version) { + findings.add('$relativePath has s.version $actual, expected $version'); + } + } + + return findings; +} + +Future> syncReleaseTrainMetadata( + Directory repoRoot, { + required String version, + required bool write, +}) async { + final edits = []; + + for (final packageName in publishablePackages) { + final file = pubspecFile(repoRoot, packageName); + final original = await file.readAsString(); + final updated = replacePubspecVersion(original, version); + if (updated != original) { + edits.add('${relativePath(repoRoot, file)} version -> $version'); + if (write) { + await file.writeAsString(updated); + } + } + } + + for (final packageName in allInternalPackages) { + final file = pubspecFile(repoRoot, packageName); + final original = await file.readAsString(); + final updated = replaceInternalDependencyFloors( + original, + packageName: packageName, + version: version, + ); + if (updated != original) { + edits.add('${relativePath(repoRoot, file)} internal floors -> ^$version'); + if (write) { + await file.writeAsString(updated); + } + } + } + + for (final relative in [ + 'packages/intentcall_platform/ios/intentcall_platform.podspec', + 'packages/intentcall_platform/macos/intentcall_platform.podspec', + ]) { + final file = File(joinPath([repoRoot.path, relative])); + final original = await file.readAsString(); + final updated = replacePodspecVersion(original, version); + if (updated != original) { + edits.add('$relative s.version -> $version'); + if (write) { + await file.writeAsString(updated); + } + } + } + + return edits; +} + +String replacePubspecVersion(String content, String version) { + return content.replaceFirst( + RegExp(r'^version:\s*[^\s]+', multiLine: true), + 'version: $version', + ); +} + +String replaceInternalDependencyFloors( + String content, { + required String packageName, + required String version, +}) { + var updated = content; + for (final dependency in publishablePackages) { + if (dependency == packageName) { + continue; + } + updated = updated.replaceAllMapped( + RegExp( + '^(\\s{2}${RegExp.escape(dependency)}:\\s*)\\^([^\\s#]+)', + multiLine: true, + ), + (match) => '${match.group(1)}^$version', + ); + } + return updated; +} + +String replacePodspecVersion(String content, String version) { + return content.replaceFirstMapped( + RegExp(r"^(\s*s\.version\s*=\s*)'[^']+'", multiLine: true), + (match) => "${match.group(1)}'$version'", + ); +} + +String? pubspecVersion(String content) { + return RegExp( + r'^version:\s*([^\s]+)', + multiLine: true, + ).firstMatch(content)?.group(1); +} + +String? dependencyFloor(String content, String dependency) { + return RegExp( + '^\\s{2}${RegExp.escape(dependency)}:\\s*\\^([^\\s#]+)', + multiLine: true, + ).firstMatch(content)?.group(1); +} + +String? podspecVersion(String content) { + return RegExp( + r"^\s*s\.version\s*=\s*'([^']+)'", + multiLine: true, + ).firstMatch(content)?.group(1); +} + +Future readPubspec(Directory repoRoot, String packageName) { + return pubspecFile(repoRoot, packageName).readAsString(); +} + +File pubspecFile(Directory repoRoot, String packageName) { + return File( + joinPath([repoRoot.path, 'packages', packageName, 'pubspec.yaml']), + ); +} + +String relativePath(Directory repoRoot, File file) { + final root = repoRoot.uri; + final uri = file.uri; + return root.toString() == uri.toString() + ? '.' + : uri.toString().replaceFirst(root.toString(), ''); +} + +String joinPath(List parts) { + return parts.where((part) => part.isNotEmpty).join(Platform.pathSeparator); +} + +String? _option(List args, String name) { + for (var i = 0; i < args.length; i++) { + final arg = args[i]; + if (arg == name && i + 1 < args.length) { + return args[i + 1]; + } + if (arg.startsWith('$name=')) { + return arg.substring(name.length + 1); + } + } + return null; +} + +int _usage() { + stderr.writeln( + 'Usage: dart tool/intentcall/bin/release_train.dart ', + ); + stderr.writeln(' check Verify release train metadata.'); + stderr.writeln( + ' sync [--version X] Rewrite train versions/floors/podspecs.', + ); + stderr.writeln(' sync --check Report the edits sync would make.'); + return 64; +} diff --git a/tool/intentcall/test/publish_preflight_test.dart b/tool/intentcall/test/publish_preflight_test.dart index 0b21167..ad7e16a 100644 --- a/tool/intentcall/test/publish_preflight_test.dart +++ b/tool/intentcall/test/publish_preflight_test.dart @@ -4,6 +4,7 @@ import 'package:path/path.dart' as p; import 'package:test/test.dart'; import '../bin/intentcall.dart' as intentcall_cli; +import '../bin/release_train.dart' as release_train; void main() { group('publish args', () { @@ -190,6 +191,61 @@ Use intentcall_core-v for release tag examples. }); }); + group('release train sync', () { + test('checks and synchronizes dependency floors and podspecs', () async { + final repo = await _createReleaseTrainFixture(); + addTearDown(() => repo.deleteSync(recursive: true)); + + expect(await release_train.runReleaseTrainCheck(repo), 1); + + final syncCode = await release_train.runReleaseTrainSync(repo); + + expect(syncCode, 0); + expect(await release_train.runReleaseTrainCheck(repo), 0); + expect( + File( + p.join(repo.path, 'packages', 'intentcall_session', 'pubspec.yaml'), + ).readAsStringSync(), + contains(' intentcall_schema: ^0.6.0'), + ); + expect( + File( + p.join(repo.path, 'packages', 'intentcall_gemma', 'pubspec.yaml'), + ).readAsStringSync(), + contains(' intentcall_testing: ^0.6.0'), + ); + expect( + File( + p.join( + repo.path, + 'packages', + 'intentcall_platform', + 'ios', + 'intentcall_platform.podspec', + ), + ).readAsStringSync(), + contains("s.version = '0.6.0'"), + ); + }); + + test('check-only sync reports stale metadata without writing', () async { + final repo = await _createReleaseTrainFixture(); + addTearDown(() => repo.deleteSync(recursive: true)); + final pubspec = File( + p.join(repo.path, 'packages', 'intentcall_core', 'pubspec.yaml'), + ); + final before = pubspec.readAsStringSync(); + + final syncCode = await release_train.runReleaseTrainSync( + repo, + checkOnly: true, + ); + + expect(syncCode, 1); + expect(pubspec.readAsStringSync(), before); + }); + }); + group('runReleaseGitCleanCheck', () { test('passes when release-critical files are clean', () async { final repo = await _createGitRepo(); @@ -280,6 +336,101 @@ Future _createGitRepo() async { return repo; } +Future _createReleaseTrainFixture() async { + final repo = await Directory.systemTemp.createTemp( + 'intentcall_release_train_', + ); + File(p.join(repo.path, 'pubspec.yaml')) + ..createSync(recursive: true) + ..writeAsStringSync('name: intentcall_workspace\npublish_to: none\n'); + File(p.join(repo.path, '.release-please-manifest.json')).writeAsStringSync(''' +{ + "packages/intentcall_schema": "0.6.0", + "packages/intentcall_core": "0.6.0", + "packages/intentcall_session": "0.6.0", + "packages/intentcall_mcp": "0.6.0", + "packages/intentcall_webmcp": "0.6.0", + "packages/intentcall_apple": "0.6.0", + "packages/intentcall_android": "0.6.0", + "packages/intentcall_codegen": "0.6.0", + "packages/intentcall_platform": "0.6.0", + "packages/intentcall_testing": "0.6.0" +} +'''); + + for (final packageName in release_train.publishablePackages) { + final deps = switch (packageName) { + 'intentcall_schema' => '', + 'intentcall_core' => ' intentcall_schema: ^0.5.0\n', + 'intentcall_session' => + ' intentcall_core: ^0.5.0\n intentcall_schema: ^0.5.0\n', + 'intentcall_mcp' => + ' intentcall_core: ^0.5.0\n intentcall_schema: ^0.5.0\n', + 'intentcall_webmcp' => + ' intentcall_core: ^0.5.0\n intentcall_testing: ^0.5.0\n', + 'intentcall_apple' => ' intentcall_core: ^0.5.0\n', + 'intentcall_android' => ' intentcall_core: ^0.5.0\n', + 'intentcall_codegen' => + ' intentcall_core: ^0.5.0\n intentcall_schema: ^0.5.0\n', + 'intentcall_platform' => + ' intentcall_core: ^0.5.0\n intentcall_schema: ^0.5.0\n', + 'intentcall_testing' => + ' intentcall_core: ^0.5.0\n intentcall_schema: ^0.5.0\n', + _ => '', + }; + _writePubspec(repo, packageName, version: '0.6.0', dependencies: deps); + } + + _writePubspec( + repo, + 'intentcall_gemma', + version: '0.1.0', + publishToNone: true, + dependencies: + ' intentcall_core: ^0.5.0\n' + ' intentcall_schema: ^0.5.0\n' + ' intentcall_testing: ^0.5.0\n', + ); + + for (final platform in ['ios', 'macos']) { + final podspec = File( + p.join( + repo.path, + 'packages', + 'intentcall_platform', + platform, + 'intentcall_platform.podspec', + ), + )..createSync(recursive: true); + podspec.writeAsStringSync(''' +Pod::Spec.new do |s| + s.name = 'intentcall_platform' + s.version = '0.5.0' +end +'''); + } + + return repo; +} + +void _writePubspec( + Directory repo, + String packageName, { + required String version, + String dependencies = '', + bool publishToNone = false, +}) { + final file = File(p.join(repo.path, 'packages', packageName, 'pubspec.yaml')) + ..createSync(recursive: true); + file.writeAsStringSync(''' +name: $packageName +${publishToNone ? 'publish_to: none\n' : ''}version: $version +environment: + sdk: ^3.9.0 +dependencies: +$dependencies'''); +} + Future _runGit(Directory repo, List args) async { final result = await Process.run('git', args, workingDirectory: repo.path); if (result.exitCode != 0) {