Skip to content

chore: release 4.2.1 - #4668

Merged
bpamiri merged 547 commits into
mainfrom
release/4.2.1-to-main
Oct 31, 2026
Merged

bpamiri merged 547 commits into
mainfrom
release/4.2.1-to-main

Conversation

@bpamiri

@bpamiri bpamiri commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Release 4.2.1

Cuts Wheels 4.2.1 from develop 04bcda8. This is a patch release with fixes only. See the # [4.2.1] section of CHANGELOG.md (#4667), which release.yml publishes as the release notes.

Merge with "Create a merge commit", NOT squash. A squash disconnects main from develop's history (4.2.0 was squashed and needed the #4566 reconnect). Once the merge lands, release.yml tags v4.2.1 and publishes. Nobody tags by hand.

Headline fix: Adobe ColdFusion 2023 update 26 / 2025 update 14

These Adobe updates changed how regex replacements parse case escapes. On a patched Adobe engine, Wheels resolved a controller named main as umain, corrupted hyphenated controller/action names, route names and flash keys, and showed /wheels/info labels with a stray u. Wheels now produces the same output on patched and unpatched engines (#4646: #4649, #4652; Discussion #4627).

Other user-facing fixes

Changed

  • Lucee 7.0.5.41 is the pinned Lucee 7 for new apps and CI (was 7.0.0.395 / 7.0.1.100). Behaviour note: on Lucee 7.0.5+, HTML that view helpers encode uses fewer character references (for example / and : stay literal, and " becomes "). Browsers render both forms the same, but tests that compare against hard-coded encoded strings may need updating. This comes from the engine's ESAPI 3.x, not from a Wheels change.
  • The CLI runs on LuCLI 0.6.2.4 (dependency updates, no behaviour change).

Release checks

  • wheels.json is 4.2.1; CHANGELOG.md has # [4.2.1](…) => 2026-10-30 followed by a --- separator
  • docs/api/v4.2.1.json is committed (the --strict docs-index step needs it); build-docs-index.mjs --strict --version 4.2.1 passes
  • Scope: since 4.2.0, develop has only fix/docs/chore/test/ci/build/perf commits plus two website-only feats; no breaking changes
  • main's tree equals the 4.2.0 develop base 7ce2eb6; develop merges into main without conflicts
  • Release readiness green (run 41114960622)
  • Compatibility Matrix Gate green (run 41114964215: 40 success, 1 skipped)

🤖 Generated with Claude Code

bpamiri and others added 30 commits October 4, 2026 23:36
The extendedInfo of Wheels.Job.EnqueueFailed still told users to enqueue
after the transaction commits whenever the enqueue ran inside a
transaction on another datasource. Since #4282, a model save or
invokeWithTransaction() on another datasource defers the write to the
commit, so that advice now applies only to a raw transaction {} block.
Reword it to match the background-jobs guide.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…RING_SPLIT parameter (#4321)

* feat(model): run IN lists past the SQL Server parameter limit via STRING_SPLIT

SQL Server binds at most 2100 parameters per statement and Wheels binds
each IN list value as its own parameter, so a statement past the adapter
limit of 2097 was refused with Wheels.TooManyParameters (#3906).

On compatibility level 130+ (STRING_SPLIT), such a statement now binds its
largest IN lists, until it fits, as one parameter each:
IN (SELECT CAST(value AS <type>) FROM STRING_SPLIT(?, NCHAR(31))).
Integer and decimal values are validated first and CAST (never TRY_CAST,
which turns an empty value into 0), so NOT IN keeps its semantics; decimals
cast to DECIMAL(38, s) exactly. String lists are compared as they are.
Statements within the limit keep their SQL. Date and time lists, and
databases below level 130, keep Wheels.TooManyParameters. The level is
read once per datasource and kept in application.wheels, so a reload
re-reads it.

Fixes #4103

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(model): say which long IN lists SQL Server runs and which still fail

Wheels.TooManyParameters on SQL Server now says that long integer, decimal,
string and uniqueidentifier lists run as one STRING_SPLIT parameter on
compatibility level 130+, and that date and time lists (#4318) or an older
level still need batching. The query builder guide covers long IN lists on
Oracle and SQL Server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(model): name every IN list SQL Server still refuses past the limit

Only lists of integers, plain decimals, strings and uniqueidentifiers run as
one STRING_SPLIT parameter. The advice, guide note and fragment now say so,
and that any other list (dates, times, float, real, bit, text or binary
values, or numbers such as +5, .5 or 1E5) still needs batching. A spec pins
the message for a float list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* perf: memoise pluralize() and singularize() per application

The inflection setup (irregulars, uncountables, the regex rule table) was rebuilt on every
call. Results are now served from application.wheels.inflectionCache, keyed by direction and
exact text, cleared by set(uncountables/irregulars), a settings fingerprint and reload,
bypassed under a tenant override, and bounded to 2,000 entries of up to 100 characters.

Refs #4150

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* test: restore vertex/index/half to the inflection corpus; pin set() of an existing irregular

#4186 fixed the BoxLang crash, so the three words go back into the equivalence corpus. A
changed plural for an existing irregular isn't fingerprinted (it would double a cache hit), so
a spec pins that changing it through set() takes effect, and the docblock and fragment say so.

Refs #4150

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…4324)

* feat(cli): wheels migrate unlock shows and clears the migration lock

An instance that died holding the cross-process migration lock blocked
every other instance until its lease ran out; the only way to clear it
sooner was a hand-written DELETE against wheels_migrator_locks.

Framework: migrationLockStatus() returns held, expired, owner, host,
heldForSeconds and expiresInSeconds (a missing lock table means not
held), and $migrationLockHolder() is built on it. releaseMigrationLock()
removes an expired lease; with force=true it removes a live one, and the
holder then fails its next renewal with Wheels.MigrationLockLost. The
DELETE names the owner it read, so it never removes a lock another
instance took in between. The bridge gains migrationLockStatus (GET)
and migrationUnlock (POST, reload password).

CLI: wheels migrate unlock prints who holds the lock. It exits 0 when
nothing live holds it and refuses non-zero, naming --force, while a live
instance does. --force (or --yes) removes it and prints what it removed.
The migrate MCP tool gains the unlock action and a force flag.

The lock-timeout error, the upgrade note and the CLI reference point at
the command, keeping the DELETE as the fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(cli): migrate unlock reports a lock that changed hands, and only --force removes it

Review fixes. releaseMigrationLock() compared the owner after the DELETE
with the owner it read, so when another instance took an expired lease
in between, the DELETE (keyed on the owner read) removed nothing yet
released came back true and the CLI printed "Removed ... owner A". It
now returns heldBy, the owner read back after the DELETE, and
released = !Len(heldBy). The CLI fails non-zero naming the new holder
when heldBy differs from the owner it set out to remove.

--yes no longer forces unlock. LuCLI hands CLI flags and MCP arguments
over the same way, so the alias let an MCP client that confirms
forget/pretend out of habit remove a live lock without naming force.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…#4203)

* test(wheelstest): add shared $snapshotRoutes/$restoreRoutes/$clearRoutes helpers (#4194)

Route specs that redefine the table must restore it in afterEach or they leak
stale state into later specs. routes, staticRoutes, namedRoutePositions, the
urlFor caches (application- AND request-scoped) and #4183's router index are
separate structures, so a partial restore is the #4192 bug class (and
formsSpec already restored only routes + namedRoutePositions, dropping
staticRoutes).

Add three helpers to wheels.WheelsTest:
- $snapshotRoutes() -> deep-copy snapshot of every route-derived structure
  present (#4183 fields behind StructKeyExists, so no rebase needed);
- $restoreRoutes(snapshot) -> exact restore, deleting managed keys that were
  absent at snapshot time;
- $clearRoutes() -> empty the table + invalidate every route cache (shared,
  replacing the per-spec shims).

PR1 of a staged migration: helpers + a round-trip self-test
(SnapshotRoutesSpec) + migrate the view/* specs (links, urls,
paginationHelpers, forms). dispatch/mapper/global/middleware/security/hardener
follow in area PRs. Design: shared-docs state/dev1-design/evidence/4194-*.

Updates the CLAUDE.md / consumer-ai "Test-specific gotchas" line (and the app
template) that said $clearRoutes is not inherited -- it now is.

Verified locally, view 605/0/0 + wheelstest 267/0/0 on lucee7, adobe2023 AND
adobe2025.

Refs #4194
Refs #4183

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* test(mapper): migrate mapper-area specs to the shared route helpers (#4194 PR2)

Replace each spec's hand-rolled route save/restore with $snapshotRoutes() /
$restoreRoutes() and drop the local $clearRoutes shims (now inherited from
wheels.WheelsTest, added in #4202). Several of these restored only a subset --
mapperSpec, mapperModernSpec, mapper/MapperSpec, mapper/MapperHardenerShouldSpec,
mapper/MapperNullPatternSpec and mapper/MapperRobustnessSpec each dropped
namedRoutePositions and/or staticRoutes -- so the shared helper also closes
those latent leaks.

PR2 of the staged migration; stacked on #4202 (the helper is not on develop
yet). Depends on #4202; merge order #4202 then this.

Verified locally: full core suite clean on lucee7 (6518 pass; the only
failures are the known TestClient port-mapped loopback artifacts, 0 real),
adobe2023 (6532/0/0) and adobe2025 (6532/0/0); 13 mapper bundles present.

Refs #4194

Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(wheelstest): address review nits on route-state helper docs

- WheelsTest.cfc docblock: drop the stale 'once it merges' wording now that
  #4183 has landed; dynamicRouteIndex / routeTableGeneration are present.
- .ai/testing.md snippet: define 'var g = application.wo;' so the beforeEach
  example compiles as written.
- .ai/testing.md prose: name the dynamic route index and route-table
  generation in the capture list (the helper already captures both).
- Re-sync the cli/lucli/templates/app copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
… transaction (#4325)

* fix(model): take SQL Server advisory locks for the session, outside a transaction

On SQL Server, withAdvisoryLock() outside a transaction failed with "The
statement or function must be executed in the context of a user
transaction": sp_getapplock used its default Transaction owner. The
docblock claimed full support and the adapter reported none.

The lock is now owned by the database session (sp_getapplock @LockOwner =
'Session'), as on MySQL and PostgreSQL, and goes through the same verified
release (#4197):
- the acquire returns @@spid from the same batch;
- a release runs only on the session that holds the lock (APPLOCK_MODE),
  since sp_releaseapplock errors on any other;
- the held check reads the holder from sys.dm_tran_locks. Without the VIEW
  SERVER (PERFORMANCE) STATE permission it falls back to whether any session
  holds the lock.
$supportsAdvisoryLocks() is true. The transaction path (#4198) shares the
session acquire.

Fixes #4220

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(model): recognise SQL Server permission errors by number, in any language

The advisory-lock held check fell back only when the error message contained
"permission": never on a server whose messages aren't in English, and never on
Adobe, whose message is just "Error Executing Database Query.". It now keys on
SQL Server's error number (300, 297, 229), read where each engine keeps it:
nativeErrorCode on Lucee and Adobe, the driver exception's cause chain on BoxLang,
errorCode for a thrown error. The message and detail text remain a fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Changelog bullets end with the issue reference and no period (the 4.1.2
section: 67 of 68 bullets). Revert the periods added to nine fragments in
#4319, #4320 and #4323, which also restores tx-advisory-locks' ending.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…r local listener (#4207)

* fix(test): auto-detect TestClient/BrowserTest base URL from the server local listener

When no base URL is explicitly configured, $testClient() and BrowserTest
auto-detect it from the in-flight test-runner request, taking the port from the
request's Host port. When the host port is mapped to a different internal port,
a loopback request to that Host port does not reach this server.

Add a precedence step, just below the explicit overrides and above the existing
cgi detection, that takes the scheme and port from the server's local listener
(the servlet request's getScheme() / getLocalPort()) and uses the loopback
address (127.0.0.1) -- but only when the local port differs from the Host port
(a mapping). Unmapped, the local port equals the Host port, so the step returns
nothing and the existing cgi detection runs byte-for-byte unchanged; CI, normal
`wheels start`, and vhost setups are unaffected.

- Shared seam $detectBaseUrlFromServletPort(cgiScope, localPort, localScheme)
  on wheels.WheelsTest, with the guarded reader $servletLocalEndpoint().
- Wired into WheelsTest.$getTestBaseUrl and BrowserTest.$resolveBaseUrl
  (BrowserTest inherits the shared helpers).
- Explicit overrides still win; #4148's host rules are untouched (host/scheme
  logic in the cgi path is unchanged).
- Non-servlet engines (e.g. RustCFML) have no servlet request, so the step is
  skipped and resolution falls through to cgi detection.

Specs: $detectBaseUrlFromServletPort covering the mapped case (http + https),
the unmapped case (defers), and the no-local-port case, for both WheelsTest and
BrowserTest.

Verified under a port-mapped container on lucee7 (6545/0/0), adobe2023
(6540/0/0) and adobe2025 (6540/0/0) -- zero TestClient/BrowserTest loopback
failures.

Closes #4195

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(changelog): add fragment for the TestClient/BrowserTest base-URL fix (#4195)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(test): probe the loopback candidate before selecting it (AJP/TLS-proxy safe)

Review follow-up (rev1-r1, rev1-r2). A local/Host port mismatch alone does NOT
prove a usable HTTP loopback endpoint:
- behind an AJP front end (IIS+BonCode, mod_jk) the local port is the AJP port,
  which speaks AJP, not HTTP;
- getScheme() is the logical request scheme, not the listener transport (a
  TLS-terminating HTTP connector can report https on a plain-HTTP socket).

So instead of trusting getLocalPort()+getScheme(), build loopback candidates
(http first, then https) and probe them, selecting the first that answers as
HTTP and otherwise falling back to the existing cgi detection. The probe:
- a bare GET to /wheels/info (no test-context header, so no recursion), with
  redirects disabled and a ~1s timeout;
- treats the request as answering only when the server actually sent a status
  line + headers (cfhttp `header` populated) -- a connect failure or read
  timeout (AJP, TLS mismatch) synthesizes a 408/502 status_code with no header,
  so status_code alone cannot be trusted;
- is memoized per candidate (scheme+port) in the application scope, negative
  results included, so a fallback app pays the timeout at most once;
- any error is swallowed as "no", so it never blocks a test run.

Shared by both the WheelsTest ($getTestBaseUrl) and BrowserTest ($resolveBaseUrl)
resolvers. Explicit overrides still win; #4148 host rules are untouched.

Specs: candidate generation; selection with an injected probe (http-answers,
https-only, nothing-answers/AJP fallback); a real dead-port probe; a socket
that accepts the TCP handshake but never answers HTTP (the AJP read-timeout
shape, capability-skipped where CreateObject("java") is unavailable), asserting
the probe returns false within its timeout bound; and override precedence for
both resolvers.

Verified under a port-mapped container on lucee7 (6551/0/0), adobe2023
(6546/0/0) and adobe2025 (6546/0/0) -- 0 TestClient/BrowserTest loopback
failures.

Refs #4195

Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(test): probe a WEB-INF path (startup-independent) + TTL-cache negatives (#4195)

Review follow-up (rev1-r1, rev1-r2, orch1). The previous probe GET /wheels/info
hit the LIVE app, which in runner-only setups (the runner binds the isolated
<name>_wheelsTest) is often cold: it triggered a full live onApplicationStart
(autoMigrate etc.), blew the 1s timeout, cached a NEGATIVE for the app lifetime,
and so a mapped container fell back to the broken cgi URL for the whole run --
reintroducing #4195 -- besides starting the live app as a side effect.

Fixes:
- Probe `<candidate>/WEB-INF/wheels-loopback-probe`. The servlet container
  refuses /WEB-INF with a 404 (status line + headers) BEFORE any URL rewrite or
  CFML dispatch, so the transport check is independent of app startup. Verified
  container-handled (404 in ~3ms, not a Wheels response) on Tomcat (Lucee/Adobe)
  and Undertow (BoxLang); still gated by the populated-`header` check so AJP and
  TLS mismatches read as no-answer.
- Cache a POSITIVE result for the app lifetime but a NEGATIVE only for a short
  TTL (~60s), so a transient failure self-heals instead of permanently pinning a
  candidate as "no HTTP here", while a real AJP front end still avoids the
  timeout on every $testClient() within the window.

Tests:
- A new test double (LoopbackResolverStub) stubs the servlet local port and the
  probe to drive the COMPLETE resolvers -- WheelsTest.$getTestBaseUrl and
  BrowserTest.$resolveBaseUrl -- through mapped-success, cgi-fallback, and
  cache-once, not only the override path.
- The socket-accepts-but-never-answers test (the AJP read-timeout shape) now
  reports skip() when CreateObject("java") is unavailable, instead of a silent
  pass. ($servletLocalPort is now public so the double can stub it.)

Verified under a port-mapped container, full core suite 0 loopback failures on
lucee7 (6556), boxlang (6556), adobe2023 (6551) and adobe2025 (6551).

Refs #4195

Signed-off-by: Peter Amiri <peter@alurium.com>

* test(wheelstest): isolate base-URL resolver specs from ambient overrides

The complete-resolver probe tests drive BrowserTest.$resolveBaseUrl /
WheelsTest.$getTestBaseUrl through the LoopbackResolverStub, but the stub
only stubbed the servlet port and the HTTP probe — not the setting / system
property / env-var override layers. The LuCLI CI job sets
WHEELS_BROWSER_TEST_BASE_URL=http://localhost:30007, which the browser
resolver returned before ever reaching the probe, so 'mapped + probe answers'
expected http://127.0.0.1:9001 but got the env value.

An env var cannot be unset from the JVM, so save/restore cannot isolate it.
Extract the ambient (non-per-instance) override lookup into a public seam on
each resolver ($configuredBrowserBaseUrl / $configuredTestClientBaseUrl) and
override both in the stub to return "", so the stub-driven specs reach the
probe/cgi path deterministically regardless of the CI environment. The
production precedence (per-instance override > setting > sysprop > env > probe
> cgi > default) is unchanged — the layers are only relocated behind the seam.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ute helpers (PR3) (#4204)

* test(wheelstest): add shared $snapshotRoutes/$restoreRoutes/$clearRoutes helpers (#4194)

Route specs that redefine the table must restore it in afterEach or they leak
stale state into later specs. routes, staticRoutes, namedRoutePositions, the
urlFor caches (application- AND request-scoped) and #4183's router index are
separate structures, so a partial restore is the #4192 bug class (and
formsSpec already restored only routes + namedRoutePositions, dropping
staticRoutes).

Add three helpers to wheels.WheelsTest:
- $snapshotRoutes() -> deep-copy snapshot of every route-derived structure
  present (#4183 fields behind StructKeyExists, so no rebase needed);
- $restoreRoutes(snapshot) -> exact restore, deleting managed keys that were
  absent at snapshot time;
- $clearRoutes() -> empty the table + invalidate every route cache (shared,
  replacing the per-spec shims).

PR1 of a staged migration: helpers + a round-trip self-test
(SnapshotRoutesSpec) + migrate the view/* specs (links, urls,
paginationHelpers, forms). dispatch/mapper/global/middleware/security/hardener
follow in area PRs. Design: shared-docs state/dev1-design/evidence/4194-*.

Updates the CLAUDE.md / consumer-ai "Test-specific gotchas" line (and the app
template) that said $clearRoutes is not inherited -- it now is.

Verified locally, view 605/0/0 + wheelstest 267/0/0 on lucee7, adobe2023 AND
adobe2025.

Refs #4194
Refs #4183

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* test(mapper): migrate mapper-area specs to the shared route helpers (#4194 PR2)

Replace each spec's hand-rolled route save/restore with $snapshotRoutes() /
$restoreRoutes() and drop the local $clearRoutes shims (now inherited from
wheels.WheelsTest, added in #4202). Several of these restored only a subset --
mapperSpec, mapperModernSpec, mapper/MapperSpec, mapper/MapperHardenerShouldSpec,
mapper/MapperNullPatternSpec and mapper/MapperRobustnessSpec each dropped
namedRoutePositions and/or staticRoutes -- so the shared helper also closes
those latent leaks.

PR2 of the staged migration; stacked on #4202 (the helper is not on develop
yet). Depends on #4202; merge order #4202 then this.

Verified locally: full core suite clean on lucee7 (6518 pass; the only
failures are the known TestClient port-mapped loopback artifacts, 0 real),
adobe2023 (6532/0/0) and adobe2025 (6532/0/0); 13 mapper bundles present.

Refs #4194

Signed-off-by: Peter Amiri <peter@alurium.com>

* test(dispatch,global,security): migrate leak-class specs to shared route helpers (#4194 PR3)

Migrate the remaining partial-clear / leak-class route specs onto
$snapshotRoutes() / $restoreRoutes() and drop their local $clearRoutes shims:

- dispatch/findMatchingRouteMegaSpec (partial-clear shim)
- global/urlforSpec (partial-clear shim; keeps its URLRewriting + obfuscateUrls
  saves, which are not route-table state)
- security/PaginationXssSpec (never saved staticRoutes -- the formsSpec leak
  class; its $clearRoutes CALL stays, now inherited)

PR3 of the staged migration; stacked on #4203 (helper chain not on develop
yet). Depends on #4203 (and #4202); merge order #4202, #4203, then this.

Verified locally, full core suite clean on lucee7 (6518 pass; only the known
TestClient port-mapped loopback artifacts, 0 real), adobe2023 (6532/0/0) and
adobe2025 (6532/0/0).

Refs #4194

Signed-off-by: Peter Amiri <peter@alurium.com>

* test(global): keep urlForCache present before the route-state snapshot

Migrating urlforSpec onto the shared $snapshotRoutes/$restoreRoutes helpers
brought urlForCache into the restored key set. urlForCache is a lazily-created
persistent app-scope memo (routing.cfm), so when it was absent at snapshot time
$restoreRoutes deleted it on the afterEach — cascading so every test ended with
it gone. The issue-2955 'memo lives in application scope so it survives across
requests' test then found the memo absent (Expected [false] to be true).

Ensure urlForCache exists before the snapshot in the outer beforeEach, so the
full-state restore preserves the key instead of deleting it — matching the
framework's treatment of it as a persistent memo. All assertions are unchanged;
the issue-2955 block still manages the cache contents via its own save/restore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…gh STRING_SPLIT (#4329)

A statement whose date or timestamp IN lists push it past SQL Server's
parameter limit threw Wheels.TooManyParameters. On compatibility level
130+, such a list now binds as one STRING_SPLIT parameter, each value cast
to the type the driver binds a cf_sql_timestamp / cf_sql_date as
(datetime2(7) / date), so it matches exactly the rows the same list
matches bound one parameter per value. Casting to the column's own type
would match more rows than that on DATETIME and SMALLDATETIME columns.

The bind types are read once per datasource with SQL_VARIANT_PROPERTY and
kept in application.wheels; a driver that binds other types keeps the
error. Values must be yyyy-mm-dd with an optional HH:nn[:ss[.lll]], or
CFML's {ts}/{d} form; time lists and other forms keep the error.

Fixes #4318

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…connection options (#4331)

* fix(model): take MySQL and PostgreSQL advisory locks with the shared connection options

The default (non-transaction) withAdvisoryLock() methods on MySQL and
PostgreSQL built {datasource, username, password} by hand, even with an
empty username and password, while model queries and the transactional
path use $advisoryLockConnection(). They now use it too, like SQL Server.

That helper resolves the datasource like $performQuery: a tenant
(non-shared) model now takes its lock on the active tenant's datasource
instead of the application's default one. Two tenants on different
databases using the same lock name no longer exclude each other; a lock
taken through a shared model stays on the default datasource.

Fixes #4223

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(model): key the in-application advisory-lock wait on the lock's datasource

withAdvisoryLock() first waits on an application-server lock named after
the model's configured datasource and the lock name. With a tenant
model's lock now taken on the tenant's datasource, two tenants using the
same lock name on one server still queued behind each other there, and
could time out. The wait is now keyed on the effective datasource:
another tenant's datasource is another connection pool, which can't
borrow the session holding the lock (#4197), so its callers needn't wait.

The docs note that MySQL lock names are server-wide, so tenant databases
on the same MySQL server still share a lock name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The SQL Server adapter reads two facts once per datasource and keeps them
in application.wheels: the database's compatibility level (STRING_SPLIT,
#4321) and the types the driver binds dates as (#4329). A failed read was
kept too, so one transient error disabled STRING_SPLIT, or date-list
splitting, until the next reload.

A failed read is no longer kept. Its time is recorded, the answer is "not
supported" until $probeRetrySeconds() (60) have passed, and then the probe
runs again; a good read is kept as before. The back-off stops a broken
probe from running before every long statement.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…callback aborts (#4330)

* test(model): pin advisory-lock release when the callback ends the request (4219)

A withAdvisoryLock() callback that ends the request from inside the lock — via
a real abort or a real redirectTo() (cflocation) — must still release the
database lock. The release runs in a finally, which executes on those exit
paths (only a catch is skipped by abort), so there is nothing to leak; this
makes that a permanent, measured guarantee instead of a caveat.

Adds a core regression spec that drives request-ending actions over HTTP via
$testClient and then, from its own separate connection, asserts the named lock
is free — for the default (session-scoped) and transaction = true paths, each
ending via abort and via redirectTo. A release regressed out of the finally
would leave the lock on the aborting request's pooled connection and red this
spec. Skips on databases without advisory-lock support; afterEach best-effort
clears any probe lock so a failure cannot hand a held lock to a later spec.

Fixtures: vendor/wheels/tests/_assets/controllers/AdvisoryLockProbe.cfc + the
/_advisorylock routes. Verified locally on Lucee 7 with MySQL and SQL Server
(4/0/0 each); the compat matrix proves every engine x MySQL/PostgreSQL/SQL Server.

Closes #4219

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* test(model): add a positive control to the advisory-lock release spec

Addresses review: the spec's 'not held afterwards' assertions passed vacuously
if the probe request never reached withAdvisoryLock (a 404, the wrong app or
datasource, an early throw) — tc.get()'s result was ignored.

Each probe callback now records, from a SECOND connection while inside the lock
and before ending the request, the result of a server-wide $isAdvisoryLockHeld
into server["wheelsAdvisoryProbe_<name>"]. The spec asserts that marker is
present and true (the lock was genuinely held at the moment of the abort /
redirect), asserts the response (200 for abort, a 3xx redirect for redirectTo),
and only then asserts the lock is free. If the request never took the lock the
marker is absent and the test fails instead of passing silently.

Verified it can fail: moving the release out of the withAdvisoryLock finally
(into a catch, release after the block) reds the abort scenarios on MySQL and
SQL Server (3 pass / 1 fail: 'a default-path lock stayed held after the callback
aborted'), confirming the spec guards the finally. redirectTo stays green under
that break because Wheels defers the cflocation to after the action, so the
callback returns normally and the release runs — redirectTo is inherently safe;
abort is the path the finally protects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(model): release withAdvisoryLock(transaction=true) lock when the callback aborts

The release lived in the same try/finally that commits or rolls back the
transaction. When an abort leaves the transaction, some engines skip that
finally, so a session-scoped lock (MySQL, SQL Server) stayed held on the
pooled connection until the connection closed. Move the release into an
inner finally that carries no transaction-action statement, so it runs on
every engine when the callback aborts; the normal path still commits
before releasing so the next holder reads committed state.

The regression spec now skips the transaction=true cases on PostgreSQL,
whose pg_advisory_xact_lock is transaction-scoped and auto-released by the
database, so there is no session-held lock to leak.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* test(model): run the transaction=true abort case on PostgreSQL as a hang detector

Address review on #4330. On PostgreSQL the transaction=true advisory lock is
transaction-scoped and auto-released, so it cannot leak — but a request that
never returns keeps it held. Un-skip the abort case there and bound it with a
short $testClient timeout, so a stuck request fails that case rather than
timing out the whole engine leg. The redirect case still skips on PostgreSQL.

Add an optional timeout (seconds, default 30) to $testClient().get() for that
bound; the default keeps existing behaviour.

Reword the locking.cfm comment to state only that the release must sit in a
finally with no transaction-action statement, which BoxLang skips on abort.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…'s type (#4333)

* fix(model): compare SQL Server date and time conditions in the column's type

Every engine binds cf_sql_timestamp as DATETIME2(7), and SQL Server then
converts a DATETIME or SMALLDATETIME column exactly, so a value with a
fraction of a second never equalled what the column stored in 1/300-second
(or one-minute) steps. timestamps() creates DATETIME columns on SQL Server,
so createdAt = <a saved value> could match nothing. cf_sql_time bound as
DATETIME on Lucee (an error against a TIME column), not at all on BoxLang,
and without its fraction on Adobe.

A condition param now carries its operator, and the SQL Server adapter casts:
- =, <>, IN and NOT IN on a DATETIME / SMALLDATETIME column to that type, so
  the value rounds the way the stored value did. Ranges keep the exact
  comparison, and DATETIME2 / DATE columns are unchanged;
- any comparison on a TIME column, bound as text, to TIME(7).
An IN list becomes one CAST per value; the parameter count is unchanged.
$temporalComparisonType() names the type for each column.

Fixes #4326
Fixes #4327

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(model): split long SQL Server date and time lists in the column's type

A long IN list on a DATETIME or SMALLDATETIME column now splits with the
column's own CAST, as the same list does when it fits, so the two paths match
the same rows (sqlServerDateStringSplitSpec). A long TIME list splits too, cast
to TIME(7) like a time condition; it used to keep Wheels.TooManyParameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(model): say that <> and NOT IN on a SQL Server DATETIME can leave out rows

Comparing at the column's precision lets = and IN match rows they never
matched, and makes <> and NOT IN leave out a row whose stored value the
rounded value equals: createdAt <> '...10:00:00.001' no longer returns a row
stored as 10:00:00.000. The fragment and upgrade guide now say so, and a spec
pins the <> / NOT IN case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(model): keep a date's milliseconds in SQL Server TIME text on RustCFML

RustCFML treats a date object as a simple value whose text drops the
milliseconds, so $timeText() read it as a date-time string and lost them.
A date-time string is now cut to its time only when it carries a fraction;
any other date goes through TimeFormat() and Millisecond().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… entry (#4334)

withAdvisoryLock(transaction = true) is new in 4.2, so no released version
had the abort bug #4330 fixed. Drop its Fixed fragment and say in the
feature's own entry that the lock is released on abort or redirectTo().

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… abort inside it (#4335)

* fix(model): roll back a Wheels transaction when the request ends with abort inside it

The rollback for a failed transaction method lived in the finally/catch
of a try that also has a catch clause. On BoxLang, abort skips a finally
whose try has a catch clause, so a request that ended with abort inside
invokeWithTransaction() (or a nested call, a savepoint unit, or
withAdvisoryLock(transaction = true)) closed the transaction with the
writes made before the abort. Lucee and Adobe ColdFusion rolled back.

$transactionBody() now runs the method through $invokeTransactionMethod():
a catch-free try/finally around an inner try/catch that records a normal
throw. When the method neither returned nor threw, the outer finally runs
transaction action="rollback". Normal completion is unchanged.

CLAUDE.md gains cross-engine invariant 22 for the rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(model): name the catch clause as what makes BoxLang skip a finally on abort

The release comments in $advisoryLockTransactionBody() said a transaction-action
statement in a finally makes BoxLang skip it on abort. Measured: the trigger is a
catch clause on the try; transaction actions make no difference. The comments now
say so and point at cross-engine invariant 22, and note that invokeWithTransaction()
performs the abort rollback on BoxLang.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…Lang (#4336)

Add the 4.1 to 4.2 entry for #4335: on BoxLang a request that ends with
abort inside invokeWithTransaction() now rolls back, as on Lucee and
Adobe ColdFusion, where 4.1 kept the writes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… the pattern (#4337)

* fix(core): run Seeder and LocalDisk cleanup in a catch-free finally; guard the pattern

BoxLang skips a finally whose try has a catch clause when the request ends with
an abort, so cleanup in a try/catch/finally wrapped around app code can be lost
(see the BoxLang finally/catch rule in CLAUDE.md Cross-Engine Invariants).

Seeder.generateSeeds() (restores request.$wheelsTransactionWrapper) and
LocalDisk.$assertSymlinkResolutionAvailable() (deletes the probe temp dir) both
used a single try/catch/finally. Nest each so the cleanup lives in an outer
catch-free try/finally that runs on every engine on abort, keeping the existing
catch on the inner try.

Add TryCatchFinallyGuardSpec: a structural scan that fails if any production
vendor/wheels source (*.cfc / *.cfm, excluding /tests/ and /wheelstest/) has a
single try with BOTH a catch and a finally. New code that needs both must nest.
withAdvisoryLock's transaction-path outer try is allowlisted, because its lock
release already runs in an inner catch-free finally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* test(security): make the try/catch/finally guard a whole-file brace matcher with a self-test

The line-based frame scan had blind spots (rev1-r3): it missed Allman-style
own-line catch/finally, a same-line catch body followed by a next-line finally,
and a one-line try/catch/finally, and it false-positived a one-line inner
try/catch nested inside a safe outer try/finally. None occur in the tree today,
but a future edit could hit them.

Replace it with a whole-file brace matcher: blank comments and string literals
to spaces in a single char pass (preserving length and newlines, no global
regex — Lucee 7 hang), then for each `try {` brace-match its body and walk the
following catch/finally chain. A try is an offender iff its own chain has BOTH a
catch and a finally, so a nested outer-finally / inner-catch is correctly clean.

Add a self-test that runs the scanner over the four wrong shapes plus the
catch-free, nested-one-line, nested-multiline and try/catch-only clean shapes.
Document that the scan covers script syntax only (the cf-tag try form is not
scanned; the angle-bracket syntax is kept out of comments because Lucee's tag
scanner parses a literal cf-tag even inside one).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…onal advisory-lock body (#4338)

* refactor(model): drop the abort rollback from the transactional advisory-lock body

$advisoryLockTransactionBody() rolled the transaction back in its outer
finally when the callback neither committed nor threw, i.e. when the
request ended with abort. Since #4335, invokeWithTransaction() itself
rolls back on abort ($invokeTransactionMethod()), and this method only
runs through invokeWithTransaction(), so that rollback is redundant.

Removing it also drops the two flags only it read and the outer catch
that only set one of them, which leaves the outer try catch-free
(cross-engine invariant 22). The inner catch-free release on abort or
throw and the release after a failed commit are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* test(core): drop the locking.cfm exception from the try/catch/finally guard

$advisoryLockTransactionBody() no longer has a try with both a catch and a finally,
so the guard needs no exceptions. With develop's locking.cfm and an empty allowlist
the guard fails at the old outer try; with this branch it passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* docs(web): bring the wheels.dev home page up to date with 4.x

Point the guides and API links at the current 4.1 docs instead of 3.0,
use the 4.x command names (wheels migrate, wheels start), and state the
supported engines the 4.x docs list (Lucee 6/7, Adobe ColdFusion
2023/2025, BoxLang, RustCFML) in both places. Reword version-specific
counts so they don't go stale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(web): refresh visual baseline(s) (landing)

Manually triggered baseline refresh via
.github/workflows/refresh-visual-baselines.yml
targeting branch peter/landing-home-fixes.

Run when an intentional content/layout change makes the visual-regression
check fail. The new PNG(s) under web/tests/visual-baselines/ are now the
expected rendering.

* chore: trigger CI after the baseline refresh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
#4342)

* feat(cli): wheels lookup searches the API reference and guides offline

An AI assistant or a person at the terminal had no way to look up a
framework function's parameters or a guide topic without a browser:
the offline HTML bundle (wheels docs fetch) is a ~35 MB download meant
for reading, and the stdio MCP server had no docs tool.

wheels lookup <query> (and the new lookup MCP tool) reads a docs index
shipped inside the CLI module. A function name (findAll, model.findAll)
prints its full entry: signature, parameters, example, link. A phrase
lists the best-matching guide sections and functions with a snippet,
a link and an id that prints the whole entry. --kind, --limit and
--format=json narrow and shape it. No network, server or project.

tools/build/scripts/build-docs-index.mjs builds the index from the
version's API JSON and guides tree. The release and snapshot builds
run it with --strict before the module tarball, so the index always
describes the CLI's own framework version (a snapshot uses the API
JSON its fast-test job generates). The index isn't committed; from a
source checkout, lookup names the build command. The builder has node
tests, and web-scripts-tests builds the index from every PR that
touches the guides or the API JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* ci: build the wheels lookup index before publishing, and keep manual develop runs working

Review fixes. The index step ran after "Publish All Packages to
ForgeBox", so an index failure would leave a version on ForgeBox with no
GitHub release, module or package-manager update behind it, the
half-published state #4314 fixed. It now runs right after the release
checklist, before any prepare or publish step, with its own Node setup.

A manual workflow_dispatch on develop has no fast-test artifact, so
--strict always failed it. The build is strict on main and on develop
when the api-docs-snapshot artifact downloaded; otherwise it builds
from the newest committed API JSON and emits a ::warning:: saying so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(web): self-host the Inter and JetBrains Mono brand fonts

tokens.css names Inter and JetBrains Mono, but nothing loaded them, so
every site rendered in system fonts. Ship both as latin-subset variable
woff2 files in @wheels-dev/ui with font-display: swap, imported from
tokens.css so every site that uses the tokens gets them. No third-party
font request. OFL license texts sit next to the files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(web): refresh visual baseline(s) (all)

Manually triggered baseline refresh via
.github/workflows/refresh-visual-baselines.yml
targeting branch peter/web-self-host-fonts.

Run when an intentional content/layout change makes the visual-regression
check fail. The new PNG(s) under web/tests/visual-baselines/ are now the
expected rendering.

* chore: trigger CI after the baseline refresh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…s own message (#4345)

wheels migrate unlock refuses while a live instance holds the lock. It
printed the holder (host, owner, how long, when the lease expires) and
the --force hint, then threw "held by another instance; nothing was
removed". LuCLI's MCP handler returns only the thrown message and drops
everything printed before it, so an MCP client saw neither the holder
nor the next command. The thrown message now carries both; on the
command line it reads the same.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ase (#4346)

$orderByClause() checked for " ASC" / " DESC" with case-sensitive Find(),
so a lowercase direction was missed and " ASC" was appended:
order="id desc" sorted ascending, and a "table.column desc" item became
"table.column desc ASC" and threw Wheels.InvalidOrderClause. The query
builder's orderBy("id", "desc") reached the same code.

Each order item now goes through $normalizeOrderItem(): whitespace runs
(spaces, tabs) collapse to one space, a trailing asc/desc is read in any
case and emitted in upper case, and an item without one sorts ascending.
The query builder stores the direction in upper case too.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…d font preloads (#4347)

* fix(web): keep the packages grid inside the page; add italic Inter and font preloads

The packages index's third card column ran past the viewport: plain 1fr
grid tracks and the card's flex command box couldn't shrink below the
longest install command. Use minmax(0, 1fr) tracks and min-width: 0 on the
command box, which already scrolls.

Add the latin italic Inter face, so italics are no longer synthesized, and
preload the two main woff2 files on every site (a FontPreload component in
the layouts and a Starlight Head override for guides and api).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(web): refresh visual baseline(s) (packages-index)

Manually triggered baseline refresh via
.github/workflows/refresh-visual-baselines.yml
targeting branch peter/web-fonts-followup.

Run when an intentional content/layout change makes the visual-regression
check fail. The new PNG(s) under web/tests/visual-baselines/ are now the
expected rendering.

* docs(web): note the Starlight version the Head override mirrors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…4344)

* feat(cli): wheels new writes .mcp.json and .opencode.json by default

A new app's AI assistant couldn't see the Wheels MCP server until the
user also ran wheels setup agents, which the scaffold output never
mentioned. wheels new (and wheels create app) now writes the same two
files in the new app's root and lists them in its summary;
--no-agents skips them. A failure to write them only warns, with the
setup agents command to run later: the app works without them.

setup agents' writer moves into $writeAgentConfigs(root, force), which
returns what it wrote without printing, so both commands share it and
setup agents' output is unchanged.

The mcp-integration guide said there was no generated file and that
OpenCode used the mcpServers shape; it now describes wheels new,
setup agents and OpenCode's own shape. The wheels new reference lists
--no-agents and the two files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* test(cli): give NewAppAgentConfigsSpec's scaffolds an explicit port

Review fix. The spec's four real wheels new calls passed no port, so
each scaffold's free-port selection probed the default 8080, and CI's
fallback-port sentinel failed the CLI job on those four connections.
They now pass an OS-assigned free port, as NewPinnedPortsSpec does, and
the mock's $defaultNewPort throws, so a probe of the default port fails
the spec itself rather than only the sentinel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Replace the version list (it named v3.1.0, which was never tagged) with
the newest release of each line: v4.1.2, v4.0.6, v3.0.0+50 and v2.5.1.
Index docs/consumer-ai, the AI reference shipped in apps, and drop two
excluded folders that no longer exist.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…guide (#4353)

The IDE-paths note described OpenCode's shape without "enabled": true,
which wheels new and wheels setup agents also write, and still ended
with "the server definition itself is identical across clients", which
the paragraph before it contradicts. It now shows the whole entry and
says what is the same for every client: the wheels mcp wheels command.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(model): report what the last save wrote with savedChanges()

hasChanged(), changedFrom(), changedProperties() and allChanges() describe
unsaved changes, so a successful save resets them, and an afterCommit had no
way to tell what its commit changed. Apps kept their own flags across a
transaction and got their lifecycle wrong.

savedChanges(), hasSavedChange(), savedChangeFrom() and
savedChangedProperties() are their saved counterparts:
- set right after the row write, before the after* callbacks, so afterSave,
  afterCommit and the caller see the same thing; a failed save puts back the
  previous state, a save that changed nothing reports nothing;
- each transaction queue entry keeps its save's snapshot, so each afterCommit
  sees the save that queued it, and an afterRollback the save that was undone,
  after which the state from before it is restored (also for a model with no
  transaction callbacks);
- on a create, every property the insert wrote, from "";
- reload() and clearChangeInformation() clear it; save(reload=true) keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(model): document a save inside a callback in savedChanges()

A save inside one of the object's own callbacks replaces savedChanges() with
its own changes, which include the outer save's because the outer save hasn't
reset its baseline yet. The guide says so with an example, and a spec pins it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…own for guides and API (#4356)

Serve plain-text docs for language models from the guides and API sites:
an llms.txt index, llms-full.txt (every current-version page),
llms-small.txt (an outline of the current version), one set per docs
version under /llms-txt/, and every page as Markdown at <page URL>.md.
The current version comes from versions.ts. Built by plain Astro
endpoints over the content collection, with no new dependencies. Add a
wheels.dev/llms.txt index, and a CI check that fails the deploy when a
file is missing, a link doesn't resolve, a page lacks its .md, or a
non-current version leaks into llms-full / llms-small.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
bpamiri and others added 25 commits October 23, 2026 11:22
…ir on restart (#4636)

* ci: cache Lucee Express and Maven downloads, stop wiping the server dir

CI pulls hundreds of GB per week from cdn.lucee.org because every job
re-downloads Lucee Express and every post-JDBC restart wipes
~/.lucli/servers (deleting the installed H2 extension and re-fetching it
from Maven). Address issue #4463:

- Cache ~/.lucli/express with actions/cache in pr.yml (fast-test and
  tutorial-e2e), snapshot.yml fast-test, and the setup-wheels-test-env
  composite used by smoke-env and the bot workflows. Keyed on the LuCLI
  template and Lucee CI config, with an OS restore-key; the cache-hit
  value is logged.
- Point Lucee's Maven repository at a path outside the server dir
  (~/.lucli's sibling .lucee-mvn) and cache it, so extension downloads
  survive independent of server state. The Docker/CommandBox matrix
  legs are deferred: their Lucee lives inside the container image, so a
  host Maven cache cannot be mounted without changing matrix behaviour.
- Stop starting the restarted server with --force. The flow is now:
  first start (Express restore/download) -> stop -> install the SQLite
  JDBC driver into the Express lib/ext -> start again without --force, so
  ~/.lucli/servers and its extensions survive. A post-restart find logs
  whether the H2 extension is still present, and a failed restart now
  fails the step with the server log.

The H2 extension version stays at 1.3.172; bumping it is a separate
follow-up.

Signed-off-by: Peter Amiri <peter@alurium.com>

* ci: keep the Lucee Maven cache under runner.temp

actions/cache rejects cache paths containing "..", and the runner context
is not available in job-level env. Set LUCEE_MAVEN_LOCAL_REPOSITORY on the
server-starting steps and cache ${{ runner.temp }}/.lucee-mvn instead, so
the Maven repository is a valid absolute cache path.

Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
…d date form helpers (#4633)

Carries the community fix from @michaeldiederich (PR #4633), with maintainer commits: refactor for the complexity gate, changelog scoping, and a conventional title.

Null values in data passed to renderWith() no longer NPE in the Oracle TIMESTAMP conversion, and the date/time form helpers no longer call isInstanceOf() on null or non-object values. JSON rendering and the form-helper path are fixed; XML rendering of null struct values goes through a separate toXML path and remains a follow-up.

Signed-off-by: Peter Amiri <peter@alurium.com>
Adds the Groundmail SMTP row to the v4.2 sending-email guide, with review edits: Groundmail in the prose provider list, a vendor-contribution disclosure, and the sandbox note rewritten around the concrete 403 behaviour. Also carries a web-deploy CI fix so fork PRs skip the Cloudflare publish step (the Build and llms checks still run) — fork runs cannot read CLOUDFLARE_API_TOKEN, which red-X'd every Deploy job on web/** changes.

Contribution by @Ktryberceo.

Signed-off-by: Peter Amiri <peter@alurium.com>
…up by (#4637)

#4634: with include=, the duplicate-prefix pass split the select clause with
ListToArray, so an expression such as
CONCAT(IF(ISNULL(titel.ausgabe)=1, '', CONCAT(titel.ausgabe, ' ')), kontakt.nachname)
was split into fragments, the tail was treated as a duplicate property and
dropped, and the generated SQL was invalid. Remove the second, duplicated
inline copy of that pass in $createSQLFieldList and split both it and the
distinct group-by item list with $splitOutsideFunctions(), which keeps
function arguments together. SQL without comma-bearing functions is unchanged.

Regression specs cover the reported expression shape, a plain select item
before it, several comma-bearing functions side by side, and a distinct
group-by that must not receive split subquery fragments.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: bpamiri <180555+bpamiri@users.noreply.github.com>
…4647)

Extra query columns (custom select aliases, joined columns, driver metadata)
were appended using the raw query columnList casing, so on Lucee JSON output
carried e.g. SGP_ID while property-backed keys used the canonical property
casing. Extra-column keys now follow resetPropertiesStructKeyCase: lowercased
when enabled (the default), raw as before when disabled. The property loop and
the joinAlias path already use canonical property names; the setting is read
once per row, not once per column.

No public API change, but camel-cased select aliases now serialize lowercased
by default, which the changelog calls out.

Signed-off-by: Peter Amiri <peter@alurium.com>
… useTestDB (#4648)

#4643: $testDataSourceDecision() returned "swap" as soon as <primary>_test was
registered, before consulting allowTestsAgainstPrimaryDatasource, so the
documented 4.2 behavior was unreachable whenever a test datasource existed.
For an omitted useTestDB the opt-out is checked first and runs on the primary
datasource with a warning; explicit useTestDB=true still requires the test
datasource and explicit useTestDB=false still targets the primary. The
framework's own suite passes ignoreAllowPrimary, so it keeps using
<primary>_test and still refuses when it is missing.

#4642: the DebugBar Test Suite panel's "Open full runner" link and in-page JSON
runner URL now include useTestDB=false when the app allows tests against the
primary datasource, so the same decision applies there; otherwise both URLs are
unchanged.

Regression specs cover the omitted/true/false x allowPrimary x candidate
combinations and render the debug bar to assert both URLs.

Signed-off-by: Peter Amiri <peter@alurium.com>
#4651)

#4629 item 2: the association JOIN memo key only carried soft-delete and alias
state, so two H2 tenants with different DATABASE_TO_UPPER/DATABASE_TO_LOWER
folding shared one memo entry and the second reused the first tenant's quoted
JOIN text. Append the adapter's quoting mode to the key.

Add $identifierCase() to databaseAdapters/Base (default "", meaning quoting is
datasource-independent) so the shared SQL builder never calls an H2-only method;
H2 already overrides it.

#4629 item 3: extract H2 $classifyIdentifierCase(settings) from
$readIdentifierCase and cover every verified settings shape with a table-driven
spec, including the H2 1.3.x case where DATABASE_TO_LOWER is absent and quoted
identifiers are case-insensitive.

Item 1 (migrator DDL quoting) and item 4 (fixture DELETE hygiene) are separate
follow-ups per the brief and are not changed here.

Signed-off-by: Peter Amiri <peter@alurium.com>
…acement (#4646) (#4649)

* fix(engineAdapters): upper-case by walking characters, not regex replacement

Adobe CF 2023 update 26 / 2025 update 14 changed regex REPLACEMENT parsing: the
backslash-u case escape now emits a literal "u", so the replacement-based
casing in Wheels produced a controller named "main" -> "umain", corrupted
hyphenated controller/action names, and prefixed /wheels/info setting labels
with "u". Replace the four affected sites with a manual character walk:

- add $upperCaseAfterDash() to engineAdapters/Base.cfc (public, inherited by
  every adapter) and use it in controllerNameToUpperCamelCase();
- use it in the BoxLang adapter override;
- use it for route action camel-casing in Dispatch.$ensureControllerAndAction();
- build the outputSetting() label with a manual loop in public/helpers.cfm.

Output is identical to the previous replacement on pre-patch engines, including
leading/repeated/trailing dashes, digits, uppercase and non-ASCII characters
after a dash. Regression specs pin the patch-26 shapes, render the info labels,
and guard the four files against the removed escape. The follow-up \l/\U
replacements (hyphenize, flash keys, named-route derivation) stay for separate
PRs per #4646.

Signed-off-by: Peter Amiri <peter@alurium.com>

* test(engineAdapters): harden the #4646 casing specs

Use toBeWithCase() for every string expectation so case regressions cannot pass
silently, correct the non-ASCII expectation (café-über -> Café-über), add the
guarded differential against the legacy regex on engines where it still parses,
and cover the base and BoxLang adapters directly, including ".user"/"..user".

Signed-off-by: Peter Amiri <peter@alurium.com>

* test(engineAdapters): make the #4646 specs engine-aware

Adobe's compiler rejects the BoxLang adapter's internal `arguments.object`
argument, so instantiate that module only on BoxLang and assert the base
adapter's dot handling elsewhere. On BoxLang the live adapter legitimately
works on the whole dotted name, so assert its own expected output.

Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
…alues (#4638) (#4650)

* fix(controller): render null XML values instead of throwing

Follow-up to #4633 (#4638). renderWith()'s JSON branch is null-safe, but the XML
branch still passed null struct/array/query/component values into toXML, whose
required data argument rejects null and whose branches read it directly. Guard
each recursion with isNull() and emit a type="null" element; a null top-level
value renders as empty output in $toXml(). The date-select helpers also coerce
a null bound value to "" before rendering, so no null reaches the select
renderers.

Regression specs cover renderWith() JSON and XML with null struct values (JSON
already passed on this head; XML failed before this change) and dateSelect()
with a null bound property. Add a changelog fragment.

Signed-off-by: Peter Amiri <peter@alurium.com>

* test(controller): assert the null XML element without formatting

BoxLang indents the generated XML differently, so match type="null" with a
format-agnostic FindNoCase instead of a contiguous element string.

Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(controller): return a null XML document for a root-level null

$toXml() is declared xml, so returning "" for a null root value throws
"Cannot cast String [] to a value of type [xml]" on null-capable engines. Return
a well-formed null document instead (mirroring the nested toXML element) and
keep the changelog wording accurate.

Cover it with a root-null spec that skips where a null cannot be passed to a
required argument (Lucee null support off, RustCFML), plus a source guard that
always checks the declared document shape. Strengthen the formsdate spec so a
null bound property must render exactly like an empty value and produce the
full 12-option month select.

Signed-off-by: Peter Amiri <peter@alurium.com>

* test(global): probe null support instead of catching $toXml failures

The root-null spec wrapped $toXml in try/catch and skipped on any exception,
so a regression on a null-capable engine (the xml-cast error) would be skipped
rather than fail. Probe null-argument support with an unrelated helper, skip
only when the engine cannot pass null, then call $toXml outside any try/catch
and assert toBeXML() + the null element. Keep the static source guard as the
cross-engine pin.

Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
…4646) (#4652)

* fix: remove \l/\U replacement case flags from names and flash keys

Adobe CF 2023 update 26 / 2025 update 14 parse the \l and \U replacement case
flags as literal letters, so hyphenated route names became
"wheels-l-lis-la-l-lframework", flash keys became "lSuccess", and named route
derivation produced "adminUposts". #4649 fixed the \u sites; this covers the
remaining \l/\U sites.

- hyphenize(): drop the redundant \l flags - the final LCase already
  lower-cases the inserted letters and the hyphen insertion means the second
  pass never depends on the first pass's case.
- redirection.cfc: lower-case the first character after the flash prefix by
  hand (Len > 5 mirrors the old required sixth character; CompareNoCase
  mirrors the case-insensitive prefix).
- mapper/matching.cfc: add $upperCaseAfterComma() and use it for named routes;
  it trims comma runs, removes them, and upper-cases the following word
  character using the same engine \w class as the old pattern.

Output is identical on unpatched engines (guarded differential + explicit
expected outputs in the specs). No public API change.

Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(controller): pass an explicit length to Mid for Adobe validation

Adobe's MID parameter validation rejects the two-argument form used in the
flash-key rewrite: onApplicationStart failed with FunctionArgMismatchException
and every request returned 500 on adobe2023/adobe2025. Pass Len(item) - 6
explicitly; the resulting key is unchanged.

Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(controller): use RemoveChars for the flash key tail

Follow-up to the Adobe MID fix: prefer RemoveChars(item, 1, 6) over the counted
Mid tail (no zero-length slicing for the six-character "flashX" input), add the
flash1 and flash- cases to redirectionSpec, guard the three follow-up files
against reintroducing the \l/\U replacement case flags, and add non-ASCII
entries to the named-route differential corpus.

Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
…ord fixture (#4653)

- AdapterSqlGenerationSpec: add lower-case and whitespace-padded
  DATABASE_TO_LOWER="false"/DATABASE_TO_UPPER="false" cases. The lower branch
  returns before reading DATABASE_TO_UPPER, so the none branch's UCase(Trim())
  normalisation was previously unexercised.
- OrderReservedWordSpec: drop the blanket DELETE FROM c_o_r_e_casepreservation.
  The reads are scoped to the ids the test creates, and beforeAll now creates a
  sentinel row that the test must leave alone. This spec is the only writer to
  the table (propertyCasePreservationSpec reads metadata only), so removing the
  blanket delete cannot change another spec's data, and a future seeded fixture
  row is now safe.

Signed-off-by: Peter Amiri <peter@alurium.com>
…4654)

Phase 1 of #4629 item 3 (cross-adapter DDL quoting, H2 only). The H2 migrator
now quotes generated-DDL identifiers in the datasource's own fold case, so
migrations can create reserved-word tables and columns and still resolve
existing unquoted schemas (H2 stored those folded). With folding off it keeps
the legacy unquoted objectCase path.

- add internal $ddlIdentifierCase()/$quoteDdlIdentifier() to migrator/Base
  (empty by default; other migrators inherit but never call them)
- H2Migrator overrides them using the H2Model fold probe (cached per datasource,
  no duplicated classifier), quotes table/column names, and routes
  rename/renameColumn/changeColumn/dropForeignKey DDL through them
- migratorObjectCase applies only when quoting is inactive

Unit coverage uses H2MigratorCaseProbe for upper/lower/none; live H2 specs cover
a reserved table/column, a legacy unquoted schema, up/down symmetry and the
objectCase interaction. Non-H2 output is unchanged.

Signed-off-by: Peter Amiri <peter@alurium.com>
…#4655)

Phase 2 of #4629 item 3 (cross-adapter DDL quoting). The PostgreSQL and
CockroachDB model adapters quote identifiers lowercase unconditionally, so the
migrators now emit the same, using phase 1's Base seam ($ddlIdentifierCase() /
$quoteDdlIdentifier()).

- PostgreSQLMigrator: $ddlIdentifierCase() returns "lower"; quoteTableName()
  splits schema-qualified names and quotes each part; quoteColumnName() quotes
  one identifier (embedded double quotes doubled by the Base helper); remove the
  remaining objectCase wrappers in changeColumnInTable/dropForeignKeyFromTable.
- CockroachDB inherits everything from PostgreSQLMigrator; no file change.
- migratorObjectCase is ignored while quoting is active (always for PG/Cockroach).
  Other adapters are untouched.

Unit coverage in AdapterSqlGenerationSpec for every inherited DDL path plus
Cockroach inheritance; live PostgreSQL/Cockroach specs cover a reserved
table/column, a legacy unquoted schema, up/down symmetry and the objectCase
interaction.

Signed-off-by: Peter Amiri <peter@alurium.com>
Phase 3 of #4629 item 3 (cross-adapter DDL quoting, final phase). Oracle model
adapters quote identifiers uppercase unconditionally, so generated migration DDL
now does the same through the phase 1 Base seam ($ddlIdentifierCase() /
$quoteDdlIdentifier()).

- OracleMigrator: $ddlIdentifierCase() returns "upper"; quoteTableName() splits
  schema-qualified names and quotes each part; quoteColumnName() quotes one
  identifier (embedded double quotes doubled by the Base helper); createTable
  quotes the table and TABLESPACE; the dropTable/dropView PL/SQL blocks, add
  column/foreign key, addIndex, removeIndex, renameTable and
  dropForeignKeyFromTable all route through the quote methods.
- migratorObjectCase is ignored while quoting is active (always for Oracle).
  Other adapters are untouched.

Unit coverage in AdapterSqlGenerationSpec for every Oracle DDL path and
OracleMigratorDropSpec for the quoted PL/SQL drops; live Oracle specs cover a
reserved table/column, a legacy unquoted schema, up/down symmetry and the
objectCase interaction when Oracle infrastructure is available.

Signed-off-by: Peter Amiri <peter@alurium.com>
…4661)

tools/ci/cli-suite-request.sh created its status-code file with a bare
mktemp. On macOS, /usr/bin/mktemp with no template writes to the per-user
Darwin temp directory under /var/folders and ignores TMPDIR, so a caller
whose sandbox only permits writes under its own TMPDIR (or /tmp) failed
before the suite request was sent, and tools/test-cli-local.sh reported
no results. Use "${TMPDIR:-/tmp}/cli-suite-code.XXXXXX", which honours
TMPDIR on every platform and keeps the old behaviour when it is unset.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The deploy-ci change detector decides whether a pull request runs the CLI
suite, but its path list missed two scripts that tools/test-cli-local.sh
executes on every run: tools/ci/cli-suite-request.sh (sends the suite
request) and tools/ci/fetch-sqlite-jdbc.sh (installs the SQLite driver when
it is absent). A pull request touching only one of them skipped the suite
that exercises it. Add both to the pattern.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The wheels analyze section described the report but never said which
rules it applies, and pointed readers at a "top-10" list that no longer
exists under that name. Name the anti-patterns directly and add a "What
it checks" table with each rule id, category, severity and trigger, plus
the thresholds (complexity 10, file length 500, parameters 5) and the
paths the scanner skips, all taken from cli/lucli/services/Analysis.cfc.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The todo-comment code smell in wheels analyze matched TODO, FIXME, HACK,
XXX and BUG with no word boundary after the marker, so ordinary comments
such as "// BUGGY path", "// TODOS list", "<!--- HACKATHON demo --->" and
"// XXXL size" were reported as technical debt. Anchor the marker with a
word boundary before the optional colon. The real forms (// TODO: x,
// FIXME x, <!--- HACK --->, // BUG: y, //TODO) are still detected.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(cli): detect analyze todo markers in block and doc comments

The todo-comment code smell in wheels analyze only looked for markers
after // and <!---. A /* TODO ... */ block comment or a doc-comment
continuation line (" * FIXME ...") was never reported, although wheels
notes already finds both. Extend the pattern to those two forms, keep
the marker a whole word, and report a doc-comment match on its own line
(the match starts at the preceding newline).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(cli): describe the analyze todo-comment rule's comment forms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.8 to 3.1.9.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](fastify/fast-uri@v3.1.8...v3.1.9)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(rustcfml): bump engine pin to v0.744.0

* ci: trigger checks for the RustCFML pin bump

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: bpamiri <180555+bpamiri@users.noreply.github.com>
Co-authored-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Migration-generated DDL now quotes identifiers on H2 (datasource fold
case), PostgreSQL and CockroachDB (lowercase) and Oracle (uppercase), so
reserved words such as order and group work as table and column names.
Add a section to the migrations guide with a per-database table of the
quoting and of whether migratorObjectCase still applies (MySQL, SQL
Server and SQLite already quoted and still honour it), plus an example.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ct (#4666)

tools/test-local.sh and tools/test-cli-local.sh accept LUCLI_HOME or
WHEELS_HOME as an isolated CLI home. With only WHEELS_HOME set, the
override reached the runtime through -Dlucli.home and user.home, but
LUCLI_HOME was not exported, so a child process that reads it (a nested
wheels call, or a spec shelling out) fell back to its own default home.
Export the resolved override as LUCLI_HOME in override mode. Default mode
is unchanged.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Run tools/changelog-promote.sh 4.2.1 2026-10-30 on develop 4c782de:
19 changelog.d fragments become the [4.2.1] section (Changed, Fixed).
The two Adobe CF 2023 u26 / 2025 u14 lines lead the Fixed list, as the
fix this release ships for.

Add docs/api/v4.2.1.json. release.yml builds the wheels lookup index
with --strict --version 4.2.1, which reads docs/api/v<version>.json and
fails the release without it. The API reference the framework generates
at 4c782de (snapshot run 41106853316) is identical to v4.2.0.json, so
this is a copy of it.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@bpamiri bpamiri changed the title Release 4.2.1 chore: release 4.2.1 Oct 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Wheels Test Matrix

Engine MySQL PostgreSQL SQL Server H2 CockroachDB Oracle SQLite
lucee6 ✅ ✅ ✅ ✅ ✅ ✅ ✅
lucee7 ✅ ✅ ✅ ✅ ✅ ✅ ✅
adobe2023 ✅ ✅ ✅ -- ✅ ✅ ✅
adobe2025 ✅ ✅ ✅ -- ✅ ✅ ✅
boxlang ✅ ✅ ✅ -- ✅ ✅ ✅
lucee7 (TZ America/New_York) ✅
adobe2025 (TZ America/New_York) ✅
boxlang (TZ America/New_York) ✅

A ':x: N tests' cell means the leg reported fewer than 4000 testcases (suite never ran; counted as failed). ':x: no result' means the leg returned no parseable result.

Results for commit 3dd7c88.

@github-actions

Copy link
Copy Markdown
Contributor

Wheels Test Results

263 970 tests   257 673 ✅  1h 50m 37s ⏱️
 21 350 suites    6 297 💤
     35 files          0 ❌

Results for commit 04bcda8.

@bpamiri
bpamiri merged commit abf7fa2 into main Oct 31, 2026
63 checks passed
@bpamiri
bpamiri deleted the release/4.2.1-to-main branch October 31, 2026 02:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants