From ee8a40ee2316b3884f150127aacbd7f80edb3fde Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 2 Jul 2026 13:56:47 +0200 Subject: [PATCH 01/30] 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 36039484255..f3574cd2c1b 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -113,7 +113,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 704c084c32f1828ecbf776a7ea2904e53c42e6a5 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 2 Jul 2026 14:01:17 +0200 Subject: [PATCH 02/30] 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 f3574cd2c1b..b45d1f9c868 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -125,7 +125,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 e6bcd9a0555be11f318c559098f5caecfe808309 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 2 Jul 2026 14:06:06 +0200 Subject: [PATCH 03/30] 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 b45d1f9c868..204373581bf 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -125,6 +125,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 2a22ab0a9ca35ff05b48ea373fb4d1d79b07b093 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 2 Jul 2026 14:21:14 +0200 Subject: [PATCH 04/30] 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 204373581bf..3b746b3a1ad 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -113,10 +113,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 @@ -125,20 +127,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 4b231529e4ee694916a04ad85c0d3c7068b6b7c8 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 2 Jul 2026 14:26:50 +0200 Subject: [PATCH 05/30] 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 3b746b3a1ad..6c3fe087b87 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -127,10 +127,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 e3bd09da59bbb34e224c083d6e40c3fb0e476d9f Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 2 Jul 2026 14:34:04 +0200 Subject: [PATCH 06/30] 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 6c3fe087b87..8c0999ae9f1 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -134,7 +134,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 9735938c7a53025d99ee4e1e3de605353e510e43 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 3 Jul 2026 11:11:34 +0200 Subject: [PATCH 07/30] 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 8c0999ae9f1..660fd2e4cd5 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -144,7 +144,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 c990f76163826f5ecf80ea4332f7bc34a019e37a Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 3 Jul 2026 11:15:37 +0200 Subject: [PATCH 08/30] 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 660fd2e4cd5..f285c3f7b2f 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -144,9 +144,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 7f5617d4ebb7820aa02473121c1f6b71c975b987 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 3 Jul 2026 11:32:39 +0200 Subject: [PATCH 09/30] 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 f285c3f7b2f..839d1dc4d8c 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -145,14 +145,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 f6c3c90b2a894bf8915799168aff58fca5b3dfb4 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 3 Jul 2026 11:58:20 +0200 Subject: [PATCH 10/30] 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 839d1dc4d8c..8e8c3ba5bb4 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -64,11 +64,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 @@ -87,53 +89,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" @@ -141,6 +122,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 @@ -154,7 +188,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 @@ -193,10 +227,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 @@ -218,10 +254,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 @@ -243,10 +281,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 c4707e95e925098784a3fd1abb6ce79fa26c085a Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 3 Jul 2026 12:04:54 +0200 Subject: [PATCH 11/30] 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 8e8c3ba5bb4..63886b77bc4 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -127,11 +127,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 187c0cd6345d07883b5f3c89c9138086edf84260 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 3 Jul 2026 12:19:45 +0200 Subject: [PATCH 12/30] 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 63886b77bc4..9fb6e14329d 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -64,6 +64,7 @@ jobs: validate-changelog: name: Validate changelog runs-on: ubuntu-latest + if: needs.get-changed-packages.outputs.package-names != '[]' needs: - prepare - get-changed-packages @@ -222,6 +223,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 @@ -249,6 +251,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 @@ -276,6 +279,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 aa75de9bc71603d9a450a86fd1f601af11973afd Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 3 Jul 2026 12:23:51 +0200 Subject: [PATCH 13/30] 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 9fb6e14329d..1a718530827 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -327,7 +327,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 1920d2f4a19b8cbc97bc8eeb8c156b1e36e5cd48 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 3 Jul 2026 14:13:32 +0200 Subject: [PATCH 14/30] 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 0da66a722feb9eff7da7eeb9f04431708b2ab82a Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 3 Jul 2026 14:18:21 +0200 Subject: [PATCH 15/30] 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 1a77046e770d1644b7d89cb21a00aa8c96b9d518 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 3 Jul 2026 14:29:09 +0200 Subject: [PATCH 16/30] 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 b08230fdbd3e7ac22aa0e4228742e98bd494f223 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 14 Jul 2026 10:30:16 +0200 Subject: [PATCH 17/30] 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 | 127 +++++++++++++++++--------- scripts/get-changed-workspaces.mts | 68 +++++++++----- scripts/lib/workspaces.mts | 34 +++++-- 3 files changed, 154 insertions(+), 75 deletions(-) diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 1a718530827..a9f45a02229 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 @@ -35,7 +91,6 @@ jobs: matrix: node-version: [24.x] script: - - lint:eslint - lint:misc:check - constraints - lint:dependencies @@ -61,17 +116,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 @@ -79,9 +133,15 @@ jobs: is-high-risk-environment: false persist-credentials: false node-version: ${{ matrix.node-version }} - - 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: | @@ -90,51 +150,34 @@ 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 }} + - 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 577771dd24d76021da45585cbddc1f85741c8b1f Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 14 Jul 2026 10:44:27 +0200 Subject: [PATCH 18/30] 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 a9f45a02229..44714fe7538 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -138,7 +138,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 557d141f956e19b2632cc7bee50de87c7765158f Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 14 Jul 2026 10:53:02 +0200 Subject: [PATCH 19/30] 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 44714fe7538..319263988cb 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 09203aa94e8442ee5701f90b47f6253499307c2d Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 14 Jul 2026 13:24:13 +0200 Subject: [PATCH 20/30] 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 319263988cb..3c604bdd7a4 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -369,7 +369,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 906ed7547336ccb757f796ad931924fb3633daa6 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 14 Jul 2026 16:03:51 +0200 Subject: [PATCH 21/30] 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 3c604bdd7a4..062a7605be0 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 }} @@ -149,6 +169,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 ac82eb1e05ecdd9309d4d3e3ac2292208499c117 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 14 Jul 2026 16:10:47 +0200 Subject: [PATCH 22/30] 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 062a7605be0..a3c3a4b7414 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -174,7 +174,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 1602e19f3d6db1b40e902d34953c6ae019189385 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 15 Jul 2026 10:09:27 +0200 Subject: [PATCH 23/30] 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 a3c3a4b7414..3c604bdd7a4 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 }} @@ -169,41 +149,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 7a72c410a8064dc942eb114b7984f8b6541bc08b Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 15 Jul 2026 10:14:52 +0200 Subject: [PATCH 24/30] 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 349b92a4739b0a08c6bebfab011cd12d08a576d0 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 15 Jul 2026 10:26:44 +0200 Subject: [PATCH 25/30] 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 3c604bdd7a4..88ad3143c58 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") @@ -118,10 +106,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] @@ -140,7 +126,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: | @@ -152,14 +138,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 @@ -193,9 +177,7 @@ jobs: build: name: Build runs-on: ubuntu-latest - needs: - - prepare - - get-changed-packages + needs: prepare strategy: matrix: node-version: [24.x] @@ -207,7 +189,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 @@ -226,7 +208,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 @@ -265,13 +247,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 @@ -293,13 +273,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 @@ -321,13 +299,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 @@ -369,10 +345,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 9f890a55f1c012904c3a548cb0922985445962dd Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 15 Jul 2026 10:33:20 +0200 Subject: [PATCH 26/30] 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 88ad3143c58..81f1451b8e9 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -121,8 +121,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: @@ -201,10 +203,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 1e218aca61d6dfd417d76346b1d5b36e03ed2f16 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 15 Jul 2026 10:37:29 +0200 Subject: [PATCH 27/30] 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 81f1451b8e9..0e83757055e 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -124,7 +124,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: @@ -203,7 +205,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 4a89ee4e186c6ee66ec543b7a9230858cd0b2e1c Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 15 Jul 2026 10:40:51 +0200 Subject: [PATCH 28/30] 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 0e83757055e..bc3ba6f0d3f 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 }} @@ -106,7 +106,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: @@ -120,17 +120,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: | @@ -201,7 +201,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 @@ -220,6 +220,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 f80672db3aff9b40d3070e5f00c47f695dd63113 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 15 Jul 2026 10:44:47 +0200 Subject: [PATCH 29/30] 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 bc3ba6f0d3f..718b34856c8 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -122,6 +122,7 @@ jobs: run: | if [[ "$CHANGED_PATHS" == "full" ]]; then echo "Running ESLint on all packages." + echo "" yarn lint:eslint else echo "Running ESLint on:" @@ -215,6 +216,7 @@ jobs: rm -f "$TSCONFIG" else echo "Building all packages." + echo "" yarn build fi env: From 664902cbb3e0efbbd8e7f309a1881922a330ea22 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 15 Jul 2026 12:12:05 +0200 Subject: [PATCH 30/30] 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 718b34856c8..daa366987da 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