Reusable Drupal module for pre-release QA gate checks and Drush reporting.
Requires
- php: ^8.3
- drupal/core: ^10 || ^11
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Reusable Drupal module for pre-release QA checks, release gating, and Drush reporting.
Purpose
The module is intended to be reused across Drupal 10 and Drupal 11 projects by installing it as a Composer package and enabling it in the target site.
Requirements
- PHP 8.3+
- Drupal 10 or 11
- No extra package is required for shared scanners; this package bundles the
qa_audit_coresubmodule
Features
- Runs a consistent pre-release QA gate through
drush qa:run - Supports full runs or targeted check selection with
--checks=<ids> - Produces table output for operators and JSON output for automation
- Auto-saves timestamped reports to
docs/report/by default - Reads shipped defaults from
qa.config.ymland project overrides fromqa.settingswithout a cache rebuild
Available Checks
config_drift: verifies active configuration matchesconfig/syncprod_split: validates expected production and development config split stateforbidden_modules: blocks dev-only modules from production releasesperformance_hardening: validates cache, asset, Twig, and error-level production settingsstorage_config: verifies public/temporary (and, if configured, private) stream wrappers are registered and writable, and that$settings['config_sync_directory']is set and points at an existing directoryrecent_errors: reads the dblogwatchdogtable and fails on error-level entries in a lookback window (default 24h), grouped by type and message with counts and last location — surfaces the real cause behind Drupal's generic "The website encountered an unexpected error" pagemigration_integrity: detects invalid translation source rows and broken entity references with field-level breakdownstranslation_completeness: reports translation coverage, missing languages, and sample untranslated nodesorphaned_content: finds stale unpublished content and configurable content lacking menu or taxonomy relationshipsbroken_links: validates entity links, internal paths, and sampled external URLsmissing_required_fields: reports published content missing required or configured critical fieldsorphaned_paragraphs: surfaces orphan paragraph findings through the sharedqa_audit_coreservice layerimage_upscaling: finds images that image styles enlarge beyond their original size, which look blurry however large the served file is. For every image field value and every image style whose derivative exists on disk (so some page requested it), it replays the style's effects on the stored original dimensions, using the editor's crop from the Crop API when there is one, and reports the upscale factor with the entities that use the file. Warns at 1.25x and fails at 2x (warn_factor,fail_factor);all_styles: truealso evaluates styles with no derivative yet, andignored_stylesskips styles such as thumbnailsrender_smoke: runs sampled render validation across selected bundlesnavigation: verifies menus are driven by Drupal: required menus have an enabled block in the default theme, links are not broken, unpublished or untranslated, content types do not offer restricted menus (admin, account) to editors, and the theme's layout templates contain no hardcodedhrefsmodule_configuration: verifies that a registered list of contrib modules (e.g.pathauto,honeypot,antibot,simple_sitemap) are installed and properly configured, not just enabledinterface_translations: on multilingual sites withlocaleenabled, reads the translation status cache (populated bydrush locale:checkor the translations report cron job) and warns when a language has updates available, a project has no translation file at all, or the status is stale pastmax_status_age_hours(default 168). "en" is never reported as missing a translation file — interface strings are authored in English, so no project ships one upstream, andtranslate_englishexists precisely to let a site override its own English strings locally via config. Useignored_projectsto skip specific projects entirely, e.g. a small contrib module with no upstream translation for a language the site otherwise supports — a permanent upstream gap a re-check will never resolve
Extending module_configuration
module_configuration delegates to one validator service per module, tagged
qa.module_config_validator (see
src/ReleaseQa/ModuleConfig/ModuleConfigValidatorInterface.php). Shipped
validators live in src/ReleaseQa/ModuleConfig/Validator/:
PathautoValidator: at least one enabled pattern exists; optionally checks specific node bundles are covered; warns when published nodes (per language) in pattern-covered bundles have no URL alias (max_missing_aliases,sample_limit), listing the offenders and the command to generate themBackupMigrateValidator: fails when no schedule is enabled, a schedule has no period or points at a missing source/destination, a destination usespublic://, an unregistered stream wrapper (typicallyprivate://with nofile_private_path) or an unwritable/in-webroot directory, or when no backup file exists; warns when the newest backup is older thanmax_backup_age_days, a schedule keeps every backup, or a source listed inrequired_sourcesis not scheduledHoneypotValidator: fails if honeypot provides no protection at all (time limit is 0, "protect all forms" is off, and no forms are individually protected); warns on a low time limit or missing required forms. Also fails when Honeypot time-checks editing forms ("protect all forms", or node/media/menu/term/block forms listed individually) and a non-admin editor role lacksbypass honeypot protection: AJAX actions count as submissions, and the wait grows with each rejection up to the expiry time, locking editors out of saving. Editor roles are detected byeditor_permissions(default: content or media overview, administer menu, administration pages, or any "create ... content/media");ignored_rolesexcludes rolesCkeditor5Validator: for every text format using CKEditor 5, fails when core's own CKEditor 5 validation rejects the editor configuration (e.g. toolbar plugins that need HTML the format strips, which loses content on save) or when a format without HTML restriction offers source editing to anonymous users; warns when such a format is available to other non-admin roles, when source editing allows active markup (<script>,<iframe>,<style>,<object>,<embed>,<form>,on*orstyleattributes), when the editor can upload images with no maximum dimensions, and when Heading 1 is offered although the page title is already the h1 (h1_allowed_formatslists formats where that is intended;ignored_formatsskips formats)AntibotValidator: warns if Antibot isn't applied to any forms, or to specific required formsSimpleSitemapValidator: fails if no content is indexed for the sitemap; warns if cron generation is disabledMetatagValidator: fails if theglobaldefault is missing or lackstitle/canonical_url; warns when the front page or content ends up without a description, title or canonical URL (inheritance fromglobalis respected), or the Open Graph / Twitter cards submodules are offSchemaMetatagValidator: warns when noschema_*tag is configured on any default (the module is enabled but outputs no JSON-LD), or when site-level (Organization/WebSite) or content (Article/WebPage) structured data is missing. Site-level tags belong on the front page default — search engines read them from the homepage only; setting them onglobalrepeats them on every page and is reported as a note, not a warning. Options:organization_types(require e.g.LocalBusiness),require_content_schema(disable for sites with nothing to mark up),note_global_site_tags. Configuration only; it does not render a page
To add another module to the checked list:
- Create a class implementing
ModuleConfigValidatorInterfaceinsrc/ReleaseQa/ModuleConfig/Validator/.getModuleName()returns the module's machine name;validate()returns aReleaseQaCheckResult(pass/warn/fail) and is only called once that module is installed. - Register it in
qa.services.ymltaggedqa.module_config_validator(follow the existingqa.module_config.*entries). - Optionally add
options.<module_name>defaults undermodule_configurationinqa.config.yml.
No changes to ModuleConfigurationCheck itself are needed — it discovers
validators through the tagged iterator.
A module with a validator that simply isn't installed is skipped (not a
failure) unless it's listed in required_modules.
Installation
composer require protonsystems/qa
drush en qa
This package bundles the reusable qa_audit_core submodule so release QA and
other ProtonSystems tools can share audit services without a third Composer
project.
Usage
# Run all enabled checks.
drush qa:run
# Run a selected subset of checks.
drush qa:run --checks=config_drift,prod_split,performance_hardening
# Emit JSON to stdout and write a specific artifact file.
drush qa:run --format=json --output=tmp/qa/latest.json
# Fail on warnings as well as failures.
drush qa:run --fail-on-warn
The command alias drush release-qa:run is also available.
Configuration
The module ships package defaults in qa.config.yml at the module root.
Projects should override them through the qa.settings Drupal config, either
through config sync or the admin form at
/admin/config/development/qa. The admin form exposes each check separately,
with an enable/disable override and a YAML field for that check's default
options.
checks:
broken_links:
enabled: false
defaults:
max_links: 200
Rules:
- Shipped
qa.config.ymlvalues act as defaults for every project qa.settingsoverrides are merged on top of those defaults per checkenabled: falseskips the check during an unfiltereddrush qa:run--checks=<id>always runs the named check even if it is disabled in YAMLdefaults:values act as baselines and CLI options always override them- Config changes are read at runtime, so no cache rebuild is required
enabledaccepts boolean values from config sync and Drush, includingfalseand'false'- Some checks expose complex list/mapping defaults, so the per-check override field remains YAML rather than one bespoke widget per option
Useful options:
--fail-on-warn: return a non-zero exit code for warnings--checks=<ids>: comma-separated subset of checks--format=table|json: choose human or machine output--output=<path>: write the JSON report to a specific location--save-report=0|1: control automatic report export todocs/report/--sample-size=<n>,--max-bundles=<n>,--bundles=<ids>: render smoke scope overrides--max-broken-refs=<n>,--max-invalid-translations=<n>: migration integrity thresholds--max-links=<n>: broken link scan limit
Reports
When --save-report is enabled, qa:run writes a timestamped JSON report to
docs/report/qa-YYYY-MM-DD_HHMMSS.json. Passing --output=<path> overrides
the default report location.
For the report format and examples, see docs/report/README.md.
Releasing A New Version On Packagist
Packagist publishes versions of protonsystems/qa from git tags. Do
not add a version field to composer.json; create and push a new release tag
instead.
For the first public release, submit the public Git repository URL to Packagist.
If you want automatic updates from GitLab, configure the Packagist integration
in GitLab under Settings > Integrations using your Packagist username and API
token.
Prepare the release commit on main:
git checkout main
git pull origin main
composer validate --no-check-publish --strict
git status
git add <files>
git commit -m "Release v1.0.0"
git push origin main
Create and push an annotated tag for the release:
git tag -a v1.0.0 -m "Release v1.0.0"
git push origin v1.0.0
Use semantic version tags such as v1.0.0. After the tag is pushed, Packagist
should detect the new version automatically. If it does not appear, open the
package page in Packagist and trigger a manual update.
Notes
- The bundled
qa_audit_coresubmodule ships with this package and does not require a separate Packagist release