Skip to content

Commit d68d37c

Browse files
agarwal-navinanthony-murphy-agent
authored andcommitted
(compat) Add PackageCommand to auto update layer compatibility generation (microsoft#25670)
## Description Each layer within a client (Loader, Driver, Runtime and Datastore) has a generation property which should be incremented monthly. It provides a simple way to determine if two layers are compatible with each other by comparing their generations since layer compatibility is expressed in number of months. For example, between the Runtime and Datastore layers, we support a 3 months compatibility. So, they are compatible if the difference in their generation is <3. See microsoft#22877 for more details on how this will work. This change adds an automated way to increment the generation of these layers for simplicity and consistency. Here is how it works: - A new `PackageCommand` called `UpdateGenerationCommand` is added. The flub command `flub generate:layerCompatGeneration` can be run this command for a package. - Added a new node to package.json file called `fluidCompatMetadata` which will contain the generation information as per the last update. It is of type `IFluidCompatibilityMetadata` and contains the last generation, last release date and last release package version. - If the command is run for the first time in a package, it will auto-generate `fluidCompatMetadata` and add it to the package.json file. It will also auto-generate a filed called `layerGenerationState.ts` under the .src directory. - If the command is run subsequently, it will read `fluidCompatMetadata` from package.json and update the generation (if needed) as follows: - If the package version has changed, i.e., a new release has happened, it will update the generation to `New generation = Old generation + no. of months since last release`. - An important requirement is that in between two subsequent releases, the generation should not be incremented by more than the minimum compat window between 2 layers across all layers boundaries. For example, say compat window between Loader / Runtime is 12 months but between Runtime / Datastore it is only 3 months. Then between 2 subsequent releases, generation should not increment by more than 2. If that happens, it doesn't give customers enough time to upgrade their packages and saturate a release before upgrading to the next one. Layer compatiility will break as soon they upgrade. - So, the logic for new generation is updated to: `New generation = Old generation + min (no. of months since last release, min. compat window between layers across all layer boundaries)`. - The `flub generate:layerGeneration` command will be added to the scripts in `client-utils`'s `package.json` file in a subsequent PR. The generation information for all the layers will be present in this package. The associated chagnes to the build system to run this command on releases will also be added in a subsequent PR. [AB#51926](https://dev.azure.com/fluidframework/235294da-091d-4c29-84fc-cdfc3d90890b/_workitems/edit/51926)
1 parent 3c2407b commit d68d37c

File tree

16 files changed

+555
-7
lines changed

16 files changed

+555
-7
lines changed

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ build-tools/packages/build-cli/README.md
2424
build-tools/packages/version-tools/README.md
2525
**/src/**/test/types/*.generated.ts
2626
**/src/packageVersion.ts
27+
**/src/layerGenerationState.ts
2728
**/build-tools/CHANGELOG.md
2829
**/*.done.build.log
2930

biome.jsonc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
// Generated files
2929
"**/src/**/test/types/*.generated.ts",
3030
"**/src/packageVersion.ts",
31+
"**/src/layerGenerationState.ts",
3132

3233
// Dependencies
3334
"**/node_modules/*",

build-tools/packages/build-cli/docs/generate.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Generate commands are used to create/update code, docs, readmes, etc.
99
* [`flub generate changelog`](#flub-generate-changelog)
1010
* [`flub generate changeset`](#flub-generate-changeset)
1111
* [`flub generate entrypoints`](#flub-generate-entrypoints)
12+
* [`flub generate layerCompatGeneration`](#flub-generate-layercompatgeneration)
1213
* [`flub generate node10Entrypoints`](#flub-generate-node10entrypoints)
1314
* [`flub generate packlist`](#flub-generate-packlist)
1415
* [`flub generate releaseNotes`](#flub-generate-releasenotes)
@@ -268,6 +269,60 @@ DESCRIPTION
268269

269270
_See code: [src/commands/generate/entrypoints.ts](https://github.com/microsoft/FluidFramework/blob/main/build-tools/packages/build-cli/src/commands/generate/entrypoints.ts)_
270271

272+
## `flub generate layerCompatGeneration`
273+
274+
Updates the generation and release date for layer compatibility.
275+
276+
```
277+
USAGE
278+
$ flub generate layerCompatGeneration [-v | --quiet] [--generationDir <value>] [--outFile <value>] [--minimumCompatWindowMonths
279+
<value>] [--concurrency <value>] [--branch <value> [--changed | [--all | --dir <value>... | --packages | -g
280+
client|server|azure|build-tools|gitrest|historian|all... | --releaseGroupRoot
281+
client|server|azure|build-tools|gitrest|historian|all...]]] [--private] [--scope <value>... | --skipScope
282+
<value>...]
283+
284+
FLAGS
285+
--concurrency=<value> [default: 25] The number of tasks to execute concurrently.
286+
--generationDir=<value> [default: ./src] The directory where the generation file is located.
287+
--minimumCompatWindowMonths=<value> [default: 3] The minimum compatibility window in months that is supported across
288+
all Fluid layers.
289+
--outFile=<value> [default: layerGenerationState.ts] Output the results to this file.
290+
291+
PACKAGE SELECTION FLAGS
292+
-g, --releaseGroup=<option>... Run on all child packages within the specified release groups. This does not
293+
include release group root packages. To include those, use the --releaseGroupRoot
294+
argument. Cannot be used with --all.
295+
<options: client|server|azure|build-tools|gitrest|historian|all>
296+
--all Run on all packages and release groups. Cannot be used with --dir, --packages,
297+
--releaseGroup, or --releaseGroupRoot.
298+
--branch=<value> [default: main] Select only packages that have been changed when compared to this
299+
base branch. Can only be used with --changed.
300+
--changed Select packages that have changed when compared to a base branch. Use the --branch
301+
option to specify a different base branch. Cannot be used with --all.
302+
--dir=<value>... Run on the package in this directory. Cannot be used with --all.
303+
--packages Run on all independent packages in the repo. Cannot be used with --all.
304+
--releaseGroupRoot=<option>... Run on the root package of the specified release groups. This does not include any
305+
child packages within the release group. To include those, use the --releaseGroup
306+
argument. Cannot be used with --all.
307+
<options: client|server|azure|build-tools|gitrest|historian|all>
308+
309+
LOGGING FLAGS
310+
-v, --verbose Enable verbose logging.
311+
--quiet Disable all logging.
312+
313+
PACKAGE FILTER FLAGS
314+
--[no-]private Only include private packages. Use --no-private to exclude private packages instead.
315+
--scope=<value>... Package scopes to filter to. If provided, only packages whose scope matches the flag will be
316+
included. Cannot be used with --skipScope.
317+
--skipScope=<value>... Package scopes to filter out. If provided, packages whose scope matches the flag will be
318+
excluded. Cannot be used with --scope.
319+
320+
DESCRIPTION
321+
Updates the generation and release date for layer compatibility.
322+
```
323+
324+
_See code: [src/commands/generate/layerCompatGeneration.ts](https://github.com/microsoft/FluidFramework/blob/main/build-tools/packages/build-cli/src/commands/generate/layerCompatGeneration.ts)_
325+
271326
## `flub generate node10Entrypoints`
272327

273328
Generates node10 type declaration entrypoints for Fluid Framework API levels (/alpha, /beta, /internal etc.) as found in package.json "exports"
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
/*!
2+
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
3+
* Licensed under the MIT License.
4+
*/
5+
6+
import { writeFile } from "node:fs/promises";
7+
import path from "node:path";
8+
import { updatePackageJsonFile } from "@fluid-tools/build-infrastructure";
9+
import type {
10+
IFluidCompatibilityMetadata,
11+
Logger,
12+
Package,
13+
PackageJson,
14+
} from "@fluidframework/build-tools";
15+
import { Flags } from "@oclif/core";
16+
import { formatISO, isDate, isValid, parseISO } from "date-fns";
17+
import { diff, parse } from "semver";
18+
import { PackageCommand } from "../../BasePackageCommand.js";
19+
import type { PackageSelectionDefault } from "../../flags.js";
20+
21+
// Approximate month as 33 days to add some buffer and avoid over-counting months in longer spans.
22+
export const daysInMonthApproximation = 33;
23+
24+
export default class UpdateGenerationCommand extends PackageCommand<
25+
typeof UpdateGenerationCommand
26+
> {
27+
static readonly description =
28+
"Updates the generation and release date for layer compatibility.";
29+
30+
static readonly flags = {
31+
generationDir: Flags.directory({
32+
description: "The directory where the generation file is located.",
33+
default: "./src",
34+
exists: true,
35+
}),
36+
outFile: Flags.string({
37+
description: `Output the results to this file.`,
38+
default: `layerGenerationState.ts`,
39+
}),
40+
minimumCompatWindowMonths: Flags.integer({
41+
description: `The minimum compatibility window in months that is supported across all Fluid layers.`,
42+
default: 3,
43+
}),
44+
...PackageCommand.flags,
45+
} as const;
46+
47+
protected defaultSelection = "dir" as PackageSelectionDefault;
48+
49+
protected async processPackage(pkg: Package): Promise<void> {
50+
const { generationDir, outFile, minimumCompatWindowMonths } = this.flags;
51+
const generationFileFullPath = path.join(pkg.directory, generationDir, outFile);
52+
53+
const currentPkgVersion = pkg.version;
54+
// "patch" versions do trigger generation updates.
55+
if (isCurrentPackageVersionPatch(currentPkgVersion)) {
56+
this.verbose(`Patch version detected; skipping generation update.`);
57+
return;
58+
}
59+
60+
// Default to generation 1 if no existing file.
61+
let newGeneration: number | undefined = 1;
62+
const { fluidCompatMetadata } = pkg.packageJson;
63+
if (fluidCompatMetadata !== undefined) {
64+
this.verbose(
65+
`Layer compatibility metadata from package.json: Generation: ${fluidCompatMetadata.generation}, ` +
66+
`Release Date: ${fluidCompatMetadata.releaseDate}, Package Version: ${fluidCompatMetadata.releasePkgVersion}`,
67+
);
68+
newGeneration = maybeGetNewGeneration(
69+
currentPkgVersion,
70+
fluidCompatMetadata,
71+
minimumCompatWindowMonths,
72+
this.logger,
73+
);
74+
if (newGeneration === undefined) {
75+
// No update needed; early exit.
76+
this.verbose(`No generation update needed; skipping.`);
77+
return;
78+
}
79+
}
80+
81+
const currentReleaseDate = formatISO(new Date(), { representation: "date" });
82+
const newFluidCompatMetadata: IFluidCompatibilityMetadata = {
83+
generation: newGeneration,
84+
releaseDate: currentReleaseDate,
85+
releasePkgVersion: currentPkgVersion,
86+
};
87+
updatePackageJsonFile(pkg.directory, (json: PackageJson) => {
88+
json.fluidCompatMetadata = newFluidCompatMetadata;
89+
});
90+
await writeFile(generationFileFullPath, generateLayerFileContent(newGeneration), {
91+
encoding: "utf8",
92+
});
93+
this.info(`Layer generation updated to ${newGeneration}`);
94+
}
95+
}
96+
97+
/**
98+
* Determines if the current package version represents a patch release.
99+
*
100+
* @param pkgVersion - The semantic version of the package (e.g., "2.0.1")
101+
* @returns True if the version is a patch release, false otherwise
102+
*
103+
* @throws Error When the provided version string is not a valid semantic version
104+
*
105+
* @example
106+
* ```typescript
107+
* isCurrentPackageVersionPatch("2.0.1"); // returns true
108+
* isCurrentPackageVersionPatch("2.1.0"); // returns false
109+
* isCurrentPackageVersionPatch("3.0.0"); // returns false
110+
* ```
111+
*/
112+
export function isCurrentPackageVersionPatch(pkgVersion: string): boolean {
113+
const parsed = parse(pkgVersion);
114+
if (parsed === null) {
115+
throw new Error(`Package version ${pkgVersion} is not a valid semver`);
116+
}
117+
return parsed.patch > 0;
118+
}
119+
120+
/**
121+
* Generates the complete content for a layer generation TypeScript file.
122+
*
123+
* Creates a properly formatted TypeScript file with copyright header, autogenerated warning,
124+
* and export for generation number.
125+
*
126+
* @param generation - The layer compatibility generation number
127+
* @returns The complete file content as a string ready to be written to disk
128+
*
129+
* @example
130+
* ```typescript
131+
* const content = generateLayerFileContent(5);
132+
* // Returns a complete TypeScript file with exports:
133+
* // export const generation = 5;
134+
* ```
135+
*/
136+
export function generateLayerFileContent(generation: number): string {
137+
return `/*!
138+
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
139+
* Licensed under the MIT License.
140+
*
141+
* THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
142+
*/
143+
144+
export const generation = ${generation};
145+
`;
146+
}
147+
148+
/**
149+
* Determines if a new generation should be generated based on package version changes and time since
150+
* the last release.
151+
*
152+
* This function parses an existing layer generation file and decides whether to increment the generation
153+
* number based on:
154+
* 1. Whether the package version has changed since the last update
155+
* 2. How much time has elapsed since the previous release date
156+
* 3. The minimum compatibility window constraints
157+
*
158+
* The generation increment is calculated as the number of months since the previous release,
159+
* but capped at (minimumCompatWindowMonths - 1) to maintain compatibility requirements.
160+
*
161+
* @param currentPkgVersion - The current package version to compare against the stored version
162+
* @param fluidCompatMetadata - The existing Fluid compatibility metadata from the previous generation
163+
* @param minimumCompatWindowMonths - The maximum number of months of compatibility to maintain across layers
164+
* @param log - Logger instance for verbose output about the calculation process
165+
* @returns The new generation number if an update is needed, or undefined if no update is required
166+
*
167+
* @throws Error When the generation file content doesn't match the expected format
168+
* @throws Error When the current date is older than the previous release date
169+
*/
170+
export function maybeGetNewGeneration(
171+
currentPkgVersion: string,
172+
fluidCompatMetadata: IFluidCompatibilityMetadata,
173+
minimumCompatWindowMonths: number,
174+
log: Logger,
175+
): number | undefined {
176+
// Only "minor" or "major" version changes trigger generation updates.
177+
const result = diff(currentPkgVersion, fluidCompatMetadata.releasePkgVersion);
178+
if (result === null || (result !== "minor" && result !== "major")) {
179+
log.verbose(`No minor or major release since last update; skipping generation update.`);
180+
return undefined;
181+
}
182+
183+
log.verbose(
184+
`Previous package version: ${fluidCompatMetadata.releasePkgVersion}, Current package version: ${currentPkgVersion}`,
185+
);
186+
187+
const previousReleaseDate = parseISO(fluidCompatMetadata.releaseDate);
188+
if (!isValid(previousReleaseDate) || !isDate(previousReleaseDate)) {
189+
throw new Error(
190+
`Previous release date "${fluidCompatMetadata.releaseDate}" is not a valid date.`,
191+
);
192+
}
193+
194+
const today = new Date();
195+
const timeDiff = today.getTime() - previousReleaseDate.getTime();
196+
if (timeDiff < 0) {
197+
throw new Error("Current date is older that previous release date");
198+
}
199+
const daysBetweenReleases = Math.round(timeDiff / (1000 * 60 * 60 * 24));
200+
const monthsBetweenReleases = Math.floor(daysBetweenReleases / daysInMonthApproximation);
201+
log.verbose(`Previous release date: ${previousReleaseDate}, Today: ${today}`);
202+
log.verbose(
203+
`Time between releases: ${daysBetweenReleases} day(s) or ~${monthsBetweenReleases} month(s)`,
204+
);
205+
206+
const newGeneration =
207+
fluidCompatMetadata.generation +
208+
Math.min(monthsBetweenReleases, minimumCompatWindowMonths - 1);
209+
if (newGeneration === fluidCompatMetadata.generation) {
210+
log.verbose(`Generation remains the same (${newGeneration}); skipping generation update.`);
211+
return undefined;
212+
}
213+
return newGeneration;
214+
}

0 commit comments

Comments
 (0)