Merge branch 'main' into dependabot/github_actions/dot-github/workflows/actions-minor-da8be134b1

This commit is contained in:
Michael B. Gale
2026-05-28 14:48:30 +01:00
committed by GitHub
13 changed files with 760 additions and 145 deletions

View File

@@ -0,0 +1,259 @@
#!/usr/bin/env npx tsx
/*
Tests for check-repo-size.ts.
*/
import * as assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import {
COMMENT_MARKER,
DEFAULT_BASE_REF,
buildCommentBody,
formatBytes,
formatPercent,
isDeltaSignificant,
measureArchiveSize,
readArgs,
} from "./check-repo-size";
describe("formatBytes", async () => {
const cases: Array<[number, boolean, string]> = [
// Unsigned values, including sub-KiB amounts which round to 0.00.
[0, false, "0.00 KiB"],
[512, false, "0.50 KiB"],
[1024, false, "1.00 KiB"],
[1024 * 1024, false, "1024.00 KiB"],
[2 * 1024 * 1024, false, "2048.00 KiB"],
// Negative values always use a leading minus.
[-2 * 1024 * 1024, false, "-2048.00 KiB"],
// signed=true prepends a + to non-negative values.
[0, true, "+0.00 KiB"],
[2 * 1024 * 1024, true, "+2048.00 KiB"],
[-2 * 1024 * 1024, true, "-2048.00 KiB"],
];
for (const [bytes, signed, expected] of cases) {
await it(`formats ${bytes} (signed=${signed}) as ${expected}`, () => {
assert.equal(formatBytes(bytes, signed), expected);
});
}
});
describe("formatPercent", async () => {
await it("formats positive fractions with a leading +", () => {
assert.equal(formatPercent(0.1), "+10.00%");
assert.equal(formatPercent(0.0123), "+1.23%");
});
await it("formats negative fractions with a leading -", () => {
assert.equal(formatPercent(-0.1), "-10.00%");
});
await it("formats zero without a sign", () => {
assert.equal(formatPercent(0), "0.00%");
});
});
describe("isDeltaSignificant", async () => {
const cases: Array<[number, number, number, boolean]> = [
// At and above threshold (both signs).
[100, 1000, 0.1, true],
[101, 1000, 0.1, true],
[-100, 1000, 0.1, true],
// Below threshold (both signs, plus exact zero).
[99, 1000, 0.1, false],
[-99, 1000, 0.1, false],
[0, 1000, 0.1, false],
];
for (const [delta, base, fraction, expected] of cases) {
await it(`returns ${expected} for delta=${delta}, base=${base}, fraction=${fraction}`, () => {
assert.equal(isDeltaSignificant(delta, base, fraction), expected);
});
}
});
describe("buildCommentBody", async () => {
await it("includes the marker, the base/PR/delta rows, and the run URL", () => {
const body = buildCommentBody({
baseRef: "main",
baseSize: 2_000_000,
prSize: 2_300_000,
runUrl: "https://example.test/run",
});
assert.match(body, new RegExp(`^${escapeRegExp(COMMENT_MARKER)}`));
assert.match(body, /Base \(`main`\) \| 1953\.13 KiB \(2000000 bytes\)/);
assert.match(body, /This PR \| 2246\.09 KiB \(2300000 bytes\)/);
assert.match(
body,
/\*\*Delta\*\* \| \*\*\+292\.97 KiB \(\+300000 bytes, \+15\.00%\)\*\*/,
);
assert.match(body, /\[workflow run\]\(https:\/\/example\.test\/run\)/);
});
await it("formats negative deltas with a leading minus and omits the run URL when missing", () => {
const body = buildCommentBody({
baseRef: "main",
baseSize: 2_000_000,
prSize: 1_800_000,
});
assert.match(
body,
/\*\*Delta\*\* \| \*\*-195\.31 KiB \(-200000 bytes, -10\.00%\)\*\*/,
);
assert.doesNotMatch(body, /workflow run/);
});
});
describe("readArgs", async () => {
await it("defaults the base ref and head commit for local runs", () => {
const originalEnv = process.env;
const originalArgv = process.argv;
try {
process.env = {};
process.argv = ["node", "check-repo-size.ts", "--output-dir", "/tmp/out"];
const args = readArgs();
assert.equal(args.baseRef, DEFAULT_BASE_REF);
assert.equal(args.baseCommitish, `origin/${DEFAULT_BASE_REF}`);
assert.equal(args.headCommitish, "HEAD");
assert.equal(args.outputDir, "/tmp/out");
assert.equal(args.runUrl, undefined);
} finally {
process.env = originalEnv;
process.argv = originalArgv;
}
});
await it("uses the base and head SHAs when provided by the workflow", () => {
const originalEnv = process.env;
const originalArgv = process.argv;
try {
process.env = {
BASE_REF: "main",
BASE_SHA: "abc123",
HEAD_SHA: "def456",
RUN_URL: "https://example.test/run",
};
process.argv = ["node", "check-repo-size.ts", "--output-dir", "/tmp/out"];
const args = readArgs();
assert.equal(args.baseRef, "main");
assert.equal(args.baseCommitish, "abc123");
assert.equal(args.headCommitish, "def456");
assert.equal(args.outputDir, "/tmp/out");
assert.equal(args.runUrl, "https://example.test/run");
} finally {
process.env = originalEnv;
process.argv = originalArgv;
}
});
await it("throws when --output-dir is missing", () => {
const originalEnv = process.env;
const originalArgv = process.argv;
try {
process.env = {};
process.argv = ["node", "check-repo-size.ts"];
assert.throws(() => readArgs(), /--output-dir is required/);
} finally {
process.env = originalEnv;
process.argv = originalArgv;
}
});
});
let repoDir: string;
beforeEach(() => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "check-repo-size-test-"));
execFileSync("git", ["init", "--initial-branch=main", "-q"], {
cwd: repoDir,
});
execFileSync("git", ["config", "user.email", "test@example.test"], {
cwd: repoDir,
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir });
execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir });
});
afterEach(() => {
fs.rmSync(repoDir, { recursive: true, force: true });
});
function commit(name: string, content: string, message: string) {
fs.writeFileSync(path.join(repoDir, name), content);
execFileSync("git", ["add", name], { cwd: repoDir });
execFileSync("git", ["commit", "-q", "-m", message], { cwd: repoDir });
}
describe("measureArchiveSize", async () => {
await it("returns a positive byte count for a non-empty repo", async () => {
commit("a.txt", "hello world\n", "first");
const size = await measureArchiveSize("HEAD", repoDir);
assert.ok(size > 0, `expected size > 0, got ${size}`);
});
await it("returns the same size on repeated runs (deterministic)", async () => {
commit("a.txt", "hello world\n", "first");
const a = await measureArchiveSize("HEAD", repoDir);
const b = await measureArchiveSize("HEAD", repoDir);
assert.equal(a, b);
});
await it("returns a larger size when more content is added", async () => {
commit("a.txt", "hello world\n", "first");
const small = await measureArchiveSize("HEAD", repoDir);
// Use random bytes so the new content is incompressible and the archive
// is guaranteed to grow even after gzip.
commit("b.bin", randomBytes(8192).toString("base64"), "second");
const big = await measureArchiveSize("HEAD", repoDir);
assert.ok(
big > small,
`expected ${big} > ${small} after adding more content`,
);
});
await it("ignores untracked files (e.g. node_modules)", async () => {
commit("a.txt", "hello\n", "first");
commit(".gitignore", "node_modules/\n", "ignore node_modules");
const sizeBefore = await measureArchiveSize("HEAD", repoDir);
fs.mkdirSync(path.join(repoDir, "node_modules"));
fs.writeFileSync(
path.join(repoDir, "node_modules", "huge.bin"),
"x".repeat(1_000_000),
);
const sizeAfter = await measureArchiveSize("HEAD", repoDir);
assert.equal(
sizeAfter,
sizeBefore,
"untracked node_modules should not affect the archive size",
);
});
await it("rejects when the ref does not exist", async () => {
commit("a.txt", "hello\n", "first");
await assert.rejects(
() => measureArchiveSize("does-not-exist", repoDir),
/git archive does-not-exist exited with code/,
);
});
});
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -0,0 +1,223 @@
#!/usr/bin/env npx tsx
/*
Measures the difference in the `.tar.gz`'d checkout size of the repo between the PR head and the PR
base. This size is relevant because it corresponds to the duration of the "Download action
repository" step that happens at the start of every job that uses this Action.
Writes the candidate sticky-comment body and a small metadata file to `--output-dir`. A separate
workflow job consumes those artifacts and decides whether to create or update a PR comment.
*/
import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import { parseArgs } from "node:util";
import { REPO_ROOT } from "./config";
/** Hidden marker used to find the existing sticky comment on a PR. */
export const COMMENT_MARKER = "<!-- repo-size-diff-bot -->";
export const DEFAULT_BASE_REF = "main";
/**
* Fraction of the base archive size at which a delta is considered significant enough to warrant
* a new sticky comment. We always update an existing comment regardless, so the comment stays in
* sync as the diff evolves.
*/
export const SIGNIFICANT_DELTA_FRACTION = 0.1;
/**
* Stream `git archive --format=tar.gz <ref>` and count the compressed bytes.
*
* `git archive` only includes tracked files, so untracked directories like `node_modules` and
* `build` aren't counted in the size downloaded when starting up a CodeQL job.
*/
export async function measureArchiveSize(
ref: string,
cwd: string,
): Promise<number> {
const git = spawn("git", ["archive", "--format=tar.gz", ref], { cwd });
let stderr = "";
git.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
let size = 0;
git.stdout.on("data", (chunk: Buffer) => {
size += chunk.length;
});
const exitCode = await new Promise<number>((resolve, reject) => {
git.on("error", reject);
git.on("close", resolve);
});
if (exitCode !== 0) {
throw new Error(
`git archive ${ref} exited with code ${exitCode}: ${stderr.trim()}`,
);
}
return size;
}
/**
* Format a byte count as KiB. If `signed` is true, a leading `+` is prepended for non-negative
* values so gains and losses are visually distinct.
*/
export function formatBytes(bytes: number, signed = false): string {
const sign = bytes < 0 ? "-" : signed ? "+" : "";
const kib = Math.abs(bytes) / 1024;
return `${sign}${kib.toFixed(2)} KiB`;
}
/** Format a fraction as a signed percentage with 2 decimal places. */
export function formatPercent(fraction: number): string {
const pct = fraction * 100;
const sign = pct > 0 ? "+" : "";
return `${sign}${pct.toFixed(2)}%`;
}
export interface CommentBodyOptions {
baseRef: string;
baseSize: number;
prSize: number;
/** Optional URL of the workflow run, included in the comment footer. */
runUrl?: string;
}
export function buildCommentBody(opts: CommentBodyOptions): string {
const { baseRef, baseSize, prSize, runUrl } = opts;
const delta = prSize - baseSize;
const signedDelta = delta >= 0 ? `+${delta}` : `${delta}`;
const runUrlLine = runUrl
? ` See the [workflow run](${runUrl}) for details.`
: "";
return [
COMMENT_MARKER,
"### Repository checkout size",
"",
"| | Compressed archive size |",
"|---|---|",
`| Base (\`${baseRef}\`) | ${formatBytes(baseSize)} (${baseSize} bytes) |`,
`| This PR | ${formatBytes(prSize)} (${prSize} bytes) |`,
`| **Delta** | **${formatBytes(delta, true)} (${signedDelta} bytes, ${formatPercent(delta / baseSize)})** |`,
"",
"Sizes are measured by streaming `git archive --format=tar.gz <ref>`, " +
"which includes tracked files and excludes untracked files such as " +
"`node_modules`. The compressed checkout is " +
"downloaded by every consumer of this Action, so changes here directly " +
`affect Action download time.${runUrlLine}`,
].join("\n");
}
/**
* Returns true when the absolute delta is at least `fraction` of the base size. Both increases and
* decreases are considered significant, so we report wins as well as losses.
*/
export function isDeltaSignificant(
delta: number,
baseSize: number,
fraction: number,
): boolean {
return Math.abs(delta) >= baseSize * fraction;
}
interface MainArgs {
/** Base ref of the PR. Defaults to `main`. Used as the label in the PR comment. */
baseRef: string;
/** Base commit-ish to archive. Defaults to `origin/<baseRef>` for local runs. */
baseCommitish: string;
/** Head commit-ish to archive. Defaults to `HEAD` for local runs. */
headCommitish: string;
/** Optional URL of the workflow run, surfaced in the comment footer. */
runUrl?: string;
/** Directory where `body.md` and `metadata.json` are written. */
outputDir: string;
}
export function readArgs(): MainArgs {
const { values } = parseArgs({
options: {
"output-dir": { type: "string" },
},
strict: true,
});
const outputDir = values["output-dir"];
if (!outputDir) {
throw new Error("--output-dir is required");
}
const baseRef = process.env.BASE_REF ?? DEFAULT_BASE_REF;
const baseCommitish = process.env.BASE_SHA ?? `origin/${baseRef}`;
const headCommitish = process.env.HEAD_SHA ?? "HEAD";
return {
baseRef,
baseCommitish,
headCommitish,
runUrl: process.env.RUN_URL,
outputDir,
};
}
async function main(): Promise<number> {
const args = readArgs();
console.log(`Measuring base archive size for ${args.baseCommitish}...`);
const baseSize = await measureArchiveSize(args.baseCommitish, REPO_ROOT);
console.log(` ${baseSize} bytes`);
console.log(`Measuring PR archive size for ${args.headCommitish}...`);
const prSize = await measureArchiveSize(args.headCommitish, REPO_ROOT);
console.log(` ${prSize} bytes`);
const delta = prSize - baseSize;
const significant = isDeltaSignificant(
delta,
baseSize,
SIGNIFICANT_DELTA_FRACTION,
);
console.log(
`Delta: ${delta} bytes (significant=${significant}, threshold=${(
SIGNIFICANT_DELTA_FRACTION * 100
).toFixed(2)}%)`,
);
const body = buildCommentBody({
baseRef: args.baseRef,
baseSize,
prSize,
runUrl: args.runUrl,
});
fs.mkdirSync(args.outputDir, { recursive: true });
fs.writeFileSync(path.join(args.outputDir, "body.md"), body);
fs.writeFileSync(
path.join(args.outputDir, "metadata.json"),
`${JSON.stringify(
{ significant, baseRef: args.baseRef, baseSize, prSize, delta },
null,
2,
)}\n`,
);
console.log(`Wrote body.md and metadata.json to ${args.outputDir}.`);
return 0;
}
async function run(): Promise<void> {
try {
process.exit(await main());
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
}
if (require.main === module) {
void run();
}

View File

@@ -6,14 +6,17 @@ export const OLDEST_SUPPORTED_MAJOR_VERSION = 3;
/** The `pr-checks` directory. */
export const PR_CHECKS_DIR = __dirname;
/** The repository root. */
export const REPO_ROOT = path.join(PR_CHECKS_DIR, "..");
/** The path of the file configuring which checks shouldn't be required. */
export const PR_CHECK_EXCLUDED_FILE = path.join(PR_CHECKS_DIR, "excluded.yml");
/** The path to the esbuild metadata file. */
export const BUNDLE_METADATA_FILE = path.join(PR_CHECKS_DIR, "..", "meta.json");
export const BUNDLE_METADATA_FILE = path.join(REPO_ROOT, "meta.json");
/** The `src` directory. */
const SOURCE_ROOT = path.join(PR_CHECKS_DIR, "..", "src");
const SOURCE_ROOT = path.join(REPO_ROOT, "src");
/** The path to the built-in languages file. */
export const BUILTIN_LANGUAGES_FILE = path.join(

View File

@@ -1,16 +1,17 @@
# PR checks to exclude from required checks
contains:
- "https://"
- "Update"
- "ESLint"
- "update"
- "https://"
- "test-setup-python-scripts"
- "update"
- "Update"
is:
- "Agent"
- "check-expected-release-files"
- "Cleanup artifacts"
- "CodeQL"
- "Dependabot"
- "check-expected-release-files"
- "Agent"
- "Cleanup artifacts"
- "Label PR with size"
- "Post repo size comment"
- "Prepare"
- "Upload results"
- "Label PR with size"

View File

@@ -7,10 +7,10 @@
"@octokit/core": "^7.0.6",
"@octokit/plugin-paginate-rest": ">=9.2.2",
"@octokit/plugin-rest-endpoint-methods": "^17.0.0",
"yaml": "^2.8.4"
"yaml": "^2.9.0"
},
"devDependencies": {
"@types/node": "^20.19.39",
"@types/node": "^20.19.41",
"tsx": "^4.21.0"
}
}