Skip to content
Merged
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
6 changes: 3 additions & 3 deletions docs/deployments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <n>`, 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

Expand All @@ -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)

Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/core/site/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,26 +84,32 @@ export async function createDeployment(
export async function finalizeStaticDeployment(
deploymentId: string,
indexHtml: Uint8Array,
sessionId: string,
): Promise<FinalizeDeploymentResponse> {
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<FinalizeDeploymentResponse> {
const appClient = getAppClient();

let response: KyResponse;
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");
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/core/site/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/core/site/static-site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export async function deployStaticSite(options: {
const finalized = await finalizeStaticDeployment(
created.deploymentId,
new Uint8Array(indexHtml),
created.sessionId,
);

return { deploymentId: finalized.deploymentId };
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/tests/cli/static_site_deployments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/tests/cli/testkit/TestAPIServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, unknown>[] = [];

/**
* Mock POST /api/apps/{appId}/deployments. Captures the JSON request body
* in `deploymentCreateRequests`.
Expand Down Expand Up @@ -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);
},
});
Expand Down
Loading