diff --git a/lib/modules/manager/gitlabci/common.spec.ts b/lib/modules/manager/gitlabci/common.spec.ts index 5000b3ccb84587..9c491ef848f968 100644 --- a/lib/modules/manager/gitlabci/common.spec.ts +++ b/lib/modules/manager/gitlabci/common.spec.ts @@ -4,6 +4,7 @@ import type { GitlabPipeline } from '../gitlabci/types'; import { replaceReferenceTags } from '../gitlabci/utils'; import { filterIncludeFromGitlabPipeline, + isGitlabIncludeComponent, isGitlabIncludeLocal, isGitlabIncludeProject, isNonEmptyObject, @@ -61,6 +62,16 @@ describe('modules/manager/gitlabci/common', () => { }); }); + describe('isGitlabIncludeComponent()', () => { + it('returns true if GitlabInclude is GitlabIncludeComponent', () => { + expect(isGitlabIncludeComponent({ component: 'something' })).toBe(true); + }); + + it('returns false if GitlabInclude is not GitlabIncludeComponent', () => { + expect(isGitlabIncludeComponent(includeLocal)).toBe(false); + }); + }); + describe('isNonEmptyObject()', () => { it('returns true if not empty', () => { expect(isNonEmptyObject({ attribute1: 1 })).toBe(true); diff --git a/lib/modules/manager/gitlabci/common.ts b/lib/modules/manager/gitlabci/common.ts index 7aefb628e62d00..38452d1b50a954 100644 --- a/lib/modules/manager/gitlabci/common.ts +++ b/lib/modules/manager/gitlabci/common.ts @@ -1,6 +1,7 @@ import is from '@sindresorhus/is'; import type { GitlabInclude, + GitlabIncludeComponent, GitlabIncludeLocal, GitlabIncludeProject, GitlabPipeline, @@ -32,3 +33,9 @@ export function isGitlabIncludeLocal( ): include is GitlabIncludeLocal { return !is.undefined((include as GitlabIncludeLocal).local); } + +export function isGitlabIncludeComponent( + include: GitlabInclude, +): include is GitlabIncludeComponent { + return !is.undefined((include as GitlabIncludeComponent).component); +} diff --git a/lib/modules/manager/gitlabci/extract.spec.ts b/lib/modules/manager/gitlabci/extract.spec.ts index db03a11829205e..820bef53ca564b 100644 --- a/lib/modules/manager/gitlabci/extract.spec.ts +++ b/lib/modules/manager/gitlabci/extract.spec.ts @@ -1,3 +1,4 @@ +import { codeBlock } from 'common-tags'; import { logger } from '../../../../test/util'; import { GlobalConfig } from '../../../config/global'; import type { RepoGlobalConfig } from '../../../config/types'; @@ -347,5 +348,63 @@ describe('modules/manager/gitlabci/extract', () => { expect(extractFromJob(undefined)).toBeEmptyArray(); expect(extractFromJob({ image: 'image:test' })).toEqual(expectedRes); }); + + it('extracts component references', () => { + const content = codeBlock` + include: + - component: gitlab.example.com/an-org/a-project/a-component@1.0 + inputs: + stage: build + - component: gitlab.example.com/an-org/a-subgroup/a-project/a-component@e3262fdd0914fa823210cdb79a8c421e2cef79d8 + - component: gitlab.example.com/an-org/a-subgroup/another-project/a-component@main + - component: gitlab.example.com/another-org/a-project/a-component@~latest + inputs: + stage: test + - component: gitlab.example.com/malformed-component-reference + - component: + malformed: true + - component: gitlab.example.com/an-org/a-component@1.0 + - component: other-gitlab.example.com/an-org/a-project/a-component@1.0 + `; + const res = extractPackageFile(content, '', {}); + expect(res?.deps).toMatchObject([ + { + currentValue: '1.0', + datasource: 'gitlab-tags', + depName: 'an-org/a-project', + depType: 'repository', + registryUrls: ['https://gitlab.example.com'], + }, + { + currentValue: 'e3262fdd0914fa823210cdb79a8c421e2cef79d8', + datasource: 'gitlab-tags', + depName: 'an-org/a-subgroup/a-project', + depType: 'repository', + registryUrls: ['https://gitlab.example.com'], + }, + { + currentValue: 'main', + datasource: 'gitlab-tags', + depName: 'an-org/a-subgroup/another-project', + depType: 'repository', + registryUrls: ['https://gitlab.example.com'], + }, + { + currentValue: '~latest', + datasource: 'gitlab-tags', + depName: 'another-org/a-project', + depType: 'repository', + registryUrls: ['https://gitlab.example.com'], + skipReason: 'unsupported-version', + }, + { + currentValue: '1.0', + datasource: 'gitlab-tags', + depName: 'an-org/a-project', + depType: 'repository', + registryUrls: ['https://other-gitlab.example.com'], + }, + ]); + }); }); }); diff --git a/lib/modules/manager/gitlabci/extract.ts b/lib/modules/manager/gitlabci/extract.ts index 8a7fdae5375602..7df1894c615d29 100644 --- a/lib/modules/manager/gitlabci/extract.ts +++ b/lib/modules/manager/gitlabci/extract.ts @@ -1,18 +1,38 @@ import is from '@sindresorhus/is'; import { logger } from '../../../logger'; import { readLocalFile } from '../../../util/fs'; +import { regEx } from '../../../util/regex'; import { trimLeadingSlash } from '../../../util/url'; import { parseSingleYaml } from '../../../util/yaml'; +import { GitlabTagsDatasource } from '../../datasource/gitlab-tags'; import type { ExtractConfig, PackageDependency, PackageFile, PackageFileContent, } from '../types'; -import { isGitlabIncludeLocal } from './common'; -import type { GitlabPipeline, Image, Job, Services } from './types'; +import { + filterIncludeFromGitlabPipeline, + isGitlabIncludeComponent, + isGitlabIncludeLocal, + isNonEmptyObject, +} from './common'; +import type { + GitlabInclude, + GitlabIncludeComponent, + GitlabPipeline, + Image, + Job, + Services, +} from './types'; import { getGitlabDep, replaceReferenceTags } from './utils'; +// See https://docs.gitlab.com/ee/ci/components/index.html#use-a-component +const componentReferenceRegex = regEx( + /(?[^/]+)\/(?.+)\/(?:.+)@(?.+)/, +); +const componentReferenceLatestVersion = '~latest'; + export function extractFromImage( image: Image | undefined, registryAliases?: Record, @@ -76,6 +96,67 @@ export function extractFromJob( return deps; } +function getIncludeComponentsFromInclude( + includeValue: GitlabInclude[] | GitlabInclude, +): GitlabIncludeComponent[] { + const includes = is.array(includeValue) ? includeValue : [includeValue]; + return includes.filter(isGitlabIncludeComponent); +} + +function getAllIncludeComponents( + data: GitlabPipeline, +): GitlabIncludeComponent[] { + const childrenData = Object.values(filterIncludeFromGitlabPipeline(data)) + .filter(isNonEmptyObject) + .map(getAllIncludeComponents) + .flat(); + + // Process include key. + if (data.include) { + childrenData.push(...getIncludeComponentsFromInclude(data.include)); + } + return childrenData; +} + +function extractDepFromIncludeComponent( + includeComponent: GitlabIncludeComponent, +): PackageDependency | null { + const componentReference = componentReferenceRegex.exec( + includeComponent.component, + )?.groups; + if (!componentReference) { + logger.debug( + { componentReference: includeComponent.component }, + 'Ignoring malformed component reference', + ); + return null; + } + const projectPathParts = componentReference.projectPath.split('/'); + if (projectPathParts.length < 2) { + logger.debug( + { componentReference: includeComponent.component }, + 'Ignoring component reference with incomplete project path', + ); + return null; + } + + const dep: PackageDependency = { + datasource: GitlabTagsDatasource.id, + depName: componentReference.projectPath, + depType: 'repository', + currentValue: componentReference.specificVersion, + registryUrls: [`https://${componentReference.fqdn}`], + }; + if (dep.currentValue === componentReferenceLatestVersion) { + logger.debug( + { componentVersion: dep.currentValue }, + 'Ignoring component version', + ); + dep.skipReason = 'unsupported-version'; + } + return dep; +} + export function extractPackageFile( content: string, packageFile: string, @@ -84,7 +165,7 @@ export function extractPackageFile( let deps: PackageDependency[] = []; try { // TODO: use schema (#9610) - const doc = parseSingleYaml(replaceReferenceTags(content), { + const doc = parseSingleYaml(replaceReferenceTags(content), { json: true, }); if (is.object(doc)) { @@ -115,11 +196,26 @@ export function extractPackageFile( } deps = deps.filter(is.truthy); } + + const includedComponents = getAllIncludeComponents(doc); + for (const includedComponent of includedComponents) { + const dep = extractDepFromIncludeComponent(includedComponent); + if (dep) { + deps.push(dep); + } + } } catch (err) /* istanbul ignore next */ { - logger.debug( - { err, packageFile }, - 'Error extracting GitLab CI dependencies', - ); + if (err.stack?.startsWith('YAMLException:')) { + logger.debug( + { err, packageFile }, + 'YAML exception extracting GitLab CI includes', + ); + } else { + logger.debug( + { err, packageFile }, + 'Error extracting GitLab CI dependencies', + ); + } } return deps.length ? { deps } : null; diff --git a/lib/modules/manager/gitlabci/index.ts b/lib/modules/manager/gitlabci/index.ts index 71946b1b4aaa99..be554bd2b73ca6 100644 --- a/lib/modules/manager/gitlabci/index.ts +++ b/lib/modules/manager/gitlabci/index.ts @@ -1,5 +1,6 @@ import type { Category } from '../../../constants'; import { DockerDatasource } from '../../datasource/docker'; +import { GitlabTagsDatasource } from '../../datasource/gitlab-tags'; import { extractAllPackageFiles, extractPackageFile } from './extract'; export { extractAllPackageFiles, extractPackageFile }; @@ -10,4 +11,7 @@ export const defaultConfig = { export const categories: Category[] = ['ci']; -export const supportedDatasources = [DockerDatasource.id]; +export const supportedDatasources = [ + DockerDatasource.id, + GitlabTagsDatasource.id, +]; diff --git a/lib/modules/manager/gitlabci/types.ts b/lib/modules/manager/gitlabci/types.ts index 9f8d7726b2f472..3728b393bfcd36 100644 --- a/lib/modules/manager/gitlabci/types.ts +++ b/lib/modules/manager/gitlabci/types.ts @@ -16,6 +16,10 @@ export interface GitlabIncludeTemplate { template: string; } +export interface GitlabIncludeComponent { + component: string; +} + export interface GitlabPipeline { include?: GitlabInclude[] | GitlabInclude; } @@ -39,4 +43,5 @@ export type GitlabInclude = | GitlabIncludeProject | GitlabIncludeRemote | GitlabIncludeTemplate + | GitlabIncludeComponent | string;