Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/packed-release-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Protect `test:packed` and the release gate from publishing synthetic runtime fixture output (#645).
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@
"preview:publish": "pkg-pr-new publish --previewVersion --peerDeps --no-compact --no-template './packages/agent-bundle' './packages/rsc-runtime' './packages/rsc-markdown-stream' './packages/create-agent-bundle'",
"pack:dry-run": "pnpm build && npm pack ./packages/agent-bundle --dry-run --json",
"lint:release": "attw --pack --profile esm-only packages/agent-bundle && attw --pack --profile esm-only packages/rsc-runtime && attw --pack --profile esm-only packages/rsc-markdown-stream && attw --pack --profile esm-only packages/create-agent-bundle && node scripts/check-declaration-imports.mjs --strict packages/agent-bundle packages/rsc-runtime packages/rsc-markdown-stream packages/create-agent-bundle",
"check:release": "pnpm pack:dry-run && pnpm lint:release && pnpm test:packed:release",
"check:release:ci": "pnpm pack:dry-run && pnpm lint:release && pnpm test:packed",
"check:release": "pnpm pack:dry-run && pnpm lint:release && pnpm test:packed:release && node scripts/check-dist-fresh.mjs",
"check:release:ci": "pnpm pack:dry-run && pnpm lint:release && pnpm test:packed && node scripts/check-dist-fresh.mjs",
"example:hooks": "pnpm build && pnpm --filter @agent-bundle-example/hooks-and-scripts dev",
"example:audiobook": "pnpm build && pnpm --filter @agent-bundle-example/audiobook-curator dev",
"example:mcp-app": "pnpm build && pnpm --filter @agent-bundle-example/mcp-app dev",
Expand Down
49 changes: 45 additions & 4 deletions packages/agent-bundle/tests/dist-freshness.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync } from 'node:fs';
import { lstat, mkdir, mkdtemp, readdir, rm, utimes, writeFile } from 'node:fs/promises';
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';

Expand All @@ -13,10 +13,12 @@ import {
isSkippedInputDirectory,
newestEntry,
runtimeExampleBuildOutputs,
runtimeRebundleFixtureMarker,
workspaceBuildOutputs,
type DistDescriptor,
type DistFreshness,
} from '../../../scripts/dist-freshness.mjs';
import { digestTree } from './support/tree-snapshot.ts';

const workspaceRoot = process.cwd();

