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

on:
workflow_call:
inputs:
dockerfiles:
description: JSON list of the Dockerfiles to check, relative to the working directory
type: string
default: '["Dockerfile"]'
build:
description: '"true" to build each image as well; nothing is pushed'
type: string
default: "false"
working-directory:
description: Directory the Dockerfiles are named from, and the context of a build
type: string
default: .

permissions:
contents: read

jobs:
hadolint:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: hadolint
env:
DOCKERFILES: ${{ inputs.dockerfiles }}
run: |
curl -fsSL -o "$RUNNER_TEMP/hadolint" https://github.com/hadolint/hadolint/releases/download/v2.15.1/hadolint-linux-x86_64
chmod +x "$RUNNER_TEMP/hadolint"
# a .hadolint.yaml here is the repository's own choice of rules; without one, errors and warnings
# fail the job and information-level rules are only printed
if [ -f .hadolint.yaml ] || [ -f .hadolint.yml ]; then
threshold=()
else
threshold=(--failure-threshold warning)
fi
jq -r '.[]' <<<"$DOCKERFILES" | tr '\n' '\0' |
xargs -0 --no-run-if-empty "$RUNNER_TEMP/hadolint" "${threshold[@]}"
build:
if: inputs.build == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Build each image
env:
DOCKERFILES: ${{ inputs.dockerfiles }}
run: |
while IFS= read -r file; do
echo "::group::docker build --file $file ."
docker build --file "$file" . </dev/null
echo "::endgroup::"
done < <(jq -r '.[]' <<<"$DOCKERFILES")
19 changes: 19 additions & 0 deletions .github/workflows/workflow-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ 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"
[docker]="fixtures/docker/ .github/workflows/stack-docker.yml"
[docker-build]="fixtures/docker/ .github/workflows/stack-docker.yml"
[codeql]="fixtures/node/ fixtures/go/ .github/workflows/codeql.yml"
)
files=$(git diff --name-only "$BASE" "$HEAD")
Expand Down Expand Up @@ -204,6 +206,23 @@ jobs:
ruby-versions: ${{ github.event_name == 'push' && '["3.3","3.4"]' || '["3.4"]' }}
install-command: bundle install
commands: '["bundle exec rake test"]'
docker:
needs: changes
if: contains(needs.changes.outputs.run, ',all,') || contains(needs.changes.outputs.run, ',docker,')
uses: ./.github/workflows/stack-docker.yml
with:
working-directory: fixtures/docker
dockerfiles: '["Dockerfile"]'
build: "false"
# the build is off unless a repository asks for it, so the fixture is run once more with it on
docker-build:
needs: changes
if: contains(needs.changes.outputs.run, ',all,') || contains(needs.changes.outputs.run, ',docker-build,')
uses: ./.github/workflows/stack-docker.yml
with:
working-directory: fixtures/docker
dockerfiles: '["Dockerfile"]'
build: "true"
# 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:
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ files, editor and gitignore settings, Dependabot, CI and releases — applied on
the standard evolves.

> Status: early development. Supported stacks: Node.js (including NestJS), Python, Dart and
> Flutter, shell and PowerShell scripts, Java (Maven and Gradle), Kotlin, .NET, Go, Rust, PHP and
> Ruby, each with CI and releases, on GitHub and on GitLab, plus repository settings through
> Flutter, shell and PowerShell scripts, Java (Maven and Gradle), Kotlin, .NET, Go, Rust, PHP, Ruby
> and Dockerfiles, each with CI and releases, on GitHub and on GitLab, plus repository settings through
> `repokeeper github apply` and `repokeeper gitlab apply`. See the
> [design](docs/superpowers/specs/2026-09-25-repokeeper-design.md).

Expand Down Expand Up @@ -119,7 +119,7 @@ Requires Node.js 22.12 or newer.

| Guide | What it covers |
| --- | --- |
| [CI and releases](docs/ci-and-releases.md) | The workflows repokeeper writes, pinning or mirroring the reusable workflows, `repokeeper eject`, script linting, release-please, the drift check, 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 |
| [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
33 changes: 32 additions & 1 deletion docs/ci-and-releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,37 @@ work:
A repository with no release yet (manifest at `0.0.0`) gets `initial-version: 0.1.0`, so its first
release is 0.1.0 rather than release-please's default 1.0.0.

## Dockerfiles

The docker stack is detected from a `Dockerfile` or `Containerfile` (also with a suffix, as in
`Dockerfile.dev`, or named `<name>.Dockerfile`). Unlike scripts, it is kept beside another stack: a
Node.js project with a Dockerfile gets both jobs. Its job runs [hadolint](https://github.com/hadolint/hadolint)
on every Dockerfile at the stack's root, and Dependabot keeps the base images up to date (and the
images of a compose file, when there is one).

hadolint fails the job on errors and warnings; information-level rules are printed and do not fail
it. To choose the rules yourself, add a `.hadolint.yaml` beside the Dockerfiles. The job then uses it
as it is, so set `failure-threshold` in it unless you want every rule to fail the job:

```yaml
failure-threshold: warning
ignored:
- DL3018 # pin versions in apk add
```

Two options, under `stack_options.docker`:

```yaml
stack_options:
docker:
files: [Dockerfile, docker/worker.Dockerfile] # instead of the ones found at the root
build: true # also `docker build` each one; nothing is pushed
```

The build is off unless you ask for it: a build may need a context, arguments or secrets a lint does
not. When it is on, each file is built with the stack's folder as its context. On GitLab only the lint
is written; building an image there needs a runner set up for it, and repokeeper says so.

## Drift check

Set `modules.drift: true` to add a `repokeeper` job to `ci.yml` that runs `repokeeper check` with
Expand Down Expand Up @@ -122,7 +153,7 @@ What is analyzed follows from the stacks in `.repokeeper.yml`:
| `go` | `go` | CodeQL's own (`autobuild`) |
| `ruby` | `ruby` | none |
| `rust` | `rust` | none |
| `dart`, `php`, `script` | not analyzed: CodeQL does not know them | |
| `dart`, `php`, `script`, `docker` | 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
Expand Down
1 change: 1 addition & 0 deletions docs/gitlab.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ container image instead of on a runner that a setup action prepares:
| dart | `dart` | `dart:<version>`, or `ghcr.io/cirruslabs/flutter:<version>` for Flutter | |
| java, kotlin | `java`, `kotlin` | `maven:3-eclipse-temurin-<version>`, `gradle:jdk<version>`, or `eclipse-temurin:<version>-jdk` when the project has its own `gradlew` | |
| dotnet | `dotnet` | `mcr.microsoft.com/dotnet/sdk:<newest version>` | one job; the other SDKs in `versions` are installed beside it |
| docker | `docker` | `hadolint/hadolint:<version>-debian` | the lint only; `build` has no effect |
| script | `shell`, `powershell`, `script-test` | `koalaman/shellcheck-alpine`, `mcr.microsoft.com/dotnet/sdk` (for its PowerShell) | ShellCheck and shfmt; PSScriptAnalyzer; your `test` command |

What to know:
Expand Down
6 changes: 6 additions & 0 deletions fixtures/docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
FROM alpine:3.22

RUN adduser -D -u 10001 fixture
USER 10001

CMD ["echo", "fixture"]
1 change: 1 addition & 0 deletions playground/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const STACKS = [
{ id: "kotlin", label: "Kotlin" },
{ id: "php", label: "PHP" },
{ id: "ruby", label: "Ruby" },
{ id: "docker", label: "Dockerfile" },
];

/** Stacks each platform has CI and releases for: all of them on both, today. */
Expand Down
3 changes: 3 additions & 0 deletions src/commands/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ export async function printWarnings(root: string, ctx: ModuleContext, io: Io): P
if (os !== undefined && os !== '["ubuntu-latest"]') {
io.out(`note: ${stack.id}.os is ignored on gitlab (Linux runners only)`);
}
if (stack.id === "docker" && stack.ci?.with.build === "true") {
io.out("note: docker.build is ignored on gitlab: building an image there needs a runner set up for it");
}
}
// repokeeper's jobs use GitLab's default stages; a stages list of the user's own replaces those
let ci: { stages?: unknown } | null = null;
Expand Down
1 change: 1 addition & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const STACK_IDS = [
"kotlin",
"php",
"ruby",
"docker",
] as const;
export type StackId = (typeof STACK_IDS)[number];
export type PlatformId = "github" | "gitlab";
Expand Down
1 change: 1 addition & 0 deletions src/platforms/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export const CODEQL_LANGUAGES: Record<StackId, CodeqlLanguage | null> = {
kotlin: { language: "java-kotlin", "build-mode": "autobuild" },
php: null,
ruby: { language: "ruby", "build-mode": "none" },
docker: null,
};

/** The languages to analyze, each once: those of the stacks, then the workflow files themselves. */
Expand Down
20 changes: 20 additions & 0 deletions src/platforms/gitlab-jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ const once = (image: string, script: string[], more: Job = {}): Job => ({
export const SHFMT_VERSION = "3.14.1";
/** The version of PSScriptAnalyzer the script stack checks with; the same as in stack-script.yml. */
export const SCRIPT_ANALYZER_VERSION = "1.25.0";
/** The version of hadolint the docker stack checks with; the same as in .github/workflows/stack-docker.yml. */
export const HADOLINT_VERSION = "2.15.1";

/** A file name as one word of a shell command. */
const quoted = (name: string) => (/^[\w./-]+$/.test(name) ? name : `'${name.replace(/'/g, `'\\''`)}'`);

const SHFMT = `wget -q -O /usr/local/bin/shfmt https://github.com/mvdan/sh/releases/download/v${SHFMT_VERSION}/shfmt_v${SHFMT_VERSION}_linux_amd64
chmod +x /usr/local/bin/shfmt
Expand Down Expand Up @@ -211,6 +216,20 @@ export function stackJobs(stack: ResolvedStack): Array<{ key: string; job: Job }
if (input["test-command"]) jobs.push({ key: "script-test", job: once(POWERSHELL_IMAGE, [input["test-command"]]) });
return jobs;
}
if (stack.id === "docker") {
const files = list(input, "dockerfiles").map(quoted).join(" ");
// the lint only: building an image needs a runner set up for it, which a project decides for itself.
// The -debian image has the shell a job's script runs in. As on GitHub: a .hadolint.yaml is the project's
// own choice of rules; without one, errors and warnings fail the job.
return [
{
key: "docker",
job: once(`hadolint/hadolint:v${HADOLINT_VERSION}-debian`, [
`if [ -f .hadolint.yaml ] || [ -f .hadolint.yml ]; then hadolint ${files}; else hadolint --failure-threshold warning ${files}; fi`,
]),
},
];
}
return [];
}

Expand All @@ -227,4 +246,5 @@ export const STACK_JOB_KEYS = [
"kotlin",
"php",
"ruby",
"docker",
] as const;
11 changes: 8 additions & 3 deletions src/platforms/gitlab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,16 @@ const ciKey = (module: string, key: string, value: unknown): Output => ({
const TOOL_IMAGE = "node:24";

/**
* The images repokeeper's own jobs name with a version Renovate would move: the tool image, Renovate itself and
* the .NET SDK of the dotnet and powershell jobs. Images named through a CI variable or by a moving tag
* The images repokeeper's own jobs name with a version Renovate would move: the tool image, Renovate itself,
* the .NET SDK of the dotnet and powershell jobs and hadolint. Images named through a CI variable or by a moving tag
* (`latest`, `stable`) are not in its reach.
*/
export const MANAGED_IMAGES = ["node", "renovate/renovate", "mcr.microsoft.com/dotnet/sdk"] as const;
export const MANAGED_IMAGES = [
"node",
"renovate/renovate",
"mcr.microsoft.com/dotnet/sdk",
"hadolint/hadolint",
] as const;

/** The node stack's CI inputs, as the GitHub reusable workflow receives them, as a GitLab job. */
function nodeJob(input: Record<string, string>): Record<string, unknown> {
Expand Down
44 changes: 44 additions & 0 deletions src/stacks/docker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { CONFIG_FILE } from "../config/load.js";
import { ConfigError } from "../errors.js";
import { checkKeys, filesMatching, stringList } from "./support.js";
import type { StackPack } from "./types.js";

const OPTION_KEYS = ["files", "build"];

/** A recipe for an image: `Dockerfile`, `Containerfile`, either with a suffix, or `<name>.Dockerfile`. */
const isDockerfile = (name: string) =>
// `Dockerfile.dockerignore` is the ignore file of the Dockerfile beside it
!name.endsWith(".dockerignore") && (/^(Dockerfile|Containerfile)(\..+)?$/.test(name) || /\.dockerfile$/i.test(name));

const dockerfiles = (root: string) => filesMatching(root, ".", /./).filter(isDockerfile);

const COMPOSE_FILE = /^(docker-)?compose\.ya?ml$/;

export const dockerStack: StackPack = {
id: "docker",
detect: (root) => dockerfiles(root).length > 0,
async resolve(root, options = {}) {
checkKeys("docker", options, OPTION_KEYS);
if (options.build !== undefined && typeof options.build !== "boolean") {
throw new ConfigError(`${CONFIG_FILE}: stack_options.docker.build must be true or false`);
}
return {
id: "docker",
// hadolint is rarely installed locally; CI enforces it
staged: [],
test: null,
install: null,
gitignore: [],
dependabot: filesMatching(root, ".", COMPOSE_FILE).length > 0 ? ["docker", "docker-compose"] : ["docker"],
ci: {
workflow: "stack-docker.yml",
with: {
dockerfiles: JSON.stringify(stringList("docker", options, "files") ?? dockerfiles(root)),
// off by default: a build may need a context, arguments or secrets a lint does not
build: String(options.build === true),
},
},
release: { type: "simple", version: null },
};
},
};
30 changes: 22 additions & 8 deletions src/stacks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { readdirSync } from "node:fs";
import { join } from "node:path";
import { STACK_IDS, type StackId } from "../config/types.js";
import { dartStack } from "./dart.js";
import { dockerStack } from "./docker.js";
import { dotnetStack } from "./dotnet.js";
import { goStack } from "./go.js";
import { javaStack } from "./java.js";
Expand All @@ -26,18 +27,23 @@ const PACKS: Record<StackId, StackPack> = {
kotlin: kotlinStack,
php: phpStack,
ruby: rubyStack,
docker: dockerStack,
};

export function getStackPack(id: StackId): StackPack {
return PACKS[id];
}

/** Stacks that are tooling around a project rather than a project: scripts, and the recipe for an image. */
const isProject = (id: StackId) => id !== "script" && id !== "docker";

export async function detectStacks(root: string): Promise<StackId[]> {
let found = STACK_IDS.filter((id) => PACKS[id].detect(root));
// a Kotlin build is also a Gradle build; one CI job runs it
if (found.includes("kotlin")) found = found.filter((id) => id !== "java");
// scripts beside another stack belong to that stack; the script pack is for script-only repositories
return found.length > 1 ? found.filter((id) => id !== "script") : found;
// scripts beside a project belong to that project; the script pack is for repositories without one.
// A Dockerfile is checked wherever it is: no other stack's job reads it.
return found.some(isProject) ? found.filter((id) => id !== "script") : found;
}

/** Folders that hold a project's by-products or samples rather than a project of the repository. */
Expand Down Expand Up @@ -75,8 +81,9 @@ export interface StackLayout {
export async function detectStackLayout(root: string): Promise<StackLayout> {
const atRoot = await detectStacks(root);
const rooted: StackLayout = { stacks: atRoot, directories: {}, skipped: [] };
// loose scripts at the root do not make it a project: a monorepo often keeps some beside its folders
if (atRoot.some((id) => id !== "script")) return rooted;
// loose scripts or a Dockerfile at the root do not make it a project: a monorepo often keeps some beside
// its folders
if (atRoot.some(isProject)) return rooted;

const directories: Partial<Record<StackId, string>> = {};
const skipped: StackLayout["skipped"] = [];
Expand All @@ -90,11 +97,18 @@ export async function detectStackLayout(root: string): Promise<StackLayout> {
else skipped.push({ stack: id, directory: folder });
}
}
let stacks = STACK_IDS.filter((id) => directories[id] !== undefined);
// nothing but scripts in the folders either: the root's own answer stands
if (atRoot.length > 0 && stacks.every((id) => id === "script")) return rooted;
// the root's own Dockerfile is the docker stack, at the root beside the projects in the folders; one in a
// folder is then a second, and is reported like any other
const dockerAtRoot = atRoot.includes("docker");
if (dockerAtRoot && directories.docker !== undefined) {
skipped.unshift({ stack: "docker", directory: directories.docker });
delete directories.docker;
}
let stacks = STACK_IDS.filter((id) => directories[id] !== undefined || (id === "docker" && dockerAtRoot));
// no project in the folders either: the root's own answer stands
if (atRoot.length > 0 && !stacks.some(isProject)) return rooted;
// as at the root: a folder of scripts beside real projects is tooling, not a stack
if (stacks.length > 1 && directories.script !== undefined) {
if (stacks.some(isProject) && directories.script !== undefined) {
stacks = stacks.filter((id) => id !== "script");
delete directories.script;
}
Expand Down
Loading
Loading