Skip to content

Commit

Permalink
Merge branch 'AlCalzone-dep-type-and-security-with-config'
Browse files Browse the repository at this point in the history
  • Loading branch information
Ahmad Nassri committed Sep 9, 2020
2 parents 6760397 + d36a271 commit 6e005d9
Show file tree
Hide file tree
Showing 15 changed files with 627 additions and 53 deletions.
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,41 @@ steps:
| `github-token` | ❌ | `github.token` | The GitHub token used to merge the pull-request |
| `command` | ❌ | `merge` | The command to pass to Dependabot |
| `approve` | ❌ | `true` | Auto-approve pull-requests |

### Configuration file syntax

Using the configuration file `.github/auto-merge.yml`, you have the option to provide a more fine-grained configuration. The following example configuration file merges

* minor development dependency updates
* patch production dependency updates
* minor security-critical production dependency updates

```yml
- match:
dependency_type: development
# Supported dependency types:
# - development
# - production
# - all
update_type: "semver:minor" # includes patch updates!
# Supported updates to automerge:
# - "security:patch"
# SemVer patch update that fixes a known security vulnerability
# - "semver:patch"
# SemVer patch update, e.g. > 1.x && 1.0.1 to 1.0.3
# - "semver:minor"
# SemVer minor update, e.g. > 1.x && 2.1.4 to 2.3.1
# - "in_range" (NOT SUPPORTED YET)
# matching the version requirement in your package manifest
# - "security:all"
# - "all"
# To allow prereleases, the corresponding prepatch, preminor and premajor types are also supported
- match:
dependency_type: production
update_type: "security:minor" # includes patch updates!
- match:
dependency_type: production
update_type: "semver:patch"
```

