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
4 changes: 3 additions & 1 deletion .github/docker/Dockerfile.web
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ RUN if [ -n "$GOOGLE_PLACES_API_KEY" ]; then \
>> compass.yaml; \
fi

RUN cd apps/calendar-web && bun run build.ts
# Measure boot gzip for logs; deploy must not fail on absolute bundle size.
ENV COMPASS_BOOT_SIZE_ENFORCE=0
RUN .github/perf/run-boot-size-check.sh

FROM oven/bun:1.3.14-slim AS runtime

Expand Down
2 changes: 1 addition & 1 deletion .github/perf/compass.boot-size.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Build-time config for the required boot-size check (test-unit.yml).
# Build-time config for boot gzip measurement (boot-bundle.yml, Dockerfile.web).
# Mirrors .github/docker/Dockerfile.web for staging-cloud deploy: same fields
# baked into compass.yaml and the same placeholder lengths for values that
# end up in the bundle (OAuth client IDs, PostHog, BUILD_VERSION via
Expand Down
4 changes: 2 additions & 2 deletions .github/perf/compass.perf.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Build-time config for the Lighthouse performance budget (perf-budget.yml).
# The required boot-size check in test-unit.yml uses compass.boot-size.yaml
# and .github/perf/run-boot-size-check.sh so it matches
# Boot gzip CI and Dockerfile.web use compass.boot-size.yaml and
# .github/perf/run-boot-size-check.sh so they match
# .github/docker/Dockerfile.web for cloud deploy.
# Only the values baked into the web bundle matter (nodeEnv, apiUrl,
# PostHog placeholders sized like production deploy); the rest
Expand Down
188 changes: 188 additions & 0 deletions .github/perf/pr-boot-size-delta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/**
* Compare deploy-shaped boot gzip on the PR head vs merge base, post a sticky
* PR comment, and exit non-zero when growth exceeds the per-PR budget unless
* the PR has the bundle-size-ok label.
*/
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";

const repoRoot = path.resolve(import.meta.dir, "../..");
const measureScript = path.join(
repoRoot,
".github/perf/run-boot-size-check.sh",
);
const maxDeltaBytes = 5 * 1024;
const commentMarker = "<!-- compass-boot-size-delta -->";
const overrideLabel = "bundle-size-ok";

interface BootMetrics {
totalGzipBytes: number;
}

function formatKb(bytes: number): string {
return `${(bytes / 1024).toFixed(1)} KB`;
}

function formatSignedKb(deltaBytes: number): string {
const kb = deltaBytes / 1024;
const sign = kb >= 0 ? "+" : "";
return `${sign}${kb.toFixed(1)} KB`;
}

function parseMetricsFile(filePath: string): BootMetrics {
const raw = JSON.parse(readFileSync(filePath, "utf8")) as unknown;
if (
!raw ||
typeof raw !== "object" ||
typeof (raw as BootMetrics).totalGzipBytes !== "number"
) {
throw new Error(`Invalid boot metrics at ${filePath}`);
}
return raw as BootMetrics;
}

function measureBootGzip(cwd: string, metricsPath: string): BootMetrics {
execFileSync("bash", [measureScript], {
cwd,
env: {
...process.env,
COMPASS_BOOT_SIZE_ENFORCE: "0",
COMPASS_BOOT_SIZE_METRICS_FILE: metricsPath,
},
stdio: "inherit",
});
return parseMetricsFile(metricsPath);
}

function ghJson(args: string[]): unknown {
const out = execFileSync("gh", args, {
encoding: "utf8",
cwd: repoRoot,
}).trim();
return out ? JSON.parse(out) : null;
}

function hasOverrideLabel(prNumber: string): boolean {
const out = execFileSync(
"gh",
["pr", "view", prNumber, "--json", "labels", "--jq", ".labels[].name"],
{ encoding: "utf8", cwd: repoRoot },
).trim();
return out.split("\n").filter(Boolean).includes(overrideLabel);
}

