From eaa26dd223a4c8d4b1998345ebbc4254df9a3f4b Mon Sep 17 00:00:00 2001 From: Ilyes512 Date: Wed, 16 Sep 2026 23:47:53 +0200 Subject: [PATCH 1/4] feat(docker): give the image the tools template hooks actually call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime stage shipped bash and nothing else, so a hook got exactly as far as `command not found` — with the tree already written. Running specsnl/specs-laravel-project through the image failed on its first hook (`task: command not found`) and left a scaffold with no .env and no git repo, while `specs use` exited non-zero. Adds git, task, and the docker CLI with the compose plugin, pinned to the same versions the vhs and bats stages already use. git also gets init.defaultBranch=main so a `git init` hook does not hand back `master` where a host would produce `main`. With these, that template now completes all four of its post-use hooks in the container, and the result is identical to the host scaffold apart from one APP_URL line the template's own hook rewrites only on darwin. Documents the sibling-container contract this exposes: a hook running docker needs the socket (--group-add 0 past its root:root 0660) and the work directory mounted at its own path, because the daemon resolves a sibling's bind mounts against the host — at /work the hook asks for a path the host does not have. --- Dockerfile | 43 ++++++++++++++++++++++++++++++- README.md | 5 ++-- docs/content/docs/installation.md | 38 +++++++++++++++++++++++++-- test/image.bats | 26 +++++++++++++++++++ 4 files changed, 107 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2cc20ba..e024931 100644 --- a/Dockerfile +++ b/Dockerfile @@ -122,9 +122,50 @@ 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 + +# 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 + COPY --from=build /src/specs /usr/local/bin/specs +# The tools a template's hooks actually reach for. bash alone gets a hook as far as +# `command not found`: `git init` is the most common closing hook there is, and a +# generated project that is driven by Task expects `task` and the docker CLI to run +# its own setup. Without these the scaffold lands and the hooks fail on top of it. +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..bdac248 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. --- 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/test/image.bats b/test/image.bats index d47ba6e..5fb802a 100644 --- a/test/image.bats +++ b/test/image.bats @@ -63,6 +63,32 @@ scaffold() { assert_success } +# A hook is a shell command from the template, so these are the interpreters it +# gets. Losing one turns a hook into "command not found" after the tree is +# already on disk, which is the failure the image exists to avoid. +@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 From d0caa8bb4bdad056081f25fe935d05c1a80e372a Mon Sep 17 00:00:00 2001 From: Ilyes512 Date: Thu, 17 Sep 2026 19:21:38 +0200 Subject: [PATCH 2/4] refactor(docker): drop the hook toolchain commentary The install list and the bats case name already say which tools the image carries and why a hook needs them. --- Dockerfile | 4 ---- test/image.bats | 3 --- 2 files changed, 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index e024931..0c0e1b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -133,10 +133,6 @@ ARG COMPOSE_VERSION=5.5.1 COPY --from=build /src/specs /usr/local/bin/specs -# The tools a template's hooks actually reach for. bash alone gets a hook as far as -# `command not found`: `git init` is the most common closing hook there is, and a -# generated project that is driven by Task expects `task` and the docker CLI to run -# its own setup. Without these the scaffold lands and the hooks fail on top of it. RUN apt-get update \ && apt-get install --assume-yes --no-install-recommends \ ca-certificates \ diff --git a/test/image.bats b/test/image.bats index 5fb802a..e1389f7 100644 --- a/test/image.bats +++ b/test/image.bats @@ -63,9 +63,6 @@ scaffold() { assert_success } -# A hook is a shell command from the template, so these are the interpreters it -# gets. Losing one turns a hook into "command not found" after the tree is -# already on disk, which is the failure the image exists to avoid. @test "has the hook toolchain on PATH" { run docker run --rm --entrypoint bash "$IMAGE" -c \ 'command -v git && command -v task && command -v docker' From bade69387151542619f6e46e9a3cbf71dd4e9c9c Mon Sep 17 00:00:00 2001 From: Ilyes512 Date: Thu, 17 Sep 2026 19:21:43 +0200 Subject: [PATCH 3/4] build(docker): share the pinned tool versions and fail on broken pipes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TASK_VERSION, DOCKER_VERSION and COMPOSE_VERSION were repeated across the vhs, bats and debian stages, so a bump meant editing the same number in up to three places. They move to a global ARG block above the first FROM; the stages re-declare the bare names to pull them into scope. The downloads are all `curl | tar`, where the default /bin/sh reports only tar's exit status — a failed download only surfaces because tar happens to choke on the truncated stream. SHELL with -o pipefail puts that back on the shell: bash for the Debian-based stages, busybox ash for the Alpine one. The base stage also drops the apt lists like the other stages already did. --- Dockerfile | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0c0e1b7..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 @@ -123,13 +133,11 @@ ENV BATS_LIB_PATH=/usr/lib/bats FROM debian:13.6-slim AS debian 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 +SHELL ["/bin/bash", "-o", "pipefail", "-c"] COPY --from=build /src/specs /usr/local/bin/specs From ca86e04bd047ce8cf78414e149ddda2d4509f76c Mon Sep 17 00:00:00 2001 From: Ilyes512 Date: Thu, 17 Sep 2026 19:21:48 +0200 Subject: [PATCH 4/4] build(lint): lint the Dockerfile with hadolint `task lint:docker` runs hadolint through a compose service under the lint profile, the same shape as golangci-lint. CI runs the task itself rather than hadolint/hadolint-action, which ships its own hadolint build and would put the version in a second place to keep in sync. Version pinning is off in .hadolint.yml: 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. --- .github/instructions/executing-commands.md | 6 ++++++ .github/workflows/ci.yml | 16 ++++++++++++++++ .hadolint.yml | 6 ++++++ README.md | 1 + compose.yml | 9 +++++++++ taskfiles/Taskfile.lint.yml | 7 +++++++ 6 files changed, 45 insertions(+) create mode 100644 .hadolint.yml 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/README.md b/README.md index bdac248..4420edc 100644 --- a/README.md +++ b/README.md @@ -109,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/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"