From 90024de7bc5b8c408e47ad1ca847c1b1b7a67f54 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Mon, 12 Oct 2026 22:38:37 +0700 Subject: [PATCH 1/2] feat: open a pull request when the standard has moved A new standard reached a repository only when someone ran repokeeper update in it: twelve repositories meant twelve runs and twelve commits by hand. modules.autoupdate (off unless asked for, GitHub only) writes a weekly workflow that runs repokeeper update with the newest repokeeper and, when that changes anything, pushes the branch repokeeper/update and opens a pull request; a later run moves the same branch and edits the same pull request. The work is in a new reusable workflow, repokeeper-update.yml. What the pull request holds is what the command writes by hand: files edited locally are kept and named in the body, with a warning on the run instead of a failure. A package.json the update changes has its package-lock.json brought in step, so npm ci still installs. An update usually changes a workflow file, which GITHUB_TOKEN may not push, so the job takes a REPOKEEPER_TOKEN secret; without one it falls back to GITHUB_TOKEN, says why a refused push failed, and has the held runs of its pull request approved as the release job does. The switch is written to .repokeeper.yml only when on, so a file without it stays readable by every earlier repokeeper. --- .github/workflows/repokeeper-update.yml | 141 ++++++++++++++++++++++++ README.md | 7 +- docs/ci-and-releases.md | 29 +++++ playground/config.d.ts | 1 + playground/config.js | 22 +++- src/commands/presets.ts | 3 + src/commands/report.ts | 5 + src/config/load.ts | 12 +- src/config/schema.ts | 1 + src/config/types.ts | 3 + src/model.ts | 2 + src/modules/autoupdate.ts | 7 ++ src/modules/index.ts | 2 + src/platforms/github.ts | 41 ++++++- src/platforms/gitlab.ts | 6 + src/templates.ts | 2 +- test/config.test.ts | 3 +- test/modules-autoupdate.test.ts | 72 ++++++++++++ test/presets.test.ts | 26 +++-- 19 files changed, 369 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/repokeeper-update.yml create mode 100644 src/modules/autoupdate.ts create mode 100644 test/modules-autoupdate.test.ts diff --git a/.github/workflows/repokeeper-update.yml b/.github/workflows/repokeeper-update.yml new file mode 100644 index 0000000..f7f0223 --- /dev/null +++ b/.github/workflows/repokeeper-update.yml @@ -0,0 +1,141 @@ +name: repokeeper-update + +on: + workflow_call: + inputs: + version: + description: repokeeper version to update with; "latest" follows every new standard + type: string + default: latest + branch: + description: Branch the update is pushed to and the pull request is opened from + type: string + default: repokeeper/update + secrets: + token: + description: Token to push the branch and open the pull request with. GITHUB_TOKEN is used when absent, but it may not push a change to a workflow file, and its pull requests do not start CI by themselves. + required: false + outputs: + changed: + description: "'true' when the update changed files and a pull request carries them" + value: ${{ jobs.update.outputs.changed }} + pr_branch: + description: Branch of the pull request this run opened or updated with GITHUB_TOKEN, whose pull_request runs GitHub holds for approval + value: ${{ jobs.update.outputs.pr_branch }} + +permissions: + contents: read + +jobs: + update: + name: repokeeper update + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + changed: ${{ steps.update.outputs.changed }} + pr_branch: ${{ steps.update.outputs.pr_branch }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # the branch is pushed with this token, so it has to stay in the checkout + token: ${{ secrets.token || github.token }} + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "24" + - name: Update to the newest standard and propose it + id: update + env: + VERSION: ${{ inputs.version }} + BRANCH: ${{ inputs.branch }} + BASE: ${{ github.event.repository.default_branch }} + GH_TOKEN: ${{ secrets.token || github.token }} + OWN_TOKEN: ${{ secrets.token != '' }} + run: | + set -euo pipefail + work="${RUNNER_TEMP:-/tmp}" + standard() { sed -n 's/^standard:[[:space:]]*//p' .repokeeper.yml | tr -d "\"'" | head -1; } + before=$(standard) + status=0 + npx --yes "repokeeper@$VERSION" update > "$work/repokeeper-update.txt" 2>&1 || status=$? + cat "$work/repokeeper-update.txt" + # update exits 1 when it kept files that were edited by hand; what it did write is still worth + # proposing, and the files it kept are named in its report. Anything else is a failure. + kept=$(grep -m1 "kept because they were edited locally" "$work/repokeeper-update.txt" || true) + if [ "$status" -ne 0 ] && [ -z "$kept" ]; then + exit "$status" + fi + if [ -n "$kept" ]; then + echo "::warning::$kept" + fi + + if [ -z "$(git status --porcelain)" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "::notice::nothing to update: the repository is on standard $before" + exit 0 + fi + after=$(standard) + + # a package.json the update changed leaves its lockfile behind, and `npm ci` then refuses both + : > "$work/lockfiles.txt" + while IFS= read -r manifest; do + dir=$(dirname "$manifest") + if [ -f "$dir/package-lock.json" ]; then + (cd "$dir" && npm install --package-lock-only --ignore-scripts --no-audit --no-fund > /dev/null) + echo "- \`$dir/package-lock.json\` was brought in step with \`$manifest\`." >> "$work/lockfiles.txt" + elif [ -f "$dir/pnpm-lock.yaml" ] || [ -f "$dir/yarn.lock" ] || [ -f "$dir/bun.lock" ] || [ -f "$dir/bun.lockb" ]; then + echo "- \`$manifest\` changed and its lockfile did not: run your package manager's install on this branch and commit the lockfile." >> "$work/lockfiles.txt" + fi + done < <(git diff --name-only | grep -E '(^|/)package\.json$' || true) + if [ "$before" = "$after" ]; then + title="chore(repokeeper): bring the managed files back to standard $after" + else + title="chore(repokeeper): update standard to $after" + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch --force-create "$BRANCH" + git add --all + git commit --quiet --message "$title" + # one branch, moved every time: an open pull request from an earlier run is brought up to date + if ! git push --force origin "$BRANCH" 2> "$work/push.txt"; then + cat "$work/push.txt" >&2 + if grep -qi "workflow" "$work/push.txt"; then + echo "::error::this update changes a workflow file, which GITHUB_TOKEN may not push. Add a REPOKEEPER_TOKEN secret: a token of your own with read and write access to contents, pull requests and workflows." + fi + exit 1 + fi + + { + if [ "$before" = "$after" ]; then + echo "\`repokeeper update\` put the managed files of this repository back to standard $after." + else + echo "\`repokeeper update\` moved this repository from standard $before to $after." + fi + echo + echo "Files you changed yourself, and the ones listed under \`owned\`, were left alone. What it reported:" + echo + echo '```' + cat "$work/repokeeper-update.txt" + echo '```' + echo + if [ -s "$work/lockfiles.txt" ]; then + cat "$work/lockfiles.txt" + echo + fi + echo "Opened by the \`repokeeper update\` workflow (\`modules.autoupdate\` in \`.repokeeper.yml\`); a later run moves this same branch." + } > "$work/body.md" + + number=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty') + if [ -z "$number" ]; then + gh pr create --head "$BRANCH" --base "$BASE" --title "$title" --body-file "$work/body.md" + else + gh pr edit "$number" --title "$title" --body-file "$work/body.md" + fi + echo "changed=true" >> "$GITHUB_OUTPUT" + # a pull request opened with the caller's own token starts CI by itself + if [ "$OWN_TOKEN" = "false" ]; then + echo "pr_branch=$BRANCH" >> "$GITHUB_OUTPUT" + fi diff --git a/README.md b/README.md index 749499d..5829f87 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,11 @@ alone. Every write command accepts `--dry-run`. Either way the choice ends up as plain `modules:` switches in `.repokeeper.yml`, which you can change later and apply with `repokeeper update`. +One switch is in no preset: `modules.autoupdate: true` adds a weekly workflow that runs +`repokeeper update` with the newest repokeeper and opens a pull request when the standard has moved, so a +new standard reaches every repository without anyone running the command in each (GitHub only; see +[CI and releases](docs/ci-and-releases.md#update-pull-requests)). + 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 applied anything yet, `init` applies it as it is written. Nothing is detected, `--stack`, @@ -106,7 +111,7 @@ 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 linting, release-please, the drift check | +| [CI and releases](docs/ci-and-releases.md) | The workflows repokeeper writes, pinning or mirroring the reusable workflows, `repokeeper eject`, script linting, release-please, the drift check, update pull requests | | [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 | | [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 | diff --git a/docs/ci-and-releases.md b/docs/ci-and-releases.md index 60558c2..25ce0bb 100644 --- a/docs/ci-and-releases.md +++ b/docs/ci-and-releases.md @@ -73,6 +73,35 @@ Set `modules.drift: true` to add a `repokeeper` job to `ci.yml` that runs `repok the version that wrote the standard, so a pull request that edits a managed file fails until the edit is resolved. +## Update pull requests + +Set `modules.autoupdate: true` and repokeeper writes `.github/workflows/repokeeper-autoupdate.yml`: every +Monday, and whenever you start it by hand, it runs `repokeeper update` with the newest repokeeper. When +that changes anything, it pushes the branch `repokeeper/update` and opens a pull request named +`chore(repokeeper): update standard to X.Y.Z`; a later run moves the same branch and edits the same pull +request. When nothing changes, the run ends with a notice and no pull request. + +What the pull request holds is what `repokeeper update` would write on your machine: + +- Files you edited by hand, and the ones listed under `owned`, are left alone. The body of the pull request + carries the update's own report, so the files it kept are named there, and the run shows a warning. +- When the update changes a `package.json` that has a `package-lock.json` beside it, the lockfile is brought + in step, so `npm ci` still installs. With pnpm, Yarn or Bun the body says that the lockfile is still to + be updated on the branch. + +The job needs a token: + +- **A token of your own, as the secret `REPOKEEPER_TOKEN`**, with read and write access to contents, pull + requests and workflows. This is what most repositories need, because an update usually changes a file + under `.github/workflows/`, which `GITHUB_TOKEN` may not push. A pull request opened with your own token + also starts CI by itself. +- **Without the secret**, `GITHUB_TOKEN` is used. It can propose an update that touches no workflow file; + for one that does, the run fails and says so. GitHub holds the `pull_request` runs of a pull request + that `GITHUB_TOKEN` opened, so the `update-pr-ci` job approves them, as `release-pr-ci` does for releases. + +The switch is off unless you ask for it, and it is for GitHub: on GitLab it writes nothing, and +`repokeeper update` run by hand does the same work. It needs repokeeper 0.9.0 or newer. + --- Back to the [README](../README.md). diff --git a/playground/config.d.ts b/playground/config.d.ts index 8881a70..61b5c93 100644 --- a/playground/config.d.ts +++ b/playground/config.d.ts @@ -12,6 +12,7 @@ export interface PlaygroundInput { export const STANDARD: string; export const NEEDS_VERSION: string; +export const AUTOUPDATE_NEEDS_VERSION: string; export const STACKS: Array<{ id: string; label: string }>; export const PLATFORM_STACKS: Record<"github" | "gitlab", string[]>; export const PRESETS: Array<{ id: string; summary: string }>; diff --git a/playground/config.js b/playground/config.js index da0f79f..f139d63 100644 --- a/playground/config.js +++ b/playground/config.js @@ -10,6 +10,9 @@ export const STANDARD = "1.4.4"; /** The first repokeeper that knows the standard the generated file names. */ export const NEEDS_VERSION = "0.8.2"; +/** The first repokeeper that knows the update pull requests; earlier ones refuse the line that asks for them. */ +export const AUTOUPDATE_NEEDS_VERSION = "0.9.0"; + export const STACKS = [ { id: "node", label: "Node.js" }, { id: "python", label: "Python" }, @@ -51,6 +54,11 @@ export const MODULES = [ { id: "deps", label: "Dependency updates", hint: "Dependabot (Renovate on GitLab)" }, { id: "health", label: "Community files", hint: "LICENSE, CONTRIBUTING, CODE_OF_CONDUCT, SECURITY, CODEOWNERS" }, { id: "drift", label: "Drift check", hint: "a CI job that fails when a managed file was edited by hand" }, + { + id: "autoupdate", + label: "Update pull requests", + hint: "a weekly workflow that opens a pull request when the standard has moved (GitHub)", + }, ]; /** What stands in for a detail the form was left empty on; `problems` reports each one still in use. */ @@ -60,11 +68,13 @@ export const PLACEHOLDERS = { codeowners: "@your-name", }; -const ESSENTIAL_OFF = ["commits", "hooks", "release", "deps", "health", "drift"]; +const ESSENTIAL_OFF = ["commits", "hooks", "release", "deps", "health", "drift", "autoupdate"]; /** The module switches of a preset, `health` as a plain on or off. */ export function presetModules(preset) { - const modules = Object.fromEntries(MODULES.map((module) => [module.id, module.id !== "drift"])); + const modules = Object.fromEntries( + MODULES.map((module) => [module.id, module.id !== "drift" && module.id !== "autoupdate"]), + ); if (preset === "essential") for (const id of ESSENTIAL_OFF) modules[id] = false; if (preset === "strict") modules.drift = true; return modules; @@ -132,6 +142,7 @@ export function buildConfig(input) { deps: modules.deps, gitignore: modules.gitignore, drift: modules.drift, + ...(modules.autoupdate ? { autoupdate: true } : {}), health: modules.health ? { license: health.license ? "MIT" : false, @@ -187,6 +198,13 @@ export function problems(input) { if (owners.length === 0) found.push("Code owners: still the placeholder."); if (owners.some((owner) => !owner.startsWith("@"))) found.push("Code owners each start with @."); } + if (input.modules.autoupdate) { + found.push( + input.platform === "github" + ? `Update pull requests need repokeeper ${AUTOUPDATE_NEEDS_VERSION} or newer.` + : "Update pull requests are 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())) { diff --git a/src/commands/presets.ts b/src/commands/presets.ts index 8f987cf..dd5e21a 100644 --- a/src/commands/presets.ts +++ b/src/commands/presets.ts @@ -24,6 +24,7 @@ export const MODULE_IDS: ModuleId[] = [ "deps", "health", "drift", + "autoupdate", ]; /** @@ -44,6 +45,7 @@ export function applyPreset(config: RepokeeperConfig, preset: Preset): Repokeepe release: false, deps: false, drift: false, + autoupdate: false, health: false, }, }; @@ -111,6 +113,7 @@ export async function chooseModules( deps: true, gitignore: true, drift: true, + autoupdate: true, health, }, }; diff --git a/src/commands/report.ts b/src/commands/report.ts index 9a2d87b..6c102ed 100644 --- a/src/commands/report.ts +++ b/src/commands/report.ts @@ -112,6 +112,11 @@ export async function printWarnings(root: string, ctx: ModuleContext, io: Io): P ); } } + if (ctx.platform.id === "gitlab" && ctx.config.modules.autoupdate) { + io.out( + "note: modules.autoupdate writes nothing on gitlab; run `repokeeper update` by hand when a new standard is out", + ); + } 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 fc45fff..9b01885 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -46,7 +46,16 @@ export function parseConfig(text: string): RepokeeperConfig { } export function renderConfig(config: RepokeeperConfig): string { - return `# repokeeper configuration: https://github.com/vannt-dev/repokeeper\n${stringify(config)}`; + // written only when on: a file without the line is one every earlier repokeeper reads as well + const { autoupdate, ...modules } = config.modules; + const written = { ...config, modules: autoupdate ? orderedModules(config.modules) : modules }; + return `# repokeeper configuration: https://github.com/vannt-dev/repokeeper\n${stringify(written)}`; +} + +/** The module switches with `health`, the one that is a block, last. */ +function orderedModules(modules: ModulesConfig): ModulesConfig { + const { health, ...flags } = modules; + return { ...flags, health }; } /** Rewrites `standard:` in place, keeping every comment and the rest of the layout. */ @@ -79,6 +88,7 @@ function withDefaults( deps: modules.deps ?? true, gitignore: modules.gitignore ?? true, drift: modules.drift ?? false, + autoupdate: modules.autoupdate ?? false, health: modules.health, }, owned: data.owned ?? [], diff --git a/src/config/schema.ts b/src/config/schema.ts index fc6ec59..dea1f92 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -25,6 +25,7 @@ export const configSchema = { deps: flag, gitignore: flag, drift: flag, + autoupdate: flag, health: { anyOf: [ { const: false }, diff --git a/src/config/types.ts b/src/config/types.ts index a91fa13..2e97ba3 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -36,6 +36,8 @@ export interface ModulesConfig { gitignore: boolean; /** A ci job that runs `repokeeper check`; off unless asked for. */ drift: boolean; + /** A scheduled workflow that opens a pull request when the standard has moved; off unless asked for. */ + autoupdate: boolean; health: HealthConfig | false; } @@ -79,6 +81,7 @@ export function defaultConfig(input: { deps: true, gitignore: true, drift: false, + autoupdate: false, health: { license: "MIT", copyright: input.copyright, contact: input.contact, codeowners: input.codeowners }, }, owned: [], diff --git a/src/model.ts b/src/model.ts index ed4e4b1..d419c3f 100644 --- a/src/model.ts +++ b/src/model.ts @@ -158,6 +158,8 @@ export interface PlatformAdapter { dependencyUpdates(updates: DependencyUpdate[], ctx: ModuleContext): Output[]; /** The caller CI workflow; empty when no job would run. */ ciWorkflow(ctx: ModuleContext): Output[]; + /** A scheduled job that proposes the update when the standard has moved; empty where the platform has none. */ + standardUpdates(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/autoupdate.ts b/src/modules/autoupdate.ts new file mode 100644 index 0000000..346bad2 --- /dev/null +++ b/src/modules/autoupdate.ts @@ -0,0 +1,7 @@ +import type { Module } from "../model.js"; + +export const autoupdateModule: Module = { + id: "autoupdate", + enabled: (config) => config.modules.autoupdate, + outputs: (ctx) => ctx.platform.standardUpdates(ctx), +}; diff --git a/src/modules/index.ts b/src/modules/index.ts index 1afbbcf..6abf57b 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,4 +1,5 @@ import type { Module } from "../model.js"; +import { autoupdateModule } from "./autoupdate.js"; import { ciModule } from "./ci.js"; import { commitsModule } from "./commits.js"; import { depsModule } from "./deps.js"; @@ -17,4 +18,5 @@ export const MODULES: Module[] = [ depsModule, ciModule, releaseModule, + autoupdateModule, ]; diff --git a/src/platforms/github.ts b/src/platforms/github.ts index 1a24627..e69c5e5 100644 --- a/src/platforms/github.ts +++ b/src/platforms/github.ts @@ -80,7 +80,7 @@ const workflowKey = (module: string, path: string, keyPath: string[], value: unk }); /** Waits briefly for the held runs to appear, since GitHub creates them just after the pull request event. */ -const APPROVE_RELEASE_PR_RUNS = `for attempt in 1 2 3 4 5 6; do +const APPROVE_HELD_PR_RUNS = `for attempt in 1 2 3 4 5 6; do ids=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs?branch=$BRANCH&event=pull_request&status=action_required" --jq '.workflow_runs[].id') [ -n "$ids" ] && break sleep 10 @@ -258,6 +258,43 @@ export const githubPlatform: PlatformAdapter = { ]; }, + standardUpdates(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/repokeeper-autoupdate.yml"; + return [ + ...localWorkflows(ctx, "autoupdate", ["repokeeper-update.yml"]), + workflowKey("autoupdate", path, ["name"], "repokeeper update"), + // weekly and by hand, never on a push: the pull request it opens must not start it again + workflowKey("autoupdate", path, ["on"], { schedule: [{ cron: "23 5 * * 1" }], workflow_dispatch: null }), + workflowKey("autoupdate", path, ["permissions"], { contents: "read" }), + workflowKey("autoupdate", path, ["jobs", "update"], { + uses: workflowRef(ctx, "repokeeper-update.yml"), + permissions: { contents: "write", "pull-requests": "write" }, + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, not a JS template + secrets: { token: "${{ secrets.REPOKEEPER_TOKEN }}" }, + }), + // as for the release pull request: one that GITHUB_TOKEN opened has its runs held until approved + workflowKey("autoupdate", path, ["jobs", "update-pr-ci"], { + needs: "update", + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, not a JS template + if: "${{ needs.update.outputs.pr_branch != '' }}", + "runs-on": "ubuntu-latest", + permissions: { actions: "write" }, + steps: [ + { + env: { + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, not a JS template + GH_TOKEN: "${{ github.token }}", + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, not a JS template + BRANCH: "${{ needs.update.outputs.pr_branch }}", + }, + run: APPROVE_HELD_PR_RUNS, + }, + ], + }), + ]; + }, + releaseAutomation(ctx: ModuleContext, release: ReleaseInfo, directory?: string): Output[] { const path = ".github/workflows/release.yml"; const seed = release.version ?? ctx.repo.releasedVersion ?? "0.0.0"; @@ -321,7 +358,7 @@ export const githubPlatform: PlatformAdapter = { // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, not a JS template BRANCH: "${{ needs.release.outputs.pr_branch }}", }, - run: APPROVE_RELEASE_PR_RUNS, + run: APPROVE_HELD_PR_RUNS, }, ], }), diff --git a/src/platforms/gitlab.ts b/src/platforms/gitlab.ts index 41ede17..5c7c577 100644 --- a/src/platforms/gitlab.ts +++ b/src/platforms/gitlab.ts @@ -199,6 +199,12 @@ export const gitlabPlatform: PlatformAdapter = { }), ]; }, + // proposing the update needs a job that pushes a branch and opens a merge request with a token of the + // project; nothing writes that job yet, and `repokeeper update` run by hand does the same work + standardUpdates(): 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 7ca89bf..0b3543c 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -7,7 +7,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)\.yml$/; +export const REUSABLE_WORKFLOW = /^(stack-[a-z]+|commitlint|release-please|repokeeper-check|repokeeper-update)\.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 eecd8b4..30117e1 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 to false) and optional lists to empty", () => { + it("defaults boolean modules to true (drift and autoupdate 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", ); @@ -39,6 +39,7 @@ describe("parseConfig", () => { deps: true, gitignore: true, drift: false, + autoupdate: false, health: false, }); expect(config.owned).toEqual([]); diff --git a/test/modules-autoupdate.test.ts b/test/modules-autoupdate.test.ts new file mode 100644 index 0000000..7f2fb9c --- /dev/null +++ b/test/modules-autoupdate.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { parseConfig, renderConfig } from "../src/config/load.js"; +import type { Output } from "../src/model.js"; +import { autoupdateModule } from "../src/modules/autoupdate.js"; +import { MODULES } from "../src/modules/index.js"; +import { planOutputs } from "../src/plan.js"; +import { WORKFLOW_REF } from "../src/version.js"; +import { gitlabContext, makeContext } from "./helpers.js"; + +const PATH = ".github/workflows/repokeeper-autoupdate.yml"; +const REUSABLE = ".github/workflows/repokeeper-update.yml"; +const keys = (outputs: Output[]) => + Object.fromEntries(outputs.flatMap((o) => (o.kind === "yaml" ? [[o.keyPath.join("."), o.value]] : []))); + +describe("autoupdate module", () => { + it("is off unless asked for, and round-trips through the configuration file", () => { + expect(makeContext().config.modules.autoupdate).toBe(false); + expect(autoupdateModule.enabled(makeContext().config)).toBe(false); + expect(planOutputs(makeContext()).some((output) => output.path === PATH)).toBe(false); + + const on = makeContext({ modules: { autoupdate: true } }).config; + expect(autoupdateModule.enabled(on)).toBe(true); + expect(parseConfig(renderConfig(on)).modules.autoupdate).toBe(true); + expect(MODULES).toContain(autoupdateModule); + }); + + it("writes a scheduled workflow that calls the reusable update workflow with the newest repokeeper", () => { + const outputs = autoupdateModule.outputs(makeContext({ modules: { autoupdate: true } })); + // 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 === "autoupdate")).toBe(true); + + const out = keys(outputs); + expect(out.name).toBe("repokeeper update"); + // weekly, and by hand; never on a push, so it cannot loop on its own pull request + expect(out.on).toEqual({ schedule: [{ cron: "23 5 * * 1" }], workflow_dispatch: null }); + expect(out.permissions).toEqual({ contents: "read" }); + expect(out["jobs.update"]).toEqual({ + uses: `vannt-dev/repokeeper/.github/workflows/repokeeper-update.yml@${WORKFLOW_REF}`, + permissions: { contents: "write", "pull-requests": "write" }, + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression + secrets: { token: "${{ secrets.REPOKEEPER_TOKEN }}" }, + }); + // a pull request opened with GITHUB_TOKEN has its runs held; they are approved as for a release + expect(out["jobs.update-pr-ci"]).toMatchObject({ + needs: "update", + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression + if: "${{ needs.update.outputs.pr_branch != '' }}", + permissions: { actions: "write" }, + }); + }); + + it("follows where the reusable workflows are taken from", () => { + const self = keys( + autoupdateModule.outputs( + makeContext({ modules: { autoupdate: true }, repo: { owner: "vannt-dev", name: "repokeeper" } }), + ), + ); + expect(self["jobs.update"]).toMatchObject({ uses: "./.github/workflows/repokeeper-update.yml" }); + + const local = autoupdateModule.outputs( + makeContext({ modules: { autoupdate: true }, config: { github: { workflows: { source: "local" } } } }), + ); + // a repository that keeps its own copies gets one of this workflow too + expect(local.some((output) => output.kind === "seed" && output.path === REUSABLE)).toBe(true); + expect(keys(local)["jobs.update"]).toMatchObject({ uses: `./${REUSABLE}` }); + }); + + it("writes nothing on GitLab", () => { + expect(autoupdateModule.outputs(gitlabContext({ modules: { autoupdate: true } }))).toEqual([]); + }); +}); diff --git a/test/presets.test.ts b/test/presets.test.ts index 788289d..2355bf8 100644 --- a/test/presets.test.ts +++ b/test/presets.test.ts @@ -70,6 +70,7 @@ describe("presets", () => { release: false, deps: false, drift: false, + autoupdate: false, health: false, }); expect(applyPreset(base(), "essential").github).toBeUndefined(); @@ -205,16 +206,17 @@ describe("init --interactive", () => { "Apply deps?", "Apply health?", "Apply drift?", + "Apply autoupdate?", "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 write + // editor ignore commits hooks ci release deps health drift update write const init = await repokeeper( dir, ["init", "--interactive"], - [undefined, undefined, true, false, undefined, false, undefined, undefined, undefined, true], + [undefined, undefined, true, false, undefined, false, undefined, undefined, undefined, undefined, true], ); expect(init.err).toBe(""); expect(init.code).toBe(0); @@ -223,11 +225,15 @@ describe("init --interactive", () => { expect(init.out).toContain("hooks:\n lefthook.yml"); expect(init.out).toContain("release:\n .github/workflows/release.yml"); expect(init.out).toContain("a job that fails a pull request when a managed file was edited by hand"); + expect(init.out).toContain("autoupdate:\n .github/workflows/repokeeper-autoupdate.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 }); // Enter on drift keeps the standard preset's answer, which is no expect(config.modules.drift).toBe(false); + // the same for the update pull requests, and a switch that is off is not written at all + expect(config.modules).not.toHaveProperty("autoupdate"); + expect(existsSync(join(dir, ".github/workflows/repokeeper-autoupdate.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); @@ -236,24 +242,28 @@ describe("init --interactive", () => { it("starts from the preset: Enter all the way through essential keeps it essential", async () => { const dir = await nodeRepo(); - const init = await repokeeper(dir, ["init", "-i", "--preset", "essential"], new Array(10).fill(undefined)); + const init = await repokeeper(dir, ["init", "-i", "--preset", "essential"], new Array(11).fill(undefined)); expect(init.code).toBe(0); expect(init.asked).toEqual(QUESTIONS); expect(existsSync(join(dir, "lefthook.yml"))).toBe(false); expect(existsSync(join(dir, ".github/workflows/ci.yml"))).toBe(true); // a part the preset left out can still be taken, with its usual settings const other = await nodeRepo(); - const answers = new Array(10).fill(undefined); + const answers = new Array(11).fill(undefined); answers[7] = true; + // and the update pull requests can be asked for here + answers[9] = 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); expect(parse(await readFile(join(other, ".repokeeper.yml"), "utf8")).modules.health.license).toBe("MIT"); expect(existsSync(join(other, "CONTRIBUTING.md"))).toBe(true); }); it("writes nothing when the last question is answered no", async () => { const dir = await nodeRepo(); - const answers = new Array(10).fill(undefined); - answers[9] = false; + const answers = new Array(11).fill(undefined); + answers[10] = false; const init = await repokeeper(dir, ["init", "--interactive"], answers); expect(init.code).toBe(0); expect(init.out).toContain("nothing written"); @@ -263,9 +273,9 @@ describe("init --interactive", () => { it("with --dry-run asks about the parts and shows the result without the last question", async () => { const dir = await nodeRepo(); - const init = await repokeeper(dir, ["init", "--interactive", "--dry-run"], new Array(9).fill(undefined)); + const init = await repokeeper(dir, ["init", "--interactive", "--dry-run"], new Array(10).fill(undefined)); expect(init.code).toBe(0); - expect(init.asked).toEqual(QUESTIONS.slice(0, 9)); + expect(init.asked).toEqual(QUESTIONS.slice(0, 10)); expect(init.out).toContain("dry run: nothing written"); expect(existsSync(join(dir, ".repokeeper.yml"))).toBe(false); }); From 29fb689b6ea45f882e5c9a31a6f9ca171217ba03 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Mon, 12 Oct 2026 22:42:40 +0700 Subject: [PATCH 2/2] test: keep git's hook environment away from the tests Pushed from a linked worktree, the pre-push hook runs the suite with GIT_DIR and GIT_WORK_TREE pointing at this repository, so every git init a test makes in a scratch directory wrote into it instead: 72 tests failed there and passed everywhere else. A setup file drops those variables before any test runs. --- test/setup.ts | 14 ++++++++++++++ vitest.config.ts | 4 +++- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 test/setup.ts diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..1d9cba3 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,14 @@ +// A test that runs git in a scratch directory has to reach the repository of that directory. Started from +// a git hook in a linked worktree (the pre-push hook runs this suite), git exports where *this* repository +// is, and every `git init` of a test then writes into it instead: 72 tests fail on a push that would pass +// from the main checkout. +for (const name of [ + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_COMMON_DIR", + "GIT_PREFIX", + "GIT_OBJECT_DIRECTORY", +]) { + delete process.env[name]; +} diff --git a/vitest.config.ts b/vitest.config.ts index f0034ec..a1b24ba 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,3 +1,5 @@ import { defineConfig } from "vitest/config"; -export default defineConfig({ test: { include: ["test/**/*.test.ts"], testTimeout: 20000 } }); +export default defineConfig({ + test: { include: ["test/**/*.test.ts"], setupFiles: ["test/setup.ts"], testTimeout: 20000 }, +});