Repository: renovatebot/renovate
Pull request #35273: feat(bazel): Support local file based bcr
Author: login: Rayahhhmed
Created: 2025-04-08T23:53:07Z | Merged: 2025-04-20T06:41:28Z

<!-- If this is your first pull request: sign the CLA with this GitHub app: https://cla-assistant.io/renovatebot/renovate -->
<!-- Make sure the `Allow edits and access to secrets by maintainers` checkbox is checked on this pull request. -->
<!-- Please read https://github.com/renovatebot/renovate/blob/main/.github/contributing.md before you create your pull request.-->

## Changes
Added a check in Bazel `index.ts` to check if it is a file based URL, if so read the file path, else it makes a http request using the BCR url.  

## Context
In addition to the previous [PR](https://github.com/renovatebot/renovate/pull/35253). This PR adds the ability to allow Renovate to read an in-house file based Bazel repository. 
This is linked in this #35252 issue. 


## Documentation (please check one with an [x])

- [ ] I have updated the documentation, or
- [x] No documentation update is required

## How I've tested my work (please select one)

I have verified these changes via:

- [ ] Code inspection only, or
- [ ] Newly added/modified unit tests, or
- [ ] No unit tests but ran on a real repository, or
- [x] Both unit tests + ran on a real repository

<!-- Do you have any suggestions about this PR template? Edit it here: https://github.com/renovatebot/renovate/edit/main/.github/pull_request_template.md -->

<!-- Please do not force push to your PR's branch after you have created your PR, as doing so forces us to review the whole PR again. This makes it harder for us to review your work because we don't know what has changed. -->
<!-- PRs will always be squashed by us when we merge your work. Commit as many times as you need in this branch. -->



Diff:
diff --git a/lib/modules/datasource/bazel/index.spec.ts b/lib/modules/datasource/bazel/index.spec.ts
index 546475c106e..f1507ffbf3b 100644
--- a/lib/modules/datasource/bazel/index.spec.ts
+++ b/lib/modules/datasource/bazel/index.spec.ts
@@ -1,14 +1,26 @@
 import { getPkgReleases } from '..';
+import { GlobalConfig } from '../../../config/global';
 import { EXTERNAL_HOST_ERROR } from '../../../constants/error-messages';
 import { BazelDatasource } from '.';
 import { Fixtures } from '~test/fixtures';
 import * as httpMock from '~test/http-mock';
+import { fs } from '~test/util';
+
+vi.mock('../../../util/fs');
 
 const datasource = BazelDatasource.id;
 const defaultRegistryUrl = BazelDatasource.bazelCentralRepoUrl;
 const packageName = 'rules_foo';
 const path = BazelDatasource.packageMetadataPath(packageName);
 
+const mockMetadata = {
+  versions: ['0.14.8', '0.14.9', '0.15.0', '0.16.0'],
+  yanked_versions: {
+    '0.15.0': 'yanked for security reasons',
+  },
+  homepage: 'https://github.com/foo/bar',
+};
+
 describe('modules/datasource/bazel/index', () => {
   describe('getReleases', () => {
     it('throws for error', async () => {
@@ -80,4 +92,75 @@ describe('modules/datasource/bazel/index', () => {
       });
     });
   });
+
+  describe('local file handling', () => {
+    const mockFilePath = `/tmp/mock-registry/modules/${packageName}/metadata.json`;
+    const localRegistryUrl = `file:///tmp/mock-registry`;
+
+    beforeEach(() => {
+      GlobalConfig.set({
+        localDir: '/tmp/mock-registry',
+      });
+    });
+
+    it('should handle local file correctly', async () => {
+      vi.mocked(fs.readLocalFile).mockImplementation((file: string) => {
+        if (file === mockFilePath) {
+          return Promise.resolve(JSON.stringify(mockMetadata));
+        }
+        return Promise.resolve(null);
+      });
+
+      vi.mocked(fs.isValidLocalPath).mockImplementation((file: string) => {
+        return file === mockFilePath;
+      });
+
+      const res = await getPkgReleases({
+        datasource,
+        packageName,
+        registryUrls: [localRegistryUrl],
+      });
+      expect(res).toEqual({
+        registryUrl: localRegistryUrl,
+        releases: [
+          { version: '0.14.8' },
+          { version: '0.14.9' },
+          { version: '0.15.0', isDeprecated: true },
+          { version: '0.16.0' },
+        ],
+        sourceUrl: 'https://github.com/foo/bar',
+      });
+    });
+
+    it('should return null for invalid file path', async () => {
+      vi.mocked(fs.isValidLocalPath).mockImplementation(() => false);
+
+      const res = await getPkgReleases({
+        datasource,
+        packageName,
+        registryUrls: [localRegistryUrl],
+      });
+      expect(res).toBeNull();
+    });
+
+    it('should return null for empty file content', async () => {
+      vi.mocked(fs.readLocalFile).mockImplementation((file: string) => {
+        if (file === mockFilePath) {
+          return Promise.resolve(null);
+        }
+        return Promise.resolve(null);
+      });
+
+      vi.mocked(fs.isValidLocalPath).mockImplementation((file: string) => {
+        return file === mockFilePath;
+      });
+
+      const res = await getPkgReleases({
+        datasource,
+        packageName,
+        registryUrls: [localRegistryUrl],
+      });
+      expect(res).toBeNull();
+    });
+  });
 });
diff --git a/lib/modules/datasource/bazel/index.ts b/lib/modules/datasource/bazel/index.ts
index 1f4b8bc8636..5ccd023b6a8 100644
--- a/lib/modules/datasource/bazel/index.ts
+++ b/lib/modules/datasource/bazel/index.ts
@@ -1,6 +1,7 @@
 import is from '@sindresorhus/is';
 import { ExternalHostError } from '../../../types/errors/external-host-error';
 import { cache } from '../../../util/cache/package/decorator';
+import { isValidLocalPath, readLocalFile } from '../../../util/fs';
 import { HttpError } from '../../../util/http';
 import { joinUrlParts } from '../../../util/url';
 import { id as bazelVersioningId } from '../../versioning/bazel-module';
@@ -40,13 +41,25 @@ export class BazelDatasource extends Datasource {
   }: GetReleasesConfig): Promise<ReleaseResult | null> {
     const path = BazelDatasource.packageMetadataPath(packageName);
     const url = joinUrlParts(registryUrl!, path);
-
     const result: ReleaseResult = { releases: [] };
     try {
-      const { body: metadata } = await this.http.getJson(
-        url,
-        BazelModuleMetadata,
-      );
+      let metadata: BazelModuleMetadata;
+      const FILE_PREFIX = 'file://';
+      if (url.startsWith(FILE_PREFIX)) {
+        const filePath = url.slice(FILE_PREFIX.length);
+        if (!isValidLocalPath(filePath)) {
+          return null;
+        }
+        const fileContent = await readLocalFile(filePath, 'utf8');
+        if (!fileContent) {
+          return null;
+        }
+        metadata = BazelModuleMetadata.parse(JSON.parse(fileContent));
+      } else {
+        const response = await this.http.getJson(url, BazelModuleMetadata);
+        metadata = response.body;
+      }
+
       result.releases = metadata.versions
         .map((v) => new BzlmodVersion(v))
         .sort(BzlmodVersion.defaultCompare)
diff --git a/lib/modules/datasource/bazel/readme.md b/lib/modules/datasource/bazel/readme.md
index e4282e3d45c..b17c1f4d380 100644
--- a/lib/modules/datasource/bazel/readme.md
+++ b/lib/modules/datasource/bazel/readme.md
@@ -1 +1 @@
-The `bazel` datasource is designed to query one or more [Bazel registries](https://bazel.build/external/registry) using the first successful result.
+The `bazel` datasource is designed to query one or more [Bazel registries](https://bazel.build/external/registry) or file based BCR using the first successful result.
diff --git a/lib/modules/datasource/bazel/schema.ts b/lib/modules/datasource/bazel/schema.ts
index df2b602b761..d02b02b0e15 100644
--- a/lib/modules/datasource/bazel/schema.ts
+++ b/lib/modules/datasource/bazel/schema.ts
@@ -5,3 +5,5 @@ export const BazelModuleMetadata = z.object({
   versions: z.array(z.string()),
   yanked_versions: z.record(z.string(), z.string()).optional(),
 });
+
+export type BazelModuleMetadata = z.infer<typeof BazelModuleMetadata>;


Reviews:
user: login: rarkins
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-19T12:49:34Z
user: login: viceice
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T13:11:56Z
user: login: viceice
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T13:13:38Z
user: login: secustor
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-20T06:36:29Z

Review comments:
diff_hunk: @@ -40,17 +41,26 @@ export class BazelDatasource extends Datasource {
   }: GetReleasesConfig): Promise<ReleaseResult | null> {
     const path = BazelDatasource.packageMetadataPath(packageName);
     const url = joinUrlParts(registryUrl!, path);
-
     const result: ReleaseResult = { releases: [] };
     try {
-      const { body: metadata } = await this.http.getJson(
-        url,
-        BazelModuleMetadata,
-      );
+      let metadata;
path: lib/modules/datasource/bazel/index.ts
user: login: viceice
body: add type 
created_at: 2025-04-19T13:09:40Z
author_association: MEMBER
diff_hunk: @@ -40,17 +41,26 @@ export class BazelDatasource extends Datasource {
   }: GetReleasesConfig): Promise<ReleaseResult | null> {
     const path = BazelDatasource.packageMetadataPath(packageName);
     const url = joinUrlParts(registryUrl!, path);
-
     const result: ReleaseResult = { releases: [] };
     try {
-      const { body: metadata } = await this.http.getJson(
-        url,
-        BazelModuleMetadata,
-      );
+      let metadata;
+      const FILE_PREFIX = 'file://';
+      if (url.startsWith(FILE_PREFIX)) {
+        const filePath = url.slice(FILE_PREFIX.length);
+        const fileContent = await readLocalFile(filePath, 'utf8');
+        if (!fileContent) {
+          return null;
+        }
+        metadata = JSON.parse(fileContent);
path: lib/modules/datasource/bazel/index.ts
user: login: viceice
body: use zod schema 
created_at: 2025-04-19T13:10:02Z
author_association: MEMBER
diff_hunk: @@ -40,17 +41,26 @@ export class BazelDatasource extends Datasource {
   }: GetReleasesConfig): Promise<ReleaseResult | null> {
     const path = BazelDatasource.packageMetadataPath(packageName);
     const url = joinUrlParts(registryUrl!, path);
-
     const result: ReleaseResult = { releases: [] };
     try {
-      const { body: metadata } = await this.http.getJson(
-        url,
-        BazelModuleMetadata,
-      );
+      let metadata;
+      const FILE_PREFIX = 'file://';
+      if (url.startsWith(FILE_PREFIX)) {
+        const filePath = url.slice(FILE_PREFIX.length);
+        const fileContent = await readLocalFile(filePath, 'utf8');
path: lib/modules/datasource/bazel/index.ts
user: login: viceice
body: this will throw if path is outside of local repo.
created_at: 2025-04-19T13:10:32Z
author_association: MEMBER
diff_hunk: @@ -40,17 +41,26 @@ export class BazelDatasource extends Datasource {
   }: GetReleasesConfig): Promise<ReleaseResult | null> {
     const path = BazelDatasource.packageMetadataPath(packageName);
     const url = joinUrlParts(registryUrl!, path);
-
     const result: ReleaseResult = { releases: [] };
     try {
-      const { body: metadata } = await this.http.getJson(
-        url,
-        BazelModuleMetadata,
-      );
+      let metadata;
+      const FILE_PREFIX = 'file://';
+      if (url.startsWith(FILE_PREFIX)) {
+        const filePath = url.slice(FILE_PREFIX.length);
+        const fileContent = await readLocalFile(filePath, 'utf8');
+        if (!fileContent) {
+          return null;
+        }
+        metadata = JSON.parse(fileContent);
+      } else {
+        const response = await this.http.getJson(url, BazelModuleMetadata);
+        metadata = response.body;
+      }
+
       result.releases = metadata.versions
-        .map((v) => new BzlmodVersion(v))
+        .map((v: string) => new BzlmodVersion(v))
path: lib/modules/datasource/bazel/index.ts
user: login: viceice
body: revert 
created_at: 2025-04-19T13:10:43Z
author_association: MEMBER
diff_hunk: @@ -40,17 +41,26 @@ export class BazelDatasource extends Datasource {
   }: GetReleasesConfig): Promise<ReleaseResult | null> {
     const path = BazelDatasource.packageMetadataPath(packageName);
     const url = joinUrlParts(registryUrl!, path);
-
     const result: ReleaseResult = { releases: [] };
     try {
-      const { body: metadata } = await this.http.getJson(
-        url,
-        BazelModuleMetadata,
-      );
+      let metadata;
+      const FILE_PREFIX = 'file://';
+      if (url.startsWith(FILE_PREFIX)) {
+        const filePath = url.slice(FILE_PREFIX.length);
+        const fileContent = await readLocalFile(filePath, 'utf8');
+        if (!fileContent) {
+          return null;
+        }
+        metadata = JSON.parse(fileContent);
+      } else {
+        const response = await this.http.getJson(url, BazelModuleMetadata);
+        metadata = response.body;
+      }
+
       result.releases = metadata.versions
-        .map((v) => new BzlmodVersion(v))
+        .map((v: string) => new BzlmodVersion(v))
         .sort(BzlmodVersion.defaultCompare)
-        .map((bv) => {
+        .map((bv: BzlmodVersion) => {
path: lib/modules/datasource/bazel/index.ts
user: login: viceice
body: revert 
created_at: 2025-04-19T13:10:51Z
author_association: MEMBER
diff_hunk: @@ -80,4 +81,27 @@ describe('modules/datasource/bazel/index', () => {
       });
     });
   });
+
+  describe('local file handling', () => {
+    it('should handle local file correctly', async () => {
+      GlobalConfig.set({
+        localDir: Fixtures.getPath('registry/'),
+      });
path: lib/modules/datasource/bazel/index.spec.ts
user: login: viceice
body: can you please use an fs mock instead and inline the fixture content?
created_at: 2025-04-19T13:13:35Z
author_association: MEMBER

PR comments:
user: login: Rayahhhmed
created_at: 2025-04-19T12:39:22Z
body: @rarkins added a new test for this, any how i can speed up this PR?
author_association: CONTRIBUTOR
user: login: Rayahhhmed
created_at: 2025-04-19T23:05:33Z
body: @viceice tysm for the quick 👀. I have fixed the changes you have proposed, please lemme know if I could do anything more 👍🏾 
author_association: CONTRIBUTOR
user: login: renovate-release
created_at: 2025-04-20T06:58:04Z
body: :tada: This PR is included in version 39.252.0 :tada:

The release is available on:
- [GitHub release](https://github.com/renovatebot/renovate/releases/tag/39.252.0)
- [npm package (@latest dist-tag)](https://www.npmjs.com/package/renovate/v/39.252.0)
- `39.252.0`

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: NONE