diff --git a/schemas/config.json b/schemas/config.json index 38694cb90..c2a13ea26 100644 --- a/schemas/config.json +++ b/schemas/config.json @@ -237,6 +237,17 @@ "type": "string" } }, + "bazel-deps-query": { + "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.", "type": "string" @@ -494,6 +505,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..0f5377631 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, resolveBazelQuery} from './util/bazel-query'; type ExtraGenericFile = { type: 'generic'; @@ -140,6 +141,7 @@ export interface ReleaserConfig { // Manifest only excludePaths?: string[]; additionalPaths?: string[]; + bazelDepsQuery?: boolean | 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'?: boolean | string; // manifest-only 'date-format'?: string; } @@ -670,14 +673,33 @@ 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) { + const queryExpression = resolveBazelQuery(config.bazelDepsQuery, path); + bazelPaths = runBazelQuery(queryExpression, path, this.logger); + this.logger.info( + `bazel-deps-query resolved paths for ${path}: ${JSON.stringify( + bazelPaths + )}` + ); + } + // 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 +1436,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 +1799,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..fa51f0be9 --- /dev/null +++ b/src/util/bazel-query.ts @@ -0,0 +1,175 @@ +// 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 {execFileSync} 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(); +} + +/** + * Build the default bazel query expression for a given package path. + * + * @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( + queryExpression: string, + excludePath?: string, + logger?: Logger +): string[] { + logger?.info(`Running bazel deps query: bazel query '${queryExpression}'`); + + try { + const output = execFileSync('bazel', ['query', queryExpression], { + 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 "${queryExpression}": ${error.message}` + ); + if (error.stderr) { + logger?.error(`stderr: ${error.stderr}`); + } + throw new Error( + `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 new file mode 100644 index 000000000..3313a4a5a --- /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": true + } + } +} diff --git a/test/manifest.ts b/test/manifest.ts index 44bca6834..094ed41da 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( + true + ); + }); 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: true, + }, + }, + { + '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, + '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: true, + }, + }, + { + '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..f287bb131 --- /dev/null +++ b/test/util/bazel-query.ts @@ -0,0 +1,145 @@ +// 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, + resolveBazelQuery, +} 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']); + }); +}); + +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)'); + }); +});