Wait for web deployment propagation before failing verification
4 files+58−80/4 viewed
| 99 | 99 | ||
| 100 | 100 | After cutover, the tool checks the live build identifier and the bytes of | |
| 101 | 101 | both old and new stylesheets, client entries and route manifests. A failed | |
| 102 | − | check marks the deploy failed; it does not automatically roll back service | |
| 102 | + | check retries during propagation, bounded by seven attempts and a two-minute | |
| 103 | + | request deadline. It still requires the exact expected build and asset bytes. | |
| 104 | + | An exhausted check marks verification failed and reports the already-deployed | |
| 105 | + | Worker version; it does not automatically roll back service | |
| 103 | 106 | code or database migrations. The production workflow serializes deploys | |
| 104 | 107 | without cancelling an active release. Manual deploys must not race that | |
| 105 | 108 | workflow. Roll back only to web versions that include the archive fallback; |
| 376 | 376 | deployed = await wrangler(args, { cwd, onLine: out.line }); | |
| 377 | 377 | } | |
| 378 | 378 | if (deployed.code !== 0) throw new Error(`wrangler deploy failed:\n${wranglerError(deployed.out)}`); | |
| 379 | + | result.version = dryRun ? "(dry run)" : versionFrom(deployed.out); | |
| 379 | 380 | if (webRelease) { | |
| 380 | − | await verifyWebAssets(webRelease); | |
| 381 | + | try { | |
| 382 | + | await verifyWebAssets(webRelease, fetch, { onLine: out.line }); | |
| 383 | + | } catch (error) { | |
| 384 | + | throw new Error(`Worker deployed, but web cutover verification failed: ${error.message}`); | |
| 385 | + | } | |
| 381 | 386 | out.line("Web cutover verified: current and previous build assets respond"); | |
| 382 | 387 | } | |
| 383 | − | result.version = dryRun ? "(dry run)" : versionFrom(deployed.out); | |
| 384 | 388 | result.ok = true; | |
| 385 | 389 | if (unit.image && !dryRun) await reportContainers(unit, cwd, out); | |
| 386 | 390 | } catch (error) { |
| 179 | 179 | } | |
| 180 | 180 | ||
| 181 | 181 | /** Check both sides of cutover, including filenames no longer in the new bundle. */ | |
| 182 | − | export async function verifyWebAssets(release, fetchImpl = fetch) { | |
| 182 | + | async function verifyWebAssetsOnce(release, fetchImpl) { | |
| 183 | 183 | if (!release.origin) return; | |
| 184 | 184 | const references = await liveReferences(release.origin, fetchImpl); | |
| 185 | − | if (!references.includes(release.manifest)) throw new Error("Web cutover is not serving the expected build"); | |
| 185 | + | if (!references.includes(release.manifest)) throw new Error(`Web cutover is not serving the expected build (expected ${release.manifest}, observed ${manifestName(references) ?? "no manifest"})`); | |
| 186 | 186 | await together(release.samples, async ([key, etag]) => { | |
| 187 | 187 | const response = await fetchImpl(new URL(key.replace(/^web-assets/, ""), release.origin), { signal: AbortSignal.timeout(30_000) }); | |
| 188 | 188 | if (!response.ok || /text\/html/i.test(response.headers.get("content-type") ?? "") || checksum(Buffer.from(await response.arrayBuffer())) !== etag) throw new Error(`Web cutover asset check failed: ${key}`); | |
| 189 | 189 | }); | |
| 190 | 190 | } | |
| 191 | 191 | ||
| 192 | + | /** Wrangler finishing does not mean every edge is already serving the new version. */ | |
| 193 | + | export async function verifyWebAssets(release, fetchImpl = fetch, { | |
| 194 | + | retryWaits = [1000, 2000, 4000, 8000, 15000, 30000], | |
| 195 | + | wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), | |
| 196 | + | onLine = () => {}, | |
| 197 | + | } = {}) { | |
| 198 | + | const deadline = AbortSignal.timeout(120_000); | |
| 199 | + | const boundedFetch = (url, options = {}) => fetchImpl(url, { ...options, signal: options.signal ? AbortSignal.any([deadline, options.signal]) : deadline }); | |
| 200 | + | for (let attempt = 0; ; attempt++) { | |
| 201 | + | try { | |
| 202 | + | await verifyWebAssetsOnce(release, boundedFetch); | |
| 203 | + | return; | |
| 204 | + | } catch (error) { | |
| 205 | + | if (attempt >= retryWaits.length || deadline.aborted) throw error; | |
| 206 | + | onLine(`Waiting for web cutover (${retryWaits[attempt] / 1000}s): ${error.message}`); | |
| 207 | + | await wait(retryWaits[attempt]); | |
| 208 | + | deadline.throwIfAborted(); | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 192 | 213 | if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { | |
| 193 | 214 | const release = await publishWebAssets(process.cwd()); | |
| 194 | 215 | if (process.argv.includes("--deploy")) { | |
| 195 | 216 | const deployed = await wrangler(["deploy"], { cwd: process.cwd(), onLine: console.log }); | |
| 196 | 217 | if (deployed.code !== 0) throw new Error("Web deploy failed"); | |
| 197 | − | await verifyWebAssets(release); | |
| 218 | + | await verifyWebAssets(release, fetch, { onLine: console.log }); | |
| 198 | 219 | console.log("Web cutover verified: current and previous build assets respond"); | |
| 199 | 220 | } | |
| 200 | 221 | } |
| 70 | 70 | const release = { origin: "https://example.com", manifest: "manifest-12345678.js", samples: [["web-assets/assets/app-old12345.css", `"${createHash("md5").update(css).digest("hex")}"`]] }; | |
| 71 | 71 | const page = '<link href="/assets/app-12345678.css"><script src="/assets/manifest-12345678.js"></script>'; | |
| 72 | 72 | await verifyWebAssets(release, async (url) => new Response(new URL(url).pathname === "/" ? page : css)); | |
| 73 | − | await assert.rejects(verifyWebAssets(release, async (url) => new Response(new URL(url).pathname === "/" ? page : "error", { headers: { "content-type": "text/html" } })), /asset check failed/); | |
| 74 | − | await assert.rejects(verifyWebAssets(release, async () => new Response(page.replaceAll("12345678", "87654321"))), /expected build/); | |
| 73 | + | await assert.rejects(verifyWebAssets(release, async (url) => new Response(new URL(url).pathname === "/" ? page : "error", { headers: { "content-type": "text/html" } }), { retryWaits: [] }), /asset check failed/); | |
| 74 | + | await assert.rejects(verifyWebAssets(release, async () => new Response(page.replaceAll("12345678", "87654321")), { retryWaits: [] }), /expected build/); | |
| 75 | + | }); | |
| 76 | + | ||
| 77 | + | test("cutover tolerates propagation delay but still requires the expected build and asset bytes", async () => { | |
| 78 | + | const css = "body{}"; | |
| 79 | + | const release = { origin: "https://example.com", manifest: "manifest-12345678.js", samples: [["web-assets/assets/app-old12345.css", `"${createHash("md5").update(css).digest("hex")}"`]] }; | |
| 80 | + | const page = '/assets/app-12345678.css /assets/manifest-12345678.js'; | |
| 81 | + | const waits = []; | |
| 82 | + | const logs = []; | |
| 83 | + | let pages = 0; | |
| 84 | + | let assets = 0; | |
| 85 | + | await verifyWebAssets(release, async (url) => { | |
| 86 | + | if (new URL(url).pathname === "/") return new Response(++pages === 1 ? page.replaceAll("12345678", "87654321") : page); | |
| 87 | + | return ++assets === 1 ? new Response("not propagated", { status: 404 }) : new Response(css); | |
| 88 | + | }, { retryWaits: [1, 2], wait: async (ms) => { waits.push(ms); }, onLine: (line) => logs.push(line) }); | |
| 89 | + | assert.deepEqual(waits, [1, 2]); | |
| 90 | + | assert.equal(pages, 3); | |
| 91 | + | assert.equal(assets, 2); | |
| 92 | + | assert.match(logs[0], /expected manifest-12345678.js, observed manifest-87654321.js/); | |
| 93 | + | assert.match(logs[1], /asset check failed/); | |
| 94 | + | let attempts = 0; | |
| 95 | + | await assert.rejects(verifyWebAssets(release, async () => { attempts++; return new Response(page.replaceAll("12345678", "87654321")); }, { retryWaits: [1, 2], wait: async () => {} }), /expected build/); | |
| 96 | + | assert.equal(attempts, 3, "a persistently wrong build must fail after bounded retries"); | |
| 75 | 97 | }); | |
| 76 | 98 | ||
| 77 | 99 | test("1000 successive releases keep old tabs' assets and perform bounded incremental archive work", async () => { |
Comments
Sign in to comment.