diff --git a/.github/instructions/executing-commands.md b/.github/instructions/executing-commands.md index 3aeeff6..21e5eac 100644 --- a/.github/instructions/executing-commands.md +++ b/.github/instructions/executing-commands.md @@ -17,6 +17,7 @@ installing host tools) may run locally on the host. | Rewrite the golden files | `task test:update` | | Build the binary | `task build` | | Check the runtime image | `task image:smoke` | +| Lint the Dockerfile | `task lint:docker` | | Check Markdown style | `task md:check` | | Fix Markdown (tables + autofixable) | `task md:fix` | | Re-record a documentation GIF | `task demo:record:` | @@ -57,6 +58,11 @@ daemon through `docker-socket-proxy`, so the repository and `TMPDIR` are mounted inside it as on the host — the tests bind-mount those paths, and the daemon resolves them on the host. CI runs the same suite, installing bats with `bats-core/bats-action` instead. +`task lint:docker` runs hadolint over the `Dockerfile` inside the `hadolint` Docker Compose service +under the `lint` profile. The rules it skips are in `.hadolint.yml` at the repository root. CI runs +the task itself in the `dockerfile-lint` job of `.github/workflows/ci.yml`, so the hadolint version +is pinned once, in `compose.yml`. + `task md:check` and `task md:fix` run `markdownlint-cli2` (and, for fixes, `markdown-table-formatter`) inside the `node` Docker Compose service under the `markdown` profile. The same checks run in CI via `.github/workflows/md.yml`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0408cfa..fa3c594 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,22 @@ jobs: - name: Integration tests (no network) run: go test -tags=integration -race -count=1 ./internal/cmd/... + dockerfile-lint: + name: Dockerfile lint + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Task + uses: go-task/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Lint the Dockerfile + run: task lint:docker + image: name: Image (${{ matrix.platform }}) runs-on: ${{ matrix.runner }} diff --git a/.hadolint.yml b/.hadolint.yml new file mode 100644 index 0000000..f8db95c --- /dev/null +++ b/.hadolint.yml @@ -0,0 +1,6 @@ +# https://github.com/hadolint/hadolint#configure +ignored: + # Pinning every apt/apk package on top of an already pinned base image trades a reproducible + # build for one that breaks the moment the distro moves a package version out from under it. + - DL3008 # Pin versions in apt-get install + - DL3018 # Pin versions in apk add diff --git a/Dockerfile b/Dockerfile index 2cc20ba..e2f51b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,13 @@ # syntax=docker/dockerfile:1 # check=error=true +# Latest version: https://github.com/go-task/task/releases/latest +ARG TASK_VERSION=3.53.1 +# Latest version: https://download.docker.com/linux/static/stable/ +ARG DOCKER_VERSION=29.8.0 +# Latest version: https://github.com/docker/compose/releases/latest +ARG COMPOSE_VERSION=5.5.1 + # Latest version: https://hub.docker.com/_/golang/tags FROM --platform=$BUILDPLATFORM golang:1.27.1-trixie AS base @@ -11,7 +18,8 @@ RUN apt-get update \ ca-certificates \ tree \ git \ - openssh-client + openssh-client \ + && rm -rf /var/lib/apt/lists/* FROM base AS builder-download @@ -48,13 +56,13 @@ COPY --from=build /src/specs /specs FROM ghcr.io/charmbracelet/vhs:v0.11.0 AS vhs ARG TARGETARCH +ARG TASK_VERSION +ARG DOCKER_VERSION +ARG COMPOSE_VERSION -# Latest version: https://github.com/go-task/task/releases/latest -ARG TASK_VERSION=3.53.1 -# Latest version: https://download.docker.com/linux/static/stable/ -ARG DOCKER_VERSION=29.8.0 -# Latest version: https://github.com/docker/compose/releases/latest -ARG COMPOSE_VERSION=5.5.1 +# The downloads below are `curl | tar`, and the default /bin/sh reports only tar's exit status. +# Without pipefail a failed download is caught by tar choking on the stream, not by the shell. +SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN apt-get update \ && apt-get install --assume-yes --no-install-recommends \ @@ -86,9 +94,8 @@ RUN git config --system init.defaultBranch main FROM bats/bats:1.14.0 AS bats ARG TARGETARCH +ARG DOCKER_VERSION -# Latest version: https://download.docker.com/linux/static/stable/ -ARG DOCKER_VERSION=29.8.0 # Latest version: https://github.com/bats-core/bats-support/releases/latest ARG BATS_SUPPORT_VERSION=0.3.0 # Latest version: https://github.com/bats-core/bats-assert/releases/latest @@ -96,6 +103,9 @@ ARG BATS_ASSERT_VERSION=2.2.4 # Latest version: https://github.com/bats-core/bats-file/releases/latest ARG BATS_FILE_VERSION=0.4.0 +# busybox ash, since this stage is Alpine and carries no bash. +SHELL ["/bin/ash", "-o", "pipefail", "-c"] + RUN apk add --no-cache \ curl \ tar @@ -122,9 +132,44 @@ ENV BATS_LIB_PATH=/usr/lib/bats # Latest version: https://hub.docker.com/_/debian/tags FROM debian:13.6-slim AS debian -COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +ARG TARGETARCH +ARG TASK_VERSION +ARG DOCKER_VERSION +ARG COMPOSE_VERSION + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + COPY --from=build /src/specs /usr/local/bin/specs +RUN apt-get update \ + && apt-get install --assume-yes --no-install-recommends \ + ca-certificates \ + curl \ + git \ + && rm -rf /var/lib/apt/lists/* + +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) altarch=x86_64 ;; \ + arm64) altarch=aarch64 ;; \ + *) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + curl --fail --silent --show-error --location \ + "https://github.com/go-task/task/releases/download/v${TASK_VERSION}/task_linux_${TARGETARCH}.tar.gz" \ + | tar --extract --gzip --directory /usr/bin task; \ + curl --fail --silent --show-error --location \ + "https://download.docker.com/linux/static/stable/${altarch}/docker-${DOCKER_VERSION}.tgz" \ + | tar --extract --gzip --directory /usr/bin --strip-components=1 docker/docker; \ + mkdir -p /usr/local/lib/docker/cli-plugins; \ + curl --fail --silent --show-error --location --output /usr/local/lib/docker/cli-plugins/docker-compose \ + "https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-${altarch}"; \ + chmod +x /usr/local/lib/docker/cli-plugins/docker-compose + +# A container carries no user gitconfig, so a `git init` hook would pick git's +# built-in default and hand back a `master` branch where the same template run on +# a host produces `main`. +RUN git config --system init.defaultBranch main + RUN groupadd --gid 1000 specs \ && useradd --uid 1000 --gid 1000 --create-home --shell /bin/bash specs \ && mkdir -p /config /work \ diff --git a/README.md b/README.md index fb05f16..4420edc 100644 --- a/README.md +++ b/README.md @@ -77,9 +77,10 @@ docker run --rm -it -v "$PWD:/work" ghcr.io/specsnl/specs-cli use specsnl/my-tem ``` `-it` is what lets it prompt, and on a host where you are not uid 1000 add -`--user "$(id -u):$(id -g)" --env HOME=/tmp` so the scaffolded files come out yours. The +`--user "$(id -u):$(id -g)" --env HOME=/tmp` so the scaffolded files come out yours. The image +carries `bash`, `git`, `task` and the `docker` CLI so a template's hooks have something to run. The [installation docs](https://cli.specs.dev/docs/installation/) cover the rest — tags, the template -registry volume, and SSH sources. +registry volume, hooks that start containers, and SSH sources. --- @@ -108,6 +109,7 @@ task dc:build # build the images once task build # build the binary for the current platform task test # run the unit tests task image:smoke # build the published runtime image and check it +task lint:docker # lint the Dockerfile with hadolint ``` The `Dockerfile` serves both purposes, and only one of its stages ships. `builder-download`, diff --git a/compose.yml b/compose.yml index bb9f07f..c608614 100644 --- a/compose.yml +++ b/compose.yml @@ -165,6 +165,15 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock + hadolint: + profiles: ["lint"] + user: ${FIXUID:-1000}:${FIXGID:-1000} + # Latest version: https://hub.docker.com/r/hadolint/hadolint/tags + image: hadolint/hadolint:v2.14.0 + working_dir: /src + volumes: + - .:/src + golangci-lint: profiles: ["lint"] user: ${FIXUID:-1000}:${FIXGID:-1000} diff --git a/docs/content/docs/installation.md b/docs/content/docs/installation.md index af20b98..13b577e 100644 --- a/docs/content/docs/installation.md +++ b/docs/content/docs/installation.md @@ -118,8 +118,19 @@ A template's hooks are shell commands from whoever wrote the template, and they it does locally, because a container makes it that much easier to run one you have never seen. {{< /callout >}} -Hooks are why the image is Debian rather than something minimal: they execute through `bash`, and a -shell-less image would silently lose every template that defines one. +Hooks are why the image is Debian rather than something minimal. They execute through `bash`, and a +hook is only as good as the commands it can reach, so the image ships the ones templates actually +use: + +| Tool | Why it is there | +|----------------------|------------------------------------------------------------------------------| +| `bash` | Every hook runs through it | +| `git` | `git init` / `git add` — the most common closing hook | +| `task` | Generated projects that use [Task](https://taskfile.dev) as their entrypoint | +| `docker` + `compose` | Hooks that drive a compose stack to do their work | + +`git` is configured with `init.defaultBranch main`, so a `git init` hook produces the same branch +name it would on a host rather than falling back to git's built-in `master`. For a **remote** template that defines hooks, `specs` prints the commands and asks before running them. Without a terminal it cannot ask, so it warns and skips the hooks — a scaffold that looks @@ -134,6 +145,29 @@ To keep them from running at all: | `--no-hooks` | Skip the hooks, render everything else | | `--safe-mode` | Also disable the env and filesystem template functions | +### Hooks that start containers + +A hook that runs `docker` talks to the host daemon, so whatever it starts is a *sibling* container, +not a child. That needs two additions — the socket, and an identical path on both sides: + +```sh +docker run --rm -it \ + --user "$(id -u):$(id -g)" --group-add 0 \ + --env HOME=/tmp \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + --volume "$PWD:$PWD" --workdir "$PWD" \ + ghcr.io/specsnl/specs-cli use specsnl/my-template ./my-project +``` + +`--group-add 0` is what gets a foreign uid past the socket's `root:root 0660`. + +The path matters because the daemon resolves a sibling's bind mounts against the *host* filesystem. +Mounted at `/work`, a hook asks the daemon for `/work/...`, which on the host is some other +directory or none at all — the sibling starts empty and the hook fails against a tree that looks +perfectly fine from inside. Mounting `$PWD` at `$PWD` makes the two agree. + +Templates whose hooks never invoke `docker` need none of this and can keep using `/work`. + ### Keeping registered templates `specs template download` and `specs template save` write to the registry under `/config`, which diff --git a/taskfiles/Taskfile.lint.yml b/taskfiles/Taskfile.lint.yml index 9cb7c98..2bfee60 100644 --- a/taskfiles/Taskfile.lint.yml +++ b/taskfiles/Taskfile.lint.yml @@ -15,3 +15,10 @@ tasks: - task: dc:run:golangci-lint vars: SUB_CMD: "golangci-lint run --fix" + + lint:docker: + desc: Lint the Dockerfile with hadolint + cmds: + - task: dc:run:hadolint + vars: + SUB_CMD: "hadolint Dockerfile" diff --git a/test/image.bats b/test/image.bats index d47ba6e..e1389f7 100644 --- a/test/image.bats +++ b/test/image.bats @@ -63,6 +63,29 @@ scaffold() { assert_success } +@test "has the hook toolchain on PATH" { + run docker run --rm --entrypoint bash "$IMAGE" -c \ + 'command -v git && command -v task && command -v docker' + + assert_success +} + +@test "has the compose plugin wired into the docker CLI" { + run docker run --rm --entrypoint docker "$IMAGE" compose version + + assert_success +} + +@test "git is usable by a foreign uid with HOME redirected" { + run docker run --rm \ + --user "$CALLER" \ + --env HOME=/tmp \ + --volume "$WORKDIR:/work" \ + --entrypoint bash "$IMAGE" -c 'git init -q . && git status --porcelain' + + assert_success +} + @test "scaffolds into a bind mount and runs the hooks" { run scaffold ./out --use-defaults --yes