function upsertStickyComment(prNumber: string, body: string): void {
const marker = commentMarker;
const fullBody = `${marker}\n${body}`;
const comments = ghJson([
"api",
`repos/${process.env.GITHUB_REPOSITORY}/issues/${prNumber}/comments`,
"--paginate",
]) as Array<{ id: number; body?: string }> | null;

const existing = (comments ?? []).find((comment) =>
(comment.body ?? "").includes(marker),
);
if (existing) {
execFileSync(
"gh",
[
"api",
"--method",
"PATCH",
`repos/${process.env.GITHUB_REPOSITORY}/issues/comments/${existing.id}`,
"-f",
`body=${fullBody}`,
],
{ cwd: repoRoot, stdio: "inherit" },
);
return;
}

const commentFile = path.join(
tmpdir(),
`compass-boot-comment-${process.pid}.md`,
);
writeFileSync(commentFile, fullBody, "utf8");
try {
execFileSync(
"gh",
["pr", "comment", prNumber, "--body-file", commentFile],
{ cwd: repoRoot, stdio: "inherit" },
);
} finally {
rmSync(commentFile, { force: true });
}
}

function main(): void {
const baseSha = process.env.BASE_SHA;
const headSha = process.env.HEAD_SHA ?? "HEAD";
const prNumber = process.env.PR_NUMBER;
const repository = process.env.GITHUB_REPOSITORY;

if (!baseSha) {
throw new Error("BASE_SHA is required");
}
if (!repository) {
throw new Error("GITHUB_REPOSITORY is required");
}

const headMetricsPath = path.join(
tmpdir(),
`compass-boot-head-${process.pid}.json`,
);
const headMetrics = measureBootGzip(repoRoot, headMetricsPath);

const baseWorktree = mkdtempSync(path.join(tmpdir(), "compass-boot-base-"));
try {
execFileSync(
"git",
["worktree", "add", "--detach", baseWorktree, baseSha],
{
cwd: repoRoot,
stdio: "inherit",
},
);
execFileSync("bun", ["install", "--frozen-lockfile"], {
cwd: baseWorktree,
stdio: "inherit",
});
const baseMetricsPath = path.join(baseWorktree, "boot-base-metrics.json");
const baseMetrics = measureBootGzip(baseWorktree, baseMetricsPath);

const deltaBytes = headMetrics.totalGzipBytes - baseMetrics.totalGzipBytes;
const summary = `Boot bundle: ${formatKb(headMetrics.totalGzipBytes)} gzip (${formatSignedKb(deltaBytes)} vs main)`;

console.log(summary);
console.log(
` head ${headSha}: ${headMetrics.totalGzipBytes} B gzip, base ${baseSha}: ${baseMetrics.totalGzipBytes} B gzip`,
);

if (prNumber) {
const limitNote =
deltaBytes > maxDeltaBytes
? `\n\nDelta exceeds +${maxDeltaBytes / 1024} KB per PR. Add the \`${overrideLabel}\` label to allow intentional growth.`
: "";
upsertStickyComment(prNumber, `${summary}${limitNote}`);
}

const overridden = prNumber ? hasOverrideLabel(prNumber) : false;
if (deltaBytes > maxDeltaBytes && !overridden) {
console.error(
`Boot gzip grew ${deltaBytes} B (> ${maxDeltaBytes} B) vs ${baseSha}.`,
);
process.exit(1);
}
} finally {
rmSync(headMetricsPath, { force: true });
execFileSync("git", ["worktree", "remove", "--force", baseWorktree], {
cwd: repoRoot,
stdio: "inherit",
});
}
}

