feat: unpublish-safe status check (#635)

Renovate now adds a status check renovate/unpublish-safe that has the following behaviour:
If any upgrade in the branch is < 24 hours old then the status check state is "pending"
If all upgrades in the branch are 24 hours or more old then the status check state is "success"
This is able to be disabled via a new option "unpublishSafe".

Closes #494
This commit is contained in:
Rhys Arkins 2017-08-06 15:38:10 +02:00 committed by GitHub
parent cfa495da61
commit d7a6bbe367
16 changed files with 233 additions and 5 deletions

View file

@ -96,6 +96,7 @@ $ node renovate --help
--semantic-prefix <string> Prefix to use if semantic commits are enabled
--recreate-closed [boolean] Recreate PRs even if same ones were closed previously
--rebase-stale-prs [boolean] Rebase stale PRs (GitHub only)
--unpublish-safe [boolean] Set a status check for unpublish-safe upgrades
--pr-creation <string> When to create the PR for a branch. Values: immediate, not-pending, status-success.
--automerge <string> What types of upgrades to merge to base branch automatically. Values: none, patch, minor or any
--automerge-type <string> How to automerge - "branch-merge-commit", "branch-push" or "pr". Branch support is GitHub-only
@ -482,6 +483,14 @@ Obviously, you can't set repository or package file location with this method.
<td>`RENOVATE_REBASE_STALE_PRS`</td>
<td>`--rebase-stale-prs`<td>
</tr>
<tr>
<td>`unpublishSafe`</td>
<td>Set a status check for unpublish-safe upgrades</td>
<td>boolean</td>
<td><pre>true</pre></td>
<td>`RENOVATE_UNPUBLISH_SAFE`</td>
<td>`--unpublish-safe`<td>
</tr>
<tr>
<td>`prCreation`</td>
<td>When to create the PR for a branch. Values: immediate, not-pending, status-success.</td>

9
docs/status-checks.md Normal file
View file

@ -0,0 +1,9 @@
# Status Checks
## unpublish-safe
Renovate includes a status showing whether upgrades are "unpublish-safe". This is because [packages less than 24 hours old may be unpublished by their authors](https://docs.npmjs.com/cli/unpublish). We recommend you wait for this status check to pass before merging unless the upgrade is urgent, otherwise you may find that packages simply disappear from the npm registry, breaking your build.
If you would like to disable this status check, add `"unpublishSafe": false` to your config.
If you would like to delay creation of Pull Requests until after this check passes, then add `"prCreation": "not-pending"` to your config. This way the PR will only be created once the upgrades in the branch are at least 24 hours old because Renovate sets the status check to "pending" in the meantime.

View file

@ -59,6 +59,7 @@ module.exports = {
isBranchStale,
getBranchPr,
getBranchStatus,
setBranchStatus,
deleteBranch,
mergeBranch,
// issue
@ -363,6 +364,26 @@ async function getBranchStatus(branchName, requiredStatusChecks) {
return res.body.state;
}
async function setBranchStatus(
branchName,
context,
description,
state,
targetUrl
) {
const branchCommit = await getBranchCommit(branchName);
const url = `repos/${config.repoName}/statuses/${branchCommit}`;
const options = {
state,
description,
context,
};
if (targetUrl) {
options.target_url = targetUrl;
}
await ghGotRetry.post(url, { body: options });
}
async function deleteBranch(branchName) {
await ghGotRetry.delete(
`repos/${config.repoName}/git/refs/heads/${branchName}`

View file

@ -15,6 +15,7 @@ module.exports = {
getBranch,
getBranchPr,
getBranchStatus,
setBranchStatus,
deleteBranch,
// issue
addAssignees,
@ -221,6 +222,30 @@ async function getBranchStatus(branchName, requiredStatusChecks) {
return status;
}
async function setBranchStatus(
branchName,
context,
description,
state,
targetUrl
) {
// First, get the branch to find the commit SHA
let url = `projects/${config.repoName}/repository/branches/${branchName}`;
const res = await glGot(url);
const branchSha = res.body.commit.id;
// Now, check the statuses for that commit
url = `projects/${config.repoName}/statuses/${branchSha}`;
const options = {
state,
description,
context,
};
if (targetUrl) {
options.target_url = targetUrl;
}
await glGot.post(url, { body: options });
}
async function deleteBranch(branchName) {
await glGot.delete(
`projects/${config.repoName}/repository/branches/${branchName}`

View file

@ -73,7 +73,7 @@ async function getDependency(name, logger) {
};
Object.keys(dep.versions).forEach(version => {
// We don't use any of the version payload currently
dep.versions[version] = {};
dep.versions[version] = { time: res.body.time[version] };
});
npmCache[cacheKey] = dep;
logger.trace({ dependency: dep }, 'dependency');

View file

@ -338,6 +338,11 @@ const options = [
type: 'boolean',
default: false,
},
{
name: 'unpublishSafe',
description: 'Set a status check for unpublish-safe upgrades',
type: 'boolean',
},
{
name: 'prCreation',
description:

View file

@ -88,7 +88,15 @@ async function ensureBranch(config) {
const api = config.api;
const packageFiles = {};
const commitFiles = [];
let unpublishable;
for (const upgrade of config.upgrades) {
if (typeof upgrade.unpublishable !== 'undefined') {
if (typeof unpublishable !== 'undefined') {
unpublishable = unpublishable && upgrade.unpublishable;
} else {
unpublishable = upgrade.unpublishable;
}
}
if (upgrade.type === 'lockFileMaintenance') {
logger.debug('branch lockFileMaintenance');
try {
@ -189,6 +197,20 @@ async function ensureBranch(config) {
// Return now if no branch exists
return false;
}
// Set unpublishable status check
if (config.unpublishSafe && typeof unpublishable !== 'undefined') {
const state = unpublishable ? 'success' : 'pending';
const description = unpublishable
? 'Packages are at least 24 hours old'
: 'Packages < 24 hours old can be unpublished';
await api.setBranchStatus(
branchName,
'renovate/unpublish-safe',
description,
state,
'https://github.com/singapore/renovate/blob/master/docs/status-checks#unpublish-safe.md'
);
}
if (config.automergeEnabled === false || config.automergeType === 'pr') {
// No branch automerge
return true;

View file

@ -3,6 +3,7 @@ const semver = require('semver');
const stable = require('semver-stable');
const _ = require('lodash');
const semverUtils = require('semver-utils');
const moment = require('moment');
module.exports = {
determineUpgrades,
@ -141,6 +142,13 @@ function determineUpgrades(npmDep, config) {
});
// Return only the values - we don't need the keys anymore
const upgrades = Object.keys(allUpgrades).map(key => allUpgrades[key]);
for (const upgrade of upgrades) {
const elapsed = moment().diff(
moment(versions[upgrade.newVersion].time),
'days'
);
upgrade.unpublishable = elapsed > 0;
}
// Return now if array is empty, or we can keep pinned version upgrades
if (upgrades.length === 0 || config.pinVersions || !isRange(currentVersion)) {

View file

@ -7,7 +7,9 @@ Object {
"name": undefined,
"repositoryUrl": "https://github.com/renovateapp/dummy",
"versions": Object {
"0.0.1": Object {},
"0.0.1": Object {
"time": "",
},
},
}
`;
@ -29,7 +31,9 @@ Object {
"name": undefined,
"repositoryUrl": "https://google.com",
"versions": Object {
"0.0.1": Object {},
"0.0.1": Object {
"time": "",
},
},
}
`;
@ -53,7 +57,9 @@ Object {
"name": undefined,
"repositoryUrl": "https://google.com",
"versions": Object {
"0.0.1": Object {},
"0.0.1": Object {
"time": "",
},
},
}
`;
@ -77,7 +83,9 @@ Object {
"name": undefined,
"repositoryUrl": "https://google.com",
"versions": Object {
"0.0.1": Object {},
"0.0.1": Object {
"time": "",
},
},
}
`;

View file

@ -823,6 +823,27 @@ describe('api/github', () => {
expect(res).toEqual('failed');
});
});
describe('setBranchStatus', () => {
it('sets branch status', async () => {
await initRepo('some/repo', 'token');
// getBranchCommit
ghGot.mockImplementationOnce(() => ({
body: {
object: {
sha: '1235',
},
},
}));
await github.setBranchStatus(
'some-branch',
'some-context',
'some-description',
'some-state',
'some-url'
);
expect(ghGot.post.mock.calls).toHaveLength(1);
});
});
describe('mergeBranch(branchName, mergeType)', () => {
it('should perform a branch-push merge', async () => {
await initRepo('some/repo', 'token');

View file

@ -326,6 +326,27 @@ describe('api/gitlab', () => {
expect(res).toEqual('foo');
});
});
describe('setBranchStatus', () => {
it('sets branch status', async () => {
await initRepo('some/repo', 'token');
// getBranchCommit
glGot.mockReturnValueOnce({
body: {
commit: {
id: 1,
},
},
});
await gitlab.setBranchStatus(
'some-branch',
'some-context',
'some-description',
'some-state',
'some-url'
);
expect(glGot.post.mock.calls).toHaveLength(1);
});
});
describe('deleteBranch(branchName)', () => {
it('should send delete', async () => {
glGot.delete = jest.fn();

View file

@ -19,6 +19,9 @@ const npmResponse = {
type: 'git',
url: 'git://github.com/renovateapp/dummy.git',
},
time: {
'0.0.1': '',
},
},
};

View file

@ -15,6 +15,7 @@ Object {
"getBranchStatus": [Function],
"getFileContent": [Function],
"mergeBranch": [Function],
"setBranchStatus": [Function],
},
],
}
@ -39,6 +40,7 @@ Object {
"getBranchStatus": [Function],
"getFileContent": [Function],
"mergeBranch": [Function],
"setBranchStatus": [Function],
},
],
}

View file

@ -119,6 +119,7 @@ describe('workers/branch', () => {
config.api.commitFilesToBranch = jest.fn();
config.api.getFileContent.mockReturnValueOnce('old content');
config.api.getBranchStatus = jest.fn();
config.api.setBranchStatus = jest.fn();
config.depName = 'dummy';
config.currentVersion = '1.0.0';
config.newVersion = '1.1.0';
@ -160,6 +161,25 @@ describe('workers/branch', () => {
expect(npm.getLockFile.mock.calls.length).toBe(1);
expect(yarn.getLockFile.mock.calls.length).toBe(1);
expect(config.api.commitFilesToBranch.mock.calls[0][1].length).toBe(1);
expect(config.api.setBranchStatus.mock.calls).toHaveLength(0);
});
it('sets branch status pending', async () => {
branchWorker.getParentBranch.mockReturnValueOnce('dummy branch');
packageJsonHelper.setNewValue.mockReturnValueOnce('new content');
config.api.branchExists.mockReturnValueOnce(true);
config.upgrades[0].unpublishable = true;
config.upgrades.push({ ...config });
config.upgrades[1].unpublishable = false;
expect(await branchWorker.ensureBranch(config)).toBe(true);
expect(config.api.setBranchStatus.mock.calls).toHaveLength(1);
});
it('sets branch status success', async () => {
branchWorker.getParentBranch.mockReturnValueOnce('dummy branch');
packageJsonHelper.setNewValue.mockReturnValueOnce('new content');
config.api.branchExists.mockReturnValueOnce(true);
config.upgrades[0].unpublishable = true;
expect(await branchWorker.ensureBranch(config)).toBe(true);
expect(config.api.setBranchStatus.mock.calls).toHaveLength(1);
});
it('automerges successful branches', async () => {
branchWorker.getParentBranch.mockReturnValueOnce('dummy branch');

View file

@ -9,6 +9,7 @@ Array [
"semanticPrefix",
"recreateClosed",
"rebaseStalePrs",
"unpublishSafe",
"prCreation",
"automerge",
"automergeType",
@ -40,6 +41,7 @@ Array [
"semanticPrefix",
"recreateClosed",
"rebaseStalePrs",
"unpublishSafe",
"prCreation",
"automerge",
"automergeType",
@ -207,6 +209,7 @@ This {{#if isGitHub}}PR{{else}}MR{{/if}} has been generated by [Renovate Bot](ht
"semanticPrefix": "chore(deps):",
"timezone": null,
"type": "error",
"unpublishSafe": true,
},
]
`;

View file

@ -13,6 +13,7 @@ Array [
"newVersion": "0.4.4",
"newVersionMajor": 0,
"type": "pin",
"unpublishable": false,
},
Object {
"automergeEnabled": false,
@ -23,6 +24,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "major",
"unpublishable": false,
},
]
`;
@ -38,6 +40,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "minor",
"unpublishable": false,
},
]
`;
@ -60,6 +63,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "major",
"unpublishable": false,
},
Object {
"automergeEnabled": true,
@ -72,6 +76,7 @@ Array [
"newVersion": "0.9.7",
"newVersionMajor": 0,
"type": "pin",
"unpublishable": false,
},
]
`;
@ -89,6 +94,7 @@ Array [
"newVersion": "1.4.1",
"newVersionMajor": 1,
"type": "pin",
"unpublishable": false,
},
]
`;
@ -127,6 +133,7 @@ Array [
"newVersionMajor": 0,
"newVersionMinor": 9,
"type": "minor",
"unpublishable": false,
},
Object {
"automergeEnabled": false,
@ -137,6 +144,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "major",
"unpublishable": false,
},
Object {
"automergeEnabled": true,
@ -149,6 +157,7 @@ Array [
"newVersion": "0.4.4",
"newVersionMajor": 0,
"type": "pin",
"unpublishable": false,
},
]
`;
@ -164,6 +173,7 @@ Array [
"newVersionMajor": 0,
"newVersionMinor": 9,
"type": "minor",
"unpublishable": false,
},
Object {
"automergeEnabled": false,
@ -174,6 +184,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "major",
"unpublishable": false,
},
]
`;
@ -191,6 +202,7 @@ Array [
"newVersion": "0.4.4",
"newVersionMajor": 0,
"type": "pin",
"unpublishable": false,
},
Object {
"automergeEnabled": true,
@ -201,6 +213,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "major",
"unpublishable": false,
},
]
`;
@ -218,6 +231,7 @@ Array [
"newVersion": "0.4.4",
"newVersionMajor": 0,
"type": "pin",
"unpublishable": false,
},
Object {
"automergeEnabled": false,
@ -228,6 +242,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "major",
"unpublishable": false,
},
]
`;
@ -243,6 +258,7 @@ Array [
"newVersionMajor": 0,
"newVersionMinor": 9,
"type": "minor",
"unpublishable": false,
},
Object {
"automergeEnabled": false,
@ -253,6 +269,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "major",
"unpublishable": false,
},
Object {
"automergeEnabled": false,
@ -263,6 +280,7 @@ Array [
"newVersionMajor": 0,
"newVersionMinor": 8,
"type": "patch",
"unpublishable": false,
},
]
`;
@ -278,6 +296,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "major",
"unpublishable": false,
},
Object {
"automergeEnabled": true,
@ -288,6 +307,7 @@ Array [
"newVersionMajor": 0,
"newVersionMinor": 9,
"type": "patch",
"unpublishable": false,
},
]
`;
@ -303,6 +323,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "major",
"unpublishable": false,
},
Object {
"automergeEnabled": false,
@ -313,6 +334,7 @@ Array [
"newVersionMajor": 0,
"newVersionMinor": 9,
"type": "patch",
"unpublishable": false,
},
]
`;
@ -328,6 +350,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 1,
"type": "minor",
"unpublishable": false,
},
]
`;
@ -340,6 +363,7 @@ Object {
"newVersionMajor": 1,
"semanticPrefix": "fix(deps):",
"type": "rollback",
"unpublishable": false,
}
`;
@ -355,6 +379,7 @@ Array [
"newVersionMajor": 0,
"newVersionMinor": 0,
"type": "minor",
"unpublishable": false,
},
]
`;
@ -370,6 +395,7 @@ Array [
"newVersionMajor": 2,
"newVersionMinor": 0,
"type": "major",
"unpublishable": false,
},
]
`;
@ -387,6 +413,7 @@ Array [
"newVersion": "2.0.3",
"newVersionMajor": 2,
"type": "pin",
"unpublishable": false,
},
]
`;
@ -402,6 +429,7 @@ Array [
"newVersionMajor": 2,
"newVersionMinor": 0,
"type": "major",
"unpublishable": false,
},
]
`;
@ -417,6 +445,7 @@ Array [
"newVersionMajor": 0,
"newVersionMinor": 9,
"type": "minor",
"unpublishable": false,
},
Object {
"automergeEnabled": false,
@ -427,6 +456,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "major",
"unpublishable": false,
},
Object {
"automergeEnabled": true,
@ -439,6 +469,7 @@ Array [
"newVersion": "0.4.4",
"newVersionMajor": 0,
"type": "pin",
"unpublishable": false,
},
]
`;
@ -454,6 +485,7 @@ Array [
"newVersionMajor": 0,
"newVersionMinor": 9,
"type": "minor",
"unpublishable": false,
},
Object {
"automergeEnabled": false,
@ -464,6 +496,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "major",
"unpublishable": false,
},
Object {
"automergeEnabled": true,
@ -476,6 +509,7 @@ Array [
"newVersion": "0.4.4",
"newVersionMajor": 0,
"type": "pin",
"unpublishable": false,
},
]
`;
@ -492,6 +526,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "major",
"unpublishable": false,
},
]
`;
@ -507,6 +542,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "minor",
"unpublishable": false,
},
Object {
"automergeEnabled": true,
@ -519,6 +555,7 @@ Array [
"newVersion": "1.3.0",
"newVersionMajor": 1,
"type": "pin",
"unpublishable": false,
},
]
`;
@ -535,6 +572,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "minor",
"unpublishable": false,
},
]
`;
@ -551,6 +589,7 @@ Array [
"newVersionMajor": 0,
"newVersionMinor": 9,
"type": "minor",
"unpublishable": false,
},
Object {
"automergeEnabled": false,
@ -562,6 +601,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "major",
"unpublishable": false,
},
]
`;
@ -577,6 +617,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "minor",
"unpublishable": false,
},
Object {
"automergeEnabled": true,
@ -589,6 +630,7 @@ Array [
"newVersion": "1.0.1",
"newVersionMajor": 1,
"type": "pin",
"unpublishable": false,
},
]
`;
@ -605,6 +647,7 @@ Array [
"newVersionMajor": 0,
"newVersionMinor": 9,
"type": "minor",
"unpublishable": false,
},
Object {
"automergeEnabled": false,
@ -616,6 +659,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "major",
"unpublishable": false,
},
]
`;
@ -632,6 +676,7 @@ Array [
"newVersionMajor": 0,
"newVersionMinor": 9,
"type": "minor",
"unpublishable": false,
},
Object {
"automergeEnabled": false,
@ -643,6 +688,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "major",
"unpublishable": false,
},
]
`;
@ -659,6 +705,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "major",
"unpublishable": false,
},
]
`;
@ -675,6 +722,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "minor",
"unpublishable": false,
},
]
`;
@ -690,6 +738,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "minor",
"unpublishable": false,
},
Object {
"automergeEnabled": true,
@ -702,6 +751,7 @@ Array [
"newVersion": "1.3.0",
"newVersionMajor": 1,
"type": "pin",
"unpublishable": false,
},
]
`;
@ -718,6 +768,7 @@ Array [
"newVersionMajor": 1,
"newVersionMinor": 4,
"type": "minor",
"unpublishable": false,
},
]
`;