Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
083108d
ci(client): scope PR tests to changed packages via pnpm filter
tylerbutler Apr 22, 2026
42db332
ci(client): move test-skip guard to job level and tighten detect_changes
tylerbutler Apr 22, 2026
c6d35a0
ci(client): replace succeeded() with explicit build.result check
tylerbutler Apr 22, 2026
ae2edf4
ci(client): drop unused enableChangedPackageTestScoping parameter
tylerbutler Apr 22, 2026
0bb3139
Merge branch 'main' into test/filtered-ci/scoping
tylerbutler Apr 23, 2026
35b7113
ci(client): fall back to full run on git diff / rev-parse failures
tylerbutler Apr 23, 2026
5392eb9
ci(client): only emit npm_config_filter when filter is non-empty
tylerbutler Apr 23, 2026
0e41263
ci(client): surface full test-skip as pipeline warning with file dump
tylerbutler Apr 23, 2026
0e812d0
Merge branch 'main' into test/filtered-ci/scoping
tylerbutler Apr 23, 2026
f3a4991
Merge branch 'main' into test/filtered-ci/scoping
tylerbutler Apr 24, 2026
89524e0
ci(client): address PR review feedback
tylerbutler Apr 24, 2026
c836304
ci(client): pin tsx as a devDep, invoke via pnpm exec
tylerbutler Apr 24, 2026
b552236
format
tylerbutler Apr 24, 2026
4d67558
switch to python
tylerbutler Apr 24, 2026
ac05fad
ci(client): address remaining review feedback on detect-changed-packages
tylerbutler Apr 24, 2026
122f6fd
ci(client): simplify detect_changed_packages and trim duplicate YAML …
tylerbutler Apr 24, 2026
86e0059
Merge branch 'main' into test/filtered-ci/scoping
tylerbutler May 7, 2026
4b317ee
Merge branch 'main' into test/filtered-ci/scoping
tylerbutler May 11, 2026
c9de62a
Merge branch 'main' into test/filtered-ci/scoping
tylerbutler May 11, 2026
da40d48
ci: port changed-package script to node
tylerbutler May 11, 2026
ba372c1
refactor(ci): simplify detect_changed_packages helpers and tests
tylerbutler May 11, 2026
bb5bea7
docs(ci): add JSDoc typing to detect_changed_packages helpers
tylerbutler May 11, 2026
fe15def
docs(ci): explain detect script rationale
tylerbutler May 11, 2026
e556445
build: move change detection to flub
tylerbutler May 11, 2026
5cadca7
refactor(build-cli): slim check changedPackages and reuse shared helpers
tylerbutler May 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions build-tools/packages/build-cli/docs/check.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
Check commands are used to verify repo state, apply policy, etc.

