feat: schedule support for lock file maintenance

This feature now allows a custom schedule to be defined for lock file maintenance. It is now enabled by default but runs only before 5m on Sundays. Closes #399

BREAKING CHANGE: lock file maintenance is enabled by default.
Rules will apply to both yarn and npm (npm is yet to be implemented however). Existing mainainYarn* variables are removed and replaced by new lockFileMaintenance object.
This commit is contained in:
Rhys Arkins 2017-07-01 06:44:41 +02:00
parent 3a68dafab2
commit 6f49927a45
20 changed files with 165 additions and 99 deletions

View file

@ -98,7 +98,6 @@ $ node renovate --help
--automerge <string> What types of upgrades to merge to base branch automatically. Values: none, minor or any
--automerge-type <string> How to automerge - "branch-merge-commit", "branch-push" or "pr". Branch support is GitHub-only
--yarn-cache-folder <string> Location of yarn cache folder to use. Set to empty string to disable
--maintain-yarn-lock [boolean] Keep yarn.lock files updated in base branch
--lazy-grouping [boolean] Use group names only when multiple dependencies upgraded
--group-name <string> Human understandable name for the dependency group
--group-slug <string> Slug to use for group (e.g. in branch name). Will be calculated from groupName if null
@ -177,11 +176,14 @@ Obviously, you can't set repository or package file location with this method.
| `prTitle` | Pull Request title template | string | `"{{semanticPrefix}}{{#if isPin}}Pin{{else}}Update{{/if}} dependency {{depName}} to version {{#if isRange}}{{newVersion}}{{else}}{{#if isMajor}}{{newVersionMajor}}.x{{else}}{{newVersion}}{{/if}}{{/if}}"` | `RENOVATE_PR_TITLE` | |
| `prBody` | Pull Request body template | string | `"This {{#if isGitHub}}Pull{{else}}Merge{{/if}} Request updates dependency [{{depName}}]({{repositoryUrl}}) from version `{{currentVersion}}` to `{{newVersion}}`\n{{#if releases.length}}\n\n### Commits\n\n<details>\n<summary>{{githubName}}</summary>\n\n{{#each releases as |release|}}\n#### {{release.version}}\n{{#each release.commits as |commit|}}\n- [`{{commit.shortSha}}`]({{commit.url}}) {{commit.message}}\n{{/each}}\n{{/each}}\n\n</details>\n{{/if}}\n<br />\n\nThis {{#if isGitHub}}PR{{else}}MR{{/if}} has been generated by [Renovate Bot](https://keylocation.sg/our-tech/renovate)."` | `RENOVATE_PR_BODY` | |
| `yarnCacheFolder` | Location of yarn cache folder to use. Set to empty string to disable | string | `"/tmp/yarn-cache"` | `RENOVATE_YARN_CACHE_FOLDER` | `--yarn-cache-folder` |
| `maintainYarnLock` | Keep yarn.lock files updated in base branch | boolean | `false` | `RENOVATE_MAINTAIN_YARN_LOCK` | `--maintain-yarn-lock` |
| `yarnMaintenanceBranchName` | Branch name template when maintaining yarn.lock | string | `"renovate/yarn-lock"` | `RENOVATE_YARN_MAINTENANCE_BRANCH_NAME` | |
| `yarnMaintenanceCommitMessage` | Commit message template when maintaining yarn.lock | string | `"Renovate yarn.lock file"` | `RENOVATE_YARN_MAINTENANCE_COMMIT_MESSAGE` | |
| `yarnMaintenancePrTitle` | Pull Request title template when maintaining yarn.lock | string | `"Renovate yarn.lock file"` | `RENOVATE_YARN_MAINTENANCE_PR_TITLE` | |
| `yarnMaintenancePrBody` | Pull Request body template when maintaining yarn.lock | string | `"This PR regenerates yarn.lock files based on the existing `package.json` files."` | `RENOVATE_YARN_MAINTENANCE_PR_BODY` | |
| `lockFileMaintenance` | Configuration for lock file maintenance | json | `{
"enabled": true,
"branchName": "renovate/lock-files",
"commitMessage": "{{semanticPrefix}}Update lock file",
"prTitle": "{{semanticPrefix}}Lock file maintenance",
"prBody": "This PR regenerates lock files to keep them up-to-date.",
"schedule": "before 5am on monday"
}` | | |
| `lazyGrouping` | Use group names only when multiple dependencies upgraded | boolean | `true` | `RENOVATE_LAZY_GROUPING` | `--lazy-grouping` |
| `groupName` | Human understandable name for the dependency group | string | `null` | `RENOVATE_GROUP_NAME` | `--group-name` |
| `groupSlug` | Slug to use for group (e.g. in branch name). Will be calculated from groupName if null | string | `null` | `RENOVATE_GROUP_SLUG` | `--group-slug` |

