From 49b7389e76ff9e35a9f1ddd86ca4f4a1042b2f06 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 07:30:38 +0000 Subject: [PATCH 1/8] feat: add bazel-deps-query config to dynamically resolve additional paths Co-Authored-By: helen --- schemas/config.json | 7 +- src/manifest.ts | 22 ++- src/util/bazel-query.ts | 121 ++++++++++++++ .../manifest/config/bazel-deps-query.json | 8 + test/manifest.ts | 152 ++++++++++++++++++ test/util/bazel-query.ts | 122 ++++++++++++++ 6 files changed, 429 insertions(+), 3 deletions(-) create mode 100644 src/util/bazel-query.ts create mode 100644 test/fixtures/manifest/config/bazel-deps-query.json create mode 100644 test/util/bazel-query.ts diff --git a/schemas/config.json b/schemas/config.json index 38694cb90..98d3f6be7 100644 --- a/schemas/config.json +++ b/schemas/config.json @@ -237,6 +237,10 @@ "type": "string" } }, + "bazel-deps-query": { + "description": "A bazel query command to run to dynamically resolve additional dependency paths. The output is parsed to extract local package paths which are merged with additional-paths. Example: \"bazel query 'deps(//my-service)'\"", + "type": "string" + }, "version-file": { "description": "Path to the specialize version file. Used by `ruby` and `simple` strategies.", "type": "string" @@ -494,6 +498,7 @@ "initial-version": true, "exclude-paths": true, "component-no-space": false, - "additional-paths": true + "additional-paths": true, + "bazel-deps-query": true } } diff --git a/src/manifest.ts b/src/manifest.ts index 1a7b2f59a..b1da8a469 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -47,6 +47,7 @@ import { } from './util/pull-request-overflow-handler'; import {signoffCommitMessage} from './util/signoff-commit-message'; import {CommitExclude} from './util/commit-exclude'; +import {runBazelQuery} from './util/bazel-query'; type ExtraGenericFile = { type: 'generic'; @@ -140,6 +141,7 @@ export interface ReleaserConfig { // Manifest only excludePaths?: string[]; additionalPaths?: string[]; + bazelDepsQuery?: string; } export interface CandidateReleasePullRequest { @@ -188,6 +190,7 @@ interface ReleaserConfigJson { 'initial-version'?: string; 'exclude-paths'?: string[]; // manifest-only 'additional-paths'?: string[]; // manifest-only + 'bazel-deps-query'?: string; // manifest-only 'date-format'?: string; } @@ -670,14 +673,27 @@ export class Manifest { ); } + // resolve bazel-deps-query for each package and merge with additionalPaths + const resolvedAdditionalPaths: Record = {}; + for (const [path, config] of Object.entries(this.repositoryConfig)) { + const staticPaths = config.additionalPaths || []; + let bazelPaths: string[] = []; + if (config.bazelDepsQuery) { + bazelPaths = runBazelQuery(config.bazelDepsQuery, path, this.logger); + } + // merge and deduplicate + const allPaths = [...new Set([...staticPaths, ...bazelPaths])]; + resolvedAdditionalPaths[path] = allPaths; + } + // split commits by path this.logger.info(`Splitting ${commits.length} commits by path`); const cs = new CommitSplit({ includeEmpty: true, packagePaths: Object.fromEntries( - Object.entries(this.repositoryConfig).map(([path, config]) => [ + Object.entries(this.repositoryConfig).map(([path]) => [ path, - config.additionalPaths || [], + resolvedAdditionalPaths[path] || [], ]) ), }); @@ -1414,6 +1430,7 @@ function extractReleaserConfig( initialVersion: config['initial-version'], excludePaths: config['exclude-paths'], additionalPaths: config['additional-paths'], + bazelDepsQuery: config['bazel-deps-query'], dateFormat: config['date-format'], }; } @@ -1776,6 +1793,7 @@ function mergeReleaserConfig( excludePaths: pathConfig.excludePaths ?? defaultConfig.excludePaths, additionalPaths: pathConfig.additionalPaths ?? defaultConfig.additionalPaths, + bazelDepsQuery: pathConfig.bazelDepsQuery ?? defaultConfig.bazelDepsQuery, dateFormat: pathConfig.dateFormat ?? defaultConfig.dateFormat, }; } diff --git a/src/util/bazel-query.ts b/src/util/bazel-query.ts new file mode 100644 index 000000000..60903a10f --- /dev/null +++ b/src/util/bazel-query.ts @@ -0,0 +1,121 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {execSync} from 'child_process'; +import {Logger} from './logger'; + +/** + * Parse the output of a bazel query command into a list of local package paths. + * + * Bazel query output contains lines like: + * //path/to/package:target_name + * @external_dep//path:target + * + * This function: + * 1. Filters out external dependencies (lines starting with `@`) + * 2. Extracts the package path from local targets (`//path/to/package:target` -> `path/to/package`) + * 3. Deduplicates paths + * 4. Optionally excludes a given path (e.g., the package's own path) + * + * @param output Raw stdout from a bazel query command + * @param excludePath Optional path to exclude from results (e.g., the package's own path) + * @returns Array of unique local package paths + */ +export function parseBazelQueryOutput( + output: string, + excludePath?: string +): string[] { + const lines = output.split('\n').filter(line => line.trim().length > 0); + + const paths = new Set(); + for (const line of lines) { + const trimmed = line.trim(); + + // Skip external dependencies (start with @) + if (trimmed.startsWith('@')) { + continue; + } + + // Match local targets: //path/to/package:target or //path/to/package + const match = trimmed.match(/^\/\/([^:]*)/); + if (!match) { + continue; + } + + const packagePath = match[1]; + + // Skip empty paths (e.g., //:target refers to the root) + if (!packagePath) { + continue; + } + + // Normalize: remove trailing slashes + const normalized = packagePath.replace(/\/+$/, ''); + if (!normalized) { + continue; + } + + // Exclude the package's own path if specified + if (excludePath && normalized === excludePath.replace(/\/+$/, '')) { + continue; + } + + paths.add(normalized); + } + + return Array.from(paths).sort(); +} + +/** + * Execute a bazel query command and return the parsed local package paths. + * + * @param query The bazel query string to execute (e.g., "bazel query 'deps(//combined-service)'") + * @param excludePath Optional path to exclude from results + * @param logger Optional logger instance + * @returns Array of unique local package paths + */ +export function runBazelQuery( + query: string, + excludePath?: string, + logger?: Logger +): string[] { + logger?.info(`Running bazel deps query: ${query}`); + + try { + const output = execSync(query, { + encoding: 'utf-8', + timeout: 120000, // 2 minute timeout + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const paths = parseBazelQueryOutput(output, excludePath); + logger?.info( + `Bazel deps query resolved ${ + paths.length + } additional paths: ${JSON.stringify(paths)}` + ); + return paths; + } catch (err) { + const error = err as Error & {stderr?: string}; + logger?.error( + `Failed to run bazel deps query "${query}": ${error.message}` + ); + if (error.stderr) { + logger?.error(`stderr: ${error.stderr}`); + } + throw new Error( + `Failed to execute bazel-deps-query "${query}": ${error.message}` + ); + } +} diff --git a/test/fixtures/manifest/config/bazel-deps-query.json b/test/fixtures/manifest/config/bazel-deps-query.json new file mode 100644 index 000000000..dff5a72cc --- /dev/null +++ b/test/fixtures/manifest/config/bazel-deps-query.json @@ -0,0 +1,8 @@ +{ + "release-type": "simple", + "packages": { + "apps/my-app": { + "bazel-deps-query": "bazel query 'deps(//apps/my-app)'" + } + } +} diff --git a/test/manifest.ts b/test/manifest.ts index 44bca6834..3fe010d9c 100644 --- a/test/manifest.ts +++ b/test/manifest.ts @@ -537,6 +537,34 @@ describe('Manifest', () => { 'path-ignore', ]); }); + it('should read bazel-deps-query from manifest', async () => { + const getFileContentsStub = sandbox.stub( + github, + 'getFileContentsOnBranch' + ); + getFileContentsStub + .withArgs('release-please-config.json', 'main') + .resolves( + buildGitHubFileContent( + fixturesPath, + 'manifest/config/bazel-deps-query.json' + ) + ) + .withArgs('.release-please-manifest.json', 'main') + .resolves( + buildGitHubFileContent( + fixturesPath, + 'manifest/versions/versions.json' + ) + ); + const manifest = await Manifest.fromManifest( + github, + github.repository.defaultBranch + ); + expect(manifest.repositoryConfig['apps/my-app'].bazelDepsQuery).to.equal( + "bazel query 'deps(//apps/my-app)'" + ); + }); it('should read additional paths from manifest', async () => { const getFileContentsStub = sandbox.stub( github, @@ -3767,6 +3795,130 @@ describe('Manifest', () => { }); }); + it('should update manifest for commits resolved by bazel-deps-query', async () => { + mockReleases(sandbox, github, []); + mockTags(sandbox, github, [ + { + name: 'apps-myapp-v1.0.0', + sha: 'abc123', + }, + ]); + mockCommits(sandbox, github, [ + { + sha: 'aaaaaa', + message: 'fix: shared-lib bugfix', + files: ['libs/shared-lib/test.txt'], + }, + { + sha: 'abc123', + message: 'chore: release main', + files: [], + pullRequest: { + headBranchName: 'release-please/branches/main/components/myapp', + baseBranchName: 'main', + number: 123, + title: 'chore: release main', + body: '', + labels: [], + files: [], + sha: 'abc123', + }, + }, + ]); + // Stub the bazel query module + const bazelQueryModule = await import('../src/util/bazel-query'); + const runBazelQueryStub = sandbox + .stub(bazelQueryModule, 'runBazelQuery') + .returns(['libs/shared-lib', 'libs/other-lib']); + + const manifest = new Manifest( + github, + 'main', + { + 'apps/my-app': { + releaseType: 'simple', + component: 'myapp', + bazelDepsQuery: "bazel query 'deps(//apps/my-app)'", + }, + }, + { + 'apps/my-app': Version.parse('1.0.0'), + } + ); + const pullRequests = await manifest.buildPullRequests(); + expect(pullRequests).lengthOf(1); + const pullRequest = pullRequests[0]; + expect(pullRequest.version?.toString()).to.eql('1.0.1'); + expect(pullRequest.headRefName).to.eql( + 'release-please--branches--main--components--myapp' + ); + // Verify the bazel query was called with the right arguments + sinon.assert.calledOnce(runBazelQueryStub); + sinon.assert.calledWith( + runBazelQueryStub, + "bazel query 'deps(//apps/my-app)'", + 'apps/my-app' + ); + }); + + it('should merge bazel-deps-query results with static additionalPaths', async () => { + mockReleases(sandbox, github, []); + mockTags(sandbox, github, [ + { + name: 'apps-myapp-v1.0.0', + sha: 'abc123', + }, + ]); + mockCommits(sandbox, github, [ + { + sha: 'aaaaaa', + message: 'fix: static-lib bugfix', + files: ['libs/static-lib/test.txt'], + }, + { + sha: 'abc123', + message: 'chore: release main', + files: [], + pullRequest: { + headBranchName: 'release-please/branches/main/components/myapp', + baseBranchName: 'main', + number: 123, + title: 'chore: release main', + body: '', + labels: [], + files: [], + sha: 'abc123', + }, + }, + ]); + // Stub the bazel query module + const bazelQueryModule = await import('../src/util/bazel-query'); + sandbox + .stub(bazelQueryModule, 'runBazelQuery') + .returns(['libs/dynamic-lib']); + + const manifest = new Manifest( + github, + 'main', + { + 'apps/my-app': { + releaseType: 'simple', + component: 'myapp', + additionalPaths: ['libs/static-lib'], + bazelDepsQuery: "bazel query 'deps(//apps/my-app)'", + }, + }, + { + 'apps/my-app': Version.parse('1.0.0'), + } + ); + const pullRequests = await manifest.buildPullRequests(); + expect(pullRequests).lengthOf(1); + const pullRequest = pullRequests[0]; + // The commit in libs/static-lib should trigger a release + expect(pullRequest.version?.toString()).to.eql('1.0.1'); + }); + it('should update manifest for commits in additionalPaths', async () => { mockReleases(sandbox, github, []); mockTags(sandbox, github, [ diff --git a/test/util/bazel-query.ts b/test/util/bazel-query.ts new file mode 100644 index 000000000..c8915be64 --- /dev/null +++ b/test/util/bazel-query.ts @@ -0,0 +1,122 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {parseBazelQueryOutput} from '../../src/util/bazel-query'; + +describe('parseBazelQueryOutput', () => { + it('should parse simple bazel query output', () => { + const output = [ + '//libs/my-lib:my-lib', + '//libs/other-lib:other-lib', + '//apps/my-app:my-app', + ].join('\n'); + + const paths = parseBazelQueryOutput(output); + expect(paths).to.deep.equal([ + 'apps/my-app', + 'libs/my-lib', + 'libs/other-lib', + ]); + }); + + it('should filter out external dependencies', () => { + const output = [ + '//libs/my-lib:my-lib', + '@maven//:com_google_guava_guava', + '@npm//:node_modules/lodash', + '//libs/other-lib:other-lib', + '@bazel_tools//tools/jdk:toolchain', + ].join('\n'); + + const paths = parseBazelQueryOutput(output); + expect(paths).to.deep.equal(['libs/my-lib', 'libs/other-lib']); + }); + + it('should exclude the specified path', () => { + const output = [ + '//apps/my-app:my-app', + '//libs/my-lib:my-lib', + '//libs/other-lib:other-lib', + ].join('\n'); + + const paths = parseBazelQueryOutput(output, 'apps/my-app'); + expect(paths).to.deep.equal(['libs/my-lib', 'libs/other-lib']); + }); + + it('should deduplicate paths from multiple targets in the same package', () => { + const output = [ + '//libs/my-lib:my-lib', + '//libs/my-lib:test-lib', + '//libs/my-lib:utils', + '//libs/other-lib:other-lib', + ].join('\n'); + + const paths = parseBazelQueryOutput(output); + expect(paths).to.deep.equal(['libs/my-lib', 'libs/other-lib']); + }); + + it('should handle targets without explicit target name', () => { + const output = ['//libs/my-lib', '//libs/other-lib:target'].join('\n'); + + const paths = parseBazelQueryOutput(output); + expect(paths).to.deep.equal(['libs/my-lib', 'libs/other-lib']); + }); + + it('should handle empty output', () => { + const paths = parseBazelQueryOutput(''); + expect(paths).to.deep.equal([]); + }); + + it('should handle output with blank lines', () => { + const output = [ + '', + '//libs/my-lib:my-lib', + '', + '//libs/other-lib:other-lib', + '', + ].join('\n'); + + const paths = parseBazelQueryOutput(output); + expect(paths).to.deep.equal(['libs/my-lib', 'libs/other-lib']); + }); + + it('should skip root-level targets', () => { + const output = ['//:root-target', '//libs/my-lib:my-lib'].join('\n'); + + const paths = parseBazelQueryOutput(output); + expect(paths).to.deep.equal(['libs/my-lib']); + }); + + it('should handle deeply nested paths', () => { + const output = [ + '//services/backend/api/v2:server', + '//libs/shared/utils/common:helpers', + ].join('\n'); + + const paths = parseBazelQueryOutput(output); + expect(paths).to.deep.equal([ + 'libs/shared/utils/common', + 'services/backend/api/v2', + ]); + }); + + it('should handle trailing slashes in exclude path', () => { + const output = ['//apps/my-app:my-app', '//libs/my-lib:my-lib'].join('\n'); + + const paths = parseBazelQueryOutput(output, 'apps/my-app/'); + expect(paths).to.deep.equal(['libs/my-lib']); + }); +}); From 8cdd60982a15dfceea868c136886cbbfb7e46386 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 07:41:12 +0000 Subject: [PATCH 2/8] fix: allow bazel-deps-query boolean and run bazel safely Co-Authored-By: helen --- schemas/config.json | 11 ++- src/manifest.ts | 9 +-- src/util/bazel-query.ts | 70 ++++++++++++++++--- .../manifest/config/bazel-deps-query.json | 2 +- test/manifest.ts | 8 +-- test/util/bazel-query.ts | 25 ++++++- 6 files changed, 105 insertions(+), 20 deletions(-) diff --git a/schemas/config.json b/schemas/config.json index 98d3f6be7..c2a13ea26 100644 --- a/schemas/config.json +++ b/schemas/config.json @@ -238,8 +238,15 @@ } }, "bazel-deps-query": { - "description": "A bazel query command to run to dynamically resolve additional dependency paths. The output is parsed to extract local package paths which are merged with additional-paths. Example: \"bazel query 'deps(//my-service)'\"", - "type": "string" + "description": "Dynamically resolve additional dependency paths by running a bazel query. Set to true to auto-construct the default query for the package path. Alternatively, provide a query expression (e.g. \"deps(//combined-service)\") or a full \"bazel query ...\" string.", + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] }, "version-file": { "description": "Path to the specialize version file. Used by `ruby` and `simple` strategies.", diff --git a/src/manifest.ts b/src/manifest.ts index b1da8a469..11795ff10 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -47,7 +47,7 @@ import { } from './util/pull-request-overflow-handler'; import {signoffCommitMessage} from './util/signoff-commit-message'; import {CommitExclude} from './util/commit-exclude'; -import {runBazelQuery} from './util/bazel-query'; +import {runBazelQuery, resolveBazelQuery} from './util/bazel-query'; type ExtraGenericFile = { type: 'generic'; @@ -141,7 +141,7 @@ export interface ReleaserConfig { // Manifest only excludePaths?: string[]; additionalPaths?: string[]; - bazelDepsQuery?: string; + bazelDepsQuery?: boolean | string; } export interface CandidateReleasePullRequest { @@ -190,7 +190,7 @@ interface ReleaserConfigJson { 'initial-version'?: string; 'exclude-paths'?: string[]; // manifest-only 'additional-paths'?: string[]; // manifest-only - 'bazel-deps-query'?: string; // manifest-only + 'bazel-deps-query'?: boolean | string; // manifest-only 'date-format'?: string; } @@ -679,7 +679,8 @@ export class Manifest { const staticPaths = config.additionalPaths || []; let bazelPaths: string[] = []; if (config.bazelDepsQuery) { - bazelPaths = runBazelQuery(config.bazelDepsQuery, path, this.logger); + const queryExpression = resolveBazelQuery(config.bazelDepsQuery, path); + bazelPaths = runBazelQuery(queryExpression, path, this.logger); } // merge and deduplicate const allPaths = [...new Set([...staticPaths, ...bazelPaths])]; diff --git a/src/util/bazel-query.ts b/src/util/bazel-query.ts index 60903a10f..fa51f0be9 100644 --- a/src/util/bazel-query.ts +++ b/src/util/bazel-query.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {execSync} from 'child_process'; +import {execFileSync} from 'child_process'; import {Logger} from './logger'; /** @@ -78,22 +78,76 @@ export function parseBazelQueryOutput( } /** - * Execute a bazel query command and return the parsed local package paths. + * Build the default bazel query expression for a given package path. * - * @param query The bazel query string to execute (e.g., "bazel query 'deps(//combined-service)'") + * @param packagePath The package path (e.g., "apps/my-app") + * @returns The bazel query expression (passed to `bazel query`) + */ +export function buildBazelQueryExpression(packagePath: string): string { + return `deps(//${packagePath})`; +} + +/** + * Resolve the bazel query expression from the config value. + * + * - If the config value is `true`, build the default expression from the package path. + * - If the config value is a string: + * - If it starts with `bazel query`, treat it as a full command and extract the query expression + * - Otherwise, treat it as a query expression directly + * + * @param configValue The `bazel-deps-query` config value (boolean or string) + * @param packagePath The package path from the config key + * @returns The resolved bazel query expression + */ +export function resolveBazelQuery( + configValue: boolean | string, + packagePath: string +): string { + if (configValue === true) { + return buildBazelQueryExpression(packagePath); + } + + // A boolean `false` behaves like "disabled". + if (configValue === false) { + return ''; + } + + const trimmed = configValue.trim(); + const bazelQueryPrefix = /^bazel\s+query\s+/; + if (bazelQueryPrefix.test(trimmed)) { + let expr = trimmed.replace(bazelQueryPrefix, '').trim(); + // If the expression is quoted, strip surrounding quotes. + if ( + (expr.startsWith('"') && expr.endsWith('"')) || + (expr.startsWith("'") && expr.endsWith("'")) + ) { + expr = expr.slice(1, -1); + } + return expr; + } + + return trimmed; +} + +/** + * Execute a bazel query expression and return the parsed local package paths. + * + * This uses `execFileSync` (no shell) to reduce the risk of command injection. + * + * @param queryExpression The query expression to execute (e.g., "deps(//combined-service)") * @param excludePath Optional path to exclude from results * @param logger Optional logger instance * @returns Array of unique local package paths */ export function runBazelQuery( - query: string, + queryExpression: string, excludePath?: string, logger?: Logger ): string[] { - logger?.info(`Running bazel deps query: ${query}`); + logger?.info(`Running bazel deps query: bazel query '${queryExpression}'`); try { - const output = execSync(query, { + const output = execFileSync('bazel', ['query', queryExpression], { encoding: 'utf-8', timeout: 120000, // 2 minute timeout stdio: ['pipe', 'pipe', 'pipe'], @@ -109,13 +163,13 @@ export function runBazelQuery( } catch (err) { const error = err as Error & {stderr?: string}; logger?.error( - `Failed to run bazel deps query "${query}": ${error.message}` + `Failed to run bazel deps query "${queryExpression}": ${error.message}` ); if (error.stderr) { logger?.error(`stderr: ${error.stderr}`); } throw new Error( - `Failed to execute bazel-deps-query "${query}": ${error.message}` + `Failed to execute bazel-deps-query "${queryExpression}": ${error.message}` ); } } diff --git a/test/fixtures/manifest/config/bazel-deps-query.json b/test/fixtures/manifest/config/bazel-deps-query.json index dff5a72cc..3313a4a5a 100644 --- a/test/fixtures/manifest/config/bazel-deps-query.json +++ b/test/fixtures/manifest/config/bazel-deps-query.json @@ -2,7 +2,7 @@ "release-type": "simple", "packages": { "apps/my-app": { - "bazel-deps-query": "bazel query 'deps(//apps/my-app)'" + "bazel-deps-query": true } } } diff --git a/test/manifest.ts b/test/manifest.ts index 3fe010d9c..094ed41da 100644 --- a/test/manifest.ts +++ b/test/manifest.ts @@ -562,7 +562,7 @@ describe('Manifest', () => { github.repository.defaultBranch ); expect(manifest.repositoryConfig['apps/my-app'].bazelDepsQuery).to.equal( - "bazel query 'deps(//apps/my-app)'" + true ); }); it('should read additional paths from manifest', async () => { @@ -3838,7 +3838,7 @@ describe('Manifest', () => { 'apps/my-app': { releaseType: 'simple', component: 'myapp', - bazelDepsQuery: "bazel query 'deps(//apps/my-app)'", + bazelDepsQuery: true, }, }, { @@ -3856,7 +3856,7 @@ describe('Manifest', () => { sinon.assert.calledOnce(runBazelQueryStub); sinon.assert.calledWith( runBazelQueryStub, - "bazel query 'deps(//apps/my-app)'", + 'deps(//apps/my-app)', 'apps/my-app' ); }); @@ -3905,7 +3905,7 @@ describe('Manifest', () => { releaseType: 'simple', component: 'myapp', additionalPaths: ['libs/static-lib'], - bazelDepsQuery: "bazel query 'deps(//apps/my-app)'", + bazelDepsQuery: true, }, }, { diff --git a/test/util/bazel-query.ts b/test/util/bazel-query.ts index c8915be64..f287bb131 100644 --- a/test/util/bazel-query.ts +++ b/test/util/bazel-query.ts @@ -14,7 +14,10 @@ import {describe, it} from 'mocha'; import {expect} from 'chai'; -import {parseBazelQueryOutput} from '../../src/util/bazel-query'; +import { + parseBazelQueryOutput, + resolveBazelQuery, +} from '../../src/util/bazel-query'; describe('parseBazelQueryOutput', () => { it('should parse simple bazel query output', () => { @@ -120,3 +123,23 @@ describe('parseBazelQueryOutput', () => { expect(paths).to.deep.equal(['libs/my-lib']); }); }); + +describe('resolveBazelQuery', () => { + it('should build default query expression when enabled', () => { + const expr = resolveBazelQuery(true, 'apps/my-app'); + expect(expr).to.equal('deps(//apps/my-app)'); + }); + + it('should treat non-prefixed strings as query expressions', () => { + const expr = resolveBazelQuery('deps(//combined-service)', 'apps/my-app'); + expect(expr).to.equal('deps(//combined-service)'); + }); + + it('should extract expression from full bazel query command', () => { + const expr = resolveBazelQuery( + "bazel query 'deps(//apps/my-app)'", + 'apps/my-app' + ); + expect(expr).to.equal('deps(//apps/my-app)'); + }); +}); From 555a90120752f79e625853e35722a427840ca9ad Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 18:16:47 +0000 Subject: [PATCH 3/8] chore: add debug logging for bazel-deps-query paths Co-Authored-By: helen --- src/manifest.ts | 3 +++ src/util/bazel-query.ts | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/manifest.ts b/src/manifest.ts index 11795ff10..6a5b104eb 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -681,6 +681,9 @@ export class Manifest { if (config.bazelDepsQuery) { const queryExpression = resolveBazelQuery(config.bazelDepsQuery, path); bazelPaths = runBazelQuery(queryExpression, path, this.logger); + this.logger.debug( + `bazel-deps-query resolved paths for ${path}: ${JSON.stringify(bazelPaths)}` + ); } // merge and deduplicate const allPaths = [...new Set([...staticPaths, ...bazelPaths])]; diff --git a/src/util/bazel-query.ts b/src/util/bazel-query.ts index fa51f0be9..658e0e103 100644 --- a/src/util/bazel-query.ts +++ b/src/util/bazel-query.ts @@ -144,7 +144,7 @@ export function runBazelQuery( excludePath?: string, logger?: Logger ): string[] { - logger?.info(`Running bazel deps query: bazel query '${queryExpression}'`); + logger?.debug(`Running bazel deps query: bazel query '${queryExpression}'`); try { const output = execFileSync('bazel', ['query', queryExpression], { @@ -154,7 +154,7 @@ export function runBazelQuery( }); const paths = parseBazelQueryOutput(output, excludePath); - logger?.info( + logger?.debug( `Bazel deps query resolved ${ paths.length } additional paths: ${JSON.stringify(paths)}` From b64e18dfe38d70e0b97ec7c081ecae29580b3412 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 18:20:15 +0000 Subject: [PATCH 4/8] style: format bazel-deps-query debug log Co-Authored-By: helen --- src/manifest.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/manifest.ts b/src/manifest.ts index 6a5b104eb..59722da3b 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -682,7 +682,9 @@ export class Manifest { const queryExpression = resolveBazelQuery(config.bazelDepsQuery, path); bazelPaths = runBazelQuery(queryExpression, path, this.logger); this.logger.debug( - `bazel-deps-query resolved paths for ${path}: ${JSON.stringify(bazelPaths)}` + `bazel-deps-query resolved paths for ${path}: ${JSON.stringify( + bazelPaths + )}` ); } // merge and deduplicate From 54b3a818674d8babc9331d09e3dadebecf56a3df Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 18:30:07 +0000 Subject: [PATCH 5/8] chore: log bazel-deps-query paths at info level Co-Authored-By: helen --- src/manifest.ts | 2 +- src/util/bazel-query.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/manifest.ts b/src/manifest.ts index 59722da3b..0f5377631 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -681,7 +681,7 @@ export class Manifest { if (config.bazelDepsQuery) { const queryExpression = resolveBazelQuery(config.bazelDepsQuery, path); bazelPaths = runBazelQuery(queryExpression, path, this.logger); - this.logger.debug( + this.logger.info( `bazel-deps-query resolved paths for ${path}: ${JSON.stringify( bazelPaths )}` diff --git a/src/util/bazel-query.ts b/src/util/bazel-query.ts index 658e0e103..fa51f0be9 100644 --- a/src/util/bazel-query.ts +++ b/src/util/bazel-query.ts @@ -144,7 +144,7 @@ export function runBazelQuery( excludePath?: string, logger?: Logger ): string[] { - logger?.debug(`Running bazel deps query: bazel query '${queryExpression}'`); + logger?.info(`Running bazel deps query: bazel query '${queryExpression}'`); try { const output = execFileSync('bazel', ['query', queryExpression], { @@ -154,7 +154,7 @@ export function runBazelQuery( }); const paths = parseBazelQueryOutput(output, excludePath); - logger?.debug( + logger?.info( `Bazel deps query resolved ${ paths.length } additional paths: ${JSON.stringify(paths)}` From 52dabff5b17561476cd937965192db4732796f73 Mon Sep 17 00:00:00 2001 From: Helen Yu Date: Fri, 25 Sep 2026 12:08:52 -0400 Subject: [PATCH 6/8] fix: track Bazel root dependencies without querying root releases --- src/manifest.ts | 3 ++- src/util/bazel-query.ts | 9 ++++--- src/util/commit-split.ts | 4 ++- test/manifest.ts | 52 +++++++++++++++++++++++++++++++++++++++ test/util/bazel-query.ts | 14 ++++++++--- test/util/commit-split.ts | 21 ++++++++++++++++ 6 files changed, 95 insertions(+), 8 deletions(-) diff --git a/src/manifest.ts b/src/manifest.ts index 0f5377631..6b7a8ea1f 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -678,7 +678,8 @@ export class Manifest { for (const [path, config] of Object.entries(this.repositoryConfig)) { const staticPaths = config.additionalPaths || []; let bazelPaths: string[] = []; - if (config.bazelDepsQuery) { + // Root packages already receive all commits and need no dependency query. + if (config.bazelDepsQuery && path !== ROOT_PROJECT_PATH) { const queryExpression = resolveBazelQuery(config.bazelDepsQuery, path); bazelPaths = runBazelQuery(queryExpression, path, this.logger); this.logger.info( diff --git a/src/util/bazel-query.ts b/src/util/bazel-query.ts index fa51f0be9..b84af25ee 100644 --- a/src/util/bazel-query.ts +++ b/src/util/bazel-query.ts @@ -25,6 +25,7 @@ import {Logger} from './logger'; * This function: * 1. Filters out external dependencies (lines starting with `@`) * 2. Extracts the package path from local targets (`//path/to/package:target` -> `path/to/package`) + * Root-package targets retain their target name to track source files. * 3. Deduplicates paths * 4. Optionally excludes a given path (e.g., the package's own path) * @@ -48,14 +49,16 @@ export function parseBazelQueryOutput( } // Match local targets: //path/to/package:target or //path/to/package - const match = trimmed.match(/^\/\/([^:]*)/); + const match = trimmed.match(/^\/\/([^:]*)(?::(.+))?$/); if (!match) { continue; } - const packagePath = match[1]; + // Root-package source labels include the file path after the colon. + // Preserve it instead of dropping the dependency or tracking the whole repo. + const packagePath = match[1] || match[2]; - // Skip empty paths (e.g., //:target refers to the root) + // Skip labels without a package or target path. if (!packagePath) { continue; } diff --git a/src/util/commit-split.ts b/src/util/commit-split.ts index 8b24f9ea9..180164849 100644 --- a/src/util/commit-split.ts +++ b/src/util/commit-split.ts @@ -128,7 +128,9 @@ export class CommitSplit { Object.entries(this.packagePaths).forEach( ([pkgName, additionalPaths]) => { if ( - additionalPaths.some(path => file.indexOf(`${path}/`) === 0) + additionalPaths.some( + path => file === path || file.indexOf(`${path}/`) === 0 + ) ) { if (dedupe.has(pkgName)) return; dedupe.add(pkgName); diff --git a/test/manifest.ts b/test/manifest.ts index 094ed41da..f986eaf14 100644 --- a/test/manifest.ts +++ b/test/manifest.ts @@ -3795,6 +3795,58 @@ describe('Manifest', () => { }); }); + it('should release the root package without running a bazel query', async () => { + mockReleases(sandbox, github, []); + mockTags(sandbox, github, []); + mockCommits(sandbox, github, [ + {sha: 'abc123', message: 'fix: root config', files: ['config.json']}, + ]); + const bazelQueryModule = await import('../src/util/bazel-query'); + const query = sandbox + .stub(bazelQueryModule, 'runBazelQuery') + .throws(new Error('Root packages must not query Bazel')); + const manifest = new Manifest( + github, + 'main', + { + '.': {releaseType: 'simple', bazelDepsQuery: true}, + }, + {} + ); + const pullRequests = await manifest.buildPullRequests(); + expect(pullRequests).lengthOf(1); + sinon.assert.notCalled(query); + }); + + for (const file of ['config.json', 'shared/config.json']) { + it(`should release a package when its root-package dependency ${file} changes`, async () => { + mockReleases(sandbox, github, []); + mockTags(sandbox, github, []); + mockCommits(sandbox, github, [ + {sha: 'abc123', message: 'fix: shared config', files: [file]}, + ]); + const bazelQueryModule = await import('../src/util/bazel-query'); + sandbox + .stub(bazelQueryModule, 'runBazelQuery') + .returns( + bazelQueryModule.parseBazelQueryOutput( + `//apps/my-app:my-app\n//:${file}`, + 'apps/my-app' + ) + ); + const manifest = new Manifest( + github, + 'main', + { + 'apps/my-app': {releaseType: 'simple', bazelDepsQuery: true}, + }, + {} + ); + const pullRequests = await manifest.buildPullRequests(); + expect(pullRequests).lengthOf(1); + }); + } + it('should update manifest for commits resolved by bazel-deps-query', async () => { mockReleases(sandbox, github, []); mockTags(sandbox, github, [ diff --git a/test/util/bazel-query.ts b/test/util/bazel-query.ts index f287bb131..f53148cab 100644 --- a/test/util/bazel-query.ts +++ b/test/util/bazel-query.ts @@ -96,11 +96,19 @@ describe('parseBazelQueryOutput', () => { expect(paths).to.deep.equal(['libs/my-lib', 'libs/other-lib']); }); - it('should skip root-level targets', () => { - const output = ['//:root-target', '//libs/my-lib:my-lib'].join('\n'); + it('should preserve root-package dependency paths', () => { + const output = [ + '//:config.json', + '//:shared/config.json', + '//libs/my-lib:my-lib', + ].join('\n'); const paths = parseBazelQueryOutput(output); - expect(paths).to.deep.equal(['libs/my-lib']); + expect(paths).to.deep.equal([ + 'config.json', + 'libs/my-lib', + 'shared/config.json', + ]); }); it('should handle deeply nested paths', () => { diff --git a/test/util/commit-split.ts b/test/util/commit-split.ts index 70a5dc7b5..c76c1c5e0 100644 --- a/test/util/commit-split.ts +++ b/test/util/commit-split.ts @@ -76,6 +76,27 @@ describe('CommitSplit', () => { expect(splitCommits['core']).lengthOf(1); expect(splitCommits['core/subpackage']).lengthOf(1); }); + it('matches additional files exactly without matching filename prefixes', () => { + const dependencyCommit = { + sha: 'dependency', + message: 'fix: dependency', + files: ['config.json', 'shared/config.json'], + }; + const unrelatedCommit = { + sha: 'unrelated', + message: 'fix: unrelated', + files: [ + 'config.json.backup', + 'shared/config.json.backup', + 'shared/other.json', + ], + }; + const split = new CommitSplit({ + packagePaths: {app: ['config.json', 'shared/config.json']}, + }).split([dependencyCommit, unrelatedCommit]); + expect(split.app).to.deep.equal([dependencyCommit]); + }); + describe('including empty commits', () => { it('should separate commits', () => { const commitSplit = new CommitSplit({ From bfa911b030c03883fe48d6110181ce1b0ca878be Mon Sep 17 00:00:00 2001 From: Helen Yu Date: Fri, 25 Sep 2026 12:28:31 -0400 Subject: [PATCH 7/8] test: cover Bazel execution failures and config overrides --- .github/workflows/ci.yaml | 19 +++++ CONTRIBUTING.md | 12 +++ test/manifest.ts | 94 ++++++++++++++++++++++ test/util/bazel-query-integration.ts | 99 +++++++++++++++++++++++ test/util/bazel-query.ts | 115 ++++++++++++++++++++++++++- 5 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 test/util/bazel-query-integration.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dc40625f3..73fc38389 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,6 +27,25 @@ jobs: - run: npm test env: MOCHA_THROW_DEPRECATION: false + bazel-integration: + runs-on: ubuntu-latest + timeout-minutes: 8 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Install Bazel 7.6.1 + run: | + mkdir -p "$RUNNER_TEMP/bazel-bin" + curl --fail --location --retry 3 https://releases.bazel.build/7.6.1/release/bazel-7.6.1-linux-x86_64 -o "$RUNNER_TEMP/bazel-bin/bazel" + chmod +x "$RUNNER_TEMP/bazel-bin/bazel" + echo "$RUNNER_TEMP/bazel-bin" >> "$GITHUB_PATH" + - run: npm install + - run: npx mocha build/test/util/bazel-query-integration.js + env: + BAZEL_INTEGRATION_TEST: '1' + MOCHA_THROW_DEPRECATION: false windows: runs-on: windows-latest steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d9e4be0fe..b21887c38 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,6 +55,18 @@ accept your pull requests. npm run fix +### Bazel integration tests + +The unit tests do not require Bazel. To run the workspace integration tests, +install Bazel 7.6.1 (or Bazelisk), then run: + +```bash +npm run compile +BAZEL_INTEGRATION_TEST=1 npx mocha build/test/util/bazel-query-integration.js +``` + +CI runs these tests in the `bazel-integration` job with Bazel 7.6.1. + ## Testing a new feature using CLI 1. After you've written some new code, in order to test it out, you can use the [CLI][CLI]. diff --git a/test/manifest.ts b/test/manifest.ts index f986eaf14..4e8730b53 100644 --- a/test/manifest.ts +++ b/test/manifest.ts @@ -565,6 +565,71 @@ describe('Manifest', () => { true ); }); + for (const defaultQuery of [true, 'deps(//libs/default)']) { + it(`inherits bazel-deps-query ${defaultQuery} and respects package overrides`, async () => { + const contents = sandbox.stub(github, 'getFileContentsOnBranch'); + contents.withArgs('release-please-config.json', 'main').resolves( + buildGitHubFileRaw( + JSON.stringify({ + 'release-type': 'simple', + 'bazel-deps-query': defaultQuery, + packages: { + '.': {}, + 'apps/inherited': {}, + 'apps/disabled': {'bazel-deps-query': false}, + 'apps/custom': {'bazel-deps-query': 'deps(//libs/custom)'}, + }, + }) + ) + ); + contents + .withArgs('.release-please-manifest.json', 'main') + .resolves(buildGitHubFileRaw('{}')); + const manifest = await Manifest.fromManifest(github, 'main'); + expect(manifest.repositoryConfig['.'].bazelDepsQuery).to.equal( + defaultQuery + ); + expect( + manifest.repositoryConfig['apps/inherited'].bazelDepsQuery + ).to.equal(defaultQuery); + expect( + manifest.repositoryConfig['apps/disabled'].bazelDepsQuery + ).to.equal(false); + expect( + manifest.repositoryConfig['apps/custom'].bazelDepsQuery + ).to.equal('deps(//libs/custom)'); + + mockReleases(sandbox, github, []); + mockTags(sandbox, github, []); + mockCommits(sandbox, github, [ + { + sha: 'abc123', + message: 'fix: shared dependency', + files: ['libs/shared/source.txt'], + }, + ]); + const bazelQueryModule = await import('../src/util/bazel-query'); + const query = sandbox + .stub(bazelQueryModule, 'runBazelQuery') + .returns(['libs/shared']); + const pullRequests = await manifest.buildPullRequests(); + expect(pullRequests).lengthOf(1); + const updatedPaths = pullRequests[0].updates.map(update => update.path); + expect(updatedPaths).to.include('apps/inherited/CHANGELOG.md'); + expect(updatedPaths).to.include('apps/custom/CHANGELOG.md'); + expect(updatedPaths).not.to.include('apps/disabled/CHANGELOG.md'); + expect( + query.getCalls().map(call => call.args.slice(0, 2)) + ).to.deep.equal([ + [ + defaultQuery === true ? 'deps(//apps/inherited)' : defaultQuery, + 'apps/inherited', + ], + ['deps(//libs/custom)', 'apps/custom'], + ]); + }); + } + it('should read additional paths from manifest', async () => { const getFileContentsStub = sandbox.stub( github, @@ -3795,6 +3860,35 @@ describe('Manifest', () => { }); }); + it('aborts release generation when dependency resolution fails', async () => { + mockReleases(sandbox, github, []); + mockTags(sandbox, github, []); + mockCommits(sandbox, github, [ + { + sha: 'abc123', + message: 'fix: app', + files: ['apps/app/source.txt'], + }, + ]); + const bazelQueryModule = await import('../src/util/bazel-query'); + const failure = new Error( + 'Failed to execute bazel-deps-query: query failed' + ); + sandbox.stub(bazelQueryModule, 'runBazelQuery').throws(failure); + const manifest = new Manifest( + github, + 'main', + { + 'apps/app': {releaseType: 'simple', bazelDepsQuery: true}, + }, + {} + ); + await assert.rejects( + manifest.buildPullRequests(), + error => error === failure + ); + }); + it('should release the root package without running a bazel query', async () => { mockReleases(sandbox, github, []); mockTags(sandbox, github, []); diff --git a/test/util/bazel-query-integration.ts b/test/util/bazel-query-integration.ts new file mode 100644 index 000000000..4e89519ec --- /dev/null +++ b/test/util/bazel-query-integration.ts @@ -0,0 +1,99 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {describe, it, before, after} from 'mocha'; +import {expect} from 'chai'; +import {mkdtempSync, mkdirSync, writeFileSync, rmSync} from 'fs'; +import {tmpdir} from 'os'; +import {join} from 'path'; +import {resolveBazelQuery, runBazelQuery} from '../../src/util/bazel-query'; +import {CommitSplit} from '../../src/util/commit-split'; + +// Run explicitly in CI with a pinned Bazel version; unit tests need no Bazel install. +const integration = + process.env.BAZEL_INTEGRATION_TEST === '1' ? describe : describe.skip; +integration('Bazel workspace integration', function () { + this.timeout(180000); + let workspace: string; + let originalDirectory: string; + + before(() => { + originalDirectory = process.cwd(); + workspace = mkdtempSync(join(tmpdir(), 'release-please-bazel-workspace-')); + mkdirSync(join(workspace, 'apps/app'), {recursive: true}); + mkdirSync(join(workspace, 'libs/shared'), {recursive: true}); + mkdirSync(join(workspace, 'shared')); + writeFileSync(join(workspace, '.bazelversion'), '7.6.1\n'); + writeFileSync( + join(workspace, '.bazelrc'), + [ + 'startup --batch', + `startup --output_user_root=${join(workspace, 'cache')}`, + 'common --enable_bzlmod=false', + 'query --noshow_progress', + ].join('\n') + ); + writeFileSync(join(workspace, 'WORKSPACE'), ''); + writeFileSync( + join(workspace, 'BUILD.bazel'), + 'exports_files(["config.json", "shared/config.json"])\n' + ); + writeFileSync(join(workspace, 'config.json'), '{}'); + writeFileSync(join(workspace, 'shared/config.json'), '{}'); + writeFileSync(join(workspace, 'libs/shared/source.txt'), 'shared source'); + writeFileSync( + join(workspace, 'libs/shared/BUILD.bazel'), + 'filegroup(name="shared", srcs=["source.txt"], visibility=["//visibility:public"])\n' + ); + writeFileSync( + join(workspace, 'apps/app/BUILD.bazel'), + 'filegroup(name="app", srcs=["//libs/shared", "//:config.json", "//:shared/config.json"])\n' + ); + process.chdir(workspace); + }); + + after(() => { + process.chdir(originalDirectory); + rmSync(workspace, {recursive: true, force: true}); + }); + + it('resolves real labels and selects only commits affecting dependencies', () => { + const paths = runBazelQuery( + resolveBazelQuery(true, 'apps/app'), + 'apps/app' + ); + expect(paths).to.deep.equal([ + 'config.json', + 'libs/shared', + 'shared/config.json', + ]); + const commits = [ + 'config.json', + 'shared/config.json', + 'libs/shared/source.txt', + 'unrelated.txt', + 'shared/unrelated.json', + ].map(file => ({sha: file, message: 'fix: dependency', files: [file]})); + const split = new CommitSplit({packagePaths: {'apps/app': paths}}).split( + commits + ); + expect(split['apps/app']).to.deep.equal(commits.slice(0, 3)); + }); + + it('propagates a real Bazel query error', () => { + expect(() => runBazelQuery('deps(//apps/app:missing)')).to.throw( + 'Failed to execute bazel-deps-query "deps(//apps/app:missing)"' + ); + }); +}); diff --git a/test/util/bazel-query.ts b/test/util/bazel-query.ts index f53148cab..8e2cd133f 100644 --- a/test/util/bazel-query.ts +++ b/test/util/bazel-query.ts @@ -12,11 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {describe, it} from 'mocha'; +import {describe, it, afterEach} from 'mocha'; +import * as childProcess from 'child_process'; +import * as sinon from 'sinon'; import {expect} from 'chai'; import { parseBazelQueryOutput, resolveBazelQuery, + runBazelQuery, } from '../../src/util/bazel-query'; describe('parseBazelQueryOutput', () => { @@ -78,6 +81,12 @@ describe('parseBazelQueryOutput', () => { expect(paths).to.deep.equal(['libs/my-lib', 'libs/other-lib']); }); + it('ignores malformed output and normalizes trailing package slashes', () => { + expect( + parseBazelQueryOutput('not a label\n//\n///\n//libs/shared/:target') + ).to.deep.equal(['libs/shared']); + }); + it('should handle empty output', () => { const paths = parseBazelQueryOutput(''); expect(paths).to.deep.equal([]); @@ -133,6 +142,22 @@ describe('parseBazelQueryOutput', () => { }); describe('resolveBazelQuery', () => { + it('disables queries when configured false', () => { + expect(resolveBazelQuery(false, 'apps/app')).to.equal(''); + }); + + for (const command of [ + ' bazel query "deps(//apps/app)" ', + 'bazel query deps(//apps/app)', + ' deps(//apps/app) ', + ]) { + it(`resolves ${command}`, () => { + expect(resolveBazelQuery(command, 'apps/app')).to.equal( + 'deps(//apps/app)' + ); + }); + } + it('should build default query expression when enabled', () => { const expr = resolveBazelQuery(true, 'apps/my-app'); expect(expr).to.equal('deps(//apps/my-app)'); @@ -151,3 +176,91 @@ describe('resolveBazelQuery', () => { expect(expr).to.equal('deps(//apps/my-app)'); }); }); + +describe('runBazelQuery execution', () => { + const sandbox = sinon.createSandbox(); + afterEach(() => sandbox.restore()); + + it('executes without a shell and returns only relevant dependency paths', () => { + const execute = sandbox + .stub(childProcess, 'execFileSync') + .returns( + '//apps/app:app\n//libs/shared:shared\n//libs/shared:source\n@external//:dep\n//:config.json\n' + ); + const logger = { + info: sandbox.stub(), + error: sandbox.stub(), + warn: sandbox.stub(), + debug: sandbox.stub(), + trace: sandbox.stub(), + }; + expect(runBazelQuery('deps(//apps/app)', 'apps/app', logger)).to.deep.equal( + ['config.json', 'libs/shared'] + ); + sinon.assert.calledOnceWithMatch( + execute, + 'bazel', + sinon.match( + (args: string[]) => + args.length === 2 && + args[0] === 'query' && + args[1].includes('deps(//apps/app)') + ), + { + encoding: 'utf-8', + timeout: 120000, + stdio: ['pipe', 'pipe', 'pipe'], + } + ); + expect(execute.firstCall.args[2]).not.to.have.property('shell', true); + sinon.assert.calledWithMatch(logger.info, 'additional paths'); + }); + + it('returns no paths for an empty query result without a logger', () => { + sandbox.stub(childProcess, 'execFileSync').returns(''); + expect(runBazelQuery('deps(//apps/app)')).to.deep.equal([]); + }); + + for (const failure of [ + {name: 'missing Bazel', message: 'spawnSync bazel ENOENT', code: 'ENOENT'}, + {name: 'timeout', message: 'spawnSync bazel ETIMEDOUT', code: 'ETIMEDOUT'}, + { + name: 'nonzero exit', + message: 'Command failed: bazel query', + stderr: 'ERROR: no such target', + }, + ]) { + it(`propagates ${failure.name} with query context and logs diagnostics`, () => { + const error = Object.assign(new Error(failure.message), failure); + sandbox.stub(childProcess, 'execFileSync').throws(error); + const logger = { + info: sandbox.stub(), + error: sandbox.stub(), + warn: sandbox.stub(), + debug: sandbox.stub(), + trace: sandbox.stub(), + }; + expect(() => + runBazelQuery('deps(//apps/app)', undefined, logger) + ).to.throw( + `Failed to execute bazel-deps-query "deps(//apps/app)": ${failure.message}` + ); + sinon.assert.calledWithMatch(logger.error, failure.message); + if (failure.stderr) { + sinon.assert.calledWithExactly( + logger.error, + `stderr: ${failure.stderr}` + ); + } else { + sinon.assert.calledOnce(logger.error); + } + }); + } + + it('propagates errors without requiring a logger', () => { + sandbox + .stub(childProcess, 'execFileSync') + .throws(new Error('query failed')); + expect(() => runBazelQuery('deps(//apps/app)')).to.throw('query failed'); + }); +}); From 545a83ffbec43f153fffec9c85f9d142097f25a1 Mon Sep 17 00:00:00 2001 From: Helen Yu Date: Fri, 25 Sep 2026 12:36:05 -0400 Subject: [PATCH 8/8] ci: pin workflow actions to immutable commit SHAs --- .github/workflows/ci.yaml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cf84f38a9..f54cd31c6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,8 +16,8 @@ jobs: matrix: node: [20, 22] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: ${{ matrix.node }} - run: node --version @@ -31,8 +31,8 @@ jobs: matrix: node: [20] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: ${{ matrix.node }} - run: node --version @@ -44,8 +44,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 8 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 20 - name: Install Bazel 7.6.1 @@ -62,8 +62,8 @@ jobs: windows: runs-on: windows-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 20 - run: npm ci @@ -73,8 +73,8 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 20 - run: npm ci @@ -82,23 +82,23 @@ jobs: docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 20 - run: npm ci - run: npm run docs - - uses: JustinBeckwith/linkinator-action@v1 + - uses: JustinBeckwith/linkinator-action@3d5ba091319fa7b0ac14703761eebb7d100e6f6d # v1 with: paths: docs/ build-action: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: repository: googleapis/release-please-action ref: main - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 20 - run: npm install --save nominal-io/release-please#${{ github.event.pull_request.head.sha || github.sha }}