From bb520fc1154b3b5a33ddf0501052c3e33e09c677 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Tue, 3 Mar 2026 08:25:38 +0100 Subject: [PATCH 01/17] CS: We want to keep PHP 8.2 baseline for now --- .php-cs-fixer.dist.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index fddb210e..a714e3e4 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -20,7 +20,7 @@ $config = (new PhpCsFixer\Config()) ->setRules([ '@PER-CS' => true, - '@PHP8x3Migration' => true, + '@PHP8x2Migration' => true, 'php_unit_test_class_requires_covers' => true, 'nullable_type_declaration_for_default_null_value' => true, 'Horde/remove_php_version_comment' => true, From 537e1c8603fd190fdd7b559d90aef9a5b2618009 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Tue, 3 Mar 2026 10:44:00 +0100 Subject: [PATCH 02/17] feat(ci): add Phase 0 template infrastructure Add template rendering system for CI configuration files: Template Infrastructure: - TemplateRenderer: Renders templates with variable substitution - TemplateLocator: Finds template files in data/ci/ directory - TemplateVersion: Extracts and compares template versions Templates: - bootstrap-github.sh.template: GitHub Actions bootstrap script - bootstrap-local.sh.template: Local development bootstrap script - workflow.yml.template: GitHub Actions workflow configuration Documentation: - README.md: Template usage guide - template-variables.md: Variable reference Tests: - TemplateRendererTest: Full test coverage for rendering logic All tests passing (10/10). Related to components-ci-implementation-plan.md Phase 0. --- data/ci/README.md | 145 ++++++++++++++ data/ci/bootstrap-github.sh.template | 124 ++++++++++++ data/ci/bootstrap-local.sh.template | 92 +++++++++ data/ci/template-variables.md | 153 ++++++++++++++ data/ci/workflow.yml.template | 36 ++++ src/Ci/Template/TemplateLocator.php | 101 ++++++++++ src/Ci/Template/TemplateRenderer.php | 128 ++++++++++++ src/Ci/Template/TemplateVersion.php | 96 +++++++++ .../Unit/Ci/Template/TemplateRendererTest.php | 189 ++++++++++++++++++ 9 files changed, 1064 insertions(+) create mode 100644 data/ci/README.md create mode 100644 data/ci/bootstrap-github.sh.template create mode 100644 data/ci/bootstrap-local.sh.template create mode 100644 data/ci/template-variables.md create mode 100644 data/ci/workflow.yml.template create mode 100644 src/Ci/Template/TemplateLocator.php create mode 100644 src/Ci/Template/TemplateRenderer.php create mode 100644 src/Ci/Template/TemplateVersion.php create mode 100644 test/Unit/Ci/Template/TemplateRendererTest.php diff --git a/data/ci/README.md b/data/ci/README.md new file mode 100644 index 00000000..95f2a9a6 --- /dev/null +++ b/data/ci/README.md @@ -0,0 +1,145 @@ +# Horde Components CI Templates + +This directory contains templates for generating CI configuration files for Horde components. + +## Template Files + +### `bootstrap-github.sh.template` +Bootstrap script for GitHub Actions. Sets up environment and downloads horde-components.phar. + +### `bootstrap-local.sh.template` +Bootstrap script for local development. Uses local horde-components installation. + +### `workflow.yml.template` +GitHub Actions workflow configuration for running CI on pull requests and pushes. + +## Template Variables + +Templates use `{{VARIABLE_NAME}}` syntax for placeholders. These are replaced during generation: + +### User-Provided Variables + +- `{{COMPONENT_NAME}}` - Component name (e.g., "Db", "Http") +- `{{COMPONENTS_PHAR_URL}}` - URL to download horde-components.phar +- `{{WORK_DIR}}` - Working directory for CI (e.g., "/tmp/horde-ci") +- `{{PHP_VERSION}}` - Bootstrap PHP version (e.g., "8.4") +- `{{LOCAL_COMPONENTS_PATH}}` - Local path to horde-components (local mode only) +- `{{LOCAL_COMPONENT_PATH}}` - Local path to component (local mode only) + +### Automatic Variables + +These are added automatically by the template renderer: + +- `{{TEMPLATE_VERSION}}` - Template version (e.g., "1.0.0") +- `{{GENERATED_DATE}}` - Generation timestamp +- `{{COMPONENTS_VERSION}}` - horde-components version + +## Usage + +### Generate CI files for a component + +```bash +cd ~/git/horde/Http +horde-components ci init +``` + +This creates: +- `bin/ci-bootstrap.sh` - Bootstrap script +- `.github/workflows/ci.yml` - GitHub Actions workflow + +### Generate for local mode + +```bash +cd ~/git/horde/Http +horde-components ci init --mode=local +``` + +### Check if templates are outdated + +```bash +cd ~/git/horde/Http +horde-components ci check +``` + +### Regenerate with latest templates + +```bash +cd ~/git/horde/Http +horde-components ci init --force +``` + +## Template Versioning + +Generated files include a version comment: + +```bash +# Template version: 1.0.0 +``` + +This allows detection of outdated generated files. When templates are updated, the version is incremented following semantic versioning: + +- **Major version** (X.0.0): Breaking changes to generated files +- **Minor version** (1.X.0): New features, backwards compatible +- **Patch version** (1.0.X): Bug fixes + +## Current Version + +**Template Version:** 1.0.0 + +**Changelog:** +- 1.0.0 (2026-03-03): Initial template release + +## Customization + +Components can customize generated files after generation. However: + +- Mark customized sections with comments +- Run `ci init --force` will overwrite customizations +- Consider submitting improvements back to templates + +Example: + +```bash +#!/bin/bash +# ... (generated code) + +# CUSTOMIZATION START - Component-specific environment +export SPECIAL_VAR="value" +# CUSTOMIZATION END + +# ... (generated code continues) +``` + +## Testing Templates + +Templates can be tested without generating files: + +```bash +# Dry run - show what would be generated +horde-components ci init --dry-run +``` + +## Adding New Templates + +1. Create new `.template` file in this directory +2. Use `{{VARIABLE}}` syntax for placeholders +3. Add variables to `template-variables.md` +4. Update this README +5. Add rendering logic to `InitCommand` if needed + +## Template Format + +Templates are plain text files with variable substitution. Keep them: + +- Simple and readable +- Well-commented +- Executable (for shell scripts) +- Valid syntax (for YAML/JSON) + +## Support + +For issues with templates or CI setup: + +1. Check generated file versions (`ci check`) +2. Try regenerating (`ci init --force`) +3. Report issues: https://github.com/horde/components/issues diff --git a/data/ci/bootstrap-github.sh.template b/data/ci/bootstrap-github.sh.template new file mode 100644 index 00000000..249a981e --- /dev/null +++ b/data/ci/bootstrap-github.sh.template @@ -0,0 +1,124 @@ +#!/bin/bash +# Horde CI Bootstrap Script (GitHub Actions) +# Generated by: horde-components {{COMPONENTS_VERSION}} +# Template version: {{TEMPLATE_VERSION}} +# Generated: {{GENERATED_DATE}} +# +# DO NOT EDIT - Regenerate with: horde-components ci init + +set -e +set -o pipefail + +# Configuration +BOOTSTRAP_PHP_VERSION="{{PHP_VERSION}}" +COMPONENTS_PHAR_URL="{{COMPONENTS_PHAR_URL}}" +COMPONENT_NAME="{{COMPONENT_NAME}}" +WORK_DIR="{{WORK_DIR}}" + +# Colors for output (disabled in CI) +RED='' +GREEN='' +YELLOW='' +NC='' + +# Logging functions +log_info() { + if [ -n "$GITHUB_ACTIONS" ]; then + echo "::notice::$*" + else + echo "[INFO] $*" + fi +} + +log_warn() { + if [ -n "$GITHUB_ACTIONS" ]; then + echo "::warning::$*" + else + echo "[WARN] $*" + fi +} + +log_error() { + if [ -n "$GITHUB_ACTIONS" ]; then + echo "::error::$*" + else + echo "[ERROR] $*" + fi >&2 +} + +# Stage 1: Validate environment +log_info "Horde CI Bootstrap - Stage 1 (GitHub Actions mode)" +log_info "Component: $COMPONENT_NAME" +log_info "Bootstrap PHP: $BOOTSTRAP_PHP_VERSION" + +# Check we're in GitHub Actions +if [ -z "$GITHUB_ACTIONS" ]; then + log_error "This script is for GitHub Actions. Use bootstrap-local.sh for local development." + exit 1 +fi + +# Validate required environment variables +if [ -z "$GITHUB_REPOSITORY" ]; then + log_error "GITHUB_REPOSITORY not set" + exit 1 +fi + +if [ -z "$GITHUB_REF" ]; then + log_error "GITHUB_REF not set" + exit 1 +fi + +if [ -z "$GITHUB_TOKEN" ]; then + log_error "GITHUB_TOKEN not set" + exit 1 +fi + +# Stage 2: Verify PHP +if ! command -v php &> /dev/null; then + log_error "PHP not found. Ensure setup-php action runs before this script." + exit 1 +fi + +PHP_VERSION=$(php -r 'echo PHP_VERSION;') +log_info "PHP version: $PHP_VERSION" + +if [ "${PHP_VERSION:0:3}" != "$BOOTSTRAP_PHP_VERSION" ]; then + log_warn "Expected PHP $BOOTSTRAP_PHP_VERSION, got $PHP_VERSION (proceeding anyway)" +fi + +# Stage 3: Download horde-components.phar +log_info "Downloading horde-components from $COMPONENTS_PHAR_URL" + +mkdir -p "$WORK_DIR/bin" +COMPONENTS_PHAR="$WORK_DIR/bin/horde-components.phar" + +if ! curl -sS -L -o "$COMPONENTS_PHAR" "$COMPONENTS_PHAR_URL"; then + log_error "Failed to download horde-components.phar" + exit 1 +fi + +# Validate it's a valid phar +if ! php "$COMPONENTS_PHAR" --version &> /dev/null; then + log_error "Downloaded phar is not valid or not executable" + exit 1 +fi + +log_info "horde-components.phar downloaded successfully" +php "$COMPONENTS_PHAR" --version + +# Stage 4: Handoff to PHP (horde-components ci setup) +log_info "Bootstrap complete. Handing off to horde-components ci setup" + +php "$COMPONENTS_PHAR" ci setup \ + --mode=github \ + --work-dir="$WORK_DIR" \ + --component="$COMPONENT_NAME" + +EXIT_CODE=$? + +if [ $EXIT_CODE -eq 0 ]; then + log_info "CI setup complete. Workspace: $WORK_DIR" +else + log_error "CI setup failed with exit code $EXIT_CODE" + exit $EXIT_CODE +fi diff --git a/data/ci/bootstrap-local.sh.template b/data/ci/bootstrap-local.sh.template new file mode 100644 index 00000000..115d4da2 --- /dev/null +++ b/data/ci/bootstrap-local.sh.template @@ -0,0 +1,92 @@ +#!/bin/bash +# Horde CI Bootstrap Script (Local Development) +# Generated by: horde-components {{COMPONENTS_VERSION}} +# Template version: {{TEMPLATE_VERSION}} +# Generated: {{GENERATED_DATE}} +# +# DO NOT EDIT - Regenerate with: horde-components ci init + +set -e +set -o pipefail + +# Configuration +COMPONENT_NAME="{{COMPONENT_NAME}}" +WORK_DIR="{{WORK_DIR}}" +LOCAL_COMPONENTS_PATH="{{LOCAL_COMPONENTS_PATH}}" +LOCAL_COMPONENT_PATH="{{LOCAL_COMPONENT_PATH}}" + +# Colors for output +if [ -t 1 ]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + NC='\033[0m' +else + RED='' + GREEN='' + YELLOW='' + NC='' +fi + +# Logging functions +log_info() { + echo -e "${GREEN}[INFO]${NC} $*" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $*" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $*" >&2 +} + +# Stage 1: Validate environment +log_info "Horde CI Bootstrap - Stage 1 (Local mode)" +log_info "Component: $COMPONENT_NAME" + +# Validate required paths +if [ ! -x "$LOCAL_COMPONENTS_PATH/bin/horde-components" ]; then + log_error "horde-components not found at $LOCAL_COMPONENTS_PATH/bin/horde-components" + log_error "Set LOCAL_COMPONENTS_PATH to your horde-components checkout" + exit 1 +fi + +if [ ! -d "$LOCAL_COMPONENT_PATH" ]; then + log_error "Component directory not found: $LOCAL_COMPONENT_PATH" + log_error "Set LOCAL_COMPONENT_PATH to your component checkout" + exit 1 +fi + +# Stage 2: Verify we have PHP +if ! command -v php &> /dev/null; then + log_error "PHP not found. Please install PHP 8.2 or higher." + exit 1 +fi + +PHP_VERSION=$(php -r 'echo PHP_VERSION;') +log_info "PHP version: $PHP_VERSION" + +# Stage 3: Use local horde-components +COMPONENTS_BIN="$LOCAL_COMPONENTS_PATH/bin/horde-components" +log_info "Using local horde-components: $COMPONENTS_BIN" + +php "$COMPONENTS_BIN" --version + +# Stage 4: Handoff to PHP (horde-components ci setup) +log_info "Bootstrap complete. Handing off to horde-components ci setup" + +php "$COMPONENTS_BIN" ci setup \ + --mode=local \ + --work-dir="$WORK_DIR" \ + --component="$COMPONENT_NAME" \ + --local-path="$LOCAL_COMPONENT_PATH" + +EXIT_CODE=$? + +if [ $EXIT_CODE -eq 0 ]; then + log_info "CI setup complete. Workspace: $WORK_DIR" +else + log_error "CI setup failed with exit code $EXIT_CODE" + exit $EXIT_CODE +fi diff --git a/data/ci/template-variables.md b/data/ci/template-variables.md new file mode 100644 index 00000000..04bac369 --- /dev/null +++ b/data/ci/template-variables.md @@ -0,0 +1,153 @@ +# CI Template Variables Reference + +This document describes all variables used in CI templates. + +## Variable Naming Convention + +- Variables use UPPER_SNAKE_CASE +- Wrapped in double curly braces: `{{VARIABLE_NAME}}` +- Must be replaced during rendering (unreplaced variables cause errors) + +## User-Provided Variables + +These must be provided when rendering templates. + +### `{{COMPONENT_NAME}}` +**Type:** String +**Example:** `Db`, `Http`, `Imap_Client` +**Description:** Component name (typically directory name) +**Used in:** All templates + +### `{{COMPONENTS_PHAR_URL}}` +**Type:** URL +**Example:** `https://dev.horde.org/ci/horde-components.phar` +**Description:** URL to download horde-components.phar +**Used in:** `bootstrap-github.sh`, `workflow.yml` +**Default:** Can be read from config or use built-in default + +### `{{WORK_DIR}}` +**Type:** Path +**Example:** `/tmp/horde-ci` +**Description:** Working directory for CI operations +**Used in:** All templates +**Default:** `/tmp/horde-ci` + +### `{{PHP_VERSION}}` +**Type:** Version string +**Example:** `8.4` +**Description:** PHP version for bootstrap (should be 8.4) +**Used in:** `bootstrap-github.sh`, `workflow.yml` +**Default:** `8.4` + +### `{{LOCAL_COMPONENTS_PATH}}` +**Type:** Path +**Example:** `/home/user/components` +**Description:** Local path to horde-components checkout +**Used in:** `bootstrap-local.sh` +**Required for:** Local mode only + +### `{{LOCAL_COMPONENT_PATH}}` +**Type:** Path +**Example:** `/home/user/git/horde/Db` +**Description:** Local path to component being tested +**Used in:** `bootstrap-local.sh` +**Required for:** Local mode only + +## Automatic Variables + +These are automatically added by TemplateRenderer. + +### `{{TEMPLATE_VERSION}}` +**Type:** Semantic version +**Example:** `1.0.0` +**Description:** Template version for tracking updates +**Source:** `TemplateRenderer::TEMPLATE_VERSION` +**Used in:** All templates + +### `{{GENERATED_DATE}}` +**Type:** Timestamp +**Example:** `2026-03-03 10:30:00 PST` +**Description:** When the file was generated +**Source:** `date('Y-m-d H:i:s T')` +**Used in:** All templates + +### `{{COMPONENTS_VERSION}}` +**Type:** Version string +**Example:** `1.0.0-alpha26` +**Description:** horde-components version that generated the file +**Source:** `.horde.yml` or constant +**Used in:** All templates + +## Variable Resolution + +Variables are resolved in this order: + +1. **Explicitly provided** - Passed to `render()` method +2. **Automatic variables** - Added by TemplateRenderer +3. **Error** - If variable remains unreplaced + +## Example Rendering + +```php +$renderer = new TemplateRenderer('/path/to/data/ci'); + +$rendered = $renderer->render('bootstrap-github.sh', [ + '{{COMPONENT_NAME}}' => 'Http', + '{{COMPONENTS_PHAR_URL}}' => 'https://dev.horde.org/ci/horde-components.phar', + '{{WORK_DIR}}' => '/tmp/horde-ci', + '{{PHP_VERSION}}' => '8.4', +]); + +// Automatic variables are added: +// {{TEMPLATE_VERSION}} => '1.0.0' +// {{GENERATED_DATE}} => '2026-03-03 10:30:00 PST' +// {{COMPONENTS_VERSION}} => '1.0.0-alpha26' +``` + +## Validation + +The renderer validates that all `{{VARIABLE}}` patterns are replaced: + +```php +// This will throw Exception: "Unreplaced template variable: {{FOO}}" +$rendered = $renderer->render('template', []); // Missing required variables +``` + +## Adding New Variables + +When adding a new variable: + +1. Update this document +2. Update README.md +3. Update templates that use it +4. Update InitCommand to provide it +5. Add tests + +## Variable Groups + +### Bootstrap Configuration +- `{{PHP_VERSION}}` +- `{{COMPONENTS_PHAR_URL}}` +- `{{WORK_DIR}}` + +### Component Identification +- `{{COMPONENT_NAME}}` + +### Local Development +- `{{LOCAL_COMPONENTS_PATH}}` +- `{{LOCAL_COMPONENT_PATH}}` + +### Metadata +- `{{TEMPLATE_VERSION}}` +- `{{GENERATED_DATE}}` +- `{{COMPONENTS_VERSION}}` + +## Future Variables + +Potential variables for future templates: + +- `{{MIN_PHP_VERSION}}` - From .horde.yml +- `{{PHPUNIT_VERSION}}` - Explicit version override +- `{{DATABASES}}` - Database list (mysql, pgsql, sqlite) +- `{{EXTENSIONS}}` - Required PHP extensions +- `{{PARALLEL}}` - Enable parallel execution (true/false) diff --git a/data/ci/workflow.yml.template b/data/ci/workflow.yml.template new file mode 100644 index 00000000..d7f4798d --- /dev/null +++ b/data/ci/workflow.yml.template @@ -0,0 +1,36 @@ +name: CI + +# Generated by: horde-components {{COMPONENTS_VERSION}} +# Template version: {{TEMPLATE_VERSION}} +# Generated: {{GENERATED_DATE}} + +on: + push: + branches: [ FRAMEWORK_6_0 ] + pull_request: + branches: [ FRAMEWORK_6_0 ] + +jobs: + test: + runs-on: ubuntu-24.04 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP {{PHP_VERSION}} (for bootstrap) + uses: shivammathur/setup-php@v2 + with: + php-version: '{{PHP_VERSION}}' + coverage: none + + - name: CI Setup + run: bash bin/ci-bootstrap.sh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMPONENTS_PHAR_URL: {{COMPONENTS_PHAR_URL}} + + - name: CI Run + run: | + php {{WORK_DIR}}/bin/horde-components.phar ci run \ + --work-dir={{WORK_DIR}} diff --git a/src/Ci/Template/TemplateLocator.php b/src/Ci/Template/TemplateLocator.php new file mode 100644 index 00000000..2672873f --- /dev/null +++ b/src/Ci/Template/TemplateLocator.php @@ -0,0 +1,101 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Ci\Template; + +use Horde\Components\Exception; + +/** + * Locates CI template files. + * + * Provides methods to find template directories and list available templates. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class TemplateLocator +{ + /** + * Get the template directory path. + * + * @return string Absolute path to template directory + * @throws Exception If template directory not found + */ + public static function getTemplateDir(): string + { + // From src/Ci/Template/TemplateLocator.php + // Go up to components/ root, then into data/ci/ + $dir = dirname(__DIR__, 3) . '/data/ci'; + + if (!is_dir($dir)) { + throw new Exception("Template directory not found: {$dir}"); + } + + return $dir; + } + + /** + * List available templates. + * + * @return array Template names (without .template extension) + */ + public static function listTemplates(): array + { + $dir = self::getTemplateDir(); + $files = glob($dir . '/*.template'); + + if ($files === false) { + return []; + } + + return array_map( + fn($file) => basename($file, '.template'), + $files + ); + } + + /** + * Check if a template exists. + * + * @param string $templateName Template name (without .template extension) + * @return bool True if template exists + */ + public static function templateExists(string $templateName): bool + { + $path = self::getTemplateDir() . '/' . $templateName . '.template'; + return file_exists($path); + } + + /** + * Get full path to a template file. + * + * @param string $templateName Template name (without .template extension) + * @return string Absolute path to template file + * @throws Exception If template not found + */ + public static function getTemplatePath(string $templateName): string + { + $path = self::getTemplateDir() . '/' . $templateName . '.template'; + + if (!file_exists($path)) { + throw new Exception("Template not found: {$templateName}"); + } + + return $path; + } +} diff --git a/src/Ci/Template/TemplateRenderer.php b/src/Ci/Template/TemplateRenderer.php new file mode 100644 index 00000000..f64ef7ee --- /dev/null +++ b/src/Ci/Template/TemplateRenderer.php @@ -0,0 +1,128 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Ci\Template; + +use Horde\Components\Exception; + +/** + * Renders CI templates with variable substitution. + * + * Templates use {{VARIABLE_NAME}} syntax for placeholders. + * The renderer performs simple string replacement and validates + * that all variables are replaced. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class TemplateRenderer +{ + /** + * Current template version. + * + * This version is embedded in generated files and used to detect + * when components are using outdated templates. + */ + private const TEMPLATE_VERSION = '1.0.0'; + + /** + * Constructor. + * + * @param string $templateDir Directory containing template files + */ + public function __construct( + private readonly string $templateDir + ) {} + + /** + * Render a template with variables. + * + * @param string $templateName Template name (without .template extension) + * @param array $variables Variables to substitute + * @return string Rendered template content + * @throws Exception If template not found or has unreplaced variables + */ + public function render(string $templateName, array $variables): string + { + $templatePath = $this->templateDir . '/' . $templateName . '.template'; + + if (!file_exists($templatePath)) { + throw new Exception("Template not found: {$templatePath}"); + } + + $template = file_get_contents($templatePath); + if ($template === false) { + throw new Exception("Failed to read template: {$templatePath}"); + } + + // Add automatic variables + $variables['{{TEMPLATE_VERSION}}'] = self::TEMPLATE_VERSION; + $variables['{{GENERATED_DATE}}'] = date('Y-m-d H:i:s T'); + $variables['{{COMPONENTS_VERSION}}'] = $this->getComponentsVersion(); + + // Simple string replacement + $rendered = str_replace( + array_keys($variables), + array_values($variables), + $template + ); + + // Validate no unreplaced variables remain + if (preg_match('/\{\{[A-Z_][A-Z0-9_]*\}\}/', $rendered, $matches)) { + throw new Exception( + "Unreplaced template variable in {$templateName}: {$matches[0]}" + ); + } + + return $rendered; + } + + /** + * Get the current template version. + * + * @return string Template version + */ + public static function getTemplateVersion(): string + { + return self::TEMPLATE_VERSION; + } + + /** + * Get horde-components version. + * + * @return string Version string + */ + private function getComponentsVersion(): string + { + // Try to read from .horde.yml + $hordeYmlPath = dirname(__DIR__, 3) . '/.horde.yml'; + if (file_exists($hordeYmlPath)) { + $content = file_get_contents($hordeYmlPath); + if ($content !== false && preg_match('/^version:\s*\n\s+release:\s*(.+)$/m', $content, $matches)) { + return trim($matches[1]); + } + } + + // Fallback to constant if defined + if (defined('COMPONENTS_VERSION')) { + return COMPONENTS_VERSION; + } + + return 'dev-main'; + } +} diff --git a/src/Ci/Template/TemplateVersion.php b/src/Ci/Template/TemplateVersion.php new file mode 100644 index 00000000..e237222d --- /dev/null +++ b/src/Ci/Template/TemplateVersion.php @@ -0,0 +1,96 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Ci\Template; + +/** + * Extracts and compares template versions from generated files. + * + * Generated files include a version comment like: + * # Template version: 1.0.0 + * + * This class can extract that version and compare it to the current version. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class TemplateVersion +{ + /** + * Extract template version from a generated file. + * + * @param string $filePath Path to generated file + * @return string|null Version string, or null if not found + */ + public static function extractFromFile(string $filePath): ?string + { + if (!file_exists($filePath)) { + return null; + } + + $content = file_get_contents($filePath); + if ($content === false) { + return null; + } + + // Look for: # Template version: X.Y.Z + if (preg_match('/^#\s*Template version:\s*(.+)$/m', $content, $matches)) { + return trim($matches[1]); + } + + return null; + } + + /** + * Check if a file's template version is outdated. + * + * @param string $filePath Path to generated file + * @return bool True if version is older than current + */ + public static function isOutdated(string $filePath): bool + { + $fileVersion = self::extractFromFile($filePath); + + if ($fileVersion === null) { + // No version found - consider it outdated + return true; + } + + $currentVersion = TemplateRenderer::getTemplateVersion(); + + return version_compare($fileVersion, $currentVersion, '<'); + } + + /** + * Get version comparison information. + * + * @param string $filePath Path to generated file + * @return array{file: string|null, current: string, outdated: bool} + */ + public static function compare(string $filePath): array + { + $fileVersion = self::extractFromFile($filePath); + $currentVersion = TemplateRenderer::getTemplateVersion(); + + return [ + 'file' => $fileVersion, + 'current' => $currentVersion, + 'outdated' => $fileVersion === null || version_compare($fileVersion, $currentVersion, '<'), + ]; + } +} diff --git a/test/Unit/Ci/Template/TemplateRendererTest.php b/test/Unit/Ci/Template/TemplateRendererTest.php new file mode 100644 index 00000000..252de7f0 --- /dev/null +++ b/test/Unit/Ci/Template/TemplateRendererTest.php @@ -0,0 +1,189 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Test\Unit\Ci\Template; + +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use Horde\Components\Ci\Template\TemplateRenderer; +use Horde\Components\Exception; + +/** + * Test the CI template renderer. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +#[CoversClass(TemplateRenderer::class)] +class TemplateRendererTest extends TestCase +{ + private string $tempDir; + + protected function setUp(): void + { + parent::setUp(); + $this->tempDir = sys_get_temp_dir() . '/horde-test-' . uniqid(); + mkdir($this->tempDir, 0755, true); + } + + protected function tearDown(): void + { + parent::tearDown(); + if (is_dir($this->tempDir)) { + $this->removeDirectory($this->tempDir); + } + } + + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir . '/' . $file; + is_dir($path) ? $this->removeDirectory($path) : unlink($path); + } + rmdir($dir); + } + + public function testRendersSimpleTemplate(): void + { + $templateContent = 'Hello {{NAME}}!'; + file_put_contents($this->tempDir . '/simple.template', $templateContent); + + $renderer = new TemplateRenderer($this->tempDir); + $result = $renderer->render('simple', ['{{NAME}}' => 'World']); + + $this->assertStringContainsString('Hello World!', $result); + } + + public function testAddsAutomaticVariables(): void + { + $templateContent = 'Version: {{TEMPLATE_VERSION}}, Generated: {{GENERATED_DATE}}, Components: {{COMPONENTS_VERSION}}'; + file_put_contents($this->tempDir . '/auto.template', $templateContent); + + $renderer = new TemplateRenderer($this->tempDir); + $result = $renderer->render('auto', []); + + $this->assertStringContainsString('Version: ' . TemplateRenderer::getTemplateVersion(), $result); + $this->assertStringContainsString('Generated:', $result); + $this->assertStringContainsString('Components:', $result); + } + + public function testThrowsExceptionForMissingTemplate(): void + { + $renderer = new TemplateRenderer($this->tempDir); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Template not found'); + + $renderer->render('nonexistent', []); + } + + public function testThrowsExceptionForUnreplacedVariable(): void + { + $templateContent = 'Hello {{NAME}}, welcome to {{PLACE}}!'; + file_put_contents($this->tempDir . '/incomplete.template', $templateContent); + + $renderer = new TemplateRenderer($this->tempDir); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Unreplaced template variable'); + + // Only provide NAME, not PLACE + $renderer->render('incomplete', ['{{NAME}}' => 'Alice']); + } + + public function testReplacesMultipleVariables(): void + { + $templateContent = <<<'TEMPLATE' +Component: {{COMPONENT}} +Version: {{VERSION}} +Path: {{PATH}} +TEMPLATE; + file_put_contents($this->tempDir . '/multi.template', $templateContent); + + $renderer = new TemplateRenderer($this->tempDir); + $result = $renderer->render('multi', [ + '{{COMPONENT}}' => 'Db', + '{{VERSION}}' => '2.0.0', + '{{PATH}}' => '/tmp/test', + ]); + + $this->assertStringContainsString('Component: Db', $result); + $this->assertStringContainsString('Version: 2.0.0', $result); + $this->assertStringContainsString('Path: /tmp/test', $result); + } + + public function testAcceptsVariablesWithNumbers(): void + { + $templateContent = 'PHP {{PHP_VERSION}} is great!'; + file_put_contents($this->tempDir . '/numbers.template', $templateContent); + + $renderer = new TemplateRenderer($this->tempDir); + $result = $renderer->render('numbers', ['{{PHP_VERSION}}' => '8.4']); + + $this->assertStringContainsString('PHP 8.4 is great!', $result); + } + + public function testRejectsLowercaseVariables(): void + { + $templateContent = 'Hello {{name}}!'; // lowercase + file_put_contents($this->tempDir . '/lowercase.template', $templateContent); + + $renderer = new TemplateRenderer($this->tempDir); + + // Lowercase variables are not detected as variables (by design) + // So this should succeed (no replacement happens) + $result = $renderer->render('lowercase', []); + + // The {{name}} should remain unreplaced, and won't trigger error + // because our regex only matches UPPER_CASE + $this->assertStringContainsString('{{name}}', $result); + } + + public function testGetTemplateVersionReturnsString(): void + { + $version = TemplateRenderer::getTemplateVersion(); + + $this->assertIsString($version); + $this->assertMatchesRegularExpression('/^\d+\.\d+\.\d+$/', $version); + } + + public function testHandlesEmptyTemplate(): void + { + file_put_contents($this->tempDir . '/empty.template', ''); + + $renderer = new TemplateRenderer($this->tempDir); + $result = $renderer->render('empty', []); + + $this->assertSame('', $result); + } + + public function testHandlesTemplateWithNoVariables(): void + { + $templateContent = 'This is just plain text with no variables.'; + file_put_contents($this->tempDir . '/plain.template', $templateContent); + + $renderer = new TemplateRenderer($this->tempDir); + $result = $renderer->render('plain', []); + + $this->assertStringContainsString('plain text', $result); + } +} From 98c64bd2fc7e4b25d588c745f5510f34e8179209 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Tue, 3 Mar 2026 10:51:48 +0100 Subject: [PATCH 03/17] feat(ci): add Phase 1 core infrastructure classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add core classes for CI setup (local mode): Configuration: - CiConfig: Configuration container with lane management - EnvironmentDetector: Detect GitHub/local mode, extract env vars Setup Components: - PhpInstaller: Install PHP 8.2-8.5 via ondrej PPA - ExtensionInstaller: Install extensions (baseline + composer.json + mapping) - LaneCopier: Copy component to test lanes (8 directories) - ComposerInstaller: Run composer install per lane with stability control Features: - Hybrid extension detection strategy - Composer retry logic (3 attempts with exponential backoff) - Lane isolation (separate dirs per PHP × stability) - Stability control via composer.json modification - Verification methods for all operations All classes use PHP 8.2+ strict types and proper error handling. No tests yet - focusing on structure first. Related to components-ci-implementation-plan.md Phase 1. --- src/Ci/Config/CiConfig.php | 225 ++++++++++++++++++++ src/Ci/Setup/ComposerInstaller.php | 307 +++++++++++++++++++++++++++ src/Ci/Setup/EnvironmentDetector.php | 231 ++++++++++++++++++++ src/Ci/Setup/ExtensionInstaller.php | 301 ++++++++++++++++++++++++++ src/Ci/Setup/LaneCopier.php | 213 +++++++++++++++++++ src/Ci/Setup/PhpInstaller.php | 288 +++++++++++++++++++++++++ 6 files changed, 1565 insertions(+) create mode 100644 src/Ci/Config/CiConfig.php create mode 100644 src/Ci/Setup/ComposerInstaller.php create mode 100644 src/Ci/Setup/EnvironmentDetector.php create mode 100644 src/Ci/Setup/ExtensionInstaller.php create mode 100644 src/Ci/Setup/LaneCopier.php create mode 100644 src/Ci/Setup/PhpInstaller.php diff --git a/src/Ci/Config/CiConfig.php b/src/Ci/Config/CiConfig.php new file mode 100644 index 00000000..ff844359 --- /dev/null +++ b/src/Ci/Config/CiConfig.php @@ -0,0 +1,225 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Ci\Config; + +/** + * CI configuration container. + * + * Holds all configuration needed for CI setup and execution. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class CiConfig +{ + /** + * Operational mode (github or local). + */ + public readonly string $mode; + + /** + * Component name (e.g., "Db", "Http"). + */ + public readonly string $componentName; + + /** + * Component branch (e.g., "FRAMEWORK_6_0"). + */ + public readonly string $componentBranch; + + /** + * Component type (library, horde-library, application, bundle). + */ + public readonly string $componentType; + + /** + * Base working directory for CI operations. + */ + public readonly string $workDir; + + /** + * Path to component source (already checked out). + */ + public readonly string $componentPath; + + /** + * PHP versions to test (e.g., ['8.2', '8.3', '8.4', '8.5']). + * + * @var array + */ + public readonly array $phpVersions; + + /** + * Minimum PHP version from component's .horde.yml. + */ + public readonly string $minPhpVersion; + + /** + * Component's stability (alpha, beta, RC, stable). + */ + public readonly string $componentStability; + + /** + * Required PHP extensions. + * + * @var array + */ + public readonly array $requiredExtensions; + + /** + * GitHub token (for API access). + */ + public readonly ?string $githubToken; + + /** + * URL to download horde-components.phar. + */ + public readonly ?string $componentsPharUrl; + + /** + * Path to horde-components binary (local mode). + */ + public readonly ?string $localComponentsPath; + + /** + * Constructor. + * + * @param array $config Configuration array + */ + public function __construct(array $config) + { + $this->mode = $config['mode'] ?? 'github'; + $this->componentName = $config['component_name'] ?? ''; + $this->componentBranch = $config['component_branch'] ?? 'FRAMEWORK_6_0'; + $this->componentType = $config['component_type'] ?? 'library'; + $this->workDir = $config['work_dir'] ?? '/tmp/horde-ci'; + $this->componentPath = $config['component_path'] ?? getcwd(); + $this->phpVersions = $config['php_versions'] ?? ['8.2', '8.3', '8.4', '8.5']; + $this->minPhpVersion = $config['min_php_version'] ?? '8.2'; + $this->componentStability = $config['component_stability'] ?? 'alpha'; + $this->requiredExtensions = $config['required_extensions'] ?? []; + $this->githubToken = $config['github_token'] ?? null; + $this->componentsPharUrl = $config['components_phar_url'] ?? null; + $this->localComponentsPath = $config['local_components_path'] ?? null; + } + + /** + * Get PHP versions to actually test. + * + * Filters out PHP versions below the component's minimum. + * + * @return array PHP versions to test + */ + public function getTestablePhpVersions(): array + { + return array_filter( + $this->phpVersions, + fn($version) => version_compare($version, $this->minPhpVersion, '>=') + ); + } + + /** + * Get all test lanes (PHP version × stability combinations). + * + * @return array + */ + public function getTestLanes(): array + { + $lanes = []; + $testablePhpVersions = $this->getTestablePhpVersions(); + + foreach ($testablePhpVersions as $phpVersion) { + // Dev lane + $lanes[] = [ + 'php' => $phpVersion, + 'stability' => 'dev', + 'dir' => $this->workDir . '/lanes/php' . $phpVersion . '-dev/' . $this->componentName, + ]; + + // Stable lane (or component's own stability) + $lanes[] = [ + 'php' => $phpVersion, + 'stability' => $this->componentStability, + 'dir' => $this->workDir . '/lanes/php' . $phpVersion . '-stable/' . $this->componentName, + ]; + } + + return $lanes; + } + + /** + * Check if this is GitHub Actions mode. + * + * @return bool + */ + public function isGithubMode(): bool + { + return $this->mode === 'github'; + } + + /** + * Check if this is local development mode. + * + * @return bool + */ + public function isLocalMode(): bool + { + return $this->mode === 'local'; + } + + /** + * Validate configuration. + * + * @return array Error messages (empty if valid) + */ + public function validate(): array + { + $errors = []; + + if (empty($this->componentName)) { + $errors[] = 'Component name is required'; + } + + if (empty($this->componentPath)) { + $errors[] = 'Component path is required'; + } + + if (!is_dir($this->componentPath)) { + $errors[] = "Component path does not exist: {$this->componentPath}"; + } + + if ($this->isGithubMode() && empty($this->githubToken)) { + $errors[] = 'GitHub token is required in github mode'; + } + + if ($this->isLocalMode() && empty($this->localComponentsPath)) { + $errors[] = 'Local components path is required in local mode'; + } + + if (empty($this->phpVersions)) { + $errors[] = 'At least one PHP version must be specified'; + } + + if (!in_array($this->componentType, ['library', 'horde-library', 'application', 'bundle'])) { + $errors[] = "Unknown component type: {$this->componentType}"; + } + + return $errors; + } +} diff --git a/src/Ci/Setup/ComposerInstaller.php b/src/Ci/Setup/ComposerInstaller.php new file mode 100644 index 00000000..46897162 --- /dev/null +++ b/src/Ci/Setup/ComposerInstaller.php @@ -0,0 +1,307 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Ci\Setup; + +use Horde\Components\Exception; +use Horde\Components\Output; + +/** + * Runs composer install for test lanes with stability control. + * + * Modifies composer.json minimum-stability per lane and runs composer install. + * Implements retry logic for network failures. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class ComposerInstaller +{ + /** + * Maximum retry attempts for composer install. + */ + private const MAX_RETRIES = 3; + + /** + * Composer timeout in seconds. + */ + private const TIMEOUT = 600; // 10 minutes + + /** + * Constructor. + * + * @param Output $output Output handler + */ + public function __construct( + private readonly Output $output + ) {} + + /** + * Install composer dependencies for a lane. + * + * @param string $laneDir Lane directory + * @param string $phpBinary Path to PHP binary for this lane + * @param string $stability Minimum stability (dev, alpha, beta, RC, stable) + * @return bool True if successful + * @throws Exception If installation fails after retries + */ + public function install(string $laneDir, string $phpBinary, string $stability): bool + { + if (!is_dir($laneDir)) { + throw new Exception("Lane directory does not exist: {$laneDir}"); + } + + $composerFile = $laneDir . '/composer.json'; + if (!file_exists($composerFile)) { + throw new Exception("composer.json not found in: {$laneDir}"); + } + + // Set minimum-stability + $this->setMinimumStability($composerFile, $stability); + + // Run composer install with retries + for ($attempt = 1; $attempt <= self::MAX_RETRIES; $attempt++) { + if ($attempt > 1) { + $this->output->warn("Retry attempt {$attempt}/" . self::MAX_RETRIES); + sleep(2 ** $attempt); // Exponential backoff: 2s, 4s, 8s + } + + $result = $this->runComposerInstall($laneDir, $phpBinary); + + if ($result['success']) { + return true; + } + + // Check if it's a recoverable error + if (!$this->isRecoverableError($result['output'])) { + throw new Exception( + "Composer install failed in {$laneDir}: " . $result['error'] + ); + } + } + + throw new Exception("Composer install failed after " . self::MAX_RETRIES . " attempts"); + } + + /** + * Set minimum-stability in composer.json. + * + * @param string $composerFile Path to composer.json + * @param string $stability Minimum stability + * @return bool True if successful + * @throws Exception If file manipulation fails + */ + private function setMinimumStability(string $composerFile, string $stability): bool + { + $content = file_get_contents($composerFile); + if ($content === false) { + throw new Exception("Failed to read {$composerFile}"); + } + + $data = json_decode($content, true); + if (!is_array($data)) { + throw new Exception("Invalid JSON in {$composerFile}"); + } + + // Set minimum-stability + $data['minimum-stability'] = $stability; + + // Write back with pretty print + $newContent = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if (file_put_contents($composerFile, $newContent) === false) { + throw new Exception("Failed to write {$composerFile}"); + } + + return true; + } + + /** + * Run composer install. + * + * @param string $laneDir Lane directory + * @param string $phpBinary Path to PHP binary + * @return array{success: bool, output: string, error: string} + */ + private function runComposerInstall(string $laneDir, string $phpBinary): array + { + // Find composer binary + $composer = $this->findComposer(); + + // Build command + $command = sprintf( + 'cd %s && %s %s install --no-interaction --no-progress --prefer-dist 2>&1', + escapeshellarg($laneDir), + escapeshellarg($phpBinary), + escapeshellarg($composer) + ); + + // Add timeout + $command = "timeout " . self::TIMEOUT . " " . $command; + + // Execute + $output = []; + $exitCode = 0; + exec($command, $output, $exitCode); + + $outputStr = implode("\n", $output); + + return [ + 'success' => $exitCode === 0, + 'output' => $outputStr, + 'error' => $exitCode !== 0 ? $this->extractError($outputStr) : '', + ]; + } + + /** + * Find composer binary. + * + * @return string Path to composer + * @throws Exception If composer not found + */ + private function findComposer(): string + { + // Try composer command + $which = shell_exec('which composer 2>/dev/null'); + if ($which !== null && trim($which) !== '') { + return trim($which); + } + + // Try composer.phar in common locations + $locations = [ + '/usr/local/bin/composer.phar', + '/usr/bin/composer.phar', + getcwd() . '/composer.phar', + ]; + + foreach ($locations as $location) { + if (file_exists($location) && is_executable($location)) { + return $location; + } + } + + throw new Exception('composer not found. Please install composer.'); + } + + /** + * Check if error is recoverable (network issues, etc.). + * + * @param string $output Command output + * @return bool True if error might be temporary + */ + private function isRecoverableError(string $output): bool + { + $recoverablePatterns = [ + '/connection.*timed out/i', + '/failed to connect/i', + '/could not fetch/i', + '/temporary failure/i', + '/network.*unreachable/i', + '/curl error/i', + ]; + + foreach ($recoverablePatterns as $pattern) { + if (preg_match($pattern, $output)) { + return true; + } + } + + return false; + } + + /** + * Extract error message from composer output. + * + * @param string $output Command output + * @return string Error message + */ + private function extractError(string $output): string + { + // Look for common error patterns + $lines = explode("\n", $output); + + foreach ($lines as $line) { + if (stripos($line, 'error') !== false || stripos($line, 'failed') !== false) { + return trim($line); + } + } + + // Return last non-empty line as fallback + $nonEmpty = array_filter($lines, fn($l) => trim($l) !== ''); + if (empty($nonEmpty)) { + return 'Unknown error'; + } + + return trim(end($nonEmpty)); + } + + /** + * Verify composer installation succeeded. + * + * @param string $laneDir Lane directory + * @return bool True if vendor directory exists + */ + public function verifyInstallation(string $laneDir): bool + { + $vendorDir = $laneDir . '/vendor'; + $autoloadFile = $vendorDir . '/autoload.php'; + + return is_dir($vendorDir) && file_exists($autoloadFile); + } + + /** + * Get installed package count. + * + * @param string $laneDir Lane directory + * @return int Number of installed packages + */ + public function getInstalledPackageCount(string $laneDir): int + { + $vendorDir = $laneDir . '/vendor'; + if (!is_dir($vendorDir)) { + return 0; + } + + $composerDir = $vendorDir . '/composer'; + $installedFile = $composerDir . '/installed.json'; + + if (!file_exists($installedFile)) { + return 0; + } + + $content = file_get_contents($installedFile); + if ($content === false) { + return 0; + } + + $data = json_decode($content, true); + if (!is_array($data)) { + return 0; + } + + // Format can be either {packages: [...]} or [...] + if (isset($data['packages']) && is_array($data['packages'])) { + return count($data['packages']); + } + + if (isset($data[0])) { + return count($data); + } + + return 0; + } +} diff --git a/src/Ci/Setup/EnvironmentDetector.php b/src/Ci/Setup/EnvironmentDetector.php new file mode 100644 index 00000000..1e19aba2 --- /dev/null +++ b/src/Ci/Setup/EnvironmentDetector.php @@ -0,0 +1,231 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Ci\Setup; + +use Horde\Components\Exception; + +/** + * Detects CI environment and extracts configuration. + * + * Determines whether we're running in GitHub Actions or local mode, + * and extracts relevant environment variables. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class EnvironmentDetector +{ + /** + * Detect operational mode. + * + * @return string 'github' or 'local' + */ + public static function detectMode(): string + { + // Check for explicit mode in environment + $mode = getenv('CI_MODE'); + if ($mode !== false && in_array($mode, ['github', 'local'])) { + return $mode; + } + + // Auto-detect based on GitHub Actions environment + if (getenv('GITHUB_ACTIONS') !== false) { + return 'github'; + } + + // Default to local + return 'local'; + } + + /** + * Detect component name. + * + * @param string $componentPath Path to component + * @return string Component name + */ + public static function detectComponentName(string $componentPath): string + { + // Try from environment first + $name = getenv('COMPONENT_NAME'); + if ($name !== false && $name !== '') { + return $name; + } + + // Try from GITHUB_REPOSITORY + $repo = getenv('GITHUB_REPOSITORY'); + if ($repo !== false) { + // Format: "horde/Db" -> "Db" + $parts = explode('/', $repo); + return end($parts); + } + + // Fall back to directory name + return basename($componentPath); + } + + /** + * Detect component branch. + * + * @param string $componentPath Path to component + * @return string Branch name + */ + public static function detectComponentBranch(string $componentPath): string + { + // Try from environment first + $branch = getenv('COMPONENT_BRANCH'); + if ($branch !== false && $branch !== '') { + return $branch; + } + + // Try from GITHUB_REF + $ref = getenv('GITHUB_REF'); + if ($ref !== false) { + // Format: "refs/heads/FRAMEWORK_6_0" -> "FRAMEWORK_6_0" + return preg_replace('#^refs/heads/#', '', $ref); + } + + // Try from git + $gitDir = $componentPath . '/.git'; + if (is_dir($gitDir)) { + $branch = shell_exec("cd " . escapeshellarg($componentPath) . " && git branch --show-current 2>/dev/null"); + if ($branch !== null && trim($branch) !== '') { + return trim($branch); + } + } + + // Default + return 'FRAMEWORK_6_0'; + } + + /** + * Get GitHub token. + * + * @return string|null Token or null if not available + */ + public static function getGithubToken(): ?string + { + $token = getenv('GITHUB_TOKEN'); + return $token !== false ? $token : null; + } + + /** + * Get work directory. + * + * @return string Work directory path + */ + public static function getWorkDir(): string + { + $workDir = getenv('CI_WORK_DIR'); + if ($workDir !== false && $workDir !== '') { + return $workDir; + } + + return '/tmp/horde-ci'; + } + + /** + * Get local components path. + * + * @return string|null Local components path or null if not set + */ + public static function getLocalComponentsPath(): ?string + { + $path = getenv('LOCAL_COMPONENTS_PATH'); + return $path !== false && $path !== '' ? $path : null; + } + + /** + * Get local component path. + * + * @return string|null Local component path or null if not set + */ + public static function getLocalComponentPath(): ?string + { + $path = getenv('LOCAL_COMPONENT_PATH'); + return $path !== false && $path !== '' ? $path : null; + } + + /** + * Get components PHAR URL. + * + * @return string PHAR URL + */ + public static function getComponentsPharUrl(): string + { + $url = getenv('COMPONENTS_PHAR_URL'); + if ($url !== false && $url !== '') { + return $url; + } + + // Default URL (should be configurable in horde-components config) + return 'https://dev.horde.org/ci/horde-components.phar'; + } + + /** + * Build configuration array from environment. + * + * @param string|null $mode Override mode (null to auto-detect) + * @param string|null $componentPath Component path (null to use current dir) + * @return array Configuration array for CiConfig + */ + public static function buildConfig(?string $mode = null, ?string $componentPath = null): array + { + $mode = $mode ?? self::detectMode(); + $componentPath = $componentPath ?? getcwd(); + + return [ + 'mode' => $mode, + 'component_name' => self::detectComponentName($componentPath), + 'component_branch' => self::detectComponentBranch($componentPath), + 'component_path' => $componentPath, + 'work_dir' => self::getWorkDir(), + 'github_token' => self::getGithubToken(), + 'components_phar_url' => self::getComponentsPharUrl(), + 'local_components_path' => self::getLocalComponentsPath(), + ]; + } + + /** + * Validate environment for given mode. + * + * @param string $mode Mode to validate (github or local) + * @return array Error messages (empty if valid) + */ + public static function validateEnvironment(string $mode): array + { + $errors = []; + + if ($mode === 'github') { + if (getenv('GITHUB_ACTIONS') === false) { + $errors[] = 'Not running in GitHub Actions environment'; + } + if (getenv('GITHUB_TOKEN') === false) { + $errors[] = 'GITHUB_TOKEN not set'; + } + } + + if ($mode === 'local') { + if (self::getLocalComponentsPath() === null) { + $errors[] = 'LOCAL_COMPONENTS_PATH not set (required in local mode)'; + } + } + + return $errors; + } +} diff --git a/src/Ci/Setup/ExtensionInstaller.php b/src/Ci/Setup/ExtensionInstaller.php new file mode 100644 index 00000000..f60eb85f --- /dev/null +++ b/src/Ci/Setup/ExtensionInstaller.php @@ -0,0 +1,301 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Ci\Setup; + +use Horde\Components\Exception; +use Horde\Components\Output; + +/** + * Installs PHP extensions for multiple PHP versions. + * + * Uses apt-get to install PHP extensions from ondrej PPA. + * Employs a hybrid detection strategy: baseline + composer.json + static mapping. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class ExtensionInstaller +{ + /** + * Baseline extensions installed for all components. + */ + private const BASELINE_EXTENSIONS = [ + 'cli', // PHP CLI (usually installed with php-cli package) + 'common', // Common files (timezone data, etc.) + 'curl', // cURL + 'dom', // DOM + 'json', // JSON (built-in in 8.0+, but package may exist) + 'mbstring', // Multibyte string + 'xml', // XML + ]; + + /** + * Component-specific extension mapping. + * + * @var array> + */ + private const COMPONENT_EXTENSIONS = [ + 'imap_client' => ['imap'], + 'db' => ['pdo', 'mysql', 'pgsql', 'sqlite3'], + 'image' => ['gd'], + 'compress' => ['zip', 'bz2'], + 'ldap' => ['ldap'], + 'soap' => ['soap'], + ]; + + /** + * Constructor. + * + * @param Output $output Output handler + */ + public function __construct( + private readonly Output $output + ) {} + + /** + * Detect required extensions for component. + * + * Uses hybrid strategy: + * 1. Baseline extensions (always included) + * 2. Extensions from composer.json require (ext-*) + * 3. Static mapping based on component name + * + * @param string $componentPath Path to component + * @param string $componentName Component name + * @return array Required extension names + */ + public function detectExtensions(string $componentPath, string $componentName): array + { + $extensions = self::BASELINE_EXTENSIONS; + + // Add from composer.json + $composerExtensions = $this->extractFromComposer($componentPath); + $extensions = array_merge($extensions, $composerExtensions); + + // Add from static mapping + $componentKey = strtolower($componentName); + if (isset(self::COMPONENT_EXTENSIONS[$componentKey])) { + $extensions = array_merge($extensions, self::COMPONENT_EXTENSIONS[$componentKey]); + } + + // Deduplicate and sort + $extensions = array_unique($extensions); + sort($extensions); + + return $extensions; + } + + /** + * Extract extensions from composer.json. + * + * Looks for "ext-*" in require and require-dev. + * + * @param string $componentPath Path to component + * @return array Extension names (without ext- prefix) + */ + private function extractFromComposer(string $componentPath): array + { + $composerFile = $componentPath . '/composer.json'; + if (!file_exists($composerFile)) { + return []; + } + + $content = file_get_contents($composerFile); + if ($content === false) { + return []; + } + + $data = json_decode($content, true); + if (!is_array($data)) { + return []; + } + + $extensions = []; + + // Check require + if (isset($data['require']) && is_array($data['require'])) { + foreach (array_keys($data['require']) as $package) { + if (is_string($package) && str_starts_with($package, 'ext-')) { + $extensions[] = substr($package, 4); // Remove "ext-" prefix + } + } + } + + // Check require-dev + if (isset($data['require-dev']) && is_array($data['require-dev'])) { + foreach (array_keys($data['require-dev']) as $package) { + if (is_string($package) && str_starts_with($package, 'ext-')) { + $extensions[] = substr($package, 4); + } + } + } + + return $extensions; + } + + /** + * Install extensions for PHP versions. + * + * @param array $extensions Extension names + * @param array $phpVersions PHP versions to install for + * @return bool True if successful + * @throws Exception If installation fails critically + */ + public function install(array $extensions, array $phpVersions): bool + { + if (empty($extensions)) { + $this->output->info('No extensions to install'); + return true; + } + + if (empty($phpVersions)) { + $this->output->warn('No PHP versions specified for extension installation'); + return true; + } + + $this->output->info('Installing extensions: ' . implode(', ', $extensions)); + $this->output->info('For PHP versions: ' . implode(', ', $phpVersions)); + + $failed = []; + $succeeded = []; + + foreach ($phpVersions as $phpVersion) { + foreach ($extensions as $extension) { + $result = $this->installExtension($extension, $phpVersion); + + if ($result === true) { + $succeeded[] = "php{$phpVersion}-{$extension}"; + } elseif ($result === false) { + $failed[] = "php{$phpVersion}-{$extension}"; + } + // null means skipped (already installed or not available) + } + } + + if (!empty($succeeded)) { + $this->output->ok('Installed: ' . implode(', ', $succeeded)); + } + + if (!empty($failed)) { + $this->output->warn('Failed to install: ' . implode(', ', $failed)); + // Don't throw exception - some extensions may not be available for all PHP versions + // This is expected behavior (e.g., json is built-in in PHP 8.0+) + } + + return true; + } + + /** + * Install single extension for specific PHP version. + * + * @param string $extension Extension name + * @param string $phpVersion PHP version + * @return bool|null True if installed, false if failed, null if skipped + */ + private function installExtension(string $extension, string $phpVersion): ?bool + { + // Some extensions don't need explicit installation + $builtIn = ['cli', 'common', 'json']; // json is built-in since PHP 8.0 + if (in_array($extension, $builtIn)) { + return null; // Skip + } + + $package = "php{$phpVersion}-{$extension}"; + + // Check if already installed + if ($this->isExtensionInstalled($extension, $phpVersion)) { + return null; // Skip + } + + // Try to install + $command = 'sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ' . escapeshellarg($package) . ' 2>&1'; + $output = []; + $exitCode = 0; + exec($command, $output, $exitCode); + + if ($exitCode !== 0) { + // Check if package doesn't exist (expected for some extensions) + $outputStr = implode("\n", $output); + if (strpos($outputStr, 'Unable to locate package') !== false) { + $this->output->plain("Package {$package} not available (may be built-in)"); + return null; // Skip + } + + $this->output->warn("Failed to install {$package}"); + return false; + } + + return true; + } + + /** + * Check if extension is installed for PHP version. + * + * @param string $extension Extension name + * @param string $phpVersion PHP version + * @return bool + */ + private function isExtensionInstalled(string $extension, string $phpVersion): bool + { + $php = "/usr/bin/php{$phpVersion}"; + + if (!file_exists($php)) { + return false; + } + + // Check if extension is loaded + $command = escapeshellarg($php) . " -m 2>/dev/null | grep -i " . escapeshellarg("^{$extension}$"); + $result = shell_exec($command); + + return $result !== null && trim($result) !== ''; + } + + /** + * Get installed extensions for PHP version. + * + * @param string $phpVersion PHP version + * @return array Extension names + */ + public function getInstalledExtensions(string $phpVersion): array + { + $php = "/usr/bin/php{$phpVersion}"; + + if (!file_exists($php)) { + return []; + } + + $output = shell_exec(escapeshellarg($php) . " -m 2>/dev/null"); + if ($output === null) { + return []; + } + + $lines = explode("\n", trim($output)); + $extensions = []; + + foreach ($lines as $line) { + $line = trim($line); + if ($line !== '' && $line !== '[PHP Modules]' && $line !== '[Zend Modules]') { + $extensions[] = strtolower($line); + } + } + + sort($extensions); + return $extensions; + } +} diff --git a/src/Ci/Setup/LaneCopier.php b/src/Ci/Setup/LaneCopier.php new file mode 100644 index 00000000..207da4c3 --- /dev/null +++ b/src/Ci/Setup/LaneCopier.php @@ -0,0 +1,213 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Ci\Setup; + +use Horde\Components\Exception; +use Horde\Components\Output; +use Horde\Components\Ci\Config\CiConfig; + +/** + * Copies component source to test lanes. + * + * Creates separate directory copies for each PHP version × stability combination. + * Each lane gets a deep copy of the source to isolate composer installations. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class LaneCopier +{ + /** + * Constructor. + * + * @param Output $output Output handler + */ + public function __construct( + private readonly Output $output + ) {} + + /** + * Copy component source to all test lanes. + * + * @param CiConfig $config CI configuration + * @return bool True if successful + * @throws Exception If copy fails + */ + public function copyToLanes(CiConfig $config): bool + { + $lanes = $config->getTestLanes(); + + if (empty($lanes)) { + throw new Exception('No test lanes defined'); + } + + $this->output->info("Copying component to {$config->componentName} lanes..."); + + foreach ($lanes as $lane) { + $this->output->plain(" → {$lane['php']}/{$lane['stability']}"); + $this->copyLane($config->componentPath, $lane['dir']); + } + + $this->output->ok("Copied to " . count($lanes) . " lanes"); + + return true; + } + + /** + * Copy component to a single lane directory. + * + * @param string $sourcePath Source component path + * @param string $targetPath Target lane path + * @return bool True if successful + * @throws Exception If copy fails + */ + private function copyLane(string $sourcePath, string $targetPath): bool + { + // Create parent directory + $parentDir = dirname($targetPath); + if (!is_dir($parentDir)) { + if (!mkdir($parentDir, 0755, true)) { + throw new Exception("Failed to create directory: {$parentDir}"); + } + } + + // Remove existing target if present + if (is_dir($targetPath)) { + $this->removeDirectory($targetPath); + } + + // Copy using cp -r (faster than PHP recursive copy) + $command = sprintf( + 'cp -r %s %s 2>&1', + escapeshellarg($sourcePath), + escapeshellarg($targetPath) + ); + + $output = []; + $exitCode = 0; + exec($command, $output, $exitCode); + + if ($exitCode !== 0) { + throw new Exception("Failed to copy to {$targetPath}: " . implode("\n", $output)); + } + + // Remove .git directory from copy (don't need git in test lanes) + $gitDir = $targetPath . '/.git'; + if (is_dir($gitDir)) { + $this->removeDirectory($gitDir); + } + + // Remove vendor directory from copy (will be recreated by composer install) + $vendorDir = $targetPath . '/vendor'; + if (is_dir($vendorDir)) { + $this->removeDirectory($vendorDir); + } + + return true; + } + + /** + * Remove directory recursively. + * + * @param string $dir Directory to remove + * @return bool True if successful + */ + private function removeDirectory(string $dir): bool + { + if (!is_dir($dir)) { + return true; + } + + // Use rm -rf for speed + $command = sprintf('rm -rf %s 2>&1', escapeshellarg($dir)); + $output = []; + $exitCode = 0; + exec($command, $output, $exitCode); + + return $exitCode === 0; + } + + /** + * Get lane directory size in MB. + * + * @param string $laneDir Lane directory path + * @return float Size in megabytes + */ + public function getLaneSize(string $laneDir): float + { + if (!is_dir($laneDir)) { + return 0.0; + } + + $output = shell_exec('du -sm ' . escapeshellarg($laneDir) . ' 2>/dev/null'); + if ($output === null) { + return 0.0; + } + + $parts = explode("\t", trim($output)); + return isset($parts[0]) ? (float)$parts[0] : 0.0; + } + + /** + * Clean up all lanes. + * + * @param CiConfig $config CI configuration + * @return bool True if successful + */ + public function cleanupLanes(CiConfig $config): bool + { + $lanesDir = $config->workDir . '/lanes'; + + if (!is_dir($lanesDir)) { + return true; + } + + $this->output->info('Cleaning up test lanes...'); + + return $this->removeDirectory($lanesDir); + } + + /** + * Verify lane copy is complete. + * + * Checks that essential files exist in the lane. + * + * @param string $laneDir Lane directory path + * @return bool True if valid + */ + public function verifyLane(string $laneDir): bool + { + // Check directory exists + if (!is_dir($laneDir)) { + return false; + } + + // Check for composer.json (should exist in all Horde components) + if (!file_exists($laneDir . '/composer.json')) { + return false; + } + + // Check for .horde.yml + if (!file_exists($laneDir . '/.horde.yml')) { + return false; + } + + return true; + } +} diff --git a/src/Ci/Setup/PhpInstaller.php b/src/Ci/Setup/PhpInstaller.php new file mode 100644 index 00000000..ff244aa0 --- /dev/null +++ b/src/Ci/Setup/PhpInstaller.php @@ -0,0 +1,288 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Ci\Setup; + +use Horde\Components\Exception; +use Horde\Components\Output; + +/** + * Installs multiple PHP versions via ondrej PPA. + * + * Uses apt-get to install PHP versions 8.2, 8.3, 8.4, 8.5 from + * the ondrej/php PPA on Ubuntu systems. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class PhpInstaller +{ + /** + * ondrej PPA URL. + */ + private const ONDREJ_PPA = 'ppa:ondrej/php'; + + /** + * Constructor. + * + * @param Output $output Output handler + */ + public function __construct( + private readonly Output $output + ) {} + + /** + * Install multiple PHP versions. + * + * @param array $versions PHP versions to install (e.g., ['8.2', '8.3']) + * @return bool True if successful + * @throws Exception If installation fails + */ + public function install(array $versions): bool + { + if (empty($versions)) { + $this->output->warn('No PHP versions specified for installation'); + return true; + } + + $this->output->info('Installing PHP versions: ' . implode(', ', $versions)); + + // Check if we're on a Debian/Ubuntu system + if (!$this->isDebianBased()) { + throw new Exception('PHP installation via ondrej PPA only works on Debian/Ubuntu systems'); + } + + // Check if we have sudo + if (!$this->hasSudo()) { + throw new Exception('sudo access required for PHP installation'); + } + + // Add ondrej PPA if not already added + if (!$this->isPpaAdded()) { + $this->output->info('Adding ondrej/php PPA...'); + if (!$this->addPpa()) { + throw new Exception('Failed to add ondrej/php PPA'); + } + } + + // Update package list + $this->output->info('Updating package list...'); + if (!$this->updatePackageList()) { + throw new Exception('Failed to update package list'); + } + + // Install each PHP version + foreach ($versions as $version) { + if ($this->isPhpVersionInstalled($version)) { + $this->output->info("PHP {$version} already installed"); + continue; + } + + $this->output->info("Installing PHP {$version}..."); + if (!$this->installPhpVersion($version)) { + throw new Exception("Failed to install PHP {$version}"); + } + } + + $this->output->ok('All PHP versions installed successfully'); + return true; + } + + /** + * Check if system is Debian-based. + * + * @return bool + */ + private function isDebianBased(): bool + { + return file_exists('/etc/debian_version') || + file_exists('/etc/lsb-release') || + is_executable('/usr/bin/apt-get'); + } + + /** + * Check if we have sudo access. + * + * @return bool + */ + private function hasSudo(): bool + { + // Check if running as root + if (posix_geteuid() === 0) { + return true; + } + + // Check if sudo is available and we can use it + $result = shell_exec('sudo -n true 2>&1'); + return $result !== null && strpos($result, 'password') === false; + } + + /** + * Check if ondrej PPA is already added. + * + * @return bool + */ + private function isPpaAdded(): bool + { + $sources = '/etc/apt/sources.list.d/'; + if (!is_dir($sources)) { + return false; + } + + $files = glob($sources . '*ondrej*'); + return $files !== false && count($files) > 0; + } + + /** + * Add ondrej PPA. + * + * @return bool + */ + private function addPpa(): bool + { + $commands = [ + 'sudo apt-get install -y software-properties-common', + 'sudo add-apt-repository -y ' . escapeshellarg(self::ONDREJ_PPA), + ]; + + foreach ($commands as $command) { + $output = []; + $exitCode = 0; + exec($command . ' 2>&1', $output, $exitCode); + + if ($exitCode !== 0) { + $this->output->error('Command failed: ' . $command); + $this->output->plain(implode("\n", $output)); + return false; + } + } + + return true; + } + + /** + * Update package list. + * + * @return bool + */ + private function updatePackageList(): bool + { + $output = []; + $exitCode = 0; + exec('sudo apt-get update 2>&1', $output, $exitCode); + + if ($exitCode !== 0) { + $this->output->error('apt-get update failed'); + $this->output->plain(implode("\n", $output)); + return false; + } + + return true; + } + + /** + * Check if PHP version is already installed. + * + * @param string $version PHP version (e.g., '8.4') + * @return bool + */ + private function isPhpVersionInstalled(string $version): bool + { + $binary = "/usr/bin/php{$version}"; + return file_exists($binary) && is_executable($binary); + } + + /** + * Install a specific PHP version. + * + * @param string $version PHP version (e.g., '8.4') + * @return bool + */ + private function installPhpVersion(string $version): bool + { + // Install CLI package + $package = "php{$version}-cli"; + $command = 'sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ' . escapeshellarg($package); + + $output = []; + $exitCode = 0; + exec($command . ' 2>&1', $output, $exitCode); + + if ($exitCode !== 0) { + $this->output->error("Failed to install {$package}"); + $this->output->plain(implode("\n", $output)); + return false; + } + + // Verify installation + if (!$this->isPhpVersionInstalled($version)) { + $this->output->error("PHP {$version} installation succeeded but binary not found"); + return false; + } + + // Show installed version + $versionOutput = shell_exec("/usr/bin/php{$version} -v 2>&1"); + if ($versionOutput !== null) { + $this->output->plain($versionOutput); + } + + return true; + } + + /** + * Get installed PHP versions. + * + * @return array Installed PHP versions + */ + public function getInstalledVersions(): array + { + $versions = []; + $binaries = glob('/usr/bin/php[0-9].[0-9]'); + + if ($binaries === false) { + return []; + } + + foreach ($binaries as $binary) { + if (preg_match('/php(\d+\.\d+)$/', $binary, $matches)) { + $versions[] = $matches[1]; + } + } + + sort($versions); + return $versions; + } + + /** + * Get path to PHP binary for version. + * + * @param string $version PHP version (e.g., '8.4') + * @return string Path to binary + * @throws Exception If version not installed + */ + public function getPhpBinary(string $version): string + { + $binary = "/usr/bin/php{$version}"; + + if (!file_exists($binary) || !is_executable($binary)) { + throw new Exception("PHP {$version} is not installed"); + } + + return $binary; + } +} From 4cc6c2980076a0f6f35918cedd56e76bb44c7daf Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Tue, 3 Mar 2026 10:53:57 +0100 Subject: [PATCH 04/17] feat(ci): add SetupCommand and InitCommand orchestrators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add main command orchestrators for Phase 1: SetupCommand: - Main CI setup orchestrator - Coordinates: validate → install PHP → install extensions → copy lanes → composer install - Reads component metadata from .horde.yml (simple YAML parser) - Validates component type (library only in Phase 1) - Reports setup progress with detailed output - Returns success/failure with lane summary InitCommand: - Generate CI files from templates (ci init) - Creates bin/ci-bootstrap.sh from template - Creates .github/workflows/ci.yml (github mode) - Template version checking (detect outdated files) - Force overwrite and dry-run support - Check command (ci check) to validate existing files Features: - Smart configuration detection (from env, .horde.yml, defaults) - Template variable substitution - File existence checking with version comparison - Executable permission setting for shell scripts - Preview mode (--dry-run) to see generated content - Comprehensive error messages Both commands use dependency injection for testability. Related to components-ci-implementation-plan.md Phase 1. --- src/Ci/Init/InitCommand.php | 390 ++++++++++++++++++++++++++++++++++ src/Ci/Setup/SetupCommand.php | 316 +++++++++++++++++++++++++++ 2 files changed, 706 insertions(+) create mode 100644 src/Ci/Init/InitCommand.php create mode 100644 src/Ci/Setup/SetupCommand.php diff --git a/src/Ci/Init/InitCommand.php b/src/Ci/Init/InitCommand.php new file mode 100644 index 00000000..0bd298a5 --- /dev/null +++ b/src/Ci/Init/InitCommand.php @@ -0,0 +1,390 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Ci\Init; + +use Horde\Components\Exception; +use Horde\Components\Output; +use Horde\Components\Ci\Template\TemplateRenderer; +use Horde\Components\Ci\Template\TemplateLocator; +use Horde\Components\Ci\Template\TemplateVersion; + +/** + * Initialize CI for a component. + * + * Generates CI configuration files from templates: + * - bin/ci-bootstrap.sh - Bootstrap script + * - .github/workflows/ci.yml - GitHub Actions workflow (github mode only) + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class InitCommand +{ + /** + * Constructor. + * + * @param Output $output Output handler + */ + public function __construct( + private readonly Output $output + ) {} + + /** + * Execute ci init command. + * + * @param string $componentPath Path to component directory + * @param string $mode Mode (github or local) + * @param bool $force Force overwrite existing files + * @param bool $dryRun Show what would be generated without writing + * @return bool True if successful + * @throws Exception If generation fails + */ + public function execute( + string $componentPath, + string $mode = 'github', + bool $force = false, + bool $dryRun = false + ): bool { + $this->output->bold('=== Horde CI Init ==='); + $this->output->info("Component path: {$componentPath}"); + $this->output->info("Mode: {$mode}"); + $this->output->plain(''); + + // Validate mode + if (!in_array($mode, ['github', 'local'])) { + throw new Exception("Invalid mode: {$mode}. Must be 'github' or 'local'."); + } + + // Validate component path + if (!is_dir($componentPath)) { + throw new Exception("Component directory does not exist: {$componentPath}"); + } + + // Detect component name + $componentName = basename(realpath($componentPath)); + $this->output->info("Component: {$componentName}"); + $this->output->plain(''); + + // Get configuration values + $config = $this->getConfiguration($componentPath, $componentName, $mode); + + // Check for existing files + $existingFiles = $this->checkExistingFiles($componentPath, $mode); + if (!empty($existingFiles) && !$force && !$dryRun) { + $this->output->warn('The following files already exist:'); + foreach ($existingFiles as $file) { + $this->output->plain(" - {$file}"); + } + $this->output->plain(''); + $this->output->warn('Use --force to overwrite existing files.'); + return false; + } + + // Generate files + $renderer = new TemplateRenderer(TemplateLocator::getTemplateDir()); + + // Generate bootstrap script + $bootstrapFile = $componentPath . '/bin/ci-bootstrap.sh'; + $this->generateBootstrapScript( + $renderer, + $mode, + $config, + $bootstrapFile, + $dryRun + ); + + // Generate workflow (GitHub mode only) + if ($mode === 'github') { + $workflowFile = $componentPath . '/.github/workflows/ci.yml'; + $this->generateWorkflow( + $renderer, + $config, + $workflowFile, + $dryRun + ); + } + + $this->output->plain(''); + if ($dryRun) { + $this->output->bold('=== Dry run complete (no files written) ==='); + } else { + $this->output->bold('=== CI initialization complete ==='); + $this->output->info('Generated files can be committed to your repository.'); + $this->output->info('To regenerate: horde-components ci init --force'); + } + + return true; + } + + /** + * Check for existing CI files. + * + * @param string $componentPath Component path + * @param string $mode Mode + * @return array Relative paths to existing files + */ + private function checkExistingFiles(string $componentPath, string $mode): array + { + $existing = []; + + $bootstrapFile = $componentPath . '/bin/ci-bootstrap.sh'; + if (file_exists($bootstrapFile)) { + $existing[] = 'bin/ci-bootstrap.sh'; + + // Check if outdated + if (TemplateVersion::isOutdated($bootstrapFile)) { + $comparison = TemplateVersion::compare($bootstrapFile); + $this->output->warn( + " (outdated: v{$comparison['file']} < v{$comparison['current']})" + ); + } + } + + if ($mode === 'github') { + $workflowFile = $componentPath . '/.github/workflows/ci.yml'; + if (file_exists($workflowFile)) { + $existing[] = '.github/workflows/ci.yml'; + } + } + + return $existing; + } + + /** + * Get configuration values. + * + * @param string $componentPath Component path + * @param string $componentName Component name + * @param string $mode Mode + * @return array Configuration variables + */ + private function getConfiguration( + string $componentPath, + string $componentName, + string $mode + ): array { + $config = [ + '{{COMPONENT_NAME}}' => $componentName, + '{{PHP_VERSION}}' => '8.4', + '{{WORK_DIR}}' => '/tmp/horde-ci', + ]; + + if ($mode === 'github') { + // GitHub mode configuration + $config['{{COMPONENTS_PHAR_URL}}'] = $this->getComponentsPharUrl(); + } else { + // Local mode configuration + $config['{{LOCAL_COMPONENTS_PATH}}'] = getenv('LOCAL_COMPONENTS_PATH') ?: '${LOCAL_COMPONENTS_PATH}'; + $config['{{LOCAL_COMPONENT_PATH}}'] = getenv('LOCAL_COMPONENT_PATH') ?: '${LOCAL_COMPONENT_PATH}'; + } + + return $config; + } + + /** + * Get components PHAR URL. + * + * @return string PHAR URL + */ + private function getComponentsPharUrl(): string + { + // Try environment variable + $url = getenv('COMPONENTS_PHAR_URL'); + if ($url !== false && $url !== '') { + return $url; + } + + // Try from config (would need to access config system) + // For now, use default + return 'https://dev.horde.org/ci/horde-components.phar'; + } + + /** + * Generate bootstrap script. + * + * @param TemplateRenderer $renderer Template renderer + * @param string $mode Mode + * @param array $config Configuration + * @param string $outputFile Output file path + * @param bool $dryRun Dry run mode + * @return bool True if successful + * @throws Exception If generation fails + */ + private function generateBootstrapScript( + TemplateRenderer $renderer, + string $mode, + array $config, + string $outputFile, + bool $dryRun + ): bool { + $templateName = $mode === 'github' ? 'bootstrap-github.sh' : 'bootstrap-local.sh'; + + $this->output->info("Generating {$outputFile}..."); + + try { + $content = $renderer->render($templateName, $config); + + if ($dryRun) { + $this->output->plain('--- Preview ---'); + $lines = explode("\n", $content); + foreach (array_slice($lines, 0, 20) as $line) { + $this->output->plain($line); + } + if (count($lines) > 20) { + $this->output->plain('... (' . (count($lines) - 20) . ' more lines)'); + } + $this->output->plain(''); + return true; + } + + // Create directory if needed + $dir = dirname($outputFile); + if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true)) { + throw new Exception("Failed to create directory: {$dir}"); + } + } + + // Write file + if (file_put_contents($outputFile, $content) === false) { + throw new Exception("Failed to write file: {$outputFile}"); + } + + // Make executable + chmod($outputFile, 0755); + + $this->output->ok("Created: {$outputFile}"); + return true; + + } catch (Exception $e) { + $this->output->error("Failed to generate bootstrap script: " . $e->getMessage()); + throw $e; + } + } + + /** + * Generate GitHub Actions workflow. + * + * @param TemplateRenderer $renderer Template renderer + * @param array $config Configuration + * @param string $outputFile Output file path + * @param bool $dryRun Dry run mode + * @return bool True if successful + * @throws Exception If generation fails + */ + private function generateWorkflow( + TemplateRenderer $renderer, + array $config, + string $outputFile, + bool $dryRun + ): bool { + $this->output->info("Generating {$outputFile}..."); + + try { + $content = $renderer->render('workflow.yml', $config); + + if ($dryRun) { + $this->output->plain('--- Preview ---'); + $lines = explode("\n", $content); + foreach (array_slice($lines, 0, 20) as $line) { + $this->output->plain($line); + } + if (count($lines) > 20) { + $this->output->plain('... (' . (count($lines) - 20) . ' more lines)'); + } + $this->output->plain(''); + return true; + } + + // Create directory if needed + $dir = dirname($outputFile); + if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true)) { + throw new Exception("Failed to create directory: {$dir}"); + } + } + + // Write file + if (file_put_contents($outputFile, $content) === false) { + throw new Exception("Failed to write file: {$outputFile}"); + } + + $this->output->ok("Created: {$outputFile}"); + return true; + + } catch (Exception $e) { + $this->output->error("Failed to generate workflow: " . $e->getMessage()); + throw $e; + } + } + + /** + * Check command for validating existing CI files. + * + * @param string $componentPath Component path + * @return bool True if files are up to date + */ + public function check(string $componentPath): bool + { + $this->output->bold('=== Checking CI Files ==='); + $this->output->info("Component: {$componentPath}"); + $this->output->plain(''); + + $bootstrapFile = $componentPath . '/bin/ci-bootstrap.sh'; + $workflowFile = $componentPath . '/.github/workflows/ci.yml'; + + $allCurrent = true; + + // Check bootstrap + if (!file_exists($bootstrapFile)) { + $this->output->warn('bin/ci-bootstrap.sh not found'); + $this->output->info(' Run: horde-components ci init'); + $allCurrent = false; + } else { + $comparison = TemplateVersion::compare($bootstrapFile); + + if ($comparison['outdated']) { + $fileVer = $comparison['file'] ?? 'unknown'; + $currentVer = $comparison['current']; + $this->output->warn("bin/ci-bootstrap.sh is outdated"); + $this->output->info(" File version: {$fileVer}"); + $this->output->info(" Current version: {$currentVer}"); + $this->output->info(" Run: horde-components ci init --force"); + $allCurrent = false; + } else { + $this->output->ok("bin/ci-bootstrap.sh is up to date (v{$comparison['file']})"); + } + } + + // Check workflow + if (file_exists($workflowFile)) { + $this->output->ok('.github/workflows/ci.yml exists'); + } + + $this->output->plain(''); + + if ($allCurrent) { + $this->output->bold('All CI files are up to date'); + } else { + $this->output->bold('Some CI files need updating'); + } + + return $allCurrent; + } +} diff --git a/src/Ci/Setup/SetupCommand.php b/src/Ci/Setup/SetupCommand.php new file mode 100644 index 00000000..29ace398 --- /dev/null +++ b/src/Ci/Setup/SetupCommand.php @@ -0,0 +1,316 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Ci\Setup; + +use Horde\Components\Exception; +use Horde\Components\Output; +use Horde\Components\Ci\Config\CiConfig; +use Horde\Components\Component; + +/** + * Main orchestrator for CI setup. + * + * Coordinates all setup steps: + * 1. Detect/validate environment + * 2. Install PHP versions + * 3. Install extensions + * 4. Copy component to lanes + * 5. Run composer install per lane + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class SetupCommand +{ + /** + * Constructor. + * + * @param Output $output Output handler + * @param PhpInstaller $phpInstaller PHP installer + * @param ExtensionInstaller $extensionInstaller Extension installer + * @param LaneCopier $laneCopier Lane copier + * @param ComposerInstaller $composerInstaller Composer installer + */ + public function __construct( + private readonly Output $output, + private readonly PhpInstaller $phpInstaller, + private readonly ExtensionInstaller $extensionInstaller, + private readonly LaneCopier $laneCopier, + private readonly ComposerInstaller $composerInstaller + ) {} + + /** + * Execute CI setup. + * + * @param CiConfig $config CI configuration + * @return bool True if successful + * @throws Exception If setup fails + */ + public function execute(CiConfig $config): bool + { + $this->output->bold('=== Horde CI Setup ==='); + $this->output->info("Component: {$config->componentName}"); + $this->output->info("Mode: {$config->mode}"); + $this->output->info("Branch: {$config->componentBranch}"); + $this->output->plain(''); + + // Validate configuration + $this->output->info('[1/5] Validating configuration...'); + $errors = $config->validate(); + if (!empty($errors)) { + foreach ($errors as $error) { + $this->output->error($error); + } + throw new Exception('Configuration validation failed'); + } + $this->output->ok('Configuration valid'); + $this->output->plain(''); + + // Detect component info from .horde.yml + $this->output->info('[2/5] Reading component metadata...'); + $componentInfo = $this->readComponentInfo($config->componentPath); + + // Update config with detected values + $config = new CiConfig(array_merge([ + 'mode' => $config->mode, + 'component_name' => $config->componentName, + 'component_branch' => $config->componentBranch, + 'component_path' => $config->componentPath, + 'work_dir' => $config->workDir, + 'github_token' => $config->githubToken, + 'components_phar_url' => $config->componentsPharUrl, + 'local_components_path' => $config->localComponentsPath, + ], $componentInfo)); + + $this->output->ok("Component type: {$config->componentType}"); + $this->output->ok("Min PHP: {$config->minPhpVersion}"); + $this->output->ok("Stability: {$config->componentStability}"); + $this->output->plain(''); + + // Check component type support + if ($config->componentType !== 'library') { + throw new Exception( + "Component type '{$config->componentType}' not yet implemented. " . + "Only 'library' is supported in Phase 1." + ); + } + + // Install PHP versions + $this->output->info('[3/5] Installing PHP versions...'); + $testableVersions = $config->getTestablePhpVersions(); + $this->output->info('Testable versions: ' . implode(', ', $testableVersions)); + + $this->phpInstaller->install($testableVersions); + $this->output->plain(''); + + // Install extensions + $this->output->info('[4/5] Installing PHP extensions...'); + $extensions = $this->extensionInstaller->detectExtensions( + $config->componentPath, + $config->componentName + ); + $this->output->info('Required extensions: ' . implode(', ', $extensions)); + + $this->extensionInstaller->install($extensions, $testableVersions); + $this->output->plain(''); + + // Copy to lanes + $this->output->info('[5/5] Setting up test lanes...'); + $lanes = $config->getTestLanes(); + $this->output->info('Creating ' . count($lanes) . ' test lanes'); + + $this->laneCopier->copyToLanes($config); + $this->output->plain(''); + + // Run composer install for each lane + $this->output->bold('=== Running composer install ==='); + $successful = 0; + $failed = 0; + + foreach ($lanes as $index => $lane) { + $num = $index + 1; + $total = count($lanes); + $this->output->info("[{$num}/{$total}] PHP {$lane['php']} ({$lane['stability']})"); + + try { + $phpBinary = $this->phpInstaller->getPhpBinary($lane['php']); + + $this->composerInstaller->install( + $lane['dir'], + $phpBinary, + $lane['stability'] + ); + + // Verify installation + if ($this->composerInstaller->verifyInstallation($lane['dir'])) { + $packageCount = $this->composerInstaller->getInstalledPackageCount($lane['dir']); + $this->output->ok(" ✓ Installed ({$packageCount} packages)"); + $successful++; + } else { + $this->output->warn(" ⚠ Installation verification failed"); + $failed++; + } + } catch (Exception $e) { + $this->output->error(" ✗ Failed: " . $e->getMessage()); + $failed++; + } + + $this->output->plain(''); + } + + // Summary + $this->output->bold('=== Setup Summary ==='); + $this->output->ok("Successful lanes: {$successful}"); + + if ($failed > 0) { + $this->output->warn("Failed lanes: {$failed}"); + } + + $this->output->plain(''); + $this->output->bold('Setup complete!'); + $this->output->info("Workspace: {$config->workDir}"); + $this->output->info("Next step: horde-components ci run --work-dir={$config->workDir}"); + + return $failed === 0; + } + + /** + * Read component information from .horde.yml. + * + * @param string $componentPath Path to component + * @return array Component info + * @throws Exception If .horde.yml not found or invalid + */ + private function readComponentInfo(string $componentPath): array + { + $hordeYml = $componentPath . '/.horde.yml'; + + if (!file_exists($hordeYml)) { + throw new Exception('.horde.yml not found in component directory'); + } + + $content = file_get_contents($hordeYml); + if ($content === false) { + throw new Exception('Failed to read .horde.yml'); + } + + // Parse YAML (simple parsing for now) + $data = $this->parseSimpleYaml($content); + + // Extract minimum PHP version + $minPhp = '8.2'; // Default + if (isset($data['dependencies']['required']['php'])) { + $phpReq = $data['dependencies']['required']['php']; + // Parse requirements like "^8.2", ">=8.3", "^8.2 || ^8.3" + if (preg_match('/[>^~]?\s*(\d+\.\d+)/', $phpReq, $matches)) { + $minPhp = $matches[1]; + } + } + + // Extract component stability + $stability = 'alpha'; // Default + if (isset($data['state']['release'])) { + $stability = $data['state']['release']; + } + + // Extract component type + $type = 'library'; // Default + if (isset($data['type'])) { + $type = $data['type']; + } + + // Detect required extensions from dependencies + $extensions = []; + if (isset($data['dependencies']['required']['ext']) && is_array($data['dependencies']['required']['ext'])) { + foreach ($data['dependencies']['required']['ext'] as $ext) { + if (is_string($ext)) { + $extensions[] = $ext; + } + } + } + + return [ + 'component_type' => $type, + 'min_php_version' => $minPhp, + 'component_stability' => $stability, + 'required_extensions' => $extensions, + ]; + } + + /** + * Simple YAML parser (handles basic structure only). + * + * This is a simplified parser for .horde.yml structure. + * For production, consider using symfony/yaml. + * + * @param string $content YAML content + * @return array Parsed data + */ + private function parseSimpleYaml(string $content): array + { + $data = []; + $lines = explode("\n", $content); + $stack = [&$data]; + $indents = [0]; + + foreach ($lines as $line) { + // Skip comments and empty lines + if (preg_match('/^\s*#/', $line) || trim($line) === '') { + continue; + } + + // Get indentation + preg_match('/^(\s*)/', $line, $matches); + $indent = strlen($matches[1]); + $line = trim($line); + + // Pop stack if indent decreased + while (count($indents) > 1 && $indent < end($indents)) { + array_pop($stack); + array_pop($indents); + } + + // Parse key: value + if (preg_match('/^([^:]+):\s*(.*)$/', $line, $matches)) { + $key = trim($matches[1]); + $value = trim($matches[2]); + + $current = &$stack[count($stack) - 1]; + + if ($value === '') { + // New array + $current[$key] = []; + $stack[] = &$current[$key]; + $indents[] = $indent; + } else { + // Simple value + $current[$key] = $value; + } + } + // Parse array item + elseif (preg_match('/^-\s+(.+)$/', $line, $matches)) { + $value = trim($matches[1]); + $current = &$stack[count($stack) - 1]; + $current[] = $value; + } + } + + return $data; + } +} From 7b820900764cce9f803b5d2167f2e08c8774dda3 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Tue, 3 Mar 2026 10:56:25 +0100 Subject: [PATCH 05/17] feat(ci): add CLI module for ci commands Add Module/Ci.php to expose CI functionality via horde-components CLI. Commands Available: - ci init [--ci-mode=MODE] [--force] [--dry-run] Generate CI configuration files from templates - ci check Validate existing CI files and check for updates - ci setup [--ci-mode=MODE] [--work-dir=DIR] Setup CI environment (install PHP, extensions, prepare lanes) - ci run Execute tests (Phase 2 - not yet implemented) Features: - Follows existing module pattern (extends Base, implements handle()) - Comprehensive help text with examples and troubleshooting - Option group for CI-specific flags (--ci-mode, --work-dir, --force, etc.) - Delegates to InitCommand and SetupCommand - Error handling with user-friendly messages - Mode auto-detection (GitHub Actions vs local) Integration: - Uses dependency injection (Output from injector) - Creates command instances with proper dependencies - Follows horde-components conventions The CI commands are now accessible via: horde-components ci horde-components help ci Related to components-ci-implementation-plan.md Phase 1. --- src/Module/Ci.php | 469 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 src/Module/Ci.php diff --git a/src/Module/Ci.php b/src/Module/Ci.php new file mode 100644 index 00000000..841ae5ab --- /dev/null +++ b/src/Module/Ci.php @@ -0,0 +1,469 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Module; + +use Horde\Components\Component; +use Horde\Components\Output; +use Horde\Components\Exception; +use Horde\Components\Ci\Setup\SetupCommand; +use Horde\Components\Ci\Setup\EnvironmentDetector; +use Horde\Components\Ci\Setup\PhpInstaller; +use Horde\Components\Ci\Setup\ExtensionInstaller; +use Horde\Components\Ci\Setup\LaneCopier; +use Horde\Components\Ci\Setup\ComposerInstaller; +use Horde\Components\Ci\Init\InitCommand; +use Horde\Components\Ci\Config\CiConfig; + +/** + * Components_Module_Ci:: manages CI setup and execution for components. + * + * Copyright 2026 Horde LLC (http://www.horde.org/) + * + * See the enclosed file LICENSE for license information (LGPL). If you + * did not receive this file, see http://www.horde.org/licenses/lgpl21. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class Ci extends Base +{ + public function getOptionGroupTitle(): string + { + return 'Continuous Integration'; + } + + public function getOptionGroupDescription(): string + { + return 'This module manages CI setup and execution for Horde components.'; + } + + public function getOptionGroupOptions(): array + { + return [ + new \Horde\Argv\Option( + '--ci-mode', + [ + 'action' => 'store', + 'help' => 'CI mode: github or local (default: auto-detect)', + ] + ), + new \Horde\Argv\Option( + '--work-dir', + [ + 'action' => 'store', + 'help' => 'Working directory for CI operations (default: /tmp/horde-ci)', + ] + ), + new \Horde\Argv\Option( + '--component', + [ + 'action' => 'store', + 'help' => 'Component name (default: auto-detect)', + ] + ), + new \Horde\Argv\Option( + '--local-path', + [ + 'action' => 'store', + 'help' => 'Local component path (local mode)', + ] + ), + new \Horde\Argv\Option( + '--force', + [ + 'action' => 'store_true', + 'help' => 'Force overwrite existing files (ci init)', + ] + ), + new \Horde\Argv\Option( + '--dry-run', + [ + 'action' => 'store_true', + 'help' => 'Show what would be generated without writing (ci init)', + ] + ), + ]; + } + + /** + * Get the usage title for this module. + * + * @return string The title. + */ + public function getTitle(): string + { + return 'ci'; + } + + /** + * Get the usage description for this module. + * + * @return string The description. + */ + public function getUsage(): string + { + return 'Manage CI setup and execution.'; + } + + /** + * Return the action arguments supported by this module. + * + * @return array A list of supported action arguments. + */ + public function getActions(): array + { + return ['ci']; + } + + /** + * Return the help text for the specified action. + * + * @param string $action The action. + * + * @return string The help text. + */ + public function getHelp($action): string + { + return 'Manage Continuous Integration for Horde components. + +USAGE: + horde-components ci [OPTIONS] + +SUBCOMMANDS: + init Generate CI configuration files for a component + check Check if CI files are up to date + setup Setup CI environment (install PHP, extensions, prepare lanes) + run Run CI tests across all lanes (Phase 2 - not yet implemented) + +DESCRIPTION: + The ci command helps set up and run automated testing for Horde components + across multiple PHP versions and dependency stability levels. + + The system works in two modes: + - github: For GitHub Actions (downloads horde-components.phar) + - local: For local development (uses local horde-components) + +CI INIT - Generate Configuration Files: + Generates bootstrap scripts and GitHub Actions workflows from templates. + + # Generate CI files (auto-detect mode) + horde-components ci init + + # Generate for GitHub Actions (default) + horde-components ci init --ci-mode=github + + # Generate for local development + horde-components ci init --ci-mode=local + + # Force overwrite existing files + horde-components ci init --force + + # Preview without writing + horde-components ci init --dry-run + + Generated files: + - bin/ci-bootstrap.sh Bootstrap script + - .github/workflows/ci.yml GitHub Actions workflow (github mode only) + +CI CHECK - Validate Existing Files: + Check if generated CI files are up to date with current templates. + + # Check CI files + horde-components ci check + + This command: + - Verifies files exist + - Checks template versions + - Warns if outdated + +CI SETUP - Prepare Environment: + Sets up the CI environment for testing: + 1. Validates configuration + 2. Reads component metadata (.horde.yml) + 3. Installs PHP versions (8.2, 8.3, 8.4, 8.5 via ondrej PPA) + 4. Installs required PHP extensions + 5. Copies component to test lanes (8 directories) + 6. Runs composer install per lane with stability control + + # Setup for local testing + horde-components ci setup --ci-mode=local + + # Setup with custom work directory + horde-components ci setup --work-dir=/tmp/my-ci + + # GitHub Actions mode (called by bootstrap script) + horde-components ci setup --ci-mode=github --component=Db + + Test lanes created: + - php8.2-dev (minimum-stability: dev) + - php8.2-stable (minimum-stability: component\'s own stability) + - php8.3-dev + - php8.3-stable + - php8.4-dev + - php8.4-stable + - php8.5-dev + - php8.5-stable + + Note: Only PHP versions >= component\'s minimum are tested. + +CI RUN - Execute Tests: + (Phase 2 - not yet implemented) + Will execute tests across all prepared lanes: + - horde-components qc linter + - PHPUnit (version depends on PHP version) + - PHPStan (level from .horde.yml) + - php-cs-fixer (once on PHP 8.4) + +TYPICAL WORKFLOW: + # 1. Generate CI files for your component + cd ~/git/horde/Http + horde-components ci init + + # 2. Commit the generated files + git add bin/ci-bootstrap.sh .github/workflows/ci.yml + git commit -m "feat(ci): add CI configuration" + + # 3. Test locally (requires Ubuntu 24.04) + export LOCAL_COMPONENTS_PATH=~/components + export LOCAL_COMPONENT_PATH=$(pwd) + ./bin/ci-bootstrap.sh + + # 4. Push to GitHub - CI runs automatically on push/PR + +ENVIRONMENT VARIABLES: + GitHub Actions mode (auto-detected): + - GITHUB_ACTIONS Must be set (GitHub sets this) + - GITHUB_TOKEN Required for API access + - GITHUB_REPOSITORY Component repository (org/name) + - GITHUB_REF Branch reference + - COMPONENTS_PHAR_URL URL to download horde-components.phar + + Local mode (must set manually): + - LOCAL_COMPONENTS_PATH Path to horde-components checkout + - LOCAL_COMPONENT_PATH Path to component being tested + - CI_WORK_DIR Working directory (default: /tmp/horde-ci) + +REQUIREMENTS: + Local mode: + - Ubuntu 24.04 (or Debian-based with apt-get) + - sudo access (for PHP installation via apt) + - Composer installed globally + - Git + + GitHub Actions: + - ubuntu-24.04 runner + - PHP 8.4 for bootstrap (via shivammathur/setup-php) + +COMPONENT TYPES: + Phase 1 supports: + - library Standard PHP library (fully supported) + + Not yet implemented: + - horde-library (Phase 6) + - application (Phase 6) + - bundle (Phase 6) + +TEMPLATE VERSIONING: + Generated files include version metadata: + # Template version: 1.0.0 + + Use "ci check" to detect outdated files. + Regenerate with "ci init --force" after template updates. + +TROUBLESHOOTING: + Problem: "sudo access required" + Solution: Ensure you can run "sudo apt-get" without password, or configure sudoers + + Problem: "composer not found" + Solution: Install composer globally: https://getcomposer.org/download/ + + Problem: "Template not found" + Solution: Ensure you\'re using horde-components from the correct path + + Problem: "Component type \'X\' not yet implemented" + Solution: Only \'library\' type is supported in Phase 1 + + Problem: Generated files outdated + Solution: Run "horde-components ci check" then "ci init --force" + +EXAMPLES: + # Initialize CI for current component + cd ~/git/horde/Http + horde-components ci init + + # Check if files are current + horde-components ci check + + # Test setup locally + export LOCAL_COMPONENTS_PATH=~/components + export LOCAL_COMPONENT_PATH=$(pwd) + horde-components ci setup --ci-mode=local + + # See what would be generated + horde-components ci init --dry-run + +MORE INFO: + See ~/horde-development/components-ci-*.md for: + - Implementation plan + - Architecture decisions + - Phase completion status + - Troubleshooting guides'; + } + + /** + * Determine if this module should act. Run all required actions if it has + * been instructed to do so. + * + * @param array $options CLI options + * @param array $arguments CLI arguments + * @param Component|null $component The selected component (if any) + * + * @return bool True if the module performed some action. + */ + public function handle(array $options, array $arguments, ?Component $component = null): bool + { + if (!isset($arguments[0]) || $arguments[0] !== 'ci') { + return false; + } + + $subcommand = $arguments[1] ?? ''; + + if (empty($subcommand)) { + $this->showHelp(); + return true; + } + + // Get output handler + $output = $this->dependencies->get(Output::class); + + try { + switch ($subcommand) { + case 'init': + return $this->handleInit($options, $output); + + case 'check': + return $this->handleCheck($options, $output); + + case 'setup': + return $this->handleSetup($options, $output); + + case 'run': + $output->warn('CI run is not yet implemented (Phase 2)'); + $output->info('Current phase: Phase 1 - Setup only'); + return false; + + default: + $output->error("Unknown subcommand: {$subcommand}"); + $output->info('Valid subcommands: init, check, setup, run'); + return false; + } + } catch (Exception $e) { + $output->error('CI command failed: ' . $e->getMessage()); + return false; + } + } + + /** + * Show general help for ci command. + */ + private function showHelp(): void + { + $output = $this->dependencies->get(Output::class); + $output->bold('Horde Components CI Management'); + $output->plain(''); + $output->plain('Usage: horde-components ci [OPTIONS]'); + $output->plain(''); + $output->plain('Subcommands:'); + $output->plain(' init Generate CI configuration files'); + $output->plain(' check Check if CI files are up to date'); + $output->plain(' setup Setup CI environment'); + $output->plain(' run Run CI tests (Phase 2 - not yet implemented)'); + $output->plain(''); + $output->plain('For detailed help: horde-components help ci'); + } + + /** + * Handle ci init subcommand. + * + * @param array $options CLI options + * @param Output $output Output handler + * @return bool True if successful + */ + private function handleInit(array $options, Output $output): bool + { + $componentPath = $options['local-path'] ?? getcwd(); + $mode = $options['ci-mode'] ?? 'github'; + $force = isset($options['force']) && $options['force']; + $dryRun = isset($options['dry-run']) && $options['dry-run']; + + $initCommand = new InitCommand($output); + return $initCommand->execute($componentPath, $mode, $force, $dryRun); + } + + /** + * Handle ci check subcommand. + * + * @param array $options CLI options + * @param Output $output Output handler + * @return bool True if files are up to date + */ + private function handleCheck(array $options, Output $output): bool + { + $componentPath = $options['local-path'] ?? getcwd(); + + $initCommand = new InitCommand($output); + return $initCommand->check($componentPath); + } + + /** + * Handle ci setup subcommand. + * + * @param array $options CLI options + * @param Output $output Output handler + * @return bool True if successful + */ + private function handleSetup(array $options, Output $output): bool + { + // Build configuration + $configArray = EnvironmentDetector::buildConfig( + $options['ci-mode'] ?? null, + $options['local-path'] ?? null + ); + + // Override with CLI options + if (isset($options['work-dir'])) { + $configArray['work_dir'] = $options['work-dir']; + } + if (isset($options['component'])) { + $configArray['component_name'] = $options['component']; + } + + $config = new CiConfig($configArray); + + // Create setup command + $setupCommand = new SetupCommand( + $output, + new PhpInstaller($output), + new ExtensionInstaller($output), + new LaneCopier($output), + new ComposerInstaller($output) + ); + + return $setupCommand->execute($config); + } +} From 1153e4faea57828b697576d6a959ab5ee056c605 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Tue, 3 Mar 2026 10:57:49 +0100 Subject: [PATCH 06/17] feat(ci): register Ci module in ModuleProvider Add Ci module to ModuleProvider so it's loaded and available via CLI. Changes: - Import Horde\Components\Module\Ci - Add Ci module to getModules() array (alphabetically after Change) Commands now work: - horde-components ci - horde-components ci init - horde-components ci check - horde-components ci setup - horde-components help ci Verified working with manual tests. Completes Phase 1 CLI integration. --- src/Cli/ModuleProvider.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cli/ModuleProvider.php b/src/Cli/ModuleProvider.php index e9302bbe..1ed6a599 100644 --- a/src/Cli/ModuleProvider.php +++ b/src/Cli/ModuleProvider.php @@ -23,6 +23,7 @@ use Horde\Components\Module\Release; use Horde\Components\Module\Status; use Horde\Components\Module\Website; +use Horde\Components\Module\Ci; /** * Components tool specific, context aware module provider @@ -50,6 +51,7 @@ public function getModules(): Modules { return new Modules([ $this->injector->get(Change::class), + $this->injector->get(Ci::class), $this->injector->get(ConfigModule::class), $this->injector->get(Composer::class), $this->injector->get(Git::class), From 65f146006e0157b693d392c03e2042c1b3546193 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Tue, 3 Mar 2026 11:32:30 +0100 Subject: [PATCH 07/17] refactor(ci): integrate SudoHelper for all privileged operations Update PhpInstaller and ExtensionInstaller to use the centralized SudoHelper class for all operations requiring root privileges. Changes: - Add SudoHelper.php with static methods for sudo operations - Update PhpInstaller to use SudoHelper::addPpa(), installPhp(), isPhpInstalled() - Update ExtensionInstaller to use SudoHelper::installExtension() - Simplify hasSudo() check using SudoHelper::canRunPasswordless() - Remove direct sudo command execution from both classes All privileged operations now go through /usr/local/bin/horde-ci-sudo-helper which validates all arguments and provides a single sudo entry point. --- src/Ci/Setup/ExtensionInstaller.php | 16 +-- src/Ci/Setup/PhpInstaller.php | 53 ++-------- src/Ci/Setup/SudoHelper.php | 159 ++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 58 deletions(-) create mode 100644 src/Ci/Setup/SudoHelper.php diff --git a/src/Ci/Setup/ExtensionInstaller.php b/src/Ci/Setup/ExtensionInstaller.php index f60eb85f..7da1b983 100644 --- a/src/Ci/Setup/ExtensionInstaller.php +++ b/src/Ci/Setup/ExtensionInstaller.php @@ -223,20 +223,8 @@ private function installExtension(string $extension, string $phpVersion): ?bool return null; // Skip } - // Try to install - $command = 'sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ' . escapeshellarg($package) . ' 2>&1'; - $output = []; - $exitCode = 0; - exec($command, $output, $exitCode); - - if ($exitCode !== 0) { - // Check if package doesn't exist (expected for some extensions) - $outputStr = implode("\n", $output); - if (strpos($outputStr, 'Unable to locate package') !== false) { - $this->output->plain("Package {$package} not available (may be built-in)"); - return null; // Skip - } - + // Try to install using sudo helper + if (!SudoHelper::installExtension($phpVersion, $extension)) { $this->output->warn("Failed to install {$package}"); return false; } diff --git a/src/Ci/Setup/PhpInstaller.php b/src/Ci/Setup/PhpInstaller.php index ff244aa0..e45bec5c 100644 --- a/src/Ci/Setup/PhpInstaller.php +++ b/src/Ci/Setup/PhpInstaller.php @@ -127,9 +127,8 @@ private function hasSudo(): bool return true; } - // Check if sudo is available and we can use it - $result = shell_exec('sudo -n true 2>&1'); - return $result !== null && strpos($result, 'password') === false; + // Check if sudo helper is available and can run passwordless + return SudoHelper::isAvailable() && SudoHelper::canRunPasswordless(); } /** @@ -155,24 +154,7 @@ private function isPpaAdded(): bool */ private function addPpa(): bool { - $commands = [ - 'sudo apt-get install -y software-properties-common', - 'sudo add-apt-repository -y ' . escapeshellarg(self::ONDREJ_PPA), - ]; - - foreach ($commands as $command) { - $output = []; - $exitCode = 0; - exec($command . ' 2>&1', $output, $exitCode); - - if ($exitCode !== 0) { - $this->output->error('Command failed: ' . $command); - $this->output->plain(implode("\n", $output)); - return false; - } - } - - return true; + return SudoHelper::addPpa(); } /** @@ -182,16 +164,8 @@ private function addPpa(): bool */ private function updatePackageList(): bool { - $output = []; - $exitCode = 0; - exec('sudo apt-get update 2>&1', $output, $exitCode); - - if ($exitCode !== 0) { - $this->output->error('apt-get update failed'); - $this->output->plain(implode("\n", $output)); - return false; - } - + // The sudo helper's add-ppa command already runs apt-get update + // So this method is no longer needed when using SudoHelper return true; } @@ -203,8 +177,7 @@ private function updatePackageList(): bool */ private function isPhpVersionInstalled(string $version): bool { - $binary = "/usr/bin/php{$version}"; - return file_exists($binary) && is_executable($binary); + return SudoHelper::isPhpInstalled($version); } /** @@ -215,17 +188,9 @@ private function isPhpVersionInstalled(string $version): bool */ private function installPhpVersion(string $version): bool { - // Install CLI package - $package = "php{$version}-cli"; - $command = 'sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ' . escapeshellarg($package); - - $output = []; - $exitCode = 0; - exec($command . ' 2>&1', $output, $exitCode); - - if ($exitCode !== 0) { - $this->output->error("Failed to install {$package}"); - $this->output->plain(implode("\n", $output)); + // Use sudo helper to install PHP + if (!SudoHelper::installPhp($version)) { + $this->output->error("Failed to install PHP {$version}"); return false; } diff --git a/src/Ci/Setup/SudoHelper.php b/src/Ci/Setup/SudoHelper.php new file mode 100644 index 00000000..b8591065 --- /dev/null +++ b/src/Ci/Setup/SudoHelper.php @@ -0,0 +1,159 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Ci\Setup; + +use Horde\Components\Exception; + +/** + * Helper for executing privileged operations via sudo helper script. + * + * Uses /usr/local/bin/horde-ci-sudo-helper for all operations requiring root. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class SudoHelper +{ + /** + * Path to sudo helper script. + */ + private const HELPER_SCRIPT = '/usr/local/bin/horde-ci-sudo-helper'; + + /** + * Check if sudo helper is available. + * + * @return bool + */ + public static function isAvailable(): bool + { + return file_exists(self::HELPER_SCRIPT) && is_executable(self::HELPER_SCRIPT); + } + + /** + * Check if we can run sudo helper without password. + * + * @return bool + */ + public static function canRunPasswordless(): bool + { + if (!self::isAvailable()) { + return false; + } + + $result = shell_exec('sudo -n ' . escapeshellarg(self::HELPER_SCRIPT) . ' check-php 8.4 2>&1'); + return $result !== null && !str_contains($result, 'password'); + } + + /** + * Add ondrej PPA. + * + * @return bool True if successful + * @throws Exception If helper not available + */ + public static function addPpa(): bool + { + if (!self::isAvailable()) { + throw new Exception('Sudo helper not found: ' . self::HELPER_SCRIPT); + } + + $command = 'sudo ' . escapeshellarg(self::HELPER_SCRIPT) . ' add-ppa 2>&1'; + $output = []; + $exitCode = 0; + exec($command, $output, $exitCode); + + return $exitCode === 0; + } + + /** + * Install PHP version. + * + * @param string $version PHP version (e.g., '8.4') + * @return bool True if successful + * @throws Exception If helper not available + */ + public static function installPhp(string $version): bool + { + if (!self::isAvailable()) { + throw new Exception('Sudo helper not found: ' . self::HELPER_SCRIPT); + } + + $command = sprintf( + 'sudo %s install-php %s 2>&1', + escapeshellarg(self::HELPER_SCRIPT), + escapeshellarg($version) + ); + + $output = []; + $exitCode = 0; + exec($command, $output, $exitCode); + + return $exitCode === 0; + } + + /** + * Install PHP extension. + * + * @param string $phpVersion PHP version (e.g., '8.4') + * @param string $extension Extension name (e.g., 'curl') + * @return bool True if successful + * @throws Exception If helper not available + */ + public static function installExtension(string $phpVersion, string $extension): bool + { + if (!self::isAvailable()) { + throw new Exception('Sudo helper not found: ' . self::HELPER_SCRIPT); + } + + $command = sprintf( + 'sudo %s install-extension %s %s 2>&1', + escapeshellarg(self::HELPER_SCRIPT), + escapeshellarg($phpVersion), + escapeshellarg($extension) + ); + + $output = []; + $exitCode = 0; + exec($command, $output, $exitCode); + + return $exitCode === 0; + } + + /** + * Check if PHP version is installed. + * + * @param string $version PHP version (e.g., '8.4') + * @return bool + */ + public static function isPhpInstalled(string $version): bool + { + if (!self::isAvailable()) { + // Fall back to direct check + return file_exists("/usr/bin/php{$version}"); + } + + $command = sprintf( + 'sudo %s check-php %s 2>&1', + escapeshellarg(self::HELPER_SCRIPT), + escapeshellarg($version) + ); + + $result = shell_exec($command); + return $result !== null && trim($result) === 'installed'; + } +} From 2d4eb8a8f9399d38a0be8aa1b0a44356e918f7c4 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Tue, 3 Mar 2026 12:37:00 +0100 Subject: [PATCH 08/17] fix(ci): preserve empty objects in composer.json during stability modification When json_decode() converts JSON to arrays, empty objects {} become empty arrays []. This causes composer schema validation to fail for fields like allow-plugins which must be object or boolean, not array. Fix by adding regex replacement to convert "allow-plugins": [] back to "allow-plugins": {} after json_encode(). Also fix timeout command syntax - wrap in bash -c so timeout can work with shell constructs like cd && command. Fixes composer install failures in all test lanes. --- src/Ci/Setup/ComposerInstaller.php | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/Ci/Setup/ComposerInstaller.php b/src/Ci/Setup/ComposerInstaller.php index 46897162..390dad91 100644 --- a/src/Ci/Setup/ComposerInstaller.php +++ b/src/Ci/Setup/ComposerInstaller.php @@ -121,8 +121,22 @@ private function setMinimumStability(string $composerFile, string $stability): b // Set minimum-stability $data['minimum-stability'] = $stability; - // Write back with pretty print + // Write back with pretty print and JSON_FORCE_OBJECT to preserve empty objects + $newContent = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_FORCE_OBJECT); + + // Fix: JSON_FORCE_OBJECT makes everything an object, so we need to fix arrays + // Better approach: preserve original structure by not using JSON_FORCE_OBJECT + // and instead fix empty arrays manually $newContent = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + // Fix empty arrays that should be objects (like allow-plugins) + // Replace "allow-plugins": [] with "allow-plugins": {} + $newContent = preg_replace( + '/"allow-plugins":\s*\[\s*\]/', + '"allow-plugins": {}', + $newContent + ); + if (file_put_contents($composerFile, $newContent) === false) { throw new Exception("Failed to write {$composerFile}"); } @@ -142,16 +156,20 @@ private function runComposerInstall(string $laneDir, string $phpBinary): array // Find composer binary $composer = $this->findComposer(); - // Build command - $command = sprintf( + // Build command - wrap in bash -c for timeout to work with cd && + $innerCommand = sprintf( 'cd %s && %s %s install --no-interaction --no-progress --prefer-dist 2>&1', escapeshellarg($laneDir), escapeshellarg($phpBinary), escapeshellarg($composer) ); - // Add timeout - $command = "timeout " . self::TIMEOUT . " " . $command; + // Wrap with timeout and bash + $command = sprintf( + 'timeout %d bash -c %s', + self::TIMEOUT, + escapeshellarg($innerCommand) + ); // Execute $output = []; From 0cea255bf2d9ab1162821d61a47f6337317df1e1 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Tue, 3 Mar 2026 12:37:21 +0100 Subject: [PATCH 09/17] docs(ci): add TODO for JSON empty object/array handling Document the need to improve JSON handling in ComposerInstaller. Current regex workaround works but a proper solution using stdClass or a JSON library would be more robust for Phase 2 and beyond. --- doc/TODO.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/TODO.rst b/doc/TODO.rst index 0fd866ec..449f758a 100644 --- a/doc/TODO.rst +++ b/doc/TODO.rst @@ -2,6 +2,12 @@ Components TODO List ====================== + - CI Setup (Phase 2): + - Improve JSON handling in ComposerInstaller to properly preserve empty objects vs arrays + (currently using regex workaround for allow-plugins field) + - Consider using stdClass for JSON manipulation or a proper JSON library + - Test with other composer.json structures that may have similar empty object fields + - Installation: - Ensure that PEAR gets all required dependencies installed (probably a package.xml v1 problem). - Ensure Horde_Role gets installed. From aa75275d01e785d08e3ae44224dc4e33763bcd05 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Tue, 3 Mar 2026 13:05:24 +0100 Subject: [PATCH 10/17] refactor(ci): use stdClass for JSON manipulation in ComposerInstaller Replace array-based JSON manipulation with stdClass objects to properly preserve empty objects as {} instead of []. This matches the approach used in Helper/Composer.php and avoids the need for regex workarounds. Changes: - Use json_decode($content, false) to preserve objects - Use $data->{'minimum-stability'} for property assignment - Remove regex workaround for allow-plugins field - Empty objects are now naturally preserved as {} in JSON output Tested with composer validate - schema is now valid without workarounds. --- doc/TODO.rst | 6 ------ src/Ci/Setup/ComposerInstaller.php | 26 +++++++++----------------- 2 files changed, 9 insertions(+), 23 deletions(-) diff --git a/doc/TODO.rst b/doc/TODO.rst index 449f758a..0fd866ec 100644 --- a/doc/TODO.rst +++ b/doc/TODO.rst @@ -2,12 +2,6 @@ Components TODO List ====================== - - CI Setup (Phase 2): - - Improve JSON handling in ComposerInstaller to properly preserve empty objects vs arrays - (currently using regex workaround for allow-plugins field) - - Consider using stdClass for JSON manipulation or a proper JSON library - - Test with other composer.json structures that may have similar empty object fields - - Installation: - Ensure that PEAR gets all required dependencies installed (probably a package.xml v1 problem). - Ensure Horde_Role gets installed. diff --git a/src/Ci/Setup/ComposerInstaller.php b/src/Ci/Setup/ComposerInstaller.php index 390dad91..ee212e2a 100644 --- a/src/Ci/Setup/ComposerInstaller.php +++ b/src/Ci/Setup/ComposerInstaller.php @@ -101,6 +101,9 @@ public function install(string $laneDir, string $phpBinary, string $stability): /** * Set minimum-stability in composer.json. * + * Uses stdClass objects to preserve empty objects as {} instead of []. + * This matches the approach used in Helper/Composer.php. + * * @param string $composerFile Path to composer.json * @param string $stability Minimum stability * @return bool True if successful @@ -113,30 +116,19 @@ private function setMinimumStability(string $composerFile, string $stability): b throw new Exception("Failed to read {$composerFile}"); } - $data = json_decode($content, true); - if (!is_array($data)) { + // Decode as objects (stdClass) to preserve empty objects + $data = json_decode($content, false); + if ($data === null) { throw new Exception("Invalid JSON in {$composerFile}"); } // Set minimum-stability - $data['minimum-stability'] = $stability; + $data->{'minimum-stability'} = $stability; - // Write back with pretty print and JSON_FORCE_OBJECT to preserve empty objects - $newContent = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_FORCE_OBJECT); - - // Fix: JSON_FORCE_OBJECT makes everything an object, so we need to fix arrays - // Better approach: preserve original structure by not using JSON_FORCE_OBJECT - // and instead fix empty arrays manually + // Write back with pretty print + // Empty stdClass objects will be encoded as {} instead of [] $newContent = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - // Fix empty arrays that should be objects (like allow-plugins) - // Replace "allow-plugins": [] with "allow-plugins": {} - $newContent = preg_replace( - '/"allow-plugins":\s*\[\s*\]/', - '"allow-plugins": {}', - $newContent - ); - if (file_put_contents($composerFile, $newContent) === false) { throw new Exception("Failed to write {$composerFile}"); } From c9631229b4cecdb79cdfb5074548010ec93d873d Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Tue, 3 Mar 2026 13:21:23 +0100 Subject: [PATCH 11/17] refactor(ci): use Horde\Composer\ComposerJsonFile for JSON manipulation Replace manual stdClass JSON manipulation with Horde\Composer\ComposerJsonFile library which provides a cleaner, more robust API. Benefits: - Uses existing Horde library (already a dependency) - Proper validation and error handling built-in - Cleaner API: setMinimumStability() + save() - Reduces code from 23 lines to 8 lines - Maintains correct empty object handling The ComposerJsonFile class internally uses stdClass and handles all the JSON encoding/decoding details correctly. --- src/Ci/Setup/ComposerInstaller.php | 33 ++++++++---------------------- 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/src/Ci/Setup/ComposerInstaller.php b/src/Ci/Setup/ComposerInstaller.php index ee212e2a..f4053e42 100644 --- a/src/Ci/Setup/ComposerInstaller.php +++ b/src/Ci/Setup/ComposerInstaller.php @@ -18,6 +18,7 @@ use Horde\Components\Exception; use Horde\Components\Output; +use Horde\Composer\ComposerJsonFile; /** * Runs composer install for test lanes with stability control. @@ -101,8 +102,7 @@ public function install(string $laneDir, string $phpBinary, string $stability): /** * Set minimum-stability in composer.json. * - * Uses stdClass objects to preserve empty objects as {} instead of []. - * This matches the approach used in Helper/Composer.php. + * Uses Horde\Composer\ComposerJsonFile for proper JSON manipulation. * * @param string $composerFile Path to composer.json * @param string $stability Minimum stability @@ -111,29 +111,14 @@ public function install(string $laneDir, string $phpBinary, string $stability): */ private function setMinimumStability(string $composerFile, string $stability): bool { - $content = file_get_contents($composerFile); - if ($content === false) { - throw new Exception("Failed to read {$composerFile}"); - } - - // Decode as objects (stdClass) to preserve empty objects - $data = json_decode($content, false); - if ($data === null) { - throw new Exception("Invalid JSON in {$composerFile}"); + try { + $composer = new ComposerJsonFile($composerFile); + $composer->setMinimumStability($stability); + $composer->save(); + return true; + } catch (\Exception $e) { + throw new Exception("Failed to set minimum-stability in {$composerFile}: " . $e->getMessage()); } - - // Set minimum-stability - $data->{'minimum-stability'} = $stability; - - // Write back with pretty print - // Empty stdClass objects will be encoded as {} instead of [] - $newContent = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - - if (file_put_contents($composerFile, $newContent) === false) { - throw new Exception("Failed to write {$composerFile}"); - } - - return true; } /** From 1ba20287457b5d3a3ca2b04f4d460cfbf559e515 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Tue, 3 Mar 2026 13:41:14 +0100 Subject: [PATCH 12/17] feat(ci): implement CI run command (Phase 2) Add ci run subcommand that executes tests across all lanes using the existing QC system via self-invocation. This approach reuses all existing test runners (PHPUnit, PHPStan, PHP CS Fixer) and reads structured JSON results. Architecture: - RunCommand orchestrates test execution across lanes - Each lane invokes: php8.X horde-components qc --unit --phpstan - ResultCollector aggregates JSON from build/ directories - Returns exit code: 0 if all passed, 1 if any failed Key files: - src/Ci/Run/RunCommand.php - Orchestrates QC execution per lane - src/Ci/Run/ResultCollector.php - Aggregates JSON results - src/Module/Ci.php - Added handleRun() method Deleted redundant files (saved ~450 lines): - src/Ci/Run/LaneRunner.php - Duplicated QC system - src/Ci/Run/TestResult.php - Over-engineered Benefits: - 100% reuse of existing QC infrastructure - Consistent with manual QC runs - Clean separation: QC executes, CI orchestrates - Structured JSON enables rich reporting - Easy to add parallelism in future Usage: horde-components ci run --work-dir=/tmp/horde-ci See ~/horde-development/components-ci-phase2-implementation-complete.md --- src/Ci/Run/ResultCollector.php | 308 +++++++++++++++++++++++++++++++++ src/Ci/Run/RunCommand.php | 219 +++++++++++++++++++++++ src/Module/Ci.php | 44 ++++- 3 files changed, 568 insertions(+), 3 deletions(-) create mode 100644 src/Ci/Run/ResultCollector.php create mode 100644 src/Ci/Run/RunCommand.php diff --git a/src/Ci/Run/ResultCollector.php b/src/Ci/Run/ResultCollector.php new file mode 100644 index 00000000..731e72e8 --- /dev/null +++ b/src/Ci/Run/ResultCollector.php @@ -0,0 +1,308 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Ci\Run; + +use Horde\Components\Output; + +/** + * Collects and aggregates test results from QC JSON output files. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class ResultCollector +{ + /** + * Results by lane and tool. + * + * Structure: [lane_name => [tool => [success, exit_code, statistics, ...]]] + * + * @var array>> + */ + private array $results = []; + + /** + * Constructor. + * + * @param Output $output Output handler + */ + public function __construct( + private readonly Output $output + ) {} + + /** + * Add result from a QC JSON file. + * + * @param string $laneName Lane name (e.g., "php8.4-dev") + * @param string $tool Tool name ("phpunit", "phpstan", "phpcsfixer") + * @param string $jsonFile Path to JSON result file + */ + public function addResultFromFile(string $laneName, string $tool, string $jsonFile): void + { + if (!file_exists($jsonFile)) { + $this->results[$laneName][$tool] = [ + 'success' => false, + 'exit_code' => 1, + 'error' => 'Result file not found', + ]; + return; + } + + $content = file_get_contents($jsonFile); + if ($content === false) { + $this->results[$laneName][$tool] = [ + 'success' => false, + 'exit_code' => 1, + 'error' => 'Failed to read result file', + ]; + return; + } + + $data = json_decode($content, true); + if ($data === null) { + $this->results[$laneName][$tool] = [ + 'success' => false, + 'exit_code' => 1, + 'error' => 'Invalid JSON in result file', + ]; + return; + } + + $this->results[$laneName][$tool] = [ + 'success' => $data['success'] ?? false, + 'exit_code' => $data['exit_code'] ?? 1, + 'statistics' => $data['statistics'] ?? [], + 'version' => $this->extractVersion($tool, $data), + ]; + } + + /** + * Add result for missing/skipped test. + * + * @param string $laneName Lane name + * @param string $tool Tool name + * @param string $reason Reason for skipping + */ + public function addSkipped(string $laneName, string $tool, string $reason): void + { + $this->results[$laneName][$tool] = [ + 'success' => true, + 'exit_code' => 0, + 'skipped' => true, + 'reason' => $reason, + ]; + } + + /** + * Extract tool version from result data. + * + * @param string $tool Tool name + * @param array $data Result data + * @return string Tool version + */ + private function extractVersion(string $tool, array $data): string + { + $versionKeys = [ + 'phpunit' => 'phpunit_version', + 'phpstan' => 'phpstan_version', + 'phpcsfixer' => 'php_cs_fixer_version', + ]; + + $key = $versionKeys[$tool] ?? "{$tool}_version"; + return $data[$key] ?? 'unknown'; + } + + /** + * Display summary to output. + */ + public function displaySummary(): void + { + $this->output->bold("\n=== Test Results ===\n"); + + // Display results for each lane + foreach ($this->results as $laneName => $tools) { + $this->output->info("\n{$laneName}:"); + + foreach ($tools as $tool => $result) { + $this->displayToolResult($tool, $result); + } + } + + // Display summary + $summary = $this->getSummary(); + $this->output->bold("\n=== Summary ==="); + + if ($summary['passed'] > 0) { + $this->output->ok("✅ Passed: {$summary['passed']}/{$summary['total']} lanes"); + } + + if ($summary['failed'] > 0) { + $this->output->fail("❌ Failed: {$summary['failed']}/{$summary['total']} lanes"); + foreach ($summary['failed_lanes'] as $laneName) { + $this->output->plain(" - {$laneName}"); + } + } + + if ($summary['failed'] === 0) { + $this->output->ok("\n✨ All lanes passed!"); + } + } + + /** + * Display result for a single tool. + * + * @param string $tool Tool name + * @param array $result Result data + */ + private function displayToolResult(string $tool, array $result): void + { + $toolName = ucfirst($tool); + + // Handle skipped + if (isset($result['skipped']) && $result['skipped']) { + $this->output->warn(" ⊘ {$toolName}: Skipped ({$result['reason']})"); + return; + } + + // Handle errors + if (isset($result['error'])) { + $this->output->fail(" ✗ {$toolName}: {$result['error']}"); + return; + } + + // Format statistics + $stats = $this->formatStats($tool, $result['statistics'] ?? []); + $statsStr = $stats ? " ({$stats})" : ''; + + if ($result['success']) { + $this->output->ok(" ✓ {$toolName}: Passed{$statsStr}"); + } else { + $this->output->fail(" ✗ {$toolName}: FAILED{$statsStr}"); + } + } + + /** + * Format statistics for display. + * + * @param string $tool Tool name + * @param array $stats Statistics array + * @return string Formatted string + */ + private function formatStats(string $tool, array $stats): string + { + if (empty($stats)) { + return ''; + } + + switch ($tool) { + case 'phpunit': + $parts = []; + if (isset($stats['tests'])) { + $parts[] = "{$stats['tests']} tests"; + } + if (isset($stats['assertions'])) { + $parts[] = "{$stats['assertions']} assertions"; + } + if (isset($stats['failures']) && $stats['failures'] > 0) { + $parts[] = "{$stats['failures']} failures"; + } + if (isset($stats['errors']) && $stats['errors'] > 0) { + $parts[] = "{$stats['errors']} errors"; + } + return implode(', ', $parts); + + case 'phpstan': + $parts = []; + if (isset($stats['files_analyzed'])) { + $parts[] = "{$stats['files_analyzed']} files"; + } + if (isset($stats['errors'])) { + $parts[] = "{$stats['errors']} errors"; + } + return implode(', ', $parts); + + case 'phpcsfixer': + $parts = []; + if (isset($stats['files_checked'])) { + $parts[] = "{$stats['files_checked']} files"; + } + if (isset($stats['files_with_issues'])) { + $parts[] = "{$stats['files_with_issues']} issues"; + } + return implode(', ', $parts); + + default: + return ''; + } + } + + /** + * Get summary statistics. + * + * @return array{passed: int, failed: int, total: int, failed_lanes: array} + */ + public function getSummary(): array + { + $passed = 0; + $failed = 0; + $failedLanes = []; + + foreach ($this->results as $laneName => $tools) { + $lanePassed = true; + + foreach ($tools as $tool => $result) { + // Skipped is not a failure + if (isset($result['skipped']) && $result['skipped']) { + continue; + } + + // Check for failure + if (!$result['success']) { + $lanePassed = false; + break; + } + } + + if ($lanePassed) { + $passed++; + } else { + $failed++; + $failedLanes[] = $laneName; + } + } + + return [ + 'passed' => $passed, + 'failed' => $failed, + 'total' => count($this->results), + 'failed_lanes' => $failedLanes, + ]; + } + + /** + * Check if all lanes passed. + * + * @return bool + */ + public function allPassed(): bool + { + $summary = $this->getSummary(); + return $summary['failed'] === 0; + } +} diff --git a/src/Ci/Run/RunCommand.php b/src/Ci/Run/RunCommand.php new file mode 100644 index 00000000..7facf549 --- /dev/null +++ b/src/Ci/Run/RunCommand.php @@ -0,0 +1,219 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Ci\Run; + +use Horde\Components\Exception; +use Horde\Components\Output; + +/** + * Orchestrates test execution across all test lanes using QC system. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class RunCommand +{ + /** + * Constructor. + * + * @param Output $output Output handler + * @param ResultCollector $collector Result collector + * @param string $componentsPath Path to horde-components binary + */ + public function __construct( + private readonly Output $output, + private readonly ResultCollector $collector, + private readonly string $componentsPath + ) {} + + /** + * Execute tests across all lanes. + * + * @param string $workDir Work directory containing lanes + * @return int Exit code (0 = success, 1 = failures) + * @throws Exception If work directory invalid + */ + public function execute(string $workDir): int + { + $this->output->bold("=== Horde CI Run ==="); + + // 1. Validate work directory + if (!is_dir($workDir)) { + throw new Exception("Work directory does not exist: {$workDir}"); + } + + $lanesDir = $workDir . '/lanes'; + if (!is_dir($lanesDir)) { + throw new Exception("Lanes directory not found: {$lanesDir}"); + } + + // 2. Discover lanes + $lanes = $this->discoverLanes($lanesDir); + + if (empty($lanes)) { + throw new Exception("No test lanes found in: {$lanesDir}"); + } + + $this->output->info("Found " . count($lanes) . " test lanes"); + $this->output->plain(''); + + // 3. Run QC in each lane + foreach ($lanes as $lane) { + $this->runQcInLane($lane); + } + + // 4. Aggregate results from JSON files + $this->aggregateResults($lanes); + + // 5. Display summary + $this->collector->displaySummary(); + + // 6. Return exit code + return $this->collector->allPassed() ? 0 : 1; + } + + /** + * Discover test lanes in work directory. + * + * @param string $lanesDir Lanes directory + * @return array Lane info + */ + private function discoverLanes(string $lanesDir): array + { + $lanes = []; + $dirs = glob($lanesDir . '/php*', GLOB_ONLYDIR); + + if ($dirs === false) { + return []; + } + + foreach ($dirs as $dir) { + $name = basename($dir); + + // Parse lane name: "php8.4-dev" -> php_version="8.4", stability="dev" + if (!preg_match('/^php(\d+\.\d+)-(dev|stable)$/', $name, $matches)) { + continue; // Skip invalid lane names + } + + $lanes[] = [ + 'name' => $name, + 'dir' => $dir, + 'php_version' => $matches[1], + 'stability' => $matches[2], + ]; + } + + // Sort by PHP version then stability + usort($lanes, function ($a, $b) { + $cmp = version_compare($a['php_version'], $b['php_version']); + if ($cmp !== 0) { + return $cmp; + } + // dev before stable + return $a['stability'] === 'dev' ? -1 : 1; + }); + + return $lanes; + } + + /** + * Run QC in a single lane using self-invocation. + * + * @param array{name: string, dir: string, php_version: string, stability: string} $lane Lane info + */ + private function runQcInLane(array $lane): void + { + $this->output->info("[{$lane['name']}] Running QC..."); + + $phpBinary = "/usr/bin/php{$lane['php_version']}"; + + // Check if PHP binary exists + if (!file_exists($phpBinary)) { + $this->output->warn("[{$lane['name']}] PHP binary not found: {$phpBinary}"); + $this->collector->addSkipped($lane['name'], 'phpunit', "PHP {$lane['php_version']} not installed"); + $this->collector->addSkipped($lane['name'], 'phpstan', "PHP {$lane['php_version']} not installed"); + return; + } + + // Determine QC tasks for this lane + $tasks = '--unit --phpstan'; + if ($lane['name'] === 'php8.4-dev') { + $tasks .= ' --phpcsfixer'; + } + + // Build command to invoke horde-components qc with specific PHP version + $command = sprintf( + 'cd %s && %s %s qc %s 2>&1', + escapeshellarg($lane['dir']), + escapeshellarg($phpBinary), + escapeshellarg($this->componentsPath), + $tasks + ); + + // Execute QC + exec($command, $output, $exitCode); + + if ($exitCode !== 0) { + $this->output->warn("[{$lane['name']}] QC exited with code: {$exitCode}"); + } else { + $this->output->ok("[{$lane['name']}] QC completed"); + } + } + + /** + * Aggregate results from JSON files written by QC. + * + * @param array $lanes Lane info + */ + private function aggregateResults(array $lanes): void + { + $this->output->plain(''); + $this->output->info('Aggregating results...'); + + foreach ($lanes as $lane) { + $buildDir = $lane['dir'] . '/build'; + + // Read PHPUnit results + $phpunitFile = $buildDir . '/phpunit-results-summary.json'; + if (file_exists($phpunitFile)) { + $this->collector->addResultFromFile($lane['name'], 'phpunit', $phpunitFile); + } else { + $this->collector->addSkipped($lane['name'], 'phpunit', 'No result file found'); + } + + // Read PHPStan results + $phpstanFile = $buildDir . '/phpstan-results.json'; + if (file_exists($phpstanFile)) { + $this->collector->addResultFromFile($lane['name'], 'phpstan', $phpstanFile); + } else { + $this->collector->addSkipped($lane['name'], 'phpstan', 'No result file found'); + } + + // Read PHP CS Fixer results (only php8.4-dev) + if ($lane['name'] === 'php8.4-dev') { + $csFixerFile = $buildDir . '/php-cs-fixer-results.json'; + if (file_exists($csFixerFile)) { + $this->collector->addResultFromFile($lane['name'], 'phpcsfixer', $csFixerFile); + } else { + $this->collector->addSkipped($lane['name'], 'phpcsfixer', 'No result file found'); + } + } + } + } +} diff --git a/src/Module/Ci.php b/src/Module/Ci.php index 841ae5ab..48d1de23 100644 --- a/src/Module/Ci.php +++ b/src/Module/Ci.php @@ -26,6 +26,8 @@ use Horde\Components\Ci\Setup\ComposerInstaller; use Horde\Components\Ci\Init\InitCommand; use Horde\Components\Ci\Config\CiConfig; +use Horde\Components\Ci\Run\RunCommand; +use Horde\Components\Ci\Run\ResultCollector; /** * Components_Module_Ci:: manages CI setup and execution for components. @@ -363,9 +365,7 @@ public function handle(array $options, array $arguments, ?Component $component = return $this->handleSetup($options, $output); case 'run': - $output->warn('CI run is not yet implemented (Phase 2)'); - $output->info('Current phase: Phase 1 - Setup only'); - return false; + return $this->handleRun($options, $output); default: $output->error("Unknown subcommand: {$subcommand}"); @@ -466,4 +466,42 @@ private function handleSetup(array $options, Output $output): bool return $setupCommand->execute($config); } + + /** + * Handle ci run subcommand. + * + * @param array $options CLI options + * @param Output $output Output handler + * @return bool True if successful + */ + private function handleRun(array $options, Output $output): bool + { + // Get work directory + $workDir = $options['work-dir'] ?? '/tmp/horde-ci'; + + if (!is_dir($workDir)) { + $output->fail("Work directory does not exist: {$workDir}"); + $output->info("Run 'horde-components ci setup' first to prepare test lanes."); + return false; + } + + // Determine horde-components path + $componentsPath = realpath(__DIR__ . '/../../bin/horde-components'); + if ($componentsPath === false) { + $output->fail("Could not locate horde-components binary"); + return false; + } + + // Create RunCommand with dependencies + $collector = new ResultCollector($output); + $runCommand = new RunCommand($output, $collector, $componentsPath); + + try { + $exitCode = $runCommand->execute($workDir); + return $exitCode === 0; + } catch (Exception $e) { + $output->fail("CI run failed: " . $e->getMessage()); + return false; + } + } } From ae3f5196b6175f10e0dabfdbd9bba0ce575ba0c8 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Tue, 3 Mar 2026 13:45:20 +0100 Subject: [PATCH 13/17] fix(ci): correct QC command syntax and path handling Fix Phase 2 issues discovered during testing: 1. QC command syntax: Use positional arguments (qc unit phpstan) not options 2. Component directory: Lanes have subdirectory structure (lane/Http/) - use component_dir 3. Output methods: Use error() not fail() to avoid fatal termination 4. Option parsing: Horde_Argv converts dashes to underscores (work_dir not work-dir) Changes: - RunCommand: Discover component_dir subdirectory in each lane - RunCommand: Use correct QC syntax (qc unit phpstan phpcsfixer) - RunCommand: cd to component_dir not lane dir when invoking QC - ResultCollector: Use output->error() instead of output->fail() - Module/Ci: Use $options['work_dir'] not ['work-dir'] Known issue: Lanes need composer install --dev to get test dependencies (PHPUnit, PHPStan). Will be fixed in Phase 1 ComposerInstaller. --- src/Ci/Run/ResultCollector.php | 6 +++--- src/Ci/Run/RunCommand.php | 22 +++++++++++++++------- src/Module/Ci.php | 4 ++-- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/Ci/Run/ResultCollector.php b/src/Ci/Run/ResultCollector.php index 731e72e8..6dadf76f 100644 --- a/src/Ci/Run/ResultCollector.php +++ b/src/Ci/Run/ResultCollector.php @@ -153,7 +153,7 @@ public function displaySummary(): void } if ($summary['failed'] > 0) { - $this->output->fail("❌ Failed: {$summary['failed']}/{$summary['total']} lanes"); + $this->output->error("❌ Failed: {$summary['failed']}/{$summary['total']} lanes"); foreach ($summary['failed_lanes'] as $laneName) { $this->output->plain(" - {$laneName}"); } @@ -182,7 +182,7 @@ private function displayToolResult(string $tool, array $result): void // Handle errors if (isset($result['error'])) { - $this->output->fail(" ✗ {$toolName}: {$result['error']}"); + $this->output->error(" ✗ {$toolName}: {$result['error']}"); return; } @@ -193,7 +193,7 @@ private function displayToolResult(string $tool, array $result): void if ($result['success']) { $this->output->ok(" ✓ {$toolName}: Passed{$statsStr}"); } else { - $this->output->fail(" ✗ {$toolName}: FAILED{$statsStr}"); + $this->output->error(" ✗ {$toolName}: FAILED{$statsStr}"); } } diff --git a/src/Ci/Run/RunCommand.php b/src/Ci/Run/RunCommand.php index 7facf549..d3fd6db3 100644 --- a/src/Ci/Run/RunCommand.php +++ b/src/Ci/Run/RunCommand.php @@ -92,7 +92,7 @@ public function execute(string $workDir): int * Discover test lanes in work directory. * * @param string $lanesDir Lanes directory - * @return array Lane info + * @return array Lane info */ private function discoverLanes(string $lanesDir): array { @@ -111,9 +111,16 @@ private function discoverLanes(string $lanesDir): array continue; // Skip invalid lane names } + // Find component subdirectory (first directory in lane) + $componentDirs = glob($dir . '/*', GLOB_ONLYDIR); + if (empty($componentDirs)) { + continue; // Skip lanes without component + } + $lanes[] = [ 'name' => $name, 'dir' => $dir, + 'component_dir' => $componentDirs[0], // e.g., /path/lanes/php8.4-dev/Http 'php_version' => $matches[1], 'stability' => $matches[2], ]; @@ -135,7 +142,7 @@ private function discoverLanes(string $lanesDir): array /** * Run QC in a single lane using self-invocation. * - * @param array{name: string, dir: string, php_version: string, stability: string} $lane Lane info + * @param array{name: string, dir: string, component_dir: string, php_version: string, stability: string} $lane Lane info */ private function runQcInLane(array $lane): void { @@ -152,15 +159,16 @@ private function runQcInLane(array $lane): void } // Determine QC tasks for this lane - $tasks = '--unit --phpstan'; + $tasks = 'unit phpstan'; if ($lane['name'] === 'php8.4-dev') { - $tasks .= ' --phpcsfixer'; + $tasks .= ' phpcsfixer'; } // Build command to invoke horde-components qc with specific PHP version + // Note: cd to component_dir (not lane dir) since QC expects to be in component root $command = sprintf( 'cd %s && %s %s qc %s 2>&1', - escapeshellarg($lane['dir']), + escapeshellarg($lane['component_dir']), escapeshellarg($phpBinary), escapeshellarg($this->componentsPath), $tasks @@ -179,7 +187,7 @@ private function runQcInLane(array $lane): void /** * Aggregate results from JSON files written by QC. * - * @param array $lanes Lane info + * @param array $lanes Lane info */ private function aggregateResults(array $lanes): void { @@ -187,7 +195,7 @@ private function aggregateResults(array $lanes): void $this->output->info('Aggregating results...'); foreach ($lanes as $lane) { - $buildDir = $lane['dir'] . '/build'; + $buildDir = $lane['component_dir'] . '/build'; // Read PHPUnit results $phpunitFile = $buildDir . '/phpunit-results-summary.json'; diff --git a/src/Module/Ci.php b/src/Module/Ci.php index 48d1de23..befc43a8 100644 --- a/src/Module/Ci.php +++ b/src/Module/Ci.php @@ -476,8 +476,8 @@ private function handleSetup(array $options, Output $output): bool */ private function handleRun(array $options, Output $output): bool { - // Get work directory - $workDir = $options['work-dir'] ?? '/tmp/horde-ci'; + // Get work directory (Horde_Argv converts dashes to underscores) + $workDir = $options['work_dir'] ?? '/tmp/horde-ci'; if (!is_dir($workDir)) { $output->fail("Work directory does not exist: {$workDir}"); From c6eda0aea17947d8c7a5bd85c371b0de1d3d0265 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Tue, 3 Mar 2026 14:03:21 +0100 Subject: [PATCH 14/17] feat(ci): add tools cache system for QC binaries Implement tools cache architecture to download and cache QC tool PHARs instead of requiring them in component vendor directories. This allows: - Components to not require PHPUnit/PHPStan/PHP-CS-Fixer as dependencies - Version-specific tools per PHP version (PHPUnit 11.5 for 8.2-8.3, 12.5 for 8.4+) - Fast setup without composer installing test tools in every lane - Shared tools across all lanes in both local and GitHub Actions modes Architecture: - ToolCache downloads PHARs to $WORK_DIR/tools/ - SetupCommand runs tool download after composer install - QC tasks accept --tools-dir option - ToolFinder prioritizes tools-dir over other locations - RunCommand passes --tools-dir to QC invocations Changes: - NEW: src/Ci/Setup/ToolCache.php - Download and cache PHAR files - Modified: SetupCommand - Add tool download step - Modified: Module/Ci - Wire up ToolCache, pass workDir to RunCommand - Modified: Module/Qc - Add --tools-dir option - Modified: ToolFinder - Accept tools-dir as highest priority search location - Modified: Qc/Task/Unit - Accept tools_dir from options, pass to ToolFinder - Modified: Qc/Task/Phpstan - Accept tools_dir, use ToolFinder - Modified: Qc/Task/Phpcsfixer - Accept tools_dir, use ToolFinder - Modified: RunCommand - Pass --tools-dir to QC command invocations - Modified: ComposerInstaller - Revert to default composer install (no --dev flag) Tool versions: - PHPUnit: 11.5 for PHP 8.2-8.3, 12.5 for PHP 8.4-8.5 - PHPStan: Latest (works on all PHP versions) - PHP-CS-Fixer: Latest (runs on PHP 8.4 only) Benefits: - Components don't need test tools in composer.json - Third parties can use conflicting tool versions - Faster CI setup (no dev dependency installation per lane) - Consistent tool versions across all environments - Works identically in local and GitHub Actions modes Related: Phase 2 CI run implementation --- src/Ci/Run/RunCommand.php | 12 +- src/Ci/Setup/ComposerInstaller.php | 1 + src/Ci/Setup/SetupCommand.php | 17 +- src/Ci/Setup/ToolCache.php | 258 +++++++++++++++++++++++++++++ src/Module/Ci.php | 10 +- src/Module/Qc.php | 7 + src/Qc/Task/Phpcsfixer.php | 36 +--- src/Qc/Task/Phpstan.php | 37 +---- src/Qc/Task/Unit.php | 7 +- src/Qc/ToolFinder.php | 28 +++- 10 files changed, 338 insertions(+), 75 deletions(-) create mode 100644 src/Ci/Setup/ToolCache.php diff --git a/src/Ci/Run/RunCommand.php b/src/Ci/Run/RunCommand.php index d3fd6db3..801a1130 100644 --- a/src/Ci/Run/RunCommand.php +++ b/src/Ci/Run/RunCommand.php @@ -35,11 +35,13 @@ class RunCommand * @param Output $output Output handler * @param ResultCollector $collector Result collector * @param string $componentsPath Path to horde-components binary + * @param string $workDir Work directory (for tools path) */ public function __construct( private readonly Output $output, private readonly ResultCollector $collector, - private readonly string $componentsPath + private readonly string $componentsPath, + private readonly string $workDir ) {} /** @@ -164,14 +166,18 @@ private function runQcInLane(array $lane): void $tasks .= ' phpcsfixer'; } + // Tools directory + $toolsDir = $this->workDir . '/tools'; + // Build command to invoke horde-components qc with specific PHP version // Note: cd to component_dir (not lane dir) since QC expects to be in component root $command = sprintf( - 'cd %s && %s %s qc %s 2>&1', + 'cd %s && %s %s qc %s --tools-dir=%s 2>&1', escapeshellarg($lane['component_dir']), escapeshellarg($phpBinary), escapeshellarg($this->componentsPath), - $tasks + $tasks, + escapeshellarg($toolsDir) ); // Execute QC diff --git a/src/Ci/Setup/ComposerInstaller.php b/src/Ci/Setup/ComposerInstaller.php index f4053e42..11c8715c 100644 --- a/src/Ci/Setup/ComposerInstaller.php +++ b/src/Ci/Setup/ComposerInstaller.php @@ -134,6 +134,7 @@ private function runComposerInstall(string $laneDir, string $phpBinary): array $composer = $this->findComposer(); // Build command - wrap in bash -c for timeout to work with cd && + // Note: Composer 2.x installs dev dependencies by default (no --dev flag needed) $innerCommand = sprintf( 'cd %s && %s %s install --no-interaction --no-progress --prefer-dist 2>&1', escapeshellarg($laneDir), diff --git a/src/Ci/Setup/SetupCommand.php b/src/Ci/Setup/SetupCommand.php index 29ace398..2afb1908 100644 --- a/src/Ci/Setup/SetupCommand.php +++ b/src/Ci/Setup/SetupCommand.php @@ -46,13 +46,15 @@ class SetupCommand * @param ExtensionInstaller $extensionInstaller Extension installer * @param LaneCopier $laneCopier Lane copier * @param ComposerInstaller $composerInstaller Composer installer + * @param ToolCache $toolCache Tool cache manager */ public function __construct( private readonly Output $output, private readonly PhpInstaller $phpInstaller, private readonly ExtensionInstaller $extensionInstaller, private readonly LaneCopier $laneCopier, - private readonly ComposerInstaller $composerInstaller + private readonly ComposerInstaller $composerInstaller, + private readonly ToolCache $toolCache ) {} /** @@ -182,9 +184,22 @@ public function execute(CiConfig $config): bool $this->output->warn("Failed lanes: {$failed}"); } + $this->output->plain(''); + + // Download QC tools to cache + $this->output->bold('=== Downloading QC Tools ==='); + try { + $this->toolCache->ensureAllTools($testableVersions); + $this->output->ok('All tools downloaded'); + } catch (Exception $e) { + $this->output->error("Tool download failed: " . $e->getMessage()); + return false; + } + $this->output->plain(''); $this->output->bold('Setup complete!'); $this->output->info("Workspace: {$config->workDir}"); + $this->output->info("Tools cache: {$config->workDir}/tools"); $this->output->info("Next step: horde-components ci run --work-dir={$config->workDir}"); return $failed === 0; diff --git a/src/Ci/Setup/ToolCache.php b/src/Ci/Setup/ToolCache.php new file mode 100644 index 00000000..0b52505e --- /dev/null +++ b/src/Ci/Setup/ToolCache.php @@ -0,0 +1,258 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Ci\Setup; + +use Horde\Components\Output; +use Horde\Components\Exception; + +/** + * Manages cached QC tool binaries (PHPUnit, PHPStan, PHP-CS-Fixer). + * + * Downloads and caches PHAR files for quality check tools, enabling: + * - Version-specific tools per PHP version + * - No need to install tools in every lane's vendor/ + * - Fast setup and consistent tool versions + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class ToolCache +{ + /** + * PHPUnit version mapping for PHP versions. + * + * PHPUnit 11.x for PHP 8.2-8.3 + * PHPUnit 12.x for PHP 8.4+ + */ + private const PHPUNIT_VERSIONS = [ + '8.2' => '11.5', + '8.3' => '11.5', + '8.4' => '12.5', + '8.5' => '12.5', + ]; + + /** + * Constructor. + * + * @param string $cacheDir Directory to store tool PHARs + * @param Output $output Output handler + */ + public function __construct( + private readonly string $cacheDir, + private readonly Output $output + ) {} + + /** + * Ensure all required tools are cached for given PHP versions. + * + * @param array $phpVersions PHP versions to prepare tools for + * @throws Exception If download fails + */ + public function ensureAllTools(array $phpVersions): void + { + // Create cache directory + if (!is_dir($this->cacheDir)) { + mkdir($this->cacheDir, 0755, true); + } + + // Download PHPUnit for each PHP version + foreach ($phpVersions as $phpVersion) { + $this->ensurePhpUnit($phpVersion); + } + + // Download PHPStan (single version for all PHP versions) + $this->ensurePhpStan(); + + // Download PHP-CS-Fixer (single version, only runs on PHP 8.4) + $this->ensurePhpCsFixer(); + } + + /** + * Ensure PHPUnit is cached for specific PHP version. + * + * @param string $phpVersion PHP version (e.g., "8.4") + * @return string Path to PHPUnit PHAR + * @throws Exception If version not supported or download fails + */ + public function ensurePhpUnit(string $phpVersion): string + { + if (!isset(self::PHPUNIT_VERSIONS[$phpVersion])) { + throw new Exception("No PHPUnit version mapped for PHP {$phpVersion}"); + } + + $phpunitVersion = self::PHPUNIT_VERSIONS[$phpVersion]; + $pharName = "phpunit-{$phpunitVersion}.phar"; + $pharPath = $this->cacheDir . '/' . $pharName; + + if (file_exists($pharPath)) { + $this->output->plain(" ✓ PHPUnit {$phpunitVersion} (cached)"); + return $pharPath; + } + + $this->output->info(" ⬇ Downloading PHPUnit {$phpunitVersion}..."); + + $url = "https://phar.phpunit.de/phpunit-{$phpunitVersion}.phar"; + $this->downloadPhar($url, $pharPath); + + $this->output->ok(" ✓ PHPUnit {$phpunitVersion}"); + return $pharPath; + } + + /** + * Ensure PHPStan is cached. + * + * @return string Path to PHPStan PHAR + * @throws Exception If download fails + */ + public function ensurePhpStan(): string + { + $pharName = 'phpstan.phar'; + $pharPath = $this->cacheDir . '/' . $pharName; + + if (file_exists($pharPath)) { + $this->output->plain(" ✓ PHPStan (cached)"); + return $pharPath; + } + + $this->output->info(" ⬇ Downloading PHPStan (latest)..."); + + $url = 'https://github.com/phpstan/phpstan/releases/latest/download/phpstan.phar'; + $this->downloadPhar($url, $pharPath); + + $this->output->ok(" ✓ PHPStan"); + return $pharPath; + } + + /** + * Ensure PHP-CS-Fixer is cached. + * + * @return string Path to PHP-CS-Fixer PHAR + * @throws Exception If download fails + */ + public function ensurePhpCsFixer(): string + { + $pharName = 'php-cs-fixer.phar'; + $pharPath = $this->cacheDir . '/' . $pharName; + + if (file_exists($pharPath)) { + $this->output->plain(" ✓ PHP-CS-Fixer (cached)"); + return $pharPath; + } + + $this->output->info(" ⬇ Downloading PHP-CS-Fixer (latest)..."); + + $url = 'https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/releases/latest/download/php-cs-fixer.phar'; + $this->downloadPhar($url, $pharPath); + + $this->output->ok(" ✓ PHP-CS-Fixer"); + return $pharPath; + } + + /** + * Get the path to PHPUnit for a specific PHP version. + * + * @param string $phpVersion PHP version (e.g., "8.4") + * @return string|null Path to PHPUnit PHAR or null if not cached + */ + public function getPhpUnitPath(string $phpVersion): ?string + { + if (!isset(self::PHPUNIT_VERSIONS[$phpVersion])) { + return null; + } + + $phpunitVersion = self::PHPUNIT_VERSIONS[$phpVersion]; + $pharPath = $this->cacheDir . "/phpunit-{$phpunitVersion}.phar"; + + return file_exists($pharPath) ? $pharPath : null; + } + + /** + * Get the path to PHPStan. + * + * @return string|null Path to PHPStan PHAR or null if not cached + */ + public function getPhpStanPath(): ?string + { + $pharPath = $this->cacheDir . '/phpstan.phar'; + return file_exists($pharPath) ? $pharPath : null; + } + + /** + * Get the path to PHP-CS-Fixer. + * + * @return string|null Path to PHP-CS-Fixer PHAR or null if not cached + */ + public function getPhpCsFixerPath(): ?string + { + $pharPath = $this->cacheDir . '/php-cs-fixer.phar'; + return file_exists($pharPath) ? $pharPath : null; + } + + /** + * Download a PHAR file. + * + * @param string $url URL to download from + * @param string $destination Local path to save to + * @throws Exception If download fails + */ + private function downloadPhar(string $url, string $destination): void + { + // Use curl for reliable downloads with redirects + $command = sprintf( + 'curl -L -f -o %s %s 2>&1', + escapeshellarg($destination), + escapeshellarg($url) + ); + + exec($command, $output, $exitCode); + + if ($exitCode !== 0) { + throw new Exception("Failed to download {$url}: " . implode("\n", $output)); + } + + // Verify it's a valid PHAR + if (!$this->isValidPhar($destination)) { + unlink($destination); + throw new Exception("Downloaded file is not a valid PHAR: {$url}"); + } + + // Make executable + chmod($destination, 0755); + } + + /** + * Check if a file is a valid PHAR. + * + * @param string $path Path to check + * @return bool True if valid PHAR + */ + private function isValidPhar(string $path): bool + { + if (!is_readable($path)) { + return false; + } + + try { + \Phar::loadPhar($path); + return true; + } catch (\Throwable $e) { + return false; + } + } +} diff --git a/src/Module/Ci.php b/src/Module/Ci.php index befc43a8..913353ae 100644 --- a/src/Module/Ci.php +++ b/src/Module/Ci.php @@ -24,6 +24,7 @@ use Horde\Components\Ci\Setup\ExtensionInstaller; use Horde\Components\Ci\Setup\LaneCopier; use Horde\Components\Ci\Setup\ComposerInstaller; +use Horde\Components\Ci\Setup\ToolCache; use Horde\Components\Ci\Init\InitCommand; use Horde\Components\Ci\Config\CiConfig; use Horde\Components\Ci\Run\RunCommand; @@ -455,13 +456,18 @@ private function handleSetup(array $options, Output $output): bool $config = new CiConfig($configArray); + // Create tools cache + $toolsDir = $config->workDir . '/tools'; + $toolCache = new ToolCache($toolsDir, $output); + // Create setup command $setupCommand = new SetupCommand( $output, new PhpInstaller($output), new ExtensionInstaller($output), new LaneCopier($output), - new ComposerInstaller($output) + new ComposerInstaller($output), + $toolCache ); return $setupCommand->execute($config); @@ -494,7 +500,7 @@ private function handleRun(array $options, Output $output): bool // Create RunCommand with dependencies $collector = new ResultCollector($output); - $runCommand = new RunCommand($output, $collector, $componentsPath); + $runCommand = new RunCommand($output, $collector, $componentsPath, $workDir); try { $exitCode = $runCommand->execute($workDir); diff --git a/src/Module/Qc.php b/src/Module/Qc.php index 8399244b..da231c84 100644 --- a/src/Module/Qc.php +++ b/src/Module/Qc.php @@ -59,6 +59,13 @@ public function getOptionGroupOptions(): array '--fix-qc-issues', ['action' => 'store_true', 'help' => 'Automatically fix QC issues where possible.'] ), + new \Horde\Argv\Option( + '--tools-dir', + [ + 'action' => 'store', + 'help' => 'Directory containing QC tool binaries (PHPUnit, PHPStan, etc.)', + ] + ), ]; } diff --git a/src/Qc/Task/Phpcsfixer.php b/src/Qc/Task/Phpcsfixer.php index 00adbcda..80e2d1e2 100644 --- a/src/Qc/Task/Phpcsfixer.php +++ b/src/Qc/Task/Phpcsfixer.php @@ -85,7 +85,7 @@ public function validate(array $options = []): array */ public function run(array &$options = []): int { - $binary = $this->findPhpCsFixerBinary(); + $binary = $this->findPhpCsFixerBinary($options['tools_dir'] ?? null); if ($binary === null) { $this->getOutput()->warn('PHP CS Fixer not found - skipping'); @@ -147,39 +147,17 @@ public function __destruct() * * @return string|null Path to binary or null if not found. */ - private function findPhpCsFixerBinary(): ?string + private function findPhpCsFixerBinary(?string $toolsDir = null): ?string { $componentPath = $this->getPath(); - // Order of preference (matching PHPUnit task): - // 1. vendor/bin (Composer) - // 2. tools/ (local tools) - // 3. ~/.phive/ (Phive) - // 4. system PATH - $locations = [ - $componentPath . '/vendor/bin/php-cs-fixer', - $componentPath . '/vendor/bin/php-cs-fixer.phar', - $componentPath . '/tools/php-cs-fixer', - $componentPath . '/tools/php-cs-fixer.phar', - $_SERVER['HOME'] . '/.phive/php-cs-fixer', - $_SERVER['HOME'] . '/.phive/php-cs-fixer.phar', - '/usr/local/bin/php-cs-fixer', - '/usr/bin/php-cs-fixer', - ]; - - foreach ($locations as $path) { - if (file_exists($path) && is_executable($path)) { - return $path; - } - } - - // Fallback: check PATH - $which = trim((string) shell_exec('which php-cs-fixer 2>/dev/null')); - if (!empty($which) && file_exists($which)) { - return $which; + if (empty($componentPath)) { + $componentPath = getcwd(); } - return null; + // Use ToolFinder for consistent tool discovery + $toolFinder = new \Horde\Components\Qc\ToolFinder($componentPath, $toolsDir); + return $toolFinder->findBinary('php-cs-fixer'); } /** diff --git a/src/Qc/Task/Phpstan.php b/src/Qc/Task/Phpstan.php index 844c6830..0eab9394 100644 --- a/src/Qc/Task/Phpstan.php +++ b/src/Qc/Task/Phpstan.php @@ -88,7 +88,7 @@ public function validate(array $options = []): array */ public function run(array &$options = []): int { - $binary = $this->findPhpStanBinary(); + $binary = $this->findPhpStanBinary($options['tools_dir'] ?? null); if ($binary === null) { $this->getOutput()->warn('PHPStan not found - skipping'); @@ -225,9 +225,10 @@ public function run(array &$options = []): int /** * Find PHPStan binary in standard locations. * + * @param string|null $toolsDir Optional tools directory to check first * @return string|null Path to binary or null if not found. */ - private function findPhpStanBinary(): ?string + private function findPhpStanBinary(?string $toolsDir = null): ?string { $componentPath = $this->getPath(); @@ -235,35 +236,9 @@ private function findPhpStanBinary(): ?string $componentPath = getcwd(); } - // Order of preference (matching other tasks): - // 1. vendor/bin (Composer) - // 2. tools/ (local tools) - // 3. ~/.phive/ (Phive) - // 4. system PATH - $locations = [ - $componentPath . '/vendor/bin/phpstan', - $componentPath . '/vendor/bin/phpstan.phar', - $componentPath . '/tools/phpstan', - $componentPath . '/tools/phpstan.phar', - $_SERVER['HOME'] . '/.phive/phpstan', - $_SERVER['HOME'] . '/.phive/phpstan.phar', - '/usr/local/bin/phpstan', - '/usr/bin/phpstan', - ]; - - foreach ($locations as $path) { - if (file_exists($path) && is_executable($path)) { - return $path; - } - } - - // Fallback: check PATH - $which = trim((string) shell_exec('which phpstan 2>/dev/null')); - if (!empty($which) && file_exists($which)) { - return $which; - } - - return null; + // Use ToolFinder for consistent tool discovery + $toolFinder = new \Horde\Components\Qc\ToolFinder($componentPath, $toolsDir); + return $toolFinder->findBinary('phpstan'); } /** diff --git a/src/Qc/Task/Unit.php b/src/Qc/Task/Unit.php index 53341b8b..42d07e1f 100644 --- a/src/Qc/Task/Unit.php +++ b/src/Qc/Task/Unit.php @@ -83,9 +83,10 @@ public function validate(array $options = []): array * * Outputs information about the detected PHPUnit installation. * + * @param string|null $toolsDir Optional tools directory to check first * @return void */ - private function loadPhpUnit(): void + private function loadPhpUnit(?string $toolsDir = null): void { // Already loaded via Composer autoloader if (class_exists('PHPUnit\TextUI\Application')) { @@ -123,7 +124,7 @@ private function loadPhpUnit(): void '/usr/bin/phpunit', ]; - $toolFinder = new ToolFinder($componentPath); + $toolFinder = new ToolFinder($componentPath, $toolsDir); foreach ($possibleLocations as $toolPath) { if (!file_exists($toolPath) || !is_readable($toolPath)) { @@ -282,7 +283,7 @@ public function notify(Skipped $event): void public function run(array &$options = []): int { // Ensure PHPUnit is loaded (handles PHAR installations) - $this->loadPhpUnit(); + $this->loadPhpUnit($options['tools_dir'] ?? null); $componentPath = $this->getPath(); if (empty($componentPath)) { diff --git a/src/Qc/ToolFinder.php b/src/Qc/ToolFinder.php index a3411ef8..6dc5721c 100644 --- a/src/Qc/ToolFinder.php +++ b/src/Qc/ToolFinder.php @@ -34,9 +34,11 @@ class ToolFinder * * @param string|null $componentPath Path to the component directory. * If null or empty, uses current working directory. + * @param string|null $toolsDir Optional directory containing tool binaries (highest priority). */ public function __construct( - private readonly ?string $componentPath + private readonly ?string $componentPath, + private readonly ?string $toolsDir = null ) {} /** @@ -56,11 +58,12 @@ private function getComponentPath(): string * Find a tool binary in standard locations. * * Searches in this order: - * 1. Component vendor/bin directory - * 2. Component tools directory - * 3. User's Phive directory (~/.phive) - * 4. System directories (/usr/local/bin, /usr/bin) - * 5. System PATH (using 'which' command) + * 1. Tools directory (if provided via constructor) + * 2. Component vendor/bin directory + * 3. Component tools directory + * 4. User's Phive directory (~/.phive) + * 5. System directories (/usr/local/bin, /usr/bin) + * 6. System PATH (using 'which' command) * * @param string $toolName Name of the tool (e.g., 'phpunit', 'phpstan') * @param bool $checkPhar Also check for .phar variants (default: true) @@ -74,6 +77,19 @@ public function findBinary(string $toolName, bool $checkPhar = true): ?string // Build list of possible locations $locations = []; + // 0. Tools directory (highest priority - for CI mode) + if (!empty($this->toolsDir)) { + $locations[] = $this->toolsDir . '/' . $toolName; + if ($checkPhar) { + $locations[] = $this->toolsDir . '/' . $toolName . '.phar'; + } + // Check for version-specific PHPUnit (phpunit-11.5.phar, phpunit-12.5.phar) + if ($toolName === 'phpunit' && $checkPhar) { + $locations[] = $this->toolsDir . '/phpunit-11.5.phar'; + $locations[] = $this->toolsDir . '/phpunit-12.5.phar'; + } + } + // 1. Component vendor/bin $locations[] = $componentPath . '/vendor/bin/' . $toolName; if ($checkPhar) { From 6ff4647d3195f9fbbbc31e156a1163a0c503f778 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Tue, 3 Mar 2026 14:14:17 +0100 Subject: [PATCH 15/17] fix(ci): remove phpunit from components dev dependencies Remove PHPUnit from horde-components' own composer.json so it doesn't conflict with component-specific PHPUnit versions loaded via tools-dir. This allows QC tasks to load PHPUnit from cached tools instead of components' vendor directory. Result: PHP 8.4+ lanes now work correctly with tools cache! Known issue: PHP 8.2-8.3 lanes fail because ToolFinder doesn't recognize version-specific PHAR filenames (phpunit-11.5.phar). It finds system PHPUnit 13 which is incompatible with PHP < 8.4. TODO: Update ToolFinder.findBinary() to: 1. Prioritize tools-dir over all other locations 2. Recognize version-specific PHARs (phpunit-11.5.phar, phpunit-12.5.phar) 3. Select appropriate version based on PHP version --- composer.json | 3 +-- src/Qc/Task/Unit.php | 55 +++++++++++++++----------------------------- 2 files changed, 20 insertions(+), 38 deletions(-) diff --git a/composer.json b/composer.json index 2c7dcefb..f75d5dab 100644 --- a/composer.json +++ b/composer.json @@ -44,8 +44,7 @@ "horde/phpconfigfile": "^0.0.1-alpha4 || dev-FRAMEWORK_6_0" }, "require-dev": { - "horde/test": "^3 || dev-FRAMEWORK_6_0", - "phpunit/phpunit": "^12 || ^11 || ^10 || ^9" + "horde/test": "^3 || dev-FRAMEWORK_6_0" }, "suggest": { "horde/test": "^3 || dev-FRAMEWORK_6_0", diff --git a/src/Qc/Task/Unit.php b/src/Qc/Task/Unit.php index 42d07e1f..2c32167b 100644 --- a/src/Qc/Task/Unit.php +++ b/src/Qc/Task/Unit.php @@ -104,48 +104,31 @@ private function loadPhpUnit(?string $toolsDir = null): void $componentPath = getcwd() ?: '.'; } - // Build list of possible locations - $possibleLocations = [ - // Local component installations - $componentPath . '/vendor/bin/phpunit.phar', - $componentPath . '/vendor/bin/phpunit', - $componentPath . '/vendor/phpunit/phpunit/phpunit', - $componentPath . '/tools/phpunit.phar', - $componentPath . '/tools/phpunit', - // Global user installations - $_SERVER['HOME'] . '/.config/composer/vendor/bin/phpunit', - $_SERVER['HOME'] . '/.composer/vendor/bin/phpunit', - $_SERVER['HOME'] . '/.phive/phpunit.phar', - $_SERVER['HOME'] . '/.phive/phpunit', - // System installations - '/usr/local/bin/phpunit.phar', - '/usr/local/bin/phpunit', - '/usr/bin/phpunit.phar', - '/usr/bin/phpunit', - ]; - + // Use ToolFinder to locate PHPUnit $toolFinder = new ToolFinder($componentPath, $toolsDir); + $phpunitPath = $toolFinder->findBinary('phpunit'); - foreach ($possibleLocations as $toolPath) { - if (!file_exists($toolPath) || !is_readable($toolPath)) { - continue; - } + if ($phpunitPath === null) { + // PHPUnit not found - will fail later during run() + return; + } - // Try to load PHPUnit using ToolFinder - if ($toolFinder->loadTool($toolPath)) { - // Check if PHPUnit classes are now available - if (class_exists('PHPUnit\TextUI\Application')) { - if (!$this->sourceReported) { - $version = $this->getPhpUnitVersion(); - $versionStr = $version ? ' version ' . $version : ''; - $type = $toolFinder->isPhar($toolPath) ? 'PHAR' : 'Composer'; - $this->getOutput()->info('Using PHPUnit' . $versionStr . ' from: ' . $toolPath . ' (' . $type . ')'); - $this->sourceReported = true; - } - return; + // Try to load PHPUnit + if ($toolFinder->loadTool($phpunitPath)) { + // Check if PHPUnit classes are now available + if (class_exists('PHPUnit\TextUI\Application')) { + if (!$this->sourceReported) { + $version = $this->getPhpUnitVersion(); + $versionStr = $version ? ' version ' . $version : ''; + $type = $toolFinder->isPhar($phpunitPath) ? 'PHAR' : 'Composer'; + $this->getOutput()->info('Using PHPUnit' . $versionStr . ' from: ' . $phpunitPath . ' (' . $type . ')'); + $this->sourceReported = true; } + return; } } + + // If we get here, loading failed - will error during run() } /** From 7441e739cff1fd96c26978b49c141bdb08946aba Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Tue, 3 Mar 2026 14:20:34 +0100 Subject: [PATCH 16/17] feat(ci): disable platform check and prioritize version-specific PHPUnit Changes: 1. Disable composer platform-check to allow running with PHP 8.2+ - components requires PHP ^8.2 but dependencies may require 8.3 - Only one readonly class usage (PHP 8.2 feature) - fully compatible 2. Update ToolFinder to select PHPUnit version based on PHP_VERSION_ID - PHP < 8.4: prioritize phpunit-11.5.phar, phpunit-11.phar - PHP >= 8.4: prioritize phpunit-12.5.phar, phpunit-12.phar - Tools-dir checked before standard locations Testing confirms: - ToolFinder.findBinary('phpunit') correctly finds phpunit-11.5.phar on PHP 8.2 - ToolFinder.loadTool() successfully loads PHPUnit 11.5.55 from PHAR - PHP 8.4-8.5 lanes work fully - PHP 8.2-8.3 lanes still finding wrong PHPUnit (needs investigation) Next: Debug why Unit task doesn't use ToolFinder result --- composer.json | 3 ++- src/Qc/ToolFinder.php | 22 +++++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index f75d5dab..73d7ba75 100644 --- a/composer.json +++ b/composer.json @@ -74,7 +74,8 @@ "config": { "allow-plugins": { "horde/horde-installer-plugin": true - } + }, + "platform-check": false }, "extra": { "branch-alias": { diff --git a/src/Qc/ToolFinder.php b/src/Qc/ToolFinder.php index 6dc5721c..32758175 100644 --- a/src/Qc/ToolFinder.php +++ b/src/Qc/ToolFinder.php @@ -79,15 +79,27 @@ public function findBinary(string $toolName, bool $checkPhar = true): ?string // 0. Tools directory (highest priority - for CI mode) if (!empty($this->toolsDir)) { + // For PHPUnit, prefer version-specific PHARs based on PHP version + if ($toolName === 'phpunit' && $checkPhar) { + $phpVersion = PHP_VERSION_ID; + + // PHPUnit 11.x for PHP 8.2-8.3, PHPUnit 12.x for PHP 8.4+ + if ($phpVersion < 80400) { + // PHP 8.2-8.3: Try PHPUnit 11.5 first + $locations[] = $this->toolsDir . '/phpunit-11.5.phar'; + $locations[] = $this->toolsDir . '/phpunit-11.phar'; + } else { + // PHP 8.4+: Try PHPUnit 12.5 first + $locations[] = $this->toolsDir . '/phpunit-12.5.phar'; + $locations[] = $this->toolsDir . '/phpunit-12.phar'; + } + } + + // Standard tool names $locations[] = $this->toolsDir . '/' . $toolName; if ($checkPhar) { $locations[] = $this->toolsDir . '/' . $toolName . '.phar'; } - // Check for version-specific PHPUnit (phpunit-11.5.phar, phpunit-12.5.phar) - if ($toolName === 'phpunit' && $checkPhar) { - $locations[] = $this->toolsDir . '/phpunit-11.5.phar'; - $locations[] = $this->toolsDir . '/phpunit-12.5.phar'; - } } // 1. Component vendor/bin From 226b23b8ca3f7be6fec9a7c7a23ccebd31c7af68 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Tue, 3 Mar 2026 14:58:47 +0100 Subject: [PATCH 17/17] feat(ci): complete GitHub Actions workflow template integration Phase 3 implementation: 1. Update workflow template: - Add workflow_dispatch for manual triggers - Add PHP extensions (json, mbstring, curl) - Add QC tools caching with actions/cache@v4 - Add artifact upload for test results - Add organization variable support (vars.COMPONENTS_PHAR_URL) - Add fallback to default URL if org variable not set 2. Update default PHAR URL: - Change from dev.horde.org to GitHub releases - Point to /latest/ for automatic updates - Document organization variable approach 3. Auto-detection already working: - PresenterFactory already detects CI environment - Automatically uses Ci presenter in GitHub Actions - Outputs workflow commands (::notice::, ::warning::, ::error::) Template features: - Tool caching: key based on .horde.yml hash - Organization variable with fallback - Artifact upload with 30-day retention - Manual trigger support This completes the GitHub Actions integration infrastructure. Next step: real GitHub Actions testing. --- data/ci/workflow.yml.template | 28 +++++++++++++++++++++++++++- src/Ci/Init/InitCommand.php | 11 +++++++++-- src/Qc/Task/Unit.php | 2 +- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/data/ci/workflow.yml.template b/data/ci/workflow.yml.template index d7f4798d..9bca1e3d 100644 --- a/data/ci/workflow.yml.template +++ b/data/ci/workflow.yml.template @@ -3,12 +3,20 @@ name: CI # Generated by: horde-components {{COMPONENTS_VERSION}} # Template version: {{TEMPLATE_VERSION}} # Generated: {{GENERATED_DATE}} +# +# DO NOT EDIT - Regenerate with: horde-components ci init on: push: branches: [ FRAMEWORK_6_0 ] pull_request: branches: [ FRAMEWORK_6_0 ] + workflow_dispatch: + inputs: + php_versions: + description: 'PHP versions to test (comma-separated, e.g., 8.2,8.4)' + required: false + default: 'all' jobs: test: @@ -22,15 +30,33 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '{{PHP_VERSION}}' + extensions: json, mbstring, curl coverage: none + - name: Cache QC tools (PHPUnit, PHPStan, PHP-CS-Fixer) + uses: actions/cache@v4 + with: + path: {{WORK_DIR}}/tools + key: ci-tools-${{ hashFiles('.horde.yml') }} + restore-keys: | + ci-tools- + - name: CI Setup run: bash bin/ci-bootstrap.sh env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COMPONENTS_PHAR_URL: {{COMPONENTS_PHAR_URL}} + COMPONENTS_PHAR_URL: ${{ vars.COMPONENTS_PHAR_URL || '{{COMPONENTS_PHAR_URL}}' }} - name: CI Run run: | php {{WORK_DIR}}/bin/horde-components.phar ci run \ --work-dir={{WORK_DIR}} + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: ci-results-${{ github.run_number }} + path: | + {{WORK_DIR}}/lanes/*/build/*.json + retention-days: 30 diff --git a/src/Ci/Init/InitCommand.php b/src/Ci/Init/InitCommand.php index 0bd298a5..99ced85c 100644 --- a/src/Ci/Init/InitCommand.php +++ b/src/Ci/Init/InitCommand.php @@ -200,6 +200,11 @@ private function getConfiguration( /** * Get components PHAR URL. * + * Priority: + * 1. COMPONENTS_PHAR_URL environment variable + * 2. Config file setting + * 3. Default to latest GitHub release + * * @return string PHAR URL */ private function getComponentsPharUrl(): string @@ -211,8 +216,10 @@ private function getComponentsPharUrl(): string } // Try from config (would need to access config system) - // For now, use default - return 'https://dev.horde.org/ci/horde-components.phar'; + // For now, use default pointing to latest GitHub release + // Note: Users should set organization variable COMPONENTS_PHAR_URL + // pointing to specific version for production use + return 'https://github.com/horde/components/releases/latest/download/horde-components.phar'; } /** diff --git a/src/Qc/Task/Unit.php b/src/Qc/Task/Unit.php index 2c32167b..5bc67688 100644 --- a/src/Qc/Task/Unit.php +++ b/src/Qc/Task/Unit.php @@ -70,7 +70,7 @@ public function getName(): string public function validate(array $options = []): array { // Try to load PHPUnit if not already available - $this->loadPhpUnit(); + $this->loadPhpUnit($options['tools_dir'] ?? null); if (!class_exists('PHPUnit\TextUI\Application')) { return ['PHPUnit is not installed!'];