szepeviktor / consistent-versions
Check that version values in different file formats are consistent
Package info
github.com/szepeviktor/consistent-versions
pkg:composer/szepeviktor/consistent-versions
Requires
- php: ^8.1
- composer/semver: ^3.4
- nette/neon: ^3.4
- symfony/yaml: ^6.4
Requires (Dev)
- phpstan/phpstan: ^2.1
This package is auto-updated.
Last update: 2026-07-24 23:12:30 UTC
README
Consistent Versions
Consistent Versions checks that version-related values stored in different project files agree.
It is designed for projects where the same information appears in several places, for example:
- a WordPress plugin header;
- a WordPress.org
readme.txt; composer.json;- a GitHub Actions workflow;
- a PHPStan NEON configuration;
- a PHPCS ruleset;
- an INI or simple
.envfile; - a Gettext PO or POT header;
- a PHP class constant;
- Markdown documentation;
- a plain
VERSIONfile.
The tool only reads and compares configured sources. It never rewrites files or changes environment variables.
Table of contents
- Why use it?
- How it works
- Requirements
- Installation
- Quick start for a WordPress plugin
- Running the checker
- Configuration reference
- JSONPath selectors
- Readers
- Normalizers
- Complete WordPress example
- GitHub Actions integration
- Using the library API
- Extending the package
- Errors and exit codes
- Security and limitations
- Development
- License
Why use it?
A WordPress plugin release may contain the same plugin version in all of these files:
VERSION
plugin.php
readme.txt
composer.json
src/Plugin.php
The minimum supported PHP version may also appear in:
composer.json
plugin.php
readme.txt
.github/workflows/tests.yml
phpcs.xml
It is easy to update four places and forget the fifth. Consistent Versions makes that mistake fail locally or in CI before a release is published.
Typical mistakes it detects include:
VERSIONcontains1.4.0, but the plugin header still contains1.3.0;- the WordPress readme has the wrong
Stable tag; - Composer requires PHP 8.1, but CI starts its test matrix at PHP 8.2;
- the plugin header and WordPress readme declare different minimum PHP versions;
- a PHP class constant was not updated for a release.
How it works
Each supported source is read by a format-aware reader. The reader converts the source into a virtual JSON-like document.
For example, this WordPress plugin header:
<?php /** * Plugin Name: Example Plugin * Version: 1.2.3 * Requires PHP: 8.1 */
is exposed as data similar to:
{
"Plugin Name": "Example Plugin",
"Version": "1.2.3",
"Requires PHP": "8.1"
}
A JSONPath selector such as $.Version selects the required scalar value.
The checking process is:
- Read the
expectedvalue. - Read every configured value under
values. - Apply source-specific normalizers.
- Apply check-level normalizers.
- Compare the normalized values using strict equality.
- Report every value that differs.
Selectors must return exactly one scalar value: a string, number, boolean, or
null. Selecting nothing, several values, an array, or an object is an error.
Requirements
- PHP 8.1 or newer
- Composer
The runtime package has no ext-* Composer dependency.
XML, HTML, PHP constants, and WordPress headers are parsed without loading or executing the inspected files. The package can run without optional PHP extensions.
PHPStan is a development-only dependency and uses the Phar support included in normal PHP CLI installations.
Installation
Install the package as a development dependency:
composer require --dev szepeviktor/consistent-versions
The executable becomes available at:
vendor/bin/consistent-versions
Quick start for a WordPress plugin
Assume the project contains the following files.
VERSION
1.2.3
plugin.php
<?php /** * Plugin Name: Example Plugin * Version: 1.2.3 * Requires at least: 6.4 * Requires PHP: 8.1 */
readme.txt
=== Example Plugin ===
Contributors: example
Requires at least: 6.4
Tested up to: 6.8
Requires PHP: 8.1
Stable tag: 1.2.3
License: MIT
== Description ==
Example plugin.
src/Plugin.php
<?php declare(strict_types=1); namespace Vendor; final class Plugin { public const VERSION = '1.2.3'; }
Create consistent-versions.yaml in the project root:
checks: plugin-version: normalize: version expected: reader: text file: VERSION values: plugin-header: reader: wordpress-plugin file: plugin.php path: $.Version wordpress-readme: reader: wordpress-readme file: readme.txt path: $['Stable tag'] php-constant: reader: php file: src/Plugin.php path: $.classes['Vendor\\Plugin'].constants.VERSION
Run:
vendor/bin/consistent-versions check
Successful output:
PASS plugin-version
OK (1 checks)
If readme.txt still contains 1.2.2, the output is:
FAIL plugin-version
expected: 1.2.3
wordpress-readme: 1.2.2
1 inconsistent value(s)
Running the checker
The default command reads consistent-versions.yaml from the current working
directory:
vendor/bin/consistent-versions check
Use a different YAML configuration:
vendor/bin/consistent-versions check config/versions.yaml
JSON configuration is also supported when the filename ends in .json:
vendor/bin/consistent-versions check config/versions.json
Display help:
vendor/bin/consistent-versions --help
All source file paths are resolved relative to the configuration file, not relative to the current working directory. Absolute paths are accepted too.
Configuration reference
The configuration root must contain a non-empty checks map:
checks: check-name: expected: ... values: value-name: ...
Check definition
Each check supports these fields:
| Field | Required | Description |
|---|---|---|
expected |
yes | The canonical source or a literal scalar value |
values |
yes | A non-empty named map of values to compare |
normalize |
no | One check-level normalizer or a list of normalizers |
The names under checks and values are report labels. They do not affect
selection or comparison.
Expected value
expected may be a source:
expected: reader: text file: VERSION
or a literal scalar:
expected: "8.1"
Quoting version-like YAML values is recommended. Without quotes, YAML may
interpret values such as 8.1 as numbers.
Source definition
A source supports:
| Field | Required | Default | Description |
|---|---|---|---|
reader |
yes | — | Reader name |
file |
for file readers | — | File path relative to the configuration |
variable |
for env |
— | Environment variable name |
path |
no | $ |
JSONPath selecting exactly one scalar |
normalize |
no | none | Source-specific normalizer or list |
Example:
reader: composer file: composer.json path: $.require.php normalize: composer-minimum
Normalizers may be written as one string:
normalize: version
or as an ordered list:
normalize: - trim - trim-v-prefix - version
Source-specific normalizers run first. Check-level normalizers run afterwards.
Comparison
After normalization, values are compared using strict equality.
This means the string "8.1" and the number 8.1 are different unless a
normalizer converts them to the same representation.
Use string or version when values may be represented with different scalar
types or version formats.
JSONPath selectors
Consistent Versions implements a dependency-free, read-only subset of RFC 9535 JSONPath.
Supported syntax
| Syntax | Meaning | Example |
|---|---|---|
$ |
Document root | $ |
@ |
Current filter item | @.name |
.name |
Object property | $.require.php |
['name'] |
Quoted property | $['Stable tag'] |
[0] |
Array index | $.matrix.php[0] |
[-1] |
Index from the end | $.versions[-1] |
.* or [*] |
Wildcard | $.items[*].version |
..name |
Recursive descent | $..version |
[?expression] |
Filter | $.children[?@.name == "version"] |
Supported filter operators:
==
!=
<
>
<=
>=
&&
||
!
Example XML selector:
path: $.children[?@.name == "version"].text
Example filter with two conditions:
path: $.children[?@.name == "config" && @.attributes.name == "testVersion"].attributes.value
String ordering comparisons only compare strings with strings. Numeric ordering comparisons only compare numbers with numbers. The tool does not perform loose PHP comparison inside filters.
Property names containing spaces or punctuation
Use bracket notation:
path: $['Requires PHP']
Backslashes in class names
Inside a YAML single-quoted scalar, use two backslashes in the JSONPath string:
path: $.classes['Vendor\\Plugin'].constants.VERSION
This selects the PHP class Vendor\Plugin.
Unsupported JSONPath features
The following features are intentionally not implemented:
- unions;
- array slices;
- functions;
- regular-expression filters;
- update or mutation expressions.
Stable project configuration should normally use direct, deterministic paths.
Readers
Every reader returns a virtual JSON-like document. Use JSONPath to select a value from that document.
env
Reads one explicitly named environment variable. It does not expose the rest of the process environment.
reader: env variable: GITHUB_REF_NAME path: $
The virtual document is the variable's string value. The default $ path is
therefore normally sufficient. An unset variable is a parsing error; a set but
empty variable is a valid empty string.
For a release tag such as v1.2.3, normalizers can remove the prefix and
validate the version:
reader: env variable: GITHUB_REF_NAME normalize: - trim-v-prefix - version
ini
Parses PHP INI syntax using raw value scanning. It works with .ini files and
simple .env files that contain KEY=value assignments.
Input:
PHP_VERSION="8.1" PLUGIN_VERSION="1.2.3" [tool] version="2.0.0"
Selectors:
# Top-level value path: $.PHP_VERSION # Value inside an INI section path: $.tool.version
A complete source definition for .env is:
reader: ini file: .env path: $.PHP_VERSION
INI_SCANNER_RAW keeps every value as a string, so 8.1 remains "8.1".
The reader does not copy parsed values into the process environment.
This is an INI reader, not a complete Dotenv implementation. Dotenv-specific
features such as export KEY=value, shell expansion, and complex multiline
values are not supported.
gettext
Reads metadata from the empty msgid header entry of a Gettext .po or .pot
file. Escaped continuation strings are decoded without requiring the PHP
Gettext extension.
Input:
msgid "" msgstr "" "Project-Id-Version: Test Mode 1.0.2\n" "MIME-Version: 1.0\n" "X-Generator: WP-CLI 2.12.0\n"
Virtual document:
{
"headers": {
"Project-Id-Version": "Test Mode 1.0.2",
"MIME-Version": "1.0",
"X-Generator": "WP-CLI 2.12.0"
},
"project": {
"name": "Test Mode",
"version": "1.0.2"
}
}
The conventional Project-Id-Version: PACKAGE VERSION value is split at its
last whitespace boundary. This provides a version scalar that can be selected
without a regular expression:
reader: gettext file: languages/test-mode.pot path: $.project.version
Any raw header remains available by name:
path: $['headers']['X-Generator']
The reader intentionally parses catalog metadata, not individual translation entries.
text
Reads the entire file and applies trim().
Input:
1.2.3
Virtual document:
"1.2.3"
Selector:
path: $
This reader is suitable for VERSION and similar one-value files.
json
Parses arbitrary JSON into objects, arrays, and scalar values.
Input:
{
"package": {
"version": "1.2.3"
}
}
Selector:
path: $.package.version
Invalid JSON is a parsing error.
composer
Parses composer.json. Its virtual document is the same as the json reader,
but the root must be a JSON object.
Project version:
reader: composer file: composer.json path: $.version
PHP constraint:
reader: composer file: composer.json path: $.require.php normalize: composer-minimum
yaml
Parses YAML into objects, arrays, and scalar values.
GitHub Actions input:
jobs: tests: strategy: matrix: php: - "8.1" - "8.2"
Selector for the lowest tested PHP version:
path: $.jobs.tests.strategy.matrix.php[0]
neon
Parses a NEON file into objects, arrays, and scalar values. It uses the native NEON parser rather than treating NEON as YAML.
For example, PHPStan may declare its target PHP version as a
PHP_VERSION_ID:
parameters: phpVersion: 80100 level: max
The raw integer is available at:
reader: neon file: phpstan.neon path: $.parameters.phpVersion
To compare it with values such as 8.1, apply the php-version-id
normalizer:
reader: neon file: phpstan.neon path: $.parameters.phpVersion normalize: php-version-id
This reader parses the contents of the selected file only. It does not merge
files referenced by PHPStan's includes setting or apply PHPStan defaults.
xml
Parses XML into a uniform node tree. Every node exposes:
{
"name": "element-name",
"attributes": {
"attribute-name": "attribute-value"
},
"children": [],
"text": "element text"
}
Children are always a list, even if there is only one child. This avoids the common ambiguity where one XML element becomes an object but repeated elements become an array.
Input:
<project active="true"> <version channel="stable">1.2.3</version> </project>
Selectors:
# Root attribute path: $.attributes.active # Child text path: $.children[?@.name == "version"].text # Child attribute path: $.children[?@.name == "version"].attributes.channel
External entities are never loaded.
phpcs
Parses a PHPCS XML ruleset and presents the commonly needed information in a simpler semantic document:
{
"name": "Project",
"description": "Project coding standard",
"config": {
"testVersion": "8.1-"
},
"arguments": [],
"rules": []
}
Input:
<ruleset name="Project"> <description>Project coding standard</description> <config name="testVersion" value="8.1-"/> <rule ref="PSR12"/> </ruleset>
Selector:
path: $.config.testVersion
wordpress-plugin
Reads the first 8192 bytes of a WordPress plugin file and exposes these header fields:
| Field |
|---|
Plugin Name |
Plugin URI |
Description |
Version |
Requires at least |
Requires PHP |
Author |
Author URI |
License |
License URI |
Text Domain |
Domain Path |
Network |
Update URI |
Requires Plugins |
Example selectors:
path: $.Version
path: $['Requires PHP']
Missing known headers are exposed as null.
wordpress-theme
Reads the first 8192 bytes of a WordPress theme style.css and exposes:
| Field |
|---|
Theme Name |
Theme URI |
Author |
Author URI |
Description |
Version |
Requires at least |
Tested up to |
Requires PHP |
License |
License URI |
Text Domain |
Tags |
Template |
Update URI |
Example:
reader: wordpress-theme file: style.css path: $.Version
wordpress-readme
Parses the header of a WordPress.org readme.txt and exposes:
| Field |
|---|
Name |
Contributors |
Donate link |
Tags |
Requires at least |
Tested up to |
Requires PHP |
Stable tag |
License |
License URI |
Example:
reader: wordpress-readme file: readme.txt path: $['Stable tag']
Parsing stops when the first section such as == Description == begins.
php
Reads global constants and class constants without including or executing the PHP file.
Input:
<?php namespace Vendor; const PACKAGE_VERSION = '1.2.3'; final class Plugin { public const VERSION = '1.' . '2.3'; }
Virtual document:
{
"constants": {
"Vendor\\PACKAGE_VERSION": "1.2.3"
},
"classes": {
"Vendor\\Plugin": {
"constants": {
"VERSION": "1.2.3"
}
}
}
}
Selectors:
path: $.constants['Vendor\\PACKAGE_VERSION']
path: $.classes['Vendor\\Plugin'].constants.VERSION
The PHP reader understands scalar literal constants and concatenated scalar literals. It intentionally does not evaluate function calls, variables, class constant references, or arbitrary PHP expressions.
Calls to define() with literal names and literal scalar values are also read.
html
Parses HTML and exposes:
{
"meta": {},
"headings": [],
"links": [],
"tree": {}
}
Input:
<meta name="version" content="1.2.3"> <h1>Example Plugin</h1> <a href="/releases/1.2.3">Release</a>
Example selectors:
path: $.meta.version
path: $.headings[0].text
path: $.links[0].url
The full parsed markup is available under $.tree using the same node model as
the xml reader.
markdown
Parses useful structural information from Markdown:
{
"frontMatter": {},
"headings": [],
"links": [],
"images": [],
"html": {},
"text": "original Markdown"
}
Markdown front matter:
--- version: 1.2.3 requires_php: "8.1" --- # Example Plugin
Selectors:
path: $.frontMatter.version
path: $.frontMatter.requires_php
Embedded HTML metadata is parsed through the HTML reader:
<meta name="version" content="1.2.3">
path: $.html.meta.version
Links and images expose text or alt, url, and optional title fields.
Fenced code blocks are ignored while collecting Markdown headings, links, and
images.
Normalizers
Normalizers convert values before comparison.
identity
Returns the value unchanged.
This is the default behavior when no normalizer is configured.
string
Converts scalar values to strings:
| Input | Output |
|---|---|
8.1 |
"8.1" |
true |
"true" |
false |
"false" |
null |
"" |
trim
Removes whitespace from the beginning and end of strings. Other scalar types are unchanged.
trim-v-prefix
Removes one lowercase v prefix:
v1.2.3 → 1.2.3
An uppercase V is not removed.
version
Parses and normalizes version strings using Composer's version parser.
This makes common equivalent representations comparable, for example:
8.1
8.1.0
Invalid versions produce a configuration error.
php-version-id
Converts PHP's integer version representation to a dotted version:
| Input | Result |
|---|---|
80100 |
8.1 |
80102 |
8.1.2 |
The input must be an integer or a string containing only decimal digits. This
is useful for PHPStan's parameters.phpVersion setting.
composer-minimum
Extracts the inclusive numeric lower bound from a Composer constraint:
| Constraint | Result |
|---|---|
^8.1 |
8.1 |
>=8.1 |
8.1 |
8.1.* |
8.1 |
It rejects:
- constraints with no numeric versions;
- constraints with an exclusive lower bound such as
>8.1, because no single concrete minimum exists.
A common pattern is:
checks: minimum-php: normalize: version expected: reader: composer file: composer.json path: $.require.php normalize: composer-minimum
The source-specific composer-minimum runs first. The check-level version
normalizer then normalizes both the expected and actual values.
Complete WordPress example
This configuration checks the plugin release version and the minimum PHP version:
checks: release-version: normalize: - trim-v-prefix - version expected: reader: text file: VERSION values: composer: reader: composer file: composer.json path: $.version plugin-header: reader: wordpress-plugin file: plugin.php path: $.Version wordpress-readme: reader: wordpress-readme file: readme.txt path: $['Stable tag'] php-constant: reader: php file: src/Plugin.php path: $.classes['Vendor\\Plugin'].constants.VERSION minimum-php: normalize: version expected: reader: composer file: composer.json path: $.require.php normalize: composer-minimum values: github-actions: reader: yaml file: .github/workflows/tests.yml path: $.jobs.tests.strategy.matrix.php[0] phpstan: reader: neon file: phpstan.neon path: $.parameters.phpVersion normalize: php-version-id plugin-header: reader: wordpress-plugin file: plugin.php path: $['Requires PHP'] wordpress-readme: reader: wordpress-readme file: readme.txt path: $['Requires PHP']
The same example is available as
consistent-versions.example.yaml.
GitHub Actions integration
Add a step after Composer dependencies are installed:
name: Tests on: push: pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: "8.1" coverage: none - run: composer install --no-interaction --prefer-dist - name: Check version consistency run: vendor/bin/consistent-versions check
An inconsistency exits with status 1, so the workflow fails. Invalid
configuration, unreadable files, parse failures, and invalid selectors exit
with status 2.
For a workflow triggered by a Git tag, the tag name is available in
GITHUB_REF_NAME. It can be compared directly without creating a temporary
file:
checks: release-version: normalize: - trim-v-prefix - version expected: reader: env variable: GITHUB_REF_NAME values: version-file: reader: text file: VERSION
Run that check only for tag events:
on: push: tags: - "v*" jobs: release: if: github.ref_type == 'tag' # Checkout, install dependencies, then run the checker.
Using the library API
The CLI is a thin wrapper around ConfigurationLoader and Engine.
<?php declare(strict_types=1); use SzepeViktor\ConsistentVersions\ConfigurationLoader; use SzepeViktor\ConsistentVersions\Engine; require __DIR__ . '/vendor/autoload.php'; $configuration = (new ConfigurationLoader())->load( __DIR__ . '/consistent-versions.yaml' ); $report = (new Engine())->run($configuration, __DIR__); foreach ($report->results as $result) { if ($result->passed()) { continue; } echo $result->name, ': expected ', (string) $result->expected, PHP_EOL; foreach ($result->mismatches as $label => $actual) { echo ' ', $label, ': ', (string) $actual, PHP_EOL; } } exit($report->passed() ? 0 : 1);
Useful report methods:
$report->passed(); $report->failureCount();
Each CheckResult exposes:
$result->name; $result->expected; $result->mismatches; $result->passed();
Extending the package
Readers and normalizers are registries. Applications may register domain-specific implementations without changing the JSONPath language.
Custom reader
Implement Reader:
<?php declare(strict_types=1); namespace Project; use SzepeViktor\ConsistentVersions\Document; use SzepeViktor\ConsistentVersions\Reader\Reader; final class ManifestReader implements Reader { public function read(string $path): Document { $contents = file_get_contents($path); if ($contents === false) { throw new \RuntimeException(sprintf('Could not read %s', $path)); } return new Document( ['release' => trim($contents)], $path ); } }
Register it:
use Project\ManifestReader; use SzepeViktor\ConsistentVersions\Engine; use SzepeViktor\ConsistentVersions\Normalizer\NormalizerRegistry; use SzepeViktor\ConsistentVersions\Reader\ReaderRegistry; $readers = ReaderRegistry::withDefaults(); $readers->register('manifest', new ManifestReader()); $engine = new Engine( $readers, NormalizerRegistry::withDefaults() );
Configuration may then use:
reader: manifest file: MANIFEST path: $.release
Readers that consume a named value rather than a file may additionally
implement DirectInputReader. Its inputField() method declares the
configuration field passed unchanged to read(). The built-in env reader,
for example, declares variable; file readers continue to receive an absolute
file path.
Custom normalizer
Implement Normalizer:
<?php declare(strict_types=1); namespace Project; use SzepeViktor\ConsistentVersions\Normalizer\Normalizer; final class LowercaseNormalizer implements Normalizer { public function normalize( string|int|float|bool|null $value ): string|int|float|bool|null { return is_string($value) ? strtolower($value) : $value; } }
Register it:
use Project\LowercaseNormalizer; use SzepeViktor\ConsistentVersions\Engine; use SzepeViktor\ConsistentVersions\Normalizer\NormalizerRegistry; use SzepeViktor\ConsistentVersions\Reader\ReaderRegistry; $normalizers = NormalizerRegistry::withDefaults(); $normalizers->register('lowercase', new LowercaseNormalizer()); $engine = new Engine( ReaderRegistry::withDefaults(), $normalizers );
Errors and exit codes
| Exit code | Meaning |
|---|---|
0 |
All configured values are consistent |
1 |
One or more values differ from the expected value |
2 |
Configuration, file reading, parsing, or selection error |
Configuration and parsing errors
These are reported to standard error:
ERROR: Configuration file is not readable: consistent-versions.yaml
Examples include:
- missing
checks; - missing
expected; - empty
values; - unknown reader;
- unknown normalizer;
- unreadable file;
- missing environment variable;
- invalid JSON, YAML, NEON, INI, Gettext, or XML;
- invalid JSONPath;
- selector returning zero or several values;
- selector returning an array or object;
- invalid version or Composer constraint.
Inconsistent values
Inconsistencies are normal check results and are printed to standard output:
FAIL release-version
expected: 1.2.3
plugin-header: 1.2.2
wordpress-readme: 1.2.1
2 inconsistent value(s)
All checks are evaluated, so one run reports every discovered inconsistency.
Security and limitations
Read-only behavior
The package never updates source files. There is no search-and-replace or automatic version bumping.
Environment variables
The env reader only reads the variable explicitly named in the configuration.
Its value may be displayed in mismatch reports, so it should be used for
non-secret metadata such as CI tag names, not credentials or tokens.
PHP files are not executed
The PHP reader uses its own limited lexer. It does not include or require
the inspected file.
Only literal scalar constant expressions are evaluated. Dynamic expressions are skipped.
XML entities
External XML entities are never loaded. Only predefined XML entities and numeric character references are decoded.
Markdown scope
The Markdown reader extracts front matter, ATX headings, inline links, images, embedded HTML metadata, and the original text. It is not a complete CommonMark renderer.
For a stable documented version, prefer explicit front matter:
--- version: 1.2.3 ---
or explicit HTML metadata:
<meta name="version" content="1.2.3">
Do not depend on finding a version inside arbitrary prose.
No automatic inference
The tool does not guess:
- which file is authoritative;
- whether two different constraints mean the same thing;
- which array element is the minimum version;
- which value inside prose represents the project version.
The configuration states these decisions explicitly.
Development
Install dependencies:
composer install
Run the test suite:
composer test
Run PHP syntax checks:
composer lint
Run PHPStan:
composer phpstan
Run every quality check:
composer all
The repository checks its own minimum PHP version declarations with
tests/consistent-versions.yaml. The
self-check compares Composer's package constraint and platform version,
PHPStan's target version, the oldest GitHub Actions matrix entry, and the
machine-readable metadata in this README:
composer versions
PHPStan analyses src and tests at level max. There is no baseline and no
ignored error category.
The test suite covers all built-in readers, JSONPath selection, normalizers, successful checks, and mismatch reporting. CI runs the complete suite on PHP 8.1 and PHP 8.5.
License
Consistent Versions is open-source software licensed under the MIT License.