Skip to content

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

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

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

Conversation

@yurynix

@yurynix yurynix commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Note

Description

Adds the versions lane: a build can be recorded as an immutable version (every frontend file plus the app's raw entity and agent payloads) and then served by pointing an environment at it. Recording and serving are separate acts, so a rollback is just a deploy of an older version rather than a second code path. The whole lane — base44 publish, base44 versions create, base44 versions deploy — is registered only when BASE44_VERSIONS_API=1, so with the gate off the commands are absent from --help and rejected as unknown. This replaces a python driver in the platform's build sandbox that used to overwrite base44/config.jsonc 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 core/version/ module

  • api.ts — the three-call flow: declare (POST versions) → upload presigned PUTs → finalize (POST versions/{session}/finalize), in that order so nothing is recorded until the bytes are in place. Uploads are paired to declared files by position (frontend files, Worker modules and Worker assets are separate namespaces, so a path can repeat), with count and order-drift guards before the first PUT. setEnvironmentVersion is one PATCH /environments/{name} carrying an idempotency key, with a keyed retry policy that also retries on client-side timeout — our own deadline expiring says nothing about whether the server committed.
  • artifacts.ts — one collectArtifacts reader for both publish and versions create; streams a full sha256 digest per file (bounded fan-out, HASH_CONCURRENCY = 32), enforces MAX_FILE_COUNT = 50_000 to match the server, and reads entities//agents/ raw (the CLI's stricter entity schema refuses real Builder apps, so the platform's validation stays authoritative).
  • publish.ts — orchestration plus the build / create_version / deploy step vocabulary; schema.ts — Zod-validated wire types with snake_case→camelCase transforms; gate.tsBASE44_VERSIONS_API, read in exactly one place (program.ts).

Full-stack (Worker) apps

  • A version declares either static_bundle or site_worker — never both. site_worker carries main (resolved to the module-set name, not the config's ./-prefixed path), the typed module set, its own assets, compatibility_date/compatibility_flags and a collapsed assets_config. No entry-file rule for a Worker: its not_found_handling answers unmatched paths, and a Worker with zero assets is a complete app.
  • Extracted resolveFullStackBuild into core/site/full-stack.ts so the deploy lane and the version lane share one reader of .wrangler/deploy/config.json.

New commands (gated)

  • base44 publish [--no-build] [--output-dir] [--target] [--git-hash] [--concurrency] — build, record, serve; --json emits {environment, versionId, manifestHash, deploymentId}.
  • base44 versions create — record built output without serving it.
  • base44 versions deploy <version-id> — point an environment at a recorded version (also the rollback). Shared flags are built once in versions/options.ts with validating arg parsers.

Supporting changes

  • core/project/target.tsresolveBuildTarget/requireOutputDir: defaults (npm run build, dist) apply only when a repo has no config at all, and nothing is written to disk. A present config wins field by field.
  • base44 build now resolves through resolveBuildTarget and requires no credential (the sandbox builds before minting a publish-capable key); a missing app id is no longer silently defaulted to "".
  • core/errors.tstagStep/stepOf: tags an error with its step via a non-enumerable symbol without wrapping it (innermost tag wins, respects non-extensible errors), and Base44Command writes it into the --json error envelope as step.
  • requireApp(ctx) middleware helper narrows the optional CLIContext.app instead of defaulting at call sites.
  • core/site/manifest.ts — extracted describeBuildOutput (one walk, .assetsignore semantics, bounded stat fan-out) and hashFileInto; the two lanes' hashes stay deliberately distinct (truncated salted upload key vs. full content digest).
  • core/site/upload.ts — extracted putPresigned, which now forwards the server's x-amz-checksum-sha256 verbatim when present (optional, since the legacy static lane's URLs pin only type and length).
  • resolveProvenanceCommit in core/site/git-hash.ts returns undefined outside a checkout — for a version the commit is provenance, not identity.
  • Docs: new docs/versions.md (linked from docs/AGENTS.md) covering the two hashes, the flow, the static/full-stack split, the Worker's identity fields, concurrency measurements, the file-count ceiling, the env gate and the sandbox's two-exec usage. .gitignore picks up local pr-review-* artifacts.

Testing

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

66 new specs across ~1,300 lines: tests/cli/publish.spec.ts (digests, raw resources, checksummed uploads, request-body shape, --json envelope, step tagging, gate on/off), tests/core/version-artifacts.spec.ts, version-api.spec.ts, version-publish.spec.ts, version-gate.spec.ts, project-target.spec.ts, site-full-stack.spec.ts, require-app.spec.ts (all under tests/core/), plus a base44 build case covering the no-credential path, TestAPIServer mocks for declare/finalize/environment (and PATCH support), and a new fixtures/publishable project.

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

  • Nothing user-facing ships here. With BASE44_VERSIONS_API unset the new commands are not registered at all. The one behaviour change outside the gate is base44 build: it no longer requires auth, and it now resolves its config through resolveBuildTarget, so a repo with no base44/ config builds with npm run build instead of erroring. What it builds and prints for a configured project is unchanged.
  • The gate is deliberately not BASE44_DEPLOYMENTS_API, which selects the legacy transport for site deploy and is already set in the sandbox — one var switching both lanes could not be rolled out apart.
  • Full-stack publish is transport-only for now: the platform records the Worker and stores its modules, but refuses a full-stack app at admission. The companion server change accepting the typed module set and assets_config is apper#24525, and the request models forbid unknown fields — so the two land together or the declare 400s.
  • Upload concurrency defaults to 8 (max 16), measured on the sandbox's pipe: at 3, a 25.5k-asset app needed ~930s of the ~450s a build leaves and was SIGKILLed mid-upload.
  • CI is green: lint, typecheck, build, knip, publish-preview, functions-compiler, claude-review and all four test matrix jobs (ubuntu/windows × npm/binary) pass. Lint keeps one pre-existing informational diagnostic in miniflare-function-manager.ts, untouched by this branch.

🤖 Generated by Claude | 2026-09-22 11:07 UTC | e28b7e8

@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.17-pr.626.a210a49

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.17-pr.626.a210a49"

Or add it to your package.json dependencies:

{
  "dependencies": {
    "base44": "npm:@base44-preview/cli@0.1.17-pr.626.a210a49"
  }
}

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 10 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>
… output dir

Two bugs from the same wrong assumption — that every build has a frontend the
platform serves from S3.

`?? requireOutputDir(target)` fired whenever a full-stack build had no assets
directory, sending the publish to the project's own `site.outputDirectory`. That
is not where a full-stack app's frontend lives, so it would have declared the
wrong files or thrown a config error on a perfectly valid app. A Worker that
answers every path itself has no assets at all, and that is now what gets
declared: nothing.

`collectBuildOutput` also required index.html unconditionally. That is the S3
serving contract — the platform answers any unmatched path with that one file —
and it does not apply when a Worker serves: the legacy deploy lane reads an
index only on its non-worker branch, because a server-rendered app renders its
own HTML. The requirement is now the caller's to state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`build` and `publish` both read `app?.id ?? ""`. Neither can reach the fallback
today — both declare `requireAppContext`, so the lifecycle has already run
`initAppContext`, which returns an app or throws on every path. The `??` was
appeasing a type, not handling a state.

It is worth removing anyway, because of what it would do if it ever fired.
`execa` extends `process.env` rather than replacing it, so `VITE_BASE44_APP_ID:
""` overrides whatever the sandbox supplied; Vite inlines that at build time
(`app-params.js` reads it); and the build, the upload and the publish all
succeed. The failure surfaces in the visitor's browser, against an app id that
addresses nothing. One `requireAppContext: false` — a plausible edit on a
command that calls no API and already sets `requireAuth: false` — is all it
takes, and `build.ts` even carried a comment saying the app id is required,
two lines above the line defaulting it.

`requireApp` narrows instead. It lives beside `ensureAppContext`, which is what
put the value there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both lanes ask the same two questions of a build directory and were answering
them separately.

`resolveFullStackBuild` is now the single answer to "what did the framework
build": the redirect file, the wrangler config, the modules, and an assets
directory confirmed to exist. The deploy lane shapes that into a Cloudflare
worker config; the versions lane hashes the modules into artifacts. Two readers
of one directory eventually give two answers, and these two lanes are meant to
agree — the full-stack path is the next one to move onto the versions API.

`describeBuildOutput` is the same for a build's files: walk under
`.assetsignore` rules, locate, size. The hash is deliberately NOT in it. A
deployment keys assets by sha256(app id ‖ bytes) truncated to 32 — a per-tenant
cache key — and a version addresses artifacts by a full sha256 signed into its
upload URL. One value for both would either never dedupe or 403 a correct file.
What they do share is the mechanism, `hashFileInto`, so neither writes the
streaming loop again.

Behaviour is unchanged; the only difference is that the asset walk now stats
with bounded concurrency rather than one file at a time, which at the lane's
own 100k ceiling it wanted anyway.

`collectSiteWorker` claimed in its docstring to reuse the full-stack lane whole.
It reused three collectors and re-did the orchestration around them. Now it
does what it said.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`static_bundle` is not a description of an app's files — the platform reads it
as "serve this from S3" and hands the prefix straight to the dist service. So a
full-stack build declaring its assets there was asking for exactly the wrong
thing: those files would be served raw, past `run_worker_first`,
`not_found_handling` and every server-rendered route the Worker owns.

They now ride as `site_worker.assets`, and the two are mutually exclusive on the
wire — a version is a static app or a full-stack one, and the server refuses a
declaration naming both. Backend functions are untouched by that rule; either
kind may have them.

`collectSiteWorker` gathers them itself, because what a Worker serves is part of
what the Worker is, and the entry-file rule does not apply to it. `publish` no
longer picks a directory: with a Worker there is simply no static bundle to
declare.

Uploads still pair by position; the order is now static frontend, then modules,
then assets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`// One commit: an app's frontend and backend are the same app at the same
source.` sat above `source_commit: options.sourceCommit`. It said nothing the
field name did not, and it was not alone.

Deleted the narration and tightened the rest to the part a reader cannot derive:
a measured number, an ordering requirement, a rejected alternative. 113 lines
out, 53 in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three findings from review, two of them real defects.

`versions create` never called `collectSiteWorker`. A full-stack app recorded
through it would declare the Worker's own files as `static_bundle` — the one
thing the platform reads as "serve from S3", past every route the Worker owns.
The either/or rule this lane just gained was enforceable on the wire and
side-stepped by a second collector. There is now one, `collectArtifacts`, and
both commands go through it.

`tagStep` called `Object.defineProperty` on whatever it caught. On a frozen or
non-extensible error that throws a TypeError from inside the catch, so the
original — message, status, request id — is lost entirely. Worse than untagged.
Guarded with `Object.isExtensible`.

`resolvePublishTarget` ran outside every tag, so an invalid `config.jsonc`
reached the envelope with no step at all. It is `create_version`, for the reason
the neighbouring test already gives: local validation is part of producing the
version.

Plus what the review found stale or dead: the upload progress claimed "N of N"
from a number a guard two lines down forces to be equal; `versions deploy`
declared `--target` inline while `publish` used the shared option the PR says is
shared; the `hashAsset` docstring had been orphaned above a different function;
two comments blamed file descriptors for a bound that is the libuv thread pool;
`git-hash` promised `null` and returns `undefined`; and three doc claims had
drifted from the code.

Also added the path assertion the review asked for: an order drift between
declared files and signed URLs now fails before the first PUT rather than as an
S3 checksum rejection part way through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
yurynix and others added 6 commits September 22, 2026 13:13
`collectSiteWorker` read both of these and then dropped them on the way into the
artifact set: each module's `type`, and the `assets` block the Worker is served
under (`run_worker_first`, `html_handling`, `not_found_handling`, `headers`,
`redirects`). `resolveFullStackBuild` already supplied them — the versions
adapter just did not carry them.

Both are behaviour. The same bytes are a string under `text` and an ArrayBuffer
under `data`; `run_worker_first` decides whether the Worker runs for a request at
all. Two builds differing only there produced identical declarations, and since
the manifest digest IS the version's identity, the platform deduped them onto one
row — after which nothing could tell them apart or roll back between them.

A config stating no setting collapses to `null` on the wire, the same as no
config at all: a bare `assets: { directory }` and an absent block describe the
identical Worker, and recording them apart would split one version in two for no
reason — the same bug in the other direction.

Needs the matching apper change, which adds `assets_config` and a per-module
`type` to the declare contract; the request models forbid unknown fields, so the
two land together.

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

Two findings from the review, both in the versions adapter.

**An existing but empty assets directory was refused.** `collectBuildOutput`
rejected a zero-length collection before the entry rule was even consulted, so
`requireEntry: false` relaxed only half of what it looked like it relaxed. A
Worker that answers every path itself SUCCEEDED with no assets directory and
FAILED with an empty one. The emptiness and entry rules are one rule — a set
nothing can enter is as broken empty as it is without its entry — so they now
travel together under `entryFile`, which only the set the platform serves passes.

**A dropped deploy response was not replayed.** Ky retries neither PATCH nor
POST by default, and rightly: a repeat is a second request unless something makes
it the same one. An idempotency key is exactly that, and the command already
sends one — so a lost response failed the command over a publication the server
had already committed, and running it again minted a NEW key and published a
second time. Retries are now bounded and attached ONLY when a key is sent;
without one a repeat is a deliberate redeploy and must stay one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`retryOnTimeout` defaults to false in ky, and the previous commit did not set
it — so the retry it added covered network errors and 5xx, and skipped the case
the idempotency key most exists for. The 180s deadline expiring says nothing
about whether the server committed; HTTP 408 in the status list is the SERVER
reporting a timeout, which is a different event.

The tests it shipped with were the other half of the miss: they mocked `patch`
itself, so they asserted the options we passed and exercised no retry at all.
Configuration that reads correctly and retries nothing is precisely the bug.

They now run a real ky client over a fake network, so ky's own retry loop
decides. Three of the five fail with `retryOnTimeout` back at false, and they
pin the whole contract: a timed-out keyed deploy is replayed and answered from
the publication the server already made, the replay carries the SAME key, a
keyless call is never replayed because a repeat there is a second publish, and
the replays are bounded at four attempts.

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

Two of the three cleanups the review left optional.

`Base44Command` is what every command runs through, and it imported
`core/version/publish.ts` for one function — `stepOf`, to put the failing step
in the `--json` envelope. A framework reaching into a feature module to read its
own output is the dependency backwards, and the next command that wants a step
tag would either import publish too or invent a second symbol.

The mechanism moves to `core/errors.ts` where the rest of the envelope lives;
the three step names stay in `publish.ts` behind a narrowed `tagStep`, so its
call sites keep their spelling check and nothing else gains a publish
vocabulary. Behaviour is unchanged, including the two details worth naming: the
innermost tag wins, and the property is defined non-enumerable.

`AssetFile.contentType` goes back to required. This branch made it optional, and
the reason given — that `putPresigned` no longer needs it — was true but beside
the point: `putPresigned` never took an `AssetFile` at all. The one producer
always sets it and the cf arm always reads it, so optional described nothing and
only moved a missing type from a compile error to an upload with no Content-Type.

Not taken: moving `resolvePublishTarget` out from under `core/version`. It is
the PUBLISH target by name and by use — three of its four callers are publish or
versions, and `build` uses it because a build is the step before a publish, as
that command's own comment says. Relocating it would need a rename away from
"publish" to stay honest, which buys less clarity than it spends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The third cleanup from the review, which I argued against and was wrong about.
I defended it by its NAME — `resolvePublishTarget` — and by counting callers,
three of four being publish or versions. Both are the wrong test. Read the
fields it resolves: root, config dir, build command, output directory, entities
dir, agents dir. Not one of them is about a version, a manifest, an artifact or
an environment. It is a thin defaults layer over `core/project`, which it
already imports, filing the gaps a Builder repo leaves.

The caller count says who happens to use it today, not what it is — and the one
caller that broke the pattern is the one that mattered: `base44 build` is not a
publish command and needed the identical resolution, so it imported the versions
lane to ask a question versions does not own.

So: `core/version/project.ts` becomes `core/project/target.ts`, and
`resolvePublishTarget`/`PublishTarget` become `resolveBuildTarget`/`BuildTarget`
— the rename is the point, not cosmetic. A name that says "publish" is what made
the wrong home look right. `build.ts` now imports no versions module at all.

Pure move plus rename: no behaviour changes, and the same tests cover it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rename left `resolvePublishTarget` and `project.ts` behind in three places,
and the module overview still listed a file that is no longer part of this lane.
Both now name `resolveBuildTarget` in `core/project/target.ts`, and the overview
says why build resolution is not here: nothing it resolves is about a version,
and `base44 build` needs the same answer without importing this lane. Its file
list now matches `ls core/version/` exactly.

Two more passages were stale for the same reason — this branch changed the thing
they describe:

- The Worker's identity is no longer just its compatibility settings. Each
  module's `type` and the whole `assets_config` are part of it too, for the
  reason the paragraph already gave about compatibility dates: the same bytes
  read as `text` instead of `data` are a different Worker. The wire shape of
  `assets_config` is spelled out, including why a block stating nothing is sent
  as `null`.
- The deploy call is now retried, and the condition is the interesting half: the
  idempotency key is what makes a repeat the same call, so the retry exists only
  when a key is sent — and it covers our own timeout, which says nothing about
  whether the server committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread packages/cli/src/core/version/api.ts Outdated
site_worker: {
main: artifacts.siteWorker.main,
modules: artifacts.siteWorker.modules.map(declaredModule),
assets: artifacts.siteWorker.assets.map(declaredFile),

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.

assets: artifacts.siteWorker.assets.map(declaredFile),
Can it actually replace static_bundle: artifacts.files.map(declaredFile),
and let the server handle that differently if it needs to, but we should have "assets" and optionally, may have a "site_worker" to come along with it.

Backend functions, although they are also workers, are different and are not related to that.

yurynix and others added 3 commits September 23, 2026 12:06
The declaration carried the app's files in two fields — `static_bundle`, or
`site_worker.assets` — and the platform kept a validator to refuse both. That
distinction exists nowhere else: the server already stores a Worker's assets
under the frontend's own artifact kind, so a static app and a full-stack app
shipping identical files share one content-addressed copy.

So the set names `assets` once, and `siteWorker` beside it says who serves them.
`collectArtifacts` no longer produces an empty array to mean "not this mode",
and the entry-file rule applies only when the platform is what serves — which is
where it belonged, since a Worker answers an unmatched path itself.

Resolving the build moved up into `collectArtifacts`, which needs the assets
directory as well as the worker; `collectSiteWorker` stays as the thin public
wrapper over a new `describeSiteWorker`. The wrangler config is still read
exactly once — a second read is a second opinion about what the framework built.

Needs the matching apper change, which is on base44-dev/apper#24525.

**A note on the test run.** `tests/cli/` spawns `bin/run.js`, so it exercises
`dist/`, not `src/` — the suite went green against a stale build while the wire
had already changed underneath it. These numbers are from a rebuilt `dist`, and
`TestAPIServer` now mirrors the new declare shape rather than the old one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`assetsConfig` was the wrong noun twice over. It is not the assets' config —
`assets` is now the set of FILES on the artifact set, so the name was the same
word for two different things, one nested inside the other. And it is not the
Worker's config either: `runWorkerFirst` decides whether the Worker is invoked at
all, which is a fact about routing rather than about either side of it.

Every field here decides what happens to a request BEFORE the Worker runs. That
is serving, so `servingConfig`, and `serving_config` on the wire.

The FIELDS stay wrangler's, name for name, because that is what the producer
read. Only the container is renamed, and the legacy deploy lane keeps
`ResolvedWranglerConfig.assetsConfig` untouched — that one is Cloudflare's block
on its way to Cloudflare.

Matching apper change on base44-dev/apper#24525; the request models forbid
unknown fields, so the two land together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	packages/cli/src/core/site/deployment.ts
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