Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
cae2f6b
test: characterize 1.0.0 legacy behavior
Zhortein Aug 2, 2026
af9ca64
test: support PHP 8.5 and Symfony 8 fixtures
Zhortein Aug 2, 2026
dd52559
ci: execute supported compatibility matrix
Zhortein Aug 2, 2026
591be40
build!: require Doctrine ORM 3 and mbstring polyfill
Zhortein Aug 2, 2026
112d199
test: verify native and polyfilled mbstring behavior
Zhortein Aug 2, 2026
98c096e
docs: document the 2.0 platform baseline
Zhortein Aug 2, 2026
26a2028
feat: add transactional audit contracts and readonly models
Zhortein Aug 2, 2026
c995ce2
feat: add deterministic Doctrine identifier extraction
Zhortein Aug 2, 2026
59fecc9
feat: add Symfony Security actor resolution
Zhortein Aug 3, 2026
93d7d35
feat: add strict transactional audit recorder
Zhortein Aug 3, 2026
79bdd88
feat: add opt-in transactional recorder wiring
Zhortein Aug 3, 2026
f60c010
test: prove PostgreSQL transactional audit atomicity
Zhortein Aug 3, 2026
5867b90
ci: run PostgreSQL transactional guarantee tests
Zhortein Aug 3, 2026
b375cf6
docs: document Doctrine transactional audit integration
Zhortein Aug 3, 2026
3bfbf0f
feat: allow disabling the legacy Doctrine mapping
Zhortein Aug 3, 2026
476f230
docs: document legacy mapping opt-out
Zhortein Aug 3, 2026
1e9cc36
test: freeze the 2.0 public API
Zhortein Aug 3, 2026
7022fac
chore: upgrade PHPStan to 2 at max level
Zhortein Aug 3, 2026
e98feac
ci: verify distributable package contents
Zhortein Aug 3, 2026
2be587d
docs: add 2.0 migration and operating guides
Zhortein Aug 3, 2026
e9120a7
docs: align project documentation for the 2.x series
Zhortein Aug 3, 2026
8f402a5
ci: require complete 2.0 documentation in package archive
Zhortein Aug 3, 2026
46f44dd
fix(ci): align Symfony 8.x version assertions
Zhortein Aug 3, 2026
e56a3b7
ci: avoid duplicate branch workflow runs
Zhortein Aug 3, 2026
a9cf8e3
chore: align development dependency workflows
Zhortein Aug 3, 2026
683ec24
docs: document the public release workflow
Zhortein Aug 3, 2026
41ba990
ci: pin GitHub Actions to reviewed commits
Zhortein Aug 4, 2026
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
11 changes: 10 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
* text=auto eol=lf

/.editorconfig export-ignore
/.env export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
/.github export-ignore
/docs export-ignore
/docker export-ignore
/tests export-ignore
/CODE_OF_CONDUCT.md export-ignore
/CONTRIBUTING.md export-ignore
/Makefile export-ignore
/SECURITY.md export-ignore
/phpunit.xml.dist export-ignore
/phpunit.postgresql.xml.dist export-ignore
/phpstan.neon.dist export-ignore
/.php-cs-fixer.dist.php export-ignore
139 changes: 139 additions & 0 deletions .github/ci/assert-versions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

declare(strict_types=1);

use Composer\InstalledVersions;

require dirname(__DIR__, 2).'/vendor/autoload.php';

if (7 !== $argc) {
fwrite(STDERR, "Usage: php .github/ci/assert-versions.php <php-minor> <symfony-minor> <doctrine-bundle-major> <doctrine-orm-major> <doctrine-dbal-major> <doctrine-persistence-major>\n");

exit(2);
}

[, $expectedPhpMinor, $expectedSymfonyConstraint, $expectedDoctrineBundleMajor, $expectedDoctrineOrmMajor, $expectedDoctrineDbalMajor, $expectedDoctrinePersistenceMajor] = $argv;
$expectedSymfonyMinor = preg_replace('/\.\*$/', '', $expectedSymfonyConstraint);

