RM-01: reproducible checkout — the pre-push gate fails on code, not environment #1027
@@ -207,6 +207,10 @@ pnpm install
|
||||
|
||||
# Verify dependencies and generated state before running source-quality gates.
|
||||
# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43.
|
||||
# The web build certifies its exact standalone symlink manifest; added, removed,
|
||||
# retargeted, or manifest-only-tampered generated links also exit 43. This detects
|
||||
# accidental/independent drift, not a same-UID actor that can rewrite both records
|
||||
# consistently (CWE-345); an external trust anchor is required for that boundary.
|
||||
pnpm preflight
|
||||
|
||||
# Optional local queue service only. This does not start PostgreSQL.
|
||||
|
||||
@@ -40,6 +40,14 @@
|
||||
- AC4 DONE at `0f706119`: in that same fresh clone and CI image, `pnpm -w build` completed 25/25 tasks and the immediately following `git status --porcelain` was empty; combined assertion exit 0.
|
||||
- Push BLOCKED after the required queue guard: `git push origin fix/rm-01-reproducible-checkout` was rejected by Gitea with `User permission denied for writing` / `pre-receive hook declined`, despite `MOSAIC_GIT_IDENTITY=f10-coder` resolving username `f10-coder` from the provisioned `gitea-mosaicstack-f10-coder.token`.
|
||||
|
||||
## Review remediation — restated AC2
|
||||
|
||||
- Independent review correctly found that an added symlink under a successfully built `.next` tree passed preflight. The exact reviewer control, `ln -s /etc/hosts apps/web/.next/reviewer-symlink && pnpm preflight`, was observed passing before remediation.
|
||||
- The original blanket symlink wording conflicts with AC4 because canonical Next `output: 'standalone'` emits legitimate pnpm dependency symlinks. The coordinator independently verified 42 such links and approved the operative restatement: `.next` itself must not be a symlink; descendant symlinks must exactly match the successful build's certified manifest.
|
||||
- RED-first controls were observed failing together against the prior implementation (exit 1): `.next` root, added, removed, retargeted, tampered-manifest, and canonical-style certified-link cases. The build now publishes the manifest atomically before the existing source certification commit marker; that marker binds the manifest SHA-256. Missing/partial/modified manifests remain untrusted.
|
||||
- GREEN evidence: the six-case symlink control passes; the exact reviewer-added link exits 43; removing it restores preflight exit 0. The added RED-first build-publication control also proves a symlinked `.next` cannot redirect certification writes outside the checkout. `pnpm test:checkout` passes 23 top-level tests / 29 including subtests. Canonical `pnpm --filter @mosaicstack/web build` and the following `pnpm preflight` both exit 0.
|
||||
- Threat-model ruling: the manifest detects accidental, independent, and stale mutation only. It does not defend against a same-UID actor able to rewrite both manifest and marker consistently (CWE-345); no local worktree construction can without an external trust anchor. The residual belongs to the planned choke-point executor / PostgreSQL spine where verification can occur outside worktree authority.
|
||||
|
||||
## Handoff
|
||||
|
||||
1. Keep the newly committed RED tests red until implementing: (a) source-fingerprint marker support for valid incremental `.next` output, and (b) ownership-safe concurrent hook activation.
|
||||
|
||||
+39
-9
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
import { sourceFingerprint } from './preflight.mjs';
|
||||
import { generatedSymlinkManifest, sourceFingerprint } from './preflight.mjs';
|
||||
|
||||
const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
@@ -26,6 +26,18 @@ function run(command, args, options) {
|
||||
|
||||
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
async function requireRealDirectory(target, { allowMissing = false } = {}) {
|
||||
try {
|
||||
const stats = await lstat(target);
|
||||
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
||||
throw new Error(`${target} must be a real directory, not a symbolic link.`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (allowMissing && error.code === 'ENOENT') return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireBuildLock(root) {
|
||||
const workRoot = path.join(root, '.mosaic-test-work');
|
||||
const lock = path.join(workRoot, 'web-build.lock');
|
||||
@@ -89,12 +101,19 @@ export async function buildWeb({
|
||||
try {
|
||||
const webDir = path.join(root, 'apps', 'web');
|
||||
const nextDir = path.join(webDir, '.next');
|
||||
const marker = path.join(nextDir, '.mosaic-source-hash');
|
||||
const temporary = `${marker}.${randomUUID()}.tmp`;
|
||||
const certificationMarker = path.join(nextDir, '.mosaic-source-hash');
|
||||
const symlinkManifest = path.join(nextDir, '.mosaic-symlink-manifest');
|
||||
const certificationTemporary = `${certificationMarker}.${randomUUID()}.tmp`;
|
||||
const manifestTemporary = `${symlinkManifest}.${randomUUID()}.tmp`;
|
||||
const before = await fingerprint(root);
|
||||
|
||||
await rm(marker, { force: true });
|
||||
await requireRealDirectory(nextDir, { allowMissing: true });
|
||||
await Promise.all([
|
||||
rm(certificationMarker, { force: true }),
|
||||
rm(symlinkManifest, { force: true }),
|
||||
]);
|
||||
await runBuild(webDir);
|
||||
await requireRealDirectory(nextDir);
|
||||
|
||||
const after = await fingerprint(root);
|
||||
if (after !== before) {
|
||||
@@ -103,9 +122,20 @@ export async function buildWeb({
|
||||
);
|
||||
}
|
||||
|
||||
await mkdir(nextDir, { recursive: true });
|
||||
await writeFile(temporary, `${before}\n`, { mode: 0o600 });
|
||||
await rename(temporary, marker);
|
||||
const manifestContents = await generatedSymlinkManifest(nextDir);
|
||||
const certificationContents = `${JSON.stringify({
|
||||
version: 1,
|
||||
sourceFingerprint: before,
|
||||
symlinkManifestHash: createHash('sha256').update(manifestContents).digest('hex'),
|
||||
})}\n`;
|
||||
await Promise.all([
|
||||
writeFile(certificationTemporary, certificationContents, { mode: 0o600 }),
|
||||
writeFile(manifestTemporary, manifestContents, { mode: 0o600 }),
|
||||
]);
|
||||
// The certification marker is the commit point. Publishing the manifest first
|
||||
// leaves interrupted builds untrusted because the marker remains absent.
|
||||
await rename(manifestTemporary, symlinkManifest);
|
||||
await rename(certificationTemporary, certificationMarker);
|
||||
} finally {
|
||||
await releaseLock();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { access, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
@@ -26,19 +26,27 @@ test.after(async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('a successful web build atomically publishes its source fingerprint', async () => {
|
||||
test('a successful web build atomically publishes its source and symlink certification', async () => {
|
||||
const root = await fixture('success');
|
||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||
|
||||
await buildWeb({ root, fingerprint: async () => 'certified', runBuild: async () => {} });
|
||||
|
||||
assert.equal(await readFile(marker, 'utf8'), 'certified\n');
|
||||
assert.deepEqual(JSON.parse(await readFile(marker, 'utf8')), {
|
||||
version: 1,
|
||||
sourceFingerprint: 'certified',
|
||||
symlinkManifestHash: '8a5a375cea6a55d24bd5f875856da63feba33adbefb15a92a0007719b84bcf11',
|
||||
});
|
||||
assert.equal(await readFile(manifest, 'utf8'), '{"version":1,"links":[]}\n');
|
||||
});
|
||||
|
||||
test('a failed web build leaves no certification marker', async () => {
|
||||
const root = await fixture('failure');
|
||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||
await writeFile(marker, 'stale\n');
|
||||
await writeFile(manifest, 'stale\n');
|
||||
|
||||
await assert.rejects(
|
||||
buildWeb({
|
||||
@@ -52,12 +60,15 @@ test('a failed web build leaves no certification marker', async () => {
|
||||
);
|
||||
|
||||
assert.equal(await exists(marker), false);
|
||||
assert.equal(await exists(manifest), false);
|
||||
});
|
||||
|
||||
test('overlapping web builds are serialized while the marker remains absent', async () => {
|
||||
const root = await fixture('overlap');
|
||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||
await writeFile(marker, 'stale\n');
|
||||
await writeFile(manifest, 'stale\n');
|
||||
let releaseFirst;
|
||||
let secondEntered = false;
|
||||
const firstEntered = new Promise((resolve) => {
|
||||
@@ -87,16 +98,41 @@ test('overlapping web builds are serialized while the marker remains absent', as
|
||||
await new Promise((resolve) => setTimeout(resolve, 75));
|
||||
assert.equal(secondEntered, false);
|
||||
assert.equal(await exists(marker), false);
|
||||
assert.equal(await exists(manifest), false);
|
||||
|
||||
releaseFirst();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(secondEntered, true);
|
||||
assert.equal(await readFile(marker, 'utf8'), 'certified\n');
|
||||
assert.equal(JSON.parse(await readFile(marker, 'utf8')).sourceFingerprint, 'certified');
|
||||
assert.equal(await readFile(manifest, 'utf8'), '{"version":1,"links":[]}\n');
|
||||
});
|
||||
|
||||
test('a build that replaces .next with a symbolic link cannot publish outside the checkout', async () => {
|
||||
const root = await fixture('symbolic-next');
|
||||
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||
const outside = path.join(root, 'outside-generated');
|
||||
await mkdir(outside);
|
||||
|
||||
await assert.rejects(
|
||||
buildWeb({
|
||||
root,
|
||||
fingerprint: async () => 'certified',
|
||||
runBuild: async () => {
|
||||
await rm(nextDir, { recursive: true });
|
||||
await symlink(outside, nextDir);
|
||||
},
|
||||
}),
|
||||
/must be a real directory/,
|
||||
);
|
||||
|
||||
assert.equal(await exists(path.join(outside, '.mosaic-source-hash')), false);
|
||||
assert.equal(await exists(path.join(outside, '.mosaic-symlink-manifest')), false);
|
||||
});
|
||||
|
||||
test('inputs changed during a web build are not certified', async () => {
|
||||
const root = await fixture('changed-inputs');
|
||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||
const fingerprints = ['before', 'after'];
|
||||
|
||||
await assert.rejects(
|
||||
@@ -109,4 +145,5 @@ test('inputs changed during a web build are not certified', async () => {
|
||||
);
|
||||
|
||||
assert.equal(await exists(marker), false);
|
||||
assert.equal(await exists(manifest), false);
|
||||
});
|
||||
|
||||
+62
-12
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { constants } from 'node:fs';
|
||||
import { access, lstat, readFile, readdir } from 'node:fs/promises';
|
||||
import { access, lstat, readFile, readdir, readlink } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -32,6 +32,19 @@ async function entries(root) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function generatedSymlinkManifest(nextDir) {
|
||||
const links = [];
|
||||
for (const target of (await entries(nextDir)).sort()) {
|
||||
const stats = await lstat(target);
|
||||
if (!stats.isSymbolicLink()) continue;
|
||||
links.push({
|
||||
path: path.relative(nextDir, target).split(path.sep).join('/'),
|
||||
target: await readlink(target),
|
||||
});
|
||||
}
|
||||
return `${JSON.stringify({ version: 1, links })}\n`;
|
||||
}
|
||||
|
||||
const webSourceRoots = (root) => [
|
||||
path.join(root, 'apps', 'web', 'src'),
|
||||
path.join(root, 'apps', 'web', 'public'),
|
||||
@@ -150,7 +163,14 @@ export async function runPreflight({ root = process.cwd(), uid = process.getuid?
|
||||
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||
let generated = [];
|
||||
try {
|
||||
await lstat(nextDir);
|
||||
const nextStats = await lstat(nextDir);
|
||||
if (nextStats.isSymbolicLink()) {
|
||||
return {
|
||||
code: GENERATED_STATE_EXIT,
|
||||
message:
|
||||
'MOSAIC_PREFLIGHT_GENERATED_STATE: apps/web/.next contains a symbolic link and is not trustworthy; run pnpm clean:generated, then rerun the gate',
|
||||
};
|
||||
}
|
||||
generated = [nextDir, ...(await entries(nextDir))];
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
@@ -158,25 +178,55 @@ export async function runPreflight({ root = process.cwd(), uid = process.getuid?
|
||||
|
||||
if (generated.length > 0) {
|
||||
const foreign = [];
|
||||
if (uid !== undefined) {
|
||||
for (const target of generated) {
|
||||
if ((await lstat(target)).uid !== uid) foreign.push(path.relative(root, target));
|
||||
}
|
||||
for (const target of generated) {
|
||||
const stats = await lstat(target);
|
||||
if (uid !== undefined && stats.uid !== uid) foreign.push(path.relative(root, target));
|
||||
}
|
||||
|
||||
let generatedFingerprint = null;
|
||||
// Threat model: this detects accidental, independent, or stale generated-state
|
||||
// mutation. It does NOT defend against an actor with same-UID write access to
|
||||
// the generated tree: that actor can regenerate both the manifest and marker
|
||||
// consistently (CWE-345). No local construction can without a trust anchor
|
||||
// outside that actor's authority.
|
||||
let certification = null;
|
||||
let certifiedManifest = null;
|
||||
try {
|
||||
generatedFingerprint = (
|
||||
await readFile(path.join(nextDir, '.mosaic-source-hash'), 'utf8')
|
||||
).trim();
|
||||
const [certificationContents, manifestContents] = await Promise.all([
|
||||
readFile(path.join(nextDir, '.mosaic-source-hash'), 'utf8'),
|
||||
readFile(path.join(nextDir, '.mosaic-symlink-manifest'), 'utf8'),
|
||||
]);
|
||||
try {
|
||||
const parsed = JSON.parse(certificationContents);
|
||||
if (
|
||||
parsed.version === 1 &&
|
||||
typeof parsed.sourceFingerprint === 'string' &&
|
||||
typeof parsed.symlinkManifestHash === 'string'
|
||||
) {
|
||||
certification = parsed;
|
||||
certifiedManifest = manifestContents;
|
||||
}
|
||||
} catch {
|
||||
// Invalid certification is handled as untrusted generated state below.
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
const stale = generatedFingerprint !== (await sourceFingerprint(root));
|
||||
if (foreign.length > 0 || stale) {
|
||||
const stale = certification?.sourceFingerprint !== (await sourceFingerprint(root));
|
||||
const actualManifest = await generatedSymlinkManifest(nextDir);
|
||||
const certifiedManifestHash =
|
||||
certifiedManifest === null
|
||||
? null
|
||||
: createHash('sha256').update(certifiedManifest).digest('hex');
|
||||
const changedSymlinks =
|
||||
certification?.symlinkManifestHash !== certifiedManifestHash ||
|
||||
certifiedManifest !== actualManifest;
|
||||
if (foreign.length > 0 || stale || changedSymlinks) {
|
||||
const reasons = [
|
||||
foreign.length > 0 ? `foreign-owned paths: ${foreign.slice(0, 3).join(', ')}` : '',
|
||||
stale ? 'generated source fingerprint does not match web source/configuration' : '',
|
||||
changedSymlinks
|
||||
? 'generated symbolic-link manifest does not match the certified build'
|
||||
: '',
|
||||
].filter(Boolean);
|
||||
return {
|
||||
code: GENERATED_STATE_EXIT,
|
||||
|
||||
+115
-4
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { chmod, mkdir, rm, symlink, utimes, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
@@ -28,6 +29,22 @@ async function installRequiredBins(root) {
|
||||
);
|
||||
}
|
||||
|
||||
async function certifyGeneratedState(root, links = []) {
|
||||
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||
await mkdir(nextDir, { recursive: true });
|
||||
const manifest = `${JSON.stringify({ version: 1, links })}\n`;
|
||||
const manifestHash = createHash('sha256').update(manifest).digest('hex');
|
||||
await writeFile(path.join(nextDir, '.mosaic-symlink-manifest'), manifest);
|
||||
await writeFile(
|
||||
path.join(nextDir, '.mosaic-source-hash'),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
sourceFingerprint: await sourceFingerprint(root),
|
||||
symlinkManifestHash: manifestHash,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
@@ -98,6 +115,103 @@ test('a generated marker mismatch is identified separately from source errors',
|
||||
assert.match(result.message, /pnpm clean:generated/);
|
||||
});
|
||||
|
||||
test('generated-state symbolic links are accepted only when exactly build-certified', async (t) => {
|
||||
await t.test('apps/web/.next itself is rejected when it is a symbolic link', async () => {
|
||||
const root = await fixture('symbolic-next-root');
|
||||
await installRequiredBins(root);
|
||||
await writeFile(path.join(root, 'outside-generated'), 'not a Next build\n');
|
||||
await symlink(path.join(root, 'outside-generated'), path.join(root, 'apps', 'web', '.next'));
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
||||
assert.match(result.message, /symbolic link/);
|
||||
});
|
||||
|
||||
await t.test('an added descendant symlink is rejected', async () => {
|
||||
const root = await fixture('symbolic-next-added');
|
||||
await installRequiredBins(root);
|
||||
await certifyGeneratedState(root);
|
||||
await symlink('/etc/hosts', path.join(root, 'apps', 'web', '.next', 'reviewer-symlink'));
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /symbolic-link manifest/);
|
||||
});
|
||||
|
||||
await t.test('a removed certified descendant symlink is rejected', async () => {
|
||||
const root = await fixture('symbolic-next-removed');
|
||||
await installRequiredBins(root);
|
||||
const link = path.join(root, 'apps', 'web', '.next', 'dependency-link');
|
||||
await mkdir(path.dirname(link), { recursive: true });
|
||||
await symlink('../dependency-one', link);
|
||||
await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]);
|
||||
await rm(link);
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /symbolic-link manifest/);
|
||||
});
|
||||
|
||||
await t.test('a retargeted certified descendant symlink is rejected', async () => {
|
||||
const root = await fixture('symbolic-next-retargeted');
|
||||
await installRequiredBins(root);
|
||||
const link = path.join(root, 'apps', 'web', '.next', 'dependency-link');
|
||||
await mkdir(path.dirname(link), { recursive: true });
|
||||
await symlink('../dependency-one', link);
|
||||
await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]);
|
||||
await rm(link);
|
||||
await symlink('../dependency-two', link);
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /symbolic-link manifest/);
|
||||
});
|
||||
|
||||
await t.test('a manifest edited to whitelist a rogue symlink is rejected', async () => {
|
||||
const root = await fixture('symbolic-next-tampered-manifest');
|
||||
await installRequiredBins(root);
|
||||
await certifyGeneratedState(root);
|
||||
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||
await symlink('/etc/hosts', path.join(nextDir, 'reviewer-symlink'));
|
||||
await writeFile(
|
||||
path.join(nextDir, '.mosaic-symlink-manifest'),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
links: [{ path: 'reviewer-symlink', target: '/etc/hosts' }],
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /symbolic-link manifest/);
|
||||
});
|
||||
|
||||
await t.test('unchanged canonical-style descendant symlinks are accepted', async () => {
|
||||
const root = await fixture('symbolic-next-certified');
|
||||
await installRequiredBins(root);
|
||||
const link = path.join(
|
||||
root,
|
||||
'apps',
|
||||
'web',
|
||||
'.next',
|
||||
'standalone',
|
||||
'node_modules',
|
||||
'dependency',
|
||||
);
|
||||
await mkdir(path.dirname(link), { recursive: true });
|
||||
await symlink('../.pnpm/dependency', link);
|
||||
await certifyGeneratedState(root, [
|
||||
{ path: 'standalone/node_modules/dependency', target: '../.pnpm/dependency' },
|
||||
]);
|
||||
|
||||
assert.deepEqual(await runPreflight({ root }), {
|
||||
code: 0,
|
||||
message: 'checkout preflight passed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('the source fingerprint includes inherited TypeScript configuration', async () => {
|
||||
const root = await fixture('inherited-typescript-config');
|
||||
const config = path.join(root, 'tsconfig.base.json');
|
||||
@@ -143,10 +257,7 @@ test('a matching generation marker accepts incremental output with mixed mtimes'
|
||||
await utimes(generated, new Date('2020-01-01T00:00:00Z'), new Date('2020-01-01T00:00:00Z'));
|
||||
const fresh = path.join(root, 'apps', 'web', '.next', 'types', 'routes.ts');
|
||||
await writeFile(fresh, 'fresh generated output');
|
||||
await writeFile(
|
||||
path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'),
|
||||
await sourceFingerprint(root),
|
||||
);
|
||||
await certifyGeneratedState(root);
|
||||
|
||||
assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user