Reusable GitHub Actions workflows for Netresearch TYPO3 extension repositories.
Copy these caller workflows into your extension's .github/workflows/ directory. Most workflows work with zero configuration.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
permissions: {}
jobs:
ci:
uses: netresearch/typo3-ci-workflows/.github/workflows/ci.yml@main
permissions:
contents: read# .github/workflows/security.yml
name: Security
on:
push:
branches: [main]
pull_request:
permissions: {}
jobs:
security:
uses: netresearch/typo3-ci-workflows/.github/workflows/security.yml@main
permissions:
contents: read
security-events: write# .github/workflows/codeql.yml
name: CodeQL
on:
push:
branches: [main]
schedule:
- cron: '0 6 * * 1'
permissions: {}
jobs:
codeql:
uses: netresearch/typo3-ci-workflows/.github/workflows/codeql.yml@main
permissions:
contents: read
security-events: write
actions: read# .github/workflows/auto-merge-deps.yml
name: Auto-merge dependency PRs
on:
pull_request:
permissions: {}
jobs:
auto-merge:
uses: netresearch/typo3-ci-workflows/.github/workflows/auto-merge-deps.yml@main
permissions:
contents: write
pull-requests: writeIn addition to workflow callers, each extension repo needs these GitHub webhooks configured. Go to Settings → Webhooks → Add webhook in each repo.
| Setting | Value |
|---|---|
| Payload URL | https://packagist.org/api/github |
| Content type | application/json |
| SSL verification | Enabled |
| Events | Just the push event |
Auto-updates the Composer package on Packagist whenever you push.
| Setting | Value |
|---|---|
| Payload URL | https://docs-hook.typo3.org |
| Content type | application/json |
| SSL verification | Enabled |
| Events | Just the push event |
Triggers automatic documentation rendering and publishing on docs.typo3.org. First-time builds require manual approval by the TYPO3 Documentation Team (1-3 business days). See the typo3-docs skill for the full deployment guide.
# Add both webhooks to a repo
gh api repos/netresearch/REPO/hooks --method POST \
-f name=web -f "config[url]=https://packagist.org/api/github" \
-f "config[content_type]=json" --raw-field "events[]=push" -f active=true
gh api repos/netresearch/REPO/hooks --method POST \
-f name=web -f "config[url]=https://docs-hook.typo3.org" \
-f "config[content_type]=json" --raw-field "events[]=push" -f active=true| Workflow | Purpose | Trigger |
|---|---|---|
ci.yml |
PHP lint, CGL, PHPStan, Rector, unit/functional tests | push, PR |
extended-testing.yml |
Coverage, mutation testing, fuzz testing, JS tests | push, PR |
e2e.yml |
Playwright browser tests with TYPO3 backend | push, PR |
| Workflow | Purpose | Trigger |
|---|---|---|
security.yml |
Composer audit + Opengrep SAST | push, PR |
codeql.yml |
GitHub CodeQL security scanning | push, schedule |
dependency-review.yml |
Dependency vulnerability review | PR only |
license-check.yml |
PHP dependency license audit | push, PR |
scorecard.yml |
OpenSSF Scorecard analysis | push, schedule |
| Workflow | Purpose | Trigger |
|---|---|---|
release.yml |
Enterprise release pipeline (archive, SBOM, cosign, attestation) | tag push |
publish-to-ter.yml |
Publish extension to TYPO3 TER | tag push |
| Workflow | Purpose | Trigger |
|---|---|---|
auto-merge-deps.yml |
Auto-merge Dependabot/Renovate PRs | PR |
pr-quality.yml |
PR size check + auto-approve for solo maintainers | PR |
labeler.yml |
Automatic PR labeling based on file paths | PR |
stale.yml |
Mark and close stale issues and PRs | schedule |
lock.yml |
Lock old inactive issues and PRs | schedule |
greetings.yml |
Greet first-time contributors | issue, PR |
docs.yml |
Render and verify TYPO3 documentation | push, PR |
The main CI workflow. Runs PHP lint, code style, PHPStan, Rector, and unit/functional tests across a PHP/TYPO3 version matrix.
jobs:
ci:
uses: netresearch/typo3-ci-workflows/.github/workflows/ci.yml@main
permissions:
contents: readjobs:
ci:
uses: netresearch/typo3-ci-workflows/.github/workflows/ci.yml@main
permissions:
contents: read
with:
php-versions: '["8.2", "8.3", "8.4"]'
typo3-versions: '["^13.4", "^14.0"]'
matrix-exclude: '[{"php":"8.2","typo3":"^14.0"}]'
run-functional-tests: true
functional-test-db: mariadb
db-image: 'mariadb:11.4'
upload-coverage: true
remove-dev-deps: '[{"dep":"saschaegerer/phpstan-typo3","only-for":"^12|^13"}]'
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}| Input | Type | Default | Description |
|---|---|---|---|
php-versions |
string | '["8.4"]' |
JSON array of PHP versions |
typo3-versions |
string | '["^13.4"]' |
JSON array of TYPO3 versions |
matrix-exclude |
string | '[]' |
JSON array of {php, typo3} combinations to exclude |
typo3-packages |
string | '["typo3/cms-core"]' |
JSON array of TYPO3 packages to require |
php-extensions |
string | intl, mbstring, xml |
PHP extensions to install |
run-lint |
boolean | true |
Run PHP syntax lint |
run-cgl |
boolean | true |
Run code style check (PHP-CS-Fixer) |
run-phpstan |
boolean | true |
Run PHPStan static analysis |
run-rector |
boolean | true |
Run Rector dry-run |
run-unit-tests |
boolean | true |
Run PHPUnit unit tests |
run-functional-tests |
boolean | false |
Run PHPUnit functional tests |
functional-test-db |
string | sqlite |
Database: sqlite, mysql, mariadb, postgres |
db-image |
string | mysql:9.6 |
Docker image for database service |
upload-coverage |
boolean | false |
Upload coverage to Codecov |
coverage-tool |
string | xdebug |
Coverage driver: xdebug (branch + path coverage, matches local XDEBUG_MODE=coverage) or pcov (line-only, ~3-10× faster) |
remove-dev-deps |
string | '[]' |
JSON array of dev deps to remove for TYPO3 version compat |
cgl-command |
string | auto-detect | Override CGL command |
phpstan-command |
string | auto-detect | Override PHPStan command |
rector-command |
string | auto-detect | Override Rector command |
unit-test-command |
string | auto-detect | Override unit test command |
functional-test-command |
string | auto-detect | Override functional test command |
| Secret | Required | Description |
|---|---|---|
CODECOV_TOKEN |
No | Required when upload-coverage: true |
Commands are auto-detected from composer scripts (in order):
- CGL:
ci:test:php:cgl,ci:cgl(+--dry-run),ci:lint:php,check:php:cs-fixer,code:style:check - PHPStan:
ci:test:php:phpstan(+--error-format=github),ci:phpstan(+--error-format=github),ci:stan,check:php:stan,code:phpstan - Rector:
ci:test:php:rector,check:php:rector - Unit tests:
ci:test:php:unit(+--no-coverage/--coverage-clover),ci:tests:unit,check:tests:unit,test:unit - Functional tests:
ci:test:php:functional(+--no-coverage/--coverage-clover),ci:tests:functional,check:tests:functional,test:functional
CGL and Rector run on the first PHP version only (code style is PHP-version-independent). PHPStan and tests run on the full matrix.
Coverage, mutation testing, fuzz testing, and JavaScript testing. Each suite is a simple boolean toggle.
jobs:
extended:
uses: netresearch/typo3-ci-workflows/.github/workflows/extended-testing.yml@main
permissions:
contents: read
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
INFECTION_DASHBOARD_API_KEY: ${{ secrets.INFECTION_DASHBOARD_API_KEY }}jobs:
extended:
uses: netresearch/typo3-ci-workflows/.github/workflows/extended-testing.yml@main
permissions:
contents: read
with:
run-integration-tests: true
run-e2e-tests: true
run-js-tests: false
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
INFECTION_DASHBOARD_API_KEY: ${{ secrets.INFECTION_DASHBOARD_API_KEY }}| Input | Type | Default | Description |
|---|---|---|---|
php-version |
string | 8.5 |
PHP version for coverage runs |
node-version |
string | 24 |
Node.js version for JS testing |
run-unit-tests |
boolean | true |
Run PHP unit tests with coverage |
run-functional-tests |
boolean | true |
Run PHP functional tests with coverage |
run-integration-tests |
boolean | false |
Run PHP integration tests with coverage |
run-e2e-tests |
boolean | false |
Run PHP E2E tests with coverage |
run-js-tests |
boolean | true |
Run JavaScript tests (Vitest) |
run-mutation-tests |
boolean | true |
Run PHP mutation testing (Infection) |
run-fuzz-tests |
boolean | true |
Run PHP fuzz tests |
unit-test-config |
string | Build/phpunit/UnitTests.xml |
PHPUnit config for unit tests |
functional-test-config |
string | Build/phpunit/FunctionalTests.xml |
PHPUnit config for functional tests |
integration-test-config |
string | Build/phpunit/IntegrationTests.xml |
PHPUnit config for integration tests |
e2e-test-config |
string | Build/phpunit/E2ETests.xml |
PHPUnit config for E2E tests |
| Secret | Required | Description |
|---|---|---|
CODECOV_TOKEN |
No | Codecov upload token |
INFECTION_DASHBOARD_API_KEY |
No | Infection dashboard API key |
| Job | Default | Depends on | Description |
|---|---|---|---|
unit-coverage |
on | - | Unit tests with coverage upload |
functional-coverage |
on | - | Functional tests with coverage upload |
integration-coverage |
off | - | Integration tests with coverage upload |
e2e-coverage |
off | - | E2E tests with coverage upload |
mutation-testing |
on | unit-coverage | Infection mutation testing |
js-coverage |
on | - | Vitest with coverage upload |
fuzz-testing |
on | - | PHPUnit fuzz test group |
Playwright browser tests against a running TYPO3 instance with database.
jobs:
e2e:
uses: netresearch/typo3-ci-workflows/.github/workflows/e2e.yml@main
permissions:
contents: readjobs:
e2e:
uses: netresearch/typo3-ci-workflows/.github/workflows/e2e.yml@main
permissions:
contents: read
with:
php-version: '8.4'
db-image: 'mariadb:11.4'
test-command: 'npm run test:e2e -- --project=chromium'| Input | Type | Default | Description |
|---|---|---|---|
php-version |
string | 8.4 |
PHP version |
node-version |
string | 24 |
Node.js version |
typo3-setup-extensions |
boolean | true |
Run extension:setup after TYPO3 setup |
playwright-browser |
string | chromium |
Playwright browser to install |
test-command |
string | npm run test:e2e |
E2E test command |
db-image |
string | mariadb:11.4 |
Database Docker image |
php-extensions |
string | mysqli, pdo_mysql, gd, intl, curl, zip |
PHP extensions to install |
timeout-minutes |
number | 30 |
Job timeout in minutes |
artifact-path |
string | Tests/E2E/Playwright/reports/ |
Path to Playwright reports |
web-dir |
string | .Build/Web |
TYPO3 web directory (document root) |
Composer dependency audit and Opengrep SAST (the fully-OSS LGPL-2.1 fork of Semgrep). Both jobs run by default.
Opengrep blocks CI on findings at severity WARNING or higher and also uploads SARIF to the repo's Security tab. Override opengrep-config to tune behavior:
- Report-only (all findings, CI never fails):
--config auto— drop both--errorand--severityso every finding reaches the Security tab without gating merges. - Block only on critical (RCE/SQLi/XXE):
--config auto --error --severity ERROR. - Block on everything, including INFO:
--config auto --error --severity INFO.
jobs:
security:
uses: netresearch/typo3-ci-workflows/.github/workflows/security.yml@main
permissions:
contents: read
security-events: write| Input | Type | Default | Description |
|---|---|---|---|
php-version |
string | 8.4 |
PHP version for Composer audit |
skip-composer-audit |
boolean | false |
Skip Composer dependency audit |
skip-opengrep |
boolean | false |
Skip Opengrep SAST scanning |
opengrep-config |
string | --config auto --error --severity WARNING |
Opengrep scan arguments (rules + behavior flags). See the overrides above for report-only, ERROR-only, and INFO-blocking variants. |
GitHub CodeQL security scanning.
jobs:
codeql:
uses: netresearch/typo3-ci-workflows/.github/workflows/codeql.yml@main
permissions:
contents: read
security-events: write
actions: read| Input | Type | Default | Description |
|---|---|---|---|
languages |
string | actions |
CodeQL languages to analyze (comma-separated) |
Dependency vulnerability review on pull requests.
jobs:
dependency-review:
uses: netresearch/typo3-ci-workflows/.github/workflows/dependency-review.yml@main
permissions:
contents: read
pull-requests: write| Input | Type | Default | Description |
|---|---|---|---|
fail-on-severity |
string | high |
Minimum severity to fail on (low, moderate, high, critical) |
PHP dependency license audit. Fails when forbidden licenses are found.
jobs:
license-check:
uses: netresearch/typo3-ci-workflows/.github/workflows/license-check.yml@main
permissions:
contents: read| Input | Type | Default | Description |
|---|---|---|---|
php-version |
string | 8.4 |
PHP version for license checking |
forbidden-licenses |
string | "(SSPL|BSL)" |
Regex pattern for forbidden licenses |
OpenSSF Scorecard analysis. No inputs.
jobs:
scorecard:
uses: netresearch/typo3-ci-workflows/.github/workflows/scorecard.yml@main
permissions:
contents: read
security-events: write
id-token: write
actions: readEnterprise release pipeline: git archive, SBOM generation (SPDX + CycloneDX), SHA256 checksums, Cosign keyless signing, build provenance attestation, and GitHub Release.
jobs:
release:
uses: netresearch/typo3-ci-workflows/.github/workflows/release.yml@main
permissions:
contents: write
id-token: write
attestations: write
with:
archive-prefix: my-extension
package-name: vendor/my-extension| Input | Type | Required | Default | Description |
|---|---|---|---|---|
archive-prefix |
string | yes | - | Prefix for archive files (e.g., contexts) |
package-name |
string | yes | - | Composer package name (e.g., netresearch/contexts) |
include-sbom |
boolean | no | true |
Include SPDX and CycloneDX SBOMs |
sign-artifacts |
boolean | no | true |
Sign artifacts with Cosign keyless signing |
Publish extension to TYPO3 TER on tag push. Auto-resolves extension key from composer.json and validates the tag version against ext_emconf.php.
jobs:
publish:
uses: netresearch/typo3-ci-workflows/.github/workflows/publish-to-ter.yml@main
permissions:
contents: read
secrets:
TYPO3_TER_ACCESS_TOKEN: ${{ secrets.TYPO3_TER_ACCESS_TOKEN }}| Input | Type | Default | Description |
|---|---|---|---|
php-version |
string | 8.4 |
PHP version for tailor CLI |
ref |
string | event ref | Git ref (tag or SHA) to check out. Set explicitly when re-triggering from workflow_dispatch against a branch. |
verify-timeout-minutes |
number | 10 |
Max minutes to wait for TER to serve the published version |
verify-poll-interval-seconds |
number | 30 |
Seconds between TER verification polls |
update-metadata |
boolean | true |
After publish, sync composer/issues/repository URLs via tailor ter:update. The manual URL is only written when manual-url is non-empty. |
manual-url |
string | '' |
TER's "External manual" field. Empty preserves TER's existing value — recommended for extensions that auto-publish to https://docs.typo3.org/p/<vendor>/<package>/... (TER then auto-links the "Extension Manual" button there). Set explicitly only when your extension does NOT auto-publish and you need a custom manual URL. |
issues-url |
string | '' |
TER's "issues" URL. Empty defaults to ${{ github.server_url }}/${{ github.repository }}/issues. |
repository-url |
string | '' |
TER's "repository" URL. Empty defaults to ${{ github.server_url }}/${{ github.repository }}. |
| Secret | Required | Description |
|---|---|---|
TYPO3_TER_ACCESS_TOKEN |
Yes | TER API access token |
TYPO3_EXTENSION_KEY |
No | Deprecated: auto-resolved from composer.json |
TER surfaces a prominent "Extension Manual" button on every listing. If external_manual is empty, TER auto-links it to https://docs.typo3.org/p/<vendor>/<package>/<major>.<minor>/en-us — the composer-docs auto-publish route. If external_manual is set, the button links there instead.
Most TYPO3 extensions that ship a Documentation/ directory and are listed on Packagist get automatic docs.typo3.org publishing, and the auto-link delivers users to nicely rendered manuals. Writing the GitHub repo URL into external_manual would point readers at raw PHP source instead — worse UX, and not what most consumers want.
This workflow defaults to preserving whatever TER currently has for external_manual (typically empty for auto-publishing extensions). Only set manual-url explicitly if your extension does NOT use docs.typo3.org auto-publish.
Automatically approves and merges Dependabot/Renovate PRs. Auto-detects the repo's allowed merge strategy.
jobs:
auto-merge:
uses: netresearch/typo3-ci-workflows/.github/workflows/auto-merge-deps.yml@main
permissions:
contents: write
pull-requests: writeNo inputs.
PR size check and auto-approve for solo maintainer projects. Skips draft PRs.
jobs:
pr-quality:
uses: netresearch/typo3-ci-workflows/.github/workflows/pr-quality.yml@main
permissions:
contents: read
pull-requests: write| Input | Type | Default | Description |
|---|---|---|---|
size-warning-threshold |
number | 500 |
Lines changed for medium size |
size-alert-threshold |
number | 1000 |
Lines changed for large size warning |
security-controls-path |
string | .github/SECURITY_CONTROLS.md |
Path to security controls docs |
Automatic PR labeling based on file paths.
jobs:
labeler:
uses: netresearch/typo3-ci-workflows/.github/workflows/labeler.yml@main
permissions:
contents: read
pull-requests: write| Input | Type | Default | Description |
|---|---|---|---|
configuration-path |
string | .github/labeler.yml |
Path to the labeler configuration file |
Mark and close stale issues and PRs.
jobs:
stale:
uses: netresearch/typo3-ci-workflows/.github/workflows/stale.yml@main
permissions:
issues: write
pull-requests: write| Input | Type | Default | Description |
|---|---|---|---|
days-before-stale |
number | 60 |
Days before marking as stale |
days-before-close |
number | 7 |
Days before closing stale issues |
exempt-issue-labels |
string | pinned,security,bug |
Comma-separated labels to exempt |
exempt-pr-labels |
string | pinned,security |
Comma-separated PR labels to exempt |
operations-per-run |
number | 30 |
Max operations per run |
stale-issue-message |
string | Generic message | Message when marking issue as stale |
stale-pr-message |
string | Generic message | Message when marking PR as stale |
close-issue-message |
string | Generic message | Message when closing stale issue |
close-pr-message |
string | Generic message | Message when closing stale PR |
Lock old inactive issues and PRs.
jobs:
lock:
uses: netresearch/typo3-ci-workflows/.github/workflows/lock.yml@main
permissions:
issues: write
pull-requests: write| Input | Type | Default | Description |
|---|---|---|---|
issue-inactive-days |
number | 365 |
Days before locking inactive issues |
pr-inactive-days |
number | 365 |
Days before locking inactive PRs |
issue-lock-reason |
string | resolved |
Reason for locking issues |
pr-lock-reason |
string | resolved |
Reason for locking PRs |
log-output |
boolean | true |
Log processed threads |
Greet first-time contributors on issues and PRs.
jobs:
greetings:
uses: netresearch/typo3-ci-workflows/.github/workflows/greetings.yml@main
permissions:
issues: write
pull-requests: write| Input | Type | Default | Description |
|---|---|---|---|
issue-message |
string | Generic welcome | Message for first-time issue authors |
pr-message |
string | Generic welcome | Message for first-time PR authors |
Render and verify TYPO3 documentation.
jobs:
docs:
uses: netresearch/typo3-ci-workflows/.github/workflows/docs.yml@main
permissions:
contents: read| Input | Type | Default | Description |
|---|---|---|---|
input |
string | Documentation |
Path to documentation source |
output |
string | Documentation-GENERATED-temp |
Path for rendered output |
upload-artifact |
boolean | true |
Upload rendered docs as artifact on PRs |
artifact-retention-days |
number | 7 |
Days to retain uploaded artifact |
Standalone fuzz tests and mutation testing with Infection (for repos not using extended-testing.yml).
jobs:
fuzz:
uses: netresearch/typo3-ci-workflows/.github/workflows/fuzz.yml@main
permissions:
contents: read
with:
run-fuzz-tests: true
run-mutation-tests: true| Input | Type | Default | Description |
|---|---|---|---|
php-version |
string | 8.4 |
PHP version for tests |
php-extensions |
string | intl, mbstring, xml |
PHP extensions to install |
fuzz-testsuite |
string | Fuzz |
PHPUnit testsuite name for fuzz tests |
phpunit-config |
string | Build/phpunit.xml |
Path to PHPUnit config |
run-fuzz-tests |
boolean | false |
Run fuzz tests |
run-mutation-tests |
boolean | false |
Run mutation tests with Infection |
mutation-min-msi |
number | 50 |
Minimum Mutation Score Indicator |
mutation-min-covered-msi |
number | 60 |
Minimum Covered MSI |
This repository provides two complementary ways to run CI tooling:
| Entrypoint | Environment | Use case |
|---|---|---|
Build/Scripts/runTests.sh |
Local development | Interactive use, quick feedback loop. Uses .Build/bin/ tools directly (no Docker). |
composer ci:test:php:* |
GitHub Actions CI | Automated CI on native runners. One PHP/DB version per matrix cell. |
Both entrypoints share the same tool configurations (Build/phpstan.neon, Build/.php-cs-fixer.php, Build/rector.php, Build/phpunit.xml), ensuring local results match CI.
A generic runTests.sh template is provided at assets/Build/Scripts/runTests.sh.dist. To use it:
- Copy to your extension:
cp .Build/vendor/netresearch/typo3-ci-workflows/assets/Build/Scripts/runTests.sh.dist Build/Scripts/runTests.sh - Make executable:
chmod +x Build/Scripts/runTests.sh - Customize the extension-point variables at the top of the script (config paths, etc.)
Supported commands: unit, functional, fuzz, mutation, phpstan, cgl, cgl:fix, rector, rector:fix, ci, all.
Add this package to your extension's require-dev:
{
"require-dev": {
"netresearch/typo3-ci-workflows": "^1.1"
}
}This brings in all dev-dependencies (PHPStan, PHP-CS-Fixer, Rector, Infection, testing-framework, etc.) with a single requirement. Your extension only needs tool configuration files (Build/phpstan.neon, Build/.php-cs-fixer.php, etc.) and the reusable GitHub Actions workflows.
When using git worktrees, .git is a file (not a directory), which causes captainhook/hook-installer to fail during composer install.
captainhook/hook-installer fails: .git/hooks is not a directory
Solution 1: --no-plugins (simplest)
composer install --no-pluginsThis skips all Composer plugins including captainhook and phpstan/extension-installer. PHPStan plugins will not auto-register.
Solution 2: Explicit PHPStan includes (recommended with Solution 1)
After --no-plugins, include the explicit plugin file in your Build/phpstan.neon:
includes:
- %currentWorkingDirectory%/.Build/vendor/netresearch/typo3-ci-workflows/config/phpstan/includes-no-extension-installer.neon
- phpstan-baseline.neonThis file lists all PHPStan plugin neon files that extension-installer would normally auto-load.
Solution 3: Create hooks directory first
# For git worktrees, .git is a file pointing to the real git dir.
# Create a hooks dir where captainhook expects it:
GITDIR=$(git rev-parse --git-dir)
mkdir -p "${GITDIR}/hooks"
composer install- All third-party actions are SHA-pinned
step-security/harden-runneron every job- Top-level
permissions: {}with job-level least-privilege persist-credentials: falseon all checkout steps- No
${{ }}expression interpolation inrun:blocks - Randomized heredoc delimiters to prevent output injection
- Timeout-minutes on every job
Never use secrets: inherit when calling these workflows. Always pass only the specific secrets each workflow needs:
# Good - explicit secrets
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
# Bad - exposes all secrets accessible to the caller to every action in the chain
secrets: inheritUsing secrets: inherit is a supply chain risk. If any third-party action in the
workflow chain is compromised (cf. netresearch/ofelia#535),
the attacker gains access to every secret the calling workflow can access. Passing
secrets explicitly limits the blast radius to only what the workflow actually needs.
See the GitHub documentation on passing secrets.