diff --git a/.github/instructions/executing-commands.md b/.github/instructions/executing-commands.md index 244c015..1fb2617 100644 --- a/.github/instructions/executing-commands.md +++ b/.github/instructions/executing-commands.md @@ -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:` | @@ -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`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bafb47f..0408cfa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72e6264..5ccbbe0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -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 diff --git a/Dockerfile b/Dockerfile index 68bf45c..2cc20ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 @@ -15,8 +15,6 @@ RUN apt-get update \ FROM base AS builder-download -ARG GOARCH=amd64 - COPY go.mod . COPY go.sum . @@ -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 @@ -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"] diff --git a/README.md b/README.md index a47dd8a..fb05f16 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/Taskfile.dist.yml b/Taskfile.dist.yml index 2c3d266..60e1bb6 100644 --- a/Taskfile.dist.yml +++ b/Taskfile.dist.yml @@ -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 diff --git a/compose.yml b/compose.yml index 55cadc8..bb9f07f 100644 --- a/compose.yml +++ b/compose.yml @@ -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: diff --git a/docs/content/docs/installation.md b/docs/content/docs/installation.md index 9990246..af20b98 100644 --- a/docs/content/docs/installation.md +++ b/docs/content/docs/installation.md @@ -38,3 +38,136 @@ go install github.com/specsnl/specs-cli@latest ## Download a binary Download a pre-built binary from the [releases page](https://github.com/specsnl/specs-cli/releases). + +## Docker + +An official image is published to GHCR on every release, 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 +``` + +The entrypoint is `specs` itself, so everything after the image name is the command line you would +type locally. + +### Tags + +| Tag | Moves to | +|--------------|------------------------------------------------| +| `1.2.3` | that exact release | +| `1.2` | the newest patch of that minor | +| `1` | the newest minor of that major — from `v1.0.0` | +| `latest` | the newest stable release | +| `1.2.0-rc.1` | that exact prerelease | + +A prerelease publishes only its own version tag: `latest` and the moving `1.2` / `1` tags never +point at an `-rc` build, the same line the `specs` and `specs@rc` casks draw. Pin to an exact +version in CI. + +### Writing into a bind mount + +`specs use` scaffolds onto the host filesystem, so the container's uid has to be able to write +there. The image runs as a non-root `specs` user at `1000:1000`, which is the first user on a +typical single-user Linux host — there, the command above just works. + +Anywhere else, pass your own ids: + +```sh +docker run --rm -it \ + --user "$(id -u):$(id -g)" \ + --env HOME=/tmp \ + --volume "$PWD:/work" \ + ghcr.io/specsnl/specs-cli use specsnl/my-template ./my-project +``` + +`HOME` belongs with `--user` rather than being optional decoration. An overridden uid owns no home +directory inside the image — Docker points `HOME` at `/home/specs` when the uid happens to be 1000 +and at `/` otherwise, and neither is writable by a foreign uid. Anything that reads or writes below +the home directory, SSH keys in particular, needs somewhere real to go. + +The template registry is unaffected by this: it hangs off `XDG_CONFIG_HOME`, which the image pins +to `/config`, not off `$HOME`. + +### Prompts need a terminal + +`-it` is what makes the interactive flow work. `specs` only prompts when its stdin is a terminal, +and a plain `docker run` gives it a pipe: + +```sh +docker run --rm -it -v "$PWD:/work" ghcr.io/specsnl/specs-cli use specsnl/my-template ./my-project +``` + +Without a terminal there is no quiet fallback to defaults — a value nobody can supply is an error +that names itself, listing the keys it could not ask for. In a pipeline, answer up front instead: + +```sh +docker run --rm \ + --user "$(id -u):$(id -g)" --env HOME=/tmp --volume "$PWD:/work" \ + ghcr.io/specsnl/specs-cli use specsnl/my-template ./my-project \ + --use-defaults --arg ProjectName=my-app --yes +``` + +`--use-defaults` takes the schema's defaults, `--arg K=V` and `--values file.json` supply values +explicitly, and the three combine. + +### Hooks run other people's code + +{{< callout type="warning" >}} +A template's hooks are shell commands from whoever wrote the template, and they run on the tree +`specs` just scaffolded. Reading them before running an unfamiliar template matters more here than +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. + +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 +successful but is missing whatever the hooks were supposed to do. `--yes` is the way to run them +unattended, and it is the flag to reach for in CI. The confirmation only guards remote templates; +hooks in a local path template run without it. + +To keep them from running at all: + +| Flag | Effect | +|---------------|--------------------------------------------------------| +| `--no-hooks` | Skip the hooks, render everything else | +| `--safe-mode` | Also disable the env and filesystem template functions | + +### Keeping registered templates + +`specs template download` and `specs template save` write to the registry under `/config`, which +disappears with the container unless it is given a volume: + +```sh +docker run --rm -it \ + --volume specs-config:/config \ + --volume "$PWD:/work" \ + ghcr.io/specsnl/specs-cli template list +``` + +The one-shot `specs use ` path needs none of this — it fetches, renders and +discards, which is what most container use looks like. + +### Private templates over SSH + +Cloning needs no `git` binary, but an SSH source does need credentials and a `known_hosts` file — +host key verification is not optional, and a missing `known_hosts` fails the clone before +authentication is even attempted. Mount the agent socket and your known hosts under whatever `HOME` +you set: + +```sh +docker run --rm -it \ + --user "$(id -u):$(id -g)" \ + --env HOME=/tmp \ + --env SSH_AUTH_SOCK=/ssh-agent \ + --volume "$SSH_AUTH_SOCK:/ssh-agent" \ + --volume "$HOME/.ssh/known_hosts:/tmp/.ssh/known_hosts:ro" \ + --volume "$PWD:/work" \ + ghcr.io/specsnl/specs-cli use git@github.com:me/private-template ./my-project +``` + +With no agent running, mount a key at `/tmp/.ssh/id_ed25519` (or `id_rsa`, `id_ecdsa`) instead — +the same three names `specs` looks for on a host. Note that the agent socket is a macOS sore point: +Docker Desktop does not forward the host's `SSH_AUTH_SOCK`, so use +`--volume /run/host-services/ssh-auth.sock:/ssh-agent` there. diff --git a/docs/operations/release.md b/docs/operations/release.md index e1c3fab..71ace0c 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -3,8 +3,8 @@ ## Overview This document describes the release pipeline: how binaries are built and distributed, -how GitHub Releases are created, how the Homebrew formula is updated, and what CI workflows -are needed. +how GitHub Releases are created, how the Homebrew formula is updated, how the container image is +published, and what CI workflows are needed. --- @@ -183,6 +183,160 @@ stanza is declared — the link failure is already clear about the cause. --- +## Container Image + +GoReleaser does not build the image. The `image` and `image-manifest` jobs in `release.yml` do, on +the same `v*` tags, by calling the org's shared workflows: + +- [`build-go-cli.yml`](https://github.com/specsnl/github-actions/blob/main/.github/workflows/build-go-cli.yml) +- [`merge-go-cli.yml`](https://github.com/specsnl/github-actions/blob/main/.github/workflows/merge-go-cli.yml) + +Both are documented in +[specsnl/github-actions](https://github.com/specsnl/github-actions/blob/main/docs/go-cli.md). What +stays in this repository is the Dockerfile and the three things only this repository can know: which +stage to publish, what the version build arg is called, and what a working image has to be able to +do. + +| What | Value | +|---------------|-----------------------------------------------------| +| Registry | `ghcr.io/specsnl/specs-cli` | +| Built from | the Dockerfile's `debian` stage | +| Base | `debian:13.6-slim` | +| Platforms | `linux/amd64`, `linux/arm64` | +| Runs as | `specs`, uid/gid `1000` | +| Entrypoint | `specs` | +| Authenticates | the built-in `GITHUB_TOKEN`, with `packages: write` | +| Published by | `specsnl/github-actions@2.4.0` | + +### Dockerfile stages + +Only one of them ships: + +| Stage | Used by | Published | +|--------------------|----------------------------------------|-----------| +| `base` | every other stage | no | +| `builder-download` | `task test` (the `go-builder` service) | no | +| `build` | compiles the binary | no | +| `export` | `task build` copies the binary out | no | +| `vhs` | `task demo:record:*` | no | +| `debian` | the release job | **yes** | + +`debian` is deliberately the last stage in the file, so a bare `docker build .` produces the image +that ships rather than a development tool. The workflows still pass `--target debian` explicitly — +being last is a safe default, being named is the contract. + +The runtime borrows two things from the builder: the binary, and `ca-certificates.crt`. The slim +Debian variants ship no CA bundle, and without one every HTTPS template source fails to clone. +Nothing else is needed — `internal/util/git` clones through go-git, so there is no `git` binary in +the image. Debian rather than scratch or distroless is a hooks decision: `internal/hooks` executes +them through `bash -c` and refuses to run when bash is absent, so a shell-less image would silently +drop every template that defines one. + +### One runner per architecture + +Nothing is emulated. The `image` job is a matrix over the two release platforms, each on a runner of +that architecture: + +| Platform | Runner | +|---------------|--------------------| +| `linux/amd64` | `ubuntu-24.04` | +| `linux/arm64` | `ubuntu-24.04-arm` | + +Arm runners are free for public repositories, which this one is. + +QEMU would be the alternative, and it is the wrong one here. The runtime stage creates its user with +a `RUN`, and a `RUN` always executes on the target platform — so an emulated arm64 build runs +`useradd` under QEMU, slowly, and gives up the ability to execute the image it just produced. A +native runner builds at full speed and can run its own output, which is what lets the pull-request +job smoke-test both architectures rather than only the one it happens to be on. + +The consequence is that each job produces a single-platform image and the multi-arch manifest has to +be assembled afterwards — each build pushes untagged by digest, and `image-manifest` joins the +digests into one tagged list. That whole dance lives in the shared workflows; see +[How the image pipeline works](https://github.com/specsnl/github-actions/blob/main/docs/pipeline.md). + +### Cross-compilation + +The `base` stage is pinned to `--platform=$BUILDPLATFORM`, so the Go toolchain always runs natively +on the builder and cross-compiles from there. On a native runner the two platforms coincide and this +changes nothing; it is what keeps a local `docker buildx build --platform linux/amd64,linux/arm64` +from emulating the compiler, and `CGO_ENABLED=0` means there is nothing lost by it. + +The `build` stage resolves its target as `GOOS=${GOOS:-$TARGETOS}` / `GOARCH=${GOARCH:-$TARGETARCH}`. +`GOOS` and `GOARCH` carry no defaults on purpose — the image build leaves them unset and follows +the platform buildx asked for, while `task build` sets both explicitly through `docker buildx bake` +to get a host binary out of the `export` stage. + +### Image tags + +`merge-go-cli.yml` owns the tag set. For this repository it produces: + +| Tag pushed | Image tags produced | +|---------------|-------------------------------| +| `v1.2.3` | `1.2.3`, `1.2`, `1`, `latest` | +| `v0.4.1` | `0.4.1`, `0.4`, `latest` | +| `v1.2.0-rc.1` | `1.2.0-rc.1` | + +A prerelease publishes its own version and moves nothing anyone could be following — the same line +the `specs` and `specs@rc` casks draw. The `{{major}}` tag is also withheld while the project is on +`v0`, where a major number carries no compatibility promise. + +Neither job passes a version. `build-go-cli.yml` defaults it to the tag **without its leading +`v`** — the same string GoReleaser injects, so the image and the Homebrew binary cut from one tag +never disagree about what they are. It reaches the binary as the `SPECS_VERSION` build arg, named +by `version-build-arg`, which is the one input here with no default: it is named after the binary +and differs per repository, and a wrong value fails silently, shipping an image that reports `dev`. +The pull-request guard asserts against exactly that. + +### One-time setup + +The GHCR package is created by the first push and is **private** until someone changes it. After +the first release, open the package settings and either make it public or link it to the repository. +Nothing in the workflow can do this. + +### Pre-release guard + +`ci.yml` builds `--target debian` on every pull request, on the same matrix of native runners and +without pushing, then runs `test/image.bats` against each. Both architectures are executed, not +merely built. + +It calls the `build-image` action directly rather than `build-go-cli.yml`, because the image has to +be built and run in the same job: a reusable workflow would load it into a daemon this job cannot +reach. `load: true` builds one platform into the runner's local daemon and reports the reference as +its `image` output, which the suite takes as `IMAGE`. + +The same suite backs `task image:smoke`, so a green local run and a green pull request check the +same things: + +```shell +task image:build # build the runtime image as specs-cli:dev +task image:smoke # build it, then run the acceptance checks +``` + +It asserts that `--version` reports the injected version rather than `dev`, that the default user is +`uid=1000(specs)`, that bash is on PATH, that a template scaffolds into a bind mount with its hooks +run and its files owned by the invoking user, and that a run without a terminal refuses instead of +quietly falling back to defaults. Each is its own test, so one broken thing does not hide the next. + +### Where bats comes from + +The suite needs bats, three of its libraries, and a docker client — and the two environments get +them differently, because only one of them has docker to begin with. + +| | bats | docker | +|-------|------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------| +| CI | `bats-core/bats-action`, which also installs `bats-support`, `bats-assert` and `bats-file` and exports `BATS_LIB_PATH` | native on the runner | +| Local | the Dockerfile's `bats` stage — `bats/bats` plus the same three libraries and a static docker client | `docker-socket-proxy`, shared with the `vhs` service | + +Running the tests in a container while they drive the host's daemon has one consequence worth +knowing before editing them: **every path the tests bind-mount has to exist on the host**, because +that is where the daemon resolves it. The compose service handles this by mounting the repository at +its own host path (`${PWD}:${PWD}`) and pointing `TMPDIR` at `/tmp/specs-image-smoke`, mounted at +the same path on both sides — the trick `vhs` already uses for `/tmp/specs-demo`. A test that writes +to a container-only path and mounts it will fail in a way that looks like a missing file. + +--- + ## Tagging and Versioning - Tags follow [Semantic Versioning](https://semver.org/): `vMAJOR.MINOR.PATCH` @@ -199,34 +353,39 @@ stanza is declared — the link failure is already clear about the cause. **Trigger:** push and pull_request on any branch. -| Step | Command | -|---------------|-----------------------------------------------| -| Checkout | `actions/checkout` with `fetch-depth: 0` | -| Setup Go | `actions/setup-go` pinned to `go.mod` version | -| Cache modules | `actions/cache` on Go module and build caches | -| Vet | `go vet ./...` | -| Test | `go test -race -count=1 ./...` | -| Build (smoke) | `go build -o /dev/null .` | +| Step | Command | +|---------------|--------------------------------------------------------------------------------------| +| Checkout | `actions/checkout` with `fetch-depth: 0` | +| Setup Go | `actions/setup-go` pinned to `go.mod` version | +| Cache modules | `actions/cache` on Go module and build caches | +| Vet | `go vet ./...` | +| Test | `go test -race -count=1 ./...` | +| Build (smoke) | `go build -o /dev/null .` | +| Image | `build-image` with `load` on a native runner per architecture, then the smoke script | ### Release workflow — `release.yml` **Trigger:** push of a tag matching `v*`. -| Step | Detail | -|----------------|----------------------------------------------------------| -| Checkout | `fetch-depth: 0` — GoReleaser needs all tags and commits | -| Setup Go | same version as `go.mod` | -| Cache modules | same as CI | -| Run GoReleaser | `goreleaser/goreleaser-action` | +| Job | Detail | +|------------------|--------------------------------------------------------------------------| +| `release` | Checkout with `fetch-depth: 0`, set up Go, cache modules, run GoReleaser | +| `version` | Strips the leading `v` off the tag for the other two | +| `image` | Matrix over amd64 and arm64, calling `build-go-cli.yml` | +| `image-manifest` | Calls `merge-go-cli.yml` to join the digests into the tagged manifest | + +`release` is independent of the rest; `image` waits on `version`, and `image-manifest` on both. Required secrets: -| Secret | Purpose | -|-----------------------------|-------------------------------------------------------| -| `GITHUB_TOKEN` | Built-in; used by GoReleaser to create GitHub Release | -| `HOMEBREW_TAP_GITHUB_TOKEN` | PAT with `contents: write` on `specsnl/homebrew-tap` | +| Secret | Purpose | +|-----------------------------|----------------------------------------------------------------| +| `GITHUB_TOKEN` | Built-in; creates the GitHub Release and authenticates to GHCR | +| `HOMEBREW_TAP_GITHUB_TOKEN` | PAT with `contents: write` on `specsnl/homebrew-tap` | -The release workflow needs `permissions: contents: write`. +Permissions are granted per job rather than to the whole workflow: `release` takes +`contents: write`, while `image` and `image-manifest` take `contents: read` and `packages: write`. +Neither side needs what the other has. --- @@ -260,3 +419,13 @@ Binaries and archives land in `dist/` for inspection. ```shell brew update && brew upgrade --cask specs ``` + +8. Verify the image was published, is multi-arch, and knows its own version: + + ```shell + docker manifest inspect ghcr.io/specsnl/specs-cli:1.0.0 # amd64 + arm64 + docker run --rm ghcr.io/specsnl/specs-cli:1.0.0 --version # 1.0.0, not dev + ``` + + On the first release only, make the GHCR package public — see + [One-time setup](#one-time-setup). diff --git a/taskfiles/Taskfile.image.yml b/taskfiles/Taskfile.image.yml new file mode 100644 index 0000000..c89ab49 --- /dev/null +++ b/taskfiles/Taskfile.image.yml @@ -0,0 +1,35 @@ +# https://taskfile.dev +version: "3" + +silent: true + +vars: + IMAGE: '{{ .IMAGE | default "specs-cli:dev" }}' + SPECS_VERSION: + sh: git describe --tags --always --dirty="-dev" 2>/dev/null || echo "dev" + +tasks: + + build: + desc: Build the published runtime image for the host platform + cmds: + - >- + docker buildx build + --target debian + --load + --build-arg SPECS_VERSION={{ .SPECS_VERSION }} + --tag {{ .IMAGE }} + . + + smoke: + desc: Build the runtime image and run the acceptance checks CI runs + deps: [build] + cmds: + # Shared with the bats container at the same path, so mktemp -d inside it + # produces a directory the daemon can bind-mount. + - mkdir -p /tmp/specs-image-smoke + - defer: docker compose --profile image rm --stop --force docker-socket-proxy + - task: :dc:run:bats + vars: + RUN_FLAGS: '--env IMAGE={{ .IMAGE }} --env EXPECTED_VERSION={{ .SPECS_VERSION }}' + SUB_CMD: test/image.bats diff --git a/test/image.bats b/test/image.bats new file mode 100644 index 0000000..d47ba6e --- /dev/null +++ b/test/image.bats @@ -0,0 +1,86 @@ +#!/usr/bin/env bats +# +# Acceptance checks for the runtime image, run by both ci.yml and +# `task image:smoke`. +# +# IMAGE and EXPECTED_VERSION name the image under test and the version it must +# report. + +setup_file() { + bats_require_minimum_version 1.5.0 + + : "${IMAGE:?set IMAGE to the image reference under test}" + : "${EXPECTED_VERSION:?set EXPECTED_VERSION to the version the image must report}" + + export TEMPLATE="${BATS_TEST_DIRNAME}/../internal/testdata/hooks" + export CALLER="$(id -u):$(id -g)" +} + +setup() { + bats_load_library bats-support + bats_load_library bats-assert + bats_load_library bats-file + + # Under TMPDIR, which points at a directory mounted at the same path on the + # host — the daemon resolves the bind mounts below against the host. + WORKDIR="$(mktemp -d)" +} + +teardown() { + chmod -R u+w "$WORKDIR" 2>/dev/null || true + rm -rf "$WORKDIR" +} + +# Scaffolds into $WORKDIR as the invoking user. HOME is redirected because an +# overridden uid owns no home directory inside the image. +scaffold() { + docker run --rm \ + --user "$CALLER" \ + --env HOME=/tmp \ + --volume "$TEMPLATE:/template:ro" \ + --volume "$WORKDIR:/work" \ + "$IMAGE" use /template "$@" +} + +@test "reports the version injected at build time" { + run docker run --rm "$IMAGE" --version + + assert_success + # 'dev' here means SPECS_VERSION never reached the ldflag. + assert_output "$EXPECTED_VERSION" +} + +@test "runs as the non-root specs user" { + run docker run --rm --entrypoint id "$IMAGE" + + assert_success + assert_output --partial "uid=1000(specs) gid=1000(specs)" +} + +@test "has bash on PATH for template hooks" { + run docker run --rm --entrypoint bash "$IMAGE" -c 'exit 0' + + assert_success +} + +@test "scaffolds into a bind mount and runs the hooks" { + run scaffold ./out --use-defaults --yes + + assert_success + assert_file_exist "$WORKDIR/out/main.txt" + assert_file_exist "$WORKDIR/out/hook-output.txt" +} + +@test "writes files owned by the invoking user" { + scaffold ./out --use-defaults --yes + + run stat -c %u "$WORKDIR/out/main.txt" + + assert_output "$(id -u)" +} + +@test "refuses to assume defaults without a terminal" { + run ! scaffold ./refused + + assert_output --partial "stdin is not a terminal" +}