fix(cli): zip the publish archive to the same bytes every time (#3358)

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.
This commit is contained in:
Miguel Ángel
2026-08-19 18:25:04 -04:00
committed by GitHub
parent 228eabd43f
commit d464f60b96
2 changed files with 48 additions and 0 deletions
@@ -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"), "<html></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 {
+21
View File
@@ -504,11 +504,32 @@ export function buildPublishFileMap(projectDir: string): Map<string, Buffer> {
/** 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<string, Buffer>): 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(),