if (null === $expectedSymfonyMinor) {
fwrite(STDERR, "Invalid Symfony version constraint.\n");

exit(2);
}

$packages = [
'symfony/framework-bundle',
'doctrine/doctrine-bundle',
'doctrine/orm',
'doctrine/dbal',
'doctrine/persistence',
'symfony/polyfill-mbstring',
'phpunit/phpunit',
'phpstan/phpstan',
'friendsofphp/php-cs-fixer',
];

printf("PHP: %s\n", PHP_VERSION);

foreach ($packages as $package) {
printf("%s: %s\n", $package, InstalledVersions::getPrettyVersion($package) ?? 'unknown');
}

$expectedPhpStanMajor = getenv('EXPECT_PHPSTAN_MAJOR');
if (false !== $expectedPhpStanMajor && '' !== $expectedPhpStanMajor) {
if (!ctype_digit($expectedPhpStanMajor)) {
fwrite(STDERR, "EXPECT_PHPSTAN_MAJOR must be a positive integer.\n");

exit(2);
}

$assertedPhpStanVersion = InstalledVersions::getVersion('phpstan/phpstan');
if (null === $assertedPhpStanVersion || !str_starts_with(ltrim($assertedPhpStanVersion, 'v'), $expectedPhpStanMajor.'.')) {
fwrite(STDERR, sprintf(
"Expected phpstan/phpstan %s.x, resolved %s.\n",
$expectedPhpStanMajor,
InstalledVersions::getPrettyVersion('phpstan/phpstan') ?? 'unknown',
));

exit(1);
}
}

if (!InstalledVersions::isInstalled('symfony/polyfill-mbstring')) {
fwrite(STDERR, "symfony/polyfill-mbstring is not installed.\n");

exit(1);
}

$nativeMbstring = extension_loaded('mbstring');
$iconv = extension_loaded('iconv');
$mbStrlen = function_exists('mb_strlen');
$mbSubstr = function_exists('mb_substr');

printf("Native mbstring: %s\n", $nativeMbstring ? 'loaded' : 'not loaded');
printf("Native iconv: %s\n", $iconv ? 'loaded' : 'not loaded');
printf("mb_strlen: %s\n", $mbStrlen ? 'available' : 'unavailable');
printf("mb_substr: %s\n", $mbSubstr ? 'available' : 'unavailable');

$assertVersion = static function (string $package, string $expectedPrefix): void {
$version = InstalledVersions::getVersion($package);

if (null === $version || !str_starts_with(ltrim($version, 'v'), $expectedPrefix.'.')) {
fwrite(STDERR, sprintf(
"Expected %s %s.x, resolved %s.\n",
$package,
$expectedPrefix,
InstalledVersions::getPrettyVersion($package) ?? 'unknown',
));

exit(1);
}
};

$actualPhpMinor = PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION;
if ($actualPhpMinor !== $expectedPhpMinor) {
fwrite(STDERR, sprintf("Expected PHP %s.x, running %s.\n", $expectedPhpMinor, PHP_VERSION));

exit(1);
}

$assertVersion('symfony/framework-bundle', $expectedSymfonyMinor);
$assertVersion('doctrine/doctrine-bundle', $expectedDoctrineBundleMajor);
$assertVersion('doctrine/orm', $expectedDoctrineOrmMajor);
$assertVersion('doctrine/dbal', $expectedDoctrineDbalMajor);
$assertVersion('doctrine/persistence', $expectedDoctrinePersistenceMajor);

$rootComposer = json_decode((string) file_get_contents(dirname(__DIR__, 2).'/composer.json'), true, flags: JSON_THROW_ON_ERROR);
$rootRequire = is_array($rootComposer) && isset($rootComposer['require']) && is_array($rootComposer['require']) ? $rootComposer['require'] : [];
if (!array_key_exists('symfony/polyfill-mbstring', $rootRequire)) {
fwrite(STDERR, "symfony/polyfill-mbstring must be a direct runtime dependency.\n");

exit(1);
}

