Files
renovate/lib/modules/manager/circleci/extract.spec.ts
Ethan Setnik afd16f7f7f fix(manager/circleci): parse config files as YAML 1.1 (#43551)
* docs(manager/circleci): document YAML merge key parsing limitation

Signed-off-by: Ethan Setnik <esetnik@gmail.com>

* fix(manager/circleci): parse config files as YAML 1.1

CircleCI processes config files as YAML 1.1, but Renovate parsed them
as YAML 1.2. Configs that use YAML 1.1 features failed to parse and
the entire file was silently skipped, dropping every `docker` and `orb`
dep in it.

The most common trigger is a mapping with more than one `<<` merge key,
which YAML 1.2 rejects as a duplicate key. CircleCI's own processor
allows it, since merge keys are a YAML 1.1 feature.

Pass `{ version: '1.1' }` to `parseSingleYaml` so the parser matches
CircleCI's behavior, and add a regression test that covers the
duplicate-merge-key case.

Replaces the workaround note added in the previous commit on this
branch (per review feedback) — the config-side `%YAML 1.1` directive is
no longer needed.

---------

Signed-off-by: Ethan Setnik <esetnik@gmail.com>
2026-05-26 21:10:42 +00:00

387 lines
12 KiB
TypeScript

import { codeBlock } from 'common-tags';
import { Fixtures } from '~test/fixtures.ts';
import { extractPackageFile } from './index.ts';
const file1 = Fixtures.get('config.yml');
const file2 = Fixtures.get('config2.yml');
const file3 = Fixtures.get('config3.yml');
const file4 = Fixtures.get('config4.yml');
describe('modules/manager/circleci/extract', () => {
describe('extractPackageFile()', () => {
it('returns null for empty', () => {
expect(extractPackageFile('nothing here')).toBeNull();
});
it('handles registry alias', () => {
const src = codeBlock`
executors:
my-executor:
docker:
- image: quay.io/myName/myPackage:0.6.2
`;
const res = extractPackageFile(src, '.circleci/config.yml', {
registryAliases: {
'quay.io': 'my-quay-mirror.registry.com',
'index.docker.io': 'my-docker-mirror.registry.com',
},
});
expect(res).toEqual({
deps: [
{
autoReplaceStringTemplate:
'quay.io/myName/myPackage:{{#if newValue}}{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
currentDigest: undefined,
currentValue: '0.6.2',
datasource: 'docker',
depName: 'quay.io/myName/myPackage',
packageName: 'my-quay-mirror.registry.com/myName/myPackage',
depType: 'docker',
replaceString: 'quay.io/myName/myPackage:0.6.2',
},
],
});
});
it('extracts multiple image and resolves yaml anchors', () => {
const res = extractPackageFile(file1);
expect(res?.deps).toEqual([
{
autoReplaceStringTemplate:
'{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
datasource: 'docker',
depName: 'node',
packageName: 'node',
depType: 'docker',
replaceString: 'node',
},
{
autoReplaceStringTemplate:
'{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
currentValue: '4',
datasource: 'docker',
depName: 'node',
packageName: 'node',
depType: 'docker',
replaceString: 'node:4',
},
{
autoReplaceStringTemplate:
'{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
currentValue: '6',
datasource: 'docker',
depName: 'node',
packageName: 'node',
depType: 'docker',
replaceString: 'node:6',
},
{
autoReplaceStringTemplate:
'{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
currentValue: '8.9.0',
datasource: 'docker',
depName: 'node',
packageName: 'node',
depType: 'docker',
replaceString: 'node:8.9.0',
},
]);
});
it('extracts orbs too', () => {
const res = extractPackageFile(file2);
expect(res?.deps).toEqual([
{
currentValue: '4.1.0',
datasource: 'orb',
depName: 'release-workflows',
depType: 'orb',
packageName: 'hutson/library-release-workflows',
versioning: 'npm',
},
{
datasource: 'orb',
depName: 'no-version',
depType: 'orb',
packageName: 'abc/def',
versioning: 'npm',
},
{
currentValue: 'volatile',
datasource: 'orb',
depName: 'volatile',
depType: 'orb',
packageName: 'zzz/zzz',
versioning: 'npm',
},
{
autoReplaceStringTemplate:
'{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
currentDigest:
'sha256:3870d35b962a943df72d948580fc66ceaaee1c4fbd205930f32e0f0760eb1077',
currentValue: '3.7',
datasource: 'docker',
depName: 'python',
packageName: 'python',
depType: 'docker',
replaceString:
'python:3.7@sha256:3870d35b962a943df72d948580fc66ceaaee1c4fbd205930f32e0f0760eb1077',
},
{
autoReplaceStringTemplate:
'{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
currentDigest:
'sha256:3870d35b962a943df72d948580fc66ceaaee1c4fbd205930f32e0f0760eb1077',
currentValue: '3.7',
datasource: 'docker',
depName: 'python',
packageName: 'python',
depType: 'docker',
replaceString:
'python:3.7@sha256:3870d35b962a943df72d948580fc66ceaaee1c4fbd205930f32e0f0760eb1077',
},
{
autoReplaceStringTemplate:
'{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
currentDigest:
'sha256:3870d35b962a943df72d948580fc66ceaaee1c4fbd205930f32e0f0760eb1077',
currentValue: '3.7',
datasource: 'docker',
depName: 'python',
packageName: 'python',
depType: 'docker',
replaceString:
'python:3.7@sha256:3870d35b962a943df72d948580fc66ceaaee1c4fbd205930f32e0f0760eb1077',
},
{
autoReplaceStringTemplate:
'{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
currentDigest:
'sha256:3870d35b962a943df72d948580fc66ceaaee1c4fbd205930f32e0f0760eb1077',
currentValue: '3.7',
datasource: 'docker',
depName: 'python',
packageName: 'python',
depType: 'docker',
replaceString:
'python:3.7@sha256:3870d35b962a943df72d948580fc66ceaaee1c4fbd205930f32e0f0760eb1077',
},
{
autoReplaceStringTemplate:
'{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
currentDigest:
'sha256:eb6325b75c1c70b4992eaa1bdd29e24e5f14d5324b4714a49f3e67783473214b',
currentValue: '3-6',
datasource: 'docker',
depName: 'pypy',
packageName: 'pypy',
depType: 'docker',
replaceString:
'pypy:3-6@sha256:eb6325b75c1c70b4992eaa1bdd29e24e5f14d5324b4714a49f3e67783473214b',
},
{
autoReplaceStringTemplate:
'{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
currentDigest:
'sha256:3870d35b962a943df72d948580fc66ceaaee1c4fbd205930f32e0f0760eb1077',
currentValue: '3.7',
datasource: 'docker',
depName: 'python',
packageName: 'python',
depType: 'docker',
replaceString:
'python:3.7@sha256:3870d35b962a943df72d948580fc66ceaaee1c4fbd205930f32e0f0760eb1077',
},
]);
});
it('extracts image without leading dash', () => {
const res = extractPackageFile(file3);
expect(res?.deps).toEqual([
{
autoReplaceStringTemplate:
'{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
currentValue: '14.8.0',
datasource: 'docker',
depName: 'cimg/node',
packageName: 'cimg/node',
depType: 'docker',
replaceString: 'cimg/node:14.8.0',
},
{
autoReplaceStringTemplate:
'{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
currentValue: '14.8.0',
datasource: 'docker',
depName: 'cimg/node',
packageName: 'cimg/node',
depType: 'docker',
replaceString: 'cimg/node:14.8.0',
},
]);
});
it('extracts and exclude android images', () => {
expect(
extractPackageFile(codeBlock`
jobs:
build:
machine:
image: android:202102-01
`),
).toBeNull();
});
it('extracts orbs without jobs', () => {
const res = extractPackageFile(file4);
expect(res?.deps).toEqual([
{
currentValue: '5.2.0',
datasource: 'orb',
depName: 'nodejs',
depType: 'orb',
packageName: 'circleci/node',
versioning: 'npm',
},
]);
});
it('extracts executors', () => {
const res = extractPackageFile(codeBlock`
executors:
my-executor:
docker:
- image: cimg/ruby:3.0.3-browsers
`);
expect(res?.deps).toEqual([
{
autoReplaceStringTemplate:
'{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
currentValue: '3.0.3-browsers',
datasource: 'docker',
depName: 'cimg/ruby',
packageName: 'cimg/ruby',
depType: 'docker',
replaceString: 'cimg/ruby:3.0.3-browsers',
},
]);
});
it('extracts orb definitions', () => {
const res = extractPackageFile(codeBlock`
version: 2.1
orbs:
myorb:
orbs:
python: circleci/python@2.1.1
executors:
python:
docker:
- image: cimg/python:3.9
jobs:
test_image:
docker:
- image: cimg/python:3.7
steps:
- checkout
workflows:
Test:
jobs:
- myorb/test_image
`);
expect(res).toEqual({
deps: [
{
currentValue: '2.1.1',
datasource: 'orb',
depName: 'python',
depType: 'orb',
packageName: 'circleci/python',
versioning: 'npm',
},
{
autoReplaceStringTemplate:
'{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
currentDigest: undefined,
currentValue: '3.9',
datasource: 'docker',
depName: 'cimg/python',
packageName: 'cimg/python',
depType: 'docker',
replaceString: 'cimg/python:3.9',
},
{
autoReplaceStringTemplate:
'{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
currentDigest: undefined,
currentValue: '3.7',
datasource: 'docker',
depName: 'cimg/python',
packageName: 'cimg/python',
depType: 'docker',
replaceString: 'cimg/python:3.7',
},
],
});
});
it('extracts deps from configs with multiple merge keys per mapping', () => {
// YAML 1.2 rejects a mapping with two `<<` keys as a duplicate key and
// the entire file is skipped. CircleCI itself parses these files as
// YAML 1.1, where merge keys are valid and additive, so Renovate must
// do the same to avoid silently missing every dep in the file.
const res = extractPackageFile(codeBlock`
version: 2.1
aliases:
- &node-base
docker:
- image: node:18
- &node-env
environment:
NODE_ENV: production
orbs:
python: circleci/python@2.1.1
jobs:
build:
<<: *node-base
<<: *node-env
steps:
- checkout
`);
expect(res?.deps).toEqual([
{
currentValue: '2.1.1',
datasource: 'orb',
depName: 'python',
depType: 'orb',
packageName: 'circleci/python',
versioning: 'npm',
},
{
autoReplaceStringTemplate:
'{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
currentDigest: undefined,
currentValue: '18',
datasource: 'docker',
depName: 'node',
packageName: 'node',
depType: 'docker',
replaceString: 'node:18',
},
]);
});
});
});