Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,14 @@ Error: Resource not accessible by integration
| `pr-number` | No | | A pull request number, only required if triggered from a workflow_dispatch event. Typically this would be triggered by a script running in a separate CI provider. See [Trigger action from workflow_dispatch event](#trigger-action-from-workflow_dispatch-event) example. |
| `skip-commit-verification` | No | `false` | If `true`, then the action will not expect the commits to have a verification signature. It is required to set this to `true` in GitHub Enterprise Server. |
| `skip-verification` | No | `false` | If true, the action will not validate the user or the commit verification status |
| `merge-window` | No | | A 5-field [cron expression](https://en.wikipedia.org/wiki/Cron) (e.g. `0 9-16 * * 1-5`) describing when merges are allowed. When set, PRs evaluated outside this window are skipped instead of merged. Supports `*`, ranges (`a-b`), lists (`a,b`), and steps (`*/n`). The day-of-week field accepts `0`-`7` (both `0` and `7` mean Sunday). See [Restricting merges to business hours](#restricting-merges-to-business-hours). |
| `merge-window-timezone` | No | `UTC` | The [IANA timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (e.g. `Europe/London`) used to evaluate `merge-window`. Defaults to `UTC`. |

## Output

| outputs | Description |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| merge_status | The result status of the merge. It can be one of the following: `approved`, `merged`, `auto_merge`, `merge_failed`, `skipped:commit_verification_failed`, `skipped:not_a_dependabot_pr`, `skipped:cannot_update_major`, `skipped:bump_higher_than_target`, `skipped:packaged_excluded` |
| merge_status | The result status of the merge. It can be one of the following: `approved`, `merged`, `auto_merge`, `merge_failed`, `skipped:commit_verification_failed`, `skipped:not_a_dependabot_pr`, `skipped:cannot_update_major`, `skipped:bump_higher_than_target`, `skipped:packaged_excluded`, `skipped:outside_merge_window` |

## Examples

Expand Down Expand Up @@ -117,6 +119,30 @@ steps:
target-production: 'minor'
```

### Restricting merges to business hours

Use `merge-window` to only auto-merge during a time window, for example to avoid
deployments while everyone is asleep. The window is a standard 5-field cron
expression and is evaluated in `merge-window-timezone` (UTC by default). PRs
evaluated outside the window are skipped (`merge_status: skipped:outside_merge_window`)
rather than merged.

```yml
steps:
- uses: fastify/github-action-merge-dependabot@v3
with:
# Allow merges Monday-Friday, 09:00-16:59 London time
merge-window: '* 9-16 * * 1-5'
merge-window-timezone: 'Europe/London'
```

Note that the `pull_request` event only fires when a PR is opened or updated, so a
PR opened outside the window stays unmerged until something triggers the action
again. To re-evaluate skipped PRs on a schedule, also run the action from a
[`schedule`](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#schedule)
trigger together with the [`workflow_dispatch` approach](#trigger-action-from-workflow_dispatch-event)
described below.

### Trigger action from workflow_dispatch event

If you need to trigger this action manually, you can use the [`workflow_dispatch`](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch) event. A use case might be that your CI runs on a seperate provider, so you would like to run this action as a result of a successful CI run.
Expand Down
8 changes: 8 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ inputs:
type: boolean
description: 'If true, the action will not validate the user or the commit verification status'
default: false
merge-window:
description: 'A 5-field cron expression (e.g. "0 9-16 * * 1-5") describing when merges are allowed. PRs evaluated outside this window are skipped.'
required: false
default: ''
merge-window-timezone:
description: 'The IANA timezone (e.g. "Europe/London") used to evaluate merge-window. Defaults to UTC.'
required: false
default: ''

outputs:
merge_status:
Expand Down
208 changes: 207 additions & 1 deletion dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -34700,6 +34700,7 @@ const {
getTarget,
} = __nccwpck_require__(3286)
const { verifyCommits } = __nccwpck_require__(877)
const { isWithinMergeWindow } = __nccwpck_require__(4392)
const { dependabotAuthor } = __nccwpck_require__(9138)
const { updateTypes } = __nccwpck_require__(2539)
const { updateTypesPriority } = __nccwpck_require__(2539)
Expand Down Expand Up @@ -34728,6 +34729,8 @@ module.exports = async function run ({
PR_NUMBER,
SKIP_COMMIT_VERIFICATION,
SKIP_VERIFICATION,
MERGE_WINDOW,
MERGE_WINDOW_TIMEZONE,
} = getInputs(inputs)

try {
Expand Down Expand Up @@ -34822,6 +34825,21 @@ ${changedExcludedPackages.join(', ')}. Skipping.`)
return
}

if (
MERGE_WINDOW &&
!isWithinMergeWindow({
mergeWindow: MERGE_WINDOW,
timezone: MERGE_WINDOW_TIMEZONE || undefined,
})
) {
core.setOutput(MERGE_STATUS_KEY, MERGE_STATUS.skippedOutsideMergeWindow)
return logInfo(
`Outside of the configured merge-window ('${MERGE_WINDOW}'${
MERGE_WINDOW_TIMEZONE ? ` in ${MERGE_WINDOW_TIMEZONE}` : ''
}), skipping.`
)
}

await client.approvePullRequest(pr.number, MERGE_COMMENT)
if (APPROVE_ONLY) {
core.setOutput(MERGE_STATUS_KEY, MERGE_STATUS.approved)
Expand Down Expand Up @@ -35030,6 +35048,191 @@ module.exports = {
}


/***/ }),

