Skip to content

feat: publish an app as a recorded version - #626

Open
yurynix wants to merge 15 commits into
mainfrom
feat/version-publish-api
Open

yurynix wants to merge 15 commits into
mainfrom
feat/version-publish-api

Conversation

@yurynix

@yurynix yurynix commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Note

Description

Adds a versions lane to the CLI: a version is one immutable record of everything a build produced — every frontend file, the app's own Worker when the framework built one, and the raw entity and agent payloads the app declares — and a deployment is that version made live at an environment. Recording and serving are separate acts, which makes a rollback a deploy of an older version rather than a second code path. The lane ships as base44 publish, base44 versions create and base44 versions deploy <version-id>, all gated behind BASE44_VERSIONS_API so they are not registered at all until the platform side is ready. It exists to replace the python driver the platform's build sandbox runs today — which overwrote base44/config.jsonc with a minimal config before building, destroying checked-in configuration.

Related Issue

None

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

New src/core/version/ module

  • api.ts — the three HTTP calls, in order: POST versions (declare the artifact set, get a session_id and one presigned PUT per file) → upload → POST versions/{session_id}/finalize. Nothing is recorded until the bytes are in place, so an interrupt leaves staged objects that expire rather than a version naming files that are not there. Uploads pair with declared files by position — the frontend then the Worker's modules, the order the server signed them in — because the two sets are separate namespaces and the same path can appear in both. setEnvironmentVersion is one PATCH /environments/{name} with an idempotency key: an environment serves one version, so making a version live is editing that pointer.
  • artifacts.ts — walks the build output and streams a full sha256: digest per file (bounded at 32 open descriptors); collectSiteWorker detects a full-stack build and describes its modules the same way, reusing the existing full-stack deploy lane's detectFullStackArtifact / resolveWranglerConfig / collectModules whole rather than reading the framework's output a second time; and reads entities//agents/ raw, keyed the way the platform names them (agents/support/triage.jsoncsupport/triage). Deliberately not the validated resource readers: the CLI's stricter entity schema refuses real Builder apps. Refuses an empty set, a set with no index.html, and more than MAX_FILE_COUNT (50 000, matching the server's ceiling).
  • publish.ts — orchestration, plus tagStep/stepOf: attaches build / create_version / deploy to a failure through a non-enumerable symbol, leaving the original error type, message, status and request id intact. Base44Command writes it into the --json error envelope as step.
  • project.tsresolvePublishTarget fills in npm run build and dist only when a repo has no config at all, and writes nothing. A config that is present wins field by field; one that omits a field still gets today's error. requireOutputDir raises at collection, not resolution.
  • gate.tsversionsApiEnabled(), read in exactly one place (program.ts). Separate from BASE44_DEPLOYMENTS_API so the two lanes can roll out apart.
  • schema.ts — Zod wire schemas with snake_case→camelCase transforms; a response mismatch raises SchemaValidationError naming the field.

Full-stack apps — when .wrangler/deploy/config.json is present, the version declares a site_worker (entry module, module set with digests, compatibility_date, compatibility_flags) alongside the frontend, and the frontend is taken from the Worker's own assets.directory rather than the project's site.outputDirectory. main is sent as the module set names it, resolved by identity, so a surviving ./ cannot name a module the set does not provide. Nothing deploys this yet — the platform stores it and still refuses a full-stack app at admission — so this is the transport, sent and stored.

New commands (publish.ts, versions/{create,deploy,index,options}.ts) — shared flags (--output-dir, --target, --git-hash, --concurrency) built in one place so two --help screens cannot describe the same flag differently. --concurrency defaults to 8 and caps at 16; --json emits a single {versionId, manifestHash, deploymentId} document via RunCommandResult.stdout.

Shared with the existing site lane

  • Extracted walkBuildOutput() from buildAssetManifest so both lanes apply one rule for what a build produced (.assetsignore semantics + always-ignored names).
  • Extracted putPresigned() from uploadPresignedAsset so both lanes share one PUT while each decides which file a signed URL is for (the site lane by path, the version lane by declared order). It forwards the server's x-amz-checksum-sha256 verbatim when present, which is what makes S3 itself reject a body other than the declared one. AssetFile.contentType became optional — only the cf arm reads it, the s3 arm echoes what the server signed.
  • Added resolveProvenanceCommit(): returns undefined outside a checkout, because a version is identified by its content and the commit is recorded beside it, never hashed.

base44 build — no longer requires a credential (it calls no API, and the sandbox builds before minting a key that can deploy) and no longer requires a linked CLI config; it resolves through resolvePublishTarget. What it builds and prints is unchanged.

Docs — new docs/versions.md (linked from docs/AGENTS.md) covering the two-hashes distinction, the declare→upload→finalize order, why the digest is signed into the URL, the full-stack Worker, raw resource payloads, the measured concurrency and file-count bounds, the env gate, and the sandbox's two-exec invocation.

