From 871f1fa9240ebc32038c3460c976b307b920ff4d Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Tue, 13 Oct 2026 21:13:33 +0700 Subject: [PATCH] feat: a docker stack that lints Dockerfiles with hadolint A repository's Dockerfile was no stack's business: nothing checked it and nothing kept its base image up to date. The docker stack is detected from a Dockerfile or Containerfile, also with a suffix or named .Dockerfile. Unlike scripts it is kept beside another stack, since no other stack's job reads a Dockerfile. Its CI job calls the new reusable workflow stack-docker.yml, which runs hadolint 2.15.1 on every Dockerfile at the stack's root. Errors and warnings fail the job and information-level rules are printed; a .hadolint.yaml beside the files is used as it is. Dependabot watches the base images, and those of a compose file when there is one. stack_options.docker takes `files`, to name the Dockerfiles, and `build`, to build each image as well without pushing it. The build is off unless asked for: it may need a context, arguments or secrets a lint does not. On GitLab the lint runs in the hadolint image, which Renovate is told to leave alone like the other managed images; `build` is not written there, and repokeeper says so. Detection: a Dockerfile at the root does not make the root the project, so a monorepo with one is still looked into, and the Dockerfile stays a stack at the root. A repository that is only an image keeps its scripts as a stack. --- .github/workflows/stack-docker.yml | 69 ++++++++++++++ .github/workflows/workflow-tests.yml | 19 ++++ README.md | 6 +- docs/ci-and-releases.md | 31 ++++++ docs/gitlab.md | 1 + fixtures/docker/Dockerfile | 6 ++ playground/config.js | 1 + src/commands/report.ts | 3 + src/config/types.ts | 1 + src/platforms/gitlab-jobs.ts | 20 ++++ src/platforms/gitlab.ts | 11 ++- src/stacks/docker.ts | 44 +++++++++ src/stacks/index.ts | 30 ++++-- test/config.test.ts | 2 +- test/platforms-gitlab-stacks.test.ts | 26 ++++- test/platforms-gitlab.test.ts | 2 +- test/stacks-docker.test.ts | 138 +++++++++++++++++++++++++++ test/workflows.test.ts | 30 ++++++ 18 files changed, 423 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/stack-docker.yml create mode 100644 fixtures/docker/Dockerfile create mode 100644 src/stacks/docker.ts create mode 100644 test/stacks-docker.test.ts diff --git a/.github/workflows/stack-docker.yml b/.github/workflows/stack-docker.yml new file mode 100644 index 0000000..33049eb --- /dev/null +++ b/.github/workflows/stack-docker.yml @@ -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" . 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). @@ -111,7 +111,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..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 25ce0bb..c0fd67c 100644 --- a/docs/ci-and-releases.md +++ b/docs/ci-and-releases.md @@ -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 `.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 diff --git a/docs/gitlab.md b/docs/gitlab.md index c22103d..6897c66 100644 --- a/docs/gitlab.md +++ b/docs/gitlab.md @@ -31,6 +31,7 @@ container image instead of on a runner that a setup action prepares: | dart | `dart` | `dart:`, or `ghcr.io/cirruslabs/flutter:` for Flutter | | | java, kotlin | `java`, `kotlin` | `maven:3-eclipse-temurin-`, `gradle:jdk`, or `eclipse-temurin:-jdk` when the project has its own `gradlew` | | | dotnet | `dotnet` | `mcr.microsoft.com/dotnet/sdk:` | one job; the other SDKs in `versions` are installed beside it | +| docker | `docker` | `hadolint/hadolint:-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: diff --git a/fixtures/docker/Dockerfile b/fixtures/docker/Dockerfile new file mode 100644 index 0000000..5d666a4 --- /dev/null +++ b/fixtures/docker/Dockerfile @@ -0,0 +1,6 @@ +FROM alpine:3.22 + +RUN adduser -D -u 10001 fixture +USER 10001 + +CMD ["echo", "fixture"] diff --git a/playground/config.js b/playground/config.js index f139d63..3445b3d 100644 --- a/playground/config.js +++ b/playground/config.js @@ -25,6 +25,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. */ diff --git a/src/commands/report.ts b/src/commands/report.ts index 6c102ed..3469622 100644 --- a/src/commands/report.ts +++ b/src/commands/report.ts @@ -123,6 +123,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; diff --git a/src/config/types.ts b/src/config/types.ts index 2e97ba3..590be18 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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"; diff --git a/src/platforms/gitlab-jobs.ts b/src/platforms/gitlab-jobs.ts index 94dfc0a..41a0714 100644 --- a/src/platforms/gitlab-jobs.ts +++ b/src/platforms/gitlab-jobs.ts @@ -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 @@ -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 []; } @@ -227,4 +246,5 @@ export const STACK_JOB_KEYS = [ "kotlin", "php", "ruby", + "docker", ] as const; diff --git a/src/platforms/gitlab.ts b/src/platforms/gitlab.ts index 5c7c577..aaea1d4 100644 --- a/src/platforms/gitlab.ts +++ b/src/platforms/gitlab.ts @@ -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): Record { diff --git a/src/stacks/docker.ts b/src/stacks/docker.ts new file mode 100644 index 0000000..f371eb8 --- /dev/null +++ b/src/stacks/docker.ts @@ -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 `.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 }, + }; + }, +}; diff --git a/src/stacks/index.ts b/src/stacks/index.ts index 2e2aad7..2d5f8b9 100644 --- a/src/stacks/index.ts +++ b/src/stacks/index.ts @@ -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"; @@ -26,18 +27,23 @@ const PACKS: Record = { 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 { 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. */ @@ -75,8 +81,9 @@ export interface StackLayout { export async function detectStackLayout(root: string): Promise { 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> = {}; const skipped: StackLayout["skipped"] = []; @@ -90,11 +97,18 @@ export async function detectStackLayout(root: string): Promise { 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; } diff --git a/test/config.test.ts b/test/config.test.ts index 30117e1..2e10db5 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -67,7 +67,7 @@ describe("parseConfig", () => { it("lists the allowed values of an enum", () => { const text = "schema: 1\nstandard: 1.0.0\nplatform: github\nstacks: [cobol]\nmodules:\n health: false\n"; expect(errorOf(text)).toBe( - ".repokeeper.yml:4: stacks.0 must be one of: node, python, dart, script, java, dotnet, go, rust, kotlin, php, ruby", + ".repokeeper.yml:4: stacks.0 must be one of: node, python, dart, script, java, dotnet, go, rust, kotlin, php, ruby, docker", ); }); diff --git a/test/platforms-gitlab-stacks.test.ts b/test/platforms-gitlab-stacks.test.ts index 80e467b..c4df889 100644 --- a/test/platforms-gitlab-stacks.test.ts +++ b/test/platforms-gitlab-stacks.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest"; import { parse } from "yaml"; import { run } from "../src/cli.js"; import { MANAGED_IMAGES } from "../src/platforms/gitlab.js"; -import { SCRIPT_ANALYZER_VERSION, SHFMT_VERSION } from "../src/platforms/gitlab-jobs.js"; +import { HADOLINT_VERSION, SCRIPT_ANALYZER_VERSION, SHFMT_VERSION } from "../src/platforms/gitlab-jobs.js"; import { PACKAGE_VERSION, TOOL_VERSIONS } from "../src/version.js"; import { capture, tempDir } from "./helpers.js"; @@ -86,6 +86,7 @@ const FIXTURES = [ "kotlin", "dotnet", "script", + "docker", ] as const; describe("gitlab ci for the other stacks", () => { @@ -262,6 +263,29 @@ describe("gitlab ci for the other stacks", () => { expect(github).toContain(`-RequiredVersion ${SCRIPT_ANALYZER_VERSION} `); }); + it("docker: hadolint on every Dockerfile, and no build", async () => { + const docker = await onGitlab("docker", { "Dockerfile.dev": "FROM alpine:3.22\n" }); + expect(Object.keys(docker.ci)).toEqual(["workflow", "docker", "commits", "renovate", "release"]); + expect(docker.ci.docker).toEqual({ + stage: "test", + image: `hadolint/hadolint:v${HADOLINT_VERSION}-debian`, + rules: FOR_CHANGES, + // a .hadolint.yaml is the project's own choice of rules; without one, errors and warnings fail the job + script: [ + "if [ -f .hadolint.yaml ] || [ -f .hadolint.yml ]; then hadolint Dockerfile Dockerfile.dev; else hadolint --failure-threshold warning Dockerfile Dockerfile.dev; fi", + ], + }); + + // a build that was asked for is not written, and repokeeper says so + const building = await onGitlab("docker", {}, " docker:\n build: true"); + expect(Object.keys(building.ci)).toEqual(["workflow", "docker", "commits", "renovate", "release"]); + const c = capture(building.dir); + expect(await run(["check"], c.io)).toBe(0); + expect(c.out).toContain( + "note: docker.build is ignored on gitlab: building an image there needs a runner set up for it", + ); + }); + // Renovate reads .gitlab-ci.yml too. An image one of repokeeper's jobs names with a version is the standard's // to move, so Renovate is told to leave it: otherwise it opens a merge request against a managed key. it("names for Renovate every image its own jobs pin to a version", async () => { diff --git a/test/platforms-gitlab.test.ts b/test/platforms-gitlab.test.ts index 23f2c54..e1d76fc 100644 --- a/test/platforms-gitlab.test.ts +++ b/test/platforms-gitlab.test.ts @@ -271,7 +271,7 @@ describe("gitlab dependency updates", () => { { matchManagers: ["gitlabci"], matchFileNames: [".gitlab-ci.yml"], - matchPackageNames: ["node", "renovate/renovate", "mcr.microsoft.com/dotnet/sdk"], + matchPackageNames: ["node", "renovate/renovate", "mcr.microsoft.com/dotnet/sdk", "hadolint/hadolint"], enabled: false, }, ], diff --git a/test/stacks-docker.test.ts b/test/stacks-docker.test.ts new file mode 100644 index 0000000..d773126 --- /dev/null +++ b/test/stacks-docker.test.ts @@ -0,0 +1,138 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { stackJobs } from "../src/platforms/gitlab-jobs.js"; +import { dockerStack } from "../src/stacks/docker.js"; +import { detectStackLayout, detectStacks } from "../src/stacks/index.js"; +import { tempDir } from "./helpers.js"; + +async function repo(files: Record): Promise { + const dir = await tempDir(); + for (const [name, text] of Object.entries(files)) { + await mkdir(dirname(join(dir, name)), { recursive: true }); + await writeFile(join(dir, name), text); + } + return dir; +} + +const FROM = "FROM alpine:3.22\n"; + +describe("docker stack", () => { + it("is detected from a Dockerfile or a Containerfile, whatever it is named after", async () => { + for (const name of ["Dockerfile", "Containerfile", "Dockerfile.dev", "api.Dockerfile", "worker.dockerfile"]) { + expect(dockerStack.detect(await repo({ [name]: FROM })), name).toBe(true); + } + // files about images that are not a recipe for one + for (const name of [ + "Dockerfile.dockerignore", + ".dockerignore", + "compose.yaml", + "docker-compose.yml", + "README.md", + ]) { + expect(dockerStack.detect(await repo({ [name]: "" })), name).toBe(false); + } + }); + + it("lints every Dockerfile at its root with hadolint, and builds nothing unless asked", async () => { + const stack = await dockerStack.resolve( + await repo({ + Dockerfile: FROM, + "Dockerfile.dev": FROM, + "worker.Dockerfile": FROM, + "Dockerfile.dockerignore": "", + "docs/Dockerfile": FROM, + }), + ); + expect(stack.ci).toEqual({ + workflow: "stack-docker.yml", + with: { dockerfiles: '["Dockerfile","Dockerfile.dev","worker.Dockerfile"]', build: "false" }, + }); + // hadolint is rarely installed locally; CI enforces it + expect(stack.staged).toEqual([]); + expect(stack.test).toBeNull(); + expect(stack.install).toBeNull(); + expect(stack.gitignore).toEqual([]); + expect(stack.dependabot).toEqual(["docker"]); + expect(stack.release).toEqual({ type: "simple", version: null }); + }); + + it("keeps the images of a compose file up to date too, when there is one", async () => { + for (const name of ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"]) { + const stack = await dockerStack.resolve(await repo({ Dockerfile: FROM, [name]: "services: {}\n" })); + expect(stack.dependabot, name).toEqual(["docker", "docker-compose"]); + } + }); + + it("takes the files and the build from stack_options", async () => { + const root = await repo({ Dockerfile: FROM, "docker/app.Dockerfile": FROM }); + const stack = await dockerStack.resolve(root, { files: ["docker/app.Dockerfile"], build: true }); + expect(stack.ci?.with).toEqual({ dockerfiles: '["docker/app.Dockerfile"]', build: "true" }); + + await expect(dockerStack.resolve(root, { push: true })).rejects.toThrow( + "stack_options.docker.push is not a known key", + ); + await expect(dockerStack.resolve(root, { build: "yes" })).rejects.toThrow( + "stack_options.docker.build must be true or false", + ); + await expect(dockerStack.resolve(root, { files: [] })).rejects.toThrow( + "stack_options.docker.files must be a non-empty list of strings", + ); + }); +}); + +describe("the docker job on GitLab", () => { + it("names a file the shell would split or end as one word", async () => { + const stack = await dockerStack.resolve(await repo({ Dockerfile: FROM }), { + files: ["Dockerfile", "my app.Dockerfile", "it's.Dockerfile"], + }); + const script = (stackJobs(stack)[0]?.job.script ?? []) as string[]; + expect(script[0]).toContain( + `else hadolint --failure-threshold warning Dockerfile 'my app.Dockerfile' 'it'\\''s.Dockerfile'; fi`, + ); + }); +}); + +describe("detection with a Dockerfile", () => { + it("keeps the docker stack beside another one: a Dockerfile is linted wherever it is", async () => { + expect(await detectStacks(await repo({ "package.json": "{}", Dockerfile: FROM }))).toEqual(["node", "docker"]); + expect(await detectStacks(await repo({ Dockerfile: FROM }))).toEqual(["docker"]); + }); + + it("lints the scripts of a repository that is only an image, and leaves them to a project otherwise", async () => { + // an image and its entrypoint: there is no project for the scripts to belong to + expect(await detectStacks(await repo({ Dockerfile: FROM, "entrypoint.sh": "" }))).toEqual(["script", "docker"]); + expect(await detectStacks(await repo({ "go.mod": "module a\n", Dockerfile: FROM, "run.sh": "" }))).toEqual([ + "go", + "docker", + ]); + }); + + it("still looks into the folders of a monorepo that has a Dockerfile at its root", async () => { + const layout = await detectStackLayout( + await repo({ Dockerfile: FROM, "web/package.json": "{}", "api/go.mod": "module a\n" }), + ); + // the root's Dockerfile stays a stack at the root + expect(layout.stacks).toEqual(["node", "go", "docker"]); + expect(layout.directories).toEqual({ node: "web", go: "api" }); + }); + + it("takes a Dockerfile in a folder with that folder, and reports a second one", async () => { + const layout = await detectStackLayout( + await repo({ + "api/go.mod": "module a\n", + "api/Dockerfile": FROM, + "web/package.json": "{}", + "web/Dockerfile": FROM, + }), + ); + expect(layout.stacks).toEqual(["node", "go", "docker"]); + expect(layout.directories).toEqual({ node: "web", go: "api", docker: "api" }); + expect(layout.skipped).toEqual([{ stack: "docker", directory: "web" }]); + }); + + it("leaves a root that is only an image as it is, whatever is beside it", async () => { + const layout = await detectStackLayout(await repo({ Dockerfile: FROM, "tools/build.sh": "" })); + expect(layout).toEqual({ stacks: ["docker"], directories: {}, skipped: [] }); + }); +}); diff --git a/test/workflows.test.ts b/test/workflows.test.ts index d7c61ac..73ec711 100644 --- a/test/workflows.test.ts +++ b/test/workflows.test.ts @@ -4,7 +4,9 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { parse } from "yaml"; +import { HADOLINT_VERSION } from "../src/platforms/gitlab-jobs.js"; import { dartStack } from "../src/stacks/dart.js"; +import { dockerStack } from "../src/stacks/docker.js"; import { dotnetStack } from "../src/stacks/dotnet.js"; import { goStack } from "../src/stacks/go.js"; import { javaStack } from "../src/stacks/java.js"; @@ -54,6 +56,7 @@ const REUSABLE = [ "stack-rust.yml", "stack-php.yml", "stack-ruby.yml", + "stack-docker.yml", ]; /** Fixture jobs in workflow-tests.yml and the pack that resolves each fixture. Each stack task appends its rows. */ @@ -71,6 +74,7 @@ const FIXTURES: { job: string; dir: string; pack: StackPack }[] = [ { job: "kotlin", dir: "fixtures/kotlin", pack: kotlinStack }, { job: "php", dir: "fixtures/php", pack: phpStack }, { job: "ruby", dir: "fixtures/ruby", pack: rubyStack }, + { job: "docker", dir: "fixtures/docker", pack: dockerStack }, ]; const read = (name: string) => readFileSync(`.github/workflows/${name}`, "utf8"); @@ -131,6 +135,32 @@ describe.each(FIXTURES)("fixture $dir", ({ job, dir, pack }) => { }); }); +it("stack-docker is run once more with the build on, which no fixture resolves to", () => { + const tests = workflow("workflow-tests.yml"); + expect(tests.jobs["docker-build"]).toMatchObject({ + 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" }, + }); + const build = workflow("stack-docker.yml").jobs.build; + expect(build?.if).toBe("inputs.build == 'true'"); + // the image is built and left on the runner: nothing logs in and nothing is pushed + const commands = (build?.steps ?? []).map((step) => step.run ?? "").join("\n"); + expect(commands).toContain('docker build --file "$file" .'); + expect(commands).not.toMatch(/docker (push|login)/); +}); + +it("stack-docker fails on errors and warnings unless the repository has its own hadolint rules", () => { + const lint = workflow("stack-docker.yml").jobs.hadolint?.steps?.find((step) => step.name === "hadolint")?.run ?? ""; + expect(lint).toContain("if [ -f .hadolint.yaml ] || [ -f .hadolint.yml ]; then"); + expect(lint).toContain("threshold=(--failure-threshold warning)"); +}); + +it("stack-docker checks with the hadolint the GitLab job uses", () => { + expect(read("stack-docker.yml")).toContain(`/download/v${HADOLINT_VERSION}/hadolint-linux-x86_64`); +}); + it("workflow-tests cancels superseded pull request runs", () => { expect(workflow("workflow-tests.yml").concurrency).toEqual({ // biome-ignore lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions