Skip to content

LTRAC-1328: ci - Automate the Commerce Hosting version pins - #3234

Open
jorgemoya wants to merge 6 commits into
canaryfrom
jorgemoya/ltrac-1328-automate-hosting-pin-bumps
Open

jorgemoya wants to merge 6 commits into
canaryfrom
jorgemoya/ltrac-1328-automate-hosting-pin-bumps

Conversation

@jorgemoya

@jorgemoya jorgemoya commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Linear: LTRAC-1328

Supersedes #3233, which automated the Wrangler pin alone.

What/Why?

Commerce Hosting builds rest on two versions that live as string literals in the CLI source rather than as manifest entries, so Dependabot can't see either and neither moved unless someone remembered:

  • WRANGLER_VERSION in commands/build.ts, interpolated into pnpm dlx wrangler@<version>
  • OPENNEXT_CLOUDFLARE_VERSION in lib/commerce-hosting.ts, what new projects get pinned to

One weekly job now maintains both, in one PR. They're deliberately not two jobs: @opennextjs/cloudflare declares which Wrangler it supports (wrangler: ^4.125.0 today), so Wrangler is pinned to the newest release the pinned adapter allows, not the newest that exists. Bumping either in isolation is how they drift out of a supported pair — with separate jobs on separate branches, neither can see the other's pending bump, and an adapter release that raised its Wrangler floor would leave catalyst build running a Wrangler the adapter doesn't support.

A useful side effect of the adapter being the authority: the job is self-healing. If the pins are already mismatched, the next run reconciles Wrangler into the adapter's range rather than just reporting it.

What holds a bump

The job is deliberately conservative — it holds both pins and reports why, rather than guessing:

Condition Why it can't be automated
Adapter moved its peerDependencies.next Judging whether core's next satisfies a new range needs judgment. Shipping a pin core can't meet is worse than not bumping: reconcileOpenNextVersion refuses to apply it and tells the merchant to upgrade core first.
Adapter needs a Wrangler major this job doesn't track A Wrangler major can change bundling for every native-hosted store.
Pin sits above a new adapter ceiling Walking Wrangler backwards is a downgrade nobody asked for.
A newer adapter major exists Same reasoning; reported in the job summary, never taken.

Even a cosmetically reworded next range holds the bump, because deciding two ranges are equivalent needs range semantics the job doesn't apply to that question. There's a test asserting exactly that.

Why the adapter is now a devDependency

cloudflare-context-symbol.spec.ts is the reason the adapter pin needs more care than a version string. core reads the Cloudflare context off Symbol.for('__cloudflare-context__') — an internal detail of the adapter — and the spec calls the real getCloudflareContext() to prove that key is still the one the adapter writes. If a release changed it, every native-hosted store would fall back to an in-process cache with no error and no signal.

That spec exercises whichever adapter is installed, so it only says something about the pin while the two agree — and nothing made them agree. It now asserts that they do, replacing a hand-maintained expect(OPENNEXT_CLOUDFLARE_VERSION).toBe('1.20.6') literal that a lockfile bump could silently leave behind. Verified it bites: with the constant at 1.21.0 it fails with expected '1.20.6' to be '1.21.0'.

What fixes the installed version is an exact devDependency on packages/catalyst, which the job moves in lockstep with the pin. The optional peerDependencies range stays at the tolerant ^1.17.3 it has always been.

That split matters. An earlier revision of this PR raised the peer range instead, which worked but said the wrong thing: the peer range is the merchant-facing declaration, and reconcileOpenNextVersion deliberately tolerates a project on an older adapter — it offers the upgrade and carries on when declined. Raising the floor contradicted that and handed those projects an unmet-peer warning for a setup the CLI still supports. devDependencies aren't installed by consumers, so this version gets the guarantee the spec needs while nothing merchant-facing changes at all — which is why this PR carries no changeset.

Exact rather than a caret on purpose: a range would let a new 1.x release move the installed version by itself and fail CI before anyone had chosen to adopt it.

One knock-on: this is the adapter's first appearance as a real dependency entry, so it enters Dependabot's scope. A Dependabot bump would desync it from the pin and redden the contract test every Monday, so it's added to the ignore list — this job owns that version.

The job syncs the devDependency only when the adapter version moves, so it won't self-heal a divergence introduced by hand. Deliberate: the spec runs in CLI Tests on every PR and fails in either direction, so such a divergence is loud and immediate rather than silent.

