ci: fail build-artifacts when committed plugin bundles go stale (#112489)

This commit is contained in:
Peter Steinberger
2026-07-21 23:44:48 -07:00
committed by GitHub
parent b8ccb5dfa9
commit 01be7aa609
5 changed files with 115 additions and 4 deletions
+6
View File
@@ -1108,6 +1108,12 @@ jobs:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build:ci-artifacts
# Reruns the (warm, seconds-cheap) asset hooks and fails on drift so
# packages/-only PRs cannot land stale committed plugin bundles; the
# extension byte-equality suites do not run for those diffs.
- name: Check bundled plugin generated assets
run: pnpm plugins:assets:check
- name: Pack built runtime artifacts
run: tar --posix -cf dist-runtime-build.tar.zst --use-compress-program zstdmt dist dist-runtime packages/*/dist
+1
View File
@@ -1601,6 +1601,7 @@
"plugins:boundary-report:json": "node --import tsx scripts/plugin-boundary-report.ts --json",
"plugins:boundary-report:summary": "node --import tsx scripts/plugin-boundary-report.ts --summary",
"plugins:assets:build": "node scripts/bundled-plugin-assets.mjs --phase build",
"plugins:assets:check": "node scripts/bundled-plugin-assets.mjs --phase build --check",
"plugins:assets:copy": "node scripts/bundled-plugin-assets.mjs --phase copy",
"plugins:inventory:check": "node scripts/generate-plugin-inventory-doc.mjs --check",
"plugins:inventory:gen": "node scripts/generate-plugin-inventory-doc.mjs --write",
+6 -1
View File
@@ -17,9 +17,14 @@ export function readBundledPluginAssetHooks(options?: Record<string, unknown>):
*/
export function runBundledPluginAssetHooks(options?: Record<string, unknown>): Promise<void>;
/**
* Parses `--phase` and repeated `--plugin` flags for asset hook scripts.
* Lists declared generated source-tree outputs that differ from the committed bytes.
*/
export function listStaleGeneratedPluginAssets(options?: Record<string, unknown>): string[];
/**
* Parses `--phase`, repeated `--plugin`, and `--check` flags for asset hook scripts.
*/
export function parseBundledPluginAssetArgs(argv: unknown): {
check: boolean;
phase: unknown;
plugins: unknown[];
};
+59 -3
View File
@@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { listGeneratedExtensionAssetSources } from "./lib/static-extension-assets.mjs";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const VALID_PHASES = new Set(["build", "copy"]);
@@ -139,12 +140,42 @@ export async function runBundledPluginAssetHooks(options = {}) {
}
/**
* Parses `--phase` and repeated `--plugin` flags for asset hook scripts.
* Lists declared generated source-tree outputs that differ from the committed
* bytes. Committed buildOutputs must match a fresh hook run; PR node-test
* selection skips extension suites for packages-only diffs, so this check is
* the guard that keeps upstream changes from landing stale committed bundles.
*/
export function listStaleGeneratedPluginAssets(options = {}) {
const repoRoot = options.rootDir ?? rootDir;
const sources = listGeneratedExtensionAssetSources({ rootDir: repoRoot });
if (sources.length === 0) {
return [];
}
const result = spawnSync("git", ["status", "--porcelain", "--", ...sources], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new Error(
`git status failed for generated plugin assets: ${result.stderr?.trim() || result.status}`,
);
}
return result.stdout
.split("\n")
.map((line) => line.slice(3).trim())
.filter(Boolean)
.toSorted((left, right) => left.localeCompare(right));
}
/**
* Parses `--phase`, repeated `--plugin`, and `--check` flags for asset hook scripts.
*/
export function parseBundledPluginAssetArgs(argv) {
const args = [...argv];
const plugins = [];
let phase = null;
let check = false;
while (args.length > 0) {
const arg = args.shift();
@@ -167,19 +198,44 @@ export function parseBundledPluginAssetArgs(argv) {
plugins.push(arg.slice("--plugin=".length));
continue;
}
if (arg === "--check") {
check = true;
continue;
}
throw new Error(`Unknown bundled plugin asset argument: ${String(arg)}`);
}
if (!VALID_PHASES.has(phase)) {
throw new Error(`Expected --phase ${[...VALID_PHASES].join("|")}`);
}
// The stale-asset scan covers every declared buildOutput, so a filtered run
// would fail on drift it never rebuilt; keep check runs whole-repo.
if (check && phase !== "build") {
throw new Error("--check requires --phase build");
}
if (check && plugins.length > 0) {
throw new Error("--check cannot be combined with --plugin filters");
}
return { phase, plugins };
return { check, phase, plugins };
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
await runBundledPluginAssetHooks(parseBundledPluginAssetArgs(process.argv.slice(2)));
const args = parseBundledPluginAssetArgs(process.argv.slice(2));
await runBundledPluginAssetHooks(args);
if (args.check) {
const stale = listStaleGeneratedPluginAssets();
if (stale.length > 0) {
console.error("Generated bundled plugin assets differ from the committed bytes:");
for (const source of stale) {
console.error(` - ${source}`);
}
console.error("Rebuild with `pnpm plugins:assets:build` and commit the regenerated files.");
process.exit(1);
}
console.log("Generated bundled plugin assets match the committed bytes.");
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
@@ -1,9 +1,11 @@
// Bundled Plugin Assets tests cover bundled plugin assets script behavior.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildDiscordActivitySdk } from "../../scripts/build-discord-activity-sdk.mjs";
import {
listStaleGeneratedPluginAssets,
parseBundledPluginAssetArgs,
readBundledPluginAssetHooks,
} from "../../scripts/bundled-plugin-assets.mjs";
@@ -169,8 +171,49 @@ describe("bundled plugin assets", () => {
it("parses phase and plugin filters", () => {
expect(parseBundledPluginAssetArgs(["--phase", "build", "--plugin=canvas"])).toEqual({
check: false,
phase: "build",
plugins: ["canvas"],
});
});
it("parses whole-repo check runs and rejects filtered or copy-phase checks", () => {
expect(parseBundledPluginAssetArgs(["--phase", "build", "--check"])).toEqual({
check: true,
phase: "build",
plugins: [],
});
expect(() => parseBundledPluginAssetArgs(["--phase", "copy", "--check"])).toThrow(
"--check requires --phase build",
);
expect(() =>
parseBundledPluginAssetArgs(["--phase", "build", "--check", "--plugin=canvas"]),
).toThrow("--check cannot be combined with --plugin filters");
});
it("reports declared generated outputs that differ from the committed bytes", async () => {
await withPluginAssetFixture(async (rootDir) => {
const generatedPath = path.join(
rootDir,
"extensions",
"canvas",
"assets",
"generated-runtime.js",
);
fs.mkdirSync(path.dirname(generatedPath), { recursive: true });
fs.writeFileSync(generatedPath, "export const generated = 1;\n");
const git = (...args: string[]) =>
execFileSync("git", args, { cwd: rootDir, stdio: ["ignore", "pipe", "pipe"] });
git("init", "--quiet");
git("-c", "user.email=t@t", "-c", "user.name=t", "add", ".");
git("-c", "user.email=t@t", "-c", "user.name=t", "commit", "--quiet", "-m", "init");
expect(listStaleGeneratedPluginAssets({ rootDir })).toEqual([]);
fs.writeFileSync(generatedPath, "export const generated = 2;\n");
expect(listStaleGeneratedPluginAssets({ rootDir })).toEqual([
"extensions/canvas/assets/generated-runtime.js",
]);
});
});
});