Skip to content

feat(config): variables #35350

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
42 changes: 42 additions & 0 deletions docs/usage/self-hosted-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1308,6 +1308,48 @@ The only time where `username` is required is if using `username` + `password` c
You don't need to configure `username` directly if you have already configured `token`.
Renovate will use the token to discover its username on the platform, including if you're running Renovate as a GitHub App.

## variables

Variables may be configured by a bot admin in `config.js`, which will then make them available for templating within repository configs.
This config option behaves exactly like [secrets](#secrets), except that it won't be masked in the logs.
For example, to configure a `SOME_VARIABLE` to be accessible by all repositories:

```js
module.exports = {
variables: {
SOME_VARIABLE: 'abc123',
},
};
```

They can also be configured per repository, e.g.

```js
module.exports = {
repositories: [
{
repository: 'abc/def',
variables: {
SOME_VARIABLE: 'abc123',
},
},
],
};
```

It could then be used in a repository config or preset like so:

```json
{
"packageRules": [
{
"matchUpdateTypes": ["patch"],
"addLabels": ["{{ variables.SOME_VARIABLE }}"]
}
]
}
```

## writeDiscoveredRepos

By default, Renovate processes each repository that it finds.
Expand Down
11 changes: 11 additions & 0 deletions lib/config/options/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,17 @@ const options: RenovateOptions[] = [
type: 'string',
},
},
{
name: 'variables',
description: 'Object which holds variable name/value pairs.',
type: 'object',
globalOnly: true,
mergeable: true,
default: {},
additionalProperties: {
type: 'string',
},
},
{
name: 'statusCheckNames',
description: 'Custom strings to use as status check names.',
Expand Down
193 changes: 63 additions & 130 deletions lib/config/secrets.spec.ts
Original file line number Diff line number Diff line change
@@ -1,181 +1,114 @@
import {
CONFIG_SECRETS_INVALID,
CONFIG_VALIDATION,
CONFIG_VARIABLES_INVALID,
} from '../constants/error-messages';
import { getConfig } from './defaults';
import { applySecretsToConfig, validateConfigSecrets } from './secrets';
import {
applySecretsAndVariablesToConfig,
validateConfigSecretsAndVariables,
} from './secrets';

describe('config/secrets', () => {
describe('validateConfigSecrets(config)', () => {
describe('validateConfigSecretsAndVariables(config)', () => {
it('works with default config', () => {
expect(() => validateConfigSecrets(getConfig())).not.toThrow();
});

it('returns if no secrets', () => {
expect(validateConfigSecrets({})).toBeUndefined();
expect(() =>
validateConfigSecretsAndVariables(getConfig()),
).not.toThrow();
});

it('throws if secrets is not an object', () => {
expect(() => validateConfigSecrets({ secrets: 'hello' } as any)).toThrow(
CONFIG_SECRETS_INVALID,
);
it('returns if no secrets/variables', () => {
expect(validateConfigSecretsAndVariables({})).toBeUndefined();
});

it('throws for invalid secret names', () => {
it('throws for invalid secret name', () => {
expect(() =>
validateConfigSecrets({ secrets: { '123': 'abc' } }),
validateConfigSecretsAndVariables({
secrets: { '123': 'abc' },
}),
).toThrow(CONFIG_SECRETS_INVALID);
});

it('throws for non-string secret', () => {
it('throws for invalid variable name', () => {
expect(() =>
validateConfigSecrets({ secrets: { abc: 123 } } as any),
).toThrow(CONFIG_SECRETS_INVALID);
validateConfigSecretsAndVariables({
variables: { '123': 'abc' },
}),
).toThrow(CONFIG_VARIABLES_INVALID);
});

it('throws for secrets inside repositories', () => {
it('throws for secrets in repositories', () => {
expect(() =>
validateConfigSecrets({
repositories: [
{ repository: 'abc/def', secrets: { abc: 123 } },
] as any,
}),
validateConfigSecretsAndVariables({
repositories: [{ repository: 'x/y', secrets: { abc: 123 } }],
} as any),
).toThrow(CONFIG_SECRETS_INVALID);
});
});

describe('applySecretsToConfig(config)', () => {
it('works with default config', () => {
expect(() => applySecretsToConfig(getConfig())).not.toThrow();
});

it('throws if disallowed field is used', () => {
const config = {
prTitle: '{{ secrets.ARTIFACTORY_TOKEN }}',
secrets: {
ARTIFACTORY_TOKEN: '123test==',
},
};
expect(() => applySecretsToConfig(config)).toThrow(CONFIG_VALIDATION);
});

it('throws if an unknown secret is used', () => {
const config = {
npmToken: '{{ secrets.ARTIFACTORY_TOKEN }}',
};
expect(() => applySecretsToConfig(config)).toThrow(CONFIG_VALIDATION);
});

it('replaces secrets in the top level', () => {
const config = {
secrets: { ARTIFACTORY_TOKEN: '123test==' },
npmToken: '{{ secrets.ARTIFACTORY_TOKEN }}',
};
const res = applySecretsToConfig(config);
expect(res).toStrictEqual({
npmToken: '123test==',
});
expect(Object.keys(res)).not.toContain('secrets');
});

it('replaces secrets in a subobject', () => {
const config = {
secrets: { ARTIFACTORY_TOKEN: '123test==' },
npm: { npmToken: '{{ secrets.ARTIFACTORY_TOKEN }}' },
};
const res = applySecretsToConfig(config);
expect(res).toStrictEqual({
npm: {
npmToken: '123test==',
},
});
expect(Object.keys(res)).not.toContain('secrets');
it('throws for variables in repositories', () => {
expect(() =>
validateConfigSecretsAndVariables({
repositories: [{ repository: 'x/y', variables: { abc: 123 } }],
} as any),
).toThrow(CONFIG_VARIABLES_INVALID);
});
});

it('replaces secrets in a array of objects', () => {
describe('applySecretsAndVariablesToConfig(config)', () => {
it('replaces both secrets and variables', () => {
const config = {
secrets: { ARTIFACTORY_TOKEN: '123test==' },
secrets: { TOKEN: 'secret123' },
variables: { MANAGER: 'npm' },
hostRules: [
{ hostType: 'npm', token: '{{ secrets.ARTIFACTORY_TOKEN }}' },
{
hostType: '{{ variables.MANAGER }}',
token: '{{ secrets.TOKEN }}',
},
],
};
const res = applySecretsToConfig(config);
expect(res).toStrictEqual({
hostRules: [{ hostType: 'npm', token: '123test==' }],
});
expect(Object.keys(res)).not.toContain('secrets');
});

it('replaces secrets in a array of strings', () => {
const config = {
secrets: { SECRET_MANAGER: 'npm' },
allowedManagers: ['{{ secrets.SECRET_MANAGER }}'],
};
const res = applySecretsToConfig(config);
expect(res).toStrictEqual({
allowedManagers: ['npm'],
const result = applySecretsAndVariablesToConfig({ config });
expect(result).toEqual({
hostRules: [{ hostType: 'npm', token: 'secret123' }],
});
expect(Object.keys(res)).not.toContain('secrets');
});

it('replaces secrets in a array of objects without deleting them', () => {
it('preserves secrets and variables if delete flags are false', () => {
const config = {
secrets: { ARTIFACTORY_TOKEN: '123test==' },
secrets: { TOKEN: 'secret123' },
variables: { MANAGER: 'npm' },
hostRules: [
{ hostType: 'npm', token: '{{ secrets.ARTIFACTORY_TOKEN }}' },
{
hostType: '{{ variables.MANAGER }}',
token: '{{ secrets.TOKEN }}',
},
],
};
const res = applySecretsToConfig(config, config.secrets, false);
expect(res).toStrictEqual({
secrets: { ARTIFACTORY_TOKEN: '123test==' },
hostRules: [{ hostType: 'npm', token: '123test==' }],
const result = applySecretsAndVariablesToConfig({
config,
deleteSecrets: false,
deleteVariables: false,
});
expect(Object.keys(res)).toContain('secrets');
});

it('replaces secrets in a array of strings without deleting them', () => {
const config = {
secrets: { SECRET_MANAGER: 'npm' },
allowedManagers: ['{{ secrets.SECRET_MANAGER }}'],
};
const res = applySecretsToConfig(config, config.secrets, false);
expect(res).toStrictEqual({
secrets: { SECRET_MANAGER: 'npm' },
allowedManagers: ['npm'],
expect(result).toEqual({
secrets: { TOKEN: 'secret123' },
variables: { MANAGER: 'npm' },
hostRules: [{ hostType: 'npm', token: 'secret123' }],
});
expect(Object.keys(res)).toContain('secrets');
});

it('{} as secrets will result in an error', () => {
it('throws if secret is missing', () => {
const config = {
secrets: { SECRET_MANAGER: 'npm' },
allowedManagers: ['{{ secrets.SECRET_MANAGER }}'],
hostRules: [{ token: '{{ secrets.MISSING_SECRET }}' }],
};
expect(() => applySecretsToConfig(config, {}, false)).toThrow(
expect(() => applySecretsAndVariablesToConfig({ config })).toThrow(
CONFIG_VALIDATION,
);
});

it('undefined as secrets will result replace the secret', () => {
const config = {
secrets: { SECRET_MANAGER: 'npm' },
allowedManagers: ['{{ secrets.SECRET_MANAGER }}'],
};
const res = applySecretsToConfig(config, undefined, false);
expect(res).toStrictEqual({
secrets: { SECRET_MANAGER: 'npm' },
allowedManagers: ['npm'],
});
expect(Object.keys(res)).toContain('secrets');
});

it('null as secrets will result in an error', () => {
it('throws if variable is missing', () => {
const config = {
secrets: { SECRET_MANAGER: 'npm' },
allowedManagers: ['{{ secrets.SECRET_MANAGER }}'],
hostRules: [{ hostType: '{{ variables.MISSING_VAR }}' }],
};
// TODO fix me? #22198
expect(() => applySecretsToConfig(config, null as never, false)).toThrow(
expect(() => applySecretsAndVariablesToConfig({ config })).toThrow(
CONFIG_VALIDATION,
);
});
Expand Down
Loading