Proving a bump actually builds

The unit suites mock Wrangler and OpenNext, so on their own they cannot say whether new pins build. Combined with GitHub declining to run checks on a PR pushed with GITHUB_TOKEN, a release that broke the real build would have produced a green bump PR whose first genuine test was native-hosting.yml after merge.

So before opening a PR, the job stands up a real native-hosting project against the new pins and completes the true pipeline: opennextjs-cloudflare build followed by wrangler deploy --dry-run, which bundles and validates without deploying anything.

Two details that make this real rather than decorative:

  • The CLI is built from this branch's source, not installed from npm. The published @bigcommerce/catalyst carries its own pins, so installing it would verify nothing about the bump — the trap native-hosting.yml would fall into, since it installs @bigcommerce/catalyst@alpha.
  • core/ has to be transformed first (middleware.ts swapped in, adapter dependency added). catalyst build dispatches on project state, so without that it falls through to next build and never touches either tool.

The bump is committed before the build runs and pushed only once it passes, because standing up that project rewrites core/ and the lockfile — neither of which belongs in the bump.

And that it actually deploys

A build proves the pins compile and bundle; it does not prove the worker deploys and serves. So the job also deploys the bundle it just verified — with --prebuilt, so what ships is exactly what was tested rather than a rebuild — and puts the URL in the generated PR body.

The merchant-facing preview system can't be reused for this, for two independent reasons:

  • deployment-preview-action handles only pull_request and issue_comment, and skips any other event outright. Neither fires for a PR pushed with GITHUB_TOKEN.
  • Nothing at the repo root calls that reusable workflow anyway. core/.github/workflows/preview-deployment.yml is the template shipped to merchants, and GitHub only reads workflows from the repo root — so Catalyst's own PRs get Vercel previews, not native-hosting ones.

It deploys into a project of its own, never the one native-hosting.yml publishes canary to, so a weekly bump can't displace that storefront.

BUMP_PREVIEW_PROJECT_UUID is configured as a repository secret; everything else reuses the existing NATIVE_HOSTING_* store hash, tokens and auth secret. Previews share one project and serve one deployment at a time, so each weekly bump replaces the previous preview.

The deploy is non-blocking. A preview is evidence about a bump, not a precondition for proposing one, and a failure is as likely to be the preview environment as the pins — losing the PR to it would throw away the build result and the contract check too. So the PR always opens, and its body states which of four things happened:

Outcome What the PR says
Deployed The URL, with a nudge to click through it
No project configured Says so; notes the pins still built and the contract passed
Deployed, URL unreadable Points at the job run
Deploy failed Flags it in bold, links the run, and says to confirm the deploy path before merging

The run is still marked failed after the PR exists, so a broken preview isn't reported as green.

Credentials and hold visibility

Two things an adversarial review caught, both fixed here.

The job runs untrusted code, so it holds no write credential while doing it. It installs dependencies, runs lifecycle scripts, and builds — and the whole point is to install an adapter release off npm that nobody has reviewed yet. All of that previously ran with the checkout's push-capable credential in .git/config, so a malicious release wouldn't have needed to break the build to reach the repo's refs; wrangler --dry-run protects Cloudflare, not GitHub. The checkout now sets persist-credentials: false, and the token is injected only at the push.

A hold is no longer indistinguishable from a no-op. Holds previously reported into a job summary on a successful run, so the cases that need a human — a moved Next requirement, a Wrangler range no tracked major satisfies — could have stalled the pins for months behind a green weekly run, which is the exact failure this job exists to prevent. Holds now emit a blocked output with the reason and fail the run after the PR step is skipped, so the Actions list and GitHub's scheduled-failure notification both carry it.

setOutput also collapses newlines: every value is single-line by construction, but a multi-line one would corrupt the key=value file and could forge a second output.

Also

  • semver is added at the root, used only to read the adapter's Wrangler peer range. Two earlier attempts were measured and rejected: pnpm update re-resolved 315 lines of unrelated lockfile tree, and a plain pnpm install after adding semver dragged in eslint-plugin-import and friends. --lockfile-only keeps it to the lines here.
  • test:scripts now runs in CI. It was defined in the root package.json but invoked by no workflow, so 139 existing script tests gated nothing. Added as a step to basic.yml's existing lint-typecheck job — the job ID and display name are untouched, since renaming a required check would break branch protection.
  • This PR deliberately does not move either pin, so the diff stays reviewable. workflow_dispatch needs the workflow on the default branch, so dispatch it once after merge; as of writing that produces a Wrangler-only bump to 4.136.2 (the adapter is already current at 1.20.6).