$expectedNativeMbstring = getenv('EXPECT_NATIVE_MBSTRING');
if (!in_array($expectedNativeMbstring, ['true', 'false'], true)) {
fwrite(STDERR, "EXPECT_NATIVE_MBSTRING must be true or false.\n");

exit(2);
}

if (('true' === $expectedNativeMbstring) !== $nativeMbstring) {
fwrite(STDERR, sprintf("Expected native mbstring %s, but it is %s.\n", $expectedNativeMbstring, $nativeMbstring ? 'loaded' : 'not loaded'));

exit(1);
}

if (!$mbStrlen || !$mbSubstr) {
fwrite(STDERR, "Multibyte string functions are unavailable.\n");

exit(1);
}

if ('false' === $expectedNativeMbstring) {
if (!$iconv || 3 !== mb_strlen('Été') || 'É' !== mb_substr('Été', 0, 1)) {
fwrite(STDERR, "The mbstring polyfill path is not functional.\n");

exit(1);
}
}
209 changes: 209 additions & 0 deletions .github/ci/check-package-archive.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
<?php

declare(strict_types=1);

if (2 !== $argc) {
fwrite(\STDERR, "Usage: php .github/ci/check-package-archive.php <archive.zip>\n");

exit(2);
}

$archivePath = $argv[1];
if (!is_file($archivePath)) {
fwrite(\STDERR, sprintf("Archive not found: %s\n", $archivePath));

exit(1);
}

$archive = new ZipArchive();
if (true !== $archive->open($archivePath)) {
fwrite(\STDERR, sprintf("Unable to open ZIP archive: %s\n", $archivePath));

exit(1);
}

