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
6 changes: 6 additions & 0 deletions .github/instructions/executing-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<tape>` |
Expand Down Expand Up @@ -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`.
Expand Down
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
6 changes: 6 additions & 0 deletions .hadolint.yml
Original file line number Diff line number Diff line change
@@ -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
65 changes: 55 additions & 10 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -86,16 +94,18 @@ 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
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
Expand All @@ -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 \
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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`,
Expand Down
9 changes: 9 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
38 changes: 36 additions & 2 deletions docs/content/docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions taskfiles/Taskfile.lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
23 changes: 23 additions & 0 deletions test/image.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading