['arch' => 'x86_64', 'runner' => 'ubuntu-latest', 'os_key' => 'Linux'], 'windows' => ['arch' => 'x86_64', 'runner' => 'windows-latest', 'os_key' => 'Windows'], 'macos' => ['arch' => 'aarch64', 'runner' => 'macos-15', 'os_key' => 'Darwin'], ]; /** * Tier 2 runners: Linux aarch64 + macOS x86_64, no Windows. */ private const array OS_RUNNERS_TIER2 = [ 'linux' => ['arch' => 'aarch64', 'runner' => 'ubuntu-24.04-arm', 'os_key' => 'Linux'], 'macos' => ['arch' => 'x86_64', 'runner' => 'macos-15-intel', 'os_key' => 'Darwin'], ]; /** * Extensions excluded from specific OS matrix entries. */ private const array OS_EXCLUDE = [ 'linux' => ['glfw'], ]; /** * Extra build flags appended when a matrix entry contains any of the listed extensions. * Key: extension display name (without ext- prefix). Value: extra flags string. */ private const array EXTRA_BUILD_FLAGS = [ 'parallel' => '--enable-zts', ]; /** * Pairs of extensions that cannot be built together in the same matrix entry. */ private const array CONFLICTS = [ ['grpc', 'protobuf'], ['swow', 'swoole'], ]; /** * Extensions that must always appear alone in their own matrix entry. * Use display names (without ext- prefix). */ private const array STANDALONE = [ 'grpc', 'glfw', 'imagick', 'intl', 'mongodb', ]; /** * Extensions that are emitted as isolated standalone entries. */ private const array STANDALONE_ISOLATED = [ 'swow' => '', 'swoole' => 'swoole-hook-', ]; /** * Maximum number of orphan extensions per matrix entry. */ private const int ORPHAN_BATCH_SIZE = 15; protected bool $no_motd = true; public function configure(): void { $this->addOption('for-extensions', null, InputOption::VALUE_OPTIONAL, 'Filter by extension display names, comma-separated', '') ->addOption('for-libs', null, InputOption::VALUE_OPTIONAL, 'Filter by lib names (depends+suggests), comma-separated', '') ->addOption('os', null, InputOption::VALUE_OPTIONAL, 'Filter by OS (Linux/Darwin/Windows), comma-separated', '') ->addOption('tier2', null, InputOption::VALUE_NONE, 'Use Tier 2 runners (Linux aarch64 + macOS x86_64, no Windows)'); } public function handle(): int { if (!spc_mode(SPC_MODE_SOURCE)) { $this->output->writeln('This command is only available in source mode.'); return static::USER_ERROR; } $parse_option = fn (string $name): array => array_values(array_filter(array_map('trim', explode(',', (string) $this->input->getOption($name))))); $filter_extensions = $parse_option('for-extensions'); $filter_libs = $parse_option('for-libs'); $filter_os_keys = $parse_option('os'); $tier2 = (bool) $this->input->getOption('tier2'); $base_runners = $tier2 ? self::OS_RUNNERS_TIER2 : self::OS_RUNNERS; $all = PackageConfig::getAll(); // Separate into regular and virtual extensions (build-static:false excluded globally) $all_regular = []; $all_virtual = []; foreach ($all as $pkg_name => $config) { if (($config['type'] ?? '') !== 'php-extension') { continue; } if (($config['php-extension']['build-static'] ?? null) === false) { continue; } if (($config['php-extension']['arg-type'] ?? '') === 'none') { $all_virtual[$pkg_name] = $config; } else { $all_regular[$pkg_name] = $config; } } $os_runners = empty($filter_os_keys) ? $base_runners : array_filter($base_runners, fn ($info) => in_array($info['os_key'], $filter_os_keys, true)); $entries = []; $all_ext_lib_deps = []; foreach ($os_runners as $os => $os_info) { $os_key = $os_info['os_key']; // Filter by OS support $os_exclude = array_fill_keys(array_map(fn ($n) => 'ext-' . $n, self::OS_EXCLUDE[$os] ?? []), true); $os_regular = array_filter($all_regular, fn ($c, $k) => $this->supportsOS($c, $os_key) && !isset($os_exclude[$k]), ARRAY_FILTER_USE_BOTH); $os_virtual = array_filter($all_virtual, fn ($c, $k) => $this->supportsOS($c, $os_key) && !isset($os_exclude[$k]), ARRAY_FILTER_USE_BOTH); // Pool: all ext-* names available on this OS (regular + virtual) $pool_set = array_fill_keys( array_merge(array_keys($os_regular), array_keys($os_virtual)), true ); // Compute ext_deps for every pool member: union of depends + suggests, limited to pool $ext_deps = []; $os_lib_deps = []; foreach (array_merge($os_regular, $os_virtual) as $pkg_name => $config) { $raw = array_merge( $this->resolvePlatformList($config, 'depends', $os), $this->resolvePlatformList($config, 'suggests', $os), ); $ext_deps[$pkg_name] = array_values(array_filter( $raw, fn ($d) => isset($pool_set[$d]) && $d !== $pkg_name )); $os_lib_deps[$this->displayName($pkg_name)] = array_values(array_filter( $raw, fn ($d) => !str_starts_with($d, 'ext-') )); } $all_ext_lib_deps[$os] = $os_lib_deps; // Which regular exts are reachable as a dep/suggest from another regular ext? $depended_on = []; foreach ($os_regular as $pkg_name => $_) { foreach ($ext_deps[$pkg_name] as $dep) { $depended_on[$dep] = true; } } // Process order: roots (not depended on) first, then non-roots; each group alpha-sorted $roots = []; $non_roots = []; foreach (array_keys($os_regular) as $pkg_name) { if (isset($depended_on[$pkg_name])) { $non_roots[] = $pkg_name; } else { $roots[] = $pkg_name; } } sort($roots); sort($non_roots); // DFS to collect dependency chains; true orphans (no ext-* relations) are batched $covered = []; $groups = []; $orphans = []; $standalone_set = array_fill_keys(self::STANDALONE, true); $standalone_isolated = self::STANDALONE_ISOLATED; foreach (array_merge($roots, $non_roots) as $ext) { if (isset($covered[$ext])) { continue; } $display = $this->displayName($ext); if (array_key_exists($display, $standalone_isolated)) { // Isolated standalone: mark only this ext + its hook virtuals as covered $covered[$ext] = true; $hook_prefix = $standalone_isolated[$display]; $group_names = [$display]; if ($hook_prefix !== '') { foreach ($os_virtual as $vpkg => $_) { $vdisplay = $this->displayName($vpkg); if (str_starts_with($vdisplay, $hook_prefix) && !isset($covered[$vpkg])) { $covered[$vpkg] = true; $group_names[] = $vdisplay; } } sort($group_names); } $groups[] = implode(',', $group_names); continue; } $chain = $this->dfsCollect($ext, $ext_deps, $pool_set, $covered); if (isset($standalone_set[$display])) { // Always emit standalone extensions as their own single entry $groups[] = $display; } elseif (count($chain) === 1 && empty($ext_deps[$ext])) { $orphans[] = $display; } else { $groups[] = implode(',', array_map($this->displayName(...), $chain)); } } // Batch orphans, splitting conflicting extensions into separate entries if (!empty($orphans)) { sort($orphans); foreach ($this->splitOrphansByConflicts($orphans) as $batch) { $groups[] = implode(',', $batch); } } sort($groups); foreach ($groups as $group) { $extra = $this->extraBuildFlags($group); $entries[] = [ 'runner' => $os_info['runner'], 'os' => $os, 'arch' => $os_info['arch'], 'extension' => $group, 'build-args' => './bin/spc build "' . $group . '" ' . self::BUILD_TARGETS . ($extra !== '' ? ' ' . $extra : ''), ]; } } if (!empty($filter_extensions)) { $entries = array_values(array_filter($entries, function (array $entry) use ($filter_extensions): bool { $names = explode(',', $entry['extension']); return count(array_intersect($names, $filter_extensions)) > 0; })); } if (!empty($filter_libs)) { $entries = array_values(array_filter($entries, function (array $entry) use ($filter_libs, $all_ext_lib_deps): bool { $names = explode(',', $entry['extension']); $lib_deps = $all_ext_lib_deps[$entry['os']] ?? []; foreach ($names as $name) { if (count(array_intersect($lib_deps[$name] ?? [], $filter_libs)) > 0) { return true; } } return false; })); } $this->output->write(json_encode($entries, JSON_UNESCAPED_SLASHES)); return static::SUCCESS; } /** * DFS-collect the dependency chain starting from $ext. * Marks all visited nodes in $covered to prevent duplicates and handle cycles. */ private function dfsCollect(string $ext, array $ext_deps, array $pool_set, array &$covered): array { if (isset($covered[$ext])) { return []; } $covered[$ext] = true; $chain = [$ext]; foreach ($ext_deps[$ext] ?? [] as $dep) { if (!isset($covered[$dep]) && isset($pool_set[$dep])) { $chain = array_merge($chain, $this->dfsCollect($dep, $ext_deps, $pool_set, $covered)); } } return $chain; } private function supportsOS(array $config, string $os_key): bool { $os_list = $config['php-extension']['os'] ?? null; return $os_list === null || in_array($os_key, $os_list, true); } private function displayName(string $pkg_name): string { return str_starts_with($pkg_name, 'ext-') ? substr($pkg_name, 4) : $pkg_name; } /** * Split orphans into batches such that no two conflicting extensions share a batch. * Uses a greedy graph-coloring approach. * * @param string[] $orphans display names, pre-sorted * @return string[][] array of batches, each batch is an array of display names */ private function splitOrphansByConflicts(array $orphans): array { $adjacency = []; foreach (self::CONFLICTS as [$a, $b]) { $adjacency[$a][$b] = true; $adjacency[$b][$a] = true; } $batches = []; foreach ($orphans as $ext) { $placed = false; foreach ($batches as &$batch) { if (count($batch) >= self::ORPHAN_BATCH_SIZE) { continue; } $conflict = false; foreach ($batch as $member) { if (isset($adjacency[$ext][$member])) { $conflict = true; break; } } if (!$conflict) { $batch[] = $ext; $placed = true; break; } } unset($batch); if (!$placed) { $batches[] = [$ext]; } } return $batches; } /** * Returns any extra build flags required for an extension group string. * Checks whether any extension in the comma-separated group matches EXTRA_BUILD_FLAGS. */ private function extraBuildFlags(string $group): string { $names = explode(',', $group); $flags = []; foreach (self::EXTRA_BUILD_FLAGS as $ext => $extra) { if (in_array($ext, $names, true)) { $flags[] = $extra; } } return implode(' ', $flags); } /** * Resolve the value of a platform-specific array field, applying the suffix fallback chain. * * Fallback rules (same as PackageConfig::get): * linux : @linux → @unix → (base) * macos : @macos → @unix → (base) * windows : @windows → (base) */ private function resolvePlatformList(array $config, string $field, string $platform): array { return match ($platform) { 'linux' => $config["{$field}@linux"] ?? $config["{$field}@unix"] ?? $config[$field] ?? [], 'macos' => $config["{$field}@macos"] ?? $config["{$field}@unix"] ?? $config[$field] ?? [], 'windows' => $config["{$field}@windows"] ?? $config[$field] ?? [], default => $config[$field] ?? [], }; } }