* [`flub check buildVersion`](#flub-check-buildversion)
* [`flub check changedPackages`](#flub-check-changedpackages)
* [`flub check changeset`](#flub-check-changeset)
* [`flub check latestVersions VERSION PACKAGE_OR_RELEASE_GROUP`](#flub-check-latestversions-version-package_or_release_group)
* [`flub check layers`](#flub-check-layers)
Expand Down Expand Up @@ -66,6 +67,35 @@ DESCRIPTION

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

## `flub check changedPackages`

Computes Azure DevOps output variables used by pipelines to conditionally skip tests.

```
USAGE
$ flub check changedPackages [--json] [-v | --quiet] [--targetBranch <value>] [--searchPath <value>]

FLAGS
--searchPath=<value> Path used to locate the build project. Defaults to the current working directory.
--targetBranch=<value> [env: TARGET_BRANCH] Target branch to compare against. Defaults to the TARGET_BRANCH
environment variable.

LOGGING FLAGS
-v, --verbose Enable verbose logging.
--quiet Disable all logging.

GLOBAL FLAGS
--json Format output as json.

DESCRIPTION
Computes Azure DevOps output variables used by pipelines to conditionally skip tests.

Compares the current PR branch to the merge base with a target branch, then emits 'shouldRunTests' and
'scopedPnpmFilter' as Azure DevOps output variables. Unexpected errors conservatively fall back to a full test run.
```

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

## `flub check changeset`

Checks if a changeset was added when compared against a branch. This is used in CI to enforce that changesets are present for a PR.
Expand Down
241 changes: 241 additions & 0 deletions build-tools/packages/build-cli/src/commands/check/changedPackages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/

import path from "node:path";

import {
getChangedSinceRef,
getMergeBaseRemote,
getPackageDirsAtRef,
getRemote,
isFileInPackageDir,
} from "@fluid-tools/build-infrastructure";
import { Flags } from "@oclif/core";

import {
formatLogIssue,
formatSetVariable,
} from "../../library/azureDevops/pipelineCommands.js";
import { normalizeTargetBranch } from "../../library/branches.js";
import { BaseCommandWithBuildProject } from "../../library/commands/base.js";

/**
* Full-run trigger patterns. A diff touching any of these paths forces running
* every package's tests. Keep this conservative since these files can affect
* dependency resolution, build behavior, or pipeline behavior across packages.
*/
const fullRunPatterns: readonly RegExp[] = [
/^package\.json$/,
/^pnpm-lock\.yaml$/,
/^pnpm-workspace\.yaml$/,
/^\.pnpmfile\.cjs$/,
/^\.npmrc$/,
/^\.nvmrc$/,
/^fluidBuild\.config\.cjs$/,
/^tsconfig[^/]*\.json$/,
/^biome\./,
/^tools\//,
/^common\//,
/^scripts\//,
/^\.changeset\/config\.json$/,
];

/**
* Result of computing which packages have changed since the target branch.
*
* The same shape is returned both for full-run fallbacks (e.g. unexpected errors or trigger-pattern
* matches) and for the normal scoped-filter case. Consumers can inspect {@link forcedFullRunPattern}
* to disambiguate.
*/
export interface ChangedPackagesResult {
/** Whether any tests should run at all. `false` only when no changed file maps to a workspace package. */
shouldRunTests: boolean;
/** The computed `pnpm --filter` expression, or an empty string for full / no-op runs. */
scopedPnpmFilter: string;
/** The (normalized) target branch the comparison was performed against. */
targetBranch: string;
/** The merge-base commit between HEAD and the target branch, when it could be determined. */
mergeBase?: string;
/** The list of files that changed since the merge base. Empty on the error fallback path. */
changedFiles: string[];
/** The source of the first {@link fullRunPatterns} entry that matched a changed file, if any. */
forcedFullRunPattern?: string;
/** Number of workspace packages reported as changed by `getChangedSinceRef`. */
changedPackageCount: number;
}

/**
* Returns the first pattern in `patterns` that matches any path in `files`, or `undefined` if
* none match.
*/
function findFullRunPatternMatch(
files: readonly string[],
patterns: readonly RegExp[],
): RegExp | undefined {
for (const pattern of patterns) {
if (files.some((file) => pattern.test(file))) {
return pattern;
}
}
return undefined;
}

export default class CheckChangedPackagesCommand extends BaseCommandWithBuildProject<
typeof CheckChangedPackagesCommand
> {
static readonly summary =
"Computes Azure DevOps output variables used by pipelines to conditionally skip tests.";

static readonly description =
"Compares the current PR branch to the merge base with a target branch, then emits 'shouldRunTests' and 'scopedPnpmFilter' as Azure DevOps output variables. Unexpected errors conservatively fall back to a full test run.";

static readonly enableJsonFlag = true;

static readonly flags = {
targetBranch: Flags.string({
description:
"Target branch to compare against. Defaults to the TARGET_BRANCH environment variable.",
env: "TARGET_BRANCH",
}),
searchPath: Flags.directory({
description:
"Path used to locate the build project. Defaults to the current working directory.",
exists: true,
}),
...BaseCommandWithBuildProject.flags,
} as const;

public async run(): Promise<ChangedPackagesResult> {
const targetBranch = normalizeTargetBranch(
this.flags.targetBranch ?? process.env.TARGET_BRANCH ?? "",
);

if (targetBranch === "") {
return this.fallbackFullRun("TARGET_BRANCH not set;", targetBranch);
}

this.info(`Target branch: ${targetBranch}`);

try {
const buildProject = this.getBuildProject(
path.resolve(this.flags.searchPath ?? process.cwd()),
);
const git = await buildProject.getGitRepository();
const remote = await getRemote(git, buildProject.upstreamRemotePartialUrl);
if (remote === undefined) {
return this.fallbackFullRun(
`Could not find upstream remote for ${buildProject.upstreamRemotePartialUrl};`,
targetBranch,
);
}

await git.fetch([remote, targetBranch]);
const mergeBase = await getMergeBaseRemote(
git,
targetBranch,
remote,
"HEAD",
(message) => this.info(message),
);
this.info(`Merge base: ${mergeBase}`);

const changed = await getChangedSinceRef(buildProject, targetBranch, remote);
const changedFiles = changed.files;
this.info(`Changed files (${changedFiles.length}):`);
for (const file of changedFiles.slice(0, 30)) {
this.info(file);
}
if (changedFiles.length > 30) {
this.info(`... and ${changedFiles.length - 30} more`);
}

const match = findFullRunPatternMatch(changedFiles, fullRunPatterns);
if (match !== undefined) {
this.info(`Match for full-run pattern '${match.source}' - forcing full test run.`);
this.emitVsoOutputs(true, "");
return {
shouldRunTests: true,
scopedPnpmFilter: "",
targetBranch,
mergeBase,
changedFiles,
forcedFullRunPattern: match.source,
changedPackageCount: changed.packages.length,
};
}

// Union of package directories at the merge-base tree and the current working tree so
// that packages added, removed, or moved between the two refs are all considered.
const historicalDirs = await getPackageDirsAtRef(git, mergeBase);
const currentDirs = await getPackageDirsAtRef(git);
const packageDirs = new Set([...historicalDirs, ...currentDirs]);

if (!changedFiles.some((file) => isFileInPackageDir(file, packageDirs))) {
this.logWarning(
`No changed files mapped to a workspace package - skipping all test execution. Files considered (${changedFiles.length}):`,
);
for (const file of changedFiles) {
this.info(` ${file}`);
}
this.emitVsoOutputs(false, "");
return {
shouldRunTests: false,
scopedPnpmFilter: "",
targetBranch,
mergeBase,
changedFiles,
changedPackageCount: 0,
};
}

const scopedPnpmFilter = `...[${mergeBase}]`;
this.info(`Computed pnpm filter: ${scopedPnpmFilter}`);
this.emitVsoOutputs(true, scopedPnpmFilter);
return {
shouldRunTests: true,
scopedPnpmFilter,
targetBranch,
mergeBase,
changedFiles,
changedPackageCount: changed.packages.length,
};
} catch (error) {
return this.fallbackFullRun(
error instanceof Error ? `${error.message};` : `${String(error)};`,
targetBranch,
);
}
}

private emitVsoOutputs(shouldRunTests: boolean, scopedPnpmFilter: string): void {
if (this.jsonEnabled()) {
return;
}

const flag = shouldRunTests ? "true" : "false";
this.log(`shouldRunTests=${flag}`);
this.log(`scopedPnpmFilter=${scopedPnpmFilter}`);
this.log(formatSetVariable("shouldRunTests", flag, { isOutput: true }));
this.log(formatSetVariable("scopedPnpmFilter", scopedPnpmFilter, { isOutput: true }));
}

private logWarning(message: string): void {
if (!this.jsonEnabled()) {
this.log(formatLogIssue("warning", message));
}
}

private fallbackFullRun(reason: string, targetBranch: string): ChangedPackagesResult {
this.logWarning(`${reason} Falling back to full test run.`);
this.emitVsoOutputs(true, "");
return {
shouldRunTests: true,
scopedPnpmFilter: "",
targetBranch,
changedFiles: [],
changedPackageCount: 0,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import { findPackageOrReleaseGroup, packageOrReleaseGroupArg, semverArg } from "../../args.js";
import { formatSetVariable } from "../../library/azureDevops/pipelineCommands.js";
import { BaseCommand } from "../../library/commands/base.js";
import { isLatestInMajor } from "../../library/latestVersions.js";

Expand Down Expand Up @@ -52,9 +53,9 @@ export default class LatestVersionsCommand extends BaseCommand<typeof LatestVers
this.log(
`Version ${versionInput.version} is the latest version for major version ${result.majorVersion}`,
);
this.log(`##vso[task.setvariable variable=shouldDeploy;isoutput=true]true`);
this.log(formatSetVariable("shouldDeploy", "true", { isOutput: true }));
this.log(
`##vso[task.setvariable variable=majorVersion;isoutput=true]${result.majorVersion}`,
formatSetVariable("majorVersion", String(result.majorVersion), { isOutput: true }),
);
return;
}
Expand All @@ -63,19 +64,19 @@ export default class LatestVersionsCommand extends BaseCommand<typeof LatestVers
this.log(
`##[warning]skipping deployment stage. input version ${versionInput.version} does not match the latest version ${result.latestVersion}`,
);
this.log(`##vso[task.setvariable variable=shouldDeploy;isoutput=true]false`);
this.log(formatSetVariable("shouldDeploy", "false", { isOutput: true }));
this.log(
`##vso[task.setvariable variable=majorVersion;isoutput=true]${result.majorVersion}`,
formatSetVariable("majorVersion", String(result.majorVersion), { isOutput: true }),
);
return;
}

this.log(
`##[warning]No major version found corresponding to input version ${versionInput.version}`,
);
this.log(`##vso[task.setvariable variable=shouldDeploy;isoutput=true]false`);
this.log(formatSetVariable("shouldDeploy", "false", { isOutput: true }));
this.log(
`##vso[task.setvariable variable=majorVersion;isoutput=true]${result.majorVersion}`,
formatSetVariable("majorVersion", String(result.majorVersion), { isOutput: true }),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getIsLatest, getSimpleVersion } from "@fluid-tools/version-tools";
import { Flags } from "@oclif/core";

import { semverFlag } from "../../flags.js";
import { formatSetVariable } from "../../library/azureDevops/pipelineCommands.js";
import { BaseCommand } from "../../library/commands/base.js";

/**
Expand Down Expand Up @@ -138,7 +139,7 @@ export default class GenerateBuildVersionCommand extends BaseCommand<
? `${simpleVersion}-test-${alphabetaTypePrefix}`
: `${simpleVersion}-test`;
this.log(`codeVersion=${codeVersion}`);
this.log(`##vso[task.setvariable variable=codeVersion;isOutput=true]${codeVersion}`);
this.log(formatSetVariable("codeVersion", codeVersion, { isOutput: true }));
}

if (isAlphaOrBetaTypes) {
Expand All @@ -154,13 +155,13 @@ export default class GenerateBuildVersionCommand extends BaseCommand<
}

this.log(`version=${version}`);
this.log(`##vso[task.setvariable variable=version;isOutput=true]${version}`);
this.log(formatSetVariable("version", version, { isOutput: true }));

if (flags.tag !== undefined) {
const isLatest = getIsLatest(flags.tag, version, tags, shouldIncludeInternalVersions);
this.log(`isLatest=${isLatest}`);
if (isRelease && isLatest === true) {
this.log(`##vso[task.setvariable variable=isLatest;isOutput=true]${isLatest}`);
this.log(formatSetVariable("isLatest", String(isLatest), { isOutput: true }));
}
}
}
Expand Down
Loading
Loading