Testing

pnpm test:scripts — 178 pass (139 existing + 39 new). Full CLI suite green (46 files, 790 tests). Lint and typecheck clean. Rebased on canary and re-verified, including pnpm install --frozen-lockfile to confirm the merged lockfile is genuinely in sync.

A real native-hosting build was run on this branch, using the CLI built from this source, to confirm the pins still build:

OpenNext build complete.
 ⛅️ wrangler 4.128.0
✨ Read 85 files from the assets directory
Total Upload: 15010.02 KiB / gzip: 3005.81 KiB
env.CATALYST_ROUTES_KV, NEXT_INC_CACHE_R2_BUCKET, NEXT_CACHE_DO_* ... bound
--dry-run: exiting now.
✔ Project built

This PR moves neither pin, so that build exercises the same 1.20.6 / 4.128.0 pair canary already ships — it confirms no regression rather than validating a new combination. Validating new combinations is the build gate's job, on each generated PR.

The devDependency approach was verified rather than assumed: it resolves to a single importer entry under devDependencies (no duplicate alongside the optional peer), installs 1.20.6, and produces a smaller lockfile diff than the peer-range approach it replaces. The contract spec passes against it.

Script paths exercised against the live npm registry:

  • Wrangler-only bump: moved 4.128.0 → 4.136.2, correctly bounded by the adapter's ^4.125.0, adapter left at 1.20.6, changeset naming only the pin that moved.
  • Adapter bump, pin lowered to 1.19.0: moved to 1.20.6, rewrote the constant and the devDependency together, left the peer range and peerDependenciesMeta untouched, Next requirement recognised as unchanged.
  • Hold path, pinned next range altered: refused the bump, wrote a > [!WARNING] job summary, touched no files.

The preview step was verified without deploying: the PR body renders correctly both with and without a preview URL, and the URL extraction recovers https://project-30ea75ac.mybigcommerce.com from realistic colorized CLI output. It uses perl rather than sed because \e isn't portable in BSD sed, and the no-URL path survives set -euo pipefail.

Workflow YAML parses and its embedded shell passes bash -n. The git/gh plumbing, the build gate and the preview deploy can't run until the workflow is on canary, so the first workflow_dispatch after merge is what exercises those.

Migration

None. No changeset and no consumer-visible change: the only manifest edits are a devDependency and the root semver devDependency, neither of which reaches consumers of @bigcommerce/catalyst.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 0330d1b

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel

vercel Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
catalyst Ready Ready Preview Sep 25, 2026 6:21pm UTC

Request Review

@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Bundle Size Report

Comparing against baseline from b341810 (2026-09-25).

No bundle size changes detected.

@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Unlighthouse Performance Comparison — Vercel

Comparing PR preview deployment Unlighthouse scores vs production Unlighthouse scores.

Summary Score

Aggregate score across all categories as reported by Unlighthouse.

Prod Desktop Prod Mobile Preview Desktop Preview Mobile
Score 92 95 93 95

Category Scores

Category Prod Desktop Prod Mobile Preview Desktop Preview Mobile
Performance 78 88 73 86
Accessibility 95 98 95 98
Best Practices 100 100 100 100
SEO 100 100 100 100

Core Web Vitals

Metric Prod Desktop Prod Mobile Preview Desktop Preview Mobile
LCP 3.4 s 3.8 s 4.5 s 4.2 s
CLS 0 0 0.039 0
FCP 1.2 s 1.2 s 1.2 s 1.2 s
TBT 0 ms 0 ms 0 ms 0 ms
Max Potential FID 40 ms 50 ms 40 ms 40 ms
Time to Interactive 3.4 s 3.9 s 4.5 s 4.3 s

Full Unlighthouse report →

@jorgemoya
jorgemoya force-pushed the jorgemoya/ltrac-1328-automate-hosting-pin-bumps branch from 376590d to 3637342 Compare September 18, 2026 21:35
@jorgemoya
jorgemoya marked this pull request as ready for review September 18, 2026 21:39
@jorgemoya
jorgemoya requested a review from a team as a code owner September 18, 2026 21:39
@jorgemoya
jorgemoya force-pushed the jorgemoya/ltrac-1328-automate-hosting-pin-bumps branch from 3637342 to a514fe5 Compare September 18, 2026 21:41
@jordanarldt

