ci: add breaking change check workflow for public TypeScript interfaces by danieljbruce · Pull Request #9583 · googleapis/google-cloud-node · GitHub
Skip to content

ci: add breaking change check workflow for public TypeScript interfaces - #9583

Draft
danieljbruce wants to merge 1 commit into
googleapis:mainfrom
danieljbruce:ci/breaking-change-check
Draft

danieljbruce wants to merge 1 commit into
googleapis:mainfrom
danieljbruce:ci/breaking-change-check

Conversation

@danieljbruce

Copy link
Copy Markdown
Contributor

Description

Adds a GitHub Actions workflow and TypeScript Compiler API checker that analyzes modified TypeScript source files on pull requests and fails if any public interface, type alias, class, constructor, function, or method signature becomes more restrictive.

Impact

Catches accidental breaking changes in public TypeScript interfaces (such as narrowing ClientConfig.port from string | number to number in #9539) during pull request review as a non-required status check and inline diff annotation without blocking intentional breaking changes from merging.

Changes

  • Added .github/workflows/breaking-change-check.yaml triggered on pull_request and workflow_dispatch to run the breaking change checker against HEAD^1...HEAD.
  • Added bin/breaking-change-check.mjs, which uses the TypeScript Compiler API (ts.createProgram and TypeChecker) with an in-memory base/head overlay CompilerHost to compare exported types, interfaces, classes, constructors, functions, methods, and values across modified .ts files and each affected package's src/index.ts entrypoint.
  • Emits GitHub Actions ::error file=... annotations and a $GITHUB_STEP_SUMMARY Markdown report when breaking changes are detected so PR readers immediately see which public interfaces became more restrictive.
  • Added unit and historical regression tests in bin/test/breaking-change-check.test.mjs.

Testing

Alternatives

  • Full workspace tsc --declaration build on base and head checkouts: Compiling .d.ts files for both revisions across the monorepo is much slower in CI and requires two separate worktrees, whereas an in-memory overlay CompilerHost within a single ts.Program per affected package checks only modified packages in a few seconds.
  • Not adding an automated check: Leaves reviewers to manually spot subtle type narrowings in public interfaces, which previously allowed unintentional breaking changes like fix(pubsub): align ClientConfig.port type with gax.GrpcClientOptions #9539 to merge.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a tool and test suite to detect breaking changes in public TypeScript interfaces by comparing ASTs and types across git revisions. The review feedback identifies critical improvements for handling renamed files in the compiler host, updating class expressions in the AST transformer, and ensuring test robustness in shallow-cloned CI environments.

Comment on lines +1475 to +1489

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

When a file is renamed, both its old and new paths need to be tracked in changedSet and headToEntry so that the custom compiler host can correctly resolve and read the base version of the file from git history.

    const headToEntry = new Map();
    for (const e of entries) {
      headToEntry.set(e.filePath, e);
      headToEntry.set(e.oldFilePath, e);
    }

    const headIndex = path.join(srcDir, 'index.ts');
    const hasIndex = 
      fs.existsSync(headIndex) ||
      getFileAtRev(
        baseRev,
        path.relative(REPO_ROOT, headIndex).split(path.sep).join('/'),
      ) !== undefined;

    const changedSet = new Set();
    for (const e of entries) {
      changedSet.add(e.filePath);
      changedSet.add(e.oldFilePath);
    }
    if (hasIndex) changedSet.add(headIndex);

Comment on lines +1578 to +1615
resolveModuleNameLiterals(
moduleLiterals,
containingFile,
redirectedReference,
options,
) {
const resolvedContaining = path.resolve(containingFile);
const isFromBase =
resolvedContaining.startsWith(baseSrcDir + path.sep) ||
resolvedContaining === baseSrcDir;
const realContaining = toRealPath(resolvedContaining);

return moduleLiterals.map(lit => {
const res = ts.resolveModuleName(
lit.text,
realContaining,
options,
this,
undefined,
redirectedReference,
);
if (
isFromBase &&
res.resolvedModule &&
changedSet.has(path.resolve(res.resolvedModule.resolvedFileName))
) {
return {
resolvedModule: {
...res.resolvedModule,
resolvedFileName: toBasePath(
res.resolvedModule.resolvedFileName,
),
},
};
}
return res;
});
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Using realContaining (which maps to the head/working directory path) for resolving module names in the base program prevents renamed files from being resolved. Since the old file name no longer exists in the head directory, the resolution fails. Passing containingFile directly allows TypeScript to resolve modules relative to the virtual __base_src__ directory, which correctly triggers the custom host's git-backed file checks.

      resolveModuleNameLiterals(
        moduleLiterals,
        containingFile,
        redirectedReference,
        options,
      ) {
        return moduleLiterals.map(lit => {
          return ts.resolveModuleName(
            lit.text,
            containingFile,
            options,
            this,
            undefined,
            redirectedReference,
          );
        });
      },

Comment on lines +260 to +269
if (ts.isClassDeclaration(node)) {
return ts.factory.updateClassDeclaration(
node,
node.modifiers,
node.name,
node.typeParameters,
node.heritageClauses,
newMembers,
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The AST transformer currently handles ClassDeclaration but misses updating ClassExpression nodes, even though both are checked in the outer if condition. This causes private/protected members of class expressions to not be stripped, which can lead to nominal type check failures during comparison.

        if (ts.isClassDeclaration(node)) {
          return ts.factory.updateClassDeclaration(
            node,
            node.modifiers,
            node.name,
            node.typeParameters,
            node.heritageClauses,
            newMembers,
          );
        } else {
          return ts.factory.updateClassExpression(
            node,
            node.modifiers,
            node.name,
            node.typeParameters,
            node.heritageClauses,
            newMembers,
          );
        }

Comment on lines +344 to +367

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

This test relies on historical commits being present in the git history. In environments using shallow clones (such as default GitHub Actions checkouts with fetch-depth: 1), these commits will be missing, causing the test suite to crash. Wrapping the check in a try-catch block to gracefully skip the test when the commits are unavailable ensures robust CI runs.

    it('detects the breaking change in PR #9539 (4443d6d6b1) and passes on its revert PR #9549 (d62a64f338)', async (t) => {
      let breakingErrors;
      try {
        breakingErrors = await checkBreakingChanges({
          diffRevArgs: ['4443d6d6b1^', '4443d6d6b1'],
          baseRev: '4443d6d6b1^',
          headRev: '4443d6d6b1',
        });
      } catch (err) {
        if (err.message.includes('bad object') || err.message.includes('fatal')) {
          t.skip('Historical commits not available in this clone (shallow clone).');
          return;
        }
        throw err;
      }
      assert.ok(
        breakingErrors.some(
          e =>
            e.includes("Interface/Type 'ClientConfig': property 'port'") &&
            e.includes("was 'string | number | undefined'") &&
            e.includes("now 'number | undefined'"),
        ),
        `Expected PR #9539 ClientConfig.port breaking change, got: ${breakingErrors.join('\n')}`,
      );

      const revertErrors = await checkBreakingChanges({
        diffRevArgs: ['d62a64f338^', 'd62a64f338'],
        baseRev: 'd62a64f338^',
        headRev: 'd62a64f338',
      });
      assert.deepEqual(revertErrors, []);
    });

danieljbruce added a commit that referenced this pull request Oct 15, 2026
## Description
This PR adds a lightweight, `pnpm`-based breaking-change check workflow
as an alternative to the 1,764-line custom TypeScript AST/TypeChecker
script in #9583, without adding any custom script files in `bin/`:

1. **Workspace dependent compilation via `pnpm --filter
"...[HEAD^1...HEAD]"`**: Passes `--config.link-workspace-packages=deep
--config.prefer-workspace-packages=true` to `pnpm` so modified `core/*`
and `handwritten/*` packages are linked and compiled alongside their
downstream workspace dependents (catching cross-package breakages like
#9496 where a `google-gax` change broke `@google-cloud/pubsub`).
2. **Declaration comparison via `pnpm dlx
@api-extractor-tools/change-detector`**: Emits `.d.ts` declarations for
modified TypeScript files (`HEAD^1` vs `HEAD`) and runs `pnpm dlx
@api-extractor-tools/change-detector /tmp/base.d.ts /tmp/head.d.ts
--json`, flagging any removed exports/members (`action === 'removed'`)
or narrowed types (`impact === 'narrowing'`).

## Impact
- **CI / Pull Requests**: Automatically checks `core/*` and
`handwritten/*` TypeScript changes for breaking public API changes and
cross-package compilation regressions.
- **Maintainability**: Only adds
`.github/workflows/breaking-change-check.yaml` (113 lines) with zero
custom `bin/breaking-change-check.mjs` code to maintain.

## Changes
-
[`.github/workflows/breaking-change-check.yaml`](.github/workflows/breaking-change-check.yaml):
Compiles affected packages and workspace dependents via `pnpm --filter
"...[HEAD^1...HEAD]"` and checks modified declarations using `pnpm dlx
@api-extractor-tools/change-detector`.

## Testing
- Verified against #9539 (`4443d6d6b1`) that `pnpm dlx
@api-extractor-tools/change-detector` detects `Narrowed type of
'ClientConfig.port' from 'string | number' to 'number'` (`impact:
"narrowing"`).
- Verified against the revert in #9549 (`d62a64f338`) that widening
`ClientConfig.port` back to `string | number` is classified as `impact:
"widening"` and passes with zero errors.
- Verified `pnpm --config.link-workspace-packages=deep
--config.prefer-workspace-packages=true --filter
"...[17978e7^1...17978e7]" --filter "!./packages/*"` discovers
`@google-cloud/pubsub` and all other `core/*` and `handwritten/*`
dependents of `google-gax` (#9496).

## Alternatives
- **1,764-line custom AST/TypeChecker script in
`bin/breaking-change-check.mjs` (#9583)**: Requires maintaining custom
AST transformation passes, custom module resolution, and undocumented
internal TypeScript `TypeChecker` calls inside this repository.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant