diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml new file mode 100644 index 0000000..e752f01 --- /dev/null +++ b/.github/workflows/dependabot-automerge.yml @@ -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" diff --git a/README.md b/README.md index 20fdadf..662af71 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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..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 diff --git a/docs/ci-and-releases.md b/docs/ci-and-releases.md index 2053fad..4e6a08c 100644 --- a/docs/ci-and-releases.md +++ b/docs/ci-and-releases.md @@ -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). diff --git a/docs/github-settings.md b/docs/github-settings.md index 8ffc249..0f4d2dd 100644 --- a/docs/github-settings.md +++ b/docs/github-settings.md @@ -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 @@ -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). diff --git a/package.json b/package.json index 28399f8..7fdb8d9 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/playground/config.d.ts b/playground/config.d.ts index aa30667..e34b9b8 100644 --- a/playground/config.d.ts +++ b/playground/config.d.ts @@ -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; export const STACKS: Array<{ id: string; label: string }>; export const PLATFORM_STACKS: Record<"github" | "gitlab", string[]>; diff --git a/playground/config.js b/playground/config.js index 43a47a0..fa510bc 100644 --- a/playground/config.js +++ b/playground/config.js @@ -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" }; @@ -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. */ @@ -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) { @@ -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, @@ -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())) { @@ -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; } diff --git a/src/commands/github.ts b/src/commands/github.ts index 497918b..80da5d3 100644 --- a/src/commands/github.ts +++ b/src/commands/github.ts @@ -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 { const config = await loadConfig(root); @@ -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; diff --git a/src/commands/presets.ts b/src/commands/presets.ts index 19efa64..092bac0 100644 --- a/src/commands/presets.ts +++ b/src/commands/presets.ts @@ -27,6 +27,7 @@ export const MODULE_IDS: ModuleId[] = [ "autoupdate", "security", "cache_cleanup", + "automerge", ]; /** @@ -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; @@ -52,6 +55,7 @@ export function applyPreset(config: RepokeeperConfig, preset: Preset): Repokeepe autoupdate: false, security: false, cache_cleanup: false, + automerge: false, health: false, }, }; @@ -122,6 +126,7 @@ export async function chooseModules( autoupdate: true, security: true, cache_cleanup: true, + automerge: true, health, }, }; diff --git a/src/commands/report.ts b/src/commands/report.ts index ca850dd..014fec4 100644 --- a/src/commands/report.ts +++ b/src/commands/report.ts @@ -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; @@ -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) { @@ -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; diff --git a/src/config/load.ts b/src/config/load.ts index 9cf1bfc..e521b1b 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -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, @@ -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 ?? [], diff --git a/src/config/schema.ts b/src/config/schema.ts index e15b165..e59ca57 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -28,6 +28,7 @@ export const configSchema = { autoupdate: flag, security: flag, cache_cleanup: flag, + automerge: flag, health: { anyOf: [ { const: false }, @@ -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", diff --git a/src/config/types.ts b/src/config/types.ts index 89d5ac9..6d0b8c6 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -43,6 +43,8 @@ export interface ModulesConfig { security: boolean; /** A workflow that deletes the Actions caches nothing will read again; off unless asked for. */ cache_cleanup: boolean; + /** A workflow that turns on auto-merge for Dependabot's patch and minor updates; off unless asked for. */ + automerge: boolean; health: HealthConfig | false; } @@ -89,6 +91,7 @@ export function defaultConfig(input: { autoupdate: false, security: false, cache_cleanup: false, + automerge: false, health: { license: "MIT", copyright: input.copyright, contact: input.contact, codeowners: input.codeowners }, }, owned: [], diff --git a/src/github/settings.ts b/src/github/settings.ts index ce83dff..b894670 100644 --- a/src/github/settings.ts +++ b/src/github/settings.ts @@ -20,7 +20,14 @@ export interface GithubSettings { }; description?: string; topics?: string[]; - merge?: { squash?: boolean; merge_commit?: boolean; rebase?: boolean; delete_branch_on_merge?: boolean }; + merge?: { + squash?: boolean; + merge_commit?: boolean; + rebase?: boolean; + delete_branch_on_merge?: boolean; + /** Lets a pull request merge by itself once what the branch requires has passed. */ + auto_merge?: boolean; + }; security?: { dependabot_alerts?: boolean; dependabot_security_updates?: boolean; @@ -85,6 +92,7 @@ const MERGE_FIELDS = { merge_commit: "allow_merge_commit", rebase: "allow_rebase_merge", delete_branch_on_merge: "delete_branch_on_merge", + auto_merge: "allow_auto_merge", } as const; interface RuleSummary { diff --git a/src/model.ts b/src/model.ts index 79739b2..765a7fe 100644 --- a/src/model.ts +++ b/src/model.ts @@ -164,6 +164,8 @@ export interface PlatformAdapter { codeScanning(ctx: ModuleContext): Output[]; /** A workflow that deletes the CI caches nothing will read again; empty where the platform has none. */ cacheCleanup(ctx: ModuleContext): Output[]; + /** A workflow that turns on auto-merge for patch and minor dependency updates; empty where the platform has none. */ + dependencyAutomerge(ctx: ModuleContext): Output[]; /** release-please configuration, its manifest and the caller release workflow. `directory` is the released stack's folder. */ releaseAutomation(ctx: ModuleContext, release: ReleaseInfo, directory?: string): Output[]; } diff --git a/src/modules/automerge.ts b/src/modules/automerge.ts new file mode 100644 index 0000000..bc7e346 --- /dev/null +++ b/src/modules/automerge.ts @@ -0,0 +1,7 @@ +import type { Module } from "../model.js"; + +export const automergeModule: Module = { + id: "automerge", + enabled: (config) => config.modules.automerge, + outputs: (ctx) => ctx.platform.dependencyAutomerge(ctx), +}; diff --git a/src/modules/index.ts b/src/modules/index.ts index 84f696e..72626cc 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,4 +1,5 @@ import type { Module } from "../model.js"; +import { automergeModule } from "./automerge.js"; import { autoupdateModule } from "./autoupdate.js"; import { cacheCleanupModule } from "./cache-cleanup.js"; import { ciModule } from "./ci.js"; @@ -23,4 +24,5 @@ export const MODULES: Module[] = [ autoupdateModule, securityModule, cacheCleanupModule, + automergeModule, ]; diff --git a/src/platforms/github.ts b/src/platforms/github.ts index de764fc..0f9863a 100644 --- a/src/platforms/github.ts +++ b/src/platforms/github.ts @@ -443,6 +443,26 @@ export const githubPlatform: PlatformAdapter = { ]; }, + dependencyAutomerge(ctx: ModuleContext): Output[] { + // not the name of the reusable workflow it calls: a repository that keeps its own copies has both + const path = ".github/workflows/automerge.yml"; + return [ + ...localWorkflows(ctx, "automerge", ["dependabot-automerge.yml"]), + workflowKey("automerge", path, ["name"], "automerge"), + workflowKey("automerge", path, ["on"], { pull_request: null }), + workflowKey("automerge", path, ["permissions"], { contents: "read" }), + workflowKey("automerge", path, ["jobs", "automerge"], { + uses: workflowRef(ctx, "dependabot-automerge.yml"), + // what the called job asks for; a run that Dependabot starts has a read-only token without it + permissions: { contents: "write", "pull-requests": "write" }, + with: { "merge-method": mergeMethod(ctx) }, + // a Dependabot secret: a run that Dependabot starts sees no Actions secret + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, not a JS template + secrets: { token: "${{ secrets.AUTOMERGE_TOKEN }}" }, + }), + ]; + }, + releaseAutomation(ctx: ModuleContext, release: ReleaseInfo, directory?: string): Output[] { const path = ".github/workflows/release.yml"; const seed = release.version ?? ctx.repo.releasedVersion ?? "0.0.0"; @@ -514,6 +534,14 @@ export const githubPlatform: PlatformAdapter = { }, }; +/** The first merge method `github.merge` leaves allowed, squash first; GitHub allows all three by default. */ +function mergeMethod(ctx: ModuleContext): "squash" | "merge" | "rebase" { + const merge = ctx.config.github?.merge ?? {}; + if (merge.squash !== false) return "squash"; + if (merge.merge_commit !== false) return "merge"; + return "rebase"; +} + // Runs the stack job unless the changes job said "only listed paths". A changes job that failed // gives no answer, and then the stack job runs: an error never skips the tests. // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, not a JS template diff --git a/src/platforms/gitlab.ts b/src/platforms/gitlab.ts index 40fabd0..cbeee90 100644 --- a/src/platforms/gitlab.ts +++ b/src/platforms/gitlab.ts @@ -221,6 +221,11 @@ export const gitlabPlatform: PlatformAdapter = { return []; }, + // Renovate opens the updates on GitLab, with its own `automerge`; repokeeper does not write it yet + dependencyAutomerge(): Output[] { + return []; + }, + ciWorkflow(ctx: ModuleContext): Output[] { const jobs: Output[] = []; const node = ctx.stacks.find((stack) => stack.id === "node")?.ci; diff --git a/src/templates.ts b/src/templates.ts index 14e6f5a..410328d 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -8,7 +8,7 @@ export function readTemplate(relativePath: string): string { /** File names of the reusable workflows generated CI and release files can call. */ export const REUSABLE_WORKFLOW = - /^(stack-[a-z]+|commitlint|release-please|repokeeper-check|repokeeper-update|codeql)\.yml$/; + /^(stack-[a-z]+|commitlint|release-please|repokeeper-check|repokeeper-update|codeql|dependabot-automerge)\.yml$/; /** Reads one of the reusable workflows shipped with the package, with LF endings. */ export function readWorkflow(file: string): string { diff --git a/test/config.test.ts b/test/config.test.ts index 55295ac..a4265da 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -26,7 +26,7 @@ describe("parseConfig", () => { expect(parseConfig(renderConfig(base))).toEqual(base); }); - it("defaults boolean modules to true (drift, autoupdate, security and cache_cleanup to false) and optional lists to empty", () => { + it("defaults boolean modules to true (drift, autoupdate, security, cache_cleanup and automerge to false) and optional lists to empty", () => { const config = parseConfig( "schema: 1\nstandard: 1.0.0\nplatform: github\nstacks: [node]\nmodules:\n health: false\n", ); @@ -42,6 +42,7 @@ describe("parseConfig", () => { autoupdate: false, security: false, cache_cleanup: false, + automerge: false, health: false, }); expect(config.owned).toEqual([]); diff --git a/test/modules-automerge.test.ts b/test/modules-automerge.test.ts new file mode 100644 index 0000000..05f1bc3 --- /dev/null +++ b/test/modules-automerge.test.ts @@ -0,0 +1,176 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; +import { githubSettings } from "../src/commands/github.js"; +import { applyPreset } from "../src/commands/presets.js"; +import { nextSteps, printWarnings } from "../src/commands/report.js"; +import { parseConfig, renderConfig } from "../src/config/load.js"; +import type { ApiResponse, GitHubApi } from "../src/github/api.js"; +import { planGithub } from "../src/github/settings.js"; +import type { Output } from "../src/model.js"; +import { automergeModule } from "../src/modules/automerge.js"; +import { MODULES } from "../src/modules/index.js"; +import { planOutputs } from "../src/plan.js"; +import type { SyncResult } from "../src/sync/sync.js"; +import { listWorkflows } from "../src/templates.js"; +import { WORKFLOW_REF } from "../src/version.js"; +import { capture, gitlabContext, makeContext, tempDir } from "./helpers.js"; + +const PATH = ".github/workflows/automerge.yml"; +const REUSABLE = ".github/workflows/dependabot-automerge.yml"; +const keys = (outputs: Output[]) => + Object.fromEntries(outputs.flatMap((o) => (o.kind === "yaml" ? [[o.keyPath.join("."), o.value]] : []))); +const on = (overrides: Parameters[0] = {}) => + makeContext({ ...overrides, modules: { automerge: true, ...overrides.modules } }); +const job = (ctx = on()) => keys(automergeModule.outputs(ctx))["jobs.automerge"] as Record; +const CONFIG = "schema: 1\nstandard: 1.0.0\nplatform: github\nstacks: [node]\n"; + +interface Step { + id?: string; + if?: string; + uses?: string; + env?: Record; + run?: string; +} + +describe("automerge module", () => { + it("is off unless asked for, and round-trips through the configuration file", () => { + expect(makeContext().config.modules.automerge).toBe(false); + expect(automergeModule.enabled(makeContext().config)).toBe(false); + expect(planOutputs(makeContext()).some((output) => output.path === PATH)).toBe(false); + expect(renderConfig(makeContext().config)).not.toContain("automerge"); + + expect(automergeModule.enabled(on().config)).toBe(true); + expect(parseConfig(renderConfig(on().config)).modules.automerge).toBe(true); + expect(MODULES).toContain(automergeModule); + }); + + it("is in no preset; under strict the required review still gates the merge", () => { + for (const preset of ["essential", "standard", "strict"] as const) { + expect(applyPreset(makeContext().config, preset).modules.automerge).toBe(false); + } + // turned on by hand under strict: the ruleset asks for a review, and the workflow never gives one + const strict = applyPreset(makeContext({ modules: { automerge: true } }).config, "strict"); + expect(strict.modules.automerge).toBe(true); + expect(strict.github?.protect).toMatchObject({ required_approvals: 1 }); + expect(readFileSync(REUSABLE, "utf8")).not.toMatch(/gh pr review|--approve/); + }); + + it("writes a workflow that calls the reusable one on Dependabot's pull requests", () => { + const outputs = automergeModule.outputs(on()); + // the caller only: the reusable workflow it calls stays in the repokeeper repository + expect(new Set(outputs.map((output) => output.path))).toEqual(new Set([PATH])); + expect(outputs.every((output) => output.module === "automerge")).toBe(true); + + const out = keys(outputs); + expect(out.name).toBe("automerge"); + expect(out.on).toEqual({ pull_request: null }); + expect(out.permissions).toEqual({ contents: "read" }); + expect(out["jobs.automerge"]).toEqual({ + uses: `vannt-dev/repokeeper/.github/workflows/dependabot-automerge.yml@${WORKFLOW_REF}`, + permissions: { contents: "write", "pull-requests": "write" }, + with: { "merge-method": "squash" }, + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression + secrets: { token: "${{ secrets.AUTOMERGE_TOKEN }}" }, + }); + }); + + it("merges the way the repository allows", () => { + const method = (merge: Record) => + (job(on({ config: { github: { merge } } })).with as Record)["merge-method"]; + expect(method({ squash: true, merge_commit: false })).toBe("squash"); + expect(method({ squash: false })).toBe("merge"); + expect(method({ squash: false, merge_commit: false, rebase: true })).toBe("rebase"); + }); + + it("follows where the reusable workflows are taken from", () => { + expect(job(on({ repo: { owner: "vannt-dev", name: "repokeeper" } }))).toMatchObject({ + uses: "./.github/workflows/dependabot-automerge.yml", + }); + const local = automergeModule.outputs(on({ config: { github: { workflows: { source: "local" } } } })); + expect(local.some((output) => output.kind === "seed" && output.path === REUSABLE)).toBe(true); + expect(keys(local)["jobs.automerge"]).toMatchObject({ uses: `./${REUSABLE}` }); + expect(listWorkflows()).toContain("dependabot-automerge.yml"); + }); + + it("writes nothing on GitLab, and says what does the work there", async () => { + expect(automergeModule.outputs(gitlabContext({ modules: { automerge: true } }))).toEqual([]); + const noted = capture(); + await printWarnings(await tempDir(), gitlabContext({ modules: { automerge: true } }), noted.io); + expect(noted.out).toContain( + "note: modules.automerge writes nothing on gitlab; Renovate's own automerge setting does that work there", + ); + }); +}); + +describe("the reusable auto-merge workflow", () => { + const wf = parse(readFileSync(REUSABLE, "utf8")) as { + jobs: { automerge: { if: string; permissions: Record; steps: Step[] } }; + }; + const { automerge } = wf.jobs; + const [metadata, merge] = automerge.steps as [Step, Step]; + + it("acts on Dependabot's pull requests only", () => { + expect(automerge.if).toBe("github.event.pull_request.user.login == 'dependabot[bot]'"); + expect(automerge.permissions).toEqual({ contents: "write", "pull-requests": "write" }); + }); + + it("turns auto-merge on for patch and minor updates, never for a major", () => { + expect(metadata.uses).toMatch(/^dependabot\/fetch-metadata@[0-9a-f]{40}$/); + expect(merge.if).toContain("steps.metadata.outputs.update-type == 'version-update:semver-patch'"); + expect(merge.if).toContain("steps.metadata.outputs.update-type == 'version-update:semver-minor'"); + expect(merge.if).not.toContain("semver-major"); + // auto-merge, not a merge: GitHub waits for the required checks and reviews + expect(merge.run).toContain('gh pr merge --auto "--$METHOD" "$PR_URL"'); + }); + + it("leaves auto-merge off where nothing is required, since it would then merge at once", () => { + expect(merge.run).toContain('rules/branches/$BASE"'); + expect(merge.run).toContain('.type == "required_status_checks"'); + expect(merge.run).toContain("required_approving_review_count // 0) > 0"); + expect(merge.run).toMatch(/requires no check and no review[^\n]*\n\s*exit 0/); + }); +}); + +describe("the auto-merge setting", () => { + it("is turned on by github apply when the module is on, unless written", () => { + expect(githubSettings(makeContext().config).merge).toBeUndefined(); + expect(githubSettings(on().config).merge).toEqual({ auto_merge: true }); + const written = on({ config: { github: { merge: { squash: true, auto_merge: true } } } }).config; + expect(githubSettings(written).merge).toEqual({ squash: true, auto_merge: true }); + }); + + it("is applied as allow_auto_merge", async () => { + const writes: { method: string; body?: unknown }[] = []; + const api: GitHubApi = { + async request(method, _path, body): Promise { + if (method !== "GET") writes.push({ method, body }); + return { status: 200, data: { allow_auto_merge: false } }; + }, + }; + const changes = await planGithub(api, "o", "r", { merge: { auto_merge: true } }); + expect(changes.map((change) => [change.setting, change.from, change.to])).toEqual([ + ["merge.auto_merge", "false", "true"], + ]); + for (const change of changes) await change.apply(api); + expect(writes).toEqual([{ method: "PATCH", body: { allow_auto_merge: true } }]); + }); + + it("refuses the module with auto-merge written off", () => { + expect(() => + parseConfig(`${CONFIG}modules:\n automerge: true\n health: false\ngithub:\n merge:\n auto_merge: false\n`), + ).toThrow( + /\.repokeeper\.yml:10: github\.merge\.auto_merge is false, but modules\.automerge needs auto-merge allowed/, + ); + }); + + it("is named in the next steps once, when the workflow is new", async () => { + const workflow: Output = { kind: "yaml", module: "automerge", path: PATH, keyPath: ["name"], value: "automerge" }; + const result = (action: string): SyncResult => + ({ decisions: [{ output: workflow, action }], removals: [] }) as unknown as SyncResult; + const created = await nextSteps(".", on(), result("create")); + expect(created).toHaveLength(1); + expect(created[0]).toContain("run `repokeeper github apply` to allow auto-merge"); + expect(await nextSteps(".", on(), result("unchanged"))).toEqual([]); + }); +}); diff --git a/test/platforms-gitlab-stacks.test.ts b/test/platforms-gitlab-stacks.test.ts index c4df889..565785b 100644 --- a/test/platforms-gitlab-stacks.test.ts +++ b/test/platforms-gitlab-stacks.test.ts @@ -312,7 +312,8 @@ describe("gitlab ci for the other stacks", () => { }); } expect([...pinned].sort()).toEqual([...MANAGED_IMAGES].sort()); - }); + // one init per fixture: about 5 s here, and past the 20 s default on a busy Windows runner + }, 60_000); it("says that os has no effect, whichever stack names one", async () => { const python = await onGitlab("python", {}, " python:\n os: [ubuntu-latest, windows-latest]"); diff --git a/test/playground.test.ts b/test/playground.test.ts index d1030fd..e8ec49c 100644 --- a/test/playground.test.ts +++ b/test/playground.test.ts @@ -260,6 +260,11 @@ describe("playground", () => { expect(shortcutCommand(custom)).toBeNull(); expect(shortcutCommand({ ...filled("strict"), protect: { enabled: true, approvals: 2 } })).toBeNull(); expect(shortcutCommand({ ...filled("standard"), security: true })).toBeNull(); + const automerge = filled("standard"); + automerge.modules.automerge = true; + expect(shortcutCommand(automerge)).toBeNull(); + expect(fileCommands(automerge)).toEqual(["npx repokeeper init", "npx repokeeper github apply"]); + expect(fileCommands({ ...automerge, platform: "gitlab" })).toEqual(["npx repokeeper init"]); const unlicensed = filled("standard"); unlicensed.health.license = false; expect(shortcutCommand(unlicensed)).toBeNull(); diff --git a/test/presets.test.ts b/test/presets.test.ts index 40a7992..e0d7874 100644 --- a/test/presets.test.ts +++ b/test/presets.test.ts @@ -73,6 +73,7 @@ describe("presets", () => { autoupdate: false, security: false, cache_cleanup: false, + automerge: false, health: false, }); expect(applyPreset(base(), "essential").github).toBeUndefined(); @@ -211,12 +212,13 @@ describe("init --interactive", () => { "Apply autoupdate?", "Apply security?", "Apply cache_cleanup?", + "Apply automerge?", "Write these files?", ]; it("names the files of each part, takes the answers, and writes only what was chosen", async () => { const dir = await nodeRepo(); - // editor, ignore, commits, hooks, ci, release, deps, health, drift, update, security, caches, write + // editor, ignore, commits, hooks, ci, release, deps, health, drift, update, security, caches, automerge, write const init = await repokeeper( dir, ["init", "--interactive"], @@ -233,6 +235,7 @@ describe("init --interactive", () => { undefined, undefined, undefined, + undefined, true, ], ); @@ -246,6 +249,7 @@ describe("init --interactive", () => { expect(init.out).toContain("autoupdate:\n .github/workflows/repokeeper-autoupdate.yml"); expect(init.out).toContain("security:\n .github/workflows/security.yml"); expect(init.out).toContain("cache_cleanup:\n .github/workflows/cache-cleanup.yml"); + expect(init.out).toContain("automerge:\n .github/workflows/automerge.yml"); const config = parse(await readFile(join(dir, ".repokeeper.yml"), "utf8")); expect(config.modules).toMatchObject({ commits: true, hooks: false, ci: true, release: false, deps: true }); @@ -260,6 +264,9 @@ describe("init --interactive", () => { // and for the cache cleanup expect(config.modules).not.toHaveProperty("cache_cleanup"); expect(existsSync(join(dir, ".github/workflows/cache-cleanup.yml"))).toBe(false); + // and for Dependabot auto-merge + expect(config.modules).not.toHaveProperty("automerge"); + expect(existsSync(join(dir, ".github/workflows/automerge.yml"))).toBe(false); expect(existsSync(join(dir, "lefthook.yml"))).toBe(false); expect(existsSync(join(dir, "release-please-config.json"))).toBe(false); expect(existsSync(join(dir, "commitlint.config.mjs"))).toBe(true); @@ -287,6 +294,8 @@ describe("init --interactive", () => { answers[10] = true; // and the cache cleanup answers[11] = true; + // and Dependabot auto-merge + answers[12] = true; await repokeeper(other, ["init", "-i", "--preset", "essential"], answers); expect(parse(await readFile(join(other, ".repokeeper.yml"), "utf8")).modules.autoupdate).toBe(true); expect(existsSync(join(other, ".github/workflows/repokeeper-autoupdate.yml"))).toBe(true); @@ -294,6 +303,8 @@ describe("init --interactive", () => { expect(existsSync(join(other, ".github/workflows/security.yml"))).toBe(true); expect(parse(await readFile(join(other, ".repokeeper.yml"), "utf8")).modules.cache_cleanup).toBe(true); expect(existsSync(join(other, ".github/workflows/cache-cleanup.yml"))).toBe(true); + expect(parse(await readFile(join(other, ".repokeeper.yml"), "utf8")).modules.automerge).toBe(true); + expect(existsSync(join(other, ".github/workflows/automerge.yml"))).toBe(true); expect(parse(await readFile(join(other, ".repokeeper.yml"), "utf8")).modules.health.license).toBe("MIT"); expect(existsSync(join(other, "CONTRIBUTING.md"))).toBe(true); }); diff --git a/test/workflows.test.ts b/test/workflows.test.ts index cfc330f..f3bfbd6 100644 --- a/test/workflows.test.ts +++ b/test/workflows.test.ts @@ -58,6 +58,7 @@ const REUSABLE = [ "stack-ruby.yml", "stack-docker.yml", "codeql.yml", + "dependabot-automerge.yml", ]; /** Fixture jobs in workflow-tests.yml and the pack that resolves each fixture. Each stack task appends its rows. */