From ee21da5e28caedc784bddb951eb95fe9f24591a5 Mon Sep 17 00:00:00 2001 From: Julian Blacher Date: Sun, 25 Oct 2026 03:52:31 -0300 Subject: [PATCH] Add a container image and pre-commit hooks The image is a static docker-lint on distroless, running as a non-root user, for linux/amd64 and linux/arm64. Pull requests build and test it; publishing a release pushes it to ghcr.io, so nothing is public before the release is. Its own Dockerfile passes docker-lint --strict. Two pre-commit hooks lint staged Dockerfiles, skipping templates as a directory search does: docker-lint builds the binary with Go, and docker-lint-docker runs the image. The release workflow fails if the image hook names another version. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 7 +++ .github/workflows/image.yml | 100 ++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 10 ++++ .pre-commit-hooks.yaml | 31 +++++++++++ CHANGELOG.md | 5 ++ CONTRIBUTING.md | 9 ++- Dockerfile | 43 +++++++++++++++ README.md | 31 ++++++++++- 8 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/image.yml create mode 100644 .pre-commit-hooks.yaml create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e5a9562 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +# The image build only needs go.mod, go.sum, cmd/ and internal/. +* +!go.mod +!go.sum +!cmd/ +!internal/ +**/*_test.go diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 0000000..6aa4785 --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,100 @@ +name: Image + +# Builds and tests the container image on pull requests, and publishes it to +# GitHub Container Registry when a release is published. Publishing waits for +# the release (not the tag), so nothing becomes public before the release does. +on: + pull_request: + branches: [main] + release: + types: [published] + +permissions: + contents: read + +jobs: + image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Image metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: ghcr.io/${{ github.repository }} + # On a release: 0.1.1 and 0.1, plus latest unless it is a prerelease. + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Build information + id: build + env: + RELEASE_VERSION: ${{ github.event_name == 'release' && steps.meta.outputs.version || 'dev' }} + run: | + echo "commit=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + echo "date=$(git log -1 --format=%cI)" >> "$GITHUB_OUTPUT" + echo "version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT" + + - name: Build the image for testing + uses: docker/build-push-action@v7 + with: + context: . + load: true + tags: docker-lint:test + build-args: | + VERSION=${{ steps.build.outputs.version }} + COMMIT=${{ steps.build.outputs.commit }} + DATE=${{ steps.build.outputs.date }} + + - name: Test the image + run: | + set -euo pipefail + docker run --rm docker-lint:test --version + + # Standard input, and a mounted directory as the working directory. + docker run --rm -i docker-lint:test < testdata/valid/minimal.Dockerfile + code=0 + docker run --rm -v "$PWD/testdata:/work:ro" docker-lint:test invalid/no-from.Dockerfile || code=$? + test "$code" = 1 + + # The image's own Dockerfile passes its own linter. + docker run --rm -v "$PWD:/work:ro" docker-lint:test --strict Dockerfile + + # Cross-compiles, so it is cheap; catches an arm64 failure before release. + - name: Build every platform + if: github.event_name != 'release' + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/amd64,linux/arm64 + + - name: Log in to GitHub Container Registry + if: github.event_name == 'release' + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish the image + if: github.event_name == 'release' + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ steps.build.outputs.version }} + COMMIT=${{ steps.build.outputs.commit }} + DATE=${{ steps.build.outputs.date }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a19e904..9f28af6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,16 @@ jobs: - name: Run tests run: go test ./... + # The docker-lint-docker pre-commit hook names the image by version, so a + # release must ship a hook that points at its own image. + - name: Check the pre-commit hook uses this version's image + run: | + image="ghcr.io/devblac/docker-lint:${GITHUB_REF_NAME#v}" + if ! grep -q "entry: $image\$" .pre-commit-hooks.yaml; then + echo "::error file=.pre-commit-hooks.yaml::the docker-lint-docker hook must use $image" + exit 1 + fi + - name: Extract release notes from CHANGELOG.md run: sh scripts/release-notes.sh "${GITHUB_REF_NAME#v}" > "$RUNNER_TEMP/release-notes.md" diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000..8108c4b --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,31 @@ +# pre-commit hooks for docker-lint. In a project's .pre-commit-config.yaml: +# +# repos: +# - repo: https://github.com/devblac/docker-lint +# rev: v0.1.1 +# hooks: +# - id: docker-lint # builds docker-lint with Go +# # or +# - id: docker-lint-docker # runs the container image instead +# +# Both pass the staged Dockerfiles to docker-lint, which also reads the +# project's .docker-lint.yaml. Templates such as Dockerfile.template are left +# out, as in a directory search. + +- id: docker-lint + name: docker-lint + description: Lint Dockerfiles for inefficiencies, anti-patterns and security issues. + entry: docker-lint + language: golang + files: &dockerfiles (?i)(^|/)(dockerfile|containerfile)(\.[^/]*)?$|\.(dockerfile|containerfile)$ + exclude: ¬-dockerfiles (?i)\.(dockerignore|template|tmpl|tpl|j2|jinja2?|erb|mustache|in|bak|orig|old|swp|rej|md|txt|sample|example)$ + require_serial: true + +- id: docker-lint-docker + name: docker-lint (Docker) + description: Lint Dockerfiles with the docker-lint container image; needs Docker but not Go. + entry: ghcr.io/devblac/docker-lint:0.1.1 + language: docker_image + files: *dockerfiles + exclude: *not-dockerfiles + require_serial: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cf9824..36eac99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,11 @@ source. Version 0.1.0 was tagged but never published. at (`@v0.1.1` installs v0.1.1), so pinning the action pins the linter - Tests that every rule reports a real-world example of its problem, alongside the corpus that guards against false positives +- Container image `ghcr.io/devblac/docker-lint` for linux/amd64 and linux/arm64, + a static binary on distroless running as a non-root user, published when a + release is published +- pre-commit hooks: `docker-lint` (built with Go) and `docker-lint-docker` (runs + the container image) ### Changed - **Breaking:** rule IDs move from `DLxxxx`, which collide with hadolint's IDs for diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c30fa9..6e876e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,8 +101,10 @@ release, so nothing is public until a maintainer publishes it. 1. Make sure `main` is green and `goreleaser release --snapshot --clean` builds locally (CI also runs it on every pull request). 2. In `CHANGELOG.md`, rename `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, add a new - empty `## [Unreleased]` above it, update the comparison links at the bottom, and - merge that change to `main`. + empty `## [Unreleased]` above it, update the comparison links at the bottom, point + the `docker-lint-docker` hook in `.pre-commit-hooks.yaml` and the version examples + in `README.md` at the new version, and merge that change to `main`. The Release + workflow fails if the hook names another version's image. 3. Tag the merge commit and push the tag: ```bash @@ -117,6 +119,9 @@ release, so nothing is public until a maintainer publishes it. verify it against `checksums.txt`, then publish it. Avoid `go install ...@vX.Y.Z` before publishing: the Go module proxy caches a tag's contents permanently, so the tag can no longer be moved if the release needs fixing. +6. Publishing the release runs the Image workflow, which pushes + `ghcr.io/devblac/docker-lint` for linux/amd64 and linux/arm64. The first time, + make the package public in its settings on GitHub; packages start out private. ## Questions? diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..61ef678 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# docker-lint container image. +# +# docker run --rm -i ghcr.io/devblac/docker-lint < Dockerfile +# docker run --rm -v "$PWD:/work" ghcr.io/devblac/docker-lint . +# +# Built for linux/amd64 and linux/arm64 by .github/workflows/image.yml when a +# release is published. The build stage runs on the build machine's platform +# and cross-compiles, so no emulation is needed. + +FROM --platform=$BUILDPLATFORM golang:1.22-alpine AS build + +ARG TARGETOS +ARG TARGETARCH +ARG VERSION=dev +ARG COMMIT=none +ARG DATE=unknown + +WORKDIR /src + +# Download modules in their own layer, so source changes reuse it. +COPY go.mod go.sum ./ +RUN go mod download + +COPY cmd/ cmd/ +COPY internal/ internal/ +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath \ + -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" \ + -o /out/docker-lint ./cmd/docker-lint + +# A static binary needs no libc or shell; the nonroot variant runs as UID 65532. +FROM gcr.io/distroless/static-debian12:nonroot + +LABEL org.opencontainers.image.title="docker-lint" \ + org.opencontainers.image.description="Static analyzer for Dockerfiles" \ + org.opencontainers.image.source="https://github.com/devblac/docker-lint" \ + org.opencontainers.image.licenses="MIT" + +COPY --from=build /out/docker-lint /usr/local/bin/docker-lint + +WORKDIR /work + +# docker-lint ignore: DKL404 +ENTRYPOINT ["docker-lint"] diff --git a/README.md b/README.md index 86a3bc6..e09805b 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,33 @@ sudo mv docker-lint /usr/local/bin/ Archives are named `docker-lint___`, with `.zip` for Windows and `.tar.gz` elsewhere. +### Docker + +A 2.5 MB image (static binary on distroless, running as a non-root user) for linux/amd64 and linux/arm64: + +```bash +# Lint from standard input +docker run --rm -i ghcr.io/devblac/docker-lint:0.1.1 < Dockerfile + +# Lint every Dockerfile in the current directory +docker run --rm -v "$PWD:/work" ghcr.io/devblac/docker-lint:0.1.1 . +``` + +### pre-commit + +Add to `.pre-commit-config.yaml` to lint staged Dockerfiles before each commit: + +```yaml +repos: + - repo: https://github.com/devblac/docker-lint + rev: v0.1.1 + hooks: + - id: docker-lint # builds docker-lint with Go + # - id: docker-lint-docker # or runs the container image (needs Docker, not Go) +``` + +Both hooks read the project's `.docker-lint.yaml` and skip Dockerfile templates. + ### From Source Requires Go 1.22 or later (as specified in `go.mod`). @@ -377,8 +404,8 @@ go test ./... # Build go build -o docker-lint ./cmd/docker-lint -# Run linter on itself -./docker-lint testdata/valid/minimal.Dockerfile +# Run linter on itself (the repository's own Dockerfile builds the image) +./docker-lint Dockerfile ``` ## License