diff --git a/AGENTS.md b/AGENTS.md index 0c75753..33398d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/app/deploy.ts b/app/deploy.ts index 9e049ee..3f59c3b 100644 --- a/app/deploy.ts +++ b/app/deploy.ts @@ -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; @@ -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}).`, }; } @@ -420,18 +429,18 @@ async function smokeApi( onPoll?: (detail: string) => void | Promise, ): Promise { 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, diff --git a/docs/README.md b/docs/README.md index 5e2db07..97dc0a3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index e068473..24d9400 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -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`;