Skip to content

Commit 7b2ca56

Browse files
bpamiriclaudewheels-bot[bot]github-actions[bot]
authored
Release 4.0.2 (#2819)
* chore: bump develop snapshot target to 4.0.2 (#2770) Manual bump after the v4.0.1 GA — `bump-develop-version.yml` fired via `repository_dispatch` (the #2609 fix worked) but failed in 12s on a second issue: `peter-evans/create-pull-request@v6` hit `remote: Duplicate header: "Authorization"` because the `actions/checkout` step left credentials persisted that conflict with the action's own token. See run 26173817714 for the failed log. Setting `wheels.json` to `4.0.2` so subsequent develop snapshots are tagged `4.0.2-snapshot.<run>`. This is a baseline, not a commitment — the next GA's scope decision is made at tag-cut time. Follow-up issue tracks the workflow fix (add `persist-credentials: false` to the checkout step). Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(release): fix bump-develop-version.yml duplicate Authorization header (#2771) `actions/checkout@v6` defaults to `persist-credentials: true`, which writes `http.https://github.com/.extraheader = AUTHORIZATION: basic <GITHUB_TOKEN>` to the local `.git/config`. `peter-evans/create-pull-request@v6` then sets its own `extraheader` for the dispatch token, and the next git operation sends both Authorization headers — GitHub returns HTTP 400 with `remote: Duplicate header: "Authorization"`. First observed on the v4.0.1 GA (2026-05-20, run 26173817714); manual workaround was #2770. Setting `persist-credentials: false` keeps peter-evans/create-pull-request as the sole Authorization authority. This is a documented peter-evans/create-pull-request gotcha when the caller uses a non-default token. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(web/blog): Wheels 4.0.1: Adobe CF hardening, Windows Scoop fixes, and the post-GA shakeout (#2772) Walks through the ~100 PRs that landed between 4.0.0 and 4.0.1: Adobe CF 2023/2025 attributeCollection + onError + env() + Vite asset-walk chain, the Windows Scoop wheels.cmd cmd.exe pre-parser fix, paginationNav() viewStyle presets, whereIn([]) short-circuit, CORS preflight/Vary/multi- origin fixes, plural mappings, Oracle bulk-insert + CockroachDB advisory locks, BoxLang adapter fixes, deploy CLI hardening, and Rocky Linux RPM fixes from the titan production cutover. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): guard application.wo in onError so init failures don't cascade (#2774) * fix(cli): guard application.wo in onError so init failures don't cascade When the Wheels Injector fails to load during onApplicationStart (a stale /wheels mapping under Lucee Express 7 is the symptom users hit on the "Your First 15 Minutes" tutorial), application.wo is never assigned. The existing recovery try/catch inside onError swallows a second failure silently and then unconditionally calls application.wo.$getRequestTimeout(), which throws "The key [WO] does not exist." and replaces the real diagnostic with a cryptic cascade. Add a StructKeyExists(application, "wo") guard right after the recovery try/catch in cli/lucli/templates/app/public/Application.cfc (the template behind `wheels new`) and the demo public/Application.cfc. When the global isn't there, render a minimal HTML error page and return — the user sees "Wheels failed to initialize" plus the original exception message instead of the cascade. Fixes #2773 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * docs(web/guides): note application error fallback and init failure in troubleshooting docs Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(cli): address Reviewer A/B consensus findings (round 1) - Set HTTP 500 status code in the onError fallback in both cli/lucli/templates/app/public/Application.cfc and public/Application.cfc so monitoring tools and CDNs don't cache the Wheels-init failure as a successful response. Uses a plain struct for cfheader's attributeCollection per CLAUDE.md cross-engine invariant #10 (Adobe CF 2023/2025 reject the arguments scope on built-in tags). - Document the no-nested-braces assumption behind catchClosePattern in vendor/wheels/tests/specs/cli/OnErrorFallbackGuardSpec.cfc so a future edit that adds nested braces inside the outer catch knows why the silent fallback to scanFrom=1 is the safety net. - Fix the contradictory recovery steps in the first-15-minutes guide (wheels reload requires a running server) at web/sites/guides/src/content/docs/v4-0-1-snapshot/start-here/first-15-minutes.mdx. - Replace the speculative "pre-4.0.2" wording in .ai/wheels/troubleshooting/common-errors.md with "4.0.1 or earlier" since the fix is still in [Unreleased]. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * chore(web): refresh visual baseline(s) (all) Manually triggered baseline refresh via .github/workflows/refresh-visual-baselines.yml on branch fix/bot-2773-first-15-minutes-tutorial-fails-the-key-wo-does-no. 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> 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> * fix(ci): stop double-nesting framework inside Linux .deb/.rpm packages (#2776) * fix(ci): stop double-nesting framework inside Linux .deb/.rpm packages The nfpm contents rule pointed `src` at `./build/framework/` for the framework staging step. `wheels-core-VER.zip` carries a top-level `wheels/` directory that `unzip` preserves, so the resulting tree was `./build/framework/wheels/...`. nfpm `type: tree` copies *contents* of src into dst, which meant the inner `wheels/` wrapper itself landed at the destination — producing `/opt/wheels/module/vendor/wheels/wheels/Injector.cfc` instead of `/opt/wheels/module/vendor/wheels/Injector.cfc`. After the user-side wrapper sync (`/opt/wheels/module/*` → `~/.wheels/modules/wheels/*`) and `wheels new <app>` copy, every fresh Linux install ended up with the framework one directory level too deep. Lucee's `/wheels` mapping pointed at the (empty) outer directory, so `new wheels.Injector("wheels.Bindings")` in the generated `public/Application.cfc` threw `could not find component or class with name [wheels.Injector]` on the first request. The existing onError handler then dereferenced `application.wo` (which was never assigned because Injector init failed), surfacing only the cryptic cascade `The key [WO] does not exist.` — issue #2773. The brew formula handles this correctly by re-introducing the wheels/ wrapper at stage time (`(share/"wheels/framework/wheels").install Dir["*"]`). Both Linux nfpm configs now pin `src` at `./build/framework/wheels/` so the contents flatten into `/opt/wheels/module/vendor/wheels/` as intended. The published 4.0.1 .deb / .rpm artifacts ship the broken layout (1 .deb download, 0 .rpm at time of fix). A re-released 4.0.2 will be needed to deliver the fix to users — the change here is to the build config only, not to any framework or CLI code. Tests: `vendor/wheels/tests/specs/cli/LinuxPackageStagingSpec.cfc` gains a per-channel `it()` that asserts `src: ./build/framework/wheels/` + `dst: /opt/wheels/module/vendor/wheels/` are paired in each nfpm yaml. Structural assertion follows the existing #2700 pattern (the file already pins four other packaging invariants the same way). Note on local verification: the structural spec was sanity-checked via equivalent grep / perl POSIX patterns over the YAMLs (positive match for the fixed form, zero matches for the buggy form). Running the spec through the CFML runner locally was blocked by a port-8081 collision with two stale wheels server processes from prior dev sessions — CI compat-matrix will run the spec across every engine × DB on this PR. Closes #2773 Signed-off-by: Peter Amiri <peter@alurium.com> * test(ci): add negative guard for buggy framework src in nfpm yamls Reviewer A on PR #2776 (wheels-bot) flagged that the new framework-src spec only asserted the *fixed* form was present, without a matching `toBeFalse` for the buggy `./build/framework/` form. The file's existing wrapper-routing checks (lines 60-68 / 81-106) already use a dual- assertion pattern; the new spec was a one-sided outlier. Add the negative guard: if a future copy-paste leaves both the bare `src: ./build/framework/` and the fixed `src: ./build/framework/wheels/` in the same yaml, nfpm would stage both — the bare one reintroduces the double-nesting and breaks every fresh Linux install. The spec now fails loudly in that scenario instead of silently passing on the presence of the fixed entry. The two regexes are mutually exclusive by construction: the positive matches `framework/wheels/` followed by whitespace + `dst:`; the negative matches `framework/` followed *immediately* by whitespace + `dst:`. Since `wheels` isn't whitespace, `[[:space:]]+` can't bridge across it, so the negative regex cannot false-positive on the fixed form. Confirmed via perl POSIX equivalent against both nfpm yamls plus a synthetic buggy fixture. Also adds an inline comment to the positive assertion documenting why `[[:space:]]+` works across the YAML line break (POSIX `[[:space:]]` resolves to Java's `\s` in both Lucee and Adobe CF, which includes `\n`) — addresses Reviewer A's Nit 2 observation that the cross-line match hadn't been locally verified. Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> * docs(web/guides): correct Linux bleeding-edge install URLs to wheels-be_* (#2777) * docs(web/guides): correct Linux bleeding-edge install URLs to wheels-be_* PR #2759 (2026-05-18) renamed the snapshot Linux artifacts from `wheels_*` to `wheels-be_*` (debs) and `wheels-be-*.x86_64.rpm` (rpms) so the package name itself differentiates the channel. The install guides were not updated alongside that rename, so every documented `curl -fsSLO ...` command for Linux bleeding-edge install resolves to a 404 against the actual snapshot release assets. Verified against v4.0.2-snapshot.1923 (published 2026-05-20): Guide says: .../wheels_4.0.2.snapshot.1923_amd64.deb → 404 Actual asset: .../wheels-be_4.0.2.snapshot.1923_amd64.deb Fix all six pages where the snippets / prose examples appear (three unique pages mirrored across v4-0-0 and v4-0-1-snapshot doc versions): start-here/installing.mdx — "Want bleeding-edge?" aside start-here/release-channels.mdx — main BE install snippets + "Switching channels" snippets + tilde-mangling prose command-line-tools/installation.mdx — bleeding-edge install snippets The substitutions are scoped to bleeding-edge contexts (snippets using `${SNAP_FILENAME_VER}` and prose `wheels_4.0.0.snapshot.*` filename examples). Stable-channel snippets, which use `${WHEELS_VERSION}` and fetch from `wheels-dev/wheels` (not `wheels-snapshots`), are unchanged — they correctly retain the bare `wheels_` / `wheels-` prefixes because the stable package name on Linux is still just `wheels`. Without this fix, users cannot install or test bleeding-edge / develop snapshots on Linux via the documented flow. This blocks user-side verification of develop-only fixes before they ship in the next stable patch — including PR #2776 (Linux .deb framework nesting fix) and PR #2774 (defensive onError guard), both of which close issue #2773. Signed-off-by: Peter Amiri <peter@alurium.com> * docs(web/guides): fix release-channels.mdx — missed BE Tab URLs + Linux switching semantics Round-1 reviewer findings on PR #2777: A's Nit 1 — primary install Tabs at lines 104-105 (Debian/Ubuntu BE) and 112 (Fedora/RHEL BE) of `release-channels.mdx` still resolved to 404. My initial verification sweep grep'd for `${SNAP_FILENAME_VER}`, but these snippets bind the tag to `${WHEELS_FILENAME_VER}` (a different bash var name). The fix is the same — point at the `wheels-be_` / `wheels-be-` artifacts. A's Nit 2 + B's catch — the "Switching channels" section had three related staleness bugs after #2759 renamed the BE package: 1. Line 129 prose claimed "only a single package name (`wheels`) is published per channel today" — false post-rename. 2. Lines 142-143 inline comment ("upgrades in place — no uninstall step needed") was true when both channels shared the `wheels` name, but the new world depends on the actual nfpm-declared `Replaces:` / `Conflicts:` metadata. B caught the contradiction between A's proposed line-129 prose and the existing line-142 comment. 3. Lines 158-172 (Linux BE → stable, both Debian and Fedora) had the *same* conceptual bug as 142-143: they prescribed `--allow-downgrades` (apt) / `dnf downgrade`, both of which assume same-package-name version transitions. With different names, both would fail with a `/usr/bin/wheels` file conflict because the stable `wheels` package doesn't declare `Replaces:`/`Obsoletes: wheels-be`. Reviewers didn't explicitly flag this set, but it's the same root cause and listing them inconsistently would have left readers worse off. Verified the actual nfpm metadata before rewriting (so the prose matches what the packages really declare): wheels-be deb: Replaces: wheels + Conflicts: wheels wheels-be rpm: Conflicts: wheels (no Obsoletes) wheels deb: no Replaces/Conflicts against wheels-be wheels rpm: no Conflicts/Obsoletes against wheels-be The new prose at line 129 explains the asymmetry up front; each snippet now carries a short comment naming the specific metadata that drives its action (or the lack of metadata that requires the explicit `apt remove` / `dnf remove`). Stable-channel snippets and stable install Tabs are unchanged. Signed-off-by: Peter Amiri <peter@alurium.com> * docs(web/guides): name the actual Conflicts declaration in BE→stable comments Reviewer A round-2 nit on PR #2777: the BE → stable (Debian) snippet's comment said apt "would fail with a /usr/bin/wheels file conflict", framing the failure mode as a dpkg-level file-ownership conflict. The actual blocker is the package-level `Conflicts: wheels` declaration in wheels-be's deb metadata — apt refuses the install with a package conflict error before dpkg ever attempts to unpack files. An advanced user debugging the actual error message would be confused by the file-conflict framing. Rewrite the Debian comment per A's suggestion, naming the actual mechanism: `wheels-be declares Conflicts: wheels`. Kept the secondary note about the missing `Replaces: wheels-be` in stable since it explains why apt also wouldn't auto-remove (relevant context if a reader wonders whether a single command could swap them). Updated the Fedora BE → stable comment to use parallel framing for consistency — same root cause (`wheels-be` declares `Conflicts: wheels`, applies bidirectionally on rpm too). Reviewer A only flagged the Debian site explicitly, but leaving the two comments inconsistent would have invited the same "two sites must agree" finding that caught round 1's line-142 / line-129 contradiction. Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> * fix(test): BrowserTest reports unwired this.browser with browserDescribe() hint (#2782) * fix(test): BrowserTest reports unwired this.browser with browserDescribe() hint Plain describe() blocks inside BrowserTest subclasses left this.browser as an empty string, so the first DSL call surfaced as "function [visitUrl] does not exist in the String" — a misleading error that hits every newcomer on iteration 1. Install an UnwiredBrowserGuard sentinel at this.browser before browserDescribe() wires a real BrowserClient (and after $endBrowserContext tears it down) so any method call throws Wheels.BrowserTest.NotWired with a message naming browserDescribe() as the fix. Fixes #2778 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * docs(web/guides): note Wheels.BrowserTest.NotWired when describe() used instead of browserDescribe() 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> * chore: rename Phase 2 bucket repos to apt-wheels / yum-wheels for naming consistency (#2788) Phase 2 Linux native-repo templates and dispatch wiring referenced `wheels-dev/apt-wheels-dev` and `wheels-dev/yum-wheels-dev`. The `-dev` suffix appeared to mirror the DNS form (`apt.wheels.dev` → `apt-wheels-dev`) but reads as a redundant org echo inside the `wheels-dev` org and breaks the established `<package-manager>-wheels` naming used by the other sister repos (`homebrew-wheels`, `scoop-wheels`, `chocolatey-wheels`). Rename everywhere to drop the `-dev` suffix: - wheels-dev/apt-wheels-dev → wheels-dev/apt-wheels - wheels-dev/yum-wheels-dev → wheels-dev/yum-wheels The actual bucket repos were just created under the new names today (2026-05-21 ~20:15 UTC), so this PR brings the templates / docs / release-workflow dispatch in sync with the on-GitHub reality before the first end-to-end dispatch fires. No live infrastructure references the old names yet — Cloudflare Pages, DNS, and the bucket-side CI secrets all post-date this rename. Mechanical substitution across 9 files (27 references). Stable `wheels` package name (the bare `wheels` in nfpm configs and `apt install wheels` snippets) is untouched — only the org-namespaced repo names change. Signed-off-by: Peter Amiri <peter@alurium.com> * fix(test): resolve BrowserTest base URL through layered lookup at instance time (#2783) * fix(test): resolve BrowserTest base URL through layered lookup at instance time Specs running against a non-default port (Titan on 60050, scaffolds on 60080) previously had to compare getBaseUrl() against a sentinel string and override it manually because $resolveBaseUrl() returned http://localhost:8080 unconditionally and the only escape hatch (WHEELS_BROWSER_TEST_BASE_URL) is cached by the JVM at process start. $resolveBaseUrl() now consults, in order: this.baseUrl per-spec override, get("browserTestBaseUrl") Wheels setting, -Dwheels.browserTest.baseUrl JVM property, WHEELS_BROWSER_TEST_BASE_URL env, $detectBaseUrlFromCgi() auto-detect (the test runner reaches the suite over HTTP, so cgi already names the right host:port), then the localhost:8080 default. The CGI auto-detect skips when port==8080 so existing default-port runs are unchanged. Fixes #2779 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * docs(web/guides): document BrowserTest layered base-URL resolution (#2779) Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(test): address Reviewer A/B consensus findings (round 1) - Strip canonical ports (http:80, https:443) in $detectBaseUrlFromCgi to match URL conventions; update the https:443 spec expectation and add a dedicated http:80 case (vendor/wheels/wheelstest/BrowserTest.cfc:297-299, vendor/wheels/tests/specs/wheelstest/BrowserTestBaseUrlResolutionSpec.cfc:51-61). - Document why the "falls back through layers" assertion is intentionally weak — JVM env vars are read-only from CFML and the Wheels get() setting needs a live framework context, so layer isolation isn't fully testable at that level (vendor/wheels/tests/specs/wheelstest/BrowserTestBaseUrlResolutionSpec.cfc:28-43). - Update browser-test guides (v4-0-0 + v4-0-1-snapshot L319) to recommend setting this.baseUrl in the component pseudo-constructor instead of beforeAll — super.beforeAll() calls $resolveBaseUrl() before a beforeAll-override can take effect, silently inheriting layer 2-6 results. - Mirror the same ordering note in .ai/wheels/testing/browser-testing.md. 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> * fix(cli): make `wheels packages install` a real alias for `add` in dispatch (#2786) * fix(cli): make `wheels packages install` a real alias for `add` in dispatch The `case "install":` branch in `Module.cfc::packages()` previously printed a warning to stdout and returned an empty string instead of installing anything. That was wrong for every caller path that actually reaches module dispatch — the stdio MCP server, scripted in-process clients, and the spec suite — because `PackagesMainCli.install()` itself has been a transparent alias for `add()` since #2729. The dispatch layer was the only place where the alias broke. The shell-facing `wheels packages install <name>` is still intercepted by LuCLI's built-in extension installer upstream of module dispatch and remains broken on that path (documented in the module-owned `--help` text). This change only fixes the paths that LuCLI does NOT intercept. Both verbs now share a single fall-through case body so validation, error shape, and install behavior cannot drift apart again. Fixes #2785 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * docs(web/guides): clarify install-as-alias behavior in packages CLI section Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(cli): address Reviewer A/B consensus findings (round 1) - PackagesCommandSpec: add `expect(installResult.type).toBe(addResult.type)` after the existing `.notToBe("")` assertion so the equivalence claim in the surrounding comment is actually enforced. A regression where `install` throws at argument validation (before the registry call) would have satisfied `.notToBe("")` but diverged from `add`'s shape; the new assertion pins it. - CHANGELOG: terminal period on the new `[Unreleased] / ### Fixed` entry for consistency with surrounding entries. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(web/guides): address Reviewer A/B consensus findings (round 2) - web/sites/guides/src/content/docs/v4-0-0/digging-deeper/packages.mdx (line 320) — scope the install-as-alias note to v4.0.1+. The previous wording asserted the alias was transparent on MCP / in-process paths, but that's only true after this PR (which targets v4.0.1). On v4.0.0 itself, MCP also no-ops; the versioned v4.0.0 docs now say so explicitly and point readers to the v4.0.1 snapshot for the alias behavior. The v4-0-1-snapshot/ copy was already correct and is untouched. 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> * fix(model): quote column identifiers in SELECT clause builder (#2787) * fix(model): quote column identifiers in SELECT clause builder The WHERE and ORDER BY clause builders already routed column names through the adapter's $quoteIdentifier, but $createSQLFieldList — the SELECT/GROUP BY engine — appended the column part raw. Models backed by tables with reserved-word column names (e.g. `key`, `order`, `group`) blew up on `findAll`/`findOne`/dynamic finders with cryptic SQL syntax errors as soon as the SELECT list mentioned the column. Also strips quote chars from the property extracted by the duplicate-column rename loop so the alias replacement still matches the unquoted ` AS <alias>` form, and updates the empty-pagination columnList extraction in read.cfc to strip identifier quotes before stripping the table prefix. Fixes #2784 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * docs(web/guides): note reserved-word column support via property alias in models guide Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(model): address Reviewer A/B consensus findings (round 1) - Condense 4-line block comments at vendor/wheels/model/read.cfc:217 and vendor/wheels/model/sql.cfc:634 to single-line comments (CLAUDE.md: "Never write multi-paragraph docstrings or multi-line comment blocks — one short line max"). - Stop using $quoteColumn() for the table-name argument in vendor/wheels/tests/specs/model/reservedColumnQuotingSpec.cfc; switch to the model's public $quotedTableName() helper so the spec names match what each helper actually quotes. - Add a zero-row paginated findAll spec to reservedColumnQuotingSpec.cfc that exercises the QueryNew branch in vendor/wheels/model/read.cfc:225 with an aliased column, covering the path the original spec did not reach. - Mention ORDER BY alongside SELECT and GROUP BY in web/sites/guides/src/content/docs/v4-0-1-snapshot/basics/models-and-the-orm.mdx so readers do not infer ORDER BY is unsafe with reserved-word columns (ORDER BY already routes through $quoteIdentifier). Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(model): address Reviewer A/B consensus findings (round 2) Condense the remaining multi-line comment blocks in reservedColumnQuotingSpec.cfc to single lines per CLAUDE.md ("Never write multi-paragraph docstrings or multi-line comment blocks — one short line max"): - vendor/wheels/tests/specs/model/reservedColumnQuotingSpec.cfc:10 — 3-line block about City's id -> countyid alias condensed. - vendor/wheels/tests/specs/model/reservedColumnQuotingSpec.cfc:18 — 3-line block about Author.firstName (property == column) condensed. - vendor/wheels/tests/specs/model/reservedColumnQuotingSpec.cfc:40 — 6-line block added in round 1 inside the zero-row pagination it() condensed to the single-line form Reviewer A supplied. The line-30 GROUP BY comment was already single-line; A's "30-32" citation was off-by-one for that one. No production code changed; pure comment-style fix. Test totals unchanged at 4 pass / 0 fail in the spec; full model suite remains 839 pass / 0 fail / 0 error / 11 skipped across 35 bundles. 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> * fix(test): auto-bind include-injected globals into WheelsTest spec scope (#2793) * fix(test): auto-bind include-injected globals into WheelsTest spec scope `WheelsTest`'s pseudo-constructor used `getMetaData(application.wo).functions` to discover which Wheels globals to copy into a spec's `variables`/`this` scope. That metadata enumerates only methods declared on the CFC body and silently skips symbols merged in via `cfinclude` — which is how `vendor/wheels/Global.cfc` pulls user helpers from `app/global/functions.cfm`. Apps with custom helpers (`can()`, `hasRole()`, etc.) had to manually rebind each one in `beforeAll()`. The loop now iterates `application.wo` as a struct and binds every UDF detected by `isCustomFunction()`, while preserving the existing public-only filter for metadata-declared methods and the don't-clobber guard for scope members the spec (or its base class) already provides. Fixes #2790 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(test): address Reviewer A/B consensus findings (round 1) - Promote include-injected UDFs from `variables` to `this` in `vendor/wheels/Global.cfc` after `include "/app/global/functions.cfm"`, so the auto-bind loop in `vendor/wheels/WheelsTest.cfc` discovers them uniformly across Lucee, Adobe CF, and BoxLang. Lucee's struct-iteration over a CFC instance surfaces both `this` and `variables` scopes, but Adobe CF only reliably exposes `this`-scope members — without the promotion, the original bug (#2790) would silently persist on Adobe CF even with the new iteration path in WheelsTest.cfc. - Fix the misleading header comment in `vendor/wheels/tests/specs/wheelstest/WheelsTestAutoBindIncludesSpec.cfc`. Bracket-notation assignment from outside writes to `this` scope, not `variables` — so the probe simulates the post-promotion shape, not the raw include shape. Comment now spells this out explicitly. - Add a new `it` case that asserts the probe key is enumerated by `for (key in application.wo)`. Guards the iteration mechanism the auto-bind loop depends on, so failures on any engine where struct- iteration is narrower than expected would fail this spec rather than silently pass-but-not-test downstream. Addresses Reviewer A's cross-engine concern (Adobe CF struct-iteration contract) and Reviewer B's joint recommendation option (b): promote include-injected helpers to `this` so the iteration path is uniform. The accompanying spec correction handles A's "spec injects via wrong scope" finding and B's "misleading header comment" note. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(test): seed `local` scope before pseudo-constructor for-iterator The promotion loop added in round 1 of the consensus fixup crashed Lucee 7 with `variable [local] doesn't exist` at Global.cfc:3861 — the test runner couldn't even reach a spec before bailing out. In a CFC pseudo-constructor (component body, not inside a function), the `local` scope is not auto-created. Direct assignment to `local.X = ...` will seed it, but `for (local.X in Y)` tries to read `local` first as the iterator's target parent and fails. WheelsTest.cfc gets away with the same loop shape only because it does `local.metaIndex = {}` earlier in its own pseudo-constructor; Global.cfc had no such seeding line. Add the minimum seeding statement (`local.varKey = "";`) directly above the loop and document the cross-engine reason inline. The loop's filter logic is unchanged. The original review couldn't catch this — the round-1 address-review sandbox lacked a working test runner so the fix went out unverified. 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> * fix(mapper): reject redundant namespace prefix in to= and controller= (#2794) * fix(mapper): reject redundant namespace prefix in to= and controller= Inside `.namespace("foo")` (or equivalent `.scope()` / `.package()`), writing `to="foo/dashboard##index"` instead of `to="dashboard##index"` silently produced a `foo.foo/dashboard` controller path that downstream got flattened to a `Foodashboard`-style class lookup with an opaque `Wheels.ViewNotFound` error — leaving users to chase the symptom rather than the route definition. `$match()` now detects when the parsed controller starts with the scope's package converted to slash form and throws `Wheels.MapperArgumentInvalid` at registration time. The error names the namespace and the offending value and points at the correct shorter form. Fixes #2791 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(mapper): address Reviewer A/B consensus findings (round 1) - Snapshot `local.fromTo` / `local.originalTo` before the `to=` parse block so the error detail can distinguish `to=` vs direct `controller=` callers (Reviewer A nit). - Add `Len(arguments.package) > 0` to the guard's outer condition so an empty package does not yield `prefix = "/"` and spuriously reject controllers whose path starts with a slash (Reviewer A response, Reviewer B round-1 missed-issue). - Collapse multi-line block comments above the guard in `matching.cfc` and above the new `it()` group in `MatchingSpec.cfc` to one-liners to comply with CLAUDE.md style (both reviewers). - Add a spec asserting `$match()` with `package = ""` and a controller starting with `/` is not falsely rejected. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(mapper): address Reviewer A/B consensus findings (round 2) - vendor/wheels/mapper/matching.cfc:328 — change local.hh = "##" to local.hh = "####" so the error-suggestion detail renders as to="dashboard##index" (source-correct CFML), not to="dashboard#index" (Reviewer A finding, Reviewer B verified). - vendor/wheels/tests/specs/mapper/MatchingSpec.cfc:241–242 — collapse the 2-line comment inside the "Allows controllers..." spec body to a single line per CLAUDE.md "one short line max" rule (both reviewers). 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> * fix(events): re-include app/global/*.cfm on bare ?reload=true when files change (#2795) * fix(events): re-include app/global/*.cfm on bare ?reload=true when files change Adding a helper to `app/global/functions.cfm` (or anything it `<cfinclude>`s) used to require the password-gated `?reload=true&password=...` path. Bare `?reload=true` re-ran config and routes but left `application.wo` (the `Global.cfc` instance) intact, so the symbols merged into its variables scope at construction time stayed frozen — the page rendered without error and the new helper was silently undefined. The fix follows the Rails/Phoenix per-request mtime-check pattern recommended by the research comment: snapshot `app/global/*.cfm` mtimes on application start, and on bare `?reload=true` in development re-evaluate the include if any tracked file has been added, removed, or touched. The password-gated `applicationStop()` path still does a full re-init unchanged — this just makes the muscle-memory path actually work. Three new helpers on `wheels.Global`: - `$snapshotGlobalIncludes(directory)` — struct of `path → dateLastModified` - `$globalIncludesChanged(snapshot, directory)` — diff against current state - `$reincludeGlobals(file)` — re-evaluate the include against the live Global instance New setting `reloadOnGlobalChange` defaults to `true` in development and `false` everywhere else; opt out with `set(reloadOnGlobalChange=false)`. Fixes #2792 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * docs(web/guides): document reloadOnGlobalChange setting and bare reload behavior Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(events): address Reviewer A/B consensus findings (round 1) - Replace `DirectoryCreate(baseDir, true)` with `java.io.File.mkdirs()` in `reloadGlobalsSpec` — the createPath flag is Lucee-only and the call lives in beforeEach, so every spec in the group fails on Adobe CI. - Assert `IsDefined("application.wo.fxReinclude")` after each `$reincludeGlobals` call so the contract (re-included helpers must remain callable on application.wo) is guarded against silent no-ops. - Lift include-declared functions from local/variables onto this in `$reincludeGlobals` so the contract holds across engines: include inside a method body would otherwise leave declarations in scopes that aren't reachable via `application.wo.helper()`. - Wrap the bare ?reload=true re-include in a double-checked `lock name="wheels_reload_globals"` to eliminate the race between two concurrent ?reload=true hits. - Document the development-only environment guard as intentional so a future maintainer doesn't try to enable `reloadOnGlobalChange` in staging and debug a silent no-op. - Add docblocks to `$globalIncludesChanged` and `$reincludeGlobals` so all three new global-includes helpers carry consistent documentation. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(events): address Reviewer A/B consensus findings (round 2) - vendor/wheels/Global.cfc:$reincludeGlobals — drop the !ArrayFind(beforeVars, key) snapshot-diff guard from the second loop. On Adobe CF the include updates variables[key] in place across calls, so the guard silently skipped re-binding the updated function onto `this` on the second ?reload=true. Re-lifting is idempotent and the path is development-only. - vendor/wheels/tests/specs/global/reloadGlobalsSpec.cfc:88 — replace the second IsDefined assertion with a return-value check so a stale Adobe-CF binding cannot slip past CI (expect(...fxReinclude()).toBe("second")). Local verification on Lucee 7 + SQLite after server reload: core suite 3698 pass / 0 fail / 0 error; global suite 113 pass / 0 fail / 0 error (the 7 reloadGlobals specs all green). Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(test): address Reviewer A/B consensus findings (round 3) - vendor/wheels/tests/specs/global/reloadGlobalsSpec.cfc — hoist `g` and `baseDir` out of the `describe()` arrow-function callback and into `run()` as a shared `ctx` struct, then update every beforeEach / afterEach / it closure to reference `ctx.g` and `ctx.baseDir`. On Adobe CF 2023/2025 CFML closures cannot reach an enclosing function's `local` scope (CLAUDE.md cross-engine invariant ##3); the prior layout silently relied on Lucee 7's lexical capture and would have thrown "variable baseDir is undefined" inside every nested closure on Adobe CI, crashing all seven specs. Local verification on Lucee 7 + SQLite (existing test server, forced ?reload=true&password=wheels first): global directory (wheels.tests.specs.global): 113 pass / 0 fail / 0 error full core suite: 3698 pass / 0 fail / 0 error Lucee was already green before this fix because it captures the enclosing arrow-function `local`; the change is to make the Adobe CI legs match. Adobe verification is left to CI as the local harness cannot run Adobe. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix: address Reviewer A/B consensus findings (round 4) - web/sites/guides/src/content/docs/v4-0-0/command-line-tools/wheels-commands/dev-server.mdx — tighten the bare `?reload=true` note so it mentions `app/global/functions.cfm` (and any files it `<cfinclude>`s) instead of the looser `app/global/*.cfm`. `$globalIncludesChanged` watches every `*.cfm` under `app/global/` but `$reincludeGlobals` re-evaluates only `functions.cfm` and the files it transitively includes; the previous wording implied a developer could drop a standalone helper file directly and have it bind, which isn't true. Matches the more accurate wording already in `core-concepts/environments-and-configuration.mdx`. - web/sites/guides/src/content/docs/v4-0-1-snapshot/command-line-tools/wheels-commands/dev-server.mdx — same wording change for the v4-0-1 snapshot copy, keeping the two doc trees in sync. - vendor/wheels/tests/specs/global/reloadGlobalsSpec.cfc — drop the dead `mappingPath` local variable; it was only used to compute `absPath`, and the `$reincludeGlobals` calls inside the `notThrows` closures already repeat the path literally (closures can't reach the enclosing `local.mappingPath` on Adobe CF anyway). Inlines the string into `ExpandPath()` directly. Local verification on Lucee 7 + SQLite: global directory (wheels.tests.specs.global): 113 pass / 0 fail / 0 error reloadGlobalsSpec only: 7 pass / 0 fail / 0 error Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(events): address Reviewer A/B consensus findings (round 5) - vendor/wheels/tests/specs/global/reloadGlobalsSpec.cfc — add a test for the DateCompare != 0 branch in $globalIncludesChanged, the "developer edited an existing helper" path the PR is designed to serve. Uses the backdated-snapshot approach from Reviewer A's response (DateAdd seconds -60 on the snapshot entry) rather than Sleep(1100), so the test is deterministic across filesystems with different mtime granularities. - vendor/wheels/events/EventMethods.cfc — append application name to the wheels_reload_globals lock so concurrent ?reload=true hits from different apps on a shared Adobe CF server no longer serialize on a single global lock. Development-only and uncommon in practice, but the fix is a one-liner. Signed-off-by: Peter Amiri <peter@alurium.com> * docs: tighten CHANGELOG entry to distinguish detection from re-evaluation scope - CHANGELOG.md — round-5 Reviewer A nit. Opening clause said "re-includes app/global/*.cfm" which overstates the re-evaluation scope: detection is broad (every *.cfm under app/global/) but $reincludeGlobals only re-evaluates functions.cfm and the files it transitively <cfinclude>s. Rewording matches the round-4 dev-server.mdx tightening. 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> * fix(test): keep test-local.sh from silently dying on missing ~/.lucli/express (#2796) Under `set -euo pipefail`, `find ~/.wheels/express ~/.lucli/express ...` exits non-zero whenever any path arg is missing (stderr suppressed via `2>/dev/null`, but the exit status survives), `pipefail` propagates it through `head -1`, and the command-substitution assignment trips `set -e`. The cleanup trap then fires with no server to clean up, so the user sees "Starting Wheels CLI server on port 8080..." with EXIT=1 and no `/tmp/wheels-test-server.log` produced — broken for every install since the lucli→wheels rebrand window closed and `~/.lucli/express/` stopped being created. Drop the now-dead `~/.lucli/express` fallback (the rename landed in 3.0 and recent CLI releases extract Lucee Express to `~/.wheels/express/` only) and add `|| true` for defense in depth so a truly fresh install (before `wheels start` has ever run) leaves `LUCEE_LIB` empty and the downstream `[ -n "\$LUCEE_LIB" ]` guard skips the JDBC pre-install cleanly. Verified: `bash tools/test-local.sh wheels.tests.specs.wheelstest` now runs the server, produces `/tmp/wheels-test-server.log`, and passes all 137 specs across 38 suites in ~17s. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: allow-list APPROVED + CHANGES_REQUESTED in Reviewer A guard (#2797) * fix(ci): skip COMMENTED reviews in Reviewer A guard The post-submission guard in bot-review-a.yml scans for "bogus" wheels-bot reviews on the head SHA and dismisses any with a body shorter than 200 chars or missing the canonical `wheels-bot:review-a` marker. GitHub's dismiss API only accepts APPROVED or CHANGES_REQUESTED reviews; passing a COMMENTED review id returns HTTP 422 ("Can not dismiss a commented pull request review") and crashes the step, red-X'ing the Reviewer A check even when A's real substantive review landed cleanly seconds earlier. wheels-bot itself occasionally posts placeholder COMMENTED reviews mid- cycle as it probes the `gh pr review` CLI before issuing the real one (observed bodies: "placeholder test - ignore", "placeholder2 - updating", "test with dollar sign: see \$reincludeGlobals function"). Those leak out as public COMMENTED reviews, which is what the guard is meant to clean up — but it can't dismiss them via this API, so it has to skip them. Add `select(.state != "COMMENTED")` to the jq pipeline that selects actionable reviews. COMMENTED reviews don't gate merging anyway — only APPROVED and CHANGES_REQUESTED do — so leaving them in PR history is acceptable noise. The guard now only acts on what it can actually dismiss. Observed on PR #2795 commit 0db188a5a5d27cd80b58939df5e0c8dd7464a00b, job run 26296842347. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * ci: allow-list APPROVED + CHANGES_REQUESTED in Reviewer A guard Tighten the previous COMMENTED-state filter to an explicit allow-list: APPROVED and CHANGES_REQUESTED are the only review states that (a) GitHub's dismiss API accepts and (b) gate merging. Switching from "!= DISMISSED && != COMMENTED" to "state IN (APPROVED, CHANGES_REQUESTED)" also covers PENDING reviews, which return the same HTTP 422 from the dismiss endpoint if they ever land on a head SHA. Follow-up to bd76e53de4 per Reviewer A's allow-list suggestion on #2797. Reviewer B confirmed PENDING is reachable and the closed-set form is strictly safer than the deny-list. No behavioral change for the COMMENTED case fixed in bd76e53de4 — that state is still excluded. 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(migrator): handle orphan versions in shared dev databases (#2780) (#2798) * fix(migrator): handle orphan versions in shared dev databases (#2780) When wheels_migrator_versions records a version whose migration file is not in the current checkout (shared dev DB / peer applied a migration whose file isn't yet in this branch), wheels migrate latest no longer takes a misleading "down" branch and silently no-ops. Changes: * Migrator.$getOrphanVersions() — diffs the tracking table against on- disk files and returns versions with no matching file, sorted ascending. * Migrator.migrateTo() — branches on "orphan-at-top" before the existing direction check. When every DB version above target is an orphan: emits a warning naming the orphans, then either applies pending local files (up branch) or prints a clear "Nothing to do" naming current vs target. When SOME above-target versions are legitimate down candidates and SOME are orphans, emits the warning but lets the existing down loop handle the rest (orphan rows skip naturally because the loop iterates files only). * Migrator.\$buildInfoOutput() — extracted info rendering so it is unit- testable without the HTTP dispatcher. Orphan rows render with a [?] marker and the literal "********** NO FILE **********" (Rails-style), with a footer explaining the cause. * public/views/cli.cfm info handler — delegates to \$buildInfoOutput(). * New spec OrphanDetectionSpec covers \$getOrphanVersions + the directional fix across five scenarios (clean state, single orphan, multiple orphans, nothing-to-do path, legitimate down still works). * New spec MigratorInfoSpec covers \$buildInfoOutput rendering. * Docs: new guides page at basics/shared-development-databases.mdx walks through what an orphan is, three resolution paths, and the recommendation to avoid shared dev DBs. AI-side reference at .ai/wheels/troubleshooting/shared-dev-databases.md. Note on local verification: a leaked JVM from another worktree is holding Tomcat's shutdown port (8081), blocking new wheels server starts on this machine. Tests are queued for CI to run across the full engine + DB matrix. Local TDD will run on next machine restart. Refs #2780 Signed-off-by: Peter Amiri <peter@alurium.com> * fix(migrator): address Reviewer A/B consensus findings (round 1) - Reword "Your latest local migration" to "Your target version" in the orphan nothing-to-do message. arguments.version is the target, not necessarily the latest local file (Migrator.cfc). - Add mixed-case OrphanDetectionSpec covering orphan + legitimate down candidate above target: warning names the orphan, down branch still runs, c_o_r_e_dropbears is dropped after rollback. - Drop docs/superpowers/plans/2026-05-22-orphan-migration-detection.md (1053-line agentic plan duplicated in PR body / commit message). - Add CHANGELOG entry under [Unreleased] Fixed describing the orphan detection, three migrateTo branches, and the [?] info row. Non-blocking items left for follow-up: double getVersionsPreviouslyMigrated() fetch in migrateTo() (B disputed scope but underlying concern is valid), and the deferred local test-local.sh migrator run (sandbox lacks the wheels CLI binary; compat-matrix CI will validate on the new SHA). Refs #2780, #2798 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * docs(migrator): fix stale plan reference in shared-dev-databases.md The 1053-line agentic plan file at docs/superpowers/plans/2026-05-22-orphan-migration-detection.md was deleted in commit ddac9690 per Reviewer A/B convergence, but the AI-side reference doc still pointed to it. Replaced the dead link with a PR reference and tightened the follow-up section to describe the work in prose instead of pointing at plan files that may or may not exist. Round-2 Reviewer A nit. Refs #2780, #2798 Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * feat(migrator): doctor/forget/pretend reconciliation commands (#2780) (#2799) * feat(migrator): doctor/forget/pretend reconciliation commands (#2780) Follow-up to #2798. Adds three new `wheels migrate` subcommands for manual reconciliation against the tracking table — the Flyway `validate` / `repair` / `SkipExecutingMigrations` analogues for Wheels. * `wheels migrate doctor` — single-command health report. Lists orphans, pending local migrations, and applied count. Pure read; never mutates. Built on Plan 1's `$getOrphanVersions()`. * `wheels migrate forget <version> --yes` — removes a single row from `wheels_migrator_versions` without running `down()`. Refuses if the version has a matching local file (use `migrate down` instead) or if the version isn't in the table. Idempotent. * `wheels migrate pretend <version> --yes` — inserts a row into `wheels_migrator_versions` without running `up()`. Refuses if already applied or if no local file matches. Both `forget` and `pretend` require explicit `--yes` to mutate; without it they print what would happen and exit. The CLI side wires through a new `runForgetOrPretend()` helper that handles the gating; the HTTP dispatcher in `cli.cfm` reads the version from `request.wheels.params.version`. New spec `MigratorReconciliationSpec.cfc` covers ~12 scenarios across the three methods: clean state, unhealthy with orphans, unhealthy with pending, refusal paths, and the no-mutation guarantee for invalid inputs. Docs: extended `basics/shared-development-databases.mdx` with the new commands (Option 2 reworked, Option 2b added, "Comprehensive diagnostic" section added). AI-side reference and CHANGELOG entry follow the same shape. Note on local verification: a leaked JVM from another worktree continues to hold Tomcat's shutdown port (8081); CI compat-matrix will validate across the full engine + DB matrix. Refs #2780, #2798 Signed-off-by: Peter Amiri <peter@alurium.com> * fix(migrator): address Reviewer A round-1 findings on #2799 - forgetVersion() now delegates to the existing private $removeVersionAsMigrated() helper instead of running an inline DELETE. The helper wraps the DELETE in the same request.$wheelsDebugSQL guard that $setVersionAsMigrated() uses, so forgetVersion() and pretendVersion() are now symmetric: both are no-ops in debug-mode request contexts instead of silently corrupting the tracking table. - runForgetOrPretend() now wraps the version argument with URLEncodedFormat() when building the reconcile URL. $sanitiseVersion() on the server side strips non-digits before SQL use (no SQL injection path), but raw URL-special characters (&, =, %) in the CLI argument could inject spurious query parameters before the sanitiser ran. Both items were flagged by Reviewer A on commit b7817459b. No new tests needed: existing MigratorReconciliationSpec covers the success and refusal paths for forgetVersion(), and the URL-encoding fix is on the CLI client side which is not exercised by core unit tests. Refs #2780, #2799 Signed-off-by: Peter Amiri <peter@alurium.com> * fix(cli): doctor output prints yellow when migrator is unhealthy Reviewer A flagged that runMigration("doctor") always printed in green regardless of the report's healthy flag. Green on an unhealthy result (orphans or pending migrations present) reads as "everything is fine" when actually attention is needed. The fix switches color to yellow when: - action == "doctor" - response includes a `healthy` key - healthy is false Other actions (latest/up/down/info) stay green on success — they either succeed or throw, so the binary mapping holds. Only `doctor` returns a structural "succeeded but unhealthy" state. Non-blocking per A's COMMENTED verdict, but a real UX improvement. Refs #2780, #2799 Signed-off-by: Peter Amiri <peter@alurium.com> * docs: cross-reference doctor/forget/pretend in main migration docs The cumulative state of #2798 (merged) and #2799 (this PR) added three new migrate subcommands plus the [?] orphan display in `migrate info`. PR-scope docs already covered the deep-dive page at basics/shared-development-databases.mdx, but the main migrations reference still said "four subcommands". Catching up: - basics/migrations.mdx now lists seven subcommands and points to shared-development-databases for the reconciliation deep dive. The `info` description mentions the [?] orphan row format. - CLAUDE.md's MCP/CLI table row now lists `doctor` under wheels_migrate and adds a separate "Migrator reconciliation" row for forget/pretend. - CLAUDE.md's Migrations & Seeding section gains a "Shared Dev DB Reconciliation" subsection summarising the new commands and pointing at the deep-dive docs. - CLAUDE.md's Reference Docs section adds the .ai/wheels/troubleshooting/shared-dev-databases.md link. Refs #2780, #2798, #2799 Signed-off-by: Peter Amiri <peter@alurium.com> * docs: replace stale schema_migrations references with wheels_migrator_versions Three carryover Rails table-name references in basics/migrations.mdx (lines 28, 36, 59) predate the c_o_r_e_* → wheels_* rename and don't match any code in vendor/wheels/, cli/, or app/. Reviewer A flagged them on PR 2799 round-4 review (f663b5a55) as worth fixing since the file was already being touched; the surrounding edits in this PR already use the correct `wheels_migrator_versions` name (line 46). Other stale references in basics/seeding.mdx and the v4-0-1-snapshot/ copies are out of scope for this PR; left for a follow-up cleanup. Refs #2780, #2799 Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> * feat(migrator): enrich wheels_migrator_versions with name + applied_at (#2780) (#2800) * feat(migrator): enrich wheels_migrator_versions with name + applied_at (#2780) Final follow-up to #2798 + #2799. Adds two nullable columns to the tracking table so `wheels migrate info` and `wheels migrate doctor` can show *what* a peer applied and *when* — even for orphan rows whose migration file isn't in the local checkout. ## Schema change - `wheels_migrator_versions.name VARCHAR(255) NULL` — migration name derived from filename (e.g. `create_users`) - `wheels_migrator_versions.applied_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP` — when the migration was applied. SQLite gets TEXT with CFML-side `Now()` injection on insert because SQLite can't DEFAULT a column on ADD COLUMN. Both nullable, additive, backward compatible. Existing rows (pre-enrichment) stay NULL and display version-only in the info output. Going-forward-only — no backfill. ## Bootstrap `Migrator.$ensureTrackingColumns()` probes via `$dbinfo` and ALTERs the table per-engine when columns are missing. Idempotent (skip when already present). Per-engine SQL covers MySQL, PostgreSQL, SQLite, MSSQL, Oracle, H2, and CockroachDB — same pattern as the existing `renameSystemTables()` machinery at Migrator.cfc:932. Wrapped by `$maybeEnsureTrackingColumns(appKey)` which caches on `application[appKey].$trackingColumnsEnsured` so the ALTER runs once per app process, not on every migrator call. Non-fatal: if the ALTER fails (locked table, weird permissions), the flag stays unset and the legacy schema continues to work. Called from `$getVersionsPreviouslyMigrated()` after both the existing-table happy path and the bootstrap-creates-table path. ## Population `$setVersionAsMigrated(version, migrationName)` gains an optional `migrationName` arg. When non-empty AND the enriched-columns flag is set, includes `name` (and on SQLite, `applied_at`) in the INSERT. Other engines rely on the column's CURRENT_TIMESTAMP default for `applied_at`. Callers updated to pass the name: both up-loops in `migrateTo()`, `migrateIndividual()`, and `pretendVersion()` (captures the migration's name from `getAvailableMigrations()` during the local-file existence check). ## Display `$getOrphanVersionsWithMeta()` — new public helper. Returns `{version, name, appliedAt}` structs for each orphan. Falls back to bare structs when columns aren't ensured or the SELECT fails. `$buildInfoOutput()` and `cli.cfm`'s `doctor` case now render `[?] <version> <name> (applied <timestamp>)` when populated, with the legacy `[?] <version> ********** NO FILE **********` fallback for legacy NULL orphans. `doctor()` adds `orphansWithMeta` to its result struct alongside the existing `orphans: array of strings` (kept for backward-compat with `migrateTo()`'s direction logic). ## Docs - `web/sites/guides/.../basics/shared-development-databases.mdx`: updated `info` sample output to show the enriched format + a note explaining the legacy fallback. - `.ai/wheels/troubleshooting/shared-dev-databases.md`: new "Schema enrichment" section documenting the helpers and behavior. - `CLAUDE.md`: updated the `info` format line and added the tracking-table schema summary in the Shared Dev DB Reconciliation subsection. - `CHANGELOG.md`: entry under `[Unreleased] Changed`. ## Tests `SchemaEnrichmentSpec.cfc` covers three scenarios: - $ensureTrackingColumns adds both columns on first call - Idempotent re-run (added=[]) - Name column populated for newly applied migrations Existing specs (migratorSpec, OrphanDetectionSpec, MigratorInfoSpec, MigratorReconciliationSpec) all preserve backward compat — the new name column is opt-in (only written when caller passes it), and the new display logic falls back to legacy rendering for NULL metadata. ## Known follow-ups (non-blocking, separate PRs) - Double `$getVersionsPreviouslyMigrated()` fetch carry-over from reviewer notes on #2798 and #2799 — still pending. This PR doesn't worsen the situation; the cached `$trackingColumnsEnsured` flag means `$ensureTrackingColumns` only probes columns once per process. Refs #2780, #2798, #2799 Signed-off-by: Peter Amiri <peter@alurium.com> * fix(migrator): don't call \$dbinfo inside $setVersionAsMigrated transaction CI on Lucee 7 + SQLite revealed two correctness bugs in #2800: 1. \$setVersionAsMigrated called \$dbinfo(type="version") to detect SQLite-vs-other-engines before deciding whether to include applied_at in the INSERT. But \$setVersionAsMigrated runs INSIDE migrateTo's open JDBC transaction, and SQLite (and possibly other engines under concurrent load) can't service JDBC metadata calls inside an open transaction. Result: [SQLITE_ERROR] SQL error or missing database, the migration's transaction rolled back, and every test that applied a migration with the enriched schema active failed. Fix: cache the engine type on application[appKey].\$migratorDbType in \$ensureTrackingColumns() (which always runs outside transactions), then read it from the cache in \$setVersionAsMigrated(). No more \$dbinfo calls during inserts. 2. \$maybeEnsureTrackingColumns set the \$trackingColumnsEnsured cache flag whenever \$ensureTrackingColumns() didn't throw — even when the column probe failed (e.g. table didn't exist yet) and no ALTERs ran. Subsequent \$setVersionAsMigrated calls then saw the flag set and tried to INSERT with a `name` column that didn't actually exist on the table → SQL error → rolled back transaction. Fix: only set the cache flag when BOTH rv.hasName and rv.hasAppliedAt are true after \$ensureTrackingColumns() returns. Failures, missing tables, or partial-ALTER states leave the flag unset so subsequent calls retry. Together these failures broke six specs across migratorSpec, OrphanDetectionSpec, and SchemaEnrichmentSpec — all symptoms of the same two root causes. With the fix in place, \$setVersionAsMigrated is back to a single $query call with no metadata interrogation, and the enriched-INSERT path only fires when the schema is genuinely confirmed. Refs #2780, #2798, #2799, #2800 Signed-off-by: Peter Amiri <peter@alurium.com> * fix(migrator): address Reviewer A round-1 findings on #2800 C1 (correctness): The shared-dev-databases guide example showed "(applied ...)" timestamps on [x] rows, but the code in $buildInfoOutput only populates appliedAt for orphan rows (the DB isn't re-queried for applied_at on local-file rows). Aligning the guide example with what the code outputs — keep the timestamp display on the [?] orphan row where the schema enrichment actually shows through, drop it from the [x] rows. Surrounding prose already correctly scopes the enrichment to orphans. T1 (test quality): SchemaEnrichmentSpec's "populates the name column" test used expect(Len(rows.name) > 0).toBeTrue() which collapses to a boolean before the matcher sees it — on failure the error message is just "Expected [false] to be [true]" with no hint of the actual value. Replaced with expect(rows.name).notToBeEmpty() so failures include the actual name (or absence of one). T2 (coverage): Added a new "populates applied_at for newly applied migrations" spec that queries the applied_at value and asserts it parses as a date. Covers both code paths: the column-DEFAULT CURRENT_TIMESTAMP that fires on MySQL/Postgres/MSSQL/Oracle/H2 AND the CFML-side Now() that SQLite needs because it can't DEFAULT a TIMESTAMP on ADD COLUMN. T3 (lint): Added a comment block above the describe block explaining why CockroachDB is skipped — mirrors the existing pattern in migratorSpec, OrphanDetectionSpec, and MigratorInfoSpec (numeric-version test fixtures don't run cleanly against CockroachDB; compat-matrix.yml treats CockroachDB as soft-fail). The guard is intentional, not vestigial. Also clearing the new $migratorDbType app-scope cache in the beforeEach/afterEach alongside the existing $trackingColumnsEnsured cleanup, so each test starts from a clean state. A's performance note about $dbinfo in $setVersionAsMigrated is already addressed in commit bb4bd761b (caches the engine type on $migratorDbType to avoid the per-insert metadata round-trip). A was reviewing 695f8300c and hadn't seen that commit yet. Refs #2780, #2800 Signed-off-by: Peter Amiri <peter@alurium.com> * docs: correct stale [x] timestamp claim in .ai schema-enrichment ref Reviewer B's round-1 convergence on #2800 caught a missed-by-A follow-on of the C1 finding: the .ai/wheels/troubleshooting/ shared-dev-databases.md doc's first display bullet read - `[x] <version> <name> (applied <timestamp>)` when populated but the code only renders the (applied ...) suffix on orphan rows. The MDX guide example was corrected in fdbbedc35; this commit brings the .ai-side reference in line and adds a sentence explaining why $buildInfoOutput doesn't show applied_at for [x] rows (it would require re-querying the tracking table for every getAvailableMigrations result, which is a bigger change than this PR is taking on). Refs #2780, #2800 Signed-off-by: Peter Amiri <peter@alurium.com> * fix(migrator): populate \$migratorDbType cache before early-return (#2800 C2) Reviewer A round-2 caught a latent bug in \$ensureTrackingColumns: the early-return when both enriched columns are already present fired BEFORE the \$migratorDbType cache was populated. The cache is on application scope, so it gets cleared on every app restart / ?reload=true. On the second app start onward, the early-return fired, \$migratorDbType stayed empty, and \$setVersionAsMigrated's SQLite branch (which writes an explicit applied_at via CFML Now() becau…
1 parent 44e9249 commit 7b2ca56

100 files changed

Lines changed: 5307 additions & 606 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.ai/wheels/testing/browser-testing.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,11 @@ bash tools/test-local.sh # skips browser specs if JARs missin
5959

6060
## Key gotchas
6161

62+
- **`this.browser` is not wired in plain `describe()` blocks.** Calling any DSL method on `this.browser` outside a `browserDescribe()` block throws `Wheels.BrowserTest.NotWired` (message names `browserDescribe()` as the fix; `detail` names the method that was called). The sentinel `UnwiredBrowserGuard` is installed at construction and after each `$endBrowserContext()` teardown.
6263
- **`##` in selectors** — CFML requires `##` to emit literal `#`. `"##email"``"#email"` at runtime.
6364
- **`client` is a Lucee reserved scope.** `var client = ...` in a closure throws "client scope is not enabled". Use `var c = ...` or `var bc = ...`. (Generalized rule: see CLAUDE.md anti-pattern #11.)
6465
- **Data URLs work for most tests** — no server needed for ~95% of DSL coverage. Full HTTP integration (cookies, form submits, redirects) needs a running fixture app; that wiring is the same as Wheels Web app bootstrap (separate server + baseUrl).
6566
- **`this.browserTestSkipped`** — when Playwright JARs aren't installed (fresh CI, clean machine), `beforeAll` sets this flag and `browserDescribe`'s hooks short-circuit. All `it`s should check `if (this.browserTestSkipped) return;` to stay green on CI.
66-
- **CI runs browser tests**`pr.yml` and `snapshot.yml` install Playwright JARs + Chromium (cached via `browser-manifest.json` hash). Browser specs run as part of the normal test suite. `WHEELS_BROWSER_TEST_BASE_URL=http://localhost:60007` is set automatically.
67+
- **CI runs browser tests**`pr.yml` and `snapshot.yml` install Playwright JARs + Chromium (cached via `browser-manifest.json` hash). Browser specs run as part of the normal test suite. `WHEELS_BROWSER_TEST_BASE_URL=http://localhost:60007` is set automatically. The base URL is resolved at instance time through a layered lookup (`this.baseUrl` → Wheels setting → JVM property `wheels.browserTest.baseUrl` → env var → CGI auto-detect → `http://localhost:8080`); per-spec `this.baseUrl` takes priority over the env var. Set `this.baseUrl` in the component pseudo-constructor (outside any function), not inside `beforeAll()``super.beforeAll()` calls `$resolveBaseUrl()` and caches the result, so a `this.baseUrl =` assignment that runs after `super.beforeAll()` is silently ignored.
6768
- **Fixture routes**`/_browser/login-as` and `/_browser/logout` are mounted automatically in test mode. They must come before `.wildcard()` in routes.cfm. In the Routes UI (`/wheels/routes`) all `/_browser/*` routes appear under the **Internal** tab, not Application.
6869
- **Dialogs are Lucee-only**`acceptDialog`, `dismissDialog`, `dialogMessage` use `createDynamicProxy` which is Lucee-specific. Specs skip gracefully on other engines.

.ai/wheels/troubleshooting/common-errors.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,26 @@ Frequent issues encountered when developing Wheels applications and their soluti
99
- Association syntax has specific Wheels conventions
1010
- Migration parameter binding can be unreliable
1111

12+
## Startup / Initialization Errors
13+
14+
### "Application Error — Wheels failed to initialize"
15+
**Error (on-page):**
16+
```
17+
Application Error
18+
Wheels failed to initialize. Check the server log for details.
19+
<pre>could not find component or class with name [wheels.Injector]</pre>
20+
```
21+
22+
**Cause:** `onApplicationStart` threw before `application.wo` was assigned — typically because the `/wheels` CFML mapping points to a directory that doesn't contain `Injector.cfc`. The `onError` handler now surfaces the original exception text instead of cascading into "The key [WO] does not exist" (fixed in [#2774](https://github.com/wheels-dev/wheels/pull/2774)).
23+
24+
**Resolution:**
25+
1. Read the `<pre>` block — it contains the original error (missing class, path mismatch, etc.).
26+
2. Check the server log for the full stack trace from `onApplicationStart`.
27+
3. Verify the `/wheels` mapping resolves: on a fresh install, `vendor/wheels/Injector.cfc` must exist. If it doesn't, re-run `wheels new` or copy the framework files manually.
28+
4. Run `wheels reload` (or stop/start the server) to pick up the corrected mapping.
29+
30+
**Note:** Before the #2774 fix, this failure cascaded into a second `[WO] does not exist` exception that hid the real cause. If you see the old cascade on a version that predates this fix (i.e. 4.0.1 or earlier), the underlying cause is always a failed `onApplicationStart` — see above.
31+
1232
## Common Association Errors
1333

1434
### "Missing argument name" in hasMany()
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Shared Development Databases
2+
3+
Short reference for the orphan-migration case. User-facing version lives at
4+
`web/sites/guides/src/content/docs/v4-0-0/basics/shared-development-databases.mdx`.
5+
6+
## What's an orphan?
7+
8+
A row in `wheels_migrator_versions` whose `version` timestamp has no matching
9+
file in `app/migrator/migrations/`. Common cause: shared dev DB, peer ran
10+
their migration first against the shared DB before their file was merged.
11+
12+
## Detection
13+
14+
`Migrator.cfc::$getOrphanVersions()` — returns an array of orphan version
15+
strings, sorted ascending. Excludes the sentinel `"0"` returned when the
16+
tracking table is empty.
17+
18+
## Display
19+
20+
`wheels migrate info` marks orphan rows with `[?]` and the literal
21+
`********** NO FILE **********` (Rails-style). Includes a footer
22+
explaining the cause. Rendering logic lives in
23+
`Migrator.cfc::$buildInfoOutput()` so it's unit-testable without the HTTP
24+
dispatcher.
25+
26+
## Behavior in `migrateTo()`
27+
28+
If `currentVersion > target` ONLY because of orphans (no local file with
29+
version > target marked migrated), the down branch is skipped. Either:
30+
31+
- Pending local migrations exist → fall through to up branch with a
32+
warning naming the orphans
33+
- Nothing pending → emit "Nothing to do" naming current vs target and
34+
return immediately
35+
36+
If SOME DB versions > target are orphans and SOME have local files, the
37+
down branch runs as usual but emits a warning naming the orphans (they
38+
get skipped by the existing loop because it iterates files only).
39+
40+
## Schema enrichment (Plan 3)
41+
42+
`wheels_migrator_versions` carries two extra columns added in 4.0.x:
43+
`name VARCHAR(255) NULL` and `applied_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP`
44+
(`TEXT` on SQLite — DEFAULT not supported on existing-table ADD COLUMN).
45+
Added automatically on first migrator call via `$ensureTrackingColumns()`,
46+
gated by `application[appKey].$trackingColumnsEnsured` cache so the ALTER
47+
runs once per app process. Failure is non-fatal (legacy schema still works).
48+
49+
Populated by `$setVersionAsMigrated(version, migrationName)` when:
50+
- The enriched columns flag is set, AND
51+
- The caller passes a non-empty `migrationName`
52+
53+
Read by `$getOrphanVersionsWithMeta()` → array of `{version, name, appliedAt}`
54+
structs. Falls back to bare-version structs when columns aren't ensured
55+
or the SELECT fails (e.g. concurrent connection hasn't committed the ALTER).
56+
57+
Display via `$buildInfoOutput()` and `cli.cfm`'s `doctor` case render:
58+
- `[x] <version> <name>` for applied local-file rows (timestamps are only
59+
surfaced on orphan rows because `getAvailableMigrations()` doesn't
60+
re-query the tracking table for applied_at on local files)
61+
- `[?] <version> ********** NO FILE **********` for legacy NULL orphans
62+
- `[?] <version> <name> (applied <timestamp>)` for enriched orphans
63+
64+
Existing rows (pre-enrichment) get NULL for both columns. Going-forward-only;
65+
no backfill at bootstrap time.
66+
67+
## Reconciliation commands
68+
69+
Three CLI subcommands for manual reconciliation against the tracking
70+
table (Flyway `validate` / `repair` / `SkipExecutingMigrations`
71+
analogues):
72+
73+
- `wheels migrate doctor` — comprehensive health report. Reads
74+
`Migrator.doctor()`. Pure read; no mutation. Reports orphans,
75+
pending, and applied counts with a human-readable summary.
76+
- `wheels migrate forget <version> --yes` — removes a single row
77+
from `wheels_migrator_versions`. Requires `--yes`. Refuses if the
78+
version has a matching local file (use `migrate down` instead) or
79+
if it's not in the tracking table.
80+
- `wheels migrate pretend <version> --yes` — inserts a row without
81+
running `up()`. Requires `--yes`. Refuses if already applied or
82+
if no local file matches.
83+
84+
Implementation:
85+
- `Migrator.cfc::doctor()`, `forgetVersion()`, `pretendVersion()`
86+
- `cli.cfm` cases: `doctor`, `forgetVersion`, `pretendVersion`
87+
- `Module.cfc::runForgetOrPretend()` handles `--yes` gating
88+
- Tests: `vendor/wheels/tests/specs/migrator/MigratorReconciliationSpec.cfc`
89+
90+
## Related
91+
92+
- Issue #2780 (the original report)
93+
- PR #2798 (orphan detection + info display + docs, merged 2026-05-22)
94+
- `vendor/wheels/Migrator.cfc::$getOrphanVersions()`
95+
- `vendor/wheels/Migrator.cfc::$buildInfoOutput()`
96+
- `vendor/wheels/Migrator.cfc::doctor()`
97+
- `vendor/wheels/Migrator.cfc::forgetVersion()`
98+
- `vendor/wheels/Migrator.cfc::pretendVersion()`
99+
- `vendor/wheels/tests/specs/migrator/OrphanDetectionSpec.cfc`
100+
- `vendor/wheels/tests/specs/migrator/MigratorInfoSpec.cfc`
101+
- `vendor/wheels/tests/specs/migrator/MigratorReconciliationSpec.cfc`
102+
- Follow-up work (separate PR):
103+
- Schema enrichment of `wheels_migrator_versions` (add `name` and `applied_at` columns)

.github/RELEASE_PLAYBOOK.md

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,25 +29,45 @@ on them is green. ~5 minutes/day max.
2929

3030
1. **Cherry-pick or merge stabilization commits to `release/X.Y.Z`** if using a
3131
release-branch workflow. Skip this if cutting directly from develop.
32-
2. **Update `CHANGELOG.md`** with the release date (replace `=> TBD` for the
33-
target version). `release.yml` will refuse to publish if it sees TBD on the
34-
target version.
35-
3. **Verify `box.json` version** matches what you want to release. After the
36-
last GA, the `bump-develop-version.yml` workflow set it to next-patch; if
37-
you're shipping a bigger bump, update it manually.
32+
2. **Update `CHANGELOG.md`**: rename `# [Unreleased]` to
33+
`# [X.Y.Z](...) => YYYY-MM-DD` with the real release date (replace any
34+
`=> TBD`). Then confirm the separator line directly below the renamed
35+
section is exactly `---` (three dashes), NOT `----`. `release.yml` refuses
36+
to publish if it sees TBD on the target version, and also fails if a `----`
37+
separator would make the release-notes awk bleed into the previous version's
38+
notes (recurring footgun — see #2606, #2768; now guarded in `release.yml`).
39+
3. **Verify `wheels.json` version** matches what you want to release.
40+
`wheels.json` is the canonical version source since the 4.0 rebrand (the
41+
workflows read it; `box.json` is legacy and may be absent or empty). After
42+
the last GA, the `bump-develop-version.yml` workflow set it to next-patch;
43+
if you're shipping a bigger bump, update it manually.
3844

3945
### Release day
4046

41-
```bash
42-
# 1. On main, fast-forward from the release branch.
43-
git checkout main
44-
git merge --ff-only release/X.Y.Z # (or develop, if no release branch)
47+
`main` has a **"PRs only" ruleset**`git push origin main` is rejected with
48+
`GH013`. You also never tag manually: `release.yml` auto-creates the tag via
49+
`softprops/action-gh-release` once the merge lands on main.
4550

46-
# 2. Tag and push. release.yml fires automatically.
47-
git tag -a vX.Y.Z -m "Release X.Y.Z"
48-
git push origin main --tags
51+
```bash
52+
# 1. Open a PR into main from a release branch. (Cutting from develop directly
53+
# is fine — just branch off develop so the PR has a head ref to merge.)
54+
git checkout develop && git pull
55+
git checkout -b release/X.Y.Z-to-main
56+
git push -u origin release/X.Y.Z-to-main
57+
gh pr create --base main --head release/X.Y.Z-to-main \
58+
--title "Release X.Y.Z" --body "Cut X.Y.Z. See CHANGELOG."
59+
60+
# 2. Merge with "Create a merge commit" (NOT squash — preserves develop
61+
# history on main).
62+
gh pr merge --merge --delete-branch
4963
```
5064

65+
The push-to-main from that merge triggers `release.yml`, which builds the
66+
artifacts and calls `softprops/action-gh-release` with `tag_name: vX.Y.Z`,
67+
creating the tag at main HEAD automatically. Do **not** run `git tag` /
68+
`git push --tags` — the legacy `git push origin main --tags` flow is rejected
69+
by the ruleset anyway.
70+
5171
The tag push triggers:
5272
- `release.yml` builds artifacts, publishes to `wheels-dev/wheels/releases`
5373
- `release.yml`'s "Dispatch downstream package managers" step then fires
@@ -149,7 +169,8 @@ If any pin to `<X.Y.Z`, open issues on those repos to widen the constraint.
149169
| Symptom | Cause | Fix |
150170
|---|---|---|
151171
| `release.yml` fails: "CHANGELOG contains TBD" | Forgot to update changelog | Add release date to `# [X.Y.Z]` line, push, re-run workflow |
152-
| `release.yml` fails: "version contains -SNAPSHOT" | `box.json` has `-SNAPSHOT` suffix on main | Strip suffix, push (release.yml asserts clean main versions) |
172+
| `release.yml` fails: "release-notes range … bleeds into a later version section" | Separator below the target version is `----` (4 dashes), not `---` — the awk extraction overruns into the previous version | Change that separator line to exactly `---`, push, re-run |
173+
| `release.yml` fails: "version contains -SNAPSHOT" | `wheels.json` has a `-snapshot` suffix on main | Strip the suffix in `wheels.json`, push (release.yml asserts clean main versions) |
153174
| brew tap PR doesn't open after release | `DOWNSTREAM_DISPATCH_TOKEN` expired or unset | Rotate token in repo secrets; re-run `release.yml` workflow_dispatch |
154175
| develop-bump PR doesn't open after GA | `DOWNSTREAM_DISPATCH_TOKEN` expired/unset, OR the `bump-develop` dispatch step warned and exited 0 | Fire it manually: Actions → "Bump Develop Version" → "Run workflow" → enter the just-released version (e.g. `4.0.0`). The workflow has a `workflow_dispatch` fallback for exactly this case. |
155176
| `brew install wheels` post-release fails | Formula sha256 mismatch | Re-run the tap bump workflow with `workflow_dispatch` to recompute |

.github/workflows/bot-review-a.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,16 @@ jobs:
143143
run: |
144144
set -euo pipefail
145145
146+
# Allow-list APPROVED and CHANGES_REQUESTED only: those are the
147+
# exclusive set of states that (a) GitHub's dismiss API accepts and
148+
# (b) gate merging. Feeding the dismiss API any other state returns
149+
# HTTP 422 — e.g. "Can not dismiss a commented pull request review"
150+
# for COMMENTED, the same shape for PENDING — which would fail this
151+
# step and red-X the check. Originally observed on PR #2795 commit
152+
# 0db188a5 when a COMMENTED placeholder leaked through.
146153
reviews=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" --paginate \
147154
| jq -c --arg sha "$HEAD_SHA" \
148-
'[.[] | select(.user.login == "wheels-bot[bot]") | select(.commit_id == $sha) | select(.state != "DISMISSED")]')
155+
'[.[] | select(.user.login == "wheels-bot[bot]") | select(.commit_id == $sha) | select(.state == "APPROVED" or .state == "CHANGES_REQUESTED")]')
149156
150157
count=$(echo "$reviews" | jq 'length')
151158
if [[ "$count" == "0" ]]; then

.github/workflows/bump-develop-version.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ jobs:
6161
with:
6262
ref: develop
6363
fetch-depth: 1
64+
# Don't persist GITHUB_TOKEN as a git extraheader — peter-evans/create-pull-request
65+
# below sets its own Authorization, and two simultaneous extraheaders make GitHub
66+
# reject git operations with "Duplicate header: Authorization" → HTTP 400.
67+
# First hit on the v4.0.1 GA (run 26173817714); manual workaround was #2770.
68+
persist-credentials: false
6469

6570
- name: Compute next snapshot target
6671
run: |

.github/workflows/release.yml

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ on:
2323
DOWNSTREAM_DISPATCH_TOKEN:
2424
required: false
2525
# Optional — when present, the build job's "Dispatch Linux repo buckets"
26-
# step fires repository_dispatch on apt-wheels-dev / yum-wheels-dev so
26+
# step fires repository_dispatch on apt-wheels / yum-wheels so
2727
# their metadata regen workflows pick up the new .deb/.rpm and republish
2828
# apt.wheels.dev / yum.wheels.dev within minutes. When absent, the step
2929
# skips silently — the bucket repos can be populated manually via each
@@ -105,6 +105,23 @@ jobs:
105105
exit 1
106106
fi
107107
108+
# Check that the CHANGELOG separator below this version is exactly
109+
# '---' (3 dashes). The release-notes extraction step below uses the
110+
# awk range /^# \[VERSION\]/,/^---$/, which terminates ONLY on a line
111+
# of exactly three dashes; a '----' (4-dash) separator silently
112+
# extends the range into the PREVIOUS version's notes. We mirror that
113+
# exact awk here and fail if the extracted range pulls in another
114+
# version heading. (Recurring footgun — see #2606, #2768.)
115+
VERSION_PATTERN=$(echo "${WHEELS_VERSION%+*}" | sed 's/\./\\./g')
116+
BLED=$(awk '/^# \['"${VERSION_PATTERN}"'\]/,/^---$/ {print}' CHANGELOG.md \
117+
| tail -n +2 | grep -cE '^# \[' || true)
118+
if [ "${BLED}" -ne 0 ]; then
119+
echo "ERROR: CHANGELOG release-notes range for [${WHEELS_VERSION%+*}] bleeds into a later version section."
120+
echo "The separator directly below the [${WHEELS_VERSION%+*}] block must be exactly '---' (3 dashes), not '----'."
121+
echo "This check mirrors the awk extraction used to build the GitHub Release notes."
122+
exit 1
123+
fi
124+
108125
# Check that source version in wheels.json is clean (no -snapshot suffix).
109126
# The workflow injects -snapshot.N for develop builds automatically.
110127
BASE_VERSION=$(jq -r '.version' wheels.json)
@@ -306,7 +323,7 @@ jobs:
306323
# CHANNEL derives from the version so develop snapshots produce the
307324
# `wheels-be` package (which the Phase 2 bleeding-edge receiver
308325
# downloads) instead of `wheels`. Without this, the snapshot release
309-
# ships `wheels_<v>_amd64.deb` and the apt-wheels-dev / yum-wheels-dev
326+
# ships `wheels_<v>_amd64.deb` and the apt-wheels / yum-wheels
310327
# receiver workflow 404s on its `wheels-be_<v>_amd64.deb` fetch.
311328
# RC builds keep CHANNEL=stable — RCs aren't fanned out to apt/yum
312329
# (see release.yml § "Dispatch Linux repo buckets").
@@ -588,8 +605,8 @@ jobs:
588605
#############################################
589606
# Dispatch Linux repo buckets (apt.wheels.dev / yum.wheels.dev)
590607
#
591-
# Fires repository_dispatch on wheels-dev/apt-wheels-dev and
592-
# wheels-dev/yum-wheels-dev so their metadata-regen workflows pick up
608+
# Fires repository_dispatch on wheels-dev/apt-wheels and
609+
# wheels-dev/yum-wheels so their metadata-regen workflows pick up
593610
# the new .deb / .rpm asset and republish apt.wheels.dev /
594611
# yum.wheels.dev within minutes. See issue #2605.
595612
#
@@ -616,7 +633,7 @@ jobs:
616633
fi
617634
618635
# Same channel derivation as the homebrew/scoop dispatch.
619-
# apt-wheels-dev / yum-wheels-dev route on `channel` in the payload
636+
# apt-wheels / yum-wheels route on `channel` in the payload
620637
# to slot the .deb/.rpm into the right pool (stable vs bleeding-edge).
621638
# RC releases aren't currently published to apt/yum, so we skip them.
622639
if echo "${WHEELS_RELEASE_VERSION}" | grep -qiE '\-snapshot[\.\+]'; then
@@ -632,7 +649,7 @@ jobs:
632649
# Static event type, fixed repo list — no untrusted input flows into
633650
# the curl arguments. WHEELS_RELEASE_VERSION is generated earlier in
634651
# the same workflow from wheels.json + run number.
635-
for repo in apt-wheels-dev yum-wheels-dev; do
652+
for repo in apt-wheels yum-wheels; do
636653
echo "Dispatching wheels-released to wheels-dev/${repo}..."
637654
http_status=$(curl -sS -o /tmp/linux-dispatch-resp.txt -w "%{http_code}" \
638655
-X POST \

.github/workflows/snapshot.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ jobs:
215215
FORGEBOX_PASS: ${{ secrets.FORGEBOX_PASS }}
216216
DOWNSTREAM_DISPATCH_TOKEN: ${{ secrets.DOWNSTREAM_DISPATCH_TOKEN }}
217217
# Optional: when present, the build job's "Dispatch Linux repo buckets"
218-
# step fires repository_dispatch on apt-wheels-dev / yum-wheels-dev so
218+
# step fires repository_dispatch on apt-wheels / yum-wheels so
219219
# the bleeding-edge channel of apt.wheels.dev / yum.wheels.dev tracks
220220
# every develop push. When absent, the step skips silently. See #2605.
221221
LINUX_REPO_DISPATCH_TOKEN: ${{ secrets.LINUX_REPO_DISPATCH_TOKEN }}

0 commit comments

Comments
 (0)