Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/deployments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.

Expand Down Expand Up @@ -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 <id> (commit <hash>)` 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 <commit>` 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

Expand All @@ -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
16 changes: 14 additions & 2 deletions packages/cli/src/cli/commands/project/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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",
};
Expand Down Expand Up @@ -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}`);
}

Expand Down Expand Up @@ -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" };
}
Expand Down
16 changes: 12 additions & 4 deletions packages/cli/src/cli/commands/site/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
deploymentsApiEnabled,
deploySite,
deployToDeployments,
hasWorkerBuild,
MAX_UPLOAD_CONCURRENCY,
resolveGitHash,
} from "@/core/site/index.js";
Expand Down Expand Up @@ -40,20 +41,27 @@ 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) {
return { outroMessage: "Deployment cancelled" };
}
}

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);
}
Expand Down
51 changes: 44 additions & 7 deletions packages/cli/src/core/project/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 };
}
Expand Down
17 changes: 11 additions & 6 deletions packages/cli/src/core/site/deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<boolean> {
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
Expand Down
42 changes: 41 additions & 1 deletion packages/cli/tests/cli/deploy.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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.
Expand Down
31 changes: 30 additions & 1 deletion packages/cli/tests/cli/fullstack_deploy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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" });
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/tests/cli/testkit/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<string> {
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.
Expand Down
Loading