From 2e8248975f92f7986a3536168f9780fed4b74a3f Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Wed, 23 Sep 2026 16:12:41 +0000 Subject: [PATCH] fix(deploy): ship worker builds through the deployments API without the env gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A build that emits a worker (`.wrangler/deploy/config.json`, e.g. TanStack Start via Nitro's cloudflare_module preset) could only be shipped with the internal BASE44_DEPLOYMENTS_API=1 gate on. `base44 deploy` and an ungated `base44 site deploy` took the legacy tar.gz upload, which cannot carry a worker, and failed on the `./dist` the build never wrote — so the command the TanStack template README documents did not work. Route on the artifact instead of the gate: `hasWorkerBuild()` sends a worker build down the deployments lane from both commands, addressed by `--git-hash` when the gate registers it and by the checkout's HEAD otherwise. The gate now decides the transport for static output only, so a build with no worker behaves exactly as before. `base44 deploy` resolves the commit before pushing any resource, so a checkout with no commit fails whole rather than half-deployed, counts a worker build as a site with no `site.outputDirectory`, and prints the deployment id in place of an app URL (what production serves is still decided by the builder publish). Verified against production with the built CLI: `base44 deploy -y` on a TanStack Start app with the gate off created deployment `-git-43b290fc4d4a` and the commit host served the SSR page and its `/api/time` server route. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DZyhGxySR6qtgBF6TPfCt3 --- docs/deployments.md | 9 ++-- .../cli/src/cli/commands/project/deploy.ts | 16 +++++- packages/cli/src/cli/commands/site/deploy.ts | 16 ++++-- packages/cli/src/core/project/deploy.ts | 51 ++++++++++++++++--- packages/cli/src/core/site/deployment.ts | 17 ++++--- packages/cli/tests/cli/deploy.spec.ts | 42 ++++++++++++++- .../cli/tests/cli/fullstack_deploy.spec.ts | 31 ++++++++++- packages/cli/tests/cli/testkit/index.ts | 19 +++++++ 8 files changed, 176 insertions(+), 25 deletions(-) diff --git a/docs/deployments.md b/docs/deployments.md index 026742c4b..31f05b7a8 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -16,7 +16,7 @@ Everything downstream follows from the server's answer rather than from a decisi ## Artifact Detection -The transport is **not** picked from the artifact: `base44 site deploy` takes the deployments API whenever `deploymentsApiEnabled()` says the env gate is on (see [the gate](#the-deployments-api-lane-experimental-env-gated)), and the legacy tar.gz upload otherwise. Detection then decides only what the create call *sends* — a worker config or none — inside `deployToDeployments()`. A gated-on deploy needs no `site.outputDirectory` when the build emitted a worker, since the worker brings its own assets directory. **This lane is reachable only from `site deploy`** — `base44 deploy` ships the site through `deployAll()`'s legacy tar.gz step and has none of these flags. +A build that carries a worker always ships through the deployments API — the tar.gz upload cannot carry a worker, so `hasWorkerBuild(projectRoot)` (a `.wrangler/deploy/config.json` check) routes it there from both `base44 site deploy` and `base44 deploy`, whatever the env says. For a build with **no** worker, the transport is the env gate's call: `site deploy` takes the deployments API when `deploymentsApiEnabled()` is on (see [the gate](#the-deployments-api-lane-experimental-env-gated)) and the legacy tar.gz upload otherwise, while `base44 deploy` always takes the tar.gz step. Detection then decides only what the create call *sends* — a worker config or none — inside `deployToDeployments()`. A worker build needs no `site.outputDirectory`, since the worker brings its own assets directory; one that names a `dist` the build never wrote (the platform's default config does) is simply not read. `detectFullStackArtifact(projectRoot)` looks for exactly one thing: `.wrangler/deploy/config.json`, the redirect file emitted by `@cloudflare/vite-plugin` builds. Its `configPath` points at the generated `wrangler.json`, **relative to the redirect file's directory**. @@ -61,19 +61,19 @@ Entry = `main` from the wrangler config. With `no_bundle: true`, every file unde **Config only, no resources.** `site deploy` reads the project config through `readProjectSettings()`, not `readProjectConfig()`: it ships the built output and touches none of the project's resource files, so an invalid one must not fail it. It used to — builder apps carry entity schemas the CLI's `EntitySchema` rejects, and every publish through this lane failed with `SCHEMA_INVALID` before reaching a single asset. Commands that do consume those resources keep using `readProjectConfig()`. -`base44 deploy` is deliberately untouched by this: it deploys the project's resources and ships the site through `deployAll()`'s legacy tar.gz step, exactly as before, and neither `--git-hash` nor `--concurrency` exists on it. Adopting the lane there is a separate decision — it would need a commit address the unified deploy has no way to take. +**`base44 deploy`** deploys the project's resources and then the site: a worker build goes through `deployToDeployments()` addressed by the checkout's HEAD (resolved *before* any resource is pushed, so a checkout with no commit fails whole rather than half-deployed), and prints `Deployment (commit )` instead of an app URL; anything else ships through the legacy tar.gz step exactly as before. `hasResourcesToDeploy()` counts a worker build as a site even with no `site.outputDirectory`. Neither `--git-hash` nor `--concurrency` exists on it — a worker build here is always the commit that is checked out. The primary automated consumer is the platform's build/deploy sandbox, which runs `base44 site deploy -y --json --git-hash ` with a scoped `apps:deploy` workspace key — so the sandbox and a human at a terminal go through the exact same door. ## The Deployments-API Lane (experimental, env-gated) -The whole lane is one env var: with `BASE44_DEPLOYMENTS_API=1` (or `true`; internal gate, not user-facing yet) `site deploy` ships through the deployments API — static output and full-stack builds alike — and without it, through the legacy tar.gz upload. `deploymentsApiEnabled()` in `core/site/deployment.ts` is read in exactly two places, both in the command: the transport choice, and the registration of `--git-hash` / `--concurrency`, which exist only on the lane that can honor them (with the gate off they are unknown options, as they were before the lane existed). +The gate covers static output only: with `BASE44_DEPLOYMENTS_API=1` (or `true`; internal gate, not user-facing yet) `site deploy` ships a build with no worker through the deployments API, and without it, through the legacy tar.gz upload. A worker build takes the lane regardless. `deploymentsApiEnabled()` in `core/site/deployment.ts` is read in exactly two places, both in the command: the static transport choice, and the registration of `--git-hash` / `--concurrency`, which exist only on the lane that can honor them (with the gate off they are unknown options, as they were before the lane existed) — so an ungated worker deploy is always addressed by HEAD. The commit comes from `--git-hash` when passed, otherwise `git rev-parse HEAD`; on the lane with neither available the deploy fails asking for the flag, rather than silently falling back to the tar.gz upload — a deployment is addressed by the commit that produced it, so a build with no address could never be published. On the lane with no worker, the output directory becomes the asset manifest (index.html included — it is only ever excluded from uploads), and the create request carries **no `config`**, which the server answers with the `s3` arm. The CLI PUTs each requested file directly to its presigned URL and finalizes with the index.html bytes; today's serving keeps working because the server stores the result the way the legacy site upload does. Same flow, same commands, same `--git-hash` addressing, same `--json` output. -With the gate off, every `site deploy` takes the legacy tar.gz path unchanged — including a full-stack project, whose worker is then not shipped at all. +With the gate off, a `site deploy` with no worker takes the legacy tar.gz path unchanged. A full-stack project is never routed there: its worker would not ship at all, so it takes the lane and fails fast when no commit can be found rather than falling back. ## Testing @@ -90,3 +90,4 @@ With the gate off, every `site deploy` takes the legacy tar.gz path unchanged - **One `createDeployment()` call site** — a worker changes its parameters, never the flow around it; do not fork the code path on "full-stack vs static" - **Say "site" to the user** — never "full-stack app"; the presence of a worker is not a distinction user-facing copy makes - **Legacy behavior stays identical** when no worker artifact exists and the static gate is off — the tar.gz site path must not change +- **A worker build never takes the tar.gz path** — it cannot carry the worker; route on `hasWorkerBuild()`, not on the env gate diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index 624abeebc..bfb0be0c8 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -24,6 +24,7 @@ import type { ConnectorSyncResult, StripeSyncResult, } from "@/core/resources/connector/index.js"; +import { hasWorkerBuild } from "@/core/site/index.js"; interface DeployOptions { yes?: boolean; @@ -41,8 +42,9 @@ export async function deployAction( } const projectData = await readProjectConfig(options.projectRoot); + const workerBuild = await hasWorkerBuild(projectData.project.root); - if (!hasResourcesToDeploy(projectData)) { + if (!hasResourcesToDeploy(projectData, { workerBuild })) { return { outroMessage: "No resources found to deploy", }; @@ -91,7 +93,9 @@ export async function deployAction( if (project.visibility) { summaryLines.push(` - Visibility: ${project.visibility}`); } - if (project.site?.outputDirectory) { + if (workerBuild) { + summaryLines.push(" - Site"); + } else if (project.site?.outputDirectory) { summaryLines.push(` - Site from ${project.site.outputDirectory}`); } @@ -167,6 +171,14 @@ export async function deployAction( `${theme.styles.header("App URL")}: ${theme.colors.links(result.appUrl)}`, ); } + // No URL: what production serves is decided when the app is published from + // the builder, not by this deploy. + if (result.deployment) { + const { deploymentId, gitHash } = result.deployment; + log.message( + `${theme.styles.header("Deployment")}: ${deploymentId} (commit ${gitHash.slice(0, 12)})`, + ); + } return { outroMessage: "App deployed successfully" }; } diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index c5d2c2671..0dcca1a0c 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -13,6 +13,7 @@ import { deploymentsApiEnabled, deploySite, deployToDeployments, + hasWorkerBuild, MAX_UPLOAD_CONCURRENCY, resolveGitHash, } from "@/core/site/index.js"; @@ -40,12 +41,17 @@ async function deployAction( await maybeBuildBeforeDeploy(ctx, project, options.build); + // A worker brings its own assets directory, so site.outputDirectory (often a + // `dist` the build never wrote) does not describe what ships. + const workerBuild = await hasWorkerBuild(project.root); + if (!options.yes) { const outputDirectory = project.site?.outputDirectory; const shouldDeploy = await confirm({ - message: outputDirectory - ? `Deploy site from ${outputDirectory}?` - : "Deploy site?", + message: + outputDirectory && !workerBuild + ? `Deploy site from ${outputDirectory}?` + : "Deploy site?", }); if (isCancel(shouldDeploy) || !shouldDeploy) { @@ -53,7 +59,9 @@ async function deployAction( } } - return deploymentsApiEnabled() + // The tar.gz upload cannot carry a worker, so a worker build takes the + // deployments API whatever the gate says; the gate decides for static output. + return workerBuild || deploymentsApiEnabled() ? await deployToDeploymentsApi(ctx, project, options) : await deployTarball(ctx, project); } diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 4a6711976..a1397a966 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -21,15 +21,24 @@ import { deployFunctionsSequentially, type SingleFunctionDeployResult, } from "@/core/resources/function/deploy.js"; -import { deploySite } from "@/core/site/index.js"; +import { + deploySite, + deployToDeployments, + hasWorkerBuild, + resolveGitHash, +} from "@/core/site/index.js"; /** * Checks if there are any resources to deploy in the project. * * @param projectData - The project configuration and resources - * @returns true if there are entities, functions, agents, connectors, or a configured site to deploy + * @param options.workerBuild - Whether the build emitted a worker, which is a site even with no `site.outputDirectory` + * @returns true if there are entities, functions, agents, connectors, or a site to deploy */ -export function hasResourcesToDeploy(projectData: ProjectData): boolean { +export function hasResourcesToDeploy( + projectData: ProjectData, + options: { workerBuild?: boolean } = {}, +): boolean { const { project, entities, @@ -40,7 +49,8 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { connectors, authConfig, } = projectData; - const hasSite = Boolean(project.site?.outputDirectory); + const hasSite = + Boolean(project.site?.outputDirectory) || Boolean(options.workerBuild); const hasEntities = entities.length > 0; const hasFunctions = functions.length > 0; const hasActors = actors.length > 0; @@ -68,9 +78,14 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { */ interface DeployAllResult { /** - * The app URL if a site was deployed, undefined otherwise. + * The app URL if a site was deployed through the legacy upload, undefined otherwise. */ appUrl?: string; + /** + * The deployment a worker build shipped as, undefined otherwise. Nothing is + * live until the app is published from the builder. + */ + deployment?: { deploymentId: string; gitHash: string }; /** * Results of connector push, including any that need OAuth. */ @@ -107,6 +122,13 @@ export async function deployAll( authConfig, } = projectData; + // A worker ships only through the deployments API, which addresses the build + // by its commit. Resolved before any resource is pushed so a checkout with no + // commit fails whole rather than half-deployed. + const workerGitHash = (await hasWorkerBuild(project.root)) + ? await resolveGitHash(project.root) + : null; + await setAppVisibility(project.visibility); if (project.visibility) { options?.onVisibilitySet?.(project.visibility); @@ -160,8 +182,23 @@ export async function deployAll( ? [] : (await pushConnectors(connectors)).results; - if (project.site?.outputDirectory) { - const outputDir = resolve(project.root, project.site.outputDirectory); + const outputDir = project.site?.outputDirectory + ? resolve(project.root, project.site.outputDirectory) + : null; + + if (workerGitHash) { + const { deploymentId } = await deployToDeployments({ + projectRoot: project.root, + outputDir, + gitHash: workerGitHash, + }); + return { + deployment: { deploymentId, gitHash: workerGitHash }, + connectorResults, + }; + } + + if (outputDir) { const { appUrl } = await deploySite(outputDir); return { appUrl, connectorResults }; } diff --git a/packages/cli/src/core/site/deployment.ts b/packages/cli/src/core/site/deployment.ts index d7f354618..15b3bba36 100644 --- a/packages/cli/src/core/site/deployment.ts +++ b/packages/cli/src/core/site/deployment.ts @@ -34,13 +34,13 @@ const NO_ASSETS: AssetManifestResult = { manifest: {}, filesByHash: new Map() }; const DEPLOYMENTS_API_ENV = "BASE44_DEPLOYMENTS_API"; /** - * Internal gate for the deployments-API lane — static output and full-stack - * builds alike, neither user-facing yet. With it off `site deploy` takes the - * legacy tar.gz upload and the flags that only mean something on this lane are - * not registered at all, so the whole lane is one env var away from existing. + * Internal gate for shipping STATIC output through the deployments-API lane. + * With it off, a build with no worker takes the legacy tar.gz upload and the + * flags that only mean something on this lane are not registered at all. * - * It is the only thing that selects the transport: whether the build carries a - * worker changes what `deployToDeployments()` sends, never which flow runs. + * A build that carries a worker never consults it: the tar.gz upload cannot + * carry a worker, so `hasWorkerBuild()` sends such a build down this lane + * whatever the env says. */ export function deploymentsApiEnabled( env: NodeJS.ProcessEnv = process.env, @@ -49,6 +49,11 @@ export function deploymentsApiEnabled( return value === "1" || value === "true"; } +/** Whether the build emitted a worker, which only the deployments API can ship. */ +export async function hasWorkerBuild(projectRoot: string): Promise { + return (await detectFullStackArtifact(projectRoot)) !== null; +} + /** * Deploy a build for a commit through the deployments API: hash its static * assets into a manifest, create the deployment at the commit's address, upload diff --git a/packages/cli/tests/cli/deploy.spec.ts b/packages/cli/tests/cli/deploy.spec.ts index ab3b3313c..dcb57dfc1 100644 --- a/packages/cli/tests/cli/deploy.spec.ts +++ b/packages/cli/tests/cli/deploy.spec.ts @@ -1,5 +1,6 @@ +import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { fixture, setupCLITests } from "./testkit/index.js"; +import { fixture, gitInitWithCommit, setupCLITests } from "./testkit/index.js"; describe("deploy command (unified)", () => { const t = setupCLITests(); @@ -70,6 +71,45 @@ describe("deploy command (unified)", () => { t.expectResult(help).toNotContain("--concurrency"); }); + it("ships a worker build through the deployments API, addressed by HEAD", async () => { + // The fixture names no site.outputDirectory: the worker's own assets + // directory is the site, and the legacy tar.gz step could not carry it. + await t.givenLoggedInWithProject(fixture("fullstack-project")); + const head = await gitInitWithCommit(join(t.getTempDir(), "project")); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockDeploymentCreate({ + deployment_id: "test-app-git-abc", + session_id: "3f9a1c07b8e44d2f", + asset_uploads: null, + }); + t.api.mockDeploymentFinalize({ deployment_id: "test-app-git-abc" }); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("- Site"); + t.expectResult(result).toContain("App deployed successfully"); + t.expectResult(result).toContain( + `Deployment: test-app-git-abc (commit ${head.slice(0, 12)})`, + ); + expect(t.api.deploymentCreateRequests).toHaveLength(1); + expect(t.api.deploymentCreateRequests[0]).toMatchObject({ + git_hash: head, + config: { main: "index.js" }, + }); + expect(t.api.finalizeRequests).toHaveLength(1); + }); + + it("fails a worker build with no commit before pushing any resource", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("no git commit was found"); + expect(t.api.deploymentCreateRequests).toHaveLength(0); + }); + it("does not deploy a site for a block that never named an output directory", async () => { // A defaulted command must never turn into an upload: `./dist` here would // publish whatever happened to be built, or fail a deploy that used to pass. diff --git a/packages/cli/tests/cli/fullstack_deploy.spec.ts b/packages/cli/tests/cli/fullstack_deploy.spec.ts index 72ab59cf2..0d9ac0234 100644 --- a/packages/cli/tests/cli/fullstack_deploy.spec.ts +++ b/packages/cli/tests/cli/fullstack_deploy.spec.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { fixture, setupCLITests } from "./testkit/index.js"; +import { fixture, gitInitWithCommit, setupCLITests } from "./testkit/index.js"; function assetHash(appId: string, content: string): string { return createHash("sha256") @@ -144,6 +144,35 @@ describe("site deploy command (full-stack)", () => { }); }); + it("ships a worker build through the deployments API without the env gate, addressed by HEAD", async () => { + // The tar.gz upload cannot carry a worker, so the gate must not be what + // stands between a full-stack build and its only transport. + await t.givenLoggedInWithProject(fixture("fullstack-project")); + const head = await gitInitWithCommit(join(t.getTempDir(), "project")); + mockHappyPath(); + + const result = await t.run("site", "deploy", "-y"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Site deployed"); + t.expectResult(result).toContain(`Deployment ${DEPLOYMENT_ID}`); + expect(t.api.deploymentCreateRequests).toHaveLength(1); + const body = t.api.deploymentCreateRequests[0] as CreateBody; + expect(body.git_hash).toBe(head); + expect(body.config.main).toBe("index.js"); + expect(t.api.finalizeRequests).toHaveLength(1); + }); + + it("fails a worker build with no commit instead of falling back to the tar.gz upload", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + + const result = await t.run("site", "deploy", "-y"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("no git commit was found"); + expect(t.api.deploymentCreateRequests).toHaveLength(0); + }); + it("normalizes and requires a commit hash", async () => { await t.givenLoggedInWithProject(fixture("fullstack-project")); t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); diff --git a/packages/cli/tests/cli/testkit/index.ts b/packages/cli/tests/cli/testkit/index.ts index f4309b7ec..7075bb715 100644 --- a/packages/cli/tests/cli/testkit/index.ts +++ b/packages/cli/tests/cli/testkit/index.ts @@ -1,4 +1,5 @@ import { resolve } from "node:path"; +import { execa } from "execa"; import { afterEach, beforeEach } from "vitest"; import type { CLIResult, CLIResultMatcher } from "./CLIResultMatcher.js"; import type { RunLiveHandle } from "./CLITestkit.js"; @@ -11,6 +12,24 @@ export function fixture(name: string): string { return resolve(FIXTURES_DIR, name); } +/** Turn a copied fixture into a git checkout with one commit and return its HEAD. */ +export async function gitInitWithCommit(projectDir: string): Promise { + const git = (...args: string[]) => execa("git", args, { cwd: projectDir }); + await git("init", "-q"); + await git( + "-c", + "user.email=test@example.com", + "-c", + "user.name=Test", + "commit", + "-q", + "--allow-empty", + "-m", + "build", + ); + return (await git("rev-parse", "HEAD")).stdout.trim(); +} + /** * Test context returned by setupCLITests. * Provides a clean API for CLI integration tests.