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
141 changes: 141 additions & 0 deletions .github/workflows/repokeeper-update.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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.<stack>.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 |
Expand Down
29 changes: 29 additions & 0 deletions docs/ci-and-releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
1 change: 1 addition & 0 deletions playground/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand Down
22 changes: 20 additions & 2 deletions playground/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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. */
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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())) {
Expand Down
3 changes: 3 additions & 0 deletions src/commands/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const MODULE_IDS: ModuleId[] = [
"deps",
"health",
"drift",
"autoupdate",
];

/**
Expand All @@ -44,6 +45,7 @@ export function applyPreset(config: RepokeeperConfig, preset: Preset): Repokeepe
release: false,
deps: false,
drift: false,
autoupdate: false,
health: false,
},
};
Expand Down Expand Up @@ -111,6 +113,7 @@ export async function chooseModules(
deps: true,
gitignore: true,
drift: true,
autoupdate: true,
health,
},
};
Expand Down
5 changes: 5 additions & 0 deletions src/commands/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 11 additions & 1 deletion src/config/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 ?? [],
Expand Down
1 change: 1 addition & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const configSchema = {
deps: flag,
gitignore: flag,
drift: flag,
autoupdate: flag,
health: {
anyOf: [
{ const: false },
Expand Down
3 changes: 3 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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: [],
Expand Down
2 changes: 2 additions & 0 deletions src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
Expand Down
7 changes: 7 additions & 0 deletions src/modules/autoupdate.ts
Original file line number Diff line number Diff line change
@@ -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),
};
2 changes: 2 additions & 0 deletions src/modules/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -17,4 +18,5 @@ export const MODULES: Module[] = [
depsModule,
ciModule,
releaseModule,
autoupdateModule,
];
Loading
Loading