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
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,9 +443,12 @@ next command that has the token.
Deployment progress distinguishes `waiting_for_services`,
`waiting_for_deploys`, and `smoke_testing`. Render reporting `live` is not
terminal: the public URL, data endpoint, and CORS checks must pass before the
run becomes `deployed`. The storefront check must also pass: the HTML of the
storefront, or a script that it loads, must contain the public hostname of the
API. The API checks cannot see the hostname that a browser uses.
run becomes `deployed`. Render can route a new hostname some minutes after its
first deploy is live, and until then the URL gives 404. So the first check of
each public URL waits `ROUTE_TIMEOUT_MS`, not the shorter `DATA_TIMEOUT_MS`.
The storefront check must also pass: the HTML of the storefront, or a script
that it loads, must contain the public hostname of the API. The API checks
cannot see the hostname that a browser uses.

Each stage in `RUN_STAGES` has an item in the stage list of
`public/index.html`, in the same order, with a tooltip that tells what the
Expand Down
21 changes: 15 additions & 6 deletions app/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,16 @@ import { verifyAppTask } from "./verify.js";
const MAX_DEPLOY_REPAIR_ROUNDS = 2;
const SERVICE_TIMEOUT_MS = 6 * 60 * 1000;
const DEPLOY_TIMEOUT_MS = 15 * 60 * 1000;
const SITE_TIMEOUT_MS = 3 * 60 * 1000;
/**
* Render can route a new onrender.com hostname some minutes after the first
* deploy of its service is live. Until then, the hostname gives 404. On
* 2026-09-24, a new static site gave 404 for more than 3 minutes after its
* deploy was live, and it gave 200 before 10 minutes. So the first check of
* each public URL waits this long.
*/
const ROUTE_TIMEOUT_MS = 10 * 60 * 1000;
/** The data check starts after the health check of the same host passed. */
const DATA_TIMEOUT_MS = 3 * 60 * 1000;
/** The Render Dashboard shows each task input, so keep the logs in it small. */
const MAX_DEPLOY_LOG_CHARS = 4_000;

Expand Down Expand Up @@ -279,13 +288,13 @@ export async function awaitDeployment(
};
}

const site = await waitForHttpOk(webUrl, SITE_TIMEOUT_MS, {
const site = await waitForHttpOk(webUrl, ROUTE_TIMEOUT_MS, {
onPoll: heartbeat,
});
if (!site.ok) {
return {
status: "deploy_failed",
summary: `${webUrl} did not return a successful response (last status ${site.status}).`,
summary: `${webUrl} did not return a successful response in ${ROUTE_TIMEOUT_MS / 60000} minutes (last status ${site.status}).`,
};
}

Expand Down Expand Up @@ -420,18 +429,18 @@ async function smokeApi(
onPoll?: (detail: string) => void | Promise<void>,
): Promise<string | null> {
const healthPath = service.healthCheckPath ?? "/health";
const health = await waitForHttpOk(`${apiUrl}${healthPath}`, SITE_TIMEOUT_MS, {
const health = await waitForHttpOk(`${apiUrl}${healthPath}`, ROUTE_TIMEOUT_MS, {
onPoll,
});
if (!health.ok) {
return `${apiUrl}${healthPath} did not answer (last status ${health.status}).`;
return `${apiUrl}${healthPath} did not answer in ${ROUTE_TIMEOUT_MS / 60000} minutes (last status ${health.status}).`;
}

if (!service.dataCheckPath) return null;

const data = await waitForHttpOk(
`${apiUrl}${service.dataCheckPath}`,
SITE_TIMEOUT_MS,
DATA_TIMEOUT_MS,
{
headers: webOrigin ? { origin: webOrigin } : undefined,
onPoll,
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ Copy `.env.example` when setting up locally.
- `verifying`: `verify-app` builds, boots, and queries the app in the sandbox. If it does not finish in 30 minutes, for example because a build command does not exit, the run ends as `failed`.
- `waiting_for_services`: Blueprint sync has not created every expected service.
- `waiting_for_deploys`: at least one Render deploy has not reached a terminal state. After a repair push, it can also mean that Render has not started the new deploy of a failed service yet. If Render does not start one in 15 minutes, the run ends as `deploy_failed`.
- `smoke_testing`: deploys are live; public URL, API hostname, data, or CORS checks are still running.
- `smoke_testing`: deploys are live; public URL, API hostname, data, or CORS checks are still running. Render can route a new hostname some minutes after its first deploy is live, and until then the URL gives 404. So the first check of the site and of the API health endpoint waits up to 10 minutes, and the data check waits up to 3 minutes.
- `done`: the stored run is terminal.

All deploy and HTTP waits have deadlines and heartbeat the database. The
Expand Down
72 changes: 72 additions & 0 deletions tests/workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,78 @@ describe("awaitDeployment Blueprint lookup", () => {
});
});

/**
* Render can route a new hostname some minutes after its first deploy is
* live. A run once ended as deploy_failed, because its site gave 404 for the
* 3 minutes of the check, and the site was live.
*/
describe("awaitDeployment smoke checks", () => {
const WEB_URL = "https://acme-demo-shop-web.onrender.com";

beforeEach(() => {
vi.clearAllMocks();
newBuild();
mocks.findBlueprint.mockResolvedValue({
id: "exs-test",
name: "factory",
status: "synced",
autoSync: true,
repo: "https://github.com/acme/apps",
branch: "main",
path: "render.yaml",
});
mocks.waitForServices.mockImplementation(
async (_workspaceId: string, names: string[]) =>
new Map(
names.map((name) => [
name,
{ id: `srv-${name}`, name, url: `https://${name}.onrender.com` },
]),
),
);
mocks.waitForDeploy.mockResolvedValue(LIVE);
mocks.waitForHttpOk.mockResolvedValue({
ok: true,
status: 200,
body: '[{"id":1}]',
headers: new Headers({ "access-control-allow-origin": "*" }),
});
mocks.pageContains.mockResolvedValue(true);
});

it("waits longer for the first answer of each hostname than for the data", async () => {
const result = await deploy();

expect(result.status, result.summary).toBe("deployed");
expect(
mocks.waitForHttpOk.mock.calls.map(([url, timeoutMs]) => [
url,
timeoutMs / 60_000,
]),
).toEqual([
[WEB_URL, 10],
[`${API_URL}/health`, 10],
[`${API_URL}/api/items`, 3],
]);
});

it("tells how long it waited for a site that did not answer", async () => {
mocks.waitForHttpOk.mockResolvedValueOnce({
ok: false,
status: 404,
body: "",
headers: new Headers(),
});

const result = await deploy();

expect(result).toEqual({
status: "deploy_failed",
summary: `${WEB_URL} did not return a successful response in 10 minutes (last status 404).`,
});
});
});

/* ── Delete ───────────────────────────────────────────────────────────── */

const APP_SOURCE = `${APP_DIR}/web/index.html`;
Expand Down
Loading