Skip to content

ci: build, test, lint only changed packages and dependants - #9373

Merged
Mrtenz merged 36 commits into
mainfrom
mrtenz/ci-incremental-build
Jul 23, 2026
Merged

ci: build, test, lint only changed packages and dependants#9373
Mrtenz merged 36 commits into
mainfrom
mrtenz/ci-incremental-build

Conversation

@Mrtenz

@Mrtenz Mrtenz commented Jul 2, 2026

Copy link
Copy Markdown
Member

Explanation

Currently, every CI run rebuilds all packages from scratch and runs tests, changelog validation, and ESLint across the entire monorepo, regardless of how many packages actually changed.

This PR scopes TypeScript builds, tests, changelog validation, and ESLint to only the packages that changed plus their transitive dependants, by:

  • Using the GitHub Compare API to find the exact merge base between the PR head and the target branch.
  • Running git diff --name-only against that merge base to find changed files.
  • Expanding the changed set to transitive dependants (so type-correctness across package boundaries is preserved), and also to transitive dependencies when building (so ts-bridge has all referenced dist/ outputs available).
  • Falling back to a full run when any file outside a package directory changes (e.g., root config files or workflow files), or when there is no merge base (push to main).

A new scripts/get-changed-workspaces.mts script computes the changed package set and is called from the prepare job (24.x matrix run only). Downstream jobs read the outputs from prepare directly — the separate get-changed-packages job has been removed, saving one sequential job hop.

The changed-paths output gates both ESLint scope and TypeScript build scope: when it is "full", both run against all packages; when it is a JSON array of paths, each runs only against that subset.

Benchmark PRs

Four benchmark PRs target this branch to verify the expected behaviour:

PR Change Expected
#9486 CI-only change (workflow file comment) Full run — workflow files are not in IGNORED_ROOT_FILES
#9487 Single package (@metamask/logging-controller) 3 packages (logging-controller + 2 transitive dependants)
#9488 Three packages (accounts-, gas-fee-, network-controller) 38 packages (those 3 + transitive dependants)
#9489 README.md (ignored) + logging-controller 3 packages — README.md does not trigger a full run

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

Note

Medium Risk
Changes how every PR is validated; incorrect workspace expansion or root-change detection could skip builds or tests, though impact is limited to CI, not shipped product code.

Overview
CI runs only what the PR touches instead of rebuilding and testing the whole monorepo on every run.

The prepare job (Node 24.x) resolves the merge base via the GitHub Compare API, runs get-changed-workspaces.mts, and exports package-names, changed-paths, and merge-base. Downstream jobs use those outputs: changelog validation and per-Node test matrices iterate only affected workspace names; wallet-cli e2e runs only when that package is in the set.

ESLint moves to a dedicated lint-eslint job that runs yarn lint:eslint on all packages when changed-paths is full, otherwise only on the listed workspace paths.

Build uses generate-partial-build-tsconfig.mts plus ts-bridge for a partial TypeScript reference graph (changed packages plus transitive dependants and dependencies) when a merge base exists and there is no root-level change; otherwise it keeps yarn build. Root or workflow changes outside ignored files (e.g. not README.md) still force a full run; pushes without a merge base fall back to all packages.

New shared logic lives in scripts/lib/workspaces.mts (git diff, dependency graph, root-change detection); tsconfig.scripts.json now includes *.mts for those scripts.

Reviewed by Cursor Bugbot for commit b910c31. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread .github/workflows/lint-build-test.yml Fixed
@Mrtenz Mrtenz changed the title ci: build only changed packages and dependants ci: build and test only changed packages and dependants Jul 3, 2026
Comment thread .github/workflows/lint-build-test.yml Fixed
@Mrtenz Mrtenz changed the title ci: build and test only changed packages and dependants ci: build, test, lint only changed packages and dependants Jul 15, 2026
@Mrtenz
Mrtenz marked this pull request as ready for review July 15, 2026 10:21
@Mrtenz
Mrtenz requested a review from a team as a code owner July 15, 2026 10:21
@Mrtenz
Mrtenz temporarily deployed to default-branch July 15, 2026 10:21 — with GitHub Actions Inactive
Mrtenz added 23 commits July 23, 2026 16:39
Instead of rebuilding all 89 packages on every CI run, use
`yarn workspaces foreach --since` to build only the packages that
changed relative to the target branch, plus their transitive dependants.

Falls back to a full `yarn build` on push-to-main events where no
base branch ref is available.
`--depth=1` on the base branch fetch doesn't give git enough history
to find the common ancestor with the PR branch, causing yarn's
`--since` to fail. Replace with `--no-tags` (full history, no tags).
The `action-checkout-and-setup` does a `fetch-depth: 1` shallow clone,
so the PR branch history only goes back one commit. Git cannot find the
merge base against `origin/$BASE_REF` from such a shallow history,
causing yarn's `--since` to fail with "No ancestor could be found".

Unshallow the checkout first, so the full history is available for the
merge-base computation.
Instead of heuristic depth fetches, call the GitHub Compare API to get
the exact merge base SHA, then fetch only that single commit. This
avoids fetching large chunks of history while being precise regardless
of how old the branch is.
The previous approach fetched the merge base commit but git still
couldn't confirm it was an ancestor of HEAD because the PR branch
checkout is depth=1. Fix by unshallowing the checkout using
`--filter=blob:none`, which fetches full commit and tree history
without downloading file content — sufficient for `git diff --name-only`.
Without an explicit refspec, `git fetch --unshallow` deepens all refs
that were fetched during checkout. Passing `origin HEAD` limits it to
the currently checked-out ref.
Instead of running `yarn workspaces foreach --since` sequentially,
generate a temporary tsconfig that lists only changed packages and
their transitive dependants as project references. This lets ts-bridge
use TypeScript's project-references to parallelise the build.
mktemp creates the file in /tmp by default, causing ts-bridge to
resolve relative package paths against /tmp instead of the repo root.
Using --tmpdir="$GITHUB_WORKSPACE" and cleaning up with rm -f after
the build keeps relative paths correct without leaving a dirty tree.
…dencies

Three bugs:
- actions/checkout checks out the merge commit (PR + base), so
  `git diff $MERGE_BASE...HEAD` included all of main's changes. Fix:
  pass the explicit PR head SHA and diff against that instead.
- Packages included as dependants of changed packages couldn't build
  because their own dependencies had no dist files. Fix: expand the
  build set to include transitive dependencies as well.
- ts-bridge rejects a tsconfig with `files: []` and no references.
  Fix: skip ts-bridge entirely when no packages need building.
Extract shared workspace logic into scripts/lib/workspaces.mts and
add scripts/get-changed-workspaces.mts. A new get-changed-packages CI
job fetches the merge base once and computes which packages changed;
test-18/20/22, validate-changelog, and build all consume its outputs
rather than computing independently.

The build job no longer calls the GitHub Compare API itself — it reads
the merge base from get-changed-packages and only needs to unshallow
its own checkout.
CI-only PRs (e.g. changes only to workflow files) would previously fall
back to running tests and changelog validation for all packages. This was
meant to ensure required status checks were not skipped, but the only
required check is at the workflow level ("all jobs pass"), not at the
individual job level. An empty matrix safely skips those jobs without
blocking merges.
GitHub Actions errors with "Matrix vector does not contain any values"
when a matrix input resolves to an empty array. Add an `if` condition to
`validate-changelog`, `test-18`, `test-20`, and `test-22` so they are
skipped entirely when `package-names` is `[]`.
This job builds the full wallet-cli dependency subtree, so it can't use
the package matrix. Instead, skip it entirely when a merge base is
available and `@metamask/wallet-cli` is not in the changed packages list.
If any changed file lives outside all package directories, rebuild and
test everything. Root-level configs, workflow files, and scripts can all
affect every package, so a full run is the safe default.

