diff --git a/lib/modules/datasource/api.ts b/lib/modules/datasource/api.ts index a9839f4f7a11de..73fcae31cda19c 100644 --- a/lib/modules/datasource/api.ts +++ b/lib/modules/datasource/api.ts @@ -6,6 +6,7 @@ import { AzurePipelinesTasksDatasource } from './azure-pipelines-tasks'; import { BazelDatasource } from './bazel'; import { BitbucketTagsDatasource } from './bitbucket-tags'; import { BitriseDatasource } from './bitrise'; +import { BuildpacksRegistryDatasource } from './buildpacks-registry'; import { CdnjsDatasource } from './cdnjs'; import { ClojureDatasource } from './clojure'; import { ConanDatasource } from './conan'; @@ -76,6 +77,7 @@ api.set(AzurePipelinesTasksDatasource.id, new AzurePipelinesTasksDatasource()); api.set(BazelDatasource.id, new BazelDatasource()); api.set(BitbucketTagsDatasource.id, new BitbucketTagsDatasource()); api.set(BitriseDatasource.id, new BitriseDatasource()); +api.set(BuildpacksRegistryDatasource.id, new BuildpacksRegistryDatasource()); api.set(CdnjsDatasource.id, new CdnjsDatasource()); api.set(ClojureDatasource.id, new ClojureDatasource()); api.set(ConanDatasource.id, new ConanDatasource()); diff --git a/lib/modules/datasource/buildpacks-registry/index.spec.ts b/lib/modules/datasource/buildpacks-registry/index.spec.ts new file mode 100644 index 00000000000000..1d91f2f4cde4fc --- /dev/null +++ b/lib/modules/datasource/buildpacks-registry/index.spec.ts @@ -0,0 +1,66 @@ +import { getPkgReleases } from '..'; +import * as httpMock from '../../../../test/http-mock'; +import { BuildpacksRegistryDatasource } from '.'; + +const baseUrl = 'https://registry.buildpacks.io/api/v1/buildpacks/'; + +describe('modules/datasource/buildpacks-registry/index', () => { + describe('getReleases', () => { + it('processes real data', async () => { + httpMock + .scope(baseUrl) + .get('/heroku/python') + .reply(200, { + latest: { + version: '0.17.1', + namespace: 'heroku', + name: 'python', + description: "Heroku's buildpack for Python applications.", + homepage: 'https://github.com/heroku/buildpacks-python', + licenses: ['BSD-3-Clause'], + stacks: ['*'], + id: '75946bf8-3f6a-4af0-a757-614bebfdfcd6', + }, + versions: [ + { + version: '0.17.1', + _link: + 'https://registry.buildpacks.io//api/v1/buildpacks/heroku/python/0.17.1', + }, + { + version: '0.17.0', + _link: + 'https://registry.buildpacks.io//api/v1/buildpacks/heroku/python/0.17.0', + }, + ], + }); + const res = await getPkgReleases({ + datasource: BuildpacksRegistryDatasource.id, + packageName: 'heroku/python', + }); + expect(res).toEqual({ + registryUrl: 'https://registry.buildpacks.io/', + releases: [{ version: '0.17.0' }, { version: '0.17.1' }], + sourceUrl: 'https://github.com/heroku/buildpacks-python', + }); + }); + + it('returns null on empty result', async () => { + httpMock.scope(baseUrl).get('/heroku/empty').reply(200, {}); + const res = await getPkgReleases({ + datasource: BuildpacksRegistryDatasource.id, + packageName: 'heroku/empty', + }); + expect(res).toBeNull(); + }); + + it('handles not found', async () => { + httpMock.scope(baseUrl).get('/heroku/notexisting').reply(404); + const res = await getPkgReleases({ + datasource: BuildpacksRegistryDatasource.id, + packageName: 'heroku/notexisting', + }); + expect(res).toBeNull(); + }); + }); +}); diff --git a/lib/modules/datasource/buildpacks-registry/index.ts b/lib/modules/datasource/buildpacks-registry/index.ts new file mode 100644 index 00000000000000..5858c4678e2b82 --- /dev/null +++ b/lib/modules/datasource/buildpacks-registry/index.ts @@ -0,0 +1,72 @@ +import urlJoin from 'url-join'; +import { ZodError } from 'zod'; +import { logger } from '../../../logger'; +import { cache } from '../../../util/cache/package/decorator'; +import { Result } from '../../../util/result'; +import { Datasource } from '../datasource'; +import { ReleasesConfig } from '../schema'; +import type { GetReleasesConfig, Release, ReleaseResult } from '../types'; +import { BuildpacksRegistryResponseSchema } from './schema'; + +export class BuildpacksRegistryDatasource extends Datasource { + static readonly id = 'buildpacks-registry'; + + constructor() { + super(BuildpacksRegistryDatasource.id); + } + + override readonly customRegistrySupport = false; + + override readonly defaultRegistryUrls = ['https://registry.buildpacks.io/']; + + override readonly releaseTimestampSupport = true; + override readonly releaseTimestampNote = + 'The release timestamp is determined from the `published_at` field in the results.'; + override readonly sourceUrlSupport = 'release'; + override readonly sourceUrlNote = + 'The source URL is determined from the `source_code_url` field of the release object in the results.'; + + @cache({ + namespace: `datasource-${BuildpacksRegistryDatasource.id}`, + key: ({ registryUrl, packageName }: GetReleasesConfig) => + `${registryUrl}:${packageName}`, + }) + async getReleases(config: GetReleasesConfig): Promise { + const result = Result.parse(config, ReleasesConfig) + .transform(({ packageName, registryUrl }) => { + const url = urlJoin( + registryUrl, + 'api', + 'v1', + 'buildpacks', + packageName, + ); + + return this.http.getJsonSafe(url, BuildpacksRegistryResponseSchema); + }) + .transform(({ versions, latest }): ReleaseResult => { + const releases: Release[] = versions; + + const res: ReleaseResult = { releases }; + + if (latest?.homepage) { + res.homepage = latest.homepage; + } + + return res; + }); + + const { val, err } = await result.unwrap(); + + if (err instanceof ZodError) { + logger.debug({ err }, 'buildpacks: validation error'); + return null; + } + + if (err) { + this.handleGenericErrors(err); + } + + return val; + } +} diff --git a/lib/modules/datasource/buildpacks-registry/schema.spec.ts b/lib/modules/datasource/buildpacks-registry/schema.spec.ts new file mode 100644 index 00000000000000..a46bd3998fe8fe --- /dev/null +++ b/lib/modules/datasource/buildpacks-registry/schema.spec.ts @@ -0,0 +1,43 @@ +import { BuildpacksRegistryResponseSchema } from './schema'; + +describe('modules/datasource/buildpacks-registry/schema', () => { + it('parses buildpack-registry schema', () => { + const response = { + latest: { + version: '0.17.1', + namespace: 'heroku', + name: 'python', + description: "Heroku's buildpack for Python applications.", + homepage: 'https://github.com/heroku/buildpacks-python', + licenses: ['BSD-3-Clause'], + stacks: ['*'], + id: '75946bf8-3f6a-4af0-a757-614bebfdfcd6', + }, + versions: [ + { + version: '0.2.0', + _link: + 'https://registry.buildpacks.io//api/v1/buildpacks/heroku/python/0.2.0', + }, + { + version: '0.1.0', + _link: + 'https://registry.buildpacks.io//api/v1/buildpacks/heroku/python/0.1.0', + }, + ], + }; + expect(BuildpacksRegistryResponseSchema.parse(response)).toMatchObject({ + latest: { + homepage: 'https://github.com/heroku/buildpacks-python', + }, + versions: [ + { + version: '0.2.0', + }, + { + version: '0.1.0', + }, + ], + }); + }); +}); diff --git a/lib/modules/datasource/buildpacks-registry/schema.ts b/lib/modules/datasource/buildpacks-registry/schema.ts new file mode 100644 index 00000000000000..6bc544196380ea --- /dev/null +++ b/lib/modules/datasource/buildpacks-registry/schema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +/** + * Response from registry.buildpacks.io + */ +export const BuildpacksRegistryResponseSchema = z.object({ + latest: z + .object({ + homepage: z.string().optional(), + }) + .optional(), + versions: z + .object({ + version: z.string(), + }) + .array(), +}); diff --git a/lib/modules/manager/buildpacks/extract.spec.ts b/lib/modules/manager/buildpacks/extract.spec.ts index dedcefcbd4f68a..0da53e92354b07 100644 --- a/lib/modules/manager/buildpacks/extract.spec.ts +++ b/lib/modules/manager/buildpacks/extract.spec.ts @@ -23,6 +23,7 @@ describe('modules/manager/buildpacks/extract', () => { [_] schema-version = "0.2" +# valid cases [io.buildpacks] builder = "registry.corp/builder/noble:1.1.1" @@ -36,16 +37,22 @@ uri = "buildpacks/nodejs:3.3.3" uri = "example/foo@1.0.0" [[io.buildpacks.group]] -uri = "example/registry-cnb" +uri = "urn:cnb:registry:example/bar@1.2.3" [[io.buildpacks.group]] -uri = "urn:cnb:registry:example/foo@1.0.0" +uri = "cnbs/some-bp@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" [[io.buildpacks.group]] -uri = "some-bp@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +uri = "cnbs/some-bp:some-tag@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" [[io.buildpacks.group]] -uri = "cnbs/some-bp:some-tag@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +id = "example/tee" +version = "2.3.4" + +#invalid cases + +[[io.buildpacks.group]] +uri = "example/registry-cnb" [[io.buildpacks.group]] uri = "from=builder:foobar" @@ -54,7 +61,10 @@ uri = "from=builder:foobar" uri = "file://local.oci" [[io.buildpacks.group]] -uri = "foo://fake.oci"`, +uri = "foo://fake.oci" + +[[io.buildpacks.group]] +id = "not/valid"`, 'project.toml', {}, ); @@ -84,15 +94,27 @@ uri = "foo://fake.oci"`, depName: 'buildpacks/nodejs', replaceString: 'buildpacks/nodejs:3.3.3', }, + { + datasource: 'buildpacks-registry', + currentValue: '1.0.0', + packageName: 'example/foo', + replaceString: 'example/foo@1.0.0', + }, + { + datasource: 'buildpacks-registry', + currentValue: '1.2.3', + packageName: 'example/bar', + replaceString: 'example/bar@1.2.3', + }, { autoReplaceStringTemplate: '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}', currentDigest: 'sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', datasource: 'docker', - depName: 'some-bp', + depName: 'cnbs/some-bp', replaceString: - 'some-bp@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'cnbs/some-bp@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', }, { autoReplaceStringTemplate: @@ -105,6 +127,12 @@ uri = "foo://fake.oci"`, replaceString: 'cnbs/some-bp:some-tag@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', }, + { + datasource: 'buildpacks-registry', + currentValue: '2.3.4', + packageName: 'example/tee', + replaceString: '2.3.4', + }, ]); }); }); diff --git a/lib/modules/manager/buildpacks/extract.ts b/lib/modules/manager/buildpacks/extract.ts index c00d4a4c681e19..bd820cd01eeb5b 100644 --- a/lib/modules/manager/buildpacks/extract.ts +++ b/lib/modules/manager/buildpacks/extract.ts @@ -1,13 +1,20 @@ import is from '@sindresorhus/is'; import { logger } from '../../../logger'; import { regEx } from '../../../util/regex'; +import { BuildpacksRegistryDatasource } from '../../datasource/buildpacks-registry'; +import { isVersion } from '../../versioning/semver'; import { getDep } from '../dockerfile/extract'; import type { ExtractConfig, PackageDependency, PackageFileContent, } from '../types'; -import { type ProjectDescriptor, ProjectDescriptorToml } from './schema'; +import { + type ProjectDescriptor, + ProjectDescriptorToml, + isBuildpackByName, + isBuildpackByURI, +} from './schema'; const dockerPrefix = regEx(/^docker:\/?\//); const dockerRef = regEx( @@ -20,6 +27,24 @@ function isDockerRef(ref: string): boolean { } return false; } +const buildpackRegistryPrefix = 'urn:cnb:registry:'; +const buildpackRegistryId = regEx( + /^[a-z0-9\-.]+\/[a-z0-9\-.]+(?:@(?.+))?$/, +); + +function isBuildpackRegistryId(ref: string): boolean { + const bpRegistryMatch = buildpackRegistryId.exec(ref); + if (!bpRegistryMatch) { + return false; + } else if (!bpRegistryMatch.groups?.version) { + return true; + } + return isVersion(bpRegistryMatch.groups.version); +} + +function isBuildpackRegistryRef(ref: string): boolean { + return isBuildpackRegistryId(ref) || ref.startsWith(buildpackRegistryPrefix); +} function parseProjectToml( content: string, @@ -76,7 +101,7 @@ export function extractPackageFile( is.array(descriptor.io.buildpacks.group) ) { for (const group of descriptor.io.buildpacks.group) { - if (group.uri && isDockerRef(group.uri)) { + if (isBuildpackByURI(group) && isDockerRef(group.uri)) { const dep = getDep( group.uri.replace(dockerPrefix, ''), true, @@ -92,6 +117,31 @@ export function extractPackageFile( ); deps.push(dep); + } else if (isBuildpackByURI(group) && isBuildpackRegistryRef(group.uri)) { + const dependency = group.uri.replace(buildpackRegistryPrefix, ''); + + if (dependency.includes('@')) { + const version = dependency.split('@')[1]; + const dep: PackageDependency = { + datasource: BuildpacksRegistryDatasource.id, + currentValue: version, + packageName: dependency.split('@')[0], + replaceString: `${dependency}`, + }; + deps.push(dep); + } + } else if (isBuildpackByName(group)) { + const version = group.version; + + if (version) { + const dep: PackageDependency = { + datasource: BuildpacksRegistryDatasource.id, + currentValue: version, + packageName: group.id, + replaceString: `${version}`, + }; + deps.push(dep); + } } } } diff --git a/lib/modules/manager/buildpacks/index.ts b/lib/modules/manager/buildpacks/index.ts index 53a3dcfeb4c221..869bcd7252b86c 100644 --- a/lib/modules/manager/buildpacks/index.ts +++ b/lib/modules/manager/buildpacks/index.ts @@ -1,4 +1,5 @@ import type { Category } from '../../../constants'; +import { BuildpacksRegistryDatasource } from '../../datasource/buildpacks-registry'; import { DockerDatasource } from '../../datasource/docker'; export { extractPackageFile } from './extract'; @@ -8,5 +9,8 @@ export const defaultConfig = { pinDigests: false, }; -export const categories: Category[] = ['docker']; -export const supportedDatasources = [DockerDatasource.id]; +export const categories: Category[] = ['docker', 'ci', 'cd']; +export const supportedDatasources = [ + DockerDatasource.id, + BuildpacksRegistryDatasource.id, +]; diff --git a/lib/modules/manager/buildpacks/readme.md b/lib/modules/manager/buildpacks/readme.md index d2f7b6c613968a..b25340bffc2431 100644 --- a/lib/modules/manager/buildpacks/readme.md +++ b/lib/modules/manager/buildpacks/readme.md @@ -6,7 +6,7 @@ Renovate can update a `project.toml` file if: - The file follows the [project descriptor specifications](https://github.com/buildpacks/spec/blob/main/extensions/project-descriptor.md) - The buildpack `uri` is an OCI image reference (references to a local file or buildpack registry are ignored) -If you use buildpacks in the `io.buildpacks.group` array, then you _must_ configure the Docker reference (`uri`) for Renovate to work. +**Note**: If you use buildpacks in the `io.buildpacks.group` array, then you _must_ configure the Docker reference (`uri`) for Renovate to work. ```toml title="Example of a project.toml file with Docker reference URIs" [_] diff --git a/lib/modules/manager/buildpacks/schema.ts b/lib/modules/manager/buildpacks/schema.ts index dc90371ca4ce93..15d5b54fa77c4c 100644 --- a/lib/modules/manager/buildpacks/schema.ts +++ b/lib/modules/manager/buildpacks/schema.ts @@ -1,10 +1,33 @@ import { z } from 'zod'; import { Toml } from '../../../util/schema-utils'; -const BuildpackGroup = z.object({ - uri: z.string().optional(), +const BuildpackByName = z.object({ + id: z.string(), + version: z.string().optional(), }); +const BuildpackByURI = z.object({ + uri: z.string(), +}); + +const BuildpackGroup = BuildpackByName.or(BuildpackByURI); + +type BuildpackByName = z.infer; +type BuildpackByURI = z.infer; +type BuildpackGroup = z.infer; + +export function isBuildpackByName( + group: BuildpackGroup, +): group is BuildpackByName { + return 'id' in group; +} + +export function isBuildpackByURI( + group: BuildpackGroup, +): group is BuildpackByURI { + return 'uri' in group; +} + const IoBuildpacks = z.object({ builder: z.string().optional(), group: z.array(BuildpackGroup).optional(), diff --git a/lib/util/cache/package/types.ts b/lib/util/cache/package/types.ts index 7435f8bc787301..afc974de9f4919 100644 --- a/lib/util/cache/package/types.ts +++ b/lib/util/cache/package/types.ts @@ -34,6 +34,7 @@ export type PackageCacheNamespace = | 'datasource-bazel' | 'datasource-bitbucket-tags' | 'datasource-bitrise' + | 'datasource-buildpacks-registry' | 'datasource-cdnjs' | 'datasource-conan' | 'datasource-conda'