Skip to content

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

Open
netanelgilad wants to merge 8 commits into
mainfrom
fullstack/deploy-git-hash
Open

feat: full-stack deploy — build a commit's worker from the CLI (cf arm, stacked on #585)#584
netanelgilad wants to merge 8 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 and static assets, POSTs asset buckets directly to Cloudflare with the upload-session JWT, then finalizes with the worker modules. Deploy still only builds — nothing here publishes, and re-deploying the same commit stays idempotent.

The static (s3) arm has since landed on main as #585, so this branch is merged with it: both arms now share one transport — one upload shape, one retry idiom — and the code lives in core/site/, since deployments are a transport of the site module rather than a module of their own. A final pass trims the module's comments (net −108 lines, no behavior change) and corrects one that was wrong about where the asset-upload completion token comes from.

Related Issue

None (pairs with the cf arm of apper#18596's discriminated create response; #585 has landed, so this is no longer stacked on it)

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

  • Artifact detection (core/site/wrangler-config.ts): the only trigger is the .wrangler/deploy/config.json redirect from @cloudflare/vite-plugin builds, 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.
  • cf create arm (core/site/schema.ts): asset_uploads becomes a discriminated union (cf alongside s3); the request carries an optional worker config whose presence is what selects the storage target server-side. Each flow rejects the arm it did not ask for.
  • Direct Cloudflare uploads (core/site/upload.ts, api.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 (the server decides completeness by manifest membership, and buckets upload concurrently, so it is usually not the last bucket), and finalize sends it alongside one multipart part per module.
  • One retry idiom for both arms: the cf arm's hand-rolled attempt loop, sleep() and bespoke 429 bookkeeping are replaced by a shared UPLOAD_RETRY on ky. ky retries network errors and its default statuses only, so an expired credential fails fast and a 429 honors Retry-After. The cf arm must name methods: ["post"] or bucket uploads would silently never retry.
  • Orchestration (core/site/full-stack.ts): resolve → collect → manifest → create → buckets → finalize, warning (rather than erroring or silently changing behavior) on missing nodejs_compat, wrangler vars, _headers/_redirects and run_worker_first route arrays.
  • Transport precedence (core/site/deploy-app.ts): deployAppSite() picks the transport for both commands, and a full-stack artifact wins over both static ones — it carries the server, so shipping static output instead would silently drop the worker. base44 deploy now passes site: false to deployAll() and ships the site itself, after the optional build step, so a freshly generated artifact is seen; hasResourcesToDeploy() also counts a buildCommand, since a full-stack project may configure nothing else.
  • Flags & UX: --git-hash/--concurrency defined once in addDeploymentOptions() and ungated (BASE44_STATIC_DEPLOYMENTS now only picks a transport, never a flag's existence); git hash from the flag else git rev-parse HEAD (core/site/git-hash.ts); one shared spinner runner (cli/commands/site/run-app-deploy.ts) with an onWorker step, log.warn warnings, a Deployment: <id> (commit <hash>) row, and --json emitting {deploymentId, gitHash} from both commands.
  • 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.
  • Layout (no behavior change): core/deployments/ folded into core/site/full-stack.ts, static-site.ts, deploy.ts (legacy tar.gz) over shared manifest.ts, upload.ts, modules.ts, wrangler-config.ts, git-hash.ts, api.ts, schema.ts.
  • Docs: docs/deployments.md rewritten for both arms plus a "never hand-roll upload retry or backoff" rule; docs/resources.md, docs/testing.md and 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 (8 cases — manifest/bucket/finalize relay, null completion token when every asset is already stored, git-hash requirement and argParser rejection, --json, nodejs_compat warning, bucket retry after a transient failure, session-expired mapping, create failure), tests/core/site-modules.spec.ts (8) and tests/core/site-wrangler-config.spec.ts (7), plus transport-precedence cases in site_deploy.spec.ts and the retargeted static_site_deployments.spec.ts. Testkit gains mockAssetUpload/mockAssetUploadAfterFailures/mockAssetUploadError and assetUploadRequests, with a new tests/fixtures/fullstack-project/ fixture (redirect file + build/server worker + build/client assets with .assetsignore).

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

  • Test status is reported, not re-verified here: this description was generated in an environment where the suite could not be run, so "All tests pass" is left unchecked. Reported on the branch head: typecheck, lint and knip clean, full suite green (714 tests, 71 files). A pre-existing "Body is unusable" flake under parallel load also reproduces on pristine main.
  • 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. Dropped with the old loop: MAX_RATE_LIMIT_WAITS, RATE_LIMIT_DELAY_MS, uploadBucketWithRetry, sleep() — the fixed 15s 429 wait had no test.
  • Merge-resolution behavior change: a malformed --git-hash is now rejected by the option's argParser before the action runs, not later by resolveGitHash.
  • Static projects behave identically (same tar.gz upload, same App URL line), but the step moved out of deployAll() into the deploy command — that's where to look if a site deploy regresses.
  • Unsupported-but-present wrangler config warns rather than erroring or silently dropping: the config is framework-generated, so the fix belongs in the adapter's settings. The client-side 40 MB module cap sits under the server's 50 MB so oversized bundles fail before any upload work.

🤖 Generated by Claude | 2026-08-04 09:46 UTC | 6f5ac21

@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.8-pr.584.c8b5c9f

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.8-pr.584.c8b5c9f"

Or add it to your package.json dependencies:

{
  "dependencies": {
    "base44": "npm:@base44-preview/cli@0.1.8-pr.584.c8b5c9f"
  }
}

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 6 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>
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