Copy link
Copy Markdown
Contributor

@jorgemoya How can we verify that a wrangler version bump won't break anything?

@jorgemoya

Copy link
Copy Markdown
Contributor Author

@jorgemoya How can we verify that a wrangler version bump won't break anything?

@jordanarldt Have included a build step to validate the build. Where you thinking of something else we need to validate?

@jordanarldt

Copy link
Copy Markdown
Contributor

@jorgemoya I'm mainly thinking of the deploy pipeline. Like if we update the wrangler version and that somehow breaks the deployment process on native hosting, or makes the storefront stop working. Is that possible to happen?

@jorgemoya

Copy link
Copy Markdown
Contributor Author

@jorgemoya I'm mainly thinking of the deploy pipeline. Like if we update the wrangler version and that somehow breaks the deployment process on native hosting, or makes the storefront stop working. Is that possible to happen?

Anything is possible 😅 . I consider this rare/low risk, but yes it could happen. Maybe we manually verify the preview deployment you added succeeds before we merge it in?

@jorgemoya

Copy link
Copy Markdown
Contributor Author

@jordanarldt Added a step to preview a deployment so we can manually test as well.

jorgemoya and others added 5 commits September 23, 2026 17:14
Commerce Hosting builds rest on two versions that live as string literals in
the CLI source rather than as manifest entries — `WRANGLER_VERSION`, which is
interpolated into `pnpm dlx wrangler@<version>`, and
`OPENNEXT_CLOUDFLARE_VERSION`, which is what new projects get pinned to.
Dependabot can see neither, and nothing else watched them, so they only moved
when someone remembered.

One weekly job now maintains both in a single PR. They are not two jobs on
purpose: the adapter declares which Wrangler it supports, so Wrangler is
pinned to the newest release that adapter allows rather than the newest that
exists. Bumping either alone is how they drift out of a supported pair, and
separate jobs on separate rolling branches could not see each other's pending
bump — an adapter release that raised its Wrangler floor would leave
`catalyst build` running a Wrangler the adapter never supported.

The job holds both pins, and reports why, rather than guess: when the adapter
moves its `next` requirement (judging whether core satisfies a new range needs
judgment, and `reconcileOpenNextVersion` refuses to apply a pin core cannot
meet), when it needs a Wrangler major this job does not track, or when the
current pin sits above a new adapter ceiling.

`cloudflare-context-symbol.spec.ts` is why the adapter pin needs more care
than a version string. It calls the real `getCloudflareContext()` to prove the
symbol key core reads is the one the adapter writes; a release that changed it
would leave every native-hosted store silently on an in-process cache. That
test exercises whichever adapter is installed, so it only says something about
the pin while the two agree — and nothing made them agree. It now asserts that
they do, replacing a hand-maintained literal that a lockfile bump could leave
behind. Holding that invariant means the peer range has to track the pin, so
it moves from `^1.17.3` to `^1.20.6`; the old floor was one nothing targeted
or verified. The job runs that spec against the new adapter before opening a
PR, which is the only place a bump gets verified, since GitHub will not run
checks on a PR pushed with `GITHUB_TOKEN`.

`semver` is added at the root, used only to read the adapter's Wrangler peer
range.

Also runs `test:scripts` in CI. It was defined but invoked by no workflow, so
the 139 existing script tests gated nothing.

Refs LTRAC-1328
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The unit suites mock Wrangler and OpenNext, so nothing in the job could tell
whether new pins actually build. Combined with GitHub declining to run checks
on a PR pushed with `GITHUB_TOKEN`, a release that broke the real build would
have produced a green bump PR whose first genuine test was `native-hosting.yml`
after merge to canary.

The job now stands up a real native-hosting project against the new pins and
completes the true pipeline — `opennextjs-cloudflare build` followed by
`wrangler deploy --dry-run`, which bundles and validates without deploying —
before the PR is opened.

Two details this depends on. The CLI is built from the branch's own source
rather than installed from npm: the published package carries its own pins and
would verify nothing about the bump. And `catalyst build` dispatches on project
state, so the upstream tree has to be transformed first (middleware.ts swapped
in, adapter dependency added) or it falls through to `next build` and never
touches either tool.

The bump is committed before the build runs and pushed only once it passes,
because standing up that project rewrites `core/` and the lockfile, none of
which belongs in the bump.

