Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 29 additions & 14 deletions web/sites/guides/src/content/docs/v4-0-0/upgrading/3x-to-4x.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,19 +87,20 @@ All six CORS settings (`allowCorsRequests` and the five `accessControlAllow*` se
| `Access-Control-Max-Age` | No | Yes |
| Available since | Wheels 2.x | Wheels 3.0 |

**Precedence when both are active**
**What happens when both are active**

The two paths can coexist, but they produce split behavior:
The two paths can technically coexist, but the result is split — and partly broken ([#3114](https://github.com/wheels-dev/wheels/issues/3114)):

- **OPTIONS requests**: the global setting fires first in `onRequestStart` and calls `abort` — the middleware never runs. CORS headers come from the global setting's configuration.
- **All other requests**: both paths run. The middleware fires after `onRequestStart`, so its `cfheader` calls overwrite what the global setting emitted. The middleware wins.
- **OPTIONS requests**: the global setting fires first in `onRequestStart` and calls `abort` — the middleware never runs. Preflight headers come exclusively from the global setting's configuration.
- **All other requests**: the two layers are independent and **additive** — neither overwrites the other. Each layer adds `Access-Control-Allow-Origin` only when the request `Origin` matches its own allow-list — but the legacy layer emits its `Access-Control-Allow-Headers` and `Access-Control-Allow-Methods` lines regardless of the origin (only the middleware gates all of its headers on the match). `cfheader` stacks rather than replaces, so a request whose `Origin` the middleware allows carries both layers' `Access-Control-Allow-Headers` and `Access-Control-Allow-Methods` lines. Worst of all, an origin allowed by **both** layers — exactly the origins a migrating app cares about — receives duplicate `Access-Control-Allow-Origin` headers, which the Fetch spec makes browsers reject. `curl` looks fine; browsers fail the CORS check.
- The middleware pipeline also doesn't run on the public root/congrats page (a bare `.root(method="get")` route), where only the global headers appear.

Unless you intentionally want this asymmetry, running both simultaneously is not recommended. Pick one.
Do not run both. The moment you configure the `Cors` middleware, remove `set(allowCorsRequests=true)` (and the `accessControlAllow*` settings) in the same change.

**Recommendation**

- Apps using the global setting: leave it. It works in 4.0 without changes.
- Apps using the middleware: add `allowOrigins` explicitly (see the breaking change above).
- Apps using the middleware: add `allowOrigins` explicitly (see the breaking change above) and make sure the global setting is off.
- New CORS configuration: prefer the middleware — it supports per-route scoping and has a secure deny-all default.

#### Migrating from global settings to the middleware
Expand Down Expand Up @@ -136,12 +137,18 @@ The `SecurityHeaders` middleware (#2036) emits `Strict-Transport-Security: max-a

**CHANGELOG:** `Breaking: Reload password must be non-empty for environment switching in production` (#2082) and the Security section item for `CSRF key enforced in production` (#2079).

The CSRF encryption key is auto-generated if empty (#2054), but cookies rotate on every deploy when that happens. Set a stable key.
This item applies only when you store CSRF tokens in cookies (`set(csrfStore="cookie")`) — the default store is `session`, which needs no key. With the cookie store, an empty key is auto-generated outside production (#2054) with a `wheels_security` warning, but cookies rotate on every deploy when that happens; in production the framework throws `Wheels.Security.MissingCsrfKey` instead of auto-generating. Set a stable key.

The setting name is `csrfCookieEncryptionSecretKey` — that is the only name the framework reads:

```cfm {test:compile} title="config/settings.cfm"
set(csrfEncryptionKey = env("WHEELS_CSRF_KEY"));
set(csrfCookieEncryptionSecretKey = env("WHEELS_CSRF_KEY"));
```

<Aside type="caution">
`wheels upgrade check` (through 4.0.3) recommends `set(csrfEncryptionKey = ...)` — a setting name the framework never consults. Following that fix text adds an inert setting and keeps the rotate-on-deploy behavior. Use `csrfCookieEncryptionSecretKey` as shown above. Tracked in [#3115](https://github.com/wheels-dev/wheels/issues/3115).
</Aside>

JWT verification now validates the `alg` claim and uses constant-time signature comparison (#2079, #2086). Tokens forged with `alg: none` or mismatched algorithms are rejected.

### 4. `allowEnvironmentSwitchViaUrl` defaults to false in production
Expand All @@ -164,7 +171,7 @@ Without this line, every boot will log the following warning even if `reloadPass
WARN Wheels: reloadPassword is empty — URL-based environment switching and application reload are disabled until a password is set in config/settings.cfm
```

The `env()` helper is the canonical pattern for flowing `.env` values into framework settings. Use the same approach for `csrfEncryptionKey` and other security settings.
The `env()` helper is the canonical pattern for flowing `.env` values into framework settings. Use the same approach for `csrfCookieEncryptionSecretKey` and other security settings.

#### `config/environment.cfm` has the same load-order gap

Expand Down Expand Up @@ -248,7 +255,7 @@ component extends="wheels.WheelsTest" {

See the [Testing guide](/v4-0-0/testing/) for matchers and the phantom-matcher list.

There is also a `wheels.Testbox` shim — a pure alias of `wheels.WheelsTest` retained for specs written before the rename. It adds no behavior. Deprecated since 4.0 with a removal target of 5.0; `wheels upgrade check` flags `extends="wheels.Testbox"` alongside `wheels.Test`, and both should migrate to `wheels.WheelsTest`.
There is also a `wheels.Testbox` shim — a pure alias of `wheels.WheelsTest` retained for specs written before the rename. It adds no behavior. Deprecated since 4.0 with a removal target of 5.0; both it and `wheels.Test` should migrate to `wheels.WheelsTest`. Note: the released 4.0.3 `wheels upgrade check` only flags `extends="wheels.Test"`; the combined rule that also flags `extends="wheels.Testbox"` lives on `develop` and ships in a post-4.0.3 release.

### 9. Tests directory renamed: `tests/specs/functions/` → `tests/specs/functional/`

Expand Down Expand Up @@ -376,7 +383,15 @@ See [Packages](/v4-0-0/digging-deeper/packages/) for manifest fields, per-method

### In-dev-server HTTP MCP endpoint

**CHANGELOG:** `Deprecated: In-dev-server HTTP MCP endpoint at /wheels/mcp — superseded by the LuCLI stdio MCP server (wheels mcp wheels).` Migrate with `wheels mcp setup --force`.
**CHANGELOG:** `Deprecated: In-dev-server HTTP MCP endpoint at /wheels/mcp — superseded by the LuCLI stdio MCP server (wheels mcp wheels).`

There is no `wheels mcp setup` command — migrate by adding the stdio server to your `.mcp.json` manually:

```json title=".mcp.json"
{"mcpServers": {"wheels": {"command": "wheels", "args": ["mcp", "wheels"]}}}
```

Note that bare `wheels mcp` does not print this snippet on the released launcher (it errors with "missing module name"); see the [MCP integration guide](/v4-0-0/command-line-tools/mcp-integration/) for editor-specific variants.

### Legacy CommandBox `wheels-cli` module

Expand All @@ -402,7 +417,7 @@ The `/_browser/home`, `/_browser/login`, `/_browser/dashboard`, `/_browser/login

- **You never used `/_browser/*` yourself** — no action. These routes were only ever meaningful when you ran Wheels' own browser specs against the dev server, and Wheels-generated app scaffolds (`wheels new`) never included them. Your `config/routes.cfm` stays as-is.
- **You ran Wheels browser specs against a running dev server** — opt in by adding `set(loadBrowserTestFixtures=true);` to `config/settings.cfm` (or to your `testing`/`development` env override). The framework then mounts the `/_browser/*` routes automatically. The setting defaults to `false` and production/maintenance/staging environments always skip the mount.
- **You defined your own `/_browser/*` routes in `config/routes.cfm`** (rare — Wheels 4.0 snapshot only) — either keep them (your routes win), or delete them and opt in via `loadBrowserTestFixtures=true` to use the framework copies. The framework registers before `config/routes.cfm` is loaded, so any same-named app route overrides the fixture.
- **You defined your own `/_browser/*` routes in `config/routes.cfm`** (rare — Wheels 4.0 snapshot only) — with the fixtures enabled, your copies are dead code: the framework registers the fixture routes *before* `config/routes.cfm` is loaded, and Wheels routing is first-registration-wins, so a same-pattern app route can **not** override a fixture. Delete your copies. If what you actually need is a custom login flow for browser specs, use `set(browserLoginAsHandler="YourController##yourAction")` instead of shadowing `/_browser/login-as`.

## Removed in 4.0

Expand Down Expand Up @@ -459,7 +474,7 @@ These are additive in 4.0 and worth adopting during the upgrade window:
1. Run the test suite.

```bash title="your shell"
bash tools/test-local.sh
wheels test
```

2. Run `wheels doctor` to surface config and environment issues.
Expand All @@ -478,7 +493,7 @@ These are additive in 4.0 and worth adopting during the upgrade window:

## Common issues

- **"CSRF token invalid" on forms after upgrade.** Either the encryption key rotated (set `csrfEncryptionKey` explicitly) or a third-party embed is hitting `SameSite`. See #2035, #2054.
- **"CSRF token invalid" on forms after upgrade.** Either the cookie encryption key rotated (cookie store only — set `csrfCookieEncryptionSecretKey` explicitly) or a third-party embed is hitting `SameSite`. See #2035, #2054, #3115.
- **Requests return 403 with no CORS header.** `allowOrigins` isn't set. See #2039.
- **CORS preflight rejected after switching from global settings to the middleware.** The `Cors` constructor's `allowHeaders` default (`Content-Type,Authorization,X-Requested-With`) does not include `X-Auth-Token`, `X-Requested-By`, or `Origin` — all present in the legacy `accessControlAllowHeaders` global default. Pass the missing headers explicitly. See [Migrating from global settings to the middleware](#migrating-from-global-settings-to-the-middleware).
- **Rate limiter counts wrong behind your load balancer.** `trustProxy` defaults to `false` now. See #2024, #2088.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ When an upgrade guide says "see #1930", reading that PR description is the faste

## Anatomy of a changelog entry

Wheels follows the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format. Each release heading groups changes under one or more of these sections:
Wheels follows the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format. Each release heading (`# [4.0.3](…) => 2026-06-09`) groups changes into sections — including, but not limited to, the six standard ones below. Real releases also use more granular headings such as `Bug Fixes`, `Breaking Changes`, `Performance`, `Documentation`, and per-layer enhancement groups (`Model Enhancements`, `View Enhancements`, …); read those with the same care as their standard counterpart (`Bug Fixes` ≈ `Fixed`, `Breaking Changes` ≈ `Changed`).

| Section | What it contains |
|---|---|
Expand All @@ -108,8 +108,10 @@ The parenthetical at the end is the PR number. If the change spans multiple PRs,

Changes merged to `develop` but not yet tagged appear under `## [Unreleased]` at the top of the file. If you track the bleeding-edge channel (`wheels-be`), your installed version may already include some of these entries. If you track stable, `[Unreleased]` is a preview of what the next release will contain.

Don't treat `[Unreleased]` as the complete picture, though: unreleased entries accumulate as fragment files under `changelog.d/` in the repo root and are only assembled into `CHANGELOG.md` when a release is cut. To see everything pending, read `[Unreleased]` *and* the `changelog.d/` folder on `develop`.

<Aside type="tip">
Snapshot version strings (`v4.0.0-SNAPSHOT+1787`) correspond to the `develop` branch at the build number shown. There is no tag per snapshot — use `git log --oneline` in the upstream repo to find the exact commit if you need to correlate a snapshot build with a changelog entry.
Snapshot version strings (`v4.0.0-SNAPSHOT+1787`) correspond to the `develop` branch at the build number shown. Most pre-GA snapshot builds got their own tag (the `v4.0.0-SNAPSHOT+NNNN` series runs to `+1783`, where the 4.0.0 GA was cut), but not every build number did — if your build has no tag, use `git log --oneline` in the upstream repo to find the exact commit and correlate it with a changelog entry.
</Aside>

## Related guides
Expand Down
11 changes: 5 additions & 6 deletions web/sites/guides/src/content/docs/v4-0-0/upgrading/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Upgrade an existing app to a new version of Wheels. Start with the policy, then

## Versioning policy

Wheels follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). The `CHANGELOG.md` at the repo root declares this explicitly and groups every release under `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, and `Security` headings in the Keep a Changelog format.
Wheels follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). The `CHANGELOG.md` at the repo root declares this explicitly and follows the Keep a Changelog format — release sections include the standard `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, and `Security` headings alongside more granular ones (`Bug Fixes`, `Breaking Changes`, per-layer enhancement groups, and others), so don't expect every release to use exactly those six.

- **Major** (`3.x` → `4.x`) — breaking changes are allowed. Read the upgrade guide for that jump before you touch code.
- **Minor** (`4.0` → `4.1`) — additive only. New features, no breaking changes to supported APIs.
Expand All @@ -22,17 +22,16 @@ Deprecations land in a minor release with a log warning and stay functional unti

## Where to find the current version

Three sources, in order of authority:
Two sources, in order of authority:

- `vendor/wheels/events/onapplicationstart.cfc` — the `application.$wheels.version` assignment is the runtime truth. As of this snapshot, `4.0.0`.
- `box.json` at the repo root — the package manifest version.
- **Runtime** — `application.$wheels.version` is the runtime truth. It is populated at application start from `BuildInfo`, which reads `vendor/wheels/wheels.json`. Don't go looking for a readable literal in `vendor/wheels/events/onapplicationstart.cfc` — there isn't one. In the source repo the manifest carries the placeholder `@build.version@`; the build pipeline substitutes the real semver (e.g. `4.0.3`) in distributed apps.
- `CHANGELOG.md` — the most recent non-`[Unreleased]` heading.

If any two disagree, the runtime constant wins — that's the version your app actually boots against.
If the two disagree, the runtime value wins — that's the version your app actually boots against.

## Release cadence

Wheels doesn't ship on a fixed schedule. Releases go out when the changelog entries warrant one and the test matrix is green. The git tag history is the authoritative record — `git tag --sort=-v:refname` shows the latest builds (currently `v4.0.0-SNAPSHOT+1524` on the way to the `4.0.0` GA tag). The `-SNAPSHOT+N` suffix is a continuous build number, not a release.
Wheels doesn't ship on a fixed schedule. Releases go out when the changelog entries warrant one and the test matrix is green. The git tag history is the authoritative record — `git tag --sort=-v:refname` shows the latest release (currently `v4.0.3`, released 2026-06-09; the 4.0 line went GA 2026-05-12 with `v4.0.0`). Pre-GA builds were tagged in a `v4.0.0-SNAPSHOT+N` series — the `+N` suffix is a continuous build number, not a release.

Don't assume a release date. Watch the [releases page on GitHub](https://github.com/wheels-dev/wheels/releases) and subscribe to the repo if you need to know when a tagged build lands.

Expand Down
Loading