From 9684dd0d640377dce1677d9eea4166c380881e34 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 20 Sep 2026 23:30:49 -0700 Subject: [PATCH 1/5] fix(ci): refresh changed sources after cache restore Fallback target archives can be newer than a fresh checkout, while the source timestamp snapshot deliberately restores only exact source matches. Touch changed, new, and mode-mismatched inputs after archive restoration so Cargo cannot accept stale fingerprints without invoking rustc. Cover the original failure with a real Cargo build that first reproduces the false Fresh result, then proves timestamp restoration forces recompilation. Co-Authored-By: Nova (GPT-6) --- .../rust-build-cache/source-mtimes.mjs | 29 ++++++++++--- .../rust-build-cache/source-mtimes.test.mjs | 42 +++++++++++++++++-- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/.github/actions/rust-build-cache/source-mtimes.mjs b/.github/actions/rust-build-cache/source-mtimes.mjs index 31fbf8d0e..a81ebb301 100644 --- a/.github/actions/rust-build-cache/source-mtimes.mjs +++ b/.github/actions/rust-build-cache/source-mtimes.mjs @@ -52,18 +52,35 @@ export function restoreSourceTimes(roots, source) { if (snapshot.version !== 2 || snapshot.sources?.length !== roots.length || snapshot.symlinks?.length !== roots.length) return 0; let restored = 0; + const refreshedAt = new Date(); roots.forEach((root, index) => { const files = sourceFiles(root); // Cargo follows symlinks when checking dependency mtimes. Restoring a new // target's old timestamp could otherwise hide changed include_str! bytes. - if (JSON.stringify(sourceSymlinks(root, files)) !== JSON.stringify(snapshot.symlinks[index])) return; + if (JSON.stringify(sourceSymlinks(root, files)) !== JSON.stringify(snapshot.symlinks[index])) { + for (const file of files) { + const absolute = regularSource(root, file); + if (!absolute) continue; + const stat = statSync(absolute); + utimesSync(absolute, stat.atime, refreshedAt); + } + return; + } const tracked = new Set(files); - for (const file of snapshot.sources[index]) { - if (!tracked.has(file.path) || !Number.isFinite(file.mtimeMs)) continue; - const absolute = regularSource(root, file.path); - if (!absolute || contentHash(absolute) !== file.hash) continue; + const previous = new Map(snapshot.sources[index].map((file) => [file.path, file])); + for (const relative of tracked) { + const file = previous.get(relative); + const absolute = regularSource(root, relative); + if (!absolute) continue; const stat = statSync(absolute); - if ((stat.mode & 0o111) !== file.executable) continue; + if (!file || !Number.isFinite(file.mtimeMs) + || contentHash(absolute) !== file.hash || (stat.mode & 0o111) !== file.executable) { + // A fallback target archive can be newer than a fresh checkout. Make + // changed and newly tracked inputs newer than the restored artifacts + // so Cargo cannot accept stale fingerprints before rustc runs. + utimesSync(absolute, stat.atime, refreshedAt); + continue; + } utimesSync(absolute, stat.atime, file.mtimeMs / 1000); restored += 1; } diff --git a/.github/actions/rust-build-cache/source-mtimes.test.mjs b/.github/actions/rust-build-cache/source-mtimes.test.mjs index a2debd8ef..a29a19fa9 100644 --- a/.github/actions/rust-build-cache/source-mtimes.test.mjs +++ b/.github/actions/rust-build-cache/source-mtimes.test.mjs @@ -65,7 +65,7 @@ test('content-qualified timestamps retain Cargo freshness but edits and removals writeFileSync(changed, 'pub const VALUE: u32 = 2;\n'); const changedTime = statSync(changed).mtimeMs; assert.equal(restoreSourceTimes([root], snapshot), 2); - assert.equal(statSync(changed).mtimeMs, changedTime); + assert.ok(statSync(changed).mtimeMs > changedTime); result = build(root); assert.equal(result.status, 0, result.stderr); assert.match(result.stderr, /Compiling freshness_fixture/); @@ -170,7 +170,36 @@ for (const directory of [false, true]) { }); } -test('executable mode changes retain checkout timestamps and legacy snapshots are ignored', () => { +test('changed source cannot look older than a restored Cargo artifact', () => { + const root = mkdtempSync(path.join(tmpdir(), 'hypercolor-stale-cargo-')); + try { + fixture(root); + let result = build(root); + assert.equal(result.status, 0, result.stderr); + const snapshot = path.join(root, 'source-times.json'); + captureSourceTimes([root], snapshot); + const recorded = JSON.parse(readFileSync(snapshot, 'utf8')) + .sources[0].find((file) => file.path === 'src/value.rs').mtimeMs; + const changed = path.join(root, 'src/value.rs'); + writeFileSync(changed, 'pub const VALUE: u32 = 2;\n'); + utimesSync(changed, recorded / 1000, recorded / 1000); + + result = build(root); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /Fresh freshness_fixture/); + assert.doesNotMatch(result.stderr, /Compiling freshness_fixture/); + + assert.equal(restoreSourceTimes([root], snapshot), 2); + assert.ok(statSync(changed).mtimeMs > recorded); + result = build(root); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /Compiling freshness_fixture/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('new files and executable mode changes receive fresh timestamps', () => { const root = mkdtempSync(path.join(tmpdir(), 'hypercolor-source-mode-')); try { fixture(root); @@ -181,7 +210,14 @@ test('executable mode changes retain checkout timestamps and legacy snapshots ar utimesSync(file, 2000, 2000); chmodSync(file, 0o755); assert.equal(restoreSourceTimes([root], snapshot), 2); - assert.equal(statSync(file).mtimeMs, 2000000); + assert.ok(statSync(file).mtimeMs > 2000000); + + const added = path.join(root, 'src/added.rs'); + writeFileSync(added, 'pub const ADDED: bool = true;\n'); + git(root, 'add', 'src/added.rs'); + utimesSync(added, 1000, 1000); + assert.equal(restoreSourceTimes([root], snapshot), 2); + assert.ok(statSync(added).mtimeMs > 1000000); const legacy = JSON.parse(readFileSync(snapshot, 'utf8')); legacy.version = 1; From 3b4cb251b5ed8df7bd21b92062f2961362b32ca8 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 20 Sep 2026 23:34:01 -0700 Subject: [PATCH 2/5] fix(ci): invalidate artifacts without source metadata Refresh every tracked source when a restored cache lacks a usable timestamp snapshot so Cargo cannot accept stale fingerprints from legacy archives. Use a monotonic whole-second timestamp for refreshed inputs to avoid moving submillisecond filesystem timestamps backward. Real Cargo fixtures cover missing, malformed, and legacy metadata alongside changed sources. Co-Authored-By: Sol (GPT-5.6) --- .../rust-build-cache/source-mtimes.mjs | 42 ++++++++++++++++--- .../rust-build-cache/source-mtimes.test.mjs | 31 ++++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/.github/actions/rust-build-cache/source-mtimes.mjs b/.github/actions/rust-build-cache/source-mtimes.mjs index a81ebb301..8e5b0026d 100644 --- a/.github/actions/rust-build-cache/source-mtimes.mjs +++ b/.github/actions/rust-build-cache/source-mtimes.mjs @@ -46,13 +46,43 @@ export function captureSourceTimes(roots, destination) { return sources.reduce((total, files) => total + files.length, 0); } +function refreshedTimestamp(current, wallClockFloor) { + return Math.max(wallClockFloor, Math.ceil(current.mtimeMs / 1000) + 1); +} + +function refreshSourceTimes(roots, wallClockFloor) { + for (const root of roots) { + for (const relative of sourceFiles(root)) { + const absolute = regularSource(root, relative); + if (!absolute) continue; + const stat = statSync(absolute); + utimesSync(absolute, stat.atime, refreshedTimestamp(stat, wallClockFloor)); + } + } +} + export function restoreSourceTimes(roots, source) { - if (!existsSync(source)) return 0; - const snapshot = JSON.parse(readFileSync(source, 'utf8')); + // Use a whole second beyond the current wall clock and the input's existing + // mtime. Date's millisecond precision can otherwise move a freshly written + // file backward on filesystems that retain submillisecond timestamps. + const wallClockFloor = Math.ceil(Date.now() / 1000) + 1; + if (!existsSync(source)) { + refreshSourceTimes(roots, wallClockFloor); + return 0; + } + let snapshot; + try { + snapshot = JSON.parse(readFileSync(source, 'utf8')); + } catch { + refreshSourceTimes(roots, wallClockFloor); + return 0; + } if (snapshot.version !== 2 || snapshot.sources?.length !== roots.length - || snapshot.symlinks?.length !== roots.length) return 0; + || snapshot.symlinks?.length !== roots.length) { + refreshSourceTimes(roots, wallClockFloor); + return 0; + } let restored = 0; - const refreshedAt = new Date(); roots.forEach((root, index) => { const files = sourceFiles(root); // Cargo follows symlinks when checking dependency mtimes. Restoring a new @@ -62,7 +92,7 @@ export function restoreSourceTimes(roots, source) { const absolute = regularSource(root, file); if (!absolute) continue; const stat = statSync(absolute); - utimesSync(absolute, stat.atime, refreshedAt); + utimesSync(absolute, stat.atime, refreshedTimestamp(stat, wallClockFloor)); } return; } @@ -78,7 +108,7 @@ export function restoreSourceTimes(roots, source) { // A fallback target archive can be newer than a fresh checkout. Make // changed and newly tracked inputs newer than the restored artifacts // so Cargo cannot accept stale fingerprints before rustc runs. - utimesSync(absolute, stat.atime, refreshedAt); + utimesSync(absolute, stat.atime, refreshedTimestamp(stat, wallClockFloor)); continue; } utimesSync(absolute, stat.atime, file.mtimeMs / 1000); diff --git a/.github/actions/rust-build-cache/source-mtimes.test.mjs b/.github/actions/rust-build-cache/source-mtimes.test.mjs index a29a19fa9..871696406 100644 --- a/.github/actions/rust-build-cache/source-mtimes.test.mjs +++ b/.github/actions/rust-build-cache/source-mtimes.test.mjs @@ -63,6 +63,8 @@ test('content-qualified timestamps retain Cargo freshness but edits and removals const changed = path.join(root, 'src/value.rs'); writeFileSync(changed, 'pub const VALUE: u32 = 2;\n'); + const submillisecondFuture = Date.now() / 1000 + 0.9999; + utimesSync(changed, submillisecondFuture, submillisecondFuture); const changedTime = statSync(changed).mtimeMs; assert.equal(restoreSourceTimes([root], snapshot), 2); assert.ok(statSync(changed).mtimeMs > changedTime); @@ -223,8 +225,37 @@ test('new files and executable mode changes receive fresh timestamps', () => { legacy.version = 1; delete legacy.symlinks; writeFileSync(snapshot, JSON.stringify(legacy)); + utimesSync(file, 1000, 1000); assert.equal(restoreSourceTimes([root], snapshot), 0); + assert.ok(statSync(file).mtimeMs > 1000000); } finally { rmSync(root, { recursive: true, force: true }); } }); + +for (const unavailable of ['missing', 'invalid']) { + test(`${unavailable} timestamp metadata invalidates restored Cargo artifacts`, () => { + const root = mkdtempSync(path.join(tmpdir(), 'hypercolor-missing-source-times-')); + try { + fixture(root); + let result = build(root); + assert.equal(result.status, 0, result.stderr); + const snapshot = path.join(root, 'source-times.json'); + if (unavailable === 'invalid') writeFileSync(snapshot, '{'); + const source = path.join(root, 'src/value.rs'); + utimesSync(source, 1000, 1000); + + result = build(root); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /Fresh freshness_fixture/); + + assert.equal(restoreSourceTimes([root], snapshot), 0); + assert.ok(statSync(source).mtimeMs > 1000000); + result = build(root); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /Compiling freshness_fixture/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +} From f77612b03f84b2a246fcd1944949ea9585fe46ec Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 20 Sep 2026 23:35:56 -0700 Subject: [PATCH 3/5] fix(ci): keep refreshed source times reusable Use the high-resolution epoch clock when invalidating stale Cargo artifacts. Advance an already newer source by one microsecond instead of a whole second so the rebuild output immediately becomes newer and remains reusable. Cover the rebuild boundary by requiring the next Cargo invocation to report the fixture as Fresh without another compilation. Co-Authored-By: Sol (GPT-5.6) --- .../rust-build-cache/source-mtimes.mjs | 27 ++++++++++--------- .../rust-build-cache/source-mtimes.test.mjs | 4 +++ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/.github/actions/rust-build-cache/source-mtimes.mjs b/.github/actions/rust-build-cache/source-mtimes.mjs index 8e5b0026d..80cbad566 100644 --- a/.github/actions/rust-build-cache/source-mtimes.mjs +++ b/.github/actions/rust-build-cache/source-mtimes.mjs @@ -4,6 +4,7 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, statSync, utimesSync, writeFileSync, } from 'node:fs'; import path from 'node:path'; +import { performance } from 'node:perf_hooks'; import { fileURLToPath } from 'node:url'; function sourceFiles(root) { @@ -46,40 +47,40 @@ export function captureSourceTimes(roots, destination) { return sources.reduce((total, files) => total + files.length, 0); } -function refreshedTimestamp(current, wallClockFloor) { - return Math.max(wallClockFloor, Math.ceil(current.mtimeMs / 1000) + 1); +function refreshedTimestamp(current, wallClockMs) { + return Math.max(wallClockMs, current.mtimeMs + 0.001) / 1000; } -function refreshSourceTimes(roots, wallClockFloor) { +function refreshSourceTimes(roots, wallClockMs) { for (const root of roots) { for (const relative of sourceFiles(root)) { const absolute = regularSource(root, relative); if (!absolute) continue; const stat = statSync(absolute); - utimesSync(absolute, stat.atime, refreshedTimestamp(stat, wallClockFloor)); + utimesSync(absolute, stat.atime, refreshedTimestamp(stat, wallClockMs)); } } } export function restoreSourceTimes(roots, source) { - // Use a whole second beyond the current wall clock and the input's existing - // mtime. Date's millisecond precision can otherwise move a freshly written - // file backward on filesystems that retain submillisecond timestamps. - const wallClockFloor = Math.ceil(Date.now() / 1000) + 1; + // Date.now() can precede a freshly written file on filesystems retaining + // submillisecond timestamps. Use the high-resolution epoch clock and advance + // by one microsecond only when the input is already newer. + const wallClockMs = performance.timeOrigin + performance.now(); if (!existsSync(source)) { - refreshSourceTimes(roots, wallClockFloor); + refreshSourceTimes(roots, wallClockMs); return 0; } let snapshot; try { snapshot = JSON.parse(readFileSync(source, 'utf8')); } catch { - refreshSourceTimes(roots, wallClockFloor); + refreshSourceTimes(roots, wallClockMs); return 0; } if (snapshot.version !== 2 || snapshot.sources?.length !== roots.length || snapshot.symlinks?.length !== roots.length) { - refreshSourceTimes(roots, wallClockFloor); + refreshSourceTimes(roots, wallClockMs); return 0; } let restored = 0; @@ -92,7 +93,7 @@ export function restoreSourceTimes(roots, source) { const absolute = regularSource(root, file); if (!absolute) continue; const stat = statSync(absolute); - utimesSync(absolute, stat.atime, refreshedTimestamp(stat, wallClockFloor)); + utimesSync(absolute, stat.atime, refreshedTimestamp(stat, wallClockMs)); } return; } @@ -108,7 +109,7 @@ export function restoreSourceTimes(roots, source) { // A fallback target archive can be newer than a fresh checkout. Make // changed and newly tracked inputs newer than the restored artifacts // so Cargo cannot accept stale fingerprints before rustc runs. - utimesSync(absolute, stat.atime, refreshedTimestamp(stat, wallClockFloor)); + utimesSync(absolute, stat.atime, refreshedTimestamp(stat, wallClockMs)); continue; } utimesSync(absolute, stat.atime, file.mtimeMs / 1000); diff --git a/.github/actions/rust-build-cache/source-mtimes.test.mjs b/.github/actions/rust-build-cache/source-mtimes.test.mjs index 871696406..ff348346a 100644 --- a/.github/actions/rust-build-cache/source-mtimes.test.mjs +++ b/.github/actions/rust-build-cache/source-mtimes.test.mjs @@ -254,6 +254,10 @@ for (const unavailable of ['missing', 'invalid']) { result = build(root); assert.equal(result.status, 0, result.stderr); assert.match(result.stderr, /Compiling freshness_fixture/); + result = build(root); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /Fresh freshness_fixture/); + assert.doesNotMatch(result.stderr, /Compiling freshness_fixture/); } finally { rmSync(root, { recursive: true, force: true }); } From a4f20f4d6acaf2964a5d310333bd385a857cdf4f Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 20 Sep 2026 23:36:36 -0700 Subject: [PATCH 4/5] fix(ci): reject malformed source snapshots Validate the full source and symlink snapshot structure before traversal. Valid JSON primitives and malformed entries now follow the safe cache invalidation path instead of throwing after target restoration. Exercise null and malformed-entry snapshots with real Cargo rebuild and immediate-reuse checks. Co-Authored-By: Sol (GPT-5.6) --- .../actions/rust-build-cache/source-mtimes.mjs | 17 +++++++++++++++-- .../rust-build-cache/source-mtimes.test.mjs | 9 +++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.github/actions/rust-build-cache/source-mtimes.mjs b/.github/actions/rust-build-cache/source-mtimes.mjs index 80cbad566..f2f7799ec 100644 --- a/.github/actions/rust-build-cache/source-mtimes.mjs +++ b/.github/actions/rust-build-cache/source-mtimes.mjs @@ -62,6 +62,20 @@ function refreshSourceTimes(roots, wallClockMs) { } } +function validSnapshot(snapshot, rootCount) { + const validSource = (file) => file && typeof file === 'object' + && typeof file.path === 'string' && typeof file.hash === 'string' + && Number.isInteger(file.executable) && Number.isFinite(file.mtimeMs); + const validSymlink = (link) => link && typeof link === 'object' + && typeof link.path === 'string' && typeof link.target === 'string'; + return snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot) + && snapshot.version === 2 + && Array.isArray(snapshot.sources) && snapshot.sources.length === rootCount + && snapshot.sources.every((files) => Array.isArray(files) && files.every(validSource)) + && Array.isArray(snapshot.symlinks) && snapshot.symlinks.length === rootCount + && snapshot.symlinks.every((links) => Array.isArray(links) && links.every(validSymlink)); +} + export function restoreSourceTimes(roots, source) { // Date.now() can precede a freshly written file on filesystems retaining // submillisecond timestamps. Use the high-resolution epoch clock and advance @@ -78,8 +92,7 @@ export function restoreSourceTimes(roots, source) { refreshSourceTimes(roots, wallClockMs); return 0; } - if (snapshot.version !== 2 || snapshot.sources?.length !== roots.length - || snapshot.symlinks?.length !== roots.length) { + if (!validSnapshot(snapshot, roots.length)) { refreshSourceTimes(roots, wallClockMs); return 0; } diff --git a/.github/actions/rust-build-cache/source-mtimes.test.mjs b/.github/actions/rust-build-cache/source-mtimes.test.mjs index ff348346a..a9818a9ba 100644 --- a/.github/actions/rust-build-cache/source-mtimes.test.mjs +++ b/.github/actions/rust-build-cache/source-mtimes.test.mjs @@ -233,7 +233,12 @@ test('new files and executable mode changes receive fresh timestamps', () => { } }); -for (const unavailable of ['missing', 'invalid']) { +for (const [unavailable, contents] of [ + ['missing', undefined], + ['malformed JSON', '{'], + ['null', 'null'], + ['malformed source entry', JSON.stringify({ version: 2, sources: [[null]], symlinks: [[]] })], +]) { test(`${unavailable} timestamp metadata invalidates restored Cargo artifacts`, () => { const root = mkdtempSync(path.join(tmpdir(), 'hypercolor-missing-source-times-')); try { @@ -241,7 +246,7 @@ for (const unavailable of ['missing', 'invalid']) { let result = build(root); assert.equal(result.status, 0, result.stderr); const snapshot = path.join(root, 'source-times.json'); - if (unavailable === 'invalid') writeFileSync(snapshot, '{'); + if (contents !== undefined) writeFileSync(snapshot, contents); const source = path.join(root, 'src/value.rs'); utimesSync(source, 1000, 1000); From 482348267a15bcf8154892d449e6cce6891d3240 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 20 Sep 2026 23:38:37 -0700 Subject: [PATCH 5/5] test(ci): express source timestamp offset in seconds Use a submillisecond offset for the timestamp precision case. The file API accepts seconds, so the previous literal advanced almost a second. --- .github/actions/rust-build-cache/source-mtimes.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/rust-build-cache/source-mtimes.test.mjs b/.github/actions/rust-build-cache/source-mtimes.test.mjs index a9818a9ba..7c63f3c8d 100644 --- a/.github/actions/rust-build-cache/source-mtimes.test.mjs +++ b/.github/actions/rust-build-cache/source-mtimes.test.mjs @@ -63,7 +63,7 @@ test('content-qualified timestamps retain Cargo freshness but edits and removals const changed = path.join(root, 'src/value.rs'); writeFileSync(changed, 'pub const VALUE: u32 = 2;\n'); - const submillisecondFuture = Date.now() / 1000 + 0.9999; + const submillisecondFuture = Date.now() / 1000 + 0.0009999; utimesSync(changed, submillisecondFuture, submillisecondFuture); const changedTime = statSync(changed).mtimeMs; assert.equal(restoreSourceTimes([root], snapshot), 2);