From 0fac87d9ea3a1b5c927f22edb3ecb7be0949eb4b Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Tue, 15 Sep 2026 10:31:55 -0400 Subject: [PATCH 1/3] Adding a split config file --- .github/monorepo-split.json | 349 ++++++++++++++++++++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 .github/monorepo-split.json diff --git a/.github/monorepo-split.json b/.github/monorepo-split.json new file mode 100644 index 00000000..32b64916 --- /dev/null +++ b/.github/monorepo-split.json @@ -0,0 +1,349 @@ +{ + "schema_version": 1, + "default_branch": "main", + "packages": [ + { + "name": "sonsofphp/bard", + "path": "src/SonsOfPHP/Bard", + "repository": "git@github.com:SonsOfPHP/bard.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/filesystem-aws", + "path": "src/SonsOfPHP/Bridge/Aws/Filesystem", + "repository": "git@github.com:SonsOfPHP/filesystem-aws.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/pager-doctrine-collections", + "path": "src/SonsOfPHP/Bridge/Doctrine/Collections/Pager", + "repository": "git@github.com:SonsOfPHP/pager-doctrine-collections.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/pager-doctrine-dbal", + "path": "src/SonsOfPHP/Bridge/Doctrine/DBAL/Pager", + "repository": "git@github.com:SonsOfPHP/pager-doctrine-dbal.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/event-sourcing-doctrine", + "path": "src/SonsOfPHP/Bridge/Doctrine/EventSourcing", + "repository": "git@github.com:SonsOfPHP/event-sourcing-doctrine.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/pager-doctrine-orm", + "path": "src/SonsOfPHP/Bridge/Doctrine/ORM/Pager", + "repository": "git@github.com:SonsOfPHP/pager-doctrine-orm.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/filesystem-liip-imagine", + "path": "src/SonsOfPHP/Bridge/LiipImagine/Filesystem", + "repository": "git@github.com:SonsOfPHP/filesystem-liip-imagine.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/cqrs-symfony", + "path": "src/SonsOfPHP/Bridge/Symfony/Cqrs", + "repository": "git@github.com:SonsOfPHP/cqrs-symfony.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/event-sourcing-symfony", + "path": "src/SonsOfPHP/Bridge/Symfony/EventSourcing", + "repository": "git@github.com:SonsOfPHP/event-sourcing-symfony.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/money-twig", + "path": "src/SonsOfPHP/Bridge/Twig/Money", + "repository": "git@github.com:SonsOfPHP/money-twig.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/cqrs-bundle", + "path": "src/SonsOfPHP/Bundle/Cqrs", + "repository": "git@github.com:SonsOfPHP/cqrs-bundle.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/feature-toggle-bundle", + "path": "src/SonsOfPHP/Bundle/FeatureToggleBundle", + "repository": "git@github.com:SonsOfPHP/feature-toggle-bundle.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/assert", + "path": "src/SonsOfPHP/Component/Assert", + "repository": "git@github.com:SonsOfPHP/assert.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/cache", + "path": "src/SonsOfPHP/Component/Cache", + "repository": "git@github.com:SonsOfPHP/cache.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/clock", + "path": "src/SonsOfPHP/Component/Clock", + "repository": "git@github.com:SonsOfPHP/clock.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/container", + "path": "src/SonsOfPHP/Component/Container", + "repository": "git@github.com:SonsOfPHP/container.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/cookie", + "path": "src/SonsOfPHP/Component/Cookie", + "repository": "git@github.com:SonsOfPHP/cookie.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/cqrs", + "path": "src/SonsOfPHP/Component/Cqrs", + "repository": "git@github.com:SonsOfPHP/cqrs.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/event-dispatcher", + "path": "src/SonsOfPHP/Component/EventDispatcher", + "repository": "git@github.com:SonsOfPHP/event-dispatcher.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/event-sourcing", + "path": "src/SonsOfPHP/Component/EventSourcing", + "repository": "git@github.com:SonsOfPHP/event-sourcing.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/feature-toggle", + "path": "src/SonsOfPHP/Component/FeatureToggle", + "repository": "git@github.com:SonsOfPHP/feature-toggle.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/filesystem", + "path": "src/SonsOfPHP/Component/Filesystem", + "repository": "git@github.com:SonsOfPHP/filesystem.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/http-factory", + "path": "src/SonsOfPHP/Component/HttpFactory", + "repository": "git@github.com:SonsOfPHP/http-factory.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/http-handler", + "path": "src/SonsOfPHP/Component/HttpHandler", + "repository": "git@github.com:SonsOfPHP/http-handler.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/http-message", + "path": "src/SonsOfPHP/Component/HttpMessage", + "repository": "git@github.com:SonsOfPHP/http-message.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/json", + "path": "src/SonsOfPHP/Component/Json", + "repository": "git@github.com:SonsOfPHP/json.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/link", + "path": "src/SonsOfPHP/Component/Link", + "repository": "git@github.com:SonsOfPHP/link.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/logger", + "path": "src/SonsOfPHP/Component/Logger", + "repository": "git@github.com:SonsOfPHP/logger.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/mailer", + "path": "src/SonsOfPHP/Component/Mailer", + "repository": "git@github.com:SonsOfPHP/mailer.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/money", + "path": "src/SonsOfPHP/Component/Money", + "repository": "git@github.com:SonsOfPHP/money.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/pager", + "path": "src/SonsOfPHP/Component/Pager", + "repository": "git@github.com:SonsOfPHP/pager.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/registry", + "path": "src/SonsOfPHP/Component/Registry", + "repository": "git@github.com:SonsOfPHP/registry.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/state-machine", + "path": "src/SonsOfPHP/Component/StateMachine", + "repository": "git@github.com:SonsOfPHP/state-machine.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/version", + "path": "src/SonsOfPHP/Component/Version", + "repository": "git@github.com:SonsOfPHP/version.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/common-contract", + "path": "src/SonsOfPHP/Contract/Common", + "repository": "git@github.com:SonsOfPHP/common-contract.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/cookie-contract", + "path": "src/SonsOfPHP/Contract/Cookie", + "repository": "git@github.com:SonsOfPHP/cookie-contract.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/cqrs-contract", + "path": "src/SonsOfPHP/Contract/Cqrs", + "repository": "git@github.com:SonsOfPHP/cqrs-contract.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/event-sourcing-contract", + "path": "src/SonsOfPHP/Contract/EventSourcing", + "repository": "git@github.com:SonsOfPHP/event-sourcing-contract.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/feature-toggle-contract", + "path": "src/SonsOfPHP/Contract/FeatureToggle", + "repository": "git@github.com:SonsOfPHP/feature-toggle-contract.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/filesystem-contract", + "path": "src/SonsOfPHP/Contract/Filesystem", + "repository": "git@github.com:SonsOfPHP/filesystem-contract.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/http-handler-contract", + "path": "src/SonsOfPHP/Contract/HttpHandler", + "repository": "git@github.com:SonsOfPHP/http-handler-contract.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/logger-contract", + "path": "src/SonsOfPHP/Contract/Logger", + "repository": "git@github.com:SonsOfPHP/logger-contract.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/mailer-contract", + "path": "src/SonsOfPHP/Contract/Mailer", + "repository": "git@github.com:SonsOfPHP/mailer-contract.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/money-contract", + "path": "src/SonsOfPHP/Contract/Money", + "repository": "git@github.com:SonsOfPHP/money-contract.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/pager-contract", + "path": "src/SonsOfPHP/Contract/Pager", + "repository": "git@github.com:SonsOfPHP/pager-contract.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/registry-contract", + "path": "src/SonsOfPHP/Contract/Registry", + "repository": "git@github.com:SonsOfPHP/registry-contract.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/state-machine-contract", + "path": "src/SonsOfPHP/Contract/StateMachine", + "repository": "git@github.com:SonsOfPHP/state-machine-contract.git", + "branch": "main", + "status": "active" + }, + { + "name": "sonsofphp/stdlib-contract", + "path": "src/SonsOfPHP/Contract/Stdlib", + "repository": null, + "branch": "main", + "status": "pending" + }, + { + "name": "sonsofphp/version-contract", + "path": "src/SonsOfPHP/Contract/Version", + "repository": "git@github.com:SonsOfPHP/version-contract.git", + "branch": "main", + "status": "active" + } + ] +} From 261dc911183510932a008ba82ab690cc3fd83f41 Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Tue, 15 Sep 2026 10:38:54 -0400 Subject: [PATCH 2/3] adding script --- .github/scripts/monorepo-split-plan.php | 348 ++++++++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100755 .github/scripts/monorepo-split-plan.php diff --git a/.github/scripts/monorepo-split-plan.php b/.github/scripts/monorepo-split-plan.php new file mode 100755 index 00000000..cbc54caa --- /dev/null +++ b/.github/scripts/monorepo-split-plan.php @@ -0,0 +1,348 @@ +#!/usr/bin/env php +] [--head=] + +Validates the monorepo split map and prints the read-only repository split plan. +This is a dry-run planner only. It does not run splitsh-lite and never pushes. + +HELP); + exit(0); +} + +$root = dirname(__DIR__, 2); +$configPath = (string) ($options['config'] ?? '.github/monorepo-split.json'); +$configFile = str_starts_with($configPath, '/') ? $configPath : $root . '/' . $configPath; +$base = isset($options['base']) ? (string) $options['base'] : null; +$head = isset($options['head']) ? (string) $options['head'] : null; + +$errors = []; +$warnings = []; + +if (!is_file($configFile)) { + fail(['Split config was not found: ' . $configFile]); +} + +$config = json_decode((string) file_get_contents($configFile), true); +if (!is_array($config)) { + fail(['Split config is not valid JSON: ' . json_last_error_msg()]); +} + +$packages = $config['packages'] ?? null; +if (!is_array($packages)) { + fail(['Split config must define a packages array.']); +} + +$allowedStatuses = ['active', 'pending', 'archived', 'disabled']; +$seenNames = []; +$seenPaths = []; +$seenRepositories = []; +$statusCounts = array_fill_keys($allowedStatuses, 0); + +foreach ($packages as $index => $package) { + $number = $index + 1; + if (!is_array($package)) { + $errors[] = sprintf('Package #%d must be an object.', $number); + continue; + } + + foreach (['name', 'path', 'branch', 'status'] as $requiredKey) { + if (!isset($package[$requiredKey]) || '' === $package[$requiredKey]) { + $errors[] = sprintf('Package #%d is missing "%s".', $number, $requiredKey); + } + } + + $name = (string) ($package['name'] ?? 'package #' . $number); + $path = (string) ($package['path'] ?? ''); + $repository = $package['repository'] ?? null; + $status = (string) ($package['status'] ?? ''); + + if (isset($seenNames[$name])) { + $errors[] = sprintf('Duplicate package name: %s.', $name); + } + $seenNames[$name] = true; + + if (isset($seenPaths[$path])) { + $errors[] = sprintf('Duplicate package path: %s.', $path); + } + $seenPaths[$path] = true; + + if (null !== $repository && '' !== $repository) { + if (!is_string($repository)) { + $errors[] = sprintf('%s repository must be a string or null.', $name); + } elseif (isset($seenRepositories[$repository])) { + $errors[] = sprintf('Duplicate repository: %s.', $repository); + } else { + $seenRepositories[$repository] = true; + } + } + + if (!in_array($status, $allowedStatuses, true)) { + $errors[] = sprintf('%s has invalid status "%s".', $name, $status); + } else { + ++$statusCounts[$status]; + } + + if ('active' === $status && (null === $repository || '' === $repository)) { + $errors[] = sprintf('%s is active but has no repository.', $name); + } + + $composerFile = $root . '/' . $path . '/composer.json'; + if ('archived' !== $status && !is_file($composerFile)) { + $errors[] = sprintf('%s is %s but %s is missing.', $name, $status, $path . '/composer.json'); + continue; + } + + if (is_file($composerFile)) { + $composer = json_decode((string) file_get_contents($composerFile), true); + if (!is_array($composer)) { + $errors[] = sprintf('%s has invalid package composer.json.', $name); + continue; + } + + if (($composer['name'] ?? null) !== $name) { + $errors[] = sprintf('%s has composer name mismatch: %s.', $name, (string) ($composer['name'] ?? 'missing')); + } + } +} + +$composerPackagePaths = discoverComposerPackagePaths($root); +$missingFromConfig = array_values(array_diff($composerPackagePaths, array_keys($seenPaths))); +$extraConfiguredPaths = array_values(array_diff(array_keys($seenPaths), $composerPackagePaths)); + +foreach ($missingFromConfig as $path) { + $errors[] = sprintf('Package composer file is missing from split config: %s/composer.json.', $path); +} + +foreach ($extraConfiguredPaths as $path) { + $package = findPackageByPath($packages, $path); + if (is_array($package) && 'archived' === ($package['status'] ?? null)) { + continue; + } + + $warnings[] = sprintf('Configured path does not currently contain a package composer file: %s.', $path); +} + +$changedFiles = []; +if (null !== $base || null !== $head) { + if (null === $base || null === $head) { + $errors[] = 'Both --base and --head are required when checking changed paths.'; + } else { + $changedFiles = changedFiles($root, $base, $head, $errors); + } +} + +if ([] !== $errors) { + printSummary($configFile, $packages, $statusCounts, $warnings, $changedFiles, [], []); + fail($errors); +} + +[$plannedPackages, $skippedPackages] = planPackages($packages, $changedFiles); +printSummary($configFile, $packages, $statusCounts, $warnings, $changedFiles, $plannedPackages, $skippedPackages); + +exit(0); + +/** + * @param list $errors + */ +function fail(array $errors): never +{ + fwrite(STDERR, PHP_EOL . 'Split plan failed:' . PHP_EOL); + foreach ($errors as $error) { + fwrite(STDERR, ' - ' . $error . PHP_EOL); + } + + exit(1); +} + +/** + * @return list + */ +function discoverComposerPackagePaths(string $root): array +{ + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root . '/src/SonsOfPHP', FilesystemIterator::SKIP_DOTS) + ); + + $paths = []; + foreach ($iterator as $file) { + if (!$file instanceof SplFileInfo || 'composer.json' !== $file->getFilename()) { + continue; + } + + $path = str_replace($root . '/', '', $file->getPath()); + if (str_contains($path, '/vendor/')) { + continue; + } + + $paths[] = $path; + } + + sort($paths); + + return $paths; +} + +/** + * @param list> $packages + * + * @return array|null + */ +function findPackageByPath(array $packages, string $path): ?array +{ + foreach ($packages as $package) { + if (is_array($package) && ($package['path'] ?? null) === $path) { + return $package; + } + } + + return null; +} + +/** + * @param list $errors + * + * @return list + */ +function changedFiles(string $root, string $base, string $head, array &$errors): array +{ + $command = sprintf( + 'git -C %s diff --name-only %s %s 2>&1', + escapeshellarg($root), + escapeshellarg($base), + escapeshellarg($head), + ); + + exec($command, $output, $exitCode); + if (0 !== $exitCode) { + $errors[] = sprintf('Unable to read changed files for %s..%s: %s', $base, $head, implode(PHP_EOL, $output)); + + return []; + } + + return array_values(array_filter($output, static fn (string $line): bool => '' !== $line)); +} + +/** + * @param list> $packages + * @param list $changedFiles + * + * @return array{0: list>, 1: list} + */ +function planPackages(array $packages, array $changedFiles): array +{ + $planned = []; + $skipped = []; + $hasChangeSet = [] !== $changedFiles; + + foreach ($packages as $package) { + if (!is_array($package)) { + continue; + } + + $name = (string) $package['name']; + $path = rtrim((string) $package['path'], '/'); + $status = (string) $package['status']; + + if ('active' !== $status) { + $skipped[] = ['name' => $name, 'reason' => $status]; + continue; + } + + if ($hasChangeSet && !packageChanged($path, $changedFiles)) { + $skipped[] = ['name' => $name, 'reason' => 'unchanged']; + continue; + } + + $planned[] = $package; + } + + return [$planned, $skipped]; +} + +/** + * @param list $changedFiles + */ +function packageChanged(string $path, array $changedFiles): bool +{ + foreach ($changedFiles as $changedFile) { + if ($changedFile === $path || str_starts_with($changedFile, $path . '/')) { + return true; + } + } + + return false; +} + +/** + * @param list> $packages + * @param array $statusCounts + * @param list $warnings + * @param list $changedFiles + * @param list> $plannedPackages + * @param list $skippedPackages + */ +function printSummary(string $configFile, array $packages, array $statusCounts, array $warnings, array $changedFiles, array $plannedPackages, array $skippedPackages): void +{ + fwrite(STDOUT, 'Monorepo split dry-run plan' . PHP_EOL); + fwrite(STDOUT, 'Config: ' . $configFile . PHP_EOL); + fwrite(STDOUT, sprintf('Packages: %d active=%d pending=%d archived=%d disabled=%d', + count($packages), + $statusCounts['active'], + $statusCounts['pending'], + $statusCounts['archived'], + $statusCounts['disabled'], + ) . PHP_EOL); + + if ([] !== $warnings) { + fwrite(STDOUT, PHP_EOL . 'Warnings:' . PHP_EOL); + foreach ($warnings as $warning) { + fwrite(STDOUT, ' - ' . $warning . PHP_EOL); + } + } + + if ([] !== $changedFiles) { + fwrite(STDOUT, PHP_EOL . sprintf('Changed files considered: %d', count($changedFiles)) . PHP_EOL); + } else { + fwrite(STDOUT, PHP_EOL . 'Changed files considered: none, planning every active package' . PHP_EOL); + } + + fwrite(STDOUT, PHP_EOL . sprintf('Would split/publish: %d package(s)', count($plannedPackages)) . PHP_EOL); + foreach ($plannedPackages as $package) { + fwrite(STDOUT, sprintf( + ' - %s | path=%s | repository=%s | branch=%s', + (string) $package['name'], + (string) $package['path'], + (string) $package['repository'], + (string) $package['branch'], + ) . PHP_EOL); + } + + $skipCounts = []; + foreach ($skippedPackages as $skippedPackage) { + $reason = $skippedPackage['reason']; + $skipCounts[$reason] = ($skipCounts[$reason] ?? 0) + 1; + } + + if ([] !== $skipCounts) { + ksort($skipCounts); + fwrite(STDOUT, PHP_EOL . 'Skipped:' . PHP_EOL); + foreach ($skipCounts as $reason => $count) { + fwrite(STDOUT, sprintf(' - %s: %d', $reason, $count) . PHP_EOL); + } + } + + fwrite(STDOUT, PHP_EOL . 'Dry run only. No splitsh-lite command was run and nothing was pushed.' . PHP_EOL); +} From 993e2e5971f3ebfa86d159eed18b14abaecc5be5 Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Tue, 15 Sep 2026 10:42:38 -0400 Subject: [PATCH 3/3] adding workflow for splitting code out of monorepo --- .github/workflows/monorepo-split-plan.yml | 37 +++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/monorepo-split-plan.yml diff --git a/.github/workflows/monorepo-split-plan.yml b/.github/workflows/monorepo-split-plan.yml new file mode 100644 index 00000000..a0c59628 --- /dev/null +++ b/.github/workflows/monorepo-split-plan.yml @@ -0,0 +1,37 @@ +name: Monorepo Split Plan +on: + pull_request: + branches: [main] + paths: + - '.github/monorepo-split.json' + - '.github/scripts/monorepo-split-plan.php' + - '.github/workflows/monorepo-split-plan.yml' + - 'src/SonsOfPHP/**/composer.json' + - 'src/**' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + plan: + name: Dry-run split plan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + - name: Validate split config + run: php .github/scripts/monorepo-split-plan.php + - name: Plan changed package splits + run: | + php .github/scripts/monorepo-split-plan.php \ + --base='${{ github.event.pull_request.base.sha }}' \ + --head='${{ github.event.pull_request.head.sha }}'