The syntax is based on the [legacy dependaBot v1 config format](https://dependabot.com/docs/config-file/#automerged_updates), but does not support `dependency_name` and `in_range` yet.
4 changes: 2 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ inputs:
default: true

target:
description: The version comparison target (major, minor, patch)
description: The version comparison target (major, minor, patch). This is ignored if .github/auto-merge.yml exists
default: patch
required: false

runs:
using: docker
image: docker://ahmadnassri/action-dependabot-auto-merge:v1
image: docker://ahmadnassri/action-dependabot-auto-merge:v2
2 changes: 1 addition & 1 deletion action/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import main from './lib/index.js'
// parse inputs
const inputs = {
token: core.getInput('github-token', { required: true }),
target: core.getInput('target', { required: true }),
target: core.getInput('target', { required: false }),
command: core.getInput('command', { required: false }),
approve: core.getInput('approve', { required: false })
}
Expand Down
26 changes: 26 additions & 0 deletions action/lib/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// internals
import fs from 'fs'
import path from 'path'

// packages
import core from '@actions/core'
import yaml from 'js-yaml'

// default value is passed from workflow
export default function ({ workspace, target }) {
const configPath = path.join(workspace, '.github', 'auto-merge.yml')

let config

// read auto-merge.yml to determine what should be merged
if (fs.existsSync(configPath)) {
// parse .github/auto-merge.yml
const configYaml = fs.readFileSync(configPath, 'utf8')
config = yaml.safeLoad(configYaml)
core.info('loaded merge config: \n' + configYaml)
} else {
// or convert the input "target" to the equivalent config
config = [{ match: { dependency_type: 'all', update_type: `semver:${target}` } }]
core.info('using workflow\'s "target": \n' + yaml.safeDump(config))
}
}
18 changes: 18 additions & 0 deletions action/lib/dependencies.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import path from 'path'
import fs from 'fs'

import core from '@actions/core'

// Look at possible package files to determine the dependency type
// For now, this only includes npm
export default function (workspace) {
const packageJsonPath = path.join(workspace, 'package.json')

if (fs.existsSync(packageJsonPath)) {
try {
return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
} catch (err) {
core.debug(err)
}
}
}
12 changes: 11 additions & 1 deletion action/lib/index.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
// packages
import core from '@actions/core'
import github from '@actions/github'

// modules
import parse from './parse.js'
import config from './config.js'
import dependencies from './dependencies.js'
import { approve, comment } from './api.js'

const workspace = process.env.GITHUB_WORKSPACE || '/github/workspace'

export default async function (inputs) {
// exit early
if (github.context.eventName !== 'pull_request') {
Expand All @@ -25,7 +30,12 @@ export default async function (inputs) {
const octokit = github.getOctokit(inputs.token)

// parse and determine what command to tell dependabot
const proceed = parse(pull_request.title, inputs.target || 'patch')
const proceed = parse({
title: pull_request.title,
labels: pull_request.labels.map(label => label.name.toLowerCase()),
config: config({ workspace, target: inputs.target }),
dependencies: dependencies(workspace)
})

if (proceed) {
const command = inputs.approve === 'true' ? approve : comment
Expand Down
104 changes: 91 additions & 13 deletions action/lib/parse.js
Original file line number Diff line number Diff line change
@@ -1,43 +1,121 @@
/* eslint-disable camelcase */

import semver from 'semver'
import core from '@actions/core'

// semver regex
const semverRegEx = /(?<version>(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<patch>0|[1-9]\d*)(?:-(?<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)/
const regex = {
// semver regex
semver: /(?<version>(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<patch>0|[1-9]\d*)(?:-(?<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)/,
// detect dependency name
name: /(?<name>(?:@[^\s]+\/)?[^\s]+) from/,
// detect dependency type from PR title
dev: /\((deps-dev)\):/,
// detect security flag
security: /(^|: )\[Security\]/i,
// config values
config: /(?<type>security|semver):(?<target>.+)/
}

const weight = {
major: 3,
minor: 2,
all: 1000,
premajor: 6,
major: 5,
preminor: 4,
minor: 3,
prepatch: 2,
prerelease: 2, // equal to prepatch
patch: 1
}

export default function (title, target) {
export default function ({ title, labels = [], config = [], dependencies = {} }) {
// log
core.info(`title: "${title}"`)

// extract dep name from the title
const depName = title.match(regex.name)?.groups.name
core.info(`depName: ${depName}`)

// exit early
if (!depName) {
core.error('failed to parse title: could not detect dependency name')
return process.exit(0) // soft exit
}

// extract version from the title
const from = title.match(new RegExp('from ' + semverRegEx.source))?.groups
const to = title.match(new RegExp('to ' + semverRegEx.source))?.groups
const from = title.match(new RegExp('from ' + regex.semver.source))?.groups
const to = title.match(new RegExp('to ' + regex.semver.source))?.groups

// exit early
if (!from || !to || !semver.valid(from.version) || !semver.valid(to.version)) {
core.error('failed to parse title: invalid semver')
return process.exit(0) // soft exit
}

// is this a security update?
const isSecurity = regex.security.test(title) || labels.includes('security')

// production dependency flag
let isProd

// check if this dependency is a devDependency
if (dependencies.devDependencies && depName in dependencies.devDependencies) {
isProd = false
}

// if we could not determine the dependency type from package files, fall back to title parsing
if (isProd === undefined && regex.dev.test(title)) {
isProd = false
}

// assume default to be production
if (isProd === undefined) {
isProd = true
}

// log
core.info(`from: ${from.version}`)
core.info(`to: ${to.version}`)
core.info(`dependency type: ${isProd ? 'production' : 'development'}`)
core.info(`security critical: ${isSecurity}`)

// analyze with semver
const result = semver.diff(from.version, to.version)
const versionChange = semver.diff(from.version, to.version)

// compare weight to target
if ((weight[target] || 0) >= (weight[result] || 0)) {
// tell dependabot to merge
core.info(`dependency update target is "${target}", found "${result}", will auto-merge`)
return true
// check all configuration variants to see if one matches
for (const { match: { dependency_type, update_type } } of config) {
if (
dependency_type === 'all' ||
(dependency_type === 'production' && isProd) ||
(dependency_type === 'development' && !isProd)
) {
switch (true) {
case update_type === 'all':
core.info(`${dependency_type}:${update_type} detected, will auto-merge`)
return true

case update_type === 'in_range':
core.error('in_range update type not supported yet')
return process.exit(0) // soft exit

// security:patch, semver:minor, ...
case regex.config.test(update_type): {
const { type, target } = update_type.match(regex.config)?.groups

// skip when config is for security update and PR is not security
if (type === 'security' && !isSecurity) continue

// evaluate weight of detected change
if ((weight[target] || 0) >= (weight[versionChange] || 0)) {
// tell dependabot to merge
core.info(`${dependency_type}:${update_type} detected, will auto-merge`)
return true
}
}
}
}
}

core.info('manual merging required')

return false
}
8 changes: 2 additions & 6 deletions action/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions action/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@
"main": "index.js",
"license": "MIT",
"scripts": {
"test": "tap --no-esm --no-coverage",
"test:watch": "tap --no-esm --watch",
"test:100": "tap --no-esm --100 --color --coverage-report=lcov --no-browser"
"test": "tap test --no-esm --no-coverage",
"test:watch": "tap test --no-esm --watch",
"test:100": "tap test --no-esm --100 --color --coverage-report=lcov --no-browser"
},
"dependencies": {
"@actions/core": "^1.2.4",
"@actions/github": "^4.0.0",
"js-yaml": "^3.14.0",
"semver": "^7.3.2"
},
"devDependencies": {
Expand Down
8 changes: 4 additions & 4 deletions action/test/main/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ tap.test('main -> wrong event', assert => {
main()

assert.ok(process.exit.called)
assert.equal(process.exit.getCall(0).args[0], 1)
assert.equal(core.error.getCall(0).args[0], 'action triggered outside of a pull_request')
assert.equal(process.exit.getCall(0).firstArg, 1)
assert.equal(core.error.getCall(0).firstArg, 'action triggered outside of a pull_request')

process.exit.restore()
core.error.restore()
Expand All @@ -44,8 +44,8 @@ tap.test('main -> not dependabot', assert => {
main()

assert.ok(process.exit.called)
assert.equal(process.exit.getCall(0).args[0], 0)
assert.equal(core.warning.getCall(0).args[0], 'expected PR by "dependabot[bot]", found "foo" instead')
assert.equal(process.exit.getCall(0).firstArg, 0)
assert.equal(core.warning.getCall(0).firstArg, 'expected PR by "dependabot[bot]", found "foo" instead')

process.exit.restore()
core.warning.restore()
Expand Down
Loading

0 comments on commit 6e005d9

Please sign in to comment.