Release 4.0.1 — merge develop into main - #2769
Merged
Merged
Conversation
Manual replacement for the `bump-develop-version.yml` PR that didn't fire on the v4.0.0 GA. The workflow triggers on `release: published` events, but neither the original release.yml-created release nor the subsequent pre-release toggle (web UI and `gh release edit` both tried) dispatched an event Actions could pick up — `softprops/action-gh-release` used the default `GITHUB_TOKEN`, which can't trigger downstream workflows (documented Actions limitation), and the post-publish toggle didn't fire a fresh `release: published` either. Sets `wheels.json` to `4.0.1` so subsequent develop snapshots resume tagging correctly as `4.0.1-snapshot.<run>`. The maintainer makes the actual scope decision at the next GA's tag-cut time — this is just the snapshot baseline. If the next GA ends up being a minor or major bump, snapshot version strings still sort strictly lower than any of those. Follow-up: add `workflow_dispatch` to `bump-develop-version.yml` or switch its trigger to something `GITHUB_TOKEN` can fire (e.g., `repository_dispatch`). Signed-off-by: Peter Amiri <peter@alurium.com>
chore: bump develop snapshot target to 4.0.1
…kages install` verb LuCLI's built-in extension installer intercepts the literal subcommand `install` across every module, so `wheels packages install <name>` never reaches Module.cfc — it prints "No git or extension dependencies to install" and exits without installing anything. The canonical install verb is `wheels packages add` (this is already documented in the PackagesMainCli header and in CLAUDE.md's packages quick reference). The generated-app templates shipped via `wheels new` still pointed users at the broken verb in two places — `_gitignore` and `app/plugins/README.md` — so every new app inherited misleading help text. Reword both to point at `wheels packages add`. Closes the CLI-side half of #2610. Docs sync (MDX guides, `.ai/wheels/`, `CLAUDE.md`) is handled separately by bot-update-docs.yml. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…`wheels packages add` Co-authored-by: claude[bot] <claude[bot]@users.noreply.github.com> Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
The v4.0.0 GA shipped today (2026-05-12). Renaming the URL slug from 'v4-0-0-snapshot' to 'v4-0-0' so the URL matches the now-stable channel status. Internal cross-link rewrites, astro redirects for the old URLs, versions.ts updates, and the new v4-0-1-snapshot clone follow in subsequent commits in this PR. Signed-off-by: Peter Amiri <peter@alurium.com>
…d dir to /v4-0-0/ After the directory rename in the previous commit, all the absolute cross-page links inside the docs still pointed at the old URL slug. Replaced /v4-0-0-snapshot/ with /v4-0-0/ in every .md/.mdx file under the renamed directory and in the renamed sidebar JSON. Each file's within-version structure is unchanged — same relative paths, same fragment anchors — just the version segment is corrected. Signed-off-by: Peter Amiri <peter@alurium.com>
versions.ts (single source of truth for both guides and api sites): - GUIDES_VERSIONS: replace v4-0-0-snapshot (snapshot) with v4-0-0 (current), demote v3-0-0 to archived, add new v4-0-1-snapshot (snapshot) for the next dev cycle. - API_VERSIONS: replace v4-0-0-snapshot (snapshot) with v4-0-0 (current), demote v3-0-0 to archived. No v4-0-1-snapshot yet because there's no docs/api/v4.0.1.json to generate against; the API site only adds a version slug when there's matching JSON to consume. astro.config.mjs: - Add explicit redirects for the high-traffic /v4-0-0-snapshot/ landing pages (root, installing, tutorial, first-15-minutes, release-channels, cfml-engines, command-line-tools/installation) so external bookmarks and SEO equity transfer to the new /v4-0-0/ slug. - Update the two Phase-0 cli-reference redirects to target /v4-0-0/ instead of /v4-0-0-snapshot/. Add the same redirect at the new slug too in case anyone hits /v4-0-0/cli-reference directly. Astro's static redirects map can't catch-all without getStaticPaths, so arbitrary deep links to /v4-0-0-snapshot/foo/bar/baz will 404 rather than redirect. The listed entries cover the URLs most likely to appear in external content (blog posts, README links, package descriptions). A Cloudflare-side wildcard could provide full coverage if SEO retention from deep-link backlinks turns out to matter post-GA. Signed-off-by: Peter Amiri <peter@alurium.com>
Ran `node web/scripts/generate-api-docs.mjs 4.0.0` to populate web/sites/api/src/content/docs/v4-0-0/ with 378 function pages across 9 sections (configuration, controller, global-helpers, migrator, model-*, view-helpers, etc.). The input JSON at docs/api/v4.0.0.json was already present from the 4.0 prep work — the API site just hadn't been generated against it. Without this, versions.ts listing slug v4-0-0 would have 404'd on the API site since no content directory existed there. The generator output is deterministic from the JSON, so re-running it on the same input produces byte-identical results. Signed-off-by: Peter Amiri <peter@alurium.com>
…e of v4-0-0/ At GA time, the next-snapshot docs are byte-identical to the just-shipped stable docs (modulo the URL slug). As fixes and new features for 4.0.1 land on develop, the two directories diverge. This commit establishes that baseline: - cp -r v4-0-0/ v4-0-1-snapshot/ — full 184-file clone - cp v4-0-0.json v4-0-1-snapshot.json — matching sidebar - find ... sed 's|/v4-0-0/|/v4-0-1-snapshot/|g' — rewrite internal absolute links inside the cloned dir so self-references resolve within the snapshot namespace (relative links untouched — they don't need version segment rewriting) The dropdown entry from versions.ts now resolves to a real, fully- populated tree instead of 404'ing. When the first 4.0.1-era doc change lands, it edits files in v4-0-1-snapshot/ specifically; v4-0-0/ remains the frozen stable docs. Signed-off-by: Peter Amiri <peter@alurium.com>
After renaming the snapshot directory to v4-0-0 and seeding
v4-0-1-snapshot as the new dev clone, sweep the rest of the repo for
references to the retired slug:
- web/content/blog/posts/*.md (6 posts) — outbound URLs to
/v4-0-0-snapshot/... rewritten to /v4-0-0/... so blog links now point
at the stable docs the posts were written against. Redirects in
astro.config.mjs handle anyone hitting the old URLs directly.
- web/sites/guides/src/content/docs/index.md — version list now leads
with v4.0.0 (current), then v4.0.1-SNAPSHOT (dev), with v3 and v2.5
pushed down to archived.
- web/sites/api/src/content/docs/index.md — version table cleaned up:
v4.0.0 promoted to "current release" with 378 functions (matches
generator output from previous commit), v3.0.0 demoted to archived.
- web/sites/guides/src/content/docs/{v2-5-0,v3-0-0}/index.md — "go to
current docs" cross-links now point at /v4-0-0/start-here/installing/
instead of the snapshot URL.
- web/sites/guides/STYLE.md — contributor-facing doc folder reference
now points at v4-0-1-snapshot/ (where new doc PRs land).
- web/packages/ui/src/data/versions.ts (JSDoc example), EditLink.astro
(JSDoc), PageTitle.astro (JSDoc) — example slugs in code comments
refreshed to v4-0-1-snapshot to match what readers see in URLs now.
- web/sites/guides/scripts/verify-docs/verify-docs.mjs — DEFAULT_TARGET
→ src/content/docs/v4-0-1-snapshot (active dev is the right default
for verify-checks).
- web/scripts/visual-regression.mjs — guides canary URL switched to
/v4-0-0/start-here/tutorial/ to match the API site's "canary watches
the stable docs" pattern.
The only file that still mentions v4-0-0-snapshot is astro.config.mjs,
which is intentional — those entries are the redirects that catch
external bookmarks pointing at the old URL.
Signed-off-by: Peter Amiri <peter@alurium.com>
- Fix stale sidebar filename references in v4-0-1-snapshot writing-docs
guide (lines 18, 162, 164, 194): point contributors at
src/sidebars/v4-0-1-snapshot.json (the active snapshot sidebar)
rather than v4-0-0.json (the frozen stable sidebar).
- Update the FileTree in the v4-0-1-snapshot writing-docs guide
(lines 33, 46) to surface both the v4-0-0/ and v4-0-1-snapshot/
content directories alongside both sidebar JSON files, so a
contributor following the guide can see the parallel-tree layout
at a glance.
- Repair the broken predicate on line 27 of both
v4-0-0/contributing/writing-docs.mdx and
v4-0-1-snapshot/contributing/writing-docs.mdx ("the next
in-development snapshot is X will sit beside it" → "the next
in-development snapshot, X, sits beside it").
Doc-only change; no framework code, no tests, no cross-engine surface.
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…version-slug rename The CI run on the previous head of this branch reported: [guides] FAIL — 10,643 pixels differ (threshold: 300) [api] FAIL — 6,167 pixels differ (threshold: 300) Reviewed the diff artifact at: https://github.com/wheels-dev/wheels/actions/runs/25720017511 Confirmed both diffs are entirely confined to the page chrome — the header version-dropdown text ("v4.0.0-SNAPSHOT (dev)" → "v4.0.1-SNAPSHOT (dev)") and the left-sidebar version label ("v3.0.0 (current)" → just "v3.0.0", since v3 is now archived). The page body content is byte- identical on both screenshots; the pixel differences are pure chrome rendering driven by the versions.ts updates already on this branch. Copied the CI-rendered .actual.png files from the visual-regression-diffs artifact directly into web/tests/visual-baselines/, per the comment in .github/workflows/web-deploy.yml that documents this exact procedure ("download the visual-regression-diffs artifact and copy each *.actual.png over the matching web/tests/visual-baselines/*.png, commit, and push"). Using the CI-captured PNGs (not a local refresh) sidesteps the Linux vs macOS font-rendering drift the script's --update mode flags as a caveat. Signed-off-by: Peter Amiri <peter@alurium.com>
…-install-command-does-not-install-p fix(cli): generated-app templates stop advertising broken `wheels packages install` verb
Pulls in PR #2611 ("wheels packages install" → "wheels packages add" cleanup) plus its companion CHANGELOG entry, _gitignore template fix, plugins/README cleanup, and the new CliTemplateInstallVerbSpec test. Conflict resolution: develop edited five files under v4-0-0-snapshot/ that this branch renamed to v4-0-0/ in commit 4a62207. Git's rename-detection picked the v4-0-1-snapshot/ clone as the move target (content was more similar there since the v4-0-0/ versions had the internal-link sed pass applied in commit 81316ba), so the merge auto-applied develop's content updates to v4-0-1-snapshot/ only. Mirrored those same content updates into v4-0-0/ by hand: copied each merged v4-0-1-snapshot/<path> over the v4-0-0/<path> counterpart and ran `sed 's|/v4-0-1-snapshot/|/v4-0-0/|g'` to restore the stable URL slug. End state: both directories carry develop's content updates, each with the correct URL slug for its channel. Files affected: - command-line-tools/commands/packages/index.mdx - command-line-tools/commands/packages/install.mdx - deployment/observability-and-logging.mdx - start-here/tutorial/03-crud-scaffold.mdx - upgrading/3x-to-4x.mdx Verified post-resolution: v4-0-0/<path> matches v4-0-1-snapshot/<path> modulo URL slugs (diff is purely /v4-0-0/ vs /v4-0-1-snapshot/ tokens). Signed-off-by: Peter Amiri <peter@alurium.com>
…hot-to-stable chore(web): post-GA docs rename — v4-0-0-snapshot → v4-0-0, seed v4-0-1-snapshot
#2619) * ci: let medium-confidence triages auto-fire propose-fix and write-docs Widen the auto-fire gate on bot-propose-fix.yml and bot-write-docs.yml so medium-confidence triage / research / docs-request markers fire the next stage, not just high. Low-confidence stays manual. Previously the gate doubled as a halt: medium meant "don't auto-fire." That stopped useful work on ambiguous-but-actionable issues. The propose-fix prompt's step-4 safety net (security / middleware / migrations / deploy / DI / cross-engine) already aborts before opening a PR for sensitive areas, posting a fix-held marker instead. Same pattern in write-docs step 2 for structural docs-architecture decisions. Reviewer A and Reviewer B then critique whatever lands, with the Senior Advisor on deadlock — the heavier review machinery does the filtering the confidence ceiling used to do. Human approval on develop is still the merge gate. Triage and research prompts now emit a :medium marker alongside the existing :high marker; low still emits no marker. The triage auto-downgrade rules are preserved as informational metadata for humans skimming the comment (medium ratings still auto-fire; propose-fix's safety net catches the sensitive cases). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * ci: correct write-docs safety-net step number in bot-write-docs.yml comment Reviewer A flagged that the bot-write-docs.yml header comment said "step-2 safety net" for the structural-docs-decision halt. The actual step number in .claude/commands/write-docs.md is 4 — step 2 is "Read the authoritative context." Fix the workflow comment and add the same "step-4" qualifier to the marker reference table in docs/contributing/wheels-bot.md so both safety-net rows are symmetrical. No behavior change. 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>
* fix(security): use Adobe-safe 3-arg mid() in MCP command parser (#2613) Adobe ColdFusion requires mid(string, start, count) with all three arguments; Lucee and BoxLang accept the 2-arg form and default count to the remainder of the string. The MCP command executor and its security spec both used the 2-arg form to strip the "wheels " prefix, which crashed the entire vendor/wheels/tests/specs/security bundle on Adobe (compile-time MID validation error) and would also crash any MCP command execution at runtime on Adobe. Switch all five call sites — one in vendor/wheels/public/mcp/McpServer.cfc and four in vendor/wheels/tests/specs/security/McpCommandInjectionSpec.cfc — to mid(s, 7, len(s)), which works identically on every supported engine. Add an explicit regression-guard spec that documents the Adobe-safe pattern so the bug cannot silently return. Verified on: - Lucee 7 + SQLite: 174/174 security specs pass - Adobe CF 2023 + SQLite: 174/174 security specs pass (was 0 pass / bundle crash) Fixes #2613 Signed-off-by: Peter Amiri <peter@alurium.com> * docs(security): drop issue-number refs from MCP mid() workaround comments CLAUDE.md says inline comments shouldn't reference the originating task or issue ("handles the case from issue #123") — that context belongs in the PR description and rots in the source tree. Keep the cross-engine "why" and the verbatim Adobe error string; drop the parenthetical issue reference. Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com>
…rs() for Adobe CF (#2616) * fix(test): route browser-test artifact dir creation through File.mkdirs() for Adobe CF `BrowserTest.$captureFailureArtifacts` and `McpServer`'s test-file generator both called `directoryCreate(path, true)`. Adobe ColdFusion rejects the Lucee-only `createPath` flag with "Parameter validation error for the DIRECTORYCREATE function. The function takes 1 parameter.", which crashed the core test suite on ACF any time a browser spec failed and the artifact directory did not yet exist. Route the recursive directory creation through `java.io.File.mkdirs()`, which works uniformly on Lucee, Adobe CF, and BoxLang — same canonical workaround already used by `services/packages/ManifestCache.cfc::$ensureDir` (#2567). Adds `BrowserTestArtifactDirSpec` covering the nested-parent and already-exists cases against the extracted `$ensureArtifactDir` helper. Fixes #2614 Signed-off-by: Peter Amiri <petera@pai.com> * refactor(test): tighten browser-test artifact dir fix per review feedback Reviewer A flagged four items on PR #2616: 1. McpServer.$ensureDir now matches ManifestCache's canonical mkdirs + re-check + throw pattern, so a silent mkdirs() failure surfaces as "Could not create test directory" instead of a downstream FileWrite error. 2. Drop the pre-existing "Debug logging removed - path resolution working correctly" meta-comment that violated CLAUDE.md's "no references to past changes" rule. 3. Shrink the $ensureArtifactDir Javadoc block to a single line per CLAUDE.md's "one short line max" guidance. 4. Compress the regression-context comment in BrowserTestArtifactDirSpec from 10 lines to 2, while preserving the issue reference in both the comment and the it() label. Note: the it() label now uses `##2614` because unescaped `#` in CFML string literals triggers the "Invalid Syntax Closing [##] not found" trap and crashes the entire spec bundle — verified locally. The shared-beforeEach suggestion was skipped (reviewer marked it "harmless here" and YAGNI applies — `new BrowserTest()` has no side-effects without Playwright). Signed-off-by: Peter Amiri <petera@pai.com> * refactor(test): align $ensureArtifactDir with ManifestCache canonical pattern Capture mkdirs() return value and re-verify with directoryExists before throwing, matching the reference implementation in ManifestCache.$ensureDir. The surrounding try/catch in $captureFailureArtifacts still swallows the throw to preserve best-effort artifact capture semantics, but the throw now produces a diagnosable log entry before the swallow when a permission- denied or filesystem failure occurs. Signed-off-by: Peter Amiri <peter@alurium.com> * docs(test): collapse two-line regression comment to one line per CLAUDE.md Reviewer A flagged the remaining 2-line block as one thought; folding it into a single line completes the trim from round 1. Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <petera@pai.com> Signed-off-by: Peter Amiri <peter@alurium.com>
…2615) * ci(release): fire bump-develop via repository_dispatch (fix #2609) The release:published event emitted by release.yml's "Create GitHub Release" step uses the default GITHUB_TOKEN, which (by GitHub's design) cannot trigger downstream workflows. As a result, bump-develop-version.yml never fired on the v4.0.0 GA cut and the maintainer had to open the develop-bump PR manually (#2608). Fix: - release.yml's existing "Dispatch downstream package managers" step now also dispatches a `bump-develop` event at wheels-dev/wheels itself (only when CHANNEL=stable), piggybacking on the same PAT (DOWNSTREAM_DISPATCH_TOKEN) that already drives the homebrew/chocolatey dispatches. - bump-develop-version.yml listens for `repository_dispatch: types: [bump-develop]` and adds a `workflow_dispatch` manual fallback. The prerelease/target-branch eligibility gates can be deleted because the dispatcher itself only fires for stable (which by construction comes from main and is non-prerelease). - RELEASE_PLAYBOOK.md updated to document the dispatch chain plus a manual-fallback entry in the failure-modes table. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(release): fail loud on unparseable workflow_dispatch input Reviewer A in #2615 found a real correctness issue: the SemVer-mismatch guard exited 0, which marks the step succeeded and lets GitHub Actions run the unguarded "Update wheels.json" and "Open bump PR" steps with NEW_VERSION/RELEASED_VERSION unset. Concrete failure: jq writes '"version": ""' to wheels.json and peter-evans/create-pull-request opens 'auto-bump/develop-' with title 'chore: bump develop snapshot target to '. The primary repository_dispatch path is safe — release.yml's CHANNEL=stable branch only fires after -snapshot/-rc filtering, so the payload is guaranteed bare SemVer. This only affects the workflow_dispatch fallback when an operator types something like '4.0.1-rc1'. Fix: exit 1 with ::error:: so the run goes red and the operator re-fires with corrected input. Applied Reviewer A's Option A (Reviewer B converged). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The title contains two colons, which without quoting causes js-yaml to treat 'Wheels 4.0' as a key and reject the rest with `bad indentation of a mapping entry` at line 1:17, breaking the Astro build for blog.wheels.dev. This unblocks today's deploy. Long-term fix is in wheels-publishing-admin PR #4 (auto-quote YAML scalars containing reserved characters in the exporter itself). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… in scaffolded Application.cfc (#2623) The CLI's scaffolded Application.cfc declared a local `injector` variable in onApplicationStart() and onError() but then referenced application.wheelsdi without an explicit assignment. The runtime only worked because Injector.init() self-registers at application.wheelsdi — reading the template suggested otherwise and matched the bug report in issue #2622 ("application.wheelsdi is never assigned"). Replace the orphan local in all four templates (the canonical CLI template and the in-repo public/, examples/tweet/, examples/starter-app/ copies) so every reference goes through application.wheelsdi. Pin the contract with a new spec at vendor/wheels/tests/specs/cli/ ApplicationCfcInjectorAssignmentSpec.cfc so freshly scaffolded apps and the in-repo derivatives cannot drift back into the inconsistent form. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
* fix(cli): honor --no-sqlite when scaffolding lucee.json
Drop the hardcoded SQLite datasource pair from cli/lucli/templates/app/lucee.json
in favor of a {{datasourcesBlock}} placeholder, and thread opts.noSQLite
through scaffoldNewApp's context so the substituted block is either the SQLite
pair (default) or {} when --no-sqlite is set. Lucee no longer auto-creates
db/development.sqlite / db/test.sqlite on first connection. Fixes #2621.
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
* docs(web/guides): clarify lucee.json honors --no-sqlite in creating-a-project guide
The prior description implied only config/app.cfm carried datasource
configuration, leaving lucee.json's role undocumented. After #2621 both
files honor --no-sqlite: lucee.json now emits an empty datasources object
so Lucee never auto-creates db/*.sqlite on first connection.
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
* fix(cli): address Reviewer A/B consensus findings (round 1)
- cli/lucli/Module.cfc: collapse 7-line comment on `var context` to a
single-line WHY note per CLAUDE.md "one short line max" rule, and fix
the unnecessary `##2621` escape inside the `//` comment (#2621)
- cli/lucli/Module.cfc: drop the 8-line docstring on
buildSQLiteDatasourcesBlock(); the function name plus its single
caller's note already explain the WHY
- cli/lucli/Module.cfc: remove the stray double-space in
`block &= pad & "}," & nl;`
- vendor/wheels/tests/specs/cli/NoSQLiteScaffoldSpec.cfc: drop the
17-line component-level docblock; the describe label
"wheels new --no-sqlite (issue 2621)" already pins the regression
context
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
* test(web/blog): refresh visual baseline after Wheels 4.0 post drift
The blog homepage baseline (web/tests/visual-baselines/blog.png) drifted
when 2b908f6 (Wheels 4.0: Closing the Maturity Gap post) and 4e695b2
(title quote fix) landed on develop — the workflow's post-merge
auto-refresh in web-deploy.yml only fires when `blog_content_changed=true`
on a PR, and these were direct merges that didn't pre-refresh.
This commit refreshes the baseline to match the current rendered blog
homepage, unblocking PR #2624 (whose CI failure was inherited drift, not
a code change) and develop's last two push runs which fail identically.
The broader auto-refresh-race fix lives on peter/fix-blog-baseline-race;
this is the immediate unblock.
Signed-off-by: Peter Amiri <peter@alurium.com>
* test(web/blog): use CI-rendered baseline (macOS Chromium diverges)
The previous baseline refresh (83f7f8d) was generated on macOS
Chromium, which produces noticeably different font hinting and
antialiasing from CI's Linux Chromium — the diff measured 43,928
pixels, well above the 300-pixel threshold.
Replaced web/tests/visual-baselines/blog.png with blog.actual.png
extracted from the visual-regression-diffs artifact of the failing
CI run (run 25763885277). This is the Linux-rendered output that
subsequent CI runs will diff against, so the comparison should now
land at 0 pixels (or well within the threshold).
Verified by visual inspection: same content as the macOS render
(Wheels 4.0 / LuCLI / Multi-Tenancy posts), just rendered with the
CI environment's font stack.
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[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Peter Amiri <peter@alurium.com>
…els.dev URL (#2640) * fix(cli): point scaffolded routes.cfm comment at canonical guides.wheels.dev URL Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(test): add scaffolded routes.cfm to ConfigRoutesStaleDocUrlSpec targets (round 1) Address Reviewer A/B consensus: add cli/lucli/templates/app/config/routes.cfm to the targets array in ConfigRoutesStaleDocUrlSpec so the canonical-URL regression guard also covers the already-corrected third template. Without this, a future edit could silently reintroduce the stale /docs/routing path in that file. All four spec assertions already hold against the file (canonical URL present, stale path / docs.cfwheels.org / cfwheels.(org|com) all absent). 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> Co-authored-by: Peter Amiri <peter@alurium.com>
…essages (#2641) * fix(config): interpolate plugin and package names in load-trace log messages The "Loading plugin..." / "Loading package..." INFO lines in wheels_security.log were emitting literal placeholder tokens (`#arguments.dirName#`, `#local.pluginKey#`, etc.) instead of the resolved values. The `WriteLog(text="...")` strings escaped each `#` as `##` — CFML's way to write a literal `#` — so the placeholders never ran through expression evaluation and the log was useless for diagnosing which plugin or package was being loaded. Drop the escaping so single-pound interpolation kicks in. Add a spec under `vendor/wheels/tests/specs/packages/` that inspects both call sites and fails fast if the escape pattern reappears. Fixes #2630 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(packages): address Reviewer A/B consensus findings (round 1) - Collapse the 13-line block comment at the top of vendor/wheels/tests/specs/packages/PackageLoaderLogInterpolationSpec.cfc to a single line per CLAUDE.md's "one short line max" rule. - Add positive assertions in both it() blocks verifying the correct single-pound pattern is present in PackageLoader.cfc and Plugins.cfc so the spec stays meaningful if the WriteLog line is later removed. Refs PR #2641, issue #2630. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(packages): address Reviewer A/B consensus findings (round 2) - Collapse the 4-line escaping-logic comment in the PackageLoader it() block of vendor/wheels/tests/specs/packages/PackageLoaderLogInterpolationSpec.cfc to a single line per CLAUDE.md's "one short line max" rule. - Collapse the 3-line positive-assertion comment in the same it() block to a single line: "Positive guard: fails if the WriteLog line is silently removed." The single-line comment in the Plugins it() block was already compliant. 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> Co-authored-by: Peter Amiri <peter@alurium.com>
…2644) Blog skeleton now lists all eleven canonical breaking changes, matching the upgrade guide's authoritative count. Fixes the test-base-class rename entry (was mislabeled 'testbox namespace' — the actual rename is wheels.Test → wheels.WheelsTest). Adds the previously-missing application.wirebox → application.wheelsdi and Vite manifest strictness breaking changes. Removes the incorrect 'not on the canonical list' framing for the security-default items. Stats table 'Breaking defaults hardened | 7' corrected to 'Breaking changes | 11'; four detail-row delta labels updated from Changed / Renamed / New → Breaking to match the canonical guide. Fixes #2632 Signed-off-by: wheels-bot[bot] <wheels-bot[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>
…rebox rename (#2636) Add an Aside callout to upgrade-guide item 10 stating that wheels-legacy-adapter does not create an application.wirebox alias and does not restore the wirebox.system.ioc.Injector package path. Add a scope table to the Legacy Compatibility Adapter section listing what the adapter covers versus what requires manual remediation. Add a Common Issues entry for the wirebox package-not-found boot failure. Applied to both v4-0-0 and v4-0-1-snapshot guides. Fixes #2627 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…nfig/settings.cfm (#2637) * docs(web/guides): document reloadPassword must be set via env() in config/settings.cfm Co-authored-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com> Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * docs(web/guides): address Reviewer A/B consensus findings (round 1) Drop the internal struct-key references from the reloadPassword explanation. The framework reads/writes `application.$wheels.reloadPassword`, not `application.wheels.reloadPassword`, but that distinction is an implementation detail users do not need. Simplify the prose to describe the user-visible mechanism only (`set(reloadPassword = ...)` in `config/settings.cfm`). - web/sites/guides/src/content/docs/v4-0-1-snapshot/upgrading/3x-to-4x.mdx (section 4 paragraph + Common issues bullet) - web/sites/guides/src/content/docs/v4-0-0/upgrading/3x-to-4x.mdx (section 4 paragraph + Common issues bullet) - CHANGELOG.md ([Unreleased] Documentation entry) Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.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[bot] <41898282+claude[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> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…2645) * fix(cli): scan seven more 3.x → 4.x breakers in wheels upgrade check The `wheels upgrade check --to=4.0.0` 3.x → 4.x branch only flagged the legacy plugins/ directory, the wheels.Test base class, and application.wirebox references — 3 of the 11 documented breakers from the canonical upgrade guide. Users could run the check, see a short list, upgrade, and trip over the unscanned breakers in production; the tool's silence read as a green light. Extend the checks array with seven more grep- and directory-based scans: - CORS middleware without allowOrigins (deny-all default in 4.0) - RateLimiter middleware without explicit trustProxy / proxyStrategy - allowEnvironmentSwitchViaUrl=true (default flipped in production) - Missing csrfEncryptionKey in config/ (cookies rotate per deploy) - Legacy `wheels snippets` invocations in Makefile, package.json, .github/workflows/*.yml, and top-level *.sh scripts - Legacy tests/specs/functions/ directory rename - Vite asset helpers (viteStrictManifest default flip) The grep runner gains two backward-compatible options to support these checks: `scanTargets` (mixed list of files + directory roots for the multi-root snippets scan) and `absent` (warn when the pattern is NOT found anywhere, used by the csrfEncryptionKey check). Existing 2.x → 3.x checks and the three pre-existing 3.x → 4.x checks are unchanged. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * docs(web/guides): list all ten 3.x→4.x checks in upgrade command reference Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(cli): address Reviewer A/B consensus findings (round 1) - Replace Elvis `?:` on `check.extensions` in upgrade-check runner with `structKeyExists` ternary (cli/lucli/Module.cfc:3739). The previous form crashed Adobe CF whenever a `scanTargets` directory target lacked an `extensions` key and the parent check had no top-level `extensions` either — a live crash path on every Adobe CF run of `wheels upgrade check` reaching the `wheels snippets` check. - Strengthen RateLimiter check description and fix text so users recognise the scan is advisory and fires on every instantiation regardless of current config (cli/lucli/Module.cfc:3629). - Correct the "Each grep scans .cfc and .cfm files" prose in both upgrade.mdx pages — the new `wheels snippets` check also scans Makefile, package.json, .yml, .yaml, and .sh files. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(cli): document intentional empty pattern in directory check Round-2 polish from Reviewer A/B exchange: B flagged that `pattern: ""` in the tests/specs/functions/ directory check is a silent trap if anyone unifies the directory and grep branches — `reFindNoCase("", anyString)` matches every line. A conceded the point and proposed a brief inline comment. Comment-only change; no behavior diff. 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[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Peter Amiri <peter@alurium.com>
…e new Wheels CLI (#2639) * fix(cli): deprecate legacy `box wheels upgrade` and point users at the new Wheels CLI The CommandBox `box wheels upgrade` command (`cli/src/commands/wheels/upgrade.cfc`) hardcoded its available-versions list at 3.1.0, so a 3.x user invoking it was silently told "You are already on the latest version" even though 4.0 had shipped. The post-upgrade-recommendations URL also pointed at the pre-4.0 upgrade guide. Print a deprecation banner directing users at the new Wheels CLI (`brew install wheels-dev/wheels/wheels` → `wheels upgrade check` → `brew upgrade wheels`) and short-circuit `run()` before the stale machinery runs. The unreachable post-upgrade-recommendations URL is updated to the canonical v4.0 guide for consistency. The legacy CommandBox `wheels-cli` module remains scheduled for removal in v5.0 per the 4.0 CHANGELOG. Fixes #2634 Signed-off-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com> * docs(web/guides): note legacy CommandBox wheels-cli deprecation in 3x-to-4x upgrade guide Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(cli): address Reviewer A/B consensus findings (round 1) - Remove `(issue #2634)` references from upgrade.cfc component doc block and run() inline comment; per CLAUDE.md, issue refs belong in the commit / PR description, not in code that outlives the ticket. - Replace bare `var repoRoot` / `var upgradePath` in LegacyUpgradeDeprecationSpec.cfc's describe() body with a shared `ctx` struct read by the nested `it()` closures; CLAUDE.md "Closure gotcha" warns inner closures cannot reliably read outer-closure local vars on Adobe CF. - Guard each `fileRead(ctx.upgradePath)` call with a `fileExists` check that surfaces a clean WheelsTest `fail()` instead of letting `fileRead` throw if the CLI source is absent. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(cli): address Reviewer A/B consensus findings (round 2) - Add `brew upgrade wheels` as a third line to the legacy CommandBox `wheels-cli` deprecation code block in `web/sites/guides/src/content/docs/v4-0-0/upgrading/3x-to-4x.mdx` and the snapshot copy at `web/sites/guides/src/content/docs/v4-0-1-snapshot/upgrading/3x-to-4x.mdx`. The deprecation banner in `upgrade.cfc` already lists three steps (install, check, upgrade); the docs previously stopped after step 2, leaving a user who got a positive `wheels upgrade check` result without guidance on how to actually perform the upgrade. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(cli): address Reviewer A/B consensus findings (round 3) - Remove `Issue #2634:` prefix from the component docblock of vendor/wheels/tests/specs/cli/LegacyUpgradeDeprecationSpec.cfc. CLAUDE.md "Doing tasks" rule: don't reference the current task or issue number in code comments. Round 1 removed the same violation from upgrade.cfc itself but missed the spec's docblock; this closes that gap. The surrounding regression rationale is self-contained. - Improve the `firstAvailableCall > 0` assertion failure message so a future contributor who removes the dead-code block under `return;` in upgrade.cfc gets a "delete this spec too" hint rather than a message that looks like the original regression has returned. The boolean check itself is unchanged. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --------- Signed-off-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com> Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Peter Amiri <peter@alurium.com>
…2736) * feat(web/blog,model): beyond findAll post + whereIn empty-array fix Fourth post in the post-GA series after the rate-limiter, packages, and stdio-MCP articles. Walks scopes, enums, and the chainable query builder as three pieces of one design — all three return deferred-query proxies that materialise into the same finder-argument struct on a terminal call. Drafting surfaced a real framework bug in QueryBuilder.whereIn() / whereNotIn() with empty arrays: - Empty input produced literal SQL "property IN ()", malformed in every supported engine (Postgres / MySQL / SQL Server / SQLite / H2), surfacing as a generic JDBC syntax error with no pointer back to the call site that built the empty collection. - whereIn now short-circuits to "1 = 0" (no rows match — SQL-spec answer for "match any of these zero values"), and whereNotIn to "1 = 1" (every row matches). Matches behaviour Rails, Sequel, Django, and Laravel Eloquent all converged on. - Four new specs in queryBuilderSpec.cfc cover empty-array, empty-list, composition with other clauses, and the whereNotIn mirror case. - Both copies of the query-builder guide (v4-0-0 and v4-0-1-snapshot) updated to document the short-circuit in the methods reference table. Article also flags three related rough edges left for follow-up: no .toSql() debugging helper, no defaultScope() / unscoped(), and no guard against enum value-name collisions with model method names. * fix(model): address Reviewer A/B consensus findings (round 1) - Collapse 5-line and 4-line comment blocks in vendor/wheels/model/query/QueryBuilder.cfc whereIn/whereNotIn empty-input branches to single lines (CLAUDE.md "Never write multi-line comment blocks"). - Add symmetric whereNotIn("id", "") empty-list spec in vendor/wheels/tests/specs/model/queryBuilderSpec.cfc to match the existing whereIn empty-list coverage. Reviewer A/B converged on these two changes across three rounds; the commit-type nit (feat vs fix) was a false positive (the PR ships a ~2,700-word blog post as its primary deliverable, so feat is correct at the PR level — this address-review commit itself is a fix). Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(model): short-circuit whereIn(empty) at terminal, not via raw SQL literal The earlier approach (7e9a283 / f1a300e) appended literal `1 = 0` and `1 = 1` clauses to variables.whereClauses for empty input. That broke at runtime: Wheels' WHERE-clause parser in vendor/wheels/model/sql.cfc runs a property-extraction regex over every clause it sees, including ones with no actual column. For `1 = 0`, the parser reads `1` as the property name, fails to find it in propertyStruct, and throws Wheels.ColumnNotFound. All four new whereIn empty-array specs failed on Lucee 7 + SQLite for this reason — same root cause for the bot's f1a300e push, which kept the literal approach. The fix that works alongside the parser instead of around it: set an $alwaysEmpty flag on the builder for empty whereIn, and check it at each terminal method (count, findAll, findOne, first, exists, updateAll, deleteAll, findEach, findInBatches). The flag short-circuits to the appropriate zero-row sentinel (0, false, QueryNew("")) before the WHERE parser sees anything. whereNotIn(empty) becomes a no-op: appending no clause means the chain proceeds normally and matches every row, which is the right semantic ("exclude none" = "match all"). User-facing behaviour matches what Rails, Sequel, Django, and Laravel Eloquent all converged on (empty IN matches no rows; empty NOT IN matches every row). Implementation differs from those frameworks because Wheels' WHERE parser has a stricter shape requirement. Also includes: - Reviewer A/B round-1/2 consensus items beyond the runtime fix: multi-line comment blocks collapsed (already in f1a300e), missing whereNotIn empty-list spec added (already in f1a300e), whereNotIn composition test added (B round 2 missed-issue). - Article, CHANGELOG, social post, and both copies of the query-builder guide updated to describe the corrected design. * fix(model): shape empty whereIn result like a normal zero-row findAll Reviewer A round-3 finding on ac32317: findAll() short-circuit returned QueryNew("") — a query with recordCount=0 but no columns. A normal findAll() returning zero rows still carries the model's full column set (read.cfc:220 constructs `QueryNew(local.columns)` in the empty-result path). Callers that introspect result.columnList — for example, an API serializer that exposes the field shape, or a debug tool — would see inconsistent results depending on whether the empty came from a real no-match query or a short-circuited whereIn. Fix: QueryNew(variables.modelReference.$classData().columnList) instead. $classData() returns variables.wheels.class which has the authoritative columnList the framework already maintains for the model. No DB call, same shape as a normal no-match findAll. Also adds three specs to address the "only count() is exercised" coverage gap noted in the same review: - findAll() with empty whereIn returns recordcount=0 and a non-empty columnList (verifies the shape fix). - first() returns false. - exists() returns false. These cover the four most commonly-called terminals — count, findAll, first, exists. The remaining four (findOne is aliased to first; updateAll, deleteAll, findEach, findInBatches are two-line wrappers that follow the same pattern) are mechanically identical to the covered cases. * fix(model): collapse test comment + document findAll short-circuit caveat Two non-blocking findings from Reviewer A round 4: - Test comment block in queryBuilderSpec.cfc (3 lines) collapsed to one line per CLAUDE.md's one-line-max rule. - Code note added to the findAll() short-circuit explaining that any chained .select() or .include() is intentionally ignored on the $alwaysEmpty path. Projection and eager-load are moot when the result has zero rows, and computing them from $classData would duplicate read.cfc's $createSQLFieldList logic. The trade-off is worth flagging in source for the next maintainer. DCO sign-off finding noted but skipped: the DCO check does not appear in this PR's required status checks (verified via get_check_runs on multiple SHAs), and PRs #2734 and #2735 both merged cleanly without Signed-off-by trailers. The CONTRIBUTING.md statement and operational reality on this repo disagree — that's a maintainer-side question, not a content fix for this PR. * style(model): shorten long inline comments in QueryBuilder Reviewer A/B round-5 convergence: three inline comments in QueryBuilder.cfc (lines 36, 128, 336) were technically one line but ran 133, 232, and 282 characters — outside the spirit of CLAUDE.md's "one short comment line max." Collapsed to short single lines (and a two-line form for the findAll() short-circuit comment that preserves both the shape rationale and the chained-select() caveat). No behaviour change. * docs: update stale "six new specs" count to nine across post/CHANGELOG/social Reviewer B round-1 nit on fee8621: CHANGELOG, blog post, and social skeleton all said "six new specs" but the actual file has nine (six count()-based, plus findAll, first, and exists). The "six" figure was correct mid-PR and got stale as round-3 added the three additional terminal specs. Numbers now match the file. No code or test changes. * docs(blog): move unpublished series posts to drafts folder CI promotes any file in web/content/blog/posts/ to the live blog on the next deploy, so unpublished drafts shouldn't live there. Move the three queued posts into docs/releases/blog-drafts/ where they wait until a human moves them back into web/content/blog/posts/ on publication day. Reschedule the every-other-day cadence the user wants, starting the day after the rate-limited-API post (published 2026-05-15): - Anatomy of a Wheels Package: 2026-05-22 -> 2026-05-17 (Sunday) - Wheels + Claude (stdio MCP): 2026-05-29 -> 2026-05-19 (Tuesday) - Beyond findAll: 2026-06-05 -> 2026-05-21 (Thursday) Also updated: - Each article's teaser line ("Coming next week") replaced with the actual weekday matching the new cadence. - Companion social-post skeletons in docs/releases/blog-skeletons/ updated to point at the new draft paths and the new dates. - New docs/releases/blog-drafts/README.md explains the promotion workflow (move draft -> web/content/blog/posts/ -> CI publishes). * test(model): close whereIn empty-array terminal + select/include gaps Reviewer A and B have both carried two non-blocking coverage gaps forward across multiple rounds, and B's most recent comment landed malformed (literal \n escapes) so the convergence marker didn't parse cleanly. The substance B keeps surfacing is real: four $alwaysEmpty terminals were unspecced, and the documented select()/include() silent-ignore on the short-circuit path had no spec lock either. Five new specs in queryBuilderSpec.cfc close both: - updateAll() returns 0 and touches no rows - deleteAll() returns 0 and removes no rows - findEach() never invokes its callback - findInBatches() never invokes its callback - findAll() ignores chained select() — the empty-result columnList is the full model column list, not a projection of the chained select. Locks in the trade-off documented in QueryBuilder.cfc lines 336-337. Total spec count for the whereIn empty-array fix goes from 9 to 14; CHANGELOG, article, and social skeleton updated to reflect the new count and the broader terminal coverage. No framework code changed. * test(model): start whereIn-empty + select chain at QueryBuilder entry point The model's onMissingMethod() only enters the QueryBuilder for a small allowlist of starting methods (where, orWhere, whereNull, whereNotNull, whereBetween, whereIn, whereNotIn, orderBy, limit, offset). select() is not in that list — it's a builder method but not an entry point. So `model("author").select("id").whereIn(...)` errors before whereIn ever sets the $alwaysEmpty flag, and the test fails before its assertions run. Lucee 7 + SQLite caught this on the prior push. Swap the chain order to start with whereIn (an entry point), then chain select() on the returned builder. The test still verifies the same documented trade-off: the $alwaysEmpty short-circuit ignores any chained select(), returning the model's full columnList rather than a projection. * test(model): collapse comment + tighten columnList assertion in select-ignore spec Two Reviewer A nits on 7e9b2e7: - Multi-line comment block (6 lines) collapsed to one short line per CLAUDE.md's one-line-max rule. - Len(result.columnList) -> ListLen(result.columnList) so the assertion counts columns rather than characters. Both forms passed for the author model in practice, but ListLen is the semantically correct check ("more than one column" vs "string longer than one char"). No behaviour change. * test(model): use ListLen on the round-3 columnList assertion too Reviewer A round-9 nit: spec 7 (the findAll() shape spec from round 3) still used Len(result.columnList).toBeGT(0), the same Len-vs-ListLen issue round 8 fixed on spec 14. Aligning the assertion across both specs — ListLen counts columns, which is what the assertion is semantically about. The round-3 form was technically correct on the author model (columnList is always non-empty when the model loads) but ListLen reads more clearly. --------- Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…#2731) * feat(view): add viewStyle preset to paginationNav and pageNumberLinks for Bootstrap markup The viewStyle argument accepts "plain" (default), "bootstrap5", "bootstrap4", or "tailwind". Bootstrap presets emit the canonical <nav><ul class="pagination"><li class="page-item active" aria-current="page"> <span class="page-link">N</span></li> structure — active class on the <li> wrapper, <span> for the current page — so Bootstrap-styled apps no longer need a Replace() regex hack to move the active class off the anchor. Default remains "plain" to preserve today's output byte-for-byte. Fixes #2718 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * docs: document viewStyle preset in CLAUDE.md pagination quick reference Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(view): address Reviewer A/B consensus findings (round 1) - vendor/wheels/view/pagination.cfc: encode arguments.text via EncodeForHTML when encode=true in the Bootstrap/Tailwind disabled branches of $renderPaginationNavLink. Previously local.encode was resolved but ignored, causing custom HTML-entity nav text (e.g. «) to render literally in the preset path while being decoded in the plain path. - vendor/wheels/tests/specs/view/paginationHelpersSpec.cfc: replace the weak Tailwind pageNumberLinks assertion (which passed even if Bootstrap markup was accidentally emitted) with Tailwind-specific class assertions (pagination-current, pagination-link), plus paginationNav coverage for viewStyle="bootstrap4" (active class on <li>, no aria-current) and viewStyle="tailwind" (flat <nav class="pagination"> with no <ul>, pagination-disabled spans on first page), and a showInfo=true positioning spec for Bootstrap 5. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(view): throw Wheels.InvalidViewStyle on unknown viewStyle (round 2) Reviewer A flagged in round 2 that the three internal $renderPagination* helpers silently emit structurally broken HTML when handed an unknown viewStyle value (e.g. a typo like "boostrap5"). The call-site guard `local.useViewStyle = Len(arguments.viewStyle) && arguments.viewStyle != "plain"` already filters out the empty/plain case, so reaching the default branch is by definition a developer typo. Replace the silent fallbacks with a Wheels.InvalidViewStyle throw so the failure is loud and immediate at development time instead of surfacing as garbled output. Specs cover both the pageNumberLinks() and paginationNav() public entry points, asserting the typed throw using the existing toThrow matcher pattern from assetsSpec/viteSpec. Round-2 carry-over per Reviewer A; round-1 address-review intentionally skipped this per Reviewer B's "nice-to-have" classification, but Reviewer A held it open as still required. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs: list bootstrap4 and tailwind under paginationNav too Reviewer A's round-2 docs nit: the CLAUDE.md "Pagination View Helpers" quick reference showed paginationNav with bootstrap5 only, while bootstrap4 and tailwind appeared only under pageNumberLinks. Both presets are equally valid on both helpers, so add the paginationNav companion lines for the two missing presets. Signed-off-by: Peter Amiri <peter@alurium.com> * docs: extend viewStyle ignore list with prepend/append/anchorDivider Reviewer A round-6 docs gap: the CLAUDE.md "Non-plain presets ignore..." list was written before PR #2730 added prepend/append/anchorDivider args. Those args also land in local.skipArgs and are NOT forwarded into $renderPaginationNav(), so the preset path silently drops them. Add the three names to the explicit ignore list so anyone reading the docs sequentially sees the full picture. 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[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Peter Amiri <peter@alurium.com>
…ppress (#2733) * fix(view): paginationNav anchor modes default to window-aware auto-suppress Widens `showFirst` / `showLast` / `showPrevious` / `showNext` on `paginationNav()` from `boolean` to `any`, accepting the tri-state strings `"auto"` / `"always"` / `"never"` with backwards-compatible boolean coercion (`true` -> `"always"`, `false` -> `"never"`). Defaults flip from `true` to `"auto"`. Under `"auto"` the first/last anchors only render when the visible page-number window does not already reach the boundary, restoring the legacy 3.x `paginationLinks(alwaysShowAnchors=false)` semantics that a like-for-like swap to `paginationNav()` previously lost. Adds a `windowSize` arg on `paginationNav()` so the auto-mode predicates stay coherent with `pageNumberLinks()`'s window. Fixes #2716 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * docs(web/guides): document paginationNav anchor display modes and alwaysShowAnchors parity Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(view): address Reviewer A/B consensus findings (round 1) Addresses the converged-changes consensus from Reviewer A and Reviewer B on PR #2733: - `vendor/wheels/view/pagination.cfc`: under `"auto"`, `showPrevious` and `showNext` now always delegate to `previousPageLink()` / `nextPageLink()`, preserving the legacy disabled-span boundary indicator that the original PR silently dropped at page 1 / last page. `"never"` remains the explicit hide-everything choice. Option (b) from the reviewers' converged guidance. - `vendor/wheels/view/pagination.cfc`: `windowSize` added to `local.skipArgs` so it no longer leaks into `firstPageLink()`, `lastPageLink()`, `previousPageLink()`, `nextPageLink()` (none of which declare it). It is passed explicitly to `pageNumberLinks()` to keep the windowed page-number coverage coherent with the auto-mode predicates. - `vendor/wheels/tests/specs/view/paginationHelpersSpec.cfc`: six new `it()` blocks under `paginationNav anchor display modes` covering Previous on page 1 and Next on last page in auto / never modes, plus the corresponding non-boundary paths. - `CHANGELOG.md`, `CLAUDE.md`, `web/sites/guides/src/content/docs/v4-0-1-snapshot/upgrading/3x-to-4x.mdx`: prose updated to describe the prev/next auto delegation and the `"never"` opt-out, alongside the existing first/last auto-suppress documentation. Local test run: `directory=wheels.tests.specs.view` against the Lucee 7 + SQLite server returns 529 pass, 0 fail, 0 error (up from 523 — six new tests added). Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * chore(web): refresh visual baseline(s) (blog) Manually triggered baseline refresh via .github/workflows/refresh-visual-baselines.yml on branch fix/bot-2716-paginationnav-default-anchor-display-ux-differs-fr. Run when an intentional content/layout change makes the visual-regression check fail. The new PNG(s) under web/tests/visual-baselines/ are now the expected rendering; re-run the failing visual-regression job to flip the check green. * chore(web): refresh visual baseline(s) (blog) Manually triggered baseline refresh via .github/workflows/refresh-visual-baselines.yml on branch fix/bot-2716-paginationnav-default-anchor-display-ux-differs-fr. Run when an intentional content/layout change makes the visual-regression check fail. The new PNG(s) under web/tests/visual-baselines/ are now the expected rendering; re-run the failing visual-regression job to flip the check green. * docs: address Reviewer A nits on CLAUDE.md pagination docs - Remove `windowSize` from the "Accepted pass-through keys" list — it is a first-class `paginationNav()` argument (in `skipArgs`), not a sub-helper passthrough. Document its first-class status explicitly so future contributors form the right mental model about what "pass-through" means in the validation context. - Annotate the Bootstrap 5 like-for-like swap example with a one-line note that callers wanting 3.x `alwaysShowAnchors=true` behaviour should pass `showFirst=\"always\"` / `showLast=\"always\"`. The upgrade guide already covers this; the CLAUDE.md quick-reference example did not. Signed-off-by: Peter Amiri <petera@pai.com> * fix(view): address Reviewer A/B consensus findings (round 3) - Move $paginationAnchorMode validation calls for showFirst/showLast/ showPrevious/showNext above the totalPages early-return in paginationNav() so invalid mode strings throw on single-page or empty result sets too. Matches the unknown-arg validation's stated rationale a few lines above; eliminates the silent-success on totalPages <= 1 that B flagged. - Strengthen the windowSize propagation spec in paginationHelpersSpec with expect(result).toInclude(">8<") so the test fails if windowSize is silently dropped from the pageNumberLinks() call. The existing notToInclude("First") alone could not catch a regression there because the First-hide predicate reads arguments.windowSize directly. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * chore(web): refresh visual baseline(s) (blog) Manually triggered baseline refresh via .github/workflows/refresh-visual-baselines.yml on branch fix/bot-2716-paginationnav-default-anchor-display-ux-differs-fr. Run when an intentional content/layout change makes the visual-regression check fail. The new PNG(s) under web/tests/visual-baselines/ are now the expected rendering; re-run the failing visual-regression job to flip the check green. * fix(view): address Reviewer A/B consensus findings (round 4) Forward `windowSize` to `pageNumberLinks()` on the `paginationNav()` viewStyle path so the auto-mode predicate and the rendered window are evaluated against the same value. - `vendor/wheels/view/pagination.cfc`: add `numeric windowSize` to `$renderPaginationNav()` signature, pass `windowSize` at the call site, and copy it into `local.pageArgs` before invoking `pageNumberLinks()`. - `vendor/wheels/tests/specs/view/paginationHelpersSpec.cfc`: add a regression-guard spec under "paginationNav with viewStyle presets" asserting that `paginationNav(viewStyle="bootstrap5", windowSize=4)` with `currentPage=5, totalRecords=100, perPage=10` includes page 8 in the output. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * chore(web): refresh visual baseline(s) (blog) Manually triggered baseline refresh via .github/workflows/refresh-visual-baselines.yml on branch fix/bot-2716-paginationnav-default-anchor-display-ux-differs-fr. Run when an intentional content/layout change makes the visual-regression check fail. The new PNG(s) under web/tests/visual-baselines/ are now the expected rendering; re-run the failing visual-regression job to flip the check green. --------- Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Signed-off-by: Peter Amiri <petera@pai.com> Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Peter Amiri <peter@alurium.com>
…S middleware is registered (#2728) * fix(middleware): short-circuit OPTIONS preflight in dispatch when CORS middleware is registered The new middleware pipeline ran AFTER route matching, so an OPTIONS preflight against a path that only declared POST/PUT/PATCH/DELETE 404'd in `$findMatchingRoute()` before `wheels.middleware.Cors`'s preflight branch could fire. The legacy `set(allowCorsRequests=true)` path aborted OPTIONS in `EventMethods.cfc` before dispatch, so the new middleware was strictly less capable than the 3.x setting it replaced. `Dispatch.$request()` now checks the verb up front and, if it is OPTIONS and the global pipeline contains a `wheels.middleware.Cors` instance, runs the pipeline against a no-op core handler so the CORS middleware can set headers and return without touching the route table. Behavior for OPTIONS without CORS middleware (still 404s) and for non-OPTIONS verbs (routed normally) is unchanged. Fixes #2703. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * docs(web/guides): note that OPTIONS preflight short-circuit requires global Cors registration Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(middleware): address Reviewer A/B consensus findings (round 1) - vendor/wheels/middleware/Cors.cfc: read request_method from arguments.request.cgi first (fall back to engine CGI scope) so the middleware respects the per-request context the pipeline passes in. Mirrors the RateLimiter pattern; required because a bare `request` reference inside a function resolves to the engine REQUEST scope, not the function argument. No production behavior change — engine CGI remains the fallback. - vendor/wheels/tests/specs/middleware/CorsSpec.cfc: add a unit test for the OPTIONS short-circuit branch that was previously dead from a unit-test perspective. - vendor/wheels/tests/specs/middleware/CorsPreflightDispatchSpec.cfc: switch _savedStaticRoutes from StructCopy (shallow) to Duplicate (deep) to match _savedRoutes; clarify in the first spec why the empty-string assertion is satisfied by Dispatch's no-op handler rather than Cors's own OPTIONS branch. - vendor/wheels/Dispatch.cfc: document the intent of the empty catch block around $getRequestMethod() (fail-closed: skip the short-circuit and let normal routing proceed). - web/sites/guides/src/content/docs/v4-0-0/digging-deeper/cors.mdx: mirror the v4-0-1-snapshot Aside warning that the preflight short-circuit requires global Cors registration; route-scoped Cors does not benefit because route matching runs first. Middleware suite: 3557 pass / 0 fail / 0 error (Lucee 7 + SQLite), including the new CorsSpec test and existing CorsPreflightDispatchSpec. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * chore(web): refresh visual baseline(s) (blog) Re-commits the auto-generated blog visual baseline PNG that was first produced by the refresh-visual-baselines.yml workflow run on this PR branch. The original commit body exceeded the 100-char limit enforced by commitlint (the branch name expanded inline), failing the Validate Commit Messages check; this re-commit carries the same binary content with a wrapped message. Run the visual-baselines workflow when an intentional content/layout change makes the visual-regression check fail. The new PNG(s) under web/tests/visual-baselines/ are now the expected rendering; re-run the failing visual-regression job to flip the check green. Signed-off-by: Peter Amiri <peter@alurium.com> * fix(middleware): address Reviewer A round-2 nits - Cors.cfc: normalise local.requestMethod with UCase() before comparing to "OPTIONS", matching the gating site in Dispatch.cfc and removing the case-sensitivity inconsistency. - Cors.cfc: correct the inaccurate comment about bare `request` resolving to the engine REQUEST scope. In CFML the arguments scope has higher lookup priority than named scopes (REQUEST, CGI), so bare `request` inside handle() resolves to arguments.request. The actual reason for the arguments.request.cgi-first lookup is that the engine CGI scope is read-only on Lucee 7, blocking unit tests from injecting OPTIONS via cgi.request_method. - Dispatch.cfc: drop the unused `method = local.preflightMethod` field from preflightContext. Cors.handle() reads the verb from arguments.request.cgi.request_method, not arguments.request.method, so the field is dead. Replace with a brief comment noting why the short-circuit context omits it (only Cors runs from this code path). No behaviour change. Existing CorsSpec and CorsPreflightDispatchSpec coverage continues to gate the OPTIONS short-circuit. Signed-off-by: Peter Amiri <peter@alurium.com> * docs(middleware): clarify preflight-context comment in Dispatch.cfc Reviewer A round-3 noted that the comment "only Cors is run from this short-circuit" is technically imprecise — `$middlewarePipeline.run()` still executes any middleware registered before Cors. Cors then short-circuits without calling next, so middleware registered after it does not run. Rewrite the comment to describe the actual pipeline behaviour. No code change. Signed-off-by: Peter Amiri <peter@alurium.com> * chore(web): refresh visual baseline(s) (blog) Re-commits the auto-generated blog visual baseline PNG produced by the refresh-visual-baselines.yml workflow run on this PR branch. The bot's default commit body expanded the branch name inline, producing a line that exceeded the 100-char limit enforced by commitlint; this re-commit carries the same binary content with a wrapped message body. Run the visual-baselines workflow when an intentional content/layout change makes the visual-regression check fail. The new PNG(s) under web/tests/visual-baselines/ are now the expected rendering; re-run the failing visual-regression job to flip the check green. Signed-off-by: Peter Amiri <peter@alurium.com> * chore(web): refresh visual baseline(s) (blog) Re-commits the auto-generated blog visual baseline PNG produced by the refresh-visual-baselines.yml workflow on this PR branch. The bot's default commit body expanded the branch name inline, producing a line that exceeded the 100-char commitlint limit; this re-commit carries the same binary content with a wrapped message body. Triggered after develop merged PR #2735, which added a new blog post (wheels + claude stdio MCP setup) that shifted the blog index render. Signed-off-by: Peter Amiri <peter@alurium.com> * chore(web): refresh visual baseline(s) (blog) Re-commits the auto-generated blog visual baseline PNG produced by the refresh-visual-baselines.yml workflow on this PR branch. The bot's default commit body expanded the branch name inline, producing a line that exceeded the 100-char commitlint limit; this re-commit carries the same binary content with a wrapped message body. Triggered after develop merged PR #2736, which added a new blog post (beyond findAll) that shifted the blog index render. Same workflow body-length bug previously hit on #2734 and #2735 baseline refreshes in this PR. Signed-off-by: Peter Amiri <peter@alurium.com> * docs(middleware): address Reviewer A/B consensus findings (round 2) Address Reviewer A's design note (echoed in Reviewer B's converged-changes verdict) about the dispatch preflight short-circuit's subclassing constraint. `Dispatch.$hasPreflightCapableMiddleware()` detects preflight-capable middleware with `IsInstanceOf(mw, "wheels.middleware.Cors")`, so the short-circuit fires for the canonical class and any subclass that extends it -- but a custom CORS middleware that implements `MiddlewareInterface` directly without extending `wheels.middleware.Cors` will not trigger the short-circuit. Existing `<Aside>` only covered the global-vs-scoped limitation; this commit extends it with the subclassing constraint and the recommended workaround (extend `wheels.middleware.Cors`). Docs updated in both v4-0-0 and v4-0-1-snapshot guides; no code change because the behaviour itself is correct and `IsInstanceOf` is the right detection primitive for a stdlib-recognised CORS contract. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.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[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Peter Amiri <peter@alurium.com>
…char limit (#2737) The auto-generated commit body inlined the branch name on the same line as the workflow path, producing lines >100 chars for normal-length branch names (e.g. fix/bot-2703-wheels-middleware-cors-cannot-short-circuit-option ran 121 chars) and failing commitlint's body-max-line-length rule. Split "on branch <BRANCH>." onto its own line so the longest body line now caps at "on branch " + branch + ".", leaving ~89 chars of headroom for the branch name itself. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ti-patterns #11-14 (#2740) CLAUDE.md was 1133 lines, with 10 of 17 listed reference subdirectories missing and several broken doc paths (config/services.cfm, docs/src/...). Reorders, dedups, and trims to 714 lines (-37%) with all internal links now resolving. Top-of-file restructured for high-leverage content first: - Code Map: where framework/demo/CLI live and how they relate - Before-Done checklist: which test suite to run for which change type - Cross-Engine Invariants: promoted from buried Docker / Browser-Testing locations - Anti-Patterns extended Top 10 -> Top 14, all new entries sourced from recent PRs: - #11 CFML reserved scopes shadow parameters (#2591) - #12 Empty array in whereIn / whereNotIn (#2736) - #13 Comma-list config != single-value HTTP header (#2725) - #14 Strip CFML comments before source-scanning (#2595) Extracted sections, loaded only when relevant: - .ai/wheels/deploy.md (92 lines) - wheels deploy Kamal port reference - .ai/wheels/wheels-bot.md (34 lines) - bot architecture - .ai/wheels/testing/browser-testing.md (68 lines) - browser DSL Other dedups: t.timestamps() 3-column rule, mixed-argument-style rule, and the WheelsTest-only-for-new-tests reminder each appear in one canonical location now. Reference Docs list at bottom lists only verified-to-exist files. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ockingSpec skips (#2746) * fix(model): override $supportsAdvisoryLocks=false on CockroachDB so lockingSpec skips CockroachDBModel extends PostgreSQLModel, which reports $supportsAdvisoryLocks()=true since pg_advisory_lock is native there. CockroachDB intentionally omits the primitive — its adapter throws "CockroachDB does not support advisory locks." from $acquireAdvisoryLock — but it never overrode the capability flag, so the four lockingSpec withAdvisoryLock tests bypassed the beforeEach skip-guard added in #2670 and reported as errors on every engine (lucee6, lucee7, boxlang). PR #2670's CHANGELOG entry already named CockroachDB in the "skips on" list, but the override itself was missed. This adds the one-method override, mirroring how MSSQL/SQLite/etc. each carry their own $supportsAdvisoryLocks override over Base.cfc's default-false. Fixes #2743 Signed-off-by: Peter Amiri <peter@alurium.com> * docs(model): trim redundant forUpdate hint from CockroachDB $supportsAdvisoryLocks docblock Reviewer A nit on #2746: the final sentence duplicates guidance already on the adjacent $acquireAdvisoryLock / $releaseAdvisoryLock docblocks. Keep the comment focused on the WHY (inheritance/capability-flag subtlety). Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com>
…ssertion (#2748) * fix(migrator): carve out MySQL in addColumnOptionsSpec text-default assertion Closes #2742. Follow-up to #2661/#2669. `addColumnOptionsSpec :: text with a real default (non-empty) still emits DEFAULT` failed on every MySQL leg of the compat matrix (lucee6/mysql, lucee7/mysql, boxlang/mysql) with the assertion message `The needle [DEFAULT] was not found in [ NULL]`. The MySQL adapter's `optionsIncludeDefault` intentionally returns false for `text`, `mediumtext`, `longtext`, and `float`, so the inherited Abstract `addColumnOptions` short-circuits the entire DEFAULT clause for those types — emitting only the `NULL`/`NOT NULL` suffix. The pre-8.0.13 MySQL constraint that TEXT/BLOB columns reject DEFAULT is what motivates the suppression, and the framework targets the broadest supported MySQL surface rather than emitting DDL that fails on older servers. The spec now mirrors the existing `isPostgresFamily` adapter-aware pattern: a new `variables.isMySQLFamily` flag in `beforeAll` carves out the MySQL branch, asserting `notToInclude("DEFAULT")` while every other Abstract-based adapter continues to assert `toInclude("DEFAULT")` plus `toInclude("'long body'")`. Header doc-comment updated to document the MySQL divergence alongside the PG/CockroachDB one. `MySQLMigrator.optionsIncludeDefault`'s one-line doc-comment was expanded to explain WHY (legacy MySQL TEXT/BLOB constraint), what the user-visible behavior is (non-empty default silently suppressed), and to reference the spec contract — keeping the suppression list and the test aligned. Pure test + doc-comment change. No production behavior delta. Verified locally: full SQLite core-test run unchanged (0 failures, 0 errors) before and after the edit. CI matrix will validate the MySQL legs. Signed-off-by: wheels-bot[bot] <283220952+wheels-bot[bot]@users.noreply.github.com> * docs: document MySQL TEXT/FLOAT DEFAULT suppression in cross-engine-compat guide MySQLMigrator.optionsIncludeDefault returns false for text-family and float columns, causing addColumnOptions to silently drop the DEFAULT clause — even for non-empty defaults. Mirrors the existing isPostgresFamily note with an isMySQLFamily carve-out example. Surfaced by #2742 / PR #2748. Signed-off-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com> Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(migrator): address Reviewer A/B consensus findings (round 1) - Rename the `it()` description at addColumnOptionsSpec.cfc:102 from "text with a real default (non-empty) still emits DEFAULT" to "text with a real default (non-empty): DEFAULT clause is adapter-dependent". The old name contradicted the MySQL branch's `notToInclude("DEFAULT")` assertion. The neutral name surfaces the adapter-dependence in CI failure reports. - CHANGELOG.md: append `/#2669` to the trailer of the Fixed entry. The PR description and commit body both cite "#2661/#2669", but the CHANGELOG trailer dropped #2669 — which is the PR that introduced the `isPostgresFamily` carve-out this change explicitly mirrors. Pure text changes: no executable test logic touched, no production code delta. Test runner not available in the address-review CI environment (no `wheels` CLI installed); `test-local.sh` portability bug aside, neither edit can regress test execution. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --------- Signed-off-by: wheels-bot[bot] <283220952+wheels-bot[bot]@users.noreply.github.com> Signed-off-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com> Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: wheels-bot[bot] <283220952+wheels-bot[bot]@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Peter Amiri <peter@alurium.com>
…with-VALUES error (#2749) * fix(model): Oracle insertAll uses INSERT ALL form to avoid RETURNING-with-VALUES error Bulk insert SQL was always emitted as the SQL-standard multi-row table value constructor — `INSERT INTO t (cols) VALUES (?,?), (?,?), ...` — which Oracle 23 rejects in combination with the JDBC driver's implicit `RETURN_GENERATED_KEYS` handling ("ORA: returning clause is not allowed with INSERT and Table Value Constructor", plus a follow-on "ORA: no statement parsed" for related lifecycle reasons). Moves bulk-insert SQL building off the model mixin and onto the database adapter (`$bulkInsertSQL` on Base.cfc, mirroring the existing `$upsertSQL` pattern). OracleModel overrides it with `INSERT ALL INTO t (cols) VALUES (...) ... SELECT 1 FROM dual` — Oracle's idiomatic multi-row insert form, which neither uses the table value constructor nor triggers the RETURNING expansion. Non-Oracle adapters keep the standard multi-row VALUES shape. The migrator-rename "Closed statement" error in the same compat-matrix run is a separate Oracle JDBC lifecycle issue and is left for a follow-up. Fixes #2745 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * docs: note Oracle INSERT ALL requirement for bulk insert in cross-engine guide 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> Co-authored-by: Peter Amiri <peter@alurium.com>
…ide (#2747) * fix(model): skip CockroachDB advisory-lock specs via capability override CockroachDBModel extends PostgreSQLModel, which reports `$supportsAdvisoryLocks() = true` because Postgres has pg_advisory_lock. CockroachDB does not, so its $acquireAdvisoryLock / $releaseAdvisoryLock throw Wheels.AdvisoryLockNotSupported. Without a capability override the four standalone advisory-lock specs in `lockingSpec.cfc` skipped past their beforeEach guard and errored on every Cockroach-backed compat- matrix run (Lucee 6, Lucee 7, BoxLang). Adding the override lets them skip cleanly, matching the H2 / SQL Server treatment from #2665. Fixes #2743 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * docs(model): rework CockroachDB $supportsAdvisoryLocks docblock per review Reviewer A flagged that the existing wording led with "lets the test suite skip standalone-lock paths instead of erroring" — framing the override as a test workaround rather than a correctness fix. Reorder to lead with the underlying behavior ($acquireAdvisoryLock / $releaseAdvisoryLock throw Wheels.AdvisoryLockNotSupported), then explain what consulting the flag enables, then cite the H2 / SQL Server precedent from #2665. No functional change. 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[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Peter Amiri <peter@alurium.com>
…e CF 2023 (#2750) * fix(dispatch): copy arguments to a plain struct in $header() for Adobe CF 2023 Adobe CF 2023 rejects the raw `arguments` scope as `attributeCollection` on `cfheader`, throwing `Failed to add HTML header` on every request and recording `0 pass / 0 fail / 0 err` in the compat-matrix Adobe 2023 job because no test endpoint can boot past application init. The helper already had the fix on the `statusText` branch (added for Adobe CF 2025): copy `arguments` into a plain `local.args` struct before calling `cfheader`. Collapse the two branches so every call hands `cfheader` a plain struct — no API change, no caller updates. Closes #2741 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(dispatch): extend plain-struct copy to all attributeCollection sites in Global.cfc Adobe CF 2023's restriction on the raw `arguments` scope as `attributeCollection` is engine-level, not specific to `cfheader`. The initial PR only patched `$header()` (the dispatch-path blocker). This applies the same plain-struct copy uniformly to all ten remaining helpers in `Global.cfc` so subsequent test paths that touch `$content`, `$cache`, `$mail`, `$location`, `$htmlhead`, `$directory`, `$file`, `$invoke`, `$wddx`, or `$zip` on Adobe CF 2023 do not hit the same rejection. Also drop the `g.$header()` call from `headerSpec.cfc`'s `afterEach`: calling the function under test during cleanup would mask a regression as an `afterEach` lifecycle error rather than a specific `it`-block failure. The `cfheader` reset alone is sufficient. Addresses Reviewer A on #2750. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * test(global): add semicolons to back-to-back cfheader calls in headerSpec.cfc Lucee 7's parser fails on two adjacent `cfheader(...)` script calls in the same block when neither terminates with a semicolon, throwing `Syntax Error, Invalid Construct` at the closing `})` of the afterEach arrow function. The error stops the entire vendor/wheels/tests/specs bundle from compiling, so every test reports HTTP 500. Semicolons on both cfheader lines disambiguate the statement boundary. Pattern matches the existing precedent in vendor/wheels/tests/specs/controller/renderingSpec.cfc:297. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(dispatch): patch direct-struct attributeCollection sites in image and dbinfo Two helpers in Global.cfc used the CFScript direct-struct form (`attributeCollection = arguments`, no `##` interpolation) rather than the string-interpolated form patched in the previous commit. Adobe CF 2023's restriction is on the `arguments` scope shape — not on the interpolation path — so both forms are at risk. $dbinfo() is the higher-priority site: it is called from databaseAdapters/Base.cfc during adapter initialization and from migrator/Base.cfc on every migration helper. A throw here on Adobe CF 2023 would keep the test matrix red even after the dispatch-path fix. $dbinfo()'s catch block mutates the arguments scope between its four cfdbinfo calls, so local.args is rebuilt before each call rather than copied once. Addresses Reviewer A's follow-up on #2750. 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>
…#2745) (#2753) * fix(model,migrator): Oracle bulk insert and migrator rename (#2745) `Model.insertAll()`: Oracle does not allow multi-row `INSERT ... VALUES (...), (...)` together with the JDBC driver's auto-RETURNING (triggered by `cfquery result="..."`). Move bulk-insert SQL generation into the adapter behind a new `$bulkInsertSQL()` hook (mirrors `$upsertSQL()`); Base keeps the multi-row form, Oracle returns one single-row INSERT per record so the auto-RETURNING transform only ever sees a plain `INSERT INTO ... VALUES (...)`. `Migrator.renameSystemTables()`: Oracle implicitly commits DDL and closes the JDBC statement, so the surrounding `transaction action="begin" { ... commit }` then raised `ORA: Closed statement`. Skip the transaction wrapper on Oracle (the existing code comment already acknowledged it was a no-op there); other engines keep their atomic-rollback path. BoxLang × Oracle was the only engine exercising these paths because Lucee 6/7 still skip Oracle on the soft-fail track from #2663. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs(ai): document Oracle DDL auto-commit transaction-wrapper pattern Add an `Oracle — DDL Auto-Commit and Transaction Wrapper` section to the cross-engine compat doc, mirroring the existing Oracle bulk-insert section. Documents the `Closed statement` failure mode and the adapter-branch pattern used in `Migrator.renameSystemTables`. Per Reviewer B's follow-up observation on PR #2753 — closes the deep-reference gap so future devs adding DDL operations find the pattern in `.ai/` instead of only in the inline Migrator.cfc comment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs(ai): clarify MySQL atomicity in DDL transaction-wrapper section Per Reviewer A's nit on PR #2753: MySQL DDL also implicitly commits (InnoDB transaction wrapper is a no-op for DDL). The MySQL atomicity guarantee comes from `RENAME TABLE a, b` being a single multi-pair statement, not from the transaction. Persistent `.ai/` reference now reflects that distinction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs: align MySQL DDL atomicity wording across CHANGELOG and Migrator.cfc Reviewer B noted the CHANGELOG entry inherits the same MySQL imprecision the `.ai/` doc had — "MySQL on InnoDB keep their atomic-rollback behavior" implies the transaction wrapper rolls MySQL DDL back, but MySQL DDL also implicitly commits. The atomicity actually comes from `RENAME TABLE a, b` being a single multi-pair statement. Update the CHANGELOG entry and the inline `Migrator.cfc` comment so all three reference points (`.ai/`, CHANGELOG, code comment) say the same thing. 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>
) * fix(test): work around BoxLang catch-scope discard in lockingSpec (#2744) `lockingSpec :: "releases lock even when callback throws an exception"` failed on BoxLang × MySQL / Postgres / SQLite — the missing leg of #2665 that #2670 intentionally deferred. The test tracked exception propagation through `local.exceptionThrown = true` inside the `catch` block; on BoxLang, writes to the `local` scope inside a catch don't survive past the block (the catch body runs under a nested `local` that gets discarded on exit), so the post-catch `expect(local.exceptionThrown)` read the un-touched outer value and failed with "Expected [false] to be true". Lucee 6 / 7 and Adobe CF 2023 / 2025 share the enclosing function's `local` across the catch, so the assignment stuck on every other engine and the gap was BoxLang-only. Switched to the same struct-field pattern TenantResolverSpec already uses for the equivalent assertion: var state = {exceptionThrown = false}; try { ... } catch (TestException e) { state.exceptionThrown = true; } expect(state.exceptionThrown).toBeTrue(); Struct field assignment targets a heap object and survives the scope transition on every engine. `vendor/wheels/model/locking.cfc` is unchanged — the lock-release contract was already correct via the existing `try { callback() } finally { release }`; the lock-release bug the issue title called out turned out not to exist. Documented the BoxLang catch-scope quirk in `.ai/wheels/cross-engine-compatibility.md` so future spec authors don't re-hit it. Both the `var struct.field` form and the `var bareName` form are called out as portable; the `local.X = ...` form is called out as the trap. Fixes #2744 Signed-off-by: Peter Amiri <peter@alurium.com> * docs(claude): address Reviewer A nits on #2744 (CLAUDE.md invariant + doc + spec) - Collapse the 8-line in-spec comment to one line. The `.ai/` doc carries the full WHY; the spec only needs the pointer. Matches CLAUDE.md's "one short line max" rule for inline comments. - Switch struct literal in `lockingSpec.cfc` from colon (`:`) to equals (`=`) syntax to match the prior-art pattern in `TenantResolverSpec` (`var result = {threw = false}`) and the WRONG/RIGHT examples in the cross-engine doc. Both are valid CFML; consistency wins. - Add a "Why the bare-`var` form survives" paragraph to the `.ai/wheels/cross-engine-compatibility.md` BoxLang catch-scope section so future readers don't ask why `var caught; caught = true` survives when `local.caught = true` doesn't. Also recommends preferring the struct-field form since the bare-var mechanism may not be a stable contract across BoxLang releases. - Add invariant #10 to CLAUDE.md's "Cross-Engine Invariants" checklist. CLAUDE.md is the canonical drive-by checklist; spec authors who stop there need this rule visible at the surface level. One-liner pattern matches the existing 9 invariants. Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com>
…CF 2023 (#2755) * docs: document attributeCollection + arguments scope gotcha on Adobe CF 2023 Add cross-engine invariant #10 to CLAUDE.md and a matching section to .ai/wheels/cross-engine-compatibility.md: Adobe CF 2023 rejects the raw arguments scope passed as attributeCollection (e.g. cfheader), while Lucee 6/7, BoxLang, and Adobe 2018/2021 accept it. Fix is to copy into a plain struct first. Follows fix in vendor/wheels/Global.cfc::$header (#2741). Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * docs: generalize attributeCollection gotcha to any built-in CFML tag Reviewer A's round-2 sub-nit and round-3 finding (and Reviewer B's convergence in the broader round) flagged that CLAUDE.md invariant #10 named only `cfheader`, while the underlying restriction applies to every built-in CFML tag that accepts `attributeCollection`. PR #2750 has since merged, patching all 12 affected wrappers in `vendor/wheels/Global.cfc` uniformly — which is hard evidence the broader rule is correct. Generalize invariant #10 in CLAUDE.md to "any built-in CFML tag", enumerate the 12 affected wrappers, and call out the two attribute forms (`"##arguments##"` and direct-struct). Update `.ai/wheels/cross-engine-compatibility.md` to match: WRONG block now shows both forms, the Reference link points to #2750 (the canonical multi-site fix), and the $dbinfo() per-call rebuild note is preserved as guidance for helpers that write through `arguments` between tag invocations. Docs-only — no code change. Addresses the substantive review feedback that survived the rebase onto #2750. Signed-off-by: Peter Amiri <peter@alurium.com> * docs(claude): use single-hash CFML interpolation in invariant #10 example Reviewer A round 5: invariant #10's inline description of "the string-interpolated form" used `"##arguments##"`. The double-hash is CFML's escape for a literal `#` character — a reader copy-pasting it into CFScript would get the string `#arguments#` rather than evaluating the arguments scope, the opposite of what the invariant warns against. The companion .ai/wheels/cross-engine-compatibility.md section at line 238 already uses single hashes correctly; align CLAUDE.md to match. Signed-off-by: Peter Amiri <peter@alurium.com> * docs: correct wrapper count from 12 to 13 in attributeCollection invariant Reviewer B round 7: the enumerated list of affected `Global.cfc` wrappers has 13 items (cfheader, cfcache, cfcontent, cfmail, cfdirectory, cffile, cflocation, cfhtmlhead, cfimage, cfdbinfo, cfinvoke, cfwddx, cfzip) but both CLAUDE.md invariant #10 and .ai/wheels/cross-engine-compatibility.md said "12". Verified against develop's `vendor/wheels/Global.cfc` — 13 distinct wrapper functions forward `arguments` to a `cf*(attributeCollection = ...)` site (cfmailparam and cfmailpart are correctly excluded — they use the pre-built `local.i` struct, not the arguments scope). Pure number fix in two places. Signed-off-by: Peter Amiri <peter@alurium.com> * docs: extend attributeCollection invariant to Adobe CF 2023 and 2025 Reviewer A round 1 on #2755 caught a real correctness gap: develop's `Global.cfc::$header()` comment explicitly says "Adobe CF 2023+ rejects the raw arguments scope" — the `+` is load-bearing — and #2750 patched all 13 wrappers precisely because the restriction extends past 2023. The docs as previously written named only "Adobe CF 2023", giving a developer reading invariant #10 no signal to guard their code on Adobe CF 2025. Updates: - CLAUDE.md invariant #10: "Adobe CF 2023" → "Adobe CF 2023 and 2025" throughout (opener, throw clause, "require the plain struct" clause). - .ai/wheels/cross-engine-compatibility.md: section heading is "(Adobe CF 2023/2025)"; intro and Why paragraph name both engines; WRONG block comment says "crashes Adobe CF 2023 and 2025". - RIGHT example in the .ai/ doc now mirrors the WRONG block by showing both invocation forms working once `local.args` is a plain struct (cfheader interpolated form + cfimage direct form), with a brief comment explaining why both are safe at that point — addresses Reviewer A's non-blocking nit that the original RIGHT example showed only the string-interpolated form while $image() uses the direct form. Engine matrix is unchanged otherwise: Lucee 6/7 + BoxLang + Adobe 2018/2021 still accept; Adobe 2023/2025 reject. 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[bot] <41898282+claude[bot]@users.noreply.github.com>
…committed (#2756) * fix(dispatch): swallow cfheader InvalidHeaderException when response committed `$header()` is called from `$runOnError` (EventMethods.cfc:113) to set `Content-Type: application/json` on the error response. On Adobe CF 2023 and 2025, the response buffer can already be committed by the time onError fires (any partial output from a view that errored mid-render flushes the buffer at the engine's default threshold). cfheader then throws `InvalidHeaderException: Failed to add HTML header`, which replaces the original exception with the cfheader-failure stack — and every adobe2023/adobe2025 job in the compat matrix returns an HTML error page whose root cause is the secondary header failure, not the real bug. Probe `response.isCommitted()` before calling `cfheader` and return silently when the buffer has already flushed. Callers that need the header guaranteed should set it before producing output; inside onError, swallowing is the right contract because the original exception is what the operator needs to see. A wrapping try/catch is kept as defense-in-depth for engines where the probe misbehaves. The new `$responseCommitted()` helper sits next to `$header()` so other tag wrappers ($content, $location, $cache, ...) can pick it up incrementally as we find further onError-cascade failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(dispatch): narrow $header() catch and add $responseCommitted spec Address review feedback on #2756: 1. The defense-in-depth `catch (any e)` around `cfheader` was swallowing every `cfheader` failure, not just the "response committed" race — so genuine caller bugs (bad attribute combos, engine bugs) would no longer propagate. The catch now re-probes `$responseCommitted()` and rethrows when the response is still uncommitted, restoring the pre-#2756 error-propagation contract for every path except the onError cascade we set out to fix. 2. New spec in `headerSpec.cfc` exercises `$responseCommitted()` and asserts the declared `boolean` return type holds on every engine. A future API shift (e.g. a BoxLang `PageContext` change) now fails here in-process instead of going invisible until a weekly compat run. 3. Added the missing CHANGELOG entry under `[Unreleased] > Fixed`, matching the existing prose style for the surrounding entries. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(test): swallow reset() when response committed in BaseReporter After the `$header()` defensive fix in dfeaed9 unmasked the original exception, the adobe2023/adobe2025 compat-matrix logs reveal the real root cause for the chronic Adobe CF compat-matrix failures: the vendored TestBox `JSONReporter.runReport()` calls `BaseReporter.resetHTMLResponse()`, which calls `getPageContextResponse().reset()` — and on Adobe CF 2023/2025 running under Undertow, that throws `IllegalStateException: UT010019: Response already commited` when the response buffer has flushed (populate.cfm or test infrastructure wrote output during setup). The adjacent Lucee-only `resetHTMLHead()` call a few lines up is already wrapped in `try/catch` for the same defensive reason — extend the same shape to the bare `reset()` call. If the reset fails the reporter content still emits, just appended to whatever already flushed; the structured JSON test result is what runner.cfm consumes downstream, so the body shape is preserved. This is the actual blocker for adobe2023/adobe2025 in the weekly compat matrix — every Adobe job has been returning an HTML error page whose cfheader cascade masked this upstream reset failure. With both the dispatch-layer fix (PR #2756) and this reporter fix in place, the Adobe legs should produce structured JSON test results for the first time since the matrix was added. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs(test): broaden BaseReporter catch-comment to match catch scope Address Reviewer A round-2 nit on #2756. The `catch (any e)` block in `resetHTMLResponse()` is broader than the previous one-line comment ("Response already committed") suggested — `any` includes any future engine-specific reason `reset()` might be unavailable, not just the known Undertow `UT010019` case. Broaden the comment to reflect actual catch scope (known case + any other reason `reset()` is unavailable) and document why the catch is deliberately silent (no `writeDump`): the adjacent `resetHTMLHead()` `writeDump` is an engine-compat diagnostic — "this Lucee version doesn't ship the method" — which fires once and is informative. The `reset()` failure here is a runtime-state condition (response already flushed) that fires on every successful Adobe CF test run by design, so a `writeDump` would produce noise on every Adobe leg without adding signal. Code behavior unchanged; comment-only fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(test): route runner.cfm header/content calls through defensive helpers The previous two fixes (#2756 dispatch + BaseReporter reset) unblocked TestBox-side bootstrap but the compat-matrix adobe2023 leg on `c3e163213` still fails — only now the cfheader cascade traces to `vendor/wheels/tests/runner.cfm:160`: cfheader(name="Access-Control-Allow-Origin", value="*"); By the time `testBox.run()` returns, Adobe CF (Undertow) has committed the response — individual test specs writing output during the run crossed the engine's buffer threshold and flushed mid-suite. The runner's post-test `cfheader` / `cfcontent` calls then throw `InvalidHeaderException: Failed to add HTML header`. Two changes: 1. `$content()` in `vendor/wheels/Global.cfc` picks up the same `$responseCommitted()` short-circuit and try/catch shape as `$header()`, so callers in error paths or post-flush contexts get best-effort behaviour. Mirrors the existing `$header()` contract that landed in this PR. 2. The eight `cfheader` and four `cfcontent` sites in `runner.cfm` now route through `application.wo.$header()` / `$content()`. The runner already uses `application.wo` elsewhere (`$dbinfo`, etc.) so the wiring is consistent. The status-code header is what CI parsers key on; a committed response keeps whatever status the engine already wrote, and the JSON body still appends below. This should be the third and final layer of the adobe2023/2025 unwind. The same defensive shape is now centralised on the two framework helpers (`$header`, `$content`), so future tag wrappers (`$location`, `$cache`, ...) can adopt it incrementally without re-deriving the isCommitted probe. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs(changelog): correct cfheader count from eight to six Reviewer A flagged the same off-by-two count error in the commit body of 024b08d under Correctness ("eight cfheader" — actual count is six) but did not carry the check through to the CHANGELOG entry, which contained the identical figure. Reviewer B caught the missed instance on round-1 convergence. Verified by grepping the runner.cfm diff: six `cfheader` sites (statuscode=500, statuscode=417 ×2, statuscode=200 ×2, Allow-Origin) and four `cfcontent` sites (application/json ×2, text/plain, text/xml). Cosmetic only; the code is correct. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(model): env() defaults work on Adobe CF when caller omits fallback `env(required string name, any default = "")` was throwing `UndefinedElementException: Element DEFAULT is undefined in ARGUMENTS` on Adobe CF 2023/2025 whenever called with a single positional arg (`env("KEY")`) — the common case for "this env var must be set." `default` is a CFML reserved word (switch/case/default), and Adobe CF's argument-binding leaves the matching arguments-scope key undefined rather than seeding it from the signature default. Lucee and BoxLang seed it correctly, so the function appeared to work everywhere except in the Adobe legs of the compat matrix — where this single test (`envHelperSpec.cfc:28`) error escaped TestBox's per-spec catch and propagated all the way up to `runner.cfm`, poisoning the entire test run with an HTML error page. Switch to defensive access — `StructKeyExists(arguments, "default") ? arguments.default : ""` — which behaves identically on engines that seed the default (Lucee/BoxLang return `""` either way) and fixes the Adobe path. Public `@default` API is unchanged; callers that pass `default = "X"` keep working. Surfaced after the three-layer dispatch/test-runner unmasking (#2756) stopped onError-cascade `cfheader` failures from hiding the real test exceptions. The compat matrix now actually runs Adobe CF tests, exposing this one as the next blocker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs(global): CHANGELOG entry + sync-guard comment for env() Adobe fix Address Reviewer A nits on ac0fead: 1. Missing CHANGELOG `[Unreleased] > Fixed` entry for the env() Adobe CF reserved-word fix — adds the standard root-cause-chain prose matching the surrounding entries. 2. Latent risk in env(): the inline `""` fallback used on the Adobe CF path must stay in sync with the signature default. A future author changing one without the other would silently diverge between Adobe (defensive path) and Lucee/BoxLang (binder path). Adds a NOTE comment calling that out. The misleading `model` scope on ac0fead is a past-commit comment nit (env() lives in Global.cfc, not a model). CLAUDE.md notes scope is optional and unrestricted, so it's documentation-only — not worth amending and force-pushing for. Using `global` going forward. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(global): rename env() default parameter to defaultValue The earlier defensive-access fix (ac0fead) closed the `UndefinedElementException` symptom on Adobe CF 2023/2025 when callers passed no second arg, but it did not close the symmetric bug: Adobe CF refuses to bind a parameter named `default` at all, so even when a caller passes the second positional arg (`env("KEY", "custom_default")`), `arguments.default` is undefined and the function silently returns `""`. The current compat run surfaced this as `envHelperSpec.cfc:33` reporting `Expected [custom_default] but received []`. Renaming the parameter to `defaultValue` is the only portable shape — Adobe CF binds non-reserved names normally, and Lucee / BoxLang bind any name including reserved words. The framework's own specs use positional calls so they're unaffected. Back-compat for the legacy named-arg form `env(name = "X", default = "Y")` is preserved: named arguments land in the arguments scope under their literal key regardless of the declared parameter list, so checking `StructKeyExists(arguments, "default")` first resolves legacy named-arg callers without re-introducing the binding bug. Docstring updated. CHANGELOG entry replaced (the previous defensive-access description no longer matches the implementation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * test(global): cover env() back-compat named-arg path Reviewer A nit on 176cf8b: the `StructKeyExists(arguments, "default")` shim in `env()` is the load-bearing piece of the back-compat story for the legacy `env(name = "X", default = "Y")` named-arg form, but no spec exercised that shape. Adds an `it` block asserting the legacy named-arg call still resolves correctly. Regression catch — if the guard is ever removed during a future refactor, or a future CFML engine rejects `default` as a named-arg key at the call site, this assertion fails before the named-arg form silently breaks again. The primary positional-arg path is already covered by the adjacent `env("NONEXISTENT_KEY_12345", "custom_default")` spec. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * test(global): wrap headerSpec afterEach cleanup in best-effort try/catch After the rest of this PR's fixes let Adobe CF 2023/2025 reach the spec-execution stage, the compat-matrix adobe2023 leg surfaced one more cascade — this time in `headerSpec.cfc`'s `afterEach`: cfheader(statuscode = 200); cfheader(name = "content-type", value = "text/html"); When an earlier spec in the same bundle writes test output that crosses Adobe's buffer threshold, the response commits and the bare `cfheader` calls in afterEach throw `InvalidHeaderException: Failed to add HTML header`, which surfaces as an opaque "Template" exception that takes down the whole bundle. The bare-cfheader contract here is deliberate — using `g.$header()` in the cleanup would let a regression in the unit-under-test mask itself as a lifecycle error. Best path is keeping the bare calls but wrapping each in its own `try/catch` so the cleanup is best-effort (the committed-response case is expected on Adobe runs, not a regression), while still exercising the bare-engine API. Lucee 7 / BoxLang / Adobe 2018/2021 paths are unchanged — they never reach the catch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(test): suppress inner-spec statuscode mutations from outer response The previous test-runner layers stopped the cfheader cascade and let TestBox actually run all 3697 specs on Adobe CF 2023/2025, returning totalPass=3681 with 11 fails + 2 errors in the JSON. But the compat matrix still failed with HTTP 404 because the OUTER response status was being mutated by INNER test fixtures: specs calling `processRequest()` hit Wheels routing that throws `Wheels.RouteNotFound` and calls `$header(statusCode = 404)` via `$throwErrorOrShow404Page`. That call goes through the framework's `$header()` helper, which sees the response is uncommitted (it typically isn't yet, mid-suite) and mutates the OUTER response status to 404. By the time `runner.cfm` runs its own `$header(statusCode = 200|417)` after `testBox.run()`, the response has committed and the helper short-circuits — leaving the bleed-through 404 in place. The compat-matrix CI parser only accepts 200 or 417; 404 is treated as a runner crash and the JSON body is dropped. Fix: introduce a `request.$wheelsTestSuppressStatusCode` flag that runner.cfm opens before `testBox.run()` and closes after. While the flag is active, `$header()` silently drops `statuscode` arguments and falls through to header-only mutations (so name/value pairs like Content-Type and Access-Control-Allow-Origin still land). Internal spec fixtures' `statusCode = 404` calls now stay confined to the controller-response struct that `processRequest()` returns to the spec — exactly where they belong. After `testBox.run()` returns, runner.cfm clears the flag so its own final `$header(statusCode = …)` call lands normally. The new flag is distinct from `request.$wheelsTestRun` (which keeps its existing flash/cookie semantics) — different concerns, different lifecycles, separate flags. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * revert(test): undo $wheelsTestSuppressStatusCode flag The flag introduced in 66f23b2 was too aggressive — it suppressed ALL inner statuscode mutations, including the legitimate ones that specs depend on. `processRequest()` reads the controller-set status code via `$statusCode()` (which queries the live servlet response), so specs like: expect(g.processRequest(params={...}, returnAs="struct").status).toBe(403) depend on the controller's `cfheader(statuscode=403)` ACTUALLY mutating the response. Suppressing it broke 10 Lucee 7 + SQLite tests in the LuCLI CI workflow (specs asserting custom status codes from renderText/renderView and from controller methods that set 4xx). Reverting both the flag declaration in runner.cfm and the suppression branch in `$header()`. The Adobe CF outer-response-pollution issue needs a different shape — save/restore in `processRequest()` (which already has an attempted `$header(statusCode = 200)` reset at line 3493 that gets dropped when the response is committed) is the right target. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(test): pre-size response buffer + $content() void + $content() spec Address Reviewer A round-6 CHANGES_REQUESTED on #2756. 1. **Pre-size response buffer in runner.cfm.** The reverted suppress-flag approach was wrong (it broke legitimate inner status-code reads), but the underlying problem is real: on Adobe CF 2023/2025 (Undertow), the default 8KB output buffer auto-flushes once any spec writes that crosses it — committing the response with whatever `statusCode = 404` an inner spec happened to set via `$throwErrorOrShow404Page`. Once committed, `runner.cfm`'s end-of-suite `$header(statusCode = 200|417)` is a no-op, and the compat-matrix CI parser sees HTTP 404 and drops the JSON body even though `totalPass = 3681`. Expanding the buffer to 16 MB before `testBox.run()` keeps the response uncommitted long enough for the final-status call to land. Wrapped in `try/catch` so engines that don't expose `setBufferSize` or reject the value fall through to the defensive `$header()` / `$content()` paths. This is a pragmatic stopgap for typical suites — a `processRequest()`- level save/restore is the durable shape and is tracked as a follow-up. The CHANGELOG entry no longer claims "third and final layer" now that the residual issue is named. 2. **`$content()` return type from `any` to `void`.** Matches `$header()` immediately below. The function has no return statement, so `any` was never accurate — and silently returned `null` to any accidental `result = $content()` callsite on every engine instead of producing a type error. 3. **Spec for `$content()` defensive path.** Parallels the `$header()` coverage in the same describe block. Exercises the plain-struct copy and the `$responseCommitted()` short-circuit on every engine in the compat matrix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(global): save/restore outer response status across processRequest The buffer pre-sizing in f1f63b1 was a useful stopgap but doesn't solve the underlying mechanism — Adobe CF 2023/2025 still ships HTTP 404 from the test runner whenever an in-suite `processRequest()` spec exercises a Wheels.RouteNotFound path. The inner action calls `$header(statusCode = 404)`, which lands on the shared servlet response, and once the test runner's response commits the outer `$header(statusCode = 200|417)` cannot override it. This is the per-method save/restore the prior PR description named as the durable fix. `processRequest()` now: 1. Captures `GetPageContext().getResponse().getStatus()` before the inner action runs (with a 200 fallback for engines or contexts where the probe fails). 2. Reads `$statusCode()` after the action returns — unchanged. This is what the spec assertion sees, so inner status mutations remain readable. 3. Restores the captured outer status via direct `getResponse().setStatus()` — best-effort. The servlet spec says `setStatus()` is a no-op on committed responses, but the bulk of in-suite `processRequest()` calls happen on uncommitted state thanks to the 16 MB buffer in `runner.cfm`, so the restore lands. 4. Falls through to the existing `$header(statusCode = 200)` / `$header(Content-Type)` reset which carries the legacy `cfheader` path for engines where the direct servlet call is unavailable. The Lucee 7 LuCLI status-code specs (the ones the reverted `66f23b2c` suppress-flag broke) still pass — the change only restores the OUTER status; the spec's `expect(processRequest(...).status).toBe(403)` reads the captured inner value, not the restored outer. CHANGELOG entry rewritten to reflect the actual implementation (direct `setStatus()` rather than the prior "tracked as follow-up" phrasing). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * revert(global): undo processRequest save/restore (caused new Adobe regression) The save/restore in f958a33 was intended to prevent inner-spec `statusCode = 404` from bleeding into the outer test-runner response. The shape was correct in principle — capture outer status before the inner action, restore via direct `setStatus()` afterwards — but it exposed an Adobe CF / Undertow quirk that turns the fix into a net regression: - Undertow returns `0` as the response's initial status before any cfheader or setStatus call (vs Lucee/BoxLang's default 200). - The save captures `0`. The restore calls `setStatus(0)`, leaving the response in an invalid state. - Downstream, any controller calling `renderText(status = $statusCode())` picks up `0` as the default `status` argument. `$setRequestStatusCode` then calls `$returnStatusText(0)`, which throws `An invalid http response code 0 was passed in.` (rendering.cfc:785). Compat-matrix results confirm the regression: - f1f63b1 (without save/restore): 3682 pass, 11 fail, 2 error. - f958a33 (with save/restore): 3681 pass, 10 fail, 4 error. Net effect: one fewer fail but two more errors, and the new error class is a framework-internal "invalid http response code 0" that wasn't present before. The Adobe CF deep issues (status=0 default, inner-spec bleed-through, response-commit timing under heavy test output) need a more thorough redesign than a per-method save/restore can deliver; tracking that as a separate PR. Reverting both the capture and restore blocks. The existing `$header(statusCode = 200)` / `$header(Content-Type)` reset path at the bottom of `processRequest()` is restored as-is — it was already the legacy best-effort reset, and it remains the right shape for engines where it can write through. CHANGELOG entry updated to drop the save/restore claim and explain the deferred-redesign reason. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs: trim PR-narrative and historical context from inline comments CLAUDE.md: "Don't reference the current task, fix, or callers — those belong in the PR description and rot as the codebase evolves" and "Never write multi-line comment blocks — one short line max." Inline comments in $header(), $content(), $responseCommitted(), env(), BaseReporter.resetHTMLResponse(), and runner.cfm's buffer pre-size block all carried PR-description-shaped prose: PR numbers, "this PR's other fixes," "since the matrix was added," step-by-step narrative of the cascade. The why those callsites need defensive shape (Adobe CF rejects cfheader/cfcontent on a committed response; Adobe CF's argument binder skips `default` as a reserved word) is invariant and worth keeping; the historical sequencing is not. Trimmed to one-line WHY per defensive block and dropped the PR-narrative paragraphs. Behavior unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs(test): trim multi-line back-compat comment in envHelperSpec Reviewer B caught a multi-line block above the back-compat `it()` that I missed in 97ccdf0. CLAUDE.md: "Never write multi-line comment blocks — one short line max." Collapsed to a single regression-guard line; the block's invariant-level WHY (env() was renamed from `default` to `defaultValue` for Adobe CF) lives in Global.cfc's `env()` comment, not duplicated here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs: one-line WHY on the two empty catch blocks Reviewer A: an empty `catch (any e) {}` on an unfamiliar Java interop call looks like an oversight without a brief WHY. Both blocks (runner.cfm's setBufferSize and BaseReporter's reset()) now carry a one-line note naming the expected fall-through case — same shape, no narrative prose. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(view): $viteWalkImports passes parent struct so Adobe CF mutates the originals Five viteSpec failures and one error on Adobe CF 2023/2025 trace to the same Cross-Engine Invariant #6: Adobe CF copies arrays by value out of struct literals. `$viteResolveAssets()` was building local.rv = {scripts: [], styles: [], preloads: []} and then calling $viteWalkImports(preloads = local.rv.preloads, styles = local.rv.styles, ...) On Adobe CF the `preloads` and `styles` arguments inside the walker were independent copies, so every `ArrayAppend(arguments.preloads, ...)` wrote to garbage that was discarded on return — leaving `local.rv` empty. Lucee and BoxLang share the array references, so the walk mutated the originals as intended, which is why the bug only showed on Adobe. Fix: pass the parent `rv` struct itself and mutate `arguments.rv.preloads` / `arguments.rv.styles`. Struct references are shared on every engine, so the inner-struct arrays stay live across the recursion. This is the pattern CLAUDE.md anti-pattern #6 calls out as the portable shape. Function is internal — no external callers in the framework or specs — so the signature change is safe. The viteSpec block tests (`$viteResolveAssets > walks transitive imports...`, `dedupes diamond...`, `terminates on cyclic...`, plus the four call-site specs for `viteScriptTag`/`viteStyleTag`/`vitePreloadTag`) all assert the walk's output shape, so they cover the regression directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs(changelog): entry for vite walker Adobe CF array-by-value fix Reviewer A nit on 96cf4b4 — every other substantive bug fix in this PR has a CHANGELOG entry; the vite walker fix should too. Adds the prose from the commit body under [Unreleased] > Fixed, matching the surrounding entries' shape (root-cause-chain, fix description, Cross-Engine Invariant reference, affected callers). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs(ai): document $responseCommitted() in cross-engine compatibility Reviewer A nit on 4f0871d — the new `$responseCommitted()` helper in `vendor/wheels/Global.cfc` is a public framework API but isn't mentioned in the cross-engine doc that future tag-wrapper authors will reach for. Adds a "cfheader / cfcontent on a Committed Response (Adobe CF 2023/2025)" section right after the existing `attributeCollection` discussion, documenting the probe-and-rethrow pattern and pointing future helpers (`$location`, `$cache`, `$htmlhead`, `$mail`, …) at `$responseCommitted()` as the canonical shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs: wrap $responseCommitted() docstring + add $content reset spec Two non-blocking nits from Reviewer A round 12: 1. $responseCommitted()'s single-line docstring ran ~150 chars while the surrounding doc-blocks wrap at ~80 cols — split to three wrapped lines for consistency. 2. Added a spec covering `\$content(type=..., reset=true)`. `reset` is a boolean argument, and some engines are picky about boolean coercion through `attributeCollection`. The plain-struct copy logic is shared with `\$header()` (which has its own boolean-arg coverage via the `charset` combo), so this isn't fixing a known bug — it's an explicit regression guard for the second-most-common `cfcontent` call shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs(test): fix incorrect line-range citation in $content() spec comment Reviewer A: the parenthetical "(Global.cfc:120-138)" pointed at $content() itself rather than $header() (which lives at 140-165 now), so the citation referenced the wrong function. Collapsed the six-line preamble to a single descriptive line; the cross-reference was the only load-bearing detail and was wrong, so dropping it removes the bug rather than just patching it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(view): $htmlhead defensive shape for committed-response (Adobe CF) `cfhtmlhead` on Adobe CF 2023/2025 throws "Unable to add text to HTML HEAD tag" once the servlet response has committed — same family as the `cfheader` / `cfcontent` cases this PR's earlier layers fixed. `$htmlhead()` now picks up the same `$responseCommitted()` probe-and- rethrow shape: short-circuit if committed; on uncommitted call, wrap `cfhtmlhead` in `try/catch` and rethrow only when the response is still uncommitted (i.e. a genuine caller bug, not the race window). This closes the three remaining viteSpec errors on Adobe CF: - `viteScriptTag > emits stylesheet links for transitive chunk CSS` - `viteScriptTag > emits modulepreload links for transitive chunks via $viteHtmlHead` - `vitePreloadTag > emits via $viteHtmlHead and returns empty with default head=true` All three call `$viteHtmlHead`, which captures into `request.$viteHeadCapture` BEFORE delegating to `$htmlhead`. The test assertion reads from the capture array, so the defensive no-op preserves the test contract while preventing the underlying engine error from escaping. 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>
… guard (#2759) End-to-end smoke test of the apt + yum bucket-repo templates (with a throwaway GPG key + Docker Ubuntu producer / Ubuntu+Fedora consumer containers) against real v4.0.0 artifacts surfaced five bugs that would block Phase 2 from working in production. Fixes them together because they're all in service of issue #2605 standup. 1. release.yml: derive CHANNEL from version, not hardcoded `stable`. Every snapshot build was producing `wheels_<v>_amd64.deb` regardless of branch. The Phase 2 bleeding-edge receiver fetches `wheels-be_<v>_amd64.deb` from wheels-snapshots → 404 every time. Now: `contains(env.WHEELS_VERSION, '-snapshot')` selects `bleeding-edge` (which routes build-linux-packages.sh to nfpm-wheels-be.yaml). 2. release.yml: upload globs `wheels_*_amd64.deb` (underscore) didn't match `wheels-be_*_amd64.deb` (dash). Loosened to `wheels*_amd64.deb` + `wheels*.x86_64.rpm` so both channels' artifacts upload. 3. apt-repo/scripts/regenerate-apt-metadata.sh: first publish to a brand-new channel crashed — apt-ftparchive aborts on a missing pool scan path. Yum side already guarded; apt didn't. Now mkdir -p pool/<dist> at the top of the dist loop so empty-channel first-runs produce empty-but-signed metadata. 4. yum-repo/scripts/regenerate-yum-metadata.sh: nfpm produces unsigned RPMs but wheels.repo sets gpgcheck=1, so dnf rejects with "Package is not signed: GPG check FAILED". Now signs each .rpm via `rpm --addsign` before createrepo_c walks the dir. Also pins %__gpg to $(command -v gpg) — Ubuntu runners ship `gpg`, not the `gpg2` rpm's macro defaults to. 5. Both receiver workflows: `gpg --import-ownertrust` was being fed the 16-char key_id from `sec:` line field 5, but the format wants the 40-char fingerprint from `fpr:` line field 10. Emitted a "gpg: error in '[stdin]': invalid fingerprint" warning every run. Non-fatal but noisy; fixed by extracting the right field. Plus: README + placeholder files updated to point at `op://Wheels/wheels-linux-repo-signing/` (Wheels project vault on my.1password.com) rather than `op://Infrastructure/` (PAI work tenant). Verified end-to-end inside Docker against real v4.0.0 artifacts: - Ubuntu 24.04 receiver produces apt + yum metadata, signs Release/ InRelease/repomd.xml + each RPM - Fresh Ubuntu 24.04 client: apt-get download wheels → success (MD5 matches Packages.gz) - Fresh Fedora 40 client: dnf install --downloadonly wheels → "Complete!" with all signatures verifying Once merged, the next develop snapshot will ship a properly named wheels-be_<v>_amd64.deb / wheels-be-<v>.x86_64.rpm pair, and the Phase 2 bucket repos (when created) will be able to consume them without code changes. See #2605 for the operational standup checklist. Signed-off-by: Peter Amiri <peter@alurium.com>
…#2758) (#2761) * fix(release): emit .zip.{sha512,md5} sidecars + add Scoop install gotchas to docs Three converging fixes that together close out the user-visible install breakage reported in #2758 (and by two additional users on the scoop-wheels side: zainforbjs#2 and a private report from Michael at PAI). ### 1. release: emit `*.zip.sha512` / `*.zip.md5` instead of `*.sha512` / `*.md5` The scoop-wheels bucket's `autoupdate` config substitutes `$url.sha512` to locate each artifact's checksum sidecar — i.e. for `wheels-core-X.X.X.zip` it looks for `wheels-core-X.X.X.zip.sha512`. The build scripts were emitting `wheels-core-X.X.X.sha512` (no `.zip.` segment), so even if Excavator (or any other autoupdate path) ran post-GA it would 404 on every non-module artifact. wheels-module already used the correct `.zip.sha512` shape; the four other build scripts didn't. Touched: - `tools/build/scripts/build-{base,cli,core,starterApp}.sh`: emit `<artifact>.zip.{md5,sha512}` (was `<artifact>.{md5,sha512}`). - `tools/build/scripts/README.md`: documents the new filename shape. - `.github/workflows/release.yml` + `release-candidate.yml`: update the `actions/upload-artifact` glob patterns to match — 40 line edits across the two files. No consumers grep for the old shape (verified across homebrew-wheels, scoop-wheels, .deb/.rpm builders, framework code). snapshot.yml inherits via `uses: ./.github/workflows/release.yml`. ### 2. docs: add missing `scoop bucket add java` step `scoop install wheels` declares `java/openjdk21` as a dependency but Scoop does not auto-add the `java` bucket on the user's behalf — both real-user reports (Michael, zainforbjs) hit the same "Couldn't find manifest for 'openjdk21' from 'java' bucket" warning before being able to proceed. The docs claimed it was automatic. Updated: - `start-here/installing.mdx` Windows tab (both v4-0-0 and v4-0-1-snapshot) - `command-line-tools/installation.mdx` (both v4-0-0 and v4-0-1-snapshot) ### 3. docs: document `lucli modules install --url=` for standalone-LuCLI users New "Advanced: existing LuCLI install" section under "Manual JAR install" in `command-line-tools/installation.mdx` (both versions). Covers stable and bleeding-edge URLs, and an Aside enumerates what the user gives up vs. the Scoop/Homebrew/.deb/.rpm paths: binary name, LUCLI_HOME isolation, JDK resolution, SQLite shim, channel awareness, upgrade story. Triggered by Jay Dee's report (#2758) of trying `lucli modules install wheels@be` based on inferred channel naming — the `@be` tag doesn't exist in LuCLI's registry, and the bare `wheels` entry points at a stale `wheels-cli-lucli` mirror that hasn't been synced from develop since 2026-04-04. ### Not in this PR (deliberate scope split) - Real sha512 hashes for v4.0.0 in `scoop-wheels/bucket/wheels.json` — that lives in a different repo. Already up as wheels-dev/scoop-wheels#3 and unblocks the immediate install failure. - Deciding whether to register scoop-wheels with Excavator or self-host a cron-driven autoupdate workflow — needs a design call, follow-up after the immediate-fix PRs land. - Updating LuCLI's bundled registry (`local.json:23`) to point at `wheels-dev/wheels` directly instead of the stale `wheels-cli-lucli` mirror — needs Mark's input on the LuCLI side. Refs #2758 Refs wheels-dev/scoop-wheels#2 Signed-off-by: Peter Amiri <peter@alurium.com> * docs: address Reviewer A/B consensus findings (round 1) - README at tools/build/scripts/: add build-starterApp.sh to the scripts list and add wheels-starter-app artifact entries to the output-structure diagram (header text bumped from "three" to "four" Wheels variants). - installation.mdx (v4-0-0 + v4-0-1-snapshot): replace the fragile curl | sed pipeline in the new "Advanced: existing LuCLI install" section with jq, and add an empty-tag guard so an API failure or rate-limit miss fails fast with a useful message instead of silently 404-ing on a malformed URL. - CHANGELOG.md: add ### Fixed entries under [Unreleased] for the .zip.{sha512,md5} sidecar rename and the Windows scoop bucket add java prerequisite. - PR description: add a manual smoke-test checkbox covering the bleeding-edge lucli modules install --url= snippet. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * ci: dispatch scoop-wheels, retire chocolatey The downstream package-manager dispatch step has fired wheels-released events to wheels-dev/homebrew-wheels and wheels-dev/chocolatey-wheels. Swap chocolatey out for scoop-wheels — the v1.x chocolatey-wheels package is CommandBox-based and can't drive v4, so the Windows install path moved permanently to Scoop in 4.0. scoop-wheels#4 adds the autoupdate workflow on the consumer side that listens for this dispatch event. Touches the dispatch loop, the secret docstring, the in-step warning message, and the three sibling comments (apt/yum dispatch + bump-develop) that referenced the homebrew/chocolatey step as their channel-derivation template. No functional change for stable v4.0.0 (it already shipped). Takes effect on the next snapshot or 4.0.1 release. Refs #2758 Refs wheels-dev/scoop-wheels#4 Signed-off-by: Peter Amiri <peter@alurium.com> * docs: address Reviewer A/B consensus findings (round 2) - tools/build/scripts/README.md:36 — "three variants" to "four variants" in the Convenience Scripts section (line 3 was fixed in round 1; line 36 was missed). - web/sites/guides/src/content/docs/v4-0-0/command-line-tools/installation.mdx (lines 76-77, 84-85) and the matching v4-0-1-snapshot mirror — replace the `sed -nE` tag-resolver in the bleeding-edge deb/rpm snippets with `jq -r '.[0].tag_name | sub("^v"; "")'` plus the same empty-tag guard added to the LuCLI section in round 1. Both items map to Reviewer B's converged-changes verdict on SHA a81345a; Reviewer A's review of the round-1 commit (`a81345ae`) and the round-2 commit (`5144fdbb`) flagged the same carry-overs. Refs #2758 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * docs: address Reviewer A/B consensus findings (round 3) - Replace remaining `curl | sed -nE` pipelines in the stable Linux `.deb`/`.rpm` install snippets (lines 60 and 67 in both `v4-0-0/command-line-tools/installation.mdx` and `v4-0-1-snapshot/command-line-tools/installation.mdx`) with the same `jq -r '.tag_name | sub("^v"; "")'` pattern plus empty-tag guard that round 1 applied to the LuCLI section and round 2 applied to the bleeding-edge `.deb`/`.rpm` snippets. The two parallel snippet pairs in each file (stable + bleeding-edge) now use the same resolver and error-handling path. Reviewer B's round-1 converged-changes on SHA `5144fdbb9` also listed amending the type of commit `5144fdbb9` from `release:` to `ci:` to satisfy commitlint. That requires rewriting published git history, which is outside the bot's allowed git operations (read-only `git` per `_shared-rails.md`). It is left for a human to amend, squash-merge with a conforming subject, or override the DCO/commitlint check at merge time. Refs PR #2761. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.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[bot] <41898282+claude[bot]@users.noreply.github.com>
#2762) scoop-wheels#4 inlined OpenJDK 21 in the Scoop manifests, so `scoop install wheels` and `scoop install wheels-be` now work from a fresh shell with no prerequisite buckets. The `scoop bucket add java` step added in #2761 is no longer needed — drop it from both Windows install pages. Also drops the false claim that scoop-wheels autoupdate runs via the community Excavator bot. The bucket now uses its own `.github/workflows/autoupdate.yml` that listens for `repository_dispatch` fired by this repo's `release.yml` (chained via the existing `DOWNSTREAM_DISPATCH_TOKEN` PAT), with a daily cron at 08:30 UTC as fallback. End-to-end latency ~5-7 min from upstream tag to manifest. Updates installing.mdx + command-line-tools/installation.mdx in both v4-0-0 and v4-0-1-snapshot doc trees. Signed-off-by: Peter Amiri <peter@alurium.com>
Lucee's CFML parser interpreted `#2345)` in the packages-command help text as the start of an expression interpolation and aborted compilation of the whole Module.cfc with `Invalid Syntax Closing [#] not found at [2165:82]`. This crashed Phase 3 of tools/ci/smoke-test-module.sh ("Scaffold a scratch app") because `wheels new` triggers eager Module.cfc compilation as soon as LuCLI loads the wheels module — see the failing snapshot run 26050790202 / job 76587148389. The repo's CLAUDE.md anti-pattern #14 covers this exactly: literal `#` in a string literal MUST be escaped as `##` (the form Lucee 5/6/7, Adobe CF 2018–2025, and BoxLang all agree on). Comments are fine — which is why the 15+ other `#NNNN` issue refs in Module.cfc compile without trouble; they all live in `//` or `/* */` blocks. Why this slipped past PR CI: smoke-test-distribution is only wired into release.yml's job graph, which runs on push-to-main or via workflow_call from snapshot.yml. PR CI exercises the framework test suite but not the built-and-installed distribution. So parse errors in cli/lucli/Module.cfc surface only after merge to develop, when the next snapshot build kicks off. Signed-off-by: Peter Amiri <peter@alurium.com>
* docs: drop `scoop bucket add java` step, update autoupdate description scoop-wheels#4 inlined OpenJDK 21 in the Scoop manifests, so `scoop install wheels` and `scoop install wheels-be` now work from a fresh shell with no prerequisite buckets. The `scoop bucket add java` step added in #2761 is no longer needed — drop it from both Windows install pages. Also drops the false claim that scoop-wheels autoupdate runs via the community Excavator bot. The bucket now uses its own `.github/workflows/autoupdate.yml` that listens for `repository_dispatch` fired by this repo's `release.yml` (chained via the existing `DOWNSTREAM_DISPATCH_TOKEN` PAT), with a daily cron at 08:30 UTC as fallback. End-to-end latency ~5-7 min from upstream tag to manifest. Updates installing.mdx + command-line-tools/installation.mdx in both v4-0-0 and v4-0-1-snapshot doc trees. Signed-off-by: Peter Amiri <peter@alurium.com> * docs: add `scoop install git` prerequisite before bucket add A real-user run from a fresh Windows shell on 2026-05-18 surfaced a missing prerequisite: Scoop's installer doesn't ship git, but `scoop bucket add` uses `git clone` under the hood, so adding any third-party bucket fails with: ERROR Git is required for buckets. Run 'scoop install git' and try again. `scoop install git` itself works from a totally fresh shell because the default `main` bucket — where git lives — is bundled with the Scoop installer rather than fetched at runtime. So the corrected sequence is: irm get.scoop.sh | iex scoop install git scoop bucket add wheels https://github.com/wheels-dev/scoop-wheels scoop install wheels Adds the `scoop install git` step (with a brief why-it's-needed note) to both Windows install pages in both v4-0-0 and v4-0-1-snapshot doc trees. The detailed `installation.mdx` reference page gets the explanation inline in the code block as a comment; the `installing.mdx` quick-start gets a numbered step with prose below. Bumps step numbering in the start-here `installing.mdx` Steps blocks (3 → 4 for the verify step). Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com>
) Real-user Scoop install on 2026-05-18 surfaced: `wheels --version` from a fresh `scoop install wheels` (stable) reports `4.0.0-SNAPSHOT+1442` — a snapshot tag, on a stable channel install of GA v4.0.0. Root cause ---------- `cli/lucli/.module-version` was tracked in git with a hardcoded snapshot string. The release-pipeline `@build.version@` substitution at release.yml:270 targets `*.json`, `*.md`, `*.cfm`, and `*.cfc` — but not `.module-version` (no extension). So every release artifact (stable GA, RC, every snapshot) shipped the same stale source-tracked value regardless of what was actually being released. This file is the source of truth for two runtime behaviors in the brew/scoop/.deb/.rpm wrappers: 1. `wheels --version` reads it and prints the content verbatim. 2. The first-run sync compares staged-module-version against installed-module-version to decide whether to re-sync. When every artifact has the same stale string, the comparison always sees "no change" and silently skips the sync — which means a user running `scoop uninstall wheels; scoop install wheels-be` would keep the OLD stable module on disk under ~/.wheels/modules/wheels/. Channel switching was broken in subtle ways. CFC code at PackagesMainCli.cfc:340 already documents that .module-version "isn't written" in dev checkouts, so the architecture already expected this to be a build-time artifact, not source-tracked. Fix --- - `git rm cli/lucli/.module-version` — drop the stale source file. - `.gitignore` entry so it can't be re-introduced accidentally. - `release.yml`: write `cli/lucli/.module-version` from `MODULE_VERSION` right after the `@build.version@` substitution, before tar/zip pack. snapshot.yml uses release.yml as a reusable workflow, so it inherits. release-candidate.yml doesn't build a wheels-module artifact at all (RCs aren't published to Scoop), so it doesn't need touching. Effect ------ - `wheels --version` on next snapshot/release: reports the actual release tag, not a stale snapshot number. - Channel switching detects the version change correctly and re-syncs. - Dev checkouts (without `.module-version`) fall through to the Tier 2 BuildInfo.cfc lookup at PackagesMainCli.cfc:341, unchanged. No effect on already-shipped 4.0.0 GA install — users with the bad version baked in will see the correct value on their next `scoop update wheels` once 4.0.1 ships through the autoupdate chain. Refs #2758 follow-up Signed-off-by: Peter Amiri <peter@alurium.com>
… bat-jar pre-parse (#2766) * fix(cli): scoop wheels.cmd invokes java.exe directly to dodge cmd.exe bat-jar pre-parse The Scoop-installed wheels.cmd wrapper failed on every invocation on at least one Windows 11 build (10.0.26200.8457). cmd.exe pre-parses the entire body of a `call <bat>` target looking for labels and control flow before running it; the lucli-<ver>.bat artifact is a bat-jar concatenation (bat preamble + :JAR_BOUNDARY + raw JAR ZIP bytes, ~915 KB), and the pre-parser trips on bytes inside the ZIP tail and aborts with `The filename, directory name, or volume label syntax is incorrect.` before lucli ever executes. Dispatch lucli via `"%JAVA_HOME%\bin\java.exe" -client -jar "%~dp0lucli-<ver>.bat" %*` instead. java reads the JAR via stream and skips the bat preamble in front of the ZIP central directory, sidestepping cmd.exe's parser entirely. The same fix applies to the `:deploy_dispatch` branch added by PR #2691, which used the same `call` pattern with rewritten args. Add a JAVA_HOME resolver near the top of the dispatch path that locates the openjdk21 dependency declared via `depends: java/openjdk21` -- preferring `%SCOOP%\apps\openjdk21\current` (the canonical Scoop layout) and falling back to the sibling-app form `%~dp0..\..\openjdk21\current`. Fail fast with a `scoop install java/openjdk21` hint when neither is found. Also restores the :deploy_rewrite block to build-manifests.py so the Python source-of-truth matches the published JSON (the block was added direct-to-JSON in #2691 and never made it back to the generator); without this, a future `python3 build-manifests.py` would wipe both the deploy rewrite AND this fix. Closes #2765. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * docs(web/guides): add scoop bucket add java step; fix JAVA_HOME prose for Windows install The Scoop packages declare `depends: java/openjdk21` — Scoop's `depends:` does not auto-add the dependency bucket, so the `java` bucket must be added explicitly before `scoop install wheels`. The `wheels.cmd` wrapper now resolves `JAVA_HOME` from that dependency automatically (#2765/#2766). Corrects the "no separate java bucket, no JAVA_HOME setup" claim that was wrong since the openjdk21 dependency was introduced. Signed-off-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com> Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --------- Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Signed-off-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…ive (#2767) The Scoop draft generator at tools/distribution-drafts/scoop/ produced two .json manifests intended to be copied into wheels-dev/scoop-wheels at bucket setup time. After the inline-JDK rework in scoop-wheels@3f22250 (May 18), the bucket grew its own self-hosted autoupdate workflow and diverged from this draft on every meaningful axis: - inline JDK as 5th URL vs. external `depends: java/openjdk21` - real auto-bumped hashes vs. placeholder zeros - self-hosted autoupdate workflow vs. Scoop built-in `autoupdate` block - JAVA_HOME fallback + direct `java -jar` invocation in wrapper (scoop-wheels#6) vs. `call lucli.bat` here The parent README originally framed this directory as a "useful diff signal during release reviews" -- in practice nobody acted on the divergence between May 18 and today (two snapshot releases later), and the stale drafts only added a misleading second source-of-truth. wheels#2765 surfaced the gap when a user hit a wrapper bug that had already been quietly fixed-then-undone in the divergence. Delete the scoop subdirectory and trim the two scoop rows + the keep-or-remove paragraph from the parent README. Bucket is now the unambiguous home; structural changes happen there directly. No CI consumed these files -- release.yml only references linux-packages/apt-repo/yum-repo from this tree. scoop-wheels#6 (the wrapper fix) and scoop-wheels#7 (tracking) remain valid in the bucket repo. Closes wheels#2765 Signed-off-by: Peter Amiri <peter@alurium.com>
Promote `## [Unreleased]` to `# [4.0.1] => 2026-05-20` so `release.yml`'s release-checklist step (which refuses to publish on `=> TBD`) and its `awk '/^# \[VERSION\]/,/^---$/'` release-notes extraction both pick up the section. Change the section terminator from `----` (4 dashes) to `---` (3 dashes) — mirrors the same fix shipped in #2606 for the 4.0.0 cut. The awk range in `release.yml` matches only `^---$`, so a 4-dash terminator would let the extraction bleed into 4.0.0's notes. `wheels.json` is already at `4.0.1` (auto-bumped by `bump-develop-version.yml` after the 4.0.0 GA), so no version bump is needed here. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Merging develop into main for the 4.0.1 patch cut. The v4.0.0 cut's tree-alignment left main's tree byte-identical to the v4.0.0 tag, so this merge is a clean forward-merge of develop's 103+1 new commits. Using `--no-ff -X theirs` as a safety net mirroring the v4.0.0 pattern (#2607); no actual conflicts expected. Pre-release archive branches preserve both prior tips: - archive/develop-pre-v4.0.1 → 63777a0 - archive/main-pre-v4.0.1 → 91e7398 Signed-off-by: Peter Amiri <peter@alurium.com>
1 task
bpamiri
added a commit
that referenced
this pull request
May 27, 2026
) The 4.0.2 release push (#2819) failed before any step ran: the pre-hooks of Wandalen/wretry.action@v3 -- the lone Node 20 action in the job, wrapping softprops/action-gh-release -- threw "Argument list too long" spawning the node20 binary. Root cause is GitHub's forced Node 20 -> 24 runner migration (forced default 2026-06-02), not workflow config: run #2769 (4.0.1) passed 7 days earlier with the identical setup, and the develop snapshot path (same action) succeeded 16 min before on a pre-migration runner. - Set FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true at workflow level so all JS actions (including wretry@v3) run on Node 24, bypassing the broken node20 launch path. This is GitHub's documented remedy for the transition. - Add a workflow_dispatch trigger so a stuck/failed release can be re-fired from the Actions tab without a fresh push. Channel selection keys on GITHUB_REF: dispatch against main -> stable, develop -> snapshot. Merging to main re-fires release.yml and completes the v4.0.2 release; prior failed attempts created no tag/release, so there is no double-publish risk. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The v4.0.1 GA cut. Merges develop into main; mirrors #2607's pattern.
After this PR merges (via "Create a merge commit", not squash),
release.ymlfires on the push to main,softprops/action-gh-releasecreates thev4.0.1tag at main HEAD, and the downstream dispatches kick off (Homebrew tap PR, Scoop bucket PR,bump-develop-version.ymlrepository_dispatch).Pre-flight
## [Unreleased]to# [4.0.1] => 2026-05-20and fixing the----→---separator sorelease.yml'sawk '/^# \[VERSION\]/,/^---$/'extraction terminates correctly.wheels.jsonversion is clean —4.0.1, no-snapshotsuffix (auto-bumped bybump-develop-version.ymlafter the v4.0.0 GA).=> TBDin CHANGELOG for 4.0.1 —release.yml's release-checklist validation step passes.archive/develop-pre-v4.0.1(63777a0a5),archive/main-pre-v4.0.1(91e7398c7) — keep for at least one cycle in case rollback is needed.git diff origin/develop release/v4.0.1-to-mainis empty: the merge result's tree is byte-identical todevelopHEAD. The v4.0.0 cut's tree-alignment dance left main's tree byte-identical to the v4.0.0 tag, so a plain--no-ff -X theirsproduced a clean forward-merge of develop's 103+1 new commits with no conflicts.awk '/^# \[4\.0\.1\]/,/^---$/' CHANGELOG.md | wc -l= 85 lines; section terminates at the new---separator immediately before# [4.0.0].What's in 4.0.1
103 commits / ~97 unique PR references since the v4.0.0 tag (8 days). Highlights:
Adobe ColdFusion 2023/2025 compatibility hardening
cfheaderattributeCollection trap — Adobe 2023/2025 rejectargumentsscope asattributeCollection; affects everycfheader/cfcontent/cfmail/cfdirectory/cffile/cflocationwrapper. 13 sites invendor/wheels/Global.cfcpatched uniformly.$header()was catastrophic because it runs on every request. (#2750)env()helper — second parameter was nameddefault, a CFML reserved word; Adobe CF refuses to bind a parameter with that name, silently returning""for every call. Renamed todefaultValue; back-compat preserved for the named-arg form. (#2756)$viteWalkImports(preloads = local.rv.preloads, ...)handed the walker independent copies. Fix passes the parent struct ref. (#2756)lockingSpeccatch-scope discard (#2754)Windows Scoop install reliability
wheels.cmdcmd.exe pre-parser tripped on JAR ZIP byte sequences in the bat-jar concatenation; fix invokesjava.exe -jardirectly. (#2766)*.zip.sha512/*.zip.md5checksum sidecars (was*.sha512/*.md5) so the scoop-wheels$url.sha512substitution no longer 404s. (#2761).module-versionbaked at build time, not from source (#2764);scoop bucket add javadocumented as prereq (#2762).paginationNav()framework presetsviewStyleargument with"plain","bootstrap5","bootstrap4","tailwind"presets (#2731).Package system
mappingsinpackage.jsonfor legacy CFML aliases (#2705, #2739).wheels packages adddocumented as canonical install verb;installstops appearing in--help(#2723, #2729).CLI
wheels deploy--releasealias for--version(#2691)..dockerignorescaffolding inwheels deploy init(#2686).$gitShortSha()returns "unknown" on non-zero git exit (#2689).Compat-matrix robustness
insertAllusesINSERT ALLform;renameSystemTablesskips transaction wrapper (#2749, #2753).addColumnOptionsSpectext-default assertion (#2748).Middleware
Access-Control-Allow-Origin(#2725).Vary: Originwhen reflecting allowed origins (#2724).windowSecondsandmaxRequestsat construction (#2695).Model
whereIn/whereNotInshort-circuit on empty arrays (was JDBC syntax error) (#2736).Merge mechanics
git merge --no-ff -X theirs --signoff origin/develop. The-X theirsis a safety net (mirrors v4.0.0); no actual conflicts because main's tree was already byte-identical to the v4.0.0 tag.ca4ee38cb420062f737205f816b337526314c40a(on this branch).release.yml.After merge — automation cascade
release.ymlfires on push to main, builds artifacts (wheels-core,wheels-cli,wheels-base-template,wheels-starter-app,wheels-module,.deb,.rpm), publishes to GitHub Releases.softprops/action-gh-releasecreates thev4.0.1tag at main HEAD.release.yml's "Dispatch downstream package managers" step fires threerepository_dispatchevents viaDOWNSTREAM_DISPATCH_TOKEN:wheels-released→wheels-dev/homebrew-wheels→ brew formula bump PRwheels-released→wheels-dev/scoop-wheels→ scoop bucket PRbump-develop→wheels-dev/wheels→bump-develop-version.ymlopens a PR against develop bumpingwheels.jsonto4.0.2LINUX_REPO_DISPATCH_TOKEN(apt.wheels.dev / yum.wheels.dev metadata regen).Total maintainer turnaround: merge 3 downstream auto-PRs across the tap/bucket repos + the develop bump PR. ~10 minutes.
Post-release smoke test (per RELEASE_PLAYBOOK.md)
Test plan
release.ymlworkflow run succeeds end-to-end on the main-branch push.v4.0.1tag created at main HEAD; GitHub Release published with the awk-extracted notes from CHANGELOG.md.bump-develop-version.ymlopens the4.0.2bump PR viarepository_dispatch(the fix for bump-develop-version.yml doesn't trigger when release.yml uses GITHUB_TOKEN to publish #2609 is now live on develop — first GA cut testing it).🤖 Generated with Claude Code