diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d725bd3df..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 @@ -40,11 +40,30 @@ jobs: # dependencies work on the given Node.js version, this helps us find # dependencies that don't match our engines field: - run: npm install --omit=dev --engine-strict --ignore-scripts --no-package-lock + bazel-integration: + runs-on: ubuntu-latest + timeout-minutes: 8 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # 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 ci + - 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: - - 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 @@ -54,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 @@ -63,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 }} 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/schemas/config.json b/schemas/config.json index 05225410b..7a0165fa3 100644 --- a/schemas/config.json +++ b/schemas/config.json @@ -245,6 +245,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" @@ -509,6 +520,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 439a2dfc4..ecab45a08 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -48,6 +48,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'; @@ -144,6 +145,7 @@ export interface ReleaserConfig { // Manifest only excludePaths?: string[]; additionalPaths?: string[]; + bazelDepsQuery?: boolean | string; } export interface CandidateReleasePullRequest { @@ -208,6 +210,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; } @@ -697,14 +700,34 @@ 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[] = []; + // 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( + `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] || [], ]) ), }); @@ -1611,6 +1634,7 @@ function extractReleaserConfig( initialVersion: config['initial-version'], excludePaths: config['exclude-paths'], additionalPaths: config['additional-paths'], + bazelDepsQuery: config['bazel-deps-query'], dateFormat: config['date-format'], }; } @@ -1979,6 +2003,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..b84af25ee --- /dev/null +++ b/src/util/bazel-query.ts @@ -0,0 +1,178 @@ +// 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`) + * 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) + * + * @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; + } + + // 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 labels without a package or target path. + 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/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/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 62cb497d7..9728719c4 100644 --- a/test/manifest.ts +++ b/test/manifest.ts @@ -537,6 +537,99 @@ 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 + ); + }); + 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, @@ -3778,6 +3871,211 @@ 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, []); + 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, [ + { + 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-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 new file mode 100644 index 000000000..8e2cd133f --- /dev/null +++ b/test/util/bazel-query.ts @@ -0,0 +1,266 @@ +// 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, 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', () => { + 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('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([]); + }); + + 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 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([ + 'config.json', + 'libs/my-lib', + 'shared/config.json', + ]); + }); + + 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('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)'); + }); + + 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)'); + }); +}); + +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'); + }); +}); 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({