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
7 changes: 7 additions & 0 deletions .github/instructions/executing-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ installing host tools) may run locally on the host.
| Run tests | `task test` |
| Rewrite the golden files | `task test:update` |
| Build the binary | `task build` |
| Check the runtime image | `task image:smoke` |
| 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 @@ -45,6 +46,12 @@ the Docker socket, and it always dirties the working tree — the same tape neve
identical bytes twice, so only re-record when the recorded output actually changed. See
[Demo Recordings](../../docs/content/docs/architecture/demo.md).

`task image:smoke` builds the Dockerfile's `debian` stage and runs `test/image.bats` against it,
inside the `bats` Docker Compose service under the `image` profile. That service drives the host's
daemon through `docker-socket-proxy`, so the repository and `TMPDIR` are mounted at the same paths
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 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
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,45 @@ jobs:

- name: Integration tests (no network)
run: go test -tags=integration -race -count=1 ./internal/cmd/...

image:
name: Image (${{ matrix.platform }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-24.04
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Set up Buildx
uses: docker/setup-buildx-action@v4

- name: Build the runtime image
id: build
uses: specsnl/github-actions/build-image@2.4.0
with:
platform: ${{ matrix.platform }}
image-name: ghcr.io/specsnl/specs-cli
target: debian
load: true
raw-tag: ci
build-args: SPECS_VERSION=ci-${{ github.sha }}

- name: Set up bats
id: bats
uses: bats-core/bats-action@4.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Smoke test
env:
BATS_LIB_PATH: ${{ steps.bats.outputs.lib-path }}
IMAGE: ${{ steps.build.outputs.image }}
EXPECTED_VERSION: ci-${{ github.sha }}
run: bats test/image.bats
36 changes: 33 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@ on:
tags:
- "v*"

permissions:
contents: write

jobs:
release:
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
Expand All @@ -37,3 +36,34 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}

image:
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
runner:
- os: ubuntu-24.04
platform: linux/amd64
- os: ubuntu-24.04-arm
platform: linux/arm64
uses: specsnl/github-actions/.github/workflows/build-go-cli.yml@2.4.0
with:
runs-on: ${{ matrix.runner.os }}
platform: ${{ matrix.runner.platform }}
image-name: ghcr.io/specsnl/specs-cli
target: debian
version-build-arg: SPECS_VERSION

image-manifest:
needs: image
permissions:
contents: read
packages: write
uses: specsnl/github-actions/.github/workflows/merge-go-cli.yml@2.4.0
with:
runs-on: ubuntu-24.04
image-name: ghcr.io/specsnl/specs-cli
target: debian
84 changes: 63 additions & 21 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# check=error=true

# Latest version: https://hub.docker.com/_/golang/tags
FROM golang:1.27.1-trixie AS base
FROM --platform=$BUILDPLATFORM golang:1.27.1-trixie AS base

WORKDIR /src

Expand All @@ -15,8 +15,6 @@ RUN apt-get update \

FROM base AS builder-download

ARG GOARCH=amd64

COPY go.mod .
COPY go.sum .

Expand All @@ -27,34 +25,21 @@ FROM builder-download AS build

COPY . .

ARG GOOS=linux
ARG GOARCH=amd64
ARG TARGETOS
ARG TARGETARCH
ARG GOOS
ARG GOARCH
ARG GO_MODULE=github.com/specsnl/specs-cli
ARG SPECS_VERSION=dev

RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go generate \
&& CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH go build \
&& CGO_ENABLED=0 GOOS=${GOOS:-$TARGETOS} GOARCH=${GOARCH:-$TARGETARCH} go build \
-trimpath \
-tags netgo \
-ldflags "-s -w -X ${GO_MODULE}/internal/cmd.Version=${SPECS_VERSION}" -o ./specs

# Latest version: https://hub.docker.com/_/debian/tags
FROM debian:13.6-slim

COPY --from=build /src/specs /usr/local/bin

CMD ["specs"]

FROM scratch AS binary

COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs
COPY --from=build /src/specs /
COPY --from=build /etc/passwd /etc/passwd

CMD ["/specs"]

FROM scratch AS export

COPY --from=build /src/specs /specs
Expand Down Expand Up @@ -96,3 +81,60 @@ RUN set -eux; \
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose

RUN git config --system init.defaultBranch main

# Latest version: https://hub.docker.com/r/bats/bats/tags
FROM bats/bats:1.14.0 AS bats

ARG TARGETARCH

# 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

RUN apk add --no-cache \
curl \
tar

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://download.docker.com/linux/static/stable/${altarch}/docker-${DOCKER_VERSION}.tgz" \
| tar --extract --gzip --directory /usr/bin --strip-components=1 docker/docker; \
for spec in "support:${BATS_SUPPORT_VERSION}" "assert:${BATS_ASSERT_VERSION}" "file:${BATS_FILE_VERSION}"; do \
name="bats-${spec%%:*}"; \
mkdir -p "/usr/lib/bats/${name}"; \
curl --fail --silent --show-error --location \
"https://github.com/bats-core/${name}/archive/refs/tags/v${spec#*:}.tar.gz" \
| tar --extract --gzip --directory "/usr/lib/bats/${name}" --strip-components=1; \
done

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
COPY --from=build /src/specs /usr/local/bin/specs

RUN groupadd --gid 1000 specs \
&& useradd --uid 1000 --gid 1000 --create-home --shell /bin/bash specs \
&& mkdir -p /config /work \
&& chown specs:specs /config /work

# Pinned so the registry lands in /config whatever uid the container runs as;
# xdg would otherwise resolve it below $HOME.
ENV XDG_CONFIG_HOME=/config

WORKDIR /work
USER specs

ENTRYPOINT ["specs"]
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ brew install specsnl/tap/specs@rc
Both casks provide a `specs` command and cannot be installed side by side — `brew uninstall specs`
before installing `specs@rc`, and the other way round.

Or run it from the official image, published for `linux/amd64` and `linux/arm64`:

```sh
docker run --rm -it -v "$PWD:/work" ghcr.io/specsnl/specs-cli use specsnl/my-template ./my-project
```

`-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
[installation docs](https://cli.specs.dev/docs/installation/) cover the rest — tags, the template
registry volume, and SSH sources.

---

## Getting started
Expand All @@ -93,11 +104,18 @@ that pin the Go and tooling versions — so a check runs the same way locally as
local Go installation needed. Run `task --list` for the full set.

```sh
task dc:build # build the images once
task build # build the binary for the current platform
task test # run the unit tests
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
```

The `Dockerfile` serves both purposes, and only one of its stages ships. `builder-download`,
`export` and `vhs` are development tools — they back `task test`, `task build` and
`task demo:record:*` respectively, and compose selects each by name. `debian` is the image
published to `ghcr.io/specsnl/specs-cli`, and it is the last stage in the file so a bare
`docker build .` produces it rather than a dev tool.

With Go 1.26+ installed you can bypass the container entirely — `go build ./...`, `go test ./...` —
but CI always runs through Docker and the Taskfile, so that is the source of truth.

Expand Down
1 change: 1 addition & 0 deletions Taskfile.dist.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ version: "3"
includes:
demo: ./taskfiles/Taskfile.demo.yml
docs: ./taskfiles/Taskfile.docs.yml
image: ./taskfiles/Taskfile.image.yml
md: ./taskfiles/Taskfile.md.yml
lint:
taskfile: ./taskfiles/Taskfile.lint.yml
Expand Down
23 changes: 22 additions & 1 deletion compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,29 @@ services:
- ./dev:/usr/local/bin:ro
- /tmp/specs-demo:/tmp/specs-demo

bats:
profiles: ["image"]
user: ${FIXUID:-1000}:${FIXGID:-1000}
build:
context: .
dockerfile: Dockerfile
target: bats
# Mounted at its own host path, and TMPDIR likewise: the tests run docker
# through the proxy, so the daemon resolves their bind mounts on the host.
# A container-only path would not exist there.
working_dir: ${PWD}
environment:
HOME: /tmp
DOCKER_HOST: tcp://docker-socket-proxy:2375
TMPDIR: /tmp/specs-image-smoke
depends_on:
- docker-socket-proxy
volumes:
- ${PWD}:${PWD}
- /tmp/specs-image-smoke:/tmp/specs-image-smoke

docker-socket-proxy:
profiles: ["demo"]
profiles: ["demo", "image"]
# Latest version: https://hub.docker.com/r/tecnativa/docker-socket-proxy/tags
image: tecnativa/docker-socket-proxy:v0.5.0
environment:
Expand Down
Loading
Loading