Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Wait for web deployment propagation before failing verification

syntaqxcommitted Parentfe7dbfeBrowse files
4 files+58−80/4 viewed
+4−1
9999
100100 After cutover, the tool checks the live build identifier and the bytes of
101101 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
103106 code or database migrations. The production workflow serializes deploys
104107 without cancelling an active release. Manual deploys must not race that
105108 workflow. Roll back only to web versions that include the archive fallback;
+6−2
376376 deployed = await wrangler(args, { cwd, onLine: out.line });
377377 }
378378 if (deployed.code !== 0) throw new Error(`wrangler deploy failed:\n${wranglerError(deployed.out)}`);
379+ result.version = dryRun ? "(dry run)" : versionFrom(deployed.out);
379380 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+ }
381386 out.line("Web cutover verified: current and previous build assets respond");
382387 }
383− result.version = dryRun ? "(dry run)" : versionFrom(deployed.out);
384388 result.ok = true;
385389 if (unit.image && !dryRun) await reportContainers(unit, cwd, out);
386390 } catch (error) {
+24−3
179179 }
180180
181181 /** 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) {
183183 if (!release.origin) return;
184184 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"})`);
186186 await together(release.samples, async ([key, etag]) => {
187187 const response = await fetchImpl(new URL(key.replace(/^web-assets/, ""), release.origin), { signal: AbortSignal.timeout(30_000) });
188188 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}`);
189189 });
190190 }
191191
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+
192213 if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
193214 const release = await publishWebAssets(process.cwd());
194215 if (process.argv.includes("--deploy")) {
195216 const deployed = await wrangler(["deploy"], { cwd: process.cwd(), onLine: console.log });
196217 if (deployed.code !== 0) throw new Error("Web deploy failed");
197− await verifyWebAssets(release);
218+ await verifyWebAssets(release, fetch, { onLine: console.log });
198219 console.log("Web cutover verified: current and previous build assets respond");
199220 }
200221 }
+24−2
7070 const release = { origin: "https://example.com", manifest: "manifest-12345678.js", samples: [["web-assets/assets/app-old12345.css", `"${createHash("md5").update(css).digest("hex")}"`]] };
7171 const page = '<link href="/assets/app-12345678.css"><script src="/assets/manifest-12345678.js"></script>';
7272 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");
7597 });
7698
7799 test("1000 successive releases keep old tabs' assets and perform bounded incremental archive work", async () => {

Comments

Sign in to comment.