try {
$entries = [];
for ($index = 0; $index < $archive->numFiles; ++$index) {
$name = $archive->getNameIndex($index);
if (false === $name) {
fwrite(\STDERR, sprintf("Unable to read ZIP entry at index %d.\n", $index));

exit(1);
}

$entries[] = ltrim(str_replace('\\', '/', $name), '/');
}

$composerEntries = array_values(array_filter(
$entries,
static fn (string $entry): bool => 'composer.json' === $entry || str_ends_with($entry, '/composer.json'),
));
if (1 !== count($composerEntries)) {
fwrite(\STDERR, sprintf("Expected exactly one composer.json, found %d.\n", count($composerEntries)));

exit(1);
}

$composerEntry = $composerEntries[0];
$rootPrefix = substr($composerEntry, 0, -strlen('composer.json'));
$relativeEntries = [];
foreach ($entries as $entry) {
if (!str_starts_with($entry, $rootPrefix)) {
fwrite(\STDERR, sprintf("ZIP entry is outside the package root: %s\n", $entry));

exit(1);
}

$relativeEntries[] = substr($entry, strlen($rootPrefix));
}

$contains = static fn (string $path): bool => in_array($path, $relativeEntries, true);
$containsDirectory = static function (string $directory) use ($relativeEntries): bool {
foreach ($relativeEntries as $entry) {
if (str_starts_with($entry, $directory.'/')) {
return true;
}
}

return false;
};

$requiredFiles = [
'composer.json',
'README.md',
'CHANGELOG.md',
'LICENSE',
'UPGRADE-2.0.md',
'docs/compatibility.md',
'docs/index.md',
'docs/legacy-mode.md',
'docs/release-process.md',
'docs/security-privacy.md',
'docs/transactional-doctrine.md',
];
foreach ($requiredFiles as $requiredFile) {
if (!$contains($requiredFile)) {
fwrite(\STDERR, sprintf("Required runtime file is missing: %s\n", $requiredFile));

exit(1);
}
}

foreach (['src', 'config'] as $requiredDirectory) {
if (!$containsDirectory($requiredDirectory)) {
fwrite(\STDERR, sprintf("Required runtime directory is missing or empty: %s/\n", $requiredDirectory));

exit(1);
}
}

$excludedFiles = [
'.editorconfig',
'.env',
'.gitattributes',
'.gitignore',
'CODE_OF_CONDUCT.md',
'CONTRIBUTING.md',
'Makefile',
'SECURITY.md',
'phpunit.xml.dist',
'phpunit.postgresql.xml.dist',
'phpstan.neon.dist',
'.php-cs-fixer.dist.php',
'composer.lock',
];
foreach ($excludedFiles as $excludedFile) {
if ($contains($excludedFile)) {
fwrite(\STDERR, sprintf("Development file must not be distributed: %s\n", $excludedFile));

exit(1);
}
}

foreach (['.github', 'tests', 'docker', 'vendor'] as $excludedDirectory) {
if ($containsDirectory($excludedDirectory)) {
fwrite(\STDERR, sprintf("Development directory must not be distributed: %s/\n", $excludedDirectory));

exit(1);
}
}

$normalizeRelativePath = static function (string $source, string $target): ?string {
$sourceDirectory = str_contains($source, '/') ? dirname($source) : '';
$candidate = '' === $sourceDirectory ? $target : $sourceDirectory.'/'.$target;
$segments = [];

foreach (explode('/', str_replace('\\', '/', $candidate)) as $segment) {
if ('' === $segment || '.' === $segment) {
continue;
}
if ('..' === $segment) {
if ([] === $segments) {
return null;
}
array_pop($segments);

continue;
}
$segments[] = $segment;
}

return implode('/', $segments);
};

$markdownFiles = array_values(array_filter(
$relativeEntries,
static fn (string $entry): bool => str_ends_with(strtolower($entry), '.md'),
));
sort($markdownFiles);
$validatedLinks = 0;
foreach ($markdownFiles as $markdownFile) {
$contents = $archive->getFromName($rootPrefix.$markdownFile);
if (false === $contents) {
fwrite(\STDERR, sprintf("Unable to read documentation from archive: %s\n", $markdownFile));

exit(1);
}

preg_match_all('/(?<!!)\[[^]]*]\(([^)]+)\)/', $contents, $matches);
foreach ($matches[1] as $rawTarget) {
$target = trim($rawTarget);
if (str_starts_with($target, '<') && str_ends_with($target, '>')) {
$target = substr($target, 1, -1);
}
if (
str_starts_with($target, 'http://')
|| str_starts_with($target, 'https://')
|| str_starts_with($target, 'mailto:')
|| str_starts_with($target, '#')
) {
continue;
}

$target = preg_split('/[?#]/', $target, 2)[0];
$resolvedTarget = $normalizeRelativePath($markdownFile, $target);
if (null === $resolvedTarget) {
fwrite(\STDERR, sprintf("Markdown link escapes package root: %s -> %s\n", $markdownFile, $rawTarget));

exit(1);
}
if (!$contains($resolvedTarget)) {
fwrite(\STDERR, sprintf("Broken relative Markdown link: %s -> %s\n", $markdownFile, $rawTarget));

exit(1);
}

++$validatedLinks;
}
}

printf(
"Package archive OK: %d entries, root prefix %s, runtime files and documentation present, %d relative Markdown links valid, development files excluded.\n",
count($relativeEntries),
'' === $rootPrefix ? '<none>' : $rootPrefix,
$validatedLinks,
);
} finally {
$archive->close();
}
25 changes: 19 additions & 6 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
version: 2

updates:
- package-ecosystem: "composer"
directory: "/"
- package-ecosystem: composer
directory: /
target-branch: develop
schedule:
interval: "weekly"
interval: monthly
groups:
compatible-non-breaking-updates:
update-types:
- minor
- patch

- package-ecosystem: "github-actions"
directory: "/"
- package-ecosystem: github-actions
directory: /
target-branch: develop
schedule:
interval: "weekly"
interval: monthly
groups:
compatible-non-breaking-updates:
update-types:
- minor
- patch
Loading