From b3e5e68d615d50438b4ea156373d3db455ded903 Mon Sep 17 00:00:00 2001 From: Tsavo Knott Date: Tue, 24 Feb 2026 12:49:53 -0500 Subject: [PATCH 1/6] feat: add audit and audit-all commands for pubspec validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two new manage_cicd commands that validate pubspec.yaml dependency declarations against the external_workspace_packages.yaml registry. `manage_cicd audit` — validate a single pubspec.yaml: - 7 validation rules: bare deps, wrong org/repo, missing/wrong tag_pattern, stale version, wrong URL format (HTTPS vs SSH) - Auto-detects registry by walking up directory tree - --fix flag for auto-remediation using yaml_edit - --severity filter (error/warning/info) `manage_cicd audit-all` — batch validate all pubspecs: - Recursive discovery with configurable exclusions - Worker pool concurrency (default 4) - Per-file status lines + detailed findings + aggregate summary - --fix applies fixes across all discovered pubspecs Shared engine in lib/src/cli/utils/audit/: - PackageRegistry — loads registry YAML, O(1) lookup by dep name - PubspecAuditor — audit + fix logic with yaml_edit - AuditFinding — severity/category data model ref open-runtime/aot_monorepo#411 Co-Authored-By: Claude Opus 4.6 --- lib/src/cli/commands/audit_all_command.dart | 330 ++++++++++++++++++++ lib/src/cli/commands/audit_command.dart | 267 ++++++++++++++++ lib/src/cli/manage_cicd_cli.dart | 4 + pubspec.yaml | 6 +- 4 files changed, 604 insertions(+), 3 deletions(-) create mode 100644 lib/src/cli/commands/audit_all_command.dart create mode 100644 lib/src/cli/commands/audit_command.dart diff --git a/lib/src/cli/commands/audit_all_command.dart b/lib/src/cli/commands/audit_all_command.dart new file mode 100644 index 0000000..09f993b --- /dev/null +++ b/lib/src/cli/commands/audit_all_command.dart @@ -0,0 +1,330 @@ +// ignore_for_file: avoid_print + +import 'dart:async'; +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:path/path.dart' as p; + +import '../manage_cicd_cli.dart'; +import '../utils/audit/audit_finding.dart'; +import '../utils/audit/package_registry.dart'; +import '../utils/audit/pubspec_auditor.dart'; +import '../utils/logger.dart'; + +/// Recursively audit all pubspec.yaml files under a directory against the +/// package registry. +/// +/// Discovers every `pubspec.yaml` in the tree, loads the shared package +/// registry once, and runs the auditor against each pubspec with configurable +/// concurrency. Produces a per-file summary and aggregated totals. +class AuditAllCommand extends Command { + @override + final String name = 'audit-all'; + + @override + final String description = 'Recursively audit all pubspec.yaml files under a directory against the package registry.'; + + AuditAllCommand() { + argParser + ..addOption('path', help: 'Root directory to scan. Defaults to current working directory.') + ..addOption( + 'registry', + help: + 'Path to external_workspace_packages.yaml. ' + 'Auto-detected from monorepo root when omitted.', + ) + ..addFlag('fix', defaultsTo: false, help: 'Automatically fix found issues in-place.') + ..addOption( + 'severity', + defaultsTo: 'error', + allowed: ['error', 'warning', 'info'], + help: 'Minimum severity to report.', + ) + ..addOption('concurrency', defaultsTo: '4', help: 'Max concurrent audit operations.') + ..addMultiOption( + 'exclude', + defaultsTo: ['.dart_tool', 'build', 'node_modules', '.git'], + help: 'Directory names to exclude from scan.', + ); + } + + @override + Future run() async { + final global = ManageCicdCli.parseGlobalOptions(globalResults); + final dryRun = global.dryRun; + final verbose = global.verbose; + + final scanRoot = argResults!['path'] as String? ?? Directory.current.path; + final registryPath = argResults!['registry'] as String?; + final fix = argResults!['fix'] as bool; + final severityFilter = _parseSeverity(argResults!['severity'] as String); + final concurrency = int.tryParse(argResults!['concurrency'] as String) ?? 4; + final excludeDirs = (argResults!['exclude'] as List).toSet(); + + // ── 1. Resolve scan root ────────────────────────────────────────────── + final rootDir = Directory(scanRoot); + if (!rootDir.existsSync()) { + Logger.error('Scan root does not exist: $scanRoot'); + exit(1); + } + + Logger.header('audit-all: Scanning ${rootDir.path}'); + + // ── 2. Resolve registry ─────────────────────────────────────────────── + final resolvedRegistry = registryPath ?? _autoDetectRegistry(scanRoot); + if (resolvedRegistry == null) { + Logger.error( + 'Could not auto-detect external_workspace_packages.yaml. ' + 'Specify --registry explicitly.', + ); + exit(1); + } + + if (!File(resolvedRegistry).existsSync()) { + Logger.error('Registry file not found: $resolvedRegistry'); + exit(1); + } + + if (verbose) { + Logger.info('Registry: $resolvedRegistry'); + } + + // ── 3. Discover pubspec.yaml files ──────────────────────────────────── + final pubspecs = _discoverPubspecs(rootDir, excludeDirs); + + if (pubspecs.isEmpty) { + Logger.warn('No pubspec.yaml files found under ${rootDir.path}'); + return; + } + + Logger.info('Found ${pubspecs.length} pubspec.yaml files\n'); + + // ── 4. Load registry & create auditor ───────────────────────────────── + final registry = PackageRegistry.load(resolvedRegistry); + final auditor = PubspecAuditor(registry: registry); + + // ── 5. Audit with worker pool ───────────────────────────────────────── + final effectiveConcurrency = concurrency.clamp(1, pubspecs.length); + final results = <_AuditResult>[]; + var index = 0; + + Future worker() async { + while (true) { + if (index >= pubspecs.length) break; + final currentIndex = index; + index++; + + final pubspecPath = pubspecs[currentIndex]; + final findings = auditor.auditPubspec(pubspecPath); + + // Filter by minimum severity. + final filtered = findings.where((f) => f.severity.index <= severityFilter.index).toList(); + + results.add(_AuditResult(index: currentIndex, pubspecPath: pubspecPath, findings: filtered)); + } + } + + final workers = >[]; + for (var i = 0; i < effectiveConcurrency; i++) { + workers.add(worker()); + } + await Future.wait(workers); + + // Sort by original discovery order. + results.sort((a, b) => a.index.compareTo(b.index)); + + // ── 6. Per-pubspec status lines ─────────────────────────────────────── + final total = pubspecs.length; + final pubspecsWithFindings = <_AuditResult>[]; + + for (final result in results) { + final relative = p.relative(result.pubspecPath, from: rootDir.path); + final label = '[${result.index + 1}/$total]'; + + if (result.findings.isEmpty) { + Logger.success('$label $relative ${'.' * _dots(relative, label, total)} OK'); + } else { + final errors = result.findings.where((f) => f.severity == AuditSeverity.error).length; + final warnings = result.findings.where((f) => f.severity == AuditSeverity.warning).length; + final infos = result.findings.where((f) => f.severity == AuditSeverity.info).length; + + final parts = []; + if (errors > 0) parts.add('$errors error${errors == 1 ? '' : 's'}'); + if (warnings > 0) parts.add('$warnings warning${warnings == 1 ? '' : 's'}'); + if (infos > 0) parts.add('$infos info'); + + Logger.warn('$label $relative ${'.' * _dots(relative, label, total)} ${parts.join(', ')}'); + pubspecsWithFindings.add(result); + } + } + + // ── 7. Detailed findings ────────────────────────────────────────────── + if (pubspecsWithFindings.isNotEmpty) { + Logger.header('DETAILS (pubspecs with findings):'); + + for (final result in pubspecsWithFindings) { + final relative = p.relative(result.pubspecPath, from: rootDir.path); + Logger.info('\n $relative:'); + + for (final finding in result.findings) { + final severityTag = finding.severity.name.toUpperCase(); + final pad = severityTag.length < 5 ? ' ' * (5 - severityTag.length) : ''; + + switch (finding.severity) { + case AuditSeverity.error: + Logger.error(' $severityTag$pad ${finding.dependencyName}: ${finding.message}'); + case AuditSeverity.warning: + Logger.warn(' $severityTag$pad ${finding.dependencyName}: ${finding.message}'); + case AuditSeverity.info: + Logger.info(' $severityTag$pad ${finding.dependencyName}: ${finding.message}'); + } + } + } + } + + // ── 8. Fix pass ─────────────────────────────────────────────────────── + var fixedCount = 0; + if (fix && !dryRun && pubspecsWithFindings.isNotEmpty) { + Logger.header('Applying fixes...'); + + for (final result in pubspecsWithFindings) { + final relative = p.relative(result.pubspecPath, from: rootDir.path); + final didFix = auditor.fixPubspec(result.pubspecPath, result.findings); + if (didFix) { + fixedCount++; + Logger.success(' Fixed: $relative'); + } else { + Logger.warn(' No auto-fix available: $relative'); + } + } + } else if (fix && dryRun && pubspecsWithFindings.isNotEmpty) { + Logger.header('[DRY-RUN] Would fix ${pubspecsWithFindings.length} pubspec(s)'); + for (final result in pubspecsWithFindings) { + final relative = p.relative(result.pubspecPath, from: rootDir.path); + Logger.info(' $relative'); + } + } + + // ── 9. Summary ──────────────────────────────────────────────────────── + final totalErrors = results.fold( + 0, + (sum, r) => sum + r.findings.where((f) => f.severity == AuditSeverity.error).length, + ); + final totalWarnings = results.fold( + 0, + (sum, r) => sum + r.findings.where((f) => f.severity == AuditSeverity.warning).length, + ); + final totalInfos = results.fold( + 0, + (sum, r) => sum + r.findings.where((f) => f.severity == AuditSeverity.info).length, + ); + final cleanCount = results.where((r) => r.findings.isEmpty).length; + final issueCount = results.where((r) => r.findings.isNotEmpty).length; + + Logger.header('Summary'); + Logger.info(' ${pubspecs.length} pubspecs scanned'); + Logger.info(' $cleanCount clean'); + + if (issueCount > 0) { + final parts = []; + if (totalErrors > 0) parts.add('$totalErrors error${totalErrors == 1 ? '' : 's'}'); + if (totalWarnings > 0) { + parts.add('$totalWarnings warning${totalWarnings == 1 ? '' : 's'}'); + } + if (totalInfos > 0) parts.add('$totalInfos info'); + Logger.warn(' $issueCount with issues (${parts.join(', ')})'); + } + + if (fixedCount > 0) { + Logger.success(' $fixedCount pubspec(s) fixed'); + } + + // ── 10. Exit code ───────────────────────────────────────────────────── + if (totalErrors > 0) { + exit(1); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /// Recursively discover `pubspec.yaml` files, respecting exclusions. + List _discoverPubspecs(Directory root, Set excludeDirs) { + final results = []; + _scanForPubspecs(root, results, excludeDirs); + results.sort(); + return results; + } + + void _scanForPubspecs(Directory dir, List results, Set excludeDirs) { + List children; + try { + children = dir.listSync(followLinks: false); + } catch (_) { + return; + } + + for (final child in children) { + if (child is File) { + final name = p.basename(child.path); + if (name == 'pubspec.yaml') { + results.add(child.path); + } + // Skip pubspec_overrides.yaml — not relevant for audit. + } else if (child is Directory) { + final name = p.basename(child.path); + // Skip hidden directories and explicitly excluded names. + if (name.startsWith('.') || excludeDirs.contains(name)) { + continue; + } + _scanForPubspecs(child, results, excludeDirs); + } + } + } + + /// Walk up from [startPath] looking for `configs/external_workspace_packages.yaml`. + String? _autoDetectRegistry(String startPath) { + var current = p.canonicalize(startPath); + while (true) { + final candidate = p.join(current, 'configs', 'external_workspace_packages.yaml'); + if (File(candidate).existsSync()) { + return candidate; + } + final parent = p.dirname(current); + if (parent == current) break; + current = parent; + } + return null; + } + + /// Parse a severity string into an [AuditSeverity]. + AuditSeverity _parseSeverity(String value) { + switch (value) { + case 'error': + return AuditSeverity.error; + case 'warning': + return AuditSeverity.warning; + case 'info': + return AuditSeverity.info; + default: + return AuditSeverity.error; + } + } + + /// Compute number of dots for alignment in status output. + int _dots(String relative, String label, int total) { + // Target ~80 chars wide. Adjust padding for the label and path. + const lineWidth = 80; + final used = label.length + 1 + relative.length + 1; + final remaining = lineWidth - used; + return remaining > 2 ? remaining : 2; + } +} + +class _AuditResult { + final int index; + final String pubspecPath; + final List findings; + + const _AuditResult({required this.index, required this.pubspecPath, required this.findings}); +} diff --git a/lib/src/cli/commands/audit_command.dart b/lib/src/cli/commands/audit_command.dart new file mode 100644 index 0000000..e534b5e --- /dev/null +++ b/lib/src/cli/commands/audit_command.dart @@ -0,0 +1,267 @@ +// ignore_for_file: avoid_print + +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:path/path.dart' as p; + +import '../manage_cicd_cli.dart'; +import '../utils/audit/audit_finding.dart'; +import '../utils/audit/package_registry.dart'; +import '../utils/audit/pubspec_auditor.dart'; +import '../utils/logger.dart'; + +/// Audit a pubspec.yaml against the package registry for dependency issues. +/// +/// Loads the external workspace packages registry and validates that all +/// dependencies in the target pubspec.yaml conform to expected git URLs, +/// version constraints, tag patterns, and organizational ownership. +class AuditCommand extends Command { + @override + final String name = 'audit'; + + @override + final String description = 'Audit a pubspec.yaml against the package registry for dependency issues.'; + + AuditCommand() { + argParser + ..addOption( + 'path', + help: + 'Path to a specific pubspec.yaml file. ' + 'Defaults to pubspec.yaml in the current directory.', + ) + ..addOption( + 'registry', + help: + 'Path to external_workspace_packages.yaml. ' + 'Defaults to configs/external_workspace_packages.yaml ' + 'relative to the auto-detected monorepo root.', + ) + ..addFlag('fix', defaultsTo: false, help: 'Automatically fix found issues in-place.') + ..addOption( + 'severity', + defaultsTo: 'error', + allowed: ['error', 'warning', 'info'], + help: 'Minimum severity to report.', + ); + } + + @override + Future run() async { + final global = ManageCicdCli.parseGlobalOptions(globalResults); + final dryRun = global.dryRun; + final verbose = global.verbose; + + final fix = argResults!['fix'] as bool; + final minSeverity = _parseSeverity(argResults!['severity'] as String); + + // ── Resolve pubspec path ────────────────────────────────────────────── + final pubspecPath = _resolvePubspecPath(argResults!['path'] as String?); + if (pubspecPath == null) { + Logger.error( + 'Could not find pubspec.yaml. ' + 'Specify --path or run from a directory containing pubspec.yaml.', + ); + exit(1); + } + + // ── Resolve registry path ───────────────────────────────────────────── + final registryPath = _resolveRegistryPath(explicit: argResults!['registry'] as String?, pubspecPath: pubspecPath); + if (registryPath == null) { + Logger.error( + 'Could not find external_workspace_packages.yaml. ' + 'Specify --registry or run from within the monorepo.', + ); + exit(1); + } + + // ── Load registry and create auditor ────────────────────────────────── + Logger.header('audit: Scanning pubspec.yaml'); + + final PackageRegistry registry; + try { + registry = PackageRegistry.load(registryPath); + } catch (e) { + Logger.error('Failed to load registry at $registryPath: $e'); + exit(1); + } + + final auditor = PubspecAuditor(registry: registry); + + Logger.info(''); + Logger.info( + ' Auditing $pubspecPath against registry ' + '(${registry.entries.length} packages)...', + ); + + // ── Run audit ───────────────────────────────────────────────────────── + final allFindings = auditor.auditPubspec(pubspecPath); + + // Filter by minimum severity. + final findings = allFindings.where((f) => f.severity.index <= minSeverity.index).toList(); + + if (findings.isEmpty) { + Logger.info(''); + Logger.success(' No issues found.'); + return; + } + + // ── Print findings grouped by severity ──────────────────────────────── + _printFindings(findings, verbose: verbose); + + // ── Fix mode ────────────────────────────────────────────────────────── + if (fix) { + final fixableFindings = allFindings.where((f) => f.severity.index <= minSeverity.index).toList(); + + if (dryRun) { + Logger.info(''); + Logger.warn( + ' [DRY-RUN] Would fix ${fixableFindings.length} finding(s) ' + 'in $pubspecPath', + ); + } else { + Logger.info(''); + Logger.info(' Applying fixes...'); + final fixed = auditor.fixPubspec(pubspecPath, fixableFindings); + if (fixed) { + Logger.success(' Fixes applied to $pubspecPath'); + + // Re-audit to check remaining issues. + final remaining = auditor.auditPubspec(pubspecPath); + final remainingErrors = remaining.where((f) => f.severity == AuditSeverity.error).toList(); + if (remainingErrors.isEmpty) { + Logger.success(' All errors resolved.'); + return; + } else { + Logger.warn(' ${remainingErrors.length} error(s) remain after fixes.'); + _printFindings(remaining, verbose: verbose); + } + } else { + Logger.warn(' No fixes were applied (nothing fixable).'); + } + } + } + + // ── Exit code ───────────────────────────────────────────────────────── + final errorCount = findings.where((f) => f.severity == AuditSeverity.error).length; + if (errorCount > 0) { + exit(1); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /// Resolve the target pubspec.yaml path. + String? _resolvePubspecPath(String? explicit) { + if (explicit != null) { + final file = File(explicit); + if (file.existsSync()) return p.canonicalize(file.path); + // Allow passing a directory — look for pubspec.yaml inside it. + final inDir = File(p.join(explicit, 'pubspec.yaml')); + if (inDir.existsSync()) return p.canonicalize(inDir.path); + return null; + } + + final defaultPath = p.join(Directory.current.path, 'pubspec.yaml'); + if (File(defaultPath).existsSync()) return p.canonicalize(defaultPath); + return null; + } + + /// Resolve the registry YAML path. + /// + /// If not provided explicitly, walk up from the pubspec's parent directory + /// looking for `configs/external_workspace_packages.yaml` — the standard + /// monorepo root detection pattern. + String? _resolveRegistryPath({required String? explicit, required String pubspecPath}) { + if (explicit != null) { + final file = File(explicit); + if (file.existsSync()) return p.canonicalize(file.path); + return null; + } + + // Auto-detect: walk up from pubspec's directory. + var current = Directory(p.dirname(pubspecPath)); + while (true) { + final candidate = File(p.join(current.path, 'configs', 'external_workspace_packages.yaml')); + if (candidate.existsSync()) return p.canonicalize(candidate.path); + + final parent = current.parent; + if (parent.path == current.path) break; // Reached filesystem root. + current = parent; + } + + return null; + } + + /// Parse a severity string into an [AuditSeverity]. + AuditSeverity _parseSeverity(String value) { + switch (value) { + case 'error': + return AuditSeverity.error; + case 'warning': + return AuditSeverity.warning; + case 'info': + return AuditSeverity.info; + default: + return AuditSeverity.error; + } + } + + /// Print findings grouped by severity using appropriate Logger methods. + void _printFindings(List findings, {required bool verbose}) { + final errors = findings.where((f) => f.severity == AuditSeverity.error).toList(); + final warnings = findings.where((f) => f.severity == AuditSeverity.warning).toList(); + final infos = findings.where((f) => f.severity == AuditSeverity.info).toList(); + + Logger.info(''); + + if (errors.isNotEmpty) { + Logger.error(' ERRORS (${errors.length}):'); + for (final finding in errors) { + _printFinding(finding, verbose: verbose, printer: Logger.error); + } + } + + if (warnings.isNotEmpty) { + Logger.warn(' WARNINGS (${warnings.length}):'); + for (final finding in warnings) { + _printFinding(finding, verbose: verbose, printer: Logger.warn); + } + } + + if (infos.isNotEmpty) { + Logger.info(' INFO (${infos.length}):'); + for (final finding in infos) { + _printFinding(finding, verbose: verbose, printer: Logger.info); + } + } + + // Summary line. + final parts = []; + if (errors.isNotEmpty) parts.add('${errors.length} error(s)'); + if (warnings.isNotEmpty) parts.add('${warnings.length} warning(s)'); + if (infos.isNotEmpty) parts.add('${infos.length} info'); + + final pubspecPath = findings.isNotEmpty ? findings.first.pubspecPath : 'pubspec.yaml'; + + Logger.info(''); + Logger.info(' Summary: ${parts.join(', ')} in $pubspecPath'); + } + + /// Print a single finding with optional verbose detail. + void _printFinding(AuditFinding finding, {required bool verbose, required void Function(String) printer}) { + printer(' ${finding.dependencyName}: ${finding.message}'); + if (finding.currentValue != null) { + printer(' Current: ${finding.currentValue}'); + } + if (finding.expectedValue != null) { + printer(' Expected: ${finding.expectedValue}'); + } + if (verbose) { + printer(' Category: ${finding.category.name}'); + printer(' File: ${finding.pubspecPath}'); + } + printer(''); + } +} diff --git a/lib/src/cli/manage_cicd_cli.dart b/lib/src/cli/manage_cicd_cli.dart index cd4b3c1..84471c8 100644 --- a/lib/src/cli/manage_cicd_cli.dart +++ b/lib/src/cli/manage_cicd_cli.dart @@ -3,6 +3,8 @@ import 'package:args/command_runner.dart'; import 'commands/analyze_command.dart'; import 'commands/archive_run_command.dart'; +import 'commands/audit_all_command.dart'; +import 'commands/audit_command.dart'; import 'commands/autodoc_command.dart'; import 'commands/compose_command.dart'; import 'commands/configure_mcp_command.dart'; @@ -51,6 +53,8 @@ class ManageCicdCli extends CommandRunner { void _addCommands() { addCommand(AnalyzeCommand()); addCommand(ArchiveRunCommand()); + addCommand(AuditAllCommand()); + addCommand(AuditCommand()); addCommand(AutodocCommand()); addCommand(ComposeCommand()); addCommand(ConfigureMcpCommand()); diff --git a/pubspec.yaml b/pubspec.yaml index 008ab38..4de44ea 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,9 +12,9 @@ executables: environment: sdk: ^3.9.0 -# NOTE: `resolution: workspace` is intentionally absent here so this package -# resolves correctly in its own standalone CI pipeline. Add it only in your -# local monorepo workspace (never commit it to this repo). +# NOTE: `resolution: workspace` is intentionally absent in this repo so this package +# resolves correctly in its own standalone CI pipeline. In your local monorepo +# workspace you may add it, but never commit it back here. dependencies: args: ^2.7.0 From bd1becd62bd5405b515b4f7e7c9b926096d9d9ac Mon Sep 17 00:00:00 2001 From: Tsavo Knott Date: Tue, 24 Feb 2026 13:02:01 -0500 Subject: [PATCH 2/6] fix(ci): increase test process timeout from 30 to 45 minutes Windows named pipe tests need more than 30 minutes on CI runners when running the full stress test suite (13 large payload tests + 500 sequential RPCs + adversarial scenarios). Co-Authored-By: Claude Opus 4.6 --- lib/src/cli/commands/test_command.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/cli/commands/test_command.dart b/lib/src/cli/commands/test_command.dart index 79bdeeb..18c73ed 100644 --- a/lib/src/cli/commands/test_command.dart +++ b/lib/src/cli/commands/test_command.dart @@ -26,7 +26,7 @@ class TestCommand extends Command { Logger.header('Running dart test'); - const processTimeout = Duration(minutes: 30); + const processTimeout = Duration(minutes: 45); final failures = []; // Skip gracefully if no test/ directory exists @@ -44,7 +44,7 @@ class TestCommand extends Command { mode: ProcessStartMode.inheritStdio, ); - // Process-level timeout: kill the test process if it exceeds 30 minutes. + // Process-level timeout: kill the test process if it exceeds 45 minutes. // Individual test timeouts should catch hangs, but this is a safety net // for cases where the test process itself doesn't exit (e.g., leaked // isolates, open sockets keeping the event loop alive). From 6a952136c0d61ddeeffb9eba73658b4d8c3c3cfe Mon Sep 17 00:00:00 2001 From: Tsavo Knott Date: Tue, 24 Feb 2026 13:13:26 -0500 Subject: [PATCH 3/6] feat: sibling dep conversion + per-package tags for multi-package releases Add convertSiblingDepsForRelease() to SubPackageUtils that automatically converts bare sibling dependencies (e.g., `custom_lint_core: ^0.8.2`) to full git dep blocks with url, tag_pattern, path, and version constraint during the release pipeline. Also strips `resolution: workspace` from sub-package pubspecs so standalone consumers can resolve. Integrates into create-release as Step 2c (after version bumping) and Step 5b (per-package tag creation after main tag). Driven by optional `tag_pattern` field in sub_packages config. Co-Authored-By: Claude Opus 4.6 --- .../cli/commands/create_release_command.dart | 42 ++++++++ lib/src/cli/utils/sub_package_utils.dart | 100 ++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/lib/src/cli/commands/create_release_command.dart b/lib/src/cli/commands/create_release_command.dart index 1a64633..3be8078 100644 --- a/lib/src/cli/commands/create_release_command.dart +++ b/lib/src/cli/commands/create_release_command.dart @@ -127,6 +127,18 @@ class CreateReleaseCommand extends Command { } } + // Step 2c: Convert sibling deps to git format + strip resolution: workspace + final siblingConversions = SubPackageUtils.convertSiblingDepsForRelease( + repoRoot: repoRoot, + newVersion: newVersion, + effectiveRepo: effectiveRepo, + subPackages: subPackages, + verbose: global.verbose, + ); + if (siblingConversions > 0) { + Logger.success('Converted $siblingConversions sibling dep(s) to git format'); + } + // Step 3: Assemble release notes folder from Stage 3 artifacts final releaseDir = Directory('$repoRoot/$kReleaseNotesDir/v$newVersion'); releaseDir.createSync(recursive: true); @@ -318,6 +330,34 @@ class CreateReleaseCommand extends Command { CiProcessRunner.exec('git', ['push', 'origin', tag], cwd: repoRoot, fatal: true, verbose: global.verbose); Logger.success('Created tag: $tag'); + // Step 5b: Create per-package tags for sub-packages with tag_pattern + final pkgTagsCreated = []; + for (final pkg in subPackages) { + final tp = pkg['tag_pattern'] as String?; + if (tp == null) continue; + final pkgTag = tp.replaceAll('{{version}}', newVersion); + final pkgTagCheck = Process.runSync('git', ['rev-parse', pkgTag], workingDirectory: repoRoot); + if (pkgTagCheck.exitCode == 0) { + Logger.warn('Per-package tag $pkgTag already exists -- skipping'); + continue; + } + CiProcessRunner.exec( + 'git', + ['tag', '-a', pkgTag, '-m', '${pkg['name']} v$newVersion'], + cwd: repoRoot, + fatal: true, + verbose: global.verbose, + ); + CiProcessRunner.exec('git', ['push', 'origin', pkgTag], cwd: repoRoot, fatal: true, verbose: global.verbose); + pkgTagsCreated.add(pkgTag); + } + if (pkgTagsCreated.isNotEmpty) { + Logger.success( + 'Created ${pkgTagsCreated.length} per-package tag(s): ' + '${pkgTagsCreated.join(', ')}', + ); + } + // Step 6: Create GitHub Release using Stage 3 release notes var releaseBody = ''; final bodyFile = File('${releaseDir.path}/release_notes.md'); @@ -359,6 +399,8 @@ class CreateReleaseCommand extends Command { | Repository | `$effectiveRepo` | | pubspec.yaml | Bumped to `$newVersion` | ${subPackages.isNotEmpty ? '| Sub-packages | ${subPackages.map((p) => '`${p['name']}`').join(', ')} bumped to `$newVersion` |' : ''} +${siblingConversions > 0 ? '| Sibling Deps | $siblingConversions dep(s) converted to git format |' : ''} +${pkgTagsCreated.isNotEmpty ? '| Per-Package Tags | ${pkgTagsCreated.map((t) => '`$t`').join(', ')} |' : ''} ### Links diff --git a/lib/src/cli/utils/sub_package_utils.dart b/lib/src/cli/utils/sub_package_utils.dart index 13b1bee..00e0ece 100644 --- a/lib/src/cli/utils/sub_package_utils.dart +++ b/lib/src/cli/utils/sub_package_utils.dart @@ -1,5 +1,8 @@ import 'dart:io'; +import 'package:yaml/yaml.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + import 'logger.dart'; import 'process_runner.dart'; import 'workflow_generator.dart'; @@ -277,6 +280,103 @@ Rules: return subPackages; } + /// Convert bare sibling dependencies to git format and strip + /// `resolution: workspace`. + /// + /// For each sub-package pubspec, any dependency whose name matches another + /// sub-package (with a `tag_pattern`) is rewritten from a bare version + /// constraint to a full git dep block with `url`, `tag_pattern`, `path`, + /// and `version: ^newVersion`. + /// + /// Returns the total number of dependency conversions across all pubspecs. + static int convertSiblingDepsForRelease({ + required String repoRoot, + required String newVersion, + required String effectiveRepo, + required List> subPackages, + bool verbose = false, + }) { + // Build sibling lookup: {packageName -> {tag_pattern, path}} + // Only packages WITH tag_pattern participate. + final siblingMap = >{}; + for (final pkg in subPackages) { + final tp = pkg['tag_pattern'] as String?; + if (tp == null) continue; + siblingMap[pkg['name'] as String] = {'tag_pattern': tp, 'path': pkg['path'] as String}; + } + if (siblingMap.isEmpty) return 0; + + final gitUrl = 'git@github.com:$effectiveRepo.git'; + var totalConversions = 0; + + for (final pkg in subPackages) { + final pkgName = pkg['name'] as String; + final pubspecFile = File('$repoRoot/${pkg['path']}/pubspec.yaml'); + if (!pubspecFile.existsSync()) { + Logger.warn('Sub-package pubspec not found: ${pkg['path']}/pubspec.yaml'); + continue; + } + + final original = pubspecFile.readAsStringSync(); + final editor = YamlEditor(original); + final doc = loadYaml(original) as YamlMap; + var conversions = 0; + + // Scan both dependency sections. + for (final sectionKey in ['dependencies', 'dev_dependencies']) { + final section = doc[sectionKey] as YamlMap?; + if (section == null) continue; + + for (final key in section.keys) { + final depName = key as String; + if (depName == pkgName) continue; // skip self + final sibling = siblingMap[depName]; + if (sibling == null) continue; // not a sibling + + final depValue = section[depName]; + // Only convert bare string constraints (e.g., "^0.8.2") and + // null values (workspace refs). Map deps are already structured. + if (depValue is! String && depValue != null) continue; + + final gitBlock = { + 'url': gitUrl, + 'tag_pattern': sibling['tag_pattern']!, + 'path': sibling['path']!, + }; + final newValue = {'git': gitBlock, 'version': '^$newVersion'}; + + try { + editor.update([sectionKey, depName], newValue); + conversions++; + } on Exception catch (e) { + Logger.warn('yaml_edit: failed to update $sectionKey.$depName -- $e'); + } + } + } + + // Strip `resolution: workspace` if present. + if (doc.containsKey('resolution')) { + try { + editor.remove(['resolution']); + if (verbose) { + Logger.info('Stripped resolution: workspace from ${pkg['name']}'); + } + } on Exception catch (_) {} + } + + final updated = editor.toString(); + if (updated != original) { + pubspecFile.writeAsStringSync(updated); + totalConversions += conversions; + if (verbose) { + Logger.info('Converted $conversions sibling dep(s) in ${pkg['name']}'); + } + } + } + + return totalConversions; + } + /// Truncate a string to a maximum length, appending an indicator. static String _truncate(String input, int maxChars) { if (input.length <= maxChars) return input; From cce18bea8bf7d418ae20dd50f0401b5ff3305147 Mon Sep 17 00:00:00 2001 From: Tsavo Knott Date: Tue, 24 Feb 2026 13:16:32 -0500 Subject: [PATCH 4/6] chore: update CI workflow templates and generator Update release template, CI skeleton, issue-triage template, and workflow generator with latest patterns. Remove deprecated config entries from templates/config.json. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yaml | 26 +-- .runtime_ci/template_versions.json | 10 +- lib/src/cli/utils/audit/pubspec_auditor.dart | 26 ++- lib/src/cli/utils/workflow_generator.dart | 2 +- templates/config.json | 6 +- templates/github/workflows/ci.skeleton.yaml | 33 ++-- .../workflows/issue-triage.template.yaml | 14 +- .../github/workflows/release.template.yaml | 186 ++++++++++-------- 8 files changed, 177 insertions(+), 126 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2b79949..56a1e31 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,4 +1,4 @@ -# Generated by runtime_ci_tooling v0.13.1 +# Generated by runtime_ci_tooling v0.12.2 # Configured via .runtime_ci/config.json — run 'dart run runtime_ci_tooling:manage_cicd update --workflows' to regenerate. name: CI @@ -89,12 +89,13 @@ jobs: - name: Configure Git for HTTPS with Token shell: bash + env: + GH_PAT: ${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN || secrets.GITHUB_TOKEN }} run: | - TOKEN="${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN || secrets.GITHUB_TOKEN }}" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${TOKEN}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${TOKEN}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" + 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/" - uses: dart-lang/setup-dart@v1.7.1 with: @@ -126,7 +127,7 @@ jobs: strategy: fail-fast: false matrix: - include: [{"platform_id":"ubuntu-x64","runner":"runtime-ubuntu-24.04-x64-256gb-64core","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-intel","os_family":"macos","arch":"x64"},{"platform_id":"windows-x64","runner":"runtime-windows-2025-x64-256gb-64core","os_family":"windows","arch":"x64"},{"platform_id":"windows-arm64","runner":"runtime-windows-11-arm64-208gb-64core","os_family":"windows","arch":"arm64"}] + 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: - uses: actions/checkout@v6.0.2 with: @@ -135,12 +136,13 @@ jobs: - name: Configure Git for HTTPS with Token shell: bash + env: + GH_PAT: ${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN || secrets.GITHUB_TOKEN }} run: | - TOKEN="${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN || secrets.GITHUB_TOKEN }}" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${TOKEN}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${TOKEN}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" + 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/" - uses: dart-lang/setup-dart@v1.7.1 with: diff --git a/.runtime_ci/template_versions.json b/.runtime_ci/template_versions.json index 2f9fd51..2c22233 100644 --- a/.runtime_ci/template_versions.json +++ b/.runtime_ci/template_versions.json @@ -1,6 +1,6 @@ { - "tooling_version": "0.12.1", - "updated_at": "2026-02-24T16:04:43.651023Z", + "tooling_version": "0.12.2", + "updated_at": "2026-02-24T18:13:35.782823Z", "templates": { "gemini_settings": { "hash": "93983f49dd2f40d2ed245271854946d8916b8f0698ed2cfaf12058305baa0b08", @@ -23,9 +23,9 @@ "updated_at": "2026-02-24T00:59:57.620091Z" }, "workflow_ci": { - "hash": "92c5c82c94e96022d4c7bd7372b1d04273e927223b9f8726a182871d89d1ef77", - "consumer_hash": "1f33e89a0ccffc4ec34838b4e76f9c4d6d7ab5eefb1d9731b554dfc0ed752696", - "updated_at": "2026-02-24T16:04:43.655468Z" + "hash": "c2a764d5e225b04729ba616bd63726da7f5a62d3e0c425fd04e69008900baf52", + "consumer_hash": "bed69d3318a843809cb7ed73821251859deb3be6735726f9503df34378679317", + "updated_at": "2026-02-24T18:13:35.784301Z" }, "workflow_release": { "hash": "326627cf41fdeb6cd61dae2fda98599d5815a34e63e4a8af1aaa8f7ad18435d3", diff --git a/lib/src/cli/utils/audit/pubspec_auditor.dart b/lib/src/cli/utils/audit/pubspec_auditor.dart index 33628ea..0635b05 100644 --- a/lib/src/cli/utils/audit/pubspec_auditor.dart +++ b/lib/src/cli/utils/audit/pubspec_auditor.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:pub_semver/pub_semver.dart'; import 'package:yaml/yaml.dart'; import 'package:yaml_edit/yaml_edit.dart'; @@ -13,6 +14,21 @@ final _sshUrlPattern = RegExp(r'^git@github\.com:([^/]+)/([^/]+)\.git$'); /// RegExp matching HTTPS-format GitHub URLs so we can detect and flag them. final _httpsUrlPattern = RegExp(r'^https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?$'); +bool _constraintsEquivalent(String left, String right) { + final a = left.trim(); + final b = right.trim(); + if (a == b) return true; + try { + final ca = VersionConstraint.parse(a); + final cb = VersionConstraint.parse(b); + // Treat equivalent ranges as "matching" even if rendered differently. + return ca.allowsAll(cb) && cb.allowsAll(ca); + } catch (_) { + // Fall back to a whitespace-insensitive compare. + return a.replaceAll(RegExp(r'\s+'), '') == b.replaceAll(RegExp(r'\s+'), ''); + } +} + /// Audits pubspec.yaml dependency declarations against a [PackageRegistry]. /// /// For every dependency that matches a registry entry, the auditor checks: @@ -127,7 +143,7 @@ class PubspecAuditor { ); // Also flag stale version if the bare constraint doesn't match. - if (value != entry.version) { + if (!_constraintsEquivalent(value, entry.version)) { findings.add( AuditFinding( pubspecPath: pubspecPath, @@ -311,8 +327,8 @@ class PubspecAuditor { } // --- Rule 6: stale_version ----------------------------------------------- - final versionValue = depMap['version'] as String?; - if (versionValue != null && versionValue != entry.version) { + final versionValue = depMap['version']?.toString(); + if (versionValue != null && !_constraintsEquivalent(versionValue, entry.version)) { findings.add( AuditFinding( pubspecPath: pubspecPath, @@ -324,12 +340,12 @@ class PubspecAuditor { expectedValue: entry.version, ), ); - } else if (versionValue == null) { + } else if (versionValue == null || versionValue.trim().isEmpty) { findings.add( AuditFinding( pubspecPath: pubspecPath, dependencyName: depName, - severity: AuditSeverity.warning, + severity: AuditSeverity.error, category: AuditCategory.staleVersion, message: 'No version constraint specified -- should have version: ' diff --git a/lib/src/cli/utils/workflow_generator.dart b/lib/src/cli/utils/workflow_generator.dart index f7ca95e..9f3016c 100644 --- a/lib/src/cli/utils/workflow_generator.dart +++ b/lib/src/cli/utils/workflow_generator.dart @@ -31,7 +31,7 @@ const _platformDefinitions = { // macOS — standard GitHub-hosted runners (no org-managed equivalents) 'macos': _PlatformDefinition(osFamily: 'macos', arch: 'arm64', runner: 'macos-latest'), 'macos-arm64': _PlatformDefinition(osFamily: 'macos', arch: 'arm64', runner: 'macos-latest'), - 'macos-x64': _PlatformDefinition(osFamily: 'macos', arch: 'x64', runner: 'macos-15-intel'), + 'macos-x64': _PlatformDefinition(osFamily: 'macos', arch: 'x64', runner: 'macos-15-large'), // Windows — org-managed runners 'windows': _PlatformDefinition(osFamily: 'windows', arch: 'x64', runner: 'runtime-windows-2025-x64-256gb-64core'), diff --git a/templates/config.json b/templates/config.json index a2c2839..060ce3f 100644 --- a/templates/config.json +++ b/templates/config.json @@ -78,10 +78,10 @@ "sub_packages": [], "_comment_platforms": "Optional: CI platform matrix. When 2+ entries are provided, CI splits into analyze + matrix test jobs.", "platforms": ["ubuntu-x64", "ubuntu-arm64", "macos-arm64", "macos-x64", "windows-x64", "windows-arm64"], - "_comment_runner_overrides": "Optional: override default org-managed runners. Defaults: ubuntu-x64=runtime-ubuntu-24.04-x64-256gb-64core, ubuntu-arm64=runtime-ubuntu-24.04-arm64-208gb-64core, windows-x64=runtime-windows-2025-x64-256gb-64core, windows-arm64=runtime-windows-11-arm64-208gb-64core, macos-arm64=macos-latest, macos-x64=macos-15-intel. Only specify overrides if you need larger runners.", + "_comment_runner_overrides": "Optional: override platform IDs to custom runs-on labels (e.g. org-managed GitHub-hosted runners). Keys must match ci.platforms entries.", "runner_overrides": { - "ubuntu-x64": "runtime-ubuntu-24.04-x64-640gb-160core", - "windows-x64": "runtime-windows-2025-x64-640gb-160core" + "ubuntu-arm64": "runtime-ubuntu-24.04-arm64-208gb-64core", + "windows-arm64": "runtime-windows-11-arm64-208gb-64core" } } } diff --git a/templates/github/workflows/ci.skeleton.yaml b/templates/github/workflows/ci.skeleton.yaml index 2954b31..0c9c94c 100644 --- a/templates/github/workflows/ci.skeleton.yaml +++ b/templates/github/workflows/ci.skeleton.yaml @@ -104,12 +104,13 @@ jobs: - name: Configure Git for HTTPS with Token shell: bash + env: + GH_PAT: ${{ secrets.<%pat_secret%> || secrets.GITHUB_TOKEN }} run: | - TOKEN="${{ secrets.<%pat_secret%> || secrets.GITHUB_TOKEN }}" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${TOKEN}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${TOKEN}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" + 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/" - uses: dart-lang/setup-dart@v1.7.1 with: @@ -212,12 +213,13 @@ jobs: - name: Configure Git for HTTPS with Token shell: bash + env: + GH_PAT: ${{ secrets.<%pat_secret%> || secrets.GITHUB_TOKEN }} run: | - TOKEN="${{ secrets.<%pat_secret%> || secrets.GITHUB_TOKEN }}" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${TOKEN}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${TOKEN}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" + 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/" - uses: dart-lang/setup-dart@v1.7.1 with: @@ -308,12 +310,13 @@ jobs: - name: Configure Git for HTTPS with Token shell: bash + env: + GH_PAT: ${{ secrets.<%pat_secret%> || secrets.GITHUB_TOKEN }} run: | - TOKEN="${{ secrets.<%pat_secret%> || secrets.GITHUB_TOKEN }}" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${TOKEN}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${TOKEN}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" + 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/" - uses: dart-lang/setup-dart@v1.7.1 with: diff --git a/templates/github/workflows/issue-triage.template.yaml b/templates/github/workflows/issue-triage.template.yaml index 80c74f4..3924e03 100644 --- a/templates/github/workflows/issue-triage.template.yaml +++ b/templates/github/workflows/issue-triage.template.yaml @@ -50,12 +50,13 @@ jobs: - name: Configure Git for HTTPS with Token if: steps.trigger.outputs.run == 'true' shell: bash + env: + GH_PAT: ${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN || secrets.GITHUB_TOKEN }} run: | - TOKEN="${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN || secrets.GITHUB_TOKEN }}" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${TOKEN}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${TOKEN}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" + 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/" - uses: dart-lang/setup-dart@v1.7.1 if: steps.trigger.outputs.run == 'true' @@ -93,4 +94,5 @@ jobs: env: GEMINI_API_KEY: ${{ secrets.CICD_GEMINI_API_KEY_OPEN_RUNTIME }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: dart run runtime_ci_tooling:triage_cli ${{ github.event.issue.number }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: dart run runtime_ci_tooling:triage_cli "$ISSUE_NUMBER" diff --git a/templates/github/workflows/release.template.yaml b/templates/github/workflows/release.template.yaml index 2f1b797..1f693b9 100644 --- a/templates/github/workflows/release.template.yaml +++ b/templates/github/workflows/release.template.yaml @@ -69,12 +69,13 @@ jobs: - name: Configure Git for HTTPS with Token shell: bash + env: + GH_PAT: ${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN || secrets.GITHUB_TOKEN }} run: | - TOKEN="${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN || secrets.GITHUB_TOKEN }}" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${TOKEN}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${TOKEN}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" + 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/" - uses: dart-lang/setup-dart@v1.7.1 with: @@ -142,12 +143,13 @@ jobs: - name: Configure Git for HTTPS with Token shell: bash + env: + GH_PAT: ${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN || secrets.GITHUB_TOKEN }} run: | - TOKEN="${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN || secrets.GITHUB_TOKEN }}" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${TOKEN}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${TOKEN}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" + 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/" - uses: dart-lang/setup-dart@v1.7.1 with: @@ -180,17 +182,19 @@ jobs: env: GEMINI_API_KEY: ${{ secrets.CICD_GEMINI_API_KEY_OPEN_RUNTIME }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PREV_TAG: ${{ needs.determine-version.outputs.prev_tag }} + NEW_VERSION: ${{ needs.determine-version.outputs.new_version }} run: | dart run runtime_ci_tooling:manage_cicd triage pre-release \ - --prev-tag "${{ needs.determine-version.outputs.prev_tag }}" \ - --version "${{ needs.determine-version.outputs.new_version }}" + --prev-tag "$PREV_TAG" \ + --version "$NEW_VERSION" # Find manifest from .runtime_ci/runs/ audit trail MANIFEST=$(find .runtime_ci/runs -name "issue_manifest.json" -type f 2>/dev/null | sort -r | head -1) if [ -n "$MANIFEST" ]; then cp "$MANIFEST" /tmp/issue_manifest.json else - echo '{"version":"${{ needs.determine-version.outputs.new_version }}","github_issues":[],"sentry_issues":[],"cross_repo_issues":[]}' > /tmp/issue_manifest.json + echo "{\"version\":\"${NEW_VERSION}\",\"github_issues\":[],\"sentry_issues\":[],\"cross_repo_issues\":[]}" > /tmp/issue_manifest.json fi - uses: actions/upload-artifact@v6.0.0 @@ -223,12 +227,13 @@ jobs: - name: Configure Git for HTTPS with Token shell: bash + env: + GH_PAT: ${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN || secrets.GITHUB_TOKEN }} run: | - TOKEN="${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN || secrets.GITHUB_TOKEN }}" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${TOKEN}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${TOKEN}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" + 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/" - uses: dart-lang/setup-dart@v1.7.1 with: @@ -266,10 +271,12 @@ jobs: env: GEMINI_API_KEY: ${{ secrets.CICD_GEMINI_API_KEY_OPEN_RUNTIME }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PREV_TAG: ${{ needs.determine-version.outputs.prev_tag }} + NEW_VERSION: ${{ needs.determine-version.outputs.new_version }} run: | dart run runtime_ci_tooling:manage_cicd explore \ - --prev-tag "${{ needs.determine-version.outputs.prev_tag }}" \ - --version "${{ needs.determine-version.outputs.new_version }}" + --prev-tag "$PREV_TAG" \ + --version "$NEW_VERSION" - name: Create fallback stage1 artifacts if missing run: | @@ -311,12 +318,13 @@ jobs: - name: Configure Git for HTTPS with Token shell: bash + env: + GH_PAT: ${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN || secrets.GITHUB_TOKEN }} run: | - TOKEN="${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN || secrets.GITHUB_TOKEN }}" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${TOKEN}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${TOKEN}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" + 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/" - uses: dart-lang/setup-dart@v1.7.1 with: @@ -359,19 +367,23 @@ jobs: env: GEMINI_API_KEY: ${{ secrets.CICD_GEMINI_API_KEY_OPEN_RUNTIME }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PREV_TAG: ${{ needs.determine-version.outputs.prev_tag }} + NEW_VERSION: ${{ needs.determine-version.outputs.new_version }} run: | dart run runtime_ci_tooling:manage_cicd compose \ - --prev-tag "${{ needs.determine-version.outputs.prev_tag }}" \ - --version "${{ needs.determine-version.outputs.new_version }}" + --prev-tag "$PREV_TAG" \ + --version "$NEW_VERSION" - name: Documentation update env: GEMINI_API_KEY: ${{ secrets.CICD_GEMINI_API_KEY_OPEN_RUNTIME }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PREV_TAG: ${{ needs.determine-version.outputs.prev_tag }} + NEW_VERSION: ${{ needs.determine-version.outputs.new_version }} run: | dart run runtime_ci_tooling:manage_cicd documentation \ - --prev-tag "${{ needs.determine-version.outputs.prev_tag }}" \ - --version "${{ needs.determine-version.outputs.new_version }}" + --prev-tag "$PREV_TAG" \ + --version "$NEW_VERSION" - uses: actions/upload-artifact@v6.0.0 with: @@ -410,12 +422,13 @@ jobs: - name: Configure Git for HTTPS with Token shell: bash + env: + GH_PAT: ${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN }} run: | - TOKEN="${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN }}" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${TOKEN}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${TOKEN}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" + 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/" - name: Set up Dart uses: dart-lang/setup-dart@v1.7.1 @@ -510,12 +523,13 @@ jobs: - name: Configure Git for HTTPS with Token shell: bash + env: + GH_PAT: ${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN || secrets.GITHUB_TOKEN }} run: | - TOKEN="${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN || secrets.GITHUB_TOKEN }}" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${TOKEN}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${TOKEN}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" + 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/" - uses: dart-lang/setup-dart@v1.7.1 with: @@ -585,30 +599,34 @@ jobs: env: GEMINI_API_KEY: ${{ secrets.CICD_GEMINI_API_KEY_OPEN_RUNTIME }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PREV_TAG: ${{ needs.determine-version.outputs.prev_tag }} + NEW_VERSION: ${{ needs.determine-version.outputs.new_version }} run: | dart run runtime_ci_tooling:manage_cicd release-notes \ - --prev-tag "${{ needs.determine-version.outputs.prev_tag }}" \ - --version "${{ needs.determine-version.outputs.new_version }}" + --prev-tag "$PREV_TAG" \ + --version "$NEW_VERSION" # Consolidate all release notes files under .runtime_ci/release_notes/ before upload. # Mixing relative and absolute paths in upload-artifact causes path # resolution issues. Keep everything under one root. - name: Consolidate release notes + env: + NEW_VERSION: ${{ needs.determine-version.outputs.new_version }} run: | - VERSION="${{ needs.determine-version.outputs.new_version }}" - mkdir -p ".runtime_ci/release_notes/v${VERSION}" - cp /tmp/release_notes_body.md ".runtime_ci/release_notes/v${VERSION}/" 2>/dev/null || true - cp /tmp/migration_guide.md ".runtime_ci/release_notes/v${VERSION}/" 2>/dev/null || true - echo "Contents of .runtime_ci/release_notes/v${VERSION}/:" - ls -la ".runtime_ci/release_notes/v${VERSION}/" 2>/dev/null || echo "(empty)" + mkdir -p ".runtime_ci/release_notes/v${NEW_VERSION}" + cp /tmp/release_notes_body.md ".runtime_ci/release_notes/v${NEW_VERSION}/" 2>/dev/null || true + cp /tmp/migration_guide.md ".runtime_ci/release_notes/v${NEW_VERSION}/" 2>/dev/null || true + echo "Contents of .runtime_ci/release_notes/v${NEW_VERSION}/:" + ls -la ".runtime_ci/release_notes/v${NEW_VERSION}/" 2>/dev/null || echo "(empty)" - name: Ensure release notes artifact is non-empty shell: bash + env: + NEW_VERSION: ${{ needs.determine-version.outputs.new_version }} run: | - VERSION="${{ needs.determine-version.outputs.new_version }}" - mkdir -p ".runtime_ci/release_notes/v${VERSION}" - if [ ! -f ".runtime_ci/release_notes/v${VERSION}/release_notes_body.md" ]; then - echo "Release notes unavailable for v${VERSION}." > ".runtime_ci/release_notes/v${VERSION}/release_notes_body.md" + mkdir -p ".runtime_ci/release_notes/v${NEW_VERSION}" + if [ ! -f ".runtime_ci/release_notes/v${NEW_VERSION}/release_notes_body.md" ]; then + echo "Release notes unavailable for v${NEW_VERSION}." > ".runtime_ci/release_notes/v${NEW_VERSION}/release_notes_body.md" echo "Created fallback release_notes_body.md" fi @@ -646,12 +664,13 @@ jobs: - name: Configure Git for HTTPS with Token shell: bash + env: + GH_PAT: ${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN }} run: | - TOKEN="${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN }}" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${TOKEN}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${TOKEN}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" + 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/" - uses: dart-lang/setup-dart@v1.7.1 with: @@ -693,34 +712,35 @@ jobs: merge-multiple: false - name: Prepare artifacts + env: + NEW_VERSION: ${{ needs.determine-version.outputs.new_version }} run: | - VERSION="${{ needs.determine-version.outputs.new_version }}" - mkdir -p ./artifacts ./.runtime_ci/version_bumps "./.runtime_ci/release_notes/v${VERSION}" + mkdir -p ./artifacts ./.runtime_ci/version_bumps "./.runtime_ci/release_notes/v${NEW_VERSION}" # Stage 3 release notes: downloaded artifact has release_notes/ root # so files land at ./release-notes-artifacts/vX.X.X/release_notes.md - if [ -d "./release-notes-artifacts/v${VERSION}" ]; then - cp -r "./release-notes-artifacts/v${VERSION}/"* "./.runtime_ci/release_notes/v${VERSION}/" 2>/dev/null || true - echo "Copied Stage 3 artifacts from release-notes-artifacts/v${VERSION}/" + if [ -d "./release-notes-artifacts/v${NEW_VERSION}" ]; then + cp -r "./release-notes-artifacts/v${NEW_VERSION}/"* "./.runtime_ci/release_notes/v${NEW_VERSION}/" 2>/dev/null || true + echo "Copied Stage 3 artifacts from release-notes-artifacts/v${NEW_VERSION}/" elif [ -d "./release-notes-artifacts" ]; then # Fallback: search recursively for release_notes.md FOUND=$(find ./release-notes-artifacts -name "release_notes.md" -type f 2>/dev/null | head -1) if [ -n "$FOUND" ]; then - cp "$(dirname "$FOUND")"/* "./.runtime_ci/release_notes/v${VERSION}/" 2>/dev/null || true + cp "$(dirname "$FOUND")"/* "./.runtime_ci/release_notes/v${NEW_VERSION}/" 2>/dev/null || true echo "Found release notes via recursive search: $FOUND" fi fi # Copy release_notes_body.md to /tmp/ for Dart script - if [ -f "./.runtime_ci/release_notes/v${VERSION}/release_notes_body.md" ]; then - cp "./.runtime_ci/release_notes/v${VERSION}/release_notes_body.md" /tmp/release_notes_body.md - elif [ -f "./.runtime_ci/release_notes/v${VERSION}/release_notes.md" ]; then - cp "./.runtime_ci/release_notes/v${VERSION}/release_notes.md" /tmp/release_notes_body.md + if [ -f "./.runtime_ci/release_notes/v${NEW_VERSION}/release_notes_body.md" ]; then + cp "./.runtime_ci/release_notes/v${NEW_VERSION}/release_notes_body.md" /tmp/release_notes_body.md + elif [ -f "./.runtime_ci/release_notes/v${NEW_VERSION}/release_notes.md" ]; then + cp "./.runtime_ci/release_notes/v${NEW_VERSION}/release_notes.md" /tmp/release_notes_body.md fi # List what we found echo "Release notes contents:" - ls -la "./.runtime_ci/release_notes/v${VERSION}/" 2>/dev/null || echo "(empty)" + ls -la "./.runtime_ci/release_notes/v${NEW_VERSION}/" 2>/dev/null || echo "(empty)" # Merge all downloaded audit trail artifacts from different jobs into # a single .runtime_ci/runs/ directory so archive-run can find them. @@ -735,20 +755,25 @@ jobs: # the release. This replaces the old post-release archive that could # never work because .runtime_ci/runs/ didn't exist on the fresh runner. - name: Archive audit trail + env: + NEW_VERSION: ${{ needs.determine-version.outputs.new_version }} run: | dart run runtime_ci_tooling:manage_cicd archive-run \ - --version "${{ needs.determine-version.outputs.new_version }}" + --version "$NEW_VERSION" - name: Create release env: GH_TOKEN: ${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN }} GITHUB_TOKEN: ${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN }} + NEW_VERSION: ${{ needs.determine-version.outputs.new_version }} + PREV_TAG: ${{ needs.determine-version.outputs.prev_tag }} + REPO_NAME: ${{ github.repository }} run: | dart run runtime_ci_tooling:manage_cicd create-release \ - --version "${{ needs.determine-version.outputs.new_version }}" \ - --prev-tag "${{ needs.determine-version.outputs.prev_tag }}" \ + --version "$NEW_VERSION" \ + --prev-tag "$PREV_TAG" \ --artifacts-dir ./artifacts \ - --repo "${{ github.repository }}" + --repo "$REPO_NAME" # ============================================================================ # Job 7: Post-Release Triage @@ -765,12 +790,13 @@ jobs: - name: Configure Git for HTTPS with Token shell: bash + env: + GH_PAT: ${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN || secrets.GITHUB_TOKEN }} run: | - TOKEN="${{ secrets.TSAVO_AT_PIECES_PERSONAL_ACCESS_TOKEN || secrets.GITHUB_TOKEN }}" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${TOKEN}@github.com/open-runtime/".insteadOf "git@github.com:open-runtime/" - git config --global url."https://x-access-token:${TOKEN}@github.com/pieces-app/".insteadOf "git@github.com:pieces-app/" + 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/" - uses: dart-lang/setup-dart@v1.7.1 with: @@ -808,9 +834,11 @@ jobs: env: GEMINI_API_KEY: ${{ secrets.CICD_GEMINI_API_KEY_OPEN_RUNTIME }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEW_VERSION: ${{ needs.determine-version.outputs.new_version }} + REPO_NAME: ${{ github.repository }} run: | dart run runtime_ci_tooling:manage_cicd triage post-release \ - --version "${{ needs.determine-version.outputs.new_version }}" \ - --release-tag "v${{ needs.determine-version.outputs.new_version }}" \ - --release-url "https://github.com/${{ github.repository }}/releases/tag/v${{ needs.determine-version.outputs.new_version }}" \ + --version "$NEW_VERSION" \ + --release-tag "v${NEW_VERSION}" \ + --release-url "https://github.com/${REPO_NAME}/releases/tag/v${NEW_VERSION}" \ --manifest /tmp/issue_manifest.json From ad9e6dc57ad3f197d00e9b436eb2d77c98ee86d0 Mon Sep 17 00:00:00 2001 From: Tsavo Knott Date: Tue, 24 Feb 2026 13:59:44 -0500 Subject: [PATCH 5/6] fix: use refs/tags/ prefix for tag existence checks + error handling for per-package tags --- .../cli/commands/create_release_command.dart | 29 ++++++++------ lib/src/cli/utils/version_detection.dart | 39 ++++++++++++++++--- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/lib/src/cli/commands/create_release_command.dart b/lib/src/cli/commands/create_release_command.dart index 3be8078..fee7d15 100644 --- a/lib/src/cli/commands/create_release_command.dart +++ b/lib/src/cli/commands/create_release_command.dart @@ -315,7 +315,8 @@ class CreateReleaseCommand extends Command { } // Step 5: Create git tag (verify it doesn't already exist) - final tagCheck = Process.runSync('git', ['rev-parse', tag], workingDirectory: repoRoot); + // Use refs/tags/ prefix to avoid matching branches with the same name. + final tagCheck = Process.runSync('git', ['rev-parse', 'refs/tags/$tag'], workingDirectory: repoRoot); if (tagCheck.exitCode == 0) { Logger.error('Tag $tag already exists. Cannot create release.'); exit(1); @@ -336,20 +337,26 @@ class CreateReleaseCommand extends Command { final tp = pkg['tag_pattern'] as String?; if (tp == null) continue; final pkgTag = tp.replaceAll('{{version}}', newVersion); - final pkgTagCheck = Process.runSync('git', ['rev-parse', pkgTag], workingDirectory: repoRoot); + // Use refs/tags/ prefix to avoid matching branches with the same name. + final pkgTagCheck = Process.runSync('git', ['rev-parse', 'refs/tags/$pkgTag'], workingDirectory: repoRoot); if (pkgTagCheck.exitCode == 0) { Logger.warn('Per-package tag $pkgTag already exists -- skipping'); continue; } - CiProcessRunner.exec( - 'git', - ['tag', '-a', pkgTag, '-m', '${pkg['name']} v$newVersion'], - cwd: repoRoot, - fatal: true, - verbose: global.verbose, - ); - CiProcessRunner.exec('git', ['push', 'origin', pkgTag], cwd: repoRoot, fatal: true, verbose: global.verbose); - pkgTagsCreated.add(pkgTag); + try { + CiProcessRunner.exec( + 'git', + ['tag', '-a', pkgTag, '-m', '${pkg['name']} v$newVersion'], + cwd: repoRoot, + fatal: true, + verbose: global.verbose, + ); + CiProcessRunner.exec('git', ['push', 'origin', pkgTag], cwd: repoRoot, fatal: true, verbose: global.verbose); + pkgTagsCreated.add(pkgTag); + } catch (e) { + Logger.error('Failed to create per-package tag $pkgTag: $e'); + Logger.error(' Recovery: git tag -a $pkgTag -m "${pkg['name']} v$newVersion" && git push origin $pkgTag'); + } } if (pkgTagsCreated.isNotEmpty) { Logger.success( diff --git a/lib/src/cli/utils/version_detection.dart b/lib/src/cli/utils/version_detection.dart index d23ce87..655d6f2 100644 --- a/lib/src/cli/utils/version_detection.dart +++ b/lib/src/cli/utils/version_detection.dart @@ -13,8 +13,11 @@ const String kGeminiProModel = 'gemini-3.1-pro-preview'; /// Version detection and semantic versioning utilities. abstract final class VersionDetection { /// Detect the previous release tag from git history. - static String detectPrevTag(String repoRoot, {bool verbose = false}) { - final result = CiProcessRunner.runSync( + /// + /// When [excludeTag] is provided (e.g. the tag about to be created), the + /// method returns the second-newest tag instead so the diff range is correct. + static String detectPrevTag(String repoRoot, {String? excludeTag, bool verbose = false}) { + var result = CiProcessRunner.runSync( "git tag -l 'v*' --sort=-version:refname | head -1", repoRoot, verbose: verbose, @@ -23,6 +26,17 @@ abstract final class VersionDetection { // No tags yet -- use the first commit return CiProcessRunner.runSync('git rev-list --max-parents=0 HEAD | head -1', repoRoot, verbose: verbose); } + // If the newest tag matches excludeTag, pick the second-newest. + if (excludeTag != null && result == excludeTag) { + result = CiProcessRunner.runSync( + "git tag -l 'v*' --sort=-version:refname | head -2 | tail -1", + repoRoot, + verbose: verbose, + ); + if (result.isEmpty || result == excludeTag) { + return CiProcessRunner.runSync('git rev-list --max-parents=0 HEAD | head -1', repoRoot, verbose: verbose); + } + } return result; } @@ -133,7 +147,7 @@ abstract final class VersionDetection { final promptPath = '${versionAnalysisDir.path}/prompt.txt'; File(promptPath).writeAsStringSync(prompt); final geminiResult = CiProcessRunner.runSync( - 'cat $promptPath | gemini ' + 'cat "$promptPath" | gemini ' '-o json --yolo ' '-m $kGeminiProModel ' "--allowed-tools 'run_shell_command(git),run_shell_command(gh)' " @@ -153,7 +167,7 @@ abstract final class VersionDetection { try { final bumpData = json.decode(File(bumpJsonPath).readAsStringSync()) as Map; final rawBump = (bumpData['bump'] as String?)?.trim().toLowerCase().replaceAll(RegExp(r'[^a-z]'), ''); - if (rawBump == 'major' || rawBump == 'minor' || rawBump == 'patch' || rawBump == 'none') { + if (rawBump == 'major' || rawBump == 'minor' || rawBump == 'patch') { Logger.info(' Gemini analysis: $rawBump (overriding regex: $bump)'); bump = rawBump!; } else { @@ -202,14 +216,27 @@ abstract final class VersionDetection { /// Compare two semver versions. Returns negative if a < b, 0 if equal, /// positive if a > b. + /// + /// Handles pre-release suffixes: `1.0.0-beta.1 < 1.0.0` per semver spec. static int compareVersions(String a, String b) { - final aParts = a.split('.').map((p) => int.tryParse(p) ?? 0).toList(); - final bParts = b.split('.').map((p) => int.tryParse(p) ?? 0).toList(); + // Split off pre-release suffix (e.g. "1.0.0-beta.1" -> "1.0.0", "beta.1") + final aSplit = a.split('-'); + final bSplit = b.split('-'); + final aBase = aSplit.first; + final bBase = bSplit.first; + final aPreRelease = aSplit.length > 1 ? aSplit.sublist(1).join('-') : null; + final bPreRelease = bSplit.length > 1 ? bSplit.sublist(1).join('-') : null; + + final aParts = aBase.split('.').map((p) => int.tryParse(p) ?? 0).toList(); + final bParts = bBase.split('.').map((p) => int.tryParse(p) ?? 0).toList(); for (var i = 0; i < 3; i++) { final av = i < aParts.length ? aParts[i] : 0; final bv = i < bParts.length ? bParts[i] : 0; if (av != bv) return av - bv; } + // When base versions are equal, pre-release < release per semver spec. + if (aPreRelease != null && bPreRelease == null) return -1; + if (aPreRelease == null && bPreRelease != null) return 1; return 0; } } From f8c7e09473881ece77f2d32adbbee3343e635b30 Mon Sep 17 00:00:00 2001 From: Tsavo Knott Date: Tue, 24 Feb 2026 14:00:33 -0500 Subject: [PATCH 6/6] fix: validate YAML before write and create backup in pubspec auditor --- lib/src/cli/utils/audit/pubspec_auditor.dart | 27 ++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/lib/src/cli/utils/audit/pubspec_auditor.dart b/lib/src/cli/utils/audit/pubspec_auditor.dart index 0635b05..1e2e088 100644 --- a/lib/src/cli/utils/audit/pubspec_auditor.dart +++ b/lib/src/cli/utils/audit/pubspec_auditor.dart @@ -445,10 +445,37 @@ class PubspecAuditor { final updated = editor.toString(); if (updated == original) return false; + // Guard against producing an invalid pubspec.yaml. + try { + loadYaml(updated); + } on YamlException catch (e) { + Logger.error('Refusing to write invalid YAML for $pubspecPath: $e'); + return false; + } + + _writeBackup(pubspecPath, original); file.writeAsStringSync(updated); return true; } + void _writeBackup(String pubspecPath, String originalContent) { + // Keep the backup next to the file so developers can revert quickly. + // Use a stable name, but avoid overwriting an existing backup. + final base = '$pubspecPath.runtime_ci_tooling.bak'; + var backupPath = base; + if (File(base).existsSync()) { + final ts = DateTime.now().toUtc().toIso8601String().replaceAll(':', '-').replaceAll('.', '-'); + backupPath = '$pubspecPath.runtime_ci_tooling.$ts.bak'; + } + + try { + File(backupPath).writeAsStringSync(originalContent); + Logger.info(' Backup written: $backupPath'); + } catch (e) { + Logger.warn(' Could not write backup for $pubspecPath: $e'); + } + } + /// Rewrite a dependency to the full git format using registry values. void _rewriteToFullGitDep(YamlEditor editor, String sectionKey, String depName, RegistryEntry entry) { final gitBlock = {'url': entry.expectedGitUrl, 'tag_pattern': entry.tagPattern};