diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 15c0dde..1c5acaf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,4 +1,4 @@ -# Generated by runtime_ci_tooling v0.14.4 +# Generated by runtime_ci_tooling v0.15.0 # Configured via .runtime_ci/config.json — run 'dart run runtime_ci_tooling:manage_cicd update --workflows' to regenerate. # Policy: test artifact retention-days = 7 (applied consistently). name: CI @@ -83,14 +83,14 @@ jobs: if: needs.pre-check.outputs.should_run == 'true' runs-on: ubuntu-latest steps: - # ── shared:checkout ── keep in sync with single_platform ── + # ── shared:checkout ── - uses: actions/checkout@v6.0.2 with: ref: ${{ needs.auto-format.outputs.sha }} fetch-depth: 1 persist-credentials: false - # ── shared:git-config ── keep in sync with single_platform ── + # ── shared:git-config ── - name: Configure Git for HTTPS with Token shell: bash env: @@ -102,12 +102,12 @@ jobs: git config --global url."https://x-access-token:${GH_PAT}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" git config --global url."https://x-access-token:${GH_PAT}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" - # ── shared:dart-setup ── keep in sync with single_platform ── + # ── shared:dart-setup ── - uses: dart-lang/setup-dart@v1.7.1 with: sdk: "3.9.2" - # ── shared:pub-cache ── keep in sync with single_platform ── + # ── shared:pub-cache ── # Windows: %LOCALAPPDATA%\Pub\Cache (Dart default). Unix: ~/.pub-cache - name: Cache Dart pub dependencies uses: actions/cache@v5.0.3 @@ -116,8 +116,8 @@ jobs: key: ${{ runner.os }}-${{ runner.arch }}-dart-pub-${{ hashFiles('**/pubspec.yaml') }} restore-keys: ${{ runner.os }}-${{ runner.arch }}-dart-pub- - # ── shared:proto-setup ── keep in sync with single_platform ── - # ── shared:pub-get ── keep in sync with single_platform ── + # ── shared:proto-setup ── + # ── shared:pub-get ── - run: dart pub get env: GIT_LFS_SKIP_SMUDGE: "1" @@ -125,9 +125,9 @@ jobs: - name: Run build_runner run: dart run build_runner build --delete-conflicting-outputs - # ── shared:analysis-cache ── keep in sync with single_platform ── - # ── shared:proto-verify ── keep in sync with single_platform ── - # ── shared:analyze ── keep in sync with single_platform ── + # ── shared:analysis-cache ── + # ── shared:proto-verify ── + # ── shared:analyze ── - name: Analyze run: | dart analyze 2>&1 | tee "$RUNNER_TEMP/analysis.txt" @@ -136,7 +136,7 @@ jobs: exit 1 fi - # ── shared:sub-packages ── keep in sync with single_platform ── + # ── shared:sub-packages ── test: needs: [pre-check, analyze, auto-format] if: needs.pre-check.outputs.should_run == 'true' @@ -146,14 +146,14 @@ jobs: matrix: include: [{"platform_id":"ubuntu-x64","runner":"ubuntu-latest","os_family":"linux","arch":"x64"},{"platform_id":"ubuntu-arm64","runner":"runtime-ubuntu-24.04-arm64-208gb-64core","os_family":"linux","arch":"arm64"},{"platform_id":"macos-arm64","runner":"macos-latest","os_family":"macos","arch":"arm64"},{"platform_id":"macos-x64","runner":"macos-15-large","os_family":"macos","arch":"x64"},{"platform_id":"windows-x64","runner":"windows-latest","os_family":"windows","arch":"x64"},{"platform_id":"windows-arm64","runner":"runtime-windows-11-arm64-208gb-64core","os_family":"windows","arch":"arm64"}] steps: - # ── shared:checkout ── keep in sync with single_platform ── + # ── shared:checkout ── - uses: actions/checkout@v6.0.2 with: ref: ${{ needs.auto-format.outputs.sha }} fetch-depth: 1 persist-credentials: false - # ── shared:git-config ── keep in sync with single_platform ── + # ── shared:git-config ── - name: Configure Git for HTTPS with Token shell: bash env: @@ -165,12 +165,12 @@ jobs: git config --global url."https://x-access-token:${GH_PAT}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" git config --global url."https://x-access-token:${GH_PAT}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" - # ── shared:dart-setup ── keep in sync with single_platform ── + # ── shared:dart-setup ── - uses: dart-lang/setup-dart@v1.7.1 with: sdk: "3.9.2" - # ── shared:pub-cache ── keep in sync with single_platform ── + # ── shared:pub-cache ── # Windows: %LOCALAPPDATA%\Pub\Cache (Dart default). Unix: ~/.pub-cache - name: Cache Dart pub dependencies uses: actions/cache@v5.0.3 @@ -179,8 +179,8 @@ jobs: key: ${{ runner.os }}-${{ runner.arch }}-dart-pub-${{ hashFiles('**/pubspec.yaml') }} restore-keys: ${{ runner.os }}-${{ runner.arch }}-dart-pub- - # ── shared:proto-setup ── keep in sync with single_platform ── - # ── shared:pub-get ── keep in sync with single_platform ── + # ── shared:proto-setup ── + # ── shared:pub-get ── - run: dart pub get env: GIT_LFS_SKIP_SMUDGE: "1" @@ -191,7 +191,7 @@ jobs: # --- BEGIN USER: pre-test --- # --- END USER: pre-test --- - # ── shared:test ── keep in sync with single_platform ── + # ── shared:test ── - name: Test shell: bash run: | diff --git a/.runtime_ci/config.json b/.runtime_ci/config.json index 426959a..ee6e539 100644 --- a/.runtime_ci/config.json +++ b/.runtime_ci/config.json @@ -68,6 +68,7 @@ "line_length": 120, "artifact_retention_days": 7, "personal_access_token_secret": "TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN", + "git_orgs": ["open-runtime", "pieces-app"], "features": { "proto": false, "lfs": false, diff --git a/.runtime_ci/template_versions.json b/.runtime_ci/template_versions.json index 00c2852..83cc814 100644 --- a/.runtime_ci/template_versions.json +++ b/.runtime_ci/template_versions.json @@ -1,6 +1,6 @@ { - "tooling_version": "0.14.4", - "updated_at": "2026-03-04T01:43:45.360518Z", + "tooling_version": "0.15.0", + "updated_at": "2026-03-04T18:46:17.822889Z", "templates": { "gemini_settings": { "hash": "93983f49dd2f40d2ed245271854946d8916b8f0698ed2cfaf12058305baa0b08", @@ -23,9 +23,9 @@ "updated_at": "2026-02-24T00:59:57.620091Z" }, "workflow_ci": { - "hash": "e2df47cd0f26ec20fcb7c01530f5ad84c5877c8397518bf4d46de5f99bb8bbf2", - "consumer_hash": "c23d7a119a57783b1b6b3472ca7b52a912fa67435e6d0f5448c414e1274a025b", - "updated_at": "2026-03-04T01:43:45.357518Z" + "hash": "1d5ab4ac372b1d5c9ff1ad9cd2cc634fea66a98d264faa9e487acfc873bf7602", + "consumer_hash": "f25b1db7a45db7631808fb1eb3135df963eece9935e0eabd89aff3d6ffe67d73", + "updated_at": "2026-03-04T18:46:17.823530Z" }, "workflow_release": { "hash": "0db4e621f478e5f255292bc3d9a0c1a6dcd78ebe73fd91d87b7facd635898f64", diff --git a/lib/src/cli/commands/autodoc_command.dart b/lib/src/cli/commands/autodoc_command.dart index 492810c..8b73861 100644 --- a/lib/src/cli/commands/autodoc_command.dart +++ b/lib/src/cli/commands/autodoc_command.dart @@ -10,12 +10,14 @@ import '../../triage/utils/config.dart'; import '../../triage/utils/run_context.dart'; import '../manage_cicd_cli.dart'; import '../options/autodoc_options.dart'; -import '../utils/autodoc_scaffold.dart'; +import '../utils/autodoc_scaffold.dart' show kAutodocIndexPath, resolveAutodocOutputPath, scaffoldAutodocJson; import '../utils/gemini_utils.dart'; import '../utils/logger.dart'; import '../utils/process_runner.dart'; import '../utils/repo_utils.dart'; import '../utils/step_summary.dart'; +import '../utils/sub_package_utils.dart'; +import '../utils/version_detection.dart'; const String _kGeminiProModel = 'gemini-3.1-pro-preview'; @@ -96,6 +98,24 @@ class AutodocCommand extends Command { return; } + final subPackages = SubPackageUtils.loadSubPackages(repoRoot); + SubPackageUtils.logSubPackages(subPackages); + var subPackageDiffContext = ''; + if (subPackages.isNotEmpty) { + String prevTag; + try { + prevTag = VersionDetection.detectPrevTag(repoRoot, verbose: global.verbose); + } catch (_) { + prevTag = ''; + } + subPackageDiffContext = SubPackageUtils.buildSubPackageDiffContext( + repoRoot: repoRoot, + prevTag: prevTag, + subPackages: subPackages, + verbose: global.verbose, + ); + } + // Build task queue based on hash comparison final tasks = Function()>[]; final updatedModules = []; @@ -106,7 +126,30 @@ class AutodocCommand extends Command { if (targetModule != null && id != targetModule) continue; final sourcePaths = (module['source_paths'] as List).cast(); - final currentHash = _computeModuleHash(repoRoot, sourcePaths); + final moduleSubPackage = _resolveModuleSubPackage( + module: module, + sourcePaths: sourcePaths, + subPackages: subPackages, + ); + if (moduleSubPackage != null && module['sub_package'] == null) { + module['sub_package'] = moduleSubPackage; + } + + final configuredOutputPath = module['output_path'] as String; + final normalizedOutputPath = _resolveOutputPathForModule( + configuredOutputPath: configuredOutputPath, + moduleSubPackage: moduleSubPackage, + ); + if (normalizedOutputPath != configuredOutputPath) { + module['output_path'] = normalizedOutputPath; + } + + final currentHash = _computeModuleHash( + repoRoot, + sourcePaths, + moduleSubPackage: moduleSubPackage, + outputPath: normalizedOutputPath, + ); final previousHash = module['hash'] as String? ?? ''; if (currentHash == previousHash && !force) { @@ -116,12 +159,15 @@ class AutodocCommand extends Command { } final name = module['name'] as String; - final outputPath = '$repoRoot/${module['output_path']}'; + final outputPath = '$repoRoot/$normalizedOutputPath'; final libPaths = (module['lib_paths'] as List?)?.cast() ?? []; final generateTypes = (module['generate'] as List).cast(); final libDir = libPaths.isNotEmpty ? '$repoRoot/${libPaths.first}' : ''; + final packageSuffix = moduleSubPackage == null ? '' : ' [sub-package: $moduleSubPackage]'; - Logger.info(' $id ($name): ${force ? "forced" : "changed"} -> generating ${generateTypes.join(", ")}'); + Logger.info( + ' $id ($name)$packageSuffix: ${force ? "forced" : "changed"} -> generating ${generateTypes.join(", ")}', + ); if (dryRun) { updatedModules.add(id); @@ -151,6 +197,13 @@ class AutodocCommand extends Command { libDir: libDir, outputPath: outputPath, previousHash: previousHash, + moduleSubPackage: moduleSubPackage, + subPackageDiffContext: subPackageDiffContext, + hierarchicalAutodocInstructions: SubPackageUtils.buildHierarchicalAutodocInstructions( + moduleName: name, + subPackages: subPackages, + moduleSubPackage: moduleSubPackage, + ), verbose: global.verbose, ), ); @@ -184,6 +237,10 @@ class AutodocCommand extends Command { await _forEachConcurrent(tasks, maxConcurrent); + if (subPackages.isNotEmpty) { + _writeHierarchicalDocsIndex(repoRoot: repoRoot, modules: modules, subPackages: subPackages); + } + // Save updated config with new hashes File(configPath).writeAsStringSync(const JsonEncoder.withIndent(' ').convert(autodocConfig)); @@ -220,6 +277,9 @@ ${StepSummary.artifactLink()} required String libDir, required String outputPath, required String previousHash, + required String? moduleSubPackage, + required String subPackageDiffContext, + required String hierarchicalAutodocInstructions, bool verbose = false, }) async { final outputFileName = switch (docType) { @@ -266,6 +326,10 @@ ${StepSummary.artifactLink()} pass1Prompt.writeAsStringSync(''' $prompt +${moduleSubPackage == null ? '' : 'This module belongs to sub-package: "$moduleSubPackage".'} +${subPackageDiffContext.isEmpty ? '' : subPackageDiffContext} +${hierarchicalAutodocInstructions.isEmpty ? '' : hierarchicalAutodocInstructions} + ## OUTPUT INSTRUCTIONS Write the generated documentation to this exact file path using write_file: @@ -321,6 +385,9 @@ Your task is to review and improve the file at: This documentation was auto-generated for the **$moduleName** module. The proto definitions are in: ${sourceDir.replaceFirst('$repoRoot/', '')} ${libDir.isNotEmpty ? 'Generated Dart code is in: ${libDir.replaceFirst('$repoRoot/', '')}' : ''} +${moduleSubPackage == null ? '' : 'This module belongs to sub-package: "$moduleSubPackage".'} +${subPackageDiffContext.isEmpty ? '' : subPackageDiffContext} +${hierarchicalAutodocInstructions.isEmpty ? '' : hierarchicalAutodocInstructions} ## Review Checklist @@ -457,7 +524,7 @@ Write the corrected file to the same path: $absOutputFile } /// Compute SHA256 hash of all source files in the given paths. - String _computeModuleHash(String repoRoot, List sourcePaths) { + String _computeModuleHash(String repoRoot, List sourcePaths, {String? moduleSubPackage, String? outputPath}) { // Pure-Dart hashing so caching works on macOS/Windows and minimal CI images // (no dependency on sha256sum/shasum/xargs/find). final filePaths = []; @@ -485,6 +552,15 @@ Write the corrected file to the same path: $absOutputFile final builder = BytesBuilder(copy: false); + if (moduleSubPackage != null) { + builder.add(utf8.encode('sub_package:$moduleSubPackage')); + builder.addByte(0); + } + if (outputPath != null) { + builder.add(utf8.encode('output_path:$outputPath')); + builder.addByte(0); + } + for (final path in filePaths) { // Include the path name in the digest so renames affect the hash. builder.add(utf8.encode(path)); @@ -503,4 +579,84 @@ Write the corrected file to the same path: $absOutputFile return sha256.convert(builder.takeBytes()).toString(); } + + String? _resolveModuleSubPackage({ + required Map module, + required List sourcePaths, + required List> subPackages, + }) { + if (subPackages.isEmpty) return null; + + final explicit = module['sub_package']; + if (explicit is String && explicit.trim().isNotEmpty) { + final explicitName = explicit.trim(); + final exists = subPackages.any((pkg) => (pkg['name'] as String) == explicitName); + if (exists) return explicitName; + } + + final normalizedSources = sourcePaths.map((s) => p.posix.normalize(s).replaceFirst(RegExp(r'^/+'), '')).toList(); + for (final pkg in subPackages) { + final packagePath = p.posix.normalize(pkg['path'] as String).replaceFirst(RegExp(r'^/+'), ''); + final packagePrefix = packagePath.endsWith('/') ? packagePath : '$packagePath/'; + if (normalizedSources.any((src) => src.startsWith(packagePrefix))) { + return pkg['name'] as String; + } + } + return null; + } + + String _resolveOutputPathForModule({required String configuredOutputPath, required String? moduleSubPackage}) { + return resolveAutodocOutputPath(configuredOutputPath: configuredOutputPath, moduleSubPackage: moduleSubPackage); + } + + void _writeHierarchicalDocsIndex({ + required String repoRoot, + required List> modules, + required List> subPackages, + }) { + if (subPackages.isEmpty) return; + + final grouped = >>{}; + for (final pkg in subPackages) { + grouped[pkg['name'] as String] = >[]; + } + + for (final module in modules) { + final sourcePaths = (module['source_paths'] as List).cast(); + final resolvedPackage = _resolveModuleSubPackage( + module: module, + sourcePaths: sourcePaths, + subPackages: subPackages, + ); + if (resolvedPackage == null) continue; + grouped.putIfAbsent(resolvedPackage, () => >[]).add(module); + } + + final buf = StringBuffer() + ..writeln('') + ..writeln() + ..writeln('# Documentation Index') + ..writeln() + ..writeln('Generated by `manage_cicd autodoc` for multi-package repositories.') + ..writeln(); + + for (final pkg in subPackages) { + final packageName = pkg['name'] as String; + final packageModules = grouped[packageName] ?? >[]; + if (packageModules.isEmpty) continue; + buf.writeln('## $packageName'); + buf.writeln(); + for (final module in packageModules) { + final moduleName = module['name'] as String; + final outputPath = (module['output_path'] as String).replaceFirst(RegExp(r'^docs/'), ''); + buf.writeln('- [$moduleName](./$outputPath)'); + } + buf.writeln(); + } + + Directory('$repoRoot/docs').createSync(recursive: true); + final indexFile = File('$repoRoot/$kAutodocIndexPath'); + indexFile.writeAsStringSync(buf.toString()); + Logger.info('Updated hierarchical docs index: $kAutodocIndexPath'); + } } diff --git a/lib/src/cli/commands/documentation_command.dart b/lib/src/cli/commands/documentation_command.dart index 3d562eb..cf6bbcd 100644 --- a/lib/src/cli/commands/documentation_command.dart +++ b/lib/src/cli/commands/documentation_command.dart @@ -13,6 +13,7 @@ import '../utils/logger.dart'; import '../utils/process_runner.dart'; import '../utils/prompt_resolver.dart'; import '../utils/repo_utils.dart'; +import '../utils/sub_package_utils.dart'; import '../utils/version_detection.dart'; const String _kGeminiProModel = 'gemini-3.1-pro-preview'; @@ -69,6 +70,16 @@ class DocumentationCommand extends Command { } ctx.savePrompt('documentation', prompt); + // Enrich prompt with sub-package context for multi-package repos. + SubPackageUtils.enrichPromptWithSubPackages( + repoRoot: repoRoot, + prevTag: prevTag, + promptFilePath: ctx.artifactPath('documentation', 'prompt.txt'), + buildInstructions: SubPackageUtils.buildHierarchicalDocumentationInstructions, + newVersion: newVersion, + verbose: global.verbose, + ); + if (global.dryRun) { Logger.info('[DRY-RUN] Would run Gemini for documentation update (${prompt.length} chars)'); return; diff --git a/lib/src/cli/commands/update_command.dart b/lib/src/cli/commands/update_command.dart index f91f052..e8ab1ad 100644 --- a/lib/src/cli/commands/update_command.dart +++ b/lib/src/cli/commands/update_command.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:args/command_runner.dart'; +import 'package:path/path.dart' as p; import '../../triage/utils/config.dart'; import '../../triage/utils/run_context.dart'; @@ -46,6 +47,7 @@ class UpdateCommand extends Command { final dryRun = global.dryRun; final verbose = global.verbose; final force = opts.force; + final diff = opts.diff; Logger.header('Update Runtime CI Tooling Templates'); @@ -99,6 +101,7 @@ class UpdateCommand extends Command { dryRun: dryRun, verbose: verbose, backup: opts.backup, + diff: diff, ), 'cautious' => _processCautious( repoRoot, @@ -110,6 +113,7 @@ class UpdateCommand extends Command { dryRun: dryRun, verbose: verbose, backup: opts.backup, + diff: diff, ), 'templated' => _processTemplated( repoRoot, @@ -120,6 +124,7 @@ class UpdateCommand extends Command { dryRun: dryRun, verbose: verbose, backup: opts.backup, + diff: diff, ), 'mergeable' => _processMergeable( repoRoot, @@ -130,8 +135,9 @@ class UpdateCommand extends Command { force: force, dryRun: dryRun, verbose: verbose, + diff: diff, ), - 'regeneratable' => _processRegeneratable(repoRoot, entry, dryRun: dryRun, verbose: verbose), + 'regeneratable' => _processRegeneratable(repoRoot, entry, dryRun: dryRun, verbose: verbose, diff: diff), _ => _UpdateResult(entry.id, 'skipped', reason: 'unknown category: ${entry.category}'), }; @@ -203,10 +209,12 @@ class UpdateCommand extends Command { required bool dryRun, required bool verbose, required bool backup, + required bool diff, }) { final templatePath = '$templatesDir/${entry.source}'; final destPath = '$repoRoot/${entry.destination}'; final templateHash = computeFileHash(templatePath); + final templateContent = File(templatePath).readAsStringSync(); final installedHash = tracker.getInstalledHash(entry.id); if (templateHash == installedHash && !force) { @@ -216,9 +224,12 @@ class UpdateCommand extends Command { final destFile = File(destPath); if (!destFile.existsSync()) { + if (diff) { + _emitUnifiedDiff(destination: entry.destination, before: '', after: templateContent); + } if (!dryRun) { Directory(destFile.parent.path).createSync(recursive: true); - File(templatePath).copySync(destPath); + destFile.writeAsStringSync(templateContent); tracker.recordUpdate( entry.id, templateHash: templateHash, @@ -236,16 +247,22 @@ class UpdateCommand extends Command { final hasLocalChanges = previousConsumerHash != null && currentDestHash != previousConsumerHash; if (hasLocalChanges && !force) { + if (diff) { + _emitUnifiedDiff(destination: entry.destination, before: destFile.readAsStringSync(), after: templateContent); + } Logger.warn(' ${entry.id}: local customizations detected, skipping (use --force to overwrite)'); return _UpdateResult(entry.id, 'warning', reason: 'local customizations -- use --force'); } + if (diff) { + _emitUnifiedDiff(destination: entry.destination, before: destFile.readAsStringSync(), after: templateContent); + } if (!dryRun) { if (backup) { destFile.copySync('$destPath.bak'); Logger.info(' ${entry.id}: backed up to ${entry.destination}.bak'); } - File(templatePath).copySync(destPath); + destFile.writeAsStringSync(templateContent); tracker.recordUpdate( entry.id, templateHash: templateHash, @@ -267,10 +284,12 @@ class UpdateCommand extends Command { required bool dryRun, required bool verbose, required bool backup, + required bool diff, }) { final templatePath = '$templatesDir/${entry.source}'; final destPath = '$repoRoot/${entry.destination}'; final templateHash = computeFileHash(templatePath); + final templateContent = File(templatePath).readAsStringSync(); final installedHash = tracker.getInstalledHash(entry.id); if (templateHash == installedHash && !force) { @@ -280,9 +299,12 @@ class UpdateCommand extends Command { final destFile = File(destPath); if (!destFile.existsSync()) { + if (diff) { + _emitUnifiedDiff(destination: entry.destination, before: '', after: templateContent); + } if (!dryRun) { Directory(destFile.parent.path).createSync(recursive: true); - File(templatePath).copySync(destPath); + destFile.writeAsStringSync(templateContent); tracker.recordUpdate( entry.id, templateHash: templateHash, @@ -300,16 +322,22 @@ class UpdateCommand extends Command { 'Review manually or use --force to overwrite: ${entry.destination}', ); Logger.info(' Compare with: $templatePath'); + if (diff) { + _emitUnifiedDiff(destination: entry.destination, before: destFile.readAsStringSync(), after: templateContent); + } return _UpdateResult(entry.id, 'warning', reason: 'template changed -- review or use --force'); } // --force: overwrite + if (diff) { + _emitUnifiedDiff(destination: entry.destination, before: destFile.readAsStringSync(), after: templateContent); + } if (!dryRun) { if (backup) { destFile.copySync('$destPath.bak'); Logger.info(' ${entry.id}: backed up to ${entry.destination}.bak'); } - File(templatePath).copySync(destPath); + destFile.writeAsStringSync(templateContent); tracker.recordUpdate( entry.id, templateHash: templateHash, @@ -330,6 +358,7 @@ class UpdateCommand extends Command { required bool force, required bool dryRun, required bool verbose, + required bool diff, }) { final destPath = '$repoRoot/${entry.destination}'; final destFile = File(destPath); @@ -346,8 +375,9 @@ class UpdateCommand extends Command { return _UpdateResult(entry.id, 'skipped', reason: 'no reference template'); } + final originalContent = destFile.readAsStringSync(); final refConfig = json.decode(refFile.readAsStringSync()) as Map; - final consumerConfig = json.decode(destFile.readAsStringSync()) as Map; + final consumerConfig = json.decode(originalContent) as Map; final addedKeys = []; _deepMergeNewKeys(refConfig, consumerConfig, addedKeys, prefix: ''); @@ -362,8 +392,12 @@ class UpdateCommand extends Command { Logger.info(' + $key'); } + final mergedContent = '${const JsonEncoder.withIndent(' ').convert(consumerConfig)}\n'; + if (diff) { + _emitUnifiedDiff(destination: entry.destination, before: originalContent, after: mergedContent); + } if (!dryRun) { - destFile.writeAsStringSync('${const JsonEncoder.withIndent(' ').convert(consumerConfig)}\n'); + destFile.writeAsStringSync(mergedContent); tracker.recordUpdate( entry.id, templateHash: computeFileHash(refPath), @@ -381,6 +415,7 @@ class UpdateCommand extends Command { TemplateEntry entry, { required bool dryRun, required bool verbose, + required bool diff, }) { final destPath = '$repoRoot/${entry.destination}'; final destFile = File(destPath); @@ -390,7 +425,8 @@ class UpdateCommand extends Command { return _UpdateResult(entry.id, 'warning', reason: 'file missing -- run init'); } - final autodocConfig = json.decode(destFile.readAsStringSync()) as Map; + final originalContent = destFile.readAsStringSync(); + final autodocConfig = json.decode(originalContent) as Map; final existingModules = (autodocConfig['modules'] as List).cast>(); final existingIds = existingModules.map((m) => m['id'] as String).toSet(); @@ -427,8 +463,12 @@ class UpdateCommand extends Command { return _UpdateResult(entry.id, 'skipped', reason: 'no new modules'); } + final regeneratedContent = '${const JsonEncoder.withIndent(' ').convert(autodocConfig)}\n'; + if (diff) { + _emitUnifiedDiff(destination: entry.destination, before: originalContent, after: regeneratedContent); + } if (!dryRun) { - destFile.writeAsStringSync('${const JsonEncoder.withIndent(' ').convert(autodocConfig)}\n'); + destFile.writeAsStringSync(regeneratedContent); } Logger.success(' ${entry.id}: added $newModuleCount new module(s)'); @@ -444,6 +484,7 @@ class UpdateCommand extends Command { required bool dryRun, required bool verbose, required bool backup, + required bool diff, }) { // Templated entries require a source skeleton if (entry.source == null) { @@ -492,6 +533,9 @@ class UpdateCommand extends Command { return _UpdateResult(entry.id, 'skipped', reason: 'output unchanged'); } + if (diff) { + _emitUnifiedDiff(destination: entry.destination, before: existingContent ?? '', after: rendered); + } if (!dryRun) { if (backup && destFile.existsSync()) { destFile.copySync('$destPath.bak'); @@ -518,6 +562,46 @@ class UpdateCommand extends Command { // Utilities // ═══════════════════════════════════════════════════════════════════════════ + void _emitUnifiedDiff({required String destination, required String before, required String after}) { + if (before == after) return; + Directory? tempDir; + try { + tempDir = Directory.systemTemp.createTempSync('runtime_ci_diff_'); + final beforeFile = File(p.join(tempDir.path, 'before.txt'))..writeAsStringSync(before); + final afterFile = File(p.join(tempDir.path, 'after.txt'))..writeAsStringSync(after); + + final result = Process.runSync('git', [ + '--no-pager', + 'diff', + '--no-index', + '--', + beforeFile.path, + afterFile.path, + ]); + var diffText = '${result.stdout ?? ''}${result.stderr ?? ''}'.trimRight(); + if (diffText.isEmpty) { + Logger.info(' [diff] ${entryPath(destination)} changed'); + return; + } + + final normalizedPath = entryPath(destination); + diffText = diffText.replaceAll('a${beforeFile.path}', 'a/$normalizedPath'); + diffText = diffText.replaceAll('b${afterFile.path}', 'b/$normalizedPath'); + diffText = diffText.replaceAll(beforeFile.path, 'a/$normalizedPath'); + diffText = diffText.replaceAll(afterFile.path, 'b/$normalizedPath'); + Logger.info(' [diff] ${entryPath(destination)}'); + Logger.info(diffText); + } catch (e) { + Logger.warn(' [diff] Could not render unified diff for ${entryPath(destination)}: $e'); + } finally { + if (tempDir != null && tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + } + } + + String entryPath(String destination) => destination.replaceAll(RegExp(r'^/+'), ''); + /// Recursively merge keys from [source] into [target] that don't exist in /// target. Records added key paths to [addedKeys]. void _deepMergeNewKeys( diff --git a/lib/src/cli/options/update_all_options.g.dart b/lib/src/cli/options/update_all_options.g.dart index 5271a54..d95617e 100644 --- a/lib/src/cli/options/update_all_options.g.dart +++ b/lib/src/cli/options/update_all_options.g.dart @@ -6,9 +6,14 @@ part of 'update_all_options.dart'; // CliGenerator // ************************************************************************** +T _$badNumberFormat(String source, String type, String argName) => + throw FormatException('Cannot parse "$source" into `$type` for option "$argName".'); + UpdateAllOptions _$parseUpdateAllOptionsResult(ArgResults result) => UpdateAllOptions( scanRoot: result['scan-root'] as String?, - concurrency: int.parse(result['concurrency'] as String), + concurrency: + int.tryParse(result['concurrency'] as String) ?? + _$badNumberFormat(result['concurrency'] as String, 'int', 'concurrency'), force: result['force'] as bool, workflows: result['workflows'] as bool, templates: result['templates'] as bool, diff --git a/lib/src/cli/options/update_options.dart b/lib/src/cli/options/update_options.dart index 54ba1e5..0782034 100644 --- a/lib/src/cli/options/update_options.dart +++ b/lib/src/cli/options/update_options.dart @@ -29,6 +29,10 @@ class UpdateOptions { @CliOption(help: 'Write .bak backup before overwriting files') final bool backup; + /// Show a unified diff preview for each file that would be updated. + @CliOption(help: 'Show unified diffs for updated files') + final bool diff; + const UpdateOptions({ this.force = false, this.templates = false, @@ -36,6 +40,7 @@ class UpdateOptions { this.workflows = false, this.autodoc = false, this.backup = false, + this.diff = false, }); factory UpdateOptions.fromArgResults(ArgResults results) { diff --git a/lib/src/cli/options/update_options.g.dart b/lib/src/cli/options/update_options.g.dart index 8738685..791b169 100644 --- a/lib/src/cli/options/update_options.g.dart +++ b/lib/src/cli/options/update_options.g.dart @@ -13,6 +13,7 @@ UpdateOptions _$parseUpdateOptionsResult(ArgResults result) => UpdateOptions( workflows: result['workflows'] as bool, autodoc: result['autodoc'] as bool, backup: result['backup'] as bool, + diff: result['diff'] as bool, ); ArgParser _$populateUpdateOptionsParser(ArgParser parser) => parser @@ -21,7 +22,8 @@ ArgParser _$populateUpdateOptionsParser(ArgParser parser) => parser ..addFlag('config', help: 'Only merge new keys into .runtime_ci/config.json') ..addFlag('workflows', help: 'Only update GitHub workflow files (.github/workflows/)') ..addFlag('autodoc', help: 'Re-scan lib/src/ and update autodoc.json modules') - ..addFlag('backup', help: 'Write .bak backup before overwriting files'); + ..addFlag('backup', help: 'Write .bak backup before overwriting files') + ..addFlag('diff', help: 'Show unified diffs for updated files'); final _$parserForUpdateOptions = _$populateUpdateOptionsParser(ArgParser()); diff --git a/lib/src/cli/utils/autodoc_scaffold.dart b/lib/src/cli/utils/autodoc_scaffold.dart index 3acebd7..033ec09 100644 --- a/lib/src/cli/utils/autodoc_scaffold.dart +++ b/lib/src/cli/utils/autodoc_scaffold.dart @@ -1,10 +1,50 @@ import 'dart:convert'; import 'dart:io'; +import 'package:path/path.dart' as p; + import '../../triage/utils/run_context.dart'; import 'logger.dart'; import 'sub_package_utils.dart'; +/// Relative path for the hierarchical docs index generated by `manage_cicd autodoc`. +/// +/// Uses a dedicated file (not docs/README.md) to avoid overwriting user-maintained +/// policy or documentation. Content is clearly marked as auto-generated. +const String kAutodocIndexPath = 'docs/AUTODOC_INDEX.md'; + +/// Resolves the output path for an autodoc module, scoping by [moduleSubPackage] +/// when present. +/// +/// Treats paths equal to `docs/` or `docs//...` as +/// already scoped to avoid duplicating the package segment (e.g. docs/pkg/pkg). +/// This prevents config drift when the configured path is already correct. +String resolveAutodocOutputPath({required String configuredOutputPath, required String? moduleSubPackage}) { + final normalized = p.posix.normalize(configuredOutputPath).replaceFirst(RegExp(r'^/+'), ''); + if (moduleSubPackage == null || moduleSubPackage.isEmpty) { + return normalized; + } + + final docsPrefix = 'docs/'; + // Only scope root docs paths. Sub-package paths like + // `packages//docs/...` are already explicitly scoped and must be + // preserved to avoid rewriting into `docs//packages/...`. + if (normalized != 'docs' && !normalized.startsWith(docsPrefix)) { + return normalized; + } + final stripped = normalized.startsWith(docsPrefix) ? normalized.substring(docsPrefix.length) : normalized; + // Root docs path (empty or bare "docs") -> scope to docs/. + if (stripped.isEmpty || stripped == 'docs') { + return '$docsPrefix$moduleSubPackage'; + } + // Treat exact package path (docs/) or docs//... as + // already scoped to avoid duplicating the package segment. + if (stripped == moduleSubPackage || stripped.startsWith('$moduleSubPackage/')) { + return '$docsPrefix$stripped'; + } + return '$docsPrefix$moduleSubPackage/$stripped'; +} + /// Capitalize a snake_case name into a display-friendly title. /// /// Splits on `_`, capitalizes the first letter of each non-empty segment, diff --git a/lib/src/cli/utils/sub_package_utils.dart b/lib/src/cli/utils/sub_package_utils.dart index 9a7d810..eb82ba4 100644 --- a/lib/src/cli/utils/sub_package_utils.dart +++ b/lib/src/cli/utils/sub_package_utils.dart @@ -250,6 +250,90 @@ Rules: '''; } + /// Build hierarchical documentation prompt instructions for multi-package repos. + /// + /// Used by the `documentation` command to ensure root README updates contain + /// both a repo-level narrative and clear per-package sections. + static String buildHierarchicalDocumentationInstructions({ + required String newVersion, + required List> subPackages, + }) { + if (subPackages.isEmpty) return ''; + final packageNames = subPackages.map((p) => p['name']).join(', '); + + final packageExamples = StringBuffer(); + for (final pkg in subPackages) { + packageExamples.writeln('### ${pkg['name']}'); + packageExamples.writeln('- Purpose: ...'); + packageExamples.writeln('- Key APIs / entry points: ...'); + packageExamples.writeln('- Notable changes in this release: ...'); + packageExamples.writeln(); + } + + return ''' + +## Hierarchical Documentation Format (Multi-Package) + +Because this is a multi-package repository ($packageNames), the updated +documentation MUST be hierarchical: + +1. A top-level overview that explains the full repository and how packages fit + together. +2. A dedicated section for each changed sub-package. +3. Explicit cross-links between root documentation and package-specific docs. + +Recommended structure: +```markdown +# + +## Overview +... + +## Package Architecture +... + +${packageExamples.toString().trimRight()} +``` + +Rules: +- Only use valid package names from: $packageNames +- Do not invent additional sub-packages +- Keep root docs coherent across all packages +- Mention package relationships where relevant +'''; + } + + /// Build hierarchical autodoc instructions for one module in a multi-package repo. + /// + /// [moduleSubPackage] is optional. When available, the generated docs should + /// explicitly identify the owning package and describe how this module + /// relates to sibling packages. + static String buildHierarchicalAutodocInstructions({ + required String moduleName, + required List> subPackages, + String? moduleSubPackage, + }) { + if (subPackages.isEmpty) return ''; + final packageNames = subPackages.map((p) => p['name']).join(', '); + final owningPackage = moduleSubPackage == null ? 'not explicitly assigned' : '"$moduleSubPackage"'; + + return ''' + +## Multi-Package Autodoc Context + +This repository is multi-package ($packageNames). + +Current module: "$moduleName" +Owning package: $owningPackage + +Documentation requirements: +- Call out which package owns this module. +- Describe how this module integrates with other packages when applicable. +- Use package-aware wording (avoid implying this module is the entire repo). +- Keep references and examples aligned with the module's owning package. +'''; + } + /// Enrich an existing prompt file with sub-package diff context and /// hierarchical formatting instructions. /// diff --git a/lib/src/cli/utils/workflow_generator.dart b/lib/src/cli/utils/workflow_generator.dart index 2b1f5b5..16cd555 100644 --- a/lib/src/cli/utils/workflow_generator.dart +++ b/lib/src/cli/utils/workflow_generator.dart @@ -71,6 +71,14 @@ bool _isSafeRunnerLabel(String s) { return RegExp(r'^[A-Za-z0-9_.-]+$').hasMatch(s); } +/// GitHub org segment used in git URL rewrites (e.g. open-runtime). +/// +/// Keep this conservative: no slashes, no whitespace, no control chars. +bool _isSafeGitOrgSegment(String s) { + if (s.contains(RegExp(r'[\r\n\t\x00-\x1f]'))) return false; + return RegExp(r'^[A-Za-z0-9_.-]+$').hasMatch(s); +} + /// Sub-package names are rendered into YAML and shell-facing messages. /// Keep them to a conservative character set. bool _isSafeSubPackageName(String s) { @@ -78,11 +86,25 @@ bool _isSafeSubPackageName(String s) { return RegExp(r'^[A-Za-z0-9_.-]+$').hasMatch(s); } +/// Resolves shared step partials for CI workflow generation. +/// Partials live in templates/github/workflows/partials/ and eliminate +/// duplicated step definitions across jobs (single_platform, multi_platform, web_test). +Template? _resolvePartial(String name) { + final path = TemplateResolver.resolveTemplatePath('github/workflows/partials/$name.mustache'); + final file = File(path); + if (!file.existsSync()) return null; + final source = file.readAsStringSync(); + return Template(source, htmlEscapeValues: false, name: 'partial:$name'); +} + /// Renders CI workflow YAML from a Mustache skeleton template and config.json. /// /// The skeleton uses `<% %>` delimiters (set via `{{=<% %>=}}` at the top) /// to avoid conflict with GitHub Actions' `${{ }}` syntax. /// +/// Shared step blocks are defined once in partials/ and included via +/// `{{> partial_name}}` to avoid duplication. +/// /// User-preservable sections are delimited by: /// `# --- BEGIN USER: ---` /// `# --- END USER: ---` @@ -175,7 +197,7 @@ class WorkflowGenerator { final skeleton = skeletonFile.readAsStringSync(); final context = _buildContext(); - final template = Template(skeleton, htmlEscapeValues: false); + final template = Template(skeleton, htmlEscapeValues: false, partialResolver: _resolvePartial); var rendered = template.renderString(context); // Re-insert user sections from existing file @@ -192,6 +214,7 @@ class WorkflowGenerator { final secretsRaw = ciConfig['secrets']; final secrets = secretsRaw is Map ? secretsRaw : {}; final subPackages = ciConfig['sub_packages'] as List? ?? []; + final gitOrgs = _resolveGitOrgs(ciConfig); // Build secrets list for env block (skip non-string values) final secretsList = >[]; @@ -261,6 +284,7 @@ class WorkflowGenerator { // Secrets / env 'has_secrets': secretsList.isNotEmpty, 'secrets_list': secretsList, + 'git_orgs': gitOrgs.map((org) => {'org': org}).toList(), // Sub-packages (filter out invalid entries) 'sub_packages': subPackages @@ -298,6 +322,25 @@ class WorkflowGenerator { return '120'; } + static List _resolveGitOrgs(Map ciConfig) { + final raw = ciConfig['git_orgs']; + final resolved = []; + if (raw is List) { + for (final value in raw) { + if (value is! String) continue; + final org = value.trim(); + if (org.isEmpty) continue; + if (!_isSafeGitOrgSegment(org)) continue; + if (resolved.contains(org)) continue; + resolved.add(org); + } + } + if (resolved.isEmpty) { + return const ['open-runtime', 'pieces-app']; + } + return resolved; + } + static String _resolveArtifactRetentionDays(dynamic raw) { if (raw is int && raw >= 1 && raw <= 90) return '$raw'; if (raw is String) { @@ -446,6 +489,34 @@ class WorkflowGenerator { } else if (pat is String && !_isSafeSecretIdentifier(pat)) { errors.add('ci.personal_access_token_secret "$pat" must be a safe identifier (e.g. GITHUB_TOKEN)'); } + final gitOrgs = ciConfig['git_orgs']; + if (gitOrgs != null) { + if (gitOrgs is! List) { + errors.add('ci.git_orgs must be an array, got ${gitOrgs.runtimeType}'); + } else if (gitOrgs.isEmpty) { + errors.add('ci.git_orgs must not be empty when provided'); + } else { + final seen = {}; + for (var i = 0; i < gitOrgs.length; i++) { + final org = gitOrgs[i]; + if (org is! String || org.trim().isEmpty) { + errors.add('ci.git_orgs[$i] must be a non-empty string'); + continue; + } + if (org != org.trim()) { + errors.add('ci.git_orgs[$i] must not have leading/trailing whitespace'); + continue; + } + if (!_isSafeGitOrgSegment(org)) { + errors.add('ci.git_orgs[$i] contains unsupported characters: "$org"'); + continue; + } + if (!seen.add(org)) { + errors.add('ci.git_orgs contains duplicate org "$org"'); + } + } + } + } final lineLength = ciConfig['line_length']; if (lineLength != null && lineLength is! int && lineLength is! String) { errors.add('ci.line_length must be a number or string, got ${lineLength.runtimeType}'); diff --git a/templates/config.json b/templates/config.json index fbff096..44f81c1 100644 --- a/templates/config.json +++ b/templates/config.json @@ -68,6 +68,8 @@ "_comment_artifact_retention_days": "Optional: retention for uploaded test artifacts in days (1-90).", "artifact_retention_days": 7, "personal_access_token_secret": "GITHUB_TOKEN", + "_comment_git_orgs": "Optional: organizations to rewrite SSH git URLs for in CI checkout/pub get steps.", + "git_orgs": ["open-runtime", "pieces-app"], "features": { "proto": false, "lfs": false, diff --git a/templates/github/workflows/ci.skeleton.yaml b/templates/github/workflows/ci.skeleton.yaml index b0d5d8f..3d0c5fb 100644 --- a/templates/github/workflows/ci.skeleton.yaml +++ b/templates/github/workflows/ci.skeleton.yaml @@ -93,106 +93,8 @@ jobs: <%/secrets_list%> <%/has_secrets%> steps: - # ── shared:checkout ── keep in sync with multi_platform ── - - uses: actions/checkout@v6.0.2 - with: -<%#format_check%> - ref: ${{ needs.auto-format.outputs.sha }} -<%/format_check%> - fetch-depth: 1 - persist-credentials: false -<%#lfs%> - lfs: true -<%/lfs%> - - # ── shared:git-config ── keep in sync with multi_platform ── - - name: Configure Git for HTTPS with Token - shell: bash - env: - GH_PAT: ${{ secrets.<%pat_secret%> || secrets.GITHUB_TOKEN }} - run: | - echo "::add-mask::${GH_PAT}" - git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${GH_PAT}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${GH_PAT}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" - - # ── shared:dart-setup ── keep in sync with multi_platform ── - - uses: dart-lang/setup-dart@v1.7.1 - with: - sdk: "<%dart_sdk%>" - - # ── shared:pub-cache ── keep in sync with multi_platform ── - # Windows: %LOCALAPPDATA%\Pub\Cache (Dart default). Unix: ~/.pub-cache - - name: Cache Dart pub dependencies - uses: actions/cache@v5.0.3 - with: - path: ${{ runner.os == 'Windows' && format('{0}\\Pub\\Cache', env.LOCALAPPDATA) || '~/.pub-cache' }} - key: ${{ runner.os }}-${{ runner.arch }}-dart-pub-${{ hashFiles('**/pubspec.yaml') }} - restore-keys: ${{ runner.os }}-${{ runner.arch }}-dart-pub- - - # ── shared:proto-setup ── keep in sync with multi_platform ── -<%#proto%> - - name: Install protoc - uses: arduino/setup-protoc@v3.0.0 - - - run: dart pub global activate protoc_plugin 25.0.0 - -<%/proto%> - # ── shared:pub-get ── keep in sync with multi_platform ── - - run: dart pub get - env: - GIT_LFS_SKIP_SMUDGE: "1" - -<%#build_runner%> - - name: Run build_runner - run: dart run build_runner build --delete-conflicting-outputs - -<%/build_runner%> - # ── shared:analysis-cache ── keep in sync with multi_platform ── -<%#analysis_cache%> - - name: Cache Dart analysis - uses: actions/cache@v5.0.3 - with: - path: ~/.dartServer - key: ${{ runner.os }}-${{ runner.arch }}-dart-analysis-${{ hashFiles('**/*.dart', '**/pubspec.yaml') }} - restore-keys: ${{ runner.os }}-${{ runner.arch }}-dart-analysis- - -<%/analysis_cache%> - # ── shared:proto-verify ── keep in sync with multi_platform ── -<%#proto%> - - name: Verify proto files - run: dart run runtime_ci_tooling:manage_cicd verify-protos - -<%/proto%> - # ── shared:analyze ── keep in sync with multi_platform ── -<%#managed_analyze%> - - name: Analyze - run: dart run runtime_ci_tooling:manage_cicd analyze -<%/managed_analyze%> -<%^managed_analyze%> - - name: Analyze - run: | - dart analyze 2>&1 | tee "$RUNNER_TEMP/analysis.txt" - if grep -q "^ error -" "$RUNNER_TEMP/analysis.txt"; then - echo "::error::Analysis errors found" - exit 1 - fi -<%/managed_analyze%> - - # ── shared:sub-packages ── keep in sync with multi_platform ── -<%#sub_packages%> - - name: Analyze (<%name%>) - working-directory: <%path%> - run: | - GIT_LFS_SKIP_SMUDGE=1 dart pub get - dart analyze 2>&1 | tee "$RUNNER_TEMP/sub_analysis.txt" - if grep -q "^ error -" "$RUNNER_TEMP/sub_analysis.txt"; then - echo "::error::Errors found in <%name%>" - exit 1 - fi - -<%/sub_packages%> +<%> shared_setup_through_build %> +<%> shared_analysis_block %> # --- BEGIN USER: pre-test --- # --- END USER: pre-test --- @@ -249,106 +151,8 @@ jobs: <%/secrets_list%> <%/has_secrets%> steps: - # ── shared:checkout ── keep in sync with single_platform ── - - uses: actions/checkout@v6.0.2 - with: -<%#format_check%> - ref: ${{ needs.auto-format.outputs.sha }} -<%/format_check%> - fetch-depth: 1 - persist-credentials: false -<%#lfs%> - lfs: true -<%/lfs%> - - # ── shared:git-config ── keep in sync with single_platform ── - - name: Configure Git for HTTPS with Token - shell: bash - env: - GH_PAT: ${{ secrets.<%pat_secret%> || secrets.GITHUB_TOKEN }} - run: | - echo "::add-mask::${GH_PAT}" - git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${GH_PAT}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${GH_PAT}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" - - # ── shared:dart-setup ── keep in sync with single_platform ── - - uses: dart-lang/setup-dart@v1.7.1 - with: - sdk: "<%dart_sdk%>" - - # ── shared:pub-cache ── keep in sync with single_platform ── - # Windows: %LOCALAPPDATA%\Pub\Cache (Dart default). Unix: ~/.pub-cache - - name: Cache Dart pub dependencies - uses: actions/cache@v5.0.3 - with: - path: ${{ runner.os == 'Windows' && format('{0}\\Pub\\Cache', env.LOCALAPPDATA) || '~/.pub-cache' }} - key: ${{ runner.os }}-${{ runner.arch }}-dart-pub-${{ hashFiles('**/pubspec.yaml') }} - restore-keys: ${{ runner.os }}-${{ runner.arch }}-dart-pub- - - # ── shared:proto-setup ── keep in sync with single_platform ── -<%#proto%> - - name: Install protoc - uses: arduino/setup-protoc@v3.0.0 - - - run: dart pub global activate protoc_plugin 25.0.0 - -<%/proto%> - # ── shared:pub-get ── keep in sync with single_platform ── - - run: dart pub get - env: - GIT_LFS_SKIP_SMUDGE: "1" - -<%#build_runner%> - - name: Run build_runner - run: dart run build_runner build --delete-conflicting-outputs - -<%/build_runner%> - # ── shared:analysis-cache ── keep in sync with single_platform ── -<%#analysis_cache%> - - name: Cache Dart analysis - uses: actions/cache@v5.0.3 - with: - path: ~/.dartServer - key: ${{ runner.os }}-${{ runner.arch }}-dart-analysis-${{ hashFiles('**/*.dart', '**/pubspec.yaml') }} - restore-keys: ${{ runner.os }}-${{ runner.arch }}-dart-analysis- - -<%/analysis_cache%> - # ── shared:proto-verify ── keep in sync with single_platform ── -<%#proto%> - - name: Verify proto files - run: dart run runtime_ci_tooling:manage_cicd verify-protos - -<%/proto%> - # ── shared:analyze ── keep in sync with single_platform ── -<%#managed_analyze%> - - name: Analyze - run: dart run runtime_ci_tooling:manage_cicd analyze -<%/managed_analyze%> -<%^managed_analyze%> - - name: Analyze - run: | - dart analyze 2>&1 | tee "$RUNNER_TEMP/analysis.txt" - if grep -q "^ error -" "$RUNNER_TEMP/analysis.txt"; then - echo "::error::Analysis errors found" - exit 1 - fi -<%/managed_analyze%> - - # ── shared:sub-packages ── keep in sync with single_platform ── -<%#sub_packages%> - - name: Analyze (<%name%>) - working-directory: <%path%> - run: | - GIT_LFS_SKIP_SMUDGE=1 dart pub get - dart analyze 2>&1 | tee "$RUNNER_TEMP/sub_analysis.txt" - if grep -q "^ error -" "$RUNNER_TEMP/sub_analysis.txt"; then - echo "::error::Errors found in <%name%>" - exit 1 - fi - -<%/sub_packages%> +<%> shared_setup_through_build %> +<%> shared_analysis_block %> test: needs: [pre-check, analyze<%#format_check%>, auto-format<%/format_check%>] if: needs.pre-check.outputs.should_run == 'true' @@ -364,66 +168,11 @@ jobs: <%/secrets_list%> <%/has_secrets%> steps: - # ── shared:checkout ── keep in sync with single_platform ── - - uses: actions/checkout@v6.0.2 - with: -<%#format_check%> - ref: ${{ needs.auto-format.outputs.sha }} -<%/format_check%> - fetch-depth: 1 - persist-credentials: false -<%#lfs%> - lfs: true -<%/lfs%> - - # ── shared:git-config ── keep in sync with single_platform ── - - name: Configure Git for HTTPS with Token - shell: bash - env: - GH_PAT: ${{ secrets.<%pat_secret%> || secrets.GITHUB_TOKEN }} - run: | - echo "::add-mask::${GH_PAT}" - git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${GH_PAT}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${GH_PAT}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" - - # ── shared:dart-setup ── keep in sync with single_platform ── - - uses: dart-lang/setup-dart@v1.7.1 - with: - sdk: "<%dart_sdk%>" - - # ── shared:pub-cache ── keep in sync with single_platform ── - # Windows: %LOCALAPPDATA%\Pub\Cache (Dart default). Unix: ~/.pub-cache - - name: Cache Dart pub dependencies - uses: actions/cache@v5.0.3 - with: - path: ${{ runner.os == 'Windows' && format('{0}\\Pub\\Cache', env.LOCALAPPDATA) || '~/.pub-cache' }} - key: ${{ runner.os }}-${{ runner.arch }}-dart-pub-${{ hashFiles('**/pubspec.yaml') }} - restore-keys: ${{ runner.os }}-${{ runner.arch }}-dart-pub- - - # ── shared:proto-setup ── keep in sync with single_platform ── -<%#proto%> - - name: Install protoc - uses: arduino/setup-protoc@v3.0.0 - - - run: dart pub global activate protoc_plugin 25.0.0 - -<%/proto%> - # ── shared:pub-get ── keep in sync with single_platform ── - - run: dart pub get - env: - GIT_LFS_SKIP_SMUDGE: "1" - -<%#build_runner%> - - name: Run build_runner - run: dart run build_runner build --delete-conflicting-outputs - -<%/build_runner%> +<%> shared_setup_through_build %> # --- BEGIN USER: pre-test --- # --- END USER: pre-test --- - # ── shared:test ── keep in sync with single_platform ── + # ── shared:test ── <%#managed_test%> - name: Test shell: bash @@ -476,68 +225,8 @@ jobs: <%/secrets_list%> <%/has_secrets%> steps: - # ── shared:checkout ── keep in sync with single_platform / multi_platform ── - - uses: actions/checkout@v6.0.2 - with: -<%#format_check%> - ref: ${{ needs.auto-format.outputs.sha }} -<%/format_check%> - fetch-depth: 1 - persist-credentials: false -<%#lfs%> - lfs: true -<%/lfs%> - - # ── shared:git-config ── keep in sync with single_platform / multi_platform ── - - name: Configure Git for HTTPS with Token - shell: bash - env: - GH_PAT: ${{ secrets.<%pat_secret%> || secrets.GITHUB_TOKEN }} - run: | - echo "::add-mask::${GH_PAT}" - git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${GH_PAT}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${GH_PAT}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" - - # ── shared:dart-setup ── keep in sync with single_platform / multi_platform ── - - uses: dart-lang/setup-dart@v1.7.1 - with: - sdk: "<%dart_sdk%>" - - # ── shared:pub-cache ── keep in sync with single_platform / multi_platform ── - # Windows: %LOCALAPPDATA%\Pub\Cache (Dart default). Unix: ~/.pub-cache - - name: Cache Dart pub dependencies - uses: actions/cache@v5.0.3 - with: - path: ${{ runner.os == 'Windows' && format('{0}\\Pub\\Cache', env.LOCALAPPDATA) || '~/.pub-cache' }} - key: ${{ runner.os }}-${{ runner.arch }}-dart-pub-${{ hashFiles('**/pubspec.yaml') }} - restore-keys: ${{ runner.os }}-${{ runner.arch }}-dart-pub- - - # ── shared:proto-setup ── keep in sync with single_platform / multi_platform ── -<%#proto%> - - name: Install protoc - uses: arduino/setup-protoc@v3.0.0 - - - run: dart pub global activate protoc_plugin 25.0.0 - -<%/proto%> - # ── shared:pub-get ── keep in sync with single_platform / multi_platform ── - - run: dart pub get - env: - GIT_LFS_SKIP_SMUDGE: "1" - -<%#build_runner%> - - name: Run build_runner - run: dart run build_runner build --delete-conflicting-outputs - -<%/build_runner%> - # ── shared:proto-verify ── keep in sync with single_platform / multi_platform ── -<%#proto%> - - name: Verify proto files - run: dart run runtime_ci_tooling:manage_cicd verify-protos - -<%/proto%> +<%> shared_setup_through_build %> +<%> shared_proto_verify %> - name: Setup Chrome id: setup-chrome uses: browser-actions/setup-chrome@4f8e94349a351df0f048634f25fec36c3c91eded # v2.1.1 diff --git a/templates/github/workflows/partials/shared_analysis_block.mustache b/templates/github/workflows/partials/shared_analysis_block.mustache new file mode 100644 index 0000000..250cbfd --- /dev/null +++ b/templates/github/workflows/partials/shared_analysis_block.mustache @@ -0,0 +1,45 @@ +{{=<% %>=}} + # ── shared:analysis-cache ── +<%#analysis_cache%> + - name: Cache Dart analysis + uses: actions/cache@v5.0.3 + with: + path: ~/.dartServer + key: ${{ runner.os }}-${{ runner.arch }}-dart-analysis-${{ hashFiles('**/*.dart', '**/pubspec.yaml') }} + restore-keys: ${{ runner.os }}-${{ runner.arch }}-dart-analysis- + +<%/analysis_cache%> + # ── shared:proto-verify ── +<%#proto%> + - name: Verify proto files + run: dart run runtime_ci_tooling:manage_cicd verify-protos + +<%/proto%> + # ── shared:analyze ── +<%#managed_analyze%> + - name: Analyze + run: dart run runtime_ci_tooling:manage_cicd analyze +<%/managed_analyze%> +<%^managed_analyze%> + - name: Analyze + run: | + dart analyze 2>&1 | tee "$RUNNER_TEMP/analysis.txt" + if grep -q "^ error -" "$RUNNER_TEMP/analysis.txt"; then + echo "::error::Analysis errors found" + exit 1 + fi +<%/managed_analyze%> + + # ── shared:sub-packages ── +<%#sub_packages%> + - name: Analyze (<%name%>) + working-directory: <%path%> + run: | + GIT_LFS_SKIP_SMUDGE=1 dart pub get + dart analyze 2>&1 | tee "$RUNNER_TEMP/sub_analysis.txt" + if grep -q "^ error -" "$RUNNER_TEMP/sub_analysis.txt"; then + echo "::error::Errors found in <%name%>" + exit 1 + fi + +<%/sub_packages%> diff --git a/templates/github/workflows/partials/shared_proto_verify.mustache b/templates/github/workflows/partials/shared_proto_verify.mustache new file mode 100644 index 0000000..68da3f1 --- /dev/null +++ b/templates/github/workflows/partials/shared_proto_verify.mustache @@ -0,0 +1,7 @@ +{{=<% %>=}} + # ── shared:proto-verify ── +<%#proto%> + - name: Verify proto files + run: dart run runtime_ci_tooling:manage_cicd verify-protos + +<%/proto%> diff --git a/templates/github/workflows/partials/shared_setup_through_build.mustache b/templates/github/workflows/partials/shared_setup_through_build.mustache new file mode 100644 index 0000000..9628ee5 --- /dev/null +++ b/templates/github/workflows/partials/shared_setup_through_build.mustache @@ -0,0 +1,58 @@ +{{=<% %>=}} + # ── shared:checkout ── + - uses: actions/checkout@v6.0.2 + with: +<%#format_check%> + ref: ${{ needs.auto-format.outputs.sha }} +<%/format_check%> + fetch-depth: 1 + persist-credentials: false +<%#lfs%> + lfs: true +<%/lfs%> + + # ── shared:git-config ── + - name: Configure Git for HTTPS with Token + shell: bash + env: + GH_PAT: ${{ secrets.<%pat_secret%> || secrets.GITHUB_TOKEN }} + run: | + echo "::add-mask::${GH_PAT}" + git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "git@github.com:" + git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "ssh://git@github.com/" +<%#git_orgs%> + git config --global url."https://x-access-token:${GH_PAT}@github.com/<%org%>/".insteadOf "git@github.com:<%org%>/" +<%/git_orgs%> + + # ── shared:dart-setup ── + - uses: dart-lang/setup-dart@v1.7.1 + with: + sdk: "<%dart_sdk%>" + + # ── shared:pub-cache ── + # Windows: %LOCALAPPDATA%\Pub\Cache (Dart default). Unix: ~/.pub-cache + - name: Cache Dart pub dependencies + uses: actions/cache@v5.0.3 + with: + path: ${{ runner.os == 'Windows' && format('{0}\\Pub\\Cache', env.LOCALAPPDATA) || '~/.pub-cache' }} + key: ${{ runner.os }}-${{ runner.arch }}-dart-pub-${{ hashFiles('**/pubspec.yaml') }} + restore-keys: ${{ runner.os }}-${{ runner.arch }}-dart-pub- + + # ── shared:proto-setup ── +<%#proto%> + - name: Install protoc + uses: arduino/setup-protoc@v3.0.0 + + - run: dart pub global activate protoc_plugin 25.0.0 + +<%/proto%> + # ── shared:pub-get ── + - run: dart pub get + env: + GIT_LFS_SKIP_SMUDGE: "1" + +<%#build_runner%> + - name: Run build_runner + run: dart run build_runner build --delete-conflicting-outputs + +<%/build_runner%> diff --git a/test/cli_utils_test.dart b/test/cli_utils_test.dart index d1fe25d..cb5a475 100644 --- a/test/cli_utils_test.dart +++ b/test/cli_utils_test.dart @@ -4,6 +4,8 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:test/test.dart' hide TestFailure; +import 'package:runtime_ci_tooling/src/cli/utils/autodoc_scaffold.dart' + show kAutodocIndexPath, resolveAutodocOutputPath; import 'package:runtime_ci_tooling/src/cli/utils/process_runner.dart'; import 'package:runtime_ci_tooling/src/cli/utils/repo_utils.dart'; import 'package:runtime_ci_tooling/src/cli/utils/step_summary.dart'; @@ -684,6 +686,111 @@ void main() { }); }); + group('SubPackageUtils hierarchical instruction builders', () { + final subPackages = >[ + {'name': 'core', 'path': 'packages/core'}, + {'name': 'api', 'path': 'packages/api'}, + ]; + + test('buildHierarchicalDocumentationInstructions includes package structure', () { + final instructions = SubPackageUtils.buildHierarchicalDocumentationInstructions( + newVersion: '1.2.3', + subPackages: subPackages, + ); + + expect(instructions, contains('Hierarchical Documentation Format')); + expect(instructions, contains('core')); + expect(instructions, contains('api')); + expect(instructions, contains('top-level overview')); + }); + + test('buildHierarchicalAutodocInstructions includes module and package context', () { + final instructions = SubPackageUtils.buildHierarchicalAutodocInstructions( + moduleName: 'Analyzer Engine', + subPackages: subPackages, + moduleSubPackage: 'core', + ); + + expect(instructions, contains('Multi-Package Autodoc Context')); + expect(instructions, contains('Analyzer Engine')); + expect(instructions, contains('"core"')); + expect(instructions, contains('core, api')); + }); + + test('hierarchical instruction builders return empty for single-package repos', () { + expect( + SubPackageUtils.buildHierarchicalDocumentationInstructions(newVersion: '1.2.3', subPackages: const []), + isEmpty, + ); + expect( + SubPackageUtils.buildHierarchicalAutodocInstructions( + moduleName: 'Any', + subPackages: const [], + moduleSubPackage: null, + ), + isEmpty, + ); + }); + }); + + group('autodoc index path', () { + test('uses dedicated file under docs, not README.md', () { + expect(kAutodocIndexPath, isNot(equals('docs/README.md'))); + expect(kAutodocIndexPath, startsWith('docs/')); + expect(kAutodocIndexPath, endsWith('.md')); + }); + + test('path indicates auto-generated content', () { + expect(kAutodocIndexPath, contains('AUTODOC')); + expect(kAutodocIndexPath, contains('INDEX')); + }); + }); + + group('resolveAutodocOutputPath', () { + test('returns configured path unchanged when no moduleSubPackage', () { + expect(resolveAutodocOutputPath(configuredOutputPath: 'docs/foo', moduleSubPackage: null), equals('docs/foo')); + expect(resolveAutodocOutputPath(configuredOutputPath: 'docs', moduleSubPackage: null), equals('docs')); + }); + + test('treats docs/ as already scoped (no duplication)', () { + expect( + resolveAutodocOutputPath(configuredOutputPath: 'docs/my_pkg', moduleSubPackage: 'my_pkg'), + equals('docs/my_pkg'), + ); + }); + + test('treats docs//nested as already scoped', () { + expect( + resolveAutodocOutputPath(configuredOutputPath: 'docs/my_pkg/api', moduleSubPackage: 'my_pkg'), + equals('docs/my_pkg/api'), + ); + }); + + test('scopes unscoped path when moduleSubPackage present', () { + expect(resolveAutodocOutputPath(configuredOutputPath: 'docs', moduleSubPackage: 'my_pkg'), equals('docs/my_pkg')); + expect( + resolveAutodocOutputPath(configuredOutputPath: 'docs/other', moduleSubPackage: 'my_pkg'), + equals('docs/my_pkg/other'), + ); + }); + + test('preserves sub-package scoped docs paths outside root docs/', () { + expect( + resolveAutodocOutputPath(configuredOutputPath: 'packages/core/docs/utils/', moduleSubPackage: 'core'), + equals('packages/core/docs/utils'), + ); + }); + + test('normalization is idempotent (no drift across runs)', () { + const path = 'docs/my_pkg'; + const subPkg = 'my_pkg'; + final first = resolveAutodocOutputPath(configuredOutputPath: path, moduleSubPackage: subPkg); + final second = resolveAutodocOutputPath(configuredOutputPath: first, moduleSubPackage: subPkg); + expect(first, equals(second)); + expect(first, equals('docs/my_pkg')); + }); + }); + group('CiProcessRunner.exec', () { test('fatal path exits with process exit code after flushing stdout/stderr', () async { final scriptPath = p.join(p.current, 'test', 'scripts', 'fatal_exit_probe.dart'); diff --git a/test/test_command_test.dart b/test/test_command_test.dart index 7d96468..d9c439c 100644 --- a/test/test_command_test.dart +++ b/test/test_command_test.dart @@ -178,5 +178,114 @@ void main() { throwsA(isA<_TestExit>().having((e) => e.code, 'code', 1)), ); }); + + test('exits when root tests exceed process timeout', () async { + writeRootPubspec(includeTest: true); + Directory(p.join(tempDir.path, 'test')).createSync(recursive: true); + File(p.join(tempDir.path, 'test', 'slow_test.dart')).writeAsStringSync(''' +import 'dart:async'; + +import 'package:test/test.dart'; + +void main() { + test('slow', () async { + await Future.delayed(const Duration(seconds: 5)); + expect(true, isTrue); + }); +} +'''); + + final pubGet = await Process.run('dart', ['pub', 'get'], workingDirectory: tempDir.path); + expect(pubGet.exitCode, equals(0), reason: 'dart pub get must succeed'); + + await expectLater( + () => TestCommand.runWithRoot( + tempDir.path, + processTimeout: const Duration(milliseconds: 1), + exitHandler: _throwingExit, + environment: const {}, + ), + throwsA(isA<_TestExit>().having((e) => e.code, 'code', 1)), + ); + }); + + test('exits when a sub-package test run fails', () async { + writeRootPubspec(); + writeSubPackageConfig([ + {'name': 'pkg_fail', 'path': 'packages/pkg_fail'}, + ]); + + final pkgDir = Directory(p.join(tempDir.path, 'packages', 'pkg_fail'))..createSync(recursive: true); + File(p.join(pkgDir.path, 'pubspec.yaml')).writeAsStringSync(''' +name: pkg_fail +version: 0.0.0 +environment: + sdk: ^3.0.0 +dev_dependencies: + test: ^1.24.0 +'''); + Directory(p.join(pkgDir.path, 'test')).createSync(recursive: true); + File(p.join(pkgDir.path, 'test', 'failing_test.dart')).writeAsStringSync(''' +import 'package:test/test.dart'; + +void main() { + test('fails', () => expect(1, equals(2))); +} +'''); + + await expectLater( + () => TestCommand.runWithRoot(tempDir.path, exitHandler: _throwingExit, environment: const {}), + throwsA(isA<_TestExit>().having((e) => e.code, 'code', 1)), + ); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('exits when mixed sub-package outcomes include at least one failure', () async { + writeRootPubspec(); + writeSubPackageConfig([ + {'name': 'pkg_pass', 'path': 'packages/pkg_pass'}, + {'name': 'pkg_fail', 'path': 'packages/pkg_fail'}, + ]); + + final passDir = Directory(p.join(tempDir.path, 'packages', 'pkg_pass'))..createSync(recursive: true); + File(p.join(passDir.path, 'pubspec.yaml')).writeAsStringSync(''' +name: pkg_pass +version: 0.0.0 +environment: + sdk: ^3.0.0 +dev_dependencies: + test: ^1.24.0 +'''); + Directory(p.join(passDir.path, 'test')).createSync(recursive: true); + File(p.join(passDir.path, 'test', 'passing_test.dart')).writeAsStringSync(''' +import 'package:test/test.dart'; + +void main() { + test('passes', () => expect(2 + 2, equals(4))); +} +'''); + + final failDir = Directory(p.join(tempDir.path, 'packages', 'pkg_fail'))..createSync(recursive: true); + File(p.join(failDir.path, 'pubspec.yaml')).writeAsStringSync(''' +name: pkg_fail +version: 0.0.0 +environment: + sdk: ^3.0.0 +dev_dependencies: + test: ^1.24.0 +'''); + Directory(p.join(failDir.path, 'test')).createSync(recursive: true); + File(p.join(failDir.path, 'test', 'failing_test.dart')).writeAsStringSync(''' +import 'package:test/test.dart'; + +void main() { + test('fails', () => expect(true, isFalse)); +} +'''); + + await expectLater( + () => TestCommand.runWithRoot(tempDir.path, exitHandler: _throwingExit, environment: const {}), + throwsA(isA<_TestExit>().having((e) => e.code, 'code', 1)), + ); + }, timeout: const Timeout(Duration(minutes: 2))); }); } diff --git a/test/update_command_test.dart b/test/update_command_test.dart new file mode 100644 index 0000000..79e3c42 --- /dev/null +++ b/test/update_command_test.dart @@ -0,0 +1,101 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +/// Tests for the update command, including PR #36: --diff must emit unified +/// diff on the local-customization skip path for overwritable files. +void main() { + group('UpdateCommand overwritable local-customization --diff', () { + late Directory tempDir; + late String packagePath; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('update_cmd_'); + // Package root = CWD when running `dart test` from package root + packagePath = p.normalize(Directory.current.path); + }); + + tearDown(() { + if (tempDir.existsSync()) tempDir.deleteSync(recursive: true); + }); + + test('emits unified diff when skipping due to local customizations and --diff is set', () async { + // Consumer repo: pubspec, config, overwritable file with local edits, + // template_versions with consumer_hash != current file (local changes). + final repoRoot = tempDir.path; + + // pubspec with path dep to runtime_ci_tooling (different name to avoid self-dep) + File(p.join(repoRoot, 'pubspec.yaml')).writeAsStringSync(''' +name: update_test_consumer +version: 0.0.0 +environment: + sdk: ^3.9.0 +dependencies: + runtime_ci_tooling: + path: $packagePath +'''); + + // .runtime_ci/config.json (repository.name must match pubspec for RepoUtils) + Directory(p.join(repoRoot, '.runtime_ci')).createSync(recursive: true); + File(p.join(repoRoot, '.runtime_ci', 'config.json')).writeAsStringSync( + json.encode({ + 'repository': {'name': 'update_test_consumer', 'owner': 'test'}, + }), + ); + + // Consumer file with local modifications (differs from template) + const localContent = '{"_comment":"local customization","model":{"maxSessionTurns":99}}\n'; + Directory(p.join(repoRoot, '.gemini')).createSync(recursive: true); + File(p.join(repoRoot, '.gemini', 'settings.json')).writeAsStringSync(localContent); + + // Template versions: consumer_hash = hash of "original" content so we + // detect local changes. hash = old template hash so template appears + // changed (we enter update path, not "up to date"). + final originalContent = '{"_comment":"original"}\n'; + final originalHash = sha256.convert(originalContent.codeUnits).toString(); + const oldTemplateHash = '0000000000000000000000000000000000000000000000000000000000000001'; + + File(p.join(repoRoot, '.runtime_ci', 'template_versions.json')).writeAsStringSync( + json.encode({ + 'tooling_version': '0.0.0', + 'updated_at': DateTime.now().toUtc().toIso8601String(), + 'templates': { + 'gemini_settings': { + 'hash': oldTemplateHash, + 'consumer_hash': originalHash, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }, + }, + }), + ); + + // Resolve dependencies so package_config.json exists + final pubGet = await Process.run('dart', ['pub', 'get'], workingDirectory: repoRoot); + expect(pubGet.exitCode, equals(0), reason: 'pub get must succeed'); + + // Run manage_cicd update --diff --templates from temp repo + final result = await Process.run( + 'dart', + ['run', 'runtime_ci_tooling:manage_cicd', 'update', '--diff', '--templates'], + workingDirectory: repoRoot, + runInShell: false, + environment: {'PATH': Platform.environment['PATH'] ?? ''}, + ); + + final stdout = result.stdout as String; + final stderr = result.stderr as String; + final combined = '$stdout\n$stderr'; + + expect(combined, contains('local customizations detected'), reason: 'Should warn about local customizations'); + expect( + combined, + contains('[diff]'), + reason: 'PR #36: --diff must emit diff preview on local-customization skip path', + ); + expect(combined, contains('.gemini/settings.json'), reason: 'Diff should reference the overwritable file path'); + }); + }); +} diff --git a/test/workflow_generator_test.dart b/test/workflow_generator_test.dart index 382a9de..7137056 100644 --- a/test/workflow_generator_test.dart +++ b/test/workflow_generator_test.dart @@ -13,6 +13,7 @@ Map _validConfig({ List? platforms, Map? secrets, String? pat, + List? gitOrgs, dynamic lineLength, List? subPackages, Map? runnerOverrides, @@ -24,6 +25,7 @@ Map _validConfig({ if (platforms != null) 'platforms': platforms, if (secrets != null) 'secrets': secrets, if (pat != null) 'personal_access_token_secret': pat, + if (gitOrgs != null) 'git_orgs': gitOrgs, if (lineLength != null) 'line_length': lineLength, if (subPackages != null) 'sub_packages': subPackages, if (runnerOverrides != null) 'runner_overrides': runnerOverrides, @@ -322,6 +324,41 @@ void main() { }); }); + // ---- git_orgs ---- + group('git_orgs', () { + test('non-list git_orgs produces error', () { + final config = _validConfig(); + config['git_orgs'] = 'open-runtime'; + final errors = WorkflowGenerator.validate(config); + expect(errors, anyElement(contains('git_orgs must be an array'))); + }); + + test('empty git_orgs list produces error', () { + final errors = WorkflowGenerator.validate(_validConfig(gitOrgs: [])); + expect(errors, anyElement(contains('git_orgs must not be empty'))); + }); + + test('git_orgs entry with whitespace produces error', () { + final errors = WorkflowGenerator.validate(_validConfig(gitOrgs: [' open-runtime '])); + expect(errors, anyElement(contains('leading/trailing whitespace'))); + }); + + test('git_orgs entry with unsupported characters produces error', () { + final errors = WorkflowGenerator.validate(_validConfig(gitOrgs: ['open/runtime'])); + expect(errors, anyElement(contains('unsupported characters'))); + }); + + test('git_orgs duplicate entries produce error', () { + final errors = WorkflowGenerator.validate(_validConfig(gitOrgs: ['open-runtime', 'open-runtime'])); + expect(errors, anyElement(contains('duplicate org "open-runtime"'))); + }); + + test('valid git_orgs list passes', () { + final errors = WorkflowGenerator.validate(_validConfig(gitOrgs: ['open-runtime', 'pieces-app', 'acme'])); + expect(errors.where((e) => e.contains('git_orgs')), isEmpty); + }); + }); + // ---- line_length ---- group('line_length', () { test('non-numeric line_length produces error', () { @@ -1687,6 +1724,22 @@ $base expect(rendered, isNot(contains('TOKEN="\${{ secrets.'))); }); + test('custom git_orgs render configurable org rewrite rules', () { + final config = _minimalValidConfig() + ..['git_orgs'] = ['acme-runtime'] + ..['personal_access_token_secret'] = 'GITHUB_TOKEN'; + final gen = WorkflowGenerator(ciConfig: config, toolingVersion: '0.0.0-test'); + final rendered = gen.render(); + expect( + rendered, + contains( + 'git config --global url."https://x-access-token:\${GH_PAT}@github.com/acme-runtime/".insteadOf "git@github.com:acme-runtime/"', + ), + ); + expect(rendered, isNot(contains('git@github.com:open-runtime/'))); + expect(rendered, isNot(contains('git@github.com:pieces-app/'))); + }); + test('web_test without format_check: web-test needs omits auto-format', () { final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(webTest: true), toolingVersion: '0.0.0-test'); final rendered = gen.render(); @@ -1917,6 +1970,42 @@ $base expect(rendered, contains("'~/.pub-cache'")); expect(rendered, contains("runner.os == 'Windows'")); }); + + // Issue #12: shared step partials — verify dedup behavior + test('shared partials are resolved (no raw partial tags in output)', () { + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); + final rendered = gen.render(); + expect(rendered, isNot(contains('<%> shared_'))); + expect(rendered, isNot(contains('shared_setup_through_build'))); + expect(rendered, isNot(contains('shared_analysis_block'))); + }); + + test('shared step markers from partials appear in rendered output', () { + final gen = WorkflowGenerator(ciConfig: _minimalValidConfig(), toolingVersion: '0.0.0-test'); + final rendered = gen.render(); + expect(rendered, contains('# ── shared:checkout ──')); + expect(rendered, contains('# ── shared:git-config ──')); + expect(rendered, contains('# ── shared:dart-setup ──')); + expect(rendered, contains('# ── shared:pub-cache ──')); + }); + + test('multi-platform jobs use same shared setup partial (analyze and test)', () { + final gen = WorkflowGenerator( + ciConfig: _minimalValidConfig(platforms: ['ubuntu', 'macos']), + toolingVersion: '0.0.0-test', + ); + final rendered = gen.render(); + final analyzeIdx = rendered.indexOf(' analyze:'); + final testIdx = rendered.indexOf(' test:'); + expect(analyzeIdx, isNot(-1)); + expect(testIdx, isNot(-1)); + final analyzeSection = rendered.substring(analyzeIdx, testIdx); + final testSection = rendered.substring(testIdx); + expect(analyzeSection, contains('Configure Git for HTTPS with Token')); + expect(testSection, contains('Configure Git for HTTPS with Token')); + expect(analyzeSection, contains('Cache Dart pub dependencies')); + expect(testSection, contains('Cache Dart pub dependencies')); + }); }); }); }