Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
32 changes: 17 additions & 15 deletions cli/commands/deploy/command.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,20 +209,20 @@ function createDeployFetchHandler(options: {
state: "ready",
manifest_version: 1,
manifest: {
schemaVersion: 1,
schemaVersion: 2,
projectId: PROJECT_ID,
releaseId: RELEASE_ID,
releaseVersion: 41,
manifestVersion: 1,
builderVersion: "test",
sourceContentHash: options.sourceDigest,
sourceContentHash: options.sourceDigest.slice("sha256:".length),
createdAt: "2026-07-10T09:20:00.000Z",
assetBasePath: "/_vf/assets",
modules: {},
css: [],
routes: {},
dependencyMode: "source",
dependencies: {},
fallback: { mode: "jit", gaps: [] },
},
});
}
Expand Down Expand Up @@ -838,13 +838,13 @@ it("uses canonical production read-back in human and JSON modes", async () => {
state: "ready",
manifest_version: 1,
manifest: {
schemaVersion: 1,
schemaVersion: 2,
projectId: PROJECT_ID,
releaseId: RELEASE_ID,
releaseVersion: 41,
manifestVersion: 1,
builderVersion: "test",
sourceContentHash: sourceDigest,
sourceContentHash: sourceDigest.slice("sha256:".length),
createdAt: "2026-07-10T09:20:00.000Z",
assetBasePath: "/_vf/assets",
modules: {
Expand All @@ -858,10 +858,11 @@ it("uses canonical production read-back in human and JSON modes", async () => {
routes: {
"/dashboard": {
modules: ["pages/dashboard.tsx"],
css: [],
},
},
dependencyMode: "source",
dependencies: {},
fallback: { mode: "jit", gaps: [] },
},
});
}
Expand Down Expand Up @@ -1208,13 +1209,13 @@ it("deploys production from a dirty worktree when the pushed digest matches the
state: "ready",
manifest_version: 1,
manifest: {
schemaVersion: 1,
schemaVersion: 2,
projectId: PROJECT_ID,
releaseId: RELEASE_ID,
releaseVersion: 41,
manifestVersion: 1,
builderVersion: "test",
sourceContentHash: sourceDigest,
sourceContentHash: sourceDigest.slice("sha256:".length),
createdAt: "2026-07-10T09:20:00.000Z",
assetBasePath: "/_vf/assets",
modules: {
Expand All @@ -1228,10 +1229,11 @@ it("deploys production from a dirty worktree when the pushed digest matches the
routes: {
"/dashboard": {
modules: ["pages/dashboard.tsx"],
css: [],
},
},
dependencyMode: "source",
dependencies: {},
fallback: { mode: "jit", gaps: [] },
},
});
}
Expand Down Expand Up @@ -1569,20 +1571,20 @@ it("uses an alternative slug when inferred first deploy project creation conflic
state: "ready",
manifest_version: 1,
manifest: {
schemaVersion: 1,
schemaVersion: 2,
projectId: PROJECT_ID,
releaseId: RELEASE_ID,
releaseVersion: 41,
manifestVersion: 1,
builderVersion: "test",
sourceContentHash: sourceDigest,
sourceContentHash: sourceDigest.slice("sha256:".length),
createdAt: "2026-07-10T09:20:00.000Z",
assetBasePath: "/_vf/assets",
modules: {},
css: [],
routes: {},
dependencyMode: "source",
dependencies: {},
fallback: { mode: "jit", gaps: [] },
},
});
}
Expand Down Expand Up @@ -1754,20 +1756,20 @@ it("collects configured app and pages routes when projectDir has a trailing slas
state: "ready",
manifest_version: 1,
manifest: {
schemaVersion: 1,
schemaVersion: 2,
projectId: PROJECT_ID,
releaseId: RELEASE_ID,
releaseVersion: 41,
manifestVersion: 1,
builderVersion: "test",
sourceContentHash: sourceDigest,
sourceContentHash: sourceDigest.slice("sha256:".length),
createdAt: "2026-07-10T09:20:00.000Z",
assetBasePath: "/_vf/assets",
modules: {},
css: [],
routes: {},
dependencyMode: "source",
dependencies: {},
fallback: { mode: "jit", gaps: [] },
},
}));
}
Expand Down
37 changes: 37 additions & 0 deletions cli/shared/deployment/control-plane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,43 @@ async function collectReleaseFiles(files: AsyncIterable<DeployReleaseFile>) {
}

