From d464f60b9600485cd97c537338e25b7f77f9ca90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 19 Aug 2026 18:25:04 -0400 Subject: [PATCH] fix(cli): zip the publish archive to the same bytes every time (#3358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adm-zip stamps every entry with `new Date()` as it is constructed, and a ZIP timestamp resolves to two seconds — so archiving identical content twice gave different bytes whenever the two runs landed either side of a boundary. The archive's digest was a function of the clock rather than of its contents, which is backwards for something `cloud render` uploads and addresses by content. It surfaced as a CI flake: publishProject.test.ts asserts two archives built back to back are byte-identical, and both sides are the same expression, so the only way it can fail is non-determinism. The window is narrow, which is why it survived since July and why re-running always cleared it. Entry times are now fixed. Built from local components deliberately: `fromDate2DOS` reads getFullYear/getMonth/getHours, so a fixed instant would still encode differently per timezone — verified identical bytes under UTC, America/Los_Angeles and Asia/Kolkata. The new test moves the clock across a boundary, which is what reproduces it; back-to-back builds land in the same bucket almost always, which is exactly how it hid. --- packages/cli/src/utils/publishProject.test.ts | 27 +++++++++++++++++++ packages/cli/src/utils/publishProject.ts | 21 +++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/packages/cli/src/utils/publishProject.test.ts b/packages/cli/src/utils/publishProject.test.ts index 2b6e62979..c3a314821 100644 --- a/packages/cli/src/utils/publishProject.test.ts +++ b/packages/cli/src/utils/publishProject.test.ts @@ -304,6 +304,33 @@ describe("createPublishArchive (U6 cloud-render regression guard)", () => { // `createPublishArchive` is exactly the thin composition of // `buildPublishFileMap` + `zipPublishFileMap` with no baking hook, and that // a local video asset's original bytes/HTML pass through unmodified. + it("zips the same files to the same bytes across a two-second boundary", () => { + // The flake this pins: adm-zip stamps each entry with `new Date()` as it is + // constructed, and a ZIP timestamp resolves to two seconds — so two runs + // either side of a boundary produced different bytes for identical content. + // The byte-identity assertion below could only ever fail this way, and did, + // rarely enough to survive since July. + // + // Moving the clock is what reproduces it: back-to-back builds land in the + // same bucket almost always, which is exactly why it hid. + const dir = makeProjectDir(); + try { + writeFileSync(join(dir, "index.html"), "", "utf-8"); + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const first = createPublishArchive(dir); + vi.setSystemTime(new Date("2026-01-01T00:00:03.000Z")); + const second = createPublishArchive(dir); + expect(first.buffer.equals(second.buffer)).toBe(true); + } finally { + vi.useRealTimers(); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("keeps a source video byte-identical and excludes proxies from the cloud-render archive", () => { const dir = makeProjectDir(); try { diff --git a/packages/cli/src/utils/publishProject.ts b/packages/cli/src/utils/publishProject.ts index c383b1f15..7d271a934 100644 --- a/packages/cli/src/utils/publishProject.ts +++ b/packages/cli/src/utils/publishProject.ts @@ -504,11 +504,32 @@ export function buildPublishFileMap(projectDir: string): Map { /** Zip an in-memory archive file map (from `buildPublishFileMap`, optionally * transformed in between, e.g. by proxy baking) into the final archive buffer. */ +/** + * Fixed entry timestamp, so the same files always zip to the same bytes. + * + * adm-zip stamps every entry with `new Date()` as it is constructed, and a ZIP + * timestamp has two-second granularity — so archiving identical content twice + * produced different bytes whenever the two runs landed either side of a + * two-second boundary. That makes the archive's digest a function of the clock + * rather than of its contents, which is the opposite of what a + * content-addressed artifact needs. + * + * Built from local components on purpose: `fromDate2DOS` reads `getFullYear`, + * `getMonth`, `getHours` and friends, so a fixed *instant* would still encode + * differently in different timezones. Fixing the wall-clock reading is what + * makes the bytes match across machines. 1980-01-01 is the earliest a DOS + * timestamp can represent. + */ +const ARCHIVE_ENTRY_TIME = new Date(1980, 0, 1, 0, 0, 0, 0); + export function zipPublishFileMap(fileContents: Map): PublishArchiveResult { const archive = new AdmZip(); for (const [filePath, content] of fileContents) { archive.addFile(filePath, content); } + for (const entry of archive.getEntries()) { + entry.header.time = ARCHIVE_ENTRY_TIME; + } return { buffer: archive.toBuffer(),