From 01be7aa609ec88d079852accc63984c820e928c4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Jul 2026 23:44:48 -0700 Subject: [PATCH] ci: fail build-artifacts when committed plugin bundles go stale (#112489) --- .github/workflows/ci.yml | 6 +++ package.json | 1 + scripts/bundled-plugin-assets.d.mts | 7 ++- scripts/bundled-plugin-assets.mjs | 62 ++++++++++++++++++++-- test/scripts/bundled-plugin-assets.test.ts | 43 +++++++++++++++ 5 files changed, 115 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 064bc1b9dbd0..dfa003aa1a0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/package.json b/package.json index 2e55282fcc49..7219a765191d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/bundled-plugin-assets.d.mts b/scripts/bundled-plugin-assets.d.mts index cbbf787512df..2c51a8d96824 100644 --- a/scripts/bundled-plugin-assets.d.mts +++ b/scripts/bundled-plugin-assets.d.mts @@ -17,9 +17,14 @@ export function readBundledPluginAssetHooks(options?: Record): */ export function runBundledPluginAssetHooks(options?: Record): Promise; /** - * 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[]; +/** + * Parses `--phase`, repeated `--plugin`, and `--check` flags for asset hook scripts. */ export function parseBundledPluginAssetArgs(argv: unknown): { + check: boolean; phase: unknown; plugins: unknown[]; }; diff --git a/scripts/bundled-plugin-assets.mjs b/scripts/bundled-plugin-assets.mjs index 8f88c9ea2bce..f1292b377a16 100644 --- a/scripts/bundled-plugin-assets.mjs +++ b/scripts/bundled-plugin-assets.mjs @@ -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); diff --git a/test/scripts/bundled-plugin-assets.test.ts b/test/scripts/bundled-plugin-assets.test.ts index 2facb9524324..7192244d047c 100644 --- a/test/scripts/bundled-plugin-assets.test.ts +++ b/test/scripts/bundled-plugin-assets.test.ts @@ -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", + ]); + }); + }); });