View file

@ -85,7 +85,7 @@ Set configuration option `pinVersions` to `false`.
### Keep `yarn.lock` sub-dependencies up-to-date, even when `package.json` hasn't changed
Set configuration option `maintainYarnLock` to `true`.
This is enabled by default, but its schedule is set to 'before 5am on monday'. If you want it more frequently, then update the `schedule` field inside the `lockFileMaintenance` object.
### Wait until tests have passed before creating the PR

View file

@ -9,6 +9,7 @@ const defaultValues = {
boolean: true,
list: [],
string: null,
json: null,
};
function getDefault(option) {

View file

@ -256,48 +256,22 @@ const options = [
default: '/tmp/yarn-cache',
},
{
name: 'maintainYarnLock',
description: 'Keep yarn.lock files updated in base branch',
name: 'lockFileMaintenance',
description: 'Configuration for lock file maintenance',
level: 'packageFile',
type: 'boolean',
default: false,
},
{
name: 'yarnMaintenanceBranchName',
description: 'Branch name template when maintaining yarn.lock',
level: 'packageFile',
type: 'string',
default: 'renovate/yarn-lock',
cli: false,
onboarding: false,
},
{
name: 'yarnMaintenanceCommitMessage',
description: 'Commit message template when maintaining yarn.lock',
level: 'packageFile',
type: 'string',
default: 'Renovate yarn.lock file',
cli: false,
onboarding: false,
},
{
name: 'yarnMaintenancePrTitle',
description: 'Pull Request title template when maintaining yarn.lock',
level: 'packageFile',
type: 'string',
default: 'Renovate yarn.lock file',
cli: false,
onboarding: false,
},
{
name: 'yarnMaintenancePrBody',
description: 'Pull Request body template when maintaining yarn.lock',
level: 'packageFile',
type: 'string',
default:
'This PR regenerates yarn.lock files based on the existing `package.json` files.',
type: 'json',
default: {
enabled: true,
branchName: 'renovate/lock-files',
commitMessage: '{{semanticPrefix}}Update lock file',
prTitle: '{{semanticPrefix}}Lock file maintenance',
prBody: 'This PR regenerates lock files to keep them up-to-date.',
schedule: 'before 5am on monday',
},
cli: false,
env: false,
onboarding: false,
mergeable: true,
},
// Dependency Groups
{

View file

@ -13,6 +13,7 @@ const githubApp = require('./github-app');
module.exports = {
parseConfigs,
mergeChildConfig,
filterConfig,
getOnboardingConfig,
};
@ -117,6 +118,25 @@ async function parseConfigs(env, argv) {
return config;
}
function mergeChildConfig(parentConfig, childConfig) {
const config = Object.assign({}, parentConfig, childConfig);
for (const option of definitions.getOptions()) {
if (option.mergeable && childConfig[option.name]) {
logger.debug(`mergeable option: ${option.name}`);
// TODO: handle arrays
config[option.name] = Object.assign(
{},
parentConfig[option.name],
childConfig[option.name]
);
logger.debug(
`config.${option.name}=${JSON.stringify(config[option.name])}`
);
}
}
return config;
}
function filterConfig(inputConfig, filterLevel) {
const outputConfig = Object.assign({}, inputConfig);
const levelScores = {

View file

@ -78,7 +78,8 @@ async function ensureBranch(upgrades) {
const packageFiles = {};
const commitFiles = [];
for (const upgrade of upgrades) {
if (upgrade.upgradeType === 'maintainYarnLock') {
if (upgrade.upgradeType === 'lockFileMaintenance') {
logger.debug('branch lockFileMaintenance');
try {
const newYarnLock = await yarn.maintainLockFile(upgrade);
if (newYarnLock) {
@ -220,7 +221,7 @@ async function updateBranch(upgrades) {
try {
if (
upgrade0.upgradeType !== 'maintainYarnLock' &&
upgrade0.upgradeType !== 'lockFileMaintenance' &&
upgrade0.groupName === null &&
!upgrade0.recreateClosed &&
(await upgrade0.api.checkForClosedPr(branchName, prTitle))
@ -243,19 +244,23 @@ async function updateBranch(upgrades) {
}
async function removeStandaloneBranches(upgrades) {
if (upgrades.length > 1) {
for (const upgrade of upgrades) {
const standaloneBranchName = handlebars.compile(upgrade.branchName)(
upgrade
);
upgrade.logger.debug(`Need to delete branch ${standaloneBranchName}`);
try {
await upgrade.api.deleteBranch(standaloneBranchName);
} catch (err) {
upgrade.logger.debug(`Couldn't delete branch ${standaloneBranchName}`);
}
// Rename to group branchName
upgrade.branchName = upgrade.groupBranchName;
if (upgrades.length <= 1) {
return;
}
if (upgrades[0].upgradeType === 'lockFileMaintenance') {
return;
}
for (const upgrade of upgrades) {
const standaloneBranchName = handlebars.compile(upgrade.branchName)(
upgrade
);
upgrade.logger.debug(`Need to delete branch ${standaloneBranchName}`);
try {
await upgrade.api.deleteBranch(standaloneBranchName);
} catch (err) {
upgrade.logger.debug(`Couldn't delete branch ${standaloneBranchName}`);
}
// Rename to group branchName
upgrade.branchName = upgrade.groupBranchName;
}
}

View file

@ -89,7 +89,7 @@ async function getLockFile(
}
async function maintainLockFile(inputConfig) {
logger.debug(`maintainYarnLock(${JSON.stringify(inputConfig)})`);
logger.debug(`maintainLockFile(${JSON.stringify(inputConfig)})`);
const packageContent = await inputConfig.api.getFileContent(
inputConfig.packageFile
);

View file

@ -50,7 +50,7 @@ async function findUpgrades(packageContent, config) {
}
function getDepConfig(depTypeConfig, dep) {
const depConfig = Object.assign({}, depTypeConfig, dep);
const depConfig = configParser.mergeChildConfig(depTypeConfig, dep);
// Apply any matching package rules
if (depConfig.packages) {
let packageRuleApplied = false;

View file

@ -32,7 +32,7 @@ function getRepositoryConfig(globalConfig, index) {
if (typeof repository === 'string') {
repository = { repository };
}
const repoConfig = Object.assign({}, globalConfig, repository);
const repoConfig = configParser.mergeChildConfig(globalConfig, repository);
repoConfig.logger = logger.child({
repository: repoConfig.repository,
});

View file

@ -1,5 +1,7 @@
const configParser = require('../../config');
const depTypeWorker = require('../dep-type');
const schedule = require('../package/schedule');
let logger = require('../../logger');
module.exports = {
@ -7,7 +9,8 @@ module.exports = {
getDepTypeConfig,
};
async function findUpgrades(config) {
async function findUpgrades(packageFileConfig) {
let config = Object.assign({}, packageFileConfig);
logger = config.logger || logger;
logger.info(`Processing package file`);
// If onboarding, use the package.json in onboarding branch
@ -29,7 +32,7 @@ async function findUpgrades(config) {
'package.json>renovate config'
);
// package.json>renovate config takes precedence over existing config
Object.assign(config, packageContent.renovate);
config = configParser.mergeChildConfig(config, packageContent.renovate);
} else {
logger.debug('Package file has no renovate configuration');
}
@ -50,24 +53,31 @@ async function findUpgrades(config) {
);
}
if (config.maintainYarnLock) {
const upgrade = Object.assign({}, config, {
upgradeType: 'maintainYarnLock',
});
upgrade.upgradeType = 'maintainYarnLock';
upgrade.commitMessage = upgrade.yarnMaintenanceCommitMessage;
upgrade.branchName = upgrade.yarnMaintenanceBranchName;
upgrade.prTitle = upgrade.yarnMaintenancePrTitle;
upgrade.prBody = upgrade.yarnMaintenancePrBody;
upgrades.push(upgrade);
// Maintain lock files
const lockFileMaintenanceConf = Object.assign(
{},
config,
config.lockFileMaintenance
);
if (lockFileMaintenanceConf.enabled) {
logger.debug('lockFileMaintenance enabled');
lockFileMaintenanceConf.upgradeType = 'lockFileMaintenance';
if (schedule.isScheduledNow(lockFileMaintenanceConf)) {
logger.debug(`lock config=${JSON.stringify(lockFileMaintenanceConf)}`);
upgrades.push(lockFileMaintenanceConf);
}
}
logger.info('Finished processing package file');
return upgrades;
}
function getDepTypeConfig(packageFileConfig, depType) {
let depTypeConfig = typeof depType === 'string' ? { depType } : depType;
depTypeConfig = Object.assign({}, packageFileConfig, depTypeConfig);
depTypeConfig = configParser.mergeChildConfig(
packageFileConfig,
depTypeConfig
);
depTypeConfig.logger = logger.child({
repository: depTypeConfig.repository,
packageFile: depTypeConfig.packageFile,

View file

@ -16,7 +16,7 @@ async function findUpgrades(config) {
return [];
}
// Check schedule
if (config.schedule && !schedule.isPackageScheduled(config)) {
if (config.schedule && !schedule.isScheduledNow(config)) {
logger.debug('Skipping package as it is not scheduled');
return [];
}

View file

@ -3,7 +3,7 @@ const moment = require('moment-timezone');
module.exports = {
hasValidSchedule,
isPackageScheduled,
isScheduledNow,
};
function fixShortHours(input) {
@ -45,7 +45,7 @@ function hasValidSchedule(schedule, logger) {
return true;
}
function isPackageScheduled(config) {
function isScheduledNow(config) {
config.logger.debug(`Checking schedule ${JSON.stringify(config.schedule)}`);
// Massage into array
const configSchedule =

View file

@ -64,7 +64,10 @@ function getPackageFileConfig(repoConfig, index) {
if (typeof packageFile === 'string') {
packageFile = { packageFile };
}
const packageFileConfig = Object.assign({}, repoConfig, packageFile);
const packageFileConfig = configParser.mergeChildConfig(
repoConfig,
packageFile
);
repoConfig.logger.trace({ config: repoConfig }, 'repoConfig');
packageFileConfig.logger = packageFileConfig.logger.child({
repository: packageFileConfig.repository,

View file

@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`config/index mergeChildConfig(parentConfig, childConfig) merges 1`] = `
Object {
"branchName": "renovate/lock-files",
"commitMessage": "{{semanticPrefix}}Update lock file",
"enabled": true,
"prBody": "This PR regenerates lock files to keep them up-to-date.",
"prTitle": "{{semanticPrefix}}Lock file maintenance",
"schedule": "on monday",
}
`;

View file

@ -1,4 +1,5 @@
const argv = require('../_fixtures/config/argv');
const defaultConfig = require('../../lib/config/defaults').getConfig();
describe('config/index', () => {
describe('.parseConfigs(env, defaultArgv)', () => {
@ -160,4 +161,22 @@ describe('config/index', () => {
expect(glGot.mock.calls.length).toBe(0);
});
});
describe('mergeChildConfig(parentConfig, childConfig)', () => {
it('merges', () => {
const parentConfig = Object.assign({}, defaultConfig);
const childConfig = {
foo: 'bar',
pinVersions: false,
lockFileMaintenance: {
schedule: 'on monday',
},
};
const configParser = require('../../lib/config/index.js');
const config = configParser.mergeChildConfig(parentConfig, childConfig);
expect(config.foo).toEqual('bar');
expect(config.pinVersions).toBe(false);
expect(config.lockFileMaintenance.schedule).toEqual('on monday');
expect(config.lockFileMaintenance).toMatchSnapshot();
});
});
});

View file

@ -279,7 +279,7 @@ describe('workers/branch', () => {
it('maintains lock files if needing updates', async () => {
branchWorker.getParentBranch.mockReturnValueOnce('dummy branch');
yarn.maintainLockFile.mockReturnValueOnce('non null response');
config.upgradeType = 'maintainYarnLock';
config.upgradeType = 'lockFileMaintenance';
await branchWorker.ensureBranch([config]);
expect(branchWorker.getParentBranch.mock.calls.length).toBe(1);
expect(packageJsonHelper.setNewValue.mock.calls.length).toBe(0);
@ -290,7 +290,7 @@ describe('workers/branch', () => {
});
it('skips maintaining lock files if no updates', async () => {
branchWorker.getParentBranch.mockReturnValueOnce('dummy branch');
config.upgradeType = 'maintainYarnLock';
config.upgradeType = 'lockFileMaintenance';
await branchWorker.ensureBranch([config]);
expect(branchWorker.getParentBranch.mock.calls.length).toBe(1);
expect(packageJsonHelper.setNewValue.mock.calls.length).toBe(0);
@ -301,7 +301,7 @@ describe('workers/branch', () => {
});
it('throws error if cannot maintain yarn.lock file', async () => {
branchWorker.getParentBranch.mockReturnValueOnce('dummy branch');
config.upgradeType = 'maintainYarnLock';
config.upgradeType = 'lockFileMaintenance';
yarn.maintainLockFile.mockImplementationOnce(() => {
throw new Error('yarn not found');
});
@ -372,6 +372,14 @@ describe('workers/branch', () => {
});
});
describe('removeStandaloneBranches(upgrades)', () => {
it('returns if length is one or less', async () => {
const upgrades = [{}];
await branchWorker.removeStandaloneBranches(upgrades);
});
it('returns if upgradeType is lockFileMaintenance', async () => {
const upgrades = [{ upgradeType: 'lockFileMaintenance' }, {}];
await branchWorker.removeStandaloneBranches(upgrades);
});
it('deletes standalone branch names', async () => {
const api = {
deleteBranch: jest.fn(),

View file

@ -1,19 +1,25 @@
const packageFileWorker = require('../../../lib/workers/package-file');
const depTypeWorker = require('../../../lib/workers/dep-type');
const schedule = require('../../../lib/workers/package/schedule');
const defaultConfig = require('../../../lib/config/defaults').getConfig();
const logger = require('../../_fixtures/logger');
jest.mock('../../../lib/workers/dep-type');
jest.mock('../../../lib/workers/package/schedule');
describe('packageFileWorker', () => {
describe('findUpgrades(config)', () => {
let config;
beforeEach(() => {
config = {
config = Object.assign({}, defaultConfig, {
repoIsOnboarded: true,
api: {
getFileJson: jest.fn(),
},
depTypes: ['dependencies', 'devDependencies'],
};
logger,
});
packageFileWorker.updateBranch = jest.fn();
});
it('handles null', async () => {
@ -55,10 +61,17 @@ describe('packageFileWorker', () => {
});
it('maintains yarn.lock', async () => {
config.api.getFileJson.mockReturnValueOnce({});
config.maintainYarnLock = true;
depTypeWorker.findUpgrades.mockReturnValue([]);
schedule.isScheduledNow.mockReturnValueOnce(true);
const res = await packageFileWorker.findUpgrades(config);
expect(res).toHaveLength(1);
});
it('skips yarn.lock', async () => {
config.api.getFileJson.mockReturnValueOnce({});
depTypeWorker.findUpgrades.mockReturnValue([]);
schedule.isScheduledNow.mockReturnValueOnce(false);
const res = await packageFileWorker.findUpgrades(config);
expect(res).toHaveLength(0);
});
});
});

View file

@ -22,14 +22,14 @@ describe('lib/workers/package/index', () => {
});
it('returns empty if package is not scheduled', async () => {
config.schedule = 'some schedule';
schedule.isPackageScheduled.mockReturnValueOnce(false);
schedule.isScheduledNow.mockReturnValueOnce(false);
const res = await pkgWorker.findUpgrades(config);
expect(res).toMatchObject([]);
expect(npmApi.getDependency.mock.calls.length).toBe(0);
});
it('returns empty if no npm dep found', async () => {
config.schedule = 'some schedule';
schedule.isPackageScheduled.mockReturnValueOnce(true);
schedule.isScheduledNow.mockReturnValueOnce(true);
const res = await pkgWorker.findUpgrades(config);
expect(res).toMatchObject([]);
expect(npmApi.getDependency.mock.calls.length).toBe(1);

View file

@ -73,7 +73,7 @@ describe('workers/package/schedule', () => {
expect(res).toBe(true);
});
});
describe('isPackageScheduled(config)', () => {
describe('isScheduledNow(config)', () => {
let config;
beforeEach(() => {
mockDate.set(1498812608678); // 2017-06-30 10:50am
@ -83,43 +83,43 @@ describe('workers/package/schedule', () => {
};
});
it('returns true if no schedule', () => {
const res = schedule.isPackageScheduled(config);
const res = schedule.isScheduledNow(config);
expect(res).toBe(true);
});
it('supports before hours true', () => {
config.schedule = 'before 4:00pm';
const res = schedule.isPackageScheduled(config);
const res = schedule.isScheduledNow(config);
expect(res).toBe(true);
});
it('supports before hours false', () => {
config.schedule = 'before 4:00am';
const res = schedule.isPackageScheduled(config);
const res = schedule.isScheduledNow(config);
expect(res).toBe(false);
});
it('supports outside hours', () => {
config.schedule = 'after 4:00pm';
const res = schedule.isPackageScheduled(config);
const res = schedule.isScheduledNow(config);
expect(res).toBe(false);
});
it('supports timezone', () => {
config.schedule = 'after 4:00pm';
config.timezone = 'Asia/Singapore';
const res = schedule.isPackageScheduled(config);
const res = schedule.isScheduledNow(config);
expect(res).toBe(true);
});
it('supports multiple schedules', () => {
config.schedule = ['after 4:00pm', 'before 11:00am'];
const res = schedule.isPackageScheduled(config);
const res = schedule.isScheduledNow(config);
expect(res).toBe(true);
});
it('supports day match', () => {
config.schedule = 'on friday and saturday';
const res = schedule.isPackageScheduled(config);
const res = schedule.isScheduledNow(config);
expect(res).toBe(true);
});
it('supports day mismatch', () => {
config.schedule = 'on monday and tuesday';
const res = schedule.isPackageScheduled(config);
const res = schedule.isScheduledNow(config);
expect(res).toBe(false);
});
});

View file

@ -96,7 +96,6 @@ Array [
\\"automerge\\": \\"none\\",
\\"branchName\\": \\"renovate/{{depName}}-{{newVersionMajor}}.x\\",
\\"commitMessage\\": \\"{{semanticPrefix}}Update dependency {{depName}} to version {{newVersion}}\\",
\\"maintainYarnLock\\": false,
\\"labels\\": [],
\\"assignees\\": [],
\\"reviewers\\": []