Releases: wheels-dev/wheels
Wheels 4.0.5
Added
- Linux
.deb/.rpmpackages are now architecture-independent (all/noarch) and install on arm64 (aarch64) as well as amd64 — the package launches the CLI through a portablejava -jarlauncher instead of an amd64-only native binary, soapt install wheels/dnf install wheelsnow work on arm64 hosts (#3223)
Fixed
- The Wheels CLI
.rpmnow starts on RHEL-family distributions (Rocky Linux, Fedora, AlmaLinux): the/usr/bin/wheelswrapper's Java-21 probe only checked Debian/Ubuntu paths, sowheelsfailed with "cannot find a Java 21 runtime" even withjava-21-openjdk-headlessinstalled. It now also resolves the RHEL/Fedora JRE layout (and falls back tojavaonPATH) (#3223)
Wheels 4.0.4
Added
wheels upgrade check --strictescalates advisory findings (the "Recommended Improvements" section) to the same hard-fail path as breaking findings, throwingWheels.UpgradeCheckFailedand exiting non-zero so CI pipelines can gate on opt-in convention changes. Without--strict, advisories continue to report-and-pass. Under--format=jsonthe emitted document'ssuccessfield is gated on both breaking findings and the strict-advisory case, and thestrictflag is echoed back sojq .successand$?always agree. The flag is documented inwheels upgradehelp output (#2963).services/ArgSpec.toInputSchema()derives a JSON-Schema-compatible{type:"object", properties, required, additionalProperties:false}envelope from a command's declared positionals / flags / options.positional()/flag()/option()now accept an optionaldescriptionargument that flows into each emitted property (FastMCP / Symfony JsonDescriptor pattern). Foundation for per-tool MCP input schemas; wiring intotools/listis a follow-up (#2963).wheels jobs work(long-lived worker loop with--queue,--interval,--max-jobs,--quiet) andwheels jobs status(--queue,--format=json) CLI commands — thin wrappers over the existingjobsProcessNext/jobsStatusframework bridge that was left without a CLI surface in the LuCLI migration; theretry/purge/monitorverbs remain tracked follow-ups (#3090)- Changelog entries are now authored as per-PR fragment files under
changelog.d/(<slug>.<type>.md) instead of directCHANGELOG.mdedits, eliminating the[Unreleased]-anchor merge conflict every concurrently-open PR used to have with every other.tools/changelog-promote.sh <version>assembles fragments (plus any legacy[Unreleased]content) into the new version section at release cut and clears the folder. PR template, bot prompts (propose-fix, review-pr, address-review), and the TDD gate updated to the fragment convention (#2958) - Development-mode warning (debug bar + wheels log) when a controller overrides
config()without callingsuper.config(), which silently drops the base controller'sprotectsFromForgery()CSRF wiring and other inherited setup such as filters and verifies (#2960) wheels deploynow deliversenv.secretvalues to app and accessory containers via a remote env file (Kamal model): the file is created with 600 permissions before any content lands and re-locked to 600 right after the upload (the SFTP layer is also told not to carry local file attributes onto the remote), values travel over SFTP only (never argv, dry-run output, or exception summaries), anddocker runreferences it with--env-file. A declared secret with no resolvable.kamal/secretsvalue fails fast withWheels.Deploy.EnvSecretMissing(names only) before any remote call; theWheels.Deploy.EnvSecretUnsupportedfail-fast from #3008 is retired (#2957)- New apps scaffolded with
wheels newnow ship a/upliveness/warm-up endpoint (app/controllers/Up.cfc+ route).wheels deploy's proxy healthcheck already probes/upbefore traffic cutover, so the dispatch → controller → render path is compiled on a freshly deployed node before the first real visitor — moving the one-time cold-start compile (the bulk of first-request latency) off user traffic. The production-config guide documents the warm-up recipe and recommends setting the engine's template-inspection mode toneverin production (#3210) mcpToolSpecs()on the CLI module returns per-tool MCP input schemas (test,seed,analyze,destroy,notes,upgrade,doctor,stats), each built from the SAME ArgSpec the command's parser uses — extracted into shared per-command builders with property descriptions — so the CLI parse surface and the MCPtools/listadvertisement cannot drift. Read by LuCLI per themcpHiddenTools()-style optional convention (runtime support ships separately); commands still on hand-rolled parsing gain entries as #2861 migrates them.wheels testnow also throwsWheels.TestRunFailed(non-zero exit) when the run crashes before producing results — previously a mid-run crash printed red but exited 0, which the post-runWheels.TestsFailedgate (failing tests only) never caught (#2963).set(subpath="/wheelsproject1")(or theWHEELS_SUBPATHenvironment variable) now overrides thecgi.script_name-derivedwebPath/rootPath/rootcomponentPath/wheelsComponentPathso apps deployed under a URL subpath — CommandBox single-site → IIS subfolder migrations, reverse proxies that fold/public/out of the URL, generally any deployment wherecgi.script_namedoes not match the public mount point — no longer need to hand-patchapplication.$wheels.webPathafter each framework upgrade. Detection priority: explicitset(subpath=...)inconfig/settings.cfmwins; otherwiseserver.system.environment.WHEELS_SUBPATHis consulted; otherwise the legacycgi.script_namederivation runs unchanged so existing root-install behavior is preserved. Path derivation is extracted into a pure, unit-testable helper$resolveFrameworkPaths()onwheels.Global. Subpath input is normalized (leading slash added, trailing slashes stripped,"/"treated as a root install) and the helper uses only cross-engine-safe primitives (Replace,ListChangeDelims,Right/Leftwith the Lucee 7Left(str, 0)guard). (#2968)wheels upgrade applyperforms the framework swap: it replaces the app'svendor/wheels/with the framework bundled in the installed CLI, announcing the exact backup destination (vendor/wheels.bak-<timestamp>/) and the one-line recovery command before touching anything (--nobackupopts out; a mid-copy failure throwsWheels.FrameworkUpgrader.CopyFailednaming the backup to restore from). Barewheels upgradeprints usage and never modifies files — destructive commands require the explicit verb, so MCP clients callingwheels_upgradewith{}can never trigger the swap. Safety rails fire before any mutation: refuses outside a Wheels app, when source or target doesn't sniff as a real framework directory (wheels.json/box.jsonmust carry a non-empty version identifying Wheels — a generic appbox.jsonis rejected), when source and target resolve to the same directory (e.g. inside the wheels repo checkout), on unknown flags/subcommands, and when--to=doesn't match the bundled framework version — downloading arbitrary--to=targets is the planned follow-up.wheels upgrade checkkeeps the read-only scan unchanged (including--strict,--format=json, and the non-zero-exit contract); its closing hint now points atwheels upgrade applyinstead ofbrew upgrade wheels, which only ever upgraded the CLI binary (#3035)
Changed
- Docs: upgrade guide, release-channels, and 3x-to-4x migration guide updated to document
wheels upgrade applyas the framework-swap verb alongsidewheels upgrade check(#3045) - The development debug bar's static CSS and JavaScript are now maintained as standalone files (
vendor/wheels/public/assets/css/debugbar.css,vendor/wheels/public/assets/js/debugbar.js) and included into the bar, eliminating the CFML##-escaped inline blocks (a documented "unescaped#crashes the suite" hazard) and trimming the per-response debug payload via inter-tag whitespace collapse. The bar remains development-only and unchanged in production (#3210) wheels deployconfig validation now rejects the 13 Kamal top-level keys the runtime never reads (boot,healthcheck,hooks,volumes,labels,logging,retain_containers,minimum_version,asset_path,require_destination,allow_empty_roles,run_directory,readiness_delay) instead of accepting-and-ignoring them; theunknown top-level keyerror now lists the allowed keys (#3088)- Dev-UI pages (
/wheels/info,/wheels/routes,/wheels/migrator, etc.) no longer inline ~1MB of JS/CSS (jQuery, Semantic UI, marked, highlight.js, base64 icon font) into every response. Bundled assets are now served from a/wheels/assets/*route withCache-Control: public, max-age=31536000, immutableand a framework-version cache-buster, shrinking typical dev-UI page payloads from ~1.1MB to under 100KB after first load (#2959).
Performance
URLFor()controller/action route lookup is now memoized in application scope with negative caching, instead of a per-request memo that only cached matches. The previous memo was rebuilt on every request and was never written on a miss, so wildcard-[controller]apps — where$addRoutestrips thecontrollerkey, guaranteeing no match — re-scanned the entire route table for everylinkTo/urlFor/redirectTocall, on every request. The newapplication.wheels.urlForCachesurvives across requests and caches both hits and misses (empty-string sentinel) for O(1) lookup. Invalidation is plumbed through both$lockedLoadRoutes(route reload) and$addRoute(any mutation, including test-suite manipulation), so a previously negative-cached(controller, action)pair that a newly-added route now matches can never serve a stale miss (#2955)model()andcontroller()(global helpers invendor/wheels/Global.cfc) now take a lock-free warm fast path on cache hits — a directStructKeyExistslookup againstapplication.wheels.models/application.wheels.controllersreturns the cached class before$doubleCheckedLock(and its$invokereflectivecfinvokedispatch) is consulted. Issue #2897 noted these as the framework's hottest warm-path calls, taking two reflective dispatches per association / per validation / per row just to evaluate a one-line cache predicate. The slow path is unchanged; cold-path bootstrap,?reload=truec...
Wheels 4.0.3
Wheels 4.0.3 — third patch on the 4.0 line. Completes the CLI argument-parsing overhaul (
ArgSpecconsumes LuCLI's structured arguments in every command —--no-*negations and named-only flags now reach their parsers, and user-error paths exit non-zero) and lands the fixes from a full 24-command CLI audit; write-side commands (migrate,seed,reload,generate admin) now refuse to attach to a sibling project's server instead of running against the wrong database; PostgreSQL/CockroachDB foreign-key migrations and pre-23c OracleDROP TABLE/DROP VIEWwork again; framework helpers can no longer be invoked as controller actions from a URL; auto-derived model properties preserve database column casing; and scaffolded apps keep their reload password out of source control (WHEELS_RELOAD_PASSWORDin.env). ~45 PRs since the 4.0.2 GA (2026-05-27).
Added
wheels <cmd> --helpnow renders command-specific help from the command function's metadatahint, resolving theg/daliases (sowheels g --helpreaches thegeneratehelp, andwheels d --helpreachesdestroy). Unknown commands and the barewheels help/wheels --helppath fall through to the global listing unchanged. Forward-compatible:showHelp()with no subcommand argument behaves exactly as before, so the feature is dormant until the matching LuCLI dispatch fix (bpamiri/LuCLI#5) ships — the runtime currently discards the subcommand on--helpand always callsshowHelpwith no args (#2886)- wheels-bot can now review fork PRs (external / first-time contributors), which it previously could not. GitHub withholds both the
varscontext andsecretsfrompull_requestruns triggered by a forked repository, so Reviewer A'svars.WHEELS_BOT_ENABLED == 'true'job gate read empty and the job skipped — and Reviewer B, which only fires after A submits a review, never ran. A newbot-review-a-fork.ymlworkflow runs the initial Reviewer A review viapull_request_target(which executes in the base-repo context, where vars + secrets are available) for fork PRs that a maintainer has tagged with thebot-reviewlabel. Hardened against thepull_request_target"pwn-request" class: it checks out the base branch only and reviews the fork's changes throughgh pr diff, never checking out or executing fork-controlled code, so the local./.github/actions/wheels-bot-skip-checkcomposite action always resolves to trusted base code (the fork's commit objects are fetched read-only viarefs/pull/<n>/headso the review's git commands still resolve).bot-review-b.ymlis hardened the same way — it previously checked outgithub.event.review.commit_id(a fork commit on fork PRs) and then ran that local composite action, a latent pwn-request that was unexploitable only because Reviewer A never started the loop on forks; it now checks out the base branch withpersist-credentials: false. Thebot-reviewlabel (appliable only by write-access users) is the human-in-the-loop vet of the fork diff, and Reviewer A's tool surface stays read-only (#2871) cli.lucli.services.ArgSpec— a typed argument-spec builder for Wheels CLI subcommands. LuCLI hands every module function a structured argument map (positionals asarg1, arg2, ...;--key=valueaskey=value;--no-keynormalized tokey=false), butModule.cfc::argsFromCollection()has historically flattened that map back to argv so each of ~18 subcommands could re-parse it with a hand-rolled token loop. The flatten step was the root cause of #2855 (it silently dropped everyfalsevalue, so--no-sqlite/--no-routes/--no-test-db/--no-open-browsernever survived the round trip) and is structurally lossy — it cannot distinguish a genuine--no-Xnegation from an explicit--X=false.ArgSpecconsumes the structured handoff directly: a command declares its positionals, flags, and options up front (.positional(name, required, default, type),.flag(name, default),.option(name, default, type)), then calls.parse(arguments)to receive a typed result struct — no flatten, no re-parse, no lossyfalseround trip. Designed for incremental adoption:getArgs()andargsFromCollection()remain in place as a deprecated shim until every call site is converted, and each command that adoptsArgSpecdrops its hand-rolled token loop in the same change. Cross-engine clean (no closures, no struct-member collisions, noapplication-scope function storage, noattributeCollection = arguments); boolean coercion handles both the string"false"LuCLI normally emits and a literalfalsevalue, so Lucee/Adobe/BoxLang all agree on the parsed semantics. Required-positional violations throwWheels.CLI.MissingArgumentwith the positional's declared name in the message. The cross-framework research that informed the API surface (Rails/Thor, Laravel/Artisan, Django/argparse, Phoenix/Mix, Spring/picocli, Symfony Console) is recorded on the issue (#2861)- A "Reserved scope names" section in the Controllers and Actions guide documenting identifiers (
client,url,form,session,cgi,request,application,cookie,server,arguments,variables,local,this) that must not be used as local variable names in Wheels controllers (and CFML components generally). Specifically calls outclient— the most confusing case — because Lucee 7 throws"client scope is not enabled"whenclientManagementis off, making the error look like an application misconfiguration rather than a bad variable name (#2833) - RustCFML is now recognized as a first-class engine in the engine-adapter layer. Wheels detects it via
server.coldfusion.productName == "RustCFML"(it exposes noserver.lucee/server.boxlang), instantiates aRustCFMLAdapter(extendsBase, whose defaults are Lucee-shaped, matching RustCFML's semantics) ordered before the Adobe ColdFusion fallback, and accepts any version in$checkMinimumVersion(RustCFML is pre-1.0 and rapidly evolving, so the usual minimum-version guard doesn't apply). Because RustCFML does not yet implement thecfcachebuilt-in, the framework's cfcache-backed template/static cache degrades gracefully to a no-op when the adapter reportssupportsCfcache() = false, so requests still render (cacheless-but-working). The newsupportsCfcache()capability defaults totrueon Lucee/Adobe/BoxLang, leaving their behavior unchanged. Support is best-effort: RustCFML is a young, JVM-free CFML interpreter and is not yet part of the CI matrix (#2837) - The built-in
/_browser/login-asbrowser-test fixture (mounted byset(loadBrowserTestFixtures = true)) now honors anapplication.wheels.browserLoginAsHandleroverride. Set it inconfig/settings.cfm—set(browserLoginAsHandler = "AuthFixture##loginAs")— and the framework dispatches/_browser/login-asto that controller##action instead of the defaultBrowserTestLogin##create, letting apps with richer session shapes (e.g.session.member = { id, email, firstName, lastName }) drive the fixture without forking the vendor tree or duplicating the route + env-gate boilerplate. Env-gating moves to a newwheels.middleware.BrowserTestFixtureGuardmiddleware attached to the/_browserscope so the gate still applies under override. The setting falls back toBrowserTestLogin##createwhen unset or empty (#2830)
Changed
- CLI user-error paths now exit non-zero instead of silently returning success. Several commands printed a red error message and then
return "", which LuCLI maps to exit code 0 — so a typo'd subcommand or a failed migration looked like success to CI pipelines, deploy scripts, and pre-commit hooks. The following now throw a typed exception (Wheels.InvalidArgumentsfor unknown input; the original error is re-thrown for runtime failures), which LuCLI maps to a non-zero exit while still printing the same friendly diagnostic first:wheels generate <unknown-type>,wheels create <unknown-type>,wheels migrate <unknown-action>,wheels db <unknown-subcommand>, a failedwheels migrate latest|up|down|info|doctor|rename-system-tables(re-throws the underlyingMigrationErrorinstead of swallowing it), andwheels routeswhen the server returns an unparseable or unsuccessful response (Wheels.RoutesFailed). Help/no-args paths (wheels generate,wheels dbwith no subcommand) and non-error states (wheels routeswith zero configured routes,wheels db resetwithout--force) are unchanged and still exit 0. Over MCP these surface as proper tool errors instead of empty results. Scripts that previously relied on these error paths exiting 0 will now see a non-zero exit — that is the intended fix. - The
consoleandtestCLI subcommands now consume LuCLI's structuredargCollectiondirectly viacli.lucli.services.ArgSpec(parseConsoleArgs/parseTestArgscalling.parse(structuredArgs(arguments))), continuing the #2861 migration (whoseArgSpecfoundation shipped in #2862) past the eight leaf commands.consolereads--password=<value>;testreads--filter(and its documented--directoryalias),--reporter,--db(tracked as explicit so the runner distinguishes an implicit default from a chosen one), the--verbose/--ci/--coreflags,--no-test-db(test-db=false), a bare positional filter, and the-vshorthand (which LuCLI delivers as a positional). Both also fix the latentarg1-gate the round trip masked: named-only invocations likewheels console --password=xandwheels test --core(no positional) now take effect instead of silently running with defaults. One deliberate behavioral delta per command: the space-separated option forms (wheels console --password secret,wheels test --filter models) are dropped for the--key=valueforms — LuCLI delivers a space-separated value as a bare flag plus a separate positional, never a named value. Everything else is preserved:test's APP-vs---coremode d...
Wheels 4.0.2
Wheels 4.0.2 — second patch on the 4.0 line. Adds shared-development-database migrator reconciliation (
wheels migrate doctor/forget/pretend, orphan-version auto-detection, andname/applied_atenrichment of thewheels_migrator_versionstracking table) pluscolumnNamesaliases acrosst.references(),t.primaryKey(), and theMigration.cfccommand helpers; ships native GPG-signed Linux package repositories atapt.wheels.devandyum.wheels.dev(Cloudflare R2); resolvesBrowserTestbase URLs through a layered instance-time lookup; and greens the compatibility matrix across BoxLang and Adobe ColdFusion 2023/2025. ~30 PRs since the 4.0.1 GA (2026-05-20).
Added
- Native Linux package repositories are now live at
apt.wheels.devandyum.wheels.dev, GPG-signed and served from Cloudflare R2. Debian/Ubuntu installs fetch the key withcurl -fsSL https://apt.wheels.dev/wheels.gpg | sudo tee /usr/share/keyrings/wheels.gpg, add adeb [signed-by=/usr/share/keyrings/wheels.gpg] https://apt.wheels.dev stable mainsource, thensudo apt install wheels; Fedora/RHEL installs add the repo viadnf config-manager --add-repo https://yum.wheels.dev/wheels.repothendnf install wheels, and upgrades collapse to a singleapt upgrade wheels/dnf upgrade wheelswith no version pinning. The buckets are backed by R2 rather than Cloudflare Pages because the.deb(80 MB) and.rpm(81 MB) artifacts exceed Pages' 25 MiB per-file limit, while R2 has no object-size limit and still supports custom-domain serving. The install and release-channel guides now lead with the native sources, keeping the one-off GitHub-Release download behind an aside for air-gapped use (#2814) t.primaryKey()in the migrator now acceptscolumnNameandcolumnNamesas aliases for the legacynameparameter, matching the argument-naming convention of every other column helper inTableDefinition.cfc. The legacyname=form keeps working (it's still whatinit()passes when adding the conventionalidprimary key). PluralcolumnNameswins when both aliases are supplied, mirroringaddReference()/dropReference()precedence semantics. Unlike sibling helpers,columnNameshere does NOT accept a comma-separated list —primaryKey()always creates one PK column, socolumnNames="a,b"produces a single column literally nameda,b; callt.primaryKey()multiple times for composite PKs (#2812)t.references()in the migrator now acceptscolumnNamesas an alias for the legacyreferenceNamesargument, matching every sibling column helper (t.string,t.integer, …) that uses$combineArgumentsto take both the plural and singular forms. The newuseUnderscoreReferenceColumnssetting (boolean, framework defaultfalse,wheels newtemplate defaulttrue) controls whethert.references(columnNames="user")producesuser_id(matching Wheels modelbelongsTodefaults) or the legacyuserid(no underscore) suffix; polymorphic references follow the same flag for the<name>_type/<name>typecolumn.Migration.cfc::addReference()andremoveColumn(referenceName=)respect the flag too. Existing apps keep working unchanged since the framework default isfalse; only new apps generated bywheels newopt into the underscore form (#2802)- The command-version migrator helpers in
Migration.cfcnow accept the same plural/singular column-name aliases as theTableDefinitionhelpers fixed in #2802, via$combineArguments:addColumn/changeColumn/removeColumntakecolumnNamesas an alias forcolumnName,addReference/dropReferencetakecolumnName/columnNamesas aliases forreferenceName, andaddForeignKeytakescolumnNameas an alias forcolumn. Legacy parameter names keep working. Two hard-coded& "id"concatenations (inremoveColumnandaddReference) now route throughuseUnderscoreReferenceColumns, so an app that opted into the underscore convention and createduser_idviat.references()can also drop or constrain that column through the command-version helpers.changeColumn's original required-argument enforcement is preserved for non-reference column types via a conditionalrequiredon$combineArguments(#2804) - Two new advisory checks in
wheels upgrade check: at.references()opt-in suggestion (fires when an app usest.references(in migrations withoutset(useUnderscoreReferenceColumns=true)) and a mixed-convention warning (fires when the flag is set so users audit legacy migrations for<name>idcolumns that pre-date the opt-in). Both surface in the "Recommended Improvements" section introduced by the upgrade-tier scaffolding — advisory severity, never gates CI. The opt-in advisory is suppressed when the flag is already set inconfig/settings.cfmto avoid contradicting the mixed-convention warning. The shared grep loop now strips CFML comments before pattern matching (Anti-Pattern #14), so commented-out code can't trip any check — a framework-wide improvement that benefits every existing breaking-change check too (#2807) - Three new
wheels migratesubcommands for manual reconciliation against the tracking table — Flywayvalidate/repair/SkipExecutingMigrationsanalogues.wheels migrate doctorprints a single-command health report covering applied/pending/orphan versions plus a human-readable summary; pure read, never mutates.wheels migrate forget <version> --yesremoves a single orphan row fromwheels_migrator_versions(refuses if a matching local file exists — usemigrate downfor legitimate rollbacks — and refuses if the version isn't in the table).wheels migrate pretend <version> --yesrecords a version as applied without running itsup()(refuses if already applied or if no local file matches, so futuredown()calls still work). Bothforgetandpretendrequire explicit--yesto mutate; without it they print what would happen and exit. Implementation lives inMigrator.cfc::doctor(),forgetVersion(),pretendVersion(). Covers the shared-dev-DB pattern surfaced in #2780 beyond what the orphan auto-detection in #2798 could resolve automatically (#2799)
Changed
wheels upgrade checkoutput now distinguishes opt-in recommendations from breaking changes. Each check struct incli/lucli/Module.cfc::runUpgradeCheck()accepts a new optionalseverityfield (default"breaking", the existing behavior); matches withseverity: "advisory"bucket into a separateRecommended Improvements (N found):section that renders alongsideBreaking Changes (N found):andAll Clear (N checks):. The same-major short-circuit was also removed so advisories can fire on point-release upgrades — the "no known breaking changes" message still prints, but execution continues into the checks loop. Scaffolding-only; advisory-annotated entries land in a follow-up PR (#2805)wheels_migrator_versionsgains two additive nullable columns:name VARCHAR(255)(the migration's human-readable name, e.g.create_users) andapplied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP(when the migration was applied). Added automatically on the first migrator call after upgrade via the newMigrator.$ensureTrackingColumns()helper — idempotent, gated byapplication[appKey].$trackingColumnsEnsuredso the ALTER runs once per app process, and non-fatal (legacy schema continues to work if the ALTER fails). Newly applied migrations populate both columns; existing rows pre-dating the enrichment stay NULL.wheels migrate infoandwheels migrate doctornow show[?] <version> <name> (applied <timestamp>)for orphan rows when the columns are populated, instead of just the literal********** NO FILE **********— letting you see what a peer applied and when even though the file isn't in your branch yet. SQLite skips the column DEFAULT (not supported on existing-table ADD COLUMN) and gets explicit timestamps from CFML on insert. Per-engine SQL covers MySQL, PostgreSQL, SQLite, MSSQL, Oracle, H2, and CockroachDB (#2800)
Fixed
- The Wheels Compatibility Matrix is green again for BoxLang and Adobe CF 2023/2025 ahead of the v4.0.2 cut. BoxLang (17 fail / 72 error on every database) traced to a single root cause:
Global.cfc's pseudo-constructor seededlocal.varKey = ""for its include-injected-UDF promotion loop, and BoxLang materializes that asvariables.local, which then shadows the function-locallocalscope of every mixed-in$-helper — soMigrator/Modelcalls likelocal.appKey = $appKey()resolved against{varKey}and threwKeyNotFoundException: The key [APPKEY] was not found in the struct. Valid keys are ([VARKEY]). Lucee/Adobe keeplocalreserved to the function scope so they never saw it; the loop now lives in a real function ($promoteIncludedGlobalsToThis()). Adobe CF 2023/2025 were crashing the whole suite (HTTP 404 + ~1MB HTML prefix corrupting the result JSON) becauseInvokeMethodSpecinvokedPublic.index(), rendering the congratulations welcome page into the test-runner response buffer, which Adobe commits mid-run — now captured withcfsavecontent. Five further Adobe-specific bugs:RequestIdmiddleware wroterequest.wheels.requestIdthrough arequestparameter that shadows the scope on Adobe (Anti-Pattern #11, now arequest-less helper);TestClientemitted acfhttpPOST with nocfhttpparamfor empty bodies (Adobe requires at least one);ParallelRunner.$collectFailuresrelied on array-by-reference mutation that Adobe passes by value (returns the array now);$reincludeGlobalsre-included an already-bound file, tripping "Routines cannot be declared more than once" (now evaluated in a throwawayGlobalIncludeLoader);RewriteConfigInstallerSpeccompared file content to a literal string where Adobe 2025'sfileWrite/fileReadround-trip appends a newline (compares to a normalized baseline now); andOuterTransactionSignalSpec's cleanup `quer...
Wheels 4.0.1
Wheels 4.0.1 — first patch on the 4.0 line. Hardens Adobe ColdFusion 2023/2025 compatibility (Adobe-specific
cfheaderattributeCollection rejection,env()reserved-word parameter, Vite asset-walk array-by-value), fixes the Windows Scoop install regressions (wheels.cmdcmd.exe pre-parser,.zip.sha512sidecar layout), and addsviewStyleframework presets topaginationNav()plus pluralmappingsaliases topackage.json. ~100 PRs since the 4.0.0 GA (2026-05-12).
Added
paginationNav()andpageNumberLinks()now accept aviewStyleargument with named CSS-framework presets ("plain","bootstrap5","bootstrap4","tailwind"). Bootstrap presets emit the canonical<nav><ul class="pagination"><li class="page-item active" aria-current="page"><span class="page-link">N</span></li>structure — with the active class on the<li>wrapper and a<span>(not anchor) for the current page — so Bootstrap-styled apps no longer need aReplace()regex hack to move the active class off the anchor.viewStyledefaults to"plain", preserving today's output byte-for-byte (#2718)- Docs: added "Reading the Changelog" guide page under the Upgrading section explaining where
CHANGELOG.mdlives (repo root, not insidevendor/wheels/), how to look up PR references cited in upgrade guides, and how to access the changelog offline when working with a vendored copy of the framework (#2719) - Document CORS allow-list defaults drift when migrating from 3.x
set(accessControlAllow*)global settings towheels.middleware.Cors; add header comparison table, explicit-constructor-args fix, and common-issues entry to the 3.x→4.x upgrade guide and a migration callout to the CORS reference page (#2708) PackageLoadernow derives a per-package CFML mapping frompackage.jsonand reflects it intoapplication.mappings, so CFCs inside a hyphenated package (e.g.vendor/wheels-sentry/) can reference siblings via a static identifier (new wheelsSentry.SentryClient()) instead ofCreateObject("component", "vendor.wheels-sentry.SentryClient"). The alias defaults to lower-camel-case of the manifestname(wheels-sentry→wheelsSentry,wheels_legacy_adapter→wheelsLegacyAdapter) and is overridable via amappingfield inpackage.json. Two packages computing the same alias are caught at load time — the first claimant keeps the mapping and the second is recorded ingetFailedPackages()so the conflict is visible. Exposed viaPackageLoader.getPackageMappings()(#2712)wheels deploy initnow scaffolds a starterDockerfile(Lucee 7 + Java 21 multi-stage,/upHEALTHCHECK aligned with the generatedkamal-proxyhealthcheck) and a.dockerignorealongsideconfig/deploy.ymland.kamal/secrets.--forcealso gates theDockerfile— an existing user-authored Dockerfile aborts the init without--force, while an existing.dockerignoreis silently preserved (since it's commonly user-curated even before adoptingwheels deploy). The npm builder stage works for any Wheels app — projects without a JS pipeline pass through unchanged; projects with apackage.jsoninstall + build automatically. Secrets (reload password, DB password, registry password) are injected at deploy time via.kamal/secrets, never baked into the image (#2673)package.jsonnow also accepts amappingsstruct (plural) so a package can register additional dotted CFML mapping aliases beyond the singularmappingidentifier. Keys are dotted names (e.g.plugins.sentry); values are paths relative to the package directory ("."for the root,"sub"for a subdirectory). Lets a package keep legacy callsites likenew plugins.sentry.SentryClient()resolving when it's installed atvendor/wheels-sentry/instead ofplugins/sentry/. Each dotted segment must match[A-Za-z_][A-Za-z0-9_]*; absolute paths and..traversal are rejected. Collisions with any existing alias (singular or plural, same or different package) fail the package and unwind its singular registration so the mapping registries stay internally consistent (#2739)
Changed
lockingSpecnow consults the new$supportsAdvisoryLocks()model adapter capability and skips thewithAdvisoryLockdescribe block viabeforeEach { skip(...) }instead of erroring on adapters that don't support standalone advisory locks (H2, SQL Server, Oracle, CockroachDB). PostgreSQL, MySQL, and SQLite (no-op) reporttrue; SQL Server reportsfalseuntil its lock path grows an implicit-transaction wrapper. Compat-matrix can now distinguish "lock implementation broken" from "lock not applicable to this DB"- Reconcile upgrade docs: blog skeleton now lists all eleven canonical breaking changes (matching the canonical upgrade guide), fixes the
wheels.Test→wheels.WheelsTesttest-base-class rename description (previously mislabeled as a "testbox namespace" move), and adds the previously-missingapplication.wirebox→application.wheelsdiand Vite manifest strictness entries; stats table "Breaking defaults hardened | 7" corrected to "Breaking changes | 11" with four detail-row delta labels updated from Changed/Renamed/New to Breaking (#2632) - Compat-matrix CF-engine readiness probe now tracks the last observed HTTP status, surfaces partial progress every 10 attempts, and on timeout distinguishes "engine never bound" (HTTP 000) from "engine bound but returning 5xx" (e.g. issue #2646's
$blockInProductionsymptom) — printing the response body and a stack-frame-stripped log slice when the latter occurs. Previously a 5-minute timeout dumpedtail -50of raw container logs, dominated by ~30 lines of undertow/runwar stack frames, hiding the actual root cause
Fixed
- The Scoop
wheels.cmdwrapper (bothwheelsandwheels-bechannels) was failing on Windows 11 build 10.0.26200.8457 withThe filename, directory name, or volume label syntax is incorrect.before LuCLI could run, due to two compounding bugs: (1)call "%~dp0lucli-<ver>.bat" %*made cmd.exe pre-parse the entire bat-jar concatenation (~915 KB of bat preamble +:JAR_BOUNDARY+ raw JAR ZIP bytes) looking for labels, and the pre-parser tripped on byte sequences in the ZIP tail; (2)JAVA_HOME=%~dp0share\jdkpointed at a location Scoop's extraction didn't reliably produce — on at least one machine the inlined OpenJDK 21 zip landed at%~dp0jdk-21.0.2(the ZIP's root layout, as ifextract_to=share/jdkwere ignored) instead. The fix landed directly in the canonical bucket via wheels-dev/scoop-wheels#6: the wrapper now dispatches LuCLI via"%JAVA_HOME%\bin\java.exe" -client -jar "%~dp0lucli-<ver>.bat" %*(java reads the JAR via stream and skips the bat preamble, bypassing cmd's pre-parser entirely), and a one-line fallbackif not exist "%JAVA_HOME%\bin\java.exe" set "JAVA_HOME=%~dp0jdk-21.0.2"handles the broken-extraction case. As part of the same cleanup, the stale Scoop manifest drafts attools/distribution-drafts/scoop/(build-manifests.py,validate.py,wheels.json,wheels-be.json, and the local README), plus the regression spec atvendor/wheels/tests/specs/cli/ScoopWrapperSpec.cfcthat pinned against those drafts, are removed — the live bucket has had its own self-hosted autoupdate workflow and an inline-JDK layout since scoop-wheels@3f22250 and the in-repo drafts had silently diverged on every meaningful axis (inline JDK vs.depends:, real hashes vs. placeholder zeros, autoupdate strategy, wrapper template).tools/distribution-drafts/README.mdis updated to note that the scoop bucket is now canonical and no longer mirrored here. Closes #2765 (#2767) - Release artifacts (
wheels-core,wheels-cli,wheels-base-template,wheels-starter-app) now ship*.zip.sha512/*.zip.md5checksum sidecars (was*.sha512/*.md5) so the scoop-wheelsautoupdateconfig — which expects the.zip.sha512shape via$url.sha512substitution — no longer 404s on every non-module artifact.wheels-modulealready used the correct shape; this brings the other four artifacts and both release workflows (release.yml,release-candidate.yml, plus thesnapshot.ymlreusable-workflow chain) into line. Closes the Windows install regression reported in #2758 + scoop-wheels#2 (#2761) - Docs: Windows install steps in
start-here/installing.mdxandcommand-line-tools/installation.mdxnow call outscoop bucket add javaas a prerequisite. Scoop'sdepends:declaration does not auto-add the dependency bucket on the user's behalf, so users hitCouldn't find manifest for 'openjdk21' from 'java' bucketbefore they could proceed (#2761) $viteResolveAssets()on Adobe CF 2023/2025 returned emptypreloadsandstylesarrays when the manifest included transitive imports with CSS chunks. Root cause: Adobe CF copies arrays by value when they are passed directly from a struct literal —$viteWalkImports(preloads = local.rv.preloads, styles = local.rv.styles, ...)handed the walker independent copies on Adobe CF, so everyArrayAppend(arguments.preloads, ...)inside the recursion wrote to garbage andlocal.rvcame back empty. Lucee and BoxLang share the array references, so the bug was Adobe-only. Fix: pass the parentrvstruct and mutatearguments.rv.preloads/arguments.rv.styles— struct references are shared on every engine (Cross-Engine Invariant #6). Affects every helper that walks transitive imports:viteScriptTag,viteStyleTag,vitePreloadTag, and$viteHtmlHead. Existing viteSpec assertions on transitive-import walk, diamond-dependency dedup, and cyclic-import termination serve as the regression catch (#2756)env("KEY")andenv("KEY", "fallback")now return the correct value on Adobe CF 2023/2025. The second parameter was nameddefault, a CFML reserved word (switch/case/default), and Adobe CF refuses to bind a parameter with that name at all — neither the signature default nor a caller-supplied positional value populatesarguments.default, so...
Wheels 4.0.0
Wheels 4.0 — the release that started as 3.1 and grew into a major version. Closes multiple framework-maturity gaps against Rails, Laravel, and Django. See docs/releases/wheels-4.0-audit.md for the full audit trail (260+ merged PRs since 3.0.0). Contributors: @bpamiri, @zainforbjs, @chapmandu, @mlibbe, @MukundaKatta.
Added
Documentation
- Correct landing page license text from "MIT licensed" to "Apache 2.0 licensed"
- Add Debug Panel guide covering each tab, configuration settings, and when the bar appears
- Clarify BoxLang server management in cfml-engines guide; update vm-deployment tip to distinguish CommandBox server management from the
wheelsdev CLI
ORM & data layer
- Chainable query builder with
where(),orWhere(),whereNull(),whereBetween(),whereIn(),whereNotIn(),orderBy(),limit(), and more for injection-safe fluent queries (#1922) - Enum support with
enum()for named property values, auto-generatedis*()checkers, auto-scopes, and inclusion validation (#1921) - Query scopes with
scope()for reusable, composable query fragments in models (#1920) - Batch processing with
findEach()andfindInBatches()for memory-efficient record iteration (#1919) - Bulk insert/upsert operations (
insertAll()/upsertAll()) with per-adapter native UPSERT syntax across MySQL, PostgreSQL, SQL Server, SQLite, H2, CockroachDB, and Oracle (#2101) - Polymorphic associations via
belongsTo(polymorphic=true)andhasMany(as=...)with type-discriminator JOINs (#2104) - Advisory locks (
withAdvisoryLock(name, callback)) and pessimistic locking (.forUpdate()on QueryBuilder) forSELECT ... FOR UPDATE(#2103) - CockroachDB database adapter — seventh supported database, with
unique_rowid()PK convention andRETURNINGclause identity select (#1876, #1986, #1993, #1999) throwOnColumnNotFoundconfig setting for strict column validation in WHERE clauses (#1938)- SQL identifier quoting for reserved-word conflicts in table/column names (#1874)
Migrations
- Auto-migration generation from model/DB schema diff (
AutoMigrator.diff(modelName),writeMigration()) (#2102) - Auto-migration rename detection via explicit hints plus heuristic suggestions (normalized-token + Levenshtein) with new
wheels dbmigrate diffCLI command and MCP integration (#2112)
Routing
- Router modernization:
group()helper, typed constraints (whereNumber,whereAlpha,whereUuid,whereSlug,whereIn), API versioning via.version(1), performance indexes (#1891, #1894) - Route model binding with
binding=trueon resource routes orset(routeModelBinding=true)globally to auto-resolve model instances from route key parameters (#1929)
Middleware pipeline (new core framework)
- Middleware pipeline: closure-based chain running at dispatch level before controller instantiation, route-scoped via
.scope(middleware=[...])or global viaset(middleware=[...])(#1924) - Rate limiting middleware with
wheels.middleware.RateLimitersupporting fixed window, sliding window, and token bucket strategies with in-memory and database storage (#1931) - SecurityHeaders middleware emits Content-Security-Policy, HSTS, and Permissions-Policy headers (#2036)
hstsargument onSecurityHeadersmiddleware to suppress theStrict-Transport-Securityheader entirely, for apps behind TLS-terminating proxies that emit HSTS themselves (#2174)- Multi-tenant support with per-request datasource switching (#1951)
Views
- Composable pagination view helpers:
paginationInfo(),previousPageLink(),nextPageLink(),firstPageLink(),lastPageLink(),pageNumberLinks(), andpaginationNav()for building custom pagination UIs (#1930) - XSS helpers formalized:
h(),hAttr(),stripTags(),stripLinks()(#2097) - Redesigned v4.0 congratulations page for scaffolded apps (#2098)
vitePreloadTag()view helper emits<link rel="modulepreload">for a Vite entrypoint and its transitive chunk imports, suitable for Turbo Drive hover-preload patternsviteScriptTag()andviteStyleTag()now resolve transitive chunk imports from the Vite manifest: modulepreload links for JS chunks are emitted into<head>, and CSS from transitive chunks is included in the stylesheet tags (brings parity with Rails/Laravel Vite integrations)viteStrictManifestsetting (defaulttrue) — missing manifest entries now throwWheels.ViteAssetNotFoundin production. Set tofalseto restore 3.x silent behavior.
Background jobs & real-time
- Job worker daemon with CLI commands (
wheels jobs work/status/retry/purge/monitor) for persistent background job processing with optimistic locking, timeout recovery, and live monitoring (#1934) - Configurable exponential backoff for jobs via
this.baseDelayandthis.maxDelaywith formulaMin(baseDelay * 2^attempt, maxDelay)(#1934) - Pub/sub channels for SSE:
subscribeToChannel(),publish(),poll(), with DatabaseAdapter and in-memory implementations (#1940)
Dependency injection
- Expanded DI container with
asRequestScoped()for per-request service instances,service()global helper, declarativeinject()in controller config,bind()interface binding, auto-wiring of init() arguments, andconfig/services.cfmfor service registration (#1933)
Testing infrastructure
- HTTP test client (
TestClient) for integration testing with fluent assertions:visit(),assertOk(),assertSee(),assertJson(),assertJsonPath(), cookie tracking, session support (#2099) - Parallel test execution runner (
ParallelRunner) partitioning bundles acrosscfthreadworkers (#2100) - Browser testing via Playwright Java with
BrowserTestbase class, fluent DSL (navigation, interaction, keyboard, waiting, scoping, cookies, auth, dialogs, viewport, script, screenshots, assertions), andwheels browser:installcommand (#2113, #2115, #2116, #2121)
Package system
- Package system (
PackageLoader) withpackages/→vendor/activation model,package.jsonmanifests withprovides.mixinstargets, per-package error isolation (#1995) - Module system with dependency graph (requires/replaces/suggests topological sort) and lazy loading (#2017)
- LuCLI module distribution via wheels-cli-lucli repo (#2018)
/wheels/packagesdeveloper page now shows a "Browse registry" section listing all packages available fromwheels-dev/wheels-packages— package name, description, latest version, and a copy-to-clipboardwheels packages install <name>snippet per row. Rows matching an already-installed package show a✓ Installedbadge. Dev/testing only;$blockInProduction()gate keeps it off production servers. Registry data comes from the CLI'sRegistry.listAll()with 24h app-scope cache (#2271, partial — wheels.dev/packages static-site work deferred)
Engine adapters & cross-engine
- Engine adapter modules encapsulating Lucee, Adobe CF, and BoxLang engine-specific behavior (#2016)
- Interface-driven design contracts for framework extension points (#2014)
Migration & legacy
- Legacy compatibility adapter for 3.x → 4.0 migration soft-landing (#2015)
CLI & LuCLI
wheels newnow prints a non-blocking hint at the end of app scaffolding when a newer Wheels release is available on the user's channel (stable, bleeding-edge). Channel-aware (skips dev/rc), 24h-cached at$LUCLI_HOME/.update-check.json, 5s HTTP timeout, silent on any failure — never delays or breakswheels new. (#2556)wheels doctornow detects a stale installed CLI module at~/.wheels/modules/wheels/that shadows a source checkout and warns with a remediation command (symlink). Previously, contributors runningwheelsfrom a checkout could silently execute a pre-install Module.cfc, making merged fixes appear not to take effect. (#2223)- LuCLI Phase 2: zero-Docker local testing via
tools/test-local.sh(#2063) - LuCLI Phase 2: service layer, generators, MCP annotations (#1941)
- LuCLI Phase 3–4: scaffold, seed, in-process services (#2065)
- LuCLI-native Lucee 7 + SQLite CI pipeline (#2032)
- LuCLI tier 1 commands module + WheelsTest test suite (#2092, #2093)
- Playwright CLI commands for browser testing (#2013, #2021)
Distribution (new in 4.0)
- macOS — Homebrew tap at
wheels-dev/homebrew-wheelswith separate formulae for stable (wheels) and bleeding-edge (wheels-be) channels. Daily auto-update workflow polls the upstream release feeds and opens PRs. - Windows — Scoop bucket at
wheels-dev/scoop-wheelswithwheels/wheels-bemanifests. Hourly auto-update via the community Excavator bot. Legacy Chocolateywheelspackage oncommunity.chocolatey.org(CommandBox-based v1.x) is no longer maintained — see Windows install docs for the migration. (#2545, #2552) - Linux —
.deband.rpmpackages built bynfpmon every release and uploaded to the GitHub Release alongside the existing zip artifacts. The package installs/usr/bin/wheels, depends on OpenJDK 21, and on first run syncs the framework module into~/.wheels/. Nativeapt/yumrepositories atapt.wheels.dev/yum.wheels.devare planned for 4.0.x. (#2545) - WinGet — manifest drafts for
Wheels.WheelsandWheels.WheelsBEstaged for post-GA submission to themicrosoft/winget-pkgscommunity repo. (#2557)
Configuration & developer experience
env()helper for cross-scope environment variable access (#1985)- Pre-request logging (#1895)
- Debug panel redesign (W-001, W-002) (#2000, #2001)
- Gap migration detection in
migrateTo()— detects and runs previously-skipped migrations, not just the endpoint (#1928) - Calculated property SQL validation at model config time ...
Wheels 4.0.0-SNAPSHOT+1783 (snapshot)
Snapshot build from develop. Not for production use.
Wheels 4.0.0-SNAPSHOT+1782 (snapshot)
Snapshot build from develop. Not for production use.
Wheels 4.0.0-SNAPSHOT+1781 (snapshot)
Snapshot build from develop. Not for production use.
Wheels 4.0.0-SNAPSHOT+1780 (snapshot)
Snapshot build from develop. Not for production use.