diff --git a/docs/deployments.md b/docs/deployments.md index 55945209..40466b2b 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` 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 @@ -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) diff --git a/packages/cli/src/core/site/api.ts b/packages/cli/src/core/site/api.ts index bdffd229..5a188274 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,11 @@ async function postFinalize( try { response = await appClient.post( `deployments/${encodeURIComponent(deploymentId)}/finalize`, - { body: formData, timeout: 180_000 }, + { + body: formData, + timeout: 180_000, + 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..31342bea 100644 --- a/packages/cli/src/core/site/schema.ts +++ b/packages/cli/src/core/site/schema.ts @@ -76,6 +76,7 @@ interface S3AssetUploads { export const CreateDeploymentResponseSchema = z .object({ deployment_id: z.string(), + session_id: z.string(), asset_uploads: z .object({ type: z.literal("s3"), @@ -96,9 +97,11 @@ export const CreateDeploymentResponseSchema = z data, ): { deploymentId: string; + sessionId: string; 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..aa674882 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 @@ -134,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); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 17d7f6ac..677b5652 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. */ + 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); }, });