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

Web deploys no longer fail on asset filename collisions that aren't: R2's weak ETag matches the build's checksum

R2's REST gateway answers a GET with a weak ETag (W/"<md5>"), and the archive compared it with "<md5>", so any build asset already in R2 but not in the live build's archive index read as a collision. That never happened until Deploy #516's first attempt uploaded the new build's assets and then failed on an upload receipt it couldn't read (a 2xx with an empty or cut-short body), before saving the index. Every web deploy since (#516's retry, #517) stopped on a file whose stored bytes were the build's own. - The stored ETag is compared without W/ or quotes. - An upload whose receipt can't be read is confirmed by the stored object's ETag (the same Range GET), and fails only when that doesn't match. Thanks to the parallel session for the suggestion.

Checked:
node --test scripts/deploy/web-assets.test.mjs (new: weak ETags, unreadable receipt); npm run test:deploy; prettier. Confirmed by hand that R2 holds web-assets/assets/applications-CXTdk_vj.js with W/"72cd5bd3…", the md5 of the live file and of this build's.
G1t-Session:
ses_01m5gahajbescr7pk21fqg3y0q
syntaqxcommitted Parentf7fae89Browse files
2 files+42−100/2 viewed
+22−10
6767 const objectUrl = (account, bucket, key) => `https://api.cloudflare.com/client/v4/accounts/${account}/r2/buckets/${bucket}/objects/${key}`;
6868 const manifestName = (names) => names.find((name) => /^manifest-[a-zA-Z0-9_-]{8}\.js$/.test(name));
6969
70−async function confirmUpload(response, bytes, label) {
70+/** An ETag as `"<md5>"`, as `checksum` writes it: R2's REST gateway answers a GET with a weak one, `W/"<md5>"`. */
71+const strong = (etag) => (etag ? `"${etag.replace(/^W\//, "").replaceAll('"', "")}"` : null);
72+
73+/** The stored object's ETag, or null when there is none. */
74+async function storedEtag(url, headers, fetchImpl, label) {
75+ // Cloudflare's R2 REST gateway does not implement HEAD. Range avoids
76+ // downloading unchanged files; cancel the body even if Range is ignored.
77+ const existing = await fetchImpl(url, { headers: { ...headers, range: "bytes=0-0" }, signal: AbortSignal.timeout(30_000) });
78+ await existing.body?.cancel();
79+ if (existing.ok) return strong(existing.headers.get("etag"));
80+ if (existing.status !== 404) throw new Error(`Asset archive lookup failed (${existing.status}): ${label}`);
81+ return null;
82+}
83+
84+/** `stored`, when given, reads the object's ETag: a 2xx whose receipt can't be read (empty or cut short) is confirmed by the object itself. */
85+async function confirmUpload(response, bytes, label, stored) {
7186 if (!response.ok) {
7287 await response.body?.cancel();
7388 throw new Error(`Asset archive upload failed (${response.status}): ${label}`);
7691 try {
7792 receipt = await response.json();
7893 } catch {
94+ if (stored && (await stored()) === checksum(bytes)) return;
7995 throw new Error(`Invalid asset upload receipt: ${label}`);
8096 }
8197 if (receipt.success !== true || `"${receipt.result?.etag?.replaceAll('"', "")}"` !== checksum(bytes))
117133 if (index.get(key) !== expected) throw new Error(`Asset filename collision: ${name}`);
118134 return;
119135 }
120− // Cloudflare's R2 REST gateway does not implement HEAD. Range avoids
121− // downloading unchanged files; cancel the body even if Range is ignored.
122− const existing = await fetchImpl(url, { headers: { ...headers, range: "bytes=0-0" }, signal: AbortSignal.timeout(30_000) });
123− if (existing.ok) {
124− await existing.body?.cancel();
125− if (existing.headers.get("etag") !== expected) throw new Error(`Asset filename collision: ${name}`);
136+ const stored = () => storedEtag(url, headers, fetchImpl, name);
137+ const existing = await stored();
138+ if (existing) {
139+ if (existing !== expected) throw new Error(`Asset filename collision: ${name}`);
126140 index?.set(key, expected);
127141 return;
128142 }
129− await existing.body?.cancel();
130− if (existing.status !== 404) throw new Error(`Asset archive lookup failed (${existing.status}): ${name}`);
131143 const uploaded = await fetchImpl(url, {
132144 method: "PUT",
133145 headers: { ...headers, "content-type": TYPES[name.split(".").at(-1)], "cache-control": "public, max-age=31536000, immutable" },
134146 body: bytes,
135147 signal: AbortSignal.timeout(60_000),
136148 });
137− await confirmUpload(uploaded, bytes, name);
149+ await confirmUpload(uploaded, bytes, name, stored);
138150 index?.set(key, expected);
139151 }
140152
+20−0
6363 return new Response(null, { headers: { etag } });
6464 },
6565 });
66+ // R2's REST gateway answers a GET with a weak ETag: the same object.
67+ await archiveAsset({ ...args, fetchImpl: async () => new Response(null, { headers: { etag: `W/${etag}` } }) });
68+ await assert.rejects(archiveAsset({ ...args, fetchImpl: async () => new Response(null, { headers: { etag: 'W/"different"' } }) }), /collision/);
69+});
70+
71+test("an upload whose receipt can't be read is confirmed by the stored object", async () => {
72+ const args = { name: "app-12345678.css", bytes: Buffer.from("body{}"), account: "account", bucket: "bucket", headers: {} };
73+ const etag = `W/"${createHash("md5").update(args.bytes).digest("hex")}"`;
74+ const landed = (stored) => {
75+ let put = false;
76+ return async (_url, init) => {
77+ if (init.method === "PUT") {
78+ put = true;
79+ return new Response("", { status: 200 });
80+ }
81+ return put ? new Response(null, { headers: { etag: stored } }) : new Response(null, { status: 404 });
82+ };
83+ };
84+ await archiveAsset({ ...args, fetchImpl: landed(etag) });
85+ await assert.rejects(archiveAsset({ ...args, fetchImpl: landed('W/"different"') }), /Invalid asset upload receipt/);
6686 });
6787
6888 test("a missing live asset aborts the archive instead of saving an HTML error as CSS", async () => {

Comments

Sign in to comment.