Refs LTRAC-1328
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The contract spec in `cloudflare-context-symbol.spec.ts` calls the real
`getCloudflareContext()` against whichever `@opennextjs/cloudflare` is
installed, so it only says something about OPENNEXT_CLOUDFLARE_VERSION while
the installed adapter and the pin agree. Making them agree by raising the peer
range worked, but said the wrong thing to consumers: the range is the
merchant-facing declaration, and `reconcileOpenNextVersion` deliberately
tolerates a project sitting on an older adapter — it offers the upgrade and
carries on when declined. A raised floor contradicted that, handing those
projects an unmet-peer warning for a setup the CLI still supports.

The installed version is now fixed by an exact devDependency instead, and the
peer range goes back to the tolerant `^1.17.3` it was. devDependencies are not
installed by consumers, so the contract test gets the guarantee it needs and
nothing merchant-facing changes at all — which is also why the changeset this
replaces is gone.

Exact rather than a caret on purpose: a range would let a new 1.x release move
the installed version on its own and fail CI before anyone had chosen to adopt
it.

This does put the adapter in Dependabot's scope for the first time, since it is
now a real dependency entry. A Dependabot bump would desync it from the pin and
redden the contract test weekly, so it is added to the ignore list — this job
owns that version.

The job only syncs the devDependency when the adapter version moves, so it will
not self-heal a divergence introduced by hand. That is deliberate: the contract
spec runs in `CLI Tests` on every PR and fails in either direction, so such a
divergence is immediate and loud rather than silent.

Refs LTRAC-1328
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A build proves the pins compile and bundle; it does not prove the worker
deploys and serves. The job now deploys the bundle it just verified and puts
the URL in the PR body, so a bump can be clicked through before it is merged.

The merchant-facing preview system is not reusable here. Its action handles
only `pull_request` and `issue_comment` — it skips any other event outright —
and neither fires for a PR pushed with `GITHUB_TOKEN`. Nothing at the repo root
calls that reusable workflow either; `core/.github/workflows/preview-deployment.yml`
is the template shipped to merchants, and GitHub only reads workflows from the
root. So the deploy happens in the run that already holds the bundle.

It uses `--prebuilt`, so what gets deployed is exactly what was verified rather
than a rebuild, and it deploys into a project of its own rather than the one
`native-hosting.yml` publishes canary to — a weekly bump must not displace that
storefront.

`BUMP_PREVIEW_PROJECT_UUID` is not set yet, and the step skips with a notice
rather than failing when it is absent, so the job keeps working until someone
creates the project. The PR body says which of the two happened instead of
implying a preview exists.

Refs LTRAC-1328
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A preview is evidence about a bump, not a precondition for proposing one, and
a failure is as likely to be the preview environment as the pins. Losing the
whole PR to it meant losing the build result and the contract check with it.

The deploy is now `continue-on-error`, so the PR opens regardless and its body
says which of four things happened: deployed with a URL, no preview project
configured, deployed but the URL could not be read back, or the deploy failed
with a link to the run. A failure reads as something to confirm before merging
rather than as a verdict on the bump.

The run is still failed afterwards, once the PR exists, so a broken preview is
not reported as green.

Refs LTRAC-1328
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…face holds

Two problems an adversarial review found.

The job installs and executes third-party code — dependency lifecycle scripts,
an OpenNext build, and, most sharply, an adapter release picked off npm that
nobody has reviewed yet, which is the entire point of the job. All of it ran
with the checkout's push-capable credential sitting in `.git/config`, so a
malicious release would not have needed to break the build to reach the
repository's refs; the `wrangler --dry-run` that protects Cloudflare does
nothing for that. The checkout now uses `persist-credentials: false` and the
token is injected only at the push itself.

Separately, a hold reported itself into a job summary on a successful run. The
cases that need a human — a moved Next requirement, a Wrangler range no tracked
major satisfies — were therefore indistinguishable from "nothing to do", and
could have stalled the pins for months behind a green weekly run. Holds now
emit a `blocked` output with the reason, and the run fails on it once the PR
step has been skipped, so the Actions list and GitHub's scheduled-failure
notification both carry it.

`setOutput` also collapses newlines now. Every value is single-line by
construction, but a multi-line one would corrupt the `key=value` file and could
forge a second output.

Refs LTRAC-1328
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 0330d1b7 Deployed Sep 25, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants