Skip to content

Release 4.0.0 — merge develop into main - #2607

Merged
bpamiri merged 1079 commits into
mainfrom
release/v4.0.0-to-main
May 12, 2026
Merged

bpamiri merged 1079 commits into
mainfrom
release/v4.0.0-to-main

Conversation

@bpamiri

@bpamiri bpamiri commented May 12, 2026

Copy link
Copy Markdown
Collaborator

TL;DR

Cut the 4.0.0 GA. This PR carries a single merge commit (d7a6b9a68) that brings all of develop into main. When this PR lands, release.yml fires on the resulting push to main, builds the GA artifacts, creates the v4.0.0 tag, and publishes the GitHub Release.

Why a merge commit instead of fast-forward

main and develop diverged when 3.x-line hotfixes (#1937 and ancestors — the throwOnColumnNotFound work) landed on main but were re-implemented independently on develop (CHANGELOG cites #1938 for the 4.0 version of the same feature). Last common ancestor was the v3.0.0 cut on 2026-01-10.

That divergence means git merge --ff-only is structurally impossible. The merge commit on d7a6b9a68 was constructed with git merge --no-ff -X theirs origin/develop, then the tree was reset to exactly match develop's tree for the handful of paths where 3-way merge took content from main's 3.x hotfix history that wasn't part of the planned 4.0 scope. The final tree is byte-identical to develop's at 3914c9080.

Pre-release archives preserve both tips:

Pre-flight checks (all passing)

  • CHANGELOG.md has # [4.0.0]...=> 2026-05-12 (no => TBD) — release.yml validation passes
  • wheels.json version is "4.0.0" (no -snapshot suffix)
  • package.json version matches at "4.0.0"
  • All six first-party packages declare wheelsVersion >=4.0
  • Release-notes awk extraction terminates at the right boundary (200 lines for the 4.0.0 entry)
  • Tree matches origin/develop exactly (git diff origin/develop returns nothing)
  • Merge commit carries DCO Signed-off-by: Peter Amiri <peter@alurium.com>

How to merge this PR

Use "Create a merge commit" (not squash, not rebase). This preserves d7a6b9a68's structure as a parent of main HEAD, keeping develop's history reachable from main. Squash would collapse d7a6b9a68 into a single new commit and lose the parentage link; rebase would replay onto main and conflict with the existing merge structure.

The resulting main HEAD will be a new merge commit whose first parent is 858bfb430 (main's current HEAD) and second parent is d7a6b9a68 (this branch's HEAD). The tree of both will be identical to develop's tree.

What fires after merge

  1. release.yml triggers on the push to main
  2. Validates CHANGELOG (=> TBD check) and wheels.json (no -snapshot suffix) — both already pass
  3. Builds wheels-core, wheels-cli, wheels-base-template, wheels-starter-app, wheels-module, .deb, .rpm
  4. Publishes ForgeBox packages
  5. Creates the v4.0.0 tag at main HEAD via softprops/action-gh-release
  6. Publishes the GitHub Release (marked as latest)
  7. Dispatches wheels-released to homebrew-wheels and scoop-wheels repos → their auto-bump workflows open PRs within ~5 min
  8. bump-develop-version.yml opens a PR against develop bumping wheels.json to the next-patch baseline (likely 4.0.1)

Post-merge smoke test

Per .github/RELEASE_PLAYBOOK.md:

# wait for the brew tap auto-bump PR to merge, then:
brew update && brew upgrade wheels
wheels --version    # should report 4.0.0 (stable)

mkdir /tmp/release-smoke && cd /tmp/release-smoke
wheels new myapp && cd myapp && wheels start
# visit http://localhost:8080 — welcome page

Bypass note

A direct git push origin main was attempted first (the playbook flow) but rejected by the main ruleset ("Changes must be made through a pull request."). This PR is the channel through which the ruleset wants the merge to land. The tag was manually pre-pushed and then deleted; release.yml will recreate it during the post-merge build.

bpamiri and others added 30 commits April 23, 2026 21:15
…rtial) (#2277)

* docs(docs): add phase 4 design spec for wheels-packages discovery UI

Proposes the approach for #2271: wheels.dev/packages listing+detail
pages plus a "Browse registry" section on the in-app /wheels/packages
view. Two separate fetchers (wheels.dev RegistryClient; in-app reuses
the CLI's Registry.cfc directly) against the same GitHub registry, 24h
cache, CLI-only install path.

* docs(docs): defer wheels.dev portion of #2271, add in-app plan

Revises the Phase 4 spec after discovering wheels.dev is now four Astro
static sites under web/sites/, not a CFML app. The in-app browse-registry
section ships first; the wheels.dev Astro work needs its own brainstorm
against the new architecture (build-time fetch + repository_dispatch).

Adds the implementation plan for the in-app half.

* feat(cli): add Registry.listAll() returning enriched package summaries

* feat(view): add $loadRegistryPackages helper for in-app browse UI

* refactor(view): narrow $loadRegistryPackages catch to typed registry errors

* feat(view): add browse-registry section to /wheels/packages

* fix(view): harden homepage/onclick rendering in browse-registry section

* fix(view): use application.wheels.public (no $) in browse-registry view

* docs(docs): note in-app browse-registry section for #2271

* fix(view): silently disable browse-registry when CLI classpath absent

* docs(docs): clarify classpath behavior in phase 4 spec
* docs(docs): add packages.wheels.dev site design spec (#2271)

Brainstorm output for the deferred wheels.dev half of #2271. Proposes a
fifth Astro static site alongside blog/api/guides under web/sites/,
deployed to Cloudflare Pages as wheels-packages, rebuilt via
repository_dispatch fired from the wheels-packages registry repo on
manifest merges. Card-grid listing, detail pages with rendered README
through the unified/remark/rehype pipeline. No runtime, build-time
fetch, fail-loud on registry errors.

* docs(docs): add implementation plan for packages.wheels.dev site

* feat(docs): scaffold packages.wheels.dev Astro site

* feat(docs): add vitest to web workspace for unit tests

* chore(docs): add uniform test script across all web sites

* feat(docs): add registry.ts build-time fetcher for packages site

* fix(docs): include URL in malformed-manifest error; document cwd gotcha

* feat(docs): add markdown.ts for sanitized README rendering

* test(docs): pin sanitizer behavior and empty-input handling

* feat(docs): add listing page + PackageCard for packages site

* fix(docs): use correct @wheels-dev/ui tokens; cache copy-button label

* feat(docs): add [name].astro detail page with version history and README

* fix(docs): add table caption; promote .visually-hidden to BaseLayout

* feat(docs): wire packages site into web-deploy matrix with registry dispatch

* test(docs): add visual-regression baselines for packages site

Adds two SITES entries (packages-index + packages-wheels-sentry) to the
visual-regression harness and captures their baseline PNGs. Without these
baselines the visual-regression CI job fails for the new packages.wheels.dev
site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(docs): guard against manifest.name vs directory-name drift

* test(docs): update packages visual baselines from CI (Linux rendering)

* feat(web): add packages.wheels.dev to shared header + footer nav

* test(web): refresh visual baselines after shared nav update

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(docs): update packages guide for accuracy and 4.1 reality

Closes #2244.

The packages.mdx guide was shipped before several key pieces landed: the
`wheels packages` CLI, the wheels-i18n and wheels-seo-suite conversions
to 4.0 format, and before the `view` mixin-target claim was noticed as
unsupported. This refresh brings the guide into sync with reality.

Substantive changes to `packages.mdx`:

- Remove `view` and `service` from the `provides.mixins` target list —
  neither is in PackageLoader's allowlist
  (application,dispatch,controller,mapper,model,base,sqlserver,mysql,
  postgresql,h2,test plus the specials global/none). Explain that
  `controller` already covers views since Wheels views run in the
  controller's variables scope.
- Replace the per-method override example's `mixin="view"` with realistic
  `mixin="model"` and `mixin="none"` examples.
- Replace "Until Wheels 4.1 lands / gh repo clone as interim" with the
  real `wheels packages install|list|search|show|update|remove|registry`
  CLI that shipped in #2276.
- Update first-party package list: drop "wheels-i18n is planned for
  conversion" (shipped as v2.0.0) and add both wheels-i18n and
  wheels-seo-suite to the list of shipped packages.
- Add dedicated sections: Service providers (register/boot lifecycle
  with ServiceProviderInterface) and Lazy loading (the `lazy: true`
  manifest flag for service-only packages).
- Rewrite the testing section: replace the broken `directory=vendor.*`
  URL-param pattern (tracked by #2280) with a BDD spec example and a
  caution box pointing at the issue, plus guidance to rely on
  per-package CI.
- Rewrite the publishing section with the actual registry submission
  checklist and flow (public repo, v<version> tag, file-type allowlist,
  10 MB cap, PR title, mirror workflow bot-commits tarball+sha256).
- Add a `lazy` field entry and mention `dependencies` ordering more
  explicitly in the manifest reference.

Smaller fixes:

- `glossary.mdx`: "Mixin target" entry no longer lists the non-existent
  `view` target.
- `upgrading/3x-to-4x.mdx`: update `mixin="view"` annotation example to
  `mixin="model"` / `mixin="none"`.
- `CLAUDE.md`: add a cross-link to the public packages guide and to
  wheels-packages/CONTRIBUTING.md, and refresh the first-party package
  list to include wheels-i18n and wheels-seo-suite.

No code changes — docs and instructions only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(docs): refresh packages-index visual baseline

The /packages landing page now renders wheels-i18n and wheels-seo-suite
tiles alongside the original four first-party packages (shipped earlier
today via wheels-dev/wheels-packages#3 and #4, closes #2268 and #2267).

The existing baseline predates those registry entries, so visual-regression
reports 34,529 pixels differ. Diff reviewed — only the two new package
tiles; layout, typography, header, and existing tiles unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…2282)

* ci(config): auto-refresh packages-index baseline on registry updates

Every time a new package is added to wheels-dev/wheels-packages the
registry fires a repository_dispatch at this repo, which rebuilds
packages.wheels.dev via the existing Deploy packages job. But the
committed visual-regression baseline at
web/tests/visual-baselines/packages-index.png was captured BEFORE the
new package tile, so the next unrelated PR hits a spurious visual-
regression failure (happened on #2281 today, following #2267 and #2268).

Add a `refresh-packages-baseline` job that runs after `deploy` on the
same registry-updated dispatch. It builds the packages site, regenerates
just the packages-index baseline via the existing visual-regression
script's --site flag, and if the PNG changed, opens a chore(docs) PR
with the refreshed baseline.

Security: the job intentionally does not interpolate the
github.event.client_payload into any shell-expanded string. Commit
messages and PR bodies are built via printf into temp files and passed
via --body-file / -F, so no heredoc/indentation traps and no injection
surface if the dispatcher ever comes from somewhere other than the
trusted wheels-packages repo.

CI gotcha: PRs opened by GITHUB_TOKEN don't trigger other workflow runs
(GitHub's infinite-loop guard). Reviewers should eyeball the diff before
merging; switch to a PAT if auto-CI on the bot PR becomes necessary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(config): move refresh-packages-baseline to its own workflow file

Having the job inside web-deploy.yml with an `if:` guard on
repository_dispatch caused it to show up as a "skipping" check on
every regular PR (noise in the checks list, potentially confusing for
branch-protection rules). Moving it to its own workflow file — which
only subscribes to `repository_dispatch: registry-updated` and
`workflow_dispatch` (manual) — means the job never shows up in PR CI.

Behavior is unchanged; same job, same security posture (env-var guards,
printf-into-tempfiles for commit/PR text).

Also added `workflow_dispatch:` so you can trigger a one-off refresh
from the Actions tab if needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes #2249.

Issue 2249 asked whether to keep or remove `"lazy": true` support in
package.json. Decision: keep. The feature is small (~15 lines in
PackageLoader), legitimately useful for third-party service-only
packages, and zero audit debt — no first-party package currently
qualifies, so we're not carrying adoption weight for an unused feature.

## What's already in place

- **Guide:** `web/sites/guides/src/content/docs/v4-0-0-snapshot/digging-deeper/packages.mdx`
  has a dedicated `## Lazy loading` section and the `lazy` field is
  documented in the manifest reference (added in #2281).
- **Tests:** `vendor/wheels/tests/specs/packages/PackageLoaderSpec.cfc`
  already covers the happy path: eager-skip, `isPackageLoaded`, and
  on-demand instantiation via `getPackage()`.

## What this PR adds

- **CHANGELOG entry** under Unreleased / Changed documenting the
  retain-and-document decision, first-party audit result (no candidates),
  and pointer to the guide.
- **Defensive spec** for the `canBeLazy` gate: a package declaring
  `lazy: true` alongside `mixins: "controller"` must still be loaded
  eagerly — if the lazy flag were honored the mixin tables would be
  incomplete at boot. New fixture at `tests/_assets/packages/lazyignored/`.

## Audit of first-party packages

All six first-party packages declare `mixins: "controller"` and
therefore cannot be lazy-loaded (lazy requires mixins=none and no
middleware). No conversion work to do:

| Package | mixins | Candidate? |
|---|---|---|
| wheels-sentry | controller | no |
| wheels-hotwire | controller | no |
| wheels-basecoat | controller | no |
| wheels-legacy-adapter | controller | no |
| wheels-i18n | controller | no |
| wheels-seo-suite | controller | no |

The feature remains valid and tested for any future third-party
service-only package that benefits.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
)

Extends the #2256 allowlist check to per-method mixin metadata overrides
in $collectMixins. A typo like `function foo() mixin="controler" {}` is
no longer silent — the package is recorded in failedPackages with an
error naming the package, method, unknown target, and the allowlist.

$validateMixinTargets gains an optional methodName context param; the
per-method call happens in a validation pre-pass so a typo on method N
cannot leave methods 1..N-1 half-registered in variables.mixins.

Closes #2257.
…extends (#2285)

The static `checkMixinCollisions` scan in Doctor.cfc missed three real
blind spots against the runtime `PackageLoader.$collectMixins` semantics:
per-method `mixin="..."` attributes (including `mixin="none"`), methods
inherited via in-package `extends`, and function-like text inside
block-comment docblocks producing false positives.

The scanner still stays text-based (no `CreateObject().init()` — packages
can have wheels-scope dependencies that fail pre-boot). The upgrade is
semantic: strip `/* ... */` comments, walk balanced parens to capture
per-method attributes in the signature tail, and follow `extends="X"`
within the package dir with cycle and depth guards. Last-write-wins in
the method map matches runtime inheritance where the child's redeclared
method shadows the parent's.

Residual limitations documented in the function docblock: out-of-package
`extends` and runtime-composed methods are still invisible statically,
but the authoritative runtime detection already catches both.

Closes #2260
The wheels-packages registry updated and the rebuilt packages.wheels.dev
index now differs from the committed visual-regression baseline. This is
the expected change — new package tiles on the listing page.

Refreshed automatically by .github/workflows/refresh-packages-baseline.yml
on registry-updated dispatch 104fcb3.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…#2287)

* feat(cli): add --verbose gating to wheels doctor mixin collisions

Default output now shows a single-line count summary; full per-collision
detail is enumerated only under `wheels doctor --verbose`. Structured
collision entries live on `results.mixinCollisions` so the service stays
presentation-neutral.

Closes #2261

* fix(cli): gate packages extraction on tar exit code, not stderr

`$extract()` threw on any non-empty stderr, which tripped on GNU tar's
informational warnings ("Ignoring unknown extended header keyword
'LIBARCHIVE.xattr.com.apple.provenance'") when extracting macOS-authored
tarballs on Linux. Extraction still succeeded — exit code 0 — so the
stderr check was spurious.

Switch to cfexecute's `result=` arg and throw only when `exitCode != 0`,
preserving stderr in extendedInfo for diagnostics.

Fixes 4 CI errors in InstallerSpec / PackagesMainCliSpec that were
previously masked by TestBox's negative totalError accounting.
…#2288)

The GitBook source tree under docs/src/ was deleted in PR #2189 when the
wheels.dev publishing pipeline was retired, but the in-app /wheels/guides
endpoint still read from it — every fileExists() returned false, so the
page silently rendered the "Select a guide from the navigation menu"
placeholder fallback.

Rewrite guides.cfm to redirect HTML callers to guides.wheels.dev with a
meta-refresh + visible section list, and return a sidebar-derived JSON
summary to AI/MCP callers. ai.cfm::getGuidesSummary() now flattens the
Starlight sidebar into 79 guide entries with external URLs. Delete the
GitBook-era renderer (layouts/guides.cfm, layouts/guides-plain.cfm,
DocsHelper.cfc) — ~700 lines no longer used.

Runtime-verified via LuCLI: HTML landing renders, JSON returns the 10
top-level sections + deep-link URL for ?path=, /wheels/ai lists 79
flattened guides.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`$parseTarget` called `Left(target, Find("@", target) - 1)` without
guarding against targets starting with `@` (e.g., `wheels packages
install @1.0.0`). That evaluates to `Left(str, 0)`, which crashes on
Lucee 7 per the documented gotcha in CLAUDE.md and produces a cryptic
empty-name install attempt on other engines.

Throw `Wheels.Packages.BadInput` explicitly when the name segment is
empty, with a message that points at the correct syntax. Preserves the
existing valid-input paths (no `@`, or `name@version`).

Adds a regression spec in PackagesMainCliSpec.
…ut (closes #2279) (#2289)

wheels new copied vendor/wheels/box.json verbatim from the resolved framework
source. When that source was a dev checkout of the wheels-dev/wheels monorepo,
the unreplaced @build.version@ placeholder propagated into the new app — the
release pipeline substitutes it at build time, but raw checkouts do not. The
framework's $readFrameworkVersion() fallback only identifies the monorepo when
the enclosing box.json is the monorepo's own, never true inside a generated
app, so the homepage reported "0.0.0-dev" forever.

copyFrameworkToVendor now delegates to a new FrameworkInstaller service that
post-processes the copied box.json: if it still contains @build.version@ AND
the source's enclosing root box.json identifies the monorepo (slug=wheels or
name=Wheels.fw) AND has a real version, rewrite the copy's placeholder to
"<rootversion>-dev". Released bundles (real version already substituted) and
third-party layouts (no monorepo markers) early-return, so no new failure
modes.

Isolated the rewrite into a service so specs can exercise it without
instantiating Module.cfc (which requires a modules.BaseModule mapping only
present at LuCLI runtime).
)

The core test runner at /wheels/core/tests hardcoded
directory="wheels.tests.specs" when instantiating TestBox, so the
documented `&directory=...` URL param was silently dropped. Package
specs in vendor/<name>/tests had no HTTP entry point even though
docs advertised the pattern.

Read url.directory and pass it through, guarded by a conservative
allowlist (wheels.tests.* or vendor.<pkg>.tests.*) since the endpoint
is unauthenticated — defense-in-depth against arbitrary CFC
compilation through whatever mappings happen to be registered.

Verified end-to-end against a fresh LuCLI server:
- baseline full suite: 3327 pass / 182 bundles / 24.2s
- &directory=wheels.tests.specs.model: 803 pass / 32 bundles / 1.6s
- &directory=vendor.wheels-fake-pkg.tests: 1 pass / 1 bundle / 6ms
- &directory=../../../etc (rejected): 3327 pass (falls back to default)

Also flips the "not honoured yet" aside in packages.mdx to working
guidance, and adds the per-package row to the filter table in
running-tests-locally.mdx.

Closes #2280

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rkVersion (closes #2291) (#2293)

The default rootBoxJsonPath resolved "../box.json" from Global.cfc's
directory (vendor/wheels/), pointing at vendor/box.json which does not
exist. At runtime boot via onapplicationstart.cfc — where the helper
is called with no arguments — this silently fell through to
"0.0.0-dev" even inside a wheels-dev/wheels checkout. Walking up two
levels reaches <repo-root>/box.json so application.wheels.version now
resolves to "<rootversion>-dev" as intended.

Adds a regression spec that asserts application.wheels.version at boot
(rather than invoking the helper directly), because WheelsTest.cfc
binds methods into each spec's scope which shifts GetCurrentTemplatePath()
and bypasses the code path we need to cover. The existing specs all
passed explicit path arguments, leaving the default-args computation
uncovered.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#2294)

* fix(cli): improve framework-source discovery errors and doctor detection

- `wheels new` error now links to the real installing page instead of
  /docs/getting-started (404), and mentions Homebrew/Chocolatey packaging
  explicitly so users distinguish "wrong directory" from "incomplete install."
- `wheels doctor` detects missing vendor/wheels/ via new
  checkFrameworkSourceBundled(), issuing CRITICAL when the CLI can't resolve
  framework source the same way Module.cfc.resolveFrameworkSource() does.
- Recommendation engine now offers "reinstall or set WHEELS_FRAMEWORK_PATH"
  instead of misleading "Run 'wheels new'" when framework source is absent.

Catches the onboarding dead-end where a fresh Homebrew install scaffolds an
app, can't boot it, and doctor points at the same broken command.

* fix(config): publish full artifact set on snapshot releases

Snapshot pre-releases from `develop` now attach wheels-core-*.zip,
wheels-base-template-*.zip, wheels-cli-*.zip, and wheels-starter-app-*.zip
alongside wheels-module-*, mirroring what main-branch releases publish.

Downstream packagers (Homebrew, Chocolatey) depend on wheels-core-*.zip
to bundle framework source alongside the CLI module. Until now, no
snapshot had ever published that asset, so every snapshot-based package
install dead-ended at "wheels new" with a framework-not-found error.

Adopts the CommandBox scheme: develop = snapshot prerelease, main = final,
identical artifact set differentiated only by the prerelease flag.
…2298)

Two help-text lines added in #2294 contained a single literal `#`
(shell-comment marker shown in the manual-recovery instructions).
CFML treats `#` as the start of a `#expr#` interpolation, so Lucee
parsed `# Pick the wheels-core-<version>.zip…` as an unterminated
interpolation and failed Module.cfc compilation:

    Invalid Syntax Closing [#] not found, at [3540:22]

This broke the post-merge Wheels Snapshots run on develop — the
"Smoke Test Installed Distribution" job in the same release workflow
the original PR added to catch exactly this class of regression.

Doubled `#` to `##` on both lines (CFML's literal-hash escape).
The other `#` references in the surrounding block (`#appName#`,
`#candidate#`, `#projectCandidate#`) are proper interpolations and
remain unchanged.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
) (#2300)

Add a line to the Aside on the Installing page explaining that
`brew install openjdk@21` is keg-only and Homebrew won't symlink it
into `/opt/homebrew`. The `wheels` CLI handles this internally, but
calling `java` directly (or from IDEs, Maven, Gradle) requires
`JAVA_HOME` and `PATH` to be set.

Also tighten the Troubleshooting block to use `brew --prefix
openjdk@21` instead of a hardcoded versioned Cellar path (rots on
point releases) and add the missing `PATH` export.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-here (closes #2296) (#2301)

The Start Here → Installing Windows tab told users to run `choco install
wheels`, which still resolves to the v1.x CommandBox-based legacy package
on the Chocolatey community feed. Following the tutorial verbatim left
Windows users with a binary that cannot drive the v4 walkthrough at all.

Replace the bare `choco install` with a caution Aside and inline manual
JAR steps mirroring the deeper CLI Installation reference. Update the
Upgrading Windows tab to match. Restore `choco install wheels` once the
v2 LuCLI-based package ships from `wheels-dev/chocolatey-wheels`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#2297)

Bumps [postcss](https://github.com/postcss/postcss) from 8.5.3 to 8.5.10.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](postcss/postcss@8.5.3...8.5.10)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.10
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* fix(docs): restore search bar in api/guides starlight header (closes #2299)

Custom Header override replaced Starlight's default header without
re-mounting <Search />, so api.wheels.dev and guides.wheels.dev lost
the search/filter field.

Add a conditional `search` slot to the shared Header and fill it from
the Starlight override using `virtual:starlight/components/Search`,
matching the pattern already used in Footer.astro for EditLink /
LastUpdated / Pagination.

Landing/blog/packages sites are unaffected — they don't pass a search
slot, so the container is not rendered (Astro.slots.has guard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(docs): refresh guides visual baseline for restored search button

Header now renders Pagefind's <Search /> button per #2299 fix. Diff
confirms only the new search button area changed (202px > 200 threshold).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`wheels new` was emitting `bundleName: "org.xerial.sqlite-jdbc"` into
the generated config/app.cfm, which routes Lucee 7's datasource
resolver through BundleProvider. That code path currently fails on
all platforms because the upstream S3 listing at
bundle-download.s3.amazonaws.com contains a malformed entry
(`javassist-3.9.0.GA copy.jar`) whose space + " copy" suffix is both
an illegal URI character and an invalid OSGi version qualifier. The
listing parse throws, bundle resolution dies, and the SQLite driver
never loads — leaving fresh `wheels new` apps with a 0-byte database
and a `wheels migrate latest` that silently no-ops.

Removing the bundleName hint lets Lucee fall through to the standard
classpath, which resolves org.sqlite.JDBC normally as long as
sqlite-jdbc is on the lib path. Companion changes to bundle the JAR
in homebrew-wheels and chocolatey-wheels will follow.

Reported by a fresh-VM tutorial onboarding run on macOS arm64.

Note: cli/src/ (legacy CommandBox surface) still emits the same
bundleName hint and will need a matching change if that distribution
is still being shipped.
…2306)

* fix(docs): correct tutorial gaps surfaced by fresh-vm onboarding run

Walk-through on a brand-new macOS VM hit a string of doc bugs that
broke the tutorial at multiple chapters. This patch rolls all
copy-edit fixes (no engineering required) into one pass.

Tutorial chapters:

- chapter 02 (seeds) — wrap `seedOnce(...)` block in `<cfscript>...</cfscript>`,
  add a note explaining why. Without the wrapper, `wheels seed` reports
  `0 created, 0 skipped` because Lucee parses .cfm as markup by default.
- chapter 03/04 (form helper) — `dateTimeField` does not exist in
  Wheels 4.0; substitute `dateField`. Already documented as missing in
  basics/forms-and-form-helpers.mdx.
- chapter 05 (cross-controller partial) — `partial="comments/form"` resolves
  into the current controller's view folder; the leading slash on
  `/comments/form` is what triggers absolute-path resolution. Also build
  an unsaved Comment via `model("Comment").new()` before the include so
  the partial's `errorMessagesFor("comment")` helper has a real model
  object to inspect on first render.
- chapter 06 (auth callback timing) — register `hashPassword` as
  `beforeValidation`, not `beforeSave`. Wheels' automatic
  `validatesPresenceOf` rules treat NOT NULL `passwordHash` and
  `passwordSalt` columns as required; with `beforeSave` the columns
  are still empty when validation fires, so every signup fails with
  `Passwordhash can't be empty`.
- chapter 07 (test command) — `wheels test run` falls through to
  LuCLI's `run SCRIPT` parser; the wired-up subcommand is `wheels test`.

Cross-doc cleanup of the same root-cause bugs:

- `wheels test run` -> `wheels test` across testing/ pages, the
  glossary, and the tutorial index. Same broken command, would-be
  inconsistent with chapter 07 if left.
- `beforeSave("hashPassword")` -> `beforeValidation("hashPassword")` in
  digging-deeper/authentication-patterns.mdx, same root cause as
  chapter 06.

Install page:

- Replace the example `wheels --version` output line; the format
  shipped by 0.3.7 does not match `Wheels Framework v4.0.0 (LuCLI
  0.3.5-SNAPSHOT)`. Now describes the actual two-line format without
  pinning a specific version string.

Out of scope (filed as separate issues):

- chapter 06b's `application.wo.service(...)` API references — needs
  canonical DI surface decision before the doc fix lands.
- The CLI bugs themselves (silent migrate failures, scaffold abort on
  existing files, `wheels test` core/app mode detection, broken
  `wheels browser install`, etc.) — separate PRs.

* fix(docs): correct ch01/ch02 'What got created' trees against real wheels new output

The "What got created" tree in chapter 01 and the "Where we left off"
recap in chapter 02 were missing several directories that `wheels new`
actually generates. A fresh-VM tutorial walker called the trees out as
"at minimum stale and at worst misleading" because users `ls` their
new app and see directories the docs don't mention (events, global,
jobs, lib, mailers, plugins, snippets) plus root-level files (.env,
.gitignore, lucee.json) — and config/app.cfm, db/test.sqlite,
public/* contents, tests/specs/* — that the trees omit.

Captured the actual `wheels new` output via the local onboarding
harness and updated both trees to match. Chapter 01 now shows the
full tree (this is the chapter where the reader first looks at the
layout) with a paragraph explaining what the additional app/ subdirs
are for. Chapter 02 keeps a focused tree (only what that chapter
touches) but the "Where we left off" prose now explicitly notes that
the rest of the layout is elided for focus, so the reader knows the
truncation is deliberate rather than a stale doc.

Tree captured from `wheels new clean-app` (LuCLI 0.3.7, wheels module
4.0.0-SNAPSHOT) on macOS arm64. Re-running with later wheels-cli
versions may surface drift; the onboarding harness in PR #2308 is the
right place to wire up automated tree-drift detection if we want
guards.

Reported as F4 in the second VM-onboarding journal.
#2307)

Both Module.cfc and MigrationRunner.cfc were mapping the user-visible
`latest` action to the framework HTTP command name `migrateTo`. The
framework's `/wheels/cli` endpoint treats `migrateTo` as "migrate to a
specific version" — and silently no-ops when no version parameter is
provided. The correct command name is `migrateToLatest`, which the
endpoint dispatches via `migrator.migrateToLatest()`.

Without this fix, `wheels migrate latest` exits 0 with the message
"Migration latest completed." in green, but no migration is actually
applied. The first VM-test journal observed this as "Migrating from 0
down to ." (the framework rolling back to nothing); the second VM-test
journal observed it as a 0-byte database file with no schema. Both
green CLI output, both broken framework behavior.

A `&version=` empty appendix in Module.cfc (with a misleading comment
claiming "omitting it runs to latest") was the smoking gun — that comment
described the author's intent, not the framework's actual behavior.

Discovered via local fresh-install harness (tools/test-onboarding.sh)
which simulates a brand-new user running `wheels new` + `wheels migrate
latest` in an isolated LUCLI_HOME and verifies the resulting sqlite
database actually contains the migrated tables.

Reported as F2 / F5 in two consecutive VM-onboarding runs.
…2308)

Local harness that simulates the brand-new-user onboarding flow without
touching the user's daily wheels install. Uses LUCLI_HOME isolation so
an isolated copy of the worktree's cli/lucli/ is exercised, with the
worktree's vendor/wheels/ as WHEELS_FRAMEWORK_PATH.

Phases (mirrors fresh-VM journal format for direct comparison):

- Phase 1: Setup isolated LUCLI_HOME (worktree mount, framework path,
  Lucee Express symlink to avoid the 74MB re-download)
- Phase 2: wheels new (assertions for F3 dup create lines, F4 file
  tree shape, F1 no bundleName in generated config/app.cfm)
- Phase 3: server boot via 'lucli server run' + sqlite-jdbc shim
- Phase 4: migration cliff (F2/F5 false-success — verifies the actual
  sqlite db has the migrated tables, not just exit 0)
- Phase 5: seed (cfscript wrapper + idempotency)
- Phase 6: CRUD walkthrough through chapters 2-3
- Phase 7: wheels packages list (F7 — currently SKIP pending a
  separate fix; reports the bug signature when it reproduces)

Closes the iteration loop: instead of fix → push → fresh-VM test
(30+ min/round), we can now fix → run harness (~90s) and only burn a
fresh VM as the final confidence check.

Modes:
- bash tools/test-onboarding.sh           # symlink mount (default)
- MODE=copy bash tools/test-onboarding.sh # copy (closer to brew install)
- BASELINE=1 bash tools/test-onboarding.sh # use brew-installed wheels
- KEEP_TEMP=1 bash tools/test-onboarding.sh # preserve temp dirs
- FROM_PHASE=4 bash tools/test-onboarding.sh # skip earlier phases

JAVA_HOME is auto-resolved (via /usr/libexec/java_home or brew-prefix
fallback for keg-only openjdk@21). LUCLI_HOME ends in '.lucli' so
LuCLI's getComponentPath() picks the absolute-file-path mode for
Module.cfc loading.

Without this harness we'd have shipped PR #2304 + #2306 + the formula
updates, watched the next VM run still fail at migrate (the
migrateToLatest bug surfaced in PR #2307 was masked by the silent
exit-0), and needed a third VM iteration to find it.
…mapping (#2309)

* fix(cli): resolve cli.lucli.* component paths via the modules.wheels mapping

Replaces every `cli.lucli.services.*` absolute dotted path in cli/lucli/
with the equivalent `modules.wheels.services.*` path. The new prefix
matches the same `modules.<name>.<...>` convention LuCLI uses to load
each module's own `Module.cfc`, so the paths resolve identically in
every context the CLI runs in:

  Dev symlink (~/.lucli/modules/wheels -> <repo>/cli/lucli)
    modules.wheels.services.X -> <repo>/cli/lucli/services/X.cfc

  Brew install (~/.wheels/modules/wheels)
    modules.wheels.services.X -> ~/.wheels/modules/wheels/services/X.cfc

  Isolated harness ($LUCLI_HOME/modules/wheels)
    modules.wheels.services.X -> $LUCLI_HOME/modules/wheels/services/X.cfc

The previous `cli.lucli.*` prefix only resolved when the module was
loaded from a path with `cli/lucli/` in its filesystem ancestry —
true for the user's daily dev symlink (because the symlink target
lives in the repo's `cli/lucli/` directory and Lucee follows the
symlink to its real path), but NOT true for any installation that
copies the module's contents (Homebrew bottle, Chocolatey package,
or any LUCLI_HOME-isolated test setup).

User-visible effect on fresh installs:

  Before:
    $ wheels deploy version
    Error: could not find component or class with name
      [cli.lucli.services.deploy.cli.DeployMainCli]

    $ wheels packages list
    Error: could not find component or class with name
      [cli.lucli.services.packages.PackagesMainCli]

  After:
    $ wheels deploy version
    wheels-deploy mirrors kamal 2.4.0 / kamal-proxy v0.8.6

    $ wheels packages list
    [reaches the next level — see follow-up note below]

Reported as F7 in the second VM-onboarding journal. Validated locally
via the onboarding harness (PR #2308): with this fix applied, the
`wheels deploy` family resolves cleanly in the LUCLI_HOME-isolated
test that simulates a fresh brew install.

Scope: 88 files in cli/lucli/, 482 -> 486 prefix substitutions (small
discrepancy because a handful of files had >1 ref per line via comments
or chained calls). Includes test specs so they continue to mirror
production paths.

Out of scope (separate follow-up):

  `wheels packages list` now reaches PackagesMainCli but fails at
  cli/lucli/services/packages/VersionResolver.cfc:17 which does
  `new wheels.SemVer()` — a reference to the framework's
  `vendor/wheels/SemVer.cfc`. The framework isn't on the CLI's
  resolution path when running outside an app, so SemVer can't be
  found. That's a different architectural issue (CLI command depends
  on framework being loaded) and warrants its own PR. The harness
  Phase 7 will continue to surface it as a SKIP until that's fixed.

* fix(cli): add /modules/wheels mapping in test Application.cfc

Test specs under cli/lucli/tests/specs/ now reference services via
the same `modules.wheels.X` dotted path the production code uses
(post the cli.lucli -> modules.wheels rewrite earlier in this PR).
The test Application.cfc already had /cli, /wheels, and /vendor
mappings for the old `cli.lucli.X` paths, but no /modules/wheels
mapping for the new prefix — so the spec runner couldn't resolve
references like modules.wheels.services.deploy.commands.Base.

Add the missing mapping so /modules/wheels resolves to cli/lucli/
the same way LuCLI's runtime maps modules.<name> at module-load time.

* fix(cli): keep test specs on cli.lucli.* prefix

Reverts the cli.lucli -> modules.wheels rewrite for files under
cli/lucli/tests/. The earlier global sed touched test specs along with
production code, breaking the test runner because the test
Application.cfc's mappings only register /cli, /wheels, and /vendor —
not /modules/wheels in a way that resolves multi-segment virtual paths
the same as LuCLI's runtime does.

Reverting the test specs to cli.lucli.* keeps them resolvable via the
existing /cli mapping (well-tested, has been in place since the deploy
subsystem landed). Production code keeps the new modules.wheels.*
prefix so it resolves in install contexts where /cli/lucli/ is not in
the filesystem hierarchy.

The /modules/wheels mapping in cli/lucli/tests/Application.cfc stays —
it's still needed for service-internal cross-references that fire when
a test instantiates a production component.

This pairs the two prefix conventions cleanly:
  - test specs: cli.lucli.X (resolves via /cli mapping in tests)
  - prod code: modules.wheels.X (resolves via LuCLI runtime + tests
    mirror that with /modules/wheels mapping)

* fix(cli): register /modules/wheels mapping at framework webroot for CI tests

The cli.lucli -> modules.wheels prefix rewrite earlier in this PR
broke the framework's own CLI test path (/wheels/cli/tests via
public/Application.cfc) because the production services use
modules.wheels.X for their internal cross-references but the framework
webroot's Application.cfc had no /modules/wheels mapping registered.

The error in CI: "invalid component definition, can't find component
[modules.wheels.services.deploy.secrets.BaseAdapter]" — fired when a
test loaded a DopplerAdapter (etc) that extends BaseAdapter via the
modules.wheels.* prefix.

Adding the mapping at public/Application.cfc and matching it in
cli/lucli/tests/Application.cfc keeps both code paths happy:
  - /wheels/cli/tests (framework webroot Application.cfc) — gets it now
  - /cli/lucli/tests/runner.cfm (CLI test runner) — already had it

User apps don't need this mapping — they don't load CLI internals.
Adds a "Local Onboarding Harness" section to CLAUDE.md (auto-loaded
into every Claude Code session in this repo, so future sessions know
the harness exists and when to reach for it) and a deeper reference
file at .ai/wheels/testing/onboarding-harness.md (auto-searched when
a session is specifically about testing).

CLAUDE.md gets a Quick Reference style block listing the modes,
phases, and finding-ID mapping. The deeper .ai/ doc covers how it
works internally, when to reach for it vs the other test scripts,
required state, and the design decision behind keeping it separate
from tools/test-local.sh.

The harness shipped in PR #2308 but only the script header was
discoverable. This makes it findable from the top-level project
documentation that Claude reads at session start.
Fires `repository_dispatch` events of type `wheels-released` to
wheels-dev/homebrew-wheels and wheels-dev/chocolatey-wheels after the
GitHub release (snapshot or final) is published. Their auto-update
workflows already accept the trigger; this drops the "wheels release
-> package manager bump" latency from up to 24h (cron) to ~5min.

Requires a DOWNSTREAM_DISPATCH_TOKEN secret (fine-grained PAT or app
token with `metadata: read` + `actions: write` on the two downstream
repos). When the secret is missing, the step warns and exits 0 so the
release itself is never blocked — cron picks up.

Static event type and a fixed repo list, no untrusted input flows
into the curl arguments.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ding (#2321)

Three small fixes against the v4-0-0-snapshot start-here tutorial that
the 2026-04-25 fresh-VM onboarding run flagged:

* Tutorial 02 — replace the "0.3.5-SNAPSHOT generator can't run" caveat
  with the real generator flow. `wheels generate model` works on
  current 0.3.7+ (the snippet-template gap was closed in #1944).
  The generator output is slim, so we walk the user through augmenting
  it with the `enum` and the extra columns instead of "hand-write
  three files."

* Tutorial 02 — `wheels new` does not scaffold `app/db/`, so the
  seed-file step now starts with `mkdir -p app/db`. (A separate CLI
  follow-up will make `wheels new` produce the directory.)

* Tutorial 03 — fix `wheels destroy controller Posts` to the actual
  CLI argument order (`<name> [type]`). Replace the "scaffold needs
  templates that don't ship" preamble with the real failure mode the
  user now hits: scaffold-on-existing-model bails with
  "Model already exists". Hand-write fallback stays as the recovery.

Refs the open CLI issues for the underlying ergonomics gaps:
  - destroy arg-order accept-both: future CLI follow-up
  - scaffold --skip-existing: future CLI follow-up

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds three new phases to tools/test-onboarding.sh that detect regressions
of issues surfaced by the fresh-VM onboarding run on 2026-04-25:

* Phase 8 — wheels routes returns route table not API JSON dump (#2317)
* Phase 9 — wheels test prints non-zero counts when a spec exists (#2318)
* Phase 10 — dev error pages return 5xx/4xx not HTTP 200 (#2319)

Each new phase SKIPs cleanly while its open issue stands and flips to
PASS once fixed — the harness stays green during normal development and
lights up regressions automatically.

Phase 7 (wheels packages list) gains a second discrimination branch:
the original PackagesMainCli failure was fixed in #2309, but a follow-on
`wheels.SemVer` dotted-path issue surfaces only after that fix landed.
The harness now distinguishes "the original bug regressed" from "the
follow-on bug is reproducing" so the output is actionable. The follow-on
is filed as a separate issue.

Phase 10 caveat: hits /__definitely_not_a_route which returns 404
correctly — the original Wheels.DataSourceNotFound case from the
fresh-VM run can't be reproduced in the harness because the sqlite
shim makes the datasource work. The 404 path is still useful as a
regression check on the dev error page status code.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ml (#2324)

The release.yml workflow runs in two modes:
1. Push to main — secrets are inherited from the workflow run
2. workflow_call from snapshot.yml — secrets must be explicitly declared
   on the callee and explicitly passed through on the caller

#2320 added the dispatch step + read of secrets.DOWNSTREAM_DISPATCH_TOKEN
but only handled mode 1. In mode 2 (which is what snapshot.yml does on
every develop push) the secret is invisible regardless of whether the
repo has it set, so the dispatch step always hits its graceful-degrade
warning branch.

Fix is a 7-line passthrough: declare the secret as optional in the
workflow_call.secrets block of release.yml, then pass it through from
snapshot.yml. Since the step exits 0 when the secret is missing, no
release ever breaks if the secret is unset — this just makes the secret
actually visible when it IS set.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bpamiri and others added 24 commits May 11, 2026 09:50
* ci: relax visual regression + commit scope checks

Visual regression now skips when all changed files are content-only
(markdown anywhere under web/, Astro content collections under
web/sites/*/src/content/, free-standing content under web/content/, and
static assets under web/sites/*/public/). A new detect-scope job emits
the decision; visual-regression gates on it via needs+if. Mixed PRs
still run the full regression with the existing blog-baseline
pre-refresh path.

Drops the commitlint scope-enum allowlist. Scope is still optional, but
any value is now accepted — the allowlist produced more friction (wrong
scope -> CI block -> amend + force-push) than value (changelog grouping
keys off type, not scope). Conventional types and the subject/header
rules remain enforced.

Updates CLAUDE.md "Commit Message Conventions" and the wheels-bot
shared rails so humans and bots get the same loosened guidance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: upload visual-regression diffs before failing the job

Previous ordering ran 'Fail job on real regression' first (which exits 1),
which caused Actions to skip the subsequent 'Upload diffs on failure'
step even though its 'if:' matched. Net effect: the diagnostic artifact
went missing on exactly the runs that needed it.

Reorder so the upload runs first, then the explicit-fail step asserts
the failure for the check status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(web): refresh blog visual baseline after LuCLI post

The previous blog-post recovery (66827a2) on develop didn't trigger
the post-merge auto-refresh, so every subsequent PR was tripping on
the stale baseline. Pulling the actual screenshot from this PR's own
CI run and committing it as the new baseline clears the trap.

This is exactly the failure mode the content-only skip in this PR
prevents going forward — once landed, content-only PRs skip the
gate entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…er failed auto-deploy) (#2580)

* docs(web/blog): LuCLI and the Zero-Docker Developer Experience (recover failed auto-deploy)

The scheduled auto-deploy for this post failed at 2026-05-11 08:19:00
with a rebase conflict on an unrelated file (multi-tenancy-built-in.md).
Root cause: GitDeployer rebases the current branch onto origin/develop
without ensuring the working tree is on develop first; when the wheels
repo was left on a feature branch (peter/bot-convergence-loop) that had
already-merged commits, the rebase replayed them and hit add/add.

The export ran successfully (lastExportedAt=2026-05-11 08:19:00, body
matches DB), so this is purely the missing commit. Same recovery pattern
as #2551 (multi-tenancy).

Companion fix landed in wheels-publishing-admin (PR #2) hardens
GitDeployer to ensure develop checkout before deploy, so tomorrow's
launch article does not hit the same trap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(web/blog): correct CLI surfaces in LuCLI post

Two factual CLI errors flagged by wheels-bot review:

- `wheels dbmigrate latest|up|down|info|diff` conflated two distinct
  surfaces. The LuCLI module exposes `wheels migrate latest|up|down|info`;
  `wheels dbmigrate diff` is the separate auto-migration surface. Split
  into two bullets so readers on a pure-LuCLI install run the right
  command.
- `wheels server start|stop|status` used a non-existent `server` prefix.
  Server lifecycle is top-level: `wheels start|stop|status` (matches
  CLAUDE.md Development Tools table and the post's own prose four
  paragraphs earlier).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two real-world failures surfaced when PR #2583 (a content-only blog
post polish) became the first PR to exercise the content-only skip:

1. detect-scope died with exit 1 on a content-only diff. The script
   used 'set -uo pipefail' but GitHub Actions runs bash with -e by
   default, and 'set -uo' doesn't touch -e. When every changed file
   matched the content patterns, the final 'grep -vE' produced no
   output and exited 1 (normal grep behavior), set -e escalated
   that to a job failure. Fix: explicit 'set +e' + a '|| true'
   guard on the offending grep chain.

2. Because visual-regression has 'needs: detect-scope', Actions
   auto-skipped visual-regression when detect-scope failed — a
   silent bypass of the layout gate, the exact failure mode the
   safety-default comments in the job warned against. Fix:
   change visual-regression's if: to 'always() && (detect-scope
   failed OR content_only != true)' so a broken detector ALWAYS
   runs the regression rather than silently skipping it.

Together: detect-scope can no longer die on its intended use case,
and even if it did, the regression would still run as a fail-safe.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eline (#2585)

Two follow-on fixes after the detect-scope grep bug exposed a deeper
design issue:

1. The content-only skip in #2581 applied to both PR and push events.
   For PR events, that's the friction-reducer we wanted. For push
   events to develop, it silently broke the blog baseline
   auto-refresh-and-commit dance, which lives inside the
   visual-regression job. After #2580 merged, the post-merge push
   couldn't refresh the baseline (detect-scope had crashed; even if
   it hadn't, content_only=true would have skipped the whole job),
   so every subsequent non-content PR trips on the stale baseline
   — exactly what's happening now on this hotfix PR.

   Fix: tighten the visual-regression `if:` to skip only on PR
   events with content_only=true. Push events always run, eating
   the few extra CI minutes so the maintenance contract holds.

2. Refresh blog.png from this PR's CI artifact. The previous baseline
   was screenshot before #2580 merged and is now 38k pixels off the
   current develop blog index. Pulling the actual.png from the
   visual-regression-diffs artifact and committing it here.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-existing nits surfaced by wheels-bot Reviewer A on the now-merged
LuCLI post:

- Engine support: the original sentence listed Lucee 5, Adobe 2018,
  and Adobe 2021 alongside the CI-gated engines, but CLAUDE.md
  explicitly notes those three are NOT in the matrix and should be
  considered unsupported for new development. Reframe so the CI
  matrix is what's "supported" and the legacy versions are called
  out as not CI-gated.
- "Wheels CLI installers" anchor pointed only to homebrew-wheels —
  misleading for Windows readers. Drop the outer anchor so the
  inline Homebrew + scoop-wheels links carry platform-specific
  navigation.

Both nits were flagged as non-blocking on PR #2580 and merged through;
addressing them now as a follow-up so the published post is accurate.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `anthropics/claude-code-action@v1` action defaults to rejecting
actors without write permission to the repo — a sensible default for
PR-related automation, but exactly backward for issue triage on a
public OSS bug tracker, where contributors filing bugs are precisely
who we want responded to.

Symptom: 5 of the last 13 bot-triage runs failed at the "Run Triage"
step with `Actor does not have write permissions to the repository`,
producing zero triage comments on issues opened by external
contributors.

Fix:
- `allowed_non_write_users: '*'` — the action input designed for this
  exact case. Docs name "issue labeling" as the canonical example.
- Added `workflow_dispatch` with `issue_number` input so missed
  issues can be re-triaged without close/reopen.
- Hardened the `if:` guard so the wheels-bot self-loop check no
  longer evaluates `github.event.issue` on dispatch events.

Safe because the workflow runs with `permissions: contents: read`,
posts via an App token independent of the actor, and `claude_args`
restricts the model's shell to `gh` + read-only `git` + file reads.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci: switch wheels-bot triage from Sonnet to Opus

Triage's job is now harder than pure classification — it reads the
issue body, optionally greps the codebase, and self-rates confidence
that downstream Opus stages (propose-fix, research, write-docs) auto-
fire on. The auto-downgrade rules force `medium` for sensitive
surfaces regardless of model, so the gate's guard-rails stay intact;
upgrading the model just gives the bot a better chance of resolving
genuine uncertainty (vs. hedging to `medium` when a deeper read of
the code would have produced a clean `high`).

Concrete example from #2582 today: triage rated `medium` because the
reporter listed `/env` as working even though it routes through the
same `consoleExec` function as the failing commands. An Opus pass on
that issue would have either read `consoleExec` to resolve the
inconsistency (likely `high`) or confirmed it as a genuine scope-
initialization nuance (justified `medium` with sharper rationale).

Tools and turn budget unchanged — same read-only allowlist (`gh`,
read-only `git`, `Read`/`Grep`/`Glob`), same 200-turn ceiling. Cost
delta is roughly 5× per triage; volume is modest (~3-4 issues/day).

Also synced the model column in CLAUDE.md's bot stages table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: address Reviewer A/B consensus findings (round 1)

- docs/contributing/wheels-bot.md:260 — replace stale "only stage that
  runs Opus on a non-coding task" claim with advisor+triage framing
  (A's main finding; B confirmed)
- docs/contributing/wheels-bot.md:224 — clarify "analytical Sonnet
  stages above" by naming Reviewer A and B explicitly (B's missed-issues
  finding)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
* ci: switch wheels-bot triage from Sonnet to Opus

Triage's job is now harder than pure classification — it reads the
issue body, optionally greps the codebase, and self-rates confidence
that downstream Opus stages (propose-fix, research, write-docs) auto-
fire on. The auto-downgrade rules force `medium` for sensitive
surfaces regardless of model, so the gate's guard-rails stay intact;
upgrading the model just gives the bot a better chance of resolving
genuine uncertainty (vs. hedging to `medium` when a deeper read of
the code would have produced a clean `high`).

Concrete example from #2582 today: triage rated `medium` because the
reporter listed `/env` as working even though it routes through the
same `consoleExec` function as the failing commands. An Opus pass on
that issue would have either read `consoleExec` to resolve the
inconsistency (likely `high`) or confirmed it as a genuine scope-
initialization nuance (justified `medium` with sharper rationale).

Tools and turn budget unchanged — same read-only allowlist (`gh`,
read-only `git`, `Read`/`Grep`/`Glob`), same 200-turn ceiling. Cost
delta is roughly 5× per triage; volume is modest (~3-4 issues/day).

Also synced the model column in CLAUDE.md's bot stages table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(contributing): adopt DCO for contributions

Wheels now requires a `Signed-off-by:` trailer on every commit, certifying
the contributor's right to submit under the project's Apache 2.0 license.
The [DCO GitHub App](https://github.com/apps/dco) enforces this on every
new PR; existing commits are grandfathered.

Changes:
- CONTRIBUTING.md: new "Developer Certificate of Origin" section covering
  the DCO text, `git commit -s`, the amend/rebase recipes for forgotten
  sign-offs, enforcement, and grandfathering. Quick Links and both
  workflow lists updated to reference it.
- .github/pull_request_template.md: new "DCO sign-off" checklist item.
- .claude/commands/_shared-rails.md: wheels-bot must include a
  `Signed-off-by: wheels-bot[bot] <...>` trailer matching its configured
  git identity.
- .claude/commands/{propose-fix,address-review,write-docs,update-docs}.md:
  example `git commit -m` → `git commit -s -m` so the bot eats its own
  dog food.
- CHANGELOG.md: entry under "Configuration & developer experience".

Closes #2575.

Co-Authored-By: Claude Opus 4.7 (1M context) <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.7 (1M context) <noreply@anthropic.com>
…UI (#2588)

* fix(router): classify /_browser fixture routes as Internal in Routes UI

The Routes UI predicate previously matched only `controller == "wheels.public"`
or `pattern == "/wheels/app/tests"`, so the `/_browser/*` browser-test fixture
routes (registered via `loadBrowserTestFixtures` in dev/test environments) fell
through to the Application tab even though they ship with the framework and
have no corresponding files under /app. Extend the predicate to also classify
routes whose pattern begins with `/_browser` as internal.

* docs(web/guides): note /_browser fixture routes appear under Internal tab in Routes UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…atasource work (#2591)

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Peter Amiri <peter@alurium.com>
…#2594)

cli/LICENSE was the MIT license inherited from Tom King's standalone
wheels-cli CommandBox module (2016) before the monorepo merge. The root
LICENSE is Apache 2.0, so the repo presented as dual-licensed to SCA
tools and created legally ambiguous inbound-equals-outbound assumptions
for cli/ contributors.

- cli/LICENSE: replace with byte-for-byte copy of root Apache 2.0 text.
- NOTICE: new root file capturing Tom King's 2016 copyright assertion
  per Apache 2.0 Section 4(c), satisfying the MIT
  notice-retention clause without preserving the conflicting license
  grant.

No cli/box.json or cli/package.json exists declaring "license": "MIT"
(verified). The only remaining MIT mentions in cli/ are (a) third-party
JAR attribution for sshj transitives + slf4j in
cli/lucli/lib/deploy/README.md, and (b) the user-pickable default value
in the plugin scaffold command — neither is a project-level license
claim.

Closes #2572
… (#2593)

Apache License 2.0 §4(d) requires NOTICE attribution to propagate to
derivative works. PR #2594 added an initial NOTICE focused on the
cli/LICENSE MIT→Apache reconciliation; this change extends the same
file with the broader contributor attribution called for in #2573 —
original CFWheels authors (Per Djurner et al.), the named long-running
contributors, and a pointer to git log for the full list — while
preserving the Tom King / wheels-cli MIT relicensing section as a
dedicated subsection.

Also updates tools/build/scripts/prepare-base.sh to bundle NOTICE
alongside LICENSE in the base template build artifact, so the
ForgeBox-published scaffold (and anything redistributing it) carries
the attribution forward.

Footer of NOTICE references LICENSE rather than restating the full
Apache 2.0 boilerplate, matching the ASF canonical template
convention (NOTICE is for attribution; license terms live in
LICENSE).

LICENSE itself is intentionally not modified; keeping the verbatim
Apache 2.0 text intact preserves the fingerprint that
license-detection tooling (FOSSA, ScanCode, GitHub linguist) relies
on.

Closes #2573.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: standardize license metadata across package.json files

Adds "license": "Apache-2.0" (SPDX identifier) to every package.json so
SCA tooling (GitHub dependency graph, FOSSA, Snyk) classifies the project
deterministically. The license field IS the SPDX declaration for JSON
manifests — there is no separate SPDX-License-Identifier slot in
package.json.

- 9 manifests previously had no license field — now Apache-2.0.
- tools/vscode-ext was MIT (looked accidental — the rest of the repo is
  Apache-2.0 and Microsoft Marketplace accepts any OSI license) → Apache-2.0.
- tools/docker/testui was ISC (npm init default) → Apache-2.0.

CFC source-file SPDX headers under vendor/wheels/ and cli/lucli/ are out
of scope here (1000+ files); see the issue for the deferred follow-up.

Closes #2574

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: replace vscode-ext LICENSE with Apache-2.0 text to match package.json (round 1)

Addresses Reviewer A/B consensus from the convergence loop: the prior
commit set tools/vscode-ext/package.json to "license": "Apache-2.0" but
left tools/vscode-ext/LICENSE as MIT text, creating a contradiction
between npm metadata and the on-disk license artifact (the very issue
this PR set out to fix for SCA tooling).

Picks Option A from A's review (replace LICENSE with Apache-2.0 text)
because the PR's stated intent in the body is deliberate relicensing of
this directory to align with the repo-wide Apache-2.0 — "MIT looked
accidental." LICENSE now matches the root LICENSE byte-for-byte.

The DCO-trailer finding on the earlier commit (8d58fb6) cannot be
addressed by the bot without rewriting history (git commit --amend on
a shared branch), which the bot's rails forbid. The original commit
author needs to amend + force-push, or add a separate fixup. This new
commit carries its own Signed-off-by via -s.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

---------

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…tends (#2595)

* fix(cli): wheels validate must strip CFML comments before checking extends

The `validateModel()` / `validateController()` checks in
`cli/lucli/services/Analysis.cfc` searched the raw file content for
`extends="Model"` / `extends="Controller"`, so a commented-out
declaration above an inheritance-less `component { … }` body satisfied
the substring match and the validator reported success. Strip line,
block, and tag-style CFML comments before the check, and add specs that
pin the behavior for both models and controllers.

Fixes #2491

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* docs(web/guides): clarify wheels validate checks active (uncommented) extends declaration

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* fix(cli): address Reviewer A/B consensus findings (round 1)

- AnalysisSpec.cfc: replace conditional expect() inside the
  CommentedAndReal.cfc loop with an explicit boolean flag pattern
  so the assertion runs unconditionally after the loop (no longer
  vacuous when the fix is working correctly).
- AnalysisSpec.cfc: add a fifth `it()` covering the tag-style
  CFML comment path (`<!--- ... --->`), the first substitution
  in $stripCfmlComments() that previously had no spec.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

---------

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…cts (#2597)

Apache 2.0 §4(a) requires LICENSE in every distributed artifact and §4(d)
requires NOTICE to propagate to derivatives. PR #2593 covered the
base-template artifact via prepare-base.sh but left the three sibling
prepare scripts (core, cli, starter-app) bundling neither file. Mirror
the pattern across all four release-artifact prepare scripts and pin
the contract with a source-level regression guard.

Fixes #2596

Signed-off-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com>
Co-authored-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com>
Co-authored-by: Peter Amiri <peter@alurium.com>
…orks (#2598)

* fix(packages): use java.io.File.mkdirs() for cache dirs so Adobe CF works

ManifestCache.$ensureDir used DirectoryCreate(path, true). The createPath
flag is a Lucee extension — Adobe CF rejects the second argument and
crashes the Tools → Packages page on every fresh ACF install with
"The function takes 1 parameter" (#2567).

Routes the recursive mkdir through java.io.File.mkdirs(), which has stable
JVM-level semantics on Lucee, Adobe CF, and BoxLang. Mirrors the fix in
the CLI-side ManifestCache so the two intentionally-paired files stay
in sync.

Adds regression coverage exercising a deeply-nested cache root (where
parents don't exist yet — the case the createPath flag was meant to
cover) and the on-first-use manifests subdirectory creation path.

Also documents the BIF gotcha in .ai/wheels/cross-engine-compatibility.md
so the next person reaches for Java NIO before tripping on it.

Closes #2567

* fix(packages): throw typed exception when mkdirs() fails + add changelog entry

Address Reviewer A on #2598:

- $ensureDir now captures mkdirs()'s boolean return and throws a typed
  Wheels.Packages.CacheDir exception when creation actually failed.
  Without this guard, a permission/read-only-fs failure surfaced as a
  misleading downstream FileWrite error rather than a clear "could not
  create cache directory" diagnostic. The !DirectoryExists re-check
  swallows the benign concurrent-creation race where mkdirs() returns
  false because another worker won.

- CHANGELOG entry under [Unreleased] → Fixed describing the user-facing
  symptom (Tools → Packages 500 on fresh ACF), root cause, and the
  cross-engine fix, mirroring the format of the surrounding entries.
…rroring (#2569) (#2600)

* fix(view): replace cfhtmlhead with body-level redirect on /wheels/guides

Adobe ColdFusion threw "Unable to add text to HTML HEAD tag" when
loading the /wheels/guides redirect page. The wrapper view includes
the layout header (which streams </head>) before the docs view runs,
so by the time the docs view tried to inject a meta-refresh via
cfhtmlhead the response had already moved past the head. Lucee
tolerates this; Adobe does not.

Drop cfhtmlhead and emit the redirect from the body: a hidden div
holds the target in a data-url attribute (HTML-attribute-encoded with
encodeForHTMLAttribute, matching the visible anchor in the same view),
and a small inline script triggers window.location.href after 3
seconds. UX is unchanged on Lucee, and the page now renders cleanly
on Adobe ColdFusion.

Fixes #2569.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(changelog): note /wheels/guides Adobe CF fix

Address Reviewer A nit on PR #2600 by recording the cfhtmlhead → body
redirect change under [Unreleased] > Fixed, mirroring the format used
by recent entries.

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

* test(view): drop invented CLAUDE.md anchor in guidesRedirectHeadSpec

Reviewer B caught that the Chr(60)-justification comment cited
"CLAUDE.md 'Lucee Tag Scanner'" — that label came from a personal
memory file, not the project CLAUDE.md, and no such section exists in
the repo docs. Anchor the convention on a verifiable in-repo precedent
(MigratorViewIconsSpec.cfc, which builds its <svg / <i icon patterns
the same way) and explain the underlying "one bad CFC takes the whole
suite down" failure mode without inventing a doc heading.

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

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… ASTcffunction (#2568) (#2599)

Adobe CF's bytecode generator crashes with
`java.lang.ArrayStoreException: coldfusion.compiler.ASTcffunction`
when a function literal is passed as a named argument to a `new
Component(...)` call. The error fires from `getComponentMetadata()`
in TestBox, and because Adobe CF eagerly compiles every CFC in the
bundle directory before running, the failure crashes the *entire*
core test suite — every database (mysql/postgres/sqlserver/oracle/
cockroachdb/sqlite) returns HTTP 500 on adobe2023 and adobe2025.

This is the same workaround already applied in SessionStrategySpec.
The fix is mechanical: hoist each `keyFunction = function(req) { ... }`
out of the constructor call into a `var keyFn = ...;` declaration
above the `new wheels.middleware.RateLimiter(...)` call, then pass
the reference. 12 sites updated. No behavior change on Lucee or
BoxLang — they compile both forms identically.

Also documents the trap in `.ai/wheels/cross-engine-compatibility.md`
and the CLAUDE.md "Known cross-engine gotchas" list so future agents
catch it before re-introducing it.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ourceName-only configs (#2489) (#2602)

* fix(cli): wheels test preamble stops doubling _test for coreTestDataSourceName-only configs (#2489)

`$resolveAppTestDataSource` in `cli/lucli/Module.cfc` matched
`config/settings.cfm` with `dataSourceName\s*=\s*"([^"]*)"`. The
case-insensitive matcher caught the trailing substring inside
`set(coreTestDataSourceName="testappdb_test")` and re-appended
`_test`, surfacing `testappdb_test_test` in the `Running app
tests (...)` preamble. The matcher now uses `\bdataSourceName\b`
and strips CFML comments first (matching the pattern already
used by `info()`), and guards against re-appending `_test` if
the resolved base already ends in `_test`. .env lookup is also
anchored to start-of-line so an unrelated key whose name happens
to end in `DATASOURCE_NAME` can't match by accident.

Issue thread also reported `--filter` still being ignored on the
latest snapshot. The CLI's `$normalizeTestFilter` + URL build
trace correctly through `app-runner.cfm`'s `?directory=` regex
(verified end-to-end against a local server: `--filter=models`
correctly scopes to `tests.specs.models`). Most likely the user
hit a stale CLI install pre-#2394. Extracted the app-runner's
`?directory=` regex into `TestDirectoryResolver` alongside
`TestDbResolver` so the silent-fallback path (bare
`?directory=models` collapsing to `tests.specs`) is unit-testable
instead of HTTP-only — if the regex ever regresses, a unit test
will catch it before it ships.

Also exposes `$resolveAppTestDataSource` as `public` (matching the
`$`-prefix-for-internal convention used by `$normalizeTestFilter`)
so the new regression specs can drive it directly.

* fix(cli): address Reviewer A/B consensus findings (round 1)

- Add regression spec for the `.env` start-of-line anchor in
  $resolveAppTestDataSource (cli/lucli/Module.cfc:5072): confirms a
  key whose name ends in DATASOURCE_NAME (`MY_DATASOURCE_NAME=...`)
  does not false-match against the DATASOURCE_NAME extraction.
  cli/lucli/tests/specs/commands/TestCommandSpec.cfc.

Closes A's one coverage-gap action item; B aligned in round 1.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

---------

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…ation property (#2412) (#2601)

* fix(model): clear error when struct/array mass-assigned to non-association property (#2412)

When `model.new(params.user)` or `setProperties()` was called with a
struct or array value for a property that wasn't declared as a nested
association, `$setProperty` fell through to `this[property] = value`
and silently overwrote the model attribute with the bad shape. The
cast error then fired much later — inside a user callback like
`beforeValidation("normalizeEmail")` doing `LCase(this.email)` — and
Lucee reported "Can't cast Complex Object Type Struct to String"
pointing at the user's code instead of at the upstream form-data
mismatch.

The most common trigger is a curl POST whose body uses bracket-nested
keys without an `=` separator:

    --data-urlencode "user[email][test@example.com]"
                                ^ no `=` here ^

Lucee's form parser reads the trailing `[test@example.com]` as a
deeper nested-struct path, so `params.user.email` arrives as
`{"test@example.com": ""}` instead of a string. Browsers always
%-encode `@` and include the `=`, which is why the rendered form
works.

Fix: when `$setProperty` reaches the assignment branch and the value
is still a struct or array (i.e. no `hasOne`/`hasMany`/`belongsTo`
with `nestedProperties()` matched it), throw
`Wheels.PropertyIsIncorrectType` with a message naming the model and
property plus extendedInfo describing the likely root cause.
Legitimate nested-attribute assignments are unaffected because they
match the existing branches above the new guard.

Also corrects the chapter 6 tutorial gotcha note: the failure mode is
the missing `=` separator, not `@` encoding per se. The previous text
claimed `user[email]=alice@example.com` was enough to trigger the
bug, which contradicts the framework's existing
`createParamsSpec.cfc`.

Note: local `tools/test-local.sh` could not run from this worktree
because another worktree already held the wheels CLI's "wheels"
server-name lock. CI is authoritative — the new
`propertiesSpec.cfc` describe block exercises the four shapes
(struct + non-assoc, array + non-assoc, struct + nested-assoc happy
path, scalar happy path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(model): scope #2412 guard to real DB columns to avoid breaking hasMany/control-param paths

CI on the first push surfaced 21 regressions across crud, nested-
properties, association, and bulk-operations specs. Two patterns:

1. Arrays of model instances being assigned to `hasMany` properties
   that don't have `nestedProperties()` enabled (e.g. `author.posts =
   [postObj1, postObj2]` after `findOne(include="posts")`). The
   original `$setProperty` skips its nested-collection branch when the
   array contains objects (`!IsObject(arguments.value[1])` check), so
   the array fell through to direct assignment — which is legitimate
   for loaded associations.

2. Framework-internal control parameters like `useIndex` (a struct of
   model→index-name mappings) leaking through `$setProperties` along
   with finder/calculation arguments. These aren't model properties at
   all, just args that happen to pass through the same path.

Both shapes hit the new guard's `IsStruct || IsArray` check and
threw, even though neither is the bug we're trying to catch.

Narrow the guard to fire only when the property is a real scalar
database column — i.e. `StructKeyExists(variables.wheels.class.properties,
arguments.property)`. `class.properties` is populated from schema
introspection at class init and contains only actual DB columns
(not associations, not transient args). The original bug case
(`email` on a User model) still hits the guard; everything else
falls through to the existing assignment branch unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(model): address Reviewer A/B consensus findings (round 1)

- Trim multi-line comment block in $setProperty to one short line (N1).
- Add regression specs for empty array and array-of-objects assigned
  to a hasMany property with nestedProperties() enabled, confirming
  the narrowed guard at properties.cfc:580 falls through for both
  shapes (T1, A's findings for #2601).
- Fix tutorial curl example: the previous illustration used
  `--data-urlencode "user[email][alice@example.com]"`, but curl reads
  any unescaped `@` in such an argument as the start of a filename
  (the `name@filename` form), so the example would error on disk
  before ever reaching Lucee. Switch to a non-`@` bracket key and
  call out the curl-level pitfall (B's missed-issue addendum).

C1 and C2 (the empty-array and array-of-objects regressions on
hasMany+nestedProperties) were already addressed by 6bae0e6, which
narrowed the guard to fire only when the property is a real scalar
DB column. The two new specs lock that behaviour against future
regressions.

CM1 (Signed-off-by trailer missing on b840c51 and 6bae0e6) cannot
be retroactively added by this bot stage — `.claude/commands/_shared-rails.md`
forbids any history-rewriting git subcommand. This commit is signed
off; the prior two will need a human-driven amend/rebase or a squash
merge to satisfy the DCO check.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* fix(model): address Reviewer A/B consensus findings (round 2)

- vendor/wheels/tests/specs/model/propertiesSpec.cfc: trim two
  multi-line comment blocks to single lines per CLAUDE.md
  ("never write multi-line comment blocks — one short line max").
  The 8-line context block at the top of the new describe and the
  3-line fixture explanation inside the "still accepts struct values
  for nested associations" it() are both replaced with one-line
  references; the issue #2412 background is already in the PR
  description and commit messages.

- CHANGELOG.md: replace the curl example
  `--data-urlencode "user[email][test@example.com]"` with
  `--data-urlencode "user[email][badkey]"`. The `@` form would have
  failed at the curl client (curl reads any unescaped `@` in
  `--data-urlencode` as the start of a filename, the `name@filename`
  form), so the example could not actually trigger the Lucee
  parser behavior the entry is describing. Matches the tutorial
  correction already applied in this PR.

Reviewer-flagged DCO trailers missing from b840c51 and 6bae0e6
remain a human-action item: rewriting published history is forbidden
by .claude/commands/_shared-rails.md, so the PR author needs to run
`git rebase --signoff develop && git push --force-with-lease` or
the PR can be merged via "Squash and merge" so the resulting commit
carries one DCO trailer.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* fix(test): escape `#` in propertiesSpec it() descriptions (Lucee tag scanner crash)

CI from the most recent push returned HTTP 500 with "Invalid Syntax
Closing [#] not found" at propertiesSpec.cfc:437. The two new specs
added by 72effe4 and shipped through cc7ecaf embed the literal
PR reference `#2601` in their `it("... regression for #2601 review")`
description STRINGS — and CFML reads `#` inside a string literal as
the start of an `#expression#` interpolation. The scanner cannot find
the closing `#`, the whole CFC fails to compile, and the test runner
crashes for the entire model bundle (taking 800+ specs down).

CLAUDE.md ("Lucee Tag Scanner" memory + the "`#` escape gotcha" call-
out in the testing section) documents this exact trap. Comments
escape the scanner because `//` lines are not evaluated, which is why
the trimmed comment blocks in cc7ecaf still containing `#2412` were
fine — but the strings inside `it(...)` calls are not comments.

Escape both occurrences as `##2601` so the `#` survives as a literal.
Reproduced and verified locally against this worktree's Lucee 7 +
SQLite server: 810 model specs pass (3445 total core), 0 failures,
0 errors (the 12 remaining errors come from `BrowserLoginSpec` /
`BrowserRouteSpec` needing `wheels browser setup` and are unrelated
to this PR).

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

---------

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…succeeds (#2603)

Scoop's install order is pre_install -> bin shim creation -> post_install.
Both manifests emitted the wheels.cmd launcher in post_install, so the shim
step ran first, failed with `Can't shim 'wheels.cmd': File doesn't exist.`,
and aborted the install before the launcher was ever written. Surfaced on
Windows as a permanently-broken `scoop install wheels-be` (and would have hit
`wheels` GA the same way).

Move the launcher-creation block to pre_install in the generator. Validator
and README pick up the same rename. The emitted JSON manifests change by
exactly one key per file; everything else (wrapper content, URLs, hashes,
extract paths, banners) is byte-identical.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…b/.rpm install (#2604)

* docs(web/guides): replace nonexistent install.sh URL with working .deb/.rpm install

The Linux tabs on the start-here installing guide and the upgrade flow pointed
at `https://get.wheels.dev/install.sh` — a URL that was planned for the Phase 2
universal installer but doesn't exist yet. Users on Linux hit a 404 and a
confusing `bash: command not found` chain.

Rewrite all three install-related guides to use the .deb/.rpm packages that
release.yml already produces (verified on snapshot v4.0.0-snapshot.1825). The
new snippets resolve the latest version dynamically via the GitHub API, so
they keep working as releases roll over.

- start-here/installing.mdx: Linux install + upgrade tabs; troubleshooting
  no longer references the never-existed `~/.wheels/env` file.
- command-line-tools/installation.mdx: split the "macOS and Linux (Homebrew)"
  section — Linux now has its own `.deb`/`.rpm` section with the package layout
  and an Aside flagging native apt/yum repos as a v4.0.x follow-up.
- start-here/release-channels.mdx: replace literal `<version>` placeholders
  with the API-driven version resolution; extend the "Switching channels"
  section beyond Homebrew (Linux uses Conflicts:/Replaces: metadata, Scoop
  refuses dual install).

Phase 2 (native apt.wheels.dev / yum.wheels.dev repositories) is planned as a
follow-up — see tools/distribution-drafts/linux-packages/README.md for the
full plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web/guides): fix bot-flagged issues on Linux install/upgrade/switch docs

Wheels-bot's Reviewer A + Reviewer B caught five issues with the previous
revision of #2604. All five address the same root tension: the docs read
as if the BE channel mirrors the stable channel filename-for-filename and
API-call-for-API-call, but the underlying release pipeline (release.yml
hardcoding CHANNEL=stable, GitHub /releases/latest excluding pre-releases)
makes the asymmetry user-visible.

1. installing.mdx Aside (bot finding 1) — the "substitute repo name in the
   URLs above" prose pointed at /releases/latest, which returns nothing
   against the pre-release-only snapshots repo. WHEELS_VERSION would be
   empty and the download URL would 404 silently. Replaced with two
   self-contained snippets (Debian/Ubuntu + Fedora/RHEL) using /releases
   + head -1, matching the pattern already used in release-channels.mdx.

2. release-channels.mdx Linux switch block (bot finding 2) — the previous
   block was comments-only with no executable command, and referenced
   "wheels-be" as a filename component that doesn't exist (release.yml
   hardcodes CHANNEL=stable, so both stable and snapshot publish a single
   package literally named `wheels`). Replaced with concrete stable →
   bleeding-edge and bleeding-edge → stable command blocks. Noted the
   --allow-downgrades requirement for BE → stable on apt (4.0.0 sorts
   below 4.0.1~snapshot.500 per dpkg).

3. release-channels.mdx comment on the version substitution (bot finding
   3) — the inline comment said "GitHub Releases rewrites ~ to ." but the
   shell substitution is /-/. The reader had to chain three concepts
   themselves (tag uses hyphen → internal package version uses tilde →
   GitHub mangles tilde to dot at upload). Expanded the comment to spell
   the chain out explicitly.

4. installing.mdx step 1 (bot finding 4) — the manual `sudo apt install
   openjdk-21-jre-headless` step contradicted both the intro paragraph
   ("depends on OpenJDK 21") and installation.mdx ("apt/dnf pulls it in
   alongside wheels"). Dropped step 1 entirely; reworded the intro to
   spell out the auto-pull behavior explicitly. Step 2 (install) and
   step 3 (verify) renumber to 1 and 2.

5. installation.mdx BE prose (Reviewer B's missed-issue finding) — same
   root cause as #1, in the deeper reference doc. Same fix: replace the
   "substitute the repo name" prose with two self-contained BE snippets
   using /releases + head -1.

Also added a brief BE pointer to the Linux upgrade tab, mirroring the
"# or, for the bleeding-edge channel:" line in the Windows upgrade tab.

Verified the BE snippet end-to-end:
  SNAP_TAG=$(curl ... /repos/wheels-dev/wheels-snapshots/releases | ... | head -1)
  → 4.0.0-snapshot.1826
  → URL: .../v4.0.0-snapshot.1826/wheels_4.0.0.snapshot.1826_amd64.deb
  → HTTP 302 (redirect to release-assets.githubusercontent.com)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web/guides): fill remaining gaps on Linux switch and Scoop reverse paths

Round-2 bot findings:

1. release-channels.mdx Switching channels — the Linux blocks named dnf in
   their comments but only showed apt commands, leaving Fedora/RHEL users
   without a usable command. Split each direction into a Debian/Ubuntu
   block and a Fedora/RHEL block. For the BE → stable direction, dnf gets
   `dnf downgrade <url>` (the rpm-native way to handle an older-version
   transition); apt keeps its --allow-downgrades trick.

2. release-channels.mdx Switching channels — Scoop only had stable → BE;
   added the symmetric bleeding-edge → stable block to match the macOS
   pair.

3. Trust-model documentation — Phase 1 ships .deb/.rpm without detached
   GPG signatures, so download integrity depends solely on GitHub's HTTPS
   transport. Added a `<Aside type="caution">` on both installing.mdx (the
   onboarding guide) and installation.mdx (the deeper reference) noting
   this explicitly and pointing forward to the Phase 2 signed repos at
   apt.wheels.dev / yum.wheels.dev.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web/guides): fill diagonal gap in Installing each channel Linux tab

Round-3 bot finding: the "Installing each channel" Linux tab in
release-channels.mdx showed only two of four distro × channel combinations
— Debian/Ubuntu (stable) with apt, and Fedora/RHEL (bleeding-edge) with
dnf. The diagonal pairing left a Fedora user looking for the stable rpm
install or a Debian user looking for the bleeding-edge .deb install with
no command they could copy out of this section.

Added the two missing snippets to make the full 2×2 (apt/dnf ×
stable/bleeding-edge) explicit, mirroring the structure already
established in the round-2 "Switching channels" section.

Co-Authored-By: Claude Opus 4.7 (1M context) <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.7 (1M context) <noreply@anthropic.com>
* chore: prep CHANGELOG for v4.0.0 GA

- Promote [Unreleased] heading to # [4.0.0] with GitHub release link and
  date 2026-05-11 (replace `=> TBD` so release.yml validation passes).
- Update PR count (185 → 260+) and contributor list (+ @MukundaKatta) to
  match docs/releases/wheels-4.0-audit.md.
- Add new "Distribution" subsection under Added — captures the four
  install channels (Homebrew, Scoop, .deb/.rpm, WinGet drafts) that
  landed in 4.0 but weren't itemized in the changelog.
- Fix the section terminator after 4.0.0's notes from `----` (4 dashes)
  to `---` (3 dashes) so release.yml's release-notes awk extraction
  (`awk '/^# \[VERSION\]/,/^---$/'`) terminates at the right boundary
  instead of running into the 3.0.0 entry.

wheels.json is already at "4.0.0" (no snapshot suffix), so the
release.yml clean-source-version check passes. First-party packages all
declare wheelsVersion >=4.0 so no coordination updates are needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(changelog): add Fixed entry for #2603 Scoop pre_install fix

Captures the user-visible impact ("scoop install fails with Can't shim
wheels.cmd") and the actual fix (move launcher emit from post_install to
pre_install, where Scoop's install order requires it). Already merged on
develop as commit e7445bc; this just makes sure the GA changelog
reflects it under v4.0.0's Fixed section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(changelog): address Reviewer A/B consensus findings (round 1)

- Fix Homebrew tap link text from `wheels-dev/wheels` to
  `wheels-dev/homebrew-wheels` so the visible repo name matches the
  URL target (CHANGELOG.md:104).
- Bump the 4.0.0 release date marker from `2026-05-11` to
  `2026-05-12` so it reflects today's tag day (CHANGELOG.md:21).

Not addressed in this commit:
- Missing `Signed-off-by:` trailers on `bff31f981` and `d971cde8e`.
  Adding them requires `git commit --amend -s` (or interactive
  rebase) plus a force-push, both of which the wheels-bot rails
  forbid. Flagged to the human in the address-review comment so a
  maintainer can amend the trailer chain before the FF-merge to main.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Merging the develop branch into main for the 4.0.0 GA cut. Main carried
six commits from the 3.0.x hotfix line (#1937 and ancestors, throwOnColumnNotFound
backport and friends) that diverged from develop, so the standard
`merge --ff-only` flow doesn't apply. Using `--no-ff -X theirs` so:

  - The merge commit preserves both branch histories on main.
  - On any file-level conflict, develop's version wins (correct — develop
    is the canonical state of the 4.0 line; main's 3.0.x throwOnColumnNotFound
    work has been re-landed on develop via PR #1938 and is captured in the
    4.0.0 CHANGELOG).

Pre-release archive branches preserve both prior tips:
  - archive/develop-pre-v4.0.0 → 3914c90
  - archive/main-pre-v4.0.0    → 858bfb4

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

# Conflicts:
#	core/src/wheels/Public.cfc
#	core/src/wheels/events/onapplicationstart.cfc
#	core/src/wheels/public/docs/guides.cfm
#	core/src/wheels/public/docs/layouts/guides.cfm
#	docs/src/SUMMARY.md
#	docs/src/command-line-tools/commands/database/dbmigrate-info.md
#	docs/src/command-line-tools/commands/database/dbmigrate-latest.md
#	docs/src/command-line-tools/commands/plugins/plugins-search.md
#	templates/base/src/tests/runner.cfm
#	vendor/wheels/public/docs/DocsHelper.cfc
#	web/sites/guides/src/content/docs/v3-0-0/command-line-tools/commands/generate/code.md
@bpamiri
bpamiri merged commit 91e7398 into main May 12, 2026
2 checks passed
@bpamiri
bpamiri deleted the release/v4.0.0-to-main branch May 12, 2026 05:56
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.

1 participant