describe("createHttpDeployControlPlane", () => {
it("treats only not-found release asset manifests as polling absence", async () => {
const notFound = { status: 404 };
const forbidden = { status: 403 };
let error: unknown = notFound;
const controlPlane = createHttpDeployControlPlane(
config,
mockClientReturning({
get: () => Promise.reject(error),
}),
);

assertEquals(
await controlPlane.getReleaseAssetManifest("my-project", "release-1"),
null,
);

error = forbidden;
await assertRejects(
() => controlPlane.getReleaseAssetManifest("my-project", "release-1"),
);
});

it("does not treat a successful null manifest response as polling absence", async () => {
const controlPlane = createHttpDeployControlPlane(
config,
mockClientReturning({
get: () => Promise.resolve(null),
}),
);

await assertRejects(
() => controlPlane.getReleaseAssetManifest("my-project", "release-1"),
Error,
"invalid state response",
);
});

it("normalizes legacy deployment references before returning them", async () => {
const controlPlane = createHttpDeployControlPlane(
config,
Expand Down
10 changes: 7 additions & 3 deletions cli/shared/deployment/control-plane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
normalizeControlPlane,
type ProjectTarget,
} from "../deployment-provenance.ts";
import type { ReleaseAssetManifestResponse } from "veryfront/release-assets";
import { DEPLOYMENT_ERROR } from "veryfront/errors";

export interface DeployProjectRecord {
Expand Down Expand Up @@ -65,7 +64,7 @@ export interface DeployControlPlane {
getReleaseAssetManifest(
projectSlug: string,
releaseId: string,
): Promise<ReleaseAssetManifestResponse | null>;
): Promise<unknown | null>;
createDeployment(
reference: string,
input: { releaseId: string; environmentId: string },
Expand Down Expand Up @@ -286,14 +285,19 @@ export function createHttpDeployControlPlane(
},

async getReleaseAssetManifest(projectSlug, releaseId) {
let response: unknown;
try {
return await client.get<ReleaseAssetManifestResponse>(
response = await client.get<unknown>(
`/projects/${projectSlug}/releases/${releaseId}/asset-manifest`,
);
} catch (error) {
if (getErrorStatus(error) === 404) return null;
throw error;
}
if (response === null) {
throw new Error(`Release assets for ${releaseId} returned an invalid state response`);
}
return response;
},

async createDeployment(reference, input) {
Expand Down
163 changes: 163 additions & 0 deletions cli/shared/deployment/deploy-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,28 @@ describe("DeployProject", () => {
});
});

it("propagates non-not-found route directory inspection errors before deployment mutation", async () => {
await withDeployEnv(async () => {
const { projectDir } = await createPushedProject();
const controlPlane = new InMemoryDeployControlPlane();
try {
await Deno.writeTextFile(
`${projectDir}/veryfront.config.ts`,
'export default { directories: { app: "app\\0" } };\n',
);

await assertRejects(
() => executeApply(projectDir, controlPlane),
TypeError,
);
assertEquals(controlPlane.createdReleases, []);
assertEquals(controlPlane.createdDeployments, []);
} finally {
await Deno.remove(projectDir, { recursive: true });
}
});
});

it("emits routing convergence warnings after deployment verification", async () => {
await withDeployEnv(async () => {
const { projectDir } = await createPushedProject();
Expand Down Expand Up @@ -1213,6 +1235,147 @@ describe("deployment verification", () => {
});

describe("release asset manifest", () => {
function manifestControlPlane(response: unknown): DeployControlPlane {
return helperControlPlane({
getReleaseAssetManifest: () => Promise.resolve(response),
});
}

const polling = {
expectedRoutes: ["/"],
pollIntervalMs: 100,
timeoutMs: 100,
};

it("parses a valid ready response for the requested release", async () => {
const result = await waitForReleaseAssetManifest(
manifestControlPlane(readyManifest()),
PROJECT_SLUG,
"release-1",
polling,
);

assertEquals(result.state, "ready");
assertEquals(result.manifest.releaseId, "release-1");
});

it("rejects legacy ready manifests", async () => {
const current = readyManifest();
await assertRejects(
() =>
waitForReleaseAssetManifest(
manifestControlPlane({
...current,
manifest: { ...current.manifest!, schemaVersion: 1 },
}),
PROJECT_SLUG,
"release-1",
polling,
),
Error,
"invalid or mismatched ready manifest",
);
});

it("rejects ready manifests for another release", async () => {
const current = readyManifest();
await assertRejects(
() =>
waitForReleaseAssetManifest(
manifestControlPlane({
...current,
manifest: { ...current.manifest!, releaseId: "release-other" },
}),
PROJECT_SLUG,
"release-1",
polling,
),
Error,
"invalid or mismatched ready manifest",
);
});

it("rejects accessor-backed states without executing accessors", async () => {
let accessorCalls = 0;
const hostileResponse: Record<string, unknown> = {};
Object.defineProperty(hostileResponse, "state", {
enumerable: true,
get() {
accessorCalls++;
return "ready";
},
});

await assertRejects(
() =>
waitForReleaseAssetManifest(
manifestControlPlane(hostileResponse),
PROJECT_SLUG,
"release-1",
polling,
),
Error,
"invalid state response",
);
assertEquals(accessorCalls, 0);
});

it("rejects oversized manifest states", async () => {
await assertRejects(
() =>
waitForReleaseAssetManifest(
manifestControlPlane({ state: "q".repeat(65) }),
PROJECT_SLUG,
"release-1",
polling,
),
Error,
"invalid state response",
);
});

it("fails closed for partial manifests", async () => {
await assertRejects(
() =>
waitForReleaseAssetManifest(
manifestControlPlane({ state: "partial" }),
PROJECT_SLUG,
"release-1",
polling,
),
Error,
"unsupported partial manifest",
);
});

it("fails closed for superseded manifests", async () => {
await assertRejects(
() =>
waitForReleaseAssetManifest(
manifestControlPlane({ state: "superseded" }),
PROJECT_SLUG,
"release-1",
polling,
),
Error,
"Release asset build failed",
);
});

it("fails closed for unsupported manifest states", async () => {
await assertRejects(
() =>
waitForReleaseAssetManifest(
manifestControlPlane({ state: "unexpected" }),
PROJECT_SLUG,
"release-1",
polling,
),
Error,
"unsupported state response",
);
});

it("rejects ready empty manifests before deployment", async () => {
const controlPlane = helperControlPlane({
getReleaseAssetManifest: () => Promise.resolve(readyManifest({})),
Expand Down
Loading