diff --git a/package.json b/package.json index b1171776..25821a39 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "changeset:version": "changeset version", "changeset:version:release": "changeset version && pnpm format:root", "release": "turbo run build && changeset publish", - "test:root": "vitest run scripts/check-changeset.test.ts scripts/package-conventions.test.ts", + "test:root": "vitest run scripts/check-changeset.test.ts scripts/check-minimum-release-age.test.ts scripts/package-conventions.test.ts", "typecheck:root": "tsc -p scripts/tsconfig.json --noEmit", "lint:root": "oxlint .", "lint:fix:root": "oxlint --fix .", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 96defa03..691471e6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,8 +4,8 @@ packages: catalog: '@arethetypeswrong/cli': ^0.18.2 '@changesets/cli': ^2.30.0 - '@types/json-schema': ^7.0.15 '@transcend-io/internationalization': ^2.3.2 + '@types/json-schema': ^7.0.15 '@types/node': ^22.19.15 husky: ^9.1.7 oxfmt: ^0.38.0 @@ -17,5 +17,7 @@ catalog: typescript: ^5.9.3 vitest: ^4.0.18 +minimumReleaseAge: 1440 + onlyBuiltDependencies: - esbuild diff --git a/scripts/check-minimum-release-age.test.ts b/scripts/check-minimum-release-age.test.ts new file mode 100644 index 00000000..b3397472 --- /dev/null +++ b/scripts/check-minimum-release-age.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, test } from 'vitest'; + +import { readRepoFile } from './lib/repo-files.ts'; + +const minimumReleaseAgePattern = /^minimumReleaseAge:\s*(\d+)\s*$/m; +const packageEntryPattern = /^ (?:'([^']+)'|([^\s][^:\n]*)):\s*$/gm; +const npmRegistryBaseUrl = 'https://registry.npmjs.org'; +const millisecondsPerMinute = 60_000; +const registryConcurrency = 12; +const registryRetryCount = 3; + +describe('minimum release age', () => { + test('lockfile packages satisfy the configured minimum release age', async () => { + const minimumReleaseAgeMinutes = readMinimumReleaseAgeMinutes(); + const lockfilePackages = getLockfilePackages(); + const packageVersionsByName = groupVersionsByPackageName(lockfilePackages); + const failures = ( + await mapWithConcurrencyLimit( + [...packageVersionsByName.entries()], + registryConcurrency, + async ([packageName, versions]) => { + const packument = await fetchPackument(packageName); + + return [...versions].flatMap((version) => { + const publishedAt = getPublishedAt(packument, version); + + if (publishedAt === undefined) { + return [ + `${packageName}@${version} is missing a publish timestamp in the npm registry`, + ]; + } + + const publishedAtTime = Date.parse(publishedAt); + + if (Number.isNaN(publishedAtTime)) { + return [`${packageName}@${version} has an invalid publish timestamp: ${publishedAt}`]; + } + + const ageMinutes = Math.floor((Date.now() - publishedAtTime) / millisecondsPerMinute); + + if (ageMinutes < minimumReleaseAgeMinutes) { + return [ + `${packageName}@${version} is only ${ageMinutes} minutes old (minimum is ${minimumReleaseAgeMinutes})`, + ]; + } + + return []; + }); + }, + ) + ) + .flat() + .sort((a, b) => a.localeCompare(b)); + + expect(failures).toEqual([]); + }, 60_000); +}); + +function readMinimumReleaseAgeMinutes(): number { + const workspaceConfig = readRepoFile('pnpm-workspace.yaml'); + const minimumReleaseAgeMatch = workspaceConfig.match(minimumReleaseAgePattern); + + if (minimumReleaseAgeMatch === null) { + throw new Error('Unable to read minimumReleaseAge from pnpm-workspace.yaml'); + } + + const minimumReleaseAge = minimumReleaseAgeMatch[1]; + + if (minimumReleaseAge === undefined) { + throw new Error('Unable to parse minimumReleaseAge from pnpm-workspace.yaml'); + } + + return Number.parseInt(minimumReleaseAge, 10); +} + +function getLockfilePackages(): string[] { + const lockfileContents = readRepoFile('pnpm-lock.yaml'); + const packagesSectionMatch = lockfileContents.match(/^packages:\n([\s\S]*?)^snapshots:\n/m); + + if (packagesSectionMatch === null) { + throw new Error('Unable to find the packages section in pnpm-lock.yaml'); + } + + const packagesSection = packagesSectionMatch[1]; + + if (packagesSection === undefined) { + throw new Error('Unable to parse the packages section in pnpm-lock.yaml'); + } + + return Array.from( + packagesSection.matchAll(packageEntryPattern), + (packageEntryMatch) => packageEntryMatch[1] ?? packageEntryMatch[2] ?? '', + ).filter((packageKey) => packageKey.length > 0); +} + +function groupVersionsByPackageName(lockfilePackageKeys: string[]): Map> { + const packageVersionsByName = new Map>(); + + for (const packageKey of lockfilePackageKeys) { + const [packageName, packageVersion] = parseLockfilePackageKey(packageKey); + const knownVersions = packageVersionsByName.get(packageName); + + if (knownVersions === undefined) { + packageVersionsByName.set(packageName, new Set([packageVersion])); + continue; + } + + knownVersions.add(packageVersion); + } + + return packageVersionsByName; +} + +function parseLockfilePackageKey(packageKey: string): [string, string] { + const normalizedPackageKey = packageKey.replace(/\(.+\)$/, ''); + const versionSeparatorIndex = normalizedPackageKey.lastIndexOf('@'); + + if (versionSeparatorIndex <= 0) { + throw new Error(`Unable to parse lockfile package entry: ${packageKey}`); + } + + return [ + normalizedPackageKey.slice(0, versionSeparatorIndex), + normalizedPackageKey.slice(versionSeparatorIndex + 1), + ]; +} + +async function fetchPackument(packageName: string): Promise { + const requestUrl = `${npmRegistryBaseUrl}/${encodeURIComponent(packageName)}`; + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= registryRetryCount; attempt += 1) { + try { + const response = await fetch(requestUrl); + + if (!response.ok) { + const shouldRetry = response.status === 429 || response.status >= 500; + + if (shouldRetry && attempt < registryRetryCount) { + await sleep(attempt * 250); + continue; + } + + throw new Error( + `Failed to fetch ${packageName} metadata from npm (${response.status} ${response.statusText})`, + ); + } + + return await response.json(); + } catch (error: unknown) { + lastError = error instanceof Error ? error : new Error(String(error)); + + if (attempt < registryRetryCount) { + await sleep(attempt * 250); + continue; + } + } + } + + throw lastError ?? new Error(`Failed to fetch ${packageName} metadata from npm`); +} + +function getPublishedAt(packument: unknown, packageVersion: string): string | undefined { + if (typeof packument !== 'object' || packument === null) { + return undefined; + } + + const packumentTime = Reflect.get(packument, 'time'); + + if (typeof packumentTime !== 'object' || packumentTime === null) { + return undefined; + } + + const publishedAt = Reflect.get(packumentTime, packageVersion); + + return typeof publishedAt === 'string' ? publishedAt : undefined; +} + +async function mapWithConcurrencyLimit( + values: T[], + concurrencyLimit: number, + mapValue: (value: T) => Promise, +): Promise { + const results = new Array(values.length); + let currentIndex = 0; + + await Promise.all( + Array.from({ length: Math.min(concurrencyLimit, values.length) }, async () => { + while (currentIndex < values.length) { + const valueIndex = currentIndex; + currentIndex += 1; + const value = values[valueIndex]; + + if (value === undefined) { + continue; + } + + results[valueIndex] = await mapValue(value); + } + }), + ); + + return results; +} + +function sleep(milliseconds: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +} diff --git a/scripts/package-conventions.test.ts b/scripts/package-conventions.test.ts index d611d701..e7afee8a 100644 --- a/scripts/package-conventions.test.ts +++ b/scripts/package-conventions.test.ts @@ -97,6 +97,12 @@ const releasedPackages = publishablePackages.filter( ); const baseCompilerOptions = readJsonFile('tsconfig.base.json').compilerOptions ?? {}; const sharedCompilerOptionKeys = sortStrings(Object.keys(baseCompilerOptions)); +const workspaceSharedCompilerOptionCases = workspacePackages.flatMap((workspacePackage) => + sharedCompilerOptionKeys.map((compilerOptionKey) => ({ + ...workspacePackage, + compilerOptionKey, + })), +); describe('package conventions', () => { test('root tsconfig references every workspace package', () => { @@ -187,14 +193,12 @@ describe('package conventions', () => { expect(tsconfig.include ?? []).toEqual(expect.arrayContaining(['src/**/*.ts'])); }); - test.skip.each(workspacePackages)( - '$directory relies on tsconfig.base.json for shared compilerOptions', - ({ tsconfig }) => { + // TODO: Remove this skip once we have a shared compilerOptions baseline for all packages + test.skip.each(workspaceSharedCompilerOptionCases)( + '$directory relies on tsconfig.base.json for shared compilerOption $compilerOptionKey', + ({ compilerOptionKey, tsconfig }) => { const packageCompilerOptions = tsconfig.compilerOptions ?? {}; - - for (const compilerOptionKey of sharedCompilerOptionKeys) { - expect(packageCompilerOptions).not.toHaveProperty(compilerOptionKey); - } + expect(packageCompilerOptions).not.toHaveProperty(compilerOptionKey); }, );