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
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ on:
pull_request:
push:
branches: [main]
# Nightly release-boundary matrix (packed-matrix job): the scaffolder
# template tests beyond the per-PR minimal-template smoke.
schedule:
- cron: '17 6 * * *'
workflow_dispatch:

permissions:
Expand All @@ -16,6 +20,7 @@ concurrency:
jobs:
# Builds and checks every public example through its own toolchain.
examples-check:
if: github.event_name != 'schedule'
name: Examples check (Node 22.19)
runs-on: ubuntu-latest
timeout-minutes: 25
Expand All @@ -30,6 +35,7 @@ jobs:
- run: pnpm examples:check

verify:
if: github.event_name != 'schedule'
name: Verify (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
timeout-minutes: 45
Expand All @@ -56,9 +62,31 @@ jobs:
- run: pnpm test

release-gates:
if: github.event_name != 'schedule'
name: Release gates (Node 22.19)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: pnpm/setup@v2
with:
cache: true
install: false
runtime: node@22.19.0
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chrome
# Per-PR packed pool: single pack+install proofs plus the
# minimal-template scaffolder smoke. The full template matrix runs in
# the nightly packed-matrix job and in pre-publish `pnpm check:release`.
- run: pnpm check:release:ci

# Release-boundary scaffolder template matrix (mcp-server, cli-tool) plus
# the full packed pool — the nightly form of pre-publish `check:release`.
packed-matrix:
if: github.event_name == 'schedule'
name: Packed release matrix (Node 22.19)
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- uses: actions/checkout@v7
- uses: pnpm/setup@v2
Expand All @@ -75,6 +103,7 @@ jobs:
# (hook -> RSC worker -> shared kernel state -> MCP tool lowering) without
# any real Claude/Codex host. Real native-host smokes stay skip-gated in
# the manually dispatched native-host-smoke workflow on purpose.
if: github.event_name != 'schedule'
name: RSC runtime micro-eval (Node 22.19)
runs-on: ubuntu-latest
timeout-minutes: 15
Expand Down
6 changes: 6 additions & 0 deletions docs/local-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ The three hosted Node-22.19 jobs fold into one `gates-node22` worktree
because each of their entry scripts starts from `pnpm build` in a fresh
install, which one worktree provides just as well as three.

`gates-node22` runs the full `check:release`, a strict superset of the hosted
per-PR release-gates job (`check:release:ci`): it additionally runs the
scaffolder template matrix that the hosted side defers to the nightly
`packed-matrix` job, so local green covers both the per-PR and nightly packed
pools.

All four legs run concurrently. The summary table (leg × step × status ×
duration × test census) is printed and written to
`.worktrees/local-ci/summary.md` (plus `summary.json`); per-step logs land in
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"eval:spot": "pnpm build && pnpm --filter @agent-bundle/rsc-agent-runtime-demo build && pnpm --filter @agent-bundle/rsc-agent-runtime-demo exec rstest run tests/micro-eval.spot.test.ts --config rstest.config.ts",
"check:runtime-topology": "node scripts/rsc-runtime-topology.mjs --root . --output docs/architecture/rsc-runtime-workbench.md --check",
"test:examples:browser": "rstest --config rstest.config.ts packages/workbench/tests/examples-real.e2e.test.ts",
"test:packed": "rstest --config rstest.config.ts packages/agent-bundle/tests/release-audit.test.ts packages/agent-bundle/tests/packed-consumer.test.ts packages/agent-bundle/tests/dev-workbench-packaging.test.ts packages/agent-bundle/tests/public-api-packed.test.ts packages/agent-bundle/tests/rsc-runtime-optional-packaging.test.ts packages/agent-bundle/tests/packed-native-smoke.test.ts packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts packages/workbench/tests/packed-release.e2e.test.ts",
"test:packed": "node scripts/run-packed-tests.mjs",
"test:packed:release": "node scripts/run-packed-tests.mjs --release",
"test:packed:native": "rstest --config rstest.config.ts packages/agent-bundle/tests/packed-native-smoke.test.ts",
"test:packed:native:claude": "pnpm build && AGENT_BUNDLE_PACKED_NATIVE_CLAUDE_SMOKE=1 pnpm test:packed:native",
"test:packed:native:codex": "pnpm build && AGENT_BUNDLE_PACKED_NATIVE_CODEX_SMOKE=1 pnpm test:packed:native",
Expand All @@ -33,7 +34,8 @@
"preview:publish": "pkg-pr-new publish --previewVersion --peerDeps --no-compact --no-template './packages/agent-bundle' './packages/rsc-runtime' './packages/create-agent-bundle'",
"pack:dry-run": "pnpm build && npm pack ./packages/agent-bundle --dry-run --json",
"audit:release": "pnpm lint:package && attw --pack --profile esm-only packages/agent-bundle && node scripts/audit-packed-release.mjs",
"check:release": "pnpm pack:dry-run && pnpm audit:release && pnpm test:packed",
"check:release": "pnpm pack:dry-run && pnpm audit:release && pnpm test:packed:release",
"check:release:ci": "pnpm pack:dry-run && pnpm audit:release && pnpm test:packed",
"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
4 changes: 2 additions & 2 deletions packages/agent-bundle/tests/dev-services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -731,9 +731,9 @@ it('reports snapshot failures as frozen preparation diagnostics', async () => {
'',
].join('\n'));
const output = join(root, 'snapshot-output');
const externalOutput = join(root, '..', 'snapshot-output-external');
// Unique path: a fixed name under tmpdir collides across concurrent runs.
const externalOutput = await mkdtemp(join(tmpdir(), 'agent-bundle-snapshot-output-external-'));
try {
await mkdir(externalOutput);
await writeFile(join(root, 'agent-bundle.config.ts'), [
"import { symlinkSync } from 'node:fs';",
'export default ({ projectRoot }) => {',
Expand Down
28 changes: 10 additions & 18 deletions packages/agent-bundle/tests/dev-workbench-packaging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,23 @@ import { promisify } from 'node:util';

import { describe, expect, it } from '@rstest/core';

import { installedEnvironment, npmInstallArguments, sharedPackedTarball } from './support/shared-pack.ts';

const execFile = promisify(executeFile);
const workspaceRoot = process.cwd();
const packageRoot = join(workspaceRoot, 'packages', 'agent-bundle');
const workbenchRoot = join(workspaceRoot, 'packages', 'workbench');
const appRendererLicense = join('src', 'mcp', 'APP-RENDERER-LICENSE');
let built: Promise<void> | undefined;

const packedEnvironment = (): NodeJS.ProcessEnv => {
const { NODE_PATH: _nodePath, ...environment } = process.env;
return environment;
};

const buildPackage = async (force = false): Promise<void> => {
if (force) {
// The stale-asset pruning test rebuilds on purpose; the prebuilt seam
// never skips it because the rebuild itself is the behavior under test.
await execFile('pnpm', ['build'], { cwd: workspaceRoot });
return;
}
if (process.env['AGENT_BUNDLE_PACKAGE_PREBUILT'] === '1') return;
built ??= execFile('pnpm', ['build'], { cwd: workspaceRoot }).then(() => undefined);
await built;
};
Expand Down Expand Up @@ -71,13 +71,10 @@ it('prunes stale copied workbench assets without removing the package library ou
}, 60_000);

it('serves prebuilt workbench assets from an installed tarball without the repository source tree', async () => {
await buildPackage();
const { tarball } = await sharedPackedTarball('agent-bundle');
const consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-workbench-consumer-'));
const project = join(consumer, 'project');
try {
const { stdout } = await execFile('npm', ['pack', '--json', '--pack-destination', consumer], { cwd: packageRoot });
const [packed] = JSON.parse(stdout) as Array<{ readonly filename: string }>;
const tarball = join(consumer, packed.filename);
const listing = await execFile('tar', ['-tf', tarball]);
expect(listing.stdout).toContain('package/dist/workbench/index.html');
expect(listing.stdout).toContain('package/dist/workbench/THIRD_PARTY_NOTICES');
Expand All @@ -86,7 +83,7 @@ it('serves prebuilt workbench assets from an installed tarball without the repos
expect(listing.stdout).not.toMatch(/package\/dist\/workbench\/.*-[a-f0-9]{8,}/iu);

await writeFile(join(consumer, 'package.json'), '{"type":"module"}\n');
await execFile('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', tarball], { cwd: consumer });
await execFile('npm', ['install', ...npmInstallArguments, tarball], { cwd: consumer });
await mkdir(join(project, 'skills', 'review'), { recursive: true });
await Promise.all([
writeFile(join(project, 'package.json'), '{"type":"module"}\n'),
Expand All @@ -113,17 +110,12 @@ it('serves prebuilt workbench assets from an installed tarball without the repos
}, 60_000);

it('runs the Agent API from an omit-dev installed tarball with its runtime MCP dependencies', async () => {
await buildPackage();
const { tarball } = await sharedPackedTarball('agent-bundle');
const consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-agent-api-consumer-'));
const project = join(consumer, 'project');
try {
const { stdout } = await execFile('npm', ['pack', '--json', '--pack-destination', consumer], { cwd: packageRoot });
const [packed] = JSON.parse(stdout) as Array<{ readonly filename: string }>;
const tarball = join(consumer, packed.filename);
await writeFile(join(consumer, 'package.json'), '{"type":"module"}\n');
await execFile('npm', [
'install', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund', tarball,
], { cwd: consumer });
await execFile('npm', ['install', '--omit=dev', ...npmInstallArguments, tarball], { cwd: consumer });
await mkdir(join(project, 'skills', 'review'), { recursive: true });
await Promise.all([
writeFile(join(project, 'package.json'), '{"type":"module"}\n'),
Expand Down Expand Up @@ -153,7 +145,7 @@ it('runs the Agent API from an omit-dev installed tarball with its runtime MCP d
].join('\n');
const result = await execFile(process.execPath, ['--input-type=module', '--eval', script], {
cwd: consumer,
env: { ...packedEnvironment(), AGENT_BUNDLE_AGENT_API_TOKEN: 'packed-agent-api-token' },
env: { ...installedEnvironment(), AGENT_BUNDLE_AGENT_API_TOKEN: 'packed-agent-api-token' },
});
expect(JSON.parse(result.stdout)).toEqual({
runtime: ['function', 'function', 'function'],
Expand Down
28 changes: 14 additions & 14 deletions packages/agent-bundle/tests/packed-consumer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { promisify } from 'node:util';
import { expect, it } from '@rstest/core';

import { sha256Hex } from '../src/core/digest.ts';
import { installedEnvironment, npmInstallArguments } from './support/shared-pack.ts';

const execFile = promisify(executeFile);
const workspaceRoot = process.cwd();
Expand All @@ -37,11 +38,6 @@ interface ManifestDigest extends FileDigest {
readonly mode?: number;
}

const installedEnvironment = (): NodeJS.ProcessEnv => {
const { NODE_PATH: _nodePath, ...environment } = process.env;
return environment;
};

const artifactDigest = async (root: string): Promise<readonly FileDigest[]> => {
const collect = async (directory: string): Promise<FileDigest[]> => {
const entries = await readdir(directory, { withFileTypes: true });
Expand Down Expand Up @@ -78,6 +74,11 @@ it('uses only an installed tarball after source deletion', async () => {
const artifact = join(projectRoot, 'artifact with spaces');

try {
// Deliberately not sharedPackedTarball: this test packs from a deletable
// copy of the package and removes that copy after install, proving the
// tarball's contents hold no path references back to the pack source. The
// shared tarball packs from the live workspace, which survives the run,
// so a leaked path would resolve and pass silently.
await cp(packageRoot, packedPackageRoot, { recursive: true });
await execFile(join(workspaceRoot, 'node_modules', '.bin', 'rslib'), [
'build', '--config', join(packageRoot, 'rslib.config.ts'), '--dist-path', join(packedPackageRoot, 'dist'),
Expand All @@ -97,10 +98,6 @@ it('uses only an installed tarball after source deletion', async () => {
stat(join(projectRoot, 'src', 'shell.sh')).then((metadata) => metadata.mode & 0o777),
stat(join(projectRoot, 'src', 'python.py')).then((metadata) => metadata.mode & 0o777),
]);
await execFile('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', tarball], {
cwd: projectRoot,
env: installedEnvironment(),
});
await mkdir(scriptProjectRoot, { recursive: true });
await Promise.all([
writeFile(join(scriptProjectRoot, 'package.json'), '{"type":"module"}\n'),
Expand All @@ -110,10 +107,13 @@ it('uses only an installed tarball after source deletion', async () => {
),
writeFile(join(scriptProjectRoot, 'shell.sh'), "printf 'packed script stdout\\n'\nprintf 'packed script stderr\\n' >&2\n"),
]);
await execFile('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', tarball], {
cwd: scriptProjectRoot,
env: installedEnvironment(),
});
// The two consumers are disjoint directories; npm's cache handles the
// concurrent installs (the scaffolder e2e relies on the same property).
await Promise.all([projectRoot, scriptProjectRoot].map(async (consumer) =>
execFile('npm', ['install', ...npmInstallArguments, tarball], {
cwd: consumer,
env: installedEnvironment(),
})));

const cli = join(projectRoot, 'node_modules', '.bin', 'agent-bundle');
const installedPackage = await realpath(join(projectRoot, 'node_modules', 'agent-bundle'));
Expand Down Expand Up @@ -331,7 +331,7 @@ it('uses only an installed tarball after source deletion', async () => {
'',
].join('\n')),
]);
await execFile('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', tarball], {
await execFile('npm', ['install', ...npmInstallArguments, tarball], {
cwd: frameworkRoot,
env: installedEnvironment(),
});
Expand Down
41 changes: 8 additions & 33 deletions packages/agent-bundle/tests/public-api-packed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { promisify } from 'node:util';
import { expect, it } from '@rstest/core';

import { writeFixtureManifest } from './support/manifest.ts';
import { npmInstallArguments, sharedPackedTarball } from './support/shared-pack.ts';

interface PackageManifest {
bin: {
Expand All @@ -18,14 +19,6 @@ interface PackageManifest {
const execFile = promisify(executeFile);
const workspaceRoot = process.cwd();
const packageRoot = join(workspaceRoot, 'packages/agent-bundle');
let buildPromise: Promise<void> | undefined;

const buildPackage = async (): Promise<void> => {
buildPromise ??= execFile('pnpm', ['build'], {
cwd: workspaceRoot,
}).then(() => undefined);
await buildPromise;
};

const readPackageManifest = async (): Promise<PackageManifest> =>
JSON.parse(
Expand Down Expand Up @@ -58,18 +51,14 @@ const producerFrom = async (output: string): Promise<{ readonly name: string; re
};

it('writes the package version as the producer of a packed CLI manifest', async () => {
await buildPackage();
const { tarball } = await sharedPackedTarball('agent-bundle');

const consumerRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-packed-manifest-'));
const manifest = await readPackageManifest();
try {
const { stdout: packedOutput } = await execFile(
'npm', ['pack', '--json', '--pack-destination', consumerRoot], { cwd: packageRoot },
);
const [packed] = JSON.parse(packedOutput) as Array<{ filename: string }>;
await writeFile(join(consumerRoot, 'package.json'), '{"type":"module"}\n');
await execFile(
'npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', join(consumerRoot, packed.filename)],
'npm', ['install', ...npmInstallArguments, tarball],
{ cwd: consumerRoot },
);

Expand All @@ -94,22 +83,14 @@ it('writes the package version as the producer of a packed CLI manifest', async
}, 30_000);

it('imports the externalized config entry from a packed npm consumer', async () => {
await buildPackage();
const { tarball } = await sharedPackedTarball('agent-bundle');

const consumerRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-consumer-'));
try {
const { stdout: packedOutput } = await execFile(
'npm',
['pack', '--json', '--pack-destination', consumerRoot],
{ cwd: packageRoot },
);
const [packed] = JSON.parse(packedOutput) as Array<{ filename: string }>;
const tarball = join(consumerRoot, packed.filename);

await writeFile(join(consumerRoot, 'package.json'), '{"type":"module"}\n');
await execFile(
'npm',
['install', '--ignore-scripts', '--no-audit', '--no-fund', tarball],
['install', ...npmInstallArguments, tarball],
{ cwd: consumerRoot },
);

Expand Down Expand Up @@ -161,10 +142,10 @@ it('imports the externalized config entry from a packed npm consumer', async ()
} finally {
await rm(consumerRoot, { force: true, recursive: true });
}
}, 15_000);
}, 30_000);

it('invokes a prebuilt MCP server from a clean packed consumer', async () => {
await buildPackage();
const { tarball } = await sharedPackedTarball('agent-bundle');

const consumerRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-consumer-'));
try {
Expand Down Expand Up @@ -214,16 +195,10 @@ it('invokes a prebuilt MCP server from a clean packed consumer', async () => {
'{"hooks":[]}\n',
);

const { stdout: packedOutput } = await execFile(
'npm',
['pack', '--json', '--pack-destination', consumerRoot],
{ cwd: packageRoot },
);
const [packed] = JSON.parse(packedOutput) as Array<{ filename: string }>;
await writeFile(join(consumerRoot, 'package.json'), '{"type":"module"}\n');
await execFile(
'npm',
['install', '--ignore-scripts', '--no-audit', '--no-fund', join(consumerRoot, packed.filename)],
['install', ...npmInstallArguments, tarball],
{ cwd: consumerRoot },
);
const { stdout } = await execFile(process.execPath, [
Expand Down
Loading
Loading