diff --git a/build-tools/packages/build-cli/src/commands/generate/layerCompatGeneration.ts b/build-tools/packages/build-cli/src/commands/generate/layerCompatGeneration.ts index 3dbbff30ea39..8e4a6f0b2b6e 100644 --- a/build-tools/packages/build-cli/src/commands/generate/layerCompatGeneration.ts +++ b/build-tools/packages/build-cli/src/commands/generate/layerCompatGeneration.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"; export default class UpdateGenerationCommand extends PackageCommand< typeof UpdateGenerationCommand @@ -94,29 +93,6 @@ export default class UpdateGenerationCommand 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. * @@ -144,71 +120,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 689f649de315..8bcbef886e23 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..ad026b9e95ba 100644 --- a/build-tools/packages/build-cli/src/handlers/checkFunctions.ts +++ b/build-tools/packages/build-cli/src/handlers/checkFunctions.ts @@ -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"; @@ -931,21 +932,12 @@ 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); + const isUpToDate = await runCompatLayerGenerationCheck(context); - // check for policy check violation - const gitRepo = await context.getGitRepository(); - const afterPolicyCheckStatus = await gitRepo.gitClient.status(); - const isClean = afterPolicyCheckStatus.isClean(); - if (!isClean) { + 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")}`, + `Layer generation needs to be updated. Please 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..26411ef281f8 --- /dev/null +++ b/build-tools/packages/build-cli/src/library/layerCompatibility.ts @@ -0,0 +1,102 @@ +/*! + * 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 daysInMonthApproximation = 33; + +/** + * 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 - 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/library/releasePrepChecks.ts b/build-tools/packages/build-cli/src/library/releasePrepChecks.ts index af48f19b6e3e..5666614c65d3 100644 --- a/build-tools/packages/build-cli/src/library/releasePrepChecks.ts +++ b/build-tools/packages/build-cli/src/library/releasePrepChecks.ts @@ -7,8 +7,15 @@ import { MonoRepo, type Package } from "@fluidframework/build-tools"; import execa from "execa"; import { ResetMode } from "simple-git"; import type { Context } from "./context.js"; +import { isCurrentPackageVersionPatch, maybeGetNewGeneration } from "./layerCompatibility.js"; import { getPreReleaseDependencies } from "./package.js"; +/** + * The default minimum compatibility window in months for layer generation. + * This matches the default value used in the layerCompatGeneration command. + */ +const DEFAULT_MINIMUM_COMPAT_WINDOW_MONTHS = 3; + /** * An async function that executes a release preparation check. The function returns a {@link CheckResult} with details * about the results of the check. @@ -228,3 +235,85 @@ 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 layerCompatGeneration --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 context - The repository context. + * @returns `true` if all configured packages have up-to-date layer generation metadata, `false` if any updates are needed. + */ +export async function runCompatLayerGenerationCheck(context: Context): Promise { + // Check all packages that have fluidCompatMetadata + for (const pkg of context.fullPackageMap.values()) { + 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; + } + + // Use a no-op logger since we don't want verbose output during checks + const noopLogger = { + log: () => {}, + verbose: () => {}, + info: () => {}, + warning: () => {}, + errorLog: () => {}, + }; + + // Check if this package needs a generation update + const newGeneration = maybeGetNewGeneration( + currentPkgVersion, + fluidCompatMetadata, + DEFAULT_MINIMUM_COMPAT_WINDOW_MONTHS, + noopLogger, + ); + + // 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 isUpToDate = await runCompatLayerGenerationCheck(context); + + if (!isUpToDate) { + return { + message: "Layer generation needs to be updated.", + fixCommand: "pnpm run -r layerGeneration:gen", + }; + } + + return; +};