diff --git a/.gitattributes b/.gitattributes
index 05fc1f38cde9..02a23de3322d 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -22,6 +22,7 @@ utils/ export-ignore
.php-cs-fixer.no-header.php export-ignore
.php-cs-fixer.tests.php export-ignore
.php-cs-fixer.user-guide.php export-ignore
+structarmed-baseline.php export-ignore
structarmed.php export-ignore
phpmetrics.json export-ignore
phpstan-baseline.php export-ignore
@@ -32,6 +33,7 @@ psalm-baseline.xml export-ignore
psalm.xml export-ignore
psalm_autoload.php export-ignore
rector.php export-ignore
+AGENTS.md export-ignore
# source user guide
user_guide_src/ export-ignore
diff --git a/.github/carson.yml b/.github/carson.yml
new file mode 100644
index 000000000000..07c841b7a021
--- /dev/null
+++ b/.github/carson.yml
@@ -0,0 +1,125 @@
+# https://github.com/NexusPHP/carson/blob/1.x/SUBSCRIBERS.md
+
+version: 1
+subscribers:
+ - auto-labeler
+ - conflicts-notifier
+ - no-merge-commits
+ - no-response-closer
+ - pr-title-linter
+ - signed-commits
+ - template-enforcer
+ - welcome
+settings:
+ auto-labeler:
+ sync_labels: true
+ sync_exempt:
+ - enhancement
+ rules:
+ - label: '4.8' # @todo change value whenever the next minor version is changed
+ base_branch: ['^4\.8$']
+ - label: github_actions
+ files: ['.github/workflows/*']
+ - label: documentation
+ files:
+ all: ['user_guide_src/source/**']
+ - label: testing
+ files:
+ all: ['tests/**']
+ - label: bug
+ title: ['^fix(\([^)]*\))?: ']
+ - label: enhancement
+ title: ['^perf(\([^)]*\))?: ']
+ - label: refactor
+ title: ['^refactor(\([^)]*\))?: ']
+ conflicts-notifier:
+ label: stale
+ message: |-
+ :wave: Hi, @{{user}}!
+
+ We detected conflicts in your PR against the base branch :speak_no_evil:
+ You may want to sync :arrows_counterclockwise: your branch with upstream!
+
+ Ref: [Syncing Your Branch](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/workflow.md#updating-your-branch)
+ no-merge-commits:
+ name: 'Carson / no-merge-commits'
+ treat_merge_commits_as: failure
+ exempt_branches:
+ - head: '^develop$'
+ base: '^master$'
+ - head: '^4\.\d+$'
+ base: '^develop$'
+ no-response-closer:
+ rules:
+ - label: needs template
+ only: issues
+ days_until_close: 2
+ close_message: |-
+ Closing this issue: the description still does not follow the bug report template after {{days_until_close}} days.
+
+ Please open a new issue using the [bug report form](https://github.com/codeigniter4/CodeIgniter4/issues/new?assignees=&labels=bug&projects=&template=bug_report.yml&title=Bug%3A+). For feature requests or support questions, please use the [forums](https://forum.codeigniter.com/forum-30.html).
+ - label: waiting for info
+ days_until_close: 14
+ unlabel_on_response: true
+ close_message: |-
+ Closing this {{type}}: there has been no response for {{days_until_close}} days after more information was requested.
+
+ If you can provide the requested details, leave a comment and a maintainer can reopen it.
+ pr-title-linter:
+ name: 'Carson / pr-title-linter'
+ rules:
+ - pattern: '^(?:(?:\[\d+\.\d+\] )?(?:feat|fix|chore|docs|perf|refactor|style|test|config|revert)(?:\([\-.@:`a-zA-Z0-9]+\))?!?: \S.+\S|Prep for \d+\.\d+\.\d+ release|\d+\.\d+\.\d+ (?:Ready|Merge) code)$'
+ description: 'PR title must include the type (feat, fix, chore, docs, perf, refactor, style, test, config, revert) of the commit per Conventional Commits specification. See https://www.conventionalcommits.org/en/v1.0.0/ for the discussion.'
+ level: error
+ signed-commits:
+ name: 'Carson / signed-commits'
+ treat_unsigned_as: failure
+ template-enforcer:
+ label: needs template
+ exempt_roles: [admin, maintain, write, triage]
+ message: |-
+ Hi there, @{{user}}! :wave:
+
+ It looks like this {{type}} does not follow our template:
+
+ {{violations}}
+
+ Please update the description to follow the template. The `{{label}}` label will be removed automatically once it does.
+ issues:
+ rules:
+ - pattern: '### PHP Version[\s\S]*### CodeIgniter4 Version'
+ description: 'Use the [bug report form](https://github.com/codeigniter4/CodeIgniter4/issues/new?assignees=&labels=bug&projects=&template=bug_report.yml&title=Bug%3A+), which asks for your PHP and CodeIgniter4 versions. For a feature request or support question, please use the [forums](https://forum.codeigniter.com/forum-30.html).'
+ pull_requests:
+ rules:
+ - pattern: '\*\*Description\*\*'
+ description: 'Keep the **Description** heading from the [pull request template](https://github.com/codeigniter4/CodeIgniter4/blob/develop/.github/PULL_REQUEST_TEMPLATE.md) and explain what you have changed, and why.'
+ - pattern: '\*\*Checklist:?\*\*'
+ description: 'Keep the **Checklist** from the pull request template and tick the items that apply. See [what we expect in a pull request](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md).'
+ - pattern: 'Explain what you have changed, and why\.'
+ description: 'Replace the placeholder text under **Description** with your own explanation.'
+ mode: forbid
+ welcome:
+ first_time:
+ pull_request: |-
+ Hi there, @{{user}}! :wave:
+
+ Thank you for sending this PR!
+
+ We expect the following in all Pull Requests (PRs).
+ - PRs must be sent to the [appropriate branch](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#branching)
+ - All git commits must be [GPG-signed](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#signing)
+ - Must follow our [style guide](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#php-style)
+ - Be [commented](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#comments) in the PHP source file
+ - Be documented in the [user guide](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#user-guide)
+ - Be [unit tested](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#unit-testing)
+ - Pass all checks in GitHub Actions
+
+ > [!IMPORTANT]
+ > We expect all code changes or bug-fixes to be accompanied by one or more tests added to our test suite to prove the code works.
+
+ If pull requests do not comply with the above, they will likely be closed. Since we are a team of volunteers, we don't have any more time to work
+ on the framework than you do. Please make it as painless for your contributions to be included as possible.
+
+ See https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md
+ issue: false
+ returning: false
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 000000000000..4ad38ee3eadf
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,30 @@
+# Copilot Code Review Instructions
+
+Repository-wide conventions, the target-branch compatibility policy, coding
+standards, and FrankenPHP Worker Mode requirements are defined in the root
+`AGENTS.md`. Apply that policy in every review; this file covers review
+behavior only.
+
+## Review priorities
+
+Prioritize actionable findings involving:
+
+- incorrect behavior or an unhandled edge case;
+- backward compatibility;
+- security, input validation, and context-appropriate output escaping;
+- request-specific state leaking through globals, static properties, or
+ shared services, including under Worker Mode where one process serves
+ many requests;
+- differences between supported database drivers (MySQLi, PostgreSQL,
+ SQLite3, SQLSRV, OCI8);
+- missing regression tests, user-guide changes, changelog entries, or
+ upgrade instructions.
+
+## Review behavior
+
+- Determine the PR base branch and apply the compatibility policy from
+ `AGENTS.md`. If the base branch cannot be determined, assume `develop`
+ and state that assumption.
+- Do not report formatting-only issues already enforced by PHP-CS-Fixer.
+- Every finding should identify a concrete failure scenario and the
+ affected code.
diff --git a/.github/instructions/database.instructions.md b/.github/instructions/database.instructions.md
new file mode 100644
index 000000000000..b550a4a0573e
--- /dev/null
+++ b/.github/instructions/database.instructions.md
@@ -0,0 +1,19 @@
+---
+applyTo: "system/Database/**/*.php,tests/system/Database/**/*.php"
+---
+
+# Database layer
+
+- Consider all supported drivers: MySQLi, PostgreSQL, SQLite3, SQLSRV, and
+ OCI8.
+- Check identifier quoting, value binding, `NULL` behavior, transactions,
+ affected rows, return types, and platform-specific SQL.
+- Do not implement driver-independent behavior in only one driver.
+- When changing a base database class, inspect driver overrides and their
+ method signatures.
+- Keep driver-independent tests separate from tests that require a live
+ database.
+- Use the `DatabaseLive` PHPUnit group only when a real database connection is
+ required.
+- Prefer focused tests for the affected base class and drivers. Leave the full
+ multi-driver matrix to GitHub Actions.
diff --git a/.github/instructions/framework-core.instructions.md b/.github/instructions/framework-core.instructions.md
new file mode 100644
index 000000000000..0ae53495c513
--- /dev/null
+++ b/.github/instructions/framework-core.instructions.md
@@ -0,0 +1,23 @@
+---
+applyTo: "system/**/*.php"
+---
+
+# Framework core
+
+- Assume public and protected symbols are extension points used by third-party
+ applications unless they are clearly marked internal.
+- Apply the target-branch compatibility policy from the root `AGENTS.md`
+ before changing any extension point.
+- Before changing a signature or behavior, inspect parent classes, implemented
+ interfaces, traits, service factories, configuration, and corresponding
+ tests.
+- Keep file paths, class names, and namespaces aligned under `CodeIgniter\`.
+- Preserve existing extension and dependency-injection points.
+- Consider long-running worker environments. Do not retain request-specific
+ state in static properties, globals, or shared services after a request
+ finishes.
+- Avoid introducing a framework-wide abstraction to solve a single local
+ problem.
+- On `develop`, keep exceptions and observable side effects compatible. On a
+ `4.*` target, change them only as an explicit, narrow, documented breaking
+ enhancement with a migration path.
diff --git a/.github/instructions/tests.instructions.md b/.github/instructions/tests.instructions.md
new file mode 100644
index 000000000000..af2d9a96ba96
--- /dev/null
+++ b/.github/instructions/tests.instructions.md
@@ -0,0 +1,21 @@
+---
+applyTo: "tests/**/*.php"
+---
+
+# PHPUnit tests
+
+- Mirror the corresponding `system/` component and local test conventions
+ where practical.
+- Add the appropriate PHPUnit `Group` attribute described in
+ `tests/README.md`.
+- Prefer `assertSame()` and dedicated assertions over loose or generic
+ assertions.
+- Cover the regression or behavior through a public API where possible.
+ Avoid testing private implementation details.
+- Restore services, factories, environment variables, superglobals, handlers,
+ locale, time-related state, and other global state modified by a test.
+- Tests must not depend on execution order or state left by another test.
+- Do not replace a meaningful assertion with a weaker assertion to make a test
+ pass.
+- After changing behavior, run the smallest relevant test file or component.
+ Do not run the complete test suite by default.
diff --git a/.github/instructions/user-guide.instructions.md b/.github/instructions/user-guide.instructions.md
new file mode 100644
index 000000000000..9b55a50ba3f1
--- /dev/null
+++ b/.github/instructions/user-guide.instructions.md
@@ -0,0 +1,27 @@
+---
+applyTo: "user_guide_src/**/*.rst"
+---
+
+# User guide
+
+- Preserve the existing reStructuredText structure, terminology, directives,
+ anchors, and surrounding writing style.
+- Keep PHP examples compatible with PHP 8.2 and verify signatures and behavior
+ against the current framework code.
+- Distinguish new behavior from existing behavior and document defaults
+ precisely.
+- Determine the PR target branch before describing compatibility:
+ `develop` follows the patch-release policy, while `4.*` follows the
+ minor-release policy in the root `AGENTS.md`.
+- Behavior changes, enhancements, deprecations, and important bug fixes may
+ require a changelog entry.
+- Changes requiring users to modify code or configuration may require an
+ upgrading-guide entry.
+- For an intentional compatibility break targeting `4.*`, document the old
+ behavior, new behavior, affected users, reason for the change, and the
+ smallest migration in both the minor changelog and upgrading guide.
+- Keep code examples minimal but complete enough to run in a normal
+ CodeIgniter application.
+- Leave the complete Sphinx build and documentation validation to GitHub
+ Actions unless the task specifically requires local documentation
+ validation.
diff --git a/.github/instructions/worker-mode.instructions.md b/.github/instructions/worker-mode.instructions.md
new file mode 100644
index 000000000000..ec6e1742ec5a
--- /dev/null
+++ b/.github/instructions/worker-mode.instructions.md
@@ -0,0 +1,38 @@
+---
+applyTo: "system/Boot.php,system/CodeIgniter.php,system/Commands/Worker/**,system/Config/BaseService.php,system/Config/Factories.php,system/Config/Factory.php,system/Config/Services.php,system/Database/Config.php,system/Database/BaseConnection.php,system/Database/ConnectionInterface.php,system/Database/*/Connection.php,system/Events/Events.php,system/Session/**,system/Cache/**,system/HTTP/**,system/Filters/**,system/Router/**,system/Security/**,system/Debug/Toolbar.php,system/Debug/Toolbar/**,app/Config/WorkerMode.php,tests/system/Commands/Worker/**,user_guide_src/source/installation/worker_mode.rst"
+---
+
+# FrankenPHP Worker Mode
+
+Use `system/Commands/Worker/Views/frankenphp-worker.php.tpl` as the canonical
+Worker Mode entry point. `worker:install` publishes this template to
+`public/frankenphp-worker.php`.
+
+When changing bootstrap or request-lifecycle code, trace all three Worker Mode
+phases:
+
+1. One-time process bootstrap through `Boot::bootWorker()`.
+2. Per-request preparation: reconnect database and cache connections, reset
+ the `CodeIgniter` instance, and replace all request superglobals before
+ calling `$app->run()`.
+3. Post-request cleanup: close the session, clean up unfinished database
+ transactions, reset factories and non-persistent services, clean up event
+ listeners and performance logs, and reset the debug toolbar.
+
+- Preserve the phase ordering unless the change explicitly proves a different
+ order is safe.
+- Check every new mutable static property, singleton, shared service, event
+ listener, connection, handler, or global for cross-request state leakage.
+- Do not preserve a service across requests merely for performance. Persistent
+ services must be safe to reuse and must not retain request, response, user,
+ route, security token, locale, or error state.
+- Ensure exceptions and early exits cannot skip cleanup needed before the next
+ request.
+- For state-isolation fixes, prefer a regression test that exercises two
+ sequential requests or reset cycles in the same process and proves the
+ second request cannot observe the first request's state.
+- If the template contract changes, update focused assertions in
+ `tests/system/Commands/Worker/WorkerCommandsTest.php`.
+- If existing installations need the new generated entry point, update the
+ changelog and upgrading guide and instruct users to run
+ `php spark worker:install --force`.
diff --git a/.github/labeler.yml b/.github/labeler.yml
deleted file mode 100644
index 69cb0c51e123..000000000000
--- a/.github/labeler.yml
+++ /dev/null
@@ -1,23 +0,0 @@
-# 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 for documentation only.
-'documentation':
-- changed-files:
- - any-glob-to-all-files:
- - 'user_guide_src/source/**'
-
-# Add the `testing` label to PRs that changes tests only.
-'testing':
-- changed-files:
- - any-glob-to-all-files:
- - 'tests/**'
diff --git a/.github/mergeable.yml b/.github/mergeable.yml
deleted file mode 100644
index 2e58526188aa..000000000000
--- a/.github/mergeable.yml
+++ /dev/null
@@ -1,69 +0,0 @@
-# https://mergeable.readthedocs.io/en/latest/configuration.html
-
-version: 2
-mergeable:
- - when: issues.opened, issues.reopened
- validate:
- - do: or
- validate:
- - do: and
- validate:
- - do: description
- must_include:
- regex: '### PHP Version'
- - do: description
- must_include:
- regex: '### CodeIgniter4 Version'
- - do: author
- must_include:
- regex: ^kenjis|lonnieezell|MGatner|michalsn|paulbalandan|samsonasik|ddevsr$
- fail:
- - do: comment
- payload:
- body: |
- Hi there, @@author! :wave:
-
- It looks like you opened an issue without following the bug report template:
-
- * Bug report ([open an issue](https://github.com/codeigniter4/CodeIgniter4/issues/new?assignees=&labels=bug&projects=&template=bug_report.yml&title=Bug%3A+))
- * For feature request or support question, please use the [forums](https://forum.codeigniter.com/forum-30.html).
-
- The current issue will be closed. This is a precaution to save maintainers' time, I hope you'll understand.
-
- Sincerely, the mergeable bot 🤖
- - do: close
-
- - when: pull_request.opened, pull_request.ready_for_review
- filter:
- - do: payload
- pull_request:
- author_association:
- must_include:
- regex: ^NONE|FIRST_TIME_CONTRIBUTOR|FIRST_TIMER$
- validate: []
- pass:
- - do: comment
- payload:
- body: |
- Hi there, @@author! :wave:
-
- Thank you for sending this PR!
-
- We expect the following in all Pull Requests (PRs).
- - PRs must be sent to the [appropriate branch](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#branching)
- - All git commits must be [GPG-signed](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#signing)
- - Must follow our [style guide](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#php-style)
- - Be [commented](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#comments) in the PHP source file
- - Be documented in the [user guide](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#user-guide)
- - Be [unit tested](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#unit-testing)
- - Pass all checks in GitHub Actions
-
- > [!IMPORTANT]
- > We expect all code changes or bug-fixes to be accompanied by one or more tests added to our test suite to prove the code works.
-
- If pull requests do not comply with the above, they will likely be closed. Since we are a team of volunteers, we don't have any more time to work
- on the framework than you do. Please make it as painless for your contributions to be included as possible.
-
- See https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md
-
- Sincerely, the mergeable bot 🤖
diff --git a/.github/prlint.json b/.github/prlint.json
deleted file mode 100644
index bb352e554b71..000000000000
--- a/.github/prlint.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "title": [
- {
- "pattern": "^(\\[\\d+\\.\\d+\\]\\s{1})?(feat|fix|chore|docs|perf|refactor|style|test|config|revert)(\\([\\-.@:`a-zA-Z0-9]+\\))?!?:\\s{1}\\S.+\\S|Prep for \\d\\.\\d\\.\\d release|\\d\\.\\d\\.\\d (Ready|Merge) code$",
- "message": "PR title must include the type (feat, fix, chore, docs, perf, refactor, style, test, config, revert) of the commit per Conventional Commits specification. See https://www.conventionalcommits.org/en/v1.0.0/ for the discussion."
- }
- ]
-}
diff --git a/.github/release.yml b/.github/release.yml
index de6e982d91a6..09ead7ca89d2 100644
--- a/.github/release.yml
+++ b/.github/release.yml
@@ -2,6 +2,7 @@ changelog:
exclude:
authors:
- dependabot
+ - dependabot[bot]
categories:
- title: Breaking Changes
labels:
diff --git a/.github/scripts/random-tests-config.txt b/.github/scripts/random-tests-config.txt
index 2435f6ea7a17..5bf0fce66733 100644
--- a/.github/scripts/random-tests-config.txt
+++ b/.github/scripts/random-tests-config.txt
@@ -11,7 +11,7 @@
API
AutoReview
Autoloader
-# Cache
+Cache
CLI
Commands
Config
@@ -20,14 +20,14 @@ Cookie
# DataConverter
# Database
# Debug
-# Email
+Email
# Encryption
# Entity
Events
Files
-# Filters
+Filters
Format
-# HTTP
+HTTP
# Helpers
Honeypot
HotReloader
diff --git a/.github/scripts/run-random-tests.sh b/.github/scripts/run-random-tests.sh
index fe321bef0492..a0a962efbba5 100755
--- a/.github/scripts/run-random-tests.sh
+++ b/.github/scripts/run-random-tests.sh
@@ -41,8 +41,9 @@ 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 )"
+script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+project_root="$( cd "$script_dir/../.." && pwd )"
+readonly script_dir project_root
readonly config_file="$script_dir/random-tests-config.txt"
readonly results_dir="$project_root/build/random-tests"
@@ -166,7 +167,8 @@ inflect() {
}
generate_phpunit_random_seed() {
- local seed=$(date +%s)
+ local seed
+ seed=$(date +%s)
if [[ ! "$seed" =~ ^[0-9]+$ ]]; then
echo 1
@@ -233,7 +235,8 @@ get_failed_test_predecessor() {
print_result() {
local type=$1 completed=$2 total=$3 component=$4 elapsed_str=$5
- local padded=$(printf "%${#total}d" "$completed")
+ local padded
+ padded=$(printf "%${#total}d" "$completed")
local color symbol
case "$type" in
@@ -462,7 +465,8 @@ run_component_tests() {
fi
local test_dir="tests/system/$component"
- local start_time=$(date +%s%N)
+ local start_time
+ start_time=$(date +%s%N)
print_debug "Running tests for: $component"
@@ -527,7 +531,8 @@ run_component_tests() {
if kill -0 "$test_pid" 2>/dev/null; then
touch "$timeout_marker"
- local pids_to_kill=$(pgrep -P "$test_pid" 2>/dev/null)
+ local pids_to_kill
+ pids_to_kill=$(pgrep -P "$test_pid" 2>/dev/null)
kill -TERM "$test_pid" 2>/dev/null || true
if [[ -n "$pids_to_kill" ]]; then
@@ -607,7 +612,8 @@ run_component_tests() {
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)
+ local last_prepared_test
+ 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
@@ -619,7 +625,8 @@ run_component_tests() {
predecessor_info+=$'\nPrevious test: (unavailable due to timeout)'
else
if [[ -f "$events_file" ]]; then
- local predecessor_result=$(get_failed_test_predecessor "$events_file")
+ local predecessor_result
+ predecessor_result=$(get_failed_test_predecessor "$events_file")
if [[ -n "$predecessor_result" ]]; then
local previous_test=${predecessor_result#*|}
predecessor_info+=$'\nFailed test: '"${predecessor_result%%|*}"
@@ -639,7 +646,7 @@ run_component_tests() {
fi
{
- echo "> ${phpunit_args[@]:0:7}"
+ echo "> ${phpunit_args[*]:0:7}"
echo ""
echo "$output"
echo "$predecessor_info"
@@ -691,8 +698,9 @@ process_result() {
first_result=false
fi
- local status=$(grep "^Exit code:" "$result_file" | sed 's/Exit code: //')
- local elapsed_str=$(format_elapsed_time "$elapsed")
+ local status elapsed_str
+ status=$(grep "^Exit code:" "$result_file" | sed 's/Exit code: //')
+ elapsed_str=$(format_elapsed_time "$elapsed")
case "$status" in
0)
@@ -728,7 +736,8 @@ get_completed_components() {
while IFS= read -r file_path; do
# Remove prefix: random_test_result_
- local temp=$(basename "$file_path")
+ local temp
+ temp=$(basename "$file_path")
temp=${temp#random_test_result_}
# Extract elapsed time (everything before first underscore after number)
@@ -749,13 +758,35 @@ get_completed_components() {
# 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
+ if [[ " ${displayed_components[*]:-} " != *" ${listed_component} "* ]]; then
echo "$listed_component|$elapsed"
fi
done
}
+print_failed_component_details() {
+ local component=$1
+ local result_file=$2
+
+ # GitHub Actions renders ::group:: lines as collapsible log sections.
+ if [[ -z "${GITHUB_ACTIONS:-}" || ! -f "$result_file" ]]; then
+ return
+ fi
+
+ echo "::group::${component}: test result"
+ cat "$result_file"
+ echo "::endgroup::"
+
+ local order_file="${result_file/random_test_result_/random_test_order_}"
+
+ if [[ -f "$order_file" ]]; then
+ echo "::group::${component}: execution order"
+ cat "$order_file"
+ echo "::endgroup::"
+ fi
+}
+
print_summary() {
local run_number=$1
local pass_percent=0.00
@@ -779,13 +810,15 @@ print_summary() {
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)
+ local result_file
+ 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)"
+ print_failed_component_details "$failed_component" "$result_file"
done < <(printf '%s\n' "${failed_components[@]}" | sort)
fi
@@ -830,7 +863,7 @@ main() {
# Read components from config file
verify_config
print_success "Configuration file: $config_file\n"
- components_array=($(read_components))
+ read -ra components_array <<< "$(read_components)"
fi
total=${#components_array[@]}
diff --git a/.github/workflows/deploy-apidocs.yml b/.github/workflows/deploy-apidocs.yml
index 3d24470ed846..8eb59179a946 100644
--- a/.github/workflows/deploy-apidocs.yml
+++ b/.github/workflows/deploy-apidocs.yml
@@ -30,13 +30,13 @@ jobs:
git config --global user.name "${GITHUB_ACTOR}"
- name: Checkout source
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: source
persist-credentials: false
- name: Checkout target
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: codeigniter4/api
token: ${{ secrets.ACCESS_TOKEN }}
diff --git a/.github/workflows/deploy-distributables.yml b/.github/workflows/deploy-distributables.yml
index 221383aafd57..1eeb86f772cb 100644
--- a/.github/workflows/deploy-distributables.yml
+++ b/.github/workflows/deploy-distributables.yml
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # fetch all tags
persist-credentials: false
@@ -50,13 +50,13 @@ jobs:
git config --global user.name "${GITHUB_ACTOR}"
- name: Checkout source
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: source
persist-credentials: false
- name: Checkout target
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: codeigniter4/framework
token: ${{ secrets.ACCESS_TOKEN }}
@@ -104,13 +104,13 @@ jobs:
git config --global user.name "${GITHUB_ACTOR}"
- name: Checkout source
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: source
persist-credentials: false
- name: Checkout target
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: codeigniter4/appstarter
token: ${{ secrets.ACCESS_TOKEN }}
@@ -158,13 +158,13 @@ jobs:
git config --global user.name "${GITHUB_ACTOR}"
- name: Checkout source
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: source
persist-credentials: false
- name: Checkout target
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: codeigniter4/userguide
token: ${{ secrets.ACCESS_TOKEN }}
@@ -172,7 +172,7 @@ jobs:
persist-credentials: false
- name: Setup Python
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
diff --git a/.github/workflows/deploy-userguide-latest.yml b/.github/workflows/deploy-userguide-latest.yml
index 9bbd7a5bb7ba..c943254325d6 100644
--- a/.github/workflows/deploy-userguide-latest.yml
+++ b/.github/workflows/deploy-userguide-latest.yml
@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -36,7 +36,7 @@ jobs:
coverage: none
- name: Setup Python
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
diff --git a/.github/workflows/label-add-conflict-all-pr.yml b/.github/workflows/label-add-conflict-all-pr.yml
deleted file mode 100644
index 774012da1f32..000000000000
--- a/.github/workflows/label-add-conflict-all-pr.yml
+++ /dev/null
@@ -1,51 +0,0 @@
-name: Auto Label "stale" for All PRs
-
-on:
- push:
- branches:
- - develop
- - '4.*'
-
-jobs:
- build:
- name: Check Conflicts
-
- permissions:
- contents: read
- pull-requests: write
-
- runs-on: ubuntu-24.04
- steps:
- - name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
-
- - name: Get PR List
- id: PR-list
- run: echo "pr_list=$(gh pr list -L 100 --json mergeable,url,labels,author)" >> $GITHUB_OUTPUT
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: 'Add label "stale" and comment'
- env:
- PR_LIST: ${{ steps.PR-list.outputs.pr_list }}
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
-
- IFS=$'\n' # Set Internal Field Separator to newline to handle array elements
-
- # Iterate through the PRs in PR_LIST
- for pr in $(echo "$PR_LIST" | jq -c '.[]'); do
- mergeable=$(echo "$pr" | jq -r '.mergeable')
- author=$(echo "$pr" | jq -r '.author.login')
- labels=$(echo "$pr" | jq -c '.labels[].name' | tr -d '[]"')
- url=$(echo "$pr" | jq -r '.url')
-
- # CONFLICTING and no 'stale' label
- if [ "$mergeable" == "CONFLICTING" ] && [[ ! "$labels" == *"stale"* ]]; then
- # Add "stale" label
- gh pr edit $url --add-label "stale"
-
- # Add a comment
- gh pr comment $url --body ":wave: Hi, @$author!
We detected conflicts in your PR against the base branch :speak_no_evil: You may want to sync :arrows_counterclockwise: your branch with upstream!
Ref: [Syncing Your Branch](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/workflow.md#updating-your-branch)"
- fi
- done
diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml
deleted file mode 100644
index 2e2659c6963a..000000000000
--- a/.github/workflows/label-pr.yml
+++ /dev/null
@@ -1,47 +0,0 @@
-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: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- with:
- persist-credentials: false
-
- - name: Verify PR source for workflow file changes
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const prFiles = await github.paginate(github.rest.pulls.listFiles.endpoint.merge({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: context.payload.pull_request.number,
- }));
- const workflowFileChanged = prFiles.some(file => file.filename === '.github/workflows/label-pr.yml');
-
- if (workflowFileChanged) {
- if (context.payload.pull_request.head.repo.full_name !== 'codeigniter4/CodeIgniter4') {
- throw new Error('Changes to label-pr.yml are not allowed from forks.');
- }
-
- console.log('Workflow file changed, but PR is from the main repository. Proceeding with label addition.');
- return;
- }
-
- console.log('No changes to workflow file detected, proceeding with label addition.');
-
- - name: Add labels
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
- with:
- sync-labels: true # Remove labels when matching files are reverted
diff --git a/.github/workflows/label-signing.yml b/.github/workflows/label-signing.yml
deleted file mode 100644
index 80c321c5b117..000000000000
--- a/.github/workflows/label-signing.yml
+++ /dev/null
@@ -1,32 +0,0 @@
-name: Check Signed PR
-on:
- pull_request:
- branches:
- - 'develop'
- - '4.*'
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
- cancel-in-progress: true
-
-permissions:
- contents: read
- pull-requests: write
-
-jobs:
- build:
- name: Check Signed Commit
- runs-on: ubuntu-24.04
- steps:
- - name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
-
- - name: Check signed commits in PR
- 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].
-
- **Note that all your commits must be signed.** If you have an unsigned commit, you can sign the previous commits by referring to [gpg-signing-old-commits][2].
- [1]: https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#signing
- [2]: https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/workflow.md#gpg-signing-old-commits
diff --git a/.github/workflows/reusable-coveralls.yml b/.github/workflows/reusable-coveralls.yml
index e47cceb1771d..cf9c02b22e11 100644
--- a/.github/workflows/reusable-coveralls.yml
+++ b/.github/workflows/reusable-coveralls.yml
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
diff --git a/.github/workflows/reusable-phpunit-test.yml b/.github/workflows/reusable-phpunit-test.yml
index 43d520458bf0..b216a71493bb 100644
--- a/.github/workflows/reusable-phpunit-test.yml
+++ b/.github/workflows/reusable-phpunit-test.yml
@@ -170,7 +170,7 @@ jobs:
sudo apt-get install -y imagemagick libmagickwand-dev ghostscript poppler-data libjbig2dec0:amd64 libopenjp2-7:amd64
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
diff --git a/.github/workflows/reusable-serviceless-phpunit-test.yml b/.github/workflows/reusable-serviceless-phpunit-test.yml
index 7858913cf531..8420d2b8c978 100644
--- a/.github/workflows/reusable-serviceless-phpunit-test.yml
+++ b/.github/workflows/reusable-serviceless-phpunit-test.yml
@@ -66,7 +66,7 @@ jobs:
sudo apt-get install -y imagemagick libmagickwand-dev ghostscript poppler-data libjbig2dec0:amd64 libopenjp2-7:amd64
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
diff --git a/.github/workflows/run-carson.yml b/.github/workflows/run-carson.yml
new file mode 100644
index 000000000000..a0a160b0930d
--- /dev/null
+++ b/.github/workflows/run-carson.yml
@@ -0,0 +1,39 @@
+name: Carson
+
+on:
+ pull_request_target:
+ types:
+ - opened
+ - edited
+ - synchronize
+ - reopened
+ - labeled
+ - ready_for_review
+ issues:
+ types:
+ - opened
+ - edited
+ - labeled
+ issue_comment:
+ types:
+ - created
+ push:
+ branches:
+ - develop
+ - '4.*'
+ schedule:
+ - cron: '0 0 * * *'
+
+permissions: {}
+
+jobs:
+ carson:
+ if: github.repository == 'codeigniter4/CodeIgniter4'
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Run Carson
+ uses: NexusPHP/carson@cb35604ab330c2e44dba673fcef712c78e75f858 # v1.7.0
+ with:
+ app_id: ${{ secrets.CARSON_APP_ID }}
+ private_key: ${{ secrets.CARSON_PRIVATE_KEY }}
+ log_level: info
diff --git a/.github/workflows/test-autoreview.yml b/.github/workflows/test-autoreview.yml
index fd7c83248b2e..f06e02619966 100644
--- a/.github/workflows/test-autoreview.yml
+++ b/.github/workflows/test-autoreview.yml
@@ -36,7 +36,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
diff --git a/.github/workflows/test-coding-standards.yml b/.github/workflows/test-coding-standards.yml
index 125dbd5b3311..b02059733b13 100644
--- a/.github/workflows/test-coding-standards.yml
+++ b/.github/workflows/test-coding-standards.yml
@@ -35,7 +35,7 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
diff --git a/.github/workflows/test-file-permissions.yml b/.github/workflows/test-file-permissions.yml
index 7116f4620869..441d555137e8 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Detect unnecessary execution permissions
run: php utils/check_permission_x.php
diff --git a/.github/workflows/test-phpstan.yml b/.github/workflows/test-phpstan.yml
index 586314917200..cc246f871808 100644
--- a/.github/workflows/test-phpstan.yml
+++ b/.github/workflows/test-phpstan.yml
@@ -46,7 +46,7 @@ jobs:
fail-fast: false
steps:
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
diff --git a/.github/workflows/test-psalm.yml b/.github/workflows/test-psalm.yml
index 43931032b9e4..3f385ce858fb 100644
--- a/.github/workflows/test-psalm.yml
+++ b/.github/workflows/test-psalm.yml
@@ -36,7 +36,7 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
diff --git a/.github/workflows/test-random-execution.yml b/.github/workflows/test-random-execution.yml
index 4f29559b2e93..e90de6bb031d 100644
--- a/.github/workflows/test-random-execution.yml
+++ b/.github/workflows/test-random-execution.yml
@@ -168,7 +168,7 @@ jobs:
-Q "CREATE DATABASE test COLLATE Latin1_General_100_CS_AS_SC_UTF8"
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
diff --git a/.github/workflows/test-rector.yml b/.github/workflows/test-rector.yml
index 4f52dfe53681..2fa9b74c0e02 100644
--- a/.github/workflows/test-rector.yml
+++ b/.github/workflows/test-rector.yml
@@ -53,7 +53,7 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
diff --git a/.github/workflows/test-scss.yml b/.github/workflows/test-scss.yml
index 3fcce79de33c..0000e700a422 100644
--- a/.github/workflows/test-scss.yml
+++ b/.github/workflows/test-scss.yml
@@ -33,10 +33,10 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
diff --git a/.github/workflows/test-structarmed.yml b/.github/workflows/test-structarmed.yml
index 296b4d22870a..62a2dbc7d1cb 100644
--- a/.github/workflows/test-structarmed.yml
+++ b/.github/workflows/test-structarmed.yml
@@ -51,7 +51,7 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
@@ -59,6 +59,7 @@ jobs:
php-version: ${{ matrix.php-version }}
extensions: intl
tools: composer
+ coverage: none
- name: Validate composer.json
run: composer validate --strict
diff --git a/.github/workflows/test-userguide.yml b/.github/workflows/test-userguide.yml
index 1dd382cd069b..88f316888f02 100644
--- a/.github/workflows/test-userguide.yml
+++ b/.github/workflows/test-userguide.yml
@@ -24,10 +24,10 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Python
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
diff --git a/.github/workflows/verify-release.yml b/.github/workflows/verify-release.yml
new file mode 100644
index 000000000000..0488b76a1a70
--- /dev/null
+++ b/.github/workflows/verify-release.yml
@@ -0,0 +1,185 @@
+# When a release is published, verify that the release pipeline delivered
+# everything: the distributable repos, the new framework version on
+# Packagist, and the user guide with its ePub.
+#
+# Use "Run workflow" with the release tag to verify an existing release.
+name: Verify Release
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+ inputs:
+ version:
+ description: The release tag to verify (e.g., v4.7.5)
+ type: string
+ required: true
+
+permissions:
+ contents: read
+
+env:
+ TAG: ${{ github.event.release.tag_name || inputs.version }}
+ PUBLISHED_AT: ${{ github.event.release.published_at }}
+
+jobs:
+ wait-for-deploy:
+ name: Wait for distributables deploy
+ if: github.repository == 'codeigniter4/CodeIgniter4'
+ runs-on: ubuntu-24.04
+ timeout-minutes: 30
+ permissions:
+ actions: read
+ contents: read
+
+ steps:
+ - name: Wait for "Deploy Distributable Repos" to succeed
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ # On workflow_dispatch the event carries no publish time.
+ if [ -z "${PUBLISHED_AT}" ]; then
+ PUBLISHED_AT=$(gh api "repos/${{ github.repository }}/releases/tags/${TAG}" --jq '.published_at')
+ echo "Resolved published_at=${PUBLISHED_AT} for ${TAG}."
+ fi
+
+ query="repos/${{ github.repository }}/actions/workflows/deploy-distributables.yml/runs?event=release&per_page=1&created=>=${PUBLISHED_AT}"
+
+ while true; do
+ run=$(gh api "${query}" --jq '.workflow_runs[0] | "\(.status) \(.conclusion)"' || true)
+ status=${run% *}
+ conclusion=${run#* }
+ echo "Deploy Distributable Repos: status=${status:-not found} conclusion=${conclusion:-n/a}"
+
+ if [ "${status}" = "completed" ]; then
+ if [ "${conclusion}" = "success" ]; then
+ exit 0
+ fi
+
+ echo "Deploy Distributable Repos did not succeed."
+ exit 1
+ fi
+
+ sleep 30
+ done
+
+ appstarter:
+ name: Install and test appstarter
+ needs: wait-for-deploy
+ runs-on: ubuntu-24.04
+ timeout-minutes: 45
+
+ steps:
+ - name: Setup PHP
+ uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
+ with:
+ php-version: '8.2'
+ tools: composer
+ extensions: intl, gd
+ coverage: none
+
+ - name: Wait for Packagist to serve the new framework version
+ run: |
+ version="${TAG#v}"
+
+ for i in $(seq 1 30); do
+ if curl -fsSL https://repo.packagist.org/p2/codeigniter4/framework.json \
+ | jq -e --arg version "v${version}" \
+ '.packages."codeigniter4/framework" | map(select(.version == $version)) | length > 0' > /dev/null; then
+ echo "Packagist serves codeigniter4/framework v${version}."
+ exit 0
+ fi
+
+ echo "Attempt ${i}: v${version} is not yet on Packagist."
+ sleep 60
+ done
+
+ echo "Timed out waiting for Packagist to serve v${version}."
+ exit 1
+
+ - name: Install appstarter
+ run: composer create-project codeigniter4/appstarter release-test --no-interaction
+
+ - name: Verify the framework version
+ working-directory: release-test
+ run: |
+ version="${TAG#v}"
+ installed=$(composer show codeigniter4/framework --format=json | jq -r '.versions[0]')
+ echo "Installed codeigniter4/framework: ${installed}"
+
+ if [ "${installed}" != "v${version}" ]; then
+ echo "Expected v${version}."
+ exit 1
+ fi
+
+ - name: Test appstarter
+ working-directory: release-test
+ run: composer test
+
+ userguide:
+ name: Verify user guide deployment
+ needs: wait-for-deploy
+ runs-on: ubuntu-24.04
+ timeout-minutes: 30
+
+ steps:
+ - name: Check that the ePub was added
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ version="${TAG#v}"
+
+ for i in $(seq 1 30); do
+ if gh api "repos/codeigniter4/userguide/contents/CodeIgniter${version}.epub" --jq '.name' > /dev/null 2>&1; then
+ echo "CodeIgniter${version}.epub is in the userguide repo."
+ exit 0
+ fi
+
+ echo "Attempt ${i}: CodeIgniter${version}.epub is not yet in the userguide repo."
+ sleep 30
+ done
+
+ echo "Timed out waiting for CodeIgniter${version}.epub."
+ exit 1
+
+ - name: Check that the user guide workflows succeeded
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ # On workflow_dispatch the event carries no publish time.
+ if [ -z "${PUBLISHED_AT}" ]; then
+ PUBLISHED_AT=$(gh api "repos/${{ github.repository }}/releases/tags/${TAG}" --jq '.published_at')
+ echo "Resolved published_at=${PUBLISHED_AT} for ${TAG}."
+ fi
+
+ for workflow in "Deploy Production" "pages build and deployment"; do
+ query="repos/codeigniter4/userguide/actions/runs?per_page=30&created=>=${PUBLISHED_AT}"
+
+ success=""
+
+ for _ in $(seq 1 30); do
+ run=$(gh api "${query}" \
+ | jq -r --arg name "${workflow}" \
+ '[.workflow_runs[] | select(.name == $name)][0] | "\(.status) \(.conclusion)"')
+ status=${run% *}
+ conclusion=${run#* }
+ echo "${workflow}: status=${status:-not found} conclusion=${conclusion:-n/a}"
+
+ if [ "${status}" = "completed" ]; then
+ if [ "${conclusion}" = "success" ]; then
+ success=1
+ break
+ fi
+
+ echo "\"${workflow}\" did not succeed."
+ exit 1
+ fi
+
+ sleep 30
+ done
+
+ if [ -z "${success}" ]; then
+ echo "Timed out waiting for \"${workflow}\"."
+ exit 1
+ fi
+ done
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000000..208568025619
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,148 @@
+# CodeIgniter 4 — Repository Guidelines
+
+CodeIgniter 4 is a mature open-source PHP framework. Framework production
+code lives in `system/`; its tests mirror that structure under
+`tests/system/`.
+
+## Before making changes
+
+Consult the relevant sections of:
+
+- `contributing/pull_request.md`
+- `contributing/internals.md`
+- `contributing/styleguide.md`
+- `tests/README.md`
+
+Follow existing code and tests in the affected component. Search for an
+existing implementation before introducing a new abstraction or convention.
+
+## Target branch and compatibility policy
+
+Compatibility is judged against the pull request's base branch, not the
+contributor's source or feature branch. When the base branch is unknown,
+apply the stricter `develop` policy.
+
+### Base branch `develop`
+
+`develop` is the next patch-release line. Intentional backward-incompatible
+changes are not allowed.
+
+- Preserve public and protected APIs, documented behavior, configuration
+ defaults, generated project files, exceptions, and observable side effects.
+- Do not remove deprecated items or change public/protected signatures,
+ visibility, or interface/abstract requirements.
+- A bug or security fix may correct faulty behavior but must preserve the
+ documented contract and ordinary valid usage.
+- If a fix necessarily breaks compatibility, target the appropriate minor
+ branch instead.
+
+### Base branch `4.*`
+
+Minor-release lines (for example `4.9`). Small, deliberate compatibility
+breaks may be accepted when they materially improve the framework or
+complete the documented deprecation lifecycle.
+
+- Keep each break narrow, explicit, and justified; never an incidental
+ side effect of refactoring.
+- Deprecated APIs may be removed no earlier than the second subsequent
+ minor release (deprecated in 4.6.x means removable in 4.8.0), with a
+ supported replacement.
+- Every accepted break needs tests for the new behavior, a minor-version
+ changelog entry, and migration instructions in the upgrading guide.
+
+### All base branches
+
+- Production code must run on PHP 8.2. Do not require a newer PHP version,
+ even when a newer version is installed locally.
+- Signatures, properties, constants, default values, exceptions, side
+ effects, configuration, and generated project files are all
+ compatibility-sensitive.
+- Major-scale redesigns and ecosystem-wide migration requirements belong in
+ a major release, not a patch or minor release.
+- Prefer the minimum useful abstraction and keep framework components as
+ independent as practical.
+- Dependencies should be injectable. When a framework service is used as a
+ default, preserve the ability for applications to replace it.
+- Do not add or update a Composer dependency unless the task explicitly
+ requires it and the change is justified.
+- Do not modify code in `system/ThirdParty/`.
+
+## Coding standards
+
+- Do not add `declare(strict_types=1)` mechanically. Follow nearby code and
+ check the `DeclareStrictTypesRector` exclusions in `rector.php`; PHP files
+ use strict types unless excluded there. Treat the existing exclusions as
+ intentional compatibility constraints. Do not remove an exclusion without
+ dedicated justification and tests.
+- Every new class property must have a native type declaration. Use the
+ most precise type supported by PHP 8.2; use `mixed` only when the
+ property intentionally accepts unrelated types.
+- Do not add or change a type on an existing public or protected property
+ mechanically. Property types affect inheritance and must follow the
+ target-branch compatibility policy.
+- Add PHPDoc only when it contributes information that native types cannot
+ express. Do not duplicate a parent or interface docblock.
+- Never suppress a static-analysis error or update a baseline to make a
+ check pass.
+
+## FrankenPHP Worker Mode
+
+In Worker Mode one process serves many requests. The entry point's source
+template is `system/Commands/Worker/Views/frankenphp-worker.php.tpl`; the
+`worker:install` command publishes it as `public/frankenphp-worker.php`.
+
+- For every change affecting application bootstrap, request handling,
+ response sending, shutdown, superglobals, sessions, database or cache
+ connections, services, factories, events, toolbar state, static state, or
+ other request-lifecycle behavior, inspect the worker entry-point template
+ even when it is not part of the diff.
+- Compare traditional per-process execution with worker execution, where
+ the framework boots once and handles multiple requests in the same
+ process.
+- Classify mutable state as process-lifetime, intentionally persistent, or
+ request-specific. Request-specific state must be refreshed or reset for
+ every request and must not leak into the next request.
+- Preserve the ordering requirements between per-request reconnection,
+ framework reset, superglobal refresh, application execution, and
+ post-request cleanup.
+- Treat the template as the source of truth. Do not edit a generated
+ `public/frankenphp-worker.php` in place.
+- When a release changes the template in a way existing installations must
+ receive, add an upgrading instruction telling Worker Mode users to
+ republish it with `php spark worker:install --force`.
+
+## Tests
+
+- Every bug fix should include a regression test that fails without the
+ fix.
+- Test failure paths, exceptions, boundary conditions, and state cleanup,
+ not only the happy path.
+- Prefer strict and dedicated PHPUnit assertions as described in the
+ project style guide.
+- Do not weaken or remove an existing test unless the documented behavior
+ or specification changed.
+
+## Documentation
+
+- Public API, behavior, message, or default-value changes may require an
+ update to the user guide and changelog.
+- Changes requiring user action, including configuration changes, may also
+ require an upgrading-guide entry.
+- For a `4.*` target, every intentional compatibility break must be
+ documented in both the minor-version changelog and its upgrading guide.
+
+## Focused validation
+
+GitHub Actions is the authoritative source for full validation. Do not
+reproduce the complete CI matrix locally.
+
+- Run only the narrowest PHPUnit test file or component that covers a code
+ change, for example
+ `vendor/bin/phpunit tests/system//Test.php`.
+- Run a file-scoped analysis or formatting check only when it is quick and
+ directly relevant.
+- Do not run the complete PHPUnit suite, `composer phpstan:check`,
+ `composer cs`, Psalm, Structarmed, or a repository-wide Rector analysis
+ by default.
+- Report which focused checks were executed and which validation was left
+ to CI.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 82d3974606b0..ddac0375df83 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,159 @@
# Changelog
+## [v4.7.5](https://github.com/codeigniter4/CodeIgniter4/tree/v4.7.5) (2026-10-09)
+[Full Changelog](https://github.com/codeigniter4/CodeIgniter4/compare/v4.7.4...v4.7.5)
+
+### Security
+
+* **CURLRequest:** Request-specific headers, body, and options
+ are now cleared when a request throws an exception and ``shareOptions`` is ``false``.
+ Previously, reusing the client after a failed request could send credentials
+ or private data to a different destination. Options passed to the constructor
+ remain available for subsequent requests.
+ Per-request ``baseURI`` and ``delay`` values are also reset after successful
+ and failed requests, preventing URI components from leaking into later requests.
+ When retrying a failed request, pass its options again, or reapply settings
+ made with ``setAuth()``, ``setBody()``, ``setForm()``, or ``setJSON()``.
+ See the `Security advisory GHSA-9g9v-xgwj-697h `_
+ for more information.
+* **Validation:** The ``is_image`` and ``mime_in`` file upload rules now reject client filenames ending in dots while
+ continuing to accept genuinely extensionless uploads. These rules and ``ext_in`` also reject filenames with a PHP
+ handler extension before the final extension, such as ``shell.php.gif``, including extensions revealed by filename
+ sanitization. These checks also reject innocent filenames such as ``logo.php.gif``. See
+ :doc:`Upgrading from 4.7.4 to 4.7.5 ` for migration instructions.
+ Previously, a GIF/PHP polyglot with one of these names could pass validation. Applications that save uploads with
+ client-provided names in executable public directories may be affected. Use a generated filename or disable script
+ execution in the upload directory. See the `Security advisory GHSA-4hwf-v4mp-hf6c `_
+ for more information.
+* **Validation:** File validation rules now check every non-empty file in an
+ optional multiple upload. Previously, an entry with ``UPLOAD_ERR_NO_FILE``
+ could cause ``max_size``, ``is_image``, ``mime_in``, ``ext_in``, ``max_dims``,
+ and ``min_dims`` to accept the upload without checking later files.
+ ``max_dims`` and ``min_dims`` also return ``false`` for upload errors other
+ than ``UPLOAD_ERR_NO_FILE`` before reading image dimensions.
+ See the `Security advisory GHSA-cwxp-v62r-xwx2 `_
+ for more information.
+* **View Parser:** Fixed a stored XSS vulnerability where a substituted value containing
+ Parser syntax (e.g., ``{text|raw}`` or ``{!text!}``) was interpreted by a later
+ substitution pass, allowing user-supplied data to bypass auto-escaping of other
+ pseudo-variables. Substituted values are now treated strictly as data and are never
+ parsed again. The same protection applies to output from built-in Parser plugins,
+ including URLs and validation messages. See :ref:`upgrade-475-view-parser-values` for more information.
+ See the `Security advisory GHSA-vgrf-mv2j-gp8w `_
+ for more information.
+* **View Parser:** Added restricted conditionals as an opt-in mitigation for
+ *Code Injection through Parser Template Source*. Conditions in ``{if ...}`` and
+ ``{elseif ...}`` tags are evaluated as PHP, so anyone who can edit Parser template
+ source can execute PHP code. This remains the default behavior. Applications that
+ let less-trusted users edit Parser templates must enable the new
+ ``Config\View::$restrictParserConditionals`` setting or the ``restrictConditionals``
+ render option. Restricted conditionals only limit this execution path; they do not
+ make every template feature safe for less-trusted authors.
+ The restriction also applies to nested renders on the same Parser instance.
+ See :ref:`parser-restricting-conditionals` and the
+ `Security advisory GHSA-4q58-jw8x-8cm7 `_
+ for more information.
+* **Views:** Fixed a code execution vulnerability caused by view data keys
+ overriding renderer-local variables. Data named ``template`` in ``Parser`` or
+ ``view`` in ``View::renderString()`` could replace the source evaluated as PHP.
+ Data named ``foundView`` in view cells could replace the selected file path
+ and cause unintended local file inclusion. These issues require applications
+ to pass less-trusted data under the affected keys.
+ See the `Security advisory GHSA-c29x-ffjj-8r7x `_
+ for more information.
+
+### Fixed Bugs
+
+* fix: safely interpolate logger context values by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10384
+* fix: resolve global state pollution causing random-order test failures in HTTP suite by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10372
+* fix: omit empty CSP headers by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10412
+* fix: remove destructive setServerArray([]) in FiltersTest::setUp to prevent random-order test failures by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10425
+* fix: resolve TypeError in CLIRequest::parseCommand when argv is missing by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10415
+* fix: resolve race conditions in Redis TTL and prevent cache test state leakage by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10374
+* fix: handle null results in get_dir_file_info() by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10407
+* fix: skip unreadable `.env` in `Boot::loadDotEnv` for separate-process tests by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10417
+* fix: return 403 for Honeypot bot detection by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10490
+* fix: pass prompt text to readline in `CLI::prompt()` so backspace does not erase it by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10507
+* fix(Cookie): validate raw cookie values by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10516
+* fix(CodeIgniter): prevent gatherOutput from being called twice when controller returns Response by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10369
+* fix: Memcached decrement() gives the wrong sign on a missing key by @mdalikadar in https://github.com/codeigniter4/CodeIgniter4/pull/10512
+* fix: correct file permissions and chmod target in File and UploadedFile move() by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10519
+* fix: strip the table prefix correctly when rebuilding a SQLite3 table by @karlgray in https://github.com/codeigniter4/CodeIgniter4/pull/10509
+* fix: write the prompt to STDOUT before readline() on Windows by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10534
+* fix(Cookie): validate cookie path and domain attributes by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10527
+* fix(I18n): compute correct date in Time::today(), yesterday(), and tomorrow() across timezones by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10532
+* fix: return an empty string when readline() reaches end-of-file by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10542
+* fix: reject unsupported database drivers in `make:migration --session` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10546
+* fix(Test): reset `is_windows()` mock state in `CIUnitTestCase::tearDown()` by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10554
+* fix(Cache): set secure 0755 permissions mode when creating directory in `FileVarExportHandler` by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10555
+* fix: stop `FileLocatorCached` from restoring a deleted cache on shutdown by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10559
+* fix: preserve sub-second precision in `BaseModel` datetime timestamps by @wakqasahmed in https://github.com/codeigniter4/CodeIgniter4/pull/10561
+* fix(Debug): treat zero time as valid timer start by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10569
+* fix: reject non-digit characters in `valid_cc_number()` by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10580
+* fix: throw Postgre query errors with warnings disabled by @wakqasahmed in https://github.com/codeigniter4/CodeIgniter4/pull/10573
+* fix: prevent infinite loop in `word_wrap()` with a character limit below 2 by @mdalikadar in https://github.com/codeigniter4/CodeIgniter4/pull/10596
+* fix: resolve schema-qualified table names in `getFieldData()` and `protectIdentifiers()` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10604
+* fix: cast Postgre batch subquery values so `updateBatch()` and `deleteBatch()` accept mixed PHP types by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10601
+* fix: cast OCI8 batch subquery values so `updateBatch()` and `deleteBatch()` accept mixed PHP types by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10602
+* fix: avoid undefined `STDERR` and `STDIN` outside the CLI by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10605
+
+### Refactoring
+
+* refactor(HTTP): split setCURLOptions into category helper methods to reduce complexity by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10339
+* refactor: use `array-key` as the benevolent union of `int|string` in array shapes by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10396
+* refactor: Enable Rector Code Quality set by @samsonasik in https://github.com/codeigniter4/CodeIgniter4/pull/10429
+* refactor: fix uses of `empty()` calls by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10439
+* refactor: fix `method.alreadyNarrowedType` errors by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10440
+* refactor: cleanup the Images library by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10441
+* refactor: add generics to `Entity` and `DataCaster` casts by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10442
+* refactor: fix `ternary.shortNotAllowed` errors by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10445
+* refactor: fix `missingType.property` errors by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10447
+* refactor: fix `return.type` errors by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10448
+* refactor: add generics to `Forge`, `BaseBuilder` and `BasePreparedQuery` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10449
+* refactor: fix `nullCoalesce.property` errors by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10450
+* refactor: add precise return type to `CodeIgniter::getPerformanceStats()` by @soccerlover29 in https://github.com/codeigniter4/CodeIgniter4/pull/10458
+* refactor: fix `property.nonObject` errors by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10451
+* refactor: sidestep PhpStorm warning on `@method` docblock in `Model` by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10444
+* refactor: fix phpstan errors in `IncomingRequestTest` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10452
+* refactor: fix phpstan errors in `Helpers` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10459
+* refactor: fix phpstan errors in `Router` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10460
+* refactor: clear out phpstan errors in `Validation` source and tests by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10461
+* refactor: clear out phpstan errors in `HTTP` source and tests by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10462
+* refactor: fix phpstan errors in `BaseBuilder` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10466
+* refactor: add precise parameter types to Router test fixtures by @soccerlover29 in https://github.com/codeigniter4/CodeIgniter4/pull/10464
+* refactor: fix phpstan errors in `Forge` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10470
+* refactor: fix phpstan errors in `Result` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10473
+* refactor: fix phpstan errors in `Connection` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10475
+* refactor: fix phpstan errors in `PreparedQuery` and `Utils` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10476
+* refactor: fix phpstan errors in `Config`, `Database`, `Query`, `MigrationRunner`, and `SQLite3\Table` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10477
+* refactor: fix phpstan errors in `Debug` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10478
+* refactor: fix phpstan errors in `Test` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10479
+* refactor: widen `Response` body and `Formatter` data typing to `mixed` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10480
+* refactor: fix remaining errors in `Helpers`' tests by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10481
+* refactor: fix phpstan errors in `Config` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10482
+* refactor: fix phpstan errors in `Commands` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10483
+* refactor: fix phpstan errors in `View` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10484
+* refactor: fix phpstan errors in `Filters` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10485
+* refactor: fix phpstan errors in `Encryption` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10486
+* refactor: fix iterable types in `Email` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10487
+* refactor: fix iterable types in `Router` tests by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10488
+* refactor: fix remaining phpstan errors in `Database` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10492
+* refactor: fix phpstan errors in `I18n` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10493
+* refactor: fix phpstan errors in `DataConverter` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10494
+* refactor: fix phpstan errors in `AutoReview` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10495
+* refactor: fix missing parameter types by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10496
+* refactor: fix remaining missing iterable types by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10497
+* refactor: fix remaining not found methods by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10498
+* refactor: fix the remaining fixable `assign.propertyType` and `phpdoc.propertyType` errors by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10499
+* refactor: fix remaining `argument.type` and `method.childParameterType` errors by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10502
+* refactor: replace anonymous class with bound closure in `PropertiesTrait` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10504
+* refactor: bound phpstan's analysed PHP versions by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10505
+* refactor: Enable MustHaveReturnTypeFunctionRule StructArmed rule on system/Helpers by @samsonasik in https://github.com/codeigniter4/CodeIgniter4/pull/10521
+* refactor: fix minor type inaccuracies found via PHPStan bleeding edge by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10522
+* refactor: fix return type covariance of `FileCollection::getIterator()` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10558
+* refactor: tighten docblock return types in Result, CLIRequest, url_helper and IncomingRequest by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10588
+* refactor: pass the session TTL to `Redis::set()` as an options array by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10600
+
## [v4.7.4](https://github.com/codeigniter4/CodeIgniter4/tree/v4.7.4) (2026-07-07)
[Full Changelog](https://github.com/codeigniter4/CodeIgniter4/compare/v4.7.3...v4.7.4)
diff --git a/admin/README.md b/admin/README.md
index 1f5dd89bfcd1..a4048bf59152 100644
--- a/admin/README.md
+++ b/admin/README.md
@@ -4,33 +4,33 @@ This folder contains tools or docs useful for project maintainers.
## Repositories inside https://github.com/codeigniter4
-- **CodeIgniter4** is the main development repository.
- It supports issues and pull requests, and has a rule to enforce GPG-signed commits.
- In addition to the framework source, it includes unit testing and documentation source.
- The three repositories following are built from this one as part of the release workflow.
+- **CodeIgniter4** is the main development repository.
+ It supports issues and pull requests, and has a rule to enforce GPG-signed commits.
+ In addition to the framework source, it includes unit testing and documentation source.
+ The three repositories following are built from this one as part of the release workflow.
This repo is meant to be forked by contributors.
-- **framework** is the released developer repository.
+- **framework** is the released developer repository.
It contains all the main pieces of the framework that developers would use to
- build their apps, but not the framework unit testing or the user guide source.
- It is meant to be downloaded by developers, or composer-installed.
+ build their apps, but not the framework unit testing or the user guide source.
+ It is meant to be downloaded by developers, or composer-installed.
This is a read-only repository.
-- **appstarter** is the released application starter repository.
+- **appstarter** is the released application starter repository.
It is derived from the framework's `app` and `public` folders, with
- a composer requirement dependency to pull in the framework itself.
- It is meant to be downloaded or composer-installed.
+ a composer requirement dependency to pull in the framework itself.
+ It is meant to be downloaded or composer-installed.
This is a read-only repository.
-- **userguide** is released documentation publishing repository.
+- **userguide** is released documentation publishing repository.
It contains built versions of the user guide, corresponding to the
- framework releases.
- It could be downloaded, forked or potentially composer-installed.
+ framework releases.
+ It could be downloaded, forked or potentially composer-installed.
This is a read-only repository.
-- **coding-standard** is the coding style standards repository.
+- **coding-standard** is the coding style standards repository.
It contains PHP-CS-Fixer rules to ensure consistent code style
- within the framework itself.
+ within the framework itself.
It is meant to be composer-installed.
- **translations** is the repository holding official translations of
- the locale-dependent system messages.
- It is community-maintained, and accepts issues and pull requests.
+ the locale-dependent system messages.
+ It is community-maintained, and accepts issues and pull requests.
It could be downloaded, forked or composer-installed.
## Contributor Scripts
@@ -41,47 +41,41 @@ This folder contains tools or docs useful for project maintainers.
to be added as part of a git commit, ensuring that they conform to the
framework coding style standards, and automatically fixing what can be.
-## Maintainer Scripts
+## Release Scripts
-- **release-config** holds variables used for the maintainer & release building
-- **docbot** re-builds the user guide from the RST source for it,
- and optionally deploys it to the `gh-pages` branch of the main
- repository (if the user running it has maintainer rights on that repo).
- See the [writeup](./docbot.md).
-
-## Release Building Scripts
-
-*Do not use these scripts! They are left here for reference only.*
-
-The release workflow is detailed in its own writeup; these are the main
-scripts used by the release manager:
-
-- **release** builds a new release branch in the main repo, for vetting.
- This includes updating version dependencies or constants,
- generating version(s) of the user guide; and possibly
- moving or ignoring stuff, distinguishing release from development.
- If successful, it will update the `config` file, with the version number
- in it, and it will run the related scripts following, to revise
- the release distributions.
- Usage: `admin/release version qualifier`
-- **release-userguide** builds the distributable userguide repo.
- It could be used on its own, but is normally part of `release`.
-- **release-deploy** pushes the release changes to the appropriate github
- repositories. Tag & create releases on GitHub. This is not easily reversible!
- Usage: `admin/release-deploy version qualifier`
-- **release-revert** can be used to restore your repositories to the state they
- were in before you started a release. **IF** you haven't deployed.
- This is in case you decide not to proceed with the release, for any reason.
- Remember to be polite when running it.
+The release process is detailed in [RELEASE.md](./RELEASE.md). These scripts
+are used as part of that process:
+- **generate-changelog.php** prepends the CHANGELOG.md entry for a new
+ release, built from GitHub's auto-generated release notes and the
+ SECURITY section of the version's detailed changelog.
+ Usage: `php admin/generate-changelog.php 4.x.x [--dry-run]`
+- **prepare-release.php** creates the `release-4.x.x` branch and updates
+ version references in the framework source, the user guide, and the
+ distribution build script.
+ Usage: `php admin/prepare-release.php 4.x.x`
+- **create-new-changelog.php** creates the changelog and upgrade guide
+ stubs for the next version and adds them to their index files.
+ Usage: `php admin/create-new-changelog.php `
+- **check-pr-labels.php** flags PRs merged after a release (default: the
+ latest) that appear to be missing the labels used to generate the
+ changelog.
+ Usage: `php admin/check-pr-labels.php []`
+- **update-upgrade-guide.php** fills the "Config" and "All Changes" sections
+ of the version's upgrade guide with the project space files changed since
+ the last release.
+ Usage: `php admin/update-upgrade-guide.php [--dry-run]`
+- **sync-release-branches.php** checks out the target branch and merges
+ upstream and the source branch into it, as done at several points of the
+ release process. Without `--push`, the result is left unpushed for review.
+ Usage: `php admin/sync-release-branches.php [--push]`
## Other Stuff
-- **release-notes.bb** is a boilerplate for forum announcements of a new release.
- It is marked up using [BBcode](https://en.wikipedia.org/wiki/BBCode).
- The **framework** and **starter** subfolders contain files that will over-ride
those from the development repository, when the distribution repositories
are built.
-- The subfolders inside `admin` contain "next release" files in the case of
+- The subfolders inside `admin` contain "next release" files in the case of
`codeigniter4` and over-written distribution files in the other cases.
-- The CHANGELOG.md file is auto-generated using the [GitHub Changelog Generator](https://github.com/github-changelog-generator/github-changelog-generator)
+- The CHANGELOG.md file is generated from GitHub's auto-generated release
+ notes, as described in [RELEASE.md](./RELEASE.md).
diff --git a/admin/RELEASE.md b/admin/RELEASE.md
index 75b4e26b2b31..75f2a0fbf35a 100644
--- a/admin/RELEASE.md
+++ b/admin/RELEASE.md
@@ -1,11 +1,10 @@
# Release Process
-> Documentation guide based on the releases of `4.0.5` and `4.1.0` on January 31, 2021.
+> Documentation guide based on the releases of `4.0.5` and `4.1.0` on January 31, 2021 (MGatner)
>
-> Updated for `4.5.0` on April 7, 2024.
-> Updated for `4.6.0` on January 19, 2025.
->
-> -MGatner, kenjis
+> Updated for `4.5.0` on April 7, 2024 (kenjis)
+> Updated for `4.6.0` on January 19, 2025 (paulbalandan)
+> Updated for `4.7.5` on July 8, 2026 (paulbalandan)
## Notation
@@ -18,17 +17,14 @@
## Merge `develop` branch into next minor version branch `4.y`
-Before starting release process, if there are commits in `develop` branch that
+Before starting the release process, if there are commits in `develop` branch that
are not merged into `4.y` branch, merge them. This is because if conflicts occur,
merging will take time.
-```console
-git fetch upstream
-git switch 4.y
-git merge upstream/4.y
-git merge upstream/develop
-git push upstream HEAD
-```
+* [ ] Merge `develop` into `4.y`:
+ ```console
+ php admin/sync-release-branches.php 4.y develop --push
+ ```
## [Minor version only] Merge minor version branch into `develop`
@@ -73,23 +69,33 @@ the following [labels](https://github.com/codeigniter4/CodeIgniter4/labels):
PRs with breaking changes must have the following additional label:
- **breaking change** ... PRs that may break existing functionalities
-### Generate Changelog
+At any time, missing labels can be checked for with
+`php admin/check-pr-labels.php`, which flags PRs merged since the last release
+that appear to lack a changelog label. It requires the authenticated
+[GitHub CLI](https://cli.github.com/).
-To auto-generate the changelog, navigate to the
-[Releases](https://github.com/codeigniter4/CodeIgniter4/releases) page,
-click the "Draft a new release" button.
+### Generate Changelog
-* Choose a tag: `v4.x.x` (Create new tag: v4.x.x on publish)
-* Target: `develop`
+The changelog is generated from GitHub's auto-generated release notes with a
+script. It requires the authenticated [GitHub CLI](https://cli.github.com/).
-Click the "Generate release notes" button.
+The script also copies the security fixes from the SECURITY section of
+**user_guide_src/source/changelogs/v4.x.x.rst**, so any security advisory PRs
+must be merged and documented there before generating the changelog.
-Check the resulting content. If there are items in the *Others* section which
-should be included in the changelog, add a label to the PR and regenerate
-the changelog.
+* [ ] Run `php admin/generate-changelog.php 4.x.x --dry-run` and check the output.
+ * The script lists the PRs that have none of the changelog labels. If any of
+ them should be included in the changelog, add a label to the PR and run
+ the script again.
+* [ ] Run `php admin/generate-changelog.php 4.x.x` to prepend the new entry to
+ **CHANGELOG.md**.
-Copy the resulting contents into **CHANGELOG.md** and adjust the format to match
-the existing content.
+If the script cannot be used, the same notes can be generated from the
+[Releases](https://github.com/codeigniter4/CodeIgniter4/releases) page:
+click "Draft a new release", choose the tag `v4.x.x` (create new tag on
+publish) with target `develop`, and click "Generate release notes". Copy the
+resulting contents into **CHANGELOG.md** and adjust the format to match the
+existing content.
## Process
@@ -99,18 +105,15 @@ the existing content.
> generating much new content.
* [ ] Merge any security advisory PRs in private forks.
-* [ ] Add the current version to **CHANGELOG.md** with the contents generated above.
+* [ ] Check that **CHANGELOG.md** contains the entry for the new version, generated above.
* [ ] Update **user_guide_src/source/changelogs/v4.x.x.rst**
* Remove the section titles that have no items
* [ ] Update **user_guide_src/source/installation/upgrade_4xx.rst**
- * [ ] fill in the "All Changes" section using the following command, and add it to **upgrade_4xx.rst**:
- ```
- git diff --name-status upstream/master -- . ':!.github/' ':!admin/' ':!changelogs/' ':!contributing/' \
- ':!system/' ':!tests/' ':!user_guide_src/' ':!utils/' \
- ':!*.json' ':!*.xml' ':!*.dist' ':!rector.php' ':!structarmed.php' \
- ':!phpstan*' ':!psalm*' ':!.php-cs-fixer.*' ':!LICENSE' ':!CHANGELOG.md'
- ```
- * Note: `tests/` is not used for distribution repos. See `admin/starter/tests/`.
+ * [ ] Run `php admin/update-upgrade-guide.php 4.x.x` to fill in the "Config" and
+ "All Changes" sections with the project space files changed since the last release.
+ * The "Config" section is not modified if it already has content. The script
+ prints any missing entries to merge manually.
+ * Add notes to the "Config" entries as needed.
* [ ] Remove the section titles that have no items
* [ ] [Minor version only] Update the "from" version in the title, (e.g., `from 4.3.x` → `from 4.3.8`).
* [ ] Run `php admin/prepare-release.php 4.x.x` and push to origin.
@@ -118,16 +121,16 @@ the existing content.
* Create a new branch `release-4.x.x`
* Update **system/CodeIgniter.php** with the new version number:
`const CI_VERSION = '4.x.x';`
- * Update **user_guide_src/source/conf.py** with the new `version = '4.x'` (if releasing
- the minor version) and `release = '4.x.x'`.
+ * Update **user_guide_src/source/conf.py** with the new `version = '4.x'`
+ and `release = '4.x.x'`.
* Update **user_guide_src/source/changelogs/{version}.rst**
* 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')`.
+ * 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`
@@ -163,37 +166,23 @@ the existing content.
**Full Changelog**: https://github.com/codeigniter4/CodeIgniter4/compare/v4.x.w...v4.x.x
```
Click the "Generate release notes" button, and get the "New Contributors".
-* [ ] Watch for the "Deploy Distributable Repos" action to make sure **framework**,
- **appstarter**, and **userguide** get updated
-* [ ] Run the following commands to install and test `appstarter` and verify the new
- version:
- ```console
- rm -rf release-test
- composer create-project codeigniter4/appstarter release-test
- cd release-test
- composer test && composer info codeigniter4/framework
- ```
-* [ ] Verify that the user guide actions succeeded:
- * [ ] "[Deploy Distributable Repos](https://github.com/codeigniter4/CodeIgniter4/actions/workflows/deploy-distributables.yml)", the main repo
- * [ ] "[Deploy Production](https://github.com/codeigniter4/userguide/actions/workflows/deploy.yml)", UG repo
- * [ ] "[pages-build-deployment](https://github.com/codeigniter4/userguide/actions/workflows/pages/pages-build-deployment)", UG repo
- * [ ] Check if "CodeIgniter4.x.x.epub" is added to UG repo. "CodeIgniter.epub" was
- created when v4.3.8 was released.
+* [ ] Watch the "[Verify Release](https://github.com/codeigniter4/CodeIgniter4/actions/workflows/verify-release.yml)"
+ workflow and make sure it passes. It does the following:
+ * Wait for the "[Deploy Distributable Repos](https://github.com/codeigniter4/CodeIgniter4/actions/workflows/deploy-distributables.yml)"
+ action to update **framework**, **appstarter**, and **userguide**
+ * Install `appstarter` from Packagist, verify it pulls framework `v4.x.x`,
+ and run its tests
+ * Verify that the user guide deployments succeeded ("Deploy Production" and
+ "pages build and deployment" in the UG repo) and that **CodeIgniter4.x.x.epub**
+ was added
+ * If it fails, fix the cause and re-run it via "Run workflow" with the tag `v4.x.x`.
* [ ] Fast-forward `develop` branch to catch the merge commit from `master`
```console
- git fetch upstream
- git checkout develop
- git merge upstream/develop
- git merge upstream/master
- git push upstream HEAD
+ php admin/sync-release-branches.php develop master --push
```
* [ ] Update the next minor version branch `4.y`:
```console
- git fetch upstream
- git switch 4.y
- git merge upstream/4.y
- git merge upstream/develop
- git push upstream HEAD
+ php admin/sync-release-branches.php 4.y develop --push
```
* [ ] [Minor version only] Create the new next minor version branch `4.z`:
```console
@@ -234,26 +223,31 @@ sudo pip3 install sphinx_rtd_theme
### Manual User Guide Process
-* Still in the **CodeIgniter4** repo enter the **user_guide_src** directory
-* Clear out any old build files: `rm -rf build/`
-* Build the HTML version of the User Guide: `make html`
-* Build the ePub version of the User Guide: `make epub`
-* Switch to the **userguide** repo and create a new branch `release-4.x.x`
-* Replace **docs/** with **CodeIgniter4/user_guide_src/build/html**
-* Ensure the file **docs/.nojekyll** exists or GitHub Pages will ignore folders
+> [!NOTE]
+> This process is normally not needed. The "Deploy Distributable Repos" action
+> builds and deploys the User Guide automatically when a release is published.
+> Follow these steps only if the automatic deployment fails.
+
+* [ ] Still in the **CodeIgniter4** repo enter the **user_guide_src** directory
+* [ ] Clear out any old build files: `rm -rf build/`
+* [ ] Build the HTML version of the User Guide: `make html`
+* [ ] Build the ePub version of the User Guide: `make epub`
+* [ ] Switch to the **userguide** repo and create a new branch `release-4.x.x`
+* [ ] Replace **docs/** with **CodeIgniter4/user_guide_src/build/html**
+* [ ] Ensure the file **docs/.nojekyll** exists or GitHub Pages will ignore folders
with an underscore prefix
-* Copy **CodeIgniter4/user_guide_src/build/epub/CodeIgniter.epub** to
+* [ ] Copy **CodeIgniter4/user_guide_src/build/epub/CodeIgniter.epub** to
**./CodeIgniter4.x.x.epub**
-* Commit the changes with "Update for 4.x.x" and push to origin
-* Create a new PR from `release-4.x.x` to `develop`:
+* [ ] Commit the changes with "Update for 4.x.x" and push to origin
+* [ ] Create a new PR from `release-4.x.x` to `develop`:
* Title: "Update for 4.x.x"
* Description: blank
-* Merge the PR
-* Create a new Release:
+* [ ] Merge the PR
+* [ ] Create a new Release:
* Version: "v4.x.x"
* Title: "CodeIgniter 4.x.x User Guide"
* Description: "CodeIgniter 4.x.x User Guide"
-* Watch for the "github pages" Environment to make sure the deployment succeeds
+* [ ] Watch for the "github pages" Environment to make sure the deployment succeeds
The User Guide website should update itself via the deploy GitHub Action. Should
this fail the server must be updated manually. See repo and hosting details in
diff --git a/admin/alldocs b/admin/alldocs
deleted file mode 100755
index 12d0b410ff84..000000000000
--- a/admin/alldocs
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/bin/bash
-
-# Rebuild and deploy all CodeIgniter4 docs
-#
-
-. admin/docbot $1
-. admin/apibot $1
\ No newline at end of file
diff --git a/admin/apibot b/admin/apibot
deleted file mode 100755
index 08b451a04bbb..000000000000
--- a/admin/apibot
+++ /dev/null
@@ -1,42 +0,0 @@
-#!/bin/bash
-
-# Rebuild and deploy CodeIgniter4 under-development user guide
-#
-# This is a quick way to test user guide changes, and if they
-# look good, to push them to the gh-pages branch of the
-# development repository.
-#
-# This is not meant for updating the "stable" user guide.
-
-UPSTREAM=https://github.com/codeigniter4/api.git
-
-# Prepare the nested repo clone folder
-rm -rf build/api*
-mkdir -p build/api/docs
-
-# Get ready for git
-cd build/api
-git init
-git remote add origin $UPSTREAM
-git fetch
-git checkout master
-git reset --hard origin/master
-rm -r docs/*
-
-# Make the new user guide
-cd ../..
-phpdoc
-cp -R api/build/* build/api/docs
-
-# All done?
-if [ $# -lt 1 ]; then
- exit 0
-fi
-
-# Optionally update the remote repo
-if [ $1 = "deploy" ]; then
- cd build/api
- git add .
- git commit -S -m "APIbot synching"
- git push -f origin master
-fi
diff --git a/admin/apibot.md b/admin/apibot.md
deleted file mode 100644
index ba63aaf269fa..000000000000
--- a/admin/apibot.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# apibot
-
-Builds & deploys API docs.
-
-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 publicly visible as the in-progress
-version of the [API](https://codeigniter4.github.io/api/).
-
-## Requirements
-
-You must have phpDocumentor installed, with a `phpdoc` alias installed globally.
-
-## Audience
-
-This script is intended for use by framework maintainers,
-i.e. someone with commit rights on the CI4 repository.
-
-You will be prompted for your github credentials and
-GPG-signing key as appropriate.
-
-## Usage
-
-Inside a shell prompt, in the project root:
-
- `admin/apibot [deploy]`
-
-If "deploy" is not added, the script execution is considered
-a trial run, and nothing is pushed to the repo.
-
-Whether or not deployed, the results are left inside
-`build/api` (which is git ignored).
-
-Generate these and the userguide together with the 'alldocs' script.
diff --git a/admin/check-pr-labels.php b/admin/check-pr-labels.php
new file mode 100644
index 000000000000..a1a2f7d9c33e
--- /dev/null
+++ b/admin/check-pr-labels.php
@@ -0,0 +1,203 @@
+&1", $repo, $number), $files, $exitCode);
+
+ if ($exitCode !== 0) {
+ return true;
+ }
+
+ foreach ($files as $file) {
+ if (str_starts_with($file, 'system/')) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+chdir(__DIR__ . '/..');
+
+$ansi = stream_isatty(STDOUT);
+$repo = 'codeigniter4/CodeIgniter4';
+
+// Labels used by the changelog categories. See ".github/release.yml".
+$changelogLabels = ['breaking change', 'bug', 'enhancement', 'new feature', 'refactor'];
+
+// PR title types that map to a changelog label.
+$typeToLabel = [
+ 'fix' => 'bug',
+ 'feat' => 'new feature',
+ 'perf' => 'enhancement',
+ 'refactor' => 'refactor',
+];
+
+// PR title types that need no changelog label.
+$typesWithoutLabel = ['chore', 'ci', 'docs', 'style', 'test'];
+
+// Release process PRs carry no labels.
+$releaseTitles = '/\A(?:Prep for \d+\.\d+\.\d+ release|\d+\.\d+\.\d+ (?:Ready|Merge) code)\z/';
+
+$tag = null;
+
+foreach (array_slice($argv, 1) as $arg) {
+ if (preg_match('/\Av?(\d+\.\d+\.\d+)\z/', $arg, $matches) === 1) {
+ $tag = "v{$matches[1]}";
+
+ continue;
+ }
+
+ echo sprintf("Usage: php %s [version]\n", $argv[0]);
+ echo sprintf("E.g.,: php %s 4.7.3\n", $argv[0]);
+ echo "Checks the PRs merged after the given release. Defaults to the latest release.\n";
+
+ exit(1);
+}
+
+$endpoint = $tag === null ? sprintf('repos/%s/releases/latest', $repo) : sprintf('repos/%s/releases/tags/%s', $repo, $tag);
+exec(sprintf('gh api %s 2>&1', $endpoint), $output, $exitCode);
+
+if ($exitCode !== 0) {
+ echo sprintf("Failed to fetch the release %s from GitHub:\n", $tag ?? '(latest)');
+ echo implode("\n", $output) . "\n";
+
+ exit(1);
+}
+
+$release = json_decode(implode("\n", $output), true);
+$since = $release['published_at'] ?? '';
+
+echo sprintf("Checking PRs merged since %s (%s).\n", $release['tag_name'], $since);
+
+$command = sprintf(
+ 'gh pr list --repo %s --search %s --json number,title,labels,baseRefName,url --limit 300 2>&1',
+ $repo,
+ escapeshellarg(sprintf('is:merged -base:master merged:>%s', $since)),
+);
+exec($command, $prOutput, $exitCode);
+
+if ($exitCode !== 0) {
+ echo "Failed to fetch the merged PRs from GitHub:\n";
+ echo implode("\n", $prOutput) . "\n";
+
+ exit(1);
+}
+
+$pulls = json_decode(implode("\n", $prOutput), true);
+
+if (! is_array($pulls)) {
+ echo "Unexpected response from GitHub.\n";
+
+ exit(1);
+}
+
+echo sprintf("Found %d merged PRs.\n\n", count($pulls));
+
+$missingLabel = [];
+$needManualLook = [];
+
+foreach ($pulls as $pull) {
+ $labels = array_column($pull['labels'], 'name');
+ $hasLabel = array_intersect($changelogLabels, $labels) !== [];
+ $type = null;
+
+ if (preg_match('/\A(\w+)(?:\([^)]*\))?:/', $pull['title'], $matches) === 1) {
+ $type = $matches[1];
+ }
+
+ if ($hasLabel || in_array($type, $typesWithoutLabel, true)) {
+ continue;
+ }
+
+ if (preg_match($releaseTitles, $pull['title']) === 1) {
+ continue;
+ }
+
+ if ($type !== null && isset($typeToLabel[$type])) {
+ // The refactor label applies only to refactoring in system/. Test-only
+ // refactors may use the testing label, which is not in the changelog.
+ if ($type === 'refactor' && ! touches_system_dir($repo, (int) $pull['number'])) {
+ continue;
+ }
+
+ $missingLabel[] = [
+ 'number' => $pull['number'],
+ 'title' => $pull['title'],
+ 'base' => $pull['baseRefName'],
+ 'url' => $pull['url'],
+ 'label' => $typeToLabel[$type],
+ ];
+ } else {
+ $needManualLook[] = [
+ 'number' => $pull['number'],
+ 'title' => $pull['title'],
+ 'base' => $pull['baseRefName'],
+ 'url' => $pull['url'],
+ ];
+ }
+}
+
+if ($missingLabel !== []) {
+ echo color('PRs that appear to be missing a changelog label:', '1', $ansi) . "\n";
+
+ foreach ($missingLabel as $pull) {
+ echo sprintf("%s [suggested: %s]\n", format_pull_line($pull, $ansi), color($pull['label'], '33', $ansi));
+ }
+
+ echo "\nTo add the suggested labels, run the following commands (drop any that are not warranted):\n";
+
+ foreach ($missingLabel as $pull) {
+ echo sprintf("gh pr edit %d --repo %s --add-label \"%s\"\n", $pull['number'], $repo, $pull['label']);
+ }
+
+ echo "\n";
+}
+
+if ($needManualLook !== []) {
+ echo color('PRs with no changelog label and no recognized title type (check manually):', '1', $ansi) . "\n";
+
+ foreach ($needManualLook as $pull) {
+ echo format_pull_line($pull, $ansi) . "\n";
+ }
+
+ echo "\n";
+}
+
+if ($missingLabel === []) {
+ echo color('No PRs are missing changelog labels.', '32', $ansi) . "\n";
+
+ exit(0);
+}
+
+exit(1);
diff --git a/admin/docbot b/admin/docbot
deleted file mode 100755
index c4dd8229c9ac..000000000000
--- a/admin/docbot
+++ /dev/null
@@ -1,42 +0,0 @@
-#!/bin/bash
-
-# Rebuild and deploy CodeIgniter4 under-development user guide
-#
-# This is a quick way to test user guide changes, and if they
-# look good, to push them to the gh-pages branch of the
-# development repository.
-#
-# This is not meant for updating the "stable" user guide.
-
-UPSTREAM=https://github.com/codeigniter4/CodeIgniter4.git
-
-# Prepare the nested repo clone folder
-cd user_guide_src
-rm -rf build/*
-mkdir build/html
-
-# Get ready for git
-cd build/html
-git init
-git remote add origin $UPSTREAM
-git fetch
-git checkout gh-pages
-git reset --hard origin/gh-pages
-rm -r *
-
-# Make the new user guide
-cd ../..
-make html
-
-# All done?
-if [ $# -lt 1 ]; then
- exit 0
-fi
-
-# Optionally update the remote repo
-if [ $1 = "deploy" ]; then
- cd build/html
- git add .
- git commit -S -m "Docbot synching"
- git push -f origin gh-pages
-fi
\ No newline at end of file
diff --git a/admin/docbot.md b/admin/docbot.md
deleted file mode 100644
index a3577b2e5d36..000000000000
--- a/admin/docbot.md
+++ /dev/null
@@ -1,39 +0,0 @@
-# docbot
-
-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 publicly visible as the in-progress
-version of the [User Guide](https://codeigniter4.github.io/CodeIgniter4/).
-
-## Requirements
-
-You must have python & sphinx installed.
-
-## Audience
-
-This script is intended for use by framework maintainers,
-i.e. someone with commit rights on the CI4 repository.
-
-This script wraps the conventional user guide building,
-i.e. `user_guide_src/make html`, with additional
-steps.
-
-You will be prompted for your github credentials and
-GPG-signing key as appropriate.
-
-## Usage
-
-Inside a shell prompt, in the project root:
-
- `admin/docbot [deploy]`
-
-If "deploy" is not added, the script execution is considered
-a trial run, and nothing is pushed to the repo.
-
-Whether or not deployed, the results are left inside
-user_guide_src/build (which is git ignored).
-
-Generate these and the API docs together with the 'alldocs' script.
diff --git a/admin/generate-changelog.php b/admin/generate-changelog.php
new file mode 100644
index 000000000000..f6578cb1372f
--- /dev/null
+++ b/admin/generate-changelog.php
@@ -0,0 +1,153 @@
+ [--dry-run]
+ */
+chdir(__DIR__ . '/..');
+
+$args = array_slice($argv, 1);
+$options = array_values(array_filter($args, static fn (string $arg): bool => str_starts_with($arg, '--')));
+$params = array_values(array_diff($args, $options));
+
+if (count($params) !== 1 || preg_match('/\A\d+\.\d+\.\d+\z/', $params[0]) !== 1) {
+ echo "Usage: php {$argv[0]} [--dry-run]\n";
+ echo "E.g.,: php {$argv[0]} 4.7.5\n";
+
+ exit(1);
+}
+
+$version = $params[0];
+$dryRun = in_array('--dry-run', $options, true);
+$repo = 'codeigniter4/CodeIgniter4';
+
+$changelogPath = './CHANGELOG.md';
+$changelog = file_get_contents($changelogPath);
+
+if (! $dryRun && str_contains($changelog, "## [v{$version}]")) {
+ echo "CHANGELOG.md already contains an entry for v{$version}.\n";
+
+ exit(1);
+}
+
+// Fetches the auto-generated release notes.
+$command = sprintf(
+ 'gh api repos/%s/releases/generate-notes -f tag_name=v%s -f target_commitish=develop 2>&1',
+ $repo,
+ $version,
+);
+exec($command, $output, $exitCode);
+
+if ($exitCode !== 0) {
+ echo "Failed to fetch the release notes from GitHub:\n";
+ echo implode("\n", $output) . "\n";
+
+ exit(1);
+}
+
+$response = json_decode(implode("\n", $output), true);
+$body = $response['body'] ?? '';
+
+// Parses the release notes into categories. See ".github/release.yml".
+$sections = [];
+$current = null;
+$previousTag = null;
+
+foreach (preg_split('/\R/', $body) as $line) {
+ if (preg_match('/^### (.+)$/', $line, $matches) === 1) {
+ $current = $matches[1];
+ $sections[$current] = [];
+
+ continue;
+ }
+
+ if (str_starts_with($line, '## ')) {
+ $current = null;
+
+ continue;
+ }
+
+ if (preg_match('~^\*\*Full Changelog\*\*: https://github\.com/[^/]+/[^/]+/compare/(.+?)\.\.\.~', $line, $matches) === 1) {
+ $previousTag = $matches[1];
+
+ continue;
+ }
+
+ if ($current !== null && str_starts_with($line, '* ')) {
+ $sections[$current][] = $line;
+ }
+}
+
+if ($previousTag === null) {
+ echo "Could not determine the previous tag from the release notes.\n";
+
+ exit(1);
+}
+
+// PRs in the catch-all category have none of the changelog labels. They are
+// listed for checking only and are not part of the generated entry.
+$othersTitle = 'Others (Only for checking. Remove this category)';
+$others = $sections[$othersTitle] ?? [];
+unset($sections[$othersTitle]);
+
+// Extracts the security fixes from the SECURITY section of the detailed
+// changelog, if any. These come from security advisories, not from PRs, so
+// they are not part of the generated release notes.
+$rstPath = "./user_guide_src/source/changelogs/v{$version}.rst";
+$security = '';
+
+if (is_file($rstPath)) {
+ $rst = file_get_contents($rstPath);
+
+ if (preg_match('/^\*+\nSECURITY\n\*+\n(.*?)(?=^\*+$|\z)/msu', $rst, $matches) === 1) {
+ $security = trim($matches[1], "\n");
+ $security = preg_replace('/^- /mu', '* ', $security);
+ }
+}
+
+$date = date('Y-m-d');
+$entry = "## [v{$version}](https://github.com/{$repo}/tree/v{$version}) ({$date})\n";
+$entry .= "[Full Changelog](https://github.com/{$repo}/compare/{$previousTag}...v{$version})\n";
+
+if ($security !== '') {
+ $entry .= "\n### Security\n\n{$security}\n";
+}
+
+foreach ($sections as $title => $items) {
+ if ($items === []) {
+ continue;
+ }
+
+ $entry .= "\n### {$title}\n\n";
+ $entry .= implode("\n", $items) . "\n";
+}
+
+if ($others !== []) {
+ echo "The following PRs have no changelog label and were NOT included.\n";
+ echo "If any of them belong in the changelog, label the PR and run this script again:\n";
+ echo implode("\n", $others) . "\n\n";
+}
+
+if ($dryRun) {
+ echo $entry;
+
+ exit(0);
+}
+
+$updated = preg_replace('/\A# Changelog\n\n/', "# Changelog\n\n{$entry}\n", $changelog, 1, $count);
+
+if ($count !== 1) {
+ echo 'Could not find the "# Changelog" header in CHANGELOG.md.' . "\n";
+
+ exit(1);
+}
+
+file_put_contents($changelogPath, $updated);
+
+echo "Added the v{$version} entry to CHANGELOG.md.\n";
diff --git a/admin/release b/admin/release
deleted file mode 100755
index d14167f0502e..000000000000
--- a/admin/release
+++ /dev/null
@@ -1,145 +0,0 @@
-#!/bin/bash
-
-## Build framework release branch & distributables
-
-# Setup variables
-. admin/release-config
-
-echo -e "${BOLD}${COLOR}CodeIgniter4 release builder${NORMAL}"
-echo '----------------------------'
-
-#---------------------------------------------------
-# Check arguments
-echo -e "${BOLD}Checking arguments...${NORMAL}"
-
-if [ $# -lt 1 ]; then
- echo -e "${BOLD}Usage: admin/release version# pre-release-qualifier${NORMAL}"
- exit 1
-fi
-
-#---------------------------------------------------
-# Create the release branch
-echo -e "${BOLD}Creating $which $branch to $action ${NORMAL}"
-
-git checkout develop
-git branch -d $branch &>/dev/null # remove the branch if there
-git checkout -b $branch
-composer update
-
-#---------------------------------------------------
-# Update version dependencies
-echo -e "${BOLD}Updating version dependencies${NORMAL}"
-
-function check_unique {
- count=`grep -c '$1' < $2 | wc -l`
- if [ $count -ne 1 ]; then
- echo -e "${BOLD}${COLOR}$2 has ${count} occurrences of '$1'${NORMAL}"
- exit 1
- fi
-}
-
-# Make sure there is only one line to affect in each file
-check_unique "const CI_VERSION" 'system/CodeIgniter.php'
-check_unique "release =" 'user_guide_src/source/conf.py'
-check_unique "|release|" 'user_guide_src/source/changelogs/index.rst'
-check_unique "Release Date.*Not Released" 'user_guide_src/source/changelogs/index.rst'
-
-# CI_VERSION definition in system/CodeIgniter.php
-sed -i "/const CI_VERSION/s/'.*'/'${RELEASE}'/" system/CodeIgniter.php
-
-# release substitution variable in user_guide_src/source/conf.py
-sed -i "/release =/s/'.*'/'${RELEASE}'/" user_guide_src/source/conf.py
-
-# version & date in user_guide_src/source/index.rst
-sed -i "/|release|/s/|.*|/${RELEASE}/" user_guide_src/source/changelogs/index.rst
-sed -i "/Release Date/s/Not Released/$(date +'%B %d, %Y')/" user_guide_src/source/changelogs/index.rst
-sed -i "/|version|/s/|version|/${RELEASE}/" user_guide_src/source/changelogs/index.rst
-
-# version & date in user_guide_src/source/next.rst
-sed -i "/Release Date/s/Not Released/$(date +'%B %d, %Y')/" user_guide_src/source/changelogs/next.rst
-sed -i "/|version|/s/|version|/${RELEASE}/" user_guide_src/source/changelogs/next.rst
-
-# establish version-specific changelog
-sed -i "|changelogs/next|s|changeslog/next|changelogs/v{$RELEASE}|" user_guide_src/source/changelogs/index.rst
-mv user_guide_src/source/changelogs/next.rst user_guide_src/source/changelogs/v${RELEASE}.rst
-touch user_guide_src/source/changelogs/next.rst
-cp admin/next.rst user_guide_src/source/changelogs/next.rst
-
-#---------------------------------------------------
-# Setup the distribution folders
-echo -e "${BOLD}Building distribution folders${NORMAL}"
-
-function setup_repo {
- echo -e "${BOLD}... $1${NORMAL}"
- if [ -d dist/$1 ]; then
- rm -rf dist/$1
- fi
- mkdir dist/$1
- cd dist/$1
- git init
- git remote add origin ${CI_ORG}/$1.git
- git fetch
- git checkout master
- git checkout -b $branch
- cd $CI_DIR
-}
-
-if [ -d dist ]; then
- rm -rf dist/
-fi
-mkdir dist
-
-setup_repo userguide
-
-#---------------------------------------------------
-# Housekeeping - make sure writable is flushed of test files
-# at least, test files that crop up on my system :-/
-rm -f writable/cache/H*
-rm -f writable/cache/d*
-rm -f writable/cache/s*
-rm -f writable/debugbar/debug*
-rm -f writable/logs/log*
-
-#---------------------------------------------------
-# Generate the user guide
-echo -e "${BOLD}Generate the user guide${NORMAL}"
-
-cd user_guide_src
-
-# make the UG
-rm -rf build/*
-echo -e "${BOLD}... HTML version${NORMAL}"
-make html
-touch build/html/.nojekyll
-echo -e "${BOLD}... epub version${NORMAL}"
-make epub
-
-cd ${CI_DIR}
-
-# add changelog preamble
-file=user_guide_src/source/changelogs/index.rst
-sed -i "4 a Version |version|" $file
-sed -i "5 a ====================================================" $file
-sed -i "6 G" $file
-sed -i "7 a Release Date: Not Released" $file
-sed -i "8 G" $file
-sed -i "9 a **Next release of CodeIgniter4**" $file
-sed -i "10 G" $file
-sed -i "11 G" $file
-sed -i "12 a :doc:\`See all the changes. \`" $file
-sed -i "13 G" $file
-
-#---------------------------------------------------
-echo -e "${BOLD}Commit the release branch${NORMAL}"
-git add .
-git commit -S -m "Release ${RELEASE}"
-
-#---------------------------------------------------
-# Build the distributables
-
-. admin/release-userguide
-
-#---------------------------------------------------
-# Done for now
-echo -e "${BOLD}Your $branch branch is ready to inspect.${NORMAL}"
-echo -e "${BOLD}Follow the directions in workflow.md to continue.${NORMAL}"
diff --git a/admin/release-config b/admin/release-config
deleted file mode 100644
index 8a969af81c68..000000000000
--- a/admin/release-config
+++ /dev/null
@@ -1,26 +0,0 @@
-# Variables used for release building
-
-if [ -z "$CI_ORG" ]; then
-
- # Initialize variables
- CI_ORG=https://github.com/codeigniter4
- CI_DIR=`pwd`
-
- BOLD='\033[1m'
- NORMAL='\033[0m'
- COLOR='\033[1;31m'
- ERROR='\033[0;31m'
-
- qualifier=
- which=release
-
- version=$1
- if [ $# -gt 1 ]; then
- qualifier="-${2}"
- which='pre-release'
- fi
-
- RELEASE=$version$qualifier
- branch="release-$RELEASE"
-
-fi
diff --git a/admin/release-deploy b/admin/release-deploy
deleted file mode 100755
index fe70284024fc..000000000000
--- a/admin/release-deploy
+++ /dev/null
@@ -1,31 +0,0 @@
-#!/bin/bash
-
-## Push local changes to github
-
-echo -e "${BOLD}${COLOR}CodeIgniter4 release deployment${NORMAL}"
-echo '-------------------------------'
-
-. admin/release-config
-
-echo -e "${BOLD}Merge release into develop${NORMAL}"
-git checkout develop
-git merge $branch
-git push origin develop
-git push ${CI_ORG}/CodeIgniter4 develop
-
-echo -e "${BOLD}Merge develop into master${NORMAL}"
-git checkout master
-git merge develop
-git push origin master
-git push ${CI_ORG}/CodeIgniter4 master
-
-echo -e "${BOLD}Pushing to the user guide repository${NORMAL}"
-cd ${CI_DIR}/dist/userguide
-git push origin master
-
-cd ${CI_DIR}
-
-#---------------------------------------------------
-# Phew!
-echo -e "${BOLD}Congratulations - we have liftoff${NORMAL}"
-echo "Don't forget to announce this release on the forum and on twitter!"
diff --git a/admin/release-notes.bb b/admin/release-notes.bb
deleted file mode 100755
index 6147a011cde1..000000000000
--- a/admin/release-notes.bb
+++ /dev/null
@@ -1,43 +0,0 @@
-CAUTION: THIS FILE IS A MIX OF BBCODE & MARKDOWN... NEEDS PROOFING
-
-CodeIgniter-4.0.0-alpha.1 launches today, after a lengthy build-up :)
-
-Huge shoutout to Lonnie Ezell for all of his hard work getting the vision
-and the core implementation in place!
-
-This is an early pre-release of 4.0.0. It is not suitable for production!
-
-There are several possible downloads, that you can see on the
-[url=https://github.com/codeigniter4/CodeIgniter4/releases/tag/v4.0.0-alpha.1]release page[/url])
-
-- the runnable versions as a
-[zip](https://github.com/codeigniter4/CodeIgniter4/releases/download/v4.0.0-alpha.1/CodeIgniter-4.0.0-alpha.1.zip) or a
-[tarball](https://github.com/codeigniter4/CodeIgniter4/releases/download/v4.0.0-alpha.1/CodeIgniter-4.0.0-alpha.1.tar.gz)/
-- the developer versions of the framework (with contributor components) as
-a [zip](https://github.com/codeigniter4/CodeIgniter4/archive/v4.0.0-alpha.1.zip) or a
-[tarball](https://github.com/codeigniter4/CodeIgniter4/archive/v4.0.0-alpha.1.tar.gz)/
-- and finally the [epub](https://github.com/codeigniter4/CodeIgniter4/releases/download/v4.0.0-alpha.1/CodeIgniter-4.0.0-alpha.1.epub) version of the user guide for this release.
-
-The release has all the major features in place, but there are still gaps
-and issues. See ...
-
-- [Bugs in the 4.0.0-alpha.1](https://github.com/codeigniter4/CodeIgniter4/issues?q=is%3Aopen+is%3Aissue+milestone%3A4.0.0-alpha)
-- [Things that we definitely want to see fixed before first proper release](https://github.com/codeigniter4/CodeIgniter4/issues?q=is%3Aopen+is%3Aissue+milestone%3A4.0.0)
-- [Features we would like to see in the initial proper release](), if possible, else the next update to it
-- [Features that we are consciously deferring to later](https://github.com/codeigniter4/CodeIgniter4/issues?q=is%3Aopen+is%3Aissue+milestone%3A4.1.0)
-
-What we need now is feedback from the community ...
-
-- What doesn't work well (raise a post in [url=https://forum.codeigniter.com/forum-27.html]CodeIgniter 4 Development[/url]).
-If you have found a bug, then by all means create a new issue on the repo.
-- What you think is missing (raise a post in [url=https://forum.codeigniter.com/forum-29.html]CodeIgniter 4 Feature Requests[/url])
-We will soon start the [url=https://forum.codeigniter.com/forum-33.html]candidate new feature[/url] posts,
-for those items in the feature requests forum that appear to have traction
-- Problems that you are having using the release (raise a post in [url=https://forum.codeigniter.com/forum-30.html]CodeIgniter 4 Support[/url])
-Do NOT create repo issues with support questions - we are using github for bug and work package tracking
-
-Do NOT post support questions or feature requests in response to this thread - those
-will be deleted. We are trying to make the best of the
-limited resources that we have!
-
-Thank you, and ENJOY!
\ No newline at end of file
diff --git a/admin/release-revert b/admin/release-revert
deleted file mode 100755
index 1c8a4eacd504..000000000000
--- a/admin/release-revert
+++ /dev/null
@@ -1,26 +0,0 @@
-#!/bin/bash
-
-## Revert local repos to pre-release state
-echo -e "${BOLD}${COLOR}CodeIgniter4 release revert${NORMAL}"
-echo '---------------------------'
-
-if [ $# -lt 2 ]; then
- echo "You forgot the magic word"
- exit 1
-fi
-if [ $1 != 'please' ]; then
- echo "What do you say?"
- exit 1
-fi
-
-. admin/release-config
-
-echo -e "${BOLD}Reverting the main repository${NORMAL}"
-git checkout master
-git pull -f ${CI_ORG}/CodeIgniter4 master
-git checkout develop
-git pull -f${CI_ORG}/CodeIgniter4 develop
-
-#---------------------------------------------------
-# Phew!
-echo -e "${BOLD}Congratulations - we have aborted liftoff${NORMAL}"
diff --git a/admin/release-userguide b/admin/release-userguide
deleted file mode 100755
index 6a8beb7bcbaf..000000000000
--- a/admin/release-userguide
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/bin/bash
-
-## Build user guide distributable
-
-# Setup variables
-. admin/release-config
-TARGET=dist/userguide
-cd $TARGET
-git checkout $branch
-
-#---------------------------------------------------
-echo -e "${BOLD}Build the user guide distributable${NORMAL}"
-
-cp -rf ${CI_DIR}/user_guide_src/build/html/* docs
-cp -rf ${CI_DIR}/user_guide_src/build/epub/CodeIgniter4.epub ./CodeIgniter${RELEASE}.epub
-
-#---------------------------------------------------
-# And finally, get ready for merging
-echo -e "${BOLD}Assemble the pieces...${NORMAL}"
-git add .
-git commit -S -m "Release ${RELEASE}"
-git checkout master
-git merge $branch
-
-cd $CI_DIR
-
-#---------------------------------------------------
-# Done for now
-echo -e "${BOLD}Distributable user guide ready..${NORMAL}"
diff --git a/admin/starter/tests/unit/HealthTest.php b/admin/starter/tests/unit/HealthTest.php
index 1df24df823a7..bae45560b788 100644
--- a/admin/starter/tests/unit/HealthTest.php
+++ b/admin/starter/tests/unit/HealthTest.php
@@ -11,7 +11,7 @@ final class HealthTest extends CIUnitTestCase
{
public function testIsDefinedAppPath(): void
{
- $this->assertTrue(defined('APPPATH'));
+ $this->assertDirectoryExists(APPPATH);
}
public function testBaseUrlHasBeenSet(): void
diff --git a/admin/sync-release-branches.php b/admin/sync-release-branches.php
new file mode 100644
index 000000000000..4f82a424bc65
--- /dev/null
+++ b/admin/sync-release-branches.php
@@ -0,0 +1,96 @@
+ [--push]
+ */
+function run_command(string $command): void
+{
+ echo sprintf("$ %s\n", $command);
+ system($command, $exitCode);
+
+ if ($exitCode !== 0) {
+ exit($exitCode);
+ }
+}
+
+function merge_branch(string $ref): void
+{
+ echo sprintf("$ git merge %s\n", $ref);
+ system(sprintf('git merge %s', escapeshellarg($ref)), $exitCode);
+
+ if ($exitCode !== 0) {
+ echo sprintf("\nMerging %s failed. Resolve the conflicts and run \"git merge --continue\",\n", $ref);
+ echo "then push with \"git push upstream HEAD\".\n";
+
+ exit(1);
+ }
+}
+
+chdir(__DIR__ . '/..');
+
+$args = array_slice($argv, 1);
+$options = array_values(array_filter($args, static fn (string $arg): bool => str_starts_with($arg, '--')));
+$params = array_values(array_diff($args, $options));
+
+$isBranch = static fn (string $name): bool => preg_match('/\A(?:develop|master|\d+\.\d+)\z/', $name) === 1;
+
+if (
+ count($params) !== 2
+ || ! $isBranch($params[0])
+ || ! $isBranch($params[1])
+ || $params[0] === $params[1]
+ || array_diff($options, ['--push']) !== []
+) {
+ echo sprintf("Usage: php %s [--push]\n", $argv[0]);
+ echo sprintf("E.g.,: php %s 4.8 develop --push # merges develop into 4.8\n", $argv[0]);
+ echo sprintf(" php %s develop master --push # merges master into develop\n", $argv[0]);
+ echo "Checks out , merges upstream/ and then upstream/ into it.\n";
+ echo "Without --push, the result is left unpushed for review.\n";
+
+ exit(1);
+}
+
+[$target, $source] = $params;
+$push = in_array('--push', $options, true);
+
+exec('git remote get-url upstream 2>&1', $remoteOutput, $exitCode);
+
+if ($exitCode !== 0) {
+ echo "The \"upstream\" remote is not configured.\n";
+
+ exit(1);
+}
+
+exec('git status --porcelain 2>&1', $statusOutput, $exitCode);
+
+if ($exitCode !== 0 || $statusOutput !== []) {
+ echo "The working tree is not clean. Commit or stash your changes first.\n";
+
+ exit(1);
+}
+
+run_command('git fetch upstream');
+
+exec(sprintf('git rev-parse --verify --quiet refs/heads/%s 2>&1', escapeshellarg($target)), $verifyOutput, $exitCode);
+
+if ($exitCode === 0) {
+ run_command(sprintf('git switch %s', escapeshellarg($target)));
+} else {
+ run_command(sprintf('git switch -c %s upstream/%s', escapeshellarg($target), escapeshellarg($target)));
+}
+
+merge_branch("upstream/{$target}");
+merge_branch("upstream/{$source}");
+
+if ($push) {
+ run_command('git push upstream HEAD');
+
+ echo sprintf("Merged upstream/%s into %s and pushed to upstream.\n", $source, $target);
+} else {
+ echo sprintf("Merged upstream/%s into %s. Review the result, then run \"git push upstream HEAD\".\n", $source, $target);
+}
diff --git a/admin/update-upgrade-guide.php b/admin/update-upgrade-guide.php
new file mode 100644
index 000000000000..c80f0a75b635
--- /dev/null
+++ b/admin/update-upgrade-guide.php
@@ -0,0 +1,197 @@
+ [--dry-run]
+ */
+function previous_release_tag(string $version): ?string
+{
+ // Not `git describe`: the base must be the highest stable tag below the
+ // given version regardless of the checked out branch, and prerelease
+ // tags (e.g. v4.0.0-rc.4) must be excluded.
+ exec("git tag -l 'v*' 2>&1", $tags, $exitCode);
+
+ if ($exitCode !== 0) {
+ return null;
+ }
+
+ $tags = array_filter(
+ $tags,
+ static fn (string $tag): bool => preg_match('/\Av\d+\.\d+\.\d+\z/', $tag) === 1
+ && version_compare($tag, "v{$version}", '<'),
+ );
+
+ if ($tags === []) {
+ return null;
+ }
+
+ usort($tags, version_compare(...));
+
+ return end($tags);
+}
+
+/**
+ * @param list $items
+ */
+function bullets(array $items): string
+{
+ return implode("\n", array_map(static fn (string $item): string => "- {$item}", $items));
+}
+
+chdir(__DIR__ . '/..');
+
+$args = array_slice($argv, 1);
+$options = array_values(array_filter($args, static fn (string $arg): bool => str_starts_with($arg, '--')));
+$params = array_values(array_diff($args, $options));
+
+if (count($params) !== 1 || preg_match('/\A\d+\.\d+\.\d+\z/', $params[0]) !== 1) {
+ echo sprintf("Usage: php %s [--dry-run]\n", $argv[0]);
+ echo sprintf("E.g.,: php %s 4.7.5\n", $argv[0]);
+
+ exit(1);
+}
+
+$version = $params[0];
+$dryRun = in_array('--dry-run', $options, true);
+
+$upgradePath = sprintf('./user_guide_src/source/installation/upgrade_%s.rst', str_replace('.', '', $version));
+
+if (! is_file($upgradePath)) {
+ echo sprintf("%s is not found. Run \"php admin/create-new-changelog.php\" first.\n", $upgradePath);
+
+ exit(1);
+}
+
+$baseTag = previous_release_tag($version);
+
+if ($baseTag === null) {
+ echo "Could not determine the previous release tag. Are the tags fetched?\n";
+
+ exit(1);
+}
+
+echo sprintf("Checking the project space files changed since %s.\n", $baseTag);
+
+// Paths that are not part of the project space. Keep in sync with the
+// distribution repos: `tests/` is not used there, see "admin/starter/tests".
+$excludes = [
+ ':!.github/', ':!admin/', ':!changelogs/', ':!contributing/',
+ ':!system/', ':!tests/', ':!user_guide_src/', ':!utils/',
+ ':!*.json', ':!*.xml', ':!*.dist', ':!rector.php', ':!structarmed.php',
+ ':!phpstan*', ':!psalm*', ':!.php-cs-fixer.*', ':!LICENSE', ':!CHANGELOG.md',
+];
+
+$command = sprintf(
+ 'git diff --name-status %s -- . %s 2>&1',
+ escapeshellarg($baseTag),
+ implode(' ', array_map(escapeshellarg(...), $excludes)),
+);
+exec($command, $diffOutput, $exitCode);
+
+if ($exitCode !== 0) {
+ echo "Failed to diff against the latest release tag:\n";
+ echo implode("\n", $diffOutput) . "\n";
+
+ exit(1);
+}
+
+$allChanges = [];
+$configChanged = [];
+$configNew = [];
+
+foreach ($diffOutput as $line) {
+ $fields = preg_split('/\t/', $line);
+
+ if ($fields === false || count($fields) < 2) {
+ continue;
+ }
+
+ $status = $fields[0][0];
+ $path = end($fields);
+
+ $allChanges[] = $status === 'D' ? "{$path} (deleted)" : $path;
+
+ if (! str_starts_with($path, 'app/Config/')) {
+ continue;
+ }
+
+ if ($status === 'A') {
+ $configNew[] = $path;
+ } elseif ($status !== 'D') {
+ $configChanged[] = $path;
+ }
+}
+
+sort($allChanges);
+sort($configChanged);
+sort($configNew);
+
+// Builds the "Config" section contents.
+$configContent = bullets($configChanged);
+
+if ($configNew !== []) {
+ $configContent .= ($configContent === '' ? '' : "\n\n") . "These files are new in this release:\n\n" . bullets($configNew);
+}
+
+if ($configContent === '') {
+ $configContent = '- No config files were changed in this release.';
+}
+
+// Builds the "All Changes" section contents.
+$allChangesContent = $allChanges === []
+ ? '- No project files were changed in this release.'
+ : bullets($allChanges);
+
+if ($dryRun) {
+ echo "\nConfig\n------\n\n{$configContent}\n";
+ echo "\nAll Changes\n===========\n\n{$allChangesContent}\n";
+
+ exit(0);
+}
+
+$rst = file_get_contents($upgradePath);
+
+// Fills the "Config" section, only if it still has the placeholder. Entries
+// may have been added ahead of time for changes that need discussion, and
+// those must not be overwritten.
+if (preg_match('/^Config\n------\n\n(.*?)(?=^[^\s-][^\n]*\n(?:-+|=+)$|\z)/msu', $rst, $matches) !== 1) {
+ echo "Could not find the \"Config\" section in {$upgradePath}. Update it manually.\n";
+} elseif (trim($matches[1]) === '- @TODO') {
+ $rst = str_replace("Config\n------\n\n{$matches[1]}", "Config\n------\n\n{$configContent}\n\n", $rst);
+
+ echo "The \"Config\" section was updated. Add notes to each entry as needed.\n";
+} else {
+ $mentioned = [];
+
+ if (preg_match_all('~app/Config/[\w./-]+~u', $matches[1], $found) > 0) {
+ $mentioned = $found[0];
+ }
+
+ $missing = array_diff([...$configChanged, ...$configNew], $mentioned);
+
+ if ($missing === []) {
+ echo "The \"Config\" section already has content and mentions all changed config files. It was not modified.\n";
+ } else {
+ echo "The \"Config\" section already has content, so it was not modified.\n";
+ echo "Merge these missing entries manually:\n" . bullets(array_values($missing)) . "\n";
+ }
+}
+
+// Fills the "All Changes" section, replacing any previous list.
+$pattern = '/^(All Changes\n===========\n\n.*?:\n\n)(.*?)(?=^\*+$|\z)/msu';
+$updated = preg_replace($pattern, "\$1{$allChangesContent}\n", $rst, 1, $count);
+
+if ($count !== 1) {
+ echo "Could not find the \"All Changes\" section in {$upgradePath}. Update it manually.\n";
+
+ exit(1);
+}
+
+file_put_contents($upgradePath, $updated);
+
+echo sprintf("The \"All Changes\" section of %s was updated.\n", $upgradePath);
+echo "Remember to remove the section titles that have no items.\n";
diff --git a/admin/workflow.md b/admin/workflow.md
deleted file mode 100644
index 85e38e018ec2..000000000000
--- a/admin/workflow.md
+++ /dev/null
@@ -1,92 +0,0 @@
-# Workflow
-
-The main repo has two branches of interest: "master" (stable) and "develop" (in progress).
-There might be other feature branches, but they are not relevant to this process.
-
-Once "develop" is ready for a new release, the general workflow is to
-
-release prep (`admin/release `)...
-- create a "release" branch from develop
-- update version dependencies or constants
-- generate version(s) of the user guide
-- move or ignore stuff, distinguishing release from development
-- test that all is as it should be
-- merge the release branch into "master" & "develop"
-
-After these have been vetted ...
-- push the release(s) to github (`admin/release-deploy `)
-- **manually** create the release & tag it on GitHub, based on master
- Include any supplementary binaries as part of releases.
-- Confirm the GitHub release action successfully deploys `appstarter` and `framework`
-- **manually** post a sticky announcement thread on the forum
-- **manually** tweet the release announcement
-
-## Assumptions
-
-You (a maintainer) have forked the main CodeIgniter4 repo,
-and the git alias `origin`, in your local clone, refers to your fork.
-The `config` script defines an additional alias, `CI_ORG`, which refers to the
-CodeIgniter 4 organization on github.
-This separation keeps the release branch isolated for any testing you want to do.
-
-The `develop` branch of the main repo should be "clean", and ready for
-a release. This means that the changelog and upgrading instructions
-have been suitably edited.
-
-This script is not intended to deal with hotfixes, i.e. PRs against
-`master` that need to also be merged into `develop`, probably
-as part of a bug fix minor release.
-
-## GitHub Action
-
-There is an action defined to run on any release publish event:
-**.github/workflows/deploy.yml**. This will cascade any release made from
-the main repo to the distribution repos, `appstarter` and `framework`. In order
-for the action to authenticate you must create a Personal Access Token and add it
-as a repo secret `ACCESS_TOKEN`. It is recommended that the PAT be to a secure bot
-account with organization access that is dedicated for this purpose.
-
-If for some reason a release needs to be made that should not cascade the easiest
-solution is to disable repo actions temporarily.
-
-## Usage
-
-Inside a shell prompt, in the project root:
-
- `admin/release version [qualifier]`
-
-Nothing is pushed to the repo at this point. You have a chance to vet
-the repository changes.
-
-The "version" should follow semantic versioning, e.g. `4.0.6`, and the
-version number should be higher than the current released one.
-
-The "qualifier" argument is a suffix to add to the version
-for a pre-release, e.g. `beta.2` or `rc.41`.
-
-Examples:
-- `admin/release 4.0.0 alpha.1` would prepare the "4.0.0-alpha.1" pre-release PR
-- `admin/release 4.0.0` would prepare the "4.0.0" release PR
-- `admin/release peanut butter banana` would complain and tell you to read these directions
-
-Once you have vetted the `dist` folder inside your local repo, you
-can merge & push everything with
-
- `admin/release-deploy please`
-
-On github.com, create an appropriate release (or pre-release),
-with any optional files from the root of `dist`.
-
-## Release notes
-
-On launch of a new release, a release notes post should be made in the
-announcements subforum. The planned text for it (so it can be previewed
-by admins) is in `admin/release_notes.bb`.
-
-## Audience
-
-These scripts are intended for use by framework maintainers,
-i.e. someone with commit rights on the CI4 repositories.
-
-You will be prompted for your github credentials and
-GPG-signing key as appropriate.
diff --git a/app/Config/View.php b/app/Config/View.php
index b52d980dc39f..427a22e56558 100644
--- a/app/Config/View.php
+++ b/app/Config/View.php
@@ -30,7 +30,7 @@ class View extends BaseView
* { title|esc(js) }
* { created_on|date(Y-m-d)|esc(attr) }
*
- * @var array
+ * @var array
*/
public $filters = [];
@@ -43,6 +43,14 @@ class View extends BaseView
*/
public $plugins = [];
+ /**
+ * When true, Parser conditionals may only contain variables, literals,
+ * arithmetic, comparison and logical operators, and parentheses. Enable
+ * this when less-trusted users can edit Parser templates. Can be overridden per
+ * call with the `restrictConditionals` render option.
+ */
+ public bool $restrictParserConditionals = false;
+
/**
* View Decorators are class methods that will be run in sequence to
* have a chance to alter the generated output just prior to caching
diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php
index ab45a7710be9..6f86f9254ed1 100644
--- a/app/Controllers/BaseController.php
+++ b/app/Controllers/BaseController.php
@@ -3,6 +3,8 @@
namespace App\Controllers;
use CodeIgniter\Controller;
+use CodeIgniter\HTTP\CLIRequest;
+use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Psr\Log\LoggerInterface;
@@ -28,6 +30,8 @@ abstract class BaseController extends Controller
// protected $session;
/**
+ * @param CLIRequest|IncomingRequest $request
+ *
* @return void
*/
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
diff --git a/composer.json b/composer.json
index 8e0990839d9f..9aa9641b8e6d 100644
--- a/composer.json
+++ b/composer.json
@@ -17,19 +17,19 @@
"psr/log": "^3.0"
},
"require-dev": {
- "boundwize/structarmed": "0.14.10",
+ "boundwize/structarmed": "^0.18",
"codeigniter/phpstan-codeigniter": "^2.1",
"fakerphp/faker": "^1.24",
"kint-php/kint": "^6.1",
"mikey179/vfsstream": "^1.6.12",
"nexusphp/tachycardia": "^2.0",
"phpstan/extension-installer": "^1.4",
- "phpstan/phpstan": "^2.2.1",
+ "phpstan/phpstan": "^2.2.15",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpcov": "^9.0.2 || ^10.0",
"phpunit/phpunit": "^10.5.16 || ^11.2",
"predis/predis": "^3.0",
- "rector/rector": "2.5.4",
+ "rector/rector": "2.7.0",
"shipmonk/phpstan-baseline-per-identifier": "^2.0"
},
"replace": {
diff --git a/contributing/README.md b/contributing/README.md
index 40796df376b3..981b69169aca 100644
--- a/contributing/README.md
+++ b/contributing/README.md
@@ -14,3 +14,4 @@ if you want to take the lead on one of them.
- [Contributor Covenant Code of Conduct](../CODE_OF_CONDUCT.md)
- [Reporting a Bug](./bug_report.md)
- [Sending a Pull Request](./pull_request.md)
+- [Configuring Upstream and Rebasing](./workflow.md#configuring-upstream-and-rebasing)
diff --git a/contributing/pull_request.md b/contributing/pull_request.md
index dbea94ccbd6f..844d62046cfa 100644
--- a/contributing/pull_request.md
+++ b/contributing/pull_request.md
@@ -192,11 +192,16 @@ specific examples to pay attention to:
Your PRs need to be mergeable and GPG-signed before they will be
considered.
-We suggest that you synchronize your repository's `develop` branch with
-that in the main repository, and then your feature branch and your
-develop branch, before submitting a PR. You will need to resolve any
-merge conflicts introduced by changes incorporated since you started
-working on your contribution.
+Before submitting a PR, rebase your contribution branch onto the latest
+appropriate branch from the main repository. Bug fixes should be rebased onto
+`upstream/develop`, while features and enhancements should be rebased onto the
+applicable `upstream/4.x` branch. See
+[Configuring Upstream and Rebasing](./workflow.md#configuring-upstream-and-rebasing)
+for the commands and conflict-resolution steps.
+
+Do not merge the target branch into your contribution branch. This may add merge
+commits that make the PR and project history harder to follow. Rebasing keeps
+the commit history linear and easier to review.
### Branching
@@ -328,10 +333,11 @@ GitHub means "making a copy of that repo to your account" and "cloning" means
tests yet, please create one.
- `> vendor/bin/phpunit tests/system/path/to/file/you/modified`
- Make sure the tests pass to have a higher chance of merging.
-11. [Push](https://docs.github.com/en/github/using-git/pushing-commits-to-a-remote-repository) your contribution branch to your fork.
+11. [Rebase your branch](./workflow.md#configuring-upstream-and-rebasing) onto the appropriate upstream branch.
+12. [Push](https://docs.github.com/en/github/using-git/pushing-commits-to-a-remote-repository) your contribution branch to your fork.
- `> git push origin `
-12. Send a [pull request](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork).
-13. Label your pull request with the appropriate label if you can.
+13. Send a [pull request](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork).
+14. Label your pull request with the appropriate label if you can.
See [Contribution workflow](./workflow.md) for Git workflow details.
@@ -340,8 +346,8 @@ the team will respond. If your change fails to meet the guidelines, it will be
rejected or feedback will be provided to help you improve it.
Once the maintainer handling your pull request is satisfied with it, they will
-approve the pull request and merge it into the "develop" branch. Your patch will
-now be part of the next release!
+approve and merge the pull request into the appropriate target branch. Your
+patch will now be part of the next release!
## Translating System Messages
diff --git a/contributing/workflow.md b/contributing/workflow.md
index a294cc9e485c..2bb3e2d7e127 100644
--- a/contributing/workflow.md
+++ b/contributing/workflow.md
@@ -67,39 +67,72 @@ Clone your repository, leaving a local folder for you to work with:
> git clone ORIGIN_URL
```
-## Syncing Your Repository
+## Configuring Upstream and Rebasing
-Within your local repository, Git will have created an alias,
-**origin**, for the GitHub repository it is bound to. You want to create
-an alias for the shared repository as well, so that you can "synch" the
-two, making sure that your repository includes any other contributions
-that have been merged by us into the shared repo:
+Git names your fork `origin` when you clone it. Add the main CodeIgniter4
+repository as `upstream` so that you can fetch changes made by other
+contributors. You only need to add this remote once:
```console
-> git remote add upstream UPSTREAM_URL
+> git remote add upstream https://github.com/codeigniter4/CodeIgniter4.git
+> git remote -v
```
-Then synchronizing is done by pulling from us and pushing to you. This
-is normally done locally, so that you can resolve any merge conflicts.
-For instance, to synchronize **develop** branches:
+If `upstream` already exists but uses the wrong URL, update it instead:
+
+```console
+> git remote set-url upstream https://github.com/codeigniter4/CodeIgniter4.git
+```
+
+Fetch the latest upstream branches before rebasing:
```console
> git fetch upstream
-> git switch develop
-> git merge upstream/develop
-> git push origin develop
```
-You might get merge conflicts when you merge. It is your
-responsibility to resolve those locally, so that you can continue
-collaborating with the shared repository. Basically, the shared
-repository is updated in the order that contributions are merged into
-it, not in the order that they might have been submitted. If two PRs
-update the same piece of code, then the first one to be merged will take
-precedence, even if it causes problems for other contributions.
+Rebase bug-fix branches onto `upstream/develop`:
+
+```console
+> git switch your-branch
+> git rebase upstream/develop
+```
+
+Features and enhancements target the next minor-version branch. Replace `4.x`
+with the branch used by your pull request:
+
+```console
+> git switch your-branch
+> git rebase upstream/4.x
+```
+
+> [!IMPORTANT]
+> Do not merge the upstream target branch into your contribution branch. A merge
+> may add merge commits that make the pull request and project history harder to
+> follow. Rebasing keeps the commit history linear and easier to review.
+
+Make sure local work is committed or stashed before rebasing. If Git reports a
+conflict, edit the affected files, mark them as resolved, and continue:
+
+```console
+> git add path/to/resolved-file
+> git rebase --continue
+```
+
+Repeat these commands until the rebase is complete. To cancel the rebase and
+return the branch to its previous state, run:
+
+```console
+> git rebase --abort
+```
+
+If the branch was already pushed to your fork, update it safely after rebasing:
+
+```console
+> git push --force-with-lease origin your-branch
+```
-It is a good idea to synchronize repositories when the shared one
-changes.
+Use `--force-with-lease` rather than `--force` so that Git does not overwrite
+remote changes that you have not fetched.
## Branching Revisited
@@ -250,25 +283,9 @@ are working in.
At some point, you will decide that your feature branch is complete, or
that it could benefit from a review by fellow developers.
-> [!NOTE]
-> Remember to sync your local repo with the shared one before pushing!
-It is a lot easier to resolve conflicts at this stage.
-
-Synchronize your repository:
-
-```console
-> git fetch upstream
-> git switch develop
-> git merge upstream/develop
-> git push origin develop
-```
-
-Bring your feature branch up to date:
-
-```console
-> git switch fix/issue-123
-> git rebase upstream/develop
-```
+Before pushing, follow
+[Configuring Upstream and Rebasing](#configuring-upstream-and-rebasing) to bring
+your contribution branch up to date and resolve any conflicts.
And finally push your local branch to your GitHub repository:
@@ -325,34 +342,15 @@ If you are asked for changes in the review, commit the fix in your branch and
push it to GitHub again.
If the __"develop"__ or next minor version branch, e.g. __"4.6"__, progresses
-and conflicts arise that prevent merging, or if you are asked to *rebase*,
-do the following:
-
-Synchronize your repository:
-
-```console
-> git fetch upstream
-> git switch develop
-> git merge upstream/develop
-> git push origin develop
-```
-
-(Optional) Create a new branch as a backup, just in case:
+and conflicts arise that prevent merging, or if you are asked to *rebase*, you
+may first create a backup branch:
```console
> git branch fix/problem123.bk fix/problem123
```
-Bring your feature branch up to date:
-
-```console
-> git switch fix/problem123
-> git rebase upstream/develop
-```
-
-You might get conflicts when you rebase. It is your
-responsibility to resolve those locally, so that you can continue
-collaborating with the shared repository.
+Then follow
+[Configuring Upstream and Rebasing](#configuring-upstream-and-rebasing).
Occasionally, the Composer packages for development may be updated. Run the
following command to use the latest packages:
@@ -380,13 +378,10 @@ Copy the IDs of any commits you made that you want to keep:
> git log
```
-Update your __"4.6"__ branch:
+Fetch the latest upstream branches:
```console
> git fetch upstream
-> git switch 4.6
-> git merge upstream/4.6
-> git push origin 4.6
```
(Optional) Create a new branch as a backup, just in case:
@@ -398,7 +393,7 @@ Update your __"4.6"__ branch:
Rebase your PR branch from `develop` onto __"4.6"__:
```console
-> git rebase --onto 4.6 develop feat-abc
+> git rebase --onto upstream/4.6 develop feat-abc
```
Force push.
diff --git a/phpdoc.dist.xml b/phpdoc.dist.xml
index 4ec68a4bfaea..0f25b06d5f66 100644
--- a/phpdoc.dist.xml
+++ b/phpdoc.dist.xml
@@ -10,7 +10,7 @@
api/cache/
-
+ system
diff --git a/phpstan.dist.neon b/phpstan.dist.neon
index 2e05af3201a5..7d6f2d81a709 100644
--- a/phpstan.dist.neon
+++ b/phpstan.dist.neon
@@ -2,7 +2,9 @@ includes:
- utils/phpstan-baseline/loader.neon
parameters:
- phpVersion: 80200
+ phpVersion:
+ min: 80200
+ max: 80599
tmpDir: build/phpstan
level: 6
bootstrapFiles:
@@ -18,7 +20,6 @@ parameters:
- app/Views/errors/html/*
- system/Commands/Generators/Views/*
- system/Debug/Toolbar/Views/toolbar.tpl.php
- - system/Images/Handlers/GDHandler.php
- system/Test/Mock/MockCommon.php
- system/ThirdParty/*
- system/Validation/Views/single.php
diff --git a/psalm.xml b/psalm.xml
index d6033313a621..6b9ccb30f513 100644
--- a/psalm.xml
+++ b/psalm.xml
@@ -30,6 +30,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/rector.php b/rector.php
index c3a1b22ad716..2698aec9f098 100644
--- a/rector.php
+++ b/rector.php
@@ -13,35 +13,25 @@
use Rector\Caching\ValueObject\Storage\FileCacheStorage;
use Rector\CodeQuality\Rector\BooleanNot\NegatedAndsToPositiveOrsRector;
-use Rector\CodeQuality\Rector\Empty_\SimplifyEmptyCheckOnEmptyArrayRector;
+use Rector\CodeQuality\Rector\ClassMethod\LocallyCalledStaticMethodToNonStaticRector;
use Rector\CodeQuality\Rector\FuncCall\CompactToVariablesRector;
-use Rector\CodeQuality\Rector\FunctionLike\SimplifyUselessVariableRector;
use Rector\CodeQuality\Rector\Isset_\IssetOnPropertyObjectToPropertyExistsRector;
-use Rector\CodeQuality\Rector\Ternary\TernaryEmptyArrayArrayDimFetchToCoalesceRector;
use Rector\CodingStyle\Rector\ClassMethod\FuncGetArgsToVariadicParamRector;
use Rector\CodingStyle\Rector\ClassMethod\MakeInheritedMethodVisibilitySameAsParentRector;
-use Rector\CodingStyle\Rector\FuncCall\CountArrayToEmptyArrayComparisonRector;
use Rector\CodingStyle\Rector\FuncCall\VersionCompareFuncCallToConstantRector;
use Rector\Config\RectorConfig;
-use Rector\DeadCode\Rector\ClassMethod\RemoveDuplicatedReturnSelfDocblockRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedConstructorParamRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedPrivateMethodRector;
use Rector\DeadCode\Rector\MethodCall\RemoveNullArgOnNullDefaultParamRector;
-use Rector\EarlyReturn\Rector\Foreach_\ChangeNestedForeachIfsToEarlyContinueRector;
-use Rector\EarlyReturn\Rector\If_\ChangeIfElseValueAssignToEarlyReturnRector;
-use Rector\EarlyReturn\Rector\If_\RemoveAlwaysElseRector;
-use Rector\EarlyReturn\Rector\Return_\PreparedValueToEarlyReturnRector;
+use Rector\DeadCode\Rector\Property\RemoveDefaultValueFromAssignedPropertyRector;
use Rector\Php70\Rector\FuncCall\RandomFunctionRector;
use Rector\Php71\Rector\FuncCall\RemoveExtraParametersRector;
use Rector\Php80\Rector\Class_\ClassPropertyAssignToConstructorPromotionRector;
-use Rector\Php81\Rector\FuncCall\NullToStrictStringFuncCallArgRector;
use Rector\PHPUnit\CodeQuality\Rector\Class_\YieldDataProviderRector;
-use Rector\PHPUnit\CodeQuality\Rector\FuncCall\AssertFuncCallToPHPUnitAssertRector;
use Rector\PHPUnit\CodeQuality\Rector\StmtsAwareInterface\DeclareStrictTypesTestsRector;
use Rector\Privatization\Rector\Class_\FinalizeTestCaseClassRector;
use Rector\Privatization\Rector\Property\PrivatizeFinalClassPropertyRector;
use Rector\Renaming\Rector\ConstFetch\RenameConstantRector;
-use Rector\Strict\Rector\Empty_\DisallowedEmptyRuleFixerRector;
use Rector\TypeDeclaration\Rector\ArrowFunction\AddArrowFunctionReturnTypeRector;
use Rector\TypeDeclaration\Rector\ClassMethod\AddMethodCallBasedStrictParamTypeRector;
use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector;
@@ -50,13 +40,14 @@
use Rector\TypeDeclaration\Rector\Function_\AddFunctionVoidReturnTypeWhereNoReturnRector;
use Rector\TypeDeclaration\Rector\Property\TypedPropertyFromAssignsRector;
use Rector\TypeDeclaration\Rector\StmtsAwareInterface\DeclareStrictTypesRector;
+use Rector\TypeDeclaration\Rector\StmtsAwareInterface\SafeDeclareStrictTypesRector;
use Utils\Rector\PassStrictParameterToFunctionParameterRector;
use Utils\Rector\RemoveErrorSuppressInTryCatchStmtsRector;
use Utils\Rector\UnderscoreToCamelCaseVariableNameRector;
return RectorConfig::configure()
->withPhpSets(php82: true)
- ->withPreparedSets(deadCode: true, instanceOf: true, phpunitCodeQuality: true)
+ ->withPreparedSets(deadCode: true, codeQuality: true, instanceOf: true, phpunitCodeQuality: true)
->withComposerBased(phpunit: true)
->withParallel(120, 8, 10)
->withCache(
@@ -77,13 +68,14 @@
__DIR__ . '/phpstan-bootstrap.php',
])
->withPHPStanConfigs([
- __DIR__ . '/phpstan.dist.neon',
__DIR__ . '/vendor/codeigniter/phpstan-codeigniter/extension.neon',
__DIR__ . '/vendor/phpstan/phpstan-strict-rules/rules.neon',
__DIR__ . '/vendor/shipmonk/phpstan-baseline-per-identifier/extension.neon',
+ __DIR__ . '/phpstan.dist.neon',
])
// is there a file you need to skip?
->withSkip([
+ __DIR__ . '/structarmed-baseline.php',
__DIR__ . '/system/Debug/Toolbar/Views/toolbar.tpl.php',
__DIR__ . '/system/ThirdParty',
__DIR__ . '/tests/system/Config/fixtures',
@@ -103,8 +95,12 @@
__DIR__ . '/system/HTTP/Response.php',
],
+ // Keep property defaults for backward compatibility.
+ RemoveDefaultValueFromAssignedPropertyRector::class,
+
// Exclude test file because `is_cli()` is mocked and Rector might remove needed parameters.
RemoveExtraParametersRector::class => [
+ __DIR__ . '/tests/system/CLI/CLITest.php',
__DIR__ . '/tests/system/Debug/ToolbarTest.php',
],
@@ -149,19 +145,11 @@
__DIR__ . '/system/HTTP/SiteURI.php',
],
- // Unnecessary (string) is inserted
- NullToStrictStringFuncCallArgRector::class,
-
CompactToVariablesRector::class,
// possibly isset() on purpose, on updated Config classes property across versions
IssetOnPropertyObjectToPropertyExistsRector::class,
- AssertFuncCallToPHPUnitAssertRector::class => [
- // use $this inside static closure
- __DIR__ . '/tests/system/AutoReview/FrameworkCodeTest.php',
- ],
-
// some tests extended by other tests
FinalizeTestCaseClassRector::class,
@@ -178,26 +166,18 @@
// to be applied in separate PRs to ease review
NegatedAndsToPositiveOrsRector::class,
- RemoveDuplicatedReturnSelfDocblockRector::class,
+ SafeDeclareStrictTypesRector::class,
+ LocallyCalledStaticMethodToNonStaticRector::class,
])
// auto import fully qualified class names
->withImportNames()
->withRules([
DeclareStrictTypesRector::class,
UnderscoreToCamelCaseVariableNameRector::class,
- SimplifyUselessVariableRector::class,
- RemoveAlwaysElseRector::class,
PassStrictParameterToFunctionParameterRector::class,
- CountArrayToEmptyArrayComparisonRector::class,
- ChangeNestedForeachIfsToEarlyContinueRector::class,
- ChangeIfElseValueAssignToEarlyReturnRector::class,
- PreparedValueToEarlyReturnRector::class,
RemoveErrorSuppressInTryCatchStmtsRector::class,
FuncGetArgsToVariadicParamRector::class,
MakeInheritedMethodVisibilitySameAsParentRector::class,
- SimplifyEmptyCheckOnEmptyArrayRector::class,
- TernaryEmptyArrayArrayDimFetchToCoalesceRector::class,
- DisallowedEmptyRuleFixerRector::class,
PrivatizeFinalClassPropertyRector::class,
VersionCompareFuncCallToConstantRector::class,
AddClosureVoidReturnTypeWhereNoReturnRector::class,
@@ -210,5 +190,4 @@
->withConfiguredRule(RenameConstantRector::class, [
'FILTER_DEFAULT' => 'FILTER_UNSAFE_RAW',
])
- ->withCodeQualityLevel(61)
->reportUnusedSkips();
diff --git a/structarmed-baseline.php b/structarmed-baseline.php
new file mode 100644
index 000000000000..eede90ce7048
--- /dev/null
+++ b/structarmed-baseline.php
@@ -0,0 +1,20 @@
+ 'base_classes.must_be_abstract',
+ 'message' => 'Extended class [CodeIgniter\Config\BaseService] must be declared abstract or instantiated',
+ 'file' => 'system/Config/BaseService.php',
+ 'class' => 'CodeIgniter\Config\BaseService',
+ 'layer' => 'Source',
+ ],
+ [
+ 'rule' => 'base_classes.must_be_abstract',
+ 'message' => 'Extended class [CodeIgniter\Debug\Toolbar\Collectors\BaseCollector] must be declared abstract or instantiated',
+ 'file' => 'system/Debug/Toolbar/Collectors/BaseCollector.php',
+ 'class' => 'CodeIgniter\Debug\Toolbar\Collectors\BaseCollector',
+ 'layer' => 'Source',
+ ],
+];
diff --git a/structarmed.php b/structarmed.php
index e1027f578ce2..990f039601bc 100644
--- a/structarmed.php
+++ b/structarmed.php
@@ -11,33 +11,38 @@
* the LICENSE file that was distributed with this source code.
*/
+use Boundwize\StructArmed\Architecture;
+use Boundwize\StructArmed\Preset\Preset;
+use Boundwize\StructArmed\Preset\Presets\CodeQualityPreset;
+use Boundwize\StructArmed\Preset\Presets\Psr4Preset;
+use Boundwize\StructArmed\Rule\Rules\Class_\ExtendedClassMustBeAbstractOrInstantiatedRule;
+use Boundwize\StructArmed\Rule\Rules\Function_\MustHaveReturnTypeFunctionRule;
use CodeIgniter\Cache\ResponseCache;
+use CodeIgniter\Database\BaseResult;
+use CodeIgniter\DataCaster\DataCaster;
+use CodeIgniter\DataConverter\DataConverter;
+use CodeIgniter\Entity\Cast\CastInterface;
+use CodeIgniter\Entity\Cast\URICast;
+use CodeIgniter\Entity\Entity;
+use CodeIgniter\Entity\Exceptions\CastException;
use CodeIgniter\HTTP\CLIRequest;
+use CodeIgniter\HTTP\DownloadResponse;
use CodeIgniter\HTTP\Header;
use CodeIgniter\HTTP\IncomingRequest;
+use CodeIgniter\HTTP\RedirectResponse;
+use CodeIgniter\HTTP\Response;
use CodeIgniter\HTTP\ResponseInterface;
-use CodeIgniter\DataCaster\DataCaster;
-use CodeIgniter\Entity\Cast\CastInterface;
-use CodeIgniter\Entity\Exceptions\CastException;
-use CodeIgniter\DataConverter\DataConverter;
-use CodeIgniter\Entity\Entity;
-use CodeIgniter\Entity\Cast\URICast;
+use CodeIgniter\HTTP\ResponseTrait;
+use CodeIgniter\HTTP\SSEResponse;
+use CodeIgniter\HTTP\StreamResponse;
use CodeIgniter\HTTP\URI;
use CodeIgniter\Log\Handlers\ChromeLoggerHandler;
-use CodeIgniter\Security\CheckPhpIni;
-use CodeIgniter\View\Table;
-use CodeIgniter\Database\BaseResult;
-use CodeIgniter\View\Plugins;
-use CodeIgniter\HTTP\ResponseTrait;
use CodeIgniter\Pager\PagerInterface;
-use CodeIgniter\HTTP\Response;
-use CodeIgniter\HTTP\RedirectResponse;
-use CodeIgniter\HTTP\DownloadResponse;
+use CodeIgniter\Security\CheckPhpIni;
use CodeIgniter\Validation\Validation;
+use CodeIgniter\View\Plugins;
use CodeIgniter\View\RendererInterface;
-use Boundwize\StructArmed\Architecture;
-use Boundwize\StructArmed\Preset\Preset;
-use Boundwize\StructArmed\Preset\Presets\Psr4Preset;
+use CodeIgniter\View\Table;
return Architecture::define()
->skip([
@@ -45,9 +50,19 @@
__DIR__ . '/tests/system/Config/fixtures',
],
__DIR__ . '/system/ThirdParty',
+ CodeQualityPreset::ANONYMOUS_FUNCTIONS_MUST_BE_STATIC => [
+ __DIR__ . '/system/View/Cells/Cell.php',
+ ],
])
+ ->baseline(__DIR__ . '/structarmed-baseline.php')
->cacheDirectory(is_dir('/tmp') ? '/tmp/structarmed' : null)
- ->withPreset(Preset::PSR4())
+ ->withPresets(Preset::PSR4(), Preset::CODEQUALITY())
+
+ ->layer('Helpers', __DIR__ . '/system/Helpers')
+ ->rule('helpers.functions_must_have_return_type', new MustHaveReturnTypeFunctionRule('Helpers'))
+
+ ->rule('base_classes.must_be_abstract', new ExtendedClassMustBeAbstractOrInstantiatedRule('Source', '/^CodeIgniter\\\\.*Base.*$/'))
+
// Resolve CodeIgniter layers from class names because several layers share directories.
->layerPattern('API', '/^CodeIgniter\\\\API\\\\.*$/')
->layerPattern('Cache', '/^CodeIgniter\\\\Cache\\\\.*$/')
@@ -64,11 +79,13 @@
->layerPattern('Filters', '/^CodeIgniter\\\\Filters\\\\Filter.*$/')
->layerPattern('Format', '/^CodeIgniter\\\\Format\\\\.*$/')
->layerPattern('Honeypot', '/^CodeIgniter\\\\.*Honeypot.*$/')
+ ->layerPattern('Input', '/^CodeIgniter\\\\Input\\\\.*$/')
->layerPattern('URI', '/^CodeIgniter\\\\HTTP\\\\URI$/')
->layerPattern('HTTP', '/^CodeIgniter\\\\HTTP\\\\.*$/', '/(Exception|URI)/')
->layerPattern('I18n', '/^CodeIgniter\\\\I18n\\\\.*$/')
->layerPattern('Images', '/^CodeIgniter\\\\Images\\\\.*$/')
->layerPattern('Language', '/^CodeIgniter\\\\Language\\\\.*$/')
+ ->layerPattern('Lock', '/^CodeIgniter\\\\Lock\\\\.*$/')
->layerPattern('Log', '/^CodeIgniter\\\\Log\\\\.*$/')
->layerPattern('Model', '/^CodeIgniter\\\\.*Model$/')
->layerPattern('Modules', '/^CodeIgniter\\\\Modules\\\\.*$/')
@@ -88,26 +105,28 @@
'Controller' => ['HTTP', 'Validation'],
'Cookie' => ['I18n'],
'Database' => ['Entity', 'Events', 'I18n'],
- 'DataCaster' => ['I18n', 'URI', 'Database'],
+ 'DataCaster' => ['I18n', 'URI', 'Database', 'Encryption'],
'DataConverter' => ['DataCaster'],
'Email' => ['I18n', 'Events'],
'Entity' => ['DataCaster', 'I18n'],
'Files' => ['I18n'],
'Filters' => ['HTTP'],
'Honeypot' => ['Filters', 'HTTP'],
- 'HTTP' => ['Cookie', 'Files', 'I18n', 'Security', 'URI'],
+ 'HTTP' => ['Cookie', 'Files', 'I18n', 'Input', 'Security', 'URI'],
+ 'Input' => ['I18n'],
'Images' => ['Files', 'I18n'],
+ 'Lock' => ['Cache'],
'Model' => ['Database', 'DataCaster', 'DataConverter', 'Entity', 'I18n', 'Pager', 'Validation'],
'Pager' => ['URI', 'View'],
'Publisher' => ['Files', 'URI'],
// +API = API + its allowed layers; +Controller = Controller + its allowed layers
- 'RESTful' => ['+API', '+Controller'],
- 'Router' => ['HTTP', 'I18n'],
- 'Security' => ['Cookie', 'HTTP', 'I18n', 'Session'],
- 'Session' => ['Cookie', 'Database', 'HTTP', 'I18n'],
- 'Throttle' => ['Cache', 'I18n'],
- 'Validation' => ['Database', 'HTTP'],
- 'View' => ['Cache'],
+ 'RESTful' => ['+API', '+Controller'],
+ 'Router' => ['HTTP', 'I18n'],
+ 'Security' => ['Cookie', 'HTTP', 'I18n', 'Session'],
+ 'Session' => ['Cookie', 'Database', 'HTTP', 'I18n'],
+ 'Throttle' => ['Cache', 'I18n'],
+ 'Validation' => ['Database', 'HTTP', 'Helpers', 'I18n', 'Input'],
+ 'View' => ['Cache'],
])
->skipPathsForRuleset(['*test*'])
// Skip violations for class-specific dependencies.
@@ -121,7 +140,7 @@
CastInterface::class,
CastException::class,
])
- ->skipClassViolation(\CodeIgniter\DataCaster\Exceptions\CastException::class, [
+ ->skipClassViolation(CodeIgniter\DataCaster\Exceptions\CastException::class, [
CastException::class,
])
->skipClassViolation(DataConverter::class, [
@@ -149,4 +168,6 @@
->skipClassViolation(Response::class, [PagerInterface::class])
->skipClassViolation(RedirectResponse::class, [PagerInterface::class])
->skipClassViolation(DownloadResponse::class, [PagerInterface::class])
+ ->skipClassViolation(SSEResponse::class, [PagerInterface::class])
+ ->skipClassViolation(StreamResponse::class, [PagerInterface::class])
->skipClassViolation(Validation::class, [RendererInterface::class]);
diff --git a/system/API/TransformerInterface.php b/system/API/TransformerInterface.php
index 3d994251b302..7f8dd95759db 100644
--- a/system/API/TransformerInterface.php
+++ b/system/API/TransformerInterface.php
@@ -43,7 +43,7 @@ public function transform(array|object|null $resource): array;
/**
* Transforms a collection of resources using $this->transform() on each item.
*
- * @param array $resources
+ * @param array $resources
*
* @return array>
*/
diff --git a/system/Autoloader/Autoloader.php b/system/Autoloader/Autoloader.php
index 4666149f27e9..92ff2026bb19 100644
--- a/system/Autoloader/Autoloader.php
+++ b/system/Autoloader/Autoloader.php
@@ -531,7 +531,7 @@ private function autoloadKint(): void
{
// If we have KINT_DIR it means it's already loaded via composer
if (! defined('KINT_DIR')) {
- spl_autoload_register(function ($class): void {
+ spl_autoload_register(static function ($class): void {
$class = explode('\\', $class);
if (array_shift($class) !== 'Kint') {
diff --git a/system/Autoloader/FileLocatorCached.php b/system/Autoloader/FileLocatorCached.php
index 3e31a33d9e84..ee1bb1577433 100644
--- a/system/Autoloader/FileLocatorCached.php
+++ b/system/Autoloader/FileLocatorCached.php
@@ -84,6 +84,7 @@ private function saveCache(): void
*/
public function deleteCache(): void
{
+ $this->cache = [];
$this->cacheUpdated = false;
$this->cacheHandler->delete($this->cacheKey);
}
diff --git a/system/BaseModel.php b/system/BaseModel.php
index 6f52c02a48c6..ad79f6f42ec6 100644
--- a/system/BaseModel.php
+++ b/system/BaseModel.php
@@ -1425,9 +1425,11 @@ protected function doProtectFieldsForInsert(array $row): array
*/
protected function setDate(?int $userDate = null)
{
- $currentDate = $userDate ?? Time::now()->getTimestamp();
+ if ($userDate === null && $this->dateFormat === 'datetime') {
+ return $this->timeToDate(Time::now());
+ }
- return $this->intToDate($currentDate);
+ return $this->intToDate($userDate ?? Time::now()->getTimestamp());
}
/**
@@ -1648,9 +1650,7 @@ public function getValidationRules(array $options = []): array
protected function ensureValidation(): void
{
- if ($this->validation === null) {
- $this->validation = service('validation', null, false);
- }
+ $this->validation ??= service('validation', null, false);
}
/**
@@ -1855,7 +1855,7 @@ protected function objectToRawArray($object, bool $onlyChanged = true, bool $rec
*
* @param object|row_array|null $row
*
- * @return array
+ * @return array
*
* @throws DataException
* @throws InvalidArgumentException
@@ -1947,7 +1947,7 @@ public function __isset(string $name): bool
/**
* Provides direct access to method in the database connection.
*
- * @param array $params
+ * @param array $params
*
* @return mixed
*/
diff --git a/system/CLI/BaseCommand.php b/system/CLI/BaseCommand.php
index 57df52d47f55..72769dfd85a9 100644
--- a/system/CLI/BaseCommand.php
+++ b/system/CLI/BaseCommand.php
@@ -99,7 +99,7 @@ public function __construct(LoggerInterface $logger, Commands $commands)
/**
* Actually execute a command.
*
- * @param array $params
+ * @param array $params
*
* @return int|void
*/
@@ -108,7 +108,7 @@ abstract public function run(array $params);
/**
* Can be used by a command to run other commands.
*
- * @param array $params
+ * @param array $params
*
* @return int|void
*
diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php
index 11f49bbc0c45..314b6a934ee1 100644
--- a/system/CLI/CLI.php
+++ b/system/CLI/CLI.php
@@ -254,12 +254,12 @@ public static function prompt(string $field, $options = null, $validation = null
$default = $options[0];
}
- static::fwrite(STDOUT, $field . (trim($field) !== '' ? ' ' : '') . $extraOutput . ': ');
static::$lastWrite = 'write';
- // Read the input from keyboard.
- $input = trim(static::$io->input());
- $input = ($input === '') ? (string) $default : $input;
+ // The reader renders the prompt itself, so readline redraws repaint it instead of erasing it.
+ $prompt = sprintf('%s%s%s: ', $field, trim($field) !== '' ? ' ' : '', $extraOutput);
+ $input = trim(static::$io->input($prompt));
+ $input = $input === '' ? (string) $default : $input;
if ($validation !== []) {
while (! static::validate('"' . trim($field) . '"', $input, $validation)) {
@@ -273,10 +273,10 @@ public static function prompt(string $field, $options = null, $validation = null
/**
* prompt(), but based on the option's key
*
- * @param list|string $text Output "field" text or an one or two value array where the first value is the text before listing the options
- * and the second value the text before asking to select one option. Provide empty string to omit
- * @param array $options A list of options (array(key => description)), the first option will be the default value
- * @param list|string|null $validation Validation rules
+ * @param list|string $text Output "field" text or an one or two value array where the first value is the text before listing the options
+ * and the second value the text before asking to select one option. Provide empty string to omit
+ * @param array $options A list of options (array(key => description)), the first option will be the default value
+ * @param list|string|null $validation Validation rules
*
* @return string The selected key of $options
*/
@@ -302,11 +302,11 @@ public static function promptByKey($text, array $options, $validation = null): s
/**
* This method is the same as promptByKey(), but this method supports multiple keys, separated by commas.
*
- * @param string $text Output "field" text or an one or two value array where the first value is the text before listing the options
- * and the second value the text before asking to select one option. Provide empty string to omit
- * @param array $options A list of options (array(key => description)), the first option will be the default value
+ * @param string $text Output "field" text or an one or two value array where the first value is the text before listing the options
+ * and the second value the text before asking to select one option. Provide empty string to omit
+ * @param array $options A list of options (array(key => description)), the first option will be the default value
*
- * @return array The selected key(s) and value(s) of $options
+ * @return array The selected key(s) and value(s) of $options
*/
public static function promptByMultipleKeys(string $text, array $options): array
{
@@ -376,7 +376,7 @@ public static function promptByMultipleKeys(string $text, array $options): array
/**
* Validation for $options in promptByKey() and promptByMultipleKeys(). Return an error if $options is an empty array.
*
- * @param array $options
+ * @param array $options
*/
private static function isZeroOptions(array $options): void
{
@@ -388,7 +388,7 @@ private static function isZeroOptions(array $options): void
/**
* Print each key and value one by one
*
- * @param array $options
+ * @param array $options
*/
private static function printKeysAndValues(array $options): void
{
@@ -481,7 +481,7 @@ public static function error(string $text, string $foreground = 'light_red', ?st
// Check color support for STDERR
$stdout = static::$isColored;
- static::$isColored = static::hasColorSupport(STDERR);
+ static::$isColored = is_cli() && static::hasColorSupport(STDERR);
if ($foreground !== '' || (string) $background !== '') {
$text = static::color($text, $foreground, $background);
@@ -493,7 +493,7 @@ public static function error(string $text, string $foreground = 'light_red', ?st
static::$lastWrite = 'write';
}
- static::fwrite(STDERR, $text . PHP_EOL);
+ static::fwrite(is_cli() ? STDERR : STDOUT, $text . PHP_EOL);
// return STDOUT color support
static::$isColored = $stdout;
@@ -572,7 +572,7 @@ public static function clearScreen()
{
// Unix systems, and Windows with VT100 Terminal support (i.e. Win10)
// can handle CSI sequences. For lower than Win10 we just shove in 40 new lines.
- is_windows() && ! static::streamSupports('sapi_windows_vt100_support', STDOUT)
+ is_windows() && is_cli() && ! static::streamSupports('sapi_windows_vt100_support', STDOUT)
? static::newLine(40)
: static::fwrite(STDOUT, "\033[H\033[2J");
}
@@ -741,7 +741,7 @@ public static function getWidth(int $default = 80): int
static::generateDimensions();
}
- return static::$width ?: $default;
+ return (static::$width === null || static::$width === 0) ? $default : static::$width;
}
/**
@@ -753,7 +753,7 @@ public static function getHeight(int $default = 32): int
static::generateDimensions();
}
- return static::$height ?: $default;
+ return (static::$height === null || static::$height === 0) ? $default : static::$height;
}
/**
@@ -819,7 +819,7 @@ public static function showProgress($thisStep = 1, int $totalSteps = 10)
if ($thisStep !== false) {
// Don't allow div by zero or negative numbers....
$thisStep = abs($thisStep);
- $totalSteps = $totalSteps < 1 ? 1 : $totalSteps;
+ $totalSteps = max(1, $totalSteps);
$percent = (int) (($thisStep / $totalSteps) * 100);
$step = (int) round($percent / 10);
@@ -1029,8 +1029,8 @@ public static function getOptionString(bool $useLongOpts = false, bool $trim = f
/**
* Returns a well formatted table
*
- * @param list> $tbody List of rows
- * @param list $thead List of columns
+ * @param list> $tbody List of rows
+ * @param list $thead List of columns
*
* @return void
*/
diff --git a/system/CLI/Commands.php b/system/CLI/Commands.php
index 8f623d509c0a..bbccf6525f15 100644
--- a/system/CLI/Commands.php
+++ b/system/CLI/Commands.php
@@ -54,7 +54,7 @@ public function __construct($logger = null)
/**
* Runs a command given
*
- * @param array $params
+ * @param array $params
*
* @return int Exit code
*/
diff --git a/system/CLI/Console.php b/system/CLI/Console.php
index b24562e52d46..82c31d60f7c5 100644
--- a/system/CLI/Console.php
+++ b/system/CLI/Console.php
@@ -74,9 +74,9 @@ public function showHeader(bool $suppress = false)
* We'll remove that as an option from `$params` and
* unshift it as argument instead.
*
- * @param array $params
+ * @param array $params
*
- * @return array
+ * @return array
*/
private function parseParamsForHelpOption(array $params): array
{
diff --git a/system/CLI/GeneratorTrait.php b/system/CLI/GeneratorTrait.php
index 7d66b8b290af..45e72c7bda71 100644
--- a/system/CLI/GeneratorTrait.php
+++ b/system/CLI/GeneratorTrait.php
@@ -96,14 +96,14 @@ trait GeneratorTrait
*
* @internal
*
- * @var array
+ * @var array
*/
private $params = [];
/**
* Execute the command.
*
- * @param array $params
+ * @param array $params
*
* @deprecated use generateClass() instead
*/
@@ -115,7 +115,7 @@ protected function execute(array $params): void
/**
* Generates a class file from an existing template.
*
- * @param array $params
+ * @param array $params
*/
protected function generateClass(array $params): void
{
@@ -138,8 +138,8 @@ protected function generateClass(array $params): void
/**
* Generate a view file from an existing template.
*
- * @param string $view namespaced view name that is generated
- * @param array $params
+ * @param string $view namespaced view name that is generated
+ * @param array $params
*/
protected function generateView(string $view, array $params): void
{
diff --git a/system/CLI/InputOutput.php b/system/CLI/InputOutput.php
index b69c19e2eee1..893b98a24595 100644
--- a/system/CLI/InputOutput.php
+++ b/system/CLI/InputOutput.php
@@ -43,15 +43,27 @@ public function input(?string $prefix = null): string
{
// readline() can't be tested.
if ($this->readlineSupport && ENVIRONMENT !== 'testing') {
- return readline($prefix); // @codeCoverageIgnore
+ // @codeCoverageIgnoreStart
+ $prompt = $this->readlinePrompt($prefix, readline_info('library_version'));
+
+ if ($prompt === null) {
+ // The library cannot render the prompt, so write it ourselves and let readline() only read the line.
+ self::fwrite(STDOUT, $prefix ?? '');
+ }
+
+ $input = readline($prompt);
+
+ return $input === false ? '' : $input;
+ // @codeCoverageIgnoreEnd
}
- echo $prefix;
+ // self:: skips MockInputOutput's fwrite override, whose filter bookkeeping must not nest inside input().
+ self::fwrite(STDOUT, $prefix ?? '');
$input = fgets(fopen('php://stdin', 'rb'));
if ($input === false) {
- $input = '';
+ return '';
}
return $input;
@@ -77,4 +89,37 @@ public function fwrite($handle, string $string): void
fwrite($handle, $string);
}
+
+ /**
+ * Builds the prompt handed to readline(), or returns null when the caller has to write
+ * the prompt to STDOUT itself because the line-editing library cannot render it.
+ *
+ * GNU readline gets ANSI sequences wrapped in its non-printing markers so line-redraw
+ * column accounting skips them. Libedit reports "EditLine wrapper" and mangles the
+ * markers, so it gets the raw prompt. Windows builds use WinEditLine, which prints ANSI
+ * sequences literally, and readline_info() omits the version there (php-src guards it
+ * with #ifndef PHP_WIN32), so they get no prompt.
+ *
+ * @param mixed $libraryVersion The value of readline_info('library_version')
+ */
+ private function readlinePrompt(?string $prefix, mixed $libraryVersion): ?string
+ {
+ if ($prefix === null || ! is_string($libraryVersion)) {
+ return null;
+ }
+
+ if (str_contains($libraryVersion, 'EditLine')) {
+ return $prefix;
+ }
+
+ return $this->markAnsiNonPrinting($prefix);
+ }
+
+ /**
+ * Wraps ANSI escape sequences in readline's non-printing markers so line-redraw column accounting skips them.
+ */
+ private function markAnsiNonPrinting(string $prefix): string
+ {
+ return preg_replace('/(\e\[[0-9;]*m)/', "\x01\$1\x02", $prefix);
+ }
}
diff --git a/system/CLI/SignalTrait.php b/system/CLI/SignalTrait.php
index 0490e15d9d55..2bc69d51e93c 100644
--- a/system/CLI/SignalTrait.php
+++ b/system/CLI/SignalTrait.php
@@ -81,9 +81,7 @@ protected function isPcntlAvailable(): bool
*/
protected function isPosixAvailable(): bool
{
- if (self::$isPosixAvailable === null) {
- self::$isPosixAvailable = is_windows() ? false : extension_loaded('posix');
- }
+ self::$isPosixAvailable ??= is_windows() ? false : extension_loaded('posix');
return self::$isPosixAvailable;
}
@@ -396,7 +394,7 @@ protected function getProcessState(): array
if ($this->isPosixAvailable()) {
$state['session_id'] = posix_getsid($pid);
$state['process_group'] = posix_getpgid($pid);
- $state['has_controlling_terminal'] = posix_isatty(STDIN);
+ $state['has_controlling_terminal'] = is_cli() && posix_isatty(STDIN);
}
return $state;
diff --git a/system/Cache/FactoriesCache/FileVarExportHandler.php b/system/Cache/FactoriesCache/FileVarExportHandler.php
index 023910c4b17d..241399a12035 100644
--- a/system/Cache/FactoriesCache/FileVarExportHandler.php
+++ b/system/Cache/FactoriesCache/FileVarExportHandler.php
@@ -23,7 +23,7 @@ public function save(string $key, mixed $val): void
// Two processes may try to create the directory at the same time.
// is_dir() confirms it exists, so suppressing the warning is safe.
- if (! is_dir($this->path) && ! @mkdir($this->path, 0777, true) && ! is_dir($this->path)) {
+ if (! is_dir($this->path) && ! @mkdir($this->path, 0755, true) && ! is_dir($this->path)) {
log_message('error', 'FactoriesCache: cannot create cache directory: ' . $this->path);
return;
@@ -37,6 +37,8 @@ public function save(string $key, mixed $val): void
return;
}
+ @chmod($tmp, 0644);
+
// Another process may have wiped the directory. Clean up on failure.
if (! @rename($tmp, $this->path . "/{$key}")) {
log_message('warning', 'FactoriesCache: failed to commit cache file for key: ' . $key);
diff --git a/system/Cache/Handlers/MemcachedHandler.php b/system/Cache/Handlers/MemcachedHandler.php
index 6d0c8aec4d97..3633dc8e40da 100644
--- a/system/Cache/Handlers/MemcachedHandler.php
+++ b/system/Cache/Handlers/MemcachedHandler.php
@@ -180,9 +180,11 @@ public function decrement(string $key, int $offset = 1): false|int
$key = static::validateKey($key, $this->prefix);
- // FIXME: third parameter isn't other handler actions.
-
- return $this->memcached->decrement($key, $offset, $offset, 60);
+ // Memcached counters are unsigned and saturate at 0, so a missing
+ // key can only ever be initialized to 0, never a negative value.
+ // Use Memcached::decrement()'s own default of 0 as the initial
+ // value instead of $offset, so a new key no longer starts positive.
+ return $this->memcached->decrement($key, $offset, 0, 60);
}
public function clean(): bool
diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php
index b806fe00a740..c3725f2388ad 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.4';
+ public const CI_VERSION = '4.7.5';
/**
* App startup time.
@@ -261,7 +261,7 @@ private function autoloadKint(): void
{
// If we have KINT_DIR it means it's already loaded via composer
if (! defined('KINT_DIR')) {
- spl_autoload_register(function ($class): void {
+ spl_autoload_register(static function ($class): void {
$class = explode('\\', $class);
if (array_shift($class) !== 'Kint') {
@@ -505,29 +505,29 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache
// If startController returned a Response (from an attribute or Closure), use it
if ($returned instanceof ResponseInterface) {
$this->gatherOutput($cacheConfig, $returned);
- }
- // Closure controller has run in startController().
- elseif (! is_callable($this->controller)) {
- $controller = $this->createController();
-
- if (! method_exists($controller, '_remap') && ! is_callable([$controller, $this->method], false)) {
- throw PageNotFoundException::forMethodNotFound($this->method);
- }
+ } else {
+ // Closure controller has run in startController().
+ if (! is_callable($this->controller)) {
+ $controller = $this->createController();
- // Is there a "post_controller_constructor" event?
- Events::trigger('post_controller_constructor');
+ if (! method_exists($controller, '_remap') && ! is_callable([$controller, $this->method], false)) {
+ throw PageNotFoundException::forMethodNotFound($this->method);
+ }
- $returned = $this->runController($controller);
- } else {
- $this->benchmark->stop('controller_constructor');
- $this->benchmark->stop('controller');
- }
+ // Is there a "post_controller_constructor" event?
+ Events::trigger('post_controller_constructor');
- // If $returned is a string, then the controller output something,
- // probably a view, instead of echoing it directly. Send it along
- // so it can be used with the output.
- $this->gatherOutput($cacheConfig, $returned);
+ $returned = $this->runController($controller);
+ } else {
+ $this->benchmark->stop('controller_constructor');
+ $this->benchmark->stop('controller');
+ }
+ // If $returned is a string, then the controller output something,
+ // probably a view, instead of echoing it directly. Send it along
+ // so it can be used with the output.
+ $this->gatherOutput($cacheConfig, $returned);
+ }
if ($this->enableFilters) {
/** @var Filters $filters */
$filters = service('filters');
@@ -624,9 +624,7 @@ protected function bootstrapEnvironment()
*/
protected function startBenchmark()
{
- if ($this->startTime === null) {
- $this->startTime = microtime(true);
- }
+ $this->startTime ??= microtime(true);
$this->benchmark = Services::timer();
$this->benchmark->start('total_execution', $this->startTime);
@@ -775,6 +773,8 @@ public function cachePage(Cache $config)
/**
* Returns an array with our basic performance stats collected.
+ *
+ * @return array{startTime: float|null, totalTime: float}
*/
public function getPerformanceStats(): array
{
diff --git a/system/Commands/Cache/ClearCache.php b/system/Commands/Cache/ClearCache.php
index 32f9466a4939..a5c60d315ac5 100644
--- a/system/Commands/Cache/ClearCache.php
+++ b/system/Commands/Cache/ClearCache.php
@@ -13,6 +13,7 @@
namespace CodeIgniter\Commands\Cache;
+use CodeIgniter\Autoloader\FileLocatorCached;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use Config\Cache;
@@ -81,6 +82,12 @@ public function run(array $params)
return EXIT_ERROR;
}
+ $locator = service('locator');
+
+ if ($handler === 'file' && $locator instanceof FileLocatorCached) {
+ $locator->deleteCache();
+ }
+
CLI::write(CLI::color('Cache cleared.', 'green'));
return EXIT_SUCCESS;
diff --git a/system/Commands/Database/CreateDatabase.php b/system/Commands/Database/CreateDatabase.php
index facd060abf6e..459b243d5632 100644
--- a/system/Commands/Database/CreateDatabase.php
+++ b/system/Commands/Database/CreateDatabase.php
@@ -79,7 +79,7 @@ public function run(array $params)
{
$name = array_shift($params);
- if (empty($name)) {
+ if ($name === null || $name === '') {
$name = CLI::prompt('Database name', null, 'required'); // @codeCoverageIgnore
}
diff --git a/system/Commands/Database/MigrateRefresh.php b/system/Commands/Database/MigrateRefresh.php
index b7863a001438..9ee39af58f1e 100644
--- a/system/Commands/Database/MigrateRefresh.php
+++ b/system/Commands/Database/MigrateRefresh.php
@@ -72,7 +72,7 @@ class MigrateRefresh extends BaseCommand
*/
public function run(array $params)
{
- $params['b'] = 0;
+ $params['b'] = '0';
if (ENVIRONMENT === 'production') {
// @codeCoverageIgnoreStart
diff --git a/system/Commands/Database/MigrateStatus.php b/system/Commands/Database/MigrateStatus.php
index 9506c2eae082..7307305307c1 100644
--- a/system/Commands/Database/MigrateStatus.php
+++ b/system/Commands/Database/MigrateStatus.php
@@ -77,8 +77,6 @@ class MigrateStatus extends BaseCommand
/**
* Displays a list of all migrations and whether they've been run or not.
- *
- * @param array $params
*/
public function run(array $params)
{
@@ -107,7 +105,7 @@ public function run(array $params)
$migrations = $runner->findNamespaceMigrations($namespace);
- if (empty($migrations)) {
+ if ($migrations === []) {
continue;
}
diff --git a/system/Commands/Database/Seed.php b/system/Commands/Database/Seed.php
index fc1c0f1ae62e..5a536d0f1b50 100644
--- a/system/Commands/Database/Seed.php
+++ b/system/Commands/Database/Seed.php
@@ -71,7 +71,7 @@ public function run(array $params)
$seeder = new Seeder(new Database());
$seedName = array_shift($params);
- if (empty($seedName)) {
+ if ($seedName === null || $seedName === '') {
$seedName = CLI::prompt(lang('Migrations.migSeeder'), null, 'required'); // @codeCoverageIgnore
}
diff --git a/system/Commands/Encryption/GenerateKey.php b/system/Commands/Encryption/GenerateKey.php
index 148fd264dc33..33858e5ab4c1 100644
--- a/system/Commands/Encryption/GenerateKey.php
+++ b/system/Commands/Encryption/GenerateKey.php
@@ -127,7 +127,7 @@ protected function generateRandomKey(string $prefix, int $length): string
/**
* Sets the new encryption key in your .env file.
*
- * @param array $params
+ * @param array $params
*/
protected function setNewEncryptionKey(string $key, array $params): bool
{
@@ -144,7 +144,7 @@ protected function setNewEncryptionKey(string $key, array $params): bool
/**
* Checks whether to overwrite existing encryption key.
*
- * @param array $params
+ * @param array $params
*/
protected function confirmOverwrite(array $params): bool
{
diff --git a/system/Commands/Generators/MigrationGenerator.php b/system/Commands/Generators/MigrationGenerator.php
index b5e64ec1bce1..5967c12c30af 100644
--- a/system/Commands/Generators/MigrationGenerator.php
+++ b/system/Commands/Generators/MigrationGenerator.php
@@ -89,10 +89,28 @@ public function run(array $params)
if (array_key_exists('session', $params) || CLI::getOption('session')) {
$table = $params['table'] ?? CLI::getOption('table') ?? 'ci_sessions';
$params[0] = "_create_{$table}_table";
+
+ $group = $params['dbgroup'] ?? CLI::getOption('dbgroup');
+ $group = is_string($group) ? $group : 'default';
+ $driver = config(Database::class)->{$group}['DBDriver'] ?? null;
+
+ if ($driver === null) {
+ CLI::error(lang('CLI.generator.undefinedDatabaseGroup', [$group]));
+
+ return EXIT_ERROR;
+ }
+
+ if ($driver !== 'MySQLi' && $driver !== 'Postgre') {
+ CLI::error(lang('CLI.generator.unsupportedSessionDriver', [$group, $driver]));
+
+ return EXIT_ERROR;
+ }
}
$this->classNameLang = 'CLI.generator.className.migration';
$this->generateClass($params);
+
+ return EXIT_SUCCESS;
}
/**
diff --git a/system/Commands/ListCommands.php b/system/Commands/ListCommands.php
index 8761e2bb7bbc..20ee5305ecaa 100644
--- a/system/Commands/ListCommands.php
+++ b/system/Commands/ListCommands.php
@@ -15,12 +15,15 @@
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
+use CodeIgniter\CLI\Commands;
/**
* CI Help command for the spark script.
*
* Lists the basic usage information for the spark script,
* and provides a way to list help for other commands.
+ *
+ * @phpstan-import-type commands_list from Commands
*/
class ListCommands extends BaseCommand
{
@@ -88,6 +91,8 @@ public function run(array $params)
/**
* Lists the commands with accompanying info.
*
+ * @param commands_list $commands
+ *
* @return int
*/
protected function listFull(array $commands)
@@ -96,9 +101,7 @@ protected function listFull(array $commands)
$groups = [];
foreach ($commands as $title => $command) {
- if (! isset($groups[$command['group']])) {
- $groups[$command['group']] = [];
- }
+ $groups[$command['group']] ??= [];
$groups[$command['group']][$title] = $command;
}
@@ -133,6 +136,8 @@ protected function listFull(array $commands)
/**
* Lists the commands only.
*
+ * @param commands_list $commands
+ *
* @return int
*/
protected function listSimple(array $commands)
diff --git a/system/Commands/Translation/LocalizationFinder.php b/system/Commands/Translation/LocalizationFinder.php
index ca7a1fb20230..8fed993a6ecc 100644
--- a/system/Commands/Translation/LocalizationFinder.php
+++ b/system/Commands/Translation/LocalizationFinder.php
@@ -213,7 +213,7 @@ private function findResolvedTranslations(string $langFileName, string $currentL
/**
* @param SplFileInfo|string $file
*
- * @return array
+ * @return array{foundLanguageKeys: array>, badLanguageKeys: array>}
*/
private function findTranslationsInFile($file): array
{
@@ -273,6 +273,9 @@ private function isIgnoredFile(SplFileInfo $file): bool
return $file->getExtension() !== 'php';
}
+ /**
+ * @param array $language
+ */
private function templateFile(array $language = []): string
{
if ($language !== []) {
@@ -337,6 +340,10 @@ private function replaceArraySyntax(string $code): string
/**
* Create multidimensional array from another keys
+ *
+ * @param list $fromKeys
+ *
+ * @return array
*/
private function buildMultiArray(array $fromKeys, string $lastArrayValue = ''): array
{
@@ -356,6 +363,10 @@ private function buildMultiArray(array $fromKeys, string $lastArrayValue = ''):
/**
* Convert multi arrays to specific CLI table rows (flat array)
+ *
+ * @param array $array
+ *
+ * @return list
*/
private function arrayToTableRows(string $langFileName, array $array): array
{
diff --git a/system/Commands/Utilities/FilterCheck.php b/system/Commands/Utilities/FilterCheck.php
index e2043fe9a3a4..5013b7ddf054 100644
--- a/system/Commands/Utilities/FilterCheck.php
+++ b/system/Commands/Utilities/FilterCheck.php
@@ -108,7 +108,7 @@ public function run(array $params)
}
/**
- * @param array $params
+ * @param array $params
*/
private function checkParams(array $params): bool
{
diff --git a/system/Commands/Utilities/Namespaces.php b/system/Commands/Utilities/Namespaces.php
index 8dda8ce0732e..51bc045759ff 100644
--- a/system/Commands/Utilities/Namespaces.php
+++ b/system/Commands/Utilities/Namespaces.php
@@ -91,9 +91,14 @@ public function run(array $params)
CLI::table($tbody, $thead);
}
+ /**
+ * @param array $params
+ *
+ * @return list
+ */
private function outputAllNamespaces(array $params): array
{
- $maxLength = $params['m'];
+ $maxLength = (int) $params['m'];
$autoloader = service('autoloader');
@@ -129,9 +134,14 @@ private function truncate(string $string, int $max): string
return $string;
}
+ /**
+ * @param array $params
+ *
+ * @return list
+ */
private function outputCINamespaces(array $params): array
{
- $maxLength = $params['m'];
+ $maxLength = (int) $params['m'];
$config = new Autoload();
@@ -145,7 +155,8 @@ private function outputCINamespaces(array $params): array
$pathOutput = $this->truncate(clean_path($path), $maxLength);
}
- $path = realpath($path) ?: $path;
+ $realPath = realpath($path);
+ $path = $realPath === false ? $path : $realPath;
$tbody[] = [
$ns,
diff --git a/system/Commands/Utilities/Optimize.php b/system/Commands/Utilities/Optimize.php
index 1a103a1b9c52..9d3964ddda1d 100644
--- a/system/Commands/Utilities/Optimize.php
+++ b/system/Commands/Utilities/Optimize.php
@@ -74,7 +74,12 @@ public function run(array $params)
private function clearCache(): void
{
- $locator = new FileLocatorCached(new FileLocator(service('autoloader')));
+ $locator = service('locator');
+
+ if (! $locator instanceof FileLocatorCached) {
+ $locator = new FileLocatorCached(new FileLocator(service('autoloader')));
+ }
+
$locator->deleteCache();
CLI::write('Removed FileLocatorCache.', 'green');
diff --git a/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php b/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php
index 60a79011426a..a19bd5f24188 100644
--- a/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php
+++ b/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php
@@ -95,9 +95,9 @@ public function get(): array
/**
* Adding Filters
*
- * @param list> $routes
+ * @param list, before?: string, after?: string}> $routes
*
- * @return list>
+ * @return list, before: string, after: string}>
*/
private function addFilters(array $routes): array
{
@@ -134,6 +134,9 @@ private function addFilters(array $routes): array
return $routes;
}
+ /**
+ * @param array{method: string, route: string, route_params: string, handler: string, params: array, before?: string, after?: string} $route
+ */
private function generateSampleUri(array $route, bool $longest = true): string
{
$sampleUri = '';
diff --git a/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php b/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php
index 599e90f4d2f3..0863388b8af5 100644
--- a/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php
+++ b/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php
@@ -45,7 +45,7 @@ public function __construct(
*
* @param class-string $class
*
- * @return list>
+ * @return list}>
*/
public function read(string $class, string $defaultController = 'Home', string $defaultMethod = 'index'): array
{
@@ -128,6 +128,9 @@ public function read(string $class, string $defaultController = 'Home', string $
return $output;
}
+ /**
+ * @return array{array, string}
+ */
private function getParameters(ReflectionMethod $method): array
{
$params = [];
diff --git a/system/Commands/Utilities/Routes/ControllerMethodReader.php b/system/Commands/Utilities/Routes/ControllerMethodReader.php
index 7c8faffd16be..aa5cddaf48d1 100644
--- a/system/Commands/Utilities/Routes/ControllerMethodReader.php
+++ b/system/Commands/Utilities/Routes/ControllerMethodReader.php
@@ -147,6 +147,8 @@ private function getUriByClass(string $classname): string
/**
* Gets a route without default controller.
+ *
+ * @return list
*/
private function getRouteWithoutController(
string $classShortname,
diff --git a/system/Commands/Utilities/Routes/FilterFinder.php b/system/Commands/Utilities/Routes/FilterFinder.php
index 98a1e3659eaf..6477e1d13300 100644
--- a/system/Commands/Utilities/Routes/FilterFinder.php
+++ b/system/Commands/Utilities/Routes/FilterFinder.php
@@ -36,6 +36,9 @@ public function __construct(?Router $router = null, ?Filters $filters = null)
$this->filters = $filters ?? service('filters');
}
+ /**
+ * @return list
+ */
private function getRouteFilters(string $uri): array
{
$this->router->handle($uri);
diff --git a/system/Common.php b/system/Common.php
index fc8abb394f06..72502d0fd5d0 100644
--- a/system/Common.php
+++ b/system/Common.php
@@ -95,7 +95,8 @@ function clean_path(string $path): string
{
// Resolve relative paths
try {
- $path = realpath($path) ?: $path;
+ $realPath = realpath($path);
+ $path = $realPath === false ? $path : $realPath;
} catch (ErrorException|ValueError) {
$path = 'error file path: ' . urlencode($path);
}
@@ -154,7 +155,7 @@ function command(string $command)
$cursor += strlen($match[0]);
}
- /** @var array */
+ /** @var array */
$params = [];
$command = array_shift($args);
$optionValue = false;
@@ -450,13 +451,13 @@ function env(string $key, $default = null)
* If $data is an array, then it loops over it, escaping each
* 'value' of the key/value pairs.
*
- * @param array|string>|string $data
- * @param 'attr'|'css'|'html'|'js'|'raw'|'url' $context
- * @param string|null $encoding Current encoding for escaping.
- * If not UTF-8, we convert strings from this encoding
- * pre-escaping and back to this encoding post-escaping.
+ * @param array|string>|string $data
+ * @param 'attr'|'css'|'html'|'js'|'raw'|'url' $context
+ * @param string|null $encoding Current encoding for escaping.
+ * If not UTF-8, we convert strings from this encoding
+ * pre-escaping and back to this encoding post-escaping.
*
- * @return ($data is string ? string : array|string>)
+ * @return ($data is string ? string : array|string>)
*
* @throws InvalidArgumentException
*/
@@ -489,9 +490,7 @@ function esc($data, string $context = 'html', ?string $encoding = null)
static $escapers = [];
$cacheKey = strtolower($encoding ?? 'utf-8');
- if (! isset($escapers[$cacheKey])) {
- $escapers[$cacheKey] = new Escaper($encoding);
- }
+ $escapers[$cacheKey] ??= new Escaper($encoding);
$data = $escapers[$cacheKey]->{$method}($data);
}
@@ -587,9 +586,7 @@ function function_usable(string $functionName): bool
static $_suhosin_func_blacklist;
if (function_exists($functionName)) {
- if (! isset($_suhosin_func_blacklist)) {
- $_suhosin_func_blacklist = extension_loaded('suhosin') ? explode(',', trim(ini_get('suhosin.executor.func.blacklist'))) : [];
- }
+ $_suhosin_func_blacklist ??= extension_loaded('suhosin') ? explode(',', trim(ini_get('suhosin.executor.func.blacklist'))) : [];
return ! in_array($functionName, $_suhosin_func_blacklist, true);
}
@@ -818,6 +815,8 @@ function lang(string $line, array $args = [], ?string $locale = null)
* - notice
* - info
* - debug
+ *
+ * @param array $context
*/
function log_message(string $level, string $message, array $context = []): void
{
@@ -860,7 +859,7 @@ function model(string $name, bool $getShared = true, ?ConnectionInterface &$conn
* @param string|null $default
* @param 'attr'|'css'|'html'|'js'|'raw'|'url'|false $escape
*
- * @return array|string|null
+ * @return array|string|null
*/
function old(string $key, $default = null, $escape = 'html')
{
@@ -1184,7 +1183,7 @@ function slash_item(string $item): ?string
* Helper function used to convert a string, array, or object
* of attributes to a string.
*
- * @param array|object|string $attributes string, array, object that can be cast to array
+ * @param array|object|string $attributes
*/
function stringify_attributes($attributes, bool $js = false): string
{
@@ -1251,7 +1250,8 @@ function timer(?string $name = null, ?callable $callable = null)
* NOTE: Does not provide any escaping of the data, so that must
* all be handled manually by the developer.
*
- * @param array $options Options for saveData or third-party extensions.
+ * @param array $data
+ * @param array $options Options for saveData or third-party extensions.
*/
function view(string $name, array $data = [], array $options = []): string
{
@@ -1274,7 +1274,7 @@ function view(string $name, array $data = [], array $options = []): string
* View cells are used within views to insert HTML chunks that are managed
* by other classes.
*
- * @param array|string|null $params
+ * @param array|string|null $params
*
* @throws ReflectionException
*/
@@ -1347,7 +1347,11 @@ function class_uses_recursive($class)
*/
function trait_uses_recursive($trait)
{
- $traits = class_uses($trait) ?: [];
+ $traits = class_uses($trait);
+
+ if ($traits === false) {
+ return [];
+ }
foreach ($traits as $trait) {
$traits += trait_uses_recursive($trait);
diff --git a/system/Config/BaseConfig.php b/system/Config/BaseConfig.php
index 40782a243b96..208797ce6d1c 100644
--- a/system/Config/BaseConfig.php
+++ b/system/Config/BaseConfig.php
@@ -37,7 +37,7 @@ class BaseConfig
* An optional array of classes that will act as Registrars
* for rapidly setting config class properties.
*
- * @var array
+ * @var list
*/
public static $registrars = [];
@@ -70,6 +70,9 @@ class BaseConfig
*/
protected static $moduleConfig;
+ /**
+ * @param array $array
+ */
public static function __set_state(array $array)
{
static::$override = false;
@@ -166,7 +169,7 @@ protected function parseEncryptionKey(string $key): string
/**
* Initialization an environment-specific configuration setting
*
- * @param array|bool|float|int|string|null $property
+ * @param array|bool|float|int|string|null $property
*
* @return void
*/
diff --git a/system/Config/BaseService.php b/system/Config/BaseService.php
index 64b5266da041..06cbabd10efd 100644
--- a/system/Config/BaseService.php
+++ b/system/Config/BaseService.php
@@ -134,7 +134,7 @@
* @method static Security security(App $config = null, $getShared = true)
* @method static Session session(ConfigSession $config = null, $getShared = true)
* @method static SiteURIFactory siteurifactory(App $config = null, Superglobals $superglobals = null, $getShared = true)
- * @method static Superglobals superglobals(array $server = null, array $get = null, bool $getShared = true)
+ * @method static Superglobals superglobals(array $server = null, array $get = null, bool $getShared = true)
* @method static Throttler throttler($getShared = true)
* @method static Timer timer($getShared = true)
* @method static Toolbar toolbar(ConfigToolbar $config = null, $getShared = true)
@@ -178,7 +178,7 @@ class BaseService
/**
* A cache of other service classes we've found.
*
- * @var array
+ * @var array{}
*
* @deprecated 4.5.0 No longer used.
*/
@@ -276,9 +276,7 @@ protected static function getSharedInstance(string $key, ...$params)
public static function autoloader(bool $getShared = true)
{
if ($getShared) {
- if (empty(static::$instances['autoloader'])) {
- static::$instances['autoloader'] = new Autoloader();
- }
+ static::$instances['autoloader'] ??= new Autoloader();
return static::$instances['autoloader'];
}
@@ -296,7 +294,7 @@ public static function autoloader(bool $getShared = true)
public static function locator(bool $getShared = true)
{
if ($getShared) {
- if (empty(static::$instances['locator'])) {
+ if (! isset(static::$instances['locator'])) {
$cacheEnabled = class_exists(Optimize::class)
&& (new Optimize())->locatorCacheEnabled;
@@ -317,6 +315,8 @@ public static function locator(bool $getShared = true)
* Provides the ability to perform case-insensitive calling of service
* names.
*
+ * @param array $arguments
+ *
* @return object|null
*/
public static function __callStatic(string $name, array $arguments)
diff --git a/system/Config/DotEnv.php b/system/Config/DotEnv.php
index 6778c44e0d8d..a2f15e7c4850 100644
--- a/system/Config/DotEnv.php
+++ b/system/Config/DotEnv.php
@@ -51,6 +51,8 @@ public function load(): bool
/**
* Parse the .env file into an array of key => value
+ *
+ * @return array|null
*/
public function parse(): ?array
{
@@ -59,15 +61,24 @@ public function parse(): ?array
return null;
}
- // Ensure the file is readable
- if (! is_readable($this->path)) {
- throw new InvalidArgumentException("The .env file is not readable: {$this->path}");
+ $lines = @file($this->path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
+
+ // The .env file may have been removed or replaced by a concurrent
+ // process between the is_file() check above and this read attempt
+ // (e.g. another test process renaming `.env`). A vanished file is
+ // treated as absent, so re-check with a fresh stat cache.
+ if ($lines === false) {
+ clearstatcache(true, $this->path);
+
+ if (is_file($this->path)) {
+ throw new InvalidArgumentException("The .env file is not readable: {$this->path}");
+ }
+
+ return null;
}
$vars = [];
- $lines = file($this->path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
-
foreach ($lines as $line) {
// Is it a comment?
if (str_starts_with(trim($line), '#')) {
@@ -98,11 +109,11 @@ protected function setVariable(string $name, string $value = '')
putenv("{$name}={$value}");
}
- if (empty($_ENV[$name])) {
+ if (! isset($_ENV[$name]) || in_array($_ENV[$name], ['', '0'], true)) {
$_ENV[$name] = $value;
}
- if (empty($_SERVER[$name])) {
+ if (! isset($_SERVER[$name]) || in_array($_SERVER[$name], ['', '0'], true)) {
$_SERVER[$name] = $value;
}
}
@@ -110,6 +121,8 @@ protected function setVariable(string $name, string $value = '')
/**
* Parses for assignment, cleans the $name and $value, and ensures
* that nested variables are handled.
+ *
+ * @return array{string, string}
*/
public function normaliseVariable(string $name, string $value = ''): array
{
@@ -195,7 +208,7 @@ protected function sanitizeValue(string $value): string
protected function resolveNestedVariables(string $value): string
{
if (str_contains($value, '$')) {
- $value = preg_replace_callback(
+ return preg_replace_callback(
'/\${([a-zA-Z0-9_\.]+)}/',
function ($matchedPatterns) {
$nestedVariable = $this->getVariable($matchedPatterns[1]);
diff --git a/system/Config/Factories.php b/system/Config/Factories.php
index ab5343d15fb8..1adbafc48e7b 100644
--- a/system/Config/Factories.php
+++ b/system/Config/Factories.php
@@ -26,8 +26,9 @@
* large performance boost and helps keep code clean of lengthy
* instantiation checks.
*
+ * @method static object|null cells(string $alias, array $options = [])
* @method static BaseConfig|null config(...$arguments)
- * @method static Model|null models(string $alias, array $options = [], ?ConnectionInterface &$conn = null)
+ * @method static Model|null models(string $alias, array $options = [], ?ConnectionInterface &$conn = null)
* @see \CodeIgniter\Config\FactoriesTest
*/
final class Factories
@@ -127,6 +128,8 @@ public static function define(string $component, string $alias, string $classnam
* Loads instances based on the method component name. Either
* creates a new instance or returns an existing shared instance.
*
+ * @param array $arguments
+ *
* @return object|null
*/
public static function __callStatic(string $component, array $arguments)
@@ -140,7 +143,7 @@ public static function __callStatic(string $component, array $arguments)
// Determine the component-specific options
$options = array_merge(self::getOptions($component), $options);
- if (! $options['getShared']) {
+ if (! (bool) $options['getShared']) {
if (isset(self::$aliases[$options['component']][$alias])) {
$class = self::$aliases[$options['component']][$alias];
@@ -192,6 +195,9 @@ public static function get(string $component, string $alias): ?object
/**
* Gets the defined instance. If not exists, creates new one.
*
+ * @param array $options
+ * @param array $arguments
+ *
* @return object|null
*/
private static function getDefinedInstance(array $options, string $alias, array $arguments)
@@ -230,6 +236,8 @@ private static function getDefinedInstance(array $options, string $alias, array
/**
* Creates the shared instance.
+ *
+ * @param array $arguments
*/
private static function createInstance(string $component, string $class, array $arguments): void
{
@@ -264,8 +272,8 @@ private static function isConfig(string $component): bool
/**
* Finds a component class
*
- * @param array $options The array of component-specific directives
- * @param string $alias Class alias. See the $aliases property.
+ * @param array $options The array of component-specific directives
+ * @param string $alias Class alias. See the $aliases property.
*/
private static function locateClass(array $options, string $alias): ?string
{
@@ -341,8 +349,8 @@ private static function isNamespaced(string $alias): bool
/**
* Verifies that a class & config satisfy the "preferApp" option
*
- * @param array $options The array of component-specific directives
- * @param string $alias Class alias. See the $aliases property.
+ * @param array $options The array of component-specific directives
+ * @param string $alias Class alias. See the $aliases property.
*/
private static function verifyPreferApp(array $options, string $alias): bool
{
@@ -362,8 +370,8 @@ private static function verifyPreferApp(array $options, string $alias): bool
/**
* Verifies that a class & config satisfy the "instanceOf" option
*
- * @param array $options The array of component-specific directives
- * @param string $alias Class alias. See the $aliases property.
+ * @param array $options The array of component-specific directives
+ * @param string $alias Class alias. See the $aliases property.
*/
private static function verifyInstanceOf(array $options, string $alias): bool
{
@@ -408,8 +416,8 @@ public static function getOptions(string $component): array
/**
* Normalizes, stores, and returns the configuration for a specific component
*
- * @param string $component Lowercase, plural component name
- * @param array $values option values
+ * @param string $component Lowercase, plural component name
+ * @param array $values Option values
*
* @return array The result after applying defaults and normalization
*/
@@ -541,6 +549,12 @@ public static function getComponentInstances(string $component): array
/**
* Sets component data
*
+ * @param array{
+ * options: array,
+ * aliases: array,
+ * instances: array,
+ * } $data
+ *
* @internal For caching only
*/
public static function setComponentInstances(string $component, array $data): void
diff --git a/system/Config/Factory.php b/system/Config/Factory.php
index b25267791beb..9203af73ff09 100644
--- a/system/Config/Factory.php
+++ b/system/Config/Factory.php
@@ -28,7 +28,7 @@ class Factory extends BaseConfig
* Supplies a default set of options to merge for
* all unspecified factory components.
*
- * @var array
+ * @var array
*/
public static $default = [
'component' => null,
@@ -42,7 +42,7 @@ class Factory extends BaseConfig
* Specifies that Models should always favor child
* classes to allow easy extension of module Models.
*
- * @var array
+ * @var array
*/
public $models = [
'preferApp' => true,
diff --git a/system/Config/Services.php b/system/Config/Services.php
index cef65a8895a9..f5a11ac19a80 100644
--- a/system/Config/Services.php
+++ b/system/Config/Services.php
@@ -198,6 +198,8 @@ public static function csp(?CSPConfig $config = null, bool $getShared = true)
* The CURL Request class acts as a simple HTTP client for interacting
* with other servers, typically through APIs.
*
+ * @param array $options
+ *
* @return CURLRequest
*/
public static function curlrequest(array $options = [], ?ResponseInterface $response = null, ?App $config = null, bool $getShared = true)
@@ -220,7 +222,7 @@ public static function curlrequest(array $options = [], ?ResponseInterface $resp
/**
* The Email class allows you to send email via mail, sendmail, SMTP.
*
- * @param array|EmailConfig|null $config
+ * @param array|EmailConfig|null $config
*
* @return Email
*/
@@ -230,7 +232,7 @@ public static function email($config = null, bool $getShared = true)
return static::getSharedInstance('email', $config);
}
- if (empty($config) || (! is_array($config) && ! $config instanceof EmailConfig)) {
+ if (! $config instanceof EmailConfig && (! is_array($config) || $config === [])) {
$config = config(EmailConfig::class);
}
@@ -343,7 +345,6 @@ public static function image(?string $handler = null, ?Images $config = null, bo
}
$config ??= config(Images::class);
- assert($config instanceof Images);
$handler = in_array($handler, [null, '', '0'], true) ? $config->defaultHandler : $handler;
$class = $config->handlers[$handler];
@@ -740,6 +741,13 @@ public static function siteurifactory(
/**
* Superglobals.
*
+ * @param array|null $server
+ * @param array|null $get
+ * @param array|null $post
+ * @param array|null $cookie
+ * @param array|null $files
+ * @param array|null $request
+ *
* @return Superglobals
*/
public static function superglobals(
diff --git a/system/Config/View.php b/system/Config/View.php
index fcfe32f40f1c..2ec8668ae543 100644
--- a/system/Config/View.php
+++ b/system/Config/View.php
@@ -37,7 +37,7 @@ class View extends BaseConfig
*
* @psalm-suppress UndefinedDocblockClass
*
- * @var array
+ * @var array
*/
public $filters = [];
@@ -52,6 +52,14 @@ class View extends BaseConfig
*/
public $plugins = [];
+ /**
+ * When true, Parser conditionals may only contain variables, literals,
+ * arithmetic, comparison and logical operators, and parentheses. Enable
+ * this when less-trusted users can edit Parser templates. Can be overridden per
+ * call with the `restrictConditionals` render option.
+ */
+ public bool $restrictParserConditionals = false;
+
/**
* Built-in View filters.
*
diff --git a/system/Controller.php b/system/Controller.php
index af74f3a3af10..2fdb5d5a59a6 100644
--- a/system/Controller.php
+++ b/system/Controller.php
@@ -74,6 +74,8 @@ class Controller
/**
* Constructor.
*
+ * @param CLIRequest|IncomingRequest $request
+ *
* @return void
*
* @throws HTTPException|RedirectException
@@ -126,8 +128,8 @@ protected function cachePage(int $time)
/**
* A shortcut to performing validation on Request data.
*
- * @param array|string $rules
- * @param array $messages An array of custom error messages
+ * @param array|string $rules
+ * @param array|string> $messages An array of custom error messages
*/
protected function validate($rules, array $messages = []): bool
{
@@ -139,10 +141,10 @@ protected function validate($rules, array $messages = []): bool
/**
* A shortcut to performing validation on any input data.
*
- * @param array $data The data to validate
- * @param array|string $rules
- * @param array $messages An array of custom error messages
- * @param string|null $dbGroup The database group to use
+ * @param array $data The data to validate
+ * @param array|string $rules
+ * @param array|string> $messages An array of custom error messages
+ * @param string|null $dbGroup The database group to use
*/
protected function validateData(array $data, $rules, array $messages = [], ?string $dbGroup = null): bool
{
@@ -152,7 +154,8 @@ protected function validateData(array $data, $rules, array $messages = [], ?stri
}
/**
- * @param array|string $rules
+ * @param array|string $rules
+ * @param array|string> $messages
*/
private function setValidator($rules, array $messages): void
{
diff --git a/system/Cookie/Cookie.php b/system/Cookie/Cookie.php
index e144d84ba031..da95f18805d4 100644
--- a/system/Cookie/Cookie.php
+++ b/system/Cookie/Cookie.php
@@ -127,7 +127,19 @@ class Cookie implements ArrayAccess, CloneableCookieInterface
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#attributes
* @see https://tools.ietf.org/html/rfc2616#section-2.2
*/
- private static string $reservedCharsList = "=,; \t\r\n\v\f()<>@:\\\"/[]?{}";
+ private static string $reservedCharsList = "=,; \t\r\n\v\f\0()<>@:\\\"/[]?{}";
+
+ /**
+ * Prohibited characters in cookie prefix and name per PHP setcookie() constraints.
+ *
+ * @see https://www.php.net/manual/en/function.setcookie.php
+ */
+ private static string $reservedPrefixCharsList = "=,; \t\r\n\v\f\0";
+
+ /**
+ * @see https://www.php.net/manual/en/function.setrawcookie.php
+ */
+ private static string $reservedValueCharsList = ",; \t\r\n\v\f\0";
/**
* Set the default attributes to a Cookie instance by injecting
@@ -227,11 +239,11 @@ public static function fromHeaderString(string $cookie, bool $raw = false)
* @param string $name The cookie's name
* @param string $value The cookie's value
* @param array{
- * prefix?: string,
+ * prefix?: string|null,
* max-age?: int|numeric-string,
* expires?: DateTimeInterface|int|string,
- * path?: string,
- * domain?: string,
+ * path?: string|null,
+ * domain?: string|null,
* secure?: bool,
* httponly?: bool,
* samesite?: string,
@@ -253,18 +265,21 @@ final public function __construct(string $name, string $value = '', array $optio
}
// to preserve backward compatibility with array-based cookies in previous CI versions
- $prefix = ($options['prefix'] === '') ? self::$defaults['prefix'] : $options['prefix'];
- $path = $options['path'] ?: self::$defaults['path'];
- $domain = $options['domain'] ?: self::$defaults['domain'];
+ $prefix = in_array($options['prefix'], [null, ''], true) ? self::$defaults['prefix'] : $options['prefix'];
+ $path = in_array($options['path'], [null, '', '0'], true) ? self::$defaults['path'] : $options['path'];
+ $domain = in_array($options['domain'], [null, ''], true) ? self::$defaults['domain'] : $options['domain'];
// empty string SameSite should use the default for browsers
- $samesite = $options['samesite'] ?: self::$defaults['samesite'];
+ $samesite = ($options['samesite'] === '') ? self::$defaults['samesite'] : $options['samesite'];
$raw = $options['raw'];
$secure = $options['secure'];
$httponly = $options['httponly'];
$this->validateName($name, $raw);
+ $this->validateValue($value, $raw);
+ $this->validatePath($path);
+ $this->validateDomain($domain);
$this->validatePrefix($prefix, $secure, $path, $domain);
$this->validateSameSite($samesite, $secure);
@@ -366,7 +381,7 @@ public function getMaxAge(): int
{
$maxAge = $this->expires - Time::now()->getTimestamp();
- return $maxAge >= 0 ? $maxAge : 0;
+ return max($maxAge, 0);
}
/**
@@ -429,7 +444,7 @@ public function getOptions(): array
'domain' => $this->domain,
'secure' => $this->secure,
'httponly' => $this->httponly,
- 'samesite' => $this->samesite ?: ucfirst(self::SAMESITE_LAX),
+ 'samesite' => ($this->samesite === '') ? ucfirst(self::SAMESITE_LAX) : $this->samesite,
];
}
@@ -470,6 +485,8 @@ public function withName(string $name)
*/
public function withValue(string $value)
{
+ $this->validateValue($value, $this->raw);
+
$cookie = clone $this;
$cookie->value = $value;
@@ -507,6 +524,7 @@ public function withExpired()
public function withPath(?string $path)
{
$path = in_array($path, [null, '', '0'], true) ? self::$defaults['path'] : $path;
+ $this->validatePath($path);
$this->validatePrefix($this->prefix, $this->secure, $path, $this->domain);
$cookie = clone $this;
@@ -522,6 +540,7 @@ public function withPath(?string $path)
public function withDomain(?string $domain)
{
$domain ??= self::$defaults['domain'];
+ $this->validateDomain($domain);
$this->validatePrefix($this->prefix, $this->secure, $this->path, $domain);
$cookie = clone $this;
@@ -578,6 +597,7 @@ public function withSameSite(string $samesite)
public function withRaw(bool $raw = true)
{
$this->validateName($this->name, $raw);
+ $this->validateValue($this->value, $raw);
$cookie = clone $this;
@@ -619,7 +639,7 @@ public function offsetGet($offset): bool|int|string
/**
* Offset to set.
*
- * @param string $offset
+ * @param string|null $offset
* @param bool|int|string $value
*
* @throws LogicException
@@ -767,12 +787,56 @@ protected function validateName(string $name, bool $raw): void
}
/**
- * Validates the special prefixes if some attribute requirements are met.
+ * Validates the cookie value.
+ *
+ * If `$raw` is true, values should not contain invalid characters
+ * as `setrawcookie()` will reject this.
+ *
+ * @throws CookieException
+ */
+ protected function validateValue(string $value, bool $raw): void
+ {
+ if ($raw && strpbrk($value, self::$reservedValueCharsList) !== false) {
+ throw CookieException::forInvalidCookieValue();
+ }
+ }
+
+ /**
+ * Validates the cookie path per PHP setcookie() constraints.
+ *
+ * @throws CookieException
+ */
+ protected function validatePath(string $path): void
+ {
+ if (strpbrk($path, self::$reservedValueCharsList) !== false) {
+ throw CookieException::forInvalidCookiePath();
+ }
+ }
+
+ /**
+ * Validates the cookie domain per PHP setcookie() constraints.
+ *
+ * @throws CookieException
+ */
+ protected function validateDomain(string $domain): void
+ {
+ if ($domain !== '' && strpbrk($domain, self::$reservedValueCharsList) !== false) {
+ throw CookieException::forInvalidCookieDomain();
+ }
+ }
+
+ /**
+ * Validates the special prefixes if some attribute requirements are met,
+ * and ensures the prefix contains no PHP-prohibited characters.
*
* @throws CookieException
*/
protected function validatePrefix(string $prefix, bool $secure, string $path, string $domain): void
{
+ if (strpbrk($prefix, self::$reservedPrefixCharsList) !== false) {
+ throw CookieException::forInvalidCookieName($prefix);
+ }
+
if (str_starts_with($prefix, '__Secure-') && ! $secure) {
throw CookieException::forInvalidSecurePrefix();
}
diff --git a/system/Cookie/CookieStore.php b/system/Cookie/CookieStore.php
index c4b2994d9cf3..ece407e98d80 100644
--- a/system/Cookie/CookieStore.php
+++ b/system/Cookie/CookieStore.php
@@ -196,6 +196,8 @@ public function getIterator(): Traversable
/**
* Validates all cookies passed to be instances of Cookie.
*
+ * @param array $cookies
+ *
* @throws CookieException
*/
protected function validateCookies(array $cookies): void
diff --git a/system/Cookie/Exceptions/CookieException.php b/system/Cookie/Exceptions/CookieException.php
index 8b6c7575983a..b05aa579e61b 100644
--- a/system/Cookie/Exceptions/CookieException.php
+++ b/system/Cookie/Exceptions/CookieException.php
@@ -60,6 +60,36 @@ public static function forEmptyCookieName()
return new static(lang('Cookie.emptyCookieName'));
}
+ /**
+ * Thrown when the cookie value contains invalid characters.
+ *
+ * @return static
+ */
+ public static function forInvalidCookieValue()
+ {
+ return new static(lang('Cookie.invalidCookieValue'));
+ }
+
+ /**
+ * Thrown when the cookie path contains invalid characters.
+ *
+ * @return static
+ */
+ public static function forInvalidCookiePath()
+ {
+ return new static(lang('Cookie.invalidCookiePath'));
+ }
+
+ /**
+ * Thrown when the cookie domain contains invalid characters.
+ *
+ * @return static
+ */
+ public static function forInvalidCookieDomain()
+ {
+ return new static(lang('Cookie.invalidCookieDomain'));
+ }
+
/**
* Thrown when using the `__Secure-` prefix but the `Secure` attribute
* is not set to true.
diff --git a/system/DataCaster/Cast/ArrayCast.php b/system/DataCaster/Cast/ArrayCast.php
index 1deddea6fc8e..81f343b3e4a8 100644
--- a/system/DataCaster/Cast/ArrayCast.php
+++ b/system/DataCaster/Cast/ArrayCast.php
@@ -14,12 +14,12 @@
namespace CodeIgniter\DataCaster\Cast;
/**
- * Class ArrayCast
- *
* (PHP) [array --> string] --> (DB driver) --> (DB column) string
* [ <-- string] <-- (DB driver) <-- (DB column) string
+ *
+ * @extends BaseCast, string>
*/
-class ArrayCast extends BaseCast implements CastInterface
+class ArrayCast extends BaseCast
{
public static function get(
mixed $value,
diff --git a/system/DataCaster/Cast/BaseCast.php b/system/DataCaster/Cast/BaseCast.php
index 06fb0eb4fa69..e6cc5ae98f1c 100644
--- a/system/DataCaster/Cast/BaseCast.php
+++ b/system/DataCaster/Cast/BaseCast.php
@@ -15,6 +15,12 @@
use CodeIgniter\Exceptions\InvalidArgumentException;
+/**
+ * @template TPhpNativeValue
+ * @template TDataSourceValue
+ *
+ * @implements CastInterface
+ */
abstract class BaseCast implements CastInterface
{
public static function get(
diff --git a/system/DataCaster/Cast/BooleanCast.php b/system/DataCaster/Cast/BooleanCast.php
index 6554081e155d..79b9dfe6d8d9 100644
--- a/system/DataCaster/Cast/BooleanCast.php
+++ b/system/DataCaster/Cast/BooleanCast.php
@@ -14,10 +14,10 @@
namespace CodeIgniter\DataCaster\Cast;
/**
- * Class BooleanCast
- *
* (PHP) [bool --> bool ] --> (DB driver) --> (DB column) bool|int(0/1)
* [ <-- string|int] <-- (DB driver) <-- (DB column) bool|int(0/1)
+ *
+ * @extends BaseCast
*/
class BooleanCast extends BaseCast
{
diff --git a/system/DataCaster/Cast/CSVCast.php b/system/DataCaster/Cast/CSVCast.php
index d77c64c87b57..3c09e1cc6103 100644
--- a/system/DataCaster/Cast/CSVCast.php
+++ b/system/DataCaster/Cast/CSVCast.php
@@ -14,10 +14,10 @@
namespace CodeIgniter\DataCaster\Cast;
/**
- * Class CSVCast
- *
* (PHP) [array --> string] --> (DB driver) --> (DB column) string
* [ <-- string] <-- (DB driver) <-- (DB column) string
+ *
+ * @extends BaseCast, string>
*/
class CSVCast extends BaseCast
{
diff --git a/system/DataCaster/Cast/CastInterface.php b/system/DataCaster/Cast/CastInterface.php
index 7f5b05102a7d..6f2a38d75c9e 100644
--- a/system/DataCaster/Cast/CastInterface.php
+++ b/system/DataCaster/Cast/CastInterface.php
@@ -13,35 +13,31 @@
namespace CodeIgniter\DataCaster\Cast;
+/**
+ * @template TPhpNativeValue
+ * @template TDataSourceValue
+ */
interface CastInterface
{
/**
- * Takes a value from DataSource, returns its value for PHP.
+ * Takes a value from a data source, returns its value for PHP.
*
- * @param mixed $value Data from database driver
+ * @param TDataSourceValue $value Data from database driver
* @param array $params Additional param
* @param object|null $helper Helper object. E.g., database connection
*
- * @return mixed PHP native value
+ * @return TPhpNativeValue
*/
- public static function get(
- mixed $value,
- array $params = [],
- ?object $helper = null,
- ): mixed;
+ public static function get(mixed $value, array $params = [], ?object $helper = null): mixed;
/**
- * Takes a PHP value, returns its value for DataSource.
+ * Takes a PHP value, returns its value for a data source.
*
- * @param mixed $value PHP native value
+ * @param TPhpNativeValue $value PHP native value
* @param array $params Additional param
* @param object|null $helper Helper object. E.g., database connection
*
- * @return mixed Data to pass to database driver
+ * @return TDataSourceValue
*/
- public static function set(
- mixed $value,
- array $params = [],
- ?object $helper = null,
- ): mixed;
+ public static function set(mixed $value, array $params = [], ?object $helper = null): mixed;
}
diff --git a/system/DataCaster/Cast/DatetimeCast.php b/system/DataCaster/Cast/DatetimeCast.php
index 398fdc7beed7..043b7ccd81ea 100644
--- a/system/DataCaster/Cast/DatetimeCast.php
+++ b/system/DataCaster/Cast/DatetimeCast.php
@@ -18,10 +18,10 @@
use CodeIgniter\I18n\Time;
/**
- * Class DatetimeCast
- *
* (PHP) [Time --> string] --> (DB driver) --> (DB column) datetime
* [ <-- string] <-- (DB driver) <-- (DB column) datetime
+ *
+ * @extends BaseCast