/***/ 4392:
/***/ ((__unused_webpack_module, exports) => {

"use strict";


// Standard 5-field cron layout: minute hour day-of-month month day-of-week.
const CRON_FIELDS = [
{ name: 'minute', min: 0, max: 59 },
{ name: 'hour', min: 0, max: 23 },
{ name: 'dayOfMonth', min: 1, max: 31 },
{ name: 'month', min: 1, max: 12 },
{ name: 'dayOfWeek', min: 0, max: 7 },
]

const WEEKDAY_TO_NUMBER = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
}

const parseInteger = (value, field) => {
if (!/^\d+$/.test(value)) {
throw new Error(
`Invalid merge-window: '${value}' is not a valid number in the ${field.name} field`
)
}
return Number(value)
}

const assertInRange = (value, field) => {
if (value < field.min || value > field.max) {
throw new Error(
`Invalid merge-window: ${value} is out of range (${field.min}-${field.max}) in the ${field.name} field`
)
}
}

// Expands a single cron field into the set of values that satisfy it.
// Supports `*`, single values, ranges (`a-b`), lists (`a,b`) and steps (`*/n`, `a-b/n`).
const parseField = (rawField, field) => {
const values = new Set()

for (const part of rawField.split(',')) {
const [range, stepRaw] = part.split('/')

if (stepRaw !== undefined && !/^\d+$/.test(stepRaw)) {
throw new Error(
`Invalid merge-window: '${stepRaw}' is not a valid step in the ${field.name} field`
)
}
const step = stepRaw === undefined ? 1 : Number(stepRaw)
if (step === 0) {
throw new Error(
`Invalid merge-window: step cannot be zero in the ${field.name} field`
)
}

let start
let end
if (range === '*') {
start = field.min
end = field.max
} else if (range.includes('-')) {
const [startRaw, endRaw] = range.split('-')
start = parseInteger(startRaw, field)
end = parseInteger(endRaw, field)
} else {
start = parseInteger(range, field)
// A bare value combined with a step (e.g. `5/10`) means "from value to max".
end = stepRaw === undefined ? start : field.max
}

assertInRange(start, field)
assertInRange(end, field)
if (start > end) {
throw new Error(
`Invalid merge-window: range ${start}-${end} is inverted in the ${field.name} field`
)
}

for (let value = start; value <= end; value += step) {
values.add(value)
}
}

return values
}

const parseCron = expression => {
const fields = expression.trim().split(/\s+/)
if (fields.length !== CRON_FIELDS.length) {
throw new Error(
`Invalid merge-window: expected 5 fields but got ${fields.length} in '${expression}'`
)
}

const schedule = {}
CRON_FIELDS.forEach((field, index) => {
const rawField = fields[index]
schedule[field.name] = {
restricted: rawField !== '*',
values: parseField(rawField, field),
}
})

// In cron, both 0 and 7 represent Sunday.
if (schedule.dayOfWeek.values.has(7)) {
schedule.dayOfWeek.values.add(0)
}

return schedule
}

// Extracts the relevant calendar fields of `date` as seen in `timezone`.
const getTimeParts = (date, timezone) => {
let parts
try {
parts = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour12: false,
weekday: 'short',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
}).formatToParts(date)
} catch {
throw new Error(`Invalid merge-window-timezone: '${timezone}'`)
}

