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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.<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
26 changes: 26 additions & 0 deletions docs/ci-and-releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
1 change: 1 addition & 0 deletions playground/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
export const STACKS: Array<{ id: string; label: string }>;
export const PLATFORM_STACKS: Record<"github" | "gitlab", string[]>;
Expand Down
30 changes: 28 additions & 2 deletions playground/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" };

Expand Down Expand Up @@ -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. */
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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())) {
Expand Down
6 changes: 5 additions & 1 deletion src/commands/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,16 @@ export const MODULE_IDS: ModuleId[] = [
"drift",
"autoupdate",
"security",
"cache_cleanup",
];

/**
* Applies a preset to a freshly built default configuration.
*
* 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;
Expand All @@ -49,6 +51,7 @@ export function applyPreset(config: RepokeeperConfig, preset: Preset): Repokeepe
drift: false,
autoupdate: false,
security: false,
cache_cleanup: false,
health: false,
},
};
Expand Down Expand Up @@ -118,6 +121,7 @@ export async function chooseModules(
drift: true,
autoupdate: true,
security: true,
cache_cleanup: 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 @@ -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;
Expand Down
9 changes: 7 additions & 2 deletions src/config/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ?? [],
Expand Down
1 change: 1 addition & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const configSchema = {
drift: flag,
autoupdate: flag,
security: flag,
cache_cleanup: 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 @@ -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;
}

Expand Down Expand Up @@ -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: [],
Expand Down
2 changes: 2 additions & 0 deletions src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
Expand Down
7 changes: 7 additions & 0 deletions src/modules/cache-cleanup.ts
Original file line number Diff line number Diff line change
@@ -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),
};
2 changes: 2 additions & 0 deletions src/modules/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -21,4 +22,5 @@ export const MODULES: Module[] = [
releaseModule,
autoupdateModule,
securityModule,
cacheCleanupModule,
];
71 changes: 71 additions & 0 deletions src/platforms/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand Down
5 changes: 5 additions & 0 deletions src/platforms/gitlab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
Expand All @@ -41,6 +41,7 @@ describe("parseConfig", () => {
drift: false,
autoupdate: false,
security: false,
cache_cleanup: false,
health: false,
});
expect(config.owned).toEqual([]);
Expand Down
Loading
Loading