From 220766c63ea215ea0be8e52cad4b0180a5686b64 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 04:58:01 +0000 Subject: [PATCH 1/5] site deploy: finalize through the session id the create returned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two deploys of one commit share a deployment id — it is derived from the commit — so they also share the upload session it addresses, and the loser of a create race finalizes against whatever the winner wrote last. The platform now hands back a per-attempt session_id at create; passing it to finalize resolves this run's own uploads. Both directions stay compatible: session_id is optional on the create response, so a CLI newer than its platform sends nothing and gets the commit-derived session, and an older CLI (the one pinned in today's sandbox images) is unaffected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G1E31AZvYBa86zpcYJFD1g --- packages/cli/src/core/site/api.ts | 12 +++++-- packages/cli/src/core/site/schema.ts | 5 +++ packages/cli/src/core/site/static-site.ts | 1 + .../tests/cli/static_site_deployments.spec.ts | 32 +++++++++++++++++++ .../cli/tests/cli/testkit/TestAPIServer.ts | 6 ++++ 5 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/core/site/api.ts b/packages/cli/src/core/site/api.ts index bdffd229..3ed5754f 100644 --- a/packages/cli/src/core/site/api.ts +++ b/packages/cli/src/core/site/api.ts @@ -84,18 +84,20 @@ export async function createDeployment( export async function finalizeStaticDeployment( deploymentId: string, indexHtml: Uint8Array, + sessionId?: string, ): Promise { const formData = new FormData(); formData.append( "index.html", new File([indexHtml], "index.html", { type: "text/html" }), ); - return await postFinalize(deploymentId, formData); + return await postFinalize(deploymentId, formData, sessionId); } async function postFinalize( deploymentId: string, formData: FormData, + sessionId?: string, ): Promise { const appClient = getAppClient(); @@ -103,7 +105,13 @@ async function postFinalize( try { response = await appClient.post( `deployments/${encodeURIComponent(deploymentId)}/finalize`, - { body: formData, timeout: 180_000 }, + { + body: formData, + timeout: 180_000, + // Resolves this attempt's uploads. Omitted against a platform that + // doesn't issue one, which falls back to the commit-derived session. + ...(sessionId ? { searchParams: { session_id: sessionId } } : {}), + }, ); } catch (error) { throw await ApiError.fromHttpError(error, "finalizing deployment"); diff --git a/packages/cli/src/core/site/schema.ts b/packages/cli/src/core/site/schema.ts index 9897fa2e..d3358045 100644 --- a/packages/cli/src/core/site/schema.ts +++ b/packages/cli/src/core/site/schema.ts @@ -76,6 +76,9 @@ interface S3AssetUploads { export const CreateDeploymentResponseSchema = z .object({ deployment_id: z.string(), + // Added server-side after the CLI shipped: a build pinned in an older + // sandbox image talks to a platform that may not send it yet. + session_id: z.string().optional(), asset_uploads: z .object({ type: z.literal("s3"), @@ -96,9 +99,11 @@ export const CreateDeploymentResponseSchema = z data, ): { deploymentId: string; + sessionId: string | undefined; assetUploads: S3AssetUploads | null; } => ({ deploymentId: data.deployment_id, + sessionId: data.session_id, assetUploads: data.asset_uploads == null ? null diff --git a/packages/cli/src/core/site/static-site.ts b/packages/cli/src/core/site/static-site.ts index 0ab1f306..1145394d 100644 --- a/packages/cli/src/core/site/static-site.ts +++ b/packages/cli/src/core/site/static-site.ts @@ -50,6 +50,7 @@ export async function deployStaticSite(options: { const finalized = await finalizeStaticDeployment( created.deploymentId, new Uint8Array(indexHtml), + created.sessionId, ); return { deploymentId: finalized.deploymentId }; diff --git a/packages/cli/tests/cli/static_site_deployments.spec.ts b/packages/cli/tests/cli/static_site_deployments.spec.ts index 7e0a5889..fa7249ef 100644 --- a/packages/cli/tests/cli/static_site_deployments.spec.ts +++ b/packages/cli/tests/cli/static_site_deployments.spec.ts @@ -158,6 +158,38 @@ describe("site deploy command (static site through the deployments API, env-gate ]); }); + it("finalizes through the session the create handed back", async () => { + // Two deploys of one commit share a deployment id, so the session id is + // what points finalize at this attempt's uploads rather than a sibling's. + await t.givenLoggedInWithProject(fixture("with-site")); + t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); + t.api.mockDeploymentCreate({ + deployment_id: DEPLOYMENT_ID, + session_id: "sess-abc123", + asset_uploads: null, + }); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toSucceed(); + expect(t.api.finalizeQueries[0]).toEqual({ session_id: "sess-abc123" }); + }); + + it("finalizes without a session id against a platform that issues none", async () => { + // The pinned CLI in a sandbox image predates the field, and a CLI newer + // than its platform must not send an empty one. + await t.givenLoggedInWithProject(fixture("with-site")); + t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); + mockStaticCreate([]); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toSucceed(); + expect(t.api.finalizeQueries[0]).toEqual({}); + }); + it("emits a single JSON document with --json", async () => { await t.givenLoggedInWithProject(fixture("with-site")); t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 17d7f6ac..aadc9623 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -237,6 +237,8 @@ interface CreateAppResponse { interface DeploymentCreateResponse { deployment_id: string; + /** This attempt's upload session; older platforms don't send it. */ + session_id?: string; /** Where the assets still owed should go; null/omitted = nothing owed. */ asset_uploads?: { type: "s3"; @@ -753,6 +755,9 @@ export class TestAPIServer { /** Captured multipart fields of finalize requests. */ readonly finalizeRequests: MultipartField[][] = []; + /** Captured query strings of finalize requests, for the session id. */ + readonly finalizeQueries: Record[] = []; + /** * Mock POST /api/apps/{appId}/deployments. Captures the JSON request body * in `deploymentCreateRequests`. @@ -804,6 +809,7 @@ export class TestAPIServer { this.finalizeRequests.push( parseMultipart(req.body as Buffer, req.headers["content-type"] ?? ""), ); + this.finalizeQueries.push({ ...req.query }); res.status(200).json(response); }, }); From c6463a5190fab75cd24c6c56b9a76b8b02630545 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 04:58:18 +0000 Subject: [PATCH 2/5] docs: record the finalize session id in the deployments guide Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G1E31AZvYBa86zpcYJFD1g --- docs/deployments.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/deployments.md b/docs/deployments.md index 55945209..dfa639f2 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -12,11 +12,11 @@ Deployments ship an app's built output addressed by the commit that produced it. ## API Contract (app-scoped, via `getAppClient()`) -1. `POST deployments` — JSON body: `git_hash` (required) and `asset_manifest` (`{"/path": {hash, size}}`). The response is `{deployment_id, asset_uploads}` where `deployment_id` is a handle for the rest of the flow and `asset_uploads` says where the assets still owed should go, discriminated on `type`: +1. `POST deployments` — JSON body: `git_hash` (required) and `asset_manifest` (`{"/path": {hash, size}}`). The response is `{deployment_id, session_id, asset_uploads}` where `deployment_id` is a handle for the rest of the flow, `session_id` identifies this attempt's upload session and `asset_uploads` says where the assets still owed should go, discriminated on `type`: - `{type: "s3", uploads: [{path, content_type, content_length, url}]}` — one presigned S3 PUT per asset still to upload, **always excluding `/index.html`** (finalize carries it). - `null` — nothing owed: no assets, or the build already exists (re-deploying a commit is idempotent). 2. **Asset upload — bytes never pass through the backend.** Each upload's raw file bytes are `PUT` directly to its presigned `url` with the signed `content_type` sent verbatim (the URL also signs `content_length`, so the body must be exactly the declared bytes). The URL itself is the credential, so no auth headers and never the app client. Per file: 3 attempts with exponential backoff. Concurrency defaults to `DEFAULT_UPLOAD_CONCURRENCY` (3) and is overridable with `--concurrency `, capped at `MAX_UPLOAD_CONCURRENCY` (50) because each worker holds a whole file in memory. -3. `POST deployments/{id}/finalize` — multipart with exactly one file field named `index.html` carrying the index.html bytes (contentType `text/html`) — no other fields. index.html is the sentinel that completes the deployment, which is also why it never appears in the uploads. Returns `{deployment_id}`. +3. `POST deployments/{id}/finalize?session_id={session_id}` — multipart with exactly one file field named `index.html` carrying the index.html bytes (contentType `text/html`) — no other fields. `session_id` is optional and echoes what create returned: the deployment id derives from the commit, so two deploys of one commit address the same upload session, and the query param is what resolves this attempt's own uploads instead of the other's. A platform that issues none is finalized without it. index.html is the sentinel that completes the deployment, which is also why it never appears in the uploads. Returns `{deployment_id}`. ## Asset Manifest & Hashing @@ -40,7 +40,7 @@ On the lane, the output directory becomes the asset manifest (index.html include ## Testing -`TestAPIServer` mocks: `mockDeploymentCreate` (captures the JSON body in `deploymentCreateRequests`; echoes whatever response shape you pass — `asset_uploads` is `{type: "s3", ...}` or `null`), `mockPresignedUpload(path)` (serves a presigned-style `PUT /presigned{path}` target, captures body/Content-Type/Authorization in `presignedUploadRequests`), `mockDeploymentFinalize` (captures multipart fields in `finalizeRequests`). Fixture: `tests/fixtures/with-site/` (static output dir); specs pass `--git-hash` to select the lane. Manifest and ignore-pattern unit tests live in `tests/core/site-manifest.spec.ts`. +`TestAPIServer` mocks: `mockDeploymentCreate` (captures the JSON body in `deploymentCreateRequests`; echoes whatever response shape you pass — `asset_uploads` is `{type: "s3", ...}` or `null`), `mockPresignedUpload(path)` (serves a presigned-style `PUT /presigned{path}` target, captures body/Content-Type/Authorization in `presignedUploadRequests`), `mockDeploymentFinalize` (captures multipart fields in `finalizeRequests` and query strings in `finalizeQueries`). Fixture: `tests/fixtures/with-site/` (static output dir); specs pass `--git-hash` to select the lane. Manifest and ignore-pattern unit tests live in `tests/core/site-manifest.spec.ts`. ## Rules (Deployments-Specific) From 0e6dc61f62a5a364a9cbbdd22d2a677fccff5957 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 08:12:51 +0000 Subject: [PATCH 3/5] Require the finalize session id rather than treating it as optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The optionality was protecting nobody. The static lane is gated by BASE44_STATIC_DEPLOYMENTS, which only the sandbox publish path sets, and nothing else in the platform calls the deployments API — so there is no client that both reaches this code and lacks a session. A platform that opens none is a mismatched deploy, and failing on it beats silently finalizing into the commit-derived session two siblings share. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G1E31AZvYBa86zpcYJFD1g --- docs/deployments.md | 2 +- packages/cli/src/core/site/api.ts | 10 +++---- packages/cli/src/core/site/schema.ts | 6 ++-- .../tests/cli/static_site_deployments.spec.ts | 28 ++++++++++--------- .../cli/tests/cli/testkit/TestAPIServer.ts | 3 +- 5 files changed, 25 insertions(+), 24 deletions(-) diff --git a/docs/deployments.md b/docs/deployments.md index dfa639f2..c15805a1 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -16,7 +16,7 @@ Deployments ship an app's built output addressed by the commit that produced it. - `{type: "s3", uploads: [{path, content_type, content_length, url}]}` — one presigned S3 PUT per asset still to upload, **always excluding `/index.html`** (finalize carries it). - `null` — nothing owed: no assets, or the build already exists (re-deploying a commit is idempotent). 2. **Asset upload — bytes never pass through the backend.** Each upload's raw file bytes are `PUT` directly to its presigned `url` with the signed `content_type` sent verbatim (the URL also signs `content_length`, so the body must be exactly the declared bytes). The URL itself is the credential, so no auth headers and never the app client. Per file: 3 attempts with exponential backoff. Concurrency defaults to `DEFAULT_UPLOAD_CONCURRENCY` (3) and is overridable with `--concurrency `, capped at `MAX_UPLOAD_CONCURRENCY` (50) because each worker holds a whole file in memory. -3. `POST deployments/{id}/finalize?session_id={session_id}` — multipart with exactly one file field named `index.html` carrying the index.html bytes (contentType `text/html`) — no other fields. `session_id` is optional and echoes what create returned: the deployment id derives from the commit, so two deploys of one commit address the same upload session, and the query param is what resolves this attempt's own uploads instead of the other's. A platform that issues none is finalized without it. index.html is the sentinel that completes the deployment, which is also why it never appears in the uploads. Returns `{deployment_id}`. +3. `POST deployments/{id}/finalize?session_id={session_id}` — multipart with exactly one file field named `index.html` carrying the index.html bytes (contentType `text/html`) — no other fields. `session_id` echoes what create returned: the deployment id derives from the commit, so two deploys of one commit address the same upload session, and the query param is what resolves this attempt's own uploads instead of the other's. The static lane is env-gated to the sandbox, so a platform that opens no session is a mismatched deploy and the CLI fails rather than finalizing into the shared session. index.html is the sentinel that completes the deployment, which is also why it never appears in the uploads. Returns `{deployment_id}`. ## Asset Manifest & Hashing diff --git a/packages/cli/src/core/site/api.ts b/packages/cli/src/core/site/api.ts index 3ed5754f..41719f3c 100644 --- a/packages/cli/src/core/site/api.ts +++ b/packages/cli/src/core/site/api.ts @@ -84,7 +84,7 @@ export async function createDeployment( export async function finalizeStaticDeployment( deploymentId: string, indexHtml: Uint8Array, - sessionId?: string, + sessionId: string, ): Promise { const formData = new FormData(); formData.append( @@ -97,7 +97,7 @@ export async function finalizeStaticDeployment( async function postFinalize( deploymentId: string, formData: FormData, - sessionId?: string, + sessionId: string, ): Promise { const appClient = getAppClient(); @@ -108,9 +108,9 @@ async function postFinalize( { body: formData, timeout: 180_000, - // Resolves this attempt's uploads. Omitted against a platform that - // doesn't issue one, which falls back to the commit-derived session. - ...(sessionId ? { searchParams: { session_id: sessionId } } : {}), + // Resolves this attempt's uploads rather than a concurrent deploy's: + // the deployment id is derived from the commit, so siblings share it. + searchParams: { session_id: sessionId }, }, ); } catch (error) { diff --git a/packages/cli/src/core/site/schema.ts b/packages/cli/src/core/site/schema.ts index d3358045..31342bea 100644 --- a/packages/cli/src/core/site/schema.ts +++ b/packages/cli/src/core/site/schema.ts @@ -76,9 +76,7 @@ interface S3AssetUploads { export const CreateDeploymentResponseSchema = z .object({ deployment_id: z.string(), - // Added server-side after the CLI shipped: a build pinned in an older - // sandbox image talks to a platform that may not send it yet. - session_id: z.string().optional(), + session_id: z.string(), asset_uploads: z .object({ type: z.literal("s3"), @@ -99,7 +97,7 @@ export const CreateDeploymentResponseSchema = z data, ): { deploymentId: string; - sessionId: string | undefined; + sessionId: string; assetUploads: S3AssetUploads | null; } => ({ deploymentId: data.deployment_id, diff --git a/packages/cli/tests/cli/static_site_deployments.spec.ts b/packages/cli/tests/cli/static_site_deployments.spec.ts index fa7249ef..c06ac47f 100644 --- a/packages/cli/tests/cli/static_site_deployments.spec.ts +++ b/packages/cli/tests/cli/static_site_deployments.spec.ts @@ -7,6 +7,8 @@ import { fixture, setupCLITests } from "./testkit/index.js"; /** The commit the fixture "build" came from. */ const GIT_HASH = "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c"; const DEPLOYMENT_ID = "test-app-git-0f1e2d3c4b5a"; +/** The upload session the server opens for one deploy attempt. */ +const SESSION_ID = "3f9a1c07b8e44d2f"; /** Server-side content types differ from the CLI's own mapping on purpose — * the tests prove the signed value wins. */ @@ -36,6 +38,7 @@ describe("site deploy command (static site through the deployments API, env-gate function mockStaticCreate(uploadPaths: string[]) { t.api.mockDeploymentCreate({ deployment_id: DEPLOYMENT_ID, + session_id: SESSION_ID, asset_uploads: uploadPaths.length === 0 ? null @@ -163,31 +166,30 @@ describe("site deploy command (static site through the deployments API, env-gate // what points finalize at this attempt's uploads rather than a sibling's. await t.givenLoggedInWithProject(fixture("with-site")); t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); - t.api.mockDeploymentCreate({ - deployment_id: DEPLOYMENT_ID, - session_id: "sess-abc123", - asset_uploads: null, - }); + mockStaticCreate([]); t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); t.expectResult(result).toSucceed(); - expect(t.api.finalizeQueries[0]).toEqual({ session_id: "sess-abc123" }); + expect(t.api.finalizeQueries[0]).toEqual({ session_id: SESSION_ID }); }); - it("finalizes without a session id against a platform that issues none", async () => { - // The pinned CLI in a sandbox image predates the field, and a CLI newer - // than its platform must not send an empty one. + it("fails clearly when the platform opens no upload session", async () => { + // The static lane is env-gated and only the sandbox turns it on, so a + // platform without session support is a mismatched deploy, not a client to + // be tolerated — surface it instead of finalizing into a shared session. await t.givenLoggedInWithProject(fixture("with-site")); t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); - mockStaticCreate([]); - t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + t.api.mockDeploymentCreate({ + deployment_id: DEPLOYMENT_ID, + asset_uploads: null, + }); const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); - t.expectResult(result).toSucceed(); - expect(t.api.finalizeQueries[0]).toEqual({}); + t.expectResult(result).toFail(); + expect(t.api.finalizeQueries).toHaveLength(0); }); it("emits a single JSON document with --json", async () => { diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index aadc9623..6511d62a 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -237,7 +237,8 @@ interface CreateAppResponse { interface DeploymentCreateResponse { deployment_id: string; - /** This attempt's upload session; older platforms don't send it. */ + /** This attempt's upload session. Optional here so a spec can mock a + * platform that opens none and assert the client rejects it. */ session_id?: string; /** Where the assets still owed should go; null/omitted = nothing owed. */ asset_uploads?: { From d18bc162d21dff0694652214694227cbdec1e66b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 04:46:59 +0000 Subject: [PATCH 4/5] Address review: drop the comment and the impossible no-session case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform always opens a session, so there was nothing to tolerate — the testkit type is required again and the negative spec is gone. The remaining session assertion folds into the happy-path deploy test rather than standing as a near-duplicate of it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G1E31AZvYBa86zpcYJFD1g --- docs/deployments.md | 2 +- packages/cli/src/core/site/api.ts | 2 -- .../tests/cli/static_site_deployments.spec.ts | 34 ++----------------- .../cli/tests/cli/testkit/TestAPIServer.ts | 5 ++- 4 files changed, 6 insertions(+), 37 deletions(-) diff --git a/docs/deployments.md b/docs/deployments.md index c15805a1..3c228710 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -16,7 +16,7 @@ Deployments ship an app's built output addressed by the commit that produced it. - `{type: "s3", uploads: [{path, content_type, content_length, url}]}` — one presigned S3 PUT per asset still to upload, **always excluding `/index.html`** (finalize carries it). - `null` — nothing owed: no assets, or the build already exists (re-deploying a commit is idempotent). 2. **Asset upload — bytes never pass through the backend.** Each upload's raw file bytes are `PUT` directly to its presigned `url` with the signed `content_type` sent verbatim (the URL also signs `content_length`, so the body must be exactly the declared bytes). The URL itself is the credential, so no auth headers and never the app client. Per file: 3 attempts with exponential backoff. Concurrency defaults to `DEFAULT_UPLOAD_CONCURRENCY` (3) and is overridable with `--concurrency `, capped at `MAX_UPLOAD_CONCURRENCY` (50) because each worker holds a whole file in memory. -3. `POST deployments/{id}/finalize?session_id={session_id}` — multipart with exactly one file field named `index.html` carrying the index.html bytes (contentType `text/html`) — no other fields. `session_id` echoes what create returned: the deployment id derives from the commit, so two deploys of one commit address the same upload session, and the query param is what resolves this attempt's own uploads instead of the other's. The static lane is env-gated to the sandbox, so a platform that opens no session is a mismatched deploy and the CLI fails rather than finalizing into the shared session. index.html is the sentinel that completes the deployment, which is also why it never appears in the uploads. Returns `{deployment_id}`. +3. `POST deployments/{id}/finalize?session_id={session_id}` — multipart with exactly one file field named `index.html` carrying the index.html bytes (contentType `text/html`) — no other fields. `session_id` echoes what create returned: the deployment id derives from the commit, so two deploys of one commit address the same upload session, and the query param is what resolves this attempt's own uploads instead of the other's. index.html is the sentinel that completes the deployment, which is also why it never appears in the uploads. Returns `{deployment_id}`. ## Asset Manifest & Hashing diff --git a/packages/cli/src/core/site/api.ts b/packages/cli/src/core/site/api.ts index 41719f3c..5a188274 100644 --- a/packages/cli/src/core/site/api.ts +++ b/packages/cli/src/core/site/api.ts @@ -108,8 +108,6 @@ async function postFinalize( { body: formData, timeout: 180_000, - // Resolves this attempt's uploads rather than a concurrent deploy's: - // the deployment id is derived from the commit, so siblings share it. searchParams: { session_id: sessionId }, }, ); diff --git a/packages/cli/tests/cli/static_site_deployments.spec.ts b/packages/cli/tests/cli/static_site_deployments.spec.ts index c06ac47f..aa674882 100644 --- a/packages/cli/tests/cli/static_site_deployments.spec.ts +++ b/packages/cli/tests/cli/static_site_deployments.spec.ts @@ -137,6 +137,9 @@ describe("site deploy command (static site through the deployments API, env-gate expect(styles?.authorization).toBeUndefined(); expect(t.api.finalizeRequests).toHaveLength(1); + // Finalize resolves this attempt's session, not the commit-derived one a + // concurrent deploy of the same commit shares. + expect(t.api.finalizeQueries[0]).toEqual({ session_id: SESSION_ID }); const fields = t.api.finalizeRequests[0]; expect(fields.map((f) => f.name)).toEqual(["index.html"]); expect(fields[0].data.equals(await readSiteFile("index.html"))).toBe(true); @@ -161,37 +164,6 @@ describe("site deploy command (static site through the deployments API, env-gate ]); }); - it("finalizes through the session the create handed back", async () => { - // Two deploys of one commit share a deployment id, so the session id is - // what points finalize at this attempt's uploads rather than a sibling's. - await t.givenLoggedInWithProject(fixture("with-site")); - t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); - mockStaticCreate([]); - t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); - - const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); - - t.expectResult(result).toSucceed(); - expect(t.api.finalizeQueries[0]).toEqual({ session_id: SESSION_ID }); - }); - - it("fails clearly when the platform opens no upload session", async () => { - // The static lane is env-gated and only the sandbox turns it on, so a - // platform without session support is a mismatched deploy, not a client to - // be tolerated — surface it instead of finalizing into a shared session. - await t.givenLoggedInWithProject(fixture("with-site")); - t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); - t.api.mockDeploymentCreate({ - deployment_id: DEPLOYMENT_ID, - asset_uploads: null, - }); - - const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); - - t.expectResult(result).toFail(); - expect(t.api.finalizeQueries).toHaveLength(0); - }); - it("emits a single JSON document with --json", async () => { await t.givenLoggedInWithProject(fixture("with-site")); t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 6511d62a..677b5652 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -237,9 +237,8 @@ interface CreateAppResponse { interface DeploymentCreateResponse { deployment_id: string; - /** This attempt's upload session. Optional here so a spec can mock a - * platform that opens none and assert the client rejects it. */ - session_id?: string; + /** This attempt's upload session. */ + session_id: string; /** Where the assets still owed should go; null/omitted = nothing owed. */ asset_uploads?: { type: "s3"; From c0623afccd53b521ae0441d69e1423e19090c26b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 11:56:47 +0000 Subject: [PATCH 5/5] docs: the session id addresses the attempt, not a shared session The server no longer keeps a commit-derived session alongside the per-attempt one, so two deploys of a commit share only the deployment id. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G1E31AZvYBa86zpcYJFD1g --- docs/deployments.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/deployments.md b/docs/deployments.md index 3c228710..40466b2b 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -16,7 +16,7 @@ Deployments ship an app's built output addressed by the commit that produced it. - `{type: "s3", uploads: [{path, content_type, content_length, url}]}` — one presigned S3 PUT per asset still to upload, **always excluding `/index.html`** (finalize carries it). - `null` — nothing owed: no assets, or the build already exists (re-deploying a commit is idempotent). 2. **Asset upload — bytes never pass through the backend.** Each upload's raw file bytes are `PUT` directly to its presigned `url` with the signed `content_type` sent verbatim (the URL also signs `content_length`, so the body must be exactly the declared bytes). The URL itself is the credential, so no auth headers and never the app client. Per file: 3 attempts with exponential backoff. Concurrency defaults to `DEFAULT_UPLOAD_CONCURRENCY` (3) and is overridable with `--concurrency `, capped at `MAX_UPLOAD_CONCURRENCY` (50) because each worker holds a whole file in memory. -3. `POST deployments/{id}/finalize?session_id={session_id}` — multipart with exactly one file field named `index.html` carrying the index.html bytes (contentType `text/html`) — no other fields. `session_id` echoes what create returned: the deployment id derives from the commit, so two deploys of one commit address the same upload session, and the query param is what resolves this attempt's own uploads instead of the other's. index.html is the sentinel that completes the deployment, which is also why it never appears in the uploads. Returns `{deployment_id}`. +3. `POST deployments/{id}/finalize?session_id={session_id}` — multipart with exactly one file field named `index.html` carrying the index.html bytes (contentType `text/html`) — no other fields. `session_id` echoes what create returned and is required: the deployment id derives from the commit, so two deploys of one commit share it, and the session id is what addresses this attempt's own uploads. index.html is the sentinel that completes the deployment, which is also why it never appears in the uploads. Returns `{deployment_id}`. ## Asset Manifest & Hashing