Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions .github/workflows/continuous-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,26 @@ jobs:
uses: "actions/checkout@v6"
with:
path: "${{ inputs.plugin-key }}"
- name: "Detect coverage configuration"
id: "coverage-config"
# Use default `bash` shell with `github-actions-runner` user
shell: "bash"
working-directory: "${{ github.workspace }}/${{ inputs.plugin-key }}"
run: |
CONFIG_FILE=".glpi-coverage.json"
if [[ -f "$CONFIG_FILE" ]]; then
ENABLED=$(jq -r '.enabled // true' "$CONFIG_FILE")
if [[ "$ENABLED" != "true" ]]; then
echo "coverage-enabled=false" >> $GITHUB_OUTPUT
echo "ℹ️ Code coverage is disabled via $CONFIG_FILE"
exit 0
fi
echo "coverage-enabled=true" >> $GITHUB_OUTPUT
else
echo "coverage-enabled=false" >> $GITHUB_OUTPUT
echo "ℹ️ No $CONFIG_FILE found, code coverage is disabled."
exit 0
fi
- name: "Execute init script"
if: ${{ inputs.init-script != '' }}
# Use default `bash` shell with `github-actions-runner` user
Expand Down Expand Up @@ -262,18 +282,55 @@ jobs:
shell: "bash"
run: |
sudo service apache2 start
- name: "Setup coverage driver"
if: ${{ !cancelled() && steps.coverage-config.outputs.coverage-enabled == 'true' }}
shell: "bash"
run: |
if ! php -m | grep -q -E 'xdebug|pcov'; then
echo -e "\033[0;33mInstalling PCOV driver...\033[0m"
sudo pecl install pcov || true
fi

- name: "PHPUnit"
if: ${{ !cancelled() && hashFiles(format('{0}/phpunit.xml', inputs.plugin-key)) != '' }}
env:
PCOV_ENABLED: "${{ steps.coverage-config.outputs.coverage-enabled == 'true' && '1' || '0' }}"
XDEBUG_MODE: "${{ steps.coverage-config.outputs.coverage-enabled == 'true' && 'coverage' || 'off' }}"
run: |
echo -e "\033[0;33mExecuting PHPUnit...\033[0m"
PHPUNIT_FLAGS="--colors=always"
PHP_CMD="php"

if [[ "${{ steps.coverage-config.outputs.coverage-enabled }}" == "true" ]]; then
PHPUNIT_FLAGS="$PHPUNIT_FLAGS --coverage-text --coverage-cobertura=cobertura.xml --coverage-clover=clover.xml"
# Explicitly load PCOV if needed
PHP_CMD="php -d extension=pcov.so"
fi

if [[ -f "vendor/bin/phpunit" ]]; then
vendor/bin/phpunit --colors=always
$PHP_CMD vendor/bin/phpunit $PHPUNIT_FLAGS
elif [[ -f "../../vendor/bin/phpunit" ]]; then
../../vendor/bin/phpunit --colors=always
$PHP_CMD ../../vendor/bin/phpunit $PHPUNIT_FLAGS
else
echo -e "\033[0;31mPHPUnit binary not found!\033[0m"
exit 1
fi
- name: "Fix coverage paths for IDE import"
if: ${{ !cancelled() && steps.coverage-config.outputs.coverage-enabled == 'true' }}
run: |
echo "Sanitizing paths in clover.xml..."
sed -i 's|/var/www/glpi/plugins/${{ inputs.plugin-key }}/|plugins/${{ inputs.plugin-key }}/|g' clover.xml
- name: "Upload coverage report"
uses: "actions/upload-artifact@v6"
if: ${{ !cancelled() && steps.coverage-config.outputs.coverage-enabled == 'true' }}
with:
name: "coverage-report"
path: |
/var/www/glpi/plugins/${{ inputs.plugin-key }}/cobertura.xml
/var/www/glpi/plugins/${{ inputs.plugin-key }}/clover.xml
/var/www/glpi/plugins/${{ inputs.plugin-key }}/.glpi-coverage.json
include-hidden-files: true
overwrite: true
- name: "Jest"
if: ${{ !cancelled() && hashFiles(format('{0}/jest.config.js', inputs.plugin-key)) != '' }}
run: |
Expand Down
94 changes: 94 additions & 0 deletions .github/workflows/coverage-refresh.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: "Coverage refresh"

