From b6bb98b6c57d4c293df6a4cde9734eaf95f6fb6f Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Mon, 2 Feb 2026 05:35:26 +0800 Subject: [PATCH 01/75] docs: add changelog and upgrade for v4.7.1 (#9920) --- user_guide_src/source/changelogs/index.rst | 1 + user_guide_src/source/changelogs/v4.7.1.rst | 35 ++++++++++++ .../source/installation/upgrade_471.rst | 55 +++++++++++++++++++ .../source/installation/upgrading.rst | 1 + 4 files changed, 92 insertions(+) create mode 100644 user_guide_src/source/changelogs/v4.7.1.rst create mode 100644 user_guide_src/source/installation/upgrade_471.rst diff --git a/user_guide_src/source/changelogs/index.rst b/user_guide_src/source/changelogs/index.rst index 5d0ab22b13ef..6f2dc8c45a0a 100644 --- a/user_guide_src/source/changelogs/index.rst +++ b/user_guide_src/source/changelogs/index.rst @@ -12,6 +12,7 @@ See all the changes. .. toctree:: :titlesonly: + v4.7.1 v4.7.0 v4.6.5 v4.6.4 diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst new file mode 100644 index 000000000000..2761dcb50e19 --- /dev/null +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -0,0 +1,35 @@ +############# +Version 4.7.1 +############# + +Release Date: Unreleased + +**4.7.1 release of CodeIgniter4** + +.. contents:: + :local: + :depth: 3 + +******** +BREAKING +******** + +*************** +Message Changes +*************** + +******* +Changes +******* + +************ +Deprecations +************ + +********** +Bugs Fixed +********** + +See the repo's +`CHANGELOG.md `_ +for a complete list of bugs fixed. diff --git a/user_guide_src/source/installation/upgrade_471.rst b/user_guide_src/source/installation/upgrade_471.rst new file mode 100644 index 000000000000..066fcf568964 --- /dev/null +++ b/user_guide_src/source/installation/upgrade_471.rst @@ -0,0 +1,55 @@ +############################# +Upgrading from 4.7.0 to 4.7.1 +############################# + +Please refer to the upgrade instructions corresponding to your installation method. + +- :ref:`Composer Installation App Starter Upgrading ` +- :ref:`Composer Installation Adding CodeIgniter4 to an Existing Project Upgrading ` +- :ref:`Manual Installation Upgrading ` + +.. contents:: + :local: + :depth: 2 + +********************** +Mandatory File Changes +********************** + +**************** +Breaking Changes +**************** + +********************* +Breaking Enhancements +********************* + +************* +Project Files +************* + +Some files in the **project space** (root, app, public, writable) received updates. Due to +these files being outside of the **system** scope they will not be changed without your intervention. + +.. note:: There are some third-party CodeIgniter modules available to assist + with merging changes to the project space: + `Explore on Packagist `_. + +Content Changes +=============== + +The following files received significant changes (including deprecations or visual adjustments) +and it is recommended that you merge the updated versions with your application: + +Config +------ + +- @TODO + +All Changes +=========== + +This is a list of all files in the **project space** that received changes; +many will be simple comments or formatting that have no effect on the runtime: + +- @TODO diff --git a/user_guide_src/source/installation/upgrading.rst b/user_guide_src/source/installation/upgrading.rst index 03c7068cfe71..9140c2298b3d 100644 --- a/user_guide_src/source/installation/upgrading.rst +++ b/user_guide_src/source/installation/upgrading.rst @@ -16,6 +16,7 @@ See also :doc:`./backward_compatibility_notes`. backward_compatibility_notes + upgrade_471 upgrade_470 upgrade_465 upgrade_464 From 044a71cd23815f0489de55a95b7c87422ac89da2 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Tue, 3 Feb 2026 00:47:30 +0800 Subject: [PATCH 02/75] chore: fix CHANGELOG formatting and filenames (#9922) --- CHANGELOG.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72b155413fa0..9c76b3ef55a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,10 +87,10 @@ * refactor: cleanup `ContentSecurityPolicy` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/9904 * refactor: deprecate `CodeIgniter\HTTP\ContentSecurityPolicy::$nonces` since never used by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/9905 -For the changelog of v4.6, see [CHANGELOG_4.6.md](./changelogs/CHANGELOG_4.6.md). -For the changelog of v4.5, see [CHANGELOG_4.5.md](./changelogs/CHANGELOG_4.5.md). -For the changelog of v4.4, see [CHANGELOG_4.5.md](./changelogs/CHANGELOG_4.4.md). -For the changelog of v4.3, see [CHANGELOG_4.5.md](./changelogs/CHANGELOG_4.3.md). -For the changelog of v4.2, see [CHANGELOG_4.5.md](./changelogs/CHANGELOG_4.2.md). -For the changelog of v4.1, see [CHANGELOG_4.5.md](./changelogs/CHANGELOG_4.1.md). -For the changelog of v4.0, see [CHANGELOG_4.5.md](./changelogs/CHANGELOG_4.0.md). +For the changelog of v4.6, see [CHANGELOG_4.6.md](./changelogs/CHANGELOG_4.6.md).
+For the changelog of v4.5, see [CHANGELOG_4.5.md](./changelogs/CHANGELOG_4.5.md).
+For the changelog of v4.4, see [CHANGELOG_4.4.md](./changelogs/CHANGELOG_4.4.md).
+For the changelog of v4.3, see [CHANGELOG_4.3.md](./changelogs/CHANGELOG_4.3.md).
+For the changelog of v4.2, see [CHANGELOG_4.2.md](./changelogs/CHANGELOG_4.2.md).
+For the changelog of v4.1, see [CHANGELOG_4.1.md](./changelogs/CHANGELOG_4.1.md).
+For the changelog of v4.0, see [CHANGELOG_4.0.md](./changelogs/CHANGELOG_4.0.md). From b5ed86c7e3adeb2153aa012321022fa05ca32372 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Thu, 5 Feb 2026 00:30:53 +0800 Subject: [PATCH 03/75] chore: create instructions file for LLM to use in organic release announcements in the forum (#9923) --- .ai/forum-announcement-instructions.md | 200 +++++++++++++++++++++++++ .gitattributes | 1 + 2 files changed, 201 insertions(+) create mode 100644 .ai/forum-announcement-instructions.md diff --git a/.ai/forum-announcement-instructions.md b/.ai/forum-announcement-instructions.md new file mode 100644 index 000000000000..7b42bbe841fe --- /dev/null +++ b/.ai/forum-announcement-instructions.md @@ -0,0 +1,200 @@ +# Forum Announcement Instructions + +## Purpose +These instructions guide the creation of forum announcements for new CodeIgniter4 releases using myBB formatting. + +## Process Overview + +### 1. Gather Information +Read the following source files: +- **CHANGELOG.md** - Main changelog with GitHub PR links +- **user_guide_src/source/changelogs/v{VERSION}.rst** - Detailed RST changelog with comprehensive explanations + +### 2. Version Strategy +For dual releases (e.g., maintenance + major): +- **List the maintenance version first** (e.g., v4.6.5 before v4.7.0) +- Clearly explain which version users should choose based on their PHP version +- Provide separate links for each version + +### 3. Announcement Structure + +Use this **example** structure with myBB formatting: + +``` +[size=x-large][b]CodeIgniter {VERSION} & {VERSION} Released![/b][/size] + +Introduction paragraph(s) - mention maintenance release first, then major release + +Links to GitHub releases and changelogs + +[hr] + +[size=large][b]Which Version Should I Use?[/b][/size] +- Guidance for users on which version to choose + +[hr] + +[size=large][b]What's in CodeIgniter {MAINTENANCE_VERSION}?[/b][/size] +- Bug fixes section (if maintenance release) + +[hr] + +[size=large][b]Highlights & New Features ({MAJOR_VERSION})[/b][/size] +- Top features + +[hr] + +[size=large][b]Notable Enhancements[/b][/size] +- Bulleted list of improvements + +[hr] + +[size=large][b]Cache Improvements[/b][/size] +- Cache-specific updates + +[hr] + +[size=large][b]Database & Model Updates[/b][/size] +- Database-related changes + +[hr] + +[size=large][b]HTTP & Request Features[/b][/size] +- HTTP/Request improvements + +[hr] + +[size=large][b]Security & Quality[/b][/size] +- Security updates + +[hr] + +[size=large][b]Breaking Changes[/b][/size] +- Detailed breaking changes with explanations +- Include "Removed Deprecated Items" subsection + +[hr] + +[size=large][b]Other Notable Changes[/b][/size] +- Other miscellaneous updates + +[hr] + +[size=large][b]Thanks to Our Contributors[/b][/size] +- Acknowledge contributors + +[hr] + +Upgrade guide links +Issue reporting link +Closing message + +[hr] + +AI disclosure note +``` + +### 4. myBB Formatting Codes + +Use these myBB codes: +- `[b]text[/b]` - Bold +- `[i]text[/i]` - Italic +- `[size=x-large]text[/size]` - Extra large text +- `[size=large]text[/size]` - Large text +- `[size=small]text[/size]` - Small text +- `[url=URL]text[/url]` - Links +- `[list]` - Unordered list +- `[list=1]` - Ordered list +- `[*]` - List item +- `[hr]` - Horizontal rule +- `` `code` `` - Inline code (use double backticks) + +### 4a. Emoticon Escaping + +myBB automatically converts emoticon patterns like `:s` (colon immediately followed by "s") into emoji. To prevent this in code blocks: + +**Replace all colons with HTML entity `:`** + +Examples: +- `Entity::setAttributes()` → `Entity::setAttributes()` +- `H:i:s` (time format) → `H:i:s` + +This prevents emoticon conversion while displaying properly as a colon character. + +### 5. Content Guidelines + +**Highlights Section:** +- Emphasize PHP version requirements +- Mark experimental features as [i]Experimental[/i] +- List 3-5 most impactful features + +**Enhancements:** +- Include specific config options and method names +- Use `` `code` `` for class names, methods, and config values +- Be specific about which handlers support which features + +**Breaking Changes:** +- Provide detailed explanations, not just bullet points +- Include the old behavior vs. new behavior +- Mention exception type changes +- List removed deprecated items separately +- Reference specific methods and properties + +**Bug Fixes (for maintenance releases):** +- Use ordered lists `[list=1]` +- Provide clear before/after descriptions + +### 6. Key Points + +1. **Tone:** Professional yet friendly, engaging for community (no emojis - they don't render properly in myBB) +2. **Accuracy:** Always cross-reference RST changelog for technical details +3. **Clarity:** Explain breaking changes thoroughly +4. **Contents:** Adjust sections based on the release content (e.g., skip "Cache Improvements" if no cache changes) +5. **Brevity:** For single releases, omit the "Which Version Should I Use?" section +6. **Links:** Include GitHub release links, changelogs, and upgrade guides +7. **Attribution:** Thank contributors by username +8. **Disclosure:** Add AI assistance disclosure at the end + +### 7. Version Priority + +For dual releases: +- Mention maintenance version (e.g., 4.6.5) **before** major version (e.g., 4.7.0) +- In "Which Version Should I Use?" section, list lower PHP version option first + +### 8. Final Disclosure + +Always include at the end: + +``` +[hr] + +[size=small][i]Note: This announcement was created with the assistance of GitHub Copilot (Claude Sonnet 4.5).[/i][/size] +``` + +Update the agent name as necessary. + +## Output File + +Save the announcement as: `v{VERSION}-announcement.txt` in the repository root + +## Example Workflow + +```bash +# 1. Read changelogs +Read: CHANGELOG.md +Read: user_guide_src/source/changelogs/v4.7.0.rst +Read: user_guide_src/source/changelogs/v4.6.5.rst (if maintenance release) + +# 2. Create announcement +Create: v4.7.0-announcement.txt + +# 3. Structure content +- Introduction with both versions +- Version selection guidance +- Maintenance release details first +- Major release details +- Breaking changes (comprehensive) +- Contributors +- Upgrade links +- AI disclosure +``` diff --git a/.gitattributes b/.gitattributes index 3f67f2e35167..91c62297637f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,6 +8,7 @@ .gitignore export-ignore # admin files +.ai/ export-ignore .github/ export-ignore admin/ export-ignore contributing/ export-ignore From 396d9512b7b2d1486ce62bab88073ae25cab04a6 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Thu, 5 Feb 2026 20:46:55 +0100 Subject: [PATCH 04/75] chore: fix misleading Imagick error message (#9928) --- system/Language/en/Images.php | 2 +- user_guide_src/source/changelogs/v4.7.1.rst | 2 ++ user_guide_src/source/libraries/images.rst | 4 ++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/system/Language/en/Images.php b/system/Language/en/Images.php index cdfe9ccbb181..2af9e0a2d1d3 100644 --- a/system/Language/en/Images.php +++ b/system/Language/en/Images.php @@ -21,7 +21,7 @@ 'pngNotSupported' => 'PNG images are not supported.', 'webpNotSupported' => 'WEBP images are not supported.', 'fileNotSupported' => 'The supplied file is not a supported image type.', - 'unsupportedImageCreate' => 'Your server does not support the GD function required to process this type of image.', + 'unsupportedImageCreate' => 'Your server does not support the required functionality to process this type of image.', 'jpgOrPngRequired' => 'The image resize protocol specified in your preferences only works with JPEG or PNG image types.', 'rotateUnsupported' => 'Image rotation does not appear to be supported by your server.', 'imageProcessFailed' => 'Image processing failed. Please verify that your server supports the chosen protocol and that the path to your image library is correct.', diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst index 2761dcb50e19..18c24b47ffb0 100644 --- a/user_guide_src/source/changelogs/v4.7.1.rst +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -18,6 +18,8 @@ BREAKING Message Changes *************** +- Updated ``Images.unsupportedImageCreate``. + ******* Changes ******* diff --git a/user_guide_src/source/libraries/images.rst b/user_guide_src/source/libraries/images.rst index b2b6b0238942..8f039c9561ec 100644 --- a/user_guide_src/source/libraries/images.rst +++ b/user_guide_src/source/libraries/images.rst @@ -38,6 +38,10 @@ The available Handlers are as follows: .. note:: The ImageMagick handler requires the imagick extension. +.. note:: + On Windows, the ImageMagick handler requires **absolute file paths** when + loading images (for example, using ``WRITEPATH`` or ``FCPATH``). + ******************* Processing an Image ******************* From 25c6f4bfec807d59609b0b3331adade126e9f7cb Mon Sep 17 00:00:00 2001 From: neznaika0 Date: Sat, 7 Feb 2026 10:51:37 +0300 Subject: [PATCH 05/75] fix: Fixed relative paths in .gitignore (#9929) --- .gitignore | 50 +++++++++++++++++++------------------- admin/framework/.gitignore | 32 ++++++++++++------------ admin/starter/.gitignore | 32 ++++++++++++------------ 3 files changed, 57 insertions(+), 57 deletions(-) diff --git a/.gitignore b/.gitignore index 485512fa4a7b..e18328fc9b15 100644 --- a/.gitignore +++ b/.gitignore @@ -44,45 +44,45 @@ $RECYCLE.BIN/ .env .vagrant Vagrantfile -user_guide_src/venv/ +/user_guide_src/venv/ .python-version -user_guide_src/.python-version +/user_guide_src/.python-version #------------------------- # Temporary Files #------------------------- -writable/cache/* -!writable/cache/index.html +/writable/cache/* +!/writable/cache/index.html -writable/logs/* -!writable/logs/index.html +/writable/logs/* +!/writable/logs/index.html -writable/session/* -!writable/session/index.html +/writable/session/* +!/writable/session/index.html -writable/uploads/* -!writable/uploads/index.html +/writable/uploads/* +!/writable/uploads/index.html -writable/debugbar/* -!writable/debugbar/index.html +/writable/debugbar/* +!/writable/debugbar/index.html -writable/**/*.db -writable/**/*.sqlite +/writable/**/*.db +/writable/**/*.sqlite php_errors.log #------------------------- # User Guide Temp Files #------------------------- -user_guide_src/build/* +/user_guide_src/build/* #------------------------- # Test Files #------------------------- -tests/coverage* +/tests/coverage* # Don't save phpunit under version control. -phpunit +/phpunit #------------------------- # Composer @@ -105,14 +105,14 @@ _modules/* *.iml # Netbeans -nbproject/ -build/ -nbbuild/ -dist/ -nbdist/ -nbactions.xml -nb-configuration.xml -.nb-gradle/ +/nbproject/ +/build/ +/nbbuild/ +/dist/ +/nbdist/ +/nbactions.xml +/nb-configuration.xml +/.nb-gradle/ # Sublime Text *.tmlanguage.cache diff --git a/admin/framework/.gitignore b/admin/framework/.gitignore index 87e86b93bd42..d69ef2f7dbdb 100644 --- a/admin/framework/.gitignore +++ b/admin/framework/.gitignore @@ -48,38 +48,38 @@ Vagrantfile #------------------------- # Temporary Files #------------------------- -writable/cache/* -!writable/cache/index.html +/writable/cache/* +!/writable/cache/index.html -writable/logs/* -!writable/logs/index.html +/writable/logs/* +!/writable/logs/index.html -writable/session/* -!writable/session/index.html +/writable/session/* +!/writable/session/index.html -writable/uploads/* -!writable/uploads/index.html +/writable/uploads/* +!/writable/uploads/index.html -writable/debugbar/* -!writable/debugbar/index.html +/writable/debugbar/* +!/writable/debugbar/index.html php_errors.log #------------------------- # User Guide Temp Files #------------------------- -user_guide_src/build/* -user_guide_src/cilexer/build/* -user_guide_src/cilexer/dist/* -user_guide_src/cilexer/pycilexer.egg-info/* +/user_guide_src/build/* +/user_guide_src/cilexer/build/* +/user_guide_src/cilexer/dist/* +/user_guide_src/cilexer/pycilexer.egg-info/* #------------------------- # Test Files #------------------------- -tests/coverage* +/tests/coverage* # Don't save phpunit under version control. -phpunit +/phpunit #------------------------- # Composer diff --git a/admin/starter/.gitignore b/admin/starter/.gitignore index 87e86b93bd42..d69ef2f7dbdb 100644 --- a/admin/starter/.gitignore +++ b/admin/starter/.gitignore @@ -48,38 +48,38 @@ Vagrantfile #------------------------- # Temporary Files #------------------------- -writable/cache/* -!writable/cache/index.html +/writable/cache/* +!/writable/cache/index.html -writable/logs/* -!writable/logs/index.html +/writable/logs/* +!/writable/logs/index.html -writable/session/* -!writable/session/index.html +/writable/session/* +!/writable/session/index.html -writable/uploads/* -!writable/uploads/index.html +/writable/uploads/* +!/writable/uploads/index.html -writable/debugbar/* -!writable/debugbar/index.html +/writable/debugbar/* +!/writable/debugbar/index.html php_errors.log #------------------------- # User Guide Temp Files #------------------------- -user_guide_src/build/* -user_guide_src/cilexer/build/* -user_guide_src/cilexer/dist/* -user_guide_src/cilexer/pycilexer.egg-info/* +/user_guide_src/build/* +/user_guide_src/cilexer/build/* +/user_guide_src/cilexer/dist/* +/user_guide_src/cilexer/pycilexer.egg-info/* #------------------------- # Test Files #------------------------- -tests/coverage* +/tests/coverage* # Don't save phpunit under version control. -phpunit +/phpunit #------------------------- # Composer From 1b413583f26240651b9a95a303e3c161af094273 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Sat, 7 Feb 2026 15:37:53 +0100 Subject: [PATCH 06/75] chore: fix withHeaders() docblock param type and example (#9932) --- system/Test/FeatureTestTrait.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/system/Test/FeatureTestTrait.php b/system/Test/FeatureTestTrait.php index a19e396e2392..618a2bcdb485 100644 --- a/system/Test/FeatureTestTrait.php +++ b/system/Test/FeatureTestTrait.php @@ -16,7 +16,6 @@ use Closure; use CodeIgniter\Events\Events; use CodeIgniter\HTTP\Exceptions\RedirectException; -use CodeIgniter\HTTP\Header; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\Method; use CodeIgniter\HTTP\Request; @@ -36,7 +35,7 @@ * against your application in trait format. * * @property array $session - * @property array> $headers + * @property array|string> $headers * @property RouteCollection|null $routes * * @mixin CIUnitTestCase @@ -115,10 +114,11 @@ public function withSession(?array $values = null) * * Example of use * withHeaders([ - * 'Authorization' => 'Token' + * 'Authorization' => 'Token', + * 'Cache-Control' => ['no-cache', 'no-store'], * ]) * - * @param array> $headers Array of headers + * @param array|string> $headers Array of headers * * @return $this */ From a58efe37d81a1b7cec4b9cdcd93a28068299a21b Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Tue, 10 Feb 2026 17:35:38 +0100 Subject: [PATCH 07/75] fix: escape CSP nonce attributes in JSON responses (#9938) Co-authored-by: John Paul E. Balandan, CPA --- system/HTTP/ContentSecurityPolicy.php | 8 +++-- tests/system/Debug/ExceptionHandlerTest.php | 2 ++ .../system/HTTP/ContentSecurityPolicyTest.php | 36 +++++++++++++++++++ user_guide_src/source/changelogs/v4.7.1.rst | 2 ++ 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/system/HTTP/ContentSecurityPolicy.php b/system/HTTP/ContentSecurityPolicy.php index 04d93365af25..f15a85df32d9 100644 --- a/system/HTTP/ContentSecurityPolicy.php +++ b/system/HTTP/ContentSecurityPolicy.php @@ -898,13 +898,17 @@ protected function generateNonces(ResponseInterface $response) return; } + // Escape quotes for JSON responses to prevent corrupting the JSON body + $jsonEscape = str_contains($response->getHeaderLine('Content-Type'), 'json'); + // Replace style and script placeholders with nonces $pattern = sprintf('/(%s|%s)/', preg_quote($this->styleNonceTag, '/'), preg_quote($this->scriptNonceTag, '/')); - $body = preg_replace_callback($pattern, function ($match): string { + $body = preg_replace_callback($pattern, function ($match) use ($jsonEscape): string { $nonce = $match[0] === $this->styleNonceTag ? $this->getStyleNonce() : $this->getScriptNonce(); + $attr = 'nonce="' . $nonce . '"'; - return "nonce=\"{$nonce}\""; + return $jsonEscape ? str_replace('"', '\\"', $attr) : $attr; }, $body); $response->setBody($body); diff --git a/tests/system/Debug/ExceptionHandlerTest.php b/tests/system/Debug/ExceptionHandlerTest.php index a1958be5f36b..7bd7bdb35465 100644 --- a/tests/system/Debug/ExceptionHandlerTest.php +++ b/tests/system/Debug/ExceptionHandlerTest.php @@ -40,6 +40,8 @@ protected function setUp(): void parent::setUp(); $this->handler = new ExceptionHandler(new ExceptionsConfig()); + + $this->resetServices(); } public function testDetermineViewsPageNotFoundException(): void diff --git a/tests/system/HTTP/ContentSecurityPolicyTest.php b/tests/system/HTTP/ContentSecurityPolicyTest.php index af9638b6a8ba..beafa54ce164 100644 --- a/tests/system/HTTP/ContentSecurityPolicyTest.php +++ b/tests/system/HTTP/ContentSecurityPolicyTest.php @@ -937,4 +937,40 @@ public function testClearDirective(): void $this->assertNotContains('report-uri http://example.com/csp/reports', $directives); $this->assertNotContains('report-to default', $directives); } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testGenerateNoncesReplacesPlaceholdersInHtml(): void + { + $body = ''; + + $this->response->setBody($body); + $this->csp->finalize($this->response); + + $result = $this->response->getBody(); + + $this->assertMatchesRegularExpression('/'; + $response->setBody($body); + + ob_start(); + $response->send(); + $actual = ob_get_clean(); + + // Nonce placeholders should be removed when CSP is disabled + $this->assertIsString($actual); + $this->assertStringNotContainsString('{csp-script-nonce}', $actual); + $this->assertStringNotContainsString('{csp-style-nonce}', $actual); + $this->assertStringContainsString('', $actual); + $this->assertStringContainsString('', $actual); + } + + public function testSendRemovesCustomNoncePlaceholdersWhenCSPDisabled(): void + { + $appConfig = new App(); + $appConfig->CSPEnabled = false; + + // Create custom CSP config with custom nonce tags + $cspConfig = new \Config\ContentSecurityPolicy(); + $cspConfig->scriptNonceTag = '{custom-script-tag}'; + $cspConfig->styleNonceTag = '{custom-style-tag}'; + + Services::injectMock('csp', new ContentSecurityPolicy($cspConfig)); + + $response = new Response($appConfig); + $response->pretend(true); + + $body = ''; + $response->setBody($body); + + ob_start(); + $response->send(); + $actual = ob_get_clean(); + + // Custom nonce placeholders should be removed when CSP is disabled + $this->assertIsString($actual); + $this->assertStringNotContainsString('{custom-script-tag}', $actual); + $this->assertStringNotContainsString('{custom-style-tag}', $actual); + $this->assertStringContainsString('', $actual); + $this->assertStringContainsString('', $actual); + } + + public function testSendNoEffectWhenBodyEmptyAndCSPDisabled(): void + { + $config = new App(); + $config->CSPEnabled = false; + + $response = new Response($config); + $response->pretend(true); + + $body = ''; + $response->setBody($body); + + ob_start(); + $response->send(); + $actual = ob_get_clean(); + + $this->assertIsString($actual); + $this->assertSame('', $actual); + } + + public function testSendNoEffectWithNoPlaceholdersAndCSPDisabled(): void + { + $config = new App(); + $config->CSPEnabled = false; + + $response = new Response($config); + $response->pretend(true); + + $body = 'Test

No placeholders here

'; + $response->setBody($body); + + ob_start(); + $response->send(); + $actual = ob_get_clean(); + + // Body should be unchanged when there are no placeholders and CSP is disabled + $this->assertIsString($actual); + $this->assertSame($body, $actual); + } + + public function testSendRemovesMultiplePlaceholdersWhenCSPDisabled(): void + { + $config = new App(); + $config->CSPEnabled = false; + + $response = new Response($config); + $response->pretend(true); + + $body = ''; + $response->setBody($body); + + ob_start(); + $response->send(); + $actual = ob_get_clean(); + + // All nonce placeholders should be removed when CSP is disabled + $this->assertIsString($actual); + $this->assertStringNotContainsString('{csp-script-nonce}', $actual); + $this->assertStringNotContainsString('{csp-style-nonce}', $actual); + $this->assertStringContainsString('', $actual); + $this->assertStringContainsString('', $actual); + $this->assertStringContainsString('', $actual); + $this->assertStringContainsString('', $actual); + } + + public function testSendRemovesPlaceholdersWhenBothCSPAndAutoNonceAreDisabled(): void + { + $appConfig = new App(); + $appConfig->CSPEnabled = false; + + // Create custom CSP config with custom nonce tags + $cspConfig = new \Config\ContentSecurityPolicy(); + $cspConfig->autoNonce = false; + + Services::injectMock('csp', new ContentSecurityPolicy($cspConfig)); + + $response = new Response($appConfig); + $response->pretend(true); + + $body = ''; + $response->setBody($body); + + ob_start(); + $response->send(); + $actual = ob_get_clean(); + + // Custom nonce placeholders should be removed when CSP is disabled + $this->assertIsString($actual); + $this->assertStringNotContainsString('{csp-script-nonce}', $actual); + $this->assertStringNotContainsString('{csp-style-nonce}', $actual); + $this->assertStringContainsString('', $actual); + $this->assertStringContainsString('', $actual); + } } diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst index a6152e840301..82ab42174966 100644 --- a/user_guide_src/source/changelogs/v4.7.1.rst +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -32,6 +32,7 @@ Deprecations Bugs Fixed ********** +- **ContentSecurityPolicy:** Fixed a bug where custom CSP tags were not removed from generated HTML when CSP was disabled. The method now ensures that all custom CSP tags are removed from the generated HTML. - **ContentSecurityPolicy:** Fixed a bug where ``generateNonces()`` produces corrupted JSON responses by replacing CSP nonce placeholders with unescaped double quotes. The method now automatically JSON-escapes nonce attributes when the response Content-Type is JSON. - **Model:** Fixed a bug where ``BaseModel::updateBatch()`` threw an exception when ``updateOnlyChanged`` was ``true`` and the index field value did not change. - **Session:** Fixed a bug in ``MemcachedHandler`` where the constructor incorrectly threw an exception when ``savePath`` was not empty. From 2fab00d2a56716812c7c224d11b5294d46618df1 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Mon, 16 Feb 2026 20:04:00 +0100 Subject: [PATCH 19/75] docs: clarify the use of namespaced views (#9954) --- user_guide_src/source/outgoing/views.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/user_guide_src/source/outgoing/views.rst b/user_guide_src/source/outgoing/views.rst index 2c6b3cbe1dc6..214468478a9f 100644 --- a/user_guide_src/source/outgoing/views.rst +++ b/user_guide_src/source/outgoing/views.rst @@ -104,6 +104,11 @@ example, you could load the **blog_view.php** file from **example/blog/Views** b .. literalinclude:: views/005.php +.. note:: You must use backslashes (``\``) when referencing namespaced views, + as this is how CodeIgniter distinguishes a namespaced view from a regular + file path. Forward slashes (``/``) are only used for subdirectories within + the standard **app/Views** folder. + .. _views-overriding-namespaced-views: Overriding Namespaced Views @@ -151,6 +156,13 @@ To override this view (using the default configuration), create a file at the ma Now, when you call ``view('Example\Blog\blog_view')``, CodeIgniter will automatically load your custom view from **app/Views/overrides/Example/Blog/blog_view.php** instead of the original module view file. +.. note:: The override path must match exactly how you reference the view. + If you load a view as ``view('Example\Blog\blog_view')``, the override + path is **app/Views/overrides/Example/Blog/blog_view.php**. However, + if you explicitly include the ``Views`` directory in the call, e.g., + ``view('Example\Blog\Views\blog_view')``, the override path must also + include it: **app/Views/overrides/Example/Blog/Views/blog_view.php**. + .. _caching-views: Caching Views From 120edf45219b8f262c08682fd0f19fdb4500ac82 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Tue, 17 Feb 2026 17:25:54 +0800 Subject: [PATCH 20/75] refactor: implement development versions for `CodeIgniter::CI_VERSION` (#9951) * Refactor for better variable names * Replace `CodeIgniter::CI_VERSION` with dev version * Add auto review test * Change CI_VERSION * Fetch tags manually --- admin/create-new-changelog.php | 91 ++++++----- system/CodeIgniter.php | 2 +- .../AutoReview/CreateNewChangelogTest.php | 148 ++++++++++++++++++ 3 files changed, 199 insertions(+), 42 deletions(-) create mode 100644 tests/system/AutoReview/CreateNewChangelogTest.php diff --git a/admin/create-new-changelog.php b/admin/create-new-changelog.php index cc7f8359a570..eca1696676b2 100644 --- a/admin/create-new-changelog.php +++ b/admin/create-new-changelog.php @@ -9,81 +9,90 @@ function replace_file_content(string $path, string $pattern, string $replace): v file_put_contents($path, $output); } -// Main. chdir(__DIR__ . '/..'); -if ($argc !== 3) { - echo "Usage: php {$argv[0]} " . PHP_EOL; - echo "E.g.,: php {$argv[0]} 4.4.3 4.4.4" . PHP_EOL; +if (! isset($argv[1]) || ! isset($argv[2])) { + echo "Usage: php {$argv[0]} [--dry-run]\n"; + echo "E.g. : php {$argv[0]} 4.4.3 4.4.4 --dry-run\n"; exit(1); } // Gets version number from argument. -$versionCurrent = $argv[1]; // e.g., '4.4.3' -$versionCurrentParts = explode('.', $versionCurrent); -$minorCurrent = $versionCurrentParts[0] . '.' . $versionCurrentParts[1]; -$version = $argv[2]; // e.g., '4.4.4' -$versionParts = explode('.', $version); -$minor = $versionParts[0] . '.' . $versionParts[1]; -$isMinorUpdate = ($minorCurrent !== $minor); - -// Creates a branch for release. -if (! $isMinorUpdate) { - system('git switch develop'); +$currentVersion = $argv[1]; // e.g., '4.4.3' +$currentVersionParts = explode('.', $currentVersion, 3); +$currentMinorVersion = $currentVersionParts[0] . '.' . $currentVersionParts[1]; +$newVersion = $argv[2]; // e.g., '4.4.4' +$newVersionParts = explode('.', $newVersion, 3); +$newMinorVersion = $newVersionParts[0] . '.' . $newVersionParts[1]; +$isMinorUpdate = $currentMinorVersion !== $newMinorVersion; + +// Creates a branch for release +if (! in_array('--dry-run', $argv, true)) { + if (! $isMinorUpdate) { + system('git switch develop'); + } + + system("git switch -c docs-changelog-{$newVersion}"); + system("git switch docs-changelog-{$newVersion}"); } -system('git switch -c docs-changelog-' . $version); -system('git switch docs-changelog-' . $version); // Copy changelog -$changelog = "./user_guide_src/source/changelogs/v{$version}.rst"; +$newChangelog = "./user_guide_src/source/changelogs/v{$newVersion}.rst"; $changelogIndex = './user_guide_src/source/changelogs/index.rst'; + if ($isMinorUpdate) { - copy('./admin/next-changelog-minor.rst', $changelog); + copy('./admin/next-changelog-minor.rst', $newChangelog); } else { - copy('./admin/next-changelog-patch.rst', $changelog); + copy('./admin/next-changelog-patch.rst', $newChangelog); } + +// Replace version in CodeIgniter.php to {version}-dev. +replace_file_content( + './system/CodeIgniter.php', + '/public const CI_VERSION = \'.*?\';/u', + "public const CI_VERSION = '{$newVersion}-dev';", +); + // Add changelog to index.rst. replace_file_content( $changelogIndex, '/\.\. toctree::\n :titlesonly:\n/u', - ".. toctree::\n :titlesonly:\n\n v{$version}", + ".. toctree::\n :titlesonly:\n\n v{$newVersion}", ); + // Replace {version} -$length = mb_strlen("Version {$version}"); -$underline = str_repeat('#', $length); +$underline = str_repeat('#', mb_strlen("Version {$newVersion}")); replace_file_content( - $changelog, + $newChangelog, '/#################\nVersion {version}\n#################/u', - "{$underline}\nVersion {$version}\n{$underline}", -); -replace_file_content( - $changelog, - '/{version}/u', - "{$version}", + "{$underline}\nVersion {$newVersion}\n{$underline}", ); +replace_file_content($newChangelog, '/{version}/u', $newVersion); // Copy upgrading -$versionWithoutDots = str_replace('.', '', $version); -$upgrading = "./user_guide_src/source/installation/upgrade_{$versionWithoutDots}.rst"; +$versionWithoutDots = str_replace('.', '', $newVersion); +$newUpgrading = "./user_guide_src/source/installation/upgrade_{$versionWithoutDots}.rst"; $upgradingIndex = './user_guide_src/source/installation/upgrading.rst'; -copy('./admin/next-upgrading-guide.rst', $upgrading); +copy('./admin/next-upgrading-guide.rst', $newUpgrading); + // Add upgrading to upgrading.rst. replace_file_content( $upgradingIndex, '/ backward_compatibility_notes\n/u', " backward_compatibility_notes\n\n upgrade_{$versionWithoutDots}", ); + // Replace {version} -$length = mb_strlen("Upgrading from {$versionCurrent} to {$version}"); -$underline = str_repeat('#', $length); +$underline = str_repeat('#', mb_strlen("Upgrading from {$currentVersion} to {$newVersion}")); replace_file_content( - $upgrading, + $newUpgrading, '/##############################\nUpgrading from {version} to {version}\n##############################/u', - "{$underline}\nUpgrading from {$versionCurrent} to {$version}\n{$underline}", + "{$underline}\nUpgrading from {$currentVersion} to {$newVersion}\n{$underline}", ); -// Commits -system("git add {$changelog} {$changelogIndex}"); -system("git add {$upgrading} {$upgradingIndex}"); -system('git commit -m "docs: add changelog and upgrade for v' . $version . '"'); +if (! in_array('--dry-run', $argv, true)) { + system("git add {$newChangelog} {$changelogIndex}"); + system("git add {$newUpgrading} {$upgradingIndex}"); + system("git commit -m \"docs: add changelog and upgrade for v{$newVersion}\""); +} diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index d59ebd61c5c8..b2cbbf802f29 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -55,7 +55,7 @@ class CodeIgniter /** * The current version of CodeIgniter Framework */ - public const CI_VERSION = '4.7.0'; + public const CI_VERSION = '4.7.1-dev'; /** * App startup time. diff --git a/tests/system/AutoReview/CreateNewChangelogTest.php b/tests/system/AutoReview/CreateNewChangelogTest.php new file mode 100644 index 000000000000..fc348565464f --- /dev/null +++ b/tests/system/AutoReview/CreateNewChangelogTest.php @@ -0,0 +1,148 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\AutoReview; + +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +#[CoversNothing] +#[Group('AutoReview')] +final class CreateNewChangelogTest extends TestCase +{ + private string $currentVersion; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + if (getenv('GITHUB_ACTIONS') !== false) { + exec('git fetch --unshallow 2>&1', $output, $exitCode); + exec('git fetch --tags 2>&1', $output, $exitCode); + + if ($exitCode !== 0) { + self::fail(sprintf( + "Failed to fetch git history and tags.\nOutput: %s", + implode("\n", $output), + )); + } + } + } + + protected function setUp(): void + { + parent::setUp(); + + exec('git describe --tags --abbrev=0 2>&1', $output, $exitCode); + + if ($exitCode !== 0) { + $this->markTestSkipped(sprintf( + "Unable to get the latest git tag.\nOutput: %s", + implode("\n", $output), + )); + } + + // Current tag should already have the next patch docs done, so for testing purposes, + // we will treat the next patch version as the current version. + $this->currentVersion = $this->incrementVersion(trim($output[0], 'v')); + } + + #[DataProvider('provideCreateNewChangelog')] + public function testCreateNewChangelog(string $mode): void + { + $currentVersion = $this->currentVersion; + $newVersion = $this->incrementVersion($currentVersion, $mode); + + exec( + sprintf('php ./admin/create-new-changelog.php %s %s --dry-run', $currentVersion, $newVersion), + $output, + $exitCode, + ); + + $this->assertSame(0, $exitCode, "Script exited with code {$exitCode}. Output: " . implode("\n", $output)); + + $this->assertStringContainsString( + "public const CI_VERSION = '{$newVersion}-dev';", + $this->getContents('./system/CodeIgniter.php'), + ); + + $this->assertFileExists("./user_guide_src/source/changelogs/v{$newVersion}.rst"); + $this->assertStringContainsString( + "Version {$newVersion}", + $this->getContents("./user_guide_src/source/changelogs/v{$newVersion}.rst"), + ); + $this->assertStringContainsString( + "**{$newVersion} release of CodeIgniter4**", + $this->getContents("./user_guide_src/source/changelogs/v{$newVersion}.rst"), + ); + $this->assertStringContainsString( + $newVersion, + $this->getContents('./user_guide_src/source/changelogs/index.rst'), + ); + + $versionWithoutDots = str_replace('.', '', $newVersion); + $this->assertFileExists("./user_guide_src/source/installation/upgrade_{$versionWithoutDots}.rst"); + $this->assertStringContainsString( + "Upgrading from {$currentVersion} to {$newVersion}", + $this->getContents("./user_guide_src/source/installation/upgrade_{$versionWithoutDots}.rst"), + ); + $this->assertStringContainsString( + "upgrade_{$versionWithoutDots}", + $this->getContents('./user_guide_src/source/installation/upgrading.rst'), + ); + + // cleanup added and modified files + exec('git restore .'); + exec('git clean -fd'); + } + + /** + * @return iterable + */ + public static function provideCreateNewChangelog(): iterable + { + yield 'patch update' => ['patch']; + + yield 'minor update' => ['minor']; + + yield 'major update' => ['major']; + } + + private function incrementVersion(string $version, string $mode = 'patch'): string + { + $parts = explode('.', $version); + + return match ($mode) { + 'major' => sprintf('%d.0.0', ++$parts[0]), + 'minor' => sprintf('%d.%d.0', $parts[0], ++$parts[1]), + 'patch' => sprintf('%d.%d.%d', $parts[0], $parts[1], ++$parts[2]), + default => $this->fail('Invalid version increment mode. Use "major", "minor", or "patch".'), + }; + } + + private function getContents(string $path): string + { + $contents = @file_get_contents($path); + + if ($contents === false) { + $this->fail("Failed to read file contents from {$path}."); + } + + return $contents; + } +} From e5111b1364904d00aa9f5b761bdaa15b83dc7372 Mon Sep 17 00:00:00 2001 From: neznaika0 Date: Tue, 17 Feb 2026 15:19:30 +0300 Subject: [PATCH 21/75] feat: Add `builds next` option (#9946) * feat: Add `builds next` option * fix: Add release changes for `builds` * fix: Apply suggestions * Apply suggestions from code review --------- Co-authored-by: John Paul E. Balandan, CPA --- admin/RELEASE.md | 4 +++ admin/prepare-release.php | 15 +++++++++++ admin/starter/builds | 27 ++++++++++--------- user_guide_src/source/changelogs/v4.7.1.rst | 6 +++++ .../installation/installing_composer.rst | 15 ++++++----- 5 files changed, 48 insertions(+), 19 deletions(-) diff --git a/admin/RELEASE.md b/admin/RELEASE.md index 647720f7ca2c..4116520297e9 100644 --- a/admin/RELEASE.md +++ b/admin/RELEASE.md @@ -124,6 +124,10 @@ the existing content. * Set the date to format `Release Date: January 31, 2021` * Update **phpdoc.dist.xml** with the new `CodeIgniter v4.x API` and `` + * Update **admin/starter/builds**: + * Set `define('LATEST_RELEASE', '^4.x')` + * Set `define('NEXT_MINOR', '4.y-dev')`. + * If the major version changes, you need to manually change to `define('NEXT_MINOR', '5.0-dev')`. * Commit the changes with `Prep for 4.x.x release` * [ ] Create a new PR from `release-4.x.x` to `develop`: * Title: `Prep for 4.x.x release` diff --git a/admin/prepare-release.php b/admin/prepare-release.php index 5d66a43b8c86..d68ae249b604 100644 --- a/admin/prepare-release.php +++ b/admin/prepare-release.php @@ -24,6 +24,9 @@ function replace_file_content(string $path, string $pattern, string $replace): v $versionParts = explode('.', $version); $minor = $versionParts[0] . '.' . $versionParts[1]; +// Note: Major version will change someday (4.x..5.x) - update manually. +$nextMinor = $versionParts[0] . '.' . $versionParts[1] + 1; + // Creates a branch for release. system('git switch develop'); system('git branch -D release-' . $version); @@ -68,6 +71,18 @@ function replace_file_content(string $path, string $pattern, string $replace): v "Release Date: {$date}", ); +// Update appstarter/builds script +replace_file_content( + './admin/starter/builds', + '/define\(\'LATEST_RELEASE\', \'.*?\'\);/mu', + "define('LATEST_RELEASE', '^{$minor}');", +); +replace_file_content( + './admin/starter/builds', + '/define\(\'NEXT_MINOR\', \'.*?\'\);/mu', + "define('NEXT_MINOR', '^{$nextMinor}-dev');", +); + // Commits system('git add -u'); system('git commit -m "Prep for ' . $version . ' release"'); diff --git a/admin/starter/builds b/admin/starter/builds index cc2ca0851ac7..f156b6944cfd 100755 --- a/admin/starter/builds +++ b/admin/starter/builds @@ -1,7 +1,9 @@ #!/usr/bin/env php `. + ************ Deprecations ************ diff --git a/user_guide_src/source/installation/installing_composer.rst b/user_guide_src/source/installation/installing_composer.rst index 00a978547fb8..ab24aeffadea 100644 --- a/user_guide_src/source/installation/installing_composer.rst +++ b/user_guide_src/source/installation/installing_composer.rst @@ -158,6 +158,8 @@ Folders in your project after set up: - app, public, tests, writable - vendor/codeigniter4/framework/system +.. _switch-to-dev-version: + Latest Dev ---------- @@ -169,6 +171,9 @@ The `development user guide `_ is Note that this differs from the released user guide, and will pertain to the develop branch explicitly. +.. note:: You should not rely on the version of the framework in your project + - the development code may contain an incorrect number. + Update for Latest Dev ^^^^^^^^^^^^^^^^^^^^^ @@ -188,15 +193,11 @@ files if necessary. Next Minor Version ^^^^^^^^^^^^^^^^^^ -If you want to use the next minor version branch, after using the ``builds`` command -edit **composer.json** manually. +If you want to use the next minor version branch: -If you try the ``4.8`` branch, change the version to ``4.8.x-dev``:: +.. code-block:: console - "require": { - "php": "^8.2", - "codeigniter4/codeigniter4": "4.8.x-dev" - }, + php builds next And run ``composer update`` to sync your vendor folder with the latest target build. Then, check the Upgrading Guide From 2582088defd885783770f4ff8e8655eb88d2cb3e Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Tue, 17 Feb 2026 13:20:03 +0100 Subject: [PATCH 22/75] fix: initialize standalone toolbar (#9950) * fix: initialize standalone toolbar * initialize standalone with ensureToolbarContainer --- system/Debug/Toolbar/Views/toolbar.tpl.php | 1 + .../Debug/Toolbar/Views/toolbarstandalone.js | 69 +++++++++++++++++++ user_guide_src/source/changelogs/v4.7.1.rst | 1 + 3 files changed, 71 insertions(+) create mode 100644 system/Debug/Toolbar/Views/toolbarstandalone.js diff --git a/system/Debug/Toolbar/Views/toolbar.tpl.php b/system/Debug/Toolbar/Views/toolbar.tpl.php index 0768f62915bb..b86baba23336 100644 --- a/system/Debug/Toolbar/Views/toolbar.tpl.php +++ b/system/Debug/Toolbar/Views/toolbar.tpl.php @@ -28,6 +28,7 @@
diff --git a/system/Debug/Toolbar/Views/toolbarstandalone.js b/system/Debug/Toolbar/Views/toolbarstandalone.js new file mode 100644 index 000000000000..f4a71d5463a7 --- /dev/null +++ b/system/Debug/Toolbar/Views/toolbarstandalone.js @@ -0,0 +1,69 @@ +/* + * Bootstrap for standalone Debug Toolbar pages (?debugbar_time=...). + */ + +if (! document.getElementById('debugbar_loader')) { + if (typeof loadDoc !== 'function') { + window.loadDoc = function (time) { + if (isNaN(time)) { + return; + } + + window.location.href = ciSiteURL + '?debugbar_time=' + time; + }; + } + + (function () { + function ensureToolbarContainer(icon, toolbar) { + let toolbarContainer = document.getElementById('toolbarContainer'); + + if (toolbarContainer) { + return; + } + + toolbarContainer = document.createElement('div'); + toolbarContainer.setAttribute('id', 'toolbarContainer'); + + if (icon) { + toolbarContainer.appendChild(icon); + } + + if (toolbar) { + toolbarContainer.appendChild(toolbar); + } + + document.body.appendChild(toolbarContainer); + } + + function initStandaloneToolbar() { + if (typeof ciDebugBar !== 'object') { + return; + } + + const icon = document.getElementById('debug-icon'); + const toolbar = document.getElementById('debug-bar'); + + if (! toolbar || ! icon) { + return; + } + + const currentTime = new URLSearchParams(window.location.search).get('debugbar_time'); + + if (currentTime && ! isNaN(currentTime)) { + if (! localStorage.getItem('debugbar-time')) { + localStorage.setItem('debugbar-time', currentTime); + } + localStorage.setItem('debugbar-time-new', currentTime); + } + + ensureToolbarContainer(icon, toolbar); + ciDebugBar.init(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initStandaloneToolbar, false); + } else { + initStandaloneToolbar(); + } + })(); +} diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst index ffd1fd2c1b0e..5f2ca988ce63 100644 --- a/user_guide_src/source/changelogs/v4.7.1.rst +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -42,6 +42,7 @@ Bugs Fixed - **ContentSecurityPolicy:** Fixed a bug where ``generateNonces()`` produces corrupted JSON responses by replacing CSP nonce placeholders with unescaped double quotes. The method now automatically JSON-escapes nonce attributes when the response Content-Type is JSON. - **Model:** Fixed a bug where ``BaseModel::updateBatch()`` threw an exception when ``updateOnlyChanged`` was ``true`` and the index field value did not change. - **Session:** Fixed a bug in ``MemcachedHandler`` where the constructor incorrectly threw an exception when ``savePath`` was not empty. +- **Toolbar:** Fixed a bug where the standalone toolbar page loaded from ``?debugbar_time=...`` was not interactive. See the repo's `CHANGELOG.md `_ From a5fc03437f92de9ff0a7c8673d4a641122db7a20 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Wed, 18 Feb 2026 04:10:02 +0800 Subject: [PATCH 23/75] refactor: use `__unserialize` instead of `__wakeup` in `TimeTrait` (#9957) --- system/I18n/TimeTrait.php | 16 ++++---------- utils/phpstan-baseline/loader.neon | 2 +- .../method.childParameterType.neon | 22 ++++++++++++++++++- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/system/I18n/TimeTrait.php b/system/I18n/TimeTrait.php index d9abb16e29a2..e8c3472d63c0 100644 --- a/system/I18n/TimeTrait.php +++ b/system/I18n/TimeTrait.php @@ -1238,19 +1238,11 @@ public function __isset($name): bool /** * This is called when we unserialize the Time object. + * + * @param array{date: string, timezone: string, timezone_type: int} $data */ - public function __wakeup(): void + public function __unserialize(array $data): void { - /** - * Prior to unserialization, this is a string. - * - * @var string $timezone - */ - $timezone = $this->timezone; - - $this->timezone = new DateTimeZone($timezone); - - // @phpstan-ignore-next-line `$this->date` is a special property for PHP internal use. - parent::__construct($this->date, $this->timezone); + parent::__construct($data['date'], new DateTimeZone($data['timezone'])); } } diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 7c302e27351d..7378ca423744 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2122 errors +# total 2126 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/method.childParameterType.neon b/utils/phpstan-baseline/method.childParameterType.neon index d6446dcbb7bd..d5b6014b15a6 100644 --- a/utils/phpstan-baseline/method.childParameterType.neon +++ b/utils/phpstan-baseline/method.childParameterType.neon @@ -1,4 +1,4 @@ -# total 8 errors +# total 12 errors parameters: ignoreErrors: @@ -41,3 +41,23 @@ parameters: message: '#^Parameter \#1 \$value \(int\) of method CodeIgniter\\Entity\\Cast\\IntBoolCast\:\:get\(\) should be contravariant with parameter \$value \(array\|bool\|float\|int\|object\|string\|null\) of method CodeIgniter\\Entity\\Cast\\CastInterface\:\:get\(\)$#' count: 1 path: ../../system/Entity/Cast/IntBoolCast.php + + - + message: '#^Parameter \#1 \$data \(array\{date\: string, timezone\: string, timezone_type\: int\}\) of method CodeIgniter\\I18n\\Time\:\:__unserialize\(\) should be contravariant with parameter \$data \(array\) of method DateTimeImmutable\:\:__unserialize\(\)$#' + count: 1 + path: ../../system/I18n/Time.php + + - + message: '#^Parameter \#1 \$data \(array\{date\: string, timezone\: string, timezone_type\: int\}\) of method CodeIgniter\\I18n\\Time\:\:__unserialize\(\) should be contravariant with parameter \$data \(array\) of method DateTimeInterface\:\:__unserialize\(\)$#' + count: 1 + path: ../../system/I18n/Time.php + + - + message: '#^Parameter \#1 \$data \(array\{date\: string, timezone\: string, timezone_type\: int\}\) of method CodeIgniter\\I18n\\TimeLegacy\:\:__unserialize\(\) should be contravariant with parameter \$data \(array\) of method DateTimeInterface\:\:__unserialize\(\)$#' + count: 1 + path: ../../system/I18n/TimeLegacy.php + + - + message: '#^Parameter \#1 \$data \(array\{date\: string, timezone\: string, timezone_type\: int\}\) of method CodeIgniter\\I18n\\TimeLegacy\:\:__unserialize\(\) should be contravariant with parameter \$data \(array\) of method DateTime\:\:__unserialize\(\)$#' + count: 1 + path: ../../system/I18n/TimeLegacy.php From 79e59761d9c6392f3ed3fa33b41a9f8883296693 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Wed, 18 Feb 2026 20:41:13 +0100 Subject: [PATCH 24/75] fix: add fallback for `appOverridesFolder` config in View (#9958) * fix: add fallback for appOverridesFolder config in View * add changelog entry --- system/View/View.php | 6 ++++-- user_guide_src/source/changelogs/v4.7.1.rst | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/system/View/View.php b/system/View/View.php index 5e860853f44c..7ca89ac0c9ad 100644 --- a/system/View/View.php +++ b/system/View/View.php @@ -202,8 +202,10 @@ public function render(string $view, ?array $options = null, ?bool $saveData = n $this->renderVars['file'] = $this->viewPath . $this->renderVars['view']; if (str_contains($this->renderVars['view'], '\\')) { - $overrideFolder = $this->config->appOverridesFolder !== '' - ? trim($this->config->appOverridesFolder, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR + $appOverridesFolder = $this->config->appOverridesFolder ?? 'overrides'; + + $overrideFolder = $appOverridesFolder !== '' + ? trim($appOverridesFolder, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR : ''; $this->renderVars['file'] = $this->viewPath diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst index 5f2ca988ce63..3bed48085ea3 100644 --- a/user_guide_src/source/changelogs/v4.7.1.rst +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -43,6 +43,7 @@ Bugs Fixed - **Model:** Fixed a bug where ``BaseModel::updateBatch()`` threw an exception when ``updateOnlyChanged`` was ``true`` and the index field value did not change. - **Session:** Fixed a bug in ``MemcachedHandler`` where the constructor incorrectly threw an exception when ``savePath`` was not empty. - **Toolbar:** Fixed a bug where the standalone toolbar page loaded from ``?debugbar_time=...`` was not interactive. +- **View:** Fixed a bug where ``View`` would throw an error if the ``appOverridesFolder`` config property was not defined. See the repo's `CHANGELOG.md `_ From c0c779276aba013cd294b007230629b1568303ec Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Wed, 18 Feb 2026 20:42:13 +0100 Subject: [PATCH 25/75] fix: avoid double-prefixing in BaseConnection::callFunction() (#9959) --- system/Database/BaseConnection.php | 2 +- tests/system/Database/BaseConnectionTest.php | 24 ++++++++++++++++++++ user_guide_src/source/changelogs/v4.7.1.rst | 1 + 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index 8e8d0dd43d24..66070942a11b 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -1508,7 +1508,7 @@ public function callFunction(string $functionName, ...$params): bool { $driver = $this->getDriverFunctionPrefix(); - if (! str_contains($driver, $functionName)) { + if (! str_starts_with($functionName, $driver)) { $functionName = $driver . $functionName; } diff --git a/tests/system/Database/BaseConnectionTest.php b/tests/system/Database/BaseConnectionTest.php index dc598241c15c..0a797c3ac9ca 100644 --- a/tests/system/Database/BaseConnectionTest.php +++ b/tests/system/Database/BaseConnectionTest.php @@ -345,4 +345,28 @@ public static function provideEscapeIdentifier(): iterable 'with dots' => ['com.sitedb.web', '"com.sitedb.web"'], ]; } + + public function testCallFunctionDoesNotDoublePrefixAlreadyPrefixedName(): void + { + $db = new class ($this->options) extends MockConnection { + protected function getDriverFunctionPrefix(): string + { + return 'str_'; + } + }; + + $this->assertTrue($db->callFunction('str_contains', 'CodeIgniter', 'Ignite')); + } + + public function testCallFunctionPrefixesUnprefixedName(): void + { + $db = new class ($this->options) extends MockConnection { + protected function getDriverFunctionPrefix(): string + { + return 'str_'; + } + }; + + $this->assertTrue($db->callFunction('contains', 'CodeIgniter', 'Ignite')); + } } diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst index 3bed48085ea3..14445ae29577 100644 --- a/user_guide_src/source/changelogs/v4.7.1.rst +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -40,6 +40,7 @@ Bugs Fixed - **ContentSecurityPolicy:** Fixed a bug where custom CSP tags were not removed from generated HTML when CSP was disabled. The method now ensures that all custom CSP tags are removed from the generated HTML. - **ContentSecurityPolicy:** Fixed a bug where ``generateNonces()`` produces corrupted JSON responses by replacing CSP nonce placeholders with unescaped double quotes. The method now automatically JSON-escapes nonce attributes when the response Content-Type is JSON. +- **Database:** Fixed a bug where ``BaseConnection::callFunction()`` could double-prefix already-prefixed function names. - **Model:** Fixed a bug where ``BaseModel::updateBatch()`` threw an exception when ``updateOnlyChanged`` was ``true`` and the index field value did not change. - **Session:** Fixed a bug in ``MemcachedHandler`` where the constructor incorrectly threw an exception when ``savePath`` was not empty. - **Toolbar:** Fixed a bug where the standalone toolbar page loaded from ``?debugbar_time=...`` was not interactive. From 0c30a5ab4e80b7bc67e25b95c098db75b53602be Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:22:47 +0000 Subject: [PATCH 26/75] chore(deps-dev): update rector/rector requirement from 2.3.6 to 2.3.7 Updates the requirements on [rector/rector](https://github.com/rectorphp/rector) to permit the latest version. - [Release notes](https://github.com/rectorphp/rector/releases) - [Commits](https://github.com/rectorphp/rector/compare/2.3.6...2.3.7) --- updated-dependencies: - dependency-name: rector/rector dependency-version: 2.3.7 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index fb185514cf64..e7c4b8cafe77 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "phpunit/phpcov": "^9.0.2 || ^10.0", "phpunit/phpunit": "^10.5.16 || ^11.2", "predis/predis": "^3.0", - "rector/rector": "2.3.6", + "rector/rector": "2.3.7", "shipmonk/phpstan-baseline-per-identifier": "^2.0" }, "replace": { From 72fa0c4f5e3c956a7a02c7175e9f1dee87a79099 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Fri, 20 Feb 2026 16:07:17 +0800 Subject: [PATCH 27/75] refactor: remove `Exceptions::isImplicitNullableDeprecationError` (#9965) --- system/Debug/Exceptions.php | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/system/Debug/Exceptions.php b/system/Debug/Exceptions.php index 6bf914c785bb..3c8b1f006ea7 100644 --- a/system/Debug/Exceptions.php +++ b/system/Debug/Exceptions.php @@ -201,10 +201,6 @@ public function errorHandler(int $severity, string $message, ?string $file = nul return true; } - if ($this->isImplicitNullableDeprecationError($message, $file, $line)) { - return true; - } - if (! $this->config->logDeprecations || (bool) env('CODEIGNITER_SCREAM_DEPRECATIONS')) { throw new ErrorException($message, 0, $severity, $file, $line); } @@ -245,38 +241,6 @@ private function isSessionSidDeprecationError(string $message, ?string $file = n return false; } - /** - * Workaround to implicit nullable deprecation errors in PHP 8.4. - * - * "Implicitly marking parameter $xxx as nullable is deprecated, - * the explicit nullable type must be used instead" - * - * @TODO remove this before v4.6.0 release - */ - private function isImplicitNullableDeprecationError(string $message, ?string $file = null, ?int $line = null): bool - { - if ( - PHP_VERSION_ID >= 80400 - && str_contains($message, 'the explicit nullable type must be used instead') - // Only Kint and Faker, which cause this error, are logged. - && (str_starts_with($message, 'Kint\\') || str_starts_with($message, 'Faker\\')) - ) { - log_message( - LogLevel::WARNING, - '[DEPRECATED] {message} in {errFile} on line {errLine}.', - [ - 'message' => $message, - 'errFile' => clean_path($file ?? ''), - 'errLine' => $line ?? 0, - ], - ); - - return true; - } - - return false; - } - /** * Checks to see if any errors have happened during shutdown that * need to be caught and handle them. From 6ee78ae5649984d6e168c3c3e6a7391ec7114774 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Fri, 20 Feb 2026 09:08:37 +0100 Subject: [PATCH 28/75] fix: generate inputs for all route params in Debug Toolbar (#9964) --- system/Debug/Toolbar/Views/toolbar.js | 9 ++++----- user_guide_src/source/changelogs/v4.7.1.rst | 1 + 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/system/Debug/Toolbar/Views/toolbar.js b/system/Debug/Toolbar/Views/toolbar.js index 9e4547b6cb35..46504e80ca25 100644 --- a/system/Debug/Toolbar/Views/toolbar.js +++ b/system/Debug/Toolbar/Views/toolbar.js @@ -762,7 +762,7 @@ var ciDebugBar = { var rowGet = this.toolbar.querySelectorAll( 'td[data-debugbar-route="GET"]' ); - var patt = /\((?:[^)(]+|\((?:[^)(]+|\([^)(]*\))*\))*\)/; + var patt = /\(.+?\)/g; for (var i = 0; i < rowGet.length; i++) { row = rowGet[i]; @@ -788,10 +788,9 @@ var ciDebugBar = { '
' + - row.innerText.replace( - patt, - '' - ) + + row.innerText.replace(patt, function (match) { + return ''; + }) + '' + "
"; } diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst index 14445ae29577..3288217b8d5b 100644 --- a/user_guide_src/source/changelogs/v4.7.1.rst +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -44,6 +44,7 @@ Bugs Fixed - **Model:** Fixed a bug where ``BaseModel::updateBatch()`` threw an exception when ``updateOnlyChanged`` was ``true`` and the index field value did not change. - **Session:** Fixed a bug in ``MemcachedHandler`` where the constructor incorrectly threw an exception when ``savePath`` was not empty. - **Toolbar:** Fixed a bug where the standalone toolbar page loaded from ``?debugbar_time=...`` was not interactive. +- **Toolbar:** Fixed a bug in the Routes panel where only the first route parameter was converted to an input field on hover. - **View:** Fixed a bug where ``View`` would throw an error if the ``appOverridesFolder`` config property was not defined. See the repo's From efe179f7ec938ab61a20a25aaa51813c0c530a68 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Fri, 20 Feb 2026 09:09:08 +0100 Subject: [PATCH 29/75] fix: preserve Postgre casts when converting named placeholders in prepared queries (#9960) * fix: preserve Postgre casts when converting named placeholders in prepared queries * refactor tests * fix psalm --- system/Database/BasePreparedQuery.php | 8 +-- tests/_support/Mock/MockPreparedQuery.php | 54 +++++++++++++++ .../system/Database/BasePreparedQueryTest.php | 61 +++++++++++++++++ .../Database/Live/PreparedQueryTest.php | 68 +++++++++++++++++++ user_guide_src/source/changelogs/v4.7.1.rst | 1 + 5 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 tests/_support/Mock/MockPreparedQuery.php create mode 100644 tests/system/Database/BasePreparedQueryTest.php diff --git a/system/Database/BasePreparedQuery.php b/system/Database/BasePreparedQuery.php index d5bb40bebd0a..f2ee6b3ed6a4 100644 --- a/system/Database/BasePreparedQuery.php +++ b/system/Database/BasePreparedQuery.php @@ -80,10 +80,10 @@ public function __construct(BaseConnection $db) */ public function prepare(string $sql, array $options = [], string $queryClass = Query::class) { - // We only supports positional placeholders (?) - // in order to work with the execute method below, so we - // need to replace our named placeholders (:name) - $sql = preg_replace('/:[^\s,)]+/', '?', $sql); + // We only support positional placeholders (?), so convert + // named placeholders (:name or :name:) while leaving dialect + // syntax like PostgreSQL casts (::type) untouched. + $sql = preg_replace('/(?db); diff --git a/tests/_support/Mock/MockPreparedQuery.php b/tests/_support/Mock/MockPreparedQuery.php new file mode 100644 index 000000000000..c31dc266d84d --- /dev/null +++ b/tests/_support/Mock/MockPreparedQuery.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Mock; + +use CodeIgniter\Database\BasePreparedQuery; + +/** + * @internal + * + * @extends BasePreparedQuery + */ +final class MockPreparedQuery extends BasePreparedQuery +{ + public string $preparedSql = ''; + + /** + * @param array $options + */ + public function _prepare(string $sql, array $options = []): self + { + $this->preparedSql = $sql; + + return $this; + } + + /** + * @param array $data + */ + public function _execute(array $data): bool + { + return true; + } + + public function _getResult() + { + return null; + } + + protected function _close(): bool + { + return true; + } +} diff --git a/tests/system/Database/BasePreparedQueryTest.php b/tests/system/Database/BasePreparedQueryTest.php new file mode 100644 index 000000000000..a7830cd2853c --- /dev/null +++ b/tests/system/Database/BasePreparedQueryTest.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockConnection; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Mock\MockPreparedQuery; + +/** + * @internal + */ +#[Group('Others')] +final class BasePreparedQueryTest extends CIUnitTestCase +{ + public function testPrepareConvertsNamedPlaceholdersToPositionalPlaceholders(): void + { + $query = $this->createPreparedQuery(); + + $query->prepare('SELECT * FROM users WHERE id = :id: AND name = :name'); + + $this->assertSame('SELECT * FROM users WHERE id = ? AND name = ?', $query->preparedSql); + } + + public function testPrepareDoesNotConvertPostgreStyleCastSyntax(): void + { + $query = $this->createPreparedQuery(); + + $query->prepare('SELECT :name: AS name, created_at::timestamp AS created FROM users WHERE id = :id:'); + + $this->assertSame( + 'SELECT ? AS name, created_at::timestamp AS created FROM users WHERE id = ?', + $query->preparedSql, + ); + } + + public function testPrepareDoesNotConvertTimeLikeLiterals(): void + { + $query = $this->createPreparedQuery(); + + $query->prepare("SELECT '12:34' AS time_value, :id: AS id"); + + $this->assertSame("SELECT '12:34' AS time_value, ? AS id", $query->preparedSql); + } + + private function createPreparedQuery(): MockPreparedQuery + { + return new MockPreparedQuery(new MockConnection([])); + } +} diff --git a/tests/system/Database/Live/PreparedQueryTest.php b/tests/system/Database/Live/PreparedQueryTest.php index c66202c191af..304269969a2d 100644 --- a/tests/system/Database/Live/PreparedQueryTest.php +++ b/tests/system/Database/Live/PreparedQueryTest.php @@ -45,6 +45,10 @@ protected function tearDown(): void { parent::tearDown(); + if (! $this->query instanceof BasePreparedQuery) { + return; + } + try { $this->query->close(); } catch (BadMethodCallException) { @@ -109,6 +113,70 @@ public function testPrepareReturnsManualPreparedQuery(): void $this->assertSame($expected, $this->query->getQueryString()); } + public function testPrepareAndExecuteManualQueryWithNamedPlaceholdersKeepsTimeLiteral(): void + { + // Quote alias to keep a consistent property name across drivers (OCI8 uppercases unquoted aliases) + $timeValue = $this->db->protectIdentifiers('time_value'); + $this->query = $this->db->prepare(static function ($db) use ($timeValue): Query { + $sql = 'SELECT ' + . $db->protectIdentifiers('name') . ', ' + . $db->protectIdentifiers('email') + . ", '12:34' AS " . $timeValue . ' ' + . 'FROM ' . $db->protectIdentifiers($db->DBPrefix . 'user') + . ' WHERE ' + . $db->protectIdentifiers('name') . ' = :name:' + . ' AND ' . $db->protectIdentifiers('email') . ' = :email'; + + return (new Query($db))->setQuery($sql); + }); + + $preparedSql = $this->query->getQueryString(); + + $this->assertStringContainsString("'12:34' AS " . $timeValue, $preparedSql); + + if ($this->db->DBDriver === 'Postgre') { + $this->assertStringContainsString(' = $1', $preparedSql); + $this->assertStringContainsString(' = $2', $preparedSql); + } else { + $this->assertStringContainsString(' = ?', $preparedSql); + } + + $result = $this->query->execute('Derek Jones', 'derek@world.com'); + + $this->assertInstanceOf(ResultInterface::class, $result); + $this->assertSame('Derek Jones', $result->getRow()->name); + $this->assertSame('derek@world.com', $result->getRow()->email); + $this->assertSame('12:34', $result->getRow()->time_value); + } + + public function testPrepareAndExecuteManualQueryWithPostgreCastKeepsDoubleColonSyntax(): void + { + if ($this->db->DBDriver !== 'Postgre') { + $this->markTestSkipped('PostgreSQL-specific cast syntax test.'); + } + + $this->query = $this->db->prepare(static function ($db): Query { + $sql = 'SELECT ' + . ':value: AS value, now()::timestamp AS created_at' + . ' FROM ' . $db->protectIdentifiers($db->DBPrefix . 'user') + . ' WHERE ' . $db->protectIdentifiers('name') . ' = :name:'; + + return (new Query($db))->setQuery($sql); + }); + + $preparedSql = $this->query->getQueryString(); + + $this->assertStringContainsString('$1 AS value', $preparedSql); + $this->assertStringContainsString('now()::timestamp AS created_at', $preparedSql); + + $result = $this->query->execute('ci4', 'Derek Jones'); + + $this->assertInstanceOf(ResultInterface::class, $result); + $this->assertSame('ci4', $result->getRow()->value); + $this->assertNotEmpty($result->getRow()->created_at); + $this->assertNotSame('now()::timestamp', $result->getRow()->created_at); + } + public function testExecuteRunsQueryAndReturnsTrue(): void { $this->query = $this->db->prepare(static fn ($db) => $db->table('user')->insert([ diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst index 3288217b8d5b..95f0a4e798d0 100644 --- a/user_guide_src/source/changelogs/v4.7.1.rst +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -41,6 +41,7 @@ Bugs Fixed - **ContentSecurityPolicy:** Fixed a bug where custom CSP tags were not removed from generated HTML when CSP was disabled. The method now ensures that all custom CSP tags are removed from the generated HTML. - **ContentSecurityPolicy:** Fixed a bug where ``generateNonces()`` produces corrupted JSON responses by replacing CSP nonce placeholders with unescaped double quotes. The method now automatically JSON-escapes nonce attributes when the response Content-Type is JSON. - **Database:** Fixed a bug where ``BaseConnection::callFunction()`` could double-prefix already-prefixed function names. +- **Database:** Fixed a bug where ``BasePreparedQuery::prepare()`` could mis-handle SQL containing colon syntax by over-broad named-placeholder replacement. It now preserves PostgreSQL cast syntax like ``::timestamp``. - **Model:** Fixed a bug where ``BaseModel::updateBatch()`` threw an exception when ``updateOnlyChanged`` was ``true`` and the index field value did not change. - **Session:** Fixed a bug in ``MemcachedHandler`` where the constructor incorrectly threw an exception when ``savePath`` was not empty. - **Toolbar:** Fixed a bug where the standalone toolbar page loaded from ``?debugbar_time=...`` was not interactive. From 54ba38ce393c7abe2160d490a65cd6d0287d1969 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Fri, 20 Feb 2026 14:04:18 +0100 Subject: [PATCH 30/75] fix: prevent extra query and invalid size in Model::chunk() (#9961) --- system/BaseModel.php | 1 + system/Model.php | 7 ++- .../system/Models/MiscellaneousModelTest.php | 58 +++++++++++++++++++ user_guide_src/source/changelogs/v4.7.1.rst | 1 + 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/system/BaseModel.php b/system/BaseModel.php index ee3d1a859955..b9d21d5a974c 100644 --- a/system/BaseModel.php +++ b/system/BaseModel.php @@ -582,6 +582,7 @@ abstract public function countAllResults(bool $reset = true, bool $test = false) * @return void * * @throws DataException + * @throws InvalidArgumentException if $size is not a positive integer */ abstract public function chunk(int $size, Closure $userFunc); diff --git a/system/Model.php b/system/Model.php index dc3e4db94db2..307d66a52cb6 100644 --- a/system/Model.php +++ b/system/Model.php @@ -21,6 +21,7 @@ use CodeIgniter\Database\Exceptions\DataException; use CodeIgniter\Entity\Entity; use CodeIgniter\Exceptions\BadMethodCallException; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Exceptions\ModelException; use CodeIgniter\Validation\ValidationInterface; use Config\Database; @@ -533,10 +534,14 @@ public function countAllResults(bool $reset = true, bool $test = false) */ public function chunk(int $size, Closure $userFunc) { + if ($size <= 0) { + throw new InvalidArgumentException('chunk() requires a positive integer for the $size argument.'); + } + $total = $this->builder()->countAllResults(false); $offset = 0; - while ($offset <= $total) { + while ($offset < $total) { $builder = clone $this->builder(); $rows = $builder->get($size, $offset); diff --git a/tests/system/Models/MiscellaneousModelTest.php b/tests/system/Models/MiscellaneousModelTest.php index 556c1b441c18..e95a709e61bd 100644 --- a/tests/system/Models/MiscellaneousModelTest.php +++ b/tests/system/Models/MiscellaneousModelTest.php @@ -14,6 +14,8 @@ namespace CodeIgniter\Models; use CodeIgniter\Database\Exceptions\DataException; +use CodeIgniter\Events\Events; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\I18n\Time; use PHPUnit\Framework\Attributes\Group; use Tests\Support\Models\EntityModel; @@ -39,6 +41,62 @@ public function testChunk(): void $this->assertSame(4, $rowCount); } + public function testChunkThrowsOnZeroSize(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('chunk() requires a positive integer for the $size argument.'); + + $this->createModel(UserModel::class)->chunk(0, static function ($row): void {}); + } + + public function testChunkThrowsOnNegativeSize(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('chunk() requires a positive integer for the $size argument.'); + + $this->createModel(UserModel::class)->chunk(-1, static function ($row): void {}); + } + + public function testChunkEarlyExit(): void + { + $rowCount = 0; + + $this->createModel(UserModel::class)->chunk(2, static function ($row) use (&$rowCount): bool { + $rowCount++; + + return false; + }); + + $this->assertSame(1, $rowCount); + } + + public function testChunkDoesNotRunExtraQuery(): void + { + $queryCount = 0; + $listener = static function () use (&$queryCount): void { + $queryCount++; + }; + + Events::on('DBQuery', $listener); + $this->createModel(UserModel::class)->chunk(4, static function ($row): void {}); + Events::removeListener('DBQuery', $listener); + + $this->assertSame(2, $queryCount); + } + + public function testChunkEmptyTable(): void + { + $this->db->table('user')->truncate(); + + $rowCount = 0; + + $this->createModel(UserModel::class)->chunk(2, static function ($row) use (&$rowCount): void { + $rowCount++; + }); + + $this->assertSame(0, $rowCount); + } + public function testCanCreateAndSaveEntityClasses(): void { $entity = $this->createModel(EntityModel::class)->where('name', 'Developer')->first(); diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst index 95f0a4e798d0..1fa19241910f 100644 --- a/user_guide_src/source/changelogs/v4.7.1.rst +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -43,6 +43,7 @@ Bugs Fixed - **Database:** Fixed a bug where ``BaseConnection::callFunction()`` could double-prefix already-prefixed function names. - **Database:** Fixed a bug where ``BasePreparedQuery::prepare()`` could mis-handle SQL containing colon syntax by over-broad named-placeholder replacement. It now preserves PostgreSQL cast syntax like ``::timestamp``. - **Model:** Fixed a bug where ``BaseModel::updateBatch()`` threw an exception when ``updateOnlyChanged`` was ``true`` and the index field value did not change. +- **Model:** Fixed a bug where ``Model::chunk()`` ran an unnecessary extra database query at the end of iteration. ``chunk()`` now also throws ``InvalidArgumentException`` when called with a non-positive chunk size. - **Session:** Fixed a bug in ``MemcachedHandler`` where the constructor incorrectly threw an exception when ``savePath`` was not empty. - **Toolbar:** Fixed a bug where the standalone toolbar page loaded from ``?debugbar_time=...`` was not interactive. - **Toolbar:** Fixed a bug in the Routes panel where only the first route parameter was converted to an input field on hover. From c127bffe41a69940776fce14e0d92ab985425dca Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Fri, 20 Feb 2026 22:19:13 +0800 Subject: [PATCH 31/75] test: fix determinism of `CacheTest` (#9967) --- tests/system/Router/Attributes/CacheTest.php | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/system/Router/Attributes/CacheTest.php b/tests/system/Router/Attributes/CacheTest.php index 2cd155aa22de..1570c9658938 100644 --- a/tests/system/Router/Attributes/CacheTest.php +++ b/tests/system/Router/Attributes/CacheTest.php @@ -22,6 +22,7 @@ use CodeIgniter\Test\Mock\MockAppConfig; use Config\Services; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\MockObject\MockObject; /** * @internal @@ -33,9 +34,8 @@ protected function setUp(): void { parent::setUp(); - // Clear cache before each test cache()->clean(); - + Services::resetSingle('response'); Time::setTestNow('2026-01-10 12:00:00'); } @@ -43,6 +43,8 @@ protected function tearDown(): void { parent::tearDown(); + cache()->clean(); + Services::resetSingle('response'); Time::setTestNow(); } @@ -215,11 +217,17 @@ private function createMockRequest(string $method, string $path, string $query = $request = $this->getMockBuilder(IncomingRequest::class) ->setConstructorArgs([$config, $uri, null, $userAgent]) - ->onlyMethods(['isCLI']) + ->onlyMethods(['isCLI', 'withMethod', 'getMethod']) ->getMock(); $request->method('isCLI')->willReturn(false); - $request->setMethod($method); + $request->method('withMethod')->willReturnCallback( + static function (string $method) use ($request): MockObject&IncomingRequest { + $request->method('getMethod')->willReturn($method); + + return $request; + }, + ); - return $request; + return $request->withMethod($method); } } From a87043ba53e2a23d0af2440a12fe9b6d1bf899bb Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 21 Feb 2026 18:07:28 +0800 Subject: [PATCH 32/75] refactor: fix `Security` test fail by itself (#9969) --- system/Security/Security.php | 138 ++++++++++++++--------- tests/system/Security/SecurityTest.php | 148 ++++++++++++------------- 2 files changed, 157 insertions(+), 129 deletions(-) diff --git a/system/Security/Security.php b/system/Security/Security.php index 5e7d0bfeb679..873fee7469a8 100644 --- a/system/Security/Security.php +++ b/system/Security/Security.php @@ -18,7 +18,6 @@ use CodeIgniter\Exceptions\LogicException; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\Method; -use CodeIgniter\HTTP\Request; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\I18n\Time; use CodeIgniter\Security\Exceptions\SecurityException; @@ -26,6 +25,7 @@ use Config\Cookie as CookieConfig; use Config\Security as SecurityConfig; use ErrorException; +use JsonException; use SensitiveParameter; /** @@ -233,32 +233,27 @@ private function configureCookie(CookieConfig $cookie): void Cookie::setDefaults($cookie); } - /** - * CSRF verification. - * - * @return $this - * - * @throws SecurityException - */ public function verify(RequestInterface $request) { - // Protects POST, PUT, DELETE, PATCH - $method = $request->getMethod(); - $methodsToProtect = [Method::POST, Method::PUT, Method::DELETE, Method::PATCH]; - if (! in_array($method, $methodsToProtect, true)) { + $method = $request->getMethod(); + + // Protect POST, PUT, DELETE, PATCH requests only + if (! in_array($method, [Method::POST, Method::PUT, Method::DELETE, Method::PATCH], true)) { return $this; } + assert($request instanceof IncomingRequest); + $postedToken = $this->getPostedToken($request); try { - $token = ($postedToken !== null && $this->config->tokenRandomize) - ? $this->derandomize($postedToken) : $postedToken; + $token = $postedToken !== null && $this->config->tokenRandomize + ? $this->derandomize($postedToken) + : $postedToken; } catch (InvalidArgumentException) { $token = null; } - // Do the tokens match? if (! isset($token, $this->hash) || ! hash_equals($this->hash, $token)) { throw SecurityException::forDisallowedAction(); } @@ -277,66 +272,107 @@ public function verify(RequestInterface $request) /** * Remove token in POST or JSON request data */ - private function removeTokenInRequest(RequestInterface $request): void + private function removeTokenInRequest(IncomingRequest $request): void { - assert($request instanceof Request); - $superglobals = service('superglobals'); - if ($superglobals->post($this->config->tokenName) !== null) { - // We kill this since we're done and we don't want to pollute the POST array. - $superglobals->unsetPost($this->config->tokenName); + $tokenName = $this->config->tokenName; + + // If the token is found in POST data, we can safely remove it. + if (is_string($superglobals->post($tokenName))) { + $superglobals->unsetPost($tokenName); $request->setGlobal('post', $superglobals->getPostArray()); - } else { - $body = $request->getBody() ?? ''; - $json = json_decode($body); - if ($json !== null && json_last_error() === JSON_ERROR_NONE) { - // We kill this since we're done and we don't want to pollute the JSON data. - unset($json->{$this->config->tokenName}); - $request->setBody(json_encode($json)); - } else { - parse_str($body, $parsed); - // We kill this since we're done and we don't want to pollute the BODY data. - unset($parsed[$this->config->tokenName]); - $request->setBody(http_build_query($parsed)); - } + + return; + } + + $body = $request->getBody() ?? ''; + + if ($body === '') { + return; + } + + // If the token is found in JSON data, we can safely remove it. + try { + $json = json_decode($body, flags: JSON_THROW_ON_ERROR); + } catch (JsonException) { + $json = null; } + + if (is_object($json) && property_exists($json, $tokenName)) { + unset($json->{$tokenName}); + $request->setBody(json_encode($json)); + + return; + } + + // If the token is found in form-encoded data, we can safely remove it. + parse_str($body, $result); + unset($result[$tokenName]); + $request->setBody(http_build_query($result)); } - private function getPostedToken(RequestInterface $request): ?string + private function getPostedToken(IncomingRequest $request): ?string { - assert($request instanceof IncomingRequest); + $tokenName = $this->config->tokenName; + $headerName = $this->config->headerName; - // Does the token exist in POST, HEADER or optionally php:://input - json data or PUT, DELETE, PATCH - raw data. + // 1. Check POST data first. + $token = $request->getPost($tokenName); - if ($tokenValue = $request->getPost($this->config->tokenName)) { - return is_string($tokenValue) ? $tokenValue : null; + if ($this->isNonEmptyTokenString($token)) { + return $token; } - if ($request->hasHeader($this->config->headerName)) { - $tokenValue = $request->header($this->config->headerName)->getValue(); + // 2. Check header data next. + if ($request->hasHeader($headerName)) { + $token = $request->header($headerName)->getValue(); - return (is_string($tokenValue) && $tokenValue !== '') ? $tokenValue : null; + if ($this->isNonEmptyTokenString($token)) { + return $token; + } } - $body = (string) $request->getBody(); + // 3. Finally, check the raw input data for JSON or form-encoded data. + $body = $request->getBody() ?? ''; - if ($body !== '') { - $json = json_decode($body); - if ($json !== null && json_last_error() === JSON_ERROR_NONE) { - $tokenValue = $json->{$this->config->tokenName} ?? null; + if ($body === '') { + return null; + } - return is_string($tokenValue) ? $tokenValue : null; + // 3a. Check if a JSON payload exists and contains the token. + try { + $json = json_decode($body, flags: JSON_THROW_ON_ERROR); + } catch (JsonException) { + $json = null; + } + + if (is_object($json) && property_exists($json, $tokenName)) { + $token = $json->{$tokenName}; + + if ($this->isNonEmptyTokenString($token)) { + return $token; } + } - parse_str($body, $parsed); - $tokenValue = $parsed[$this->config->tokenName] ?? null; + // 3b. Check if form-encoded data exists and contains the token. + parse_str($body, $result); + $token = $result[$tokenName] ?? null; - return is_string($tokenValue) ? $tokenValue : null; + if ($this->isNonEmptyTokenString($token)) { + return $token; } return null; } + /** + * @phpstan-assert-if-true non-empty-string $token + */ + private function isNonEmptyTokenString(mixed $token): bool + { + return is_string($token) && $token !== ''; + } + /** * Returns the CSRF Token. */ diff --git a/tests/system/Security/SecurityTest.php b/tests/system/Security/SecurityTest.php index 8c2a46760810..90f2139b2ccd 100644 --- a/tests/system/Security/SecurityTest.php +++ b/tests/system/Security/SecurityTest.php @@ -16,7 +16,6 @@ use CodeIgniter\Config\Factories; use CodeIgniter\Config\Services; use CodeIgniter\HTTP\IncomingRequest; -use CodeIgniter\HTTP\Request; use CodeIgniter\HTTP\SiteURI; use CodeIgniter\HTTP\UserAgent; use CodeIgniter\Security\Exceptions\SecurityException; @@ -36,13 +35,17 @@ #[Group('Others')] final class SecurityTest extends CIUnitTestCase { + private const CORRECT_CSRF_HASH = '8b9218a55906f9dcc1dc263dce7f005a'; + private const INVALID_CSRF_HASH = '8b9218a55906f9dcc1dc263dce7f005b'; + protected function setUp(): void { parent::setUp(); $this->resetServices(); - Services::injectMock('superglobals', new Superglobals(null, null, null, [])); + // Ensure that POST and COOKIE superglobals are reset for each test to prevent cross-test pollution. + Services::injectMock('superglobals', new Superglobals(post: [], cookie: [])); } private static function createMockSecurity(SecurityConfig $config = new SecurityConfig()): MockSecurity @@ -54,12 +57,7 @@ private static function createIncomingRequest(): IncomingRequest { $config = new MockAppConfig(); - return new IncomingRequest( - $config, - new SiteURI($config), - null, - new UserAgent(), - ); + return new IncomingRequest($config, new SiteURI($config), null, new UserAgent()); } public function testBasicConfigIsSaved(): void @@ -74,47 +72,44 @@ public function testBasicConfigIsSaved(): void public function testHashIsReadFromCookie(): void { - service('superglobals')->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a'); + service('superglobals')->setCookie('csrf_cookie_name', self::CORRECT_CSRF_HASH); $security = $this->createMockSecurity(); - $this->assertSame( - '8b9218a55906f9dcc1dc263dce7f005a', - $security->getHash(), - ); + $this->assertSame(self::CORRECT_CSRF_HASH, $security->getHash()); } - public function testGetHashSetsCookieWhenGETWithoutCSRFCookie(): void + public function testGetHashSetsCookieWhenGetWithoutCsrfCookie(): void { $security = $this->createMockSecurity(); service('superglobals')->setServer('REQUEST_METHOD', 'GET'); - $security->verify(new Request(new MockAppConfig())); + $security->verify($this->createIncomingRequest()); $cookie = service('response')->getCookie('csrf_cookie_name'); $this->assertSame($security->getHash(), $cookie->getValue()); } - public function testGetHashReturnsCSRFCookieWhenGETWithCSRFCookie(): void + public function testGetHashReturnsCsrfCookieWhenGetWithCsrfCookie(): void { service('superglobals') ->setServer('REQUEST_METHOD', 'GET') - ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a'); + ->setCookie('csrf_cookie_name', self::CORRECT_CSRF_HASH); $security = $this->createMockSecurity(); - $security->verify(new Request(new MockAppConfig())); + $security->verify($this->createIncomingRequest()); - $this->assertSame('8b9218a55906f9dcc1dc263dce7f005a', $security->getHash()); + $this->assertSame(self::CORRECT_CSRF_HASH, $security->getHash()); } - public function testCSRFVerifyPostThrowsExceptionOnNoMatch(): void + public function testCsrfVerifyPostThrowsExceptionOnNoMatch(): void { service('superglobals') ->setServer('REQUEST_METHOD', 'POST') - ->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005a') - ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005b'); + ->setPost('csrf_test_name', self::CORRECT_CSRF_HASH) + ->setCookie('csrf_cookie_name', self::INVALID_CSRF_HASH); $security = $this->createMockSecurity(); $request = $this->createIncomingRequest(); @@ -123,13 +118,13 @@ public function testCSRFVerifyPostThrowsExceptionOnNoMatch(): void $security->verify($request); } - public function testCSRFVerifyPostReturnsSelfOnMatch(): void + public function testCsrfVerifyPostReturnsSelfOnMatch(): void { service('superglobals') ->setServer('REQUEST_METHOD', 'POST') ->setPost('foo', 'bar') - ->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005a') - ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a'); + ->setPost('csrf_test_name', self::CORRECT_CSRF_HASH) + ->setCookie('csrf_cookie_name', self::CORRECT_CSRF_HASH); $security = $this->createMockSecurity(); $request = $this->createIncomingRequest(); @@ -140,67 +135,67 @@ public function testCSRFVerifyPostReturnsSelfOnMatch(): void $this->assertCount(1, service('superglobals')->getPostArray()); } - public function testCSRFVerifyHeaderThrowsExceptionOnNoMatch(): void + public function testCsrfVerifyHeaderThrowsExceptionOnNoMatch(): void { service('superglobals') ->setServer('REQUEST_METHOD', 'POST') - ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005b'); + ->setCookie('csrf_cookie_name', self::INVALID_CSRF_HASH); $security = $this->createMockSecurity(); $request = $this->createIncomingRequest(); - $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005a'); + $request->setHeader('X-CSRF-TOKEN', self::CORRECT_CSRF_HASH); $this->expectException(SecurityException::class); $security->verify($request); } - public function testCSRFVerifyHeaderReturnsSelfOnMatch(): void + public function testCsrfVerifyHeaderReturnsSelfOnMatch(): void { service('superglobals') ->setServer('REQUEST_METHOD', 'POST') ->setPost('foo', 'bar') - ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a'); + ->setCookie('csrf_cookie_name', self::CORRECT_CSRF_HASH); $security = $this->createMockSecurity(); $request = $this->createIncomingRequest(); - $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005a'); + $request->setHeader('X-CSRF-TOKEN', self::CORRECT_CSRF_HASH); $this->assertInstanceOf(Security::class, $security->verify($request)); $this->assertLogged('info', 'CSRF token verified.'); - $this->assertCount(1, service('superglobals')->getPostArray()); + $this->assertSame(['foo' => 'bar'], service('superglobals')->getPostArray()); } - public function testCSRFVerifyJsonThrowsExceptionOnNoMatch(): void + public function testCsrfVerifyJsonThrowsExceptionOnNoMatch(): void { service('superglobals') ->setServer('REQUEST_METHOD', 'POST') - ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005b'); + ->setCookie('csrf_cookie_name', self::INVALID_CSRF_HASH); $security = $this->createMockSecurity(); $request = $this->createIncomingRequest(); $request->setBody( - '{"csrf_test_name":"8b9218a55906f9dcc1dc263dce7f005a"}', + '{"csrf_test_name":"' . self::CORRECT_CSRF_HASH . '"}', ); $this->expectException(SecurityException::class); $security->verify($request); } - public function testCSRFVerifyJsonReturnsSelfOnMatch(): void + public function testCsrfVerifyJsonReturnsSelfOnMatch(): void { service('superglobals') ->setServer('REQUEST_METHOD', 'POST') - ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a'); + ->setCookie('csrf_cookie_name', self::CORRECT_CSRF_HASH); $security = $this->createMockSecurity(); $request = $this->createIncomingRequest(); $request->setBody( - '{"csrf_test_name":"8b9218a55906f9dcc1dc263dce7f005a","foo":"bar"}', + '{"csrf_test_name":"' . self::CORRECT_CSRF_HASH . '","foo":"bar"}', ); $this->assertInstanceOf(Security::class, $security->verify($request)); @@ -209,34 +204,34 @@ public function testCSRFVerifyJsonReturnsSelfOnMatch(): void $this->assertSame('{"foo":"bar"}', $request->getBody()); } - public function testCSRFVerifyPutBodyThrowsExceptionOnNoMatch(): void + public function testCsrfVerifyPutBodyThrowsExceptionOnNoMatch(): void { service('superglobals') ->setServer('REQUEST_METHOD', 'PUT') - ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005b'); + ->setCookie('csrf_cookie_name', self::INVALID_CSRF_HASH); $security = $this->createMockSecurity(); $request = $this->createIncomingRequest(); $request->setBody( - 'csrf_test_name=8b9218a55906f9dcc1dc263dce7f005a', + 'csrf_test_name=' . self::CORRECT_CSRF_HASH, ); $this->expectException(SecurityException::class); $security->verify($request); } - public function testCSRFVerifyPutBodyReturnsSelfOnMatch(): void + public function testCsrfVerifyPutBodyReturnsSelfOnMatch(): void { service('superglobals') ->setServer('REQUEST_METHOD', 'PUT') - ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a'); + ->setCookie('csrf_cookie_name', self::CORRECT_CSRF_HASH); $security = $this->createMockSecurity(); $request = $this->createIncomingRequest(); $request->setBody( - 'csrf_test_name=8b9218a55906f9dcc1dc263dce7f005a&foo=bar', + 'csrf_test_name=' . self::CORRECT_CSRF_HASH . '&foo=bar', ); $this->assertInstanceOf(Security::class, $security->verify($request)); @@ -258,8 +253,8 @@ public function testRegenerateWithFalseSecurityRegenerateProperty(): void { service('superglobals') ->setServer('REQUEST_METHOD', 'POST') - ->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005a') - ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a'); + ->setPost('csrf_test_name', self::CORRECT_CSRF_HASH) + ->setCookie('csrf_cookie_name', self::CORRECT_CSRF_HASH); $config = new SecurityConfig(); $config->regenerate = false; @@ -279,8 +274,8 @@ public function testRegenerateWithFalseSecurityRegeneratePropertyManually(): voi { service('superglobals') ->setServer('REQUEST_METHOD', 'POST') - ->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005a') - ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a'); + ->setPost('csrf_test_name', self::CORRECT_CSRF_HASH) + ->setCookie('csrf_cookie_name', self::CORRECT_CSRF_HASH); $config = new SecurityConfig(); $config->regenerate = false; @@ -301,8 +296,8 @@ public function testRegenerateWithTrueSecurityRegenerateProperty(): void { service('superglobals') ->setServer('REQUEST_METHOD', 'POST') - ->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005a') - ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a'); + ->setPost('csrf_test_name', self::CORRECT_CSRF_HASH) + ->setCookie('csrf_cookie_name', self::CORRECT_CSRF_HASH); $config = new SecurityConfig(); $config->regenerate = true; @@ -331,67 +326,64 @@ public function testGetters(): void public function testGetPostedTokenReturnsTokenFromPost(): void { - service('superglobals')->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005a'); + service('superglobals')->setPost('csrf_test_name', self::CORRECT_CSRF_HASH); $request = $this->createIncomingRequest(); $method = self::getPrivateMethodInvoker($this->createMockSecurity(), 'getPostedToken'); - $this->assertSame('8b9218a55906f9dcc1dc263dce7f005a', $method($request)); + $this->assertSame(self::CORRECT_CSRF_HASH, $method($request)); } public function testGetPostedTokenReturnsTokenFromHeader(): void { - $request = $this->createIncomingRequest()->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005a'); + $request = $this->createIncomingRequest()->setHeader('X-CSRF-TOKEN', self::CORRECT_CSRF_HASH); $method = self::getPrivateMethodInvoker($this->createMockSecurity(), 'getPostedToken'); - $this->assertSame('8b9218a55906f9dcc1dc263dce7f005a', $method($request)); + $this->assertSame(self::CORRECT_CSRF_HASH, $method($request)); } public function testGetPostedTokenReturnsTokenFromJsonBody(): void { - $jsonBody = json_encode(['csrf_test_name' => '8b9218a55906f9dcc1dc263dce7f005a']); + $jsonBody = json_encode(['csrf_test_name' => self::CORRECT_CSRF_HASH]); $request = $this->createIncomingRequest()->setBody($jsonBody); $method = self::getPrivateMethodInvoker($this->createMockSecurity(), 'getPostedToken'); - $this->assertSame('8b9218a55906f9dcc1dc263dce7f005a', $method($request)); + $this->assertSame(self::CORRECT_CSRF_HASH, $method($request)); } public function testGetPostedTokenReturnsTokenFromFormBody(): void { - $formBody = 'csrf_test_name=8b9218a55906f9dcc1dc263dce7f005a'; + $formBody = 'csrf_test_name=' . self::CORRECT_CSRF_HASH; $request = $this->createIncomingRequest()->setBody($formBody); $method = self::getPrivateMethodInvoker($this->createMockSecurity(), 'getPostedToken'); - $this->assertSame('8b9218a55906f9dcc1dc263dce7f005a', $method($request)); + $this->assertSame(self::CORRECT_CSRF_HASH, $method($request)); } #[DataProvider('provideGetPostedTokenReturnsNullForInvalidInputs')] - public function testGetPostedTokenReturnsNullForInvalidInputs(string $case, IncomingRequest $request): void + public function testGetPostedTokenReturnsNullForInvalidInputs(IncomingRequest $request): void { - $method = self::getPrivateMethodInvoker($this->createMockSecurity(), 'getPostedToken'); + $getPostedToken = self::getPrivateMethodInvoker($this->createMockSecurity(), 'getPostedToken'); - $this->assertNull( - $method($request), - sprintf('Failed asserting that %s returns null on invalid input.', $case), - ); + $this->assertNull($getPostedToken($request)); } /** - * @return iterable + * @return iterable */ public static function provideGetPostedTokenReturnsNullForInvalidInputs(): iterable { - $testCases = [ - 'empty_post' => self::createIncomingRequest(), - 'invalid_post_data' => self::createIncomingRequest()->setGlobal('post', ['csrf_test_name' => ['invalid' => 'data']]), - 'empty_header' => self::createIncomingRequest()->setHeader('X-CSRF-TOKEN', ''), - 'invalid_json_data' => self::createIncomingRequest()->setBody(json_encode(['csrf_test_name' => ['invalid' => 'data']])), - 'invalid_json' => self::createIncomingRequest()->setBody('{invalid json}'), - 'missing_token_in_body' => self::createIncomingRequest()->setBody('other=value&another=test'), - 'invalid_form_data' => self::createIncomingRequest()->setBody('csrf_test_name[]=invalid'), - ]; - - foreach ($testCases as $case => $request) { - yield $case => [$case, $request]; - } + yield 'empty_post' => [self::createIncomingRequest()]; + + yield 'invalid_post_data' => [self::createIncomingRequest()->setGlobal('post', ['csrf_test_name' => ['invalid' => 'data']])]; + + yield 'empty_header' => [self::createIncomingRequest()->setHeader('X-CSRF-TOKEN', '')]; + + yield 'invalid_json_data' => [self::createIncomingRequest()->setBody(json_encode(['csrf_test_name' => ['invalid' => 'data']]))]; + + yield 'invalid_json' => [self::createIncomingRequest()->setBody('{invalid json}')]; + + yield 'missing_token_in_body' => [self::createIncomingRequest()->setBody('other=value&another=test')]; + + yield 'invalid_form_data' => [self::createIncomingRequest()->setBody('csrf_test_name[]=invalid')]; } } From c38abf9c92dcd46c1d7849585824edbf70c748d3 Mon Sep 17 00:00:00 2001 From: Vansh Patel Date: Sat, 21 Feb 2026 22:42:08 +0530 Subject: [PATCH 33/75] chore: Update PR template next minor version (#9971) * Updating the next minor version in template * Remove the example entirely. --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e39d50b0b187..38f7db70cc60 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,7 +7,7 @@ Each pull request should address a single issue and have a meaningful title. - If a pull request fixes an issue, reference the issue with a suitable keyword (e.g., Fixes ). - Your branch name and the target name should be different. - All bug fixes should be sent to the __"develop"__ branch, this is where the next bug fix version will be developed. -- PRs with any enhancement should be sent to the next minor version branch, e.g. __"4.7"__ +- PRs with any enhancement should be sent to the next minor version branch. --> **Description** From fc44bb3132fbbf44cac46edc52e63a8e2fffeb9d Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Sun, 22 Feb 2026 12:15:54 +0100 Subject: [PATCH 34/75] refactor: make random-order API tests deterministic (#9983) --- tests/system/API/ResponseTraitTest.php | 19 ++++--- tests/system/API/TransformerTest.php | 45 ++++++++++++++++ .../function.alreadyNarrowedType.neon | 4 +- .../function.impossibleType.neon | 4 +- utils/phpstan-baseline/loader.neon | 2 +- .../missingType.iterableValue.neon | 52 +------------------ 6 files changed, 64 insertions(+), 62 deletions(-) diff --git a/tests/system/API/ResponseTraitTest.php b/tests/system/API/ResponseTraitTest.php index 432d254abd40..f5d33491a655 100644 --- a/tests/system/API/ResponseTraitTest.php +++ b/tests/system/API/ResponseTraitTest.php @@ -53,9 +53,17 @@ protected function setUp(): void { parent::setUp(); + Services::superglobals()->setGetArray([]); $this->formatter = new JSONFormatter(); } + protected function tearDown(): void + { + Services::superglobals()->setGetArray([]); + + parent::tearDown(); + } + private function createAppConfig(): App { $config = new App(); @@ -826,7 +834,7 @@ public function testPaginateWithPageParameter(): void // Create controller with page=2 in query string $controller = $this->makeController('/api/items?page=2'); - Services::superglobals()->setGet('page', '2'); + $this->request->setGlobal('get', ['page' => '2']); $this->invoke($controller, 'paginate', [$model, 20]); @@ -847,8 +855,8 @@ public function testPaginateLinksStructure(): void $model = $this->createMockModelWithPager($data, 2, 20, 100, 5); - Services::superglobals()->setGet('page', '2'); $controller = $this->makeController('/api/items?page=2'); + $this->request->setGlobal('get', ['page' => '2']); $this->invoke($controller, 'paginate', [$model, 20]); @@ -893,8 +901,8 @@ public function testPaginateLastPageNoNextLink(): void $model = $this->createMockModelWithPager($data, 3, 20, 50, 3); - Services::superglobals()->setGet('page', '3'); $controller = $this->makeController('/api/items?page=3'); + $this->request->setGlobal('get', ['page' => '3']); $this->invoke($controller, 'paginate', [$model, 20]); @@ -912,8 +920,8 @@ public function testPaginateLinkHeader(): void $model = $this->createMockModelWithPager($data, 2, 20, 100, 5); - Services::superglobals()->setGet('page', '2'); $controller = $this->makeController('/api/items?page=2'); + $this->request->setGlobal('get', ['page' => '2']); $this->invoke($controller, 'paginate', [$model, 20]); @@ -1018,9 +1026,8 @@ public function testPaginatePreservesOtherQueryParameters(): void $model = $this->createMockModelWithPager($data, 1, 20, 50, 3); - Services::superglobals()->setGet('filter', 'active'); - Services::superglobals()->setGet('sort', 'name'); $controller = $this->makeController('/api/items?filter=active&sort=name'); + $this->request->setGlobal('get', ['filter' => 'active', 'sort' => 'name']); $this->invoke($controller, 'paginate', [$model, 20]); diff --git a/tests/system/API/TransformerTest.php b/tests/system/API/TransformerTest.php index 9d780d7b437f..8c6f50857d23 100644 --- a/tests/system/API/TransformerTest.php +++ b/tests/system/API/TransformerTest.php @@ -19,6 +19,7 @@ use CodeIgniter\HTTP\UserAgent; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockAppConfig; +use Config\Services; use PHPUnit\Framework\Attributes\Group; use stdClass; @@ -28,6 +29,20 @@ #[Group('Others')] final class TransformerTest extends CIUnitTestCase { + protected function setUp(): void + { + parent::setUp(); + + Services::superglobals()->setGetArray([]); + } + + protected function tearDown(): void + { + Services::superglobals()->setGetArray([]); + + parent::tearDown(); + } + private function createMockRequest(string $query = ''): IncomingRequest { $config = new MockAppConfig(); @@ -318,6 +333,9 @@ public function toArray(mixed $resource): array return $resource; } + /** + * @return list + */ protected function includePosts(): array { return [['id' => 1, 'title' => 'Post 1']]; @@ -341,6 +359,9 @@ public function toArray(mixed $resource): array return $resource; } + /** + * @return list + */ protected function includePosts(): array { return [['id' => 1, 'title' => 'Post 1']]; @@ -367,11 +388,17 @@ public function toArray(mixed $resource): array return $resource; } + /** + * @return list + */ protected function includePosts(): array { return [['id' => 1, 'title' => 'Post 1']]; } + /** + * @return list + */ protected function includeComments(): array { return [['id' => 1, 'text' => 'Comment 1']]; @@ -399,6 +426,9 @@ public function toArray(mixed $resource): array return $resource; } + /** + * @return list + */ protected function includePosts(): array { return [['id' => 1, 'title' => 'Post 1']]; @@ -427,6 +457,9 @@ protected function getAllowedIncludes(): array return []; } + /** + * @return list + */ protected function includePosts(): array { return [['id' => 1, 'title' => 'Post 1']]; @@ -450,6 +483,9 @@ public function toArray(mixed $resource): array return $resource; } + /** + * @return list + */ protected function includePosts(): array { return [['id' => 1, 'title' => 'Post 1']]; @@ -502,6 +538,9 @@ public function toArray(mixed $resource): array return $resource; } + /** + * @return list + */ protected function includePosts(): array { return [['id' => 1, 'title' => 'Post 1']]; @@ -561,6 +600,9 @@ public function toArray(mixed $resource): array return ['id' => $resource['id'], 'name' => $resource['name']]; } + /** + * @return list + */ protected function includePosts(): array { return [['id' => 1, 'title' => 'Post 1']]; @@ -584,6 +626,9 @@ public function toArray(mixed $resource): array return ['id' => $resource['id'], 'name' => $resource['name']]; } + /** + * @return list + */ protected function includePosts(): array { return [['id' => 1, 'title' => 'Post 1']]; diff --git a/utils/phpstan-baseline/function.alreadyNarrowedType.neon b/utils/phpstan-baseline/function.alreadyNarrowedType.neon index e39d8b2c49fc..3ca000aeecb5 100644 --- a/utils/phpstan-baseline/function.alreadyNarrowedType.neon +++ b/utils/phpstan-baseline/function.alreadyNarrowedType.neon @@ -3,11 +3,11 @@ parameters: ignoreErrors: - - message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:190\) and ''stringAsHtml'' will always evaluate to true\.$#' + message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:198\) and ''stringAsHtml'' will always evaluate to true\.$#' count: 1 path: ../../tests/system/API/ResponseTraitTest.php - - message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:310\) and ''stringAsHtml'' will always evaluate to true\.$#' + message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:318\) and ''stringAsHtml'' will always evaluate to true\.$#' count: 1 path: ../../tests/system/API/ResponseTraitTest.php diff --git a/utils/phpstan-baseline/function.impossibleType.neon b/utils/phpstan-baseline/function.impossibleType.neon index 43296fa89d08..f13d9a4010c2 100644 --- a/utils/phpstan-baseline/function.impossibleType.neon +++ b/utils/phpstan-baseline/function.impossibleType.neon @@ -8,11 +8,11 @@ parameters: path: ../../system/Debug/ExceptionHandler.php - - message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:132\) and ''stringAsHtml'' will always evaluate to false\.$#' + message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:140\) and ''stringAsHtml'' will always evaluate to false\.$#' count: 1 path: ../../tests/system/API/ResponseTraitTest.php - - message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:630\) and ''stringAsHtml'' will always evaluate to false\.$#' + message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:638\) and ''stringAsHtml'' will always evaluate to false\.$#' count: 1 path: ../../tests/system/API/ResponseTraitTest.php diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 7378ca423744..b277717823bf 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2126 errors +# total 2116 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index 40f5697844e5..94f900209bdd 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -1,4 +1,4 @@ -# total 1269 errors +# total 1259 errors parameters: ignoreErrors: @@ -4742,56 +4742,6 @@ parameters: count: 1 path: ../../system/View/Parser.php - - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:315\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../tests/system/API/TransformerTest.php - - - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:338\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../tests/system/API/TransformerTest.php - - - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:364\:\:includeComments\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../tests/system/API/TransformerTest.php - - - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:364\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../tests/system/API/TransformerTest.php - - - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:396\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../tests/system/API/TransformerTest.php - - - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:419\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../tests/system/API/TransformerTest.php - - - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:447\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../tests/system/API/TransformerTest.php - - - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:499\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../tests/system/API/TransformerTest.php - - - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:558\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../tests/system/API/TransformerTest.php - - - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:581\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../tests/system/API/TransformerTest.php - - message: '#^Method CodeIgniter\\AutoReview\\ComposerJsonTest\:\:checkConfig\(\) has parameter \$fromComponent with no value type specified in iterable type array\.$#' count: 1 From a02639a9ae012005509e9efe098e3a2481b77d65 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Mon, 23 Feb 2026 11:44:21 +0800 Subject: [PATCH 35/75] chore: update GitHub actions workflows (#9988) * update workflows to use `$GITHUB_OUTPUT` * remove inexistent matrix key * Update phpunit workflow in starter --- .github/workflows/deploy-distributables.yml | 11 ++++++----- .github/workflows/reusable-coveralls.yml | 5 +++-- .github/workflows/reusable-phpunit-test.yml | 18 ++++++++++-------- .../reusable-serviceless-phpunit-test.yml | 18 ++++++++++-------- .github/workflows/test-coding-standards.yml | 5 +++-- .github/workflows/test-deptrac.yml | 5 +++-- .github/workflows/test-phpstan.yml | 5 +++-- .github/workflows/test-phpunit.yml | 4 ---- .github/workflows/test-psalm.yml | 5 +++-- .github/workflows/test-rector.yml | 5 +++-- admin/starter/.github/workflows/phpunit.yml | 15 +++++++-------- 11 files changed, 51 insertions(+), 45 deletions(-) diff --git a/.github/workflows/deploy-distributables.yml b/.github/workflows/deploy-distributables.yml index e1a63e792ffa..742dc32ec603 100644 --- a/.github/workflows/deploy-distributables.yml +++ b/.github/workflows/deploy-distributables.yml @@ -21,16 +21,17 @@ jobs: fetch-depth: 0 # fetch all tags - name: Get latest version + id: version run: | - echo 'LATEST_VERSION<> $GITHUB_ENV - echo $(git describe --tags --abbrev=0) | sed "s/v//" >> $GITHUB_ENV - echo 'EOF' >> $GITHUB_ENV + echo 'LATEST_VERSION<> $GITHUB_OUTPUT + echo $(git describe --tags --abbrev=0) | sed "s/v//" >> $GITHUB_OUTPUT + echo 'EOF' >> $GITHUB_OUTPUT - name: Search for updated version - if: ${{ env.LATEST_VERSION }} + if: ${{ steps.version.outputs.LATEST_VERSION }} run: | chmod +x ${GITHUB_WORKSPACE}/.github/scripts/validate-version - ${GITHUB_WORKSPACE}/.github/scripts/validate-version ${{ env.LATEST_VERSION }} + ${GITHUB_WORKSPACE}/.github/scripts/validate-version ${{ steps.version.outputs.LATEST_VERSION }} framework: name: Deploy to framework diff --git a/.github/workflows/reusable-coveralls.yml b/.github/workflows/reusable-coveralls.yml index e8af4347f9f6..8c0219edaca5 100644 --- a/.github/workflows/reusable-coveralls.yml +++ b/.github/workflows/reusable-coveralls.yml @@ -39,13 +39,14 @@ jobs: working-directory: build/cov - name: Get composer cache directory + id: composer-cache run: | - echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV + echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies uses: actions/cache@v5 with: - path: ${{ env.COMPOSER_CACHE_FILES_DIR }} + path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ github.job }}-php-${{ inputs.php-version }}-${{ hashFiles('**/composer.*') }} restore-keys: | ${{ github.job }}-php-${{ inputs.php-version }}- diff --git a/.github/workflows/reusable-phpunit-test.yml b/.github/workflows/reusable-phpunit-test.yml index 0a8f37422c58..cc49c4be5892 100644 --- a/.github/workflows/reusable-phpunit-test.yml +++ b/.github/workflows/reusable-phpunit-test.yml @@ -173,14 +173,15 @@ jobs: COVERAGE_DRIVER: ${{ inputs.enable-coverage && 'xdebug' || 'none' }} - name: Setup global environment variables + id: setup-env run: | - echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV - echo "ARTIFACT_NAME=${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}-db-${{ inputs.db-platform || 'none' }}${{ inputs.mysql-version || '' }}" >> $GITHUB_ENV + echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + echo "ARTIFACT_NAME=${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}-db-${{ inputs.db-platform || 'none' }}${{ inputs.mysql-version || '' }}" >> $GITHUB_OUTPUT - name: Cache dependencies uses: actions/cache@v5 with: - path: ${{ env.COMPOSER_CACHE_FILES_DIR }} + path: ${{ steps.setup-env.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}-db-${{ inputs.db-platform || 'none' }}-${{ hashFiles('**/composer.*') }} restore-keys: | ${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}-db-${{ inputs.db-platform || 'none' }}- @@ -202,14 +203,15 @@ jobs: composer update --ansi ${{ inputs.extra-composer-options }} - name: Compute additional PHPUnit options + id: phpunit-options run: | - echo "EXTRA_PHPUNIT_OPTIONS=${{ format('{0} {1} {2}', env.GROUP_OPTION, env.COVERAGE_OPTION, inputs.extra-phpunit-options) }}" >> $GITHUB_ENV + echo "EXTRA_PHPUNIT_OPTIONS=${{ format('{0} {1} {2}', env.GROUP_OPTION, env.COVERAGE_OPTION, inputs.extra-phpunit-options) }}" >> $GITHUB_OUTPUT env: - COVERAGE_OPTION: ${{ inputs.enable-coverage && format('--coverage-php build/cov/coverage-{0}.cov', env.ARTIFACT_NAME) || '--no-coverage' }} + COVERAGE_OPTION: ${{ inputs.enable-coverage && format('--coverage-php build/cov/coverage-{0}.cov', steps.setup-env.outputs.ARTIFACT_NAME) || '--no-coverage' }} GROUP_OPTION: ${{ inputs.group-name && format('--group {0}', inputs.group-name) || '' }} - name: Run tests - run: script -e -c "vendor/bin/phpunit --color=always ${{ env.EXTRA_PHPUNIT_OPTIONS }}" + run: script -e -c "vendor/bin/phpunit --color=always ${{ steps.phpunit-options.outputs.EXTRA_PHPUNIT_OPTIONS }}" env: DB: ${{ inputs.db-platform }} TACHYCARDIA_MONITOR_GA: ${{ inputs.enable-profiling && 'enabled' || '' }} @@ -220,7 +222,7 @@ jobs: if: ${{ inputs.enable-artifact-upload }} uses: actions/upload-artifact@v6 with: - name: ${{ env.ARTIFACT_NAME }} - path: build/cov/coverage-${{ env.ARTIFACT_NAME }}.cov + name: ${{ steps.setup-env.outputs.ARTIFACT_NAME }} + path: build/cov/coverage-${{ steps.setup-env.outputs.ARTIFACT_NAME }}.cov if-no-files-found: error retention-days: 1 diff --git a/.github/workflows/reusable-serviceless-phpunit-test.yml b/.github/workflows/reusable-serviceless-phpunit-test.yml index bbca4fe2acc8..caa1469fff81 100644 --- a/.github/workflows/reusable-serviceless-phpunit-test.yml +++ b/.github/workflows/reusable-serviceless-phpunit-test.yml @@ -85,14 +85,15 @@ jobs: COVERAGE_DRIVER: ${{ inputs.enable-coverage && 'xdebug' || 'none' }} - name: Setup global environment variables + id: setup-env run: | - echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV - echo "ARTIFACT_NAME=${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}" >> $GITHUB_ENV + echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + echo "ARTIFACT_NAME=${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}" >> $GITHUB_OUTPUT - name: Cache Composer dependencies uses: actions/cache@v5 with: - path: ${{ env.COMPOSER_CACHE_FILES_DIR }} + path: ${{ steps.setup-env.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}-${{ hashFiles('**/composer.*') }} restore-keys: | ${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}- @@ -113,14 +114,15 @@ jobs: composer update --ansi ${{ inputs.extra-composer-options }} - name: Compute additional PHPUnit options + id: phpunit-options run: | - echo "EXTRA_PHPUNIT_OPTIONS=${{ format('{0} {1} {2}', env.GROUP_OPTION, env.COVERAGE_OPTION, inputs.extra-phpunit-options) }}" >> $GITHUB_ENV + echo "EXTRA_PHPUNIT_OPTIONS=${{ format('{0} {1} {2}', env.GROUP_OPTION, env.COVERAGE_OPTION, inputs.extra-phpunit-options) }}" >> $GITHUB_OUTPUT env: - COVERAGE_OPTION: ${{ inputs.enable-coverage && format('--coverage-php build/cov/coverage-{0}.cov', env.ARTIFACT_NAME) || '--no-coverage' }} + COVERAGE_OPTION: ${{ inputs.enable-coverage && format('--coverage-php build/cov/coverage-{0}.cov', steps.setup-env.outputs.ARTIFACT_NAME) || '--no-coverage' }} GROUP_OPTION: ${{ inputs.group-name && format('--group {0}', inputs.group-name) || '' }} - name: Run tests - run: script -e -c "vendor/bin/phpunit --color=always ${{ env.EXTRA_PHPUNIT_OPTIONS }}" + run: script -e -c "vendor/bin/phpunit --color=always ${{ steps.phpunit-options.outputs.EXTRA_PHPUNIT_OPTIONS }}" env: TACHYCARDIA_MONITOR_GA: ${{ inputs.enable-profiling && 'enabled' || '' }} TERM: xterm-256color @@ -129,7 +131,7 @@ jobs: if: ${{ inputs.enable-artifact-upload }} uses: actions/upload-artifact@v6 with: - name: ${{ env.ARTIFACT_NAME }} - path: build/cov/coverage-${{ env.ARTIFACT_NAME }}.cov + name: ${{ steps.setup-env.outputs.ARTIFACT_NAME }} + path: build/cov/coverage-${{ steps.setup-env.outputs.ARTIFACT_NAME }}.cov if-no-files-found: error retention-days: 1 diff --git a/.github/workflows/test-coding-standards.yml b/.github/workflows/test-coding-standards.yml index b20ea882772b..aa70a480bfd7 100644 --- a/.github/workflows/test-coding-standards.yml +++ b/.github/workflows/test-coding-standards.yml @@ -51,12 +51,13 @@ jobs: coverage: none - name: Get composer cache directory - run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV + id: composer-cache + run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies uses: actions/cache@v5 with: - path: ${{ env.COMPOSER_CACHE_FILES_DIR }} + path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} restore-keys: | ${{ runner.os }}-${{ matrix.php-version }}- diff --git a/.github/workflows/test-deptrac.yml b/.github/workflows/test-deptrac.yml index b95bb8ec717e..f339230591c0 100644 --- a/.github/workflows/test-deptrac.yml +++ b/.github/workflows/test-deptrac.yml @@ -56,12 +56,13 @@ jobs: run: composer validate --strict - name: Get composer cache directory - run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV + id: composer-cache + run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies uses: actions/cache@v5 with: - path: ${{ env.COMPOSER_CACHE_FILES_DIR }} + path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} restore-keys: ${{ runner.os }}-composer- diff --git a/.github/workflows/test-phpstan.yml b/.github/workflows/test-phpstan.yml index 62dd759b6bbb..2b874792f308 100644 --- a/.github/workflows/test-phpstan.yml +++ b/.github/workflows/test-phpstan.yml @@ -68,12 +68,13 @@ jobs: run: composer validate --strict - name: Get composer cache directory - run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV + id: composer-cache + run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies uses: actions/cache@v5 with: - path: ${{ env.COMPOSER_CACHE_FILES_DIR }} + path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} restore-keys: ${{ runner.os }}-composer- diff --git a/.github/workflows/test-phpunit.yml b/.github/workflows/test-phpunit.yml index d9191cec858d..eba3a2dadd1f 100644 --- a/.github/workflows/test-phpunit.yml +++ b/.github/workflows/test-phpunit.yml @@ -71,7 +71,6 @@ jobs: enable-coverage: ${{ matrix.php-version == needs.coverage-php-version.outputs.version }} enable-profiling: ${{ matrix.php-version == needs.coverage-php-version.outputs.version }} extra-extensions: imagick, redis, memcached - extra-composer-options: ${{ matrix.composer-option }} database-live-tests: name: DatabaseLive @@ -112,7 +111,6 @@ jobs: enable-coverage: ${{ matrix.php-version == needs.coverage-php-version.outputs.version }} enable-profiling: ${{ matrix.php-version == needs.coverage-php-version.outputs.version }} extra-extensions: mysqli, oci8, pgsql, sqlsrv, sqlite3 - extra-composer-options: ${{ matrix.composer-option }} separate-process-tests: name: SeparateProcess @@ -138,7 +136,6 @@ jobs: enable-coverage: true # needs xdebug for assertHeaderEmitted() tests enable-profiling: ${{ matrix.php-version == needs.coverage-php-version.outputs.version }} extra-extensions: mysqli, oci8, pgsql, sqlsrv-5.10.1, sqlite3 - extra-composer-options: ${{ matrix.composer-option }} cache-live-tests: name: CacheLive @@ -165,7 +162,6 @@ jobs: enable-profiling: ${{ matrix.php-version == needs.coverage-php-version.outputs.version }} extra-extensions: redis, memcached, apcu extra-ini-options: apc.enable_cli=1 - extra-composer-options: ${{ matrix.composer-option }} coveralls: name: Upload coverage results to Coveralls diff --git a/.github/workflows/test-psalm.yml b/.github/workflows/test-psalm.yml index c7a85cd275d9..9f956aaeaf0d 100644 --- a/.github/workflows/test-psalm.yml +++ b/.github/workflows/test-psalm.yml @@ -51,12 +51,13 @@ jobs: COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Get composer cache directory - run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV + id: composer-cache + run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies uses: actions/cache@v5 with: - path: ${{ env.COMPOSER_CACHE_FILES_DIR }} + path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ hashFiles('**/composer.lock') }} restore-keys: ${{ runner.os }}-composer- diff --git a/.github/workflows/test-rector.yml b/.github/workflows/test-rector.yml index 964823299a38..6770c6112bc6 100644 --- a/.github/workflows/test-rector.yml +++ b/.github/workflows/test-rector.yml @@ -74,12 +74,13 @@ jobs: run: composer validate --strict - name: Get composer cache directory - run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV + id: composer-cache + run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies uses: actions/cache@v5 with: - path: ${{ env.COMPOSER_CACHE_FILES_DIR }} + path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} restore-keys: ${{ runner.os }}-composer- diff --git a/admin/starter/.github/workflows/phpunit.yml b/admin/starter/.github/workflows/phpunit.yml index fb5e3a2cb7e0..23d883582a8f 100644 --- a/admin/starter/.github/workflows/phpunit.yml +++ b/admin/starter/.github/workflows/phpunit.yml @@ -11,15 +11,14 @@ jobs: strategy: matrix: - php-versions: ['8.2', '8.4'] + php-versions: ['8.2', '8.5'] runs-on: ubuntu-latest - - if: (! contains(github.event.head_commit.message, '[ci skip]')) + if: (! contains(github.event.pull_request.title, '[ci skip]')) steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v6 - name: Setup PHP, with composer and extensions uses: shivammathur/setup-php@v2 @@ -31,17 +30,17 @@ jobs: - name: Get composer cache directory id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" + run: echo "COMPOSER_CACHE_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v2 + uses: actions/cache@v4 with: - path: ${{ steps.composer-cache.outputs.dir }} + path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: ${{ runner.os }}-composer- - name: Install dependencies - run: composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader + run: composer install --no-progress --no-interaction # To prevent rate limiting you may need to supply an OAuth token in Settings > Secrets # env: # https://getcomposer.org/doc/articles/troubleshooting.md#api-rate-limit-and-oauth-tokens From 95477b403ab99b426bec6b665fe6ae57866db720 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Mon, 23 Feb 2026 23:57:36 +0800 Subject: [PATCH 36/75] refactor: fix latest phpstan errors --- system/Model.php | 4 +--- utils/phpstan-baseline/loader.neon | 2 +- utils/phpstan-baseline/return.type.neon | 7 ++++++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/system/Model.php b/system/Model.php index 307d66a52cb6..2c9230a90ca0 100644 --- a/system/Model.php +++ b/system/Model.php @@ -146,9 +146,7 @@ class Model extends BaseModel public function __construct(?ConnectionInterface $db = null, ?ValidationInterface $validation = null) { - /** - * @var BaseConnection|null $db - */ + /** @var BaseConnection $db */ $db ??= Database::connect($this->DBGroup); $this->db = $db; diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index b277717823bf..91d2bdc61339 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2116 errors +# total 2117 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/return.type.neon b/utils/phpstan-baseline/return.type.neon index 40b214c3fce9..b181bcabebd9 100644 --- a/utils/phpstan-baseline/return.type.neon +++ b/utils/phpstan-baseline/return.type.neon @@ -1,7 +1,12 @@ -# total 1 error +# total 2 errors parameters: ignoreErrors: + - + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:cleanClone\(\) should return \$this\(CodeIgniter\\Database\\BaseBuilder\) but returns static\(CodeIgniter\\Database\\BaseBuilder\)\.$#' + count: 1 + path: ../../system/Database/BaseBuilder.php + - message: '#^Method CodeIgniter\\Router\\Router\:\:getRouteAttributes\(\) should return array\{class\: list\, method\: list\\} but returns array\{class\: list\, method\: list\\}\.$#' count: 1 From 888b4018ffc140efc164304fb146620a5fa938ce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:02:17 +0800 Subject: [PATCH 37/75] chore(deps-dev): update rector/rector requirement from 2.3.7 to 2.3.8 (#9994) * chore(deps-dev): update rector/rector requirement from 2.3.7 to 2.3.8 Updates the requirements on [rector/rector](https://github.com/rectorphp/rector) to permit the latest version. - [Release notes](https://github.com/rectorphp/rector/releases) - [Commits](https://github.com/rectorphp/rector/compare/2.3.7...2.3.8) --- updated-dependencies: - dependency-name: rector/rector dependency-version: 2.3.8 dependency-type: direct:development ... Signed-off-by: dependabot[bot] * refactor: run rector --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Abdul Malik Ikhsan --- composer.json | 2 +- system/Cache/Handlers/MemcachedHandler.php | 2 +- system/Cache/Handlers/PredisHandler.php | 2 +- system/Cache/Handlers/RedisHandler.php | 2 +- system/Database/SQLite3/Connection.php | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index e7c4b8cafe77..db5545d4694e 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "phpunit/phpcov": "^9.0.2 || ^10.0", "phpunit/phpunit": "^10.5.16 || ^11.2", "predis/predis": "^3.0", - "rector/rector": "2.3.7", + "rector/rector": "2.3.8", "shipmonk/phpstan-baseline-per-identifier": "^2.0" }, "replace": { diff --git a/system/Cache/Handlers/MemcachedHandler.php b/system/Cache/Handlers/MemcachedHandler.php index 10e2ecae0a96..6d0c8aec4d97 100644 --- a/system/Cache/Handlers/MemcachedHandler.php +++ b/system/Cache/Handlers/MemcachedHandler.php @@ -97,7 +97,7 @@ public function initialize(): void throw new CriticalError('Cache: Not support Memcache(d) extension.'); } } catch (Exception $e) { - throw new CriticalError('Cache: Memcache(d) connection refused (' . $e->getMessage() . ').'); + throw new CriticalError('Cache: Memcache(d) connection refused (' . $e->getMessage() . ').', $e->getCode(), $e); } } diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index 8919c7a1db2b..94e03773de88 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -76,7 +76,7 @@ public function initialize(): void $this->redis = new Client($this->config, ['prefix' => $this->prefix]); $this->redis->time(); } catch (Exception $e) { - throw new CriticalError('Cache: Predis connection refused (' . $e->getMessage() . ').'); + throw new CriticalError('Cache: Predis connection refused (' . $e->getMessage() . ').', $e->getCode(), $e); } } diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php index 98cf33651687..7ab6e392bfbc 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -94,7 +94,7 @@ public function initialize(): void throw new CriticalError('Cache: Redis select database failed.'); } } catch (RedisException $e) { - throw new CriticalError('Cache: RedisException occurred with message (' . $e->getMessage() . ').'); + throw new CriticalError('Cache: RedisException occurred with message (' . $e->getMessage() . ').', $e->getCode(), $e); } } diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php index 3865669a9e2d..9f015b8e9cb6 100644 --- a/system/Database/SQLite3/Connection.php +++ b/system/Database/SQLite3/Connection.php @@ -115,7 +115,7 @@ public function connect(bool $persistent = false) return $sqlite; } catch (Exception $e) { - throw new DatabaseException('SQLite3 error: ' . $e->getMessage()); + throw new DatabaseException('SQLite3 error: ' . $e->getMessage(), $e->getCode(), $e); } } From 9357570b025b77aa278ca723817eb2fdf7788617 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Tue, 24 Feb 2026 03:54:37 +0800 Subject: [PATCH 38/75] chore: bump mssql image to 2025 (#9987) * chore: bump mssql image to 2025 * Add alter DB query before dropping database Co-authored-by: michalsn --------- Co-authored-by: michalsn --- .github/workflows/reusable-phpunit-test.yml | 25 +++++++++++++++++---- .github/workflows/test-phpunit.yml | 2 +- system/Database/SQLSRV/Forge.php | 19 ++++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/.github/workflows/reusable-phpunit-test.yml b/.github/workflows/reusable-phpunit-test.yml index cc49c4be5892..80e3ed3ec926 100644 --- a/.github/workflows/reusable-phpunit-test.yml +++ b/.github/workflows/reusable-phpunit-test.yml @@ -65,7 +65,7 @@ env: jobs: tests: name: ${{ inputs.job-name }} - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 # Service containers cannot be extracted to caller workflows yet services: @@ -97,7 +97,7 @@ jobs: --health-retries=3 mssql: - image: mcr.microsoft.com/mssql/server:2022-latest + image: mcr.microsoft.com/mssql/server:2025-CU2-ubuntu-24.04 env: MSSQL_SA_PASSWORD: 1Secure*Password1 ACCEPT_EULA: Y @@ -140,15 +140,32 @@ jobs: - 11211:11211 steps: + - name: Install mssql-tools on runner + if: ${{ inputs.db-platform == 'SQLSRV' }} + run: | + # Detect Ubuntu version used by the runner (fallback to 24.04) + DISTRO=$(lsb_release -rs 2>/dev/null || echo '24.04') + curl -sSL https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add - + curl -sSL https://packages.microsoft.com/config/ubuntu/${DISTRO}/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list + sudo apt-get update + sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 mssql-tools18 unixodbc-dev + + # Make sqlcmd available to subsequent steps + echo "/opt/mssql-tools18/bin" >> $GITHUB_PATH + - name: Create database for MSSQL Server if: ${{ inputs.db-platform == 'SQLSRV' }} - run: sqlcmd -S 127.0.0.1 -U sa -P 1Secure*Password1 -Q "CREATE DATABASE test COLLATE Latin1_General_100_CS_AS_SC_UTF8" + run: | + sqlcmd -S 127.0.0.1 \ + -U sa -P 1Secure*Password1 \ + -N -C \ + -Q "CREATE DATABASE test COLLATE Latin1_General_100_CS_AS_SC_UTF8" - name: Install latest ImageMagick if: ${{ contains(inputs.extra-extensions, 'imagick') }} run: | sudo apt-get update - sudo apt-get install --reinstall libgs9-common fonts-noto-mono libgs9:amd64 libijs-0.35:amd64 fonts-urw-base35 ghostscript poppler-data libjbig2dec0:amd64 libopenjp2-7:amd64 fonts-droid-fallback fonts-dejavu-core + sudo apt-get install --reinstall fonts-noto-mono libijs-0.35:amd64 fonts-urw-base35 ghostscript poppler-data libjbig2dec0:amd64 libopenjp2-7:amd64 fonts-droid-fallback fonts-dejavu-core sudo apt-get install -y gsfonts libmagickwand-dev imagemagick sudo apt-get install --fix-broken diff --git a/.github/workflows/test-phpunit.yml b/.github/workflows/test-phpunit.yml index eba3a2dadd1f..00dee9b9e43d 100644 --- a/.github/workflows/test-phpunit.yml +++ b/.github/workflows/test-phpunit.yml @@ -41,7 +41,7 @@ jobs: # in the caller workflow are not propagated to the called workflow. coverage-php-version: name: Setup PHP Version for Code Coverage - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 outputs: version: ${{ steps.coverage-php-version.outputs.version }} steps: diff --git a/system/Database/SQLSRV/Forge.php b/system/Database/SQLSRV/Forge.php index b64c25a05100..0622de5e8e81 100644 --- a/system/Database/SQLSRV/Forge.php +++ b/system/Database/SQLSRV/Forge.php @@ -168,6 +168,25 @@ public function createDatabase(string $dbName, bool $ifNotExists = false): bool } } + /** + * {@inheritDoc} + * + * @see https://stackoverflow.com/questions/7469130/cannot-drop-database-because-it-is-currently-in-use + */ + public function dropDatabase(string $dbName): bool + { + try { + $this->db->query(sprintf( + 'ALTER DATABASE %s SET SINGLE_USER WITH ROLLBACK IMMEDIATE', + $this->db->escapeIdentifier($dbName), + )); + } catch (DatabaseException) { + // no-op + } + + return parent::dropDatabase($dbName); + } + /** * CREATE TABLE attributes */ From 3535c7af0e9f0ccb20572851883933f031735e1b Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Tue, 24 Feb 2026 17:51:57 +0100 Subject: [PATCH 39/75] fix: worker mode events cleanup (#9997) --- app/Config/WorkerMode.php | 12 +++++++++ .../Worker/Views/frankenphp-worker.php.tpl | 4 ++- system/Events/Events.php | 14 +++++++--- user_guide_src/source/changelogs/v4.7.1.rst | 9 +++++++ .../source/installation/upgrade_471.rst | 16 +++++++++-- .../source/installation/worker_mode.rst | 27 +++++++++++++++++++ .../source/installation/worker_mode/001.php | 8 ++++++ .../source/installation/worker_mode/002.php | 8 ++++++ 8 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 user_guide_src/source/installation/worker_mode/001.php create mode 100644 user_guide_src/source/installation/worker_mode/002.php diff --git a/app/Config/WorkerMode.php b/app/Config/WorkerMode.php index 1c005f63a67a..fd8b49434332 100644 --- a/app/Config/WorkerMode.php +++ b/app/Config/WorkerMode.php @@ -40,6 +40,18 @@ class WorkerMode 'cache', ]; + /** + * Reset Event Listeners + * + * List of event names whose listeners should be removed between requests. + * Use this if you register event listeners inside other event callbacks + * (rather than at the top level of Config/Events.php), which would cause + * them to accumulate across requests in worker mode. + * + * @var list + */ + public array $resetEventListeners = []; + /** * Force Garbage Collection * diff --git a/system/Commands/Worker/Views/frankenphp-worker.php.tpl b/system/Commands/Worker/Views/frankenphp-worker.php.tpl index 4a3c4ecd90b1..067d9d33dc1f 100644 --- a/system/Commands/Worker/Views/frankenphp-worker.php.tpl +++ b/system/Commands/Worker/Views/frankenphp-worker.php.tpl @@ -122,8 +122,10 @@ while (frankenphp_handle_request($handler)) { // Reset services except persistent ones Services::resetForWorkerMode($workerConfig); + // Reset event listeners + Events::cleanupForWorkerMode($workerConfig->resetEventListeners); + if (CI_DEBUG) { - Events::cleanupForWorkerMode(); Services::toolbar()->reset(); } } diff --git a/system/Events/Events.php b/system/Events/Events.php index 979f531591fe..3cad30995f94 100644 --- a/system/Events/Events.php +++ b/system/Events/Events.php @@ -289,10 +289,18 @@ public static function getPerformanceLogs() * Cleanup performance log and request-specific listeners for worker mode. * * Called at the END of each request to clean up state. + * + * @param list $resetEventListeners Additional event names to reset. */ - public static function cleanupForWorkerMode(): void + public static function cleanupForWorkerMode(array $resetEventListeners = []): void { - static::$performanceLog = []; - static::removeAllListeners('DBQuery'); + if (CI_DEBUG) { + static::$performanceLog = []; + static::removeAllListeners('DBQuery'); + } + + foreach ($resetEventListeners as $event) { + static::removeAllListeners($event); + } } } diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst index 1fa19241910f..4eeceecdb162 100644 --- a/user_guide_src/source/changelogs/v4.7.1.rst +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -24,6 +24,15 @@ Message Changes Changes ******* +Events +====== + +- **Worker Mode:** :php:func:`Events::cleanupForWorkerMode()` now accepts an optional + ``$resetEventListeners`` array parameter, corresponding to the new + ``$resetEventListeners`` property in ``Config\WorkerMode``. This allows users to + declare event names that should be cleaned up between requests when listeners are + registered inside event callbacks. See :ref:`worker-mode-reset-event-listeners`. + Others ====== diff --git a/user_guide_src/source/installation/upgrade_471.rst b/user_guide_src/source/installation/upgrade_471.rst index 066fcf568964..695027d10a99 100644 --- a/user_guide_src/source/installation/upgrade_471.rst +++ b/user_guide_src/source/installation/upgrade_471.rst @@ -16,6 +16,16 @@ Please refer to the upgrade instructions corresponding to your installation meth Mandatory File Changes ********************** +Worker Mode +=========== + +If you are using Worker Mode, you must update **public/frankenphp-worker.php** after +upgrading. The easiest way is to re-run the install command: + +.. code-block:: console + + php spark worker:install --force + **************** Breaking Changes **************** @@ -44,7 +54,9 @@ and it is recommended that you merge the updated versions with your application: Config ------ -- @TODO +- app/Config/WorkerMode.php + - ``Config\WorkerMode::$resetEventListeners`` has been added, with a default + value set to ``[]``. See :ref:`worker-mode-reset-event-listeners` for details. All Changes =========== @@ -52,4 +64,4 @@ All Changes This is a list of all files in the **project space** that received changes; many will be simple comments or formatting that have no effect on the runtime: -- @TODO +- app/Config/WorkerMode.php diff --git a/user_guide_src/source/installation/worker_mode.rst b/user_guide_src/source/installation/worker_mode.rst index 4dea3d1aad32..6f71af593ee9 100644 --- a/user_guide_src/source/installation/worker_mode.rst +++ b/user_guide_src/source/installation/worker_mode.rst @@ -186,6 +186,10 @@ Option Type Description in this list are destroyed after each request to prevent state leakage. Default: ``['autoloader', 'locator', 'exceptions', 'commands', 'codeigniter', 'superglobals', 'routes', 'cache']`` +**$resetEventListeners** array Event names whose listeners are removed between requests. Use this + when you register event listeners inside other event callbacks rather + than at the top level of **Config/Events.php**, which would cause them + to accumulate across requests. Default: ``[]`` **$forceGarbageCollection** bool Whether to force garbage collection after each request. ``true`` (default, recommended): Prevents memory leaks. ``false``: Relies on PHP's automatic garbage collection. @@ -214,6 +218,29 @@ Service Purpose state management can cause data leakage between requests. Only persist services that are truly stateless or manage their own request isolation. +.. _worker-mode-reset-event-listeners: + +Reset Event Listeners +===================== + +.. versionadded:: 4.7.1 + +Event listeners registered at the top level of **Config/Events.php** are loaded once +at worker startup and persist correctly across requests. However, if you register a +listener inside another event's callback, it will be re-registered on every request +and accumulate: + +.. literalinclude:: worker_mode/001.php + +To clean up such listeners between requests, add the event name to +``$resetEventListeners`` in **app/Config/WorkerMode.php**: + +.. literalinclude:: worker_mode/002.php + +.. note:: The recommended approach is to register listeners at the top level of + **Config/Events.php** instead of inside callbacks. Use ``$resetEventListeners`` + only when registering inside a callback is unavoidable. + ********************** Optimize Configuration ********************** diff --git a/user_guide_src/source/installation/worker_mode/001.php b/user_guide_src/source/installation/worker_mode/001.php new file mode 100644 index 000000000000..3140208b9e95 --- /dev/null +++ b/user_guide_src/source/installation/worker_mode/001.php @@ -0,0 +1,8 @@ + Date: Tue, 24 Feb 2026 17:52:32 +0100 Subject: [PATCH 40/75] refactor: make random-order CLI tests deterministic (#9998) * refactor: make random-order CLI tests deterministic * fix CLI::reset() * apply code suggestion --- system/CLI/CLI.php | 32 ++++++++++++++++++++++++++++++-- tests/system/CLI/CLITest.php | 15 +++++++++++---- tests/system/CLI/ConsoleTest.php | 8 ++++++++ 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php index b7c9ed64e2de..bafaeb167f12 100644 --- a/system/CLI/CLI.php +++ b/system/CLI/CLI.php @@ -1129,7 +1129,35 @@ protected static function fwrite($handle, string $string) /** * Testing purpose only * - * @testTag + * @internal + */ + public static function reset(): void + { + static::$initialized = false; + static::$segments = []; + static::$options = []; + static::$lastWrite = 'write'; + static::$height = null; + static::$width = null; + static::$isColored = static::hasColorSupport(STDOUT); + + static::resetInputOutput(); + } + + /** + * Testing purpose only + * + * @internal + */ + public static function resetLastWrite(): void + { + static::$lastWrite = null; + } + + /** + * Testing purpose only + * + * @internal */ public static function setInputOutput(InputOutput $io): void { @@ -1139,7 +1167,7 @@ public static function setInputOutput(InputOutput $io): void /** * Testing purpose only * - * @testTag + * @internal */ public static function resetInputOutput(): void { diff --git a/tests/system/CLI/CLITest.php b/tests/system/CLI/CLITest.php index 146d1066766e..6c6ce4569c4c 100644 --- a/tests/system/CLI/CLITest.php +++ b/tests/system/CLI/CLITest.php @@ -36,6 +36,15 @@ protected function setUp(): void parent::setUp(); Services::injectMock('superglobals', new Superglobals()); + + CLI::init(); + } + + protected function tearDown(): void + { + CLI::reset(); + + parent::tearDown(); } public function testNew(): void @@ -286,10 +295,6 @@ public function testStreamSupports(): void public function testColor(): void { - // After the tests on NO_COLOR and TERM_PROGRAM above, - // the $isColored variable is rigged. So we reset this. - CLI::init(); - $this->assertSame( "\033[1;37m\033[42m\033[4mtest\033[0m", CLI::color('test', 'white', 'green', 'underline'), @@ -330,6 +335,8 @@ public function testPrintBackground(): void public function testWrite(): void { + CLI::resetLastWrite(); + CLI::write('test'); $expected = PHP_EOL . 'test' . PHP_EOL; diff --git a/tests/system/CLI/ConsoleTest.php b/tests/system/CLI/ConsoleTest.php index 8110698c4212..ff4ac02ab29b 100644 --- a/tests/system/CLI/ConsoleTest.php +++ b/tests/system/CLI/ConsoleTest.php @@ -37,6 +37,7 @@ protected function setUp(): void parent::setUp(); Services::injectMock('superglobals', new Superglobals()); + CLI::init(); $env = new DotEnv(ROOTPATH); $env->load(); @@ -50,6 +51,13 @@ protected function setUp(): void $this->app->initialize(); } + protected function tearDown(): void + { + CLI::reset(); + + parent::tearDown(); + } + public function testHeader(): void { $console = new Console(); From 93595cf650c6d4a22ccb62618af1c0293c647e88 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Wed, 25 Feb 2026 01:14:32 +0800 Subject: [PATCH 41/75] test: turn `MySQLi` `$strictOn` to `true` by default (#9996) --- app/Config/Database.php | 2 +- tests/_support/Config/Registrar.php | 10 +- .../_support/Database/Seeds/CITestSeeder.php | 36 ++-- tests/system/Database/Live/ForgeTest.php | 4 + tests/system/Database/Live/InsertTest.php | 33 +--- .../Database/Live/MySQLi/RawSqlTest.php | 162 ++++++++++-------- .../system/Database/Live/TransactionTest.php | 33 +--- 7 files changed, 133 insertions(+), 147 deletions(-) diff --git a/app/Config/Database.php b/app/Config/Database.php index d7939bc26f9e..29df3641adf7 100644 --- a/app/Config/Database.php +++ b/app/Config/Database.php @@ -177,7 +177,7 @@ class Database extends Config 'swapPre' => '', 'encrypt' => false, 'compress' => false, - 'strictOn' => false, + 'strictOn' => true, 'failover' => [], 'port' => 3306, 'foreignKeys' => true, diff --git a/tests/_support/Config/Registrar.php b/tests/_support/Config/Registrar.php index 8759dcb5a1a2..d69d5c83e463 100644 --- a/tests/_support/Config/Registrar.php +++ b/tests/_support/Config/Registrar.php @@ -41,7 +41,7 @@ class Registrar 'swapPre' => '', 'encrypt' => false, 'compress' => false, - 'strictOn' => false, + 'strictOn' => true, 'failover' => [], 'port' => 3306, ], @@ -60,7 +60,7 @@ class Registrar 'swapPre' => '', 'encrypt' => false, 'compress' => false, - 'strictOn' => false, + 'strictOn' => true, // @todo 4.7.0 to remove in v4.8.0 'failover' => [], 'port' => 5432, ], @@ -79,7 +79,7 @@ class Registrar 'swapPre' => '', 'encrypt' => false, 'compress' => false, - 'strictOn' => false, + 'strictOn' => true, // @todo 4.7.0 to remove in v4.8.0 'failover' => [], 'port' => 3306, 'foreignKeys' => true, @@ -100,7 +100,7 @@ class Registrar 'swapPre' => '', 'encrypt' => false, 'compress' => false, - 'strictOn' => false, + 'strictOn' => true, // @todo 4.7.0 to remove in v4.8.0 'failover' => [], 'port' => 1433, ], @@ -119,7 +119,7 @@ class Registrar 'swapPre' => '', 'encrypt' => false, 'compress' => false, - 'strictOn' => false, + 'strictOn' => true, // @todo 4.7.0 to remove in v4.8.0 'failover' => [], ], ]; diff --git a/tests/_support/Database/Seeds/CITestSeeder.php b/tests/_support/Database/Seeds/CITestSeeder.php index 09dba65f155b..a34a5b304f03 100644 --- a/tests/_support/Database/Seeds/CITestSeeder.php +++ b/tests/_support/Database/Seeds/CITestSeeder.php @@ -19,7 +19,6 @@ class CITestSeeder extends Seeder { public function run(): void { - // Job Data $data = [ 'user' => [ [ @@ -147,7 +146,8 @@ public function run(): void ], ]; - // set SQL times to more correct format + // Normalize formats and remove unsupported types for each database driver + if ($this->db->DBDriver === 'SQLite3') { $data['type_test'][0]['type_date'] = '2020/01/11'; $data['type_test'][0]['type_time'] = '15:22:00'; @@ -157,7 +157,15 @@ public function run(): void if ($this->db->DBDriver === 'Postgre') { $data['type_test'][0]['type_time'] = '15:22:00'; - $data['type_test'][0]['type_boolean'] = true; + $data['type_test'][0]['type_boolean'] = true; // PostgreSQL has native boolean type + + $data['ci_sessions'][] = [ + 'id' => 'ci_session:1f5o06b43phsnnf8if6bo33b635e4p2o', + 'ip_address' => '127.0.0.1', + 'timestamp' => '2021-06-25 21:54:14.991403+02', + 'data' => '\x' . bin2hex('__ci_last_regenerate|i:1624650854;_ci_previous_url|s:40:\"http://localhost/index.php/home/index\";'), + ]; + unset( $data['type_test'][0]['type_enum'], $data['type_test'][0]['type_set'], @@ -185,6 +193,11 @@ public function run(): void } if ($this->db->DBDriver === 'MySQLi') { + $data['type_test'][0]['type_time'] = '15:22:00'; + $data['type_test'][0]['type_date'] = '2020-01-11'; + $data['type_test'][0]['type_datetime'] = '2020-06-18 05:12:24'; + $data['type_test'][0]['type_timestamp'] = '2019-07-18 21:53:21'; + $data['ci_sessions'][] = [ 'id' => 'ci_session:1f5o06b43phsnnf8if6bo33b635e4p2o', 'ip_address' => '127.0.0.1', @@ -193,29 +206,22 @@ public function run(): void ]; } - if ($this->db->DBDriver === 'Postgre') { - $data['ci_sessions'][] = [ - 'id' => 'ci_session:1f5o06b43phsnnf8if6bo33b635e4p2o', - 'ip_address' => '127.0.0.1', - 'timestamp' => '2021-06-25 21:54:14.991403+02', - 'data' => '\x' . bin2hex('__ci_last_regenerate|i:1624650854;_ci_previous_url|s:40:\"http://localhost/index.php/home/index\";'), - ]; - } - if ($this->db->DBDriver === 'OCI8') { $this->db->query('alter session set NLS_DATE_FORMAT=?', ['YYYY-MM-DD HH24:MI:SS']); + $data['type_test'][0]['type_date'] = '2020-01-11 22:11:00'; $data['type_test'][0]['type_time'] = '2020-07-18 15:22:00'; $data['type_test'][0]['type_datetime'] = '2020-06-18 05:12:24'; $data['type_test'][0]['type_timestamp'] = '2020-06-18 21:53:21'; + unset($data['type_test'][0]['type_blob']); } - foreach ($data as $table => $dummyData) { + foreach ($data as $table => $seeds) { $this->db->table($table)->truncate(); - foreach ($dummyData as $singleDummyData) { - $this->db->table($table)->insert($singleDummyData); + foreach ($seeds as $seed) { + $this->db->table($table)->insert($seed); } } } diff --git a/tests/system/Database/Live/ForgeTest.php b/tests/system/Database/Live/ForgeTest.php index 9faf9d79afb7..39433abde857 100644 --- a/tests/system/Database/Live/ForgeTest.php +++ b/tests/system/Database/Live/ForgeTest.php @@ -1621,6 +1621,10 @@ public function testAddTextColumnWithConstraint(): void $this->forge->addColumn('user', [ 'text_with_constraint' => ['type' => 'nvarchar(max)', 'default' => ''], ]); + } elseif (in_array($this->db->DBDriver, ['MySQLi', 'Postgre', 'SQLite3'], true)) { + $this->forge->addColumn('user', [ + 'text_with_constraint' => ['type' => 'text'], + ]); } else { $this->forge->addColumn('user', [ 'text_with_constraint' => ['type' => 'text', 'constraint' => 255, 'default' => ''], diff --git a/tests/system/Database/Live/InsertTest.php b/tests/system/Database/Live/InsertTest.php index 79281a384e9a..1483d660c174 100644 --- a/tests/system/Database/Live/InsertTest.php +++ b/tests/system/Database/Live/InsertTest.php @@ -30,13 +30,8 @@ final class InsertTest extends CIUnitTestCase { use DatabaseTestTrait; - /** - * @var Forge - */ - public $forge; - - protected $refresh = true; - protected $seed = CITestSeeder::class; + protected $seed = CITestSeeder::class; + private Forge $forge; public function testInsert(): void { @@ -93,26 +88,10 @@ public function testInsertBatchFailed(): void { $this->expectException(DatabaseException::class); - $data = [ - [ - 'name' => 'Grocery Sales', - ], - [ - 'name' => null, - ], - ]; - - $db = $this->db; - - if ($this->db->DBDriver === 'MySQLi') { - // strict mode is required for MySQLi to throw an exception here - $config = config('Database'); - $config->tests['strictOn'] = true; - - $db = Database::connect($config->tests); - } - - $db->table('job')->insertBatch($data); + $this->db->table('job')->insertBatch([ + ['name' => 'Grocery Sales'], + ['name' => null], + ]); } public function testReplaceWithNoMatchingData(): void diff --git a/tests/system/Database/Live/MySQLi/RawSqlTest.php b/tests/system/Database/Live/MySQLi/RawSqlTest.php index 03c694d0ab47..b14f8d4d400d 100644 --- a/tests/system/Database/Live/MySQLi/RawSqlTest.php +++ b/tests/system/Database/Live/MySQLi/RawSqlTest.php @@ -28,8 +28,7 @@ final class RawSqlTest extends CIUnitTestCase { use DatabaseTestTrait; - protected $refresh = true; - protected $seed = CITestSeeder::class; + protected $seed = CITestSeeder::class; protected function setUp(): void { @@ -37,50 +36,49 @@ protected function setUp(): void if ($this->db->DBDriver !== 'MySQLi') { $this->markTestSkipped('Only MySQLi has its own implementation.'); - } else { - $this->addSqlFunction(); } + + $this->addSqlFunction(); } - protected function addSqlFunction(): void + private function addSqlFunction(): void { $this->db->query('DROP FUNCTION IF EXISTS setDateTime'); - - $sql = "CREATE FUNCTION setDateTime ( setDate varchar(20) ) - RETURNS DATETIME - READS SQL DATA - DETERMINISTIC - BEGIN - RETURN CONVERT(CONCAT(setDate,' ','01:01:11'), DATETIME); - END;"; - - $this->db->query($sql); + $this->db->query(<<<'SQL_WRAP' + CREATE FUNCTION setDateTime ( setDate varchar(20) ) + RETURNS DATETIME + READS SQL DATA + DETERMINISTIC + BEGIN + RETURN CONVERT(CONCAT(setDate,' ','01:01:11'), DATETIME); + END; + SQL_WRAP); } public function testRawSqlUpdateObject(): void { - $data = []; - - $row = new stdClass(); - $row->email = 'derek@world.com'; - $row->created_at = new RawSql("setDateTime('2022-01-01')"); - $data[] = $row; - - $row = new stdClass(); - $row->email = 'ahmadinejad@world.com'; - $row->created_at = new RawSql("setDateTime('2022-01-01')"); - $data[] = $row; - - $this->db->table('user')->updateBatch($data, 'email'); - - $row->created_at = new RawSql("setDateTime('2022-01-11')"); - - $this->db->table('user')->update($row, "email = 'ahmadinejad@world.com'"); + $this->db->table('user')->updateBatch([ + (object) [ + 'email' => 'derek@world.com', + 'created_at' => new RawSql("setDateTime('2022-01-01')"), + ], + (object) [ + 'email' => 'ahmadinejad@world.com', + 'created_at' => new RawSql("setDateTime('2022-01-01')"), + ], + ], 'email'); + $this->db->table('user')->update( + (object) ['created_at' => new RawSql("setDateTime('2022-01-11')")], + "email = 'ahmadinejad@world.com'", + ); $this->seeInDatabase('user', ['email' => 'derek@world.com', 'created_at' => '2022-01-01 01:01:11']); $this->seeInDatabase('user', ['email' => 'ahmadinejad@world.com', 'created_at' => '2022-01-11 01:01:11']); } + /** + * @deprecated This test covers the deprecated setUpdateBatch() method. + */ public function testRawSqlSetUpdateObject(): void { $data = []; @@ -107,99 +105,115 @@ public function testRawSqlSetUpdateObject(): void public function testRawSqlUpdateArray(): void { - $data = [ + $this->db->table('user')->updateBatch([ ['email' => 'derek@world.com', 'created_at' => new RawSql("setDateTime('2022-03-01')")], ['email' => 'ahmadinejad@world.com', 'created_at' => new RawSql("setDateTime('2022-03-01')")], - ]; - - $this->db->table('user')->updateBatch($data, 'email'); - + ], 'email'); $this->seeInDatabase('user', ['email' => 'derek@world.com', 'created_at' => '2022-03-01 01:01:11']); $this->seeInDatabase('user', ['email' => 'ahmadinejad@world.com', 'created_at' => '2022-03-01 01:01:11']); - $data = ['email' => 'ahmadinejad@world.com', 'created_at' => new RawSql("setDateTime('2022-03-11')")]; - - $this->db->table('user')->update($data, "email = 'ahmadinejad@world.com'"); - + $this->db->table('user')->update( + ['email' => 'ahmadinejad@world.com', 'created_at' => new RawSql("setDateTime('2022-03-11')")], + "email = 'ahmadinejad@world.com'", + ); $this->seeInDatabase('user', ['email' => 'ahmadinejad@world.com', 'created_at' => '2022-03-11 01:01:11']); } public function testRawSqlInsertArray(): void { - $data = [ - ['email' => 'pedro@world.com', 'created_at' => new RawSql("setDateTime('2022-04-01')")], - ['email' => 'todd@world.com', 'created_at' => new RawSql("setDateTime('2022-04-01')")], - ]; - - $this->db->table('user')->insertBatch($data); - + $this->db->table('user')->insertBatch([ + [ + 'name' => 'Pedro Pascal', + 'email' => 'pedro@world.com', + 'country' => 'Chile', + 'created_at' => new RawSql("setDateTime('2022-04-01')"), + ], + [ + 'name' => 'Todd Howard', + 'email' => 'todd@world.com', + 'country' => 'US', + 'created_at' => new RawSql("setDateTime('2022-04-01')"), + ], + ]); $this->seeInDatabase('user', ['email' => 'pedro@world.com', 'created_at' => '2022-04-01 01:01:11']); $this->seeInDatabase('user', ['email' => 'todd@world.com', 'created_at' => '2022-04-01 01:01:11']); - $data = ['email' => 'jason@world.com', 'created_at' => new RawSql("setDateTime('2022-04-11')")]; - - $this->db->table('user')->insert($data); - + $this->db->table('user')->insert([ + 'name' => 'Jason Momoa', + 'email' => 'jason@world.com', + 'country' => 'US', + 'created_at' => new RawSql("setDateTime('2022-04-11')"), + ]); $this->seeInDatabase('user', ['email' => 'jason@world.com', 'created_at' => '2022-04-11 01:01:11']); } public function testRawSqlInsertObject(): void { - $data = []; - - $row = new stdClass(); - $row->email = 'tony@world.com'; - $row->created_at = new RawSql("setDateTime('2022-05-01')"); - $data[] = $row; - - $row = new stdClass(); - $row->email = 'sara@world.com'; - $row->created_at = new RawSql("setDateTime('2022-05-01')"); - $data[] = $row; - - $this->db->table('user')->insertBatch($data); - - $row->email = 'jessica@world.com'; - $row->created_at = new RawSql("setDateTime('2022-05-11')"); - - $this->db->table('user')->insert($row); + $this->db->table('user')->insertBatch([ + (object) [ + 'name' => 'Tony Stark', + 'email' => 'tony@world.com', + 'country' => 'US', + 'created_at' => new RawSql("setDateTime('2022-05-01')"), + ], + (object) [ + 'name' => 'Sara Connor', + 'email' => 'sara@world.com', + 'country' => 'US', + 'created_at' => new RawSql("setDateTime('2022-05-01')"), + ], + ]); + $this->db->table('user')->insert((object) [ + 'name' => 'Jessica Jones', + 'email' => 'jessica@world.com', + 'country' => 'US', + 'created_at' => new RawSql("setDateTime('2022-05-11')"), + ]); $this->seeInDatabase('user', ['email' => 'tony@world.com', 'created_at' => '2022-05-01 01:01:11']); $this->seeInDatabase('user', ['email' => 'sara@world.com', 'created_at' => '2022-05-01 01:01:11']); $this->seeInDatabase('user', ['email' => 'jessica@world.com', 'created_at' => '2022-05-11 01:01:11']); } + /** + * @deprecated This test covers the deprecated setInsertBatch() method. + */ public function testRawSqlSetInsertObject(): void { $data = []; $row = new stdClass(); + $row->name = 'Laura Palmer'; $row->email = 'laura@world.com'; + $row->country = 'US'; $row->created_at = new RawSql("setDateTime('2022-06-01')"); $data[] = $row; $row = new stdClass(); + $row->name = 'Travis Touchdown'; $row->email = 'travis@world.com'; + $row->country = 'US'; $row->created_at = new RawSql("setDateTime('2022-06-01')"); $data[] = $row; $this->db->table('user')->setInsertBatch($data)->insertBatch(); - $this->seeInDatabase('user', ['email' => 'laura@world.com', 'created_at' => '2022-06-01 01:01:11']); $this->seeInDatabase('user', ['email' => 'travis@world.com', 'created_at' => '2022-06-01 01:01:11']); + $row = new stdClass(); + $row->name = 'Steve Rogers'; $row->email = 'steve@world.com'; + $row->country = 'US'; $row->created_at = new RawSql("setDateTime('2022-06-11')"); - $this->db->table('user')->set($row)->insert(); - $this->seeInDatabase('user', ['email' => 'steve@world.com', 'created_at' => '2022-06-11 01:01:11']); $this->db->table('user') + ->set('name', 'Dan Brown') ->set('email', 'dan@world.com') + ->set('country', 'US') ->set('created_at', new RawSql("setDateTime('2022-06-13')")) ->insert(); - $this->seeInDatabase('user', ['email' => 'dan@world.com', 'created_at' => '2022-06-13 01:01:11']); } } diff --git a/tests/system/Database/Live/TransactionTest.php b/tests/system/Database/Live/TransactionTest.php index 74df4e3fd974..d36cbf35d677 100644 --- a/tests/system/Database/Live/TransactionTest.php +++ b/tests/system/Database/Live/TransactionTest.php @@ -245,33 +245,16 @@ public function testTransStrictFalseAndDBDebugFalse(): void */ public function testTransInsertBatchFailed(): void { - $data = [ - [ - 'name' => 'Grocery Sales', - ], - [ - 'name' => null, - ], - ]; - - $db = $this->db; - - if ($this->db->DBDriver === 'MySQLi') { - // strict mode is required for MySQLi to throw an exception here - $config = config('Database'); - $config->tests['strictOn'] = true; - - $db = Database::connect($config->tests); - } + $this->db->transStrict(false)->transBegin(); + $this->db->table('job')->insertBatch([ + ['name' => 'Grocery Sales'], + ['name' => null], + ]); - $db->transStrict(false)->transBegin(); - $db->table('job')->insertBatch($data); - - $this->assertFalse($db->transStatus()); - - $db->transComplete(); + $this->assertFalse($this->db->transStatus()); - $db->transStrict(); + $this->db->transComplete(); + $this->db->transStrict(); $this->dontSeeInDatabase('job', ['name' => 'Grocery Sales']); } From 0fe15adf0c93df0e87f8c954f98326ff0e218d5e Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Tue, 24 Feb 2026 21:26:37 +0100 Subject: [PATCH 42/75] fix: add nonce to script-src-elem and style-src-elem when configured (#9999) --- system/HTTP/ContentSecurityPolicy.php | 8 ++++ .../system/HTTP/ContentSecurityPolicyTest.php | 42 +++++++++++++++++++ user_guide_src/source/changelogs/v4.7.1.rst | 1 + 3 files changed, 51 insertions(+) diff --git a/system/HTTP/ContentSecurityPolicy.php b/system/HTTP/ContentSecurityPolicy.php index a7c67647823b..c94fed4c8e73 100644 --- a/system/HTTP/ContentSecurityPolicy.php +++ b/system/HTTP/ContentSecurityPolicy.php @@ -400,6 +400,10 @@ public function getStyleNonce(): string if ($this->styleNonce === null) { $this->styleNonce = base64_encode(random_bytes(12)); $this->addStyleSrc('nonce-' . $this->styleNonce); + + if ($this->styleSrcElem !== []) { + $this->addStyleSrcElem('nonce-' . $this->styleNonce); + } } return $this->styleNonce; @@ -413,6 +417,10 @@ public function getScriptNonce(): string if ($this->scriptNonce === null) { $this->scriptNonce = base64_encode(random_bytes(12)); $this->addScriptSrc('nonce-' . $this->scriptNonce); + + if ($this->scriptSrcElem !== []) { + $this->addScriptSrcElem('nonce-' . $this->scriptNonce); + } } return $this->scriptNonce; diff --git a/tests/system/HTTP/ContentSecurityPolicyTest.php b/tests/system/HTTP/ContentSecurityPolicyTest.php index beafa54ce164..5bced590228a 100644 --- a/tests/system/HTTP/ContentSecurityPolicyTest.php +++ b/tests/system/HTTP/ContentSecurityPolicyTest.php @@ -900,6 +900,48 @@ public function testGetStyleNonce(): void ); } + public function testGetScriptNonceAddsNonceToScriptSrcElemWhenConfigured(): void + { + $this->csp->clearDirective('script-src-elem'); + $this->csp->addScriptSrcElem('cdn.example.com'); + $nonce = $this->csp->getScriptNonce(); + $this->csp->finalize($this->response); + + $directives = $this->getCspDirectives($this->response->getHeaderLine('Content-Security-Policy')); + $this->assertContains("script-src-elem cdn.example.com 'nonce-{$nonce}'", $directives); + } + + public function testGetScriptNonceDoesNotAddNonceToScriptSrcElemWhenCleared(): void + { + $this->csp->clearDirective('script-src-elem'); + $this->csp->getScriptNonce(); + $this->csp->finalize($this->response); + + $header = $this->response->getHeaderLine('Content-Security-Policy'); + $this->assertStringNotContainsString('script-src-elem', $header); + } + + public function testGetStyleNonceAddsNonceToStyleSrcElemWhenConfigured(): void + { + $this->csp->clearDirective('style-src-elem'); + $this->csp->addStyleSrcElem('cdn.example.com'); + $nonce = $this->csp->getStyleNonce(); + $this->csp->finalize($this->response); + + $directives = $this->getCspDirectives($this->response->getHeaderLine('Content-Security-Policy')); + $this->assertContains("style-src-elem cdn.example.com 'nonce-{$nonce}'", $directives); + } + + public function testGetStyleNonceDoesNotAddNonceToStyleSrcElemWhenCleared(): void + { + $this->csp->clearDirective('style-src-elem'); + $this->csp->getStyleNonce(); + $this->csp->finalize($this->response); + + $header = $this->response->getHeaderLine('Content-Security-Policy'); + $this->assertStringNotContainsString('style-src-elem', $header); + } + #[PreserveGlobalState(false)] #[RunInSeparateProcess] public function testHeaderScriptNonceEmittedOnceGetScriptNonceCalled(): void diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst index 4eeceecdb162..46bb7cd809cb 100644 --- a/user_guide_src/source/changelogs/v4.7.1.rst +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -49,6 +49,7 @@ Bugs Fixed - **ContentSecurityPolicy:** Fixed a bug where custom CSP tags were not removed from generated HTML when CSP was disabled. The method now ensures that all custom CSP tags are removed from the generated HTML. - **ContentSecurityPolicy:** Fixed a bug where ``generateNonces()`` produces corrupted JSON responses by replacing CSP nonce placeholders with unescaped double quotes. The method now automatically JSON-escapes nonce attributes when the response Content-Type is JSON. +- **ContentSecurityPolicy:** Fixed a bug where nonces generated by ``getScriptNonce()`` and ``getStyleNonce()`` were not added to the ``script-src-elem`` and ``style-src-elem`` directives, causing nonces to be silently ignored by browsers when those directives were present. - **Database:** Fixed a bug where ``BaseConnection::callFunction()`` could double-prefix already-prefixed function names. - **Database:** Fixed a bug where ``BasePreparedQuery::prepare()`` could mis-handle SQL containing colon syntax by over-broad named-placeholder replacement. It now preserves PostgreSQL cast syntax like ``::timestamp``. - **Model:** Fixed a bug where ``BaseModel::updateBatch()`` threw an exception when ``updateOnlyChanged`` was ``true`` and the index field value did not change. From cfa704225f3828195b790557e081a76adcee0997 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Wed, 25 Feb 2026 10:25:32 +0800 Subject: [PATCH 43/75] perf: optimize ImageMagick installation in GitHub Actions workflows (#10005) Remove unnecessary dependencies and redundant installation commands that cause significant slowdowns on PHP 8.4. Changes: - Remove --reinstall flag which forces unnecessary downloads - Remove unneeded packages (fonts, ghostscript, poppler, etc.) - Remove problematic --fix-broken call - Use --no-install-recommends to skip optional dependencies - Consolidate installation into single apt-get command This reduces installation time from 10+ minutes on PHP 8.4 to 2-3 minutes, matching other PHP versions. --- .github/workflows/reusable-phpunit-test.yml | 4 +--- .github/workflows/reusable-serviceless-phpunit-test.yml | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/reusable-phpunit-test.yml b/.github/workflows/reusable-phpunit-test.yml index 80e3ed3ec926..3262207655a2 100644 --- a/.github/workflows/reusable-phpunit-test.yml +++ b/.github/workflows/reusable-phpunit-test.yml @@ -165,9 +165,7 @@ jobs: if: ${{ contains(inputs.extra-extensions, 'imagick') }} run: | sudo apt-get update - sudo apt-get install --reinstall fonts-noto-mono libijs-0.35:amd64 fonts-urw-base35 ghostscript poppler-data libjbig2dec0:amd64 libopenjp2-7:amd64 fonts-droid-fallback fonts-dejavu-core - sudo apt-get install -y gsfonts libmagickwand-dev imagemagick - sudo apt-get install --fix-broken + sudo apt-get install -y imagemagick libmagickwand-dev ghostscript poppler-data libjbig2dec0:amd64 libopenjp2-7:amd64 - name: Checkout base branch for PR if: github.event_name == 'pull_request' diff --git a/.github/workflows/reusable-serviceless-phpunit-test.yml b/.github/workflows/reusable-serviceless-phpunit-test.yml index caa1469fff81..5f355d37bb13 100644 --- a/.github/workflows/reusable-serviceless-phpunit-test.yml +++ b/.github/workflows/reusable-serviceless-phpunit-test.yml @@ -60,9 +60,7 @@ jobs: if: ${{ contains(inputs.extra-extensions, 'imagick') }} run: | sudo apt-get update - sudo apt-get install --reinstall libgs9-common fonts-noto-mono libgs9:amd64 libijs-0.35:amd64 fonts-urw-base35 ghostscript poppler-data libjbig2dec0:amd64 gsfonts libopenjp2-7:amd64 fonts-droid-fallback fonts-dejavu-core - sudo apt-get install -y imagemagick - sudo apt-get install --fix-broken + sudo apt-get install -y imagemagick libmagickwand-dev ghostscript poppler-data libjbig2dec0:amd64 libopenjp2-7:amd64 - name: Checkout base branch for PR if: github.event_name == 'pull_request' From eb6ac9fa639730133b355ba062828dfac12bf29f Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Wed, 25 Feb 2026 10:30:13 +0800 Subject: [PATCH 44/75] fix: `FeatureTestTrait::withRoutes()` may throw all sorts of errors on invalid HTTP methods (#10004) * add failing test * add fix and changelog --- system/HTTP/Method.php | 2 +- system/Test/FeatureTestTrait.php | 16 +++++++---- tests/system/Test/FeatureTestTraitTest.php | 30 +++++++++++++++++++++ user_guide_src/source/changelogs/v4.7.1.rst | 1 + 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/system/HTTP/Method.php b/system/HTTP/Method.php index ee3a09ec4b0d..09e1dbc95ecb 100644 --- a/system/HTTP/Method.php +++ b/system/HTTP/Method.php @@ -102,7 +102,7 @@ class Method /** * Returns all HTTP methods. * - * @return list + * @return list */ public static function all(): array { diff --git a/system/Test/FeatureTestTrait.php b/system/Test/FeatureTestTrait.php index 618a2bcdb485..a271191a2271 100644 --- a/system/Test/FeatureTestTrait.php +++ b/system/Test/FeatureTestTrait.php @@ -15,6 +15,7 @@ use Closure; use CodeIgniter\Events\Events; +use CodeIgniter\Exceptions\RuntimeException; use CodeIgniter\HTTP\Exceptions\RedirectException; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\Method; @@ -76,11 +77,16 @@ protected function withRoutes(?array $routes = null) ); } - /** - * @TODO For backward compatibility. Remove strtolower() in the future. - * @deprecated 4.5.0 - */ - $method = strtolower($route[0]); + // @todo v4.7.1 Remove the strtoupper() and use 'add' in v4.8.0 + if (! in_array(strtoupper($route[0]), ['ADD', 'CLI', ...Method::all()], true)) { + throw new RuntimeException(sprintf( + 'Invalid HTTP method "%s" provided for route "%s".', + $route[0], + $route[1], + )); + } + + $method = strtolower($route[0]); // convert to method of RouteCollection if (isset($route[3])) { $collection->{$method}($route[1], $route[2], $route[3]); diff --git a/tests/system/Test/FeatureTestTraitTest.php b/tests/system/Test/FeatureTestTraitTest.php index 870572174204..a5472ddd969d 100644 --- a/tests/system/Test/FeatureTestTraitTest.php +++ b/tests/system/Test/FeatureTestTraitTest.php @@ -16,6 +16,7 @@ use CodeIgniter\Config\Factories; use CodeIgniter\Events\Events; use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\Exceptions\RuntimeException; use CodeIgniter\HTTP\Method; use CodeIgniter\HTTP\Response; use CodeIgniter\Test\Mock\MockCodeIgniter; @@ -689,4 +690,33 @@ public function testForceGlobalSecureRequests(): void // Do not redirect. $response->assertStatus(200); } + + #[DataProvider('provideWithRoutesWithInvalidMethod')] + public function testWithRoutesWithInvalidMethod(string $method): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(sprintf('Invalid HTTP method "%s" provided for route "home".', $method)); + + $this->withRoutes([ + [ + $method, + 'home', + static fn (): string => 'Hello World', + ], + ]); + } + + /** + * @return iterable + */ + public static function provideWithRoutesWithInvalidMethod(): iterable + { + foreach (['ADD', 'CLI', ...Method::all()] as $method) { + yield "wrong {$method}" => [$method . 'S']; + } + + yield 'route collection addRedirect' => ['addRedirect']; + + yield 'route collection setHTTPVerb' => ['setHTTPVerb']; + } } diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst index 46bb7cd809cb..9cc1efde2af9 100644 --- a/user_guide_src/source/changelogs/v4.7.1.rst +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -57,6 +57,7 @@ Bugs Fixed - **Session:** Fixed a bug in ``MemcachedHandler`` where the constructor incorrectly threw an exception when ``savePath`` was not empty. - **Toolbar:** Fixed a bug where the standalone toolbar page loaded from ``?debugbar_time=...`` was not interactive. - **Toolbar:** Fixed a bug in the Routes panel where only the first route parameter was converted to an input field on hover. +- **Testing:** Fixed a bug in ``FeatureTestTrait::withRoutes()`` where invalid HTTP methods were not properly validated, thus passing them all to ``RouteCollection``. - **View:** Fixed a bug where ``View`` would throw an error if the ``appOverridesFolder`` config property was not defined. See the repo's From 2134205aa20f138f6f550a900361db00ecd2aad7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 20:47:30 +0100 Subject: [PATCH 45/75] chore(deps): bump actions/download-artifact from 7 to 8 (#10010) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v7...v8) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/reusable-coveralls.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/reusable-coveralls.yml b/.github/workflows/reusable-coveralls.yml index 8c0219edaca5..06fecffe5fcd 100644 --- a/.github/workflows/reusable-coveralls.yml +++ b/.github/workflows/reusable-coveralls.yml @@ -30,7 +30,7 @@ jobs: coverage: xdebug - name: Download coverage files - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: path: build/cov From 5b641bb3f70e153ceac06a3b6c8600beb09ef2fb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 20:47:40 +0100 Subject: [PATCH 46/75] chore(deps): bump actions/upload-artifact from 6 to 7 (#10011) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/deploy-userguide-latest.yml | 2 +- .github/workflows/reusable-phpunit-test.yml | 2 +- .github/workflows/reusable-serviceless-phpunit-test.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-userguide-latest.yml b/.github/workflows/deploy-userguide-latest.yml index 94ba76dd9fbd..a692f903f965 100644 --- a/.github/workflows/deploy-userguide-latest.yml +++ b/.github/workflows/deploy-userguide-latest.yml @@ -57,7 +57,7 @@ jobs: # Create an artifact of the html output - name: Upload artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: HTML Documentation path: user_guide_src/build/html/ diff --git a/.github/workflows/reusable-phpunit-test.yml b/.github/workflows/reusable-phpunit-test.yml index 3262207655a2..7881d688bc70 100644 --- a/.github/workflows/reusable-phpunit-test.yml +++ b/.github/workflows/reusable-phpunit-test.yml @@ -235,7 +235,7 @@ jobs: - name: Upload coverage results as artifact if: ${{ inputs.enable-artifact-upload }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: ${{ steps.setup-env.outputs.ARTIFACT_NAME }} path: build/cov/coverage-${{ steps.setup-env.outputs.ARTIFACT_NAME }}.cov diff --git a/.github/workflows/reusable-serviceless-phpunit-test.yml b/.github/workflows/reusable-serviceless-phpunit-test.yml index 5f355d37bb13..2a5a13d3db26 100644 --- a/.github/workflows/reusable-serviceless-phpunit-test.yml +++ b/.github/workflows/reusable-serviceless-phpunit-test.yml @@ -127,7 +127,7 @@ jobs: - name: Upload coverage results as artifact if: ${{ inputs.enable-artifact-upload }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: ${{ steps.setup-env.outputs.ARTIFACT_NAME }} path: build/cov/coverage-${{ steps.setup-env.outputs.ARTIFACT_NAME }}.cov From 31a321232aeaeb769799d1035d6c9568a879fea9 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 28 Feb 2026 20:43:25 +0800 Subject: [PATCH 47/75] test: add test that DBs throw error on too long inputs (#10009) --- tests/system/Database/Live/InsertTest.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/system/Database/Live/InsertTest.php b/tests/system/Database/Live/InsertTest.php index 1483d660c174..9527c14fa189 100644 --- a/tests/system/Database/Live/InsertTest.php +++ b/tests/system/Database/Live/InsertTest.php @@ -275,4 +275,18 @@ public function testInsertBatchWithQueryAndRawSqlAndManualColumns(): void $this->forge->dropTable('user2', true); } + + public function testInsertWithTooLongCharactersThrowsError(): void + { + if ($this->db->DBDriver === 'SQLite3') { + $this->markTestSkipped('SQLite does not enforce VARCHAR length constraints.'); + } + + $this->expectException(DatabaseException::class); + + $this->db->table('misc')->insert([ + 'key' => 'too_long', + 'value' => str_repeat('a', 401), // 'value' is VARCHAR(400), so this should throw an error + ]); + } } From d03de8aeac6328699c8ccf39fd2240d2c40e4371 Mon Sep 17 00:00:00 2001 From: neznaika0 Date: Sun, 1 Mar 2026 14:32:00 +0300 Subject: [PATCH 48/75] fix: Typo in the translation Validation --- system/Language/en/Validation.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/Language/en/Validation.php b/system/Language/en/Validation.php index facf512d559a..2e4094645d65 100644 --- a/system/Language/en/Validation.php +++ b/system/Language/en/Validation.php @@ -65,7 +65,7 @@ 'valid_json' => 'The {field} field must contain a valid json.', // Credit Cards - 'valid_cc_num' => '{field} does not appear to be a valid credit card number.', + 'valid_cc_number' => '{field} does not appear to be a valid credit card number.', // Files 'uploaded' => '{field} is not a valid uploaded file.', From eff1813ab9e14066cccb323744029c6e045ef117 Mon Sep 17 00:00:00 2001 From: neznaika0 Date: Sun, 1 Mar 2026 14:38:54 +0300 Subject: [PATCH 49/75] docs: Update changelog --- user_guide_src/source/changelogs/v4.7.1.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst index 9cc1efde2af9..bfcd7121f965 100644 --- a/user_guide_src/source/changelogs/v4.7.1.rst +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -19,6 +19,7 @@ Message Changes *************** - Updated ``Images.unsupportedImageCreate``. +- Renamed the ``Validation.valid_cc_num`` key to ``Validation.valid_cc_number``. ******* Changes @@ -58,6 +59,7 @@ Bugs Fixed - **Toolbar:** Fixed a bug where the standalone toolbar page loaded from ``?debugbar_time=...`` was not interactive. - **Toolbar:** Fixed a bug in the Routes panel where only the first route parameter was converted to an input field on hover. - **Testing:** Fixed a bug in ``FeatureTestTrait::withRoutes()`` where invalid HTTP methods were not properly validated, thus passing them all to ``RouteCollection``. +- **Validation:** Rule ``valid_cc_number`` now has the correct translation. - **View:** Fixed a bug where ``View`` would throw an error if the ``appOverridesFolder`` config property was not defined. See the repo's From 0a5ce99c41ff0ff00e8ca2ab5cc77da76e2daf3f Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Mon, 2 Mar 2026 07:53:11 +0100 Subject: [PATCH 50/75] fix: validation when key does not exists (#10006) * fix: validation when key does not exists * add changelog * refactor --- system/Validation/Validation.php | 89 +++++++++ tests/system/Validation/ValidationTest.php | 210 +++++++++++++++++++- user_guide_src/source/changelogs/v4.7.1.rst | 1 + 3 files changed, 297 insertions(+), 3 deletions(-) diff --git a/system/Validation/Validation.php b/system/Validation/Validation.php index 57e52d3cd15e..6ca3233f746f 100644 --- a/system/Validation/Validation.php +++ b/system/Validation/Validation.php @@ -178,6 +178,15 @@ public function run(?array $data = null, ?string $group = null, $dbGroup = null) ARRAY_FILTER_USE_KEY, ); + // Emit null for every leaf path that is structurally reachable + // but whose key is absent from the data. This mirrors the + // non-wildcard behaviour where a missing key is treated as null, + // so that all rules behave consistently regardless of whether + // the field uses a wildcard or not. + foreach ($this->walkForAllPossiblePaths(explode('.', $field), $data, '') as $path) { + $values[$path] = null; + } + // if keys not found $values = $values !== [] ? $values : [$field => null]; } else { @@ -987,6 +996,86 @@ protected function splitRules(string $rules): array return array_unique($rules); } + /** + * Entry point: allocates a single accumulator and delegates to the + * recursive collector, so no intermediate arrays are built or unpacked. + * + * @param list $segments + * @param array|mixed $current + * + * @return list + */ + private function walkForAllPossiblePaths(array $segments, mixed $current, string $prefix): array + { + $result = []; + $this->collectMissingPaths($segments, 0, count($segments), $current, $prefix, $result); + + return $result; + } + + /** + * Recursively walks the data structure, expanding wildcard segments over + * all array keys, and appends to $result by reference. Only concrete leaf + * paths where the key is genuinely absent are recorded - intermediate + * missing segments are silently skipped so `*` never appears in a result. + * + * @param list $segments + * @param int<0, max> $segmentCount + * @param array|mixed $current + * @param list $result + */ + private function collectMissingPaths( + array $segments, + int $index, + int $segmentCount, + mixed $current, + string $prefix, + array &$result, + ): void { + if ($index >= $segmentCount) { + // Successfully navigated every segment - the path exists in the data. + return; + } + + $segment = $segments[$index]; + $nextIndex = $index + 1; + + if ($segment === '*') { + if (! is_array($current)) { + return; + } + + foreach ($current as $key => $value) { + $keyPrefix = $prefix !== '' ? $prefix . '.' . $key : (string) $key; + + // Non-array elements with remaining segments are a structural + // mismatch (e.g. the DBGroup sentinel, scalar siblings) - skip. + if (! is_array($value) && $nextIndex < $segmentCount) { + continue; + } + + $this->collectMissingPaths($segments, $nextIndex, $segmentCount, $value, $keyPrefix, $result); + } + + return; + } + + $newPrefix = $prefix !== '' ? $prefix . '.' . $segment : $segment; + + if (! is_array($current) || ! array_key_exists($segment, $current)) { + // Only record a missing path for the leaf key. When an intermediate + // segment is absent there is nothing to validate in that branch, + // so skip it to avoid false-positive errors. + if ($nextIndex === $segmentCount) { + $result[] = $newPrefix; + } + + return; + } + + $this->collectMissingPaths($segments, $nextIndex, $segmentCount, $current[$segment], $newPrefix, $result); + } + /** * Resets the class to a blank slate. Should be called whenever * you need to process more than one array. diff --git a/tests/system/Validation/ValidationTest.php b/tests/system/Validation/ValidationTest.php index 386266f44391..830296b75b97 100644 --- a/tests/system/Validation/ValidationTest.php +++ b/tests/system/Validation/ValidationTest.php @@ -1850,13 +1850,217 @@ public function testRuleWithAsteriskToMultiDimensionalArray(): void ); $this->assertFalse($this->validation->run($data)); $this->assertSame( - // The data for `contacts.*.name` does not exist. So it is interpreted - // as `null`, and this error message returns. - ['contacts.*.name' => 'The contacts.*.name field is required.'], + // `contacts.just` exists but has no `name` key, so null is injected + // and the error is reported on the concrete path. + ['contacts.just.name' => 'The contacts.*.name field is required.'], $this->validation->getErrors(), ); } + public function testRequiredWildcardFailsWhenSomeElementsMissingKey(): void + { + $data = [ + 'contacts' => [ + 'friends' => [ + ['name' => 'Fred', 'age' => 20], + ['age' => 21], + ], + ], + ]; + + $this->validation->setRules(['contacts.friends.*.name' => 'required']); + $this->assertFalse($this->validation->run($data)); + $this->assertSame( + ['contacts.friends.1.name' => 'The contacts.friends.*.name field is required.'], + $this->validation->getErrors(), + ); + } + + public function testRequiredWildcardFailsForEachMissingElement(): void + { + // One element has the key (creating a non-empty initial match set), + // the other two are missing it - each missing element gets its own error. + $data = [ + 'contacts' => [ + 'friends' => [ + ['name' => 'Fred', 'age' => 20], + ['age' => 21], + ['age' => 22], + ], + ], + ]; + + $this->validation->setRules(['contacts.friends.*.name' => 'required']); + $this->assertFalse($this->validation->run($data)); + $this->assertSame( + [ + 'contacts.friends.1.name' => 'The contacts.friends.*.name field is required.', + 'contacts.friends.2.name' => 'The contacts.friends.*.name field is required.', + ], + $this->validation->getErrors(), + ); + } + + public function testWildcardNonRequiredRuleFiresForMissingElements(): void + { + // A missing key is treated as null, consistent with non-wildcard behaviour. + // Use `if_exist` or `permit_empty` to explicitly skip absent keys. + $data = [ + 'contacts' => [ + 'friends' => [ + ['name' => 'Fred'], // passes in_list + ['age' => 21], // key absent - null injected, in_list fails + ], + ], + ]; + + $this->validation->setRules(['contacts.friends.*.name' => 'in_list[Fred,Wilma]']); + $this->assertFalse($this->validation->run($data)); + $this->assertSame( + ['contacts.friends.1.name' => 'The contacts.friends.*.name field must be one of: Fred,Wilma.'], + $this->validation->getErrors(), + ); + } + + public function testWildcardIfExistRequiredSkipsMissingElements(): void + { + // `if_exist` must short-circuit before `required` fires for elements + // whose key is absent from the data structure. + $data = [ + 'contacts' => [ + 'friends' => [ + ['name' => 'Fred'], // exists and non-empty - passes + ['age' => 21], // key absent - if_exist skips it + ], + ], + ]; + + $this->validation->setRules(['contacts.friends.*.name' => 'if_exist|required']); + $this->assertTrue($this->validation->run($data)); + $this->assertSame([], $this->validation->getErrors()); + } + + public function testWildcardPermitEmptySkipsMissingElements(): void + { + // `permit_empty` treats null as empty and short-circuits remaining rules, + // so both an explicitly empty value and an absent key (injected as null) pass. + $data = [ + 'contacts' => [ + 'friends' => [ + ['name' => ''], // exists but empty - permit_empty lets it through + ['age' => 21], // key absent - null injected, permit_empty lets it through + ], + ], + ]; + + $this->validation->setRules(['contacts.friends.*.name' => 'permit_empty|min_length[2]']); + $this->assertTrue($this->validation->run($data)); + $this->assertSame([], $this->validation->getErrors()); + } + + public function testWildcardRequiredWithFailsForMissingElementWhenConditionMet(): void + { + // The missing key is injected as null. When the condition field is present + // the rule fires and the missing element generates an error. + $data = [ + 'has_friends' => '1', + 'contacts' => [ + 'friends' => [ + ['name' => 'Fred', 'age' => 20], // passes + ['age' => 21], // missing name, condition met - error + ], + ], + ]; + + $this->validation->setRules(['contacts.friends.*.name' => 'required_with[has_friends]']); + $this->assertFalse($this->validation->run($data)); + $this->assertSame( + ['contacts.friends.1.name' => 'The contacts.friends.*.name field is required when has_friends is present.'], + $this->validation->getErrors(), + ); + } + + public function testWildcardRequiredWithPassesForMissingElementWhenConditionNotMet(): void + { + // The missing key is injected as null, but required_with passes because + // the condition field is absent, so no error is generated. + $data = [ + 'contacts' => [ + 'friends' => [ + ['name' => 'Fred', 'age' => 20], // passes + ['age' => 21], // missing name, condition absent - ok + ], + ], + ]; + + $this->validation->setRules(['contacts.friends.*.name' => 'required_with[has_friends]']); + $this->assertTrue($this->validation->run($data)); + $this->assertSame([], $this->validation->getErrors()); + } + + public function testWildcardRequiredNoFalsePositiveForMissingIntermediateSegment(): void + { + // users.1 has no `contacts` key at all - an intermediate segment is + // absent, not the leaf. Only the leaf-absent branch (users.0.contacts.1) + // should produce an error; the entirely-missing branch must be silent. + $data = [ + 'users' => [ + [ + 'contacts' => [ + ['name' => 'Alice'], // leaf present + ['age' => 20], // leaf absent - error + ], + ], + ['age' => 30], // intermediate segment `contacts` missing - no error + ], + ]; + + $this->validation->setRules(['users.*.contacts.*.name' => 'required']); + $this->assertFalse($this->validation->run($data)); + $this->assertSame( + ['users.0.contacts.1.name' => 'The users.*.contacts.*.name field is required.'], + $this->validation->getErrors(), + ); + } + + public function testWildcardFieldExistsFailsWhenSomeElementsMissingKey(): void + { + // field_exists uses dotKeyExists against the whole wildcard pattern, so + // it reports on the template field rather than individual concrete paths + // (unlike `required`, which reports per concrete path). + $data = [ + 'contacts' => [ + 'friends' => [ + ['name' => 'Fred', 'age' => 20], + ['age' => 21], // 'name' key absent + ], + ], + ]; + + $this->validation->setRules(['contacts.friends.*.name' => 'field_exists']); + $this->assertFalse($this->validation->run($data)); + $this->assertSame( + ['contacts.friends.*.name' => 'The contacts.friends.*.name field must exist.'], + $this->validation->getErrors(), + ); + } + + public function testWildcardFieldExistsPassesWhenAllElementsHaveKey(): void + { + $data = [ + 'contacts' => [ + 'friends' => [ + ['name' => 'Fred', 'age' => 20], + ['name' => 'Wilma', 'age' => 25], + ], + ], + ]; + + $this->validation->setRules(['contacts.friends.*.name' => 'field_exists']); + $this->assertTrue($this->validation->run($data)); + $this->assertSame([], $this->validation->getErrors()); + } + /** * @param array $data * @param array $rules diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst index bfcd7121f965..bd5ef485d17c 100644 --- a/user_guide_src/source/changelogs/v4.7.1.rst +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -60,6 +60,7 @@ Bugs Fixed - **Toolbar:** Fixed a bug in the Routes panel where only the first route parameter was converted to an input field on hover. - **Testing:** Fixed a bug in ``FeatureTestTrait::withRoutes()`` where invalid HTTP methods were not properly validated, thus passing them all to ``RouteCollection``. - **Validation:** Rule ``valid_cc_number`` now has the correct translation. +- **Validation:** Fixed a bug where rules did not fire for array elements missing a key when using wildcard fields (e.g., ``contacts.friends.*.name``). - **View:** Fixed a bug where ``View`` would throw an error if the ``appOverridesFolder`` config property was not defined. See the repo's From 68146b2a37676715ef1625e6dac382966c0315f5 Mon Sep 17 00:00:00 2001 From: Adi Prasetyo Date: Mon, 2 Mar 2026 13:55:00 +0700 Subject: [PATCH 51/75] refactor: fix phpstan no type specified ValidationModelTest (#10008) Co-authored-by: neznaika0 --- tests/system/Models/ValidationModelTest.php | 3 +++ utils/phpstan-baseline/loader.neon | 2 +- utils/phpstan-baseline/missingType.property.neon | 7 +------ 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/system/Models/ValidationModelTest.php b/tests/system/Models/ValidationModelTest.php index 744238076e13..ba03bea98231 100644 --- a/tests/system/Models/ValidationModelTest.php +++ b/tests/system/Models/ValidationModelTest.php @@ -243,6 +243,9 @@ public function testValidationPassesWithMissingFields(): void public function testValidationWithGroupName(): void { $config = new class () extends Validation { + /** + * @var array{'id': string, 'name': array{string, string}, 'token': string} + */ public $grouptest = [ 'id' => 'is_natural_no_zero', 'name' => [ diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 91d2bdc61339..b277717823bf 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2117 errors +# total 2116 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/missingType.property.neon b/utils/phpstan-baseline/missingType.property.neon index b708adad5fab..df409e85e4df 100644 --- a/utils/phpstan-baseline/missingType.property.neon +++ b/utils/phpstan-baseline/missingType.property.neon @@ -1,4 +1,4 @@ -# total 102 errors +# total 101 errors parameters: ignoreErrors: @@ -506,8 +506,3 @@ parameters: message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:401\:\:\$updated_at has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property Config\\Validation@anonymous/tests/system/Models/ValidationModelTest\.php\:245\:\:\$grouptest has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/ValidationModelTest.php From d109a1a25e5d49a8359a7cbdea1cd77144b4845a Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Mon, 2 Mar 2026 13:25:20 +0100 Subject: [PATCH 52/75] refactor: fix dependency on test execution order (#10014) * refactor: fix dependency on test execution order * reset in SignalTest --- system/CLI/CLI.php | 2 +- tests/system/CLI/CLITest.php | 19 ++++++++++++------- tests/system/CLI/SignalTest.php | 7 +++++++ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php index bafaeb167f12..d5980bb9bc19 100644 --- a/system/CLI/CLI.php +++ b/system/CLI/CLI.php @@ -1136,7 +1136,7 @@ public static function reset(): void static::$initialized = false; static::$segments = []; static::$options = []; - static::$lastWrite = 'write'; + static::$lastWrite = null; static::$height = null; static::$width = null; static::$isColored = static::hasColorSupport(STDOUT); diff --git a/tests/system/CLI/CLITest.php b/tests/system/CLI/CLITest.php index 6c6ce4569c4c..3dc3a20c43fe 100644 --- a/tests/system/CLI/CLITest.php +++ b/tests/system/CLI/CLITest.php @@ -347,7 +347,7 @@ public function testWriteForeground(): void { CLI::write('test', 'red'); - $expected = "\033[0;31mtest\033[0m" . PHP_EOL; + $expected = PHP_EOL . "\033[0;31mtest\033[0m" . PHP_EOL; $this->assertSame($expected, $this->getStreamFilterBuffer()); } @@ -355,7 +355,7 @@ public function testWriteForegroundWithColorBefore(): void { CLI::write(CLI::color('green', 'green') . ' red', 'red'); - $expected = "\033[0;32mgreen\033[0m\033[0;31m red\033[0m" . PHP_EOL; + $expected = PHP_EOL . "\033[0;32mgreen\033[0m\033[0;31m red\033[0m" . PHP_EOL; $this->assertSame($expected, $this->getStreamFilterBuffer()); } @@ -363,7 +363,7 @@ public function testWriteForegroundWithColorAfter(): void { CLI::write('red ' . CLI::color('green', 'green'), 'red'); - $expected = "\033[0;31mred \033[0m\033[0;32mgreen\033[0m" . PHP_EOL; + $expected = PHP_EOL . "\033[0;31mred \033[0m\033[0;32mgreen\033[0m" . PHP_EOL; $this->assertSame($expected, $this->getStreamFilterBuffer()); } @@ -377,7 +377,7 @@ public function testWriteForegroundWithColorTwice(): void 'red', ); - $expected = "\033[0;32mgreen\033[0m\033[0;31m red \033[0m\033[0;32mgreen\033[0m" . PHP_EOL; + $expected = PHP_EOL . "\033[0;32mgreen\033[0m\033[0;31m red \033[0m\033[0;32mgreen\033[0m" . PHP_EOL; $this->assertSame($expected, $this->getStreamFilterBuffer()); } @@ -385,7 +385,7 @@ public function testWriteBackground(): void { CLI::write('test', 'red', 'green'); - $expected = "\033[0;31m\033[42mtest\033[0m" . PHP_EOL; + $expected = PHP_EOL . "\033[0;31m\033[42mtest\033[0m" . PHP_EOL; $this->assertSame($expected, $this->getStreamFilterBuffer()); } @@ -427,7 +427,7 @@ public function testShowProgress(): void CLI::write('third.'); CLI::showProgress(1, 20); - $expected = 'first.' . PHP_EOL . + $expected = PHP_EOL . 'first.' . PHP_EOL . "[\033[32m#.........\033[0m] 5% Complete" . PHP_EOL . "\033[1A[\033[32m#####.....\033[0m] 50% Complete" . PHP_EOL . "\033[1A[\033[32m##########\033[0m] 100% Complete" . PHP_EOL . @@ -447,7 +447,7 @@ public function testShowProgressWithoutBar(): void CLI::showProgress(false, 20); CLI::showProgress(false, 20); - $expected = 'first.' . PHP_EOL . "\007\007\007"; + $expected = PHP_EOL . 'first.' . PHP_EOL . "\007\007\007"; $this->assertSame($expected, $this->getStreamFilterBuffer()); } @@ -620,6 +620,7 @@ public static function provideTable(): iterable [ $oneRow, [], + PHP_EOL . '+---+-----+' . PHP_EOL . '| 1 | bar |' . PHP_EOL . '+---+-----+' . PHP_EOL . PHP_EOL, @@ -627,6 +628,7 @@ public static function provideTable(): iterable [ $oneRow, $head, + PHP_EOL . '+----+-------+' . PHP_EOL . '| ID | Title |' . PHP_EOL . '+----+-------+' . PHP_EOL . @@ -636,6 +638,7 @@ public static function provideTable(): iterable [ $manyRows, [], + PHP_EOL . '+---+-----------------+' . PHP_EOL . '| 1 | bar |' . PHP_EOL . '| 2 | bar * 2 |' . PHP_EOL . @@ -645,6 +648,7 @@ public static function provideTable(): iterable [ $manyRows, $head, + PHP_EOL . '+----+-----------------+' . PHP_EOL . '| ID | Title |' . PHP_EOL . '+----+-----------------+' . PHP_EOL . @@ -665,6 +669,7 @@ public static function provideTable(): iterable 'ID', 'タイトル', ], + PHP_EOL . '+------+----------+' . PHP_EOL . '| ID | タイトル |' . PHP_EOL . '+------+----------+' . PHP_EOL . diff --git a/tests/system/CLI/SignalTest.php b/tests/system/CLI/SignalTest.php index 4c1044d1e8e3..8e444895f226 100644 --- a/tests/system/CLI/SignalTest.php +++ b/tests/system/CLI/SignalTest.php @@ -53,6 +53,13 @@ protected function setUp(): void $this->command = new SignalCommand($this->logger, service('commands')); } + protected function tearDown(): void + { + CLI::reset(); + + parent::tearDown(); + } + public function testSignalRegistration(): void { $this->command->testRegisterSignals([SIGTERM, SIGINT], [SIGTERM => 'customTermHandler']); From df5ad3ca92a9639347555ddc6979884f2218bd41 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Tue, 3 Mar 2026 19:29:34 +0100 Subject: [PATCH 53/75] docs: add `none` parameter example to the query builder `like()` method (#10019) * docs: add none parameter example to the query builder like method * align code comment --- user_guide_src/source/database/query_builder.rst | 2 +- user_guide_src/source/database/query_builder/040.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index e901869fb5f7..480b8c18d92e 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -444,7 +444,7 @@ searches. .. literalinclude:: query_builder/039.php If you want to control where the wildcard (**%**) is placed, you can use - an optional third argument. Your options are ``before``, ``after`` and + an optional third argument. Your options are ``none``, ``before``, ``after`` and ``both`` (which is the default). .. literalinclude:: query_builder/040.php diff --git a/user_guide_src/source/database/query_builder/040.php b/user_guide_src/source/database/query_builder/040.php index 519686f9e4d9..d72a590cc11b 100644 --- a/user_guide_src/source/database/query_builder/040.php +++ b/user_guide_src/source/database/query_builder/040.php @@ -1,5 +1,6 @@ like('title', 'match', 'none'); // Produces: WHERE `title` LIKE 'match' ESCAPE '!' $builder->like('title', 'match', 'before'); // Produces: WHERE `title` LIKE '%match' ESCAPE '!' $builder->like('title', 'match', 'after'); // Produces: WHERE `title` LIKE 'match%' ESCAPE '!' $builder->like('title', 'match', 'both'); // Produces: WHERE `title` LIKE '%match%' ESCAPE '!' From a002b7c75ba9aa3f5c5fea772210c8a20857c853 Mon Sep 17 00:00:00 2001 From: Adi Prasetyo Date: Fri, 6 Mar 2026 04:29:32 +0700 Subject: [PATCH 54/75] refactor: fix phpstan no type specified UpdateModelTest (#10016) * refactor: fix phpstan no type specified UpdateModelTest * use native php type * use DateTimeInterface type --- tests/system/Models/UpdateModelTest.php | 55 +++++--- utils/phpstan-baseline/loader.neon | 2 +- .../missingType.property.neon | 122 +----------------- 3 files changed, 36 insertions(+), 143 deletions(-) diff --git a/tests/system/Models/UpdateModelTest.php b/tests/system/Models/UpdateModelTest.php index 7f0f6dd98527..2e2e61c03e34 100644 --- a/tests/system/Models/UpdateModelTest.php +++ b/tests/system/Models/UpdateModelTest.php @@ -19,6 +19,7 @@ use CodeIgniter\Entity\Entity; use CodeIgniter\Exceptions\InvalidArgumentException; use Config\Database; +use DateTimeInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use stdClass; @@ -208,13 +209,17 @@ public function testUpdateBatchValidationFail(): void public function testUpdateBatchWithEntity(): void { $entity1 = new class () extends Entity { - protected $id; - protected $name; - protected $email; - protected $country; - protected $deleted; - protected $created_at; - protected $updated_at; + protected int $id; + protected string $name; + protected string $email; + protected string $country; + protected bool $deleted; + protected DateTimeInterface $created_at; + protected DateTimeInterface $updated_at; + + /** + * @var array{'datamap': array{}, 'dates': array{string, string, string}, 'casts': array{}} + */ protected $_options = [ 'datamap' => [], 'dates' => [ @@ -227,13 +232,17 @@ public function testUpdateBatchWithEntity(): void }; $entity2 = new class () extends Entity { - protected $id; - protected $name; - protected $email; - protected $country; - protected $deleted; - protected $created_at; - protected $updated_at; + protected int $id; + protected string $name; + protected string $email; + protected string $country; + protected bool $deleted; + protected DateTimeInterface $created_at; + protected DateTimeInterface $updated_at; + + /** + * @var array{'datamap': array{}, 'dates': array{string, string, string}, 'casts': array{}} + */ protected $_options = [ 'datamap' => [], 'dates' => [ @@ -399,13 +408,17 @@ public function testUpdateWithEntityNoAllowedFields(): void $this->createModel(UserModel::class); $entity = new class () extends Entity { - protected $id; - protected $name; - protected $email; - protected $country; - protected $deleted; - protected $created_at; - protected $updated_at; + protected int $id; + protected string $name; + protected string $email; + protected string $country; + protected bool $deleted; + protected DateTimeInterface $created_at; + protected DateTimeInterface $updated_at; + + /** + * @var array{'datamap': array{}, 'dates': array{string, string, string}, 'casts': array{}} + */ protected $_options = [ 'datamap' => [], 'dates' => [ diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index b277717823bf..1bc172390faa 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2116 errors +# total 2092 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/missingType.property.neon b/utils/phpstan-baseline/missingType.property.neon index df409e85e4df..66f4f74b5ff3 100644 --- a/utils/phpstan-baseline/missingType.property.neon +++ b/utils/phpstan-baseline/missingType.property.neon @@ -1,4 +1,4 @@ -# total 101 errors +# total 77 errors parameters: ignoreErrors: @@ -386,123 +386,3 @@ parameters: message: '#^Property CodeIgniter\\Model@anonymous/tests/system/Models/SaveModelTest\.php\:290\:\:\$name has no type specified\.$#' count: 1 path: ../../tests/system/Models/SaveModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:210\:\:\$_options has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:210\:\:\$country has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:210\:\:\$created_at has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:210\:\:\$deleted has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:210\:\:\$email has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:210\:\:\$id has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:210\:\:\$name has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:210\:\:\$updated_at has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:229\:\:\$_options has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:229\:\:\$country has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:229\:\:\$created_at has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:229\:\:\$deleted has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:229\:\:\$email has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:229\:\:\$id has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:229\:\:\$name has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:229\:\:\$updated_at has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:401\:\:\$_options has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:401\:\:\$country has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:401\:\:\$created_at has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:401\:\:\$deleted has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:401\:\:\$email has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:401\:\:\$id has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:401\:\:\$name has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:401\:\:\$updated_at has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php From c1343867cce707b087ea02e8a5d6ed20f2bc00b8 Mon Sep 17 00:00:00 2001 From: neznaika0 Date: Fri, 6 Mar 2026 00:30:22 +0300 Subject: [PATCH 55/75] test: fix `CreateNewChangelogTest` removing uncommitted changes (#10020) * fix: Fixed CreateNewChangelogTest * fix: Update warning in test --- tests/system/AutoReview/CreateNewChangelogTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/system/AutoReview/CreateNewChangelogTest.php b/tests/system/AutoReview/CreateNewChangelogTest.php index fc348565464f..b6a89e047f88 100644 --- a/tests/system/AutoReview/CreateNewChangelogTest.php +++ b/tests/system/AutoReview/CreateNewChangelogTest.php @@ -65,6 +65,12 @@ protected function setUp(): void #[DataProvider('provideCreateNewChangelog')] public function testCreateNewChangelog(string $mode): void { + $output = exec('git status --porcelain | wc -l'); + + if ($output !== '0') { + $this->markTestSkipped('You have uncommitted operations that will be erased by this test.'); + } + $currentVersion = $this->currentVersion; $newVersion = $this->incrementVersion($currentVersion, $mode); From 61072c9df3525bbfb546c0dd891f730fb29d7702 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Sun, 8 Mar 2026 07:40:44 +0100 Subject: [PATCH 56/75] refactor: update tests with old entities definition (#10026) * refactor: update tests with old entities definition * phpstan baseline * remove unused public properties from UserModel --- tests/_support/Models/UserModel.php | 3 - tests/system/Models/InsertModelTest.php | 51 +++--- tests/system/Models/SaveModelTest.php | 52 +++--- tests/system/Models/UpdateModelTest.php | 111 +++++-------- utils/phpstan-baseline/loader.neon | 2 +- .../missingType.property.neon | 149 +----------------- 6 files changed, 83 insertions(+), 285 deletions(-) diff --git a/tests/_support/Models/UserModel.php b/tests/_support/Models/UserModel.php index 8e3a1b34e13f..d4d41b5bf0b7 100644 --- a/tests/_support/Models/UserModel.php +++ b/tests/_support/Models/UserModel.php @@ -30,7 +30,4 @@ class UserModel extends Model protected $returnType = 'object'; protected $useSoftDeletes = true; protected $dateFormat = 'datetime'; - public $name = ''; - public $email = ''; - public $country = ''; } diff --git a/tests/system/Models/InsertModelTest.php b/tests/system/Models/InsertModelTest.php index baf6b45ea7f0..74a2ffaa89fa 100644 --- a/tests/system/Models/InsertModelTest.php +++ b/tests/system/Models/InsertModelTest.php @@ -194,21 +194,19 @@ public function testInsertResultFail(): void public function testInsertBatchNewEntityWithDateTime(): void { $entity = new class () extends Entity { - protected $id; - protected $name; - protected $email; - protected $country; - protected $deleted; - protected $created_at; - protected $updated_at; - protected $_options = [ - 'datamap' => [], - 'dates' => [ - 'created_at', - 'updated_at', - 'deleted_at', - ], - 'casts' => [], + protected $attributes = [ + 'id' => null, + 'name' => null, + 'email' => null, + 'country' => null, + 'deleted_at' => null, + 'created_at' => null, + 'updated_at' => null, + ]; + protected $dates = [ + 'created_at', + 'updated_at', + 'deleted_at', ]; }; @@ -219,13 +217,13 @@ public function testInsertBatchNewEntityWithDateTime(): void $entity->name = 'Mark One'; $entity->email = 'markone@example.com'; $entity->country = 'India'; - $entity->deleted = 0; + $entity->deleted_at = null; $entity->created_at = new Time('now'); $entityTwo->name = 'Mark Two'; $entityTwo->email = 'marktwo@example.com'; $entityTwo->country = 'India'; - $entityTwo->deleted = 0; + $entityTwo->deleted_at = null; $entityTwo->created_at = $entity->created_at; $this->setPrivateProperty($this->model, 'useTimestamps', true); @@ -288,21 +286,10 @@ public function testInsertEntityWithNoDataExceptionNoAllowedData(): void $this->createModel(UserModel::class); $entity = new class () extends Entity { - protected $id; - protected $name; - protected $email; - protected $country; - protected $deleted; - protected $created_at; - protected $updated_at; - protected $_options = [ - 'datamap' => [], - 'dates' => [ - 'created_at', - 'updated_at', - 'deleted_at', - ], - 'casts' => [], + protected $dates = [ + 'created_at', + 'updated_at', + 'deleted_at', ]; }; diff --git a/tests/system/Models/SaveModelTest.php b/tests/system/Models/SaveModelTest.php index bf283c4a6c52..b2bdaa2a4103 100644 --- a/tests/system/Models/SaveModelTest.php +++ b/tests/system/Models/SaveModelTest.php @@ -239,21 +239,19 @@ public function testEmptySaveData(): void public function testSaveNewEntityWithDateTime(): void { $entity = new class () extends Entity { - protected $id; - protected $name; - protected $email; - protected $country; - protected $deleted; - protected $created_at; - protected $updated_at; - protected $_options = [ - 'datamap' => [], - 'dates' => [ - 'created_at', - 'updated_at', - 'deleted_at', - ], - 'casts' => [], + protected $attributes = [ + 'id' => null, + 'name' => null, + 'email' => null, + 'country' => null, + 'deleted_at' => null, + 'created_at' => null, + 'updated_at' => null, + ]; + protected $dates = [ + 'created_at', + 'updated_at', + 'deleted_at', ]; }; @@ -262,7 +260,7 @@ public function testSaveNewEntityWithDateTime(): void $entity->name = 'Mark'; $entity->email = 'mark@example.com'; $entity->country = 'India'; - $entity->deleted = 0; + $entity->deleted_at = null; $entity->created_at = new Time('now'); $this->setPrivateProperty($this->model, 'useTimestamps', true); @@ -272,18 +270,16 @@ public function testSaveNewEntityWithDateTime(): void public function testSaveNewEntityWithDate(): void { $entity = new class () extends Entity { - protected $id; - protected $name; - protected $created_at; - protected $updated_at; - protected $_options = [ - 'datamap' => [], - 'dates' => [ - 'created_at', - 'updated_at', - 'deleted_at', - ], - 'casts' => [], + protected $attributes = [ + 'id' => null, + 'name' => null, + 'created_at' => null, + 'updated_at' => null, + ]; + protected $dates = [ + 'created_at', + 'updated_at', + 'deleted_at', ]; }; diff --git a/tests/system/Models/UpdateModelTest.php b/tests/system/Models/UpdateModelTest.php index 2e2e61c03e34..ed2ebf7a9a93 100644 --- a/tests/system/Models/UpdateModelTest.php +++ b/tests/system/Models/UpdateModelTest.php @@ -19,7 +19,6 @@ use CodeIgniter\Entity\Entity; use CodeIgniter\Exceptions\InvalidArgumentException; use Config\Database; -use DateTimeInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use stdClass; @@ -209,64 +208,34 @@ public function testUpdateBatchValidationFail(): void public function testUpdateBatchWithEntity(): void { $entity1 = new class () extends Entity { - protected int $id; - protected string $name; - protected string $email; - protected string $country; - protected bool $deleted; - protected DateTimeInterface $created_at; - protected DateTimeInterface $updated_at; - - /** - * @var array{'datamap': array{}, 'dates': array{string, string, string}, 'casts': array{}} - */ - protected $_options = [ - 'datamap' => [], - 'dates' => [ - 'created_at', - 'updated_at', - 'deleted_at', - ], - 'casts' => [], + protected $attributes = [ + 'id' => null, + 'name' => null, + 'country' => null, + 'deleted_at' => null, ]; - }; - - $entity2 = new class () extends Entity { - protected int $id; - protected string $name; - protected string $email; - protected string $country; - protected bool $deleted; - protected DateTimeInterface $created_at; - protected DateTimeInterface $updated_at; - - /** - * @var array{'datamap': array{}, 'dates': array{string, string, string}, 'casts': array{}} - */ - protected $_options = [ - 'datamap' => [], - 'dates' => [ - 'created_at', - 'updated_at', - 'deleted_at', - ], - 'casts' => [], + protected $dates = [ + 'created_at', + 'updated_at', + 'deleted_at', ]; }; - $entity1->id = 1; - $entity1->name = 'Jones Martin'; - $entity1->country = 'India'; - $entity1->deleted = 0; + $entity2 = clone $entity1; + + $entity1->id = 1; + $entity1->name = 'Jones Martin'; + $entity1->country = 'India'; + $entity1->deleted_at = null; $entity1->syncOriginal(); // Update the entity. $entity1->country = 'China'; // This entity is not updated. - $entity2->id = 4; - $entity2->name = 'Jones Martin'; - $entity2->country = 'India'; - $entity2->deleted = 0; + $entity2->id = 4; + $entity2->name = 'Jones Martin'; + $entity2->country = 'India'; + $entity2->deleted_at = null; $entity2->syncOriginal(); $model = $this->createModel(UserModel::class); @@ -408,33 +377,27 @@ public function testUpdateWithEntityNoAllowedFields(): void $this->createModel(UserModel::class); $entity = new class () extends Entity { - protected int $id; - protected string $name; - protected string $email; - protected string $country; - protected bool $deleted; - protected DateTimeInterface $created_at; - protected DateTimeInterface $updated_at; - - /** - * @var array{'datamap': array{}, 'dates': array{string, string, string}, 'casts': array{}} - */ - protected $_options = [ - 'datamap' => [], - 'dates' => [ - 'created_at', - 'updated_at', - 'deleted_at', - ], - 'casts' => [], + protected $attributes = [ + 'id' => null, + 'name' => null, + 'email' => null, + 'country' => null, + 'deleted_at' => null, + 'created_at' => null, + 'updated_at' => null, + ]; + protected $dates = [ + 'created_at', + 'updated_at', + 'deleted_at', ]; }; - $entity->id = 1; - $entity->name = 'Jones Martin'; - $entity->email = 'jones@example.org'; - $entity->country = 'India'; - $entity->deleted = 0; + $entity->id = 1; + $entity->name = 'Jones Martin'; + $entity->email = 'jones@example.org'; + $entity->country = 'India'; + $entity->deleted_at = null; $id = $this->model->insert($entity); diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 1bc172390faa..65057bd0736b 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2092 errors +# total 2063 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/missingType.property.neon b/utils/phpstan-baseline/missingType.property.neon index 66f4f74b5ff3..2dce190bf003 100644 --- a/utils/phpstan-baseline/missingType.property.neon +++ b/utils/phpstan-baseline/missingType.property.neon @@ -1,4 +1,4 @@ -# total 77 errors +# total 48 errors parameters: ignoreErrors: @@ -238,151 +238,6 @@ parameters: path: ../../tests/system/Database/Live/MySQLi/NumberNativeTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:196\:\:\$_options has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/InsertModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:196\:\:\$country has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/InsertModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:196\:\:\$created_at has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/InsertModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:196\:\:\$deleted has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/InsertModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:196\:\:\$email has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/InsertModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:196\:\:\$id has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/InsertModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:196\:\:\$name has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/InsertModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:196\:\:\$updated_at has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/InsertModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:290\:\:\$_options has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/InsertModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:290\:\:\$country has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/InsertModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:290\:\:\$created_at has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/InsertModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:290\:\:\$deleted has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/InsertModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:290\:\:\$email has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/InsertModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:290\:\:\$id has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/InsertModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:290\:\:\$name has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/InsertModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:290\:\:\$updated_at has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/InsertModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/SaveModelTest\.php\:241\:\:\$_options has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/SaveModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/SaveModelTest\.php\:241\:\:\$country has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/SaveModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/SaveModelTest\.php\:241\:\:\$created_at has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/SaveModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/SaveModelTest\.php\:241\:\:\$deleted has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/SaveModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/SaveModelTest\.php\:241\:\:\$email has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/SaveModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/SaveModelTest\.php\:241\:\:\$id has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/SaveModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/SaveModelTest\.php\:241\:\:\$name has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/SaveModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/SaveModelTest\.php\:241\:\:\$updated_at has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/SaveModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/SaveModelTest\.php\:274\:\:\$_options has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/SaveModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/SaveModelTest\.php\:274\:\:\$created_at has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/SaveModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/SaveModelTest\.php\:274\:\:\$id has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/SaveModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/SaveModelTest\.php\:274\:\:\$name has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/SaveModelTest.php - - - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/SaveModelTest\.php\:274\:\:\$updated_at has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/SaveModelTest.php - - - - message: '#^Property CodeIgniter\\Model@anonymous/tests/system/Models/SaveModelTest\.php\:290\:\:\$name has no type specified\.$#' + message: '#^Property CodeIgniter\\Model@anonymous/tests/system/Models/SaveModelTest\.php\:286\:\:\$name has no type specified\.$#' count: 1 path: ../../tests/system/Models/SaveModelTest.php From 84834037b2f170a3b860ee119a3e631f8ca4b928 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Tue, 10 Mar 2026 16:07:57 +0800 Subject: [PATCH 57/75] chore: convert forum announcement instruction as a proper agent skill (#10031) --- .ai/forum-announcement-instructions.md | 200 --------------------- .gitattributes | 1 - .github/skills/README.md | 25 +++ .github/skills/forum-announcement/SKILL.md | 112 ++++++++++++ 4 files changed, 137 insertions(+), 201 deletions(-) delete mode 100644 .ai/forum-announcement-instructions.md create mode 100644 .github/skills/README.md create mode 100644 .github/skills/forum-announcement/SKILL.md diff --git a/.ai/forum-announcement-instructions.md b/.ai/forum-announcement-instructions.md deleted file mode 100644 index 7b42bbe841fe..000000000000 --- a/.ai/forum-announcement-instructions.md +++ /dev/null @@ -1,200 +0,0 @@ -# Forum Announcement Instructions - -## Purpose -These instructions guide the creation of forum announcements for new CodeIgniter4 releases using myBB formatting. - -## Process Overview - -### 1. Gather Information -Read the following source files: -- **CHANGELOG.md** - Main changelog with GitHub PR links -- **user_guide_src/source/changelogs/v{VERSION}.rst** - Detailed RST changelog with comprehensive explanations - -### 2. Version Strategy -For dual releases (e.g., maintenance + major): -- **List the maintenance version first** (e.g., v4.6.5 before v4.7.0) -- Clearly explain which version users should choose based on their PHP version -- Provide separate links for each version - -### 3. Announcement Structure - -Use this **example** structure with myBB formatting: - -``` -[size=x-large][b]CodeIgniter {VERSION} & {VERSION} Released![/b][/size] - -Introduction paragraph(s) - mention maintenance release first, then major release - -Links to GitHub releases and changelogs - -[hr] - -[size=large][b]Which Version Should I Use?[/b][/size] -- Guidance for users on which version to choose - -[hr] - -[size=large][b]What's in CodeIgniter {MAINTENANCE_VERSION}?[/b][/size] -- Bug fixes section (if maintenance release) - -[hr] - -[size=large][b]Highlights & New Features ({MAJOR_VERSION})[/b][/size] -- Top features - -[hr] - -[size=large][b]Notable Enhancements[/b][/size] -- Bulleted list of improvements - -[hr] - -[size=large][b]Cache Improvements[/b][/size] -- Cache-specific updates - -[hr] - -[size=large][b]Database & Model Updates[/b][/size] -- Database-related changes - -[hr] - -[size=large][b]HTTP & Request Features[/b][/size] -- HTTP/Request improvements - -[hr] - -[size=large][b]Security & Quality[/b][/size] -- Security updates - -[hr] - -[size=large][b]Breaking Changes[/b][/size] -- Detailed breaking changes with explanations -- Include "Removed Deprecated Items" subsection - -[hr] - -[size=large][b]Other Notable Changes[/b][/size] -- Other miscellaneous updates - -[hr] - -[size=large][b]Thanks to Our Contributors[/b][/size] -- Acknowledge contributors - -[hr] - -Upgrade guide links -Issue reporting link -Closing message - -[hr] - -AI disclosure note -``` - -### 4. myBB Formatting Codes - -Use these myBB codes: -- `[b]text[/b]` - Bold -- `[i]text[/i]` - Italic -- `[size=x-large]text[/size]` - Extra large text -- `[size=large]text[/size]` - Large text -- `[size=small]text[/size]` - Small text -- `[url=URL]text[/url]` - Links -- `[list]` - Unordered list -- `[list=1]` - Ordered list -- `[*]` - List item -- `[hr]` - Horizontal rule -- `` `code` `` - Inline code (use double backticks) - -### 4a. Emoticon Escaping - -myBB automatically converts emoticon patterns like `:s` (colon immediately followed by "s") into emoji. To prevent this in code blocks: - -**Replace all colons with HTML entity `:`** - -Examples: -- `Entity::setAttributes()` → `Entity::setAttributes()` -- `H:i:s` (time format) → `H:i:s` - -This prevents emoticon conversion while displaying properly as a colon character. - -### 5. Content Guidelines - -**Highlights Section:** -- Emphasize PHP version requirements -- Mark experimental features as [i]Experimental[/i] -- List 3-5 most impactful features - -**Enhancements:** -- Include specific config options and method names -- Use `` `code` `` for class names, methods, and config values -- Be specific about which handlers support which features - -**Breaking Changes:** -- Provide detailed explanations, not just bullet points -- Include the old behavior vs. new behavior -- Mention exception type changes -- List removed deprecated items separately -- Reference specific methods and properties - -**Bug Fixes (for maintenance releases):** -- Use ordered lists `[list=1]` -- Provide clear before/after descriptions - -### 6. Key Points - -1. **Tone:** Professional yet friendly, engaging for community (no emojis - they don't render properly in myBB) -2. **Accuracy:** Always cross-reference RST changelog for technical details -3. **Clarity:** Explain breaking changes thoroughly -4. **Contents:** Adjust sections based on the release content (e.g., skip "Cache Improvements" if no cache changes) -5. **Brevity:** For single releases, omit the "Which Version Should I Use?" section -6. **Links:** Include GitHub release links, changelogs, and upgrade guides -7. **Attribution:** Thank contributors by username -8. **Disclosure:** Add AI assistance disclosure at the end - -### 7. Version Priority - -For dual releases: -- Mention maintenance version (e.g., 4.6.5) **before** major version (e.g., 4.7.0) -- In "Which Version Should I Use?" section, list lower PHP version option first - -### 8. Final Disclosure - -Always include at the end: - -``` -[hr] - -[size=small][i]Note: This announcement was created with the assistance of GitHub Copilot (Claude Sonnet 4.5).[/i][/size] -``` - -Update the agent name as necessary. - -## Output File - -Save the announcement as: `v{VERSION}-announcement.txt` in the repository root - -## Example Workflow - -```bash -# 1. Read changelogs -Read: CHANGELOG.md -Read: user_guide_src/source/changelogs/v4.7.0.rst -Read: user_guide_src/source/changelogs/v4.6.5.rst (if maintenance release) - -# 2. Create announcement -Create: v4.7.0-announcement.txt - -# 3. Structure content -- Introduction with both versions -- Version selection guidance -- Maintenance release details first -- Major release details -- Breaking changes (comprehensive) -- Contributors -- Upgrade links -- AI disclosure -``` diff --git a/.gitattributes b/.gitattributes index 91c62297637f..3f67f2e35167 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,7 +8,6 @@ .gitignore export-ignore # admin files -.ai/ export-ignore .github/ export-ignore admin/ export-ignore contributing/ export-ignore diff --git a/.github/skills/README.md b/.github/skills/README.md new file mode 100644 index 000000000000..1c641b9e335a --- /dev/null +++ b/.github/skills/README.md @@ -0,0 +1,25 @@ +# Skills + +This directory contains workspace agent skills for maintainers. + +## Available Skills +- `forum-announcement`: Create CodeIgniter4 release forum announcements in myBB format. + +## Structure +- `.github/skills//SKILL.md`: Skill metadata and workflow. +- `.github/skills//references/`: Optional supporting docs loaded on demand. +- `.github/skills//assets/`: Optional templates and reusable files. +- `.github/skills//scripts/`: Optional executables for automation. + +## Usage +1. Open Copilot Chat in this workspace. +2. Invoke `/`. +3. Provide inputs, for example: +``` +/forum-announcement 4.7.0 +``` + +## Maintainer Notes +- Keep `name` in `SKILL.md` identical to its folder name. +- Keep the `description` keyword-rich so the skill is discoverable. +- Use references only when needed; avoid duplicating guidance between `SKILL.md` and `references/`. diff --git a/.github/skills/forum-announcement/SKILL.md b/.github/skills/forum-announcement/SKILL.md new file mode 100644 index 000000000000..e9e7c731a1a3 --- /dev/null +++ b/.github/skills/forum-announcement/SKILL.md @@ -0,0 +1,112 @@ +--- +name: forum-announcement +description: 'Create CodeIgniter4 release forum announcements in myBB format. Use when preparing release posts, dual-version announcements (maintenance + major), changelog summaries, contributor acknowledgements, upgrade guidance, and final forum-ready text files.' +argument-hint: 'Target release version(s), e.g. "4.6.5 and 4.7.0" or "4.7.1"' +user-invocable: true +--- + +# Forum Announcement + +Create a forum-ready CodeIgniter4 release announcement using myBB markup. + +## When to Use +- Publish a new CodeIgniter4 release announcement on the forum. +- Convert changelog and RST details into a structured, readable post. +- Produce a dual-release announcement with maintenance-first ordering. + +## Inputs +- One version (`4.x.y`) or two versions (`maintenance`, then `major`). + +## Output Naming +- `v{VERSION}` is a placeholder and must be replaced by an actual version string. +- Single release example: `v4.7.1-announcement.txt`. +- Dual release example (`4.6.5` + `4.7.0`): use the major version filename `v4.7.0-announcement.txt`. +- Save the file at repository root. + +## Procedure +1. Read `CHANGELOG.md` for release summary and PR references. +2. Read `user_guide_src/source/changelogs/v{VERSION}.rst` for each target version. +3. Determine release mode: + - Single release: one `4.x.y` version. + - Dual release: maintenance + major; maintenance is presented first in the post. +4. Draft the post with this structure (remove sections that do not apply): + - Title. + - Intro paragraphs. + - Release links. + - Which version to use (dual release only). + - Maintenance release section (dual release only). + - Highlights and new features. + - Notable enhancements. + - Security and quality. + - Breaking changes. + - Other notable changes. + - Contributor thanks. + - Upgrade links, issue reporting link, closing. + - AI-assistance disclosure. +5. Apply myBB formatting and escaping rules in this file. +6. Save final content as `v{VERSION}-announcement.txt` at repository root, replacing `{VERSION}` with the actual version. + +## Required Announcement Template +```text +[size=x-large][b]CodeIgniter {VERSION} Released![/b][/size] + +Introduction paragraph(s) + +[url=RELEASE_URL]GitHub Release[/url] +[url=CHANGELOG_URL]Changelog[/url] + +[hr] + +[size=large][b]Highlights & New Features[/b][/size] + +[hr] + +[size=large][b]Notable Enhancements[/b][/size] + +[hr] + +[size=large][b]Breaking Changes[/b][/size] + +[hr] + +[size=large][b]Thanks to Our Contributors[/b][/size] + +[hr] + +[url=UPGRADE_GUIDE_URL]Upgrade Guide[/url] +[url=ISSUES_URL]Report Issues[/url] + +[hr] + +[size=small][i]Note: This announcement was created with the assistance of GitHub Copilot (GPT-5.3-Codex).[/i][/size] +``` + +For dual releases, adapt the title and body to include both versions, with maintenance version first, plus a dedicated "Which Version Should I Use?" section. + +## myBB Rules +- Use `[b]`, `[i]`, `[size=x-large]`, `[size=large]`, `[size=small]`, `[url=...]`, `[list]`, `[list=1]`, `[*]`, and `[hr]`. +- Use double backticks for inline code-like text when needed. +- Escape colon patterns that may trigger myBB emoticons by replacing `:` with `:` in code-like snippets. + +## Content Quality Rules +- Tone is professional, friendly, and community-facing. +- Do not use emojis. +- Verify details against RST changelog entries before finalizing. +- Explain breaking changes with old behavior versus new behavior. +- Include a "Removed Deprecated Items" subsection when applicable. +- For maintenance bug-fix summaries, prefer ordered lists with concise before/after statements. +- Thank contributors by GitHub username. +- Include upgrade links and issue reporting links. + +## Version Guidance Rules +- Dual releases must list maintenance before major. +- The "Which Version Should I Use?" section is required for dual releases. +- In version guidance, present the lower-PHP-support option first. +- For single releases, omit the "Which Version Should I Use?" section. + +## Self-Check +- Maintenance version is presented before major version. +- PHP-version guidance and upgrade links are included. +- Breaking changes are clearly explained. +- Final AI-assistance disclosure is present and should be adjusted to the actual AI model used if different from the template. +- Output filename is concrete and does not include braces. From a37cdf35369bf24f4e2fecd24c08f63a17a69491 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Tue, 10 Mar 2026 16:11:49 +0800 Subject: [PATCH 58/75] feat: Add random test execution verification system (#10015) * feat: Add random test execution verification system * Add local instructions in `tests/README.md` --- .github/scripts/random-tests-config.txt | 49 ++ .github/scripts/run-random-tests.sh | 888 ++++++++++++++++++++ .github/workflows/test-random-execution.yml | 225 +++++ tests/README.md | 42 + 4 files changed, 1204 insertions(+) create mode 100644 .github/scripts/random-tests-config.txt create mode 100755 .github/scripts/run-random-tests.sh create mode 100644 .github/workflows/test-random-execution.yml diff --git a/.github/scripts/random-tests-config.txt b/.github/scripts/random-tests-config.txt new file mode 100644 index 000000000000..bb08ccef669d --- /dev/null +++ b/.github/scripts/random-tests-config.txt @@ -0,0 +1,49 @@ +# CodeIgniter4 Components Verified for Random Test Execution +# This file lists components that have been verified to pass all tests +# when run in random order (--order-by=random) +# +# Format: One component directory name per line (matching tests/system//) +# Comments start with # +# Uncomment components as they are verified to work with random execution +# +# Reference: https://github.com/codeigniter4/CodeIgniter4/issues/9968 + +API +# AutoReview +# Autoloader +# Cache +CLI +# Commands +# Config +# Cookie +# DataCaster +# DataConverter +# Database +# Debug +# Email +# Encryption +# Entity +# Events +# Files +# Filters +# Format +# HTTP +# Helpers +# Honeypot +# HotReloader +# I18n +# Images +# Language +# Log +# Models +# Pager +# Publisher +# RESTful +# Router +# Security +# Session +# Test +# Throttle +# Typography +# Validation +# View diff --git a/.github/scripts/run-random-tests.sh b/.github/scripts/run-random-tests.sh new file mode 100755 index 000000000000..45376fb58fb2 --- /dev/null +++ b/.github/scripts/run-random-tests.sh @@ -0,0 +1,888 @@ +#!/usr/bin/env bash + +################################################################################ +# CodeIgniter4 - Random Test Execution Verification +# +# Verifies that tests for each component pass when run in random order. +# Reads a list of components from a config file and tests each with parallel +# execution while respecting a configurable concurrency limit. +# +# Usage: ./run-random-tests.sh [options] +# Options: +# -q, --quiet Suppress debug output +# -c, --component COMPONENT Test single COMPONENT (overrides config file) +# -n, --max-jobs MAX_JOBS Limit concurrent test jobs (auto-detect if omitted) +# -r, --repeat REPEAT Repeat full component run REPEAT times +# -t, --timeout TIMEOUT Per-component TIMEOUT in seconds (0 disables, default: 300) +# -h, --help Show this help message +# +# Examples: +# ./run-random-tests.sh --repeat 10 +# ./run-random-tests.sh --component Database --repeat 5 +# ./run-random-tests.sh --repeat 10 --max-jobs 4 --quiet +################################################################################ + +set -u +trap 'kill "${bg_pids[@]:-}" 2>/dev/null; wait 2>/dev/null' EXIT INT TERM + +################################################################################ +# CONFIGURATION & INITIALIZATION +################################################################################ + +# Color codes for terminal output +readonly RED='\033[0;31m' +readonly BOLD_RED='\033[1;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[0;33m' +readonly BOLD_YELLOW='\033[1;33m' +readonly BLUE='\033[0;34m' +readonly BOLD='\033[1m' +readonly RESET='\033[0m' + +# Script paths +readonly script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +readonly project_root="$( cd "$script_dir/../.." && pwd )" +readonly config_file="$script_dir/random-tests-config.txt" +readonly results_dir="$project_root/build/random-tests" + +# Runtime variables +quiet="" +component="" +max_jobs="" +repeat_count=1 +timeout_seconds=300 +first_result=true +declare -a bg_pids=() + +# Counters +completed=0 +passed=0 +failed=0 +skipped=0 +total=0 + +# Component state tracking +declare -a displayed_components=() +declare -a failed_components=() +declare -a skipped_components=() + +################################################################################ +# UTILITY FUNCTIONS +################################################################################ + +is_quiet() { + [[ "$quiet" == "--quiet" || "$quiet" == "-q" ]] +} + +should_show_spinner() { + if ! is_quiet; then + return 1 + fi + + if [[ -n "${GITHUB_ACTIONS:-}" ]]; then + return 1 + fi + + return 0 +} + +show_spinner() { + local spinner_marker="$results_dir/run_random_tests_$$.spinner" + touch "$spinner_marker" + + local spinner_chars=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') + local spinner_index=0 + + echo -ne "\033[?25l" >&2 + + ( + while [[ -f "$spinner_marker" ]]; do + echo -ne "\033[2K\r${BLUE}${spinner_chars[$((spinner_index % 10))]} Running tests in parallel...${RESET}" >&2 + ((spinner_index++)) + sleep 0.1 + done + echo -ne "\033[2K\r" >&2 + ) & + + echo "$!" > "${spinner_marker}.pid" +} + +stop_spinner() { + local spinner_marker="$results_dir/run_random_tests_$$.spinner" + + if [[ ! -f "$spinner_marker" ]]; then + echo -ne "\033[?25h" >&2 + return + fi + + rm -f "$spinner_marker" + echo -ne "\033[2K\r" >&2 + + if [[ -f "${spinner_marker}.pid" ]]; then + kill "$(cat "${spinner_marker}.pid")" 2>/dev/null || true + rm -f "${spinner_marker}.pid" + fi + + wait 2>/dev/null + echo -ne "\033[?25h" >&2 +} + +print_header() { + echo -e "${BLUE}==============================================================================${RESET}" + echo -e "${BLUE}$1${RESET}" + echo -e "${BLUE}==============================================================================${RESET}" +} + +print_success() { + echo -e "${GREEN}✓ $1${RESET}" +} + +print_error() { + echo -e "${RED}✗ $1${RESET}" +} + +print_warning() { + echo -e "${YELLOW}⚠ $1${RESET}" +} + +print_debug() { + if ! is_quiet; then + echo -e "${BLUE}🔧 $1${RESET}" + fi +} + +inflect() { + local count=$1 + local singular=$2 + local plural=${3:-${singular}s} + + if [[ "$count" -eq 1 ]]; then + echo "$singular" + return + fi + + echo "$plural" +} + +generate_phpunit_random_seed() { + local seed=$(date +%s) + + if [[ ! "$seed" =~ ^[0-9]+$ ]]; then + echo 1 + return + fi + + echo $((seed + (RANDOM % 1000))) +} + +extract_test_order() { + local events_file="$1" + + if [[ ! -f "$events_file" ]]; then + return + fi + + while IFS= read -r line; do + if [[ "$line" =~ ^Test\ Prepared\ \((.*)\)$ ]]; then + echo "${BASH_REMATCH[1]}" + fi + done < "$events_file" +} + +get_failed_test_predecessor() { + local events_file="$1" + + if [[ ! -f "$events_file" ]]; then + return + fi + + local failed_test="" + while IFS= read -r line; do + if [[ "$line" =~ ^Test\ Failed\ \((.*)\)$ ]] \ + || [[ "$line" =~ ^Test\ Errored\ \((.*)\)$ ]] \ + || [[ "$line" =~ ^Test\ Considered\ Risky\ \((.*)\)$ ]] \ + || [[ "$line" =~ ^Test\ Triggered\ Warning\ \((.*)\)$ ]] \ + || [[ "$line" =~ ^Test\ Triggered\ PHP\ Warning\ \((.*)\)$ ]] \ + || [[ "$line" =~ ^Test\ Triggered\ PHP\ Notice\ \((.*)\)$ ]] \ + || [[ "$line" =~ ^Test\ Triggered\ PHP\ Deprecation\ \((.*)\)$ ]] \ + || [[ "$line" =~ ^Test\ Triggered\ PHP\ Unit\ Deprecation\ \((.*)\)$ ]]; then + failed_test="${BASH_REMATCH[1]}" + break + fi + done < "$events_file" + + if [[ -z "$failed_test" ]]; then + return + fi + + local previous_test="" + while IFS= read -r line; do + if [[ "$line" =~ ^Test\ Prepared\ \((.*)\)$ ]]; then + local current_test="${BASH_REMATCH[1]}" + if [[ "$current_test" == "$failed_test" ]]; then + echo "$failed_test|$previous_test" + return + fi + previous_test="$current_test" + fi + done < "$events_file" + + echo "$failed_test|" +} + +print_result() { + local type=$1 completed=$2 total=$3 component=$4 elapsed_str=$5 + local padded=$(printf "%${#total}d" "$completed") + local color symbol + + case "$type" in + success) color=$GREEN; symbol="✓" ;; + failure) color=$RED; symbol="✗" ;; + warning) color=$YELLOW; symbol="⚠" ;; + esac + + echo -e "${BOLD_YELLOW}[${padded}/${total}]${RESET} ${color}${symbol}${RESET} Component: ${BLUE}$component${RESET} ${YELLOW}($elapsed_str)${RESET}" +} + +format_elapsed_time() { + local elapsed=$1 + + if ! [[ "$elapsed" =~ ^[0-9]+$ ]]; then + echo "N/A" + return + fi + + if [[ $elapsed -ge 60000 ]]; then + printf "%dm %.2fs" "$((elapsed / 60000))" "$(echo "scale=2; ($elapsed % 60000) / 1000" | bc)" + elif [[ $elapsed -ge 1000 ]]; then + printf "%.2fs" "$(echo "scale=2; $elapsed / 1000" | bc)" + else + echo "${elapsed}ms" + fi +} + +################################################################################ +# CONFIGURATION & VALIDATION +################################################################################ + +parse_arguments() { + while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + show_help + exit 0 + ;; + -q|--quiet) + quiet="--quiet" + shift + ;; + -c|--component) + if [[ $# -lt 2 ]]; then + print_error "Missing value for --component" + exit 1 + fi + + component="$2" + shift 2 + ;; + -n|--max-jobs) + if [[ $# -lt 2 ]]; then + print_error "Missing value for --max-jobs" + exit 1 + fi + + if [[ ! "$2" =~ ^[1-9][0-9]*$ ]]; then + print_error "--max-jobs must be a positive integer" + exit 1 + fi + + max_jobs="$2" + shift 2 + ;; + -r|--repeat) + if [[ $# -lt 2 ]]; then + print_error "Missing value for --repeat" + exit 1 + fi + + if [[ ! "$2" =~ ^[1-9][0-9]*$ ]]; then + print_error "--repeat must be a positive integer" + exit 1 + fi + + repeat_count="$2" + shift 2 + ;; + -t|--timeout) + if [[ $# -lt 2 ]]; then + print_error "Missing value for --timeout" + exit 1 + fi + + if [[ ! "$2" =~ ^[0-9]+$ ]]; then + print_error "--timeout must be a non-negative integer" + exit 1 + fi + + timeout_seconds="$2" + shift 2 + ;; + *) + print_error "Unknown option '$1'" + exit 1 + ;; + esac + done +} + +show_help() { + echo "CodeIgniter4 - Random Test Execution Verification" + echo "" + echo -e "${YELLOW}Usage:${RESET}" + echo -e " ${GREEN}$0${RESET} [options]" + echo "" + echo -e "${YELLOW}Options:${RESET}" + echo -e " ${GREEN}-q, --quiet${RESET} Suppress debug output" + echo -e " ${GREEN}-c, --component COMPONENT${RESET} Test single ${GREEN}COMPONENT${RESET} (overrides config file)" + echo -e " ${GREEN}-n, --max-jobs MAX_JOBS${RESET} Limit concurrent test jobs (auto-detect if omitted)" + echo -e " ${GREEN}-r, --repeat REPEAT${RESET} Repeat full component run ${GREEN}REPEAT${RESET} times" + echo -e " ${GREEN}-t, --timeout TIMEOUT${RESET} Per-component ${GREEN}TIMEOUT${RESET} in seconds (0 disables, default: 300)" + echo -e " ${GREEN}-h, --help${RESET} Show this help message" +} + +auto_detect_max_jobs() { + if command -v nproc &>/dev/null; then + nproc + return + fi + + if command -v sysctl &>/dev/null; then + sysctl -n hw.ncpu 2>/dev/null + return + fi + + echo 4 +} + +check_required_tools() { + local -a missing_tools=() + local -a required_tools=(date find sort sed grep bc) + local tool + + for tool in "${required_tools[@]}"; do + if ! command -v "$tool" >/dev/null 2>&1; then + missing_tools+=("$tool") + fi + done + + if [[ $timeout_seconds -gt 0 ]]; then + if ! command -v timeout >/dev/null 2>&1; then + if ! command -v gtimeout >/dev/null 2>&1; then + if ! command -v pgrep >/dev/null 2>&1; then + missing_tools+=("pgrep") + fi + + if ! command -v pkill >/dev/null 2>&1; then + missing_tools+=("pkill") + fi + fi + fi + fi + + if [[ ! -x "$project_root/vendor/bin/phpunit" ]]; then + print_error "PHPUnit executable not found or not executable: $project_root/vendor/bin/phpunit" + echo "Run composer install before running this script." + exit 1 + fi + + if [[ ${#missing_tools[@]} -gt 0 ]]; then + print_error "Missing required command(s): ${missing_tools[*]}" + echo "Install the missing tools and re-run this script." + exit 1 + fi +} + +verify_config() { + print_debug "Verifying configuration file: $config_file" + + if [[ ! -f "$config_file" ]]; then + print_error "Configuration file not found: $config_file" + echo "Please create $config_file with a list of components, one per line." + exit 1 + fi +} + +validate_component_name() { + local component="$1" + + # Only allow alphanumeric characters, hyphens, and underscores + # Reject path traversal attempts and command injection characters + if [[ ! "$component" =~ ^[a-zA-Z0-9_-]+$ ]]; then + return 1 + fi + + return 0 +} + +read_components() { + declare -a components=() + + while IFS= read -r line; do + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + + if [[ -z "$line" || "$line" =~ ^# ]]; then + continue + fi + + # Validate component name for security + if ! validate_component_name "$line"; then + print_warning "Skipping invalid/unsafe component name: $line" + continue + fi + + components+=("$line") + done < "$config_file" + + echo "${components[@]}" +} + +################################################################################ +# TEST EXECUTION +################################################################################ + +run_component_tests() { + local component=$1 + + # Security: Validate component name before use + if ! validate_component_name "$component"; then + print_error "Security: Invalid component name rejected: $component" + return 1 + fi + + local test_dir="tests/system/$component" + local start_time=$(date +%s%N) + + print_debug "Running tests for: $component" + + if [[ ! -d "$test_dir" ]]; then + local elapsed=$((($(date +%s%N) - $start_time) / 1000000)) + { + echo "Exit code: 2" + echo "Elapsed time: $elapsed" + } > "$results_dir/random_test_result_${elapsed}_${component}.txt" + return + fi + + local output_file="$results_dir/random_test_output_${component}_$$.log" + local events_file="$results_dir/random_test_events_${component}_$$.log" + local random_seed=$(generate_phpunit_random_seed) + local exit_code=0 + + # Security: Use array to avoid eval and prevent command injection + local -a phpunit_args=( + "vendor/bin/phpunit" + "$test_dir" + "--colors=never" + "--no-coverage" + "--order-by=random" + "--random-order-seed=${random_seed}" + "--log-events-text" + "$events_file" + ) + + if [[ $timeout_seconds -gt 0 ]] && command -v timeout >/dev/null 2>&1; then + (cd "$project_root" && timeout --kill-after=2s "${timeout_seconds}s" "${phpunit_args[@]}") > "$output_file" 2>&1 + exit_code=$? + elif [[ $timeout_seconds -gt 0 ]] && command -v gtimeout >/dev/null 2>&1; then + (cd "$project_root" && gtimeout --kill-after=2s "${timeout_seconds}s" "${phpunit_args[@]}") > "$output_file" 2>&1 + exit_code=$? + else + local timeout_marker="$output_file.timeout" + (cd "$project_root" && "${phpunit_args[@]}") > "$output_file" 2>&1 & + local test_pid=$! + + if [[ $timeout_seconds -gt 0 ]]; then + # Watchdog: monitors test process and kills it after timeout + # Uses 1-second sleep intervals to respond quickly when test finishes early + ( + local elapsed=0 + while [[ $elapsed -lt $timeout_seconds ]]; do + sleep 1 + elapsed=$((elapsed + 1)) + kill -0 "$test_pid" 2>/dev/null || exit 0 + done + + if kill -0 "$test_pid" 2>/dev/null; then + touch "$timeout_marker" + local pids_to_kill=$(pgrep -P "$test_pid" 2>/dev/null) + + kill -TERM "$test_pid" 2>/dev/null || true + if [[ -n "$pids_to_kill" ]]; then + echo "$pids_to_kill" | xargs kill -TERM 2>/dev/null || true + fi + + sleep 2 + + if kill -0 "$test_pid" 2>/dev/null; then + kill -KILL "$test_pid" 2>/dev/null || true + if [[ -n "$pids_to_kill" ]]; then + echo "$pids_to_kill" | xargs kill -KILL 2>/dev/null || true + fi + # Security: Quote and escape test_dir for safe pattern matching + pkill -KILL -f "phpunit.*${test_dir//\//\\/}" 2>/dev/null || true + fi + fi + ) & + disown $! 2>/dev/null || true + fi + + wait "$test_pid" 2>/dev/null + exit_code=$? + + if [[ -f "$timeout_marker" ]]; then + exit_code=124 + rm -f "$timeout_marker" + elif [[ $exit_code -eq 143 || $exit_code -eq 137 ]]; then + exit_code=124 + fi + fi + + local elapsed=$((($(date +%s%N) - $start_time) / 1000000)) + local result_file="$results_dir/random_test_result_${elapsed}_${component}.txt" + local order_file="$results_dir/random_test_order_${elapsed}_${component}.txt" + + if [[ -f "$events_file" ]]; then + extract_test_order "$events_file" > "$order_file" + else + echo "Execution order unavailable (events file not created)." > "$order_file" + fi + + if [[ $exit_code -eq 0 ]]; then + { + echo "Exit code: 0" + echo "Elapsed time: $elapsed" + } > "$result_file" + rm -f "$output_file" "$events_file" "$order_file" + else + local output="" + if [[ -f "$output_file" ]]; then + output=$(cat "$output_file") + fi + + if [[ $exit_code -eq 124 ]]; then + output+=$'\n\nTest timed out after '"${timeout_seconds}s" + fi + + local predecessor_info=$'\nExecution order file: '"${order_file}" + if [[ $exit_code -eq 124 ]]; then + predecessor_info+=$'\nFailed test: (timeout before PHPUnit emitted failure event)' + if [[ -f "$events_file" ]]; then + local last_prepared_test=$(extract_test_order "$events_file" | tail -n 1) + if [[ -n "$last_prepared_test" ]]; then + predecessor_info+=$'\nLast prepared test before timeout: '"${last_prepared_test}" + else + predecessor_info+=$'\nLast prepared test before timeout: (unavailable)' + fi + else + predecessor_info+=$'\nLast prepared test before timeout: (events file unavailable)' + fi + predecessor_info+=$'\nPrevious test: (unavailable due to timeout)' + else + if [[ -f "$events_file" ]]; then + local predecessor_result=$(get_failed_test_predecessor "$events_file") + if [[ -n "$predecessor_result" ]]; then + local previous_test=${predecessor_result#*|} + predecessor_info+=$'\nFailed test: '"${predecessor_result%%|*}" + if [[ -n "$previous_test" ]]; then + predecessor_info+=$'\nPrevious test: '"${previous_test}" + else + predecessor_info+=$'\nPrevious test: (none - failed test ran first)' + fi + else + predecessor_info+=$'\nFailed test: (not detected from PHPUnit events log)' + predecessor_info+=$'\nPrevious test: (unavailable)' + fi + else + predecessor_info+=$'\nFailed test: (events file unavailable)' + predecessor_info+=$'\nPrevious test: (unavailable)' + fi + fi + + { + echo "> ${phpunit_args[@]:0:6}" + echo "" + echo "$output" + echo "$predecessor_info" + echo "" + echo "Exit code: 1" + echo "Elapsed time: $elapsed" + } > "$result_file" + rm -f "$output_file" "$events_file" + fi +} + +cleanup_finished_pids() { + local -a active=() + for pid in "${bg_pids[@]:-}"; do + if kill -0 "$pid" 2>/dev/null; then + active+=("$pid") + fi + done + bg_pids=("${active[@]:-}") +} + +spawn_limited_job() { + local component=$1 + + cleanup_finished_pids + + while [[ ${#bg_pids[@]} -ge $max_jobs ]]; do + sleep 0.05 + cleanup_finished_pids + done + + run_component_tests "$component" & + bg_pids+=($!) +} + +process_result() { + local component=$1 + local elapsed=$2 + local result_file="$results_dir/random_test_result_${elapsed}_${component}.txt" + + if [[ ! -f "$result_file" ]]; then + return 1 + fi + + ((completed++)) + + if [[ "$first_result" == true ]] && ! is_quiet; then + echo "" + first_result=false + fi + + local status=$(grep "^Exit code:" "$result_file" | sed 's/Exit code: //') + local elapsed_str=$(format_elapsed_time "$elapsed") + + case "$status" in + 0) + ((passed++)) + print_result "success" "$completed" "$total" "$component" "$elapsed_str" + rm -f "$result_file" + ;; + 2) + ((skipped++)) + skipped_components+=("$component") + print_result "warning" "$completed" "$total" "$component" "$elapsed_str" + rm -f "$result_file" + ;; + *) + ((failed++)) + failed_components+=("$component") + print_result "failure" "$completed" "$total" "$component" "$elapsed_str" + ;; + esac + + displayed_components+=("$component") + + return 0 +} + +get_completed_components() { + # Returns unprocessed test results sorted by elapsed time (fastest first) + # Filename format: random_test_result_${elapsed}_${component}.txt + # Output format: "component|elapsed" (one per line) + + # Extract and sort files by elapsed time + local -a entries=() + + while IFS= read -r file_path; do + # Remove prefix: random_test_result_ + local temp=$(basename "$file_path") + temp=${temp#random_test_result_} + + # Extract elapsed time (everything before first underscore after number) + local elapsed=${temp%%_*} + + # Validate elapsed is numeric + if [[ ! "$elapsed" =~ ^[0-9]+$ ]]; then + continue + fi + + # Extract component (everything after elapsed and underscore, before .txt) + local listed_component=${temp#${elapsed}_} + listed_component=${listed_component%.txt} + + entries+=("$elapsed|$listed_component") + done < <(find "$results_dir" -maxdepth 1 -type f -name "random_test_result_*.txt" 2>/dev/null) + + # Sort entries by elapsed time numerically + printf '%s\n' "${entries[@]}" | sort -t'|' -k1,1n | + while IFS='|' read -r elapsed listed_component; do + if [[ ! " ${displayed_components[*]:-} " =~ " ${listed_component} " ]]; then + echo "$listed_component|$elapsed" + fi + done +} + + +print_summary() { + local run_number=$1 + local pass_percent=0.00 + local fail_percent=0.00 + local skip_percent=0.00 + + if [[ $total -gt 0 ]]; then + pass_percent=$(printf "%.2f" "$(echo "scale=2; $passed * 100 / $total" | bc)") + fail_percent=$(printf "%.2f" "$(echo "scale=2; $failed * 100 / $total" | bc)") + skip_percent=$(printf "%.2f" "$(echo "scale=2; $skipped * 100 / $total" | bc)") + fi + + echo "" + print_header "Test Execution Summary" + printf "%-20s %b\n" "Total $(inflect "$total" "Component" "Components"):" "${BLUE}$total${RESET}" + printf "%-20s %b\n" "Passed:" "${GREEN}$passed${RESET} (${GREEN}${pass_percent}%${RESET})" + printf "%-20s %b\n" "Failed:" "${RED}$failed${RESET} (${RED}${fail_percent}%${RESET})" + printf "%-20s %b\n" "Skipped:" "${YELLOW}$skipped${RESET} (${YELLOW}${skip_percent}%${RESET})" + printf "%-20s %b\n" "Completed Runs:" "${BLUE}$run_number${RESET} / ${BLUE}$repeat_count${RESET}" + + if [[ $failed -gt 0 ]]; then + echo -e "\n${BOLD_RED}Failed $(inflect "$failed" "Component" "Components"):${RESET}" + while IFS= read -r failed_component; do + local result_file=$(find "$results_dir" -name "random_test_result_*_${failed_component}.txt" 2>/dev/null | head -n 1) + + if [[ -z "$result_file" ]]; then + result_file="$results_dir/random_test_result_*_${failed_component}.txt" + fi + + echo -e " ${RED}✗${RESET} ${BOLD}$failed_component${RESET} ($result_file)" + done < <(printf '%s\n' "${failed_components[@]}" | sort) + fi + + if [[ $skipped -gt 0 ]]; then + echo -e "\n${BOLD_YELLOW}Skipped $(inflect "$skipped" "Component" "Components"):${RESET}" + while IFS= read -r skipped_component; do + echo -e " ${YELLOW}⚠${RESET} ${BOLD}$skipped_component${RESET} (no such directory: ${BOLD}tests/system/$skipped_component${RESET})" + done < <(printf '%s\n' "${skipped_components[@]}" | sort) + fi +} + +################################################################################ +# MAIN SCRIPT +################################################################################ + +main() { + cd "$project_root" || exit 1 + + parse_arguments "$@" + check_required_tools + + if [[ -z "$max_jobs" ]]; then + max_jobs=$(auto_detect_max_jobs) + fi + + print_header "CodeIgniter4 - Random Test Execution Verification" + echo "" + + declare -a components_array + + if [[ -n "$component" ]]; then + # Single component specified via command line + if ! validate_component_name "$component"; then + print_error "Invalid component name: $component" + echo " Component name must contain only: alphanumeric, hyphens, underscores, forward slashes" + echo " Cannot contain: spaces, dots, consecutive slashes, or start with slash" + exit 1 + fi + print_debug "Testing single component specified via command line: $component\n" + components_array=("$component") + else + # Read components from config file + verify_config + print_success "Configuration file: $config_file\n" + components_array=($(read_components)) + fi + + total=${#components_array[@]} + + if [[ $total -eq 0 ]]; then + if [[ -n "$component" ]]; then + print_error "Component not found or inaccessible: $component" + else + print_warning "No components configured in $config_file" + echo "Please add component names to the configuration file, one per line." + fi + exit 0 + fi + + print_debug "Found $total $(inflect "$total" "component") to test" + print_debug "Max concurrent jobs: $max_jobs" + print_debug "Per-component timeout: ${timeout_seconds}s" + print_debug "Total runs: $repeat_count\n" + + local run=1 + while [[ $run -le $repeat_count ]]; do + completed=0 + passed=0 + failed=0 + skipped=0 + displayed_components=() + failed_components=() + skipped_components=() + bg_pids=() + first_result=true + + if [[ $run -gt 1 ]]; then + echo "" + fi + + if [[ $repeat_count -gt 1 ]]; then + print_header "Run $run/$repeat_count" + echo "" + fi + + print_debug "Setting up results directory: $results_dir\n" + mkdir -p "$results_dir" + rm -f "$results_dir"/* + + if ! should_show_spinner; then + echo -e "${BLUE}Running tests in parallel...${RESET}\n" + else + show_spinner + fi + + for next_component in "${components_array[@]}"; do + spawn_limited_job "$next_component" + done + + for finished_pid in "${bg_pids[@]:-}"; do + wait "$finished_pid" 2>/dev/null || true + done + + if should_show_spinner; then + stop_spinner + fi + + while IFS='|' read -r next_component next_elapsed; do + process_result "$next_component" "$next_elapsed" || true + done < <(get_completed_components) + + print_summary "$run" + + if [[ $failed -gt 0 || $skipped -gt 0 ]]; then + exit 1 + fi + + ((run++)) + done + + echo "" + if [[ -n "$component" ]]; then + print_success "Component '$component' passed random execution tests!" + else + print_success "All components passed random execution tests!" + fi +} + +main "$@" diff --git a/.github/workflows/test-random-execution.yml b/.github/workflows/test-random-execution.yml new file mode 100644 index 000000000000..707cf01aaded --- /dev/null +++ b/.github/workflows/test-random-execution.yml @@ -0,0 +1,225 @@ +name: Random Test Execution Verification + +on: + push: + branches: + - develop + - '4.*' + paths: + - '.github/scripts/run-random-tests.sh' + - '.github/scripts/random-tests-config.txt' + - '.github/workflows/test-random-execution.yml' + - 'phpunit.xml.dist' + - 'system/**.php' + - 'tests/**.php' + + pull_request: + branches: + - develop + - '4.*' + paths: + - '.github/scripts/run-random-tests.sh' + - '.github/scripts/random-tests-config.txt' + - '.github/workflows/test-random-execution.yml' + - 'phpunit.xml.dist' + - 'system/**.php' + - 'tests/**.php' + + workflow_call: + inputs: + quiet: + description: Suppress debug output + type: boolean + required: false + default: false + component: + description: Test single component (overrides config file) + type: string + required: false + default: '' + max-jobs: + description: Limit concurrent test jobs (auto-detect if omitted) + type: string + required: false + default: '' + repeat: + description: Repeat full component run REPEAT times + type: string + required: false + default: '10' + timeout: + description: Per-component timeout in seconds (0 disables) + type: string + required: false + default: '300' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + random-tests: + name: PHP ${{ matrix.php-version }} - ${{ matrix.db-platform }} + runs-on: ubuntu-24.04 + + strategy: + fail-fast: false + matrix: + php-version: + - '8.5' + db-platform: + - MySQLi + - Postgre + - SQLSRV + - SQLite3 + - Oracle + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: yes + MYSQL_DATABASE: test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + postgres: + image: postgres + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: test + ports: + - 5432:5432 + options: >- + --health-cmd=pg_isready + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + mssql: + image: mcr.microsoft.com/mssql/server:2025-CU2-ubuntu-24.04 + env: + MSSQL_SA_PASSWORD: 1Secure*Password1 + ACCEPT_EULA: Y + MSSQL_PID: Developer + ports: + - 1433:1433 + options: >- + --health-cmd="/opt/mssql-tools18/bin/sqlcmd -C -S 127.0.0.1 -U sa -P 1Secure*Password1 -Q 'SELECT @@VERSION'" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + oracle: + image: gvenzl/oracle-xe:21 + env: + ORACLE_RANDOM_PASSWORD: true + APP_USER: ORACLE + APP_USER_PASSWORD: ORACLE + ports: + - 1521:1521 + options: >- + --health-cmd healthcheck.sh + --health-interval 20s + --health-timeout 10s + --health-retries 10 + + redis: + image: redis + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + memcached: + image: memcached:1.6-alpine + ports: + - 11211:11211 + + steps: + - name: Install mssql-tools on runner + if: ${{ matrix.db-platform == 'SQLSRV' }} + run: | + source /etc/os-release + curl -sSL https://packages.microsoft.com/keys/microsoft.asc | sudo gpg --dearmor --batch --yes -o /usr/share/keyrings/microsoft-prod.gpg + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/${VERSION_ID}/prod ${UBUNTU_CODENAME} main" | sudo tee /etc/apt/sources.list.d/mssql-release.list + sudo apt-get update + sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 mssql-tools18 unixodbc-dev + echo "/opt/mssql-tools18/bin" >> $GITHUB_PATH + + - name: Create database for MSSQL Server + if: ${{ matrix.db-platform == 'SQLSRV' }} + run: | + sqlcmd -S 127.0.0.1 \ + -U sa -P 1Secure*Password1 \ + -N -C \ + -Q "CREATE DATABASE test COLLATE Latin1_General_100_CS_AS_SC_UTF8" + + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup PHP ${{ matrix.php-version }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: gd, curl, iconv, json, mbstring, openssl, sodium + coverage: none + + - name: Get composer cache directory + id: composer-cache + run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache composer dependencies + uses: actions/cache@v5 + with: + path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} + key: PHP_${{ matrix.php-version }}-${{ hashFiles('**/composer.*') }} + restore-keys: | + PHP_${{ matrix.php-version }}- + + - name: Install project dependencies + run: | + composer config --global github-oauth.github.com ${{ secrets.GITHUB_TOKEN }} + composer update --ansi + + - name: Run random test execution verification + run: | + args=() + + # Add --quiet flag if input is true + if [[ "${{ inputs.quiet }}" == "true" ]]; then + args+=("--quiet") + fi + + # Add --component flag if component is specified + if [[ -n "${{ inputs.component }}" ]]; then + args+=("--component" "${{ inputs.component }}") + fi + + # Add --max-jobs flag if specified (empty means auto-detect) + if [[ -n "${{ inputs.max-jobs }}" ]]; then + args+=("--max-jobs" "${{ inputs.max-jobs }}") + fi + + # Add --repeat flag (always, default is 10) + args+=("--repeat" "${{ inputs.repeat || '10' }}") + + # Add --timeout flag (always, default is 300) + args+=("--timeout" "${{ inputs.timeout || '300' }}") + + .github/scripts/run-random-tests.sh "${args[@]}" + env: + DB: ${{ matrix.db-platform }} + TERM: xterm-256color diff --git a/tests/README.md b/tests/README.md index 3848a83540dd..2ce023d37119 100644 --- a/tests/README.md +++ b/tests/README.md @@ -108,6 +108,48 @@ You can run the tests without running the live database and the live cache tests ./phpunit --exclude-group DatabaseLive --exclude-group CacheLive ``` +## Verifying Random Test Execution Order + +To help detect hidden test inter-dependencies, you can run component tests in random order using: + +```console +.github/scripts/run-random-tests.sh +``` + +This script reads enabled components from `.github/scripts/random-tests-config.txt` and runs each +component under `tests/system/` with PHPUnit random ordering. + +Common examples: + +```console +# Run all configured components once +.github/scripts/run-random-tests.sh + +# Run all configured components 10 times +.github/scripts/run-random-tests.sh --repeat 10 + +# Run one component 10 times +.github/scripts/run-random-tests.sh --component CLI --repeat 10 + +# Limit parallel jobs and suppress debug lines +.github/scripts/run-random-tests.sh --max-jobs 4 --quiet +``` + +Short-option equivalents: + +```console +# Run all configured components 10 times +.github/scripts/run-random-tests.sh -r 10 + +# Run one component 10 times +.github/scripts/run-random-tests.sh -c CLI -r 10 + +# Limit parallel jobs and suppress debug lines +.github/scripts/run-random-tests.sh -n 4 -q +``` + +Failure diagnostics and execution-order logs are written to `build/random-tests/`. + ## Generating Code Coverage The coverage reports are generated by default after the execution of tests. These reports From ed5d0808f917099367b281e834254ac5b3676048 Mon Sep 17 00:00:00 2001 From: Adi Prasetyo Date: Wed, 11 Mar 2026 23:45:08 +0700 Subject: [PATCH 59/75] refactor: fix phpstan no type specified SaveModelTest (#10032) * refactor: fix phpstan no type specified SaveModelTest * remove unused properties --- tests/system/Models/SaveModelTest.php | 2 -- utils/phpstan-baseline/loader.neon | 2 +- utils/phpstan-baseline/missingType.property.neon | 7 +------ 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/tests/system/Models/SaveModelTest.php b/tests/system/Models/SaveModelTest.php index b2bdaa2a4103..be77d958ff7f 100644 --- a/tests/system/Models/SaveModelTest.php +++ b/tests/system/Models/SaveModelTest.php @@ -291,7 +291,6 @@ public function testSaveNewEntityWithDate(): void protected $returnType = 'object'; protected $useSoftDeletes = true; protected $dateFormat = 'date'; - public $name = ''; }; $entity->name = 'Mark'; @@ -370,7 +369,6 @@ public function testUseAutoIncrementSetToFalseSaveObject(): void public function testSaveNewEntityWithMappedPrimaryKey(): void { $entity = new class () extends Entity { - protected string $name; protected $attributes = [ 'id' => null, 'name' => null, diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 65057bd0736b..c065fa299fd3 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2063 errors +# total 2062 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/missingType.property.neon b/utils/phpstan-baseline/missingType.property.neon index 2dce190bf003..451773e8bc5d 100644 --- a/utils/phpstan-baseline/missingType.property.neon +++ b/utils/phpstan-baseline/missingType.property.neon @@ -1,4 +1,4 @@ -# total 48 errors +# total 47 errors parameters: ignoreErrors: @@ -236,8 +236,3 @@ parameters: message: '#^Property CodeIgniter\\Database\\Live\\MySQLi\\NumberNativeTest\:\:\$tests has no type specified\.$#' count: 1 path: ../../tests/system/Database/Live/MySQLi/NumberNativeTest.php - - - - message: '#^Property CodeIgniter\\Model@anonymous/tests/system/Models/SaveModelTest\.php\:286\:\:\$name has no type specified\.$#' - count: 1 - path: ../../tests/system/Models/SaveModelTest.php From 0343c06fcf9fc803f22fd5c2fd7062224b6e7eb4 Mon Sep 17 00:00:00 2001 From: Thomas Meschke Date: Fri, 13 Mar 2026 17:34:42 +0100 Subject: [PATCH 60/75] refactor: fix typo in log messages (#10035) --- system/Email/Email.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/system/Email/Email.php b/system/Email/Email.php index 14268ea87182..b886d07fdc72 100644 --- a/system/Email/Email.php +++ b/system/Email/Email.php @@ -1708,7 +1708,7 @@ protected function spoolEmail() $success = $this->{$method}(); } catch (ErrorException $e) { $success = false; - log_message('error', 'Email: ' . $method . ' throwed ' . $e); + log_message('error', 'Email: ' . $method . ' threw ' . $e); } if (! $success) { @@ -2265,7 +2265,7 @@ public function __destruct() } catch (ErrorException $e) { $protocol = $this->getProtocol(); $method = 'sendWith' . ucfirst($protocol); - log_message('error', 'Email: ' . $method . ' throwed ' . $e); + log_message('error', 'Email: ' . $method . ' threw ' . $e); } } } From 006fc6873cf6625857a7e95e563988b6c7e98510 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 14 Mar 2026 19:20:44 +0800 Subject: [PATCH 61/75] chore: bump runners to ubuntu-24.04 (#10036) --- .github/workflows/deploy-apidocs.yml | 2 +- .github/workflows/deploy-distributables.yml | 8 ++++---- .github/workflows/deploy-userguide-latest.yml | 2 +- .github/workflows/label-add-conflict-all-pr.yml | 2 +- .github/workflows/label-signing.yml | 2 +- .github/workflows/reusable-coveralls.yml | 2 +- .github/workflows/reusable-serviceless-phpunit-test.yml | 2 +- .github/workflows/test-autoreview.yml | 2 +- .github/workflows/test-coding-standards.yml | 2 +- .github/workflows/test-deptrac.yml | 2 +- .github/workflows/test-file-permissions.yml | 2 +- .github/workflows/test-phpstan.yml | 2 +- .github/workflows/test-psalm.yml | 2 +- .github/workflows/test-rector.yml | 2 +- .github/workflows/test-scss.yml | 2 +- .github/workflows/test-userguide.yml | 2 +- admin/framework/.github/workflows/close-pull-request.yml | 2 +- admin/starter/.github/workflows/close-pull-request.yml | 2 +- admin/starter/.github/workflows/phpunit.yml | 2 +- admin/userguide/.github/workflows/close-pull-request.yml | 2 +- admin/userguide/.github/workflows/deploy.yml | 2 +- 21 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/deploy-apidocs.yml b/.github/workflows/deploy-apidocs.yml index 686487346655..35f9fb08688f 100644 --- a/.github/workflows/deploy-apidocs.yml +++ b/.github/workflows/deploy-apidocs.yml @@ -20,7 +20,7 @@ jobs: permissions: contents: write if: github.repository == 'codeigniter4/CodeIgniter4' - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Setup credentials diff --git a/.github/workflows/deploy-distributables.yml b/.github/workflows/deploy-distributables.yml index 742dc32ec603..b8162381e760 100644 --- a/.github/workflows/deploy-distributables.yml +++ b/.github/workflows/deploy-distributables.yml @@ -12,7 +12,7 @@ permissions: jobs: check-version: name: Check for updated version - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Checkout @@ -39,7 +39,7 @@ jobs: # Allow actions/github-script to create release contents: write if: github.repository == 'codeigniter4/CodeIgniter4' - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 needs: check-version steps: @@ -89,7 +89,7 @@ jobs: # Allow actions/github-script to create release contents: write if: github.repository == 'codeigniter4/CodeIgniter4' - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 needs: check-version steps: @@ -139,7 +139,7 @@ jobs: # Allow actions/github-script to create release contents: write if: github.repository == 'codeigniter4/CodeIgniter4' - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 needs: check-version steps: diff --git a/.github/workflows/deploy-userguide-latest.yml b/.github/workflows/deploy-userguide-latest.yml index a692f903f965..1831a3108401 100644 --- a/.github/workflows/deploy-userguide-latest.yml +++ b/.github/workflows/deploy-userguide-latest.yml @@ -22,7 +22,7 @@ jobs: # Allow ad-m/github-push-action to push commit to branch gh-pages contents: write if: (github.repository == 'codeigniter4/CodeIgniter4') - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.github/workflows/label-add-conflict-all-pr.yml b/.github/workflows/label-add-conflict-all-pr.yml index 16467abd161c..a0a8fc4e384a 100644 --- a/.github/workflows/label-add-conflict-all-pr.yml +++ b/.github/workflows/label-add-conflict-all-pr.yml @@ -14,7 +14,7 @@ jobs: contents: read pull-requests: write - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.github/workflows/label-signing.yml b/.github/workflows/label-signing.yml index 5f6b99290e89..60d10a230748 100644 --- a/.github/workflows/label-signing.yml +++ b/.github/workflows/label-signing.yml @@ -16,7 +16,7 @@ permissions: jobs: build: name: Check Signed Commit - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.github/workflows/reusable-coveralls.yml b/.github/workflows/reusable-coveralls.yml index 06fecffe5fcd..d7fb064dea8d 100644 --- a/.github/workflows/reusable-coveralls.yml +++ b/.github/workflows/reusable-coveralls.yml @@ -10,7 +10,7 @@ on: jobs: coveralls: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Checkout base branch for PR diff --git a/.github/workflows/reusable-serviceless-phpunit-test.yml b/.github/workflows/reusable-serviceless-phpunit-test.yml index 2a5a13d3db26..201abb984655 100644 --- a/.github/workflows/reusable-serviceless-phpunit-test.yml +++ b/.github/workflows/reusable-serviceless-phpunit-test.yml @@ -53,7 +53,7 @@ on: jobs: tests: name: ${{ inputs.job-name }} - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Install latest ImageMagick diff --git a/.github/workflows/test-autoreview.yml b/.github/workflows/test-autoreview.yml index 891b928793b8..b32bf1019833 100644 --- a/.github/workflows/test-autoreview.yml +++ b/.github/workflows/test-autoreview.yml @@ -33,7 +33,7 @@ jobs: composer-normalize-tests: name: Check normalized composer.json - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout base branch for PR if: github.event_name == 'pull_request' diff --git a/.github/workflows/test-coding-standards.yml b/.github/workflows/test-coding-standards.yml index aa70a480bfd7..c9c50c83dc9c 100644 --- a/.github/workflows/test-coding-standards.yml +++ b/.github/workflows/test-coding-standards.yml @@ -22,7 +22,7 @@ permissions: jobs: lint: name: PHP ${{ matrix.php-version }} Lint with PHP CS Fixer - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: fail-fast: false diff --git a/.github/workflows/test-deptrac.yml b/.github/workflows/test-deptrac.yml index f339230591c0..5bb463d09f2f 100644 --- a/.github/workflows/test-deptrac.yml +++ b/.github/workflows/test-deptrac.yml @@ -34,7 +34,7 @@ permissions: jobs: build: name: Architectural Inspection - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Checkout base branch for PR if: github.event_name == 'pull_request' diff --git a/.github/workflows/test-file-permissions.yml b/.github/workflows/test-file-permissions.yml index c6fed8f71d1b..726cace25cc9 100644 --- a/.github/workflows/test-file-permissions.yml +++ b/.github/workflows/test-file-permissions.yml @@ -14,7 +14,7 @@ permissions: jobs: permission-check: name: Check File Permission - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Checkout diff --git a/.github/workflows/test-phpstan.yml b/.github/workflows/test-phpstan.yml index 2b874792f308..ad9880d434b6 100644 --- a/.github/workflows/test-phpstan.yml +++ b/.github/workflows/test-phpstan.yml @@ -41,7 +41,7 @@ permissions: jobs: build: name: PHP ${{ matrix.php-versions }} Static Analysis - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: fail-fast: false steps: diff --git a/.github/workflows/test-psalm.yml b/.github/workflows/test-psalm.yml index 9f956aaeaf0d..7f2e1339cd58 100644 --- a/.github/workflows/test-psalm.yml +++ b/.github/workflows/test-psalm.yml @@ -23,7 +23,7 @@ on: jobs: build: name: Psalm Analysis - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: fail-fast: false diff --git a/.github/workflows/test-rector.yml b/.github/workflows/test-rector.yml index 6770c6112bc6..abbd7ed7369f 100644 --- a/.github/workflows/test-rector.yml +++ b/.github/workflows/test-rector.yml @@ -41,7 +41,7 @@ permissions: jobs: build: name: PHP ${{ matrix.php-version }} Analyze code (Rector) - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: diff --git a/.github/workflows/test-scss.yml b/.github/workflows/test-scss.yml index 71ca99253872..75000cd291ef 100644 --- a/.github/workflows/test-scss.yml +++ b/.github/workflows/test-scss.yml @@ -29,7 +29,7 @@ permissions: jobs: build: name: Compilation of SCSS (Dart Sass) - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Checkout diff --git a/.github/workflows/test-userguide.yml b/.github/workflows/test-userguide.yml index 0405af7cd6d7..94ec8af53022 100644 --- a/.github/workflows/test-userguide.yml +++ b/.github/workflows/test-userguide.yml @@ -20,7 +20,7 @@ permissions: jobs: syntax_check: name: Check User Guide syntax - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Checkout diff --git a/admin/framework/.github/workflows/close-pull-request.yml b/admin/framework/.github/workflows/close-pull-request.yml index 96675f69e878..ed698e3e33c1 100644 --- a/admin/framework/.github/workflows/close-pull-request.yml +++ b/admin/framework/.github/workflows/close-pull-request.yml @@ -9,7 +9,7 @@ permissions: jobs: main: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Close PR with nice message run: gh pr close ${{ env.ISSUE }} -c "${{ env.COMMENT }}" diff --git a/admin/starter/.github/workflows/close-pull-request.yml b/admin/starter/.github/workflows/close-pull-request.yml index 96675f69e878..ed698e3e33c1 100644 --- a/admin/starter/.github/workflows/close-pull-request.yml +++ b/admin/starter/.github/workflows/close-pull-request.yml @@ -9,7 +9,7 @@ permissions: jobs: main: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Close PR with nice message run: gh pr close ${{ env.ISSUE }} -c "${{ env.COMMENT }}" diff --git a/admin/starter/.github/workflows/phpunit.yml b/admin/starter/.github/workflows/phpunit.yml index 23d883582a8f..7d51bc242532 100644 --- a/admin/starter/.github/workflows/phpunit.yml +++ b/admin/starter/.github/workflows/phpunit.yml @@ -13,7 +13,7 @@ jobs: matrix: php-versions: ['8.2', '8.5'] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: (! contains(github.event.pull_request.title, '[ci skip]')) steps: diff --git a/admin/userguide/.github/workflows/close-pull-request.yml b/admin/userguide/.github/workflows/close-pull-request.yml index 96675f69e878..ed698e3e33c1 100644 --- a/admin/userguide/.github/workflows/close-pull-request.yml +++ b/admin/userguide/.github/workflows/close-pull-request.yml @@ -9,7 +9,7 @@ permissions: jobs: main: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Close PR with nice message run: gh pr close ${{ env.ISSUE }} -c "${{ env.COMMENT }}" diff --git a/admin/userguide/.github/workflows/deploy.yml b/admin/userguide/.github/workflows/deploy.yml index d244eb415978..b26397dbb03c 100644 --- a/admin/userguide/.github/workflows/deploy.yml +++ b/admin/userguide/.github/workflows/deploy.yml @@ -9,7 +9,7 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: executing remote ssh commands using ssh key From fe768c9ac43f083b188af93b94abbb82439b0ebb Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Sun, 15 Mar 2026 12:31:54 +0100 Subject: [PATCH 62/75] fix: controller filter attribute docs and improve invalid attribute logging (#10040) --- system/Router/Router.php | 39 ++++++++++++++++--- .../InvalidAttributeController.php | 27 +++++++++++++ tests/system/Router/RouterTest.php | 25 ++++++++++++ .../incoming/controller_attributes/003.php | 5 ++- 4 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 tests/_support/Router/Controllers/InvalidAttributeController.php diff --git a/system/Router/Router.php b/system/Router/Router.php index 4c2ba230f6a0..0348daf10cdc 100644 --- a/system/Router/Router.php +++ b/system/Router/Router.php @@ -803,8 +803,8 @@ private function processRouteAttributes(): void if ($instance instanceof RouteAttributeInterface) { $this->routeAttributes['class'][] = $instance; } - } catch (Throwable) { - log_message('error', 'Failed to instantiate attribute: ' . $attribute->getName()); + } catch (Throwable $e) { + $this->logRouteAttributeInstantiationFailure($attribute->getName(), $this->controller, null, $e); } } @@ -823,14 +823,43 @@ private function processRouteAttributes(): void if ($instance instanceof RouteAttributeInterface) { $this->routeAttributes['method'][] = $instance; } - } catch (Throwable) { - // Skip attributes that fail to instantiate - log_message('error', 'Failed to instantiate attribute: ' . $attribute->getName()); + } catch (Throwable $e) { + $this->logRouteAttributeInstantiationFailure($attribute->getName(), $this->controller, $this->method, $e); } } } } + /** + * Logs an attribute instantiation failure with the resolved route context. + * + * @param string $attributeName Fully qualified attribute class name. + * @param string $controller Resolved controller class name. + * @param string|null $method Resolved controller method name, if applicable. + */ + private function logRouteAttributeInstantiationFailure( + string $attributeName, + string $controller, + ?string $method, + Throwable $e, + ): void { + $location = $controller; + + if ($method !== null && $method !== '') { + $location .= '::' . $method . '()'; + } + + log_message( + 'error', + 'Failed to instantiate route attribute "{attribute}" on "{location}": {message}', + [ + 'attribute' => $attributeName, + 'location' => $location, + 'message' => $e->getMessage(), + ], + ); + } + /** * Execute beforeController() on all route attributes. * Called by CodeIgniter before controller execution. diff --git a/tests/_support/Router/Controllers/InvalidAttributeController.php b/tests/_support/Router/Controllers/InvalidAttributeController.php new file mode 100644 index 000000000000..9caebad03508 --- /dev/null +++ b/tests/_support/Router/Controllers/InvalidAttributeController.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Router\Controllers; + +use CodeIgniter\Controller; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\Router\Attributes\Filter; + +class InvalidAttributeController extends Controller +{ + #[Filter(by: ['auth', 'csrf'])] + public function invalidMultipleFilters(): ResponseInterface + { + return $this->response->setBody('Invalid attributes'); + } +} diff --git a/tests/system/Router/RouterTest.php b/tests/system/Router/RouterTest.php index ef3418f3bcee..7eef0a7569aa 100644 --- a/tests/system/Router/RouterTest.php +++ b/tests/system/Router/RouterTest.php @@ -20,6 +20,7 @@ use CodeIgniter\HTTP\Exceptions\RedirectException; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\Method; +use CodeIgniter\Router\Attributes\Filter; use CodeIgniter\Router\Exceptions\RouterException; use CodeIgniter\Test\CIUnitTestCase; use Config\App; @@ -29,6 +30,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use Tests\Support\Filters\Customfilter; +use Tests\Support\Router\Controllers\InvalidAttributeController; /** * @internal @@ -1003,4 +1005,27 @@ public function testRoutePlaceholderAnyWithMultipleSegmentsParamTrue(): void $this->assertSame('productLookup', $router->methodName()); $this->assertSame(['123/456'], $router->params()); } + + public function testLogsRouteAttributeInstantiationFailureWithContext(): void + { + $collection = clone $this->collection; + $collection->resetRoutes(); + $collection->get( + 'invalid-attribute', + 'Tests\Support\Router\Controllers\InvalidAttributeController::invalidMultipleFilters', + ); + + $router = new Router($collection, $this->request); + + $router->handle('invalid-attribute'); + + $this->assertSame( + '\\' . InvalidAttributeController::class, + $router->controllerName(), + ); + $this->assertSame('invalidMultipleFilters', $router->methodName()); + $this->assertSame([], $router->getFilters()); + $this->assertLogContains('error', 'Failed to instantiate route attribute "' . Filter::class . '" on "\Tests\Support\Router\Controllers\InvalidAttributeController::invalidMultipleFilters()":'); + $this->assertLogContains('error', 'must be of type string'); + } } diff --git a/user_guide_src/source/incoming/controller_attributes/003.php b/user_guide_src/source/incoming/controller_attributes/003.php index ef6c65ac51ca..39725375f4e2 100644 --- a/user_guide_src/source/incoming/controller_attributes/003.php +++ b/user_guide_src/source/incoming/controller_attributes/003.php @@ -18,8 +18,9 @@ public function api() { } - // Multiple filters can be applied - #[Filter(by: ['auth', 'csrf'])] + // Multiple filters can be applied by repeating the attribute + #[Filter(by: 'auth')] + #[Filter(by: 'csrf')] public function admin() { } From c1cfd45731d4b4db98bc2db420b4cd627b315674 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Mon, 16 Mar 2026 01:33:55 +0800 Subject: [PATCH 63/75] chore: enforce security hardening for GitHub Actions workflows (#10038) * chore: set explicit workflow permissions defaults * chore: disable persisted checkout credentials * chore: centralize secure authenticated git push * chore: pin workflow actions to immutable SHAs * chore: group dependabot updates --- .github/dependabot.yml | 20 +++++++---- .github/scripts/deploy-appstarter | 2 +- .github/scripts/deploy-framework | 2 +- .github/scripts/deploy-userguide | 2 +- .github/scripts/secure-git-push | 22 ++++++++++++ .github/workflows/deploy-apidocs.yml | 12 ++++--- .github/workflows/deploy-distributables.yml | 35 +++++++++++++------ .github/workflows/deploy-userguide-latest.yml | 21 +++++------ .../workflows/label-add-conflict-all-pr.yml | 2 +- .github/workflows/label-signing.yml | 4 +-- .github/workflows/reusable-coveralls.yml | 18 ++++++---- .github/workflows/reusable-phpunit-test.yml | 18 ++++++---- .../reusable-serviceless-phpunit-test.yml | 18 ++++++---- .github/workflows/test-autoreview.yml | 6 ++-- .github/workflows/test-coding-standards.yml | 8 ++--- .github/workflows/test-deptrac.yml | 10 +++--- .github/workflows/test-file-permissions.yml | 2 +- .github/workflows/test-phpcpd.yml | 7 +++- .github/workflows/test-phpstan.yml | 10 +++--- .github/workflows/test-psalm.yml | 16 ++++++--- .github/workflows/test-random-execution.yml | 6 ++-- .github/workflows/test-rector.yml | 10 +++--- .github/workflows/test-scss.yml | 4 +-- .github/workflows/test-userguide.yml | 4 +-- admin/starter/.github/workflows/phpunit.yml | 11 ++++-- admin/userguide/.github/workflows/deploy.yml | 4 ++- 26 files changed, 179 insertions(+), 95 deletions(-) create mode 100644 .github/scripts/secure-git-push diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d3afefe5c9fd..c6188a21b9e6 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,13 +6,21 @@ updates: schedule: interval: 'daily' open-pull-requests-limit: 10 + groups: + composer-dependencies: + patterns: + - '*' - package-ecosystem: 'github-actions' - directory: '/' + directories: + - '/' + - '/admin/framework' + - '/admin/starter' + - '/admin/userguide' schedule: interval: 'daily' - ignore: - - dependency-name: '*' - update-types: - - 'version-update:semver-minor' - - 'version-update:semver-patch' + groups: + github-actions: + patterns: + - '*' + group-by: dependency-name diff --git a/.github/scripts/deploy-appstarter b/.github/scripts/deploy-appstarter index 29dfe66db64c..86d7bad2241a 100644 --- a/.github/scripts/deploy-appstarter +++ b/.github/scripts/deploy-appstarter @@ -32,4 +32,4 @@ cp -Rf ${SOURCE}/admin/starter/. ./ # Commit the changes git add . git commit -m "Release ${RELEASE}" -git push +bash ${SOURCE}/.github/scripts/secure-git-push https://github.com/codeigniter4/appstarter.git HEAD:master diff --git a/.github/scripts/deploy-framework b/.github/scripts/deploy-framework index cc9d89e7acc7..ea18cadf1607 100644 --- a/.github/scripts/deploy-framework +++ b/.github/scripts/deploy-framework @@ -34,4 +34,4 @@ cp -Rf ${SOURCE}/admin/starter/tests/. ./tests/ # Commit the changes git add . git commit -m "Release ${RELEASE}" -git push +bash ${SOURCE}/.github/scripts/secure-git-push https://github.com/codeigniter4/framework.git HEAD:master diff --git a/.github/scripts/deploy-userguide b/.github/scripts/deploy-userguide index 6d1c755107b7..e664ac1b7ff5 100755 --- a/.github/scripts/deploy-userguide +++ b/.github/scripts/deploy-userguide @@ -58,4 +58,4 @@ touch ${TARGET}/docs/.nojekyll # Commit the changes git add . git commit -m "Release ${RELEASE}" -git push +bash ${SOURCE}/.github/scripts/secure-git-push https://github.com/codeigniter4/userguide.git HEAD:master diff --git a/.github/scripts/secure-git-push b/.github/scripts/secure-git-push new file mode 100644 index 000000000000..217581f0a806 --- /dev/null +++ b/.github/scripts/secure-git-push @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: secure-git-push " >&2 + exit 1 +fi + +if [[ -z "${PUSH_TOKEN:-}" ]]; then + echo "PUSH_TOKEN is required" >&2 + exit 1 +fi + +REMOTE_URL="$1" +REFSPEC="$2" +AUTH_HEADER="$(printf 'x-access-token:%s' "${PUSH_TOKEN}" | base64 | tr -d '\n')" + +echo "::add-mask::${AUTH_HEADER}" +git -c http.https://github.com/.extraheader="AUTHORIZATION: basic ${AUTH_HEADER}" push "${REMOTE_URL}" "${REFSPEC}" + +unset AUTH_HEADER PUSH_TOKEN diff --git a/.github/workflows/deploy-apidocs.yml b/.github/workflows/deploy-apidocs.yml index 35f9fb08688f..c728febeee30 100644 --- a/.github/workflows/deploy-apidocs.yml +++ b/.github/workflows/deploy-apidocs.yml @@ -29,19 +29,21 @@ jobs: git config --global user.name "${GITHUB_ACTOR}" - name: Checkout source - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: path: source + persist-credentials: false - name: Checkout target - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: codeigniter4/api token: ${{ secrets.ACCESS_TOKEN }} path: api + persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 with: php-version: '8.2' tools: phive @@ -66,9 +68,11 @@ jobs: - name: Deploy to API repo working-directory: api + env: + PUSH_TOKEN: ${{ secrets.ACCESS_TOKEN }} run: | git add . if ! git diff-index --quiet HEAD; then git commit -m "Updated API for commit ${GITHUB_SHA}" - git push origin master + bash ${GITHUB_WORKSPACE}/.github/scripts/secure-git-push https://github.com/codeigniter4/api.git HEAD:master fi diff --git a/.github/workflows/deploy-distributables.yml b/.github/workflows/deploy-distributables.yml index b8162381e760..cb3f2ca8194b 100644 --- a/.github/workflows/deploy-distributables.yml +++ b/.github/workflows/deploy-distributables.yml @@ -16,9 +16,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 # fetch all tags + persist-credentials: false - name: Get latest version id: version @@ -49,25 +50,29 @@ jobs: git config --global user.name "${GITHUB_ACTOR}" - name: Checkout source - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: path: source + persist-credentials: false - name: Checkout target - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: codeigniter4/framework token: ${{ secrets.ACCESS_TOKEN }} path: framework + persist-credentials: false - name: Chmod run: chmod +x ./source/.github/scripts/deploy-framework - name: Deploy + env: + PUSH_TOKEN: ${{ secrets.ACCESS_TOKEN }} run: ./source/.github/scripts/deploy-framework ${GITHUB_WORKSPACE}/source ${GITHUB_WORKSPACE}/framework ${GITHUB_REF##*/} - name: Release - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{secrets.ACCESS_TOKEN}} script: | @@ -99,25 +104,29 @@ jobs: git config --global user.name "${GITHUB_ACTOR}" - name: Checkout source - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: path: source + persist-credentials: false - name: Checkout target - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: codeigniter4/appstarter token: ${{ secrets.ACCESS_TOKEN }} path: appstarter + persist-credentials: false - name: Chmod run: chmod +x ./source/.github/scripts/deploy-appstarter - name: Deploy + env: + PUSH_TOKEN: ${{ secrets.ACCESS_TOKEN }} run: ./source/.github/scripts/deploy-appstarter ${GITHUB_WORKSPACE}/source ${GITHUB_WORKSPACE}/appstarter ${GITHUB_REF##*/} - name: Release - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{secrets.ACCESS_TOKEN}} script: | @@ -149,19 +158,21 @@ jobs: git config --global user.name "${GITHUB_ACTOR}" - name: Checkout source - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: path: source + persist-credentials: false - name: Checkout target - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: codeigniter4/userguide token: ${{ secrets.ACCESS_TOKEN }} path: userguide + persist-credentials: false - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.12' @@ -174,10 +185,12 @@ jobs: run: chmod +x ./source/.github/scripts/deploy-userguide - name: Deploy + env: + PUSH_TOKEN: ${{ secrets.ACCESS_TOKEN }} run: ./source/.github/scripts/deploy-userguide ${GITHUB_WORKSPACE}/source ${GITHUB_WORKSPACE}/userguide ${GITHUB_REF##*/} - name: Release - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{secrets.ACCESS_TOKEN}} script: | diff --git a/.github/workflows/deploy-userguide-latest.yml b/.github/workflows/deploy-userguide-latest.yml index 1831a3108401..48b5b9b8a70a 100644 --- a/.github/workflows/deploy-userguide-latest.yml +++ b/.github/workflows/deploy-userguide-latest.yml @@ -19,22 +19,24 @@ jobs: build: name: Deploy to gh-pages permissions: - # Allow ad-m/github-push-action to push commit to branch gh-pages + # Allow push to branch gh-pages contents: write if: (github.repository == 'codeigniter4/CodeIgniter4') runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 with: php-version: '8.2' coverage: none - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.12' @@ -57,7 +59,7 @@ jobs: # Create an artifact of the html output - name: Upload artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: HTML Documentation path: user_guide_src/build/html/ @@ -75,8 +77,7 @@ jobs: git commit -m "Update User Guide" -a || true - name: Push changes - uses: ad-m/github-push-action@v1.0.0 - with: - branch: gh-pages - directory: gh-pages - github_token: ${{ secrets.ACCESS_TOKEN }} + working-directory: gh-pages + env: + PUSH_TOKEN: ${{ secrets.ACCESS_TOKEN }} + run: bash ${GITHUB_WORKSPACE}/.github/scripts/secure-git-push https://github.com/codeigniter4/CodeIgniter4.git HEAD:gh-pages diff --git a/.github/workflows/label-add-conflict-all-pr.yml b/.github/workflows/label-add-conflict-all-pr.yml index a0a8fc4e384a..a6f5ba276cbe 100644 --- a/.github/workflows/label-add-conflict-all-pr.yml +++ b/.github/workflows/label-add-conflict-all-pr.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Get PR List id: PR-list diff --git a/.github/workflows/label-signing.yml b/.github/workflows/label-signing.yml index 60d10a230748..00cda536ef05 100644 --- a/.github/workflows/label-signing.yml +++ b/.github/workflows/label-signing.yml @@ -19,10 +19,10 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check signed commits in PR - uses: 1Password/check-signed-commits-action@v1 + uses: 1Password/check-signed-commits-action@ed2885f3ed2577a4f5d3c3fe895432a557d23d52 # v1.2.0 with: comment: | You must GPG-sign your work, certifying that you either wrote the work or otherwise have the right to pass it on to an open-source project. See Developer's Certificate of Origin. See [signing][1]. diff --git a/.github/workflows/reusable-coveralls.yml b/.github/workflows/reusable-coveralls.yml index d7fb064dea8d..36106f04e3c0 100644 --- a/.github/workflows/reusable-coveralls.yml +++ b/.github/workflows/reusable-coveralls.yml @@ -8,6 +8,9 @@ on: type: string required: true +permissions: + contents: read + jobs: coveralls: runs-on: ubuntu-24.04 @@ -15,22 +18,25 @@ jobs: steps: - name: Checkout base branch for PR if: github.event_name == 'pull_request' - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.base_ref }} + persist-credentials: false - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 with: php-version: ${{ inputs.php-version }} tools: composer coverage: xdebug - name: Download coverage files - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: build/cov @@ -44,7 +50,7 @@ jobs: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ github.job }}-php-${{ inputs.php-version }}-${{ hashFiles('**/composer.*') }} @@ -53,7 +59,7 @@ jobs: ${{ github.job }}- - name: Cache PHPUnit's static analysis cache - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: build/.phpunit.cache/code-coverage key: phpunit-code-coverage-${{ hashFiles('**/phpunit.*') }} diff --git a/.github/workflows/reusable-phpunit-test.yml b/.github/workflows/reusable-phpunit-test.yml index 7881d688bc70..a83c01a9f8b0 100644 --- a/.github/workflows/reusable-phpunit-test.yml +++ b/.github/workflows/reusable-phpunit-test.yml @@ -56,6 +56,9 @@ on: type: string required: false +permissions: + contents: read + env: NLS_LANG: 'AMERICAN_AMERICA.UTF8' NLS_DATE_FORMAT: 'YYYY-MM-DD HH24:MI:SS' @@ -169,15 +172,18 @@ jobs: - name: Checkout base branch for PR if: github.event_name == 'pull_request' - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.base_ref }} + persist-credentials: false - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 with: php-version: ${{ inputs.php-version }} tools: composer @@ -194,7 +200,7 @@ jobs: echo "ARTIFACT_NAME=${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}-db-${{ inputs.db-platform || 'none' }}${{ inputs.mysql-version || '' }}" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ${{ steps.setup-env.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}-db-${{ inputs.db-platform || 'none' }}-${{ hashFiles('**/composer.*') }} @@ -205,7 +211,7 @@ jobs: - name: Cache PHPUnit's static analysis cache if: ${{ inputs.enable-artifact-upload }} - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: build/.phpunit.cache/code-coverage key: phpunit-code-coverage-${{ hashFiles('**/phpunit.*') }} @@ -235,7 +241,7 @@ jobs: - name: Upload coverage results as artifact if: ${{ inputs.enable-artifact-upload }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: ${{ steps.setup-env.outputs.ARTIFACT_NAME }} path: build/cov/coverage-${{ steps.setup-env.outputs.ARTIFACT_NAME }}.cov diff --git a/.github/workflows/reusable-serviceless-phpunit-test.yml b/.github/workflows/reusable-serviceless-phpunit-test.yml index 201abb984655..af1d05dcb68e 100644 --- a/.github/workflows/reusable-serviceless-phpunit-test.yml +++ b/.github/workflows/reusable-serviceless-phpunit-test.yml @@ -50,6 +50,9 @@ on: type: string required: false +permissions: + contents: read + jobs: tests: name: ${{ inputs.job-name }} @@ -64,15 +67,18 @@ jobs: - name: Checkout base branch for PR if: github.event_name == 'pull_request' - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.base_ref }} + persist-credentials: false - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 with: php-version: ${{ inputs.php-version }} tools: composer @@ -89,7 +95,7 @@ jobs: echo "ARTIFACT_NAME=${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}" >> $GITHUB_OUTPUT - name: Cache Composer dependencies - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ${{ steps.setup-env.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}-${{ hashFiles('**/composer.*') }} @@ -99,7 +105,7 @@ jobs: - name: Cache PHPUnit's static analysis cache if: ${{ inputs.enable-artifact-upload }} - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: build/.phpunit.cache/code-coverage key: phpunit-code-coverage-${{ hashFiles('**/phpunit.*') }} @@ -127,7 +133,7 @@ jobs: - name: Upload coverage results as artifact if: ${{ inputs.enable-artifact-upload }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: ${{ steps.setup-env.outputs.ARTIFACT_NAME }} path: build/cov/coverage-${{ steps.setup-env.outputs.ARTIFACT_NAME }}.cov diff --git a/.github/workflows/test-autoreview.yml b/.github/workflows/test-autoreview.yml index b32bf1019833..aefdb8cc213a 100644 --- a/.github/workflows/test-autoreview.yml +++ b/.github/workflows/test-autoreview.yml @@ -37,15 +37,15 @@ jobs: steps: - name: Checkout base branch for PR if: github.event_name == 'pull_request' - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.base_ref }} - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 with: php-version: '8.2' diff --git a/.github/workflows/test-coding-standards.yml b/.github/workflows/test-coding-standards.yml index c9c50c83dc9c..0a2de3ccb268 100644 --- a/.github/workflows/test-coding-standards.yml +++ b/.github/workflows/test-coding-standards.yml @@ -36,15 +36,15 @@ jobs: steps: - name: Checkout base branch for PR if: github.event_name == 'pull_request' - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.base_ref }} - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 with: php-version: ${{ matrix.php-version }} extensions: tokenizer @@ -55,7 +55,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} diff --git a/.github/workflows/test-deptrac.yml b/.github/workflows/test-deptrac.yml index 5bb463d09f2f..33a734507ba2 100644 --- a/.github/workflows/test-deptrac.yml +++ b/.github/workflows/test-deptrac.yml @@ -38,15 +38,15 @@ jobs: steps: - name: Checkout base branch for PR if: github.event_name == 'pull_request' - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.base_ref }} - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 with: php-version: '8.2' tools: composer @@ -60,7 +60,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -70,7 +70,7 @@ jobs: run: mkdir -p build/ - name: Cache Deptrac results - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: build key: ${{ runner.os }}-deptrac-${{ github.sha }} diff --git a/.github/workflows/test-file-permissions.yml b/.github/workflows/test-file-permissions.yml index 726cace25cc9..8c61248d215f 100644 --- a/.github/workflows/test-file-permissions.yml +++ b/.github/workflows/test-file-permissions.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Detect unnecessary execution permissions run: php utils/check_permission_x.php diff --git a/.github/workflows/test-phpcpd.yml b/.github/workflows/test-phpcpd.yml index 1de4c8167b9a..636252763ddf 100644 --- a/.github/workflows/test-phpcpd.yml +++ b/.github/workflows/test-phpcpd.yml @@ -22,9 +22,14 @@ on: - 'system/**.php' - '.github/workflows/test-phpcpd.yml' +permissions: + contents: read + jobs: phpcpd: - uses: codeigniter4/.github/.github/workflows/phpcpd.yml@main + # Note: Reusable workflow SHA must be manually updated. Check for updates with: + # git ls-remote https://github.com/codeigniter4/.github main | head -1 + uses: codeigniter4/.github/.github/workflows/phpcpd.yml@0ad5e1bc5620281e766d3267205dc4c22f4ac0ee # main with: dirs: "app/ public/ system/" options: >- diff --git a/.github/workflows/test-phpstan.yml b/.github/workflows/test-phpstan.yml index ad9880d434b6..b4bd7e14a3c3 100644 --- a/.github/workflows/test-phpstan.yml +++ b/.github/workflows/test-phpstan.yml @@ -47,15 +47,15 @@ jobs: steps: - name: Checkout base branch for PR if: github.event_name == 'pull_request' - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.base_ref }} - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 with: php-version: '8.2' extensions: intl @@ -72,7 +72,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -82,7 +82,7 @@ jobs: run: mkdir -p build/phpstan - name: Cache PHPStan result cache directory - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: build/phpstan key: ${{ runner.os }}-phpstan-${{ github.sha }} diff --git a/.github/workflows/test-psalm.yml b/.github/workflows/test-psalm.yml index 7f2e1339cd58..a53d0cb6580b 100644 --- a/.github/workflows/test-psalm.yml +++ b/.github/workflows/test-psalm.yml @@ -20,6 +20,9 @@ on: - 'psalm*' - '.github/workflows/test-psalm.yml' +permissions: + contents: read + jobs: build: name: Psalm Analysis @@ -34,15 +37,18 @@ jobs: steps: - name: Checkout base branch for PR if: github.event_name == 'pull_request' - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.base_ref }} + persist-credentials: false - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 with: php-version: ${{ matrix.php-version }} extensions: intl, json, mbstring, xml, mysqli, oci8, pgsql, sqlsrv, sqlite3 @@ -55,7 +61,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ hashFiles('**/composer.lock') }} @@ -65,7 +71,7 @@ jobs: run: mkdir -p build/psalm - name: Cache Psalm results - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: build/psalm key: ${{ runner.os }}-psalm-${{ github.sha }} diff --git a/.github/workflows/test-random-execution.yml b/.github/workflows/test-random-execution.yml index 707cf01aaded..067d9b78d22c 100644 --- a/.github/workflows/test-random-execution.yml +++ b/.github/workflows/test-random-execution.yml @@ -168,10 +168,10 @@ jobs: -Q "CREATE DATABASE test COLLATE Latin1_General_100_CS_AS_SC_UTF8" - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP ${{ matrix.php-version }} - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 with: php-version: ${{ matrix.php-version }} extensions: gd, curl, iconv, json, mbstring, openssl, sodium @@ -182,7 +182,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: PHP_${{ matrix.php-version }}-${{ hashFiles('**/composer.*') }} diff --git a/.github/workflows/test-rector.yml b/.github/workflows/test-rector.yml index abbd7ed7369f..25f68a41db30 100644 --- a/.github/workflows/test-rector.yml +++ b/.github/workflows/test-rector.yml @@ -54,15 +54,15 @@ jobs: steps: - name: Checkout base branch for PR if: github.event_name == 'pull_request' - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.base_ref }} - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 with: php-version: ${{ matrix.php-version }} extensions: intl @@ -78,7 +78,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -88,7 +88,7 @@ jobs: run: composer update --ansi --no-interaction ${{ matrix.composer-option }} - name: Rector Cache - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: /tmp/rector key: ${{ runner.os }}-rector-${{ github.run_id }} diff --git a/.github/workflows/test-scss.yml b/.github/workflows/test-scss.yml index 75000cd291ef..9fe8de6dc471 100644 --- a/.github/workflows/test-scss.yml +++ b/.github/workflows/test-scss.yml @@ -33,10 +33,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node - uses: actions/setup-node@v6.0.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: # node version based on dart-sass test workflow node-version: 16 diff --git a/.github/workflows/test-userguide.yml b/.github/workflows/test-userguide.yml index 94ec8af53022..310eff3277f9 100644 --- a/.github/workflows/test-userguide.yml +++ b/.github/workflows/test-userguide.yml @@ -24,10 +24,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.12' diff --git a/admin/starter/.github/workflows/phpunit.yml b/admin/starter/.github/workflows/phpunit.yml index 7d51bc242532..308e0565835f 100644 --- a/admin/starter/.github/workflows/phpunit.yml +++ b/admin/starter/.github/workflows/phpunit.yml @@ -5,6 +5,9 @@ on: branches: - develop +permissions: + contents: read + jobs: main: name: Build and test @@ -18,10 +21,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup PHP, with composer and extensions - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 with: php-version: ${{ matrix.php-versions }} tools: composer, pecl, phpunit @@ -33,7 +38,7 @@ jobs: run: echo "COMPOSER_CACHE_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} diff --git a/admin/userguide/.github/workflows/deploy.yml b/admin/userguide/.github/workflows/deploy.yml index b26397dbb03c..8dad96764a03 100644 --- a/admin/userguide/.github/workflows/deploy.yml +++ b/admin/userguide/.github/workflows/deploy.yml @@ -7,13 +7,15 @@ on: branches: - master +permissions: {} + jobs: build: runs-on: ubuntu-24.04 steps: - name: executing remote ssh commands using ssh key - uses: appleboy/ssh-action@master + uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5 with: host: ${{ secrets.HOST }} username: ${{ secrets.USERNAME }} From f47c5b28e0b85df4e2433fd909f4720579bd7dac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:53:40 +0800 Subject: [PATCH 64/75] chore(deps): bump shivammathur/setup-php in / (#10041) Bumps [shivammathur/setup-php](https://github.com/shivammathur/setup-php) in `/` from 2.36.0 to 2.37.0. Updates `shivammathur/setup-php` from 2.36.0 to 2.37.0 - [Release notes](https://github.com/shivammathur/setup-php/releases) - [Commits](https://github.com/shivammathur/setup-php/compare/44454db4f0199b8b9685a5d763dc37cbf79108e1...accd6127cb78bee3e8082180cb391013d204ef9f) --- updated-dependencies: - dependency-name: shivammathur/setup-php dependency-version: 2.37.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github_actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/deploy-apidocs.yml | 2 +- .github/workflows/deploy-userguide-latest.yml | 2 +- .github/workflows/reusable-coveralls.yml | 2 +- .github/workflows/reusable-phpunit-test.yml | 2 +- .github/workflows/reusable-serviceless-phpunit-test.yml | 2 +- .github/workflows/test-autoreview.yml | 2 +- .github/workflows/test-coding-standards.yml | 2 +- .github/workflows/test-deptrac.yml | 2 +- .github/workflows/test-phpstan.yml | 2 +- .github/workflows/test-psalm.yml | 2 +- .github/workflows/test-random-execution.yml | 2 +- .github/workflows/test-rector.yml | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/deploy-apidocs.yml b/.github/workflows/deploy-apidocs.yml index c728febeee30..b1c4380587ba 100644 --- a/.github/workflows/deploy-apidocs.yml +++ b/.github/workflows/deploy-apidocs.yml @@ -43,7 +43,7 @@ jobs: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: '8.2' tools: phive diff --git a/.github/workflows/deploy-userguide-latest.yml b/.github/workflows/deploy-userguide-latest.yml index 48b5b9b8a70a..196c764587e6 100644 --- a/.github/workflows/deploy-userguide-latest.yml +++ b/.github/workflows/deploy-userguide-latest.yml @@ -30,7 +30,7 @@ jobs: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: '8.2' coverage: none diff --git a/.github/workflows/reusable-coveralls.yml b/.github/workflows/reusable-coveralls.yml index 36106f04e3c0..a865a5ca3098 100644 --- a/.github/workflows/reusable-coveralls.yml +++ b/.github/workflows/reusable-coveralls.yml @@ -29,7 +29,7 @@ jobs: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: ${{ inputs.php-version }} tools: composer diff --git a/.github/workflows/reusable-phpunit-test.yml b/.github/workflows/reusable-phpunit-test.yml index a83c01a9f8b0..1e0e79dd721f 100644 --- a/.github/workflows/reusable-phpunit-test.yml +++ b/.github/workflows/reusable-phpunit-test.yml @@ -183,7 +183,7 @@ jobs: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: ${{ inputs.php-version }} tools: composer diff --git a/.github/workflows/reusable-serviceless-phpunit-test.yml b/.github/workflows/reusable-serviceless-phpunit-test.yml index af1d05dcb68e..daa3f09ebc85 100644 --- a/.github/workflows/reusable-serviceless-phpunit-test.yml +++ b/.github/workflows/reusable-serviceless-phpunit-test.yml @@ -78,7 +78,7 @@ jobs: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: ${{ inputs.php-version }} tools: composer diff --git a/.github/workflows/test-autoreview.yml b/.github/workflows/test-autoreview.yml index aefdb8cc213a..fd28e6d4b1c8 100644 --- a/.github/workflows/test-autoreview.yml +++ b/.github/workflows/test-autoreview.yml @@ -45,7 +45,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: '8.2' diff --git a/.github/workflows/test-coding-standards.yml b/.github/workflows/test-coding-standards.yml index 0a2de3ccb268..ed1176ca98d3 100644 --- a/.github/workflows/test-coding-standards.yml +++ b/.github/workflows/test-coding-standards.yml @@ -44,7 +44,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: ${{ matrix.php-version }} extensions: tokenizer diff --git a/.github/workflows/test-deptrac.yml b/.github/workflows/test-deptrac.yml index 33a734507ba2..4ee1070d0af9 100644 --- a/.github/workflows/test-deptrac.yml +++ b/.github/workflows/test-deptrac.yml @@ -46,7 +46,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: '8.2' tools: composer diff --git a/.github/workflows/test-phpstan.yml b/.github/workflows/test-phpstan.yml index b4bd7e14a3c3..c063d2fc927c 100644 --- a/.github/workflows/test-phpstan.yml +++ b/.github/workflows/test-phpstan.yml @@ -55,7 +55,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: '8.2' extensions: intl diff --git a/.github/workflows/test-psalm.yml b/.github/workflows/test-psalm.yml index a53d0cb6580b..e0d0e89de13f 100644 --- a/.github/workflows/test-psalm.yml +++ b/.github/workflows/test-psalm.yml @@ -48,7 +48,7 @@ jobs: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: ${{ matrix.php-version }} extensions: intl, json, mbstring, xml, mysqli, oci8, pgsql, sqlsrv, sqlite3 diff --git a/.github/workflows/test-random-execution.yml b/.github/workflows/test-random-execution.yml index 067d9b78d22c..058b575eb278 100644 --- a/.github/workflows/test-random-execution.yml +++ b/.github/workflows/test-random-execution.yml @@ -171,7 +171,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP ${{ matrix.php-version }} - uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: ${{ matrix.php-version }} extensions: gd, curl, iconv, json, mbstring, openssl, sodium diff --git a/.github/workflows/test-rector.yml b/.github/workflows/test-rector.yml index 25f68a41db30..bac692b0fc76 100644 --- a/.github/workflows/test-rector.yml +++ b/.github/workflows/test-rector.yml @@ -62,7 +62,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: ${{ matrix.php-version }} extensions: intl From cc73ff935b393db9163eefdfbf5db8f3062d87a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:31:23 +0800 Subject: [PATCH 65/75] chore(deps): bump actions/setup-node in / (#10042) Bumps [actions/setup-node](https://github.com/actions/setup-node) in `/` from 6.0.0 to 6.3.0. Updates `actions/setup-node` from 6.0.0 to 6.3.0 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/2028fbc5c25fe9cf00d9f06a71cc4710d4507903...53b83947a5a98c8d113130e565377fae1a50d02f) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 6.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github_actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test-scss.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-scss.yml b/.github/workflows/test-scss.yml index 9fe8de6dc471..b1227e35e0d0 100644 --- a/.github/workflows/test-scss.yml +++ b/.github/workflows/test-scss.yml @@ -36,7 +36,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: # node version based on dart-sass test workflow node-version: 16 From e7bf630a3842ebc06fd17eb12e8a036f1e7ea98c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:26:51 +0100 Subject: [PATCH 66/75] chore(deps-dev): update rector/rector requirement (#10045) Updates the requirements on [rector/rector](https://github.com/rectorphp/rector) to permit the latest version. Updates `rector/rector` to 2.3.9 - [Release notes](https://github.com/rectorphp/rector/releases) - [Commits](https://github.com/rectorphp/rector/compare/2.3.8...2.3.9) --- updated-dependencies: - dependency-name: rector/rector dependency-version: 2.3.9 dependency-type: direct:development dependency-group: composer-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index db5545d4694e..7ba9b8dd44e2 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "phpunit/phpcov": "^9.0.2 || ^10.0", "phpunit/phpunit": "^10.5.16 || ^11.2", "predis/predis": "^3.0", - "rector/rector": "2.3.8", + "rector/rector": "2.3.9", "shipmonk/phpstan-baseline-per-identifier": "^2.0" }, "replace": { From fb4082da8a4eb0346818be93be7cee973994201f Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Thu, 19 Mar 2026 07:39:11 +0100 Subject: [PATCH 67/75] fix: handle HTTP/2 responses without a reason phrase in CURLRequest (#10050) * fix: handle HTTP/2 responses without a reason phrase in CURLRequest * add changelog * fix changelog * fix changelog --- system/HTTP/CURLRequest.php | 4 ++-- tests/system/Cookie/CookieTest.php | 2 +- tests/system/HTTP/CURLRequestTest.php | 17 +++++++++++++++++ user_guide_src/source/changelogs/v4.7.1.rst | 3 ++- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/system/HTTP/CURLRequest.php b/system/HTTP/CURLRequest.php index 29516a094a86..a1dc31dd7517 100644 --- a/system/HTTP/CURLRequest.php +++ b/system/HTTP/CURLRequest.php @@ -504,14 +504,14 @@ protected function setResponseHeaders(array $headers = []) $this->response->setHeader($title, $value); } } elseif (str_starts_with($header, 'HTTP')) { - preg_match('#^HTTP\/([12](?:\.[01])?) (\d+) (.+)#', $header, $matches); + preg_match('#^HTTP\/([12](?:\.[01])?) (\d+)(?: (.+))?#', $header, $matches); if (isset($matches[1])) { $this->response->setProtocolVersion($matches[1]); } if (isset($matches[2])) { - $this->response->setStatusCode((int) $matches[2], $matches[3] ?? null); + $this->response->setStatusCode((int) $matches[2], $matches[3] ?? ''); } } } diff --git a/tests/system/Cookie/CookieTest.php b/tests/system/Cookie/CookieTest.php index 1438cfb9d513..542ff096d0ce 100644 --- a/tests/system/Cookie/CookieTest.php +++ b/tests/system/Cookie/CookieTest.php @@ -301,7 +301,7 @@ public function testArrayAccessOfCookie(): void $this->assertSame($cookie['path'], $cookie->getPath()); $this->expectException('InvalidArgumentException'); - $cookie['expiry']; // @phpstan-ignore expr.resultUnused + $cookie['expiry']; } public function testCannotSetPropertyViaArrayAccess(): void diff --git a/tests/system/HTTP/CURLRequestTest.php b/tests/system/HTTP/CURLRequestTest.php index 792304e8087a..9fac71493b39 100644 --- a/tests/system/HTTP/CURLRequestTest.php +++ b/tests/system/HTTP/CURLRequestTest.php @@ -1041,6 +1041,23 @@ public function testResponseHeadersShortProtocol(): void $this->assertSame(235, $response->getStatusCode()); } + public function testResponseHeadersWithoutReasonPhrase(): void + { + // HTTP/2 does not include a reason phrase per RFC 7540. + // curl synthesizes the status line as "HTTP/2 200" with no trailing reason. + $request = $this->getRequest([ + 'baseURI' => 'http://www.foo.com/api/v1/', + 'delay' => 100, + ]); + + $request->setOutput("HTTP/2 200\x0d\x0aContent-Type: text/html\x0d\x0a\x0d\x0aHi there"); + $response = $request->get('bogus'); + + $this->assertSame('2.0', $response->getProtocolVersion()); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('OK', $response->getReasonPhrase()); + } + public function testPostFormEncoded(): void { $params = [ diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst index bd5ef485d17c..46e739b61f64 100644 --- a/user_guide_src/source/changelogs/v4.7.1.rst +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -51,14 +51,15 @@ Bugs Fixed - **ContentSecurityPolicy:** Fixed a bug where custom CSP tags were not removed from generated HTML when CSP was disabled. The method now ensures that all custom CSP tags are removed from the generated HTML. - **ContentSecurityPolicy:** Fixed a bug where ``generateNonces()`` produces corrupted JSON responses by replacing CSP nonce placeholders with unescaped double quotes. The method now automatically JSON-escapes nonce attributes when the response Content-Type is JSON. - **ContentSecurityPolicy:** Fixed a bug where nonces generated by ``getScriptNonce()`` and ``getStyleNonce()`` were not added to the ``script-src-elem`` and ``style-src-elem`` directives, causing nonces to be silently ignored by browsers when those directives were present. +- **CURLRequest:** Fixed a bug where HTTP/2 responses without a reason phrase (e.g., ``HTTP/2 200``) were not parsed correctly, causing the status code and protocol version to be ignored. - **Database:** Fixed a bug where ``BaseConnection::callFunction()`` could double-prefix already-prefixed function names. - **Database:** Fixed a bug where ``BasePreparedQuery::prepare()`` could mis-handle SQL containing colon syntax by over-broad named-placeholder replacement. It now preserves PostgreSQL cast syntax like ``::timestamp``. - **Model:** Fixed a bug where ``BaseModel::updateBatch()`` threw an exception when ``updateOnlyChanged`` was ``true`` and the index field value did not change. - **Model:** Fixed a bug where ``Model::chunk()`` ran an unnecessary extra database query at the end of iteration. ``chunk()`` now also throws ``InvalidArgumentException`` when called with a non-positive chunk size. - **Session:** Fixed a bug in ``MemcachedHandler`` where the constructor incorrectly threw an exception when ``savePath`` was not empty. +- **Testing:** Fixed a bug in ``FeatureTestTrait::withRoutes()`` where invalid HTTP methods were not properly validated, thus passing them all to ``RouteCollection``. - **Toolbar:** Fixed a bug where the standalone toolbar page loaded from ``?debugbar_time=...`` was not interactive. - **Toolbar:** Fixed a bug in the Routes panel where only the first route parameter was converted to an input field on hover. -- **Testing:** Fixed a bug in ``FeatureTestTrait::withRoutes()`` where invalid HTTP methods were not properly validated, thus passing them all to ``RouteCollection``. - **Validation:** Rule ``valid_cc_number`` now has the correct translation. - **Validation:** Fixed a bug where rules did not fire for array elements missing a key when using wildcard fields (e.g., ``contacts.friends.*.name``). - **View:** Fixed a bug where ``View`` would throw an error if the ``appOverridesFolder`` config property was not defined. From cef5cb5539e5b8db9fa574e7e28b45dd826de81f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:11:49 +0100 Subject: [PATCH 68/75] chore(deps): bump actions/cache in / (#10049) Bumps [actions/cache](https://github.com/actions/cache) in `/` from 5.0.3 to 5.0.4. Updates `actions/cache` from 5.0.3 to 5.0.4 - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/cdf6c1fa76f9f475f3d7449005a359c84ca0f306...668228422ae6a00e4ad889ee87cd7109ec5666a7) --- updated-dependencies: - dependency-name: actions/cache dependency-version: 5.0.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github_actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/reusable-coveralls.yml | 4 ++-- .github/workflows/reusable-phpunit-test.yml | 4 ++-- .github/workflows/reusable-serviceless-phpunit-test.yml | 4 ++-- .github/workflows/test-coding-standards.yml | 2 +- .github/workflows/test-deptrac.yml | 4 ++-- .github/workflows/test-phpstan.yml | 4 ++-- .github/workflows/test-psalm.yml | 4 ++-- .github/workflows/test-random-execution.yml | 2 +- .github/workflows/test-rector.yml | 4 ++-- 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/reusable-coveralls.yml b/.github/workflows/reusable-coveralls.yml index a865a5ca3098..aca808161485 100644 --- a/.github/workflows/reusable-coveralls.yml +++ b/.github/workflows/reusable-coveralls.yml @@ -50,7 +50,7 @@ jobs: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ github.job }}-php-${{ inputs.php-version }}-${{ hashFiles('**/composer.*') }} @@ -59,7 +59,7 @@ jobs: ${{ github.job }}- - name: Cache PHPUnit's static analysis cache - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: build/.phpunit.cache/code-coverage key: phpunit-code-coverage-${{ hashFiles('**/phpunit.*') }} diff --git a/.github/workflows/reusable-phpunit-test.yml b/.github/workflows/reusable-phpunit-test.yml index 1e0e79dd721f..c71ab575a53d 100644 --- a/.github/workflows/reusable-phpunit-test.yml +++ b/.github/workflows/reusable-phpunit-test.yml @@ -200,7 +200,7 @@ jobs: echo "ARTIFACT_NAME=${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}-db-${{ inputs.db-platform || 'none' }}${{ inputs.mysql-version || '' }}" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ${{ steps.setup-env.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}-db-${{ inputs.db-platform || 'none' }}-${{ hashFiles('**/composer.*') }} @@ -211,7 +211,7 @@ jobs: - name: Cache PHPUnit's static analysis cache if: ${{ inputs.enable-artifact-upload }} - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: build/.phpunit.cache/code-coverage key: phpunit-code-coverage-${{ hashFiles('**/phpunit.*') }} diff --git a/.github/workflows/reusable-serviceless-phpunit-test.yml b/.github/workflows/reusable-serviceless-phpunit-test.yml index daa3f09ebc85..98bda6f81922 100644 --- a/.github/workflows/reusable-serviceless-phpunit-test.yml +++ b/.github/workflows/reusable-serviceless-phpunit-test.yml @@ -95,7 +95,7 @@ jobs: echo "ARTIFACT_NAME=${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}" >> $GITHUB_OUTPUT - name: Cache Composer dependencies - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ${{ steps.setup-env.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}-${{ hashFiles('**/composer.*') }} @@ -105,7 +105,7 @@ jobs: - name: Cache PHPUnit's static analysis cache if: ${{ inputs.enable-artifact-upload }} - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: build/.phpunit.cache/code-coverage key: phpunit-code-coverage-${{ hashFiles('**/phpunit.*') }} diff --git a/.github/workflows/test-coding-standards.yml b/.github/workflows/test-coding-standards.yml index ed1176ca98d3..dcef94f5f0fa 100644 --- a/.github/workflows/test-coding-standards.yml +++ b/.github/workflows/test-coding-standards.yml @@ -55,7 +55,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} diff --git a/.github/workflows/test-deptrac.yml b/.github/workflows/test-deptrac.yml index 4ee1070d0af9..de0da7242503 100644 --- a/.github/workflows/test-deptrac.yml +++ b/.github/workflows/test-deptrac.yml @@ -60,7 +60,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -70,7 +70,7 @@ jobs: run: mkdir -p build/ - name: Cache Deptrac results - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: build key: ${{ runner.os }}-deptrac-${{ github.sha }} diff --git a/.github/workflows/test-phpstan.yml b/.github/workflows/test-phpstan.yml index c063d2fc927c..00b858923ed8 100644 --- a/.github/workflows/test-phpstan.yml +++ b/.github/workflows/test-phpstan.yml @@ -72,7 +72,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -82,7 +82,7 @@ jobs: run: mkdir -p build/phpstan - name: Cache PHPStan result cache directory - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: build/phpstan key: ${{ runner.os }}-phpstan-${{ github.sha }} diff --git a/.github/workflows/test-psalm.yml b/.github/workflows/test-psalm.yml index e0d0e89de13f..c165dc259adc 100644 --- a/.github/workflows/test-psalm.yml +++ b/.github/workflows/test-psalm.yml @@ -61,7 +61,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ hashFiles('**/composer.lock') }} @@ -71,7 +71,7 @@ jobs: run: mkdir -p build/psalm - name: Cache Psalm results - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: build/psalm key: ${{ runner.os }}-psalm-${{ github.sha }} diff --git a/.github/workflows/test-random-execution.yml b/.github/workflows/test-random-execution.yml index 058b575eb278..91ccfc174148 100644 --- a/.github/workflows/test-random-execution.yml +++ b/.github/workflows/test-random-execution.yml @@ -182,7 +182,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: PHP_${{ matrix.php-version }}-${{ hashFiles('**/composer.*') }} diff --git a/.github/workflows/test-rector.yml b/.github/workflows/test-rector.yml index bac692b0fc76..c3462d8fba09 100644 --- a/.github/workflows/test-rector.yml +++ b/.github/workflows/test-rector.yml @@ -78,7 +78,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -88,7 +88,7 @@ jobs: run: composer update --ansi --no-interaction ${{ matrix.composer-option }} - name: Rector Cache - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: /tmp/rector key: ${{ runner.os }}-rector-${{ github.run_id }} From 601cfa9f3ce007f34b8e95e76071cdcdb689fccd Mon Sep 17 00:00:00 2001 From: Carlos Baeza Date: Thu, 19 Mar 2026 05:27:09 -0300 Subject: [PATCH 69/75] docs: class defined as "Ping" but endpoints trying to fetch "Pings" (#10033) --- .../source/guides/api/first-endpoint.rst | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/user_guide_src/source/guides/api/first-endpoint.rst b/user_guide_src/source/guides/api/first-endpoint.rst index 46d56d687a49..90eed3e45e74 100644 --- a/user_guide_src/source/guides/api/first-endpoint.rst +++ b/user_guide_src/source/guides/api/first-endpoint.rst @@ -33,7 +33,7 @@ Then, in **app/Config/Routing.php**, confirm auto-routing is **enabled**: public bool $autoRoute = true; -That's all you need for CodeIgniter to automatically map your controller classes and to URIs like ``GET /api/pings`` or ``POST /api/pings``. +That's all you need for CodeIgniter to automatically map your controller classes and to URIs like ``GET /api/ping`` or ``POST /api/ping``. Create a Ping Controller ======================== @@ -53,7 +53,7 @@ Edit the file so it looks like this: Here we: - Use the :php:class:`ResponseTrait`, which already includes REST helpers such as :php:meth:`respond()` and proper status codes. -- Define a ``getIndex()`` method. The ``get`` prefix means it responds to ``GET`` requests, and the ``Index`` name means it matches the base URI (``/api/pings``). +- Define a ``getIndex()`` method. The ``get`` prefix means it responds to ``GET`` requests, and the ``Index`` name means it matches the base URI (``/api/ping``). Test the route ============== @@ -66,8 +66,8 @@ Start the development server if it isn't running: Now visit: -- **Browser:** ``http://localhost:8080/api/pings`` -- **cURL:** ``curl http://localhost:8080/api/pings`` +- **Browser:** ``http://localhost:8080/api/ping`` +- **cURL:** ``curl http://localhost:8080/api/ping`` Expected response: @@ -82,9 +82,9 @@ Congratulations — that's your first working JSON endpoint! Understand how it works ======================= -When you request ``/api/pings``: +When you request ``/api/ping``: -1. The **Improved Auto Router** finds the ``App\Controllers\Api\Pings`` class. +1. The **Improved Auto Router** finds the ``App\Controllers\Api\Ping`` class. 2. It detects the HTTP verb (``GET``). 3. It calls the corresponding method name: ``getIndex()``. 4. :php:trait:`ResponseTrait` provides helper methods to produce consistent output. @@ -94,9 +94,9 @@ Here's how other verbs would map if you added them later: +-----------------------+--------------------------------+ | HTTP Verb | Method Name | +=======================+================================+ -| ``GET /api/pings`` | ``getIndex()`` | -| ``POST /api/pings`` | ``postIndex()`` | -| ``DELETE /api/pings`` | ``deleteIndex()`` | +| ``GET /api/ping`` | ``getIndex()`` | +| ``POST /api/ping`` | ``postIndex()`` | +| ``DELETE /api/ping`` | ``deleteIndex()`` | +-----------------------+--------------------------------+ Content Negotiation with the Format Class From fa57ec752622236bae868d2985afa350c5918c70 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Sat, 21 Mar 2026 09:27:38 +0100 Subject: [PATCH 70/75] fix: SQLite3 config type handling for `.env` overrides (#10037) --- system/Database/BaseConnection.php | 145 +++++++++++++++++- system/Database/SQLite3/Connection.php | 2 +- tests/system/Database/BaseConnectionTest.php | 125 +++++++++++++++ user_guide_src/source/changelogs/v4.7.1.rst | 3 + .../source/installation/upgrade_471.rst | 12 ++ 5 files changed, 284 insertions(+), 3 deletions(-) diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index 66070942a11b..bd2cb62053dd 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -16,6 +16,10 @@ use Closure; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Events\Events; +use ReflectionClass; +use ReflectionNamedType; +use ReflectionType; +use ReflectionUnionType; use stdClass; use Stringable; use Throwable; @@ -59,6 +63,13 @@ */ abstract class BaseConnection implements ConnectionInterface { + /** + * Cached builtin type names per class/property. + * + * @var array>> + */ + private static array $propertyBuiltinTypesCache = []; + /** * Data Source Name / Connect string * @@ -372,9 +383,14 @@ public function __construct(array $params) unset($params['dateFormat']); } + $typedPropertyTypes = $this->getBuiltinPropertyTypesMap(array_keys($params)); + foreach ($params as $key => $value) { if (property_exists($this, $key)) { - $this->{$key} = $value; + $this->{$key} = $this->castScalarValueForTypedProperty( + $value, + $typedPropertyTypes[$key] ?? [], + ); } } @@ -392,6 +408,126 @@ public function __construct(array $params) } } + /** + * Some config values (especially env overrides without clear source type) + * can still reach us as strings. Coerce them for typed properties to keep + * strict typing compatible. + * + * @param list $types + */ + private function castScalarValueForTypedProperty(mixed $value, array $types): mixed + { + if (! is_string($value)) { + return $value; + } + + if ($types === [] || in_array('string', $types, true) || in_array('mixed', $types, true)) { + return $value; + } + + $trimmedValue = trim($value); + + if (in_array('null', $types, true) && strtolower($trimmedValue) === 'null') { + return null; + } + + if (in_array('int', $types, true) && preg_match('/^[+-]?\d+$/', $trimmedValue) === 1) { + return (int) $trimmedValue; + } + + if (in_array('float', $types, true) && is_numeric($trimmedValue)) { + return (float) $trimmedValue; + } + + if (in_array('bool', $types, true) || in_array('false', $types, true) || in_array('true', $types, true)) { + $boolValue = filter_var($trimmedValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + if ($boolValue !== null) { + if (in_array('bool', $types, true)) { + return $boolValue; + } + + if ($boolValue === false && in_array('false', $types, true)) { + return false; + } + + if ($boolValue === true && in_array('true', $types, true)) { + return true; + } + } + } + + return $value; + } + + /** + * @param list $properties + * + * @return array> + */ + private function getBuiltinPropertyTypesMap(array $properties): array + { + $className = static::class; + $requested = array_fill_keys($properties, true); + + if (! isset(self::$propertyBuiltinTypesCache[$className])) { + self::$propertyBuiltinTypesCache[$className] = []; + } + + // Fill only the properties requested by this call that are not cached yet. + $missing = array_diff_key($requested, self::$propertyBuiltinTypesCache[$className]); + + if ($missing !== []) { + $reflection = new ReflectionClass($className); + + foreach ($reflection->getProperties() as $property) { + $propertyName = $property->getName(); + + if (! isset($missing[$propertyName])) { + continue; + } + + $type = $property->getType(); + + if (! $type instanceof ReflectionType) { + self::$propertyBuiltinTypesCache[$className][$propertyName] = []; + + continue; + } + + $namedTypes = $type instanceof ReflectionUnionType ? $type->getTypes() : [$type]; + $builtinTypes = []; + + foreach ($namedTypes as $namedType) { + if (! $namedType instanceof ReflectionNamedType || ! $namedType->isBuiltin()) { + continue; + } + + $builtinTypes[] = $namedType->getName(); + } + + if ($type->allowsNull() && ! in_array('null', $builtinTypes, true)) { + $builtinTypes[] = 'null'; + } + + self::$propertyBuiltinTypesCache[$className][$propertyName] = $builtinTypes; + } + + // Untyped or unresolved properties are cached as empty to avoid re-reflecting them. + foreach (array_keys($missing) as $propertyName) { + self::$propertyBuiltinTypesCache[$className][$propertyName] ??= []; + } + } + + $typedProperties = []; + + foreach ($properties as $property) { + $typedProperties[$property] = self::$propertyBuiltinTypesCache[$className][$property] ?? []; + } + + return $typedProperties; + } + /** * Initializes the database connection/settings. * @@ -433,10 +569,15 @@ public function initialize() if (! empty($this->failover) && is_array($this->failover)) { // Go over all the failovers foreach ($this->failover as $index => $failover) { + $typedPropertyTypes = $this->getBuiltinPropertyTypesMap(array_keys($failover)); + // Replace the current settings with those of the failover foreach ($failover as $key => $val) { if (property_exists($this, $key)) { - $this->{$key} = $val; + $this->{$key} = $this->castScalarValueForTypedProperty( + $val, + $typedPropertyTypes[$key] ?? [], + ); } } diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php index 9f015b8e9cb6..4495e68c4418 100644 --- a/system/Database/SQLite3/Connection.php +++ b/system/Database/SQLite3/Connection.php @@ -55,7 +55,7 @@ class Connection extends BaseConnection * * @see https://www.php.net/manual/en/sqlite3.busytimeout */ - protected $busyTimeout; + protected ?int $busyTimeout = null; /** * The setting of the "synchronous" flag diff --git a/tests/system/Database/BaseConnectionTest.php b/tests/system/Database/BaseConnectionTest.php index 0a797c3ac9ca..4d6d1f76173d 100644 --- a/tests/system/Database/BaseConnectionTest.php +++ b/tests/system/Database/BaseConnectionTest.php @@ -19,6 +19,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use Throwable; +use TypeError; /** * @internal @@ -95,6 +96,130 @@ public function testSavesConfigOptions(): void ], $db->dateFormat); } + public function testCastsStringConfigValuesToTypedProperties(): void + { + $db = new class ([...$this->options, 'synchronous' => '1', 'busyTimeout' => '4000', 'typedBool' => '0', 'nullInt' => 'null']) extends MockConnection { + protected ?int $synchronous = null; + protected ?int $busyTimeout = null; + protected bool $typedBool = true; + protected ?int $nullInt = 1; + + public function getSynchronous(): ?int + { + return $this->synchronous; + } + + public function getBusyTimeout(): ?int + { + return $this->busyTimeout; + } + + public function isTypedBool(): bool + { + return $this->typedBool; + } + + public function getNullInt(): ?int + { + return $this->nullInt; + } + }; + + $this->assertSame(1, $db->getSynchronous()); + $this->assertSame(4000, $db->getBusyTimeout()); + $this->assertFalse($db->isTypedBool()); + $this->assertNull($db->getNullInt()); + } + + public function testCastsExtendedBoolStringsToBool(): void + { + $db = new class ([...$this->options, 'enabledYes' => 'yes', 'enabledOn' => 'on', 'disabledNo' => 'no', 'disabledOff' => 'off']) extends MockConnection { + protected bool $enabledYes = false; + protected bool $enabledOn = false; + protected bool $disabledNo = true; + protected bool $disabledOff = true; + + public function isEnabledYes(): bool + { + return $this->enabledYes; + } + + public function isEnabledOn(): bool + { + return $this->enabledOn; + } + + public function isDisabledNo(): bool + { + return $this->disabledNo; + } + + public function isDisabledOff(): bool + { + return $this->disabledOff; + } + }; + + $this->assertTrue($db->isEnabledYes()); + $this->assertTrue($db->isEnabledOn()); + $this->assertFalse($db->isDisabledNo()); + $this->assertFalse($db->isDisabledOff()); + } + + public function testCastsFalseAndTrueStandaloneUnionTypes(): void + { + $db = new class ([...$this->options, 'withFalse' => 'false', 'withTrue' => 'true']) extends MockConnection { + protected false|int $withFalse = 0; + protected int|true $withTrue = 0; + + public function getWithFalse(): false|int + { + return $this->withFalse; + } + + public function getWithTrue(): int|true + { + return $this->withTrue; + } + }; + + $this->assertFalse($db->getWithFalse()); + $this->assertTrue($db->getWithTrue()); + } + + public function testCachesTypedPropertiesIncrementally(): void + { + $factory = static fn (array $options): MockConnection => new class ($options) extends MockConnection { + protected ?int $synchronous = null; + protected ?int $busyTimeout = null; + + public function getSynchronous(): ?int + { + return $this->synchronous; + } + + public function getBusyTimeout(): ?int + { + return $this->busyTimeout; + } + }; + + $first = $factory([...$this->options, 'synchronous' => '1']); + $second = $factory([...$this->options, 'busyTimeout' => '4000']); + + $this->assertSame(1, $first->getSynchronous()); + $this->assertSame(4000, $second->getBusyTimeout()); + } + + public function testInvalidStringValueForTypedPropertyThrowsTypeError(): void + { + $this->expectException(TypeError::class); + + new class ([...$this->options, 'synchronous' => 'not-an-int']) extends MockConnection { + protected ?int $synchronous = null; + }; + } + public function testConnectionThrowExceptionWhenCannotConnect(): void { try { diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst index 46e739b61f64..fc780d72acb8 100644 --- a/user_guide_src/source/changelogs/v4.7.1.rst +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -14,6 +14,8 @@ Release Date: Unreleased BREAKING ******** +- **Database:** ``CodeIgniter\Database\SQLite3\Connection::$busyTimeout`` is now typed as ``?int``. Custom subclasses that redeclare this property will need to be updated. + *************** Message Changes *************** @@ -54,6 +56,7 @@ Bugs Fixed - **CURLRequest:** Fixed a bug where HTTP/2 responses without a reason phrase (e.g., ``HTTP/2 200``) were not parsed correctly, causing the status code and protocol version to be ignored. - **Database:** Fixed a bug where ``BaseConnection::callFunction()`` could double-prefix already-prefixed function names. - **Database:** Fixed a bug where ``BasePreparedQuery::prepare()`` could mis-handle SQL containing colon syntax by over-broad named-placeholder replacement. It now preserves PostgreSQL cast syntax like ``::timestamp``. +- **Database:** Fixed a bug where string values from config arrays (including ``.env`` overrides) were not normalized for typed connection properties, which could cause SQLite3 options like ``synchronous`` and ``busyTimeout`` to be assigned with the wrong type. - **Model:** Fixed a bug where ``BaseModel::updateBatch()`` threw an exception when ``updateOnlyChanged`` was ``true`` and the index field value did not change. - **Model:** Fixed a bug where ``Model::chunk()`` ran an unnecessary extra database query at the end of iteration. ``chunk()`` now also throws ``InvalidArgumentException`` when called with a non-positive chunk size. - **Session:** Fixed a bug in ``MemcachedHandler`` where the constructor incorrectly threw an exception when ``savePath`` was not empty. diff --git a/user_guide_src/source/installation/upgrade_471.rst b/user_guide_src/source/installation/upgrade_471.rst index 695027d10a99..7e3074f1378a 100644 --- a/user_guide_src/source/installation/upgrade_471.rst +++ b/user_guide_src/source/installation/upgrade_471.rst @@ -34,6 +34,18 @@ Breaking Changes Breaking Enhancements ********************* +Database Connection Property Casting +====================================== + +``BaseConnection`` now casts string values coming from ``.env`` overrides to match +the declared type of each connection property. This affects properties that are +``null`` in the config array and then set via ``.env`` - such as SQLite3's +``synchronous`` or ``busyTimeout`` - which previously arrived as strings and were +stored without conversion. + +If you extended the SQLite3 handler, review your custom typed properties and update +them if needed. + ************* Project Files ************* From da26637125f4143eef6adecf49e68c3734bfc51a Mon Sep 17 00:00:00 2001 From: John Paul E Balandan Date: Sun, 22 Mar 2026 01:26:36 +0800 Subject: [PATCH 71/75] chore: fix path of `secure-git-push` in deploy-apidocs.yml (#10054) --- .github/scripts/secure-git-push | 0 .github/workflows/deploy-apidocs.yml | 9 +++++++-- utils/check_permission_x.php | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) mode change 100644 => 100755 .github/scripts/secure-git-push diff --git a/.github/scripts/secure-git-push b/.github/scripts/secure-git-push old mode 100644 new mode 100755 diff --git a/.github/workflows/deploy-apidocs.yml b/.github/workflows/deploy-apidocs.yml index b1c4380587ba..d91d7982dee3 100644 --- a/.github/workflows/deploy-apidocs.yml +++ b/.github/workflows/deploy-apidocs.yml @@ -8,8 +8,9 @@ on: branches: - 'develop' paths: - - 'system/**' + - '.github/scripts/secure-git-push' - '.github/workflows/deploy-apidocs.yml' + - 'system/**' permissions: contents: read @@ -72,7 +73,11 @@ jobs: PUSH_TOKEN: ${{ secrets.ACCESS_TOKEN }} run: | git add . + if ! git diff-index --quiet HEAD; then git commit -m "Updated API for commit ${GITHUB_SHA}" - bash ${GITHUB_WORKSPACE}/.github/scripts/secure-git-push https://github.com/codeigniter4/api.git HEAD:master + bash "${GITHUB_WORKSPACE}/source/.github/scripts/secure-git-push" https://github.com/codeigniter4/api.git HEAD:master + echo "API documentation deployed successfully." + else + echo "No changes to deploy." fi diff --git a/utils/check_permission_x.php b/utils/check_permission_x.php index 9922174b152d..606a5f9982ca 100644 --- a/utils/check_permission_x.php +++ b/utils/check_permission_x.php @@ -31,6 +31,7 @@ function findExecutableFiles(string $dir, array $excludeDirs = []): array { static $execFileList = [ '.github/scripts/deploy-userguide', + '.github/scripts/secure-git-push', 'admin/release-userguide', 'admin/release-deploy', 'admin/apibot', From 37a4b1c8ddf360ed97354b61ca011eec0a16c710 Mon Sep 17 00:00:00 2001 From: "A. V." <32509996+valchevio@users.noreply.github.com> Date: Sat, 21 Mar 2026 20:27:34 +0200 Subject: [PATCH 72/75] docs: fix typos (#10056) * fix: Fix typos * fix: Fix typos --- admin/apibot.md | 2 +- admin/docbot.md | 2 +- rector.php | 2 +- system/Database/SQLSRV/Builder.php | 2 +- system/HTTP/URI.php | 2 +- user_guide_src/source/changelogs/v4.6.0.rst | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/admin/apibot.md b/admin/apibot.md index ef22277069ec..ba63aaf269fa 100644 --- a/admin/apibot.md +++ b/admin/apibot.md @@ -6,7 +6,7 @@ The in-progress CI4 API docs, warts & all, are rebuilt and then copied to a nested repository clone (`build/api`), with the result optionally pushed to the `master` branch of the `api` repo. -That would then be publically visible as the in-progress +That would then be publicly visible as the in-progress version of the [API](https://codeigniter4.github.io/api/). ## Requirements diff --git a/admin/docbot.md b/admin/docbot.md index f40b81a35d77..a3577b2e5d36 100644 --- a/admin/docbot.md +++ b/admin/docbot.md @@ -5,7 +5,7 @@ Builds & deploys user guide. The in-progress CI4 user guide, warts & all, is rebuilt in a nested repository clone (`user_guide_src/build/html`), with the result optionally pushed to the `gh-pages` branch of the repo. -That would then be publically visible as the in-progress +That would then be publicly visible as the in-progress version of the [User Guide](https://codeigniter4.github.io/CodeIgniter4/). ## Requirements diff --git a/rector.php b/rector.php index 6f12a4d11c38..1a78def1c1e2 100644 --- a/rector.php +++ b/rector.php @@ -174,7 +174,7 @@ CompactToVariablesRector::class, - // possibly isset() on purpose, on updated Config classes property accross versions + // possibly isset() on purpose, on updated Config classes property across versions IssetOnPropertyObjectToPropertyExistsRector::class, AssertFuncCallToPHPUnitAssertRector::class => [ diff --git a/system/Database/SQLSRV/Builder.php b/system/Database/SQLSRV/Builder.php index 0b17a52ff344..3c3e798c3e18 100644 --- a/system/Database/SQLSRV/Builder.php +++ b/system/Database/SQLSRV/Builder.php @@ -321,7 +321,7 @@ private function getFullName(string $table): string } /** - * Add permision statements for index value inserts + * Add permission statements for index value inserts */ private function addIdentity(string $fullTable, string $insert): string { diff --git a/system/HTTP/URI.php b/system/HTTP/URI.php index 6b4aeca8e56f..bc848ba5c73e 100644 --- a/system/HTTP/URI.php +++ b/system/HTTP/URI.php @@ -629,7 +629,7 @@ public function getTotalSegments(): int /** * Formats the URI as a string. * - * Warning: For backwards-compatability this method + * Warning: For backwards-compatibility this method * assumes URIs with the same host as baseURL should * be relative to the project's configuration. * This aspect of __toString() is deprecated and should be avoided. diff --git a/user_guide_src/source/changelogs/v4.6.0.rst b/user_guide_src/source/changelogs/v4.6.0.rst index e452483ce872..c26d80dd249a 100644 --- a/user_guide_src/source/changelogs/v4.6.0.rst +++ b/user_guide_src/source/changelogs/v4.6.0.rst @@ -246,7 +246,7 @@ Routing Negotiator ========== -- Added a feature flag ``Feature::$strictLocaleNegotiation`` to enable strict locale comparision. +- Added a feature flag ``Feature::$strictLocaleNegotiation`` to enable strict locale comparison. Previously, response with language headers ``Accept-language: en-US,en-GB;q=0.9`` returned the first allowed language ``en`` could instead of the exact language ``en-US`` or ``en-GB``. Set the value to ``true`` to enable comparison not only by language code ('en' - ISO 639-1) but also by regional code ('en-US' - ISO 639-1 plus ISO 3166-1 alpha). From 451b6560bab948ab26894f8e1e86506fb0b3f634 Mon Sep 17 00:00:00 2001 From: John Paul E Balandan Date: Sun, 22 Mar 2026 02:31:55 +0800 Subject: [PATCH 73/75] chore: add labeler action workflow (#10055) * chore: add labeler action workflow * chore: change event to `pull_request_target` right before merge --- .github/labeler.yml | 23 +++++++++++++++++++++++ .github/workflows/label-pr.yml | 20 ++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 .github/labeler.yml create mode 100644 .github/workflows/label-pr.yml diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000000..380ff441c04a --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,23 @@ +# https://github.com/actions/labeler?tab=readme-ov-file#usage + +# Add the `4.8` label to PRs that target the `4.8` branch. +'4.8': # @todo change value whenever the next minor version is changed +- base-branch: '4.8' + +# Add the `github_actions` label to PRs that change any file in the `.github/workflows/` directory. +'github_actions': +- changed-files: + - any-glob-to-any-file: + - '.github/workflows/*' + +# Add the `documentation` label to PRs that change any file in the `user_guide_src/source/` directory. +'documentation': +- changed-files: + - any-glob-to-all-files: + - 'user_guide_src/source/*' + +# Add the `testing` label to PRs that change files in the `tests/` directory ONLY. +'testing': +- changed-files: + - any-glob-to-all-files: + - 'tests/*' diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml new file mode 100644 index 000000000000..730c98f271c1 --- /dev/null +++ b/.github/workflows/label-pr.yml @@ -0,0 +1,20 @@ +name: Add Labels to PRs + +# NOTE: When updating this workflow, you should first change the event to `pull_request` to test the changes +# in a PR, and then change it back to `pull_request_target` before merging. +# @see https://github.com/actions/labeler?tab=readme-ov-file#updating-major-version-of-the-labeler +on: + - pull_request_target + +jobs: + add-labels: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-24.04 + + steps: + - name: Add labels + uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 + with: + sync-labels: true # Remove labels when matching files are reverted From 1eb42e24ea3e254baca04f7a6196d3b776ab82d1 Mon Sep 17 00:00:00 2001 From: Alex <46262688+AlexRasch@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:09:21 +0100 Subject: [PATCH 74/75] docs: fix singular table names in API guide's `withAuthorInfo()` method (#10058) Co-authored-by: John Paul E Balandan --- user_guide_src/source/guides/api/code/013.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/user_guide_src/source/guides/api/code/013.php b/user_guide_src/source/guides/api/code/013.php index d342cf4bfae9..4ae1102385ba 100644 --- a/user_guide_src/source/guides/api/code/013.php +++ b/user_guide_src/source/guides/api/code/013.php @@ -16,7 +16,7 @@ class BookModel extends Model public function withAuthorInfo() { return $this - ->select('book.*, author.id as author_id, author.name as author_name') - ->join('author', 'book.author_id = author.id'); + ->select('books.*, authors.name as author_name') + ->join('authors', 'books.author_id = authors.id'); } } From a9a2eb0510fb7e0481ca557789d337dfa5d17493 Mon Sep 17 00:00:00 2001 From: John Paul E Balandan Date: Sun, 22 Mar 2026 23:53:36 +0800 Subject: [PATCH 75/75] Prep for 4.7.1 release (#10059) --- CHANGELOG.md | 39 +++++++++++++++++++ phpdoc.dist.xml | 2 +- system/CodeIgniter.php | 2 +- user_guide_src/source/changelogs/v4.7.1.rst | 6 +-- user_guide_src/source/conf.py | 2 +- .../source/installation/upgrade_471.rst | 5 +-- 6 files changed, 44 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c76b3ef55a5..cbe2903158fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog +## [v4.7.1](https://github.com/codeigniter4/CodeIgniter4/tree/v4.7.1) (2026-03-22) +[Full Changelog](https://github.com/codeigniter4/CodeIgniter4/compare/v4.7.0...v4.7.1) + +### Breaking Changes + +* fix: SQLite3 config type handling for `.env` overrides by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10037 + +### Fixed Bugs + +* fix: escape CSP nonce attributes in JSON responses by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/9938 +* fix: correct `savePath` check in `MemcachedHandler` constructor by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/9941 +* fix: preserve index field in `updateBatch()` when `updateOnlyChanged` is `true` by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/9944 +* fix: Hardcoded CSP Nonce Tags in ResponseTrait by @patel-vansh in https://github.com/codeigniter4/CodeIgniter4/pull/9937 +* fix: initialize standalone toolbar by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/9950 +* fix: add fallback for `appOverridesFolder` config in View by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/9958 +* fix: avoid double-prefixing in `BaseConnection::callFunction()` by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/9959 +* fix: generate inputs for all route params in Debug Toolbar by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/9964 +* fix: preserve Postgre casts when converting named placeholders in prepared queries by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/9960 +* fix: prevent extra query and invalid size in `Model::chunk()` by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/9961 +* fix: worker mode events cleanup by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/9997 +* fix: add nonce to script-src-elem and style-src-elem when configured by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/9999 +* fix: `FeatureTestTrait::withRoutes()` may throw all sorts of errors on invalid HTTP methods by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10004 +* fix: validation when key does not exists by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10006 +* fix: handle HTTP/2 responses without a reason phrase in CURLRequest by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10050 + +### Refactoring + +* chore: signature for the `$headers` param in `FeatureTestTrait::withHeaders()` by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/9932 +* refactor: implement development versions for `CodeIgniter::CI_VERSION` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/9951 +* feat: Add `builds next` option by @neznaika0 in https://github.com/codeigniter4/CodeIgniter4/pull/9946 +* refactor: use `__unserialize` instead of `__wakeup` in `TimeTrait` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/9957 +* refactor: remove `Exceptions::isImplicitNullableDeprecationError` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/9965 +* refactor: fix `Security` test fail by itself by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/9969 +* refactor: make random-order API tests deterministic by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/9983 +* refactor: make random-order CLI tests deterministic by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/9998 +* refactor: fix phpstan no type specified ValidationModelTest by @adiprsa in https://github.com/codeigniter4/CodeIgniter4/pull/10008 +* refactor: fix dependency on test execution order by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10014 +* refactor: update tests with old entities definition by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10026 + ## [v4.7.0](https://github.com/codeigniter4/CodeIgniter4/tree/v4.7.0) (2026-02-01) [Full Changelog](https://github.com/codeigniter4/CodeIgniter4/compare/v4.6.5...v4.7.0) diff --git a/phpdoc.dist.xml b/phpdoc.dist.xml index 441a942b00f6..4c589ae8c5b0 100644 --- a/phpdoc.dist.xml +++ b/phpdoc.dist.xml @@ -10,7 +10,7 @@ api/build/ api/cache/ - + system diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index b2cbbf802f29..c29efc7781f8 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -55,7 +55,7 @@ class CodeIgniter /** * The current version of CodeIgniter Framework */ - public const CI_VERSION = '4.7.1-dev'; + public const CI_VERSION = '4.7.1'; /** * App startup time. diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst index fc780d72acb8..37eec1c09620 100644 --- a/user_guide_src/source/changelogs/v4.7.1.rst +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -2,7 +2,7 @@ Version 4.7.1 ############# -Release Date: Unreleased +Release Date: March 22, 2026 **4.7.1 release of CodeIgniter4** @@ -42,10 +42,6 @@ Others - **builds:** In the ``builds`` script (for ``codeigniter4/appstarter``), the ``next`` argument has been added to switch ``4.7.x`` to the next minor version ``4.8.x-dev``. See :ref:`Latest Dev`. -************ -Deprecations -************ - ********** Bugs Fixed ********** diff --git a/user_guide_src/source/conf.py b/user_guide_src/source/conf.py index cfc26956287c..7f8bfc3b3f75 100644 --- a/user_guide_src/source/conf.py +++ b/user_guide_src/source/conf.py @@ -26,7 +26,7 @@ version = '4.7' # The full version, including alpha/beta/rc tags. -release = '4.7.0' +release = '4.7.1' # -- General configuration --------------------------------------------------- diff --git a/user_guide_src/source/installation/upgrade_471.rst b/user_guide_src/source/installation/upgrade_471.rst index 7e3074f1378a..b85bcceebb3b 100644 --- a/user_guide_src/source/installation/upgrade_471.rst +++ b/user_guide_src/source/installation/upgrade_471.rst @@ -26,10 +26,6 @@ upgrading. The easiest way is to re-run the install command: php spark worker:install --force -**************** -Breaking Changes -**************** - ********************* Breaking Enhancements ********************* @@ -76,4 +72,5 @@ All Changes This is a list of all files in the **project space** that received changes; many will be simple comments or formatting that have no effect on the runtime: +- app/Config/Database.php - app/Config/WorkerMode.php