diff --git a/README.md b/README.md index 187632e..20fdadf 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ 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`. -Two switches are in no preset: +These switches are 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 @@ -92,6 +92,10 @@ Two switches are in no preset: [CI and releases](docs/ci-and-releases.md#code-scanning)). Secret scanning is a setting of the repository: `github.security.secret_scanning`, applied by `repokeeper github apply` ([GitHub settings](docs/github-settings.md)). +- `modules.cache_cleanup: true` adds a workflow that deletes the Actions caches nothing will read + 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)). 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 @@ -121,7 +125,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 and Dockerfile linting, release-please, the drift check, update pull requests | +| [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 | | [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 575aa81..2053fad 100644 --- a/docs/ci-and-releases.md +++ b/docs/ci-and-releases.md @@ -198,6 +198,32 @@ Three things to know: It needs repokeeper 0.9.0 or newer. +## Cache cleanup + +Actions caches share a 10 GB limit per repository. Over it, GitHub evicts caches on its own, including +ones still in use. Set `modules.cache_cleanup: true` and repokeeper writes +`.github/workflows/cache-cleanup.yml`, which deletes the caches nothing will read again: + +- **When a pull request closes** (merged or not), the caches it saved. Only that pull request could read + them. The workflow runs on `pull_request_target`, so a pull request from a fork gets a token that can + delete them too. Nothing is checked out and no code from the pull request runs: the job reads only the + pull request number. +- **Once a week** (Sunday 03:23 UTC, or when you start it by hand), a sweep: + - deletes the caches of every closed pull request, including ones that closed before the workflow + existed; + - on each branch, groups the caches by kind (the key without its trailing hash) and deletes every copy + left unused for more than a day longer than the newest one. These are older copies that a newer run + saved again under a new key, after a lockfile changed for example. GitHub would drop them only after + 7 days without use. + +The job uses only `gh` and `jq`, which the runner already has, with `GITHUB_TOKEN` and +`actions: write`. It lists each deleted cache in the log and writes the count and the size to the run +summary. + +The switch is off unless you ask for it, and no preset turns it on: it deletes things, so you decide. +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. + --- Back to the [README](../README.md). diff --git a/playground/config.d.ts b/playground/config.d.ts index 37329b3..aa30667 100644 --- a/playground/config.d.ts +++ b/playground/config.d.ts @@ -14,6 +14,7 @@ export const STANDARD: string; 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 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 344bbff..43a47a0 100644 --- a/playground/config.js +++ b/playground/config.js @@ -16,6 +16,9 @@ export const AUTOUPDATE_NEEDS_VERSION = "0.9.0"; /** The same for code scanning. */ 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 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" }; @@ -71,6 +74,11 @@ export const MODULES = [ label: "Code scanning", hint: "a workflow that runs CodeQL on pull requests, pushes and weekly (GitHub; free on public repositories)", }, + { + id: "cache_cleanup", + label: "Cache cleanup", + hint: "a workflow that deletes the Actions caches of closed pull requests and stale copies (GitHub)", + }, ]; /** What stands in for a detail the form was left empty on; `problems` reports each one still in use. */ @@ -80,10 +88,20 @@ export const PLACEHOLDERS = { codeowners: "@your-name", }; -const ESSENTIAL_OFF = ["commits", "hooks", "release", "deps", "health", "drift", "autoupdate", "security"]; +const ESSENTIAL_OFF = [ + "commits", + "hooks", + "release", + "deps", + "health", + "drift", + "autoupdate", + "security", + "cache_cleanup", +]; /** The switches no preset turns on. */ -const IN_NO_PRESET = ["drift", "autoupdate", "security"]; +const IN_NO_PRESET = ["drift", "autoupdate", "security", "cache_cleanup"]; /** The module switches of a preset, `health` as a plain on or off. */ export function presetModules(preset) { @@ -157,6 +175,7 @@ export function buildConfig(input) { drift: modules.drift, ...(modules.autoupdate ? { autoupdate: true } : {}), ...(modules.security ? { security: true } : {}), + ...(modules.cache_cleanup ? { cache_cleanup: true } : {}), health: modules.health ? { license: health.license ? "MIT" : false, @@ -230,6 +249,13 @@ export function problems(input) { : "Code scanning is written for GitHub only: on GitLab the switch writes nothing.", ); } + if (input.modules.cache_cleanup) { + found.push( + input.platform === "github" + ? `The cache cleanup needs repokeeper ${CACHE_CLEANUP_NEEDS_VERSION} or newer.` + : "The cache cleanup 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())) { diff --git a/src/commands/presets.ts b/src/commands/presets.ts index b6d589a..19efa64 100644 --- a/src/commands/presets.ts +++ b/src/commands/presets.ts @@ -26,6 +26,7 @@ export const MODULE_IDS: ModuleId[] = [ "drift", "autoupdate", "security", + "cache_cleanup", ]; /** @@ -33,7 +34,8 @@ 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. + * 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. */ export function applyPreset(config: RepokeeperConfig, preset: Preset): RepokeeperConfig { if (preset === "standard") return config; @@ -49,6 +51,7 @@ export function applyPreset(config: RepokeeperConfig, preset: Preset): Repokeepe drift: false, autoupdate: false, security: false, + cache_cleanup: false, health: false, }, }; @@ -118,6 +121,7 @@ export async function chooseModules( drift: true, autoupdate: true, security: true, + cache_cleanup: true, health, }, }; diff --git a/src/commands/report.ts b/src/commands/report.ts index f82fdf8..ca850dd 100644 --- a/src/commands/report.ts +++ b/src/commands/report.ts @@ -128,6 +128,11 @@ export async function printWarnings(root: string, ctx: ModuleContext, io: Io): P "note: modules.security writes nothing on gitlab; include GitLab's SAST and Secret-Detection templates in .gitlab-ci.yml instead", ); } + if (ctx.platform.id === "gitlab" && ctx.config.modules.cache_cleanup) { + io.out( + "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") { 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 946549b..9cf1bfc 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -62,8 +62,12 @@ export function parseConfig(text: string): RepokeeperConfig { 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, ...modules } = config.modules; - const asked = { ...(autoupdate ? { autoupdate } : {}), ...(security ? { security } : {}) }; + const { autoupdate, security, cache_cleanup, ...modules } = config.modules; + const asked = { + ...(autoupdate ? { autoupdate } : {}), + ...(security ? { security } : {}), + ...(cache_cleanup ? { cache_cleanup } : {}), + }; const written = { ...config, modules: Object.keys(asked).length > 0 ? orderedModules({ ...modules, ...asked }) : modules, @@ -109,6 +113,7 @@ function withDefaults( drift: modules.drift ?? false, autoupdate: modules.autoupdate ?? false, security: modules.security ?? false, + cache_cleanup: modules.cache_cleanup ?? false, health: modules.health, }, owned: data.owned ?? [], diff --git a/src/config/schema.ts b/src/config/schema.ts index f7929b9..e15b165 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -27,6 +27,7 @@ export const configSchema = { drift: flag, autoupdate: flag, security: flag, + cache_cleanup: flag, health: { anyOf: [ { const: false }, diff --git a/src/config/types.ts b/src/config/types.ts index 7e2f1ea..89d5ac9 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -41,6 +41,8 @@ export interface ModulesConfig { autoupdate: boolean; /** A workflow that runs code scanning (CodeQL) on pull requests, pushes and weekly; off unless asked for. */ security: boolean; + /** A workflow that deletes the Actions caches nothing will read again; off unless asked for. */ + cache_cleanup: boolean; health: HealthConfig | false; } @@ -86,6 +88,7 @@ export function defaultConfig(input: { drift: false, autoupdate: false, security: false, + cache_cleanup: 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 a2a74aa..79739b2 100644 --- a/src/model.ts +++ b/src/model.ts @@ -162,6 +162,8 @@ export interface PlatformAdapter { standardUpdates(ctx: ModuleContext): Output[]; /** A workflow that scans the code for vulnerabilities; empty where the platform has none written. */ 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[]; /** 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/cache-cleanup.ts b/src/modules/cache-cleanup.ts new file mode 100644 index 0000000..9936d4b --- /dev/null +++ b/src/modules/cache-cleanup.ts @@ -0,0 +1,7 @@ +import type { Module } from "../model.js"; + +export const cacheCleanupModule: Module = { + id: "cache_cleanup", + enabled: (config) => config.modules.cache_cleanup, + outputs: (ctx) => ctx.platform.cacheCleanup(ctx), +}; diff --git a/src/modules/index.ts b/src/modules/index.ts index 866e0c9..84f696e 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,5 +1,6 @@ import type { Module } from "../model.js"; import { autoupdateModule } from "./autoupdate.js"; +import { cacheCleanupModule } from "./cache-cleanup.js"; import { ciModule } from "./ci.js"; import { commitsModule } from "./commits.js"; import { depsModule } from "./deps.js"; @@ -21,4 +22,5 @@ export const MODULES: Module[] = [ releaseModule, autoupdateModule, securityModule, + cacheCleanupModule, ]; diff --git a/src/platforms/github.ts b/src/platforms/github.ts index 3f15e19..de764fc 100644 --- a/src/platforms/github.ts +++ b/src/platforms/github.ts @@ -90,6 +90,39 @@ if [ -z "$ids" ]; then echo "::notice::no held runs for $BRANCH"; fi for id in $ids; do gh api -X POST "repos/$GITHUB_REPOSITORY/actions/runs/$id/approve"; done `; +/** + * Deletes the Actions caches nothing will read again. With a pull request number (the pull request has + * closed): that pull request's caches, which only it could read. Without (the weekly sweep): the caches of + * every closed pull request, and on each branch, per kind of cache (its key without the trailing hash), + * the copies left unused for a day longer than the newest one. GitHub itself drops them only after 7 days. + */ +const CACHE_CLEANUP = String.raw`set -euo pipefail +gh api --paginate "repos/$GH_REPO/actions/caches?per_page=100" --jq '.actions_caches[]' > caches.json +if [ -n "$PR_NUMBER" ]; then + open='[]' + filter='map(select(.ref == "refs/pull/'"$PR_NUMBER"'/merge"))' +else + open=$(gh pr list --state open --limit 500 --json number --jq '[.[] | "refs/pull/\(.number)/merge"]') + filter=' + def t: .last_accessed_at[0:19] + "Z" | fromdateiso8601; + def kind: .key | sub("[-_|][0-9a-f]{12,}.*$"; ""); + map(select(.ref | startswith("refs/pull/")) | select(.ref as $r | $open | index($r) | not)) + + (map(select(.ref | startswith("refs/pull/") | not)) + | group_by([.ref, kind]) + | map((map(t) | max) as $newest | map(select(t < $newest - 86400))) + | add // [])' +fi +jq -rs --argjson open "$open" "$filter | .[] | [.id, .size_in_bytes, .ref, .key] | @tsv" caches.json > delete.tsv +deleted=0; bytes=0 +while IFS=$'\t' read -r id size ref key; do + if gh api -X DELETE "repos/$GH_REPO/actions/caches/$id" > /dev/null; then + echo "deleted $ref $key" + deleted=$((deleted + 1)); bytes=$((bytes + size)) + fi +done < delete.tsv +echo "### Deleted $deleted caches, $((bytes / 1048576)) MB" >> "$GITHUB_STEP_SUMMARY" +`; + interface CodeqlLanguage { /** CodeQL's identifier of the language. */ language: string; @@ -372,6 +405,44 @@ export const githubPlatform: PlatformAdapter = { ]; }, + cacheCleanup(): Output[] { + const path = ".github/workflows/cache-cleanup.yml"; + return [ + workflowKey("cache_cleanup", path, ["name"], "cache cleanup"), + workflowKey("cache_cleanup", path, ["on"], { + // not pull_request: a pull request from a fork would get a token that cannot delete caches + pull_request_target: { types: ["closed"] }, + // also catches the pull requests that closed before the workflow existed + schedule: [{ cron: "23 3 * * 0" }], + workflow_dispatch: null, + }), + // nothing is checked out: the job reads only the pull request number + workflowKey("cache_cleanup", path, ["permissions"], { + actions: "write", + contents: "read", + "pull-requests": "read", + }), + workflowKey("cache_cleanup", path, ["jobs", "cleanup"], { + "runs-on": "ubuntu-latest", + "timeout-minutes": 10, + steps: [ + { + name: "Delete caches nothing will read again", + 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 + GH_REPO: "${{ github.repository }}", + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, not a JS template + PR_NUMBER: "${{ github.event.pull_request.number }}", + }, + run: CACHE_CLEANUP, + }, + ], + }), + ]; + }, + releaseAutomation(ctx: ModuleContext, release: ReleaseInfo, directory?: string): Output[] { const path = ".github/workflows/release.yml"; const seed = release.version ?? ctx.repo.releasedVersion ?? "0.0.0"; diff --git a/src/platforms/gitlab.ts b/src/platforms/gitlab.ts index 52e9f92..40fabd0 100644 --- a/src/platforms/gitlab.ts +++ b/src/platforms/gitlab.ts @@ -216,6 +216,11 @@ export const gitlabPlatform: PlatformAdapter = { return []; }, + // Actions caches are GitHub's; a GitLab runner's cache lives with the runner and expires there + cacheCleanup(): Output[] { + return []; + }, + ciWorkflow(ctx: ModuleContext): Output[] { const jobs: Output[] = []; const node = ctx.stacks.find((stack) => stack.id === "node")?.ci; diff --git a/test/config.test.ts b/test/config.test.ts index de92efc..55295ac 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 and security to false) and optional lists to empty", () => { + it("defaults boolean modules to true (drift, autoupdate, security and cache_cleanup 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", ); @@ -41,6 +41,7 @@ describe("parseConfig", () => { drift: false, autoupdate: false, security: false, + cache_cleanup: false, health: false, }); expect(config.owned).toEqual([]); diff --git a/test/modules-cache-cleanup.test.ts b/test/modules-cache-cleanup.test.ts new file mode 100644 index 0000000..39597b0 --- /dev/null +++ b/test/modules-cache-cleanup.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { printWarnings } from "../src/commands/report.js"; +import { parseConfig, renderConfig } from "../src/config/load.js"; +import type { Output } from "../src/model.js"; +import { cacheCleanupModule } from "../src/modules/cache-cleanup.js"; +import { MODULES } from "../src/modules/index.js"; +import { planOutputs } from "../src/plan.js"; +import { capture, gitlabContext, makeContext, tempDir } from "./helpers.js"; + +const PATH = ".github/workflows/cache-cleanup.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: { cache_cleanup: true, ...overrides.modules } }); + +interface Step { + name: string; + env: Record; + run: string; +} + +describe("cache cleanup module", () => { + it("is off unless asked for, and round-trips through the configuration file", () => { + expect(makeContext().config.modules.cache_cleanup).toBe(false); + expect(cacheCleanupModule.enabled(makeContext().config)).toBe(false); + expect(planOutputs(makeContext()).some((output) => output.path === PATH)).toBe(false); + // a file without the line is one every earlier repokeeper reads as well + expect(renderConfig(makeContext().config)).not.toContain("cache_cleanup"); + + expect(cacheCleanupModule.enabled(on().config)).toBe(true); + expect(parseConfig(renderConfig(on().config)).modules.cache_cleanup).toBe(true); + expect(MODULES).toContain(cacheCleanupModule); + }); + + it("deletes a pull request's caches when it closes, and sweeps weekly", () => { + const outputs = cacheCleanupModule.outputs(on()); + expect(new Set(outputs.map((output) => output.path))).toEqual(new Set([PATH])); + expect(outputs.every((output) => output.module === "cache_cleanup")).toBe(true); + + const out = keys(outputs); + expect(out.name).toBe("cache cleanup"); + expect(out.on).toEqual({ + // pull_request_target: a pull request from a fork gets a token that can delete caches too + pull_request_target: { types: ["closed"] }, + schedule: [{ cron: "23 3 * * 0" }], + workflow_dispatch: null, + }); + // nothing is checked out: the job reads only the pull request number + expect(out.permissions).toEqual({ actions: "write", contents: "read", "pull-requests": "read" }); + const job = out["jobs.cleanup"] as { "runs-on": string; "timeout-minutes": number; steps: Step[] }; + expect(job["runs-on"]).toBe("ubuntu-latest"); + expect(job["timeout-minutes"]).toBe(10); + expect(job.steps).toHaveLength(1); + const [step] = job.steps as [Step]; + expect(step.env).toEqual({ + // biome-ignore lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions + GH_TOKEN: "${{ github.token }}", + // biome-ignore lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions + GH_REPO: "${{ github.repository }}", + // biome-ignore lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions + PR_NUMBER: "${{ github.event.pull_request.number }}", + }); + // no expression in the script itself: the pull request number reaches it through the environment + expect(step.run).not.toContain("${{"); + expect(step.run).toContain("filter='map(select(.ref == \"refs/pull/'\"$PR_NUMBER\"'/merge\"))'"); + // per branch and kind of cache, a copy left unused for a day longer than the newest one goes + expect(step.run).toContain("map(select(t < $newest - 86400))"); + expect(step.run).toContain('gh api -X DELETE "repos/$GH_REPO/actions/caches/$id"'); + // no third-party action: Dependabot has nothing to bump in a file repokeeper manages + expect(JSON.stringify(job)).not.toContain('"uses"'); + }); + + it("writes nothing on GitLab, where there are no Actions caches", () => { + expect(cacheCleanupModule.outputs(gitlabContext({ modules: { cache_cleanup: true } }))).toEqual([]); + }); + + it("says so on GitLab, and nothing on GitHub", async () => { + const noted = capture(); + await printWarnings(await tempDir(), gitlabContext({ modules: { cache_cleanup: true } }), noted.io); + expect(noted.out).toContain( + "note: modules.cache_cleanup writes nothing on gitlab; there are no Actions caches there, and runner caches expire on the runner", + ); + const quiet = capture(); + await printWarnings(await tempDir(), on(), quiet.io); + expect(quiet.out.join("\n")).not.toContain("cache_cleanup"); + }); +}); diff --git a/test/presets.test.ts b/test/presets.test.ts index 7f90374..40a7992 100644 --- a/test/presets.test.ts +++ b/test/presets.test.ts @@ -72,6 +72,7 @@ describe("presets", () => { drift: false, autoupdate: false, security: false, + cache_cleanup: false, health: false, }); expect(applyPreset(base(), "essential").github).toBeUndefined(); @@ -209,12 +210,13 @@ describe("init --interactive", () => { "Apply drift?", "Apply autoupdate?", "Apply security?", + "Apply cache_cleanup?", "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, write + // editor, ignore, commits, hooks, ci, release, deps, health, drift, update, security, caches, write const init = await repokeeper( dir, ["init", "--interactive"], @@ -230,6 +232,7 @@ describe("init --interactive", () => { undefined, undefined, undefined, + undefined, true, ], ); @@ -242,6 +245,7 @@ describe("init --interactive", () => { 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"); expect(init.out).toContain("security:\n .github/workflows/security.yml"); + expect(init.out).toContain("cache_cleanup:\n .github/workflows/cache-cleanup.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 }); @@ -253,6 +257,9 @@ describe("init --interactive", () => { // and for code scanning expect(config.modules).not.toHaveProperty("security"); expect(existsSync(join(dir, ".github/workflows/security.yml"))).toBe(false); + // and for the cache cleanup + expect(config.modules).not.toHaveProperty("cache_cleanup"); + expect(existsSync(join(dir, ".github/workflows/cache-cleanup.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); @@ -261,32 +268,40 @@ 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(12).fill(undefined)); + const init = await repokeeper( + dir, + ["init", "-i", "--preset", "essential"], + new Array(QUESTIONS.length).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(12).fill(undefined); + const answers = new Array(QUESTIONS.length).fill(undefined); answers[7] = true; // and the update pull requests can be asked for here answers[9] = true; // and code scanning answers[10] = true; + // and the cache cleanup + answers[11] = 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.security).toBe(true); 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.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(12).fill(undefined); - answers[11] = false; + const answers = new Array(QUESTIONS.length).fill(undefined); + answers[QUESTIONS.length - 1] = false; const init = await repokeeper(dir, ["init", "--interactive"], answers); expect(init.code).toBe(0); expect(init.out).toContain("nothing written"); @@ -296,9 +311,13 @@ 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(11).fill(undefined)); + const init = await repokeeper( + dir, + ["init", "--interactive", "--dry-run"], + new Array(QUESTIONS.length - 1).fill(undefined), + ); expect(init.code).toBe(0); - expect(init.asked).toEqual(QUESTIONS.slice(0, 11)); + expect(init.asked).toEqual(QUESTIONS.slice(0, -1)); expect(init.out).toContain("dry run: nothing written"); expect(existsSync(join(dir, ".repokeeper.yml"))).toBe(false); });