on:
workflow_call:
inputs:
plugin-key:
required: true
type: string
workflow-name:
description: "Name of the CI workflow to trigger for coverage refresh. Must match the 'name' field in the plugin's CI workflow file."
required: false
type: string
default: "Continuous integration"

jobs:
check-and-refresh:
name: "Check and refresh coverage artifact"
runs-on: "ubuntu-latest"
steps:
- name: "Checkout"
uses: "actions/checkout@v6"
with:
sparse-checkout: ".glpi-coverage.json"

- name: "Check coverage configuration"
id: "coverage-config"
run: |
CONFIG_FILE=".glpi-coverage.json"
if [[ ! -f "$CONFIG_FILE" ]]; then
echo "ℹ️ No $CONFIG_FILE found, skipping coverage refresh."
echo "skip=true" >> $GITHUB_OUTPUT
exit 0
fi

ENABLED=$(jq -r '.enabled // true' "$CONFIG_FILE")
if [[ "$ENABLED" != "true" ]]; then
echo "ℹ️ Code coverage is disabled via $CONFIG_FILE, skipping refresh."
echo "skip=true" >> $GITHUB_OUTPUT
exit 0
fi

echo "skip=false" >> $GITHUB_OUTPUT

- name: "Check artifact expiry"
if: steps.coverage-config.outputs.skip != 'true'
id: "check-expiry"
env:
GH_TOKEN: ${{ github.token }}
run: |
echo "Checking for existing coverage artifacts..."

# The clearlyip action uses the naming pattern: coverage-{branch_name}
DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
ARTIFACT_NAME="coverage-${DEFAULT_BRANCH}"

# List artifacts matching the coverage pattern
ARTIFACTS=$(gh api \
"/repos/${{ github.repository }}/actions/artifacts?name=${ARTIFACT_NAME}&per_page=1" \
--jq '.artifacts[0]' 2>/dev/null || echo "null")

if [[ "$ARTIFACTS" == "null" || -z "$ARTIFACTS" ]]; then
echo "⚠️ No coverage artifact found. Refresh needed."
echo "needs-refresh=true" >> $GITHUB_OUTPUT
exit 0
fi

EXPIRES_AT=$(echo "$ARTIFACTS" | jq -r '.expires_at // empty')
if [[ -z "$EXPIRES_AT" ]]; then
echo "⚠️ Could not determine artifact expiry. Refresh needed."
echo "needs-refresh=true" >> $GITHUB_OUTPUT
exit 0
fi

EXPIRES_TS=$(date -d "$EXPIRES_AT" +%s)
TOMORROW_TS=$(date -d "+1 day" +%s)

if [[ "$EXPIRES_TS" -le "$TOMORROW_TS" ]]; then
echo "⏰ Coverage artifact expires at $EXPIRES_AT (within 1 day). Refresh needed."
echo "needs-refresh=true" >> $GITHUB_OUTPUT
else
echo "✅ Coverage artifact is valid until $EXPIRES_AT. No refresh needed."
echo "needs-refresh=false" >> $GITHUB_OUTPUT
fi

- name: "Trigger CI workflow"
if: steps.check-expiry.outputs.needs-refresh == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
echo "🔄 Triggering CI workflow on default branch to refresh coverage artifact..."
gh workflow run "${{ inputs.workflow-name }}" \
--repo "${{ github.repository }}" \
--ref "${{ github.event.repository.default_branch }}"
echo "✅ Workflow dispatch triggered."
103 changes: 103 additions & 0 deletions .github/workflows/coverage-report.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
name: "Coverage report"

on:
workflow_call:
inputs:
plugin-key:
required: true
type: string

permissions:
pull-requests: write
actions: read

jobs:
coverage-report:
runs-on: "ubuntu-latest"
name: "Coverage report"
steps:
- name: "Download coverage report"
uses: "actions/download-artifact@v7"
with:
name: "coverage-report"

