diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..2b3ce36 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Reporting Security Issues +If you believe you have found a security vulnerability in any MacPaw-owned repository, please report it to us through coordinated disclosure. + +Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests. + +Instead, please send an email to `security[@]macpaw.com`. + +Please include as much of the information listed below as you can to help us better understand and resolve the issue: + +- The type of issue (e.g., buffer overflow, SQL injection, or cross-site scripting) +- Full paths of source file(s) related to the manifestation of the issue +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- Step-by-step instructions to reproduce the issue +- Proof-of-concept or exploit code (if possible) +- Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + +## Policy +See MacPaw's [Vulnerability Disclosure Policy](https://macpaw.com/vulnerability-disclosure-policy) diff --git a/.github/workflows/release-reusable.yml b/.github/workflows/release-reusable.yml new file mode 100644 index 0000000..749793c --- /dev/null +++ b/.github/workflows/release-reusable.yml @@ -0,0 +1,73 @@ +name: Release + +on: + workflow_call: + secrets: + GH_TOKEN: + description: 'Token for release' + required: true + inputs: + dry_run: + description: 'Dry run (no release will be created)' + required: false + type: boolean + default: false + +permissions: + contents: write + issues: write + pull-requests: write + discussions: write + +jobs: + release: + name: Semantic Release + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + + - name: Semantic Release + uses: cycjimmy/semantic-release-action@v4 + id: semantic + with: + dry_run: ${{ inputs.dry_run || false }} + extra_plugins: | + @semantic-release/changelog@6.0.3 + @semantic-release/git@10.0.1 + conventional-changelog-conventionalcommits@7.0.2 + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + + - name: Summary + if: steps.semantic.outputs.new_release_published == 'true' + run: | + echo "## ๐ŸŽ‰ Release Created Successfully!" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Version:** ${{ steps.semantic.outputs.new_release_version }}" >> $GITHUB_STEP_SUMMARY + echo "- **Tag:** v${{ steps.semantic.outputs.new_release_version }}" >> $GITHUB_STEP_SUMMARY + echo "- **Channel:** ${{ steps.semantic.outputs.new_release_channel }}" >> $GITHUB_STEP_SUMMARY + echo "- **Release Notes:** Auto-generated by semantic-release" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "๐Ÿ”— [View Release](https://github.com/${{ github.repository }}/releases/tag/v${{ steps.semantic.outputs.new_release_version }})" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### ๐Ÿ“ Release Notes" >> $GITHUB_STEP_SUMMARY + echo "${{ steps.semantic.outputs.new_release_notes }}" >> $GITHUB_STEP_SUMMARY + + - name: No Release Summary + if: steps.semantic.outputs.new_release_published != 'true' + run: | + echo "## โ„น๏ธ No Release Created" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "No new version was released. This could be because:" >> $GITHUB_STEP_SUMMARY + echo "- No commits since last release with valid conventional commit messages" >> $GITHUB_STEP_SUMMARY + echo "- All commits are non-release types (chore, docs, style, refactor, test)" >> $GITHUB_STEP_SUMMARY + echo "- Running in dry-run mode" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..cd0f2c4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: Release + +on: + push: + branches: + - main + - master + - next + - beta + - alpha + - '[0-9]+.x' + - '[0-9]+.[0-9]+.x' + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run (no release will be created)' + required: false + type: boolean + default: false + +jobs: + release: + name: Release + uses: ./.github/workflows/release-reusable.yml + secrets: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + dry_run: ${{ inputs.dry_run || false }} diff --git a/.github/workflows/symfony-php-reusable.yml b/.github/workflows/symfony-php-reusable.yml index b7858c8..edb4160 100644 --- a/.github/workflows/symfony-php-reusable.yml +++ b/.github/workflows/symfony-php-reusable.yml @@ -2,9 +2,35 @@ name: Symfony PHP Reusable Workflow on: workflow_call: + + secrets: + CODECOV_TOKEN: + description: 'Token for Codecov uploads (optional)' + required: false + INFECTION_BADGE_API_KEY: + description: 'API key for Infection badge (optional)' + required: false + inputs: + matrix-include: + description: | + JSON array of additional matrix combinations to include. + Use this to add specific package version combinations or custom configurations. + Examples: + - Add lowest dependencies test: [{"php":"8.2","dependencies":"lowest"}] + - Add code coverage: [{"php":"8.3","symfony":"7.0.*","coverage":"xdebug"}] + - Test specific package versions: [{"php":"8.2","symfony":"6.4.*","doctrine/orm":"2.14.*"}] + required: false + type: string + default: '[]' versions-matrix: - description: 'JSON object with package versions to test. Format: {"php": ["8.2", "8.3"], "symfony/framework-bundle": ["6.4.*", "7.0.*"], "package/name": ["1.0.*"]}' + description: | + JSON object with package versions to test. + - PHP versions create matrix combinations with Symfony versions + - Symfony versions are tested with each PHP version + - Additional packages are installed from first version in array + - Use matrix-include for specific package version combinations + Format: {"php": ["8.2", "8.3"], "symfony/framework-bundle": ["6.4.*", "7.0.*"], "package/name": ["1.0.*"]} required: false type: string default: | @@ -12,11 +38,6 @@ on: "php": ["8.2", "8.3", "8.4"], "symfony/framework-bundle": ["6.4.*", "7.0.*", "7.3.*"] } - enable-code-coverage: - description: 'Enable code coverage reporting' - required: false - type: boolean - default: true enable-phpstan: description: 'Enable PHPStan static analysis' required: false @@ -52,11 +73,26 @@ on: required: false type: boolean default: true - test-lowest-dependencies: - description: 'Test with lowest dependencies' + enable-coverage-check: + description: 'Enable coverage validation and PR comments' required: false type: boolean default: false + coverage-threshold: + description: 'Minimum code coverage percentage required (0-100)' + required: false + type: number + default: 70 + coverage-file: + description: 'Coverage file name (Clover XML format)' + required: false + type: string + default: 'coverage.xml' + phpcov-version: + description: 'phpcov PHAR version to download from phar.phpunit.de (e.g. 8.2 -> phpcov-8.2.phar)' + required: false + type: string + default: '8.2' phpstan-level: description: 'PHPStan level (0-9 or max)' required: false @@ -72,6 +108,11 @@ on: required: false type: string default: 'mbstring, json' + php-version-quality-tools: + description: 'PHP version to use for quality tools (PHPStan, PHPCS, Rector, Infection, etc.)' + required: false + type: string + default: '8.3' matrix-exclude: description: 'JSON array of matrix combinations to exclude (e.g. [{"php": "8.1", "symfony/framework-bundle": "7.0.*"}]). Default excludes Symfony 7.x with PHP < 8.2' required: false @@ -131,19 +172,17 @@ on: required: false type: string default: '' - secrets: - codecov-token: - description: 'Codecov token for coverage upload' - required: false env: COMPOSER_ROOT_VERSION: "1.0.0" jobs: + + commitlint: name: Commit Lint runs-on: ubuntu-latest - if: inputs.enable-commitlint + if: inputs['enable-commitlint'] steps: - name: Checkout code uses: actions/checkout@v4 @@ -177,10 +216,11 @@ jobs: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose fi + composer-validate: name: Composer Validate runs-on: ubuntu-latest - if: inputs.enable-composer-validate + if: inputs['enable-composer-validate'] timeout-minutes: 5 steps: - name: Checkout code @@ -189,17 +229,18 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: ${{ inputs['php-version-quality-tools'] }} coverage: none - name: Validate composer.json and composer.lock - working-directory: ${{ inputs.working-directory }} + working-directory: ${{ inputs['working-directory'] }} run: composer validate --strict --no-check-publish + phpcs: name: PHP_CodeSniffer runs-on: ubuntu-latest - if: inputs.enable-phpcs + if: inputs['enable-phpcs'] timeout-minutes: 10 steps: - name: Checkout code @@ -208,13 +249,13 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: ${{ inputs['php-version-quality-tools'] }} coverage: none - extensions: ${{ inputs.php-extensions }} + extensions: ${{ inputs['php-extensions'] }} xdebug - name: Get Composer cache directory id: composer-cache - working-directory: ${{ inputs.working-directory }} + working-directory: ${{ inputs['working-directory'] }} run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache Composer dependencies @@ -237,10 +278,11 @@ jobs: vendor/bin/phpcs fi + php-cs-fixer: name: PHP-CS-Fixer runs-on: ubuntu-latest - if: inputs.enable-php-cs-fixer + if: inputs['enable-php-cs-fixer'] timeout-minutes: 10 steps: - name: Checkout code @@ -249,13 +291,13 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: ${{ inputs['php-version-quality-tools'] }} coverage: none - extensions: ${{ inputs.php-extensions }} + extensions: ${{ inputs['php-extensions'] }} - name: Get Composer cache directory id: composer-cache - working-directory: ${{ inputs.working-directory }} + working-directory: ${{ inputs['working-directory'] }} run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache Composer dependencies @@ -270,18 +312,19 @@ jobs: run: composer install --no-interaction --no-progress --prefer-dist - name: Run PHP-CS-Fixer - working-directory: ${{ inputs.working-directory }} + working-directory: ${{ inputs['working-directory'] }} run: | - if [ -n "${{ inputs.php-cs-fixer-config }}" ]; then - vendor/bin/php-cs-fixer fix --dry-run --diff --verbose --config=${{ inputs.php-cs-fixer-config }} + if [ -n "${{ inputs['php-cs-fixer-config'] }}" ]; then + vendor/bin/php-cs-fixer fix --dry-run --diff --verbose --config=${{ inputs['php-cs-fixer-config'] }} else vendor/bin/php-cs-fixer fix --dry-run --diff --verbose fi + phpstan: name: PHPStan runs-on: ubuntu-latest - if: inputs.enable-phpstan + if: inputs['enable-phpstan'] timeout-minutes: 10 steps: - name: Checkout code @@ -290,13 +333,13 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: ${{ inputs['php-version-quality-tools'] }} coverage: none - extensions: ${{ inputs.php-extensions }} + extensions: ${{ inputs['php-extensions'] }} - name: Get Composer cache directory id: composer-cache - working-directory: ${{ inputs.working-directory }} + working-directory: ${{ inputs['working-directory'] }} run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache Composer dependencies @@ -311,18 +354,19 @@ jobs: run: composer install --no-interaction --no-progress --prefer-dist - name: Run PHPStan - working-directory: ${{ inputs.working-directory }} + working-directory: ${{ inputs['working-directory'] }} run: | - if [ -n "${{ inputs.phpstan-config }}" ]; then - vendor/bin/phpstan analyse --level=${{ inputs.phpstan-level }} --no-progress -c ${{ inputs.phpstan-config }} + if [ -n "${{ inputs['phpstan-config'] }}" ]; then + vendor/bin/phpstan analyse --level=${{ inputs['phpstan-level'] }} --no-progress -c ${{ inputs['phpstan-config'] }} else - vendor/bin/phpstan analyse --level=${{ inputs.phpstan-level }} --no-progress + vendor/bin/phpstan analyse --level=${{ inputs['phpstan-level'] }} --no-progress fi + rector: name: Rector runs-on: ubuntu-latest - if: inputs.enable-rector + if: inputs['enable-rector'] timeout-minutes: 10 steps: - name: Checkout code @@ -331,13 +375,13 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: ${{ inputs['php-version-quality-tools'] }} coverage: none - extensions: ${{ inputs.php-extensions }} + extensions: ${{ inputs['php-extensions'] }} - name: Get Composer cache directory id: composer-cache - working-directory: ${{ inputs.working-directory }} + working-directory: ${{ inputs['working-directory'] }} run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache Composer dependencies @@ -352,30 +396,158 @@ jobs: run: composer install --no-interaction --no-progress --prefer-dist - name: Run Rector - working-directory: ${{ inputs.working-directory }} + working-directory: ${{ inputs['working-directory'] }} run: | - if [ -n "${{ inputs.rector-config }}" ]; then - vendor/bin/rector process --dry-run --config ${{ inputs.rector-config }} + if [ -n "${{ inputs['rector-config'] }}" ]; then + vendor/bin/rector process --dry-run --config ${{ inputs['rector-config'] }} else vendor/bin/rector process --dry-run fi + + generate-matrix: + name: Generate Test Matrix + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.generate.outputs.matrix }} + steps: + - name: Generate matrix combinations + id: generate + run: | + cat > generate_matrix.py << 'PYTHON_SCRIPT' + import json + import sys + from itertools import product + + # Read inputs + versions_matrix = json.loads('''${{ inputs['versions-matrix'] }}''') + matrix_include = json.loads('''${{ inputs['matrix-include'] }}''') + matrix_exclude = json.loads('''${{ inputs['matrix-exclude'] }}''') + + # Separate PHP from other packages + php_versions = versions_matrix.get('php', []) + packages = {k: v for k, v in versions_matrix.items() if k != 'php'} + + # Generate Cartesian product of all package versions + if not packages: + # If no packages, just use PHP versions + combinations = [ + { + 'php': php, + 'dependencies': 'highest', + 'title': f"PHP {php}" + } + for php in php_versions + ] + else: + package_names = list(packages.keys()) + package_versions = [packages[name] for name in package_names] + + combinations = [] + for php in php_versions: + for combo in product(*package_versions): + combination = { + 'php': php, + 'dependencies': 'highest' + } + # Add each package version to the combination + for i, package_name in enumerate(package_names): + combination[package_name] = combo[i] + + # Generate title for this combination + title_parts = [f"PHP {php}"] + for i, package_name in enumerate(package_names): + # Shorten package name for display (e.g., "symfony/cache" -> "cache") + short_name = package_name.split('/')[-1] if '/' in package_name else package_name + title_parts.append(f"{short_name} {combo[i]}") + combination['title'] = ', '.join(title_parts) + + combinations.append(combination) + + # Add matrix-include items and generate titles if missing + for include_item in matrix_include: + if 'title' not in include_item: + # Generate title for matrix-include items + title_parts = [f"PHP {include_item.get('php', '?')}"] + for key, value in include_item.items(): + if key not in ['php', 'dependencies', 'coverage', 'title']: + short_name = key.split('/')[-1] if '/' in key else key + title_parts.append(f"{short_name} {value}") + if include_item.get('dependencies') == 'lowest': + title_parts.append('lowest deps') + if include_item.get('coverage'): + title_parts.append(f"coverage: {include_item['coverage']}") + include_item['title'] = ', '.join(title_parts) + combinations.append(include_item) + + # Filter out excluded combinations + def _normalize_version(val: str): + """ + Normalize versions so that values with trailing .* behave as a prefix filter. + Examples: + rule: "7.3.*" matches combo values "7.3", "7.3.*", "7.3.x", etc. (prefix "7.3") + rule: "7.3" requires exact match "7.3" (no wildcard semantics) + """ + return val[:-2] if isinstance(val, str) and val.endswith('.*') else val + + def _matches(rule_val, combo_val): + # Exact match when rule has no wildcard + if not (isinstance(rule_val, str) and rule_val.endswith('.*')): + return combo_val == rule_val + + # Wildcard/prefix match when rule ends with .* (treat as startswith of the prefix) + prefix = _normalize_version(rule_val) + + if not isinstance(combo_val, str): + return False + + # Also normalize combo like "7.3.*" to its prefix for matching consistency + combo_norm = _normalize_version(combo_val) + return str(combo_norm).startswith(str(prefix)) + + def should_exclude(combo, exclude_rules): + for rule in exclude_rules: + # All keys in rule must match the combo using wildcard-aware semantics + if all(_matches(v, combo.get(k)) for k, v in rule.items()): + return True + return False + + excluded = [] + final_combinations = [] + for c in combinations: + if should_exclude(c, matrix_exclude): + excluded.append(c) + else: + final_combinations.append(c) + + # Output as JSON + matrix_json = json.dumps({'include': final_combinations}) + print(f"Generated {len(final_combinations)} test combinations (excluded {len(excluded)})") + + # Write to GITHUB_OUTPUT + import os + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write(f"matrix={matrix_json}\n") + PYTHON_SCRIPT + + python3 generate_matrix.py + + - name: Display matrix + run: | + echo "Generated matrix:" + echo '${{ steps.generate.outputs.matrix }}' | jq . + + unit-tests: - name: Unit Tests (PHP ${{ matrix.php }}) + name: Unit Tests (${{ matrix.title }}) runs-on: ubuntu-latest + needs: generate-matrix timeout-minutes: 15 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} strategy: fail-fast: false - matrix: - php: ${{ fromJson(fromJson(inputs.versions-matrix).php) }} - dependencies: ['highest'] - versions-matrix: ['${{ inputs.versions-matrix }}'] - include: - - php: ${{ fromJson(fromJson(inputs.versions-matrix).php)[0] }} - dependencies: 'lowest' - coverage: ${{ inputs.enable-code-coverage && 'xdebug' || 'none' }} - versions-matrix: '${{ inputs.versions-matrix }}' - exclude: ${{ fromJson(inputs.matrix-exclude) }} + matrix: ${{ fromJSON(needs.generate-matrix.outputs.matrix) }} steps: - name: Checkout code @@ -386,11 +558,11 @@ jobs: with: php-version: ${{ matrix.php }} coverage: ${{ matrix.coverage || 'none' }} - extensions: ${{ inputs.php-extensions }} + extensions: ${{ inputs['php-extensions'] }}${{ matrix.coverage == 'xdebug' && ', xdebug' || '' }} - name: Get Composer cache directory id: composer-cache - working-directory: ${{ inputs.working-directory }} + working-directory: ${{ inputs['working-directory'] }} run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache Composer dependencies @@ -400,60 +572,283 @@ jobs: key: ${{ runner.os }}-php-${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: ${{ runner.os }}-php-${{ matrix.php }}-composer- - - name: Apply package version constraints + - name: Display matrix combination + run: | + echo "Testing with following package versions:" + echo '${{ toJSON(matrix) }}' | jq . + + - name: Apply package version constraints from matrix + id: apply-constraints working-directory: ${{ inputs.working-directory }} run: | - # Parse versions-matrix and apply constraints for all packages except PHP - echo "Applying version constraints from matrix..." - VERSIONS_JSON='${{ matrix.versions-matrix }}' - - # Extract and apply Symfony constraint if present - SYMFONY_VERSION=$(echo "$VERSIONS_JSON" | jq -r '."symfony/framework-bundle"[0] // empty') - if [ ! -z "$SYMFONY_VERSION" ]; then - echo "Setting Symfony framework-bundle to $SYMFONY_VERSION" - composer require "symfony/framework-bundle:$SYMFONY_VERSION" --no-update --no-interaction - composer config extra.symfony.require "$SYMFONY_VERSION" + echo "Applying package version constraints from matrix combination..." + + # Get all matrix keys except system keys + MATRIX_JSON='${{ toJSON(matrix) }}' + + # Extract and apply each package version from matrix + # Skip system keys: php, dependencies, coverage, title, description + PACKAGES_STR="" + PACKAGES_JSON="{}" + SYMFONY_VERSION="" + + for package in $(echo "$MATRIX_JSON" | jq -r 'keys[]' | grep -v -E '^(php|dependencies|coverage|title|description)$'); do + VERSION=$(echo "$MATRIX_JSON" | jq -r --arg pkg "$package" '.[$pkg]') + + if [ ! -z "$VERSION" ] && [ "$VERSION" != "null" ] && [ "$VERSION" != "" ]; then + echo "Setting $package to $VERSION" + composer require "$package:$VERSION" --no-update --no-interaction || echo "Warning: Could not set constraint for $package" + + # Set Symfony version globally if this is symfony/framework-bundle + if [ "$package" = "symfony/framework-bundle" ]; then + echo "Setting Symfony global version constraint" + composer config extra.symfony.require "$VERSION" || true + SYMFONY_VERSION="$VERSION" + fi + + # Build outputs + if [ -z "$PACKAGES_STR" ]; then + PACKAGES_STR="$package:$VERSION" + else + PACKAGES_STR="$PACKAGES_STR $package:$VERSION" + fi + PACKAGES_JSON=$(echo "$PACKAGES_JSON" | jq --arg k "$package" --arg v "$VERSION" '. + {($k): $v}') + fi + done + + # If Symfony version wasn't set via framework-bundle, try to infer from any symfony/* package in the matrix + if [ -z "$SYMFONY_VERSION" ]; then + SYMFONY_VERSION=$(echo "$MATRIX_JSON" | jq -r 'to_entries | map(select(.key|test("^symfony/"))) | (.[0].value // "")') + if [ -n "$SYMFONY_VERSION" ]; then + echo "Inferred Symfony version $SYMFONY_VERSION from symfony/* package; setting extra.symfony.require" + composer config extra.symfony.require "$SYMFONY_VERSION" || true + fi fi - # Apply constraints for all other packages - for package in $(echo "$VERSIONS_JSON" | jq -r 'keys[]' | grep -v '^php$' | grep -v '^symfony/framework-bundle$'); do - VERSION=$(echo "$VERSIONS_JSON" | jq -r --arg pkg "$package" '.[$pkg][0]') - echo "Setting $package to $VERSION" - composer require "$package:$VERSION" --no-update --no-interaction || echo "Warning: Could not set constraint for $package" - done + echo "โœ“ Package version constraints applied successfully" - - name: Install dependencies (highest) - if: matrix.dependencies == 'highest' - working-directory: ${{ inputs.working-directory }} - run: composer update --no-interaction --no-progress --prefer-dist + # Export step outputs + echo "composer_packages=$PACKAGES_STR" >> "$GITHUB_OUTPUT" + echo "composer_packages_json=$(echo "$PACKAGES_JSON" | jq -c '.')" >> "$GITHUB_OUTPUT" + echo "symfony_version=$SYMFONY_VERSION" >> "$GITHUB_OUTPUT" - - name: Install dependencies (lowest) - if: matrix.dependencies == 'lowest' - working-directory: ${{ inputs.working-directory }} - run: composer update --no-interaction --no-progress --prefer-dist --prefer-lowest --prefer-stable + - name: Install dependencies + working-directory: ${{ inputs['working-directory'] }} + env: + # Pass detected Symfony version to Composer/Symfony Flex, if any + SYMFONY_REQUIRE: ${{ steps.apply-constraints.outputs.symfony_version }} + run: | + # Make sure Flex is allowed/available (if not already in your workflow) + composer global config --no-plugins allow-plugins.symfony/flex true + composer global require --no-progress --no-scripts --no-plugins symfony/flex + if [ -n "${SYMFONY_REQUIRE}" ]; then + echo "Using Symfony constraint: ${SYMFONY_REQUIRE}" + composer update --prefer-dist --no-progress --no-interaction + else + echo "No SYMFONY_REQUIRE set, using lock file" + composer install --prefer-dist --no-progress --no-interaction + fi - name: Run PHPUnit if: matrix.coverage != 'xdebug' - working-directory: ${{ inputs.working-directory }} - run: vendor/bin/phpunit --testdox + working-directory: ${{ inputs['working-directory'] }} + run: vendor/bin/phpunit --testdox --no-coverage - name: Run PHPUnit with coverage if: matrix.coverage == 'xdebug' - working-directory: ${{ inputs.working-directory }} - run: vendor/bin/phpunit --testdox --coverage-clover=coverage.xml + working-directory: ${{ inputs['working-directory'] }} + run: | + # Generate both Clover XML (for Codecov and fallback) and raw PHP coverage (for phpcov merge) + vendor/bin/phpunit \ + --testdox \ + --coverage-clover=${{ inputs['coverage-file'] }} \ + --coverage-php=coverage.cov - name: Upload coverage to Codecov - if: matrix.coverage == 'xdebug' && secrets.codecov-token != '' uses: codecov/codecov-action@v4 + if: matrix.coverage == 'xdebug' && env.CODECOV_TOKEN != '' with: - token: ${{ secrets.codecov-token }} - files: ${{ inputs.working-directory }}/coverage.xml + token: ${{ env.CODECOV_TOKEN }} + files: ${{ inputs['working-directory'] }}/${{ inputs['coverage-file'] }} fail_ci_if_error: false + - name: Upload coverage artifact for validation + if: matrix.coverage == 'xdebug' + uses: actions/upload-artifact@v4 + with: + name: coverage-report-${{ matrix.php }}-${{ github.run_id }} + path: | + ${{ inputs['working-directory'] }}/${{ inputs['coverage-file'] }} + ${{ inputs['working-directory'] }}/coverage.cov + retention-days: 1 + if-no-files-found: warn + + + coverage-validation: + name: Coverage Validation + runs-on: ubuntu-latest + needs: unit-tests + if: inputs['enable-coverage-check'] && github.event_name == 'pull_request' + env: + COVERAGE_FILE: ${{ inputs['coverage-file'] }} + permissions: + contents: read + pull-requests: write + + steps: + - name: Setup PHP for phpcov + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs['php-version-quality-tools'] }} + coverage: none + + - name: Install coverage tools (phpcov PHAR + phpunit-coverage-check) + run: | + set -e + # Download phpcov PHAR for requested version + PHPCOV_VERSION="${{ inputs['phpcov-version'] }}" + PHPCOV_URL="https://phar.phpunit.de/phpcov-${PHPCOV_VERSION}.phar" + echo "Downloading phpcov from ${PHPCOV_URL}" + wget -q -O phpcov.phar "$PHPCOV_URL" + chmod +x phpcov.phar + + # Install phpunit-coverage-check globally via Composer + composer global require --no-interaction --no-progress \ + rregeer/phpunit-coverage-check:* + # Add Composer global bin to PATH for both Composer 1 and 2 locations + echo "$HOME/.composer/vendor/bin" >> $GITHUB_PATH + echo "$HOME/.config/composer/vendor/bin" >> $GITHUB_PATH + + - name: Download coverage artifacts + uses: actions/download-artifact@v4 + with: + pattern: coverage-report-* + path: coverage-artifacts + merge-multiple: false + continue-on-error: true + + - name: Merge coverage reports with phpcov + id: phpcov-merge + run: | + set -e + if [ ! -d "coverage-artifacts" ] || [ -z "$(ls -A coverage-artifacts || true)" ]; then + echo "No coverage artifacts to merge" + echo "merged=false" >> $GITHUB_OUTPUT + exit 0 + fi + + # Build list of directories that contain raw PHP coverage files produced by PHPUnit (--coverage-php) + mapfile -t COVERAGE_DIRS < <(find coverage-artifacts -type f -name "coverage.cov" -exec dirname {} \; | sort -u) + + if [ ${#COVERAGE_DIRS[@]} -eq 0 ]; then + echo "No raw PHP coverage files (coverage.cov) found for merging" + echo "merged=false" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "Found ${#COVERAGE_DIRS[@]} coverage directories to merge" + printf '%s\n' "${COVERAGE_DIRS[@]}" | sed 's/^/- /' + + # Try merging with phpcov; if it fails, we will fallback later + set +e + php phpcov.phar merge --clover merged-coverage.xml "${COVERAGE_DIRS[@]}" + EXIT_CODE=$? + set -e + + if [ $EXIT_CODE -ne 0 ] || [ ! -s merged-coverage.xml ]; then + echo "phpcov merge failed or produced empty output; will continue without merged file" + echo "merged=false" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "phpcov merge succeeded" + echo "merged=true" >> $GITHUB_OUTPUT + + - name: Parse coverage and validate threshold (coverage-check) + id: coverage + env: + COVERAGE_THRESHOLD: ${{ inputs['coverage-threshold'] }} + run: | + set -e + # Ensure artifacts exist + if [ ! -d "coverage-artifacts" ] || [ -z "$(ls -A coverage-artifacts || true)" ]; then + echo "โš ๏ธ No coverage artifacts found" + echo "coverage=0.0" >> $GITHUB_OUTPUT + echo "status=no-coverage" >> $GITHUB_OUTPUT + exit 0 + fi + + # Determine target files for checking + if [ -s "merged-coverage.xml" ]; then + TARGETS=("merged-coverage.xml") + else + mapfile -t TARGETS < <(find coverage-artifacts -type f -name "${{ inputs['coverage-file'] }}") + fi + + if [ ${#TARGETS[@]} -eq 0 ]; then + echo "โš ๏ธ No Clover XML files found" + echo "coverage=0.0" >> $GITHUB_OUTPUT + echo "status=no-coverage" >> $GITHUB_OUTPUT + exit 0 + fi + + # Run coverage-check (from rregeer/phpunit-coverage-check). If multiple files, record minimum coverage and mark failure if any fail. + OVERALL_STATUS=success + MIN_COVERAGE="" + for FILE in "${TARGETS[@]}"; do + set +e + coverage-check "$FILE" "${COVERAGE_THRESHOLD}" | tee coverage_check_output.txt + EXIT_CODE=$? + set -e + # Extract the current coverage percentage (first percentage in output) strictly from command output only. + # Accept optional space before % and comma decimals, normalize to dot. + PCT=$(grep -Eo '([0-9]+([\.,][0-9]+)?)\s*%' coverage_check_output.txt | head -n1 | sed -E 's/%//; s/,/./; s/^[[:space:]]+|[[:space:]]+$//g') + # If percentage not found in output, default to 0.00 (no file/XML parsing fallback by design) + if [ -z "$PCT" ]; then PCT="0.00"; fi + + if [ -z "$MIN_COVERAGE" ]; then + MIN_COVERAGE="$PCT" + else + awk -v a="$MIN_COVERAGE" -v b="$PCT" 'BEGIN { exit !(a+0 > b+0) }' || MIN_COVERAGE="$PCT" + fi + + if [ $EXIT_CODE -ne 0 ]; then + OVERALL_STATUS=failure + fi + done + + echo "coverage=${MIN_COVERAGE:-0.00}" >> $GITHUB_OUTPUT + echo "status=$OVERALL_STATUS" >> $GITHUB_OUTPUT + + - name: Create or update PR comment + uses: peter-evans/create-or-update-comment@v5 + if: always() && steps.coverage.outputs.status != 'no-coverage' + with: + issue-number: ${{ github.event.pull_request.number }} + body: | + + ## ๐Ÿ“Š Code Coverage Report + ${{ steps.coverage.outputs.status == 'success' + && format('โœ… Coverage: {0}% (min {1}%) โ€” OK', steps.coverage.outputs.coverage, inputs['coverage-threshold']) + || format('โŒ Coverage: {0}% (min {1}%) โ€” NOT OK', steps.coverage.outputs.coverage, inputs['coverage-threshold']) + }} + + --- + Generated by [Macpaw Symfony PHP Reusable Workflow](https://github.com/MacPaw/github-actions) + body-includes: '' + edit-mode: replace + + - name: Fail if coverage below threshold + if: steps.coverage.outputs.status == 'failure' + run: | + echo "โŒ Coverage ${{ steps.coverage.outputs.coverage }}% is below threshold ${{ inputs['coverage-threshold'] }}%" + exit 1 + + infection: name: Infection Mutation Testing runs-on: ubuntu-latest - if: inputs.enable-infection + if: inputs['enable-infection'] timeout-minutes: 20 steps: - name: Checkout code @@ -462,13 +857,13 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: ${{ inputs['php-version-quality-tools'] }} coverage: xdebug - extensions: ${{ inputs.php-extensions }} + extensions: ${{ inputs['php-extensions'] }} - name: Get Composer cache directory id: composer-cache - working-directory: ${{ inputs.working-directory }} + working-directory: ${{ inputs['working-directory'] }} run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache Composer dependencies @@ -479,15 +874,15 @@ jobs: restore-keys: ${{ runner.os }}-composer- - name: Install dependencies - working-directory: ${{ inputs.working-directory }} + working-directory: ${{ inputs['working-directory'] }} run: composer install --no-interaction --no-progress --prefer-dist - name: Run Infection - working-directory: ${{ inputs.working-directory }} + working-directory: ${{ inputs['working-directory'] }} run: | - CMD="vendor/bin/infection --min-msi=${{ inputs.infection-min-msi }} --min-covered-msi=${{ inputs.infection-min-covered-msi }} --threads=4 --no-progress" - if [ -n "${{ inputs.infection-config }}" ]; then - CMD="$CMD --configuration=${{ inputs.infection-config }}" + CMD="vendor/bin/infection --min-msi=${{ inputs['infection-min-msi'] }} --min-covered-msi=${{ inputs['infection-min-covered-msi'] }} --threads=4 --no-progress" + if [ -n "${{ inputs['infection-config'] }}" ]; then + CMD="$CMD --configuration=${{ inputs['infection-config'] }}" fi $CMD env: diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..573d429 --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,183 @@ +{ + "branches": [ + "main", + "master", + { + "name": "next", + "prerelease": true + }, + { + "name": "beta", + "prerelease": true + }, + { + "name": "alpha", + "prerelease": true + }, + { + "name": "[0-9]+.x", + "range": "${name.replace(/\\.x$/, '')}", + "channel": "${name}" + }, + { + "name": "[0-9]+.[0-9]+.x", + "range": "${name.replace(/\\.x$/, '')}", + "channel": "${name}" + } + ], + "preset": "conventionalcommits", + "plugins": [ + [ + "@semantic-release/commit-analyzer", + { + "preset": "conventionalcommits", + "releaseRules": [ + { + "type": "feat", + "release": "minor" + }, + { + "type": "fix", + "release": "patch" + }, + { + "type": "perf", + "release": "patch" + }, + { + "type": "revert", + "release": "patch" + }, + { + "type": "docs", + "scope": "README", + "release": "patch" + }, + { + "type": "refactor", + "release": "patch" + }, + { + "type": "style", + "release": false + }, + { + "type": "chore", + "release": false + }, + { + "type": "test", + "release": false + }, + { + "scope": "no-release", + "release": false + }, + { + "breaking": true, + "release": "major" + } + ], + "parserOpts": { + "noteKeywords": [ + "BREAKING CHANGE", + "BREAKING CHANGES", + "BREAKING" + ] + } + } + ], + [ + "@semantic-release/release-notes-generator", + { + "preset": "conventionalcommits", + "presetConfig": { + "types": [ + { + "type": "feat", + "section": "โœจ Features" + }, + { + "type": "fix", + "section": "๐Ÿ› Bug Fixes" + }, + { + "type": "perf", + "section": "โšก Performance Improvements" + }, + { + "type": "revert", + "section": "โช Reverts" + }, + { + "type": "docs", + "section": "๐Ÿ“š Documentation" + }, + { + "type": "style", + "section": "๐Ÿ’„ Styles", + "hidden": true + }, + { + "type": "refactor", + "section": "โ™ป๏ธ Code Refactoring" + }, + { + "type": "test", + "section": "โœ… Tests", + "hidden": true + }, + { + "type": "build", + "section": "๐Ÿ—๏ธ Build System" + }, + { + "type": "ci", + "section": "๐Ÿ‘ท CI/CD" + }, + { + "type": "chore", + "section": "๐Ÿ”ง Maintenance", + "hidden": true + } + ] + }, + "writerOpts": { + "commitsSort": [ + "subject", + "scope" + ] + } + } + ], + [ + "@semantic-release/changelog", + { + "changelogFile": "CHANGELOG.md", + "changelogTitle": "# Changelog\n\nAll notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines." + } + ], + [ + "@semantic-release/github", + { + "successComment": false, + "failComment": false, + "releasedLabels": false, + "addReleases": "bottom" + } + ], + [ + "@semantic-release/git", + { + "assets": [ + "CHANGELOG.md", + "package.json", + "package-lock.json", + "composer.json", + "composer.lock" + ], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + } + ] + ] +} diff --git a/README.md b/README.md index f23a5f5..de45a11 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,18 @@ All reusable workflows are located in `.github/workflows` folder - โšก **Performance Optimized** - Parallel execution, intelligent caching - [๐Ÿ“š Detailed Documentation](docs/workflows/symfony-php-reusable.md) +- [release-reusable.yml](.github/workflows/release-reusable.yml) - Fully automated release workflow using semantic-release + - ๐Ÿค– **Fully Automated** - No manual version bumping or changelog writing required + - โœ… **Semantic Versioning** - Automatic version calculation from commit messages + - ๐Ÿท๏ธ **Auto Tag & Release** - Automatically creates tags and GitHub releases + - ๐Ÿ“ **Auto-Generated Changelogs** - Beautiful, categorized release notes + - ๐ŸŽฏ **Conventional Commits** - Based on conventional commit standards + - ๐Ÿ”€ **Multi-Branch Support** - main/master, next, beta, alpha, maintenance branches + - ๐Ÿ›ก๏ธ **Safe & Idempotent** - Won't create duplicate releases + - ๐Ÿงช **Dry Run Mode** - Test releases without publishing + - ๐Ÿ”‘ **Secure Authentication** - Uses GH_TOKEN repository secret for release operations + - [๐Ÿ“š Detailed Documentation](docs/workflows/release.md) + ### Documentation - [Symfony PHP Reusable Workflow](docs/workflows/symfony-php-reusable.md) - Complete workflow documentation with: @@ -35,6 +47,23 @@ All reusable workflows are located in `.github/workflows` folder - ๐Ÿ“Š Analysis of MacPaw's Symfony repositories - ๐Ÿ“ˆ Best practices and recommendations +- [Release Workflow](docs/workflows/release.md) - Complete semantic-release documentation with: + - ๐Ÿค– Fully automated release process using semantic-release + - ๐Ÿ“ Conventional commits specification and examples + - ๐Ÿท๏ธ Automatic version calculation (feat โ†’ minor, fix โ†’ patch, BREAKING โ†’ major) + - ๐Ÿš€ Multiple usage examples (feature, bugfix, breaking changes, pre-releases) + - ๐Ÿ”€ Multi-branch release channels (main, beta, alpha, maintenance) + - ๐Ÿ“Š Auto-generated categorized changelogs + - ๐Ÿงช Dry run mode for testing releases + - ๐Ÿ”‘ GH_TOKEN secret configuration and setup guide + - ๐Ÿ” GitHub App token support for organizations + - ๐ŸŽฏ Fine-grained Personal Access Token (PAT) instructions + - โš™๏ธ Configuration via .releaserc.json + - ๐Ÿ”ง Comprehensive troubleshooting guide + - ๐Ÿ“‹ Best practices for conventional commits + - ๐Ÿ› ๏ธ Advanced configuration and customization examples + - ๐Ÿ”„ Migration guide from manual releases + ### Contributing Contributions are welcome! When adding new features: @@ -58,4 +87,5 @@ For information about reporting security vulnerabilities, please see our [Securi - [MacPaw GitHub Organization](https://github.com/MacPaw/) - [GitHub Actions Documentation](https://docs.github.com/en/actions) - [Symfony PHP Package reusable workflow](docs/workflows/symfony-php-reusable.md) +- [Release workflow](docs/workflows/release.md) - [Repository Analysis](ANALYSIS.md) diff --git a/docs/workflows/release.md b/docs/workflows/release.md new file mode 100644 index 0000000..a83e340 --- /dev/null +++ b/docs/workflows/release.md @@ -0,0 +1,785 @@ +# Release Workflow + +Automated release workflow using semantic-release for fully automated version management and package publishing. + +## Overview + +The release workflow uses [semantic-release](https://semantic-release.gitbook.io/) to automate the entire release process based on [Conventional Commits](https://www.conventionalcommits.org/). It automatically determines the next version number, generates release notes, creates GitHub releases, and updates the changelog - all without manual intervention. + +## Features + +- ๐Ÿค– **Fully Automated** - No manual version bumping or changelog writing +- โœ… **Semantic Versioning** - Automatic version calculation based on commit messages +- ๐Ÿ“ **Auto-Generated Changelogs** - Beautiful, categorized release notes +- ๐Ÿท๏ธ **Auto Tag & Release** - Automatically creates tags and GitHub releases +- ๐Ÿ”€ **Multi-Branch Support** - main/master, next, beta, alpha, and maintenance branches +- ๐ŸŽฏ **Conventional Commits** - Enforces commit message standards +- ๐Ÿ›ก๏ธ **Safe & Idempotent** - Won't create duplicate releases +- ๐Ÿงช **Dry Run Mode** - Test releases without publishing +- ๐Ÿ”‘ **Flexible Token** - Use default or custom GitHub token + +## How It Works + +1. **Analyze Commits** - Scans commits since last release using Conventional Commits format +2. **Determine Version** - Calculates next version based on commit types: + - `feat:` โ†’ Minor version bump (1.0.0 โ†’ 1.1.0) + - `fix:` โ†’ Patch version bump (1.0.0 โ†’ 1.0.1) + - `BREAKING CHANGE:` โ†’ Major version bump (1.0.0 โ†’ 2.0.0) +3. **Generate Changelog** - Creates categorized release notes +4. **Create Tag** - Creates git tag with version number +5. **Publish Release** - Creates GitHub release with notes +6. **Update Files** - Commits CHANGELOG.md back to repository + +## Triggers + +### 1. Automatic (Push to Branch) + +The workflow automatically runs when code is pushed to release branches: + +```bash +# Push to main/master branch +git push origin main + +# Semantic-release will: +# 1. Analyze commits since last release +# 2. Determine if a release is needed +# 3. Calculate next version +# 4. Create tag and release automatically +``` + +**Supported branches:** +- `main` or `master` - Production releases +- `next` - Pre-releases for next major version +- `beta` - Beta pre-releases +- `alpha` - Alpha pre-releases +- `N.x` or `N.N.x` - Maintenance releases (e.g., `1.x`, `1.0.x`) + +### 2. Manual Trigger (Workflow Dispatch) + +Manually trigger the workflow from GitHub Actions UI or CLI: + +```bash +# Using GitHub CLI +gh workflow run release.yml + +# With dry run (no release will be created) +gh workflow run release.yml -f dry_run=true +``` + +## Conventional Commits + +Semantic-release requires commits to follow the [Conventional Commits](https://www.conventionalcommits.org/) specification. + +### Commit Format + +``` +(): + + + +