A set of known-safe root files (yarn.lock, README.md, .gitignore, etc.)
are excluded from this check since they don't affect package builds or tests.
getAllWorkspaces was using `yarn workspaces list` without `--no-private`,
so private packages (e.g. wallet-framework-docs) were included in the
test matrix. The prepare job always used `--no-private`, so these were
never tested before and have no working test scripts.
Extract a `checkRootChange` helper and an optional `changedFiles`
parameter in `computeChangedWorkspaces` to avoid a double `git diff`
call. Rewrite `get-changed-workspaces.mts` with Yargs and change its
output to a single JSON object (`names`, `locations`, `hasRootChange`)
so one script invocation covers all three fields.

In CI, move `lint:eslint` out of the matrix into a dedicated
`lint-eslint` job. When a PR only touches packages, pass their paths
directly to `yarn lint:eslint`; when root files change, fall back to
a full run.
Avoids the unquoted subshell (shellcheck SC2046) and keeps the
invocation to a single eslint process. With ~93 packages averaging
~30 chars each, the argument list is well under ARG_MAX.
`jq` pretty-prints arrays across multiple lines by default, which
causes "Invalid format" errors when writing to GITHUB_OUTPUT. Adding
`-c` keeps the JSON on a single line.
- Fix dead `== ''` guard in `test-wallet-cli-e2e` (should be `== '[]'`
  since `package-names` is always a JSON array string, never empty)
- Remove `eslint-suppressions.json` from `IGNORED_ROOT_FILES` so a
  PR that only adds suppressions still runs ESLint
- Pass `getAllWorkspaces()` to `computeChangedWorkspaces` in
  `generate-partial-build-tsconfig.mts` so that JS-only package changes
  are correctly attributed and don't trigger a spurious full TS rebuild
- Parallelise `package.json` reads in `getWorkspaceDependencies` with
  `Promise.all`
Removes the duplicate `analyse-code` job from `main.yml` and moves it
into `lint-build-test.yml` where it can consume the `scan-paths` output
from `get-changed-packages` directly. When only specific packages
changed, the scanner receives their locations; when root files changed
or there is no merge base, it scans everything.

Uses a temporary commit SHA for the scanner action until `paths` support
is released as a stable version.
Aligns `scanner-ref` with the action SHA so the internal scanner also
picks up the `paths` input implementation.
Paths scoping in CodeQL doesn't reduce runtime — most time is spent
compiling .qls query files regardless of the analysis scope. Revert
the analyse-code job back to main.yml at @v2.
Mrtenz added 7 commits July 23, 2026 16:43
Saves one sequential job hop (~30s) by running the merge base fetch
and changed package detection directly in the 24.x matrix run of
prepare, guarded with `if: matrix.node-version == '24.x'`.
Makes it clear at a glance whether CI is running a full or partial
check, and which packages are included.
Print each package on its own line with a "- " prefix, and add a
blank line after the list to visually separate it from the command
output.
Renames eslint-paths to changed-paths and uses it in the build step
too. Previously, a root change would set MERGE_BASE and trigger a
partial build even though everything should be rebuilt. Now both steps
check CHANGED_PATHS == "full" as the authoritative signal.
The partial-run branches already print a blank line after the package
list. Add the same blank line to the full-run branches.
Declares the minimum permissions required by the workflow explicitly.
@Mrtenz
Mrtenz force-pushed the mrtenz/ci-incremental-build branch from 664902c to a508a37 Compare July 23, 2026 14:43
Comment thread .github/workflows/lint-build-test.yml Outdated
The previous condition ran the job when package-names was empty, which
would only happen when nothing changed — the wrong time to run e2e
tests. On a full run, package-names already contains all packages
including wallet-cli, so contains() is sufficient on its own.
Comment thread scripts/lib/workspaces.mts Outdated
mergeBase: string,
headRef: string,
includeDependencies: boolean,
changedFiles?: string[],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: It's a little odd for this parameter to exist, because the required mergeBase and headRef parameters aren't actually used when this is passed in.

It seems clearer to choose one other the other: either require changed packages to be passed in, or require mergeBase and headRef.

Given how this is used in get-changed-workspaces, probably requiring changedPackages would be better

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another nit: I prefer to use option objects for cases like this so that each parameter can be identified at the callsite. I had to scroll back and forth to ensure they were passed in the correct order, and to identify what the true 4th param represented.

I'm tempted to propose this as a lint rule, but it feels a bit too opinionated 🤔 Not sure.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Removed mergeBase and headRef from this function. I would change it to an option object, but it probably makes less sense now that it only has three parameters?

f7f904f

Comment thread scripts/lib/workspaces.mts Outdated
'CLAUDE.md',
'README.md',
'teams.json',
'yarn.lock',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about this one. A lockfile change could definitely break something

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still on the fence about this too.

If we do consider changes to yarn.lock as needing to run everything, I feel like we limit the number of PRs where these changes are useful a lot.

I was thinking we could try to parse the changes to yarn.lock to make it more granular, but that's probably not worth the effort.

I'm open to removing this from IGNORED_ROOT_FILES for now.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe as a compromise we could ignore yarn.lock changes that are just workspace package bumps (e.g. only run the workspaces that were bumped, rather than all). But if any external dependency changes, re-run everything.

That would be far more achievable than trying to calculate exactly which packages were impacted by a given lockfile change.

We already have some code for parsing lockfile diffs here: https://github.com/MetaMask/metamask-extension/blob/446c5a25b56d6521ad963e7503ce337ad975a8fb/.github/scripts/yarn-audit-diff.mts#L233
(though I didn't review it carefully)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now I'm leaning towards removing it from the ignore list. We can always add it later.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. Will remove it for now, and create a ticket for us to consider this in the future.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: b910c31.

Comment thread scripts/get-changed-workspaces.mts Outdated
.help()
.parseAsync();

const mergeBase = argv._[0] as string;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I found that we can get rid of these type assertions by getting the named parameters from argv, e.g. argv.mergeBase, argv.headSha, etc.).

I had to switch demandCommand to demandOption though; apparently demandCommand only works for sub-commands not positional parameters (which their docs seem to contradict, but 🤷 I tested it).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's the full snippet I tested:

const argv = await yargs(hideBin(process.argv))
  .usage('$0 <merge-base> [head-sha] [options]')
  .positional('merge-base', {
    type: 'string',
    describe: 'Merge base SHA',
  })
  .positional('head-sha', {
    type: 'string',
    describe: 'PR branch tip SHA (defaults to HEAD)',
    default: 'HEAD',
  })
  .option('include-dependencies', {
    type: 'boolean',
    default: false,
    describe:
      'Also expand to transitive dependencies (needed for TypeScript builds)',
  })
  .demandOption('merge-base')
  .help()
  .parseAsync();

const { mergeBase, headSha: headRef, includeDependencies } = argv;
const workspaces = await getAllWorkspaces();

const changedFiles = await getChangedFiles(mergeBase, headRef);
const hasRootChange = checkRootChange(workspaces, changedFiles);

const changed = await computeChangedWorkspaces(
  workspaces,
  mergeBase,
  headRef,
  includeDependencies,
  changedFiles,
);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, nice. That's definitely a lot better.

Done in c5ad03c.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoops, I guess I didn't test it properly. I see the CI failure.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I tried the demandOption parameter in the positional options too, but then it's not added to the argv object as parameter, only to argv._. I'll just make them regular parameters.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: 5a90382.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I found the problem. Our .usage call has no description. If I pass a description, then .positional works as expected, and demandOption gives it the proper type.

Why the usage description matters here, I have no idea

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, making it an option works too

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. 😅 I guess it doesn't really matter if we use an option compared to a positional, so will just keep it as-is.

Gudahtt
Gudahtt previously approved these changes Jul 23, 2026

@Gudahtt Gudahtt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Mostly had nits, though I was tempted to block on the yarn.lock ignore issue. I am on the fence about that.

