Keywords: versions, publish, deployment, rollback, artifact set, sha256, digest, staged upload, presigned, x-amz-checksum-sha256, entities, agents, backend functions, backend_bundles, actors, Durable Objects, functions-compiler, shards, raw payloads, provenance, commit, idempotency key, step, BASE44_VERSIONS_API, env gate, build sandbox
A version is one immutable thing a build produced — every frontend file, plus the entity and agent payloads the app declares. A deployment is a version made live at an environment. Recording one and serving one are separate acts, which is what makes a rollback a deploy of an older version rather than a second code path.
This lives in src/core/version/: artifacts.ts (the build-output walk and the raw resource reads), functions.ts (compiling the backend functions), actors.ts (compiling the actors), api.ts (the three HTTP calls), publish.ts (orchestration, and the step names behind the shared tagger in core/errors.ts), gate.ts (the env gate), schema.ts (wire types).
Where to build and what to collect is not here — it is core/project/target.ts, because nothing it resolves is about a version and base44 build needs the same answer without importing this lane.
It is not src/core/site/ — see Deployments. That lane ships a build to the legacy hosting API and its deploymentId names a Cloudflare script; this one records a version on the platform's version plane and its deploymentId names a deployment there. A caller that could not tell the two apart would publish by accident, so they are separate commands with separate envelope field names.
hashAsset in core/site/manifest.ts is the first 32 hex characters of sha256(utf8(app_id) ‖ bytes) — a provider upload identifier, salted so a tenant can only collide with its own files.
ArtifactFile.digest is a full sha256 over the stored bytes, streamed so a large file is never read whole. It is durable artifact identity, and the platform signs it into the upload URL. Different purpose, different moment, different value: conflating them produces an asset that uploads fine and never dedupes, or a digest check that fails on a correct file.
createVersion(artifacts, options) in api.ts — three calls, in this order and no other, so nothing is recorded until the bytes are in place:
- Declare.
POST versionswithassets(path, size, digest per file), optionally asite_workerbeside it, the compiledbackend_bundlesandactors, plus the rawentitiesandagentspayloads andsource_commit. The response carries asession_idand one presigned PUT per declared file, in declared order: the assets, the Worker's modules, one per bundle, then one per actor. Each kind comes after the ones before it, so an app gaining one moves no existing slot. - Upload.
putPresignedper file — the same PUT the static deployments lane uses, same ky retry policy. Each PUT sends the server'sContent-Typeand itsx-amz-checksum-sha256verbatim; deriving either locally would 403 on any mapping difference. Uploads are paired with declared files by position, not by path: the frontend and the Worker's modules are separate namespaces, so the same name can appear in both and mean two different files. - Finalize.
POST versions/{session_id}/finalize, no body. The set was fixed at declare, so there is nothing left for the caller to change.
The response carries version_id and manifest_hash, and no flag for "this content already existed" — the hash is the identity, so a caller asking whether a rebuild changed anything compares it against the last one. An existing version is not necessarily the one being served, so such a flag would be misleading anyway.
Each of the three responses is parsed through its Zod schema and a mismatch raises SchemaValidationError — the house pattern from Making API calls, and the only thing keeping this client aligned with the server. There is no generated type and no shared contract fixture here, the same as every other CLI↔platform surface: a server that renames or retypes a response field fails the publish with an error naming the field, rather than propagating undefined. In the other direction the server's request models forbid unknown fields, so a field this client sends that the server no longer accepts is a 422.
What that does not catch is a change of meaning behind an unchanged shape. Nothing here does; the lane is small enough that both sides are reviewed together.
setEnvironmentVersion(environment, versionId, options) is one PATCH /environments/{name} carrying the version id and an idempotency key. The key is what makes a repeat the SAME call rather than a second publish, so the request is retried — bounded, and only when a key is sent, including on our own timeout, which says nothing about whether the server committed. Without a key a repeat is a deliberate redeploy and is never retried. An environment serves one version, so making a version live is editing that pointer — there is no deployment to create. The Deployment record the switch leaves behind is how the plane remembers what it prepared, returned so a caller can correlate a log line.
Nothing else is the caller's to say: the app comes from the credential, and so do the acting principal, the runtime environment variables, every artifact key, the manifest hash and the publication revision. The request models on the server forbid unknown fields, so sending one is an error rather than a silent drop.
collectSiteWorker(projectRoot) reads through resolveFullStackBuild — the same call site deploy makes — which looks for .wrangler/deploy/config.json, the redirect file a @cloudflare/vite-plugin build leaves behind. No second reader, because a second reader is a second opinion about what the framework built. publish and versions create both collect through one collectArtifacts, for the same reason.
A commit is a static app or a full-stack one, never both, and backend functions ride on either. So the two are mutually exclusive on the wire and declaring both is refused:
- Static — no
site_worker, so the platform serves the assets from S3. They must containindex.html, because any unmatched path is answered with that one file. - Full-stack — a
site_workeris named, so the Worker serves them, and they are taken from its ownassets.directoryrather than the project's build output. No entry file is required: its ownnot_found_handlingdecides. A Worker that answers every path itself declares no assets at all, which is a complete app.
One set, two readings, and the Worker's presence is the whole of the difference — so there is no way to claim both, because there is no second field to claim it in. The manifest still has a static_bundle field and it is not a description: the platform reads it as serve this from S3 and hands the prefix straight to the dist service. Which field names the set is the server's to decide from the declaration, not the producer's to state.
main is sent as the module set names it, not as the config wrote it. The platform matches the entry against the names it was sent, so a surviving ./ would name a module nothing in the set provides.
compatibility_date, compatibility_flags, each module's type and the whole serving_config ride along. They are part of the Worker's identity on the platform, not metadata: the same modules under a different compatibility date are a different Worker, and so are the same bytes read as text instead of data, or served with run_worker_first flipped. A version that dropped any of them would record two different Workers as one and could never tell them apart afterwards.
serving_config decides what happens to a request BEFORE the Worker runs — whether it runs at all, which asset a path resolves to, what a miss gets. It is named for that rather than for the assets, which are the set of files at the top of the declaration. Its FIELDS are wrangler's own assets block, name for name — html_handling, not_found_handling, run_worker_first, headers, redirects — because that is what the producer read. A block stating none of them is sent as null, the same as no block at all: a bare assets: { directory } describes the identical Worker, and recording them apart would split one version in two.
Nothing deploys this yet. The platform records the Worker on the version and stores its modules, and refuses a full-stack app at admission — so today this proves the transport, not a publish.
compileBackendBundles in functions.ts reads the project's functions with the same reader base44 deploy uses, compiles them with @base44/functions-compiler (compileFunctionShards), and declares each emitted Worker shard as a bundle: the module's size and digest, the functions it serves — each with its entry file's source as written, which no compiled module carries and the platform shows for that function — and what the compiler wrapped them in. The platform stores and deploys the module as it is — it compiles nothing for a version — so the bytes it serves are the bytes this CLI emitted.
The compile inputs are the platform's own: each function is handed over as cfwBundleInput builds it, under its project-relative path, so a function that reaches nothing beyond its entry compiles as the flat main.ts it always has, and one that imports ../../shared/x.ts compiles under base44/functions/<name>/entry.ts. That entry is what the declaration carries.
The shard policy and the modes are fixed, at the platform's production defaults with Worker sharding off: one shard holding every function, halved only when its module is over the 9.5 MB compressed cap, and runtime secrets and post-response telemetry both off. They are the same for every app, so there is nothing to ask the platform.
Each bundle carries a wrapper — secrets (binding, or blob_pull for a runtime-secrets build), post_response_telemetry, and static_egress — copied from the compiler's own CompiledShard.wrapper rather than restated here. All three change the emitted bytes, so they describe the module, and the platform deploys it under what they say. Today that is always binding, false, and the compiler's STATIC_EGRESS_ARTIFACT_MARKER.
Functions are sorted by name before compiling. A single shard compiles in the order it is given and that order is in the bytes, so a filesystem walk's order would mint a new version for unchanged code.
backend_bundles is always sent — [] for an app with none. An absent field is what a CLI that cannot compile functions sends, and the platform refuses it for an app that has them: its version would describe the app without them, and the deploy would remove them. For the same reason one function that fails to compile fails the whole publish, at step build, before anything is declared.
Compiled during collection, not by base44 build: --no-build skips only the site's build command, so the publish exec compiles. The compiler runs no repo code — esbuild bundles the sources and @deno/loader resolves dependencies without lifecycle scripts — so this does not widen what runs while the publish key exists.
The compiler is bundled like the rest of the CLI, and imports esbuild and @deno/loader — the external runtime packages base44 dev uses too — only when it compiles. The standalone binary cannot carry those, so from there an app with functions fails with DEPENDENCY_NOT_FOUND; an app without functions publishes as before.
compileActors in actors.ts compiles each actor into a module of its own — an actor is a Durable Object Worker, never a shard — with the compiler's single-function bundle(). Each is declared with its name (the folder's, which is also the Durable Object class), its entry, the entry file's source as written, the module's size and digest, and the static_egress contract it was compiled for: the compiler's STATIC_EGRESS_ARTIFACT_MARKER, because an actor's prelude installs the same static-egress fetch every worker entry does. Actors are sorted by name, and each one's module uploads at actors/<name>.mjs, a path the client checks the server signed.
The entry is always base44/actors/<name>/entry.ts, an entry.js included, and that path is not cosmetic: it is how the compiler knows the entry is an actor. That is why this does not go through cfwBundleInput, which flattens an entry that reaches nothing to main.ts. The files are the ones the entry reaches among its own folder and the shared modules, keyed at base44/actors/<name>/ and base44/shared/ wherever the project keeps them — which is how apper assembles an actor, so ../../shared/x.ts resolves and a reach into another actor or a function does not.
actors is always sent, [] for an app with none, for the reason backend_bundles is: an absent field is what a CLI from before actors sends, and the platform refuses it (409 actors_undeclared) for an app that has them. One actor that fails to compile fails the publish at step build. The platform caps a version at 50 actors and the CLI refuses more before compiling any; the standalone binary refuses an app with actors as it does one with functions.
The platform pins content type, content length and sha256 into each presigned PUT, so S3 itself rejects a body that does not hash to the declared digest. The URL is permission to write exactly one payload, once — which is what lets the server commit those bytes with a server-side copy instead of reading them back to re-hash. A frontend of any size is recorded without its bytes passing through a worker.
The practical consequence for this CLI: send the checksum the server gave you, unchanged. PresignedAssetUpload.checksumSha256 is optional because the legacy static lane's URLs pin only type and length.
collectResources reads entities/ and agents/ with readJsonFile and sends the parsed payloads untouched, keyed by the file's path with the schema extension stripped — entities/Todo.jsonc → Todo, agents/support/triage.jsonc → support/triage. That is the same name the platform derives from the same file.
Deliberately not entityResource.readAll / agentResource.readAll. The platform's own extractor and validation are authoritative, and this CLI's stricter entity schema refuses real Builder apps — which is exactly why site deploy reads no resources at all. Re-introducing the strict parse here would reproduce that block.
resolveProvenanceCommit(projectRoot, explicit?) in core/site/git-hash.ts returns undefined rather than failing when there is no checkout. A version is identified by its content; the commit is recorded beside it and never hashed, so a build outside a git checkout is still a complete version.
That is the whole difference from resolveGitHash, whose caller addresses a deployment by the hash and therefore cannot go without one.
One commit, not two: an app's frontend and backend are the same app at the same source, so a version records a single source_commit.
resolveBuildTarget(projectRoot, overrides) in core/project/target.ts fills in npm run build and dist for whatever the config lacks — the whole file, its site block, or a field in it — and writes nothing. The python driver it replaces used to overwrite base44/config.jsonc with a minimal config before building, destroying any checked-in configuration — and, for a full-stack app, its build command.
A config that is present wins, field by field. See Config fallback for how a missing or invalid one is read.
A publish is three steps and they fail differently: a user's build failing, an artifact set the platform refused, and a lost publication race are three incidents with three responses. tagStep(step, run) attaches the step to the error through a non-enumerable symbol — the original error type, message, status code and request id all survive — and Base44Command writes it into the --json error envelope as step.
A callback that throws synchronously is tagged too; run().catch(...) would let that one escape untagged.
base44 publish [--no-build] [--output-dir <dir>] [--target <name>] [--git-hash <hash>] [--concurrency <n>] — build, record a version, serve it. Under --json, stdout is a single {environment, versionId, manifestHash, deploymentId} document.
base44 versions create — record built output without serving it. A version can sit unpublished for as long as it likes.
base44 versions deploy <version-id> [--target <name>] — point an environment at a recorded version: no checkout, no build, no upload. Passing an older id is how a rollback is done.
base44 build is not part of this group and is not gated, but the lane depends on two things about it: it needs no credential (the publish sandbox builds before minting a key that can deploy), and it resolves its config through resolveBuildTarget (a Builder repo has none). What it builds and what it prints are unchanged.
The group is plural to match agents, entities, functions, secrets and workflows — and because base44 version shadowed base44 --version two lines above it in --help.
DEFAULT_VERSION_UPLOAD_CONCURRENCY is 8, MAX_VERSION_UPLOAD_CONCURRENCY is 16. Measured on the build sandbox's pipe: at 3, a 25 500-asset app moved 27 assets/s (~109 ms per PUT, 23 KiB mean — latency-bound) and needed ~930 s of the ~450 s a build leaves, so it was SIGKILLed mid-upload every time. 8 is the rate the python driver it replaced already sustained to the same bucket; 16 failed a degraded pipe on 2026-07-02.
MAX_FILE_COUNT is 50 000, matching the server. It is a cost bound, not a guess
about app size: the platform signs one presigned URL per declared file — measured
at ~108 µs of blocking crypto each — and holds the whole declared set in Redis
until the version finalizes. At the ceiling that is ~5.4 s and ~7 MB for a single
request, which is why declaring is rate-limited far more tightly than finalizing
or deploying.
The largest frontend ever measured through the build sandbox is 25 500 assets, so the ceiling is roughly 2x a real worst case. Raising it on one side alone only earns a rejection after the walk.
The whole lane is one env var. With BASE44_VERSIONS_API=1 (or true; internal gate, not user-facing yet) base44 publish and the versions group are registered; without it they are not registered at all, so they are absent from --help and typing one is an unknown command. versionsApiEnabled() in core/version/gate.ts is read in exactly one place: the registration in program.ts.
Deliberately not BASE44_DEPLOYMENTS_API. That one selects the legacy deployments transport for site deploy, and the build sandbox already sets it for that arm; one var switching both lanes would make them impossible to roll out apart.
The platform's build sandbox runs the lane in two execs and builds once:
base44 build --json # no credential in the environment
base44 publish --no-build --json \ # the key exists only for this one
--git-hash <commit> --concurrency 8
Two execs because the key now carries publish authority and the build is code the repo controls, so the key is minted only after the build finishes and revoked on the way out. --no-build is what keeps it one build rather than two.