diff --git a/build-tools/packages/build-cli/docs/release.md b/build-tools/packages/build-cli/docs/release.md index ed4cc7c65c72..db266eb3fdff 100644 --- a/build-tools/packages/build-cli/docs/release.md +++ b/build-tools/packages/build-cli/docs/release.md @@ -161,6 +161,7 @@ DESCRIPTION - Has no pre-release Fluid dependencies - No repo policy violations - No untagged asserts + - Compatibility layer generation is up to date ALIASES $ flub release prep diff --git a/build-tools/packages/build-cli/src/commands/generate/compatLayerGeneration.ts b/build-tools/packages/build-cli/src/commands/generate/compatLayerGeneration.ts index dc0c13b3e713..058fdd25be62 100644 --- a/build-tools/packages/build-cli/src/commands/generate/compatLayerGeneration.ts +++ b/build-tools/packages/build-cli/src/commands/generate/compatLayerGeneration.ts @@ -8,18 +8,17 @@ import path from "node:path"; import { updatePackageJsonFile } from "@fluid-tools/build-infrastructure"; import type { IFluidCompatibilityMetadata, - Logger, Package, PackageJson, } from "@fluidframework/build-tools"; import { Flags } from "@oclif/core"; -import { formatISO, isDate, isValid, parseISO } from "date-fns"; -import { diff, parse } from "semver"; +import { formatISO } from "date-fns"; import { PackageCommand } from "../../BasePackageCommand.js"; import type { PackageSelectionDefault } from "../../flags.js"; - -// Approximate month as 33 days to add some buffer and avoid over-counting months in longer spans. -export const daysInMonthApproximation = 33; +import { + isCurrentPackageVersionPatch, + maybeGetNewGeneration, +} from "../../library/layerCompatibility.js"; /** * Command to update the generation value of Fluid's compatibility layers. @@ -97,29 +96,6 @@ export default class UpdateGenerationLayerCommand extends PackageCommand< } } -/** - * Determines if the current package version represents a patch release. - * - * @param pkgVersion - The semantic version of the package (e.g., "2.0.1") - * @returns True if the version is a patch release, false otherwise - * - * @throws Error When the provided version string is not a valid semantic version - * - * @example - * ```typescript - * isCurrentPackageVersionPatch("2.0.1"); // returns true - * isCurrentPackageVersionPatch("2.1.0"); // returns false - * isCurrentPackageVersionPatch("3.0.0"); // returns false - * ``` - */ -export function isCurrentPackageVersionPatch(pkgVersion: string): boolean { - const parsed = parse(pkgVersion); - if (parsed === null) { - throw new Error(`Package version ${pkgVersion} is not a valid semver`); - } - return parsed.patch > 0; -} - /** * Generates the complete content for a layer generation TypeScript file. * @@ -147,71 +123,3 @@ export function generateLayerFileContent(generation: number): string { export const generation = ${generation}; `; } - -/** - * Determines if a new generation should be generated based on package version changes and time since - * the last release. - * - * This function parses an existing layer generation file and decides whether to increment the generation - * number based on: - * 1. Whether the package version has changed since the last update - * 2. How much time has elapsed since the previous release date - * 3. The minimum compatibility window constraints - * - * The generation increment is calculated as the number of months since the previous release, - * but capped at (minimumCompatWindowMonths - 1) to maintain compatibility requirements. - * - * @param currentPkgVersion - The current package version to compare against the stored version - * @param fluidCompatMetadata - The existing Fluid compatibility metadata from the previous generation - * @param minimumCompatWindowMonths - The maximum number of months of compatibility to maintain across layers - * @param log - Logger instance for verbose output about the calculation process - * @returns The new generation number if an update is needed, or undefined if no update is required - * - * @throws Error When the generation file content doesn't match the expected format - * @throws Error When the current date is older than the previous release date - */ -export function maybeGetNewGeneration( - currentPkgVersion: string, - fluidCompatMetadata: IFluidCompatibilityMetadata, - minimumCompatWindowMonths: number, - log: Logger, -): number | undefined { - // Only "minor" or "major" version changes trigger generation updates. - const result = diff(currentPkgVersion, fluidCompatMetadata.releasePkgVersion); - if (result === null || (result !== "minor" && result !== "major")) { - log.verbose(`No minor or major release since last update; skipping generation update.`); - return undefined; - } - - log.verbose( - `Previous package version: ${fluidCompatMetadata.releasePkgVersion}, Current package version: ${currentPkgVersion}`, - ); - - const previousReleaseDate = parseISO(fluidCompatMetadata.releaseDate); - if (!isValid(previousReleaseDate) || !isDate(previousReleaseDate)) { - throw new Error( - `Previous release date "${fluidCompatMetadata.releaseDate}" is not a valid date.`, - ); - } - - const today = new Date(); - const timeDiff = today.getTime() - previousReleaseDate.getTime(); - if (timeDiff < 0) { - throw new Error("Current date is older that previous release date"); - } - const daysBetweenReleases = Math.round(timeDiff / (1000 * 60 * 60 * 24)); - const monthsBetweenReleases = Math.floor(daysBetweenReleases / daysInMonthApproximation); - log.verbose(`Previous release date: ${previousReleaseDate}, Today: ${today}`); - log.verbose( - `Time between releases: ${daysBetweenReleases} day(s) or ~${monthsBetweenReleases} month(s)`, - ); - - const newGeneration = - fluidCompatMetadata.generation + - Math.min(monthsBetweenReleases, minimumCompatWindowMonths - 1); - if (newGeneration === fluidCompatMetadata.generation) { - log.verbose(`Generation remains the same (${newGeneration}); skipping generation update.`); - return undefined; - } - return newGeneration; -} diff --git a/build-tools/packages/build-cli/src/commands/release/prepare.ts b/build-tools/packages/build-cli/src/commands/release/prepare.ts index 509baa055c17..979c5156cc61 100644 --- a/build-tools/packages/build-cli/src/commands/release/prepare.ts +++ b/build-tools/packages/build-cli/src/commands/release/prepare.ts @@ -8,6 +8,7 @@ import chalk from "picocolors"; import { findPackageOrReleaseGroup, packageOrReleaseGroupArg } from "../../args.js"; import { BaseCommand } from "../../library/index.js"; import { + CheckCompatLayerGeneration, CheckDependenciesInstalled, type CheckFunction, CheckHasNoPrereleaseDependencies, @@ -32,6 +33,7 @@ const allChecks: ReadonlyMap = new Map([ ["Has no pre-release Fluid dependencies", CheckHasNoPrereleaseDependencies], ["No repo policy violations", CheckNoPolicyViolations], ["No untagged asserts", CheckNoUntaggedAsserts], + ["Compatibility layer generation is up to date", CheckCompatLayerGeneration], ]); /** diff --git a/build-tools/packages/build-cli/src/handlers/checkFunctions.ts b/build-tools/packages/build-cli/src/handlers/checkFunctions.ts index 608815809015..a6bb43966544 100644 --- a/build-tools/packages/build-cli/src/handlers/checkFunctions.ts +++ b/build-tools/packages/build-cli/src/handlers/checkFunctions.ts @@ -11,7 +11,7 @@ import execa from "execa"; import type { Machine } from "jssm"; import { bumpVersionScheme } from "@fluid-tools/version-tools"; -import { FluidRepo } from "@fluidframework/build-tools"; +import { FluidRepo, type Package } from "@fluidframework/build-tools"; import { generateBumpDepsBranchName, @@ -23,6 +23,7 @@ import { getReleaseSourceForReleaseGroup, isReleased, } from "../library/index.js"; +import { runCompatLayerGenerationCheck } from "../library/releasePrepChecks.js"; import type { CommandLogger } from "../logging.js"; import type { MachineState } from "../machines/index.js"; import { type ReleaseSource, isReleaseGroup } from "../releaseGroups.js"; @@ -923,7 +924,7 @@ export const checkCompatLayerGeneration: StateHandlerFunction = async ( ): Promise => { if (testMode) return true; - const { context, bumpType } = data; + const { context, bumpType, releaseGroup } = data; if (bumpType === "patch") { log.verbose(`Skipping layer compat generation check for patch release.`); @@ -931,21 +932,19 @@ export const checkCompatLayerGeneration: StateHandlerFunction = async ( return true; } - // layerGeneration:gen should be run from the root. It will only update packages that have the layerGeneration:gen - // script defined in their package.json. - const result = await execa.command(`pnpm run -r layerGeneration:gen`, { - cwd: context.root, - }); - log.verbose(result.stdout); + // Get packages for the release group or individual package being released + const packagesToCheck = isReleaseGroup(releaseGroup) + ? context.packagesInReleaseGroup(releaseGroup) + : [context.fullPackageMap.get(releaseGroup)].filter( + (pkg): pkg is Package => pkg !== undefined, + ); - // check for policy check violation - const gitRepo = await context.getGitRepository(); - const afterPolicyCheckStatus = await gitRepo.gitClient.status(); - const isClean = afterPolicyCheckStatus.isClean(); - if (!isClean) { + const isUpToDate = await runCompatLayerGenerationCheck(packagesToCheck); + + if (!isUpToDate) { log.logHr(); log.errorLog( - `Layer generation needs to be updated. Please create a PR for the changes and merge before retrying.\n${afterPolicyCheckStatus.files.map((fileStatus) => `${fileStatus.index} ${fileStatus.path}`).join("\n")}`, + `Compat layer generation needs to be updated. Run "pnpm run layerGeneration:gen" from the repo root, then create a PR for the changes and merge before retrying.`, ); BaseStateHandler.signalFailure(machine, state); return false; diff --git a/build-tools/packages/build-cli/src/library/layerCompatibility.ts b/build-tools/packages/build-cli/src/library/layerCompatibility.ts new file mode 100644 index 000000000000..cdd0750b11cc --- /dev/null +++ b/build-tools/packages/build-cli/src/library/layerCompatibility.ts @@ -0,0 +1,113 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import type { IFluidCompatibilityMetadata, Logger } from "@fluidframework/build-tools"; +import { formatISO, isDate, isValid, parseISO } from "date-fns"; +import { diff, parse } from "semver"; + +/** + * Approximate month as 33 days to add some buffer and avoid over-counting months in longer spans. + */ +export const DAYS_IN_MONTH_APPROXIMATION = 33; + +/** + * The default minimum compatibility window in months for layer generation. + * This matches the default value used in the compatLayerGeneration command. + */ +export const DEFAULT_MINIMUM_COMPAT_WINDOW_MONTHS = 3; + +/** + * Determines if the current package version represents a patch release. + * + * @param pkgVersion - The semantic version of the package (e.g., "2.0.1") + * @returns True if the version is a patch release, false otherwise + * + * @throws Error When the provided version string is not a valid semantic version + * + * @example + * ```typescript + * isCurrentPackageVersionPatch("2.0.1"); // returns true + * isCurrentPackageVersionPatch("2.1.0"); // returns false + * isCurrentPackageVersionPatch("3.0.0"); // returns false + * ``` + */ +export function isCurrentPackageVersionPatch(pkgVersion: string): boolean { + const parsed = parse(pkgVersion); + if (parsed === null) { + throw new Error(`Package version ${pkgVersion} is not a valid semver`); + } + return parsed.patch > 0; +} + +/** + * Determines if a new generation should be generated based on package version changes and time since + * the last release. + * + * This function parses an existing layer generation file and decides whether to increment the generation + * number based on: + * 1. Whether the package version has changed since the last update + * 2. How much time has elapsed since the previous release date + * 3. The minimum compatibility window constraints + * + * The generation increment is calculated as the number of months since the previous release, + * but capped at (minimumCompatWindowMonths - 1) to maintain compatibility requirements. + * + * @param currentPkgVersion - The current package version to compare against the stored version + * @param fluidCompatMetadata - The existing Fluid compatibility metadata from the previous generation + * @param minimumCompatWindowMonths - The maximum number of months of compatibility to maintain across layers + * @param log - Optional logger instance for verbose output about the calculation process + * @param currentDate - Optional current date for testing purposes. Defaults to new Date() + * @returns The new generation number if an update is needed, or undefined if no update is required + * + * @throws Error When the generation file content doesn't match the expected format + * @throws Error When the current date is older than the previous release date + */ +export function maybeGetNewGeneration( + currentPkgVersion: string, + fluidCompatMetadata: IFluidCompatibilityMetadata, + minimumCompatWindowMonths: number, + log?: Logger, + currentDate: Date = new Date(), +): number | undefined { + // Only "minor" or "major" version changes trigger generation updates. + const result = diff(currentPkgVersion, fluidCompatMetadata.releasePkgVersion); + if (result === null || (result !== "minor" && result !== "major")) { + log?.verbose(`No minor or major release since last update; skipping generation update.`); + return undefined; + } + + log?.verbose( + `Previous package version: ${fluidCompatMetadata.releasePkgVersion}, Current package version: ${currentPkgVersion}`, + ); + + const previousReleaseDate = parseISO(fluidCompatMetadata.releaseDate); + if (!isValid(previousReleaseDate) || !isDate(previousReleaseDate)) { + throw new Error( + `Previous release date "${fluidCompatMetadata.releaseDate}" is not a valid date.`, + ); + } + + const timeDiff = currentDate.getTime() - previousReleaseDate.getTime(); + if (timeDiff < 0) { + throw new Error("Current date is older that previous release date"); + } + const daysBetweenReleases = Math.round(timeDiff / (1000 * 60 * 60 * 24)); + const monthsBetweenReleases = Math.floor(daysBetweenReleases / DAYS_IN_MONTH_APPROXIMATION); + log?.verbose(`Previous release date: ${previousReleaseDate}, Today: ${currentDate}`); + log?.verbose( + `Time between releases: ${daysBetweenReleases} day(s) or ~${monthsBetweenReleases} month(s)`, + ); + + const newGeneration = + fluidCompatMetadata.generation + + Math.min(monthsBetweenReleases, minimumCompatWindowMonths - 1); + if (newGeneration === fluidCompatMetadata.generation) { + log?.verbose( + `Generation remains the same (${newGeneration}); skipping generation update.`, + ); + return undefined; + } + return newGeneration; +} diff --git a/build-tools/packages/build-cli/src/library/releasePrepChecks.ts b/build-tools/packages/build-cli/src/library/releasePrepChecks.ts index af48f19b6e3e..f008c4902bce 100644 --- a/build-tools/packages/build-cli/src/library/releasePrepChecks.ts +++ b/build-tools/packages/build-cli/src/library/releasePrepChecks.ts @@ -7,6 +7,11 @@ import { MonoRepo, type Package } from "@fluidframework/build-tools"; import execa from "execa"; import { ResetMode } from "simple-git"; import type { Context } from "./context.js"; +import { + DEFAULT_MINIMUM_COMPAT_WINDOW_MONTHS, + isCurrentPackageVersionPatch, + maybeGetNewGeneration, +} from "./layerCompatibility.js"; import { getPreReleaseDependencies } from "./package.js"; /** @@ -228,3 +233,82 @@ export const CheckNoUntaggedAsserts: CheckFunction = async ( }; } }; + +/** + * Checks if any packages need a compatibility layer generation update using the layer generation functions directly. + * This is a shared helper function used by both the prepare command checks and the state machine checks. + * + * Only validates packages that already have `fluidCompatMetadata` configured. This is appropriate for + * release checks since we only want to validate packages that are already participating in layer compatibility. + * Packages without metadata are skipped - not all packages need layer compatibility. + * + * **Setting up a new package for layer compatibility:** + * To add layer compatibility to a package that doesn't have it yet: + * 1. Add a `layerGeneration:gen` script to the package's package.json: + * `"layerGeneration:gen": "flub generate compatLayerGeneration --dir . -v"` + * 2. Run the command to initialize the package: `pnpm run layerGeneration:gen` + * 3. The command will create the `fluidCompatMetadata` field in package.json with generation 1 + * and generate the layer generation file (e.g., `src/layerGenerationState.ts`) + * + * @param packages - The list of packages to check. + * @returns `true` if all configured packages have up-to-date layer generation metadata, `false` if any updates are needed. + */ +export async function runCompatLayerGenerationCheck( + packages: Iterable, +): Promise { + // Check all packages that have fluidCompatMetadata + for (const pkg of packages) { + const { fluidCompatMetadata } = pkg.packageJson; + + // Skip packages without compatibility metadata - not all packages need layer compatibility + if (fluidCompatMetadata === undefined) { + continue; + } + + const currentPkgVersion = pkg.version; + + // Skip patch versions as they don't trigger generation updates + if (isCurrentPackageVersionPatch(currentPkgVersion)) { + continue; + } + + // Check if this package needs a generation update (no logger for checks) + const newGeneration = maybeGetNewGeneration( + currentPkgVersion, + fluidCompatMetadata, + DEFAULT_MINIMUM_COMPAT_WINDOW_MONTHS, + ); + + // If any package needs an update, return false + if (newGeneration !== undefined) { + return false; + } + } + + // All packages are up to date + return true; +} + +/** + * Checks that the compatibility layer generation is up to date. Any necessary changes will return a failure result. + */ +export const CheckCompatLayerGeneration: CheckFunction = async ( + _context: Context, + releaseGroupOrPackage: MonoRepo | Package, +): Promise => { + const packagesToCheck = + releaseGroupOrPackage instanceof MonoRepo + ? releaseGroupOrPackage.packages + : [releaseGroupOrPackage]; + + const isUpToDate = await runCompatLayerGenerationCheck(packagesToCheck); + + if (!isUpToDate) { + return { + message: "Layer generation needs to be updated.", + fixCommand: "pnpm run layerGeneration:gen", + }; + } + + return; +}; diff --git a/build-tools/packages/build-cli/src/test/commands/generate/compatLayerGeneration.test.ts b/build-tools/packages/build-cli/src/test/commands/generate/compatLayerGeneration.test.ts index 9eae123a9d17..be7e4402989a 100644 --- a/build-tools/packages/build-cli/src/test/commands/generate/compatLayerGeneration.test.ts +++ b/build-tools/packages/build-cli/src/test/commands/generate/compatLayerGeneration.test.ts @@ -9,11 +9,13 @@ import { formatISO } from "date-fns"; import { describe, it } from "mocha"; import UpdateGenerationCommand, { - daysInMonthApproximation, generateLayerFileContent, +} from "../../../commands/generate/compatLayerGeneration.js"; +import { + DAYS_IN_MONTH_APPROXIMATION, isCurrentPackageVersionPatch, maybeGetNewGeneration, -} from "../../../commands/generate/compatLayerGeneration.js"; +} from "../../../library/layerCompatibility.js"; describe("generate:compatLayerGeneration", () => { const minimumCompatWindowMonths = UpdateGenerationCommand.flags.minimumCompatWindowMonths @@ -77,7 +79,7 @@ describe("generate:compatLayerGeneration", () => { // Create a date 2 months ago (should normally trigger increment) const oldDate = new Date(); - oldDate.setDate(oldDate.getDate() - 2 * daysInMonthApproximation); + oldDate.setDate(oldDate.getDate() - 2 * DAYS_IN_MONTH_APPROXIMATION); const oldDateString = formatISO(oldDate, { representation: "date" }); const mockMetadata: IFluidCompatibilityMetadata = { generation: previousGeneration, @@ -102,7 +104,9 @@ describe("generate:compatLayerGeneration", () => { // Create a date monthsSincePreviousRelease months ago const oldDate = new Date(); - oldDate.setDate(oldDate.getDate() - monthsSincePreviousRelease * daysInMonthApproximation); + oldDate.setDate( + oldDate.getDate() - monthsSincePreviousRelease * DAYS_IN_MONTH_APPROXIMATION, + ); const oldDateString = formatISO(oldDate, { representation: "date" }); const mockMetadata: IFluidCompatibilityMetadata = { generation: previousGeneration, @@ -126,7 +130,9 @@ describe("generate:compatLayerGeneration", () => { // Create a date monthsSincePreviousRelease months ago const oldDate = new Date(); - oldDate.setDate(oldDate.getDate() - monthsSincePreviousRelease * daysInMonthApproximation); + oldDate.setDate( + oldDate.getDate() - monthsSincePreviousRelease * DAYS_IN_MONTH_APPROXIMATION, + ); const oldDateString = formatISO(oldDate, { representation: "date" }); const mockMetadata: IFluidCompatibilityMetadata = { generation: previousGeneration,