Mrtenz added 3 commits July 23, 2026 21:23
Previously the function accepted optional `changedFiles` alongside
`mergeBase`/`headRef`, which were redundant when the file list was
already provided. Now callers always fetch changed files first and
pass them in, removing the implicit fallback fetch from inside the
function.
Replaces positional arguments with `--merge-base` and `--head-ref`
named options, using yargs `demandOption` for the required one.
Updates the workflow call site to match.
Changes to `yarn.lock` alone should trigger a full run, since they
may indicate dependency upgrades that affect package behaviour.
@Mrtenz
Mrtenz requested a review from Gudahtt July 23, 2026 19:39

@Gudahtt Gudahtt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@Mrtenz
Mrtenz added this pull request to the merge queue Jul 23, 2026
Merged via the queue into main with commit 007a618 Jul 23, 2026
427 checks passed
@Mrtenz
Mrtenz deleted the mrtenz/ci-incremental-build branch July 23, 2026 19:58
pull Bot pushed a commit to dmrazzy/core that referenced this pull request Jul 24, 2026
…ces in incremental CI (MetaMask#9643)

## Explanation

Previously, any PR that touched `yarn.lock` would fall through to a full
rebuild and test run of all packages, because the script had no way to
know which packages were actually affected by the lockfile change.
Similarly, any change to the root `package.json` (including version-only
bumps from release PRs) triggered a full run.

This PR adds smart diffing for both files:

**`yarn.lock` diffing:** When `yarn.lock` appears in the changed files,
it is parsed using `@yarnpkg/parsers` and compared against the
merge-base version. Entry checksums are compared (rather than
resolutions) so that any change to installed package content is
detected, including the rare case of a package being re-released under
the same version. The changed package names are then cross-referenced
against each workspace's full transitive dependency closure (built by
walking the resolved lockfile graph via `@yarnpkg/core`) to produce the
minimal set of workspaces that need to be rebuilt and tested.

**Root `package.json` diffing:** When `package.json` changes, both sides
are parsed and compared with the `version` field stripped. A
version-only diff (e.g. a release PR bump) is ignored; any other change
(scripts, dependencies, etc.) still triggers a full run.

**Refactored `computeChangedWorkspaces`:** The function now fetches
workspaces and changed files internally, and returns `{ workspaces:
Workspace[], hasRootChange: boolean }` instead of `Set<string>`. This
removes boilerplate from both callers and allows
`get-changed-workspaces` to short-circuit before doing any workspace
computation when `hasRootChange` is true.

## References

- Part of the incremental CI build work from MetaMask#9373

## Checklist

- [ ] I've updated the test suite for new or updated code as appropriate
- [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or
updated code as appropriate
- [ ] I've communicated my changes to consumers by [updating changelogs
for packages I've
changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md)
- [ ] I've introduced [breaking
changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md)
in this PR and have prepared draft pull requests for clients and
consumer packages to resolve them

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes how CI decides what to build/test; incorrect lockfile or
package.json diff logic could skip needed workspaces or over-build. New
Yarn Berry APIs in scripts add maintenance surface but no runtime
product impact.
> 
> **Overview**
> **Incremental CI** no longer treats every `yarn.lock` touch as a full
monorepo run. When the lockfile changes, the script diffs merge-base vs
head checksums, maps changed packages to workspaces whose **transitive**
closure includes them (via `@yarnpkg/core`), then applies the usual
dependant/dependency expansion.
> 
> **Root `package.json`** is ignored for “root change” unless something
besides `version` changed (release-only bumps stay incremental).
`yarn.lock` is also excluded from the generic root-change path so
lockfile logic handles it.
> 
> **`computeChangedWorkspaces`** now takes `{ mergeBase, headRef,
includeDependencies }`, loads workspaces and git diffs internally, and
returns `{ workspaces, hasRootChange }` with dependency graph edges as
`Workspace` objects. Callers `get-changed-workspaces.mts` and
`generate-partial-build-tsconfig.mts` were simplified to match.
> 
> Adds `@yarnpkg/cli`, `@yarnpkg/core`, `@yarnpkg/fslib`, and
`@yarnpkg/parsers` dev deps (large `yarn.lock` churn). **`foundryup`**
gets a `@ts-expect-error` on `tar` extract `transform` for `tar@7.5.12`
typing.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
4bf933e. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants