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
46 changes: 46 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: codeql

on:
workflow_call:
inputs:
languages:
description: >-
JSON list of what to analyze, each {"language", "build-mode"} in CodeQL's own names
("javascript-typescript", "java-kotlin", ...; "none" or "autobuild")
type: string
required: true
upload:
description: Whether the results become code scanning alerts of the repository ("always", "failure-only" or "never")
type: string
default: always

permissions:
contents: read

jobs:
analyze:
name: codeql (${{ matrix.language }})
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(inputs.languages) }}
runs-on: ubuntu-latest
permissions:
# code scanning alerts are security events
security-events: write
# CodeQL reads the workflow run it is part of, which a private repository does not let it do otherwise
actions: read
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
languages: ${{ matrix.language }}
# with "autobuild", the analysis below runs the build itself
build-mode: ${{ matrix.build-mode }}
- uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
category: /language:${{ matrix.language }}
upload: ${{ inputs.upload }}
17 changes: 17 additions & 0 deletions .github/workflows/workflow-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ jobs:
[kotlin]="fixtures/kotlin/ .github/workflows/stack-java.yml"
[php]="fixtures/php/ .github/workflows/stack-php.yml"
[ruby]="fixtures/ruby/ .github/workflows/stack-ruby.yml"
[codeql]="fixtures/node/ fixtures/go/ .github/workflows/codeql.yml"
)
files=$(git diff --name-only "$BASE" "$HEAD")
run=","
Expand Down Expand Up @@ -203,3 +204,19 @@ jobs:
ruby-versions: ${{ github.event_name == 'push' && '["3.3","3.4"]' || '["3.4"]' }}
install-command: bundle install
commands: '["bundle exec rake test"]'
# One language CodeQL reads from source, one it has to build, and the workflow files. Nothing is
# uploaded: the fixtures are not this repository's code, and their findings are not its alerts.
codeql:
needs: changes
if: contains(needs.changes.outputs.run, ',all,') || contains(needs.changes.outputs.run, ',codeql,')
permissions:
security-events: write
actions: read
contents: read
uses: ./.github/workflows/codeql.yml
with:
languages: >-
[{"language":"javascript-typescript","build-mode":"none"},
{"language":"go","build-mode":"autobuild"},
{"language":"actions","build-mode":"none"}]
upload: never
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,19 +69,27 @@ alone. Every write command accepts `--dry-run`.
Dependabot security updates into `.repokeeper.yml`; `repokeeper github apply` then puts them in
place. On GitLab it writes the protection of the default branch (Maintainers push and merge, no
force push) and a merge request that needs a passing pipeline, for `repokeeper gitlab apply`; a
required review is not part of it, because approval rules are a paid-tier setting there. There is
no preset that promises code or secret scanning: repokeeper does not set those up.
required review is not part of it, because approval rules are a paid-tier setting there. No preset
switches code or secret scanning on: they are free on a public repository and paid on a private
one, so they are yours to ask for (see below).
- `repokeeper init --interactive` (or `-i`) asks about each part in turn, naming the files it would
write, shows the result, and asks once more before writing anything. Enter keeps the preset's
answer. Add `--dry-run` to go through the questions without the possibility of writing.

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)).
Two 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
repository without anyone running the command in each (GitHub only; see
[CI and releases](docs/ci-and-releases.md#update-pull-requests)).
- `modules.security: true` adds a workflow that runs CodeQL on every pull request, on every push to
the default branch and once a week, for the languages of your stacks (GitHub only; see
[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)).

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
39 changes: 39 additions & 0 deletions docs/ci-and-releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,45 @@ The job needs a token:
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.

## Code scanning

Set `modules.security: true` and repokeeper writes `.github/workflows/security.yml`. Its `codeql` job
calls the reusable workflow `codeql.yml`, which runs [CodeQL](https://codeql.github.com/) and reports
what it finds as code scanning alerts, on the pull request and under the repository's Security tab.
It runs on every pull request, on every push to the default branch, and once a week, because new
queries find things in code that has not changed.

What is analyzed follows from the stacks in `.repokeeper.yml`:

| Stack | CodeQL language | Build |
| --- | --- | --- |
| `node` | `javascript-typescript` | none |
| `python` | `python` | none |
| `java` | `java-kotlin` | none |
| `kotlin` | `java-kotlin` | CodeQL's own (`autobuild`) |
| `dotnet` | `csharp` | none |
| `go` | `go` | CodeQL's own (`autobuild`) |
| `ruby` | `ruby` | none |
| `rust` | `rust` | none |
| `dart`, `php`, `script` | not analyzed: CodeQL does not know them | |

The workflow files themselves are always analyzed too (`actions`), so a repository with only Dart or
PHP still gets that. Most languages are read from source. Go and Kotlin are not: CodeQL has to watch a
build, and works out how to run one. When that build needs something it cannot guess, list
`.github/workflows/security.yml#jobs.codeql` under `owned` and call CodeQL with your own build steps.

Three things to know:

- **Public and private repositories.** Code scanning is free on a public repository. A private one
needs GitHub Code Security; without it the analysis runs and then fails when it uploads its results.
This is why no preset switches the module on.
- **Default setup.** If CodeQL's default setup is on in the repository's settings, GitHub refuses the
results of a workflow. Use one or the other.
- **GitLab.** The switch writes nothing there. GitLab has its own SAST and Secret Detection
templates, which you include in `.gitlab-ci.yml` yourself.

It needs repokeeper 0.9.0 or newer.

---

Back to the [README](../README.md).
11 changes: 10 additions & 1 deletion docs/github-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ github:
description: Keeps repositories on one standard
topics: [cli, conventional-commits]
merge: { squash: true, merge_commit: false, rebase: false, delete_branch_on_merge: true }
security: { dependabot_alerts: true, dependabot_security_updates: true }
security:
dependabot_alerts: true
dependabot_security_updates: true
secret_scanning: true # GitHub's scan for committed credentials
secret_scanning_push_protection: true # refuse a push that contains one; needs secret_scanning
protect: # a ruleset named "repokeeper" on the default branch; false removes it
require_pull_request: true
required_approvals: 0
Expand All @@ -21,6 +25,11 @@ It prints every change first and applies them with `--yes`, or after you confirm
access to the repository. Legacy branch protection, visibility, secrets and collaborators are never
touched.

Secret scanning and push protection are free on a public repository, where GitHub usually has them
on already. A private repository needs GitHub Secret Protection; without it GitHub refuses the change
and `github apply` reports its answer. Code scanning is not a setting here but a workflow: see
`modules.security` in [CI and releases](ci-and-releases.md#code-scanning).

---

Back to the [README](../README.md).
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
".github/workflows/commitlint.yml",
".github/workflows/release-please.yml",
".github/workflows/repokeeper-check.yml",
".github/workflows/repokeeper-update.yml"
".github/workflows/repokeeper-update.yml",
".github/workflows/codeql.yml"
],
"engines": {
"node": ">=22.12.0"
Expand Down
1 change: 1 addition & 0 deletions playground/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface PlaygroundInput {
export const STANDARD: string;
export const NEEDS_VERSION: string;
export const AUTOUPDATE_NEEDS_VERSION: string;
export const SECURITY_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
25 changes: 21 additions & 4 deletions playground/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ 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";

/** The same for code scanning. */
export const SECURITY_NEEDS_VERSION = "0.9.0";

export const STACKS = [
{ id: "node", label: "Node.js" },
{ id: "python", label: "Python" },
Expand Down Expand Up @@ -59,6 +62,11 @@ export const MODULES = [
label: "Update pull requests",
hint: "a weekly workflow that opens a pull request when the standard has moved (GitHub)",
},
{
id: "security",
label: "Code scanning",
hint: "a workflow that runs CodeQL on pull requests, pushes and weekly (GitHub; free on public repositories)",
},
];

/** What stands in for a detail the form was left empty on; `problems` reports each one still in use. */
Expand All @@ -68,13 +76,14 @@ export const PLACEHOLDERS = {
codeowners: "@your-name",
};

const ESSENTIAL_OFF = ["commits", "hooks", "release", "deps", "health", "drift", "autoupdate"];
const ESSENTIAL_OFF = ["commits", "hooks", "release", "deps", "health", "drift", "autoupdate", "security"];

/** The switches no preset turns on. */
const IN_NO_PRESET = ["drift", "autoupdate", "security"];

/** 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" && module.id !== "autoupdate"]),
);
const modules = Object.fromEntries(MODULES.map((module) => [module.id, !IN_NO_PRESET.includes(module.id)]));
if (preset === "essential") for (const id of ESSENTIAL_OFF) modules[id] = false;
if (preset === "strict") modules.drift = true;
return modules;
Expand Down Expand Up @@ -143,6 +152,7 @@ export function buildConfig(input) {
gitignore: modules.gitignore,
drift: modules.drift,
...(modules.autoupdate ? { autoupdate: true } : {}),
...(modules.security ? { security: true } : {}),
health: modules.health
? {
license: health.license ? "MIT" : false,
Expand Down Expand Up @@ -205,6 +215,13 @@ export function problems(input) {
: "Update pull requests are written for GitHub only: on GitLab the switch writes nothing.",
);
}
if (input.modules.security) {
found.push(
input.platform === "github"
? `Code scanning needs repokeeper ${SECURITY_NEEDS_VERSION} or newer, and on a private repository GitHub Code Security.`
: "Code scanning 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
8 changes: 6 additions & 2 deletions src/commands/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ export const MODULE_IDS: ModuleId[] = [
"health",
"drift",
"autoupdate",
"security",
];

/**
* Applies a preset to a freshly built default configuration.
*
* A preset only switches on what repokeeper really does. There is no tier that promises code or
* secret scanning, because no module writes it.
* 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.
*/
export function applyPreset(config: RepokeeperConfig, preset: Preset): RepokeeperConfig {
if (preset === "standard") return config;
Expand All @@ -46,6 +48,7 @@ export function applyPreset(config: RepokeeperConfig, preset: Preset): Repokeepe
deps: false,
drift: false,
autoupdate: false,
security: false,
health: false,
},
};
Expand Down Expand Up @@ -114,6 +117,7 @@ export async function chooseModules(
gitignore: true,
drift: true,
autoupdate: true,
security: true,
health,
},
};
Expand Down
11 changes: 11 additions & 0 deletions src/commands/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ export async function nextSteps(root: string, ctx: ModuleContext, result: SyncRe
// lockfile that disagrees with it. A new standard moves such a range without touching lefthook.yml.
steps.push("package.json changed; install dependencies and commit the lockfile with it");
}
// said once, when the workflow is new: whether the repository is public is not something repokeeper can see
if (result.decisions.some((d) => d.output.path === ".github/workflows/security.yml" && d.action === "create")) {
steps.push(
"code scanning is free on a public repository; on a private one, turn on GitHub Code Security, or the security workflow fails when it uploads its results",
);
}
if (wrote(".gitattributes")) {
const crlf = await crlfTrackedFiles(root);
if (crlf.length > 0) {
Expand All @@ -117,6 +123,11 @@ export async function printWarnings(root: string, ctx: ModuleContext, io: Io): P
"note: modules.autoupdate writes nothing on gitlab; run `repokeeper update` by hand when a new standard is out",
);
}
if (ctx.platform.id === "gitlab" && ctx.config.modules.security) {
io.out(
"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") {
for (const stack of ctx.stacks) {
const os = stack.ci?.with.os;
Expand Down
13 changes: 9 additions & 4 deletions src/config/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,19 @@ 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, ...modules } = config.modules;
const written = { ...config, modules: autoupdate ? orderedModules(config.modules) : modules };
const { autoupdate, security, ...modules } = config.modules;
const asked = { ...(autoupdate ? { autoupdate } : {}), ...(security ? { security } : {}) };
const written = {
...config,
modules: Object.keys(asked).length > 0 ? orderedModules({ ...modules, ...asked }) : 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 {
function orderedModules<T extends Pick<ModulesConfig, "health">>(modules: T): T {
const { health, ...flags } = modules;
return { ...flags, health };
return { ...flags, health } as T;
}

/** Rewrites `standard:` in place, keeping every comment and the rest of the layout. */
Expand Down Expand Up @@ -89,6 +93,7 @@ function withDefaults(
gitignore: modules.gitignore ?? true,
drift: modules.drift ?? false,
autoupdate: modules.autoupdate ?? false,
security: modules.security ?? false,
health: modules.health,
},
owned: data.owned ?? [],
Expand Down
8 changes: 7 additions & 1 deletion src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const configSchema = {
gitignore: flag,
drift: flag,
autoupdate: flag,
security: flag,
health: {
anyOf: [
{ const: false },
Expand Down Expand Up @@ -71,7 +72,12 @@ export const configSchema = {
security: {
type: "object",
additionalProperties: false,
properties: { dependabot_alerts: flag, dependabot_security_updates: flag },
properties: {
dependabot_alerts: flag,
dependabot_security_updates: flag,
secret_scanning: flag,
secret_scanning_push_protection: flag,
},
},
protect: {
anyOf: [
Expand Down
3 changes: 3 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export interface ModulesConfig {
drift: boolean;
/** A scheduled workflow that opens a pull request when the standard has moved; off unless asked for. */
autoupdate: boolean;
/** A workflow that runs code scanning (CodeQL) on pull requests, pushes and weekly; off unless asked for. */
security: boolean;
health: HealthConfig | false;
}

Expand Down Expand Up @@ -82,6 +84,7 @@ export function defaultConfig(input: {
gitignore: true,
drift: false,
autoupdate: false,
security: false,
health: { license: "MIT", copyright: input.copyright, contact: input.contact, codeowners: input.codeowners },
},
owned: [],
Expand Down
Loading
Loading