From c7b83b59f624a55ca2fff3f3fd9255093a9421c0 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 2 Jul 2026 13:56:47 +0200 Subject: [PATCH 01/31] Build only changed packages and dependants in CI 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. --- .github/workflows/lint-build-test.yml | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 76ff47fe639..5d884573a24 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -118,7 +118,31 @@ jobs: is-high-risk-environment: false persist-credentials: false node-version: ${{ matrix.node-version }} - - run: yarn build + - name: Fetch base branch + id: fetch-base-branch + if: github.base_ref != '' || github.event.merge_group.base_ref != '' + run: | + # Strip invalid prefix from `github.event.merge_group.base_ref` + # It comes prefixed with `refs/heads/`, but the branch is not checked out in this context + # We need to express it as a remote branch + PREFIXED_REF_REGEX='refs/heads/(.+)' + if [[ "$BASE_REF" =~ $PREFIXED_REF_REGEX ]]; then + BASE_REF="${BASH_REMATCH[1]}" + fi + + git fetch origin "$BASE_REF" --depth=1 + echo "base-ref=$BASE_REF" >> "$GITHUB_OUTPUT" + env: + BASE_REF: ${{ github.event.pull_request.base.ref || github.event.merge_group.base_ref }} + - name: Build + run: | + if [[ -n "$BASE_REF" ]]; then + yarn workspaces foreach --since="origin/$BASE_REF" --topological --recursive --no-private run build + else + yarn build + fi + env: + BASE_REF: ${{ steps.fetch-base-branch.outputs.base-ref }} - name: Require clean working directory shell: bash run: | From 9e83091ba2df93ca9c0c132cd65f5a677ecd081b Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 2 Jul 2026 14:01:17 +0200 Subject: [PATCH 02/31] Fix shallow clone preventing merge-base resolution `--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). --- .github/workflows/lint-build-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 5d884573a24..f2a606549e2 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -130,7 +130,7 @@ jobs: BASE_REF="${BASH_REMATCH[1]}" fi - git fetch origin "$BASE_REF" --depth=1 + git fetch origin "$BASE_REF" --no-tags echo "base-ref=$BASE_REF" >> "$GITHUB_OUTPUT" env: BASE_REF: ${{ github.event.pull_request.base.ref || github.event.merge_group.base_ref }} From 9355d7b54751cef53204498ac04eb74464ec5f70 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 2 Jul 2026 14:06:06 +0200 Subject: [PATCH 03/31] Unshallow the checkout before computing the merge base 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. --- .github/workflows/lint-build-test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index f2a606549e2..5b94e2754bd 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -130,6 +130,7 @@ jobs: BASE_REF="${BASH_REMATCH[1]}" fi + git fetch --unshallow git fetch origin "$BASE_REF" --no-tags echo "base-ref=$BASE_REF" >> "$GITHUB_OUTPUT" env: From dd473c664e5b9fc40bd18135ba67c33c13528989 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 2 Jul 2026 14:21:14 +0200 Subject: [PATCH 04/31] Use GitHub Compare API to fetch exact merge base 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. --- .github/workflows/lint-build-test.yml | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 5b94e2754bd..532f3114b8a 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -118,10 +118,12 @@ jobs: is-high-risk-environment: false persist-credentials: false node-version: ${{ matrix.node-version }} - - name: Fetch base branch - id: fetch-base-branch + - name: Fetch merge base + id: fetch-merge-base if: github.base_ref != '' || github.event.merge_group.base_ref != '' run: | + set -euo pipefail + # Strip invalid prefix from `github.event.merge_group.base_ref` # It comes prefixed with `refs/heads/`, but the branch is not checked out in this context # We need to express it as a remote branch @@ -130,20 +132,24 @@ jobs: BASE_REF="${BASH_REMATCH[1]}" fi - git fetch --unshallow - git fetch origin "$BASE_REF" --no-tags - echo "base-ref=$BASE_REF" >> "$GITHUB_OUTPUT" + # Use the GitHub Compare API to get the exact merge base SHA, + # then fetch only that single commit. + MERGE_BASE=$(gh api "repos/$GITHUB_REPOSITORY/compare/$BASE_REF...$HEAD_SHA" --jq '.merge_base_commit.sha') + git fetch origin "$MERGE_BASE" --depth=1 --no-tags + echo "merge-base=$MERGE_BASE" >> "$GITHUB_OUTPUT" env: BASE_REF: ${{ github.event.pull_request.base.ref || github.event.merge_group.base_ref }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} + GH_TOKEN: ${{ github.token }} - name: Build run: | - if [[ -n "$BASE_REF" ]]; then - yarn workspaces foreach --since="origin/$BASE_REF" --topological --recursive --no-private run build + if [[ -n "$MERGE_BASE" ]]; then + yarn workspaces foreach --since="$MERGE_BASE" --topological --recursive --no-private run build else yarn build fi env: - BASE_REF: ${{ steps.fetch-base-branch.outputs.base-ref }} + MERGE_BASE: ${{ steps.fetch-merge-base.outputs.merge-base }} - name: Require clean working directory shell: bash run: | From 3d2e7576219a1a18305098aecc031b9957ea8699 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 2 Jul 2026 14:26:50 +0200 Subject: [PATCH 05/31] Unshallow with blobless filter to confirm merge base ancestry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- .github/workflows/lint-build-test.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 532f3114b8a..9099a54fe22 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -132,10 +132,15 @@ jobs: BASE_REF="${BASH_REMATCH[1]}" fi - # Use the GitHub Compare API to get the exact merge base SHA, - # then fetch only that single commit. + # Use the GitHub Compare API to get the exact merge base SHA. MERGE_BASE=$(gh api "repos/$GITHUB_REPOSITORY/compare/$BASE_REF...$HEAD_SHA" --jq '.merge_base_commit.sha') - git fetch origin "$MERGE_BASE" --depth=1 --no-tags + + # Unshallow the checkout so git can confirm the ancestry + # relationship required by `--since`. Using `--filter=blob:none` + # avoids downloading file content — commit and tree metadata is + # all that `git diff --name-only` needs. + git fetch --unshallow --filter=blob:none --no-tags + echo "merge-base=$MERGE_BASE" >> "$GITHUB_OUTPUT" env: BASE_REF: ${{ github.event.pull_request.base.ref || github.event.merge_group.base_ref }} From 5626cb5a7dcb5006ce389bb14c98a6f50e1fbabf Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 2 Jul 2026 14:34:04 +0200 Subject: [PATCH 06/31] Restrict unshallow fetch to HEAD 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. --- .github/workflows/lint-build-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 9099a54fe22..9e8a962f72c 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -139,7 +139,7 @@ jobs: # relationship required by `--since`. Using `--filter=blob:none` # avoids downloading file content — commit and tree metadata is # all that `git diff --name-only` needs. - git fetch --unshallow --filter=blob:none --no-tags + git fetch --unshallow --filter=blob:none --no-tags origin HEAD echo "merge-base=$MERGE_BASE" >> "$GITHUB_OUTPUT" env: From 8512b54cbcc32d9326d96968afe82c094e041ab0 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 3 Jul 2026 11:11:34 +0200 Subject: [PATCH 07/31] Use ts-bridge with partial tsconfig for partial builds 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. --- .github/workflows/lint-build-test.yml | 4 +- scripts/generate-partial-build-tsconfig.mts | 139 ++++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 scripts/generate-partial-build-tsconfig.mts diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 9e8a962f72c..74ddcb6953d 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -149,7 +149,9 @@ jobs: - name: Build run: | if [[ -n "$MERGE_BASE" ]]; then - yarn workspaces foreach --since="$MERGE_BASE" --topological --recursive --no-private run build + TSCONFIG=$(mktemp --suffix=.json) + yarn tsx scripts/generate-partial-build-tsconfig.mts "$MERGE_BASE" > "$TSCONFIG" + yarn ts-bridge --project "$TSCONFIG" --verbose else yarn build fi diff --git a/scripts/generate-partial-build-tsconfig.mts b/scripts/generate-partial-build-tsconfig.mts new file mode 100644 index 00000000000..d0719d9b83f --- /dev/null +++ b/scripts/generate-partial-build-tsconfig.mts @@ -0,0 +1,139 @@ +import { fileExists } from '@metamask/utils/node'; +import execa from 'execa'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +const ROOT_WORKSPACE = new URL('..', import.meta.url).pathname; + +type Workspace = { + location: string; + name: string; +}; + +/** + * Get all TypeScript workspaces in the monorepo. This checks for packages + * containing a "tsconfig.build.json" file. + * + * @returns The workspaces in the monorepo. + */ +async function getWorkspaces(): Promise { + const { stdout } = await execa('yarn', ['workspaces', 'list', '--json'], { + cwd: ROOT_WORKSPACE, + encoding: 'utf8', + }); + + const entries: Workspace[] = stdout + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + .filter(({ location }: Workspace) => location !== '.'); + + return ( + await Promise.all( + entries.map(async (workspace) => { + const hasTsConfig = await fileExists( + join(ROOT_WORKSPACE, workspace.location, 'tsconfig.build.json'), + ); + + return hasTsConfig ? workspace : null; + }), + ) + ).filter((workspace): workspace is Workspace => workspace !== null); +} + +/** + * Get a map of package name -> names of packages that depend on it. + * + * @param workspaces - The workspaces in the monorepo. + * @returns A map of package name -> names of packages that depend on it. + */ +async function getWorkspaceDependencies( + workspaces: Workspace[], +): Promise>> { + const dependants: Record> = Object.fromEntries( + workspaces.map(({ name }) => [name, new Set()]), + ); + + for (const { name, location } of workspaces) { + const pkg = JSON.parse( + await readFile(join(ROOT_WORKSPACE, location, 'package.json'), { + encoding: 'utf-8', + }), + ); + + for (const dependency of Object.keys({ + ...pkg.dependencies, + ...pkg.devDependencies, + })) { + dependants[dependency]?.add(name); + } + } + + return dependants; +} + +/** + * Get the list of files changed since the given merge base. + * + * @param mergeBase - The merge base SHA to diff against. + * @returns A list of changed file paths. + */ +async function getChangedFiles(mergeBase: string): Promise { + const { stdout } = await execa( + 'git', + ['diff', '--name-only', `${mergeBase}...HEAD`], + { + cwd: ROOT_WORKSPACE, + encoding: 'utf8', + }, + ); + + return stdout.trim().split('\n').filter(Boolean); +} + +/** + * Generate a filtered tsconfig.build.json for partial CI builds. + * + * Given a merge base SHA, outputs a tsconfig that references only the + * packages that changed since that commit plus their transitive dependants. + * Pipe the output to a temp file and pass it to `ts-bridge --project`. + * + * Usage: `tsx scripts/generate-partial-build-tsconfig.ts `. + */ +async function main() { + const mergeBase = process.argv[2]; + if (!mergeBase) { + console.error('Usage: generate-partial-build-tsconfig.ts '); + + process.exitCode = 1; + return; + } + + const workspaces = await getWorkspaces(); + const changedFiles = await getChangedFiles(mergeBase); + const dependants = await getWorkspaceDependencies(workspaces); + + const packagesToBuild = new Set( + changedFiles.flatMap((file) => { + const workspace = workspaces.find(({ location }) => + file.startsWith(`${location}/`), + ); + + return workspace ? [workspace.name] : []; + }), + ); + + for (const packageToBuild of packagesToBuild) { + for (const dependant of dependants[packageToBuild] ?? []) { + packagesToBuild.add(dependant); + } + } + + const references = workspaces + .filter(({ name }) => packagesToBuild.has(name)) + .map(({ location }) => ({ path: `./${location}/tsconfig.build.json` })); + + console.log(JSON.stringify({ files: [], include: [], references }, null, 2)); +} + +await main(); From fe987dd5159788c768e26ab9471dd11f9d89aaa2 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 3 Jul 2026 11:15:37 +0200 Subject: [PATCH 08/31] Write partial tsconfig to workspace root so relative paths resolve 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. --- .github/workflows/lint-build-test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 74ddcb6953d..8bfb6e38642 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -149,9 +149,10 @@ jobs: - name: Build run: | if [[ -n "$MERGE_BASE" ]]; then - TSCONFIG=$(mktemp --suffix=.json) + TSCONFIG=$(mktemp --tmpdir="$GITHUB_WORKSPACE" --suffix=.json) yarn tsx scripts/generate-partial-build-tsconfig.mts "$MERGE_BASE" > "$TSCONFIG" yarn ts-bridge --project "$TSCONFIG" --verbose + rm -f "$TSCONFIG" else yarn build fi From 94006aafa7920269682a6707eb5dc20c7623d465 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 3 Jul 2026 11:32:39 +0200 Subject: [PATCH 09/31] Fix incremental build detecting wrong changed files and missing dependencies 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. --- .github/workflows/lint-build-test.yml | 7 ++- scripts/generate-partial-build-tsconfig.mts | 59 ++++++++++++++++----- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 8bfb6e38642..7cb195a4009 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -150,14 +150,17 @@ jobs: run: | if [[ -n "$MERGE_BASE" ]]; then TSCONFIG=$(mktemp --tmpdir="$GITHUB_WORKSPACE" --suffix=.json) - yarn tsx scripts/generate-partial-build-tsconfig.mts "$MERGE_BASE" > "$TSCONFIG" - yarn ts-bridge --project "$TSCONFIG" --verbose + yarn tsx scripts/generate-partial-build-tsconfig.mts "$MERGE_BASE" "$HEAD_SHA" > "$TSCONFIG" + if [[ -s "$TSCONFIG" ]]; then + yarn ts-bridge --project "$TSCONFIG" --verbose + fi rm -f "$TSCONFIG" else yarn build fi env: MERGE_BASE: ${{ steps.fetch-merge-base.outputs.merge-base }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} - name: Require clean working directory shell: bash run: | diff --git a/scripts/generate-partial-build-tsconfig.mts b/scripts/generate-partial-build-tsconfig.mts index d0719d9b83f..5cbbf49d496 100644 --- a/scripts/generate-partial-build-tsconfig.mts +++ b/scripts/generate-partial-build-tsconfig.mts @@ -41,18 +41,26 @@ async function getWorkspaces(): Promise { ).filter((workspace): workspace is Workspace => workspace !== null); } +type DependencyGraph = { + dependants: Record>; + dependencies: Record>; +}; + /** - * Get a map of package name -> names of packages that depend on it. + * Get dependency and dependant maps for all workspaces. * * @param workspaces - The workspaces in the monorepo. - * @returns A map of package name -> names of packages that depend on it. + * @returns Maps of package name -> dependants and package name -> dependencies. */ async function getWorkspaceDependencies( workspaces: Workspace[], -): Promise>> { +): Promise { const dependants: Record> = Object.fromEntries( workspaces.map(({ name }) => [name, new Set()]), ); + const dependencies: Record> = Object.fromEntries( + workspaces.map(({ name }) => [name, new Set()]), + ); for (const { name, location } of workspaces) { const pkg = JSON.parse( @@ -65,23 +73,30 @@ async function getWorkspaceDependencies( ...pkg.dependencies, ...pkg.devDependencies, })) { - dependants[dependency]?.add(name); + if (dependants[dependency] !== undefined) { + dependants[dependency].add(name); + dependencies[name].add(dependency); + } } } - return dependants; + return { dependants, dependencies }; } /** - * Get the list of files changed since the given merge base. + * Get the list of files changed between the merge base and the PR head. * - * @param mergeBase - The merge base SHA to diff against. + * @param mergeBase - The merge base SHA. + * @param headRef - The PR branch tip SHA (or "HEAD" as fallback). * @returns A list of changed file paths. */ -async function getChangedFiles(mergeBase: string): Promise { +async function getChangedFiles( + mergeBase: string, + headRef: string, +): Promise { const { stdout } = await execa( 'git', - ['diff', '--name-only', `${mergeBase}...HEAD`], + ['diff', '--name-only', `${mergeBase}...${headRef}`], { cwd: ROOT_WORKSPACE, encoding: 'utf8', @@ -98,20 +113,25 @@ async function getChangedFiles(mergeBase: string): Promise { * packages that changed since that commit plus their transitive dependants. * Pipe the output to a temp file and pass it to `ts-bridge --project`. * - * Usage: `tsx scripts/generate-partial-build-tsconfig.ts `. + * Usage: `tsx scripts/generate-partial-build-tsconfig.ts [head-sha]`. */ async function main() { const mergeBase = process.argv[2]; if (!mergeBase) { - console.error('Usage: generate-partial-build-tsconfig.ts '); + console.error( + 'Usage: generate-partial-build-tsconfig.ts [head-sha]', + ); process.exitCode = 1; return; } + const headRef = process.argv[3] ?? 'HEAD'; + const workspaces = await getWorkspaces(); - const changedFiles = await getChangedFiles(mergeBase); - const dependants = await getWorkspaceDependencies(workspaces); + const changedFiles = await getChangedFiles(mergeBase, headRef); + const { dependants, dependencies } = + await getWorkspaceDependencies(workspaces); const packagesToBuild = new Set( changedFiles.flatMap((file) => { @@ -123,16 +143,29 @@ async function main() { }), ); + // Expand to transitive dependants (packages that depend on what changed). for (const packageToBuild of packagesToBuild) { for (const dependant of dependants[packageToBuild] ?? []) { packagesToBuild.add(dependant); } } + // Expand to transitive dependencies (dist files must exist to build + // dependants). + for (const packageToBuild of packagesToBuild) { + for (const dependency of dependencies[packageToBuild] ?? []) { + packagesToBuild.add(dependency); + } + } + const references = workspaces .filter(({ name }) => packagesToBuild.has(name)) .map(({ location }) => ({ path: `./${location}/tsconfig.build.json` })); + if (references.length === 0) { + return; + } + console.log(JSON.stringify({ files: [], include: [], references }, null, 2)); } From 37d7f09a1e241b19262a1b5df27571ae740745d8 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 3 Jul 2026 11:58:20 +0200 Subject: [PATCH 10/31] Extend partial builds to tests and changelog validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/lint-build-test.yml | 112 ++++++++----- scripts/generate-partial-build-tsconfig.mts | 158 +++--------------- scripts/get-changed-workspaces.mts | 43 +++++ scripts/lib/workspaces.mts | 167 ++++++++++++++++++++ tsconfig.scripts.json | 2 +- 5 files changed, 305 insertions(+), 177 deletions(-) create mode 100644 scripts/get-changed-workspaces.mts create mode 100644 scripts/lib/workspaces.mts diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 7cb195a4009..d4aa17b9f73 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -66,11 +66,13 @@ jobs: validate-changelog: name: Validate changelog runs-on: ubuntu-latest - needs: prepare + needs: + - prepare + - get-changed-packages strategy: matrix: node-version: [24.x] - package-name: ${{ fromJson(needs.prepare.outputs.child-workspace-package-names) }} + package-name: ${{ fromJson(needs.get-changed-packages.outputs.package-names) }} steps: - name: Checkout and setup environment uses: MetaMask/action-checkout-and-setup@v3 @@ -92,53 +94,32 @@ jobs: exit 1 fi - validate-changelog-diffs: - name: Validate changelog diffs - if: github.event_name == 'pull_request' || github.event_name == 'merge_group' - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v7 - with: - persist-credentials: false - - name: Validate changelog diffs - uses: ./.github/actions/check-merge-queue-changelogs - - build: - name: Build + get-changed-packages: + name: Get changed packages runs-on: ubuntu-latest needs: prepare - strategy: - matrix: - node-version: [24.x] + outputs: + merge-base: ${{ steps.fetch-merge-base.outputs.merge-base }} + package-names: ${{ steps.packages.outputs.package-names }} steps: - name: Checkout and setup environment uses: MetaMask/action-checkout-and-setup@v3 with: is-high-risk-environment: false persist-credentials: false - node-version: ${{ matrix.node-version }} + node-version: 24.x - name: Fetch merge base id: fetch-merge-base if: github.base_ref != '' || github.event.merge_group.base_ref != '' run: | set -euo pipefail - # Strip invalid prefix from `github.event.merge_group.base_ref` - # It comes prefixed with `refs/heads/`, but the branch is not checked out in this context - # We need to express it as a remote branch PREFIXED_REF_REGEX='refs/heads/(.+)' if [[ "$BASE_REF" =~ $PREFIXED_REF_REGEX ]]; then BASE_REF="${BASH_REMATCH[1]}" fi - # Use the GitHub Compare API to get the exact merge base SHA. MERGE_BASE=$(gh api "repos/$GITHUB_REPOSITORY/compare/$BASE_REF...$HEAD_SHA" --jq '.merge_base_commit.sha') - - # Unshallow the checkout so git can confirm the ancestry - # relationship required by `--since`. Using `--filter=blob:none` - # avoids downloading file content — commit and tree metadata is - # all that `git diff --name-only` needs. git fetch --unshallow --filter=blob:none --no-tags origin HEAD echo "merge-base=$MERGE_BASE" >> "$GITHUB_OUTPUT" @@ -146,6 +127,59 @@ jobs: BASE_REF: ${{ github.event.pull_request.base.ref || github.event.merge_group.base_ref }} HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} GH_TOKEN: ${{ github.token }} + - name: Get changed package names + id: packages + run: | + if [[ -n "$MERGE_BASE" ]]; then + PACKAGES=$(yarn tsx scripts/get-changed-workspaces.mts "$MERGE_BASE" "$HEAD_SHA") + # Fall back to all packages if no packages changed (e.g. CI-only PRs), + # to ensure required status checks are not skipped. + if [[ "$PACKAGES" == "[]" ]]; then + PACKAGES=$(yarn workspaces list --no-private --json | jq --slurp --raw-output 'map(.name) | @json') + fi + else + PACKAGES=$(yarn workspaces list --no-private --json | jq --slurp --raw-output 'map(.name) | @json') + fi + echo "package-names=$PACKAGES" >> "$GITHUB_OUTPUT" + env: + MERGE_BASE: ${{ steps.fetch-merge-base.outputs.merge-base }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} + + validate-changelog-diffs: + name: Validate changelog diffs + if: github.event_name == 'pull_request' || github.event_name == 'merge_group' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Validate changelog diffs + uses: ./.github/actions/check-merge-queue-changelogs + + build: + name: Build + runs-on: ubuntu-latest + needs: + - prepare + - get-changed-packages + strategy: + matrix: + node-version: [24.x] + steps: + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 + with: + is-high-risk-environment: false + persist-credentials: false + node-version: ${{ matrix.node-version }} + - name: Unshallow checkout + if: needs.get-changed-packages.outputs.merge-base != '' + run: | + # Unshallow so git can walk history back to the merge base for + # `git diff --name-only`. Using `--filter=blob:none` avoids + # downloading file content — only commit and tree objects are needed. + git fetch --unshallow --filter=blob:none --no-tags origin HEAD - name: Build run: | if [[ -n "$MERGE_BASE" ]]; then @@ -159,7 +193,7 @@ jobs: yarn build fi env: - MERGE_BASE: ${{ steps.fetch-merge-base.outputs.merge-base }} + MERGE_BASE: ${{ needs.get-changed-packages.outputs.merge-base }} HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} - name: Require clean working directory shell: bash @@ -199,10 +233,12 @@ jobs: test-18: name: Test (18.x) runs-on: ubuntu-latest - needs: prepare + needs: + - prepare + - get-changed-packages strategy: matrix: - package-name: ${{ fromJson(needs.prepare.outputs.child-workspace-package-names) }} + package-name: ${{ fromJson(needs.get-changed-packages.outputs.package-names) }} steps: - name: Checkout and setup environment uses: MetaMask/action-checkout-and-setup@v3 @@ -224,10 +260,12 @@ jobs: test-20: name: Test (20.x) runs-on: ubuntu-latest - needs: prepare + needs: + - prepare + - get-changed-packages strategy: matrix: - package-name: ${{ fromJson(needs.prepare.outputs.child-workspace-package-names) }} + package-name: ${{ fromJson(needs.get-changed-packages.outputs.package-names) }} steps: - name: Checkout and setup environment uses: MetaMask/action-checkout-and-setup@v3 @@ -249,10 +287,12 @@ jobs: test-22: name: Test (22.x) runs-on: ubuntu-latest - needs: prepare + needs: + - prepare + - get-changed-packages strategy: matrix: - package-name: ${{ fromJson(needs.prepare.outputs.child-workspace-package-names) }} + package-name: ${{ fromJson(needs.get-changed-packages.outputs.package-names) }} steps: - name: Checkout and setup environment uses: MetaMask/action-checkout-and-setup@v3 diff --git a/scripts/generate-partial-build-tsconfig.mts b/scripts/generate-partial-build-tsconfig.mts index 5cbbf49d496..978afed55d6 100644 --- a/scripts/generate-partial-build-tsconfig.mts +++ b/scripts/generate-partial-build-tsconfig.mts @@ -1,163 +1,41 @@ -import { fileExists } from '@metamask/utils/node'; -import execa from 'execa'; -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; - -const ROOT_WORKSPACE = new URL('..', import.meta.url).pathname; - -type Workspace = { - location: string; - name: string; -}; - -/** - * Get all TypeScript workspaces in the monorepo. This checks for packages - * containing a "tsconfig.build.json" file. - * - * @returns The workspaces in the monorepo. - */ -async function getWorkspaces(): Promise { - const { stdout } = await execa('yarn', ['workspaces', 'list', '--json'], { - cwd: ROOT_WORKSPACE, - encoding: 'utf8', - }); - - const entries: Workspace[] = stdout - .trim() - .split('\n') - .map((line) => JSON.parse(line)) - .filter(({ location }: Workspace) => location !== '.'); - - return ( - await Promise.all( - entries.map(async (workspace) => { - const hasTsConfig = await fileExists( - join(ROOT_WORKSPACE, workspace.location, 'tsconfig.build.json'), - ); - - return hasTsConfig ? workspace : null; - }), - ) - ).filter((workspace): workspace is Workspace => workspace !== null); -} - -type DependencyGraph = { - dependants: Record>; - dependencies: Record>; -}; - -/** - * Get dependency and dependant maps for all workspaces. - * - * @param workspaces - The workspaces in the monorepo. - * @returns Maps of package name -> dependants and package name -> dependencies. - */ -async function getWorkspaceDependencies( - workspaces: Workspace[], -): Promise { - const dependants: Record> = Object.fromEntries( - workspaces.map(({ name }) => [name, new Set()]), - ); - const dependencies: Record> = Object.fromEntries( - workspaces.map(({ name }) => [name, new Set()]), - ); - - for (const { name, location } of workspaces) { - const pkg = JSON.parse( - await readFile(join(ROOT_WORKSPACE, location, 'package.json'), { - encoding: 'utf-8', - }), - ); - - for (const dependency of Object.keys({ - ...pkg.dependencies, - ...pkg.devDependencies, - })) { - if (dependants[dependency] !== undefined) { - dependants[dependency].add(name); - dependencies[name].add(dependency); - } - } - } - - return { dependants, dependencies }; -} - -/** - * Get the list of files changed between the merge base and the PR head. - * - * @param mergeBase - The merge base SHA. - * @param headRef - The PR branch tip SHA (or "HEAD" as fallback). - * @returns A list of changed file paths. - */ -async function getChangedFiles( - mergeBase: string, - headRef: string, -): Promise { - const { stdout } = await execa( - 'git', - ['diff', '--name-only', `${mergeBase}...${headRef}`], - { - cwd: ROOT_WORKSPACE, - encoding: 'utf8', - }, - ); - - return stdout.trim().split('\n').filter(Boolean); -} +import { + getTypeScriptWorkspaces, + computeChangedWorkspaces, +} from './lib/workspaces.mjs'; /** * Generate a filtered tsconfig.build.json for partial CI builds. * * Given a merge base SHA, outputs a tsconfig that references only the - * packages that changed since that commit plus their transitive dependants. - * Pipe the output to a temp file and pass it to `ts-bridge --project`. + * TypeScript packages that changed since that commit plus their transitive + * dependants and dependencies. Pipe the output to a temp file and pass it + * to `ts-bridge --project`. + * + * Dependencies are always included because TypeScript project references + * require every referenced project's dist output to already exist on disk. * - * Usage: `tsx scripts/generate-partial-build-tsconfig.ts [head-sha]`. + * Usage: `tsx scripts/generate-partial-build-tsconfig.mts []` */ async function main() { const mergeBase = process.argv[2]; if (!mergeBase) { console.error( - 'Usage: generate-partial-build-tsconfig.ts [head-sha]', + 'Usage: generate-partial-build-tsconfig.mts []', ); - process.exitCode = 1; return; } const headRef = process.argv[3] ?? 'HEAD'; - const workspaces = await getWorkspaces(); - const changedFiles = await getChangedFiles(mergeBase, headRef); - const { dependants, dependencies } = - await getWorkspaceDependencies(workspaces); - - const packagesToBuild = new Set( - changedFiles.flatMap((file) => { - const workspace = workspaces.find(({ location }) => - file.startsWith(`${location}/`), - ); - - return workspace ? [workspace.name] : []; - }), + const workspaces = await getTypeScriptWorkspaces(); + const packagesToBuild = await computeChangedWorkspaces( + workspaces, + mergeBase, + headRef, + true, ); - // Expand to transitive dependants (packages that depend on what changed). - for (const packageToBuild of packagesToBuild) { - for (const dependant of dependants[packageToBuild] ?? []) { - packagesToBuild.add(dependant); - } - } - - // Expand to transitive dependencies (dist files must exist to build - // dependants). - for (const packageToBuild of packagesToBuild) { - for (const dependency of dependencies[packageToBuild] ?? []) { - packagesToBuild.add(dependency); - } - } - const references = workspaces .filter(({ name }) => packagesToBuild.has(name)) .map(({ location }) => ({ path: `./${location}/tsconfig.build.json` })); diff --git a/scripts/get-changed-workspaces.mts b/scripts/get-changed-workspaces.mts new file mode 100644 index 00000000000..48962750da5 --- /dev/null +++ b/scripts/get-changed-workspaces.mts @@ -0,0 +1,43 @@ +import { + getAllWorkspaces, + computeChangedWorkspaces, +} from './lib/workspaces.mjs'; + +/** + * List workspace package names that need to be checked given a merge base. + * + * Outputs a JSON array of package names to stdout. Always includes packages + * that changed since the merge base plus their transitive dependants. Pass + * `--include-dependencies` to also include transitive dependencies (needed + * for TypeScript project reference builds where dist outputs must exist). + * + * Usage: `tsx scripts/get-changed-workspaces.mts [] [--include-dependencies]` + */ +const args = process.argv.slice(2); +const includeDependencies = args.includes('--include-dependencies'); +const positional = args.filter((arg) => !arg.startsWith('--')); + +const mergeBase = positional[0]; +if (!mergeBase) { + console.error( + 'Usage: get-changed-workspaces.mts [] [--include-dependencies]', + ); + process.exitCode = 1; + process.exit(); +} + +const headRef = positional[1] ?? 'HEAD'; + +const workspaces = await getAllWorkspaces(); +const changed = await computeChangedWorkspaces( + workspaces, + mergeBase, + headRef, + includeDependencies, +); + +const names = workspaces + .filter(({ name }) => changed.has(name)) + .map(({ name }) => name); + +console.log(JSON.stringify(names)); diff --git a/scripts/lib/workspaces.mts b/scripts/lib/workspaces.mts new file mode 100644 index 00000000000..a6366f07215 --- /dev/null +++ b/scripts/lib/workspaces.mts @@ -0,0 +1,167 @@ +import { fileExists } from '@metamask/utils/node'; +import execa from 'execa'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +export const ROOT_WORKSPACE = new URL('../..', import.meta.url).pathname; + +export type Workspace = { + location: string; + name: string; +}; + +export type DependencyGraph = { + dependants: Record>; + dependencies: Record>; +}; + +/** + * Get all non-root workspaces in the monorepo. + * + * @returns All workspaces. + */ +export async function getAllWorkspaces(): Promise { + const { stdout } = await execa('yarn', ['workspaces', 'list', '--json'], { + cwd: ROOT_WORKSPACE, + encoding: 'utf8', + }); + + return stdout + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + .filter(({ location }: Workspace) => location !== '.'); +} + +/** + * Get all TypeScript workspaces in the monorepo. This filters to packages + * containing a "tsconfig.build.json" file. + * + * @returns All TypeScript workspaces. + */ +export async function getTypeScriptWorkspaces(): Promise { + const workspaces = await getAllWorkspaces(); + + return ( + await Promise.all( + workspaces.map(async (workspace) => { + const hasTsConfig = await fileExists( + join(ROOT_WORKSPACE, workspace.location, 'tsconfig.build.json'), + ); + return hasTsConfig ? workspace : null; + }), + ) + ).filter((workspace): workspace is Workspace => workspace !== null); +} + +/** + * Get dependency and dependant maps for all workspaces. + * + * @param workspaces - The workspaces to build the graph for. + * @returns Maps of package name to dependants and package name to dependencies. + */ +export async function getWorkspaceDependencies( + workspaces: Workspace[], +): Promise { + const dependants: Record> = Object.fromEntries( + workspaces.map(({ name }) => [name, new Set()]), + ); + const dependencies: Record> = Object.fromEntries( + workspaces.map(({ name }) => [name, new Set()]), + ); + + for (const { name, location } of workspaces) { + const pkg = JSON.parse( + await readFile(join(ROOT_WORKSPACE, location, 'package.json'), { + encoding: 'utf-8', + }), + ); + + for (const dependency of Object.keys({ + ...pkg.dependencies, + ...pkg.devDependencies, + })) { + if (dependants[dependency] !== undefined) { + dependants[dependency].add(name); + dependencies[name].add(dependency); + } + } + } + + return { dependants, dependencies }; +} + +/** + * Get the list of files changed between the merge base and the PR head. + * + * @param mergeBase - The merge base SHA. + * @param headRef - The PR branch tip SHA (or "HEAD" as fallback). + * @returns A list of changed file paths. + */ +export async function getChangedFiles( + mergeBase: string, + headRef: string, +): Promise { + const { stdout } = await execa( + 'git', + ['diff', '--name-only', `${mergeBase}...${headRef}`], + { + cwd: ROOT_WORKSPACE, + encoding: 'utf8', + }, + ); + + return stdout.trim().split('\n').filter(Boolean); +} + +/** + * Compute the set of workspace names that need to be checked given a merge + * base, by finding changed packages and expanding to transitive dependants. + * + * When `includeDependencies` is true, also expands to transitive dependencies. + * This is needed for TypeScript project reference builds, where every + * referenced project's dist output must already exist on disk. + * + * @param workspaces - The workspace set to compute against. + * @param mergeBase - The merge base SHA. + * @param headRef - The PR branch tip SHA (or "HEAD" as fallback). + * @param includeDependencies - Whether to also expand to transitive dependencies. + * @returns The set of workspace names to check. + */ +export async function computeChangedWorkspaces( + workspaces: Workspace[], + mergeBase: string, + headRef: string, + includeDependencies: boolean, +): Promise> { + const changedFiles = await getChangedFiles(mergeBase, headRef); + const { dependants, dependencies } = + await getWorkspaceDependencies(workspaces); + + const result = new Set( + changedFiles.flatMap((file) => { + const workspace = workspaces.find(({ location }) => + file.startsWith(`${location}/`), + ); + return workspace ? [workspace.name] : []; + }), + ); + + // Expand to transitive dependants (packages that depend on what changed). + for (const pkg of result) { + for (const dependant of dependants[pkg] ?? []) { + result.add(dependant); + } + } + + if (includeDependencies) { + // Expand to transitive dependencies (dist files must exist to build dependants). + for (const pkg of result) { + for (const dependency of dependencies[pkg] ?? []) { + result.add(dependency); + } + } + } + + return result; +} diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json index b4adc2c51e5..691fa67fa7e 100644 --- a/tsconfig.scripts.json +++ b/tsconfig.scripts.json @@ -18,6 +18,6 @@ "noErrorTruncation": true, "noUncheckedIndexedAccess": true }, - "include": ["./scripts/**/*.ts"], + "include": ["./scripts/**/*.ts", "./scripts/**/*.mts"], "exclude": ["**/node_modules"] } From 72b9e97e03c2218875644d16b37e10e53846e63d Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 3 Jul 2026 12:04:54 +0200 Subject: [PATCH 11/31] Remove fallback to all packages when no packages changed 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/workflows/lint-build-test.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index d4aa17b9f73..276a1cb79fc 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -132,11 +132,6 @@ jobs: run: | if [[ -n "$MERGE_BASE" ]]; then PACKAGES=$(yarn tsx scripts/get-changed-workspaces.mts "$MERGE_BASE" "$HEAD_SHA") - # Fall back to all packages if no packages changed (e.g. CI-only PRs), - # to ensure required status checks are not skipped. - if [[ "$PACKAGES" == "[]" ]]; then - PACKAGES=$(yarn workspaces list --no-private --json | jq --slurp --raw-output 'map(.name) | @json') - fi else PACKAGES=$(yarn workspaces list --no-private --json | jq --slurp --raw-output 'map(.name) | @json') fi From 22d0c65b0543f467a2bc1ac23c0a87d3db3878b3 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 3 Jul 2026 12:19:45 +0200 Subject: [PATCH 12/31] Skip matrix jobs when no packages changed 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 `[]`. --- .github/workflows/lint-build-test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 276a1cb79fc..86f160d11e1 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -66,6 +66,7 @@ jobs: validate-changelog: name: Validate changelog runs-on: ubuntu-latest + if: needs.get-changed-packages.outputs.package-names != '[]' needs: - prepare - get-changed-packages @@ -228,6 +229,7 @@ jobs: test-18: name: Test (18.x) runs-on: ubuntu-latest + if: needs.get-changed-packages.outputs.package-names != '[]' needs: - prepare - get-changed-packages @@ -255,6 +257,7 @@ jobs: test-20: name: Test (20.x) runs-on: ubuntu-latest + if: needs.get-changed-packages.outputs.package-names != '[]' needs: - prepare - get-changed-packages @@ -282,6 +285,7 @@ jobs: test-22: name: Test (22.x) runs-on: ubuntu-latest + if: needs.get-changed-packages.outputs.package-names != '[]' needs: - prepare - get-changed-packages From 49af95a4bb5eaedaef77222d3ecb23d074e9e9a2 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 3 Jul 2026 12:23:51 +0200 Subject: [PATCH 13/31] Gate test-wallet-cli-e2e on wallet-cli being changed 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. --- .github/workflows/lint-build-test.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 86f160d11e1..211201d8864 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -333,7 +333,10 @@ jobs: test-wallet-cli-e2e: name: Test wallet-cli daemon e2e (${{ matrix.node-version }}) runs-on: ubuntu-latest - needs: prepare + if: needs.get-changed-packages.outputs.package-names == '' || contains(fromJson(needs.get-changed-packages.outputs.package-names), '@metamask/wallet-cli') + needs: + - prepare + - get-changed-packages strategy: matrix: node-version: [20.x, 22.x, 24.x] From c1f80bc1e10de7dd84ea95484775881bbe2a4389 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 3 Jul 2026 14:13:32 +0200 Subject: [PATCH 14/31] Fall back to all packages when root files change 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. --- scripts/lib/workspaces.mts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/scripts/lib/workspaces.mts b/scripts/lib/workspaces.mts index a6366f07215..af50945e921 100644 --- a/scripts/lib/workspaces.mts +++ b/scripts/lib/workspaces.mts @@ -5,6 +5,17 @@ import { join } from 'node:path'; export const ROOT_WORKSPACE = new URL('../..', import.meta.url).pathname; +// Files that can change without requiring a full rebuild/test run. +const IGNORED_ROOT_FILES = new Set([ + '.gitignore', + 'AGENTS.md', + 'CLAUDE.md', + 'README.md', + 'eslint-suppressions.json', + 'teams.json', + 'yarn.lock', +]); + export type Workspace = { location: string; name: string; @@ -138,6 +149,18 @@ export async function computeChangedWorkspaces( const { dependants, dependencies } = await getWorkspaceDependencies(workspaces); + // If any changed file lives outside all package directories (e.g. root + // configs, workflow files, scripts), rebuild and test everything. + const hasRootChange = changedFiles.some( + (file) => + !IGNORED_ROOT_FILES.has(file) && + !workspaces.some(({ location }) => file.startsWith(`${location}/`)), + ); + + if (hasRootChange) { + return new Set(workspaces.map(({ name }) => name)); + } + const result = new Set( changedFiles.flatMap((file) => { const workspace = workspaces.find(({ location }) => From 83faade24ae29aaddfbf7e35ba9dde7ef2a1d4f5 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 3 Jul 2026 14:18:21 +0200 Subject: [PATCH 15/31] Exclude private packages from changed workspace list 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. --- scripts/lib/workspaces.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/lib/workspaces.mts b/scripts/lib/workspaces.mts index af50945e921..1dcd744a179 100644 --- a/scripts/lib/workspaces.mts +++ b/scripts/lib/workspaces.mts @@ -32,7 +32,7 @@ export type DependencyGraph = { * @returns All workspaces. */ export async function getAllWorkspaces(): Promise { - const { stdout } = await execa('yarn', ['workspaces', 'list', '--json'], { + const { stdout } = await execa('yarn', ['workspaces', 'list', '--no-private', '--json'], { cwd: ROOT_WORKSPACE, encoding: 'utf8', }); From ace9b2208a459bffff728b2bd482bcc721d46395 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 3 Jul 2026 14:29:09 +0200 Subject: [PATCH 16/31] Fix lint error in getAllWorkspaces --- scripts/lib/workspaces.mts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/lib/workspaces.mts b/scripts/lib/workspaces.mts index 1dcd744a179..c73f7b1e4aa 100644 --- a/scripts/lib/workspaces.mts +++ b/scripts/lib/workspaces.mts @@ -32,10 +32,14 @@ export type DependencyGraph = { * @returns All workspaces. */ export async function getAllWorkspaces(): Promise { - const { stdout } = await execa('yarn', ['workspaces', 'list', '--no-private', '--json'], { - cwd: ROOT_WORKSPACE, - encoding: 'utf8', - }); + const { stdout } = await execa( + 'yarn', + ['workspaces', 'list', '--no-private', '--json'], + { + cwd: ROOT_WORKSPACE, + encoding: 'utf8', + }, + ); return stdout .trim() From d3467449737d5ca33cb0711d63eb5c89e8602063 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 14 Jul 2026 10:30:16 +0200 Subject: [PATCH 17/31] Scope ESLint to changed packages and their dependants 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. --- .github/workflows/lint-build-test.yml | 133 +++++++++++++++++--------- scripts/get-changed-workspaces.mts | 68 ++++++++----- scripts/lib/workspaces.mts | 34 +++++-- 3 files changed, 157 insertions(+), 78 deletions(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 211201d8864..09babb59796 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -27,6 +27,62 @@ jobs: echo "child-workspace-package-names=$(yarn workspaces list --no-private --json | jq --slurp --raw-output 'map(.name) | @json')" >> "$GITHUB_OUTPUT" shell: bash + + get-changed-packages: + name: Get changed packages + runs-on: ubuntu-latest + needs: prepare + outputs: + merge-base: ${{ steps.fetch-merge-base.outputs.merge-base }} + package-names: ${{ steps.packages.outputs.package-names }} + eslint-paths: ${{ steps.packages.outputs.eslint-paths }} + steps: + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 + with: + is-high-risk-environment: false + persist-credentials: false + node-version: 24.x + - name: Fetch merge base + id: fetch-merge-base + if: github.base_ref != '' || github.event.merge_group.base_ref != '' + run: | + set -euo pipefail + + PREFIXED_REF_REGEX='refs/heads/(.+)' + if [[ "$BASE_REF" =~ $PREFIXED_REF_REGEX ]]; then + BASE_REF="${BASH_REMATCH[1]}" + fi + + MERGE_BASE=$(gh api "repos/$GITHUB_REPOSITORY/compare/$BASE_REF...$HEAD_SHA" --jq '.merge_base_commit.sha') + git fetch --unshallow --filter=blob:none --no-tags origin HEAD + + echo "merge-base=$MERGE_BASE" >> "$GITHUB_OUTPUT" + env: + BASE_REF: ${{ github.event.pull_request.base.ref || github.event.merge_group.base_ref }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} + GH_TOKEN: ${{ github.token }} + - name: Get changed package names + id: packages + run: | + if [[ -n "$MERGE_BASE" ]]; then + OUTPUT=$(yarn tsx scripts/get-changed-workspaces.mts "$MERGE_BASE" "$HEAD_SHA") + PACKAGES=$(echo "$OUTPUT" | jq '.names') + if [[ $(echo "$OUTPUT" | jq '.hasRootChange') == "true" ]]; then + ESLINT_PATHS="full" + else + ESLINT_PATHS=$(echo "$OUTPUT" | jq '.locations') + fi + else + PACKAGES=$(yarn workspaces list --no-private --json | jq --slurp --raw-output 'map(.name) | @json') + ESLINT_PATHS="full" + fi + echo "package-names=$PACKAGES" >> "$GITHUB_OUTPUT" + echo "eslint-paths=$ESLINT_PATHS" >> "$GITHUB_OUTPUT" + env: + MERGE_BASE: ${{ steps.fetch-merge-base.outputs.merge-base }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} + lint: name: Lint (${{ matrix.script }}) runs-on: ubuntu-latest @@ -38,7 +94,6 @@ jobs: - codeowners:check - constraints - lint:dependencies - - lint:eslint - lint:misc:check - lint:teams - lint:tsconfigs:all @@ -63,17 +118,16 @@ jobs: exit 1 fi - validate-changelog: - name: Validate changelog + lint-eslint: + name: Lint (lint:eslint) runs-on: ubuntu-latest - if: needs.get-changed-packages.outputs.package-names != '[]' + if: needs.get-changed-packages.outputs.eslint-paths != '[]' needs: - prepare - get-changed-packages strategy: matrix: node-version: [24.x] - package-name: ${{ fromJson(needs.get-changed-packages.outputs.package-names) }} steps: - name: Checkout and setup environment uses: MetaMask/action-checkout-and-setup@v3 @@ -81,12 +135,15 @@ jobs: is-high-risk-environment: false persist-credentials: false node-version: ${{ matrix.node-version }} - # Full history so `changelog:validate` can find the merge base with the - # base branch to detect which packages are being released. - fetch-depth: 0 - - run: yarn workspace "$PACKAGE_NAME" changelog:validate + - name: Lint + run: | + if [[ "$ESLINT_PATHS" == "full" ]]; then + yarn lint:eslint + else + yarn lint:eslint $(echo "$ESLINT_PATHS" | jq -r '.[]' | tr '\n' ' ') + fi env: - PACKAGE_NAME: ${{ matrix.package-name }} + ESLINT_PATHS: ${{ needs.get-changed-packages.outputs.eslint-paths }} - name: Require clean working directory shell: bash run: | @@ -95,51 +152,37 @@ jobs: exit 1 fi - get-changed-packages: - name: Get changed packages + validate-changelog: + name: Validate changelog runs-on: ubuntu-latest - needs: prepare - outputs: - merge-base: ${{ steps.fetch-merge-base.outputs.merge-base }} - package-names: ${{ steps.packages.outputs.package-names }} + if: needs.get-changed-packages.outputs.package-names != '[]' + needs: + - prepare + - get-changed-packages + strategy: + matrix: + node-version: [24.x] + package-name: ${{ fromJson(needs.get-changed-packages.outputs.package-names) }} steps: - name: Checkout and setup environment uses: MetaMask/action-checkout-and-setup@v3 with: is-high-risk-environment: false persist-credentials: false - node-version: 24.x - - name: Fetch merge base - id: fetch-merge-base - if: github.base_ref != '' || github.event.merge_group.base_ref != '' - run: | - set -euo pipefail - - PREFIXED_REF_REGEX='refs/heads/(.+)' - if [[ "$BASE_REF" =~ $PREFIXED_REF_REGEX ]]; then - BASE_REF="${BASH_REMATCH[1]}" - fi - - MERGE_BASE=$(gh api "repos/$GITHUB_REPOSITORY/compare/$BASE_REF...$HEAD_SHA" --jq '.merge_base_commit.sha') - git fetch --unshallow --filter=blob:none --no-tags origin HEAD - - echo "merge-base=$MERGE_BASE" >> "$GITHUB_OUTPUT" + node-version: ${{ matrix.node-version }} + # Full history so `changelog:validate` can find the merge base with the + # base branch to detect which packages are being released. + fetch-depth: 0 + - run: yarn workspace "$PACKAGE_NAME" changelog:validate env: - BASE_REF: ${{ github.event.pull_request.base.ref || github.event.merge_group.base_ref }} - HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} - GH_TOKEN: ${{ github.token }} - - name: Get changed package names - id: packages + PACKAGE_NAME: ${{ matrix.package-name }} + - name: Require clean working directory + shell: bash run: | - if [[ -n "$MERGE_BASE" ]]; then - PACKAGES=$(yarn tsx scripts/get-changed-workspaces.mts "$MERGE_BASE" "$HEAD_SHA") - else - PACKAGES=$(yarn workspaces list --no-private --json | jq --slurp --raw-output 'map(.name) | @json') + if ! git diff --exit-code; then + echo "Working tree dirty at end of job" + exit 1 fi - echo "package-names=$PACKAGES" >> "$GITHUB_OUTPUT" - env: - MERGE_BASE: ${{ steps.fetch-merge-base.outputs.merge-base }} - HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} validate-changelog-diffs: name: Validate changelog diffs diff --git a/scripts/get-changed-workspaces.mts b/scripts/get-changed-workspaces.mts index 48962750da5..c198cacb842 100644 --- a/scripts/get-changed-workspaces.mts +++ b/scripts/get-changed-workspaces.mts @@ -1,43 +1,63 @@ +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; import { getAllWorkspaces, + checkRootChange, computeChangedWorkspaces, + getChangedFiles, } from './lib/workspaces.mjs'; /** - * List workspace package names that need to be checked given a merge base. + * List workspaces that need to be checked given a merge base. * - * Outputs a JSON array of package names to stdout. Always includes packages - * that changed since the merge base plus their transitive dependants. Pass - * `--include-dependencies` to also include transitive dependencies (needed - * for TypeScript project reference builds where dist outputs must exist). + * Outputs a JSON object to stdout: + * - `names`: package names of changed workspaces and their transitive dependants + * - `locations`: workspace-relative paths (e.g. `packages/foo`) for the same set + * - `hasRootChange`: true if any non-ignored root file changed (triggers a full run) * - * Usage: `tsx scripts/get-changed-workspaces.mts [] [--include-dependencies]` + * Usage: `tsx scripts/get-changed-workspaces.mts [] [options]` */ -const args = process.argv.slice(2); -const includeDependencies = args.includes('--include-dependencies'); -const positional = args.filter((arg) => !arg.startsWith('--')); +const argv = await yargs(hideBin(process.argv)) + .usage('$0 [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)', + }) + .option('include-dependencies', { + type: 'boolean', + default: false, + describe: + 'Also expand to transitive dependencies (needed for TypeScript builds)', + }) + .demandCommand(1, 'merge-base is required') + .help() + .parseAsync(); -const mergeBase = positional[0]; -if (!mergeBase) { - console.error( - 'Usage: get-changed-workspaces.mts [] [--include-dependencies]', - ); - process.exitCode = 1; - process.exit(); -} +const mergeBase = argv._[0] as string; +const headRef = (argv._[1] as string | undefined) ?? 'HEAD'; +const workspaces = await getAllWorkspaces(); -const headRef = positional[1] ?? 'HEAD'; +const changedFiles = await getChangedFiles(mergeBase, headRef); +const hasRootChange = checkRootChange(workspaces, changedFiles); -const workspaces = await getAllWorkspaces(); const changed = await computeChangedWorkspaces( workspaces, mergeBase, headRef, - includeDependencies, + argv['include-dependencies'], + changedFiles, ); -const names = workspaces - .filter(({ name }) => changed.has(name)) - .map(({ name }) => name); +const changedWorkspaces = workspaces.filter(({ name }) => changed.has(name)); -console.log(JSON.stringify(names)); +console.log( + JSON.stringify({ + names: changedWorkspaces.map(({ name }) => name), + locations: changedWorkspaces.map(({ location }) => location), + hasRootChange, + }), +); diff --git a/scripts/lib/workspaces.mts b/scripts/lib/workspaces.mts index c73f7b1e4aa..8baffec83ca 100644 --- a/scripts/lib/workspaces.mts +++ b/scripts/lib/workspaces.mts @@ -129,6 +129,26 @@ export async function getChangedFiles( return stdout.trim().split('\n').filter(Boolean); } +/** + * Check whether any changed file lives outside all package directories and + * is not in the ignored root files list. When true, a full + * rebuild/test/lint run is required. + * + * @param workspaces - The workspace set to check against. + * @param changedFiles - The list of changed file paths. + * @returns Whether any non-ignored root file changed. + */ +export function checkRootChange( + workspaces: Workspace[], + changedFiles: string[], +): boolean { + return changedFiles.some( + (file) => + !IGNORED_ROOT_FILES.has(file) && + !workspaces.some(({ location }) => file.startsWith(`${location}/`)), + ); +} + /** * Compute the set of workspace names that need to be checked given a merge * base, by finding changed packages and expanding to transitive dependants. @@ -141,6 +161,7 @@ export async function getChangedFiles( * @param mergeBase - The merge base SHA. * @param headRef - The PR branch tip SHA (or "HEAD" as fallback). * @param includeDependencies - Whether to also expand to transitive dependencies. + * @param changedFiles - Pre-fetched list of changed files; fetched if omitted. * @returns The set of workspace names to check. */ export async function computeChangedWorkspaces( @@ -148,25 +169,20 @@ export async function computeChangedWorkspaces( mergeBase: string, headRef: string, includeDependencies: boolean, + changedFiles?: string[], ): Promise> { - const changedFiles = await getChangedFiles(mergeBase, headRef); + const files = changedFiles ?? (await getChangedFiles(mergeBase, headRef)); const { dependants, dependencies } = await getWorkspaceDependencies(workspaces); // If any changed file lives outside all package directories (e.g. root // configs, workflow files, scripts), rebuild and test everything. - const hasRootChange = changedFiles.some( - (file) => - !IGNORED_ROOT_FILES.has(file) && - !workspaces.some(({ location }) => file.startsWith(`${location}/`)), - ); - - if (hasRootChange) { + if (checkRootChange(workspaces, files)) { return new Set(workspaces.map(({ name }) => name)); } const result = new Set( - changedFiles.flatMap((file) => { + files.flatMap((file) => { const workspace = workspaces.find(({ location }) => file.startsWith(`${location}/`), ); From e23f824a20478977bc529800e122072caefa25cd Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 14 Jul 2026 10:44:27 +0200 Subject: [PATCH 18/31] Use xargs to pass ESLint paths in lint-eslint job 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. --- .github/workflows/lint-build-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 09babb59796..0c810cb811c 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -140,7 +140,7 @@ jobs: if [[ "$ESLINT_PATHS" == "full" ]]; then yarn lint:eslint else - yarn lint:eslint $(echo "$ESLINT_PATHS" | jq -r '.[]' | tr '\n' ' ') + echo "$ESLINT_PATHS" | jq -r '.[]' | xargs yarn lint:eslint fi env: ESLINT_PATHS: ${{ needs.get-changed-packages.outputs.eslint-paths }} From 857afa986ce1b2ff2987166537fc09f3d9b90d7f Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 14 Jul 2026 10:53:02 +0200 Subject: [PATCH 19/31] Use compact jq output when writing to GITHUB_OUTPUT `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. --- .github/workflows/lint-build-test.yml | 5 ++--- scripts/get-changed-workspaces.mts | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 0c810cb811c..4603ae7fe35 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -27,7 +27,6 @@ jobs: echo "child-workspace-package-names=$(yarn workspaces list --no-private --json | jq --slurp --raw-output 'map(.name) | @json')" >> "$GITHUB_OUTPUT" shell: bash - get-changed-packages: name: Get changed packages runs-on: ubuntu-latest @@ -67,11 +66,11 @@ jobs: run: | if [[ -n "$MERGE_BASE" ]]; then OUTPUT=$(yarn tsx scripts/get-changed-workspaces.mts "$MERGE_BASE" "$HEAD_SHA") - PACKAGES=$(echo "$OUTPUT" | jq '.names') + PACKAGES=$(echo "$OUTPUT" | jq -c '.names') if [[ $(echo "$OUTPUT" | jq '.hasRootChange') == "true" ]]; then ESLINT_PATHS="full" else - ESLINT_PATHS=$(echo "$OUTPUT" | jq '.locations') + ESLINT_PATHS=$(echo "$OUTPUT" | jq -c '.locations') fi else PACKAGES=$(yarn workspaces list --no-private --json | jq --slurp --raw-output 'map(.name) | @json') diff --git a/scripts/get-changed-workspaces.mts b/scripts/get-changed-workspaces.mts index c198cacb842..03d1528b35b 100644 --- a/scripts/get-changed-workspaces.mts +++ b/scripts/get-changed-workspaces.mts @@ -1,5 +1,6 @@ import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; + import { getAllWorkspaces, checkRootChange, From 96910e808357e7347ab5be77408baf843f8afab4 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 14 Jul 2026 13:24:13 +0200 Subject: [PATCH 20/31] Fix several issues found in code review - 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` --- .github/workflows/lint-build-test.yml | 2 +- scripts/generate-partial-build-tsconfig.mts | 8 +++++--- scripts/lib/workspaces.mts | 14 ++++++++------ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 4603ae7fe35..1e19c3ca736 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -375,7 +375,7 @@ jobs: test-wallet-cli-e2e: name: Test wallet-cli daemon e2e (${{ matrix.node-version }}) runs-on: ubuntu-latest - if: needs.get-changed-packages.outputs.package-names == '' || contains(fromJson(needs.get-changed-packages.outputs.package-names), '@metamask/wallet-cli') + if: needs.get-changed-packages.outputs.package-names == '[]' || contains(fromJson(needs.get-changed-packages.outputs.package-names), '@metamask/wallet-cli') needs: - prepare - get-changed-packages diff --git a/scripts/generate-partial-build-tsconfig.mts b/scripts/generate-partial-build-tsconfig.mts index 978afed55d6..1df7600fb8b 100644 --- a/scripts/generate-partial-build-tsconfig.mts +++ b/scripts/generate-partial-build-tsconfig.mts @@ -1,4 +1,5 @@ import { + getAllWorkspaces, getTypeScriptWorkspaces, computeChangedWorkspaces, } from './lib/workspaces.mjs'; @@ -28,15 +29,16 @@ async function main() { const headRef = process.argv[3] ?? 'HEAD'; - const workspaces = await getTypeScriptWorkspaces(); + const allWorkspaces = await getAllWorkspaces(); + const typeScriptWorkspaces = await getTypeScriptWorkspaces(); const packagesToBuild = await computeChangedWorkspaces( - workspaces, + allWorkspaces, mergeBase, headRef, true, ); - const references = workspaces + const references = typeScriptWorkspaces .filter(({ name }) => packagesToBuild.has(name)) .map(({ location }) => ({ path: `./${location}/tsconfig.build.json` })); diff --git a/scripts/lib/workspaces.mts b/scripts/lib/workspaces.mts index 8baffec83ca..fbfec573cf9 100644 --- a/scripts/lib/workspaces.mts +++ b/scripts/lib/workspaces.mts @@ -11,7 +11,6 @@ const IGNORED_ROOT_FILES = new Set([ 'AGENTS.md', 'CLAUDE.md', 'README.md', - 'eslint-suppressions.json', 'teams.json', 'yarn.lock', ]); @@ -85,13 +84,16 @@ export async function getWorkspaceDependencies( workspaces.map(({ name }) => [name, new Set()]), ); - for (const { name, location } of workspaces) { - const pkg = JSON.parse( - await readFile(join(ROOT_WORKSPACE, location, 'package.json'), { + const packages = await Promise.all( + workspaces.map(({ location }) => + readFile(join(ROOT_WORKSPACE, location, 'package.json'), { encoding: 'utf-8', - }), - ); + }).then(JSON.parse), + ), + ); + for (const [index, { name }] of workspaces.entries()) { + const pkg = packages[index]; for (const dependency of Object.keys({ ...pkg.dependencies, ...pkg.devDependencies, From b6ea09d058a8fb39c4974c89b26afc2c11532f2c Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 14 Jul 2026 16:03:51 +0200 Subject: [PATCH 21/31] Move analyse-code into lint-build-test workflow 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. --- .github/workflows/lint-build-test.yml | 55 +++++++++++++++++++++++++++ .github/workflows/main.yml | 40 +++---------------- 2 files changed, 61 insertions(+), 34 deletions(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 1e19c3ca736..987c8e6124a 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -2,6 +2,11 @@ name: Lint, Build, and Test on: workflow_call: + secrets: + SECURITY_SCAN_METRICS_TOKEN: + required: false + APPSEC_BOT_SLACK_WEBHOOK: + required: false jobs: prepare: @@ -35,6 +40,7 @@ jobs: merge-base: ${{ steps.fetch-merge-base.outputs.merge-base }} package-names: ${{ steps.packages.outputs.package-names }} eslint-paths: ${{ steps.packages.outputs.eslint-paths }} + scan-paths: ${{ steps.packages.outputs.scan-paths }} steps: - name: Checkout and setup environment uses: MetaMask/action-checkout-and-setup@v3 @@ -69,15 +75,29 @@ jobs: PACKAGES=$(echo "$OUTPUT" | jq -c '.names') if [[ $(echo "$OUTPUT" | jq '.hasRootChange') == "true" ]]; then ESLINT_PATHS="full" + SCAN_PATHS="" else ESLINT_PATHS=$(echo "$OUTPUT" | jq -c '.locations') + SCAN_PATHS=$(echo "$OUTPUT" | jq -r '.locations[] | . + "/"') fi else PACKAGES=$(yarn workspaces list --no-private --json | jq --slurp --raw-output 'map(.name) | @json') ESLINT_PATHS="full" + SCAN_PATHS="" fi + echo "package-names=$PACKAGES" >> "$GITHUB_OUTPUT" echo "eslint-paths=$ESLINT_PATHS" >> "$GITHUB_OUTPUT" + + if [[ -n "$SCAN_PATHS" ]]; then + { + echo "scan-paths<> "$GITHUB_OUTPUT" + else + echo "scan-paths=" >> "$GITHUB_OUTPUT" + fi env: MERGE_BASE: ${{ steps.fetch-merge-base.outputs.merge-base }} HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} @@ -151,6 +171,41 @@ jobs: exit 1 fi + analyse-code: + name: Analyse code + needs: get-changed-packages + uses: MetaMask/action-security-code-scanner/.github/workflows/security-scan.yml@da7cf8b27792d992d87cf37c2e0e7e27b4c4ad0a + with: + scanner-ref: v2 + paths: ${{ needs.get-changed-packages.outputs.scan-paths }} + paths-ignored: | + .storybook/ + **/__snapshots__/ + **/*.snap + **/*.stories.js + **/*.stories.tsx + **/*.test.browser.ts* + **/*.test.js* + **/*.test.ts* + **/fixtures/ + **/jest.config.js + **/jest.environment.js + **/mocks/ + **/test*/ + docs/ + e2e/ + merged-packages/ + node_modules/ + storybook/ + test*/ + secrets: + project-metrics-token: ${{ secrets.SECURITY_SCAN_METRICS_TOKEN }} + slack-webhook: ${{ secrets.APPSEC_BOT_SLACK_WEBHOOK }} + permissions: + actions: read + contents: read + security-events: write + validate-changelog: name: Validate changelog runs-on: ubuntu-latest diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0d60d95ba94..9e2a2fc165c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -51,44 +51,17 @@ jobs: env: ACTIONLINT: ${{ steps.download-actionlint.outputs.executable }} - analyse-code: - name: Analyse code + lint-build-test: + name: Lint, build, and test needs: check-workflows - uses: MetaMask/action-security-code-scanner/.github/workflows/security-scan.yml@v2 - with: - scanner-ref: v2 - paths-ignored: | - .storybook/ - **/__snapshots__/ - **/*.snap - **/*.stories.js - **/*.stories.tsx - **/*.test.browser.ts* - **/*.test.js* - **/*.test.ts* - **/fixtures/ - **/jest.config.js - **/jest.environment.js - **/mocks/ - **/test*/ - docs/ - e2e/ - merged-packages/ - node_modules/ - storybook/ - test*/ - secrets: - project-metrics-token: ${{ secrets.SECURITY_SCAN_METRICS_TOKEN }} - slack-webhook: ${{ secrets.APPSEC_BOT_SLACK_WEBHOOK }} + uses: ./.github/workflows/lint-build-test.yml permissions: actions: read contents: read security-events: write - - lint-build-test: - name: Lint, build, and test - needs: check-workflows - uses: ./.github/workflows/lint-build-test.yml + secrets: + SECURITY_SCAN_METRICS_TOKEN: ${{ secrets.SECURITY_SCAN_METRICS_TOKEN }} + APPSEC_BOT_SLACK_WEBHOOK: ${{ secrets.APPSEC_BOT_SLACK_WEBHOOK }} deploy-platform-api-docs: name: Deploy Platform API Docs @@ -156,7 +129,6 @@ jobs: name: All jobs complete runs-on: ubuntu-latest needs: - - analyse-code - check-release - lint-build-test outputs: From 7f0e057d5b57b86a02f1bbde324db8a1a25cdaab Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 14 Jul 2026 16:10:47 +0200 Subject: [PATCH 22/31] Point scanner-ref at temporary commit with paths support Aligns `scanner-ref` with the action SHA so the internal scanner also picks up the `paths` input implementation. --- .github/workflows/lint-build-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 987c8e6124a..31ee353e00c 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -176,7 +176,7 @@ jobs: needs: get-changed-packages uses: MetaMask/action-security-code-scanner/.github/workflows/security-scan.yml@da7cf8b27792d992d87cf37c2e0e7e27b4c4ad0a with: - scanner-ref: v2 + scanner-ref: da7cf8b27792d992d87cf37c2e0e7e27b4c4ad0a paths: ${{ needs.get-changed-packages.outputs.scan-paths }} paths-ignored: | .storybook/ From bcfbc6fb4c11178a4cde8132303c3da2a5ec2a0f Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 15 Jul 2026 10:09:27 +0200 Subject: [PATCH 23/31] Revert analyse-code move to lint-build-test workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/lint-build-test.yml | 55 --------------------------- .github/workflows/main.yml | 34 +++++++++++++++-- 2 files changed, 31 insertions(+), 58 deletions(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 31ee353e00c..1e19c3ca736 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -2,11 +2,6 @@ name: Lint, Build, and Test on: workflow_call: - secrets: - SECURITY_SCAN_METRICS_TOKEN: - required: false - APPSEC_BOT_SLACK_WEBHOOK: - required: false jobs: prepare: @@ -40,7 +35,6 @@ jobs: merge-base: ${{ steps.fetch-merge-base.outputs.merge-base }} package-names: ${{ steps.packages.outputs.package-names }} eslint-paths: ${{ steps.packages.outputs.eslint-paths }} - scan-paths: ${{ steps.packages.outputs.scan-paths }} steps: - name: Checkout and setup environment uses: MetaMask/action-checkout-and-setup@v3 @@ -75,29 +69,15 @@ jobs: PACKAGES=$(echo "$OUTPUT" | jq -c '.names') if [[ $(echo "$OUTPUT" | jq '.hasRootChange') == "true" ]]; then ESLINT_PATHS="full" - SCAN_PATHS="" else ESLINT_PATHS=$(echo "$OUTPUT" | jq -c '.locations') - SCAN_PATHS=$(echo "$OUTPUT" | jq -r '.locations[] | . + "/"') fi else PACKAGES=$(yarn workspaces list --no-private --json | jq --slurp --raw-output 'map(.name) | @json') ESLINT_PATHS="full" - SCAN_PATHS="" fi - echo "package-names=$PACKAGES" >> "$GITHUB_OUTPUT" echo "eslint-paths=$ESLINT_PATHS" >> "$GITHUB_OUTPUT" - - if [[ -n "$SCAN_PATHS" ]]; then - { - echo "scan-paths<> "$GITHUB_OUTPUT" - else - echo "scan-paths=" >> "$GITHUB_OUTPUT" - fi env: MERGE_BASE: ${{ steps.fetch-merge-base.outputs.merge-base }} HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} @@ -171,41 +151,6 @@ jobs: exit 1 fi - analyse-code: - name: Analyse code - needs: get-changed-packages - uses: MetaMask/action-security-code-scanner/.github/workflows/security-scan.yml@da7cf8b27792d992d87cf37c2e0e7e27b4c4ad0a - with: - scanner-ref: da7cf8b27792d992d87cf37c2e0e7e27b4c4ad0a - paths: ${{ needs.get-changed-packages.outputs.scan-paths }} - paths-ignored: | - .storybook/ - **/__snapshots__/ - **/*.snap - **/*.stories.js - **/*.stories.tsx - **/*.test.browser.ts* - **/*.test.js* - **/*.test.ts* - **/fixtures/ - **/jest.config.js - **/jest.environment.js - **/mocks/ - **/test*/ - docs/ - e2e/ - merged-packages/ - node_modules/ - storybook/ - test*/ - secrets: - project-metrics-token: ${{ secrets.SECURITY_SCAN_METRICS_TOKEN }} - slack-webhook: ${{ secrets.APPSEC_BOT_SLACK_WEBHOOK }} - permissions: - actions: read - contents: read - security-events: write - validate-changelog: name: Validate changelog runs-on: ubuntu-latest diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9e2a2fc165c..acb3a2ce216 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -55,13 +55,40 @@ jobs: name: Lint, build, and test needs: check-workflows uses: ./.github/workflows/lint-build-test.yml + + analyse-code: + name: Analyse code + needs: check-workflows + uses: MetaMask/action-security-code-scanner/.github/workflows/security-scan.yml@v2 + with: + scanner-ref: v2 + paths-ignored: | + .storybook/ + **/__snapshots__/ + **/*.snap + **/*.stories.js + **/*.stories.tsx + **/*.test.browser.ts* + **/*.test.js* + **/*.test.ts* + **/fixtures/ + **/jest.config.js + **/jest.environment.js + **/mocks/ + **/test*/ + docs/ + e2e/ + merged-packages/ + node_modules/ + storybook/ + test*/ + secrets: + project-metrics-token: ${{ secrets.SECURITY_SCAN_METRICS_TOKEN }} + slack-webhook: ${{ secrets.APPSEC_BOT_SLACK_WEBHOOK }} permissions: actions: read contents: read security-events: write - secrets: - SECURITY_SCAN_METRICS_TOKEN: ${{ secrets.SECURITY_SCAN_METRICS_TOKEN }} - APPSEC_BOT_SLACK_WEBHOOK: ${{ secrets.APPSEC_BOT_SLACK_WEBHOOK }} deploy-platform-api-docs: name: Deploy Platform API Docs @@ -129,6 +156,7 @@ jobs: name: All jobs complete runs-on: ubuntu-latest needs: + - analyse-code - check-release - lint-build-test outputs: From f8aa718e960d38e8052f20b48c54a6ad2ef32c0c Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 15 Jul 2026 10:14:52 +0200 Subject: [PATCH 24/31] Fix position of lint-build-test in main workflow --- .github/workflows/main.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index acb3a2ce216..0d60d95ba94 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -51,11 +51,6 @@ jobs: env: ACTIONLINT: ${{ steps.download-actionlint.outputs.executable }} - lint-build-test: - name: Lint, build, and test - needs: check-workflows - uses: ./.github/workflows/lint-build-test.yml - analyse-code: name: Analyse code needs: check-workflows @@ -90,6 +85,11 @@ jobs: contents: read security-events: write + lint-build-test: + name: Lint, build, and test + needs: check-workflows + uses: ./.github/workflows/lint-build-test.yml + deploy-platform-api-docs: name: Deploy Platform API Docs needs: lint-build-test From 5edbf534597f797b909d0d7902fffc7425d6f4ec Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 15 Jul 2026 10:26:44 +0200 Subject: [PATCH 25/31] Merge get-changed-packages into prepare job 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'`. --- .github/workflows/lint-build-test.yml | 76 +++++++++------------------ 1 file changed, 25 insertions(+), 51 deletions(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 1e19c3ca736..41b30005b2f 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -12,6 +12,9 @@ jobs: node-version: [18.x, 20.x, 22.x, 24.x] outputs: child-workspace-package-names: ${{ steps.workspace-package-names.outputs.child-workspace-package-names }} + merge-base: ${{ steps.fetch-merge-base.outputs.merge-base }} + package-names: ${{ steps.packages.outputs.package-names }} + eslint-paths: ${{ steps.packages.outputs.eslint-paths }} steps: - name: Checkout and setup environment uses: MetaMask/action-checkout-and-setup@v3 @@ -26,25 +29,9 @@ jobs: run: | echo "child-workspace-package-names=$(yarn workspaces list --no-private --json | jq --slurp --raw-output 'map(.name) | @json')" >> "$GITHUB_OUTPUT" shell: bash - - get-changed-packages: - name: Get changed packages - runs-on: ubuntu-latest - needs: prepare - outputs: - merge-base: ${{ steps.fetch-merge-base.outputs.merge-base }} - package-names: ${{ steps.packages.outputs.package-names }} - eslint-paths: ${{ steps.packages.outputs.eslint-paths }} - steps: - - name: Checkout and setup environment - uses: MetaMask/action-checkout-and-setup@v3 - with: - is-high-risk-environment: false - persist-credentials: false - node-version: 24.x - name: Fetch merge base id: fetch-merge-base - if: github.base_ref != '' || github.event.merge_group.base_ref != '' + if: matrix.node-version == '24.x' && (github.base_ref != '' || github.event.merge_group.base_ref != '') run: | set -euo pipefail @@ -63,6 +50,7 @@ jobs: GH_TOKEN: ${{ github.token }} - name: Get changed package names id: packages + if: matrix.node-version == '24.x' run: | if [[ -n "$MERGE_BASE" ]]; then OUTPUT=$(yarn tsx scripts/get-changed-workspaces.mts "$MERGE_BASE" "$HEAD_SHA") @@ -120,10 +108,8 @@ jobs: lint-eslint: name: Lint (lint:eslint) runs-on: ubuntu-latest - if: needs.get-changed-packages.outputs.eslint-paths != '[]' - needs: - - prepare - - get-changed-packages + if: needs.prepare.outputs.eslint-paths != '[]' + needs: prepare strategy: matrix: node-version: [24.x] @@ -142,7 +128,7 @@ jobs: echo "$ESLINT_PATHS" | jq -r '.[]' | xargs yarn lint:eslint fi env: - ESLINT_PATHS: ${{ needs.get-changed-packages.outputs.eslint-paths }} + ESLINT_PATHS: ${{ needs.prepare.outputs.eslint-paths }} - name: Require clean working directory shell: bash run: | @@ -154,14 +140,12 @@ jobs: validate-changelog: name: Validate changelog runs-on: ubuntu-latest - if: needs.get-changed-packages.outputs.package-names != '[]' - needs: - - prepare - - get-changed-packages + if: needs.prepare.outputs.package-names != '[]' + needs: prepare strategy: matrix: node-version: [24.x] - package-name: ${{ fromJson(needs.get-changed-packages.outputs.package-names) }} + package-name: ${{ fromJson(needs.prepare.outputs.package-names) }} steps: - name: Checkout and setup environment uses: MetaMask/action-checkout-and-setup@v3 @@ -198,9 +182,7 @@ jobs: build: name: Build runs-on: ubuntu-latest - needs: - - prepare - - get-changed-packages + needs: prepare strategy: matrix: node-version: [24.x] @@ -212,7 +194,7 @@ jobs: persist-credentials: false node-version: ${{ matrix.node-version }} - name: Unshallow checkout - if: needs.get-changed-packages.outputs.merge-base != '' + if: needs.prepare.outputs.merge-base != '' run: | # Unshallow so git can walk history back to the merge base for # `git diff --name-only`. Using `--filter=blob:none` avoids @@ -231,7 +213,7 @@ jobs: yarn build fi env: - MERGE_BASE: ${{ needs.get-changed-packages.outputs.merge-base }} + MERGE_BASE: ${{ needs.prepare.outputs.merge-base }} HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} - name: Require clean working directory shell: bash @@ -271,13 +253,11 @@ jobs: test-18: name: Test (18.x) runs-on: ubuntu-latest - if: needs.get-changed-packages.outputs.package-names != '[]' - needs: - - prepare - - get-changed-packages + if: needs.prepare.outputs.package-names != '[]' + needs: prepare strategy: matrix: - package-name: ${{ fromJson(needs.get-changed-packages.outputs.package-names) }} + package-name: ${{ fromJson(needs.prepare.outputs.package-names) }} steps: - name: Checkout and setup environment uses: MetaMask/action-checkout-and-setup@v3 @@ -299,13 +279,11 @@ jobs: test-20: name: Test (20.x) runs-on: ubuntu-latest - if: needs.get-changed-packages.outputs.package-names != '[]' - needs: - - prepare - - get-changed-packages + if: needs.prepare.outputs.package-names != '[]' + needs: prepare strategy: matrix: - package-name: ${{ fromJson(needs.get-changed-packages.outputs.package-names) }} + package-name: ${{ fromJson(needs.prepare.outputs.package-names) }} steps: - name: Checkout and setup environment uses: MetaMask/action-checkout-and-setup@v3 @@ -327,13 +305,11 @@ jobs: test-22: name: Test (22.x) runs-on: ubuntu-latest - if: needs.get-changed-packages.outputs.package-names != '[]' - needs: - - prepare - - get-changed-packages + if: needs.prepare.outputs.package-names != '[]' + needs: prepare strategy: matrix: - package-name: ${{ fromJson(needs.get-changed-packages.outputs.package-names) }} + package-name: ${{ fromJson(needs.prepare.outputs.package-names) }} steps: - name: Checkout and setup environment uses: MetaMask/action-checkout-and-setup@v3 @@ -375,10 +351,8 @@ jobs: test-wallet-cli-e2e: name: Test wallet-cli daemon e2e (${{ matrix.node-version }}) runs-on: ubuntu-latest - if: needs.get-changed-packages.outputs.package-names == '[]' || contains(fromJson(needs.get-changed-packages.outputs.package-names), '@metamask/wallet-cli') - needs: - - prepare - - get-changed-packages + if: needs.prepare.outputs.package-names == '[]' || contains(fromJson(needs.prepare.outputs.package-names), '@metamask/wallet-cli') + needs: prepare strategy: matrix: node-version: [20.x, 22.x, 24.x] From f4cfef4c97f57f2e862d5c679205ec94ec33a6f2 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 15 Jul 2026 10:33:20 +0200 Subject: [PATCH 26/31] Add logging to ESLint and build steps Makes it clear at a glance whether CI is running a full or partial check, and which packages are included. --- .github/workflows/lint-build-test.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 41b30005b2f..c7eb9032ba9 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -123,8 +123,10 @@ jobs: - name: Lint run: | if [[ "$ESLINT_PATHS" == "full" ]]; then + echo "Running ESLint on all packages." yarn lint:eslint else + echo "Running ESLint on: $(echo "$ESLINT_PATHS" | jq -r '[.[]] | join(", ")')." echo "$ESLINT_PATHS" | jq -r '.[]' | xargs yarn lint:eslint fi env: @@ -206,10 +208,14 @@ jobs: TSCONFIG=$(mktemp --tmpdir="$GITHUB_WORKSPACE" --suffix=.json) yarn tsx scripts/generate-partial-build-tsconfig.mts "$MERGE_BASE" "$HEAD_SHA" > "$TSCONFIG" if [[ -s "$TSCONFIG" ]]; then + echo "Building changed packages: $(jq -r '[.references[].path | ltrimstr("./") | rtrimstr("/tsconfig.build.json")] | join(", ")' "$TSCONFIG")." yarn ts-bridge --project "$TSCONFIG" --verbose + else + echo "No packages to build." fi rm -f "$TSCONFIG" else + echo "Building all packages." yarn build fi env: From 3a6de8bd419c1b1b73a8beecbfa60a5da0d0dbda Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 15 Jul 2026 10:37:29 +0200 Subject: [PATCH 27/31] Improve logging format in ESLint and build steps 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. --- .github/workflows/lint-build-test.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index c7eb9032ba9..4d68d9de2ae 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -126,7 +126,9 @@ jobs: echo "Running ESLint on all packages." yarn lint:eslint else - echo "Running ESLint on: $(echo "$ESLINT_PATHS" | jq -r '[.[]] | join(", ")')." + echo "Running ESLint on:" + echo "$ESLINT_PATHS" | jq -r '"- " + .[]' + echo "" echo "$ESLINT_PATHS" | jq -r '.[]' | xargs yarn lint:eslint fi env: @@ -208,7 +210,9 @@ jobs: TSCONFIG=$(mktemp --tmpdir="$GITHUB_WORKSPACE" --suffix=.json) yarn tsx scripts/generate-partial-build-tsconfig.mts "$MERGE_BASE" "$HEAD_SHA" > "$TSCONFIG" if [[ -s "$TSCONFIG" ]]; then - echo "Building changed packages: $(jq -r '[.references[].path | ltrimstr("./") | rtrimstr("/tsconfig.build.json")] | join(", ")' "$TSCONFIG")." + echo "Building changed packages:" + jq -r '"- " + (.references[].path | ltrimstr("./") | rtrimstr("/tsconfig.build.json"))' "$TSCONFIG" + echo "" yarn ts-bridge --project "$TSCONFIG" --verbose else echo "No packages to build." From 62e974030425824de40a04e4effb103ee651b57e Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 15 Jul 2026 10:40:51 +0200 Subject: [PATCH 28/31] Use changed-paths to gate both ESLint and build scope 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. --- .github/workflows/lint-build-test.yml | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 4d68d9de2ae..d411da580a7 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -14,7 +14,7 @@ jobs: child-workspace-package-names: ${{ steps.workspace-package-names.outputs.child-workspace-package-names }} merge-base: ${{ steps.fetch-merge-base.outputs.merge-base }} package-names: ${{ steps.packages.outputs.package-names }} - eslint-paths: ${{ steps.packages.outputs.eslint-paths }} + changed-paths: ${{ steps.packages.outputs.changed-paths }} steps: - name: Checkout and setup environment uses: MetaMask/action-checkout-and-setup@v3 @@ -56,16 +56,16 @@ jobs: OUTPUT=$(yarn tsx scripts/get-changed-workspaces.mts "$MERGE_BASE" "$HEAD_SHA") PACKAGES=$(echo "$OUTPUT" | jq -c '.names') if [[ $(echo "$OUTPUT" | jq '.hasRootChange') == "true" ]]; then - ESLINT_PATHS="full" + CHANGED_PATHS="full" else - ESLINT_PATHS=$(echo "$OUTPUT" | jq -c '.locations') + CHANGED_PATHS=$(echo "$OUTPUT" | jq -c '.locations') fi else PACKAGES=$(yarn workspaces list --no-private --json | jq --slurp --raw-output 'map(.name) | @json') - ESLINT_PATHS="full" + CHANGED_PATHS="full" fi echo "package-names=$PACKAGES" >> "$GITHUB_OUTPUT" - echo "eslint-paths=$ESLINT_PATHS" >> "$GITHUB_OUTPUT" + echo "changed-paths=$CHANGED_PATHS" >> "$GITHUB_OUTPUT" env: MERGE_BASE: ${{ steps.fetch-merge-base.outputs.merge-base }} HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} @@ -108,7 +108,7 @@ jobs: lint-eslint: name: Lint (lint:eslint) runs-on: ubuntu-latest - if: needs.prepare.outputs.eslint-paths != '[]' + if: needs.prepare.outputs.changed-paths != '[]' needs: prepare strategy: matrix: @@ -122,17 +122,17 @@ jobs: node-version: ${{ matrix.node-version }} - name: Lint run: | - if [[ "$ESLINT_PATHS" == "full" ]]; then + if [[ "$CHANGED_PATHS" == "full" ]]; then echo "Running ESLint on all packages." yarn lint:eslint else echo "Running ESLint on:" - echo "$ESLINT_PATHS" | jq -r '"- " + .[]' + echo "$CHANGED_PATHS" | jq -r '"- " + .[]' echo "" - echo "$ESLINT_PATHS" | jq -r '.[]' | xargs yarn lint:eslint + echo "$CHANGED_PATHS" | jq -r '.[]' | xargs yarn lint:eslint fi env: - ESLINT_PATHS: ${{ needs.prepare.outputs.eslint-paths }} + CHANGED_PATHS: ${{ needs.prepare.outputs.changed-paths }} - name: Require clean working directory shell: bash run: | @@ -206,7 +206,7 @@ jobs: git fetch --unshallow --filter=blob:none --no-tags origin HEAD - name: Build run: | - if [[ -n "$MERGE_BASE" ]]; then + if [[ -n "$MERGE_BASE" && "$CHANGED_PATHS" != "full" ]]; then TSCONFIG=$(mktemp --tmpdir="$GITHUB_WORKSPACE" --suffix=.json) yarn tsx scripts/generate-partial-build-tsconfig.mts "$MERGE_BASE" "$HEAD_SHA" > "$TSCONFIG" if [[ -s "$TSCONFIG" ]]; then @@ -225,6 +225,7 @@ jobs: env: MERGE_BASE: ${{ needs.prepare.outputs.merge-base }} HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} + CHANGED_PATHS: ${{ needs.prepare.outputs.changed-paths }} - name: Require clean working directory shell: bash run: | From da7f80216a4fe551732fabca2ca742ec7689cd19 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 15 Jul 2026 10:44:47 +0200 Subject: [PATCH 29/31] Add blank line after full-run log messages for consistency The partial-run branches already print a blank line after the package list. Add the same blank line to the full-run branches. --- .github/workflows/lint-build-test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index d411da580a7..6f9124d1875 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -124,6 +124,7 @@ jobs: run: | if [[ "$CHANGED_PATHS" == "full" ]]; then echo "Running ESLint on all packages." + echo "" yarn lint:eslint else echo "Running ESLint on:" @@ -220,6 +221,7 @@ jobs: rm -f "$TSCONFIG" else echo "Building all packages." + echo "" yarn build fi env: From a508a37b4559881bfc3b84155474293f97cb997c Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 15 Jul 2026 12:12:05 +0200 Subject: [PATCH 30/31] Add permissions block to lint-build-test reusable workflow Declares the minimum permissions required by the workflow explicitly. --- .github/workflows/lint-build-test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 6f9124d1875..c1dc2e72bc7 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -3,6 +3,9 @@ name: Lint, Build, and Test on: workflow_call: +permissions: + contents: read + jobs: prepare: name: Prepare From fe898a189b95dd6ffe3bf990acc8d733d049d9e5 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Mon, 13 Jul 2026 14:42:01 +0200 Subject: [PATCH 31/31] Benchmark: ignored root file (README.md) + change to logging-controller --- README.md | 2 +- packages/logging-controller/src/LoggingController.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 24c9ede1fed..dbb50bf69e6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Core Monorepo +# Core Monorepo This monorepo is a collection of packages used across multiple MetaMask clients (e.g. [`metamask-extension`](https://github.com/MetaMask/metamask-extension/), [`metamask-mobile`](https://github.com/MetaMask/metamask-mobile/)). diff --git a/packages/logging-controller/src/LoggingController.ts b/packages/logging-controller/src/LoggingController.ts index f950cba141a..fae4425c3f4 100644 --- a/packages/logging-controller/src/LoggingController.ts +++ b/packages/logging-controller/src/LoggingController.ts @@ -1,3 +1,4 @@ +// Benchmark: mixed change (ignored root + single package). import type { ControllerGetStateAction, ControllerStateChangeEvent,