const lookup = {}
for (const { type, value } of parts) {
lookup[type] = value
}

// `hour` can be reported as '24' at midnight by some runtimes; normalise to 0.
const hour = Number(lookup.hour) % 24

return {
minute: Number(lookup.minute),
hour,
dayOfMonth: Number(lookup.day),
month: Number(lookup.month),
dayOfWeek: WEEKDAY_TO_NUMBER[lookup.weekday],
}
}

/**
* Returns true when `now` falls inside the window described by the cron
* expression `mergeWindow`, evaluated in `timezone`.
*
* Mirrors the standard cron rule for the day fields: when both day-of-month
* and day-of-week are restricted the match is an OR, otherwise it is an AND.
*/
const isWithinMergeWindow = ({ mergeWindow, timezone = 'UTC', now = new Date() }) => {
const schedule = parseCron(mergeWindow)
const parts = getTimeParts(now, timezone)

const minuteMatch = schedule.minute.values.has(parts.minute)
const hourMatch = schedule.hour.values.has(parts.hour)
const monthMatch = schedule.month.values.has(parts.month)

const domMatch = schedule.dayOfMonth.values.has(parts.dayOfMonth)
const dowMatch = schedule.dayOfWeek.values.has(parts.dayOfWeek)

const dayMatch =
schedule.dayOfMonth.restricted && schedule.dayOfWeek.restricted
? domMatch || dowMatch
: domMatch && dowMatch

return minuteMatch && hourMatch && monthMatch && dayMatch
}

exports.isWithinMergeWindow = isWithinMergeWindow
exports.parseCron = parseCron
exports.getTimeParts = getTimeParts


/***/ }),

/***/ 3286:
Expand Down Expand Up @@ -35093,6 +35296,8 @@ exports.getInputs = inputs => {
PR_NUMBER: inputs['pr-number'],
SKIP_COMMIT_VERIFICATION: /true/i.test(inputs['skip-commit-verification']),
SKIP_VERIFICATION: /true/i.test(inputs['skip-verification']),
MERGE_WINDOW: (inputs['merge-window'] || '').trim(),
MERGE_WINDOW_TIMEZONE: (inputs['merge-window-timezone'] || '').trim(),
}
}

Expand Down Expand Up @@ -35123,6 +35328,7 @@ exports.MERGE_STATUS = {
skippedBumpHigherThanTarget: 'skipped:bump_higher_than_target',
skippedPackageExcluded: 'skipped:packaged_excluded',
skippedInvalidVersion: 'skipped:invalid_semver',
skippedOutsideMergeWindow: 'skipped:outside_merge_window',
}

