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
59 changes: 59 additions & 0 deletions .github/workflows/dependabot-automerge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: dependabot-automerge

on:
workflow_call:
inputs:
merge-method:
description: How the pull request is merged once its checks pass ("squash", "merge" or "rebase")
type: string
default: squash
secrets:
token:
description: >-
Token that turns auto-merge on, kept as a Dependabot secret. GITHUB_TOKEN is used when absent,
but then the push of the merge starts no workflow on the default branch.
required: false

permissions:
contents: read

jobs:
automerge:
# only Dependabot's own pull requests; everyone else's are merged by a person
if: github.event.pull_request.user.login == 'dependabot[bot]'
runs-on: ubuntu-latest
permissions:
# a run that Dependabot starts gets a read-only token unless the workflow asks for these
contents: write
pull-requests: write
steps:
- id: metadata
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
# a grouped pull request reports its highest update, so one major in the group keeps it all manual
- name: Turn on auto-merge for a patch or minor update
if: >-
steps.metadata.outputs.update-type == 'version-update:semver-patch' ||
steps.metadata.outputs.update-type == 'version-update:semver-minor'
env:
GH_TOKEN: ${{ secrets.token || github.token }}
PR_URL: ${{ github.event.pull_request.html_url }}
BASE: ${{ github.event.pull_request.base.ref }}
METHOD: ${{ inputs.merge-method }}
run: |
set -euo pipefail
case "$METHOD" in squash | merge | rebase) ;; *) echo "::error::unknown merge-method $METHOD"; exit 1 ;; esac
# Auto-merge waits for what the branch requires. With nothing required it would merge at once,
# before CI has run, so it is turned on only behind a required check or a required review.
gated=$(gh api "repos/$GITHUB_REPOSITORY/rules/branches/$BASE" --jq '[.[] | select(
.type == "required_status_checks" or
(.type == "pull_request" and (.parameters.required_approving_review_count // 0) > 0)
)] | length')
if [ "$gated" = 0 ]; then
gated=$(gh api "repos/$GITHUB_REPOSITORY/branches/$BASE" \
--jq '(.protection.required_status_checks.contexts // []) | length')
fi
if [ "$gated" = 0 ]; then
echo "::warning::$BASE requires no check and no review, so auto-merge would merge at once; left off"
exit 0
fi
gh pr merge --auto "--$METHOD" "$PR_URL"
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ These switches are in no preset:
again: a pull request's caches when it closes, and in a weekly sweep the caches of closed pull
requests and the older copies of a cache that a newer run saved again (GitHub only; see
[CI and releases](docs/ci-and-releases.md#cache-cleanup)).
- `modules.automerge: true` adds a workflow that turns on auto-merge for Dependabot's patch and minor
updates, so they merge once the checks and reviews the default branch requires have passed; majors
stay manual (GitHub only; see [CI and releases](docs/ci-and-releases.md#dependabot-auto-merge)).

You can also start from the file instead of from detection: put a `.repokeeper.yml` you wrote
yourself in the repository and run `repokeeper init`. When the file is there and repokeeper has not
Expand Down Expand Up @@ -125,9 +128,9 @@ Requires Node.js 22.12 or newer.

| Guide | What it covers |
| --- | --- |
| [CI and releases](docs/ci-and-releases.md) | The workflows repokeeper writes, pinning or mirroring the reusable workflows, `repokeeper eject`, script and Dockerfile linting, release-please, the drift check, update pull requests, code scanning, cache cleanup |
| [CI and releases](docs/ci-and-releases.md) | The workflows repokeeper writes, pinning or mirroring the reusable workflows, `repokeeper eject`, script and Dockerfile linting, release-please, the drift check, update pull requests, code scanning, cache cleanup, Dependabot auto-merge |
| [Monorepos](docs/monorepos.md) | Stacks in folders: `stack_options.<stack>.directory`, what runs where, the limits |
| [GitHub settings](docs/github-settings.md) | `repokeeper github apply`: description, topics, merge settings, security, branch protection |
| [GitHub settings](docs/github-settings.md) | `repokeeper github apply`: description, topics, merge settings and auto-merge, security, branch protection |
| [GitLab](docs/gitlab.md) | What differs on GitLab, the image each stack's job runs in, how releases write the version, the two jobs that need a token, and `repokeeper gitlab apply`: merge settings, branch protection, the Renovate schedule |

## Pilots
Expand Down
42 changes: 42 additions & 0 deletions docs/ci-and-releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,48 @@ The switch is off unless you ask for it, and no preset turns it on: it deletes t
It is for GitHub only. On GitLab it writes nothing, because GitLab has no Actions caches: a runner keeps
its own cache and expires it there. It needs repokeeper 0.11.0 or newer.

## Dependabot auto-merge

Set `modules.automerge: true` and repokeeper writes `.github/workflows/automerge.yml`. On each of
Dependabot's pull requests, its job calls the reusable workflow `dependabot-automerge.yml`. That
workflow reads what the update is with
[`dependabot/fetch-metadata`](https://github.com/dependabot/fetch-metadata) and, for a **patch or
minor** update, turns on GitHub's auto-merge for the pull request. A **major** update is never
auto-merged. A grouped pull request counts as its biggest update, so one major in the group leaves the
whole group for you. Pull requests from anyone else are not touched.

Auto-merge does not merge right away. GitHub merges the pull request only once everything the default
branch requires has passed: the required checks, and the required reviews if the branch asks for any.
A failing check keeps the pull request open. Because of this, the workflow turns auto-merge on only
when the default branch requires something: a ruleset with a required check or with at least one
required approval, or classic branch protection with required status checks. When nothing is required,
auto-merge would merge before CI has run, so the run leaves it off and shows a warning. Set
`github.protect.required_checks` (see [GitHub settings](github-settings.md)) to the checks that must
pass, for example `["node / test (ubuntu-latest, 22)", "commits / commitlint"]`.

Two things to set up, once:

- **Allow auto-merge in the repository.** `repokeeper github apply` turns on the repository setting
(`allow_auto_merge`) whenever the module is on. You can also write it yourself as
`github.merge.auto_merge: true`. Setting it to `false` while the module is on is refused, because
the workflow would fail on every pull request.
- **Optional: a token, as the Dependabot secret `AUTOMERGE_TOKEN`.** With the default `GITHUB_TOKEN`,
the push that the merge makes to the default branch starts no workflow: the `ci` run on the default
branch and the `release` workflow wait for the next push. A fine-grained token with read and write
access to contents and pull requests, saved under *Settings → Secrets and variables → Dependabot*
(not under Actions: a run that Dependabot starts sees only Dependabot secrets), makes the merge
start them as usual.

The merge method is the first one `github.merge` allows, in this order: squash, merge commit, rebase.

**Presets.** No preset turns the module on. If you turn it on with the `strict` preset, its ruleset
still asks for one approving review on every pull request. Auto-merge then waits for a person to
approve, because the workflow never approves anything itself. Under `strict`, the module saves you the
merge click and nothing more.

On GitLab the switch writes nothing: Renovate opens the updates there and has its own `automerge`
setting. It needs repokeeper 0.11.0 or newer.

---

Back to the [README](../README.md).
6 changes: 5 additions & 1 deletion docs/github-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
github:
description: Keeps repositories on one standard
topics: [cli, conventional-commits]
merge: { squash: true, merge_commit: false, rebase: false, delete_branch_on_merge: true }
merge: { squash: true, merge_commit: false, rebase: false, delete_branch_on_merge: true, auto_merge: true }
security:
dependabot_alerts: true
dependabot_security_updates: true
Expand All @@ -30,6 +30,10 @@ on already. A private repository needs GitHub Secret Protection; without it GitH
and `github apply` reports its answer. Code scanning is not a setting here but a workflow: see
`modules.security` in [CI and releases](ci-and-releases.md#code-scanning).

`merge.auto_merge` is the repository's "Allow auto-merge" setting. With `modules.automerge` on,
`github apply` turns it on even when the line is not written; see
[Dependabot auto-merge](ci-and-releases.md#dependabot-auto-merge).

---

Back to the [README](../README.md).
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
".github/workflows/release-please.yml",
".github/workflows/repokeeper-check.yml",
".github/workflows/repokeeper-update.yml",
".github/workflows/codeql.yml"
".github/workflows/codeql.yml",
".github/workflows/dependabot-automerge.yml"
],
"engines": {
"node": ">=22.12.0"
Expand Down
1 change: 1 addition & 0 deletions playground/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const NEEDS_VERSION: string;
export const AUTOUPDATE_NEEDS_VERSION: string;
export const SECURITY_NEEDS_VERSION: string;
export const CACHE_CLEANUP_NEEDS_VERSION: string;
export const AUTOMERGE_NEEDS_VERSION: string;
export const STACK_NEEDS_VERSION: Record<string, string>;
export const STACKS: Array<{ id: string; label: string }>;
export const PLATFORM_STACKS: Record<"github" | "gitlab", string[]>;
Expand Down
22 changes: 20 additions & 2 deletions playground/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export const SECURITY_NEEDS_VERSION = "0.9.0";

/** The same for the cache cleanup. */
export const CACHE_CLEANUP_NEEDS_VERSION = "0.11.0";
/** The same for Dependabot auto-merge. */
export const AUTOMERGE_NEEDS_VERSION = "0.11.0";

/** The same for the stacks added after the standard the file names: an earlier repokeeper does not know them. */
export const STACK_NEEDS_VERSION = { docker: "0.9.0" };
Expand Down Expand Up @@ -79,6 +81,11 @@ export const MODULES = [
label: "Cache cleanup",
hint: "a workflow that deletes the Actions caches of closed pull requests and stale copies (GitHub)",
},
{
id: "automerge",
label: "Dependabot auto-merge",
hint: "auto-merge for patch and minor updates, after the required checks and reviews (GitHub)",
},
];

/** What stands in for a detail the form was left empty on; `problems` reports each one still in use. */
Expand All @@ -98,10 +105,11 @@ const ESSENTIAL_OFF = [
"autoupdate",
"security",
"cache_cleanup",
"automerge",
];

/** The switches no preset turns on. */
const IN_NO_PRESET = ["drift", "autoupdate", "security", "cache_cleanup"];
const IN_NO_PRESET = ["drift", "autoupdate", "security", "cache_cleanup", "automerge"];

/** The module switches of a preset, `health` as a plain on or off. */
export function presetModules(preset) {
Expand Down Expand Up @@ -176,6 +184,7 @@ export function buildConfig(input) {
...(modules.autoupdate ? { autoupdate: true } : {}),
...(modules.security ? { security: true } : {}),
...(modules.cache_cleanup ? { cache_cleanup: true } : {}),
...(modules.automerge ? { automerge: true } : {}),
health: modules.health
? {
license: health.license ? "MIT" : false,
Expand Down Expand Up @@ -256,6 +265,13 @@ export function problems(input) {
: "The cache cleanup is written for GitHub only: on GitLab the switch writes nothing.",
);
}
if (input.modules.automerge) {
found.push(
input.platform === "github"
? `Dependabot auto-merge needs repokeeper ${AUTOMERGE_NEEDS_VERSION} or newer.`
: "Dependabot auto-merge is written for GitHub only: on GitLab the switch writes nothing.",
);
}
if (input.platform === "github") {
const { mode, ref, source } = input.workflows;
if ((mode === "ref" || (mode === "mirror" && ref.trim() !== "")) && !/^[A-Za-z0-9_./-]+$/.test(ref.trim())) {
Expand Down Expand Up @@ -311,7 +327,9 @@ export function shortcutCommand(input) {
export function fileCommands(input) {
const config = buildConfig(input);
const commands = ["npx repokeeper init"];
if (config.github?.protect || config.github?.security) commands.push("npx repokeeper github apply");
// auto-merge is a setting of the repository as well, which `github apply` turns on for the module
if (config.github?.protect || config.github?.security || (input.platform === "github" && input.modules.automerge))
commands.push("npx repokeeper github apply");
if (config.gitlab?.protect) commands.push("npx repokeeper gitlab apply");
return commands;
}
Expand Down
15 changes: 13 additions & 2 deletions src/commands/github.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import type { Io } from "../cli.js";
import { loadConfig } from "../config/load.js";
import type { RepokeeperConfig } from "../config/types.js";
import { UsageError } from "../errors.js";
import { repoInfo } from "../git.js";
import { resolveToken, restApi } from "../github/api.js";
import { planGithub } from "../github/settings.js";
import { type GithubSettings, planGithub } from "../github/settings.js";
import type { CommandOptions } from "./report.js";

/**
* The settings `github apply` puts in place: the `github:` section, plus what a module needs of the
* repository. `modules.automerge` turns auto-merge on unless `github.merge.auto_merge` says otherwise.
*/
export function githubSettings(config: RepokeeperConfig): GithubSettings {
const settings = config.github ?? {};
if (!config.modules.automerge || settings.merge?.auto_merge !== undefined) return settings;
return { ...settings, merge: { ...settings.merge, auto_merge: true } };
}

/** `repokeeper github apply`: diff the repository's GitHub settings against `github:` and apply on confirmation. */
export async function githubApplyCommand(root: string, options: CommandOptions, io: Io): Promise<number> {
const config = await loadConfig(root);
Expand All @@ -14,7 +25,7 @@ export async function githubApplyCommand(root: string, options: CommandOptions,
if (!repo.owner) throw new UsageError("the origin remote is not a GitHub repository");
const slug = `${repo.owner}/${repo.name}`;
const api = io.githubApi ?? restApi(await resolveToken());
const changes = await planGithub(api, repo.owner, repo.name, config.github ?? {});
const changes = await planGithub(api, repo.owner, repo.name, githubSettings(config));
if (changes.length === 0) {
io.out(`GitHub settings of ${slug} match .repokeeper.yml`);
return 0;
Expand Down
7 changes: 6 additions & 1 deletion src/commands/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const MODULE_IDS: ModuleId[] = [
"autoupdate",
"security",
"cache_cleanup",
"automerge",
];

/**
Expand All @@ -35,7 +36,9 @@ export const MODULE_IDS: ModuleId[] = [
* A preset only switches on what works in every repository. Code scanning (`modules.security`) is in no
* preset: it is free on a public repository and needs GitHub Code Security on a private one, where a
* preset would otherwise leave a failing workflow behind. The cache cleanup (`modules.cache_cleanup`) is in
* none either: it deletes things, so it is yours to ask for.
* none either: it deletes things, so it is yours to ask for. Nor is Dependabot auto-merge
* (`modules.automerge`): merging without a person is a choice the repository's owner makes. Under
* `strict` it still waits for the review the ruleset requires, since the workflow never approves.
*/
export function applyPreset(config: RepokeeperConfig, preset: Preset): RepokeeperConfig {
if (preset === "standard") return config;
Expand All @@ -52,6 +55,7 @@ export function applyPreset(config: RepokeeperConfig, preset: Preset): Repokeepe
autoupdate: false,
security: false,
cache_cleanup: false,
automerge: false,
health: false,
},
};
Expand Down Expand Up @@ -122,6 +126,7 @@ export async function chooseModules(
autoupdate: true,
security: true,
cache_cleanup: true,
automerge: true,
health,
},
};
Expand Down
10 changes: 10 additions & 0 deletions src/commands/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type { RemovalAction } from "../sync/decide.js";
import type { SyncResult } from "../sync/sync.js";
import { TOOL_VERSIONS } from "../version.js";

const AUTOMERGE_WORKFLOW = ".github/workflows/automerge.yml";

export interface CommandOptions {
dryRun: boolean;
force: boolean;
Expand Down Expand Up @@ -97,6 +99,11 @@ export async function nextSteps(root: string, ctx: ModuleContext, result: SyncRe
"code scanning is free on a public repository; on a private one, turn on GitHub Code Security, or the security workflow fails when it uploads its results",
);
}
if (result.decisions.some((d) => d.output.path === AUTOMERGE_WORKFLOW && d.action === "create")) {
steps.push(
"run `repokeeper github apply` to allow auto-merge; Dependabot's updates then merge once the checks and reviews the default branch requires have passed, and not at all while it requires none",
);
}
if (wrote(".gitattributes")) {
const crlf = await crlfTrackedFiles(root);
if (crlf.length > 0) {
Expand Down Expand Up @@ -133,6 +140,9 @@ export async function printWarnings(root: string, ctx: ModuleContext, io: Io): P
"note: modules.cache_cleanup writes nothing on gitlab; there are no Actions caches there, and runner caches expire on the runner",
);
}
if (ctx.platform.id === "gitlab" && ctx.config.modules.automerge) {
io.out("note: modules.automerge writes nothing on gitlab; Renovate's own automerge setting does that work there");
}
if (ctx.platform.id === "gitlab") {
for (const stack of ctx.stacks) {
const os = stack.ci?.with.os;
Expand Down
11 changes: 10 additions & 1 deletion src/config/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,24 @@ export function parseConfig(text: string): RepokeeperConfig {
);
}
}
if (typed.modules?.automerge === true && typed.github?.merge?.auto_merge === false) {
// the workflow would fail on every Dependabot pull request
throw new ConfigError(
`${CONFIG_FILE}:${lineOf(doc, lineCounter, ["github", "merge", "auto_merge"])}: github.merge.auto_merge is false, ` +
"but modules.automerge needs auto-merge allowed; remove the line, or switch the module off",
);
}
return withDefaults(typed);
}

export function renderConfig(config: RepokeeperConfig): string {
// written only when on: a file without the line is one every earlier repokeeper reads as well
const { autoupdate, security, cache_cleanup, ...modules } = config.modules;
const { autoupdate, security, cache_cleanup, automerge, ...modules } = config.modules;
const asked = {
...(autoupdate ? { autoupdate } : {}),
...(security ? { security } : {}),
...(cache_cleanup ? { cache_cleanup } : {}),
...(automerge ? { automerge } : {}),
};
const written = {
...config,
Expand Down Expand Up @@ -114,6 +122,7 @@ function withDefaults(
autoupdate: modules.autoupdate ?? false,
security: modules.security ?? false,
cache_cleanup: modules.cache_cleanup ?? false,
automerge: modules.automerge ?? false,
health: modules.health,
},
owned: data.owned ?? [],
Expand Down
9 changes: 8 additions & 1 deletion src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const configSchema = {
autoupdate: flag,
security: flag,
cache_cleanup: flag,
automerge: flag,
health: {
anyOf: [
{ const: false },
Expand Down Expand Up @@ -81,7 +82,13 @@ export const configSchema = {
merge: {
type: "object",
additionalProperties: false,
properties: { squash: flag, merge_commit: flag, rebase: flag, delete_branch_on_merge: flag },
properties: {
squash: flag,
merge_commit: flag,
rebase: flag,
delete_branch_on_merge: flag,
auto_merge: flag,
},
},
security: {
type: "object",
Expand Down
Loading
Loading