diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19cebe0bda..35a5120696 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,5 +136,22 @@ jobs: check-latest: true - name: Update npm to 11.19.1 run: npm install -g npm@11.19.1 + - name: Resolve min-release-age + id: age + run: | + age="" + if [ -f .npmrc ]; then + age=$(tr -d ' \t\r' < .npmrc | grep -E '^min-release-age=[0-9]+$' | tail -n 1 | cut -d= -f2) + fi + if [ -z "$age" ]; then + echo "::error::min-release-age not set in .npmrc" + exit 1 + fi + echo "Using min-release-age=$age days" + echo "min-release-age=$age" >> "$GITHUB_OUTPUT" + - name: Check release age + run: npm run check-release-age + env: + MIN_RELEASE_AGE: ${{ steps.age.outputs.min-release-age }} - run: npm ci - run: npm run build diff --git a/jest.config.ts b/jest.config.ts index 0878d6cf91..d5a7c29fc6 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -60,8 +60,24 @@ const serverConfig: Config = { ...baseProjectConfig, }; +const scriptsConfig: Config = { + rootDir, + displayName: { name: "Scripts", color: "magenta" }, + testEnvironment: "node", + moduleFileExtensions: ["mjs", "js", "jsx", "ts", "tsx"], + transform: { + "^.+\\.(js|mjs|jsx|ts|tsx)$": "babel-jest", + }, + // we can widen this to include more script tests if needed + testMatch: ["/scripts/check-release-age.test.mjs"], + coveragePathIgnorePatterns: ["/node_modules", "/scripts"], + transformIgnorePatterns: [ + `/node_modules/(?!(${esmOnlyPackages.join("|")}))`, + ], +}; + const globalConfig: Config = { - projects: [clientConfig, serverConfig], + projects: [clientConfig, serverConfig, scriptsConfig], notify: false, collectCoverage: true, coverageReporters: ["text-summary", "html", "json"], diff --git a/package.json b/package.json index a4773e95c4..489a864262 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "build-storybook:prod": "npm run generate-tokens && dotenvx run -f .env.production -- storybook build -c .storybook", "start:static": "npx http-server -p 9001 ./storybook-static", "clean-lib": "rimraf ./lib && rimraf ./esm", + "check-release-age": "node ./scripts/check-release-age.mjs", "commit": "node ./scripts/commit/commit.mjs", "generate-metadata": "node ./scripts/generate_metadata/index.mjs", "type-check": "tsc --noEmit", diff --git a/scripts/check-release-age.mjs b/scripts/check-release-age.mjs new file mode 100644 index 0000000000..8c793705eb --- /dev/null +++ b/scripts/check-release-age.mjs @@ -0,0 +1,222 @@ +import fs from "node:fs/promises"; + +const DEFAULT_REGISTRY = "https://registry.npmjs.org"; +const MILLISECONDS_IN_DAY = 24 * 60 * 60 * 1000; +const REGISTRY_REQUEST_TIMEOUT_MS = 30 * 1000; +const MAX_CONCURRENT_REQUESTS = 20; +const MAX_FAILURES_TO_PRINT = 25; + +export async function readMinReleaseAge() { + if (process.env.MIN_RELEASE_AGE) { + return Number.parseInt(process.env.MIN_RELEASE_AGE, 10); + } + + let npmrc; + try { + npmrc = await fs.readFile(".npmrc", "utf8"); + } catch (error) { + if (error.code === "ENOENT") { + throw new Error("min-release-age not set in .npmrc"); + } + throw error; + } + + const age = npmrc + .split("\n") + .map((line) => line.replace(/[ \t\r]/g, "")) + .filter((line) => /^min-release-age=[0-9]+$/.test(line)) + .at(-1) + ?.split("=")[1]; + + if (!age) { + throw new Error("min-release-age not set in .npmrc"); + } + + return Number.parseInt(age, 10); +} + +export function getPackageNameFromLockEntry(packagePath, packageInfo) { + if (packageInfo.name) { + return packageInfo.name; + } + + const pathAfterNodeModules = packagePath.slice( + packagePath.lastIndexOf("node_modules/") + "node_modules/".length, + ); + const [firstSegment, secondSegment] = pathAfterNodeModules.split("/"); + + return firstSegment.startsWith("@") + ? `${firstSegment}/${secondSegment}` + : firstSegment; +} + +export async function readLockedPackages() { + const lockfile = JSON.parse(await fs.readFile("package-lock.json", "utf8")); + + if (!lockfile.packages) { + throw new Error("package-lock.json does not contain a packages map"); + } + + const lockedPackages = new Map(); + + for (const [packagePath, packageInfo] of Object.entries(lockfile.packages)) { + if ( + !packagePath.includes("node_modules/") || + packageInfo.link || + !packageInfo.version + ) { + continue; + } + + const name = getPackageNameFromLockEntry(packagePath, packageInfo); + lockedPackages.set(`${name}@${packageInfo.version}`, { + name, + version: packageInfo.version, + }); + } + + return [...lockedPackages.values()]; +} + +export function getPackageUrl(registry, packageName) { + const encodedPackageName = encodeURIComponent(packageName); + return `${registry.replace(/\/$/, "")}/${encodedPackageName}`; +} + +export async function fetchPackageTimes(registry, packageName) { + let response; + try { + response = await fetch(getPackageUrl(registry, packageName), { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS), + }); + } catch (error) { + if (error.name === "TimeoutError" || error.name === "AbortError") { + throw new Error( + `Timed out fetching ${packageName} metadata after ${REGISTRY_REQUEST_TIMEOUT_MS / 1000} seconds`, + { cause: error }, + ); + } + throw error; + } + + if (!response.ok) { + throw new Error( + `Could not fetch ${packageName} metadata: ${response.status} ${response.statusText}`, + ); + } + + const packageMetadata = await response.json(); + + if (!packageMetadata.time) { + throw new Error(`Registry metadata for ${packageName} does not include time`); + } + + return packageMetadata.time; +} + +export async function runConcurrently(items, worker) { + const results = []; + let nextIndex = 0; + + async function runWorker() { + while (nextIndex < items.length) { + const currentIndex = nextIndex; + nextIndex += 1; + results[currentIndex] = await worker(items[currentIndex]); + } + } + + await Promise.all( + Array.from( + { length: Math.min(MAX_CONCURRENT_REQUESTS, items.length) }, + runWorker, + ), + ); + + return results; +} + +export function formatAge(ageInMilliseconds) { + return `${(ageInMilliseconds / MILLISECONDS_IN_DAY).toFixed(2)} days`; +} + +async function main() { + const minReleaseAge = await readMinReleaseAge(); + + if (!Number.isInteger(minReleaseAge) || minReleaseAge < 0) { + throw new Error("MIN_RELEASE_AGE must be a non-negative integer"); + } + + const registry = + process.env.NPM_CONFIG_REGISTRY || + process.env.npm_config_registry || + DEFAULT_REGISTRY; + const lockedPackages = await readLockedPackages(); + const packageNames = [...new Set(lockedPackages.map(({ name }) => name))]; + const packageTimes = new Map(); + + console.log( + `Checking ${lockedPackages.length} locked package releases against min-release-age=${minReleaseAge} days`, + ); + + await runConcurrently(packageNames, async (packageName) => { + packageTimes.set(packageName, await fetchPackageTimes(registry, packageName)); + }); + + const now = Date.now(); + const minimumAgeInMilliseconds = minReleaseAge * MILLISECONDS_IN_DAY; + const tooNewPackages = lockedPackages + .map(({ name, version }) => { + const publishedAt = packageTimes.get(name)[version]; + + if (!publishedAt) { + throw new Error(`No publish time found for ${name}@${version}`); + } + + const publishedAtTime = new Date(publishedAt).getTime(); + const ageInMilliseconds = now - publishedAtTime; + + return { + name, + version, + publishedAt, + ageInMilliseconds, + isTooNew: ageInMilliseconds < minimumAgeInMilliseconds, + }; + }) + .filter(({ isTooNew }) => isTooNew) + .sort((a, b) => a.ageInMilliseconds - b.ageInMilliseconds); + + if (tooNewPackages.length > 0) { + console.error( + `::error::${tooNewPackages.length} locked package release(s) do not meet min-release-age=${minReleaseAge} days`, + ); + + tooNewPackages + .slice(0, MAX_FAILURES_TO_PRINT) + .forEach(({ name, version, publishedAt, ageInMilliseconds }) => { + console.error( + `- ${name}@${version} was published at ${publishedAt} (${formatAge(ageInMilliseconds)} old)`, + ); + }); + + if (tooNewPackages.length > MAX_FAILURES_TO_PRINT) { + console.error( + `...and ${tooNewPackages.length - MAX_FAILURES_TO_PRINT} more package release(s)`, + ); + } + + process.exit(1); + } + + console.log("All locked package releases meet the configured release age."); +} + +// Only auto-run when executed as a CLI, not when imported by tests. +if (process.env.NODE_ENV !== "test") { + main().catch((error) => { + console.error(`::error::${error.message}`); + process.exit(1); + }); +} \ No newline at end of file diff --git a/scripts/check-release-age.test.mjs b/scripts/check-release-age.test.mjs new file mode 100644 index 0000000000..5a1f7335d6 --- /dev/null +++ b/scripts/check-release-age.test.mjs @@ -0,0 +1,230 @@ +import fs from "node:fs/promises"; +import { + fetchPackageTimes, + formatAge, + getPackageNameFromLockEntry, + getPackageUrl, + readLockedPackages, + readMinReleaseAge, + runConcurrently, +} from "./check-release-age.mjs"; + +jest.mock("node:fs/promises", () => ({ + readFile: jest.fn(), +})); + +const originalFetch = global.fetch; +const originalEnv = process.env; + +beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.MIN_RELEASE_AGE; + global.fetch = jest.fn(); +}); + +afterEach(() => { + jest.restoreAllMocks(); + fs.readFile.mockReset(); + process.env = originalEnv; + global.fetch = originalFetch; +}); + +describe("getPackageNameFromLockEntry", () => { + it("uses an explicit name from the lock entry when present", () => { + expect( + getPackageNameFromLockEntry("node_modules/aliased", { name: "real-name" }), + ).toBe("real-name"); + }); + + it("derives an unscoped name from the package path", () => { + expect(getPackageNameFromLockEntry("node_modules/lodash", {})).toBe( + "lodash", + ); + }); + + it("derives a scoped name from the package path", () => { + expect( + getPackageNameFromLockEntry("node_modules/@sage/design-tokens", {}), + ).toBe("@sage/design-tokens"); + }); + + it("resolves the name from the deepest node_modules segment", () => { + expect( + getPackageNameFromLockEntry("node_modules/a/node_modules/b", {}), + ).toBe("b"); + }); +}); + +describe("getPackageUrl", () => { + it("encodes scoped package names", () => { + expect( + getPackageUrl("https://registry.npmjs.org", "@sage/design-tokens"), + ).toBe("https://registry.npmjs.org/%40sage%2Fdesign-tokens"); + }); + + it("strips a trailing slash from the registry", () => { + expect(getPackageUrl("https://registry.npmjs.org/", "lodash")).toBe( + "https://registry.npmjs.org/lodash", + ); + }); +}); + +describe("formatAge", () => { + it("formats an age in milliseconds as a number of days", () => { + expect(formatAge(86400000)).toBe("1.00 days"); + expect(formatAge(129600000)).toBe("1.50 days"); + }); +}); + +describe("readMinReleaseAge", () => { + it("prefers the MIN_RELEASE_AGE environment variable", async () => { + process.env.MIN_RELEASE_AGE = "7"; + + await expect(readMinReleaseAge()).resolves.toBe(7); + expect(fs.readFile).not.toHaveBeenCalled(); + }); + + it("reads the value from .npmrc when the env var is absent", async () => { + fs.readFile.mockResolvedValue("ignore-scripts=true\nmin-release-age=3\n"); + + await expect(readMinReleaseAge()).resolves.toBe(3); + expect(fs.readFile).toHaveBeenCalledWith(".npmrc", "utf8"); + }); + + it("uses the last matching line in .npmrc", async () => { + fs.readFile.mockResolvedValue("min-release-age=3\nmin-release-age=9\n"); + + await expect(readMinReleaseAge()).resolves.toBe(9); + }); + + it("throws when .npmrc does not set min-release-age", async () => { + fs.readFile.mockResolvedValue("ignore-scripts=true\n"); + + await expect(readMinReleaseAge()).rejects.toThrow( + "min-release-age not set in .npmrc", + ); + }); + + it("throws a helpful error when .npmrc is missing", async () => { + fs.readFile.mockRejectedValue( + Object.assign(new Error("nope"), { code: "ENOENT" }), + ); + + await expect(readMinReleaseAge()).rejects.toThrow( + "min-release-age not set in .npmrc", + ); + }); + + it("rethrows unexpected errors while reading .npmrc", async () => { + fs.readFile.mockRejectedValue( + Object.assign(new Error("disk on fire"), { code: "EACCES" }), + ); + + await expect(readMinReleaseAge()).rejects.toThrow("disk on fire"); + }); +}); + +describe("readLockedPackages", () => { + it("collects versioned dependencies and de-duplicates them", async () => { + fs.readFile.mockResolvedValue( + JSON.stringify({ + packages: { + "": { name: "carbon-react", version: "1.0.0" }, + "node_modules/lodash": { version: "4.17.21" }, + "node_modules/@sage/design-tokens": { version: "4.17.0" }, + "node_modules/a/node_modules/lodash": { version: "4.17.21" }, + }, + }), + ); + + await expect(readLockedPackages()).resolves.toEqual([ + { name: "lodash", version: "4.17.21" }, + { name: "@sage/design-tokens", version: "4.17.0" }, + ]); + }); + + it("skips linked and version-less entries", async () => { + fs.readFile.mockResolvedValue( + JSON.stringify({ + packages: { + "node_modules/linked": { version: "1.0.0", link: true }, + "node_modules/no-version": {}, + "node_modules/real": { version: "2.0.0" }, + }, + }), + ); + + await expect(readLockedPackages()).resolves.toEqual([ + { name: "real", version: "2.0.0" }, + ]); + }); + + it("throws when the lockfile has no packages map", async () => { + fs.readFile.mockResolvedValue(JSON.stringify({ dependencies: {} })); + + await expect(readLockedPackages()).rejects.toThrow( + "package-lock.json does not contain a packages map", + ); + }); +}); + +describe("fetchPackageTimes", () => { + it("returns the time map for a successful response", async () => { + const time = { "1.0.0": "2020-01-01T00:00:00.000Z" }; + global.fetch.mockResolvedValue({ ok: true, json: async () => ({ time }) }); + + await expect( + fetchPackageTimes("https://registry.npmjs.org", "lodash"), + ).resolves.toBe(time); + expect(global.fetch).toHaveBeenCalledWith( + "https://registry.npmjs.org/lodash", + expect.objectContaining({ headers: { Accept: "application/json" } }), + ); + }); + + it("throws when the response is not ok", async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: "Server Error", + }); + + await expect( + fetchPackageTimes("https://registry.npmjs.org", "lodash"), + ).rejects.toThrow("Could not fetch lodash metadata: 500 Server Error"); + }); + + it("throws a timeout-specific error when the request aborts", async () => { + global.fetch.mockRejectedValue( + Object.assign(new Error("aborted"), { name: "TimeoutError" }), + ); + + await expect( + fetchPackageTimes("https://registry.npmjs.org", "lodash"), + ).rejects.toThrow("Timed out fetching lodash metadata after 30 seconds"); + }); + + it("rethrows unexpected network errors", async () => { + global.fetch.mockRejectedValue(new Error("ECONNRESET")); + + await expect( + fetchPackageTimes("https://registry.npmjs.org", "lodash"), + ).rejects.toThrow("ECONNRESET"); + }); + + it("throws when the registry metadata has no time field", async () => { + global.fetch.mockResolvedValue({ ok: true, json: async () => ({}) }); + + await expect( + fetchPackageTimes("https://registry.npmjs.org", "lodash"), + ).rejects.toThrow("Registry metadata for lodash does not include time"); + }); +}); + +describe("runConcurrently", () => { + it("runs the worker for every item and preserves order", async () => { + const results = await runConcurrently([1, 2, 3], async (value) => value * 2); + + expect(results).toEqual([2, 4, 6]); + }); +});