exports.MERGE_STATUS_KEY = 'merge_status'
Expand Down Expand Up @@ -35184,7 +35390,7 @@ module.exports = {
/***/ ((module) => {

"use strict";
module.exports = /*#__PURE__*/JSON.parse('{"name":"github-action-merge-dependabot","version":"3.12.0","description":"A GitHub action to automatically merge and approve Dependabot pull requests","main":"src/index.js","type":"commonjs","scripts":{"build":"ncc build src/index.js","lint":"eslint .","lint:fix":"eslint . --fix","test":"c8 --100 node --test"},"author":{"name":"Salman Mitha","email":"SalmanMitha@gmail.com"},"contributors":["Simone Busoli <simone.busoli@nearform.com>"],"license":"MIT","repository":{"type":"git","url":"git+https://github.com/fastify/github-action-merge-dependabot.git"},"bugs":{"url":"https://github.com/fastify/github-action-merge-dependabot/issues"},"homepage":"https://github.com/fastify/github-action-merge-dependabot#readme","dependencies":{"@actions/core":"^1.11.1","actions-toolkit":"github:nearform/actions-toolkit"},"devDependencies":{"@vercel/ncc":"^0.38.4","c8":"^11.0.0","eslint":"^9.39.2","neostandard":"^0.13.0","proxyquire":"^2.1.3","sinon":"^21.0.3"}}');
module.exports = /*#__PURE__*/JSON.parse('{"name":"github-action-merge-dependabot","version":"3.12.0","description":"A GitHub action to automatically merge and approve Dependabot pull requests","main":"src/index.js","type":"commonjs","scripts":{"build":"ncc build src/index.js","lint":"eslint .","lint:fix":"eslint . --fix","test":"c8 --100 node --test"},"author":{"name":"Salman Mitha","email":"SalmanMitha@gmail.com"},"contributors":["Simone Busoli <simone.busoli@nearform.com>"],"license":"MIT","repository":{"type":"git","url":"git+https://github.com/fastify/github-action-merge-dependabot.git"},"bugs":{"url":"https://github.com/fastify/github-action-merge-dependabot/issues"},"homepage":"https://github.com/fastify/github-action-merge-dependabot#readme","dependencies":{"@actions/core":"^1.11.1","actions-toolkit":"github:nearform/actions-toolkit"},"devDependencies":{"@vercel/ncc":"^0.38.4","c8":"^11.0.0","eslint":"^9.39.2","neostandard":"^0.13.0","proxyquire":"^2.1.3","sinon":"^21.1.2"}}');

/***/ })

Expand Down
18 changes: 18 additions & 0 deletions src/action.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const {
getTarget,
} = require('./util')
const { verifyCommits } = require('./verifyCommitSignatures')
const { isWithinMergeWindow } = require('./mergeWindow')
const { dependabotAuthor } = require('./getDependabotDetails')
const { updateTypes } = require('./mapUpdateType')
const { updateTypesPriority } = require('./mapUpdateType')
Expand Down Expand Up @@ -42,6 +43,8 @@ module.exports = async function run ({
PR_NUMBER,
SKIP_COMMIT_VERIFICATION,
SKIP_VERIFICATION,
MERGE_WINDOW,
MERGE_WINDOW_TIMEZONE,
} = getInputs(inputs)

try {
Expand Down Expand Up @@ -136,6 +139,21 @@ ${changedExcludedPackages.join(', ')}. Skipping.`)
return
}

if (
MERGE_WINDOW &&
!isWithinMergeWindow({
mergeWindow: MERGE_WINDOW,
timezone: MERGE_WINDOW_TIMEZONE || undefined,
})
) {
core.setOutput(MERGE_STATUS_KEY, MERGE_STATUS.skippedOutsideMergeWindow)
return logInfo(
`Outside of the configured merge-window ('${MERGE_WINDOW}'${
MERGE_WINDOW_TIMEZONE ? ` in ${MERGE_WINDOW_TIMEZONE}` : ''
}), skipping.`
)
}

await client.approvePullRequest(pr.number, MERGE_COMMENT)
if (APPROVE_ONLY) {
core.setOutput(MERGE_STATUS_KEY, MERGE_STATUS.approved)
Expand Down
Loading
Loading