Expand Down Expand Up @@ -79,6 +81,14 @@ const createPackageFixture = async (): Promise<PackageFixture> => {
const touch = (path: string, mtime: Date): Promise<void> => utimes(path, mtime, mtime);

describe('distFreshness', () => {
it('checks the marker emitted by the runtime fixture', async () => {
const fixture = await readFile(
join(workspaceRoot, 'packages/agent-bundle/tests/fixtures/runtime-rebundle/private-sibling.ts'),
'utf8',
);
expect(fixture).toContain(`'${runtimeRebundleFixtureMarker}'`);
});

it('is fresh when every input predates the newest built file, ignoring declared inputs that do not exist', async () => {
const { descriptor, root } = await createPackageFixture();
const result = distFreshness(descriptor);
Expand Down Expand Up @@ -121,6 +131,17 @@ describe('distFreshness', () => {
expect(distFreshness(descriptor).status).toBe('missing');
});

it('rejects a fresh dist containing the packed runtime fixture marker', async () => {
const { descriptor, root } = await createPackageFixture();
const marked = join(root, 'dist/chunks/shared.js');
await writeFile(marked, runtimeRebundleFixtureMarker);
await touch(marked, buildTime);
expect(distFreshness(descriptor)).toMatchObject({
fixtureMarkerPath: marked,
status: 'contaminated',
});
});

it('skips node_modules, dist, .rstest-temp and dot-directories inside inputs', async () => {
const { descriptor, root } = await createPackageFixture();
const skipped = ['src/node_modules/dep/index.js', 'src/dist/out.js', 'src/.rstest-temp/chunk.js', 'src/.cache/entry.js'];
Expand Down Expand Up @@ -178,6 +199,15 @@ describe('newestEntry', () => {
});
});

describe('digestTree', () => {
it('changes when any built byte changes', async () => {
const { root } = await createPackageFixture();
const before = await digestTree(join(root, 'dist'));
await writeFile(join(root, 'dist/chunks/shared.js'), 'changed');
expect(await digestTree(join(root, 'dist'))).not.toBe(before);
});
});

describe('formatDistFreshnessFailure and assertFreshDist', () => {
const fresh: DistFreshness = {
name: 'fresh-package',
Expand All @@ -200,15 +230,26 @@ describe('formatDistFreshnessFailure and assertFreshDist', () => {
output: '/repo/packages/workbench/dist',
status: 'missing',
};
const contaminated: DistFreshness = {
fixtureMarkerPath: '/repo/packages/agent-bundle/dist/mcp-server-runtime.js',
name: 'agent-bundle',
newestInput: { mtimeMs: sourceTime.getTime(), path: '/repo/packages/agent-bundle/src/index.ts' },
newestOutput: { mtimeMs: buildTime.getTime(), path: '/repo/packages/agent-bundle/dist/index.js' },
output: '/repo/packages/agent-bundle/dist',
status: 'contaminated',
};

it('names every stale or missing output with its evidence and ends with the rebuild instruction', () => {
const message = formatDistFreshnessFailure([fresh, stale, missing], { relativeTo: '/repo' });
it('names every rejected output with its evidence and ends with the rebuild instruction', () => {
const message = formatDistFreshnessFailure([fresh, stale, missing, contaminated], { relativeTo: '/repo' });
expect(message).not.toContain('fresh-package');
expect(message).toContain(
' @agent-bundle/runtime: stale — packages/rsc-runtime/src/index.ts (2026-01-03T00:00:00.000Z)'
+ ' is newer than packages/rsc-runtime/dist/index.js (2026-01-02T00:00:00.000Z)',
);
expect(message).toContain(' agent-bundle-workbench: missing — packages/workbench/dist has no built files');
expect(message).toContain(
' agent-bundle: contaminated — packages/agent-bundle/dist/mcp-server-runtime.js contains the packed runtime fixture marker',
);
expect(message.endsWith('run `pnpm build`.')).toBe(true);
});

Expand All @@ -225,7 +266,7 @@ describe('formatDistFreshnessFailure and assertFreshDist', () => {
expect(() => assertFreshDist([descriptor])).not.toThrow();
await touch(join(root, 'src/index.ts'), editTime);
expect(() => assertFreshDist([descriptor], { relativeTo: root })).toThrow(
/^Built output is stale or missing[\s\S]*fixture: stale — src\/index\.ts[\s\S]*run `pnpm build`\.$/u,
/^Built output is stale, missing, or contaminated[\s\S]*fixture: stale — src\/index\.ts[\s\S]*run `pnpm build`\.$/u,
);
expect(checkDistFreshness([descriptor]).map((result) => result.status)).toEqual(['stale']);
});
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-bundle/tests/packed-stdio-projection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { userDataStateRoot } from '@agent-bundle/runtime';
import { specTypeSchemas as clientSchemas } from '@modelcontextprotocol/client';
import { expect, it } from '@rstest/core';

import { runtimeRebundleFixtureMarker } from '../../../scripts/dist-freshness.mjs';
import { exists } from '../src/core/paths.ts';
import { requestEventRuntime } from '../src/events/ipc.ts';
import { compileTestManifest } from '../src/test/manifest.ts';
Expand Down Expand Up @@ -265,7 +266,7 @@ it.each([
// exported `HARNESS_HOST_WINS` is untouched, and nothing was logged.
for (const [name, value] of [
...(packageName === 'agent-bundle-runtime-rebundle'
? [['AGENT_BUNDLE_RUNTIME_REBUNDLE_FIXTURE_EXECUTED', '1'] as const]
? [[runtimeRebundleFixtureMarker, '1'] as const]
: []),
['HARNESS_FROM_FILE', 's3cr3t-from-file'],
['HARNESS_LOCAL', 'from-local'],
Expand Down
76 changes: 6 additions & 70 deletions packages/agent-bundle/tests/support/tree-snapshot.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,6 @@
import { createHash } from 'node:crypto';
import { lstat, readdir, readFile, readlink } from 'node:fs/promises';
import { join } from 'node:path';

/**
* A byte-level picture of a directory tree: every entry (files by sha256 and
* mode bits, directories, symlink targets), sorted, POSIX-relative to the
* root. Two snapshots are equal exactly when the trees are byte-identical
* (timestamps excepted), which is what the uninstall proofs compare a home
* against before install and after uninstall.
*/
export type TreeSnapshot = ReadonlyMap<string, string>;

export const snapshotTree = async (root: string): Promise<TreeSnapshot> => {
const entries = new Map<string, string>();
const visit = async (relativePath: string): Promise<void> => {
let names: readonly string[];
try {
names = (await readdir(join(root, relativePath))).sort((left, right) => left.localeCompare(right));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
throw error;
}
for (const name of names) {
const child = relativePath === '' ? name : `${relativePath}/${name}`;
const path = join(root, child);
const metadata = await lstat(path);
if (metadata.isSymbolicLink()) {
entries.set(child, `link ${await readlink(path)}`);
} else if (metadata.isDirectory()) {
entries.set(child, 'dir');
await visit(child);
} else if (metadata.isFile()) {
const digest = createHash('sha256').update(await readFile(path)).digest('hex');
entries.set(child, `file ${digest} ${(metadata.mode & 0o777).toString(8)}`);
} else {
entries.set(child, 'special');
}
}
};
await visit('');
return entries;
};

export interface TreeSnapshotDifference {
readonly added: readonly string[];
readonly changed: readonly string[];
readonly removed: readonly string[];
}

/** Entries present only after (`added`), only before (`removed`), or with different bytes (`changed`). */
export const diffTreeSnapshots = (before: TreeSnapshot, after: TreeSnapshot): TreeSnapshotDifference => {
const added: string[] = [];
const changed: string[] = [];
const removed: string[] = [];
for (const [path, description] of after) {
const previous = before.get(path);
if (previous === undefined) added.push(path);
else if (previous !== description) changed.push(path);
}
for (const path of before.keys()) {
if (!after.has(path)) removed.push(path);
}
return Object.freeze({ added: Object.freeze(added), changed: Object.freeze(changed), removed: Object.freeze(removed) });
};

export const treesIdentical = (before: TreeSnapshot, after: TreeSnapshot): boolean => {
const difference = diffTreeSnapshots(before, after);
return difference.added.length === 0 && difference.changed.length === 0 && difference.removed.length === 0;
};
export {
diffTreeSnapshots,
digestTree,
snapshotTree,
treesIdentical,
} from '../../../../scripts/tree-snapshot.mjs';
9 changes: 5 additions & 4 deletions scripts/check-dist-fresh.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* `node scripts/check-dist-fresh.mjs` — exits 1 with the rebuild instruction
* when any dist `pnpm build` produces is older than its inputs or absent;
* silent and 0 otherwise. The root `typecheck` script runs it first, because
* `tsc` types the tests against `dist/*.d.ts` (scripts/dist-freshness.mjs
* explains the descriptors and the mtime rule).
* when any dist `pnpm build` produces is older than its inputs, absent, or
* contains the packed runtime fixture marker; silent and 0 otherwise. The
* root `typecheck` script runs it first, because `tsc` types the tests against
* `dist/*.d.ts` (scripts/dist-freshness.mjs explains the descriptors and the
* mtime rule).
*/
import { resolve } from 'node:path';

Expand Down
6 changes: 5 additions & 1 deletion scripts/dist-freshness.d.mts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type DistFreshnessStatus = 'fresh' | 'stale' | 'missing';
export type DistFreshnessStatus = 'fresh' | 'stale' | 'missing' | 'contaminated';

export interface DistDescriptor {
/** Package name, as printed in the failure message. */
Expand Down Expand Up @@ -26,6 +26,8 @@ export interface DistFreshness {
readonly newestInput: NewestEntry;
/** Undefined when `status` is `missing`. */
readonly newestOutput: NewestEntry | undefined;
/** Built file containing the packed-test fixture marker. */
readonly fixtureMarkerPath?: string;
}

export interface NewestEntryOptions {
Expand All @@ -44,6 +46,8 @@ export declare const isSkippedInputDirectory: (name: string) => boolean;

export declare const newestEntry: (path: string, options?: NewestEntryOptions) => NewestEntry | undefined;

export declare const runtimeRebundleFixtureMarker: string;

export declare const distFreshness: (descriptor: DistDescriptor) => DistFreshness;

export declare const checkDistFreshness: (descriptors: readonly DistDescriptor[]) => readonly DistFreshness[];
Expand Down
56 changes: 44 additions & 12 deletions scripts/dist-freshness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
* rebuilds the example payload instead of short-circuiting on a stale one.
*
* Comparison. A package is `missing` when its output directory is absent or
* holds no file, `stale` when the newest input mtime is later than the
* newest output mtime, `fresh` otherwise. Inputs count files and
* holds no file, `contaminated` when an output contains the packed runtime
* fixture marker, `stale` when the newest input mtime is later than the
* newest output mtime, and `fresh` otherwise. Inputs count files and
* directories: a directory's mtime moves when an entry is created, renamed
* or deleted, which is how a removed source file is noticed. Outputs count
* files only — the bytes a test loads. While walking, `node_modules`,
Expand Down Expand Up @@ -86,7 +87,7 @@
* descriptor per payload tree (`dist/app`, `dist/runtime`) keeps
* "missing" per tree, matching the presence probes it replaces.
*/
import { readdirSync, statSync } from 'node:fs';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { relative, resolve } from 'node:path';

/**
Expand All @@ -107,9 +108,10 @@ import { relative, resolve } from 'node:path';
* @typedef {object} DistFreshness
* @property {string} name
* @property {string} output Absolute path of the output directory.
* @property {'fresh' | 'stale' | 'missing'} status
* @property {'fresh' | 'stale' | 'missing' | 'contaminated'} status
* @property {NewestEntry} newestInput
* @property {NewestEntry | undefined} newestOutput Undefined when `status` is `missing`.
* @property {string | undefined} fixtureMarkerPath File containing the packed-test fixture marker.
*/

const skippedInputDirectoryNames = new Set(['node_modules', 'dist', '.rstest-temp']);
Expand Down Expand Up @@ -163,6 +165,25 @@ export const newestEntry = (path, options = {}) => {
return newest;
};

export const runtimeRebundleFixtureMarker = 'AGENT_BUNDLE_RUNTIME_REBUNDLE_FIXTURE_EXECUTED';

/** Returns the first built file containing the packed-only runtime marker. */
const fixtureMarkerPath = (root) => {
const marker = Buffer.from(runtimeRebundleFixtureMarker);
const pending = [root];
while (pending.length > 0) {
const directory = pending.pop();
const entries = readdirSync(directory, { withFileTypes: true });
for (const entry of entries) {
if (isSkippedOutputDirectory(entry.name)) continue;
const path = resolve(directory, entry.name);
if (entry.isDirectory()) pending.push(path);
else if (entry.isFile() && readFileSync(path).includes(marker)) return path;
}
}
return undefined;
};

/**
* Freshness of one built output. Declared inputs that do not exist are
* ignored (a package may lack an optional config file); a descriptor none of
Expand All @@ -182,8 +203,15 @@ export const distFreshness = (descriptor) => {
}
const output = resolve(root, descriptor.output);
const newestOutput = newestEntry(output, { skip: isSkippedOutputDirectory });
const status = newestOutput === undefined ? 'missing' : newestInput.mtimeMs > newestOutput.mtimeMs ? 'stale' : 'fresh';
return { name, output, status, newestInput, newestOutput };
const markerPath = newestOutput === undefined ? undefined : fixtureMarkerPath(output);
const status = newestOutput === undefined
? 'missing'
: markerPath !== undefined
? 'contaminated'
: newestInput.mtimeMs > newestOutput.mtimeMs
? 'stale'
: 'fresh';
return { name, output, status, newestInput, newestOutput, fixtureMarkerPath: markerPath };
};

/**
Expand All @@ -195,10 +223,10 @@ export const checkDistFreshness = (descriptors) => descriptors.map(distFreshness
const timestamp = (mtimeMs) => new Date(mtimeMs).toISOString();

/**
* One actionable message naming every stale or missing output and ending
* with the fix, or the empty string when every result is fresh. Paths print
* relative to `relativeTo` (default: the working directory) when they lie
* under it.
* One actionable message naming every stale, missing, or contaminated output
* and ending with the fix, or the empty string when every result is fresh.
* Paths print relative to `relativeTo` (default: the working directory) when
* they lie under it.
*
* @param {readonly DistFreshness[]} results
* @param {{ readonly relativeTo?: string }} [options]
Expand All @@ -213,6 +241,10 @@ export const formatDistFreshnessFailure = (results, options = {}) => {
const lines = [];
for (const result of results) {
if (result.status === 'fresh') continue;
if (result.status === 'contaminated') {
lines.push(` ${result.name}: contaminated — ${display(result.fixtureMarkerPath ?? result.output)} contains the packed runtime fixture marker`);
continue;
}
if (result.status === 'missing' || result.newestOutput === undefined) {
lines.push(` ${result.name}: missing — ${display(result.output)} has no built files`);
continue;
Expand All @@ -224,15 +256,15 @@ export const formatDistFreshnessFailure = (results, options = {}) => {
}
if (lines.length === 0) return '';
return [
'Built output is stale or missing; tests and `pnpm typecheck` load it from dist:',
'Built output is stale, missing, or contaminated; tests and `pnpm typecheck` load it from dist:',
...lines,
'A green run over that dist tests old code; run `pnpm build`.',
].join('\n');
};

/**
* Throws an Error carrying `formatDistFreshnessFailure`'s message when any
* descriptor's output is stale or missing.
* descriptor's output is stale, missing, or contaminated.
*
* @param {readonly DistDescriptor[]} descriptors
* @param {{ readonly relativeTo?: string }} [options]
Expand Down
Loading
Loading