main();
4 changes: 2 additions & 2 deletions .github/scripts/agent-loop-merge-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@ pr_has_automerge() {
# closed: an unreadable result counts as red.
#
# release-on-main.yml counts too: green tests do not mean a shippable image
# (#3814-#3816). perf-budget.yml is left out because the boot-size budget it
# exercises is already a required PR check in test-unit.yml.
# (#3814-#3816). perf-budget.yml is left out because boot gzip is gated in
# boot-bundle.yml (+5 KB delta vs main).
main_is_red() {
local workflow conclusion
for workflow in test-unit.yml test-e2e.yml release-on-main.yml; do
Expand Down
4 changes: 2 additions & 2 deletions .github/scripts/detect-code-changes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ pull_request_number=${3:-}
#
# code: TS/CI-relevant changes (excludes docs and apps/calendar-macos/**).
# e2e: Playwright-reachable changes (excludes backend, sync, scripts, macos, booking-web).
# static_web: web boot-size budget and other web-blast-radius static work.
# static_web: boot gzip delta check and other web-blast-radius static work.
# contracts: bun cli contracts:swift --check (core schemas or swift-contracts emitter).
# core/web/backend/sync/scripts: unit-leg filters. packages/core, root package.json,
# bun.lock, or root tsconfig* turn every leg on. merge_group and push always run
Expand Down Expand Up @@ -99,7 +99,7 @@ backend_files=$(printf '%s\n' "$ts_code_files" | grep -E '^packages/backend(/|$)
sync_files=$(printf '%s\n' "$ts_code_files" | grep -E '^packages/sync(/|$)' || true)
scripts_files=$(printf '%s\n' "$ts_code_files" | grep -E '^packages/scripts(/|$)' || true)
static_web_files=$(printf '%s\n' "$ts_code_files" |
grep -E '^(apps/calendar-web(/|$)|\.github/perf/)' || true)
grep -E '^(apps/calendar-web(/|$)|\.github/perf/|\.github/workflows/boot-bundle\.yml)' || true)
swift_contract_files=$(printf '%s\n' "$ts_code_files" |
grep -E '^packages/scripts/src/swift-contracts/' || true)

Expand Down
120 changes: 120 additions & 0 deletions .github/workflows/boot-bundle.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
name: Boot bundle

on:
pull_request:
branches:
- main
merge_group:
push:
branches:
- main
paths:
- "apps/calendar-web/**"
- "packages/core/**"
- "bun.lock"
- ".github/perf/**"
- ".github/docker/Dockerfile.web"
- ".github/workflows/boot-bundle.yml"

permissions:
contents: read
pull-requests: write

concurrency:
group: boot-bundle-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
changes:
runs-on: ubuntu-latest
timeout-minutes: 2
outputs:
static_web: ${{ steps.filter.outputs.static_web }}
steps:
- name: Check out Git repository
uses: actions/checkout@v7

- name: Detect web blast-radius changes
id: filter
env:
GH_TOKEN: ${{ github.token }}
run: |
bash .github/scripts/detect-code-changes.sh \
"${{ github.event_name }}" \
"${{ github.repository }}" \
"${{ github.event.pull_request.number }}"

delta:
needs: changes
if: >-
github.event_name != 'pull_request' ||
needs.changes.outputs.static_web == 'true'
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Check out pull request head
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0

- name: Install Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14

- name: Cache Bun dependencies
uses: actions/cache@v6
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-

- name: Install Dependencies
run: |
bun install --frozen-lockfile

- name: Compare boot gzip vs merge base
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
env:
GH_TOKEN: ${{ github.token }}
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
bun .github/perf/pr-boot-size-delta.ts

- name: Measure boot gzip on main
if: github.event_name == 'push'
env:
COMPASS_BOOT_SIZE_ENFORCE: "0"
COMPASS_BOOT_SIZE_METRICS_FILE: boot-main-metrics.json
run: |
.github/perf/run-boot-size-check.sh
cat boot-main-metrics.json

boot-bundle:
needs: [changes, delta]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Require boot bundle delta when web blast radius changed
env:
EVENT: ${{ github.event_name }}
STATIC_WEB: ${{ needs.changes.outputs.static_web }}
DELTA_RESULT: ${{ needs.delta.result }}
run: |
if [ "$EVENT" = "pull_request" ] && [ "$STATIC_WEB" != "true" ]; then
echo "Skipped: no static web blast radius on this PR."
exit 0
fi
case "$DELTA_RESULT" in
success) ;;
skipped)
echo "Boot bundle delta job was skipped unexpectedly." >&2
exit 1
;;
*) exit 1 ;;
esac
8 changes: 0 additions & 8 deletions .github/workflows/test-unit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,6 @@ jobs:
run: |
bun cli desktop:export --check

# build.ts enforces the boot-size budget. run-boot-size-check.sh matches
# .github/docker/Dockerfile.web (staging-cloud deploy) so PRs fail here
# instead of on Release → staging-cloud (#3814-#3816, #4200-#4201).
- name: Build web bundle (boot-size budget)
if: needs.changes.outputs.static_web == 'true'
run: |
.github/perf/run-boot-size-check.sh

# Workflow files auto-merge under the same size rails as other paths,
# so they are linted here. Pinned; the image bundles shellcheck for
# run: steps.
Expand Down
1 change: 0 additions & 1 deletion apps/calendar-web/boot-size-budget.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"chunkCount": 88,
"totalGzipBytes": 519500,
"totalGzipSlackBytes": 1024,
"packages": {
"@phosphor-icons/react": 17995,
"@react-oauth/google": 2043,
Expand Down
Loading
Loading