From ee8a40ee2316b3884f150127aacbd7f80edb3fde Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 2 Jul 2026 13:56:47 +0200 Subject: [PATCH 01/16] 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 3603948425..f3574cd2c1 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/16] 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 f3574cd2c1..b45d1f9c86 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/16] 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 b45d1f9c86..204373581b 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/16] 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 204373581b..3b746b3a1a 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/16] 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 3b746b3a1a..6c3fe087b8 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/16] 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 6c3fe087b8..8c0999ae9f 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/16] 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 8c0999ae9f..660fd2e4cd 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 0000000000..d0719d9b83 --- /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/16] 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 660fd2e4cd..f285c3f7b2 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/16] 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 f285c3f7b2..839d1dc4d8 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 d0719d9b83..5cbbf49d49 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/16] 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 839d1dc4d8..8e8c3ba5bb 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 5cbbf49d49..978afed55d 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 0000000000..48962750da --- /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 0000000000..a6366f0721 --- /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 b4adc2c51e..691fa67fa7 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/16] 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 8e8c3ba5bb..63886b77bc 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/16] 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 63886b77bc..9fb6e14329 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/16] 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 9fb6e14329..1a71853082 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/16] 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 a6366f0721..af50945e92 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/16] 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 af50945e92..1dcd744a17 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/16] 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 1dcd744a17..c73f7b1e4a 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()