Testing

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

4 new spec files plus additions to build.spec.ts (~785 lines): end-to-end publish coverage (digests declared, resources sent raw, checksum header forwarded, environment named in the path and version in the body, --json envelope, step tagging, --no-build), a full-stack app (Worker declared in the same version, frontend taken from the Worker's assets directory, each declared file's own bytes at the URL signed for it), gate on/off/--help, artifact collection (nested paths, .assetsignore, descriptor exhaustion on a large set, missing entry point, module set vs. assets), publish-target resolution (including "writes nothing" and "leaves a checked-in config exactly as it was"), and tagStep semantics. Testkit gained PATCH routing, mockVersionDeclare/mockVersionFinalize/mockEnvironmentSet/mockVersionDeclareError with request capture, and a publishable fixture. Unchecked boxes are not claims of failure — this description was generated in an environment that could not execute the suite; CI is the source of truth.

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

The lane is inert until BASE44_VERSIONS_API=1, so nothing here is reachable by a user on this release. Three things worth a reviewer's attention: the base44 build auth relaxation is the one change visible outside the gate; AssetFile.contentType becoming optional and the putPresigned extraction touch the existing site deploy lane; and the full-stack Worker declaration is transport only, with no platform arm behind it yet. The .gitignore additions (pr-review-*.md/.html) are local review scratch files.


🤖 Generated by Claude | 2026-09-16 11:08 UTC | 451886c

@yurynix
yurynix force-pushed the feat/version-publish-api branch from ff1d672 to 9b9e94e Compare September 14, 2026 14:51
@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

🚀 Package Preview Available!


Install this PR's preview build with npm:

npm i @base44-preview/cli@0.1.15-pr.626.da7a7d8

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.15-pr.626.da7a7d8"

Or add it to your package.json dependencies:

{
  "dependencies": {
    "base44": "npm:@base44-preview/cli@0.1.15-pr.626.da7a7d8"
  }
}

Preview published to npm registry — try new features instantly!

yurynix and others added 3 commits September 14, 2026 18:05
Adds `base44 publish` — build, record the result as a version, serve that
version — over the platform's new version endpoints, plus the two halves as
commands of their own:

    base44 build                      run the build and print its artifact set
    base44 version create             record built output, without deploying
    base44 version deploy <id>        serve a recorded version (also a rollback)
    base44 publish                    all three, in order

Not `site deploy`. That command drives the legacy full-stack hosting lane,
whose own source says nothing there publishes, and whose `deploymentId` means a
Cloudflare script. A caller that could not tell them apart would publish by
accident, so this is a separate command with separate envelope field names —
`versionId`, `manifestHash`, `deploymentId`, `revision`.

A file is named by a FULL sha256 over its bytes. That is artifact identity, and
the platform signs it into the upload URL so S3 refuses any other body. It is
not `hashAsset`, a 32-hex truncation of sha256(app id ‖ bytes) that keys a
provider's asset cache — conflating them gives a file that uploads fine and
never dedupes, or a digest check that fails on a correct file.

Entity and agent payloads go up raw. The platform's extractor and validation are
authoritative, and the CLI's own entity schema refuses real Builder apps — which
is exactly why `site deploy` reads no resources at all.

`uploadPresignedAssets` is shared with the static lane; it now sends the
server's `x-amz-checksum-sha256` when the URL carries one. Uploads default to 8
in parallel: measured on the sandbox's pipe, 3 needed ~930s of the ~450s a build
leaves, and 16 failed a degraded pipe.

A Builder repo carries no CLI config, so one is defaulted — without writing
anything. Only a WHOLLY absent config is defaulted; a config that omits a field
said so deliberately and still gets today's error.

The failing step travels out through the `--json` envelope as `step`. A user's
build failing, a rejected artifact set and a lost publication race are three
incidents with three responses, and one exit code for all of them is how a
sandbox log stops being diagnostic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It runs a local command and reads local files — it never calls the API, so
prompting for a login was always pointless. It matters now because it is the
step a publish sandbox runs BEFORE minting its publish key: requiring auth there
either fails that exec or puts the key beside the repo-controlled code the build
runs.

The app id is still required; it is injected into the build as
VITE_BASE44_APP_ID.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`base44 version` printed a command group's help while `base44 --version`
printed the version number, two lines apart in the same `--help` output.
Anyone reaching for the CLI's version got the wrong thing.

Plural is also this CLI's own convention for a group you have many of —
`agents`, `entities`, `functions`, `secrets`, `workflows` — against the
singular groups you have exactly one of: `auth`, `site`, `sandbox`, `types`.

`create` and `deploy` keep their names. `create` is the word this CLI already
uses for bringing a new thing into existence (`base44 create`), and `deploy` is
already its verb for making something live (`functions deploy`, `site deploy`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@yurynix
yurynix force-pushed the feat/version-publish-api branch from 9b9e94e to c473ba9 Compare September 14, 2026 15:05
yurynix and others added 4 commits September 14, 2026 18:13
`base44 publish` and the `versions` group are not registered without the env
var, so with it off they are absent from `--help` and typing one is an unknown
command. Not `.hidden()`: a command that runs but is unlisted is discoverable by
anyone who reads the source, and cannot be un-shipped once someone scripts
against it. This is the shape `site deploy` already uses for the flags that only
mean something on the deployments lane.

Deliberately a second var rather than `BASE44_DEPLOYMENTS_API`. That one selects
the legacy deployments transport, and the build sandbox already sets it for that
arm; one var switching both lanes would make them impossible to roll out apart.

`base44 build` stays ungated — it is a pre-existing public command, and what
changed there is what lets the sandbox build before it holds a publish key.

Adds `docs/versions.md` alongside `docs/deployments.md`: the house rule is to
gate the lane out of the CLI's surface and document it in `docs/`, not to hide
it from the repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`--output-dir`, `--target`, `--git-hash` and `--concurrency` were defined once
per command, with the flag names, descriptions, defaults and validators copied
between `publish`, `versions create` and `versions deploy`. Nothing stopped the
copies drifting, and a user comparing two `--help` screens would have been the
one to find out.

Four plain functions returning an `Option`, composed with `addOption`. 82 lines
out, 20 in. Kept deliberately small — the CLI has no other shared option
builders, so this is not a pattern to generalize until something else needs it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`PublishTarget`, `PublishStep` and `PublishResult` are each used only inside
their own module, and `DeclareVersionResponse` was used nowhere at all — the
response is consumed through the schema's inferred type at the call site.

Exporting them put four names on the package's surface that no consumer names,
which is what `knip` flags. The alias is deleted; the other three stay where
they are, unexported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The house pattern from api-patterns.md — every response parsed through its Zod
schema, a mismatch raised as SchemaValidationError — is the whole mechanism, and
the doc now says so rather than leaving the next reader to ask.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
yurynix and others added 5 commits September 15, 2026 13:57
Every docstring and comment added by this branch, trimmed to one or two lines:
no narration of what the code already says, and no paragraph where a clause
works.

58 lines out, no behaviour change. What stays is the non-obvious — why the
digest is not `hashAsset`, why resources go up unvalidated, why the gate is a
second env var rather than the deployments one, why `build` needs no credential,
and the measurements behind the upload concurrency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`deduplicated` was derived state: the response already carries `manifest_hash`,
which IS the version's identity, so a caller asking whether a rebuild changed
anything compares that. The flag also answered two different questions — line
245 hardcoded it for "you already made this call", the other branch computed
"this content already existed" — and an existing version is not necessarily the
one being served, so it was misleading either way.

`revision` was read back from the environment AFTER publication, so a concurrent
deploy could bump it in between and the caller would be told someone else's
number. `store.publish` returns the right one; `deploy()` logs it and drops it,
because `Deployment` has no such field. Reporting it honestly would mean
widening the primitive, and nothing consumes it.

Both existed to print five words. The conditional-write fence that makes
publication safe is untouched — that is `Environment.revision` in the store,
which nothing on the wire ever needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**EMFILE at ~1.2k files.** `collectBuildOutput` opened one descriptor per file
through a bare `Promise.all`, so it died far below the 100k it advertises. Now
bounded with `p-map` at 32. The test writes its fixture sequentially and was
verified to fail with EMFILE against the unbounded version under `ulimit -n 256`
and pass against the bounded one.

**A missing output directory emitted no `step`.** Resolving the directory and
reading it ran outside `tagStep`, so the sandbox could not tell a rejected build
output from a transport failure. Producing the artifact set is part of
create_version, so it is tagged as one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Declare signed one presigned URL per declared file at a ceiling of 100 000, and
held the whole set in Redis for an hour. Measured: ~108 µs of blocking crypto per
URL and ~138 bytes per file, so the ceiling was ~10.8 s of pinned event loop and
~13.8 MB of Redis in a single request — at 600 requests a minute, because that
limit was copied from `fullstack_deploy` when the comparable endpoint,
`dist_deploy`, allows 5.

Three changes. The ceiling is 50 000, roughly 2x the largest frontend ever
measured through the build sandbox (25 500 assets) and ~5.4 s at the very top.
Declare gets its own rate limit of 10/min, since its cost scales with the app
while finalize and deploy are flat; those drop to 60/min. A test pins the CPU
bound, so raising the ceiling is a decision someone makes rather than a number
that drifts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`AssetFile.contentType` exists for the Cloudflare arm, which puts it on each
multipart part. The s3 arm echoes the type the server signed into the presigned
URL — deriving a second opinion here is how the two diverge — so the version
lane was filling the field with `application/octet-stream` purely to satisfy the
shape. It read as though it decided what the file is served as, and it did not.

The field is optional now, and the version lane omits it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread packages/cli/src/core/version/api.ts Outdated
entities: artifacts.entities,
agents: artifacts.agents,
source_commit: options.sourceCommit,
frontend_commit: options.sourceCommit,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why both?
and source_commit should be optional (for BaaS)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in fe5c313. frontend_commit is gone from the wire, the session, the contract and the log — a version describes an app at a source, and its frontend and backend are that same app. build still refuses two inputs naming different commits, which is what earns the single field. And source_commit was already optional server-side (str | None); it stays that way, so a build outside a checkout is still a complete version.

Comment thread packages/cli/src/core/version/api.ts Outdated
DeployVersionResponseSchema,
await (
await post(
`versions/${encodeURIComponent(versionId)}/deployments`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's make it an API on an Environment object
PATCH /environments/
{ version: ... }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in fe5c313 — agreed, this is the truer model. PATCH /environments/{name} { version_id } replaces POST /versions/{id}/deployments, on its own router rather than under versions, since an environment outlives every version it has pointed at. The Deployment record still exists internally (it is what the conditional pointer write points at) and comes back in the response so a log line can be correlated — but nobody asks for one.

@@ -0,0 +1,146 @@
import { createHash } from "node:crypto";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check how to reduce duplication here with the deployments api code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 3fddea1. It was worse than similar: my collector had its own copy of the globby call, the .assetsignore handling, the ALWAYS_IGNORED set and even the comment about globby's ignore/ignoreFiles footgun. Two collectors disagreeing would mean the two lanes publish different sets from the same directory. walkBuildOutput in core/site/manifest.ts is now the one rule; both callers use it and do their own per-file work. The hashes stay separate deliberately — hashAsset is a salted, truncated provider cache key, digestFile is full sha256 artifact identity.

Comment thread pr-review-626-24525.html Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove these files

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 3fddea1 — my mistake, those were local review artifacts. Untracked and added to .gitignore.

yurynix and others added 3 commits September 16, 2026 11:06
**Duplication (review comment).** `collectBuildOutput` had its own copy of the
deployments lane's globby call, its `.assetsignore` handling, the
`ALWAYS_IGNORED` set and even the comment about globby's `ignore`/`ignoreFiles`
footgun. What counts as "a file this build produced" is one rule, and two
collectors disagreeing about it would mean the two lanes publish different sets
from the same directory.

`walkBuildOutput` in `core/site/manifest.ts` is now that rule; both callers use
it and then do their own per-file work. The hashes stay separate on purpose —
`hashAsset` is a salted, truncated provider cache key, `digestFile` is full
sha256 artifact identity.

**Stray files.** `pr-review-626-24525.{md,html}` were committed by accident;
untracked and gitignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two review comments from @netanelgilad.

**`PATCH /environments/{name} { version_id }`** replaces
`POST /versions/{id}/deployments`. An environment is the thing that serves, and
it serves one version — so making a version live is editing that pointer, not
creating a resource. The `Deployment` record the switch leaves behind is how the
plane remembers what it prepared; it is returned so a caller can correlate a log
line, not asked for. `versions deploy <id>` keeps its name and now points an
environment at that version.

**One commit, not two.** `source_commit` and `frontend_commit` were both sent
and both stored, and the CLI put the same value in each. A version describes an
app at a source, and its frontend and backend are that same app. `build` still
refuses two inputs that name different commits — that check is what earns the
single field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Netanel's point on this PR: the full-stack deploy lane already knows how to read
what a framework built, and this lane should not learn it a second time. It
doesn't — `collectSiteWorker` reuses `detectFullStackArtifact`,
`resolveWranglerConfig` and `collectModules` whole. A second reader would be a
second opinion about what the build produced.

Two consequences of a Worker being present:

- The frontend comes from the Worker's own `assets.directory`, not from the
  project's `site.outputDirectory`. That is where a full-stack build puts the
  files the Worker serves, and publishing the output directory instead would
  declare the wrong frontend or none at all.
- `main` is sent as the module set names it. The platform matches the entry
  against the names it was sent, so a surviving "./" would name a module nothing
  in the set provides — resolved by identity here rather than by position.

Uploads now pair with declared files by POSITION rather than 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; resolving by path would upload one
of them under the other's checksum. `uploadPresignedAssets` keeps resolving by
path for the site lane, and the PUT they share is `putPresigned`.

Nothing deploys this yet: the platform records the Worker on the version and
refuses a full-stack app at admission. This is the transport, sent and stored.

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