ci(eve): report build profile timings (#815)

Signed-off-by: Casey Gowrie <ctgowrie@gmail.com>
This commit is contained in:
Casey Gowrie
2026-07-15 13:05:42 -04:00
committed by GitHub
parent bfbbe92174
commit 56a1174fe7
10 changed files with 843 additions and 64 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
"eve": patch
---
Add `eve build --profile <path>` for a machine-readable build-timing and final-output-size report.
Add `eve build --profile <path>` for a machine-readable build-timing and final-output-size report. Profile collection is best-effort, so reporting failures do not fail an otherwise successful build.
+137 -21
View File
@@ -3,7 +3,6 @@ name: Bundle Analysis
on:
workflow_dispatch:
pull_request:
branches: [main]
types:
- opened
- reopened
@@ -11,12 +10,14 @@ on:
- labeled
paths:
- ".github/workflows/bundle-analysis.yml"
- "e2e/fixtures/agent-tools-sandbox/**"
- "apps/fixtures/weather-agent/**"
- "package.json"
- "packages/eve/**"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "scripts/init-install-report.mjs"
- "scripts/build-profile-report.mjs"
- "scripts/nitro-bundle-report-compare.mjs"
- "scripts/nitro-bundle-report-budget.mjs"
- "scripts/nitro-bundle-report.mjs"
@@ -36,6 +37,10 @@ jobs:
runs-on: ubuntu-latest
env:
BUNDLE_WARNING_ACK_LABEL: acknowledge-bundle-warning
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_USE_EXPERIMENTAL_FRAMEWORKS: "1"
steps:
- name: Resolve workflow mode
@@ -105,29 +110,30 @@ jobs:
if: ${{ steps.mode.outputs.acknowledge_only != 'true' }}
run: pnpm install --frozen-lockfile
- name: Prepare main baseline worktree
- name: Prepare pull request base worktree
if: ${{ steps.mode.outputs.acknowledge_only != 'true' && github.event_name == 'pull_request' }}
id: baseline
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
BASELINE_ROOT="$RUNNER_TEMP/eve-main-baseline"
BASELINE_ROOT="$RUNNER_TEMP/eve-pr-base"
rm -rf "$BASELINE_ROOT"
git worktree add --detach "$BASELINE_ROOT" "$BASE_SHA"
echo "root=$BASELINE_ROOT" >> "$GITHUB_OUTPUT"
echo "label=main (${BASE_SHA:0:7})" >> "$GITHUB_OUTPUT"
echo "label=${BASE_REF} (${BASE_SHA:0:7})" >> "$GITHUB_OUTPUT"
- name: Install main baseline dependencies
- name: Install pull request base dependencies
if: ${{ steps.mode.outputs.acknowledge_only != 'true' && github.event_name == 'pull_request' }}
working-directory: ${{ steps.baseline.outputs.root }}
run: pnpm install --frozen-lockfile
- name: Build eve package on main baseline
- name: Build eve package on pull request base
if: ${{ steps.mode.outputs.acknowledge_only != 'true' && github.event_name == 'pull_request' }}
working-directory: ${{ steps.baseline.outputs.root }}
run: pnpm --filter eve build
- name: Resolve weather app on main baseline
- name: Resolve weather app on pull request base
if: ${{ steps.mode.outputs.acknowledge_only != 'true' && github.event_name == 'pull_request' }}
id: baseline_weather_app
working-directory: ${{ steps.baseline.outputs.root }}
@@ -146,26 +152,26 @@ jobs:
exit 1
fi
- name: Build weather app on main baseline in Vercel mode
- name: Build weather app on pull request base for size analysis
if: ${{ steps.mode.outputs.acknowledge_only != 'true' && github.event_name == 'pull_request' }}
id: baseline_build
continue-on-error: true
working-directory: ${{ steps.baseline.outputs.root }}
run: >-
VERCEL=1 pnpm --filter "${STEPS_BASELINE_WEATHER_APP_OUTPUTS_FILTER}"
exec eve build --skip-sandbox-prewarm
env:
STEPS_BASELINE_WEATHER_APP_OUTPUTS_FILTER: ${{ steps.baseline_weather_app.outputs.filter }}
run: >-
VERCEL=1 pnpm --filter "${STEPS_BASELINE_WEATHER_APP_OUTPUTS_FILTER}" \
exec eve build --skip-sandbox-prewarm
- name: Note skipped main baseline
- name: Note skipped pull request base size analysis
if: ${{ steps.mode.outputs.acknowledge_only != 'true' && github.event_name == 'pull_request' && steps.baseline_build.outcome != 'success' }}
run: |
echo "Main baseline build failed; bundle analysis will run without a baseline comparison." >> "$GITHUB_STEP_SUMMARY"
echo "Pull request base build failed; bundle analysis will run without a baseline comparison." >> "$GITHUB_STEP_SUMMARY"
- name: Generate main baseline bundle report
- name: Generate pull request base bundle report
if: ${{ steps.mode.outputs.acknowledge_only != 'true' && github.event_name == 'pull_request' && steps.baseline_build.outcome == 'success' }}
env:
BASELINE_REPORT_JSON: ${{ runner.temp }}/weather-agent-bundle-report-main.json
BASELINE_REPORT_JSON: ${{ runner.temp }}/weather-agent-bundle-report-base.json
STEPS_BASELINE_WEATHER_APP_OUTPUTS_PATH: ${{ steps.baseline_weather_app.outputs.path }}
working-directory: ${{ steps.baseline.outputs.root }}
run: |
@@ -176,20 +182,89 @@ jobs:
--package-label packages/eve \
--output-json "$BASELINE_REPORT_JSON"
- name: Build sandbox benchmark on pull request base in Vercel mode
if: ${{ steps.mode.outputs.acknowledge_only != 'true' && github.event_name == 'pull_request' }}
id: baseline_sandbox_build
continue-on-error: true
working-directory: ${{ steps.baseline.outputs.root }}
env:
BASELINE_SANDBOX_BUILD_PROFILE: ${{ runner.temp }}/sandbox-build-profile-base.json
run: |
token_args=()
if [ -n "${VERCEL_TOKEN:-}" ]; then
token_args=(--token "$VERCEL_TOKEN")
fi
team_args=()
if [ -n "${VERCEL_ORG_ID:-}" ]; then
team_args=(--team "$VERCEL_ORG_ID")
fi
pnpm --filter agent-tools-sandbox exec vc link --yes \
--project "$VERCEL_PROJECT_ID" "${team_args[@]}" "${token_args[@]}"
pnpm --filter agent-tools-sandbox exec vc env pull --yes --environment=preview \
"${token_args[@]}"
profile_args=()
if pnpm --filter agent-tools-sandbox exec eve build --help | grep -Fq -- "--profile"; then
profile_args=(--profile "$BASELINE_SANDBOX_BUILD_PROFILE")
else
echo "Pull request base predates eve build --profile; build timing will report the current build only." >> "$GITHUB_STEP_SUMMARY"
fi
# Sandbox templates key on VERCEL_PROJECT_ID at build and runtime.
# Do not add VERCEL_TEAM_ID: runtime does not receive it.
VERCEL=1 \
VERCEL_ENV=preview \
VERCEL_TARGET_ENV=preview \
VERCEL_PROJECT_ID="$VERCEL_PROJECT_ID" \
pnpm --filter agent-tools-sandbox exec eve build "${profile_args[@]}"
- name: Note skipped sandbox benchmark base
if: ${{ steps.mode.outputs.acknowledge_only != 'true' && github.event_name == 'pull_request' && steps.baseline_sandbox_build.outcome != 'success' }}
run: |
echo "Pull request base sandbox benchmark failed; build timing will run without a baseline comparison." >> "$GITHUB_STEP_SUMMARY"
- name: Build eve package
if: ${{ steps.mode.outputs.acknowledge_only != 'true' }}
run: pnpm --filter eve build
- name: Build weather-agent in Vercel mode
- name: Build weather-agent for size analysis
if: ${{ steps.mode.outputs.acknowledge_only != 'true' }}
run: >-
VERCEL=1 pnpm --filter weather-agent
exec eve build --skip-sandbox-prewarm
- name: Build sandbox benchmark in Vercel mode
if: ${{ steps.mode.outputs.acknowledge_only != 'true' }}
env:
SANDBOX_BUILD_PROFILE: ${{ runner.temp }}/sandbox-build-profile.json
run: |
token_args=()
if [ -n "${VERCEL_TOKEN:-}" ]; then
token_args=(--token "$VERCEL_TOKEN")
fi
team_args=()
if [ -n "${VERCEL_ORG_ID:-}" ]; then
team_args=(--team "$VERCEL_ORG_ID")
fi
pnpm --filter agent-tools-sandbox exec vc link --yes \
--project "$VERCEL_PROJECT_ID" "${team_args[@]}" "${token_args[@]}"
pnpm --filter agent-tools-sandbox exec vc env pull --yes --environment=preview \
"${token_args[@]}"
# Sandbox templates key on VERCEL_PROJECT_ID at build and runtime.
# Do not add VERCEL_TEAM_ID: runtime does not receive it.
VERCEL=1 \
VERCEL_ENV=preview \
VERCEL_TARGET_ENV=preview \
VERCEL_PROJECT_ID="$VERCEL_PROJECT_ID" \
pnpm --filter agent-tools-sandbox exec eve build --profile "$SANDBOX_BUILD_PROFILE"
- name: Generate bundle report
if: ${{ steps.mode.outputs.acknowledge_only != 'true' }}
env:
BASELINE_REPORT_JSON: ${{ runner.temp }}/weather-agent-bundle-report-main.json
BASELINE_REPORT_JSON: ${{ runner.temp }}/weather-agent-bundle-report-base.json
BASELINE_REPORT_LABEL: ${{ steps.baseline.outputs.label }}
HAS_BASELINE_REPORT: ${{ github.event_name == 'pull_request' && steps.baseline_build.outcome == 'success' }}
BUNDLE_REPORT_JSON: ${{ runner.temp }}/weather-agent-bundle-report.json
@@ -216,11 +291,49 @@ jobs:
--output-json "$BUNDLE_REPORT_JSON" \
--output-markdown "$BUNDLE_REPORT_MARKDOWN"
- name: Generate build timing report
if: ${{ steps.mode.outputs.acknowledge_only != 'true' }}
env:
BASELINE_BUILD_PROFILE: ${{ runner.temp }}/sandbox-build-profile-base.json
BASELINE_BUILD_PROFILE_LABEL: ${{ steps.baseline.outputs.label }}
BUILD_PROFILE: ${{ runner.temp }}/sandbox-build-profile.json
BUILD_PROFILE_REPORT_JSON: ${{ runner.temp }}/sandbox-build-profile-report.json
BUILD_PROFILE_REPORT_MARKDOWN: ${{ runner.temp }}/sandbox-build-profile-report.md
HAS_BASELINE_BUILD_PROFILE: ${{ github.event_name == 'pull_request' && steps.baseline_sandbox_build.outcome == 'success' }}
run: |
report_args=()
if [ "$HAS_BASELINE_BUILD_PROFILE" = "true" ] && [ -f "$BASELINE_BUILD_PROFILE" ]; then
report_args=(
--baseline-profile "$BASELINE_BUILD_PROFILE"
--baseline-label "$BASELINE_BUILD_PROFILE_LABEL"
)
fi
node ./scripts/build-profile-report.mjs \
--profile "$BUILD_PROFILE" \
--app-label e2e/fixtures/agent-tools-sandbox \
--sandbox-prewarm included \
--require-phase sandbox.prewarm \
"${report_args[@]}" \
--output-json "$BUILD_PROFILE_REPORT_JSON" \
--output-markdown "$BUILD_PROFILE_REPORT_MARKDOWN"
- name: Combine bundle and timing reports
if: ${{ steps.mode.outputs.acknowledge_only != 'true' }}
env:
ANALYSIS_REPORT_MARKDOWN: ${{ runner.temp }}/weather-agent-analysis-report.md
BUILD_PROFILE_REPORT_MARKDOWN: ${{ runner.temp }}/sandbox-build-profile-report.md
BUNDLE_REPORT_MARKDOWN: ${{ runner.temp }}/weather-agent-bundle-report.md
run: |
cat "$BUNDLE_REPORT_MARKDOWN" > "$ANALYSIS_REPORT_MARKDOWN"
printf '\n\n' >> "$ANALYSIS_REPORT_MARKDOWN"
cat "$BUILD_PROFILE_REPORT_MARKDOWN" >> "$ANALYSIS_REPORT_MARKDOWN"
- name: Publish job summary
if: ${{ steps.mode.outputs.acknowledge_only != 'true' }}
env:
BUNDLE_REPORT_MARKDOWN: ${{ runner.temp }}/weather-agent-bundle-report.md
run: cat "$BUNDLE_REPORT_MARKDOWN" >> "$GITHUB_STEP_SUMMARY"
ANALYSIS_REPORT_MARKDOWN: ${{ runner.temp }}/weather-agent-analysis-report.md
run: cat "$ANALYSIS_REPORT_MARKDOWN" >> "$GITHUB_STEP_SUMMARY"
- name: Upload bundle report artifact
if: ${{ steps.mode.outputs.acknowledge_only != 'true' }}
@@ -230,18 +343,21 @@ jobs:
path: |
${{ runner.temp }}/weather-agent-bundle-report.json
${{ runner.temp }}/weather-agent-bundle-report.md
${{ runner.temp }}/sandbox-build-profile*.json
${{ runner.temp }}/sandbox-build-profile*.md
${{ runner.temp }}/weather-agent-analysis-report.md
- name: Update pull request comment
if: ${{ steps.mode.outputs.acknowledge_only != 'true' && github.event_name == 'pull_request' }}
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
BUNDLE_REPORT_MARKDOWN: ${{ runner.temp }}/weather-agent-bundle-report.md
ANALYSIS_REPORT_MARKDOWN: ${{ runner.temp }}/weather-agent-analysis-report.md
with:
script: |
const fs = require("node:fs");
const marker = "<!-- eve-weather-agent-bundle-analysis -->";
const reportBody = fs.readFileSync(process.env.BUNDLE_REPORT_MARKDOWN, "utf8").trim();
const reportBody = fs.readFileSync(process.env.ANALYSIS_REPORT_MARKDOWN, "utf8").trim();
const body = `${marker}\n${reportBody}`;
const comments = await github.paginate(github.rest.issues.listComments, {
+17 -2
View File
@@ -36,7 +36,7 @@ jobs:
fi
# HEAD is the PR merge commit; HEAD^1 is the base branch tip, so
# this diff is exactly the PR's changed files.
if git diff --name-only HEAD^1 HEAD | grep -qE '^(packages/eve/|apps/fixtures/|e2e/|pnpm-workspace\.yaml$|pnpm-lock\.yaml$|\.github/workflows/e2e-vercel\.yml$|\.github/scripts/discover-e2e-fixtures\.sh$)'; then
if git diff --name-only HEAD^1 HEAD | grep -qE '^(packages/eve/|apps/fixtures/|e2e/|pnpm-workspace\.yaml$|pnpm-lock\.yaml$|scripts/build-profile-report\.mjs$|\.github/workflows/e2e-vercel\.yml$|\.github/scripts/discover-e2e-fixtures\.sh$)'; then
echo "relevant=true" >> "$GITHUB_OUTPUT"
else
echo "relevant=false" >> "$GITHUB_OUTPUT"
@@ -112,6 +112,8 @@ jobs:
- name: Deploy fixture and run evals
if: needs.changes.outputs.relevant == 'true'
env:
EVE_BUILD_PROFILE: ${{ runner.temp }}/eve-build-profile-${{ matrix.fixture.name }}-${{ matrix.model.name }}.json
run: |
set -euo pipefail
mkdir -p "$EVE_EVAL_JUNIT_DIR"
@@ -137,7 +139,7 @@ jobs:
VERCEL_ENV=preview \
VERCEL_TARGET_ENV=preview \
VERCEL_PROJECT_ID="$VERCEL_PROJECT_ID" \
pnpm exec eve build
pnpm exec eve build --profile "$EVE_BUILD_PROFILE"
DEPLOYMENT_URL="$(pnpm exec vc deploy --prebuilt --yes --target=preview \
--env "EVE_E2E_MODEL=$EVE_E2E_MODEL" "${token_args[@]}" | tail -n 1)"
@@ -160,6 +162,19 @@ jobs:
--junit "$EVE_EVAL_JUNIT_DIR/${{ matrix.fixture.name }}-${{ matrix.model.name }}-redeploy.xml"
fi
- name: Log deployable build timing
if: needs.changes.outputs.relevant == 'true'
env:
BUILD_PROFILE: ${{ runner.temp }}/eve-build-profile-${{ matrix.fixture.name }}-${{ matrix.model.name }}.json
FIXTURE_PATH: ${{ matrix.fixture.dir }}
run: |
node ./scripts/build-profile-report.mjs \
--profile "$BUILD_PROFILE" \
--app-label "$FIXTURE_PATH" \
--sandbox-prewarm included \
--require-phase sandbox.prewarm \
--output-format build-time
- name: Upload eval artifacts
if: (failure()) && needs.changes.outputs.relevant == 'true'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v6
+5 -5
View File
@@ -95,10 +95,10 @@ eve build [--profile <path>] [--skip-sandbox-prewarm]
Compiles and bundles in an invocation-owned directory under `.eve/builds/`, then publishes the completed host output and prints its path. Scratch workspaces are removed after success or failure.
| Flag | Type | Default | Description |
| ------------------------ | ------ | ------- | ----------------------------------------------------------------------------------------- |
| `--profile <path>` | string | off | Write a versioned JSON report with build-phase timings and final output-size measurements |
| `--skip-sandbox-prewarm` | flag | off | Skip sandbox template prewarm for a Vercel build; the output might not be deployable |
| Flag | Type | Default | Description |
| ------------------------ | ------ | ------- | --------------------------------------------------------------------------------------------- |
| `--profile <path>` | string | off | Best-effort versioned JSON report with build-phase timings and final output-size measurements |
| `--skip-sandbox-prewarm` | flag | off | Skip sandbox template prewarm for a Vercel build; the output might not be deployable |
Use a profile file to establish a repeatable baseline before changing the build pipeline:
@@ -106,7 +106,7 @@ Use a profile file to establish a repeatable baseline before changing the build
eve build --profile .eve/build-profiles/baseline.json
```
The report is written only after a successful build. It records total elapsed time, completed phase timings, and final regular-file totals for file count, raw bytes, and the sum of each file compressed with gzip. For Vercel output it also includes a subtotal for every real `.func` directory, so app and flow bundles can be compared separately. The profile path resolves from the app root and must be outside the published output directory; profile collection does not add a file to the deployment.
The report is attempted only after a successful build. It records total elapsed time, completed phase timings, and final regular-file totals for file count, raw bytes, and the sum of each file compressed with gzip. For Vercel output it also includes a subtotal for every real `.func` directory, so app and flow bundles can be compared separately. The profile path resolves from the app root and should be outside the published output directory; profile collection does not add a file to the deployment. If collection or writing fails, eve emits a warning but keeps the completed build successful.
Production builds do not write through the stable compiler, host, Nitro, or Workflow files owned by `eve dev`, so builds can run while a local dev server is active. A failed build leaves the last successful `.output/` and agent summary untouched. Concurrent completed builds serialize only the final publication window.
+1 -10
View File
@@ -28,7 +28,7 @@ export function registerBuildCommand(input: {
input.program
.command("build")
.description("Build the current eve application.")
.option("--profile <path>", "Write timing and output-size profile JSON to a file")
.option("--profile <path>", "Write best-effort timing and output-size profile JSON to a file")
.option(
"--skip-sandbox-prewarm",
"Skip sandbox template prewarm for a Vercel build; output may not be deployable",
@@ -61,14 +61,5 @@ export function registerBuildCommand(input: {
tone: "success",
}),
);
if (profileOutputPath !== undefined) {
input.logger.log(
renderCliTaggedLine(theme, {
message: `wrote build profile to ${profileOutputPath}`,
tag: "build",
tone: "success",
}),
);
}
});
}
+2 -6
View File
@@ -405,14 +405,13 @@ describe("eve dev local server ownership", () => {
});
describe("eve build output ownership", () => {
it("forwards a profile path relative to the application root and reports it", async () => {
it("forwards a profile path relative to the application root", async () => {
const buildHost = vi.fn(async () => "/app/.output");
const output: string[] = [];
const profilePath = ".eve/build-profiles/notes.json";
await runCli(
["build", "--profile", profilePath],
{ error: () => {}, log: (message) => output.push(message) },
{ error: () => {}, log: () => {} },
{ buildHost },
);
@@ -421,9 +420,6 @@ describe("eve build output ownership", () => {
skipVercelSandboxPrewarm: false,
vercelServiceOutput: undefined,
});
expect(output.join("\n")).toContain(
`wrote build profile to ${resolve(process.cwd(), profilePath)}`,
);
});
it("resolves the internal service output directory from the build working directory", async () => {
@@ -282,19 +282,67 @@ describe("buildApplication", () => {
]);
});
it("rejects a profile path inside the published output", async () => {
it("keeps a profile path inside the published output from failing or changing a build", async () => {
vi.stubEnv("VERCEL", "");
const appRoot = await createScratchDirectory("eve-build-application-profile-output-");
const profilePath = join(appRoot, ".output", "build-profile.json");
const { buildApplication } = await import("#internal/nitro/host/build-application.js");
await expect(
buildApplication(appRoot, {
profileOutputPath: join(appRoot, ".output", "build-profile.json"),
prepareProductionApplicationHostMock.mockImplementationOnce(prepareHostBuildWorkspace);
createProductionApplicationNitroMock.mockImplementationOnce(
async (_preparedHost: PreparedApplicationHost, options: { outputDir: string }) =>
createNitroStub(options.outputDir),
);
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
try {
const { buildApplication } = await import("#internal/nitro/host/build-application.js");
const outputDir = await buildApplication(appRoot, {
profileOutputPath: profilePath,
skipVercelSandboxPrewarm: false,
}),
).rejects.toThrow("must be outside the published output directory");
});
expect(prepareProductionApplicationHostMock).not.toHaveBeenCalled();
expect(outputDir).toBe(join(appRoot, ".output"));
await expect(readFile(profilePath, "utf8")).rejects.toThrow();
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("must be outside the published output directory"),
);
} finally {
warn.mockRestore();
}
});
it("keeps profile write errors from failing a completed build", async () => {
vi.stubEnv("VERCEL", "");
const appRoot = await createScratchDirectory("eve-build-application-profile-write-error-");
const profileParentPath = join(appRoot, "profile-parent");
const profilePath = join(profileParentPath, "build.json");
prepareProductionApplicationHostMock.mockImplementationOnce(prepareHostBuildWorkspace);
createProductionApplicationNitroMock.mockImplementationOnce(
async (_preparedHost: PreparedApplicationHost, options: { outputDir: string }) =>
createNitroStub(options.outputDir),
);
await writeFile(profileParentPath, "not-a-directory\n");
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
try {
const { buildApplication } = await import("#internal/nitro/host/build-application.js");
const outputDir = await buildApplication(appRoot, {
profileOutputPath: profilePath,
skipVercelSandboxPrewarm: false,
});
expect(outputDir).toBe(join(appRoot, ".output"));
await expect(readFile(join(outputDir, "eve-cache.json"), "utf8")).resolves.toContain(
"eveVersion",
);
await expect(readFile(profilePath, "utf8")).rejects.toThrow();
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("failed to write optional build profile"),
);
} finally {
warn.mockRestore();
}
});
it("keeps the last-good output when Nitro mutates its target before failing", async () => {
@@ -339,6 +387,7 @@ describe("buildApplication", () => {
it("builds isolated Vercel Nitro surfaces and stitches workflow functions", async () => {
vi.stubEnv("VERCEL", "1");
const appRoot = await createScratchDirectory("eve-build-application-vercel-");
const profilePath = join(appRoot, ".eve", "profiles", "vercel-build.json");
const stableFlowOutputDir = join(appRoot, ".eve", "nitro-output", "flow");
const staleFlowOutputPath = join(stableFlowOutputDir, "stale-flow.txt");
@@ -382,7 +431,10 @@ describe("buildApplication", () => {
);
const { buildApplication } = await import("#internal/nitro/host/build-application.js");
const outputDir = await buildApplication(appRoot, DEPLOYABLE_BUILD_OPTIONS);
const outputDir = await buildApplication(appRoot, {
...DEPLOYABLE_BUILD_OPTIONS,
profileOutputPath: profilePath,
});
expect(outputDir).toBe(join(appRoot, ".vercel", "output"));
expect(createProductionApplicationNitroMock).toHaveBeenCalledTimes(2);
@@ -450,6 +502,11 @@ describe("buildApplication", () => {
log: expect.any(Function),
}),
);
const profile = JSON.parse(await readFile(profilePath, "utf8")) as ApplicationBuildProfile;
expect(profile.target).toBe("vercel");
expect(profile.phases).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "sandbox.prewarm" })]),
);
const summary = JSON.parse(
await readFile(join(appRoot, VERCEL_EVE_AGENT_SUMMARY_OUTPUT_PATH), "utf8"),
@@ -39,6 +39,7 @@ import type {
PreparedApplicationHost,
} from "#internal/nitro/host/types.js";
import { findClosestVercelOutputDirectory } from "#shared/vercel-output-directory.js";
import { toErrorMessage } from "#shared/errors.js";
import { resolveDiscoveryProject } from "#discover/project.js";
import { createDiskRuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js";
@@ -73,6 +74,30 @@ function assertProfileOutputOutsideBuildOutput(
}
}
async function writeOptionalApplicationBuildProfile(input: {
readonly outputDirectory: string;
readonly profileOutputPath: string;
readonly profiler: ApplicationBuildProfiler;
}): Promise<void> {
try {
assertProfileOutputOutsideBuildOutput(input.profileOutputPath, input.outputDirectory);
const timing = input.profiler.finish();
const output = await measureApplicationBuildOutput(input.outputDirectory);
await writeApplicationBuildProfile(
input.profileOutputPath,
createApplicationBuildProfile({
output,
target: process.env.VERCEL ? "vercel" : "local",
timing,
}),
);
} catch (error) {
console.warn(
`eve: failed to write optional build profile to ${input.profileOutputPath}; continuing with the published build output: ${toErrorMessage(error)}`,
);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -389,7 +414,6 @@ export async function buildApplication(
let preserveWorkspaceForRecovery = false;
let outputDirectory: string;
try {
assertProfileOutputOutsideBuildOutput(profileOutputPath, workspace.publication.output.finalDir);
outputDirectory = await buildApplicationInWorkspace(workspace, options, profiler);
} catch (error) {
preserveWorkspaceForRecovery = error instanceof RecoverablePublicationError;
@@ -403,16 +427,11 @@ export async function buildApplication(
}
if (profiler !== undefined && profileOutputPath !== undefined) {
const timing = profiler.finish();
const output = await measureApplicationBuildOutput(outputDirectory);
await writeApplicationBuildProfile(
await writeOptionalApplicationBuildProfile({
outputDirectory,
profileOutputPath,
createApplicationBuildProfile({
output,
target: process.env.VERCEL ? "vercel" : "local",
timing,
}),
);
profiler,
});
}
return outputDirectory;
@@ -0,0 +1,193 @@
import { describe, expect, it } from "vitest";
interface BuildProfile {
durationMs: number;
kind: "eve-build-profile";
phases: Array<{
durationMs: number;
name: string;
}>;
schemaVersion: 1;
target: "local" | "vercel";
}
interface BuildProfileReport {
baseline: null | {
label: string;
profile: BuildProfile;
};
comparison: null | {
durationMs: {
baseline: number;
current: number;
delta: number;
};
phases: Array<{
baselineDurationMs: null | number;
currentDurationMs: null | number;
deltaMs: null | number;
name: string;
}>;
};
current: {
label: string;
profile: BuildProfile;
};
sandboxPrewarm: "included" | "skipped";
}
interface BuildProfileReportModule {
createBuildProfileReport(input: {
appLabel: string;
baselineLabel?: string;
baselineProfile?: BuildProfile | null;
currentLabel?: string;
currentProfile: BuildProfile;
requiredCurrentPhases?: string[];
sandboxPrewarm?: "included" | "skipped";
}): BuildProfileReport;
renderBuildProfileReportMarkdown(report: BuildProfileReport): string;
renderBuildProfileTimingLog(report: BuildProfileReport): string;
}
async function loadBuildProfileReportModule(): Promise<BuildProfileReportModule> {
const moduleUrl = new URL("../../../scripts/build-profile-report.mjs", import.meta.url);
return (await import(moduleUrl.href)) as BuildProfileReportModule;
}
function createProfile(input: {
durationMs: number;
phases: BuildProfile["phases"];
}): BuildProfile {
return {
durationMs: input.durationMs,
kind: "eve-build-profile",
phases: input.phases,
schemaVersion: 1,
target: "vercel",
};
}
describe("build profile report", () => {
it("renders a phase-by-phase comparison against a baseline profile", async () => {
const { createBuildProfileReport, renderBuildProfileReportMarkdown } =
await loadBuildProfileReportModule();
const report = createBuildProfileReport({
appLabel: "apps/fixtures/weather-agent",
baselineLabel: "main (abc1234)",
baselineProfile: createProfile({
durationMs: 1_000,
phases: [
{ durationMs: 600, name: "nitro.app.bundle" },
{ durationMs: 200, name: "workflow.emit" },
],
}),
currentProfile: createProfile({
durationMs: 1_250,
phases: [
{ durationMs: 700, name: "nitro.app.bundle" },
{ durationMs: 300, name: "workflow.emit" },
{ durationMs: 40, name: "output.publish" },
],
}),
sandboxPrewarm: "skipped",
});
const markdown = renderBuildProfileReportMarkdown(report);
expect(report.comparison?.durationMs).toEqual({
baseline: 1_000,
current: 1_250,
delta: 250,
});
expect(report.comparison?.phases).toEqual([
{
baselineDurationMs: 600,
currentDurationMs: 700,
deltaMs: 100,
name: "nitro.app.bundle",
},
{
baselineDurationMs: 200,
currentDurationMs: 300,
deltaMs: 100,
name: "workflow.emit",
},
{
baselineDurationMs: null,
currentDurationMs: 40,
deltaMs: null,
name: "output.publish",
},
]);
expect(markdown).toContain("## Build Timing: `apps/fixtures/weather-agent`");
expect(markdown).toContain("sandbox template prewarm skipped");
expect(markdown).toContain(
"Build pipeline: 1.00 s -> **1.25 s** (+250.0 ms) vs `main (abc1234)`.",
);
expect(markdown).toContain("<details>");
expect(markdown).toContain("<summary>Detailed phase timings vs `main (abc1234)`</summary>");
expect(markdown).toContain("| `nitro.app.bundle` | 600.0 ms | 700.0 ms | +100.0 ms |");
expect(markdown).toContain("| `output.publish` | — | 40.0 ms | — |");
});
it("renders the current profile without a baseline", async () => {
const { createBuildProfileReport, renderBuildProfileReportMarkdown } =
await loadBuildProfileReportModule();
const report = createBuildProfileReport({
appLabel: "apps/fixtures/weather-agent",
currentProfile: createProfile({
durationMs: 500,
phases: [{ durationMs: 450, name: "nitro.flow.bundle" }],
}),
});
const markdown = renderBuildProfileReportMarkdown(report);
expect(report.baseline).toBeNull();
expect(report.comparison).toBeNull();
expect(markdown).toContain("Current build pipeline: **500.0 ms** (target: `vercel`).");
expect(markdown).toContain("deployable Vercel build with sandbox template prewarm included");
expect(markdown).toContain("No baseline build profile was supplied");
expect(markdown).toContain("<details>");
expect(markdown).toContain("<summary>Detailed phase timings</summary>");
expect(markdown).toContain("| `nitro.flow.bundle` | 450.0 ms |");
});
it("requires prewarm when a deployable-build regression check requests it", async () => {
const { createBuildProfileReport } = await loadBuildProfileReportModule();
expect(() =>
createBuildProfileReport({
appLabel: "apps/fixtures/weather-agent",
currentProfile: createProfile({
durationMs: 500,
phases: [{ durationMs: 450, name: "nitro.flow.bundle" }],
}),
requiredCurrentPhases: ["sandbox.prewarm"],
}),
).toThrow('Current build profile is missing required phase "sandbox.prewarm".');
});
it("renders only the build time for E2E logs without copying unrecognized profile fields", async () => {
const { createBuildProfileReport, renderBuildProfileTimingLog } =
await loadBuildProfileReportModule();
const profile = {
...createProfile({
durationMs: 500,
phases: [{ durationMs: 450, name: "nitro.flow.bundle" }],
}),
VERCEL_OIDC_TOKEN: "must-not-appear-in-summary",
};
const report = createBuildProfileReport({
appLabel: "e2e/fixtures/agent-tools-sandbox",
currentProfile: profile,
});
const output = renderBuildProfileTimingLog(report);
expect(output).toBe(
"eve build (e2e/fixtures/agent-tools-sandbox): 500.0 ms (sandbox template prewarm included).",
);
expect(output).not.toContain("must-not-appear-in-summary");
expect(JSON.stringify(report)).not.toContain("must-not-appear-in-summary");
});
});
+392
View File
@@ -0,0 +1,392 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const BUILD_PROFILE_KIND = "eve-build-profile";
const BUILD_PROFILE_SCHEMA_VERSION = 1;
const BUILD_PROFILE_REPORT_KIND = "eve-build-profile-report";
const BUILD_PROFILE_REPORT_SCHEMA_VERSION = 1;
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function readDuration(value, label) {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new Error(`${label} must be a non-negative finite number.`);
}
return value;
}
function readBuildProfile(value, sourceLabel) {
if (!isRecord(value)) {
throw new Error(`${sourceLabel} must be a JSON object.`);
}
if (value.kind !== BUILD_PROFILE_KIND) {
throw new Error(`${sourceLabel} must have kind "${BUILD_PROFILE_KIND}".`);
}
if (value.schemaVersion !== BUILD_PROFILE_SCHEMA_VERSION) {
throw new Error(
`${sourceLabel} must use schema version ${BUILD_PROFILE_SCHEMA_VERSION}, received ${String(value.schemaVersion)}.`,
);
}
if (value.target !== "local" && value.target !== "vercel") {
throw new Error(`${sourceLabel} must have target "local" or "vercel".`);
}
if (!Array.isArray(value.phases)) {
throw new Error(`${sourceLabel}.phases must be an array.`);
}
const phaseNames = new Set();
const phases = value.phases.map((phase, index) => {
if (!isRecord(phase) || typeof phase.name !== "string" || phase.name.length === 0) {
throw new Error(`${sourceLabel}.phases[${index}].name must be a non-empty string.`);
}
if (phaseNames.has(phase.name)) {
throw new Error(`${sourceLabel}.phases must not contain duplicate phase names.`);
}
phaseNames.add(phase.name);
return {
durationMs: readDuration(phase.durationMs, `${sourceLabel}.phases[${index}].durationMs`),
name: phase.name,
};
});
return {
durationMs: readDuration(value.durationMs, `${sourceLabel}.durationMs`),
phases,
target: value.target,
};
}
function formatDuration(durationMs) {
if (durationMs < 1_000) {
return `${durationMs.toFixed(1)} ms`;
}
return `${(durationMs / 1_000).toFixed(2)} s`;
}
function formatSignedDuration(durationMs) {
if (durationMs === 0) {
return "0.0 ms";
}
return `${durationMs > 0 ? "+" : "-"}${formatDuration(Math.abs(durationMs))}`;
}
function formatMarkdownCode(value) {
return String(value).replaceAll("`", "\\`");
}
function createPhaseComparison(currentProfile, baselineProfile) {
const currentPhases = new Map(currentProfile.phases.map((phase) => [phase.name, phase]));
const baselinePhases = new Map(baselineProfile.phases.map((phase) => [phase.name, phase]));
const phaseNames = [
...currentProfile.phases.map((phase) => phase.name),
...baselineProfile.phases
.map((phase) => phase.name)
.filter((phaseName) => !currentPhases.has(phaseName)),
];
return phaseNames.map((name) => {
const current = currentPhases.get(name) ?? null;
const baseline = baselinePhases.get(name) ?? null;
return {
baselineDurationMs: baseline?.durationMs ?? null,
currentDurationMs: current?.durationMs ?? null,
deltaMs:
current === null || baseline === null ? null : current.durationMs - baseline.durationMs,
name,
};
});
}
/**
* Creates a stable, CI-oriented view of one or two `eve build --profile` files.
*/
export function createBuildProfileReport(input) {
const current = readBuildProfile(input.currentProfile, "Current build profile");
const baseline =
input.baselineProfile === undefined || input.baselineProfile === null
? null
: readBuildProfile(input.baselineProfile, "Baseline build profile");
if (baseline !== null && baseline.target !== current.target) {
throw new Error(
`Build profile targets must match: baseline is "${baseline.target}" and current is "${current.target}".`,
);
}
const requiredCurrentPhases = input.requiredCurrentPhases ?? [];
const currentPhaseNames = new Set(current.phases.map((phase) => phase.name));
for (const phaseName of requiredCurrentPhases) {
if (!currentPhaseNames.has(phaseName)) {
throw new Error(`Current build profile is missing required phase "${phaseName}".`);
}
}
const sandboxPrewarm = input.sandboxPrewarm ?? "included";
if (sandboxPrewarm !== "included" && sandboxPrewarm !== "skipped") {
throw new Error('sandboxPrewarm must be "included" or "skipped".');
}
return {
appLabel: input.appLabel,
baseline:
baseline === null
? null
: {
label: input.baselineLabel ?? "baseline",
profile: baseline,
},
comparison:
baseline === null
? null
: {
durationMs: {
baseline: baseline.durationMs,
current: current.durationMs,
delta: current.durationMs - baseline.durationMs,
},
phases: createPhaseComparison(current, baseline),
},
current: {
label: input.currentLabel ?? "current",
profile: current,
},
kind: BUILD_PROFILE_REPORT_KIND,
sandboxPrewarm,
schemaVersion: BUILD_PROFILE_REPORT_SCHEMA_VERSION,
};
}
function renderCurrentPhaseTable(profile) {
return [
"| Phase | Duration |",
"| --- | ---: |",
...profile.phases.map(
(phase) => `| \`${formatMarkdownCode(phase.name)}\` | ${formatDuration(phase.durationMs)} |`,
),
];
}
function renderComparisonPhaseTable(comparison) {
return [
"| Phase | Baseline | Current | Delta |",
"| --- | ---: | ---: | ---: |",
...comparison.phases.map((phase) => {
const baseline =
phase.baselineDurationMs === null ? "—" : formatDuration(phase.baselineDurationMs);
const current =
phase.currentDurationMs === null ? "—" : formatDuration(phase.currentDurationMs);
const delta = phase.deltaMs === null ? "—" : formatSignedDuration(phase.deltaMs);
return `| \`${formatMarkdownCode(phase.name)}\` | ${baseline} | ${current} | ${delta} |`;
}),
];
}
/** Renders one compact timing report for a GitHub job summary or pull request comment. */
export function renderBuildProfileReportMarkdown(report) {
const current = report.current.profile;
const lines = [
`## Build Timing: \`${formatMarkdownCode(report.appLabel)}\``,
"",
"This is an informational timing measurement inside `eve build`, from preflight through publication. Output-size measurement and profile writing are excluded.",
"",
report.sandboxPrewarm === "skipped"
? "**Benchmark mode:** Vercel bundling with sandbox template prewarm skipped. It is intentionally reproducible for Bundle Analysis, but not the full wall-clock time of a deployable Vercel build."
: "**Build mode:** deployable Vercel build with sandbox template prewarm included.",
"",
];
if (report.comparison === null || report.baseline === null) {
lines.push(
`- Current build pipeline: **${formatDuration(current.durationMs)}** (target: \`${current.target}\`).`,
"- No baseline build profile was supplied, so this report shows the current phases only.",
"",
"<details>",
"<summary>Detailed phase timings</summary>",
"",
...renderCurrentPhaseTable(current),
"",
"</details>",
);
return lines.join("\n");
}
const comparison = report.comparison;
lines.push(
`- Build pipeline: ${formatDuration(comparison.durationMs.baseline)} -> **${formatDuration(comparison.durationMs.current)}** (${formatSignedDuration(comparison.durationMs.delta)}) vs \`${formatMarkdownCode(report.baseline.label)}\`.`,
"- Timing is informational: shared GitHub runners are too variable for a hard timing budget.",
"",
"<details>",
`<summary>Detailed phase timings vs \`${formatMarkdownCode(report.baseline.label)}\`</summary>`,
"",
...renderComparisonPhaseTable(comparison),
"",
"</details>",
);
return lines.join("\n");
}
/** Renders one deployable-build duration for an E2E job log. */
export function renderBuildProfileTimingLog(report) {
const current = report.current.profile;
const buildMode =
report.sandboxPrewarm === "skipped"
? "sandbox template prewarm skipped"
: "sandbox template prewarm included";
return `eve build (${report.appLabel}): ${formatDuration(current.durationMs)} (${buildMode}).`;
}
function printUsage() {
process.stdout.write(
[
"Usage: node ./scripts/build-profile-report.mjs --profile <path> [options]",
"",
"Options:",
" --profile <path> Current eve build profile JSON",
" --app-label <label> Application label shown in the report",
" --baseline-profile <path> Optional baseline eve build profile JSON",
" --baseline-label <label> Display label for the baseline profile",
" --current-label <label> Display label for the current profile",
" --sandbox-prewarm <mode> Whether prewarm was included or skipped (default: included)",
" --output-format <format> Render markdown or a one-line build-time log (default: markdown)",
" --require-phase <name> Require a phase in the current profile; repeatable",
" --output-json <path> Write the JSON report to this file",
" --output-markdown <path> Write the Markdown report to this file",
" --help Show this help text",
"",
].join("\n"),
);
}
function parseArguments(argv) {
const parsedArguments = {
appLabel: "eve application",
baselineLabel: "baseline",
baselineProfilePath: null,
currentLabel: "current",
outputFormat: "markdown",
outputJsonPath: null,
outputMarkdownPath: null,
profilePath: null,
requiredCurrentPhases: [],
sandboxPrewarm: "included",
};
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--help") {
printUsage();
process.exit(0);
}
const value = argv[index + 1];
if (value === undefined) {
throw new Error(`Missing value for "${argument}".`);
}
if (argument === "--profile") {
parsedArguments.profilePath = value;
} else if (argument === "--app-label") {
parsedArguments.appLabel = value;
} else if (argument === "--baseline-profile") {
parsedArguments.baselineProfilePath = value;
} else if (argument === "--baseline-label") {
parsedArguments.baselineLabel = value;
} else if (argument === "--current-label") {
parsedArguments.currentLabel = value;
} else if (argument === "--sandbox-prewarm") {
if (value !== "included" && value !== "skipped") {
throw new Error('The "--sandbox-prewarm" value must be "included" or "skipped".');
}
parsedArguments.sandboxPrewarm = value;
} else if (argument === "--output-format") {
if (value !== "build-time" && value !== "markdown") {
throw new Error('The "--output-format" value must be "build-time" or "markdown".');
}
parsedArguments.outputFormat = value;
} else if (argument === "--require-phase") {
parsedArguments.requiredCurrentPhases.push(value);
} else if (argument === "--output-json") {
parsedArguments.outputJsonPath = value;
} else if (argument === "--output-markdown") {
parsedArguments.outputMarkdownPath = value;
} else {
throw new Error(`Unknown argument "${argument}".`);
}
index += 1;
}
if (parsedArguments.profilePath === null) {
throw new Error('The "--profile" option is required.');
}
return parsedArguments;
}
async function readJson(path) {
return JSON.parse(await readFile(resolve(path), "utf8"));
}
async function writeOutputFile(path, contents) {
await mkdir(dirname(resolve(path)), {
recursive: true,
});
await writeFile(path, contents, "utf8");
}
async function main() {
const args = parseArguments(process.argv.slice(2));
const currentProfile = await readJson(args.profilePath);
const baselineProfile =
args.baselineProfilePath === null ? null : await readJson(args.baselineProfilePath);
const report = createBuildProfileReport({
appLabel: args.appLabel,
baselineLabel: args.baselineLabel,
baselineProfile,
currentLabel: args.currentLabel,
currentProfile,
requiredCurrentPhases: args.requiredCurrentPhases,
sandboxPrewarm: args.sandboxPrewarm,
});
const output =
args.outputFormat === "build-time"
? renderBuildProfileTimingLog(report)
: renderBuildProfileReportMarkdown(report);
if (args.outputJsonPath !== null) {
await writeOutputFile(args.outputJsonPath, `${JSON.stringify(report, null, 2)}\n`);
}
if (args.outputMarkdownPath !== null) {
await writeOutputFile(args.outputMarkdownPath, `${output}\n`);
}
if (args.outputMarkdownPath === null) {
process.stdout.write(`${output}\n`);
}
}
const executedScriptPath = process.argv[1] ? resolve(process.argv[1]) : null;
const moduleScriptPath = resolve(fileURLToPath(import.meta.url));
if (executedScriptPath !== null && executedScriptPath === moduleScriptPath) {
await main();
}