Skip to content

feat: full-stack deploy — build a commit's worker from the CLI (cf arm) - #584

Merged
netanelgilad merged 17 commits into
mainfrom
fullstack/deploy-git-hash
Sep 1, 2026
Merged

feat: full-stack deploy — build a commit's worker from the CLI (cf arm)#584
netanelgilad merged 17 commits into
mainfrom
fullstack/deploy-git-hash

Conversation

@netanelgilad

@netanelgilad netanelgilad commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Note

Description

Adds the worker (cf) arm of the deployments API so a full-stack framework build (React Router 7, TanStack Start, Astro 6, vinext — anything built through @cloudflare/vite-plugin) can be deployed from the CLI as a Workers deployment addressed by the commit that produced it. The CLI detects the .wrangler/deploy/config.json redirect artifact, resolves and validates the generated wrangler config, collects the unbundled worker modules plus the static assets, POSTs asset buckets straight to Cloudflare under the upload-session JWT, then finalizes with the worker modules.

The worker and static arms are one flow with one createDeployment() call, not two lanes: deployToDeployments() checks for a worker itself, and that changes only what it sends (the worker's config on create, its modules at finalize). Everything downstream follows the server's answer — uploadDeploymentAssets() dispatches on the asset_uploads discriminant, finalizeDeployment() takes whichever payload completes the deployment. Deploy still only builds; nothing here publishes, and re-deploying a commit stays idempotent.

Related Issue

None (pairs with the cf arm of the backend's discriminated create response)

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactoring (no functional changes)
  • Other (please describe):

Changes Made

  • One flow (core/site/deployment.ts, replacing static-site.ts): deployToDeployments() — resolve the worker if there is one → manifest → one createDeployment() → upload → finalize. config: worker?.config is the only difference on create, and its presence is what selects the storage target server-side.
  • One env gate (deploymentsApiEnabled(), BASE44_DEPLOYMENTS_API): the gate is the only thing that selects the transport — deployments API for static output and full-stack builds alike, legacy tar.gz otherwise. It is read in exactly two places, both in the command: the transport choice, and the registration of --git-hash / --concurrency, which exist only on the lane that can honor them. On the lane a worker build needs no site.outputDirectory, since the worker brings its own assets directory.
  • Artifact detection (core/site/wrangler-config.ts): the only trigger is .wrangler/deploy/config.json, with configPath resolved relative to the redirect file's own directory; hand-authored root configs are deliberately not artifacts. The resolved config must be no_bundle: true, and only fields the deploy acts on are declared, so bindings and the worker name ride along ignored.
  • Module collection (core/site/modules.ts): entry module plus the rules globs, relative paths as module names, wrangler.json / .dev.vars / the assets dir excluded, sourcemaps beside modules (all of them under upload_source_maps), 40 MB total cap under the server's 50 MB.
  • Commit address (core/site/git-hash.ts): resolveGitHash() — explicit --git-hash wins, otherwise git rev-parse HEAD; neither available fails fast with guidance. A malformed value is still rejected by the option's argParser before the action runs.
  • cf create arm (core/site/schema.ts): asset_uploads becomes a discriminated union (cf alongside s3), the request carries an optional worker config, and AssetUploadResponseSchema parses Cloudflare's bucket reply.
  • Direct Cloudflare uploads (core/site/upload.ts): buckets POST straight to Cloudflare with ?base64=true and a bearer session JWT — never the app client, which would leak app auth. 401/403 maps to "upload session expired — rerun deploy". The completion token is read from whichever bucket reply carries one, since the server decides completeness by manifest membership and buckets upload concurrently. Both arms now share one UPLOAD_RETRY on ky (the cf arm must name methods: ["post"] or bucket uploads would never retry).
  • One finalize (core/site/api.ts): finalizeDeployment(deploymentId, sessionId, payload) where payload is {modules, completionJwt} or {indexHtml}; the if lives inside the multipart form builder, where the protocol itself differs.
  • Warnings, not silent drops: _headers/_redirects and run_worker_first route arrays are surfaced as warnings after the task rather than erroring or quietly changing behavior. vars and a missing nodejs_compat are passed over untouched — the config is framework-generated, so the fix belongs in the adapter's settings.
  • Content types (core/site/manifest.ts): per-file MIME lookup for the cf arm's multipart parts; the s3 arm still echoes the server-signed Content-Type verbatim.
  • Command surface (cli/commands/site/deploy.ts): the build step now runs before the deploy confirmation, so the prompt is the last gate before anything leaves the machine; maybeBuildBeforeDeploy no longer requires site.outputDirectory; the flow keeps saying "site" to the user whether or not there is a worker; and the tar.gz path is now the one that raises "No site configuration found."
  • Docs: docs/deployments.md rewritten to cover the single flow and both arms; docs/resources.md, docs/testing.md, docs/AGENTS.md updated.

Testing

  • I have tested these changes locally
  • I have added/updated tests as needed
  • All tests pass (npm test)

New: tests/cli/fullstack_deploy.spec.ts (manifest/bucket/finalize round trip, null completion token when every asset is already stored, git-hash requirement and argParser rejection, --json, a config with no nodejs_compat forwarded as-is, bucket retry resending the body, session-expired mapping, create failure), tests/core/site-modules.spec.ts, tests/core/site-wrangler-config.spec.ts, transport cases in site_deploy.spec.ts, a base44 deploy case asserting the lane's flags stay off the unified command, and a retargeted static_site_deployments.spec.ts. Testkit gains mockAssetUpload / mockAssetUploadAfterFailures / mockAssetUploadError and assetUploadRequests, plus a tests/fixtures/fullstack-project/ fixture.

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation (if applicable)
  • My changes generate no new warnings
  • I have updated docs/ (AGENTS.md) if I made architectural changes

Additional Notes

  • Two behavior changes against main. (1) main selected the deployments lane by the presence of --git-hash; here the env gate picks the transport and the commit defaults to HEAD, so with the gate on and no commit available the deploy fails asking for --git-hash instead of silently falling back to tar.gz — a build with no address could never be published. (2) The build step moved ahead of the confirmation prompt, and no longer needs site.outputDirectory to run.
  • Backwards compatible otherwise. With BASE44_DEPLOYMENTS_API unset, every site deploy takes the legacy tar.gz upload unchanged (a full-stack project's worker is then simply not shipped), and base44 deploy (unified) is untouched — it still ships the site through deployAll()'s tar.gz step and has neither flag. Adopting the lane there needs a commit address the unified deploy has no way to take.
  • The retry test guards two ky behaviors: POST must be named in retry.methods, and ky clones a pristine request so a FormData body survives being resent.
  • Current limitations, each an explicit error or warning rather than a silent drop: output that still needs bundling (no_bundle !== true) is rejected; wrangler vars are ignored in favor of app secrets; _headers/_redirects and run_worker_first route patterns are not forwarded yet.
  • CI on this head: build, lint, typecheck, knip, publish-preview and the gateway check pass; the four test jobs (ubuntu/windows × npm/binary) were still running when this description was generated, so the testing boxes above are left unchecked rather than asserted.

🤖 Generated by Claude | 2026-09-01 03:57 UTC | f81051e

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

🚀 Package Preview Available!


Install this PR's preview build with npm:

npm i @base44-preview/cli@0.1.13-pr.584.f81051e

Prefer not to change any import paths? Install using npm alias so your code still imports base44:

npm i "base44@npm:@base44-preview/cli@0.1.13-pr.584.f81051e"

Or add it to your package.json dependencies:

{
  "dependencies": {
    "base44": "npm:@base44-preview/cli@0.1.13-pr.584.f81051e"
  }
}

Preview published to npm registry — try new features instantly!

claude added 2 commits August 3, 2026 17:16
…ted)

Adds the deployments core (commit-addressed create/finalize, asset
manifest hashing, presigned uploads) and routes site.outputDirectory
through it when BASE44_STATIC_DEPLOYMENTS is set: POST deployments with
{git_hash, asset_manifest} and no worker config, PUT each requested
file directly to its presigned URL echoing the signed content_type
(the URL also signs content_length), finalize with the index.html
bytes as the completion sentinel. asset_uploads: null means nothing
is owed — re-deploying a commit is idempotent.

The create response is a type-discriminated ADT so the worker (cf)
arm can slot in next to s3 without protocol changes. Gate off keeps
the legacy tar.gz upload byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DvhQfqxACcq25XAQRpoSh9
Stacks the worker lane on the deployments API: detect the
@cloudflare/vite-plugin redirect artifact, resolve the generated
wrangler config (no_bundle only), collect modules, and create the
deployment with the worker config — which the server answers with the
cf arm: asset buckets POSTed directly to Cloudflare with the
upload-session jwt (never through the app client), finalize with
payload{completion_jwt} plus the module parts. A full-stack artifact
wins over the static transports; nothing here publishes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DvhQfqxACcq25XAQRpoSh9
@netanelgilad
netanelgilad force-pushed the fullstack/deploy-git-hash branch from 5c32b34 to a588d65 Compare August 3, 2026 17:17
@netanelgilad netanelgilad changed the title feat: full-stack deploy — build a commit's worker from the CLI feat: full-stack deploy — build a commit's worker from the CLI (cf arm, stacked on #585) Aug 3, 2026
@netanelgilad
netanelgilad changed the base branch from main to fullstack/static-workers-lane August 3, 2026 17:17
Base automatically changed from fullstack/static-workers-lane to main August 4, 2026 07:31
netanelgilad and others added 7 commits August 4, 2026 11:28
Main landed PR #585 (static-site deploys) in a reworked form after review:
the code moved to `core/site/`, the `.assetsignore` walk switched to globby,
uploads switched to p-map + ky retry, and `--concurrency` was added. This
branch had built the full-stack (cf) arm on top of #585's original layout in
`core/deployments/`.

Resolution: keep this branch's structure and CLI contract, adopt main's
review improvements into it.

Structure — kept `core/deployments/`; the cf arm deploys Workers, which is
not site-specific. Reverted main's duplicate `core/site/{manifest,static-site,
upload}.ts` and the deployment additions to `core/site/{api,schema}.ts`;
`core/site/deploy-app.ts` remains the bridge from the site module.

Adopted from main:
- globby-based `.assetsignore` via `ignoreFiles`, replacing the hand-rolled
  matcher — gains real gitignore semantics including negation. Kept the MIME
  table and `AssetFile.contentType`, which the cf arm's multipart parts need.
- p-map for upload concurrency (both arms) and ky's built-in retry for
  presigned PUTs, so a 403 from an expired URL fails fast.
- `--concurrency <n>` (default 3, max 50) and `isGitCommitHash()` in
  `core/utils/git.ts`; `GIT_HASH_PATTERN` dropped from the deployments schema.
- Main's manifest suite (negation, brace/extglob, anchoring, dotfiles).
- v0.1.8, globby ^16.2.2, p-map ^7.0.6.

Contract kept from this branch — main gated `--git-hash`/`--concurrency`
registration on BASE44_STATIC_DEPLOYMENTS. That cannot hold here: full-stack
deploys are ungated and need `--git-hash`, which now defaults to the
checkout's HEAD. Both flags are therefore always registered, on `deploy` and
`site deploy` alike, from a shared `addDeploymentOptions()`. The env gate now
decides only whether a static output takes the deployments API or the legacy
tar.gz path. Main's flag-hiding tests were dropped as no longer applicable;
its `--git-hash`/`--concurrency` validation tests were ported.

One behavior change: a malformed `--git-hash` is now rejected by the option's
argParser ("Expected a git commit hash") before the action runs, rather than
later by resolveGitHash.

typecheck, lint, and knip clean; 713 tests pass. The suite has a pre-existing
"Body is unusable" flake under parallel load that reproduces on pristine main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The merge kept the full-stack deploy code in `core/deployments/`, but main
had already settled this: deployments are a transport of the site module, not
a module of their own, and they live in `core/site/`. Follow main.

`core/deployments/` is gone. One flow per file — `full-stack.ts` (Workers, the
cf arm), `static-site.ts` (deployments-API static, the s3 arm), `deploy.ts`
(legacy tar.gz) — over shared `manifest.ts`, `upload.ts`, `modules.ts`,
`wrangler-config.ts`, and `git-hash.ts`. The deployment requests and schemas
merge into the module's existing `api.ts` / `schema.ts` next to the tar.gz
upload, as main has them. `deploy-app.ts` stays the transport picker.

The Workers flow is `full-stack.ts` rather than `deploy.ts` because that name
is already the legacy tar.gz path; it reads as a pair with `static-site.ts`.
Unit tests follow the same naming: `tests/core/site-*.spec.ts`.

No behavior change — moves, import rewrites, and the barrel/doc updates that
follow from them. While merging the two `api.ts` files, the worker-module read
switched to the existing `@/core/utils/fs.js` helper instead of a second raw
`readFile` binding, for typed FileNotFound/FileRead errors.

typecheck, lint, and knip clean; the 8 deploy spec files pass (77 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The s3 arm already used pMap for concurrency and ky's own retry. The cf arm
had a hand-rolled attempt loop, a `sleep()` setTimeout helper, and bespoke
429 bookkeeping alongside it. Two idioms in one file for the same job.

Both arms now share `UPLOAD_RETRY` and let ky do the retrying. ky retries
network errors and its default status codes only (408/413/429/500/502/503/504),
which is what these uploads actually want: an expired credential (401/403)
fails fast instead of burning every attempt, and a 429 waits out the server's
`Retry-After` instead of the flat 15s the old loop invented. The 401/403
"upload session expired" mapping is unchanged.

Two things worth knowing, both now covered:

- POST is absent from ky's default retry `methods`, so the cf arm has to name
  it explicitly or bucket uploads would silently never retry. The new test
  fails if that option is dropped.
- ky clones a pristine request before sending, so the FormData body survives
  being resent. The test asserts every attempt carried the full body, since a
  consumed body would fail as "Body is unusable" only under real retries.

`uploadAssetBucket` moved from api.ts into upload.ts, where it can share the
retry config without a cycle. That also sharpens the split: api.ts is the
app-client (authenticated Base44 API) calls, upload.ts is the direct-to-storage
uploads that deliberately bypass that client.

Dropped: MAX_RATE_LIMIT_WAITS, RATE_LIMIT_DELAY_MS, uploadBucketWithRetry,
sleep(). The 429 fixed-wait behavior they implemented had no test.

typecheck, lint, and knip clean; the deploy specs pass (51 tests, 3 runs).

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

Comment and docstring cleanup across the site deploy module — the prose was
carrying its own weight badly: restating what the next line of code already
said, re-explaining the same protocol fact at every call site, and narrating
obvious parameters. Net -108 lines with no behavior change.

Also corrects one comment that was actively wrong. CfAssetUploads said the
*last bucket's* reply carries the completion token. Verified against
Cloudflare's direct-upload docs and wrangler's syncAssets(): the server decides
completeness by manifest membership ("once every file in the manifest has been
uploaded"), so the token goes to whichever request completes the set. Buckets
upload concurrently, so that is usually not buckets[n-1] — indexing the final
bucket would read an empty result and discard a token already in hand. The
implementation was already right; only the comment lied.

typecheck, lint, and knip clean; full suite green (714 tests, 71 files).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`base44 deploy` had grown the whole deployments lane: --git-hash,
--concurrency, full-stack artifact detection, and a deployment summary. It
doesn't need any of it. The fullstack flow is an addition to the existing
*site* deploy flow, so that is where it lives.

`base44 deploy` reverts to what it was: resources plus the legacy tar.gz site
step through `deployAll()`. Reverted to origin/main verbatim —
cli/commands/project/deploy.ts (drops the flags, detectAppDeployKind,
`site: false`, printDeploymentSummary and the deployment JSON output) and
core/project/deploy.ts (drops the `site` option and the buildCommand clause in
hasResourcesToDeploy, which only existed to serve the removed flow).

`base44 site deploy` keeps the lane and is now its only entry point. With one
caller left, the shared addDeploymentOptions() helper was over-abstraction:
the two option definitions and their parsers are inlined and
cli/commands/site/deploy-options.ts is deleted.

Specs for both arms move from the unified deploy to `site deploy`, which also
drops their resource-push mocks — nothing pushes resources there. Added a test
pinning the decision: `base44 deploy` rejects --git-hash and --concurrency as
unknown options and shows neither in --help.

typecheck, lint, and knip clean; full suite green (715 tests, 71 files).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR #585 pulled the spinner wiring and result shaping out of `deployAction` into
`deployToDeploymentsApi` / `deployTarball`. This branch had undone that: the
action ended in an if/else chain over a result union, and the progress wiring
had moved off to its own cli/commands/site/run-app-deploy.ts. Back to main's
shape, with the full-stack flow folded in as a third helper.

`deployAction` now reads as plan → confirm → build → dispatch, and each
transport owns its own labels, progress and result:

- deployFullStackApp      — Workers, the cf arm
- deployToDeploymentsApi  — deployments-API static, the s3 arm
- deployTarball           — legacy tar.gz (unchanged from main)

`runDeployTask` holds the spinner/progress wiring the two deployments-API
helpers share, and `deploymentResult` the outro + --json document. run-app-deploy.ts
is deleted.

That let core shed an orchestrator it no longer needs: `deployAppSite()` and the
AppDeployResult union are gone, and core/site/deploy-app.ts is just the planner
now — `planAppDeploy()` returns the plan (with outputDir where there is one) and
the command calls deployFullStack / deployStaticSite / deploySite itself. Core
still decides which transport applies; the CLI no longer round-trips through a
second dispatch to find out what it already asked for.

The command plans twice, deliberately: once before the build for the prompt and
the no-config error, once after for the transport it acts on, since a full-stack
artifact is itself a build output.

typecheck, lint, and knip clean; full suite green (715 tests, 71 files).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	docs/deployments.md
#	packages/cli/src/cli/commands/site/deploy.ts
#	packages/cli/tests/cli/static_site_deployments.spec.ts
@netanelgilad netanelgilad changed the title feat: full-stack deploy — build a commit's worker from the CLI (cf arm, stacked on #585) feat: full-stack deploy — build a commit's worker from the CLI (cf arm) Aug 16, 2026
Integrates the session id main's #602 added to the deployments protocol:
create returns `session_id` and finalize now addresses this attempt's
uploads through it, so the cf arm threads `created.sessionId` into
`finalizeDeployment` alongside the static arm.

Conflicts resolved in docs/deployments.md (both arms plus session_id) and
core/site/schema.ts (kept sessionId alongside the cf/s3 upload union).
Comment thread packages/cli/src/cli/commands/site/deploy.ts Outdated
Comment thread packages/cli/src/cli/commands/site/deploy.ts Outdated
Comment thread packages/cli/src/cli/commands/site/deploy.ts Outdated
claude and others added 7 commits August 31, 2026 07:59
Review feedback on #584.

A worker is not a different kind of deploy, so the code no longer forks on
one. `deployToDeployments()` checks for a worker itself and that changes only
what it sends: the worker's config on the single create call (which is what
makes the server answer with the cf arm) and its modules at finalize. The
parallel full-stack.ts / static-site.ts flows are gone, and with them the two
create call sites, the two finalize entry points and the arm-mismatch guards
each flow needed to reject the other's answer.

Downstream now follows the server's answer rather than a decision of ours:
uploadDeploymentAssets() dispatches on the asset_uploads discriminant and owns
the asset progress; finalizeDeployment() takes whichever payload completes the
deployment. The only place the two shapes are still spelled out is the finalize
form in api.ts, where the protocol itself differs.

User-facing copy says "site" again, as it did before this branch: a site is
whatever we deploy, worker or no worker, so the prompt, spinner, success line
and errors no longer say "full-stack app". The command description, the
no-config hints and the deploy labels are back to main's wording; the confirm
prompt drops the output directory only when there is none to name. Errors from
the wrangler and module paths lost "full-stack" too.

Transport plan is now deployment | tarball | none, since the deployments arms
merged. A worker build stays ungated and needs no site.outputDirectory.
…hash

# Conflicts:
#	docs/deployments.md
#	packages/cli/src/core/site/manifest.ts
@netanelgilad
netanelgilad merged commit dd0e52e into main Sep 1, 2026
14 checks passed
@netanelgilad
netanelgilad deleted the fullstack/deploy-git-hash branch September 1, 2026 04:02
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