- name: "Read coverage configuration"
id: "coverage-config"
run: |
CONFIG_FILE=".glpi-coverage.json"
if [[ ! -f "$CONFIG_FILE" ]]; then
echo "⚠️ No $CONFIG_FILE found, skipping coverage report."
echo "skip=true" >> $GITHUB_OUTPUT
exit 0
fi

ENABLED=$(jq -r '.enabled // true' "$CONFIG_FILE")
if [[ "$ENABLED" != "true" ]]; then
echo "ℹ️ Code coverage is disabled via $CONFIG_FILE"
echo "skip=true" >> $GITHUB_OUTPUT
exit 0
fi

echo "skip=false" >> $GITHUB_OUTPUT
echo "only-list-changed-files=$(jq -r '.only_list_changed_files // true' "$CONFIG_FILE")" >> $GITHUB_OUTPUT
echo "badge=$(jq -r '.badge // true' "$CONFIG_FILE")" >> $GITHUB_OUTPUT
echo "overall-coverage-fail-threshold=$(jq -r '.overall_coverage_fail_threshold // 0' "$CONFIG_FILE")" >> $GITHUB_OUTPUT
echo "file-coverage-error-min=$(jq -r '.file_coverage_error_min // 50' "$CONFIG_FILE")" >> $GITHUB_OUTPUT
echo "file-coverage-warning-max=$(jq -r '.file_coverage_warning_max // 75' "$CONFIG_FILE")" >> $GITHUB_OUTPUT
echo "fail-on-negative-difference=$(jq -r '.fail_on_negative_difference // false' "$CONFIG_FILE")" >> $GITHUB_OUTPUT
echo "retention-days=$(jq -r '.retention_days // 90' "$CONFIG_FILE")" >> $GITHUB_OUTPUT

- name: "Generate coverage report"
if: steps.coverage-config.outputs.skip != 'true'
uses: "clearlyip/code-coverage-report-action@v6"
id: "coverage-report"
with:
filename: "cobertura.xml"
only_list_changed_files: ${{ steps.coverage-config.outputs.only-list-changed-files }}
badge: ${{ steps.coverage-config.outputs.badge }}
overall_coverage_fail_threshold: ${{ steps.coverage-config.outputs.overall-coverage-fail-threshold }}
file_coverage_error_min: ${{ steps.coverage-config.outputs.file-coverage-error-min }}
file_coverage_warning_max: ${{ steps.coverage-config.outputs.file-coverage-warning-max }}
fail_on_negative_difference: ${{ steps.coverage-config.outputs.fail-on-negative-difference }}
retention_days: ${{ steps.coverage-config.outputs.retention-days }}
artifact_download_workflow_names: "Continuous integration"

- name: "Generating Markdown report"
if: github.event_name == 'pull_request' && steps.coverage-config.outputs.skip != 'true' && steps.coverage-report.outputs.file != ''
run: |
COVERAGE="${{ steps.coverage-report.outputs.coverage }}"
REPORT_FILE="code-coverage-results.md"
ARTIFACT_LINK="📥 [Download coverage-report artifact](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) _(contains \`cobertura.xml\` for IDE import + config file)_"

# Split: keep header/badge visible, collapse the table inside <details>
FIRST_TABLE_LINE=$(grep -n "^|" "$REPORT_FILE" | head -1 | cut -d: -f1)

if [[ -z "$FIRST_TABLE_LINE" ]]; then
{
cat "$REPORT_FILE"
echo ""
echo "$ARTIFACT_LINK"
} > "${REPORT_FILE}.tmp"
else
{
head -n "$((FIRST_TABLE_LINE - 1))" "$REPORT_FILE"
echo ""
echo "<details>"
echo "<summary>📋 Details</summary>"
echo ""
tail -n "+${FIRST_TABLE_LINE}" "$REPORT_FILE"
echo ""
echo "$ARTIFACT_LINK"
echo ""
echo "</details>"
} > "${REPORT_FILE}.tmp"
fi

mv "${REPORT_FILE}.tmp" "$REPORT_FILE"

- name: "Add coverage PR comment"
if: github.event_name == 'pull_request' && steps.coverage-config.outputs.skip != 'true' && steps.coverage-report.outputs.file != ''
uses: "marocchino/sticky-pull-request-comment@v2"
with:
header: coverage
path: code-coverage-results.md
79 changes: 77 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ jobs:
plugin-key: "myplugin"

# The version of GLPI on which to run the tests.
glpi-version: "10.0.x"
glpi-version: "11.0.x"

# The version of PHP on which to run the tests.
php-version: "8.1"
php-version: "8.2"

# The database docker image on which to run the tests.
db-image: "mariadb:11.4"
Expand All @@ -62,6 +62,75 @@ The `db-image` parameter is a combination of the DB server engine (`mysql`, `mar
An optional `init-script` parameter can be used to define the path of an initialization script. This script will be executed with `bash`.
It can be used, for instance, to install a specific PHP extension.

## Code coverage

Code coverage is automatically enabled when a `.glpi-coverage.json` configuration file is present at the root of the plugin directory.

If the file is not present, or if its `enabled` field is explicitly set to `false`, code coverage steps will be skipped entirely.

### `.glpi-coverage.json` format

All fields are optional. Default values are shown below:

```json
{
"enabled": true,
"only_list_changed_files": true,
"badge": true,
"overall_coverage_fail_threshold": 0,
"file_coverage_error_min": 50,
"file_coverage_warning_max": 75,
"fail_on_negative_difference": false,
"retention_days": 90
}
```

| Field | Default | Description |
|-----------------------------------|---------|-----------------------------------------------------------------------------------------------------|
| `enabled` | `true` | Set to `false` to disable code coverage entirely. |
| `only_list_changed_files` | `true` | Only list files changed in the PR in the coverage report. |
| `badge` | `true` | Include a coverage badge in the report using shields.io. |
| `overall_coverage_fail_threshold` | `0` | Fail the workflow if overall coverage is below this percentage. |
| `file_coverage_error_min` | `50` | Files with coverage below this percentage are marked as error (red). |
| `file_coverage_warning_max` | `75` | Files with coverage below this percentage are marked as warning (orange). Above is success (green). |
| `fail_on_negative_difference` | `false` | Fail the workflow if any file coverage decreased compared to the base branch. |
| `retention_days` | `90` | Number of days to retain coverage artifacts for base branch comparison. |

> **Tip:** To use as a reference without enabling coverage (e.g. for `glpi-empty`), create the file with `"enabled": false`.

### IDE Integration

The workflow produces a `coverage-report` artifact containing:
- `clover.xml`: Use this file to import coverage into PhpStorm or other IDEs. Paths are automatically sanitized to match `plugins/<plugin-key>/`.
- `cobertura.xml`: Used for the PR comment report.

### Coverage report workflow

The `coverage-report.yml` reusable workflow generates a PR comment with a coverage summary. It compares the coverage from the current PR against the base branch (using stored artifacts).

```yaml
coverage-report:
needs: "ci"
uses: "glpi-project/plugin-ci-workflows/.github/workflows/coverage-report.yml@v1"
with:
plugin-key: "myplugin"
```

### Coverage refresh workflow

The `coverage-refresh.yml` reusable workflow ensures that the base branch coverage artifact stays available for comparison.
It checks the artifact expiry date via the GitHub API and triggers the CI workflow on the default branch only if the artifact is missing or will expire within the next day.

It should be triggered on `schedule` events (the daily cron in the CI workflow):

```yaml
coverage-refresh:
if: github.event_name == 'schedule'
uses: "glpi-project/plugin-ci-workflows/.github/workflows/coverage-refresh.yml@v1"
with:
plugin-key: "myplugin"
```

## Generate CI matrix

This workflow can be used to generate a matrix that contains the default PHP/SQL versions that are supported by the target GLPI version.
Expand Down Expand Up @@ -107,4 +176,10 @@ jobs:
glpi-version: "${{ matrix.glpi-version }}"
php-version: "${{ matrix.php-version }}"
db-image: "${{ matrix.db-image }}"

coverage-report:
needs: "ci"
uses: "glpi-project/plugin-ci-workflows/.github/workflows/coverage-report.yml@v1"
with:
plugin-key: "myplugin"
```