From cada235ef2dceff2eafdb530d940a5498c946bd7 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Tue, 20 Oct 2026 22:17:30 +0700 Subject: [PATCH] feat: delete Actions caches nothing will read again, when asked modules.cache_cleanup, off unless asked for and in no preset, writes .github/workflows/cache-cleanup.yml. When a pull request closes it deletes that pull request's caches; a weekly sweep deletes the caches of closed pull requests and, per branch and kind of cache, copies left unused for a day longer than the newest one. The job uses only gh and jq, so Dependabot has nothing to bump in it. It is the workflow copied by hand into five repositories on 17/10. On GitLab the switch writes nothing and says so: there are no Actions caches. --- README.md | 8 ++- docs/ci-and-releases.md | 26 +++++++++ playground/config.d.ts | 1 + playground/config.js | 30 ++++++++++- src/commands/presets.ts | 6 ++- src/commands/report.ts | 5 ++ src/config/load.ts | 9 +++- src/config/schema.ts | 1 + src/config/types.ts | 3 ++ src/model.ts | 2 + src/modules/cache-cleanup.ts | 7 +++ src/modules/index.ts | 2 + src/platforms/github.ts | 71 ++++++++++++++++++++++++ src/platforms/gitlab.ts | 5 ++ test/config.test.ts | 3 +- test/modules-cache-cleanup.test.ts | 87 ++++++++++++++++++++++++++++++ test/presets.test.ts | 33 +++++++++--- 17 files changed, 284 insertions(+), 15 deletions(-) create mode 100644 src/modules/cache-cleanup.ts create mode 100644 test/modules-cache-cleanup.test.ts 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); });