diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index be2c8123..375dc1fb 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -1,1612 +1,1698 @@ -name: Quality - -on: - workflow_call: - inputs: - app-name: - description: "Nextcloud app ID (used for checkout path in NC server)" - required: true - type: string - php-version: - description: "Primary PHP version for linting" - required: false - type: string - default: "8.3" - php-test-versions: - description: "JSON array of PHP versions for PHPUnit matrix" - required: false - type: string - default: '["8.3", "8.4"]' - nextcloud-test-refs: - description: "JSON array of Nextcloud server branches for PHPUnit matrix" - required: false - type: string - default: '["stable31", "stable32"]' - enable-psalm: - description: "Run Psalm static analysis" - required: false - type: boolean - default: true - enable-phpstan: - description: "Run PHPStan static analysis" - required: false - type: boolean - default: true - enable-phpmd: - description: "Run PHPMD mess detection" - required: false - type: boolean - default: true - enable-phpcs: - description: "Run PHPCS code style checks" - required: false - type: boolean - default: true - enable-phpmetrics: - description: "Run phpmetrics code metrics" - required: false - type: boolean - default: true - enable-frontend: - description: "Run frontend quality checks (ESLint + Stylelint)" - required: false - type: boolean - default: true - enable-eslint: - description: "Run ESLint (requires enable-frontend)" - required: false - type: boolean - default: true - enable-license-check: - description: "Run dependency license compliance check (composer + npm)" - required: false - type: boolean - default: true - enable-phpunit: - description: "Run PHPUnit tests against Nextcloud server" - required: false - type: boolean - default: false - enable-newman: - description: "Run Newman integration tests against Nextcloud server" - required: false - type: boolean - default: false - newman-collection-path: - description: "Path to Newman/Postman collection files (relative to app root)" - required: false - type: string - default: "tests/integration" - newman-environment-path: - description: "Path to Postman environment JSON file (relative to app root). When set, passes --environment to Newman instead of ad-hoc --env-var flags." - required: false - type: string - default: "" - newman-seed-command: - description: "Shell command to run after server start but before Newman tests (e.g. 'php occ maintenance:repair' to seed test fixtures). Runs with cwd set to the Nextcloud server root." - required: false - type: string - default: "" - enable-playwright: - description: "Run Playwright E2E browser tests against Nextcloud server" - required: false - type: boolean - default: false - playwright-test-path: - description: "Path to Playwright test files (relative to app root)" - required: false - type: string - default: "tests/e2e" - playwright-seed-command: - description: "Shell command to run after server start but before Playwright tests (e.g. 'php occ maintenance:repair' to seed test fixtures). Runs with cwd set to the Nextcloud server root." - required: false - type: string - default: "" - enable-playwright-coverage: - description: "Collect V8 code coverage during Playwright tests and enforce threshold" - required: false - type: boolean - default: false - playwright-coverage-threshold: - description: "Minimum line coverage percentage for Playwright tests (0-100). Only enforced when enable-playwright-coverage is true." - required: false - type: number - default: 75 - additional-apps: - description: "JSON array of additional app repos to checkout and enable (e.g. '[{\"repo\":\"ConductionNL/openregister\",\"app\":\"openregister\"}]')" - required: false - type: string - default: "[]" - enable-coverage-guard: - description: "Run coverage baseline guard and auto-update" - required: false - type: boolean - default: false - enable-sbom: - description: "Generate CycloneDX SBOM, validate (CVE + license), and commit to repo" - required: false - type: boolean - default: true - -concurrency: - group: quality-${{ github.ref }} - cancel-in-progress: true - -jobs: - # ╔══════════════════════════════════════════════╗ - # ║ STAGE 1 — Four parallel quality gates ║ - # ║ ║ - # ║ Each group uses a matrix strategy so GitHub ║ - # ║ renders it as ONE collapsible box with ║ - # ║ individual tool results visible inside. ║ - # ║ ║ - # ║ ┌────────────┐ ┌────────────┐ ║ - # ║ │PHP Quality │ │Vue Quality │ ║ - # ║ │ ◦ lint │ │ ◦ eslint │ ║ - # ║ │ ◦ phpcs │ │ ◦ stylelint│ ║ - # ║ │ ◦ phpmd │ └────────────┘ ║ - # ║ │ ◦ psalm │ ┌────────────┐ ║ - # ║ │ ◦ phpstan │ │ License │ ║ - # ║ │ ◦ metrics │ │ ◦ composer │ ║ - # ║ └────────────┘ │ ◦ npm │ ║ - # ║ ┌────────────┐ └────────────┘ ║ - # ║ │ Security │ ║ - # ║ │ ◦ composer │ ║ - # ║ │ ◦ npm │ ║ - # ║ └────────────┘ ║ - # ╚══════════════════════════════════════════════╝ - - # ── Group 1: PHP Quality ──────────────────────── - php-quality: - runs-on: ubuntu-latest - name: "PHP Quality (${{ matrix.tool }})" - strategy: - matrix: - tool: [lint, phpcs, phpmd, psalm, phpstan, phpmetrics] - fail-fast: false - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ inputs.php-version }} - extensions: mbstring, intl, zip, gd, curl, xml, json - tools: composer:v2 - - - name: Cache Composer dependencies - uses: actions/cache@v4 - with: - path: vendor - key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }} - restore-keys: ${{ runner.os }}-composer- - - - name: Install dependencies - run: composer install --no-progress --prefer-dist --optimize-autoloader - - - name: Run ${{ matrix.tool }} - run: | - case "${{ matrix.tool }}" in - lint) - composer lint - ;; - phpcs) - if [ "${{ inputs.enable-phpcs }}" != "true" ]; then - echo "PHPCS is disabled — skipping." - exit 0 - fi - # Exit code 0 = clean, 1 = warnings only, 2 = errors found. - # We treat warnings as non-blocking (exit 1 → success). - composer phpcs; RC=$? - if [ "$RC" -eq 1 ]; then - echo "::warning::PHPCS found warnings but no errors — passing." - exit 0 - fi - exit $RC - ;; - phpmd) - if [ "${{ inputs.enable-phpmd }}" != "true" ]; then - echo "PHPMD is disabled — skipping." - exit 0 - fi - composer phpmd - ;; - psalm) - if [ "${{ inputs.enable-psalm }}" != "true" ]; then - echo "Psalm is disabled — skipping." - exit 0 - fi - composer psalm - ;; - phpstan) - if [ "${{ inputs.enable-phpstan }}" != "true" ]; then - echo "PHPStan is disabled — skipping." - exit 0 - fi - composer phpstan - ;; - phpmetrics) - if [ "${{ inputs.enable-phpmetrics }}" != "true" ]; then - echo "phpmetrics is disabled — skipping." - exit 0 - fi - composer phpmetrics - ;; - esac - - - name: Record result - if: always() - run: | - mkdir -p quality-results - echo "${{ job.status }}" > quality-results/${{ matrix.tool }}.txt - - name: Upload result - if: always() - uses: actions/upload-artifact@v4 - with: - name: result-php-quality-${{ matrix.tool }} - path: quality-results/ - - # ── Group 2: Vue Quality ──────────────────────── - vue-quality: - if: ${{ inputs.enable-frontend }} - runs-on: ubuntu-latest - name: "Vue Quality (${{ matrix.tool }})" - strategy: - matrix: - tool: [eslint, stylelint] - fail-fast: false - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - - - name: Install dependencies - run: npm ci - - - name: Run ${{ matrix.tool }} - run: | - case "${{ matrix.tool }}" in - eslint) - if [ "${{ inputs.enable-eslint }}" != "true" ]; then - echo "ESLint is disabled — skipping." - exit 0 - fi - npm run lint - ;; - stylelint) - npm run stylelint - ;; - esac - - - name: Record result - if: always() - run: | - mkdir -p quality-results - echo "${{ job.status }}" > quality-results/${{ matrix.tool }}.txt - - name: Upload result - if: always() - uses: actions/upload-artifact@v4 - with: - name: result-vue-quality-${{ matrix.tool }} - path: quality-results/ - - # ── Group 3: Security ────────────────────────── - security: - runs-on: ubuntu-latest - name: "Security (${{ matrix.ecosystem }})" - strategy: - matrix: - ecosystem: [composer, npm] - fail-fast: false - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP - if: matrix.ecosystem == 'composer' - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ inputs.php-version }} - tools: composer:v2 - - - name: Setup Node.js - if: matrix.ecosystem == 'npm' - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - - - name: Run ${{ matrix.ecosystem }} audit - run: | - case "${{ matrix.ecosystem }}" in - composer) - composer install --no-progress --prefer-dist --optimize-autoloader - composer audit --format=plain --abandoned=report - ;; - npm) - npm ci - npm audit --audit-level=critical --omit=dev - ;; - esac - - - name: Record result - if: always() - run: | - mkdir -p quality-results - echo "${{ job.status }}" > quality-results/${{ matrix.ecosystem }}.txt - - name: Upload result - if: always() - uses: actions/upload-artifact@v4 - with: - name: result-security-${{ matrix.ecosystem }} - path: quality-results/ - - # ── Group 4: License Compliance ───────────────── - # - # Checks dependencies against an allowlist of approved SPDX license - # identifiers. Generates a license report as a workflow artifact for - # audit/compliance purposes. - # - # Configuration (optional files in your app repo root): - # - # .license-allowlist.json - # ───────────────────────────────────────────── - # A JSON array of approved SPDX license identifiers. If this file is - # absent, the built-in default allowlist is used (see step below). - # Example: - # ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", - # "LGPL-2.1-only", "LGPL-2.1-or-later", "LGPL-3.0-only", - # "LGPL-3.0-or-later", "EUPL-1.2", "AGPL-3.0-or-later", - # "GPL-2.0-only", "GPL-2.0-or-later", "GPL-3.0-only", - # "GPL-3.0-or-later", "MPL-2.0", "Unlicense", "0BSD", - # "CC0-1.0", "CC-BY-3.0", "CC-BY-4.0", "PSF-2.0", - # "Zlib", "BlueOak-1.0.0", "OSL-3.0"] - # - # .license-overrides.json - # ───────────────────────────────────────────── - # A JSON object to manually approve specific packages even if their - # license is not on the allowlist. Use this for packages where: - # - The license is valid but uses a non-standard SPDX string - # - Legal has explicitly approved a specific dependency - # - The package is dual-licensed and the checker picks the wrong one - # - # Keys are package names (composer: "vendor/package", npm: "package-name"). - # Values are a justification string (who approved it and when). - # Example: - # { - # "vendor/some-package": "Approved by legal (Jan de Vries) — 2026-03-14, dual-licensed MIT/proprietary, we use MIT terms", - # "some-npm-package": "License is 'Artistic-2.0', compatible with EUPL — approved 2026-03-01" - # } - # - # How it works: - # 1. Extracts all dependency licenses from composer or npm - # 2. For dual-licensed packages ("MIT OR Apache-2.0"), passes if ANY - # license in the OR expression is on the allowlist - # 3. Checks each package against overrides first, then the allowlist - # 4. Generates a markdown + JSON report uploaded as a workflow artifact - # 5. Fails the workflow if any dependency has a disallowed license - # ──────────────────────────────────────────────── - license: - if: ${{ inputs.enable-license-check }} - runs-on: ubuntu-latest - name: "License (${{ matrix.ecosystem }})" - strategy: - matrix: - ecosystem: [composer, npm] - fail-fast: false - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP - if: matrix.ecosystem == 'composer' - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ inputs.php-version }} - tools: composer:v2 - - - name: Setup Node.js - if: matrix.ecosystem == 'npm' - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - - - name: Install dependencies - run: | - if [ "${{ matrix.ecosystem }}" = "composer" ]; then - composer install --no-progress --prefer-dist --optimize-autoloader - else - npm ci - npm install -g license-checker - fi - - - name: Check ${{ matrix.ecosystem }} licenses - run: | - set -euo pipefail - - # ── Default allowlist (EUPL-1.2 / AGPL-3.0 compatible) ── - DEFAULT_ALLOWLIST='[ - "MIT", "ISC", "BSD-2-Clause", "BSD-3-Clause", "0BSD", - "Apache-2.0", - "LGPL-2.0-only", "LGPL-2.0-or-later", - "LGPL-2.1-only", "LGPL-2.1-or-later", - "LGPL-3.0-only", "LGPL-3.0-or-later", - "GPL-2.0-only", "GPL-2.0-or-later", - "GPL-3.0-only", "GPL-3.0-or-later", - "AGPL-3.0-only", "AGPL-3.0-or-later", - "EUPL-1.1", "EUPL-1.2", - "MPL-2.0", "OSL-3.0", - "Unlicense", "CC0-1.0", "CC-BY-3.0", "CC-BY-4.0", - "PSF-2.0", "Python-2.0", "Zlib", "BlueOak-1.0.0", - "Artistic-2.0", "BSL-1.0", "WTFPL", - "OFL-1.1", "OFL-1.0" - ]' - - if [ -f .license-allowlist.json ]; then - ALLOWLIST=$(cat .license-allowlist.json) - echo "Using project .license-allowlist.json" - else - ALLOWLIST="$DEFAULT_ALLOWLIST" - echo "Using built-in default allowlist" - fi - - if [ -f .license-overrides.json ]; then - OVERRIDES=$(cat .license-overrides.json) - echo "Loaded .license-overrides.json with $(echo "$OVERRIDES" | jq 'length') override(s)" - else - OVERRIDES='{}' - fi - - mkdir -p license-report - - # Helper: check if a license string is allowed - # Handles SPDX OR expressions and trailing "*" from license-checker - check_license() { - local license_str="$1" - IFS='|' read -ra PARTS <<< "$(echo "$license_str" | sed 's/ OR /|/g; s/ or /|/g')" - for part in "${PARTS[@]}"; do - part=$(echo "$part" | sed 's/^ *//;s/ *$//' | sed 's/[()]//g' | sed 's/\*$//') - if echo "$ALLOWLIST" | jq -e --arg l "$part" 'map(ascii_downcase) | index($l | ascii_downcase)' > /dev/null 2>&1; then - return 0 - fi - done - return 1 - } - - VIOLATIONS=0 - REPORT_JSON='[]' - ECOSYSTEM="${{ matrix.ecosystem }}" - - if [ "$ECOSYSTEM" = "composer" ]; then - echo "" - echo "=== Checking Composer dependencies ===" - COMPOSER_JSON=$(composer licenses --format=json 2>/dev/null || echo '{"dependencies":{}}') - - for pkg in $(echo "$COMPOSER_JSON" | jq -r '.dependencies | keys[]' 2>/dev/null); do - license=$(echo "$COMPOSER_JSON" | jq -r --arg p "$pkg" '.dependencies[$p].license | join(" OR ")') - version=$(echo "$COMPOSER_JSON" | jq -r --arg p "$pkg" '.dependencies[$p].version') - - override=$(echo "$OVERRIDES" | jq -r --arg p "$pkg" '.[$p] // empty') - if [ -n "$override" ]; then - status="approved-override" - echo " OVERRIDE: $pkg ($license) — $override" - elif check_license "$license"; then - status="approved" - else - status="DENIED" - VIOLATIONS=$((VIOLATIONS + 1)) - echo "::error::Disallowed license: $pkg ($version) uses '$license'" - fi - - REPORT_JSON=$(echo "$REPORT_JSON" | jq \ - --arg pkg "$pkg" --arg ver "$version" --arg lic "$license" \ - --arg st "$status" --arg ovr "${override:-}" \ - '. + [{"package": $pkg, "version": $ver, "license": $lic, "status": $st, "override": $ovr, "ecosystem": "composer"}]') - done - - else - echo "" - echo "=== Checking npm dependencies ===" - NPM_JSON=$(license-checker --json --production 2>/dev/null || echo '{}') - - for pkg in $(echo "$NPM_JSON" | jq -r 'keys[]' 2>/dev/null); do - license=$(echo "$NPM_JSON" | jq -r --arg p "$pkg" '.[$p].licenses // .[$p].license // "UNKNOWN"') - pkg_name=$(echo "$pkg" | sed 's/@[^@]*$//') - pkg_version=$(echo "$pkg" | grep -oP '@[^@]+$' | sed 's/^@//') - - override=$(echo "$OVERRIDES" | jq -r --arg p "$pkg_name" '.[$p] // empty') - if [ -n "$override" ]; then - status="approved-override" - echo " OVERRIDE: $pkg_name ($license) — $override" - elif check_license "$license"; then - status="approved" - else - status="DENIED" - VIOLATIONS=$((VIOLATIONS + 1)) - echo "::error::Disallowed license: $pkg_name ($pkg_version) uses '$license'" - fi - - REPORT_JSON=$(echo "$REPORT_JSON" | jq \ - --arg pkg "$pkg_name" --arg ver "$pkg_version" --arg lic "$license" \ - --arg st "$status" --arg ovr "${override:-}" \ - '. + [{"package": $pkg, "version": $ver, "license": $lic, "status": $st, "override": $ovr, "ecosystem": "npm"}]') - done - fi - - TOTAL=$(echo "$REPORT_JSON" | jq 'length') - APPROVED=$(echo "$REPORT_JSON" | jq '[.[] | select(.status == "approved")] | length') - OVERRIDDEN=$(echo "$REPORT_JSON" | jq '[.[] | select(.status == "approved-override")] | length') - DENIED=$(echo "$REPORT_JSON" | jq '[.[] | select(.status == "DENIED")] | length') - - # Save JSON report - echo "$REPORT_JSON" | jq '.' > license-report/$ECOSYSTEM-licenses.json - - # Generate Markdown report - { - echo "# $ECOSYSTEM License Report" - echo "" - echo "**Generated:** $(date -u +'%Y-%m-%d %H:%M UTC')" - echo "**Repository:** ${{ github.repository }}" - echo "**Ref:** ${{ github.sha }}" - echo "" - echo "## Summary" - echo "" - echo "| Metric | Count |" - echo "|--------|-------|" - echo "| Total dependencies | $TOTAL |" - echo "| Approved (allowlist) | $APPROVED |" - echo "| Approved (manual override) | $OVERRIDDEN |" - echo "| **Denied** | **$DENIED** |" - echo "" - echo "## Dependencies" - echo "" - echo "| Package | Version | License | Status | Override |" - echo "|---------|---------|---------|--------|----------|" - } > license-report/$ECOSYSTEM-licenses.md - - echo "$REPORT_JSON" | jq -r '.[] | "| \(.package) | \(.version) | \(.license) | \(.status) | \(.override) |"' >> license-report/$ECOSYSTEM-licenses.md - - if [ "$DENIED" -gt 0 ]; then - { - echo "" - echo "## Denied Dependencies" - echo "" - echo "| Package | License |" - echo "|---------|---------|" - } >> license-report/$ECOSYSTEM-licenses.md - echo "$REPORT_JSON" | jq -r '.[] | select(.status == "DENIED") | "| \(.package) | \(.license) |"' >> license-report/$ECOSYSTEM-licenses.md - { - echo "" - echo "### How to resolve" - echo "" - echo "1. Add the license SPDX identifier to \`.license-allowlist.json\` if the license is generally acceptable" - echo "2. Add the specific package to \`.license-overrides.json\` with a justification if only this package is approved" - echo "" - echo "See the workflow comments in quality.yml for configuration details." - } >> license-report/$ECOSYSTEM-licenses.md - fi - - echo "" - echo "════════════════════════════════════════" - echo "$ECOSYSTEM: $TOTAL total, $APPROVED approved, $OVERRIDDEN overridden, $DENIED denied" - echo "════════════════════════════════════════" - - if [ "$VIOLATIONS" -gt 0 ]; then - echo "::error::$VIOLATIONS $ECOSYSTEM dependency license(s) are not on the allowlist. Add them to .license-allowlist.json or .license-overrides.json" - exit 1 - fi - echo "All $ECOSYSTEM dependency licenses are compliant." - - - name: Upload license report - if: always() - uses: actions/upload-artifact@v4 - with: - name: license-report-${{ matrix.ecosystem }} - path: license-report/ - retention-days: 90 - - - name: Record result - if: always() - run: | - mkdir -p quality-results - echo "${{ job.status }}" > quality-results/${{ matrix.ecosystem }}.txt - - name: Upload result - if: always() - uses: actions/upload-artifact@v4 - with: - name: result-license-${{ matrix.ecosystem }} - path: quality-results/ - - # ╔══════════════════════════════════════════════╗ - # ║ STAGE 2 — Tests (gated behind Stage 1) ║ - # ║ ║ - # ║ • PHPUnit matrix (PHP × Nextcloud versions) ║ - # ║ • Integration tests (Newman) ║ - # ║ • E2E browser tests (Playwright) ║ - # ╚══════════════════════════════════════════════╝ - - phpunit: - if: ${{ inputs.enable-phpunit && !cancelled() && needs.php-quality.result != 'failure' && needs.security.result != 'failure' }} - runs-on: ubuntu-latest - name: "PHPUnit (PHP ${{ matrix.php-version }}, NC ${{ matrix.nextcloud-ref }})" - needs: [php-quality, security] - - strategy: - matrix: - php-version: ${{ fromJSON(inputs.php-test-versions) }} - nextcloud-ref: ${{ fromJSON(inputs.nextcloud-test-refs) }} - fail-fast: false - - steps: - - name: Checkout Nextcloud server - uses: actions/checkout@v4 - with: - repository: nextcloud/server - ref: ${{ matrix.nextcloud-ref }} - path: server - - - name: Checkout server submodules - run: | - cd server - git submodule update --init 3rdparty - - - name: Checkout app - uses: actions/checkout@v4 - with: - path: server/apps/${{ inputs.app-name }} - - - name: Checkout additional apps - if: ${{ inputs.additional-apps != '[]' }} - run: | - echo '${{ inputs.additional-apps }}' | jq -c '.[]' | while read -r app; do - repo=$(echo "$app" | jq -r '.repo') - name=$(echo "$app" | jq -r '.app') - ref=$(echo "$app" | jq -r '.ref // "main"') - echo "Checking out $repo ($name) at $ref..." - git clone --depth 1 --branch "$ref" "https://github.com/$repo.git" "server/apps/$name" - if [ -f "server/apps/$name/composer.json" ]; then - cd "server/apps/$name" - composer install --no-progress --prefer-dist --optimize-autoloader --no-dev 2>/dev/null || true - cd - - fi - done - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - extensions: mbstring, intl, sqlite3, zip, gd, curl, xml, json - coverage: xdebug - tools: composer:v2 - - - name: Install Nextcloud - run: | - cd server - php occ maintenance:install \ - --database sqlite \ - --admin-user admin \ - --admin-pass admin - if [ '${{ inputs.additional-apps }}' != '[]' ]; then - echo '${{ inputs.additional-apps }}' | jq -r '.[].app' | while read -r name; do - echo "Enabling app: $name" - php occ app:enable "$name" || echo "::warning::Failed to enable $name, continuing..." - done - fi - php occ app:enable ${{ inputs.app-name }} - chmod -R a+w config - - - name: Install app dependencies - run: | - cd server/apps/${{ inputs.app-name }} - composer install --no-progress --prefer-dist --optimize-autoloader - - - name: Validate test infrastructure - run: | - cd server/apps/${{ inputs.app-name }} - if [ ! -f phpunit.xml ] && [ ! -f phpunit-unit.xml ]; then - echo "::error::PHPUnit is enabled but no phpunit.xml or phpunit-unit.xml found." - exit 1 - fi - if [ ! -d tests/Unit ] && [ ! -d tests/unit ] && [ ! -d tests/Service ] && [ ! -d tests/Integration ] && [ ! -d tests/integration ]; then - echo "::error::PHPUnit is enabled but no test directories found." - exit 1 - fi - echo "PHPUnit infrastructure validated." - - - name: Run PHPUnit tests - run: | - cd server/apps/${{ inputs.app-name }} - ./vendor/bin/phpunit \ - -c phpunit.xml \ - --colors=always \ - --coverage-clover=coverage/clover.xml - - - name: Guard coverage baseline - if: inputs.enable-coverage-guard && matrix.php-version == inputs.php-version && matrix.nextcloud-ref == fromJSON(inputs.nextcloud-test-refs)[0] - run: | - cd server/apps/${{ inputs.app-name }} - php scripts/coverage-guard.php coverage/clover.xml - - - name: Upload coverage artifact - if: always() && matrix.php-version == inputs.php-version && matrix.nextcloud-ref == fromJSON(inputs.nextcloud-test-refs)[0] - uses: actions/upload-artifact@v4 - with: - name: coverage-report - path: server/apps/${{ inputs.app-name }}/coverage/ - retention-days: 14 - if-no-files-found: ignore - - newman: - if: ${{ inputs.enable-newman && !cancelled() && needs.php-quality.result != 'failure' && needs.security.result != 'failure' }} - runs-on: ubuntu-latest - name: "Integration Tests (Newman)" - needs: [php-quality, security] - - steps: - - name: Checkout Nextcloud server - uses: actions/checkout@v4 - with: - repository: nextcloud/server - ref: ${{ fromJSON(inputs.nextcloud-test-refs)[0] }} - path: server - - - name: Checkout server submodules - run: | - cd server - git submodule update --init 3rdparty - - - name: Checkout app - uses: actions/checkout@v4 - with: - path: server/apps/${{ inputs.app-name }} - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ inputs.php-version }} - extensions: mbstring, intl, sqlite3, zip, gd, curl, xml, json - tools: composer:v2 - - - name: Checkout additional apps - if: ${{ inputs.additional-apps != '[]' }} - run: | - echo '${{ inputs.additional-apps }}' | jq -c '.[]' | while read -r app; do - repo=$(echo "$app" | jq -r '.repo') - name=$(echo "$app" | jq -r '.app') - ref=$(echo "$app" | jq -r '.ref // "main"') - echo "Checking out $repo ($name) at $ref..." - git clone --depth 1 --branch "$ref" "https://github.com/$repo.git" "server/apps/$name" - if [ -f "server/apps/$name/composer.json" ]; then - echo "Installing composer dependencies for $name..." - cd "server/apps/$name" - composer install --no-progress --prefer-dist --optimize-autoloader --no-dev - cd - - fi - done - - - name: Install Nextcloud - run: | - cd server - php occ maintenance:install \ - --database sqlite \ - --admin-user admin \ - --admin-pass admin - if [ '${{ inputs.additional-apps }}' != '[]' ]; then - echo '${{ inputs.additional-apps }}' | jq -r '.[].app' | while read -r name; do - echo "Enabling app: $name" - php occ app:enable "$name" || echo "::warning::Failed to enable $name, continuing..." - done - fi - - - name: Install and enable app - run: | - cd server/apps/${{ inputs.app-name }} - composer install --no-progress --prefer-dist --optimize-autoloader - cd ../../ - php occ app:enable ${{ inputs.app-name }} - - - name: Start PHP built-in server - run: | - cd server - php -S 0.0.0.0:8080 & - sleep 3 - curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/status.php || echo "Server not ready" - - - name: Seed test data - if: inputs.newman-seed-command != '' - run: | - cd server - echo "Running seed command: ${{ inputs.newman-seed-command }}" - # Run the seed command (e.g. maintenance:repair, custom script, etc.) - # Use eval to support complex commands with pipes or && - eval ${{ inputs.newman-seed-command }} - echo "Seed command completed." - - - name: Setup Node.js and Newman - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install Newman - run: npm install -g newman - - - name: Validate Newman collections - run: | - cd server/apps/${{ inputs.app-name }} - COLLECTION_PATH="${{ inputs.newman-collection-path }}" - if [ ! -d "$COLLECTION_PATH" ]; then - echo "::error::Newman is enabled but collection directory '$COLLECTION_PATH' does not exist." - exit 1 - fi - COLLECTIONS=$(find "$COLLECTION_PATH" -name "*.postman_collection.json" 2>/dev/null | wc -l) - if [ "$COLLECTIONS" -eq 0 ]; then - echo "::error::No .postman_collection.json files found in '$COLLECTION_PATH'." - exit 1 - fi - echo "Found $COLLECTIONS Postman collection(s) in $COLLECTION_PATH." - - - name: Run Newman tests - run: | - cd server/apps/${{ inputs.app-name }}/${{ inputs.newman-collection-path }} - - # Build environment args: use --environment file if provided, otherwise ad-hoc vars - ENV_ARGS="" - if [ -n "${{ inputs.newman-environment-path }}" ]; then - ENV_FILE="../../../apps/${{ inputs.app-name }}/${{ inputs.newman-environment-path }}" - if [ -f "$ENV_FILE" ]; then - # Rewrite localhost:8080 for CI (PHP built-in server runs on 8080) - echo "Using environment file: ${{ inputs.newman-environment-path }}" - ENV_ARGS="--environment $ENV_FILE" - else - echo "::warning::Environment file '${{ inputs.newman-environment-path }}' not found, falling back to default env vars." - ENV_ARGS='--env-var "base_url=http://localhost:8080" --env-var "admin_user=admin" --env-var "admin_password=admin"' - fi - else - ENV_ARGS='--env-var "base_url=http://localhost:8080" --env-var "admin_user=admin" --env-var "admin_password=admin"' - fi - - FAIL=0 - for collection in *.postman_collection.json; do - echo "" - echo "=== Running: $collection ===" - eval newman run "\"$collection\"" $ENV_ARGS --reporters cli || FAIL=1 - done - if [ "$FAIL" -ne 0 ]; then - echo "::error::One or more Newman collections failed." - exit 1 - fi - - - name: Show Nextcloud log on failure - if: failure() - run: | - echo "=== Last 50 lines of Nextcloud log ===" - tail -50 server/data/nextcloud.log 2>/dev/null | python3 -m json.tool --no-ensure-ascii 2>/dev/null || tail -50 server/data/nextcloud.log 2>/dev/null || echo "No log found" - - playwright: - if: ${{ inputs.enable-playwright && !cancelled() && needs.security.result != 'failure' }} - runs-on: ubuntu-latest - name: "E2E Tests (Playwright)" - needs: [php-quality, security] - - steps: - - name: Checkout Nextcloud server - uses: actions/checkout@v4 - with: - repository: nextcloud/server - ref: ${{ fromJSON(inputs.nextcloud-test-refs)[0] }} - path: server - - - name: Checkout server submodules - run: | - cd server - git submodule update --init 3rdparty - - - name: Checkout app - uses: actions/checkout@v4 - with: - path: server/apps/${{ inputs.app-name }} - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ inputs.php-version }} - extensions: mbstring, intl, sqlite3, zip, gd, curl, xml, json - tools: composer:v2 - - - name: Checkout additional apps - if: ${{ inputs.additional-apps != '[]' }} - run: | - echo '${{ inputs.additional-apps }}' | jq -c '.[]' | while read -r app; do - repo=$(echo "$app" | jq -r '.repo') - name=$(echo "$app" | jq -r '.app') - ref=$(echo "$app" | jq -r '.ref // "main"') - echo "Checking out $repo ($name) at $ref..." - git clone --depth 1 --branch "$ref" "https://github.com/$repo.git" "server/apps/$name" - if [ -f "server/apps/$name/composer.json" ]; then - echo "Installing composer dependencies for $name..." - cd "server/apps/$name" - composer install --no-progress --prefer-dist --optimize-autoloader --no-dev - cd - - fi - done - - - name: Install Nextcloud - run: | - cd server - php occ maintenance:install \ - --database sqlite \ - --admin-user admin \ - --admin-pass admin - if [ '${{ inputs.additional-apps }}' != '[]' ]; then - echo '${{ inputs.additional-apps }}' | jq -r '.[].app' | while read -r name; do - echo "Enabling app: $name" - php occ app:enable "$name" || echo "::warning::Failed to enable $name, continuing..." - done - fi - - - name: Install and enable app - run: | - cd server/apps/${{ inputs.app-name }} - composer install --no-progress --prefer-dist --optimize-autoloader - cd ../../ - php occ app:enable ${{ inputs.app-name }} - - - name: Start PHP built-in server - run: | - cd server - php -S 0.0.0.0:8080 & - sleep 3 - curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/status.php || echo "Server not ready" - - - name: Seed test data - if: inputs.playwright-seed-command != '' - run: | - cd server - echo "Running seed command: ${{ inputs.playwright-seed-command }}" - eval ${{ inputs.playwright-seed-command }} - echo "Seed command completed." - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install app npm dependencies - run: | - cd server/apps/${{ inputs.app-name }} - if [ -f "package.json" ]; then - npm ci - fi - - - name: Install Playwright - run: | - cd server/apps/${{ inputs.app-name }} - npm install --save-dev @playwright/test - npx playwright install --with-deps chromium - - - name: Validate Playwright tests exist - run: | - cd server/apps/${{ inputs.app-name }} - TEST_PATH="${{ inputs.playwright-test-path }}" - if [ ! -d "$TEST_PATH" ]; then - echo "::error::Playwright is enabled but test directory '$TEST_PATH' does not exist." - exit 1 - fi - TESTS=$(find "$TEST_PATH" -name "*.spec.ts" -o -name "*.spec.js" 2>/dev/null | wc -l) - if [ "$TESTS" -eq 0 ]; then - echo "::error::No .spec.ts or .spec.js files found in '$TEST_PATH'." - exit 1 - fi - echo "Found $TESTS Playwright test file(s) in $TEST_PATH." - - - name: Run Playwright tests - run: | - cd server/apps/${{ inputs.app-name }} - CONFIG="${{ inputs.playwright-test-path }}/playwright.config.ts" - if [ ! -f "$CONFIG" ] && [ -f "playwright.config.ts" ]; then - CONFIG="playwright.config.ts" - fi - npx playwright test --config="$CONFIG" - env: - BASE_URL: http://localhost:8080 - ADMIN_USER: admin - ADMIN_PASSWORD: admin - COLLECT_COVERAGE: ${{ inputs.enable-playwright-coverage }} - - - name: Generate spec-to-test coverage report - if: ${{ inputs.enable-playwright-coverage && !cancelled() }} - run: | - cd server/apps/${{ inputs.app-name }} - node -e " - const fs = require('fs'); - const path = require('path'); - - // Collect all spec scenarios from openspec - const specDir = 'openspec/specs'; - const changeDir = 'openspec/changes'; - let scenarios = []; - - function extractScenarios(dir) { - if (!fs.existsSync(dir)) return; - for (const entry of fs.readdirSync(dir, { recursive: true })) { - const file = path.join(dir, entry); - if (!file.endsWith('spec.md') && !file.endsWith('spec.md')) continue; - if (!fs.statSync(file).isFile()) continue; - const content = fs.readFileSync(file, 'utf8'); - const matches = content.match(/^###?\s+(S\d+|Scenario[:\s]|REQ-)[^\n]*/gm) || []; - for (const m of matches) { - scenarios.push({ file: file.replace(/\\\\/g, '/'), scenario: m.replace(/^#+\s+/, '').trim() }); - } - } - } - extractScenarios(specDir); - extractScenarios(changeDir); - - // Collect all test descriptions from Playwright specs - const testDir = '${{ inputs.playwright-test-path }}'; - let tests = []; - if (fs.existsSync(testDir)) { - for (const entry of fs.readdirSync(testDir, { recursive: true })) { - const file = path.join(testDir, entry); - if (!file.endsWith('.spec.ts') && !file.endsWith('.spec.js')) continue; - if (!fs.statSync(file).isFile()) continue; - const content = fs.readFileSync(file, 'utf8'); - const matches = content.match(/test\(['\x60]([^'\x60]+)/g) || []; - for (const m of matches) { - tests.push({ file: file.replace(/\\\\/g, '/'), test: m.replace(/test\(['\x60]/, '').trim() }); - } - } - } - - // Collect test flow files - const flowDir = 'tests/flows'; - let flows = []; - if (fs.existsSync(flowDir)) { - for (const entry of fs.readdirSync(flowDir)) { - if (entry.endsWith('.md') && entry !== 'README.md') flows.push(entry); - } - } - - const report = { - specScenarios: scenarios.length, - playwrightTests: tests.length, - testFlows: flows.length, - coverage: scenarios.length > 0 ? Math.round((tests.length / scenarios.length) * 100) : 100, - scenarios, - tests, - flows, - }; - - fs.writeFileSync('playwright-coverage.json', JSON.stringify(report, null, 2)); - console.log('Spec scenarios: ' + report.specScenarios); - console.log('Playwright tests: ' + report.playwrightTests); - console.log('Test flows: ' + report.testFlows); - console.log('Spec-to-test coverage: ' + report.coverage + '%'); - - if (report.coverage < ${{ inputs.playwright-coverage-threshold }}) { - console.log('::warning::Spec-to-test coverage ' + report.coverage + '% is below threshold ' + ${{ inputs.playwright-coverage-threshold }} + '%'); - } - " - - - name: Upload Playwright report - if: always() - uses: actions/upload-artifact@v4 - with: - name: playwright-report - path: | - server/apps/${{ inputs.app-name }}/playwright-report/ - server/apps/${{ inputs.app-name }}/playwright-coverage.json - retention-days: 14 - if-no-files-found: ignore - - - name: Upload Playwright traces - if: failure() - uses: actions/upload-artifact@v4 - with: - name: playwright-traces - path: server/apps/${{ inputs.app-name }}/test-results/ - retention-days: 14 - if-no-files-found: ignore - - - name: Show Nextcloud log on failure - if: failure() - run: | - echo "=== Last 50 lines of Nextcloud log ===" - tail -50 server/data/nextcloud.log 2>/dev/null | python3 -m json.tool --no-ensure-ascii 2>/dev/null || tail -50 server/data/nextcloud.log 2>/dev/null || echo "No log found" - - # ╔══════════════════════════════════════════════╗ - # ║ STAGE 3 — Reporting & coverage baseline ║ - # ╚══════════════════════════════════════════════╝ - - baseline-protection: - if: ${{ inputs.enable-coverage-guard && github.event_name == 'pull_request' }} - runs-on: ubuntu-latest - name: "Coverage Baseline Protection" - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Check for manual baseline changes - run: | - if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -q '^\.coverage-baseline$'; then - echo "::error::Manual changes to .coverage-baseline are not allowed." - exit 1 - fi - echo "No manual baseline changes detected." - - update-baseline: - if: ${{ inputs.enable-coverage-guard && github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/development') }} - runs-on: ubuntu-latest - name: "Update Coverage Baseline" - needs: phpunit - permissions: - contents: write - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - token: ${{ github.token }} - - name: Download coverage artifact - uses: actions/download-artifact@v4 - with: - name: coverage-report - path: coverage/ - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ inputs.php-version }} - - name: Update baseline if improved - run: php scripts/coverage-guard.php coverage/clover.xml --update-baseline - - name: Commit updated baseline - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - if git diff --quiet .coverage-baseline; then - echo "Baseline unchanged, nothing to commit." - else - git add .coverage-baseline - git commit -m "ci: update coverage baseline [skip ci]" - git push - fi - - report: - runs-on: ubuntu-latest - name: "Quality Report" - needs: [php-quality, vue-quality, security, license, phpunit, newman, playwright, baseline-protection] - if: always() - - steps: - - name: Download all result artifacts - uses: actions/download-artifact@v4 - with: - pattern: result-* - path: results/ - merge-multiple: false - - - name: Download license reports - uses: actions/download-artifact@v4 - with: - pattern: license-report-* - path: license-reports/ - merge-multiple: true - continue-on-error: true - - - name: Download coverage report - uses: actions/download-artifact@v4 - with: - name: coverage-report - path: coverage/ - continue-on-error: true - - - name: Download Playwright coverage - uses: actions/download-artifact@v4 - with: - name: playwright-report - path: results/playwright-report/ - continue-on-error: true - - - name: Install PDF tools - run: sudo apt-get update -qq && sudo apt-get install -y -qq pandoc wkhtmltopdf - - - name: Generate report - run: | - set -euo pipefail - - REPORT="quality-report.md" - REPO="${{ github.repository }}" - SHA="${{ github.sha }}" - SHORT_SHA="${SHA:0:7}" - BRANCH="${{ github.ref_name }}" - EVENT="${{ github.event_name }}" - DATE="$(date -u +'%Y-%m-%d %H:%M UTC')" - RUN_URL="https://github.com/$REPO/actions/runs/${{ github.run_id }}" - - # Helper to read result from artifact files - read_result() { - local file="$1" - if [ -f "$file" ]; then - cat "$file" | tr -d '[:space:]' - else - echo "skipped" - fi - } - - icon() { - case "$1" in - success|Success) echo "PASS" ;; - skipped|Skipped) echo "SKIP" ;; - *) echo "FAIL" ;; - esac - } - - # ── Header ── - { - echo "# Quality Report" - echo "" - echo "| | |" - echo "|---|---|" - echo "| **Repository** | $REPO |" - echo "| **Commit** | $SHORT_SHA |" - echo "| **Branch** | $BRANCH |" - echo "| **Event** | $EVENT |" - echo "| **Generated** | $DATE |" - echo "| **Workflow Run** | $RUN_URL |" - echo "" - } > "$REPORT" - - # ── Overall Summary ── - { - echo "## Summary" - echo "" - echo "| Group | Result |" - echo "|-------|--------|" - echo "| PHP Quality | $(icon '${{ needs.php-quality.result }}') |" - echo "| Vue Quality | $(icon '${{ needs.vue-quality.result }}') |" - echo "| Security | $(icon '${{ needs.security.result }}') |" - echo "| License | $(icon '${{ needs.license.result }}') |" - echo "| PHPUnit | $(icon '${{ needs.phpunit.result }}') |" - echo "| Newman | $(icon '${{ needs.newman.result }}') |" - echo "| Playwright | $(icon '${{ needs.playwright.result }}') |" - echo "" - } >> "$REPORT" - - # ── PHP Quality Details ── - { - echo "## PHP Quality" - echo "" - echo "| Tool | Result |" - echo "|------|--------|" - for tool in lint phpcs phpmd psalm phpstan phpmetrics; do - result=$(read_result "results/result-php-quality-$tool/$tool.txt") - echo "| $tool | $(icon "$result") |" - done - echo "" - } >> "$REPORT" - - # ── Vue Quality Details ── - { - echo "## Vue Quality" - echo "" - echo "| Tool | Result |" - echo "|------|--------|" - for tool in eslint stylelint; do - result=$(read_result "results/result-vue-quality-$tool/$tool.txt") - echo "| $tool | $(icon "$result") |" - done - echo "" - } >> "$REPORT" - - # ── Security Details ── - { - echo "## Security" - echo "" - echo "| Ecosystem | Result |" - echo "|-----------|--------|" - for eco in composer npm; do - result=$(read_result "results/result-security-$eco/$eco.txt") - echo "| $eco | $(icon "$result") |" - done - echo "" - } >> "$REPORT" - - # ── License Compliance Details ── - { - echo "## License Compliance" - echo "" - echo "| Ecosystem | Result |" - echo "|-----------|--------|" - for eco in composer npm; do - result=$(read_result "results/result-license-$eco/$eco.txt") - echo "| $eco | $(icon "$result") |" - done - echo "" - - # Include license summary from JSON reports if available - for eco in composer npm; do - JSON_FILE="license-reports/$eco-licenses.json" - if [ -f "$JSON_FILE" ]; then - TOTAL=$(jq 'length' "$JSON_FILE") - APPROVED=$(jq '[.[] | select(.status == "approved")] | length' "$JSON_FILE") - OVERRIDDEN=$(jq '[.[] | select(.status == "approved-override")] | length' "$JSON_FILE") - DENIED=$(jq '[.[] | select(.status == "DENIED")] | length' "$JSON_FILE") - echo "### $eco dependencies ($TOTAL total)" - echo "" - echo "| Metric | Count |" - echo "|--------|-------|" - echo "| Approved (allowlist) | $APPROVED |" - echo "| Approved (override) | $OVERRIDDEN |" - echo "| **Denied** | **$DENIED** |" - echo "" - - if [ "$DENIED" -gt 0 ]; then - echo "#### Denied packages" - echo "" - echo "| Package | Version | License |" - echo "|---------|---------|---------|" - jq -r '.[] | select(.status == "DENIED") | "| \(.package) | \(.version) | \(.license) |"' "$JSON_FILE" - echo "" - fi - fi - done - } >> "$REPORT" - - # ── PHPUnit Results ── - { - echo "## PHPUnit Tests" - echo "" - if [ "${{ needs.phpunit.result }}" = "skipped" ]; then - echo "*PHPUnit tests were not enabled for this run.*" - else - echo "| PHP | Nextcloud | Result |" - echo "|-----|-----------|--------|" - echo "| Overall | | $(icon '${{ needs.phpunit.result }}') |" - fi - echo "" - - # Include coverage summary if available - if [ -f "coverage/clover.xml" ]; then - COVERED=$(grep -oP 'coveredstatements="\K[0-9]+' coverage/clover.xml | head -1 || echo "0") - TOTAL_STMTS=$(grep -oP 'statements="\K[0-9]+' coverage/clover.xml | head -1 || echo "0") - if [ "$TOTAL_STMTS" -gt 0 ]; then - COVERAGE=$(echo "scale=1; $COVERED * 100 / $TOTAL_STMTS" | bc) - echo "**Code coverage:** ${COVERAGE}% ($COVERED / $TOTAL_STMTS statements)" - echo "" - fi - fi - } >> "$REPORT" - - # ── Newman Results ── - { - echo "## Integration Tests (Newman)" - echo "" - if [ "${{ needs.newman.result }}" = "skipped" ]; then - echo "*Newman integration tests were not enabled for this run.*" - else - echo "| Result |" - echo "|--------|" - echo "| $(icon '${{ needs.newman.result }}') |" - fi - echo "" - } >> "$REPORT" - - # ── Playwright Results ── - { - echo "## E2E Tests (Playwright)" - echo "" - if [ "${{ needs.playwright.result }}" = "skipped" ]; then - echo "*Playwright E2E tests were not enabled for this run.*" - else - echo "| Result |" - echo "|--------|" - echo "| $(icon '${{ needs.playwright.result }}') |" - fi - echo "" - - # Include spec-to-test coverage if available - COVERAGE_FILE="results/playwright-report/playwright-coverage.json" - if [ -f "$COVERAGE_FILE" ]; then - SPEC_COUNT=$(jq '.specScenarios' "$COVERAGE_FILE") - TEST_COUNT=$(jq '.playwrightTests' "$COVERAGE_FILE") - FLOW_COUNT=$(jq '.testFlows' "$COVERAGE_FILE") - COVERAGE=$(jq '.coverage' "$COVERAGE_FILE") - echo "### Test Coverage" - echo "" - echo "| Metric | Value |" - echo "|--------|-------|" - echo "| Spec scenarios | $SPEC_COUNT |" - echo "| Playwright tests | $TEST_COUNT |" - echo "| Test flows (LLM) | $FLOW_COUNT |" - echo "| **Spec-to-test coverage** | **${COVERAGE}%** |" - echo "| Threshold | ${{ inputs.playwright-coverage-threshold }}% |" - echo "" - if [ "$COVERAGE" -lt "${{ inputs.playwright-coverage-threshold }}" ]; then - echo "> **Warning:** Coverage ${COVERAGE}% is below the ${​{ inputs.playwright-coverage-threshold }}% threshold." - echo "" - fi - fi - } >> "$REPORT" - - # ── Footer ── - { - echo "---" - echo "" - echo "*Generated automatically by the [Quality workflow]($RUN_URL).*" - } >> "$REPORT" - - echo "Report generated: $(wc -l < "$REPORT") lines" - - - name: Convert to PDF - run: | - pandoc quality-report.md \ - -o quality-report.pdf \ - --pdf-engine=wkhtmltopdf \ - --metadata title="Quality Report" \ - -V margin-top=20mm \ - -V margin-bottom=20mm \ - -V margin-left=15mm \ - -V margin-right=15mm - - - name: Write step summary - if: always() - run: cat quality-report.md >> $GITHUB_STEP_SUMMARY - - - name: Upload PDF report - if: always() - uses: actions/upload-artifact@v4 - with: - name: quality-report - path: | - quality-report.pdf - quality-report.md - retention-days: 90 - - - name: Comment PR with results - if: github.event_name == 'pull_request' - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - let body = fs.readFileSync('quality-report.md', 'utf8'); - body += `\n\n> Download the [full PDF report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) from the workflow artifacts.\n`; - - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body - }); - - # ╔══════════════════════════════════════════════╗ - # ║ STAGE 4 — SBOM Generation & Validation ║ - # ║ ║ - # ║ Generates a CycloneDX SBOM from Composer ║ - # ║ and npm dependencies, validates against CVE ║ - # ║ databases and license policies, then commits ║ - # ║ the validated SBOM to the repository. ║ - # ╚══════════════════════════════════════════════╝ - - sbom: - if: ${{ inputs.enable-sbom && !cancelled() && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta' || github.ref == 'refs/heads/development') }} - runs-on: ubuntu-latest - name: "SBOM" - needs: [security] - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref || github.ref_name }} - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ inputs.php-version }} - extensions: mbstring, intl, zip, gd, curl, xml, json - tools: composer:v2 - - - name: Setup Node - if: ${{ inputs.enable-frontend }} - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Cache Composer dependencies - uses: actions/cache@v4 - with: - path: vendor - key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }} - restore-keys: ${{ runner.os }}-composer- - - - name: Install Composer dependencies - run: composer install --no-progress --prefer-dist --optimize-autoloader - - - name: Generate PHP SBOM - run: composer CycloneDX:make-sbom --output-format=JSON --output-file=bom-php.cdx.json --spec-version=1.5 --omit=dev --omit=plugin - - - name: Install npm dependencies - if: ${{ inputs.enable-frontend }} - run: npm ci - - - name: Generate npm SBOM - if: ${{ inputs.enable-frontend }} - run: npx @cyclonedx/cyclonedx-npm --output-file bom-npm.cdx.json --spec-version 1.5 --omit dev - - - name: Merge PHP + npm SBOMs - if: ${{ inputs.enable-frontend }} - run: | - jq -s '.[0] * {components: ([.[].components[]?] | unique_by(.purl // .name))}' bom-php.cdx.json bom-npm.cdx.json > sbom.cdx.json - - - name: Use PHP SBOM as single SBOM - if: ${{ !inputs.enable-frontend }} - run: mv bom-php.cdx.json sbom.cdx.json - - # ── Validation gate ── - - - name: Install Grype - uses: anchore/scan-action/download-grype@v5 - id: grype-install - - - name: Create Grype ignore list for known false positives - run: | - if [ ! -f .grype.yaml ]; then - cat > .grype.yaml << 'GRYPE' - ignore: - # False positives: Grype matches unscoped CycloneDX component names - # against malware advisories for typosquatting packages. Actual deps - # are scoped (@jridgewell/gen-mapping, @babel/helper-validator-identifier). - - vulnerability: GHSA-8rmg-jf7p-4p22 - - vulnerability: GHSA-pvjq-589m-3mc8 - GRYPE - fi - - - name: CVE scan SBOM - run: ${{ steps.grype-install.outputs.cmd }} sbom:sbom.cdx.json --fail-on critical - - - name: Composer audit - run: composer audit --format=json || true - # composer audit exits non-zero for any advisory; we rely on Grype for blocking - - - name: npm audit - if: ${{ inputs.enable-frontend }} - run: npm audit --audit-level=critical - - # ── Commit validated SBOM ── - - - name: Commit SBOM - if: ${{ github.ref == 'refs/heads/main' }} - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add sbom.cdx.json - if git diff --cached --quiet; then - echo "No SBOM changes to commit" - else - git commit -m "chore: update SBOM" - git push - fi - - - name: Upload SBOM artifact - uses: actions/upload-artifact@v4 - with: - name: sbom-${{ inputs.app-name }} - path: sbom.cdx.json - retention-days: 90 - - - name: Attach SBOM to release - if: ${{ startsWith(github.ref, 'refs/tags/') }} - uses: softprops/action-gh-release@v2 - with: - files: sbom.cdx.json +name: Quality + +on: + workflow_call: + inputs: + app-name: + description: "Nextcloud app ID (used for checkout path in NC server)" + required: true + type: string + php-version: + description: "Primary PHP version for linting" + required: false + type: string + default: "8.3" + php-test-versions: + description: "JSON array of PHP versions for PHPUnit matrix" + required: false + type: string + default: '["8.3", "8.4"]' + nextcloud-test-refs: + description: "JSON array of Nextcloud server branches for PHPUnit matrix" + required: false + type: string + default: '["stable31", "stable32"]' + enable-psalm: + description: "Run Psalm static analysis" + required: false + type: boolean + default: true + enable-phpstan: + description: "Run PHPStan static analysis" + required: false + type: boolean + default: true + enable-phpmd: + description: "Run PHPMD mess detection" + required: false + type: boolean + default: true + enable-phpcs: + description: "Run PHPCS code style checks" + required: false + type: boolean + default: true + enable-phpmetrics: + description: "Run phpmetrics code metrics" + required: false + type: boolean + default: true + enable-frontend: + description: "Run frontend quality checks (ESLint + Stylelint)" + required: false + type: boolean + default: true + enable-eslint: + description: "Run ESLint (requires enable-frontend)" + required: false + type: boolean + default: true + enable-license-check: + description: "Run dependency license compliance check (composer + npm)" + required: false + type: boolean + default: true + enable-phpunit: + description: "Run PHPUnit tests against Nextcloud server" + required: false + type: boolean + default: false + enable-newman: + description: "Run Newman integration tests against Nextcloud server" + required: false + type: boolean + default: false + newman-collection-path: + description: "Path to Newman/Postman collection files (relative to app root)" + required: false + type: string + default: "tests/integration" + newman-environment-path: + description: "Path to Postman environment JSON file (relative to app root). When set, passes --environment to Newman instead of ad-hoc --env-var flags." + required: false + type: string + default: "" + newman-seed-command: + description: "Shell command to run after server start but before Newman tests (e.g. 'php occ maintenance:repair' to seed test fixtures). Runs with cwd set to the Nextcloud server root." + required: false + type: string + default: "" + enable-playwright: + description: "Run Playwright E2E browser tests against Nextcloud server" + required: false + type: boolean + default: false + playwright-test-path: + description: "Path to Playwright test files (relative to app root)" + required: false + type: string + default: "tests/e2e" + playwright-seed-command: + description: "Shell command to run after server start but before Playwright tests (e.g. 'php occ maintenance:repair' to seed test fixtures). Runs with cwd set to the Nextcloud server root." + required: false + type: string + default: "" + enable-playwright-coverage: + description: "Collect V8 code coverage during Playwright tests and enforce threshold" + required: false + type: boolean + default: false + playwright-coverage-threshold: + description: "Minimum line coverage percentage for Playwright tests (0-100). Only enforced when enable-playwright-coverage is true." + required: false + type: number + default: 75 + additional-apps: + description: "JSON array of additional app repos to checkout and enable (e.g. '[{\"repo\":\"ConductionNL/openregister\",\"app\":\"openregister\"}]')" + required: false + type: string + default: "[]" + enable-coverage-guard: + description: "Run coverage baseline guard and auto-update" + required: false + type: boolean + default: false + enable-sbom: + description: "Generate CycloneDX SBOM, validate (CVE + license), and commit to repo" + required: false + type: boolean + default: true + database: + description: "Database backend for integration tests (sqlite, pgsql, mysql)" + required: false + type: string + default: "pgsql" + +concurrency: + group: quality-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ╔══════════════════════════════════════════════╗ + # ║ STAGE 1 — Four parallel quality gates ║ + # ║ ║ + # ║ Each group uses a matrix strategy so GitHub ║ + # ║ renders it as ONE collapsible box with ║ + # ║ individual tool results visible inside. ║ + # ║ ║ + # ║ ┌────────────┐ ┌────────────┐ ║ + # ║ │PHP Quality │ │Vue Quality │ ║ + # ║ │ ◦ lint │ │ ◦ eslint │ ║ + # ║ │ ◦ phpcs │ │ ◦ stylelint│ ║ + # ║ │ ◦ phpmd │ └────────────┘ ║ + # ║ │ ◦ psalm │ ┌────────────┐ ║ + # ║ │ ◦ phpstan │ │ License │ ║ + # ║ │ ◦ metrics │ │ ◦ composer │ ║ + # ║ └────────────┘ │ ◦ npm │ ║ + # ║ ┌────────────┐ └────────────┘ ║ + # ║ │ Security │ ║ + # ║ │ ◦ composer │ ║ + # ║ │ ◦ npm │ ║ + # ║ └────────────┘ ║ + # ╚══════════════════════════════════════════════╝ + + # ── Group 1: PHP Quality ──────────────────────── + php-quality: + runs-on: ubuntu-latest + name: "PHP Quality (${{ matrix.tool }})" + strategy: + matrix: + tool: [lint, phpcs, phpmd, psalm, phpstan, phpmetrics] + fail-fast: false + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.php-version }} + extensions: mbstring, intl, zip, gd, curl, xml, json + tools: composer:v2 + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer install --no-progress --prefer-dist --optimize-autoloader + + - name: Run ${{ matrix.tool }} + run: | + case "${{ matrix.tool }}" in + lint) + composer lint + ;; + phpcs) + if [ "${{ inputs.enable-phpcs }}" != "true" ]; then + echo "PHPCS is disabled — skipping." + exit 0 + fi + # Exit code 0 = clean, 1 = warnings only, 2 = errors found. + # We treat warnings as non-blocking (exit 1 → success). + composer phpcs; RC=$? + if [ "$RC" -eq 1 ]; then + echo "::warning::PHPCS found warnings but no errors — passing." + exit 0 + fi + exit $RC + ;; + phpmd) + if [ "${{ inputs.enable-phpmd }}" != "true" ]; then + echo "PHPMD is disabled — skipping." + exit 0 + fi + composer phpmd + ;; + psalm) + if [ "${{ inputs.enable-psalm }}" != "true" ]; then + echo "Psalm is disabled — skipping." + exit 0 + fi + composer psalm + ;; + phpstan) + if [ "${{ inputs.enable-phpstan }}" != "true" ]; then + echo "PHPStan is disabled — skipping." + exit 0 + fi + composer phpstan + ;; + phpmetrics) + if [ "${{ inputs.enable-phpmetrics }}" != "true" ]; then + echo "phpmetrics is disabled — skipping." + exit 0 + fi + composer phpmetrics + ;; + esac + + - name: Record result + if: always() + run: | + mkdir -p quality-results + echo "${{ job.status }}" > quality-results/${{ matrix.tool }}.txt + - name: Upload result + if: always() + uses: actions/upload-artifact@v4 + with: + name: result-php-quality-${{ matrix.tool }} + path: quality-results/ + + # ── Group 2: Vue Quality ──────────────────────── + vue-quality: + if: ${{ inputs.enable-frontend }} + runs-on: ubuntu-latest + name: "Vue Quality (${{ matrix.tool }})" + strategy: + matrix: + tool: [eslint, stylelint] + fail-fast: false + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run ${{ matrix.tool }} + run: | + case "${{ matrix.tool }}" in + eslint) + if [ "${{ inputs.enable-eslint }}" != "true" ]; then + echo "ESLint is disabled — skipping." + exit 0 + fi + npm run lint + ;; + stylelint) + npm run stylelint + ;; + esac + + - name: Record result + if: always() + run: | + mkdir -p quality-results + echo "${{ job.status }}" > quality-results/${{ matrix.tool }}.txt + - name: Upload result + if: always() + uses: actions/upload-artifact@v4 + with: + name: result-vue-quality-${{ matrix.tool }} + path: quality-results/ + + # ── Group 3: Security ────────────────────────── + security: + runs-on: ubuntu-latest + name: "Security (${{ matrix.ecosystem }})" + strategy: + matrix: + ecosystem: [composer, npm] + fail-fast: false + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + if: matrix.ecosystem == 'composer' + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.php-version }} + tools: composer:v2 + + - name: Setup Node.js + if: matrix.ecosystem == 'npm' + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Run ${{ matrix.ecosystem }} audit + run: | + case "${{ matrix.ecosystem }}" in + composer) + composer install --no-progress --prefer-dist --optimize-autoloader + composer audit --format=plain --abandoned=report + ;; + npm) + npm ci + npm audit --audit-level=critical --omit=dev + ;; + esac + + - name: Record result + if: always() + run: | + mkdir -p quality-results + echo "${{ job.status }}" > quality-results/${{ matrix.ecosystem }}.txt + - name: Upload result + if: always() + uses: actions/upload-artifact@v4 + with: + name: result-security-${{ matrix.ecosystem }} + path: quality-results/ + + # ── Group 4: License Compliance ───────────────── + # + # Checks dependencies against an allowlist of approved SPDX license + # identifiers. Generates a license report as a workflow artifact for + # audit/compliance purposes. + # + # Configuration (optional files in your app repo root): + # + # .license-allowlist.json + # ───────────────────────────────────────────── + # A JSON array of approved SPDX license identifiers. If this file is + # absent, the built-in default allowlist is used (see step below). + # Example: + # ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", + # "LGPL-2.1-only", "LGPL-2.1-or-later", "LGPL-3.0-only", + # "LGPL-3.0-or-later", "EUPL-1.2", "AGPL-3.0-or-later", + # "GPL-2.0-only", "GPL-2.0-or-later", "GPL-3.0-only", + # "GPL-3.0-or-later", "MPL-2.0", "Unlicense", "0BSD", + # "CC0-1.0", "CC-BY-3.0", "CC-BY-4.0", "PSF-2.0", + # "Zlib", "BlueOak-1.0.0", "OSL-3.0"] + # + # .license-overrides.json + # ───────────────────────────────────────────── + # A JSON object to manually approve specific packages even if their + # license is not on the allowlist. Use this for packages where: + # - The license is valid but uses a non-standard SPDX string + # - Legal has explicitly approved a specific dependency + # - The package is dual-licensed and the checker picks the wrong one + # + # Keys are package names (composer: "vendor/package", npm: "package-name"). + # Values are a justification string (who approved it and when). + # Example: + # { + # "vendor/some-package": "Approved by legal (Jan de Vries) — 2026-03-14, dual-licensed MIT/proprietary, we use MIT terms", + # "some-npm-package": "License is 'Artistic-2.0', compatible with EUPL — approved 2026-03-01" + # } + # + # How it works: + # 1. Extracts all dependency licenses from composer or npm + # 2. For dual-licensed packages ("MIT OR Apache-2.0"), passes if ANY + # license in the OR expression is on the allowlist + # 3. Checks each package against overrides first, then the allowlist + # 4. Generates a markdown + JSON report uploaded as a workflow artifact + # 5. Fails the workflow if any dependency has a disallowed license + # ──────────────────────────────────────────────── + license: + if: ${{ inputs.enable-license-check }} + runs-on: ubuntu-latest + name: "License (${{ matrix.ecosystem }})" + strategy: + matrix: + ecosystem: [composer, npm] + fail-fast: false + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + if: matrix.ecosystem == 'composer' + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.php-version }} + tools: composer:v2 + + - name: Setup Node.js + if: matrix.ecosystem == 'npm' + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: | + if [ "${{ matrix.ecosystem }}" = "composer" ]; then + composer install --no-progress --prefer-dist --optimize-autoloader + else + npm ci + npm install -g license-checker + fi + + - name: Check ${{ matrix.ecosystem }} licenses + run: | + set -euo pipefail + + # ── Default allowlist (EUPL-1.2 / AGPL-3.0 compatible) ── + DEFAULT_ALLOWLIST='[ + "MIT", "ISC", "BSD-2-Clause", "BSD-3-Clause", "0BSD", + "Apache-2.0", + "LGPL-2.0-only", "LGPL-2.0-or-later", + "LGPL-2.1-only", "LGPL-2.1-or-later", + "LGPL-3.0-only", "LGPL-3.0-or-later", + "GPL-2.0-only", "GPL-2.0-or-later", + "GPL-3.0-only", "GPL-3.0-or-later", + "AGPL-3.0-only", "AGPL-3.0-or-later", + "EUPL-1.1", "EUPL-1.2", + "MPL-2.0", "OSL-3.0", + "Unlicense", "CC0-1.0", "CC-BY-3.0", "CC-BY-4.0", + "PSF-2.0", "Python-2.0", "Zlib", "BlueOak-1.0.0", + "Artistic-2.0", "BSL-1.0", "WTFPL", + "OFL-1.1", "OFL-1.0" + ]' + + if [ -f .license-allowlist.json ]; then + ALLOWLIST=$(cat .license-allowlist.json) + echo "Using project .license-allowlist.json" + else + ALLOWLIST="$DEFAULT_ALLOWLIST" + echo "Using built-in default allowlist" + fi + + if [ -f .license-overrides.json ]; then + OVERRIDES=$(cat .license-overrides.json) + echo "Loaded .license-overrides.json with $(echo "$OVERRIDES" | jq 'length') override(s)" + else + OVERRIDES='{}' + fi + + mkdir -p license-report + + # Helper: check if a license string is allowed + # Handles SPDX OR expressions and trailing "*" from license-checker + check_license() { + local license_str="$1" + IFS='|' read -ra PARTS <<< "$(echo "$license_str" | sed 's/ OR /|/g; s/ or /|/g')" + for part in "${PARTS[@]}"; do + part=$(echo "$part" | sed 's/^ *//;s/ *$//' | sed 's/[()]//g' | sed 's/\*$//') + if echo "$ALLOWLIST" | jq -e --arg l "$part" 'map(ascii_downcase) | index($l | ascii_downcase)' > /dev/null 2>&1; then + return 0 + fi + done + return 1 + } + + VIOLATIONS=0 + REPORT_JSON='[]' + ECOSYSTEM="${{ matrix.ecosystem }}" + + if [ "$ECOSYSTEM" = "composer" ]; then + echo "" + echo "=== Checking Composer dependencies ===" + COMPOSER_JSON=$(composer licenses --format=json 2>/dev/null || echo '{"dependencies":{}}') + + for pkg in $(echo "$COMPOSER_JSON" | jq -r '.dependencies | keys[]' 2>/dev/null); do + license=$(echo "$COMPOSER_JSON" | jq -r --arg p "$pkg" '.dependencies[$p].license | join(" OR ")') + version=$(echo "$COMPOSER_JSON" | jq -r --arg p "$pkg" '.dependencies[$p].version') + + override=$(echo "$OVERRIDES" | jq -r --arg p "$pkg" '.[$p] // empty') + if [ -n "$override" ]; then + status="approved-override" + echo " OVERRIDE: $pkg ($license) — $override" + elif check_license "$license"; then + status="approved" + else + status="DENIED" + VIOLATIONS=$((VIOLATIONS + 1)) + echo "::error::Disallowed license: $pkg ($version) uses '$license'" + fi + + REPORT_JSON=$(echo "$REPORT_JSON" | jq \ + --arg pkg "$pkg" --arg ver "$version" --arg lic "$license" \ + --arg st "$status" --arg ovr "${override:-}" \ + '. + [{"package": $pkg, "version": $ver, "license": $lic, "status": $st, "override": $ovr, "ecosystem": "composer"}]') + done + + else + echo "" + echo "=== Checking npm dependencies ===" + NPM_JSON=$(license-checker --json --production 2>/dev/null || echo '{}') + + for pkg in $(echo "$NPM_JSON" | jq -r 'keys[]' 2>/dev/null); do + license=$(echo "$NPM_JSON" | jq -r --arg p "$pkg" '.[$p].licenses // .[$p].license // "UNKNOWN"') + pkg_name=$(echo "$pkg" | sed 's/@[^@]*$//') + pkg_version=$(echo "$pkg" | grep -oP '@[^@]+$' | sed 's/^@//') + + override=$(echo "$OVERRIDES" | jq -r --arg p "$pkg_name" '.[$p] // empty') + if [ -n "$override" ]; then + status="approved-override" + echo " OVERRIDE: $pkg_name ($license) — $override" + elif check_license "$license"; then + status="approved" + else + status="DENIED" + VIOLATIONS=$((VIOLATIONS + 1)) + echo "::error::Disallowed license: $pkg_name ($pkg_version) uses '$license'" + fi + + REPORT_JSON=$(echo "$REPORT_JSON" | jq \ + --arg pkg "$pkg_name" --arg ver "$pkg_version" --arg lic "$license" \ + --arg st "$status" --arg ovr "${override:-}" \ + '. + [{"package": $pkg, "version": $ver, "license": $lic, "status": $st, "override": $ovr, "ecosystem": "npm"}]') + done + fi + + TOTAL=$(echo "$REPORT_JSON" | jq 'length') + APPROVED=$(echo "$REPORT_JSON" | jq '[.[] | select(.status == "approved")] | length') + OVERRIDDEN=$(echo "$REPORT_JSON" | jq '[.[] | select(.status == "approved-override")] | length') + DENIED=$(echo "$REPORT_JSON" | jq '[.[] | select(.status == "DENIED")] | length') + + # Save JSON report + echo "$REPORT_JSON" | jq '.' > license-report/$ECOSYSTEM-licenses.json + + # Generate Markdown report + { + echo "# $ECOSYSTEM License Report" + echo "" + echo "**Generated:** $(date -u +'%Y-%m-%d %H:%M UTC')" + echo "**Repository:** ${{ github.repository }}" + echo "**Ref:** ${{ github.sha }}" + echo "" + echo "## Summary" + echo "" + echo "| Metric | Count |" + echo "|--------|-------|" + echo "| Total dependencies | $TOTAL |" + echo "| Approved (allowlist) | $APPROVED |" + echo "| Approved (manual override) | $OVERRIDDEN |" + echo "| **Denied** | **$DENIED** |" + echo "" + echo "## Dependencies" + echo "" + echo "| Package | Version | License | Status | Override |" + echo "|---------|---------|---------|--------|----------|" + } > license-report/$ECOSYSTEM-licenses.md + + echo "$REPORT_JSON" | jq -r '.[] | "| \(.package) | \(.version) | \(.license) | \(.status) | \(.override) |"' >> license-report/$ECOSYSTEM-licenses.md + + if [ "$DENIED" -gt 0 ]; then + { + echo "" + echo "## Denied Dependencies" + echo "" + echo "| Package | License |" + echo "|---------|---------|" + } >> license-report/$ECOSYSTEM-licenses.md + echo "$REPORT_JSON" | jq -r '.[] | select(.status == "DENIED") | "| \(.package) | \(.license) |"' >> license-report/$ECOSYSTEM-licenses.md + { + echo "" + echo "### How to resolve" + echo "" + echo "1. Add the license SPDX identifier to \`.license-allowlist.json\` if the license is generally acceptable" + echo "2. Add the specific package to \`.license-overrides.json\` with a justification if only this package is approved" + echo "" + echo "See the workflow comments in quality.yml for configuration details." + } >> license-report/$ECOSYSTEM-licenses.md + fi + + echo "" + echo "════════════════════════════════════════" + echo "$ECOSYSTEM: $TOTAL total, $APPROVED approved, $OVERRIDDEN overridden, $DENIED denied" + echo "════════════════════════════════════════" + + if [ "$VIOLATIONS" -gt 0 ]; then + echo "::error::$VIOLATIONS $ECOSYSTEM dependency license(s) are not on the allowlist. Add them to .license-allowlist.json or .license-overrides.json" + exit 1 + fi + echo "All $ECOSYSTEM dependency licenses are compliant." + + - name: Upload license report + if: always() + uses: actions/upload-artifact@v4 + with: + name: license-report-${{ matrix.ecosystem }} + path: license-report/ + retention-days: 90 + + - name: Record result + if: always() + run: | + mkdir -p quality-results + echo "${{ job.status }}" > quality-results/${{ matrix.ecosystem }}.txt + - name: Upload result + if: always() + uses: actions/upload-artifact@v4 + with: + name: result-license-${{ matrix.ecosystem }} + path: quality-results/ + + # ╔══════════════════════════════════════════════╗ + # ║ STAGE 2 — Tests (gated behind Stage 1) ║ + # ║ ║ + # ║ • PHPUnit matrix (PHP × Nextcloud versions) ║ + # ║ • Integration tests (Newman) ║ + # ║ • E2E browser tests (Playwright) ║ + # ╚══════════════════════════════════════════════╝ + + phpunit: + if: ${{ inputs.enable-phpunit && !cancelled() && needs.php-quality.result != 'failure' && needs.security.result != 'failure' }} + runs-on: ubuntu-latest + name: "PHPUnit (PHP ${{ matrix.php-version }}, NC ${{ matrix.nextcloud-ref }})" + needs: [php-quality, security] + + services: + postgres: + image: ${{ inputs.database == 'pgsql' && 'postgres:16' || '' }} + env: + POSTGRES_USER: nextcloud + POSTGRES_PASSWORD: nextcloud + POSTGRES_DB: nextcloud + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U nextcloud" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + strategy: + matrix: + php-version: ${{ fromJSON(inputs.php-test-versions) }} + nextcloud-ref: ${{ fromJSON(inputs.nextcloud-test-refs) }} + fail-fast: false + + steps: + - name: Checkout Nextcloud server + uses: actions/checkout@v4 + with: + repository: nextcloud/server + ref: ${{ matrix.nextcloud-ref }} + path: server + + - name: Checkout server submodules + run: | + cd server + git submodule update --init 3rdparty + + - name: Checkout app + uses: actions/checkout@v4 + with: + path: server/apps/${{ inputs.app-name }} + + - name: Checkout additional apps + if: ${{ inputs.additional-apps != '[]' }} + run: | + echo '${{ inputs.additional-apps }}' | jq -c '.[]' | while read -r app; do + repo=$(echo "$app" | jq -r '.repo') + name=$(echo "$app" | jq -r '.app') + ref=$(echo "$app" | jq -r '.ref // "main"') + echo "Checking out $repo ($name) at $ref..." + git clone --depth 1 --branch "$ref" "https://github.com/$repo.git" "server/apps/$name" + if [ -f "server/apps/$name/composer.json" ]; then + cd "server/apps/$name" + composer install --no-progress --prefer-dist --optimize-autoloader --no-dev 2>/dev/null || true + cd - + fi + done + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, intl, sqlite3, pgsql, pdo_pgsql, zip, gd, curl, xml, json + coverage: xdebug + tools: composer:v2 + + - name: Install Nextcloud + run: | + cd server + if [ "${{ inputs.database }}" = "pgsql" ]; then + php occ maintenance:install \ + --database pgsql \ + --database-host 127.0.0.1 \ + --database-port 5432 \ + --database-name nextcloud \ + --database-user nextcloud \ + --database-pass nextcloud \ + --admin-user admin \ + --admin-pass admin + else + php occ maintenance:install \ + --database ${{ inputs.database }} \ + --admin-user admin \ + --admin-pass admin + fi + if [ '${{ inputs.additional-apps }}' != '[]' ]; then + echo '${{ inputs.additional-apps }}' | jq -r '.[].app' | while read -r name; do + echo "Enabling app: $name" + php occ app:enable "$name" || echo "::warning::Failed to enable $name, continuing..." + done + fi + php occ app:enable ${{ inputs.app-name }} + chmod -R a+w config + + - name: Install app dependencies + run: | + cd server/apps/${{ inputs.app-name }} + composer install --no-progress --prefer-dist --optimize-autoloader + + - name: Validate test infrastructure + run: | + cd server/apps/${{ inputs.app-name }} + if [ ! -f phpunit.xml ] && [ ! -f phpunit-unit.xml ]; then + echo "::error::PHPUnit is enabled but no phpunit.xml or phpunit-unit.xml found." + exit 1 + fi + if [ ! -d tests/Unit ] && [ ! -d tests/unit ] && [ ! -d tests/Service ] && [ ! -d tests/Integration ] && [ ! -d tests/integration ]; then + echo "::error::PHPUnit is enabled but no test directories found." + exit 1 + fi + echo "PHPUnit infrastructure validated." + + - name: Run PHPUnit tests + run: | + cd server/apps/${{ inputs.app-name }} + ./vendor/bin/phpunit \ + -c phpunit.xml \ + --colors=always \ + --coverage-clover=coverage/clover.xml + + - name: Guard coverage baseline + if: inputs.enable-coverage-guard && matrix.php-version == inputs.php-version && matrix.nextcloud-ref == fromJSON(inputs.nextcloud-test-refs)[0] + run: | + cd server/apps/${{ inputs.app-name }} + php scripts/coverage-guard.php coverage/clover.xml + + - name: Upload coverage artifact + if: always() && matrix.php-version == inputs.php-version && matrix.nextcloud-ref == fromJSON(inputs.nextcloud-test-refs)[0] + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: server/apps/${{ inputs.app-name }}/coverage/ + retention-days: 14 + if-no-files-found: ignore + + newman: + if: ${{ inputs.enable-newman && !cancelled() && needs.php-quality.result != 'failure' && needs.security.result != 'failure' }} + runs-on: ubuntu-latest + name: "Integration Tests (Newman)" + needs: [php-quality, security] + + services: + postgres: + image: ${{ inputs.database == 'pgsql' && 'postgres:16' || '' }} + env: + POSTGRES_USER: nextcloud + POSTGRES_PASSWORD: nextcloud + POSTGRES_DB: nextcloud + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U nextcloud" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + steps: + - name: Checkout Nextcloud server + uses: actions/checkout@v4 + with: + repository: nextcloud/server + ref: ${{ fromJSON(inputs.nextcloud-test-refs)[0] }} + path: server + + - name: Checkout server submodules + run: | + cd server + git submodule update --init 3rdparty + + - name: Checkout app + uses: actions/checkout@v4 + with: + path: server/apps/${{ inputs.app-name }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.php-version }} + extensions: mbstring, intl, sqlite3, pgsql, pdo_pgsql, zip, gd, curl, xml, json + tools: composer:v2 + + - name: Checkout additional apps + if: ${{ inputs.additional-apps != '[]' }} + run: | + echo '${{ inputs.additional-apps }}' | jq -c '.[]' | while read -r app; do + repo=$(echo "$app" | jq -r '.repo') + name=$(echo "$app" | jq -r '.app') + ref=$(echo "$app" | jq -r '.ref // "main"') + echo "Checking out $repo ($name) at $ref..." + git clone --depth 1 --branch "$ref" "https://github.com/$repo.git" "server/apps/$name" + if [ -f "server/apps/$name/composer.json" ]; then + echo "Installing composer dependencies for $name..." + cd "server/apps/$name" + composer install --no-progress --prefer-dist --optimize-autoloader --no-dev + cd - + fi + done + + - name: Install Nextcloud + run: | + cd server + if [ "${{ inputs.database }}" = "pgsql" ]; then + php occ maintenance:install \ + --database pgsql \ + --database-host 127.0.0.1 \ + --database-port 5432 \ + --database-name nextcloud \ + --database-user nextcloud \ + --database-pass nextcloud \ + --admin-user admin \ + --admin-pass admin + else + php occ maintenance:install \ + --database ${{ inputs.database }} \ + --admin-user admin \ + --admin-pass admin + fi + if [ '${{ inputs.additional-apps }}' != '[]' ]; then + echo '${{ inputs.additional-apps }}' | jq -r '.[].app' | while read -r name; do + echo "Enabling app: $name" + php occ app:enable "$name" || echo "::warning::Failed to enable $name, continuing..." + done + fi + + - name: Install and enable app + run: | + cd server/apps/${{ inputs.app-name }} + composer install --no-progress --prefer-dist --optimize-autoloader + cd ../../ + php occ app:enable ${{ inputs.app-name }} + + - name: Start PHP built-in server + run: | + cd server + php -S 0.0.0.0:8080 & + sleep 3 + curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/status.php || echo "Server not ready" + + - name: Seed test data + if: inputs.newman-seed-command != '' + run: | + cd server + echo "Running seed command: ${{ inputs.newman-seed-command }}" + # Run the seed command (e.g. maintenance:repair, custom script, etc.) + # Use eval to support complex commands with pipes or && + eval ${{ inputs.newman-seed-command }} + echo "Seed command completed." + + - name: Setup Node.js and Newman + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install Newman + run: npm install -g newman + + - name: Validate Newman collections + run: | + cd server/apps/${{ inputs.app-name }} + COLLECTION_PATH="${{ inputs.newman-collection-path }}" + if [ ! -d "$COLLECTION_PATH" ]; then + echo "::error::Newman is enabled but collection directory '$COLLECTION_PATH' does not exist." + exit 1 + fi + COLLECTIONS=$(find "$COLLECTION_PATH" -name "*.postman_collection.json" 2>/dev/null | wc -l) + if [ "$COLLECTIONS" -eq 0 ]; then + echo "::error::No .postman_collection.json files found in '$COLLECTION_PATH'." + exit 1 + fi + echo "Found $COLLECTIONS Postman collection(s) in $COLLECTION_PATH." + + - name: Run Newman tests + run: | + cd server/apps/${{ inputs.app-name }}/${{ inputs.newman-collection-path }} + + # Build environment args: use --environment file if provided, otherwise ad-hoc vars + ENV_ARGS="" + if [ -n "${{ inputs.newman-environment-path }}" ]; then + ENV_FILE="../../../apps/${{ inputs.app-name }}/${{ inputs.newman-environment-path }}" + if [ -f "$ENV_FILE" ]; then + # Rewrite localhost:8080 for CI (PHP built-in server runs on 8080) + echo "Using environment file: ${{ inputs.newman-environment-path }}" + ENV_ARGS="--environment $ENV_FILE" + else + echo "::warning::Environment file '${{ inputs.newman-environment-path }}' not found, falling back to default env vars." + ENV_ARGS='--env-var "base_url=http://localhost:8080" --env-var "admin_user=admin" --env-var "admin_password=admin"' + fi + else + ENV_ARGS='--env-var "base_url=http://localhost:8080" --env-var "admin_user=admin" --env-var "admin_password=admin"' + fi + + FAIL=0 + for collection in *.postman_collection.json; do + echo "" + echo "=== Running: $collection ===" + eval newman run "\"$collection\"" $ENV_ARGS --reporters cli || FAIL=1 + done + if [ "$FAIL" -ne 0 ]; then + echo "::error::One or more Newman collections failed." + exit 1 + fi + + - name: Show Nextcloud log on failure + if: failure() + run: | + echo "=== Last 50 lines of Nextcloud log ===" + tail -50 server/data/nextcloud.log 2>/dev/null | python3 -m json.tool --no-ensure-ascii 2>/dev/null || tail -50 server/data/nextcloud.log 2>/dev/null || echo "No log found" + + playwright: + if: ${{ inputs.enable-playwright && !cancelled() && needs.security.result != 'failure' }} + runs-on: ubuntu-latest + name: "E2E Tests (Playwright)" + needs: [php-quality, security] + + services: + postgres: + image: ${{ inputs.database == 'pgsql' && 'postgres:16' || '' }} + env: + POSTGRES_USER: nextcloud + POSTGRES_PASSWORD: nextcloud + POSTGRES_DB: nextcloud + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U nextcloud" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + steps: + - name: Checkout Nextcloud server + uses: actions/checkout@v4 + with: + repository: nextcloud/server + ref: ${{ fromJSON(inputs.nextcloud-test-refs)[0] }} + path: server + + - name: Checkout server submodules + run: | + cd server + git submodule update --init 3rdparty + + - name: Checkout app + uses: actions/checkout@v4 + with: + path: server/apps/${{ inputs.app-name }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.php-version }} + extensions: mbstring, intl, sqlite3, pgsql, pdo_pgsql, zip, gd, curl, xml, json + tools: composer:v2 + + - name: Checkout additional apps + if: ${{ inputs.additional-apps != '[]' }} + run: | + echo '${{ inputs.additional-apps }}' | jq -c '.[]' | while read -r app; do + repo=$(echo "$app" | jq -r '.repo') + name=$(echo "$app" | jq -r '.app') + ref=$(echo "$app" | jq -r '.ref // "main"') + echo "Checking out $repo ($name) at $ref..." + git clone --depth 1 --branch "$ref" "https://github.com/$repo.git" "server/apps/$name" + if [ -f "server/apps/$name/composer.json" ]; then + echo "Installing composer dependencies for $name..." + cd "server/apps/$name" + composer install --no-progress --prefer-dist --optimize-autoloader --no-dev + cd - + fi + done + + - name: Install Nextcloud + run: | + cd server + if [ "${{ inputs.database }}" = "pgsql" ]; then + php occ maintenance:install \ + --database pgsql \ + --database-host 127.0.0.1 \ + --database-port 5432 \ + --database-name nextcloud \ + --database-user nextcloud \ + --database-pass nextcloud \ + --admin-user admin \ + --admin-pass admin + else + php occ maintenance:install \ + --database ${{ inputs.database }} \ + --admin-user admin \ + --admin-pass admin + fi + if [ '${{ inputs.additional-apps }}' != '[]' ]; then + echo '${{ inputs.additional-apps }}' | jq -r '.[].app' | while read -r name; do + echo "Enabling app: $name" + php occ app:enable "$name" || echo "::warning::Failed to enable $name, continuing..." + done + fi + + - name: Install and enable app + run: | + cd server/apps/${{ inputs.app-name }} + composer install --no-progress --prefer-dist --optimize-autoloader + cd ../../ + php occ app:enable ${{ inputs.app-name }} + + - name: Start PHP built-in server + run: | + cd server + php -S 0.0.0.0:8080 & + sleep 3 + curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/status.php || echo "Server not ready" + + - name: Seed test data + if: inputs.playwright-seed-command != '' + run: | + cd server + echo "Running seed command: ${{ inputs.playwright-seed-command }}" + eval ${{ inputs.playwright-seed-command }} + echo "Seed command completed." + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install app npm dependencies + run: | + cd server/apps/${{ inputs.app-name }} + if [ -f "package.json" ]; then + npm ci + fi + + - name: Install Playwright + run: | + cd server/apps/${{ inputs.app-name }} + npm install --save-dev @playwright/test + npx playwright install --with-deps chromium + + - name: Validate Playwright tests exist + run: | + cd server/apps/${{ inputs.app-name }} + TEST_PATH="${{ inputs.playwright-test-path }}" + if [ ! -d "$TEST_PATH" ]; then + echo "::error::Playwright is enabled but test directory '$TEST_PATH' does not exist." + exit 1 + fi + TESTS=$(find "$TEST_PATH" -name "*.spec.ts" -o -name "*.spec.js" 2>/dev/null | wc -l) + if [ "$TESTS" -eq 0 ]; then + echo "::error::No .spec.ts or .spec.js files found in '$TEST_PATH'." + exit 1 + fi + echo "Found $TESTS Playwright test file(s) in $TEST_PATH." + + - name: Run Playwright tests + run: | + cd server/apps/${{ inputs.app-name }} + CONFIG="${{ inputs.playwright-test-path }}/playwright.config.ts" + if [ ! -f "$CONFIG" ] && [ -f "playwright.config.ts" ]; then + CONFIG="playwright.config.ts" + fi + npx playwright test --config="$CONFIG" + env: + BASE_URL: http://localhost:8080 + ADMIN_USER: admin + ADMIN_PASSWORD: admin + COLLECT_COVERAGE: ${{ inputs.enable-playwright-coverage }} + + - name: Generate spec-to-test coverage report + if: ${{ inputs.enable-playwright-coverage && !cancelled() }} + run: | + cd server/apps/${{ inputs.app-name }} + node -e " + const fs = require('fs'); + const path = require('path'); + + // Collect all spec scenarios from openspec + const specDir = 'openspec/specs'; + const changeDir = 'openspec/changes'; + let scenarios = []; + + function extractScenarios(dir) { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { recursive: true })) { + const file = path.join(dir, entry); + if (!file.endsWith('spec.md') && !file.endsWith('spec.md')) continue; + if (!fs.statSync(file).isFile()) continue; + const content = fs.readFileSync(file, 'utf8'); + const matches = content.match(/^###?\s+(S\d+|Scenario[:\s]|REQ-)[^\n]*/gm) || []; + for (const m of matches) { + scenarios.push({ file: file.replace(/\\\\/g, '/'), scenario: m.replace(/^#+\s+/, '').trim() }); + } + } + } + extractScenarios(specDir); + extractScenarios(changeDir); + + // Collect all test descriptions from Playwright specs + const testDir = '${{ inputs.playwright-test-path }}'; + let tests = []; + if (fs.existsSync(testDir)) { + for (const entry of fs.readdirSync(testDir, { recursive: true })) { + const file = path.join(testDir, entry); + if (!file.endsWith('.spec.ts') && !file.endsWith('.spec.js')) continue; + if (!fs.statSync(file).isFile()) continue; + const content = fs.readFileSync(file, 'utf8'); + const matches = content.match(/test\(['\x60]([^'\x60]+)/g) || []; + for (const m of matches) { + tests.push({ file: file.replace(/\\\\/g, '/'), test: m.replace(/test\(['\x60]/, '').trim() }); + } + } + } + + // Collect test flow files + const flowDir = 'tests/flows'; + let flows = []; + if (fs.existsSync(flowDir)) { + for (const entry of fs.readdirSync(flowDir)) { + if (entry.endsWith('.md') && entry !== 'README.md') flows.push(entry); + } + } + + const report = { + specScenarios: scenarios.length, + playwrightTests: tests.length, + testFlows: flows.length, + coverage: scenarios.length > 0 ? Math.round((tests.length / scenarios.length) * 100) : 100, + scenarios, + tests, + flows, + }; + + fs.writeFileSync('playwright-coverage.json', JSON.stringify(report, null, 2)); + console.log('Spec scenarios: ' + report.specScenarios); + console.log('Playwright tests: ' + report.playwrightTests); + console.log('Test flows: ' + report.testFlows); + console.log('Spec-to-test coverage: ' + report.coverage + '%'); + + if (report.coverage < ${{ inputs.playwright-coverage-threshold }}) { + console.log('::warning::Spec-to-test coverage ' + report.coverage + '% is below threshold ' + ${{ inputs.playwright-coverage-threshold }} + '%'); + } + " + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + server/apps/${{ inputs.app-name }}/playwright-report/ + server/apps/${{ inputs.app-name }}/playwright-coverage.json + retention-days: 14 + if-no-files-found: ignore + + - name: Upload Playwright traces + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-traces + path: server/apps/${{ inputs.app-name }}/test-results/ + retention-days: 14 + if-no-files-found: ignore + + - name: Show Nextcloud log on failure + if: failure() + run: | + echo "=== Last 50 lines of Nextcloud log ===" + tail -50 server/data/nextcloud.log 2>/dev/null | python3 -m json.tool --no-ensure-ascii 2>/dev/null || tail -50 server/data/nextcloud.log 2>/dev/null || echo "No log found" + + # ╔══════════════════════════════════════════════╗ + # ║ STAGE 3 — Reporting & coverage baseline ║ + # ╚══════════════════════════════════════════════╝ + + baseline-protection: + if: ${{ inputs.enable-coverage-guard && github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + name: "Coverage Baseline Protection" + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check for manual baseline changes + run: | + if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -q '^\.coverage-baseline$'; then + echo "::error::Manual changes to .coverage-baseline are not allowed." + exit 1 + fi + echo "No manual baseline changes detected." + + update-baseline: + if: ${{ inputs.enable-coverage-guard && github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/development') }} + runs-on: ubuntu-latest + name: "Update Coverage Baseline" + needs: phpunit + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ github.token }} + - name: Download coverage artifact + uses: actions/download-artifact@v4 + with: + name: coverage-report + path: coverage/ + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.php-version }} + - name: Update baseline if improved + run: php scripts/coverage-guard.php coverage/clover.xml --update-baseline + - name: Commit updated baseline + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + if git diff --quiet .coverage-baseline; then + echo "Baseline unchanged, nothing to commit." + else + git add .coverage-baseline + git commit -m "ci: update coverage baseline [skip ci]" + git push + fi + + report: + runs-on: ubuntu-latest + name: "Quality Report" + needs: [php-quality, vue-quality, security, license, phpunit, newman, playwright, baseline-protection] + if: always() + + steps: + - name: Download all result artifacts + uses: actions/download-artifact@v4 + with: + pattern: result-* + path: results/ + merge-multiple: false + + - name: Download license reports + uses: actions/download-artifact@v4 + with: + pattern: license-report-* + path: license-reports/ + merge-multiple: true + continue-on-error: true + + - name: Download coverage report + uses: actions/download-artifact@v4 + with: + name: coverage-report + path: coverage/ + continue-on-error: true + + - name: Download Playwright coverage + uses: actions/download-artifact@v4 + with: + name: playwright-report + path: results/playwright-report/ + continue-on-error: true + + - name: Install PDF tools + run: sudo apt-get update -qq && sudo apt-get install -y -qq pandoc wkhtmltopdf + + - name: Generate report + run: | + set -euo pipefail + + REPORT="quality-report.md" + REPO="${{ github.repository }}" + SHA="${{ github.sha }}" + SHORT_SHA="${SHA:0:7}" + BRANCH="${{ github.ref_name }}" + EVENT="${{ github.event_name }}" + DATE="$(date -u +'%Y-%m-%d %H:%M UTC')" + RUN_URL="https://github.com/$REPO/actions/runs/${{ github.run_id }}" + + # Helper to read result from artifact files + read_result() { + local file="$1" + if [ -f "$file" ]; then + cat "$file" | tr -d '[:space:]' + else + echo "skipped" + fi + } + + icon() { + case "$1" in + success|Success) echo "PASS" ;; + skipped|Skipped) echo "SKIP" ;; + *) echo "FAIL" ;; + esac + } + + # ── Header ── + { + echo "# Quality Report" + echo "" + echo "| | |" + echo "|---|---|" + echo "| **Repository** | $REPO |" + echo "| **Commit** | $SHORT_SHA |" + echo "| **Branch** | $BRANCH |" + echo "| **Event** | $EVENT |" + echo "| **Generated** | $DATE |" + echo "| **Workflow Run** | $RUN_URL |" + echo "" + } > "$REPORT" + + # ── Overall Summary ── + { + echo "## Summary" + echo "" + echo "| Group | Result |" + echo "|-------|--------|" + echo "| PHP Quality | $(icon '${{ needs.php-quality.result }}') |" + echo "| Vue Quality | $(icon '${{ needs.vue-quality.result }}') |" + echo "| Security | $(icon '${{ needs.security.result }}') |" + echo "| License | $(icon '${{ needs.license.result }}') |" + echo "| PHPUnit | $(icon '${{ needs.phpunit.result }}') |" + echo "| Newman | $(icon '${{ needs.newman.result }}') |" + echo "| Playwright | $(icon '${{ needs.playwright.result }}') |" + echo "" + } >> "$REPORT" + + # ── PHP Quality Details ── + { + echo "## PHP Quality" + echo "" + echo "| Tool | Result |" + echo "|------|--------|" + for tool in lint phpcs phpmd psalm phpstan phpmetrics; do + result=$(read_result "results/result-php-quality-$tool/$tool.txt") + echo "| $tool | $(icon "$result") |" + done + echo "" + } >> "$REPORT" + + # ── Vue Quality Details ── + { + echo "## Vue Quality" + echo "" + echo "| Tool | Result |" + echo "|------|--------|" + for tool in eslint stylelint; do + result=$(read_result "results/result-vue-quality-$tool/$tool.txt") + echo "| $tool | $(icon "$result") |" + done + echo "" + } >> "$REPORT" + + # ── Security Details ── + { + echo "## Security" + echo "" + echo "| Ecosystem | Result |" + echo "|-----------|--------|" + for eco in composer npm; do + result=$(read_result "results/result-security-$eco/$eco.txt") + echo "| $eco | $(icon "$result") |" + done + echo "" + } >> "$REPORT" + + # ── License Compliance Details ── + { + echo "## License Compliance" + echo "" + echo "| Ecosystem | Result |" + echo "|-----------|--------|" + for eco in composer npm; do + result=$(read_result "results/result-license-$eco/$eco.txt") + echo "| $eco | $(icon "$result") |" + done + echo "" + + # Include license summary from JSON reports if available + for eco in composer npm; do + JSON_FILE="license-reports/$eco-licenses.json" + if [ -f "$JSON_FILE" ]; then + TOTAL=$(jq 'length' "$JSON_FILE") + APPROVED=$(jq '[.[] | select(.status == "approved")] | length' "$JSON_FILE") + OVERRIDDEN=$(jq '[.[] | select(.status == "approved-override")] | length' "$JSON_FILE") + DENIED=$(jq '[.[] | select(.status == "DENIED")] | length' "$JSON_FILE") + echo "### $eco dependencies ($TOTAL total)" + echo "" + echo "| Metric | Count |" + echo "|--------|-------|" + echo "| Approved (allowlist) | $APPROVED |" + echo "| Approved (override) | $OVERRIDDEN |" + echo "| **Denied** | **$DENIED** |" + echo "" + + if [ "$DENIED" -gt 0 ]; then + echo "#### Denied packages" + echo "" + echo "| Package | Version | License |" + echo "|---------|---------|---------|" + jq -r '.[] | select(.status == "DENIED") | "| \(.package) | \(.version) | \(.license) |"' "$JSON_FILE" + echo "" + fi + fi + done + } >> "$REPORT" + + # ── PHPUnit Results ── + { + echo "## PHPUnit Tests" + echo "" + if [ "${{ needs.phpunit.result }}" = "skipped" ]; then + echo "*PHPUnit tests were not enabled for this run.*" + else + echo "| PHP | Nextcloud | Result |" + echo "|-----|-----------|--------|" + echo "| Overall | | $(icon '${{ needs.phpunit.result }}') |" + fi + echo "" + + # Include coverage summary if available + if [ -f "coverage/clover.xml" ]; then + COVERED=$(grep -oP 'coveredstatements="\K[0-9]+' coverage/clover.xml | head -1 || echo "0") + TOTAL_STMTS=$(grep -oP 'statements="\K[0-9]+' coverage/clover.xml | head -1 || echo "0") + if [ "$TOTAL_STMTS" -gt 0 ]; then + COVERAGE=$(echo "scale=1; $COVERED * 100 / $TOTAL_STMTS" | bc) + echo "**Code coverage:** ${COVERAGE}% ($COVERED / $TOTAL_STMTS statements)" + echo "" + fi + fi + } >> "$REPORT" + + # ── Newman Results ── + { + echo "## Integration Tests (Newman)" + echo "" + if [ "${{ needs.newman.result }}" = "skipped" ]; then + echo "*Newman integration tests were not enabled for this run.*" + else + echo "| Result |" + echo "|--------|" + echo "| $(icon '${{ needs.newman.result }}') |" + fi + echo "" + } >> "$REPORT" + + # ── Playwright Results ── + { + echo "## E2E Tests (Playwright)" + echo "" + if [ "${{ needs.playwright.result }}" = "skipped" ]; then + echo "*Playwright E2E tests were not enabled for this run.*" + else + echo "| Result |" + echo "|--------|" + echo "| $(icon '${{ needs.playwright.result }}') |" + fi + echo "" + + # Include spec-to-test coverage if available + COVERAGE_FILE="results/playwright-report/playwright-coverage.json" + if [ -f "$COVERAGE_FILE" ]; then + SPEC_COUNT=$(jq '.specScenarios' "$COVERAGE_FILE") + TEST_COUNT=$(jq '.playwrightTests' "$COVERAGE_FILE") + FLOW_COUNT=$(jq '.testFlows' "$COVERAGE_FILE") + COVERAGE=$(jq '.coverage' "$COVERAGE_FILE") + echo "### Test Coverage" + echo "" + echo "| Metric | Value |" + echo "|--------|-------|" + echo "| Spec scenarios | $SPEC_COUNT |" + echo "| Playwright tests | $TEST_COUNT |" + echo "| Test flows (LLM) | $FLOW_COUNT |" + echo "| **Spec-to-test coverage** | **${COVERAGE}%** |" + echo "| Threshold | ${{ inputs.playwright-coverage-threshold }}% |" + echo "" + if [ "$COVERAGE" -lt "${{ inputs.playwright-coverage-threshold }}" ]; then + echo "> **Warning:** Coverage ${COVERAGE}% is below the ${​{ inputs.playwright-coverage-threshold }}% threshold." + echo "" + fi + fi + } >> "$REPORT" + + # ── Footer ── + { + echo "---" + echo "" + echo "*Generated automatically by the [Quality workflow]($RUN_URL).*" + } >> "$REPORT" + + echo "Report generated: $(wc -l < "$REPORT") lines" + + - name: Convert to PDF + run: | + pandoc quality-report.md \ + -o quality-report.pdf \ + --pdf-engine=wkhtmltopdf \ + --metadata title="Quality Report" \ + -V margin-top=20mm \ + -V margin-bottom=20mm \ + -V margin-left=15mm \ + -V margin-right=15mm + + - name: Write step summary + if: always() + run: cat quality-report.md >> $GITHUB_STEP_SUMMARY + + - name: Upload PDF report + if: always() + uses: actions/upload-artifact@v4 + with: + name: quality-report + path: | + quality-report.pdf + quality-report.md + retention-days: 90 + + - name: Comment PR with results + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + let body = fs.readFileSync('quality-report.md', 'utf8'); + body += `\n\n> Download the [full PDF report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) from the workflow artifacts.\n`; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body + }); + + # ╔══════════════════════════════════════════════╗ + # ║ STAGE 4 — SBOM Generation & Validation ║ + # ║ ║ + # ║ Generates a CycloneDX SBOM from Composer ║ + # ║ and npm dependencies, validates against CVE ║ + # ║ databases and license policies, then commits ║ + # ║ the validated SBOM to the repository. ║ + # ╚══════════════════════════════════════════════╝ + + sbom: + if: ${{ inputs.enable-sbom && !cancelled() && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta' || github.ref == 'refs/heads/development') }} + runs-on: ubuntu-latest + name: "SBOM" + needs: [security] + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref || github.ref_name }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.php-version }} + extensions: mbstring, intl, zip, gd, curl, xml, json + tools: composer:v2 + + - name: Setup Node + if: ${{ inputs.enable-frontend }} + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install Composer dependencies + run: composer install --no-progress --prefer-dist --optimize-autoloader + + - name: Generate PHP SBOM + run: composer CycloneDX:make-sbom --output-format=JSON --output-file=bom-php.cdx.json --spec-version=1.5 --omit=dev --omit=plugin + + - name: Install npm dependencies + if: ${{ inputs.enable-frontend }} + run: npm ci + + - name: Generate npm SBOM + if: ${{ inputs.enable-frontend }} + run: npx @cyclonedx/cyclonedx-npm --output-file bom-npm.cdx.json --spec-version 1.5 --omit dev + + - name: Merge PHP + npm SBOMs + if: ${{ inputs.enable-frontend }} + run: | + jq -s '.[0] * {components: ([.[].components[]?] | unique_by(.purl // .name))}' bom-php.cdx.json bom-npm.cdx.json > sbom.cdx.json + + - name: Use PHP SBOM as single SBOM + if: ${{ !inputs.enable-frontend }} + run: mv bom-php.cdx.json sbom.cdx.json + + # ── Validation gate ── + + - name: Install Grype + uses: anchore/scan-action/download-grype@v5 + id: grype-install + + - name: Create Grype ignore list for known false positives + run: | + if [ ! -f .grype.yaml ]; then + cat > .grype.yaml << 'GRYPE' + ignore: + # False positives: Grype matches unscoped CycloneDX component names + # against malware advisories for typosquatting packages. Actual deps + # are scoped (@jridgewell/gen-mapping, @babel/helper-validator-identifier). + - vulnerability: GHSA-8rmg-jf7p-4p22 + - vulnerability: GHSA-pvjq-589m-3mc8 + GRYPE + fi + + - name: CVE scan SBOM + run: ${{ steps.grype-install.outputs.cmd }} sbom:sbom.cdx.json --fail-on critical + + - name: Composer audit + run: composer audit --format=json || true + # composer audit exits non-zero for any advisory; we rely on Grype for blocking + + - name: npm audit + if: ${{ inputs.enable-frontend }} + run: npm audit --audit-level=critical + + # ── Commit validated SBOM ── + + - name: Commit SBOM + if: ${{ github.ref == 'refs/heads/main' }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add sbom.cdx.json + if git diff --cached --quiet; then + echo "No SBOM changes to commit" + else + git commit -m "chore: update SBOM" + git push + fi + + - name: Upload SBOM artifact + uses: actions/upload-artifact@v4 + with: + name: sbom-${{ inputs.app-name }} + path: sbom.cdx.json + retention-days: 90 + + - name: Attach SBOM to release + if: ${{ startsWith(github.ref, 'refs/tags/') }} + uses: softprops/action-gh-release@v2 + with: + files: sbom.cdx.json