diff --git a/lib/modules/manager/api.ts b/lib/modules/manager/api.ts index 726479f4d079ce..7c84644cdf2a4f 100644 --- a/lib/modules/manager/api.ts +++ b/lib/modules/manager/api.ts @@ -10,6 +10,7 @@ import * as bazelModule from './bazel-module'; import * as bazelisk from './bazelisk'; import * as bicep from './bicep'; import * as bitbucketPipelines from './bitbucket-pipelines'; +import * as bitrise from './bitrise'; import * as buildkite from './buildkite'; import * as bun from './bun'; import * as bundler from './bundler'; @@ -108,6 +109,7 @@ api.set('bazel-module', bazelModule); api.set('bazelisk', bazelisk); api.set('bicep', bicep); api.set('bitbucket-pipelines', bitbucketPipelines); +api.set('bitrise', bitrise); api.set('buildkite', buildkite); api.set('bun', bun); api.set('bundler', bundler); diff --git a/lib/modules/manager/bitrise/extract.spec.ts b/lib/modules/manager/bitrise/extract.spec.ts new file mode 100644 index 00000000000000..a40c8f2997cb0d --- /dev/null +++ b/lib/modules/manager/bitrise/extract.spec.ts @@ -0,0 +1,138 @@ +import { codeBlock } from 'common-tags'; +import { BitriseDatasource } from '../../datasource/bitrise'; +import { extractPackageFile } from '.'; + +describe('modules/manager/bitrise/extract', () => { + describe('extractPackageFile()', () => { + it('returns null on an empty file', () => { + expect(extractPackageFile('', 'bitrise.yml')).toBeNull(); + }); + + it('returns a valid file', () => { + expect( + extractPackageFile( + codeBlock` + workflows: + test: + steps: + - script@1.1.5: + `, + 'bitrise.yml', + ), + ).toEqual({ + deps: [ + { + datasource: BitriseDatasource.id, + packageName: 'script', + currentValue: '1.1.5', + replaceString: 'script@1.1.5', + }, + ], + }); + }); + + it('returns a valid file with custom default_step_lib_source', () => { + expect( + extractPackageFile( + codeBlock` + format_version: 11 + default_step_lib_source: https://github.com/custom/steplib.git + project_type: android + app: + envs: + - MY_NAME: My Name + workflows: + test: + steps: + - script@1.1.5: + inputs: + - content: echo "Hello!" + - restore-cache@1.1.2: + foo: bar + `, + 'bitrise.yml', + ), + ).toEqual({ + deps: [ + { + datasource: BitriseDatasource.id, + packageName: 'script', + currentValue: '1.1.5', + replaceString: 'script@1.1.5', + registryUrls: ['https://github.com/custom/steplib.git'], + }, + { + datasource: BitriseDatasource.id, + packageName: 'restore-cache', + currentValue: '1.1.2', + replaceString: 'restore-cache@1.1.2', + registryUrls: ['https://github.com/custom/steplib.git'], + }, + ], + }); + }); + + it('extracts git and path prefixes', () => { + expect( + extractPackageFile( + codeBlock` + workflows: + test: + steps: + - git::https://github.com/bitrise-io/steps-script.git@1.1.3: + - path::./relative/path: + - https://github.com/foo/bar.git::script@1: + `, + 'bitrise.yml', + ), + ).toEqual({ + deps: [ + { + currentValue: '1.1.3', + datasource: 'git-tags', + packageName: 'https://github.com/bitrise-io/steps-script.git', + replaceString: + 'git::https://github.com/bitrise-io/steps-script.git@1.1.3', + }, + { + datasource: 'bitrise', + packageName: 'path::./relative/path', + replaceString: 'path::./relative/path', + skipReason: 'unspecified-version', + }, + { + currentValue: '1', + datasource: 'bitrise', + packageName: 'script', + registryUrls: ['https://github.com/foo/bar.git'], + replaceString: 'https://github.com/foo/bar.git::script@1', + }, + ], + }); + }); + + it('extracts Bitrise library reference', () => { + expect( + extractPackageFile( + codeBlock` + workflows: + test: + steps: + - https://github.com/foo/bar.git::script@1: + `, + 'bitrise.yml', + ), + ).toEqual({ + deps: [ + { + currentValue: '1', + datasource: 'bitrise', + packageName: 'script', + registryUrls: ['https://github.com/foo/bar.git'], + replaceString: 'https://github.com/foo/bar.git::script@1', + }, + ], + }); + }); + }); +}); diff --git a/lib/modules/manager/bitrise/extract.ts b/lib/modules/manager/bitrise/extract.ts new file mode 100644 index 00000000000000..8bc593e922c037 --- /dev/null +++ b/lib/modules/manager/bitrise/extract.ts @@ -0,0 +1,38 @@ +import is from '@sindresorhus/is'; +import { logger } from '../../../logger'; +import { parseSingleYaml } from '../../../util/yaml'; +import type { PackageDependency, PackageFileContent } from '../types'; +import { BitriseFile } from './schema'; +import { parseStep } from './utils'; + +export function extractPackageFile( + content: string, + packageFile: string, +): PackageFileContent | null { + const deps: PackageDependency[] = []; + + try { + const parsed = parseSingleYaml(content, { + customSchema: BitriseFile, + }); + + const workflows = Object.values(parsed.workflows); + for (const workflow of workflows) { + const steps = workflow.steps.flatMap((step) => Object.keys(step)); + for (const step of steps) { + const dep = parseStep(step, parsed.default_step_lib_source); + + if (!is.nullOrUndefined(dep)) { + deps.push(dep); + } + } + } + } catch (err) { + logger.debug({ err, packageFile }, `Failed to parse Bitrise YAML config`); + } + + if (!deps.length) { + return null; + } + return { deps }; +} diff --git a/lib/modules/manager/bitrise/index.ts b/lib/modules/manager/bitrise/index.ts new file mode 100644 index 00000000000000..70a97588c3b3d6 --- /dev/null +++ b/lib/modules/manager/bitrise/index.ts @@ -0,0 +1,23 @@ +import type { Category } from '../../../constants'; +import { BitriseDatasource } from '../../datasource/bitrise'; +import { GitTagsDatasource } from '../../datasource/git-tags'; +import { extractPackageFile } from './extract'; + +export { extractPackageFile }; + +export const defaultConfig = { + fileMatch: ['(^|/)bitrise\\.ya?ml$'], +}; + +export const displayName = 'Bitrise'; + +export const categories: Category[] = ['ci']; + +export const supportedDatasources = [ + BitriseDatasource.id, + GitTagsDatasource.id, +]; + +export const urls = [ + 'https://devcenter.bitrise.io/en/steps-and-workflows/introduction-to-steps.html', +]; diff --git a/lib/modules/manager/bitrise/readme.md b/lib/modules/manager/bitrise/readme.md new file mode 100644 index 00000000000000..cad96aa8b1d6c0 --- /dev/null +++ b/lib/modules/manager/bitrise/readme.md @@ -0,0 +1 @@ +Updates step references of `bitrise.yml` files. diff --git a/lib/modules/manager/bitrise/schema.ts b/lib/modules/manager/bitrise/schema.ts new file mode 100644 index 00000000000000..f32030d93dc966 --- /dev/null +++ b/lib/modules/manager/bitrise/schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const BitriseStep = z.record(z.string(), z.unknown()); + +export const BitriseWorkflow = z.object({ + steps: z.array(BitriseStep), +}); + +export const BitriseFile = z.object({ + default_step_lib_source: z.string().optional(), + workflows: z.record(z.string(), BitriseWorkflow), +}); diff --git a/lib/modules/manager/bitrise/utils.spec.ts b/lib/modules/manager/bitrise/utils.spec.ts new file mode 100644 index 00000000000000..d5ed5beb9c26d5 --- /dev/null +++ b/lib/modules/manager/bitrise/utils.spec.ts @@ -0,0 +1,28 @@ +import { BitriseDatasource } from '../../datasource/bitrise'; +import { parseStep } from './utils'; + +describe('modules/manager/bitrise/utils', () => { + describe('parseStep()', () => { + it('returns null on an empty string', () => { + expect(parseStep('')).toBeNull(); + }); + + it('returns dependency for step', () => { + expect(parseStep('restore-gradle-cache@1.1.2')).toEqual({ + currentValue: '1.1.2', + datasource: BitriseDatasource.id, + packageName: 'restore-gradle-cache', + replaceString: 'restore-gradle-cache@1.1.2', + }); + }); + + it('parses missing version', () => { + expect(parseStep('share-pipeline-variable')).toEqual({ + datasource: BitriseDatasource.id, + packageName: 'share-pipeline-variable', + replaceString: 'share-pipeline-variable', + skipReason: 'unspecified-version', + }); + }); + }); +}); diff --git a/lib/modules/manager/bitrise/utils.ts b/lib/modules/manager/bitrise/utils.ts new file mode 100644 index 00000000000000..66904fc347898a --- /dev/null +++ b/lib/modules/manager/bitrise/utils.ts @@ -0,0 +1,85 @@ +import is from '@sindresorhus/is'; +import { BitriseDatasource } from '../../datasource/bitrise'; +import { GitTagsDatasource } from '../../datasource/git-tags'; +import type { PackageDependency } from '../types'; + +export function parseStep( + stepRef: string, + defaultRegistry?: string, +): PackageDependency | null { + if (is.emptyString(stepRef)) { + return null; + } + + const dep: PackageDependency = { + datasource: BitriseDatasource.id, + replaceString: stepRef, + }; + + const [ref, currentValue] = stepRef.split('@', 2); + + const refDep = parseStepRef(ref, defaultRegistry); + + // no version + if (is.nullOrUndefined(currentValue)) { + return { + ...dep, + packageName: stepRef, + skipReason: 'unspecified-version', + }; + } + + return { + ...dep, + ...refDep, + currentValue, + }; +} + +export function parseStepRef( + ref: string, + defaultRegistry?: string, +): PackageDependency { + // handle local path + // https://devcenter.bitrise.io/en/references/steps-reference/step-reference-id-format.html + if (ref.startsWith('path::')) { + return { + depName: ref.split('::', 2)[1], + skipReason: 'local-dependency', + }; + } + + // handle Git references + // https://devcenter.bitrise.io/en/references/steps-reference/step-reference-id-format.html + if (ref.startsWith('git::')) { + const [, packageName] = ref.split('::'); + return { + packageName, + datasource: GitTagsDatasource.id, + }; + } + + // step library references + // https://devcenter.bitrise.io/en/references/steps-reference/step-reference-id-format.html + const splitted = ref.split('::', 2); + + // reference which uses default registry + // - script: + if (splitted.length === 1) { + const [packageName] = splitted; + return { + packageName, + datasource: BitriseDatasource.id, + registryUrls: defaultRegistry ? [defaultRegistry] : undefined, + }; + } + + // reference which overwrites Bitrise registry + // https://github.com/bitrise-io/bitrise-steplib.git::script@1: + const [registryUrl, packageName] = splitted; + return { + packageName, + datasource: BitriseDatasource.id, + registryUrls: [registryUrl], + }; +}