From ce924fd762e05ff1b5dad58fd74d338df3424977 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 5 Nov 2026 04:41:47 -0800 Subject: [PATCH] Add Makefile and bin/ scripts for one-command multi-arch release make release builds, smoke-tests and pushes amd64 (on fsn1app1 via DOCKER_HOST) and arm64 (local), joins them into latest and dated multi-arch manifests, and commits the day's logs. Logic lives in bin/; the Makefile only wires dependencies so -j2 runs the arches in parallel. --- Makefile | 47 +++++++++++++++++++++++++++++++++++++++++++++++ README.md | 42 ++++++++++++++++++++++-------------------- bin/build | 11 +++++++++++ bin/clean | 10 ++++++++++ bin/commit-logs | 13 +++++++++++++ bin/lib.sh | 32 ++++++++++++++++++++++++++++++++ bin/manifest | 17 +++++++++++++++++ bin/native-arch | 7 +++++++ bin/push | 11 +++++++++++ bin/test | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 217 insertions(+), 20 deletions(-) create mode 100644 Makefile create mode 100755 bin/build create mode 100755 bin/clean create mode 100755 bin/commit-logs create mode 100644 bin/lib.sh create mode 100755 bin/manifest create mode 100755 bin/native-arch create mode 100755 bin/push create mode 100755 bin/test diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..00c2628 --- /dev/null +++ b/Makefile @@ -0,0 +1,47 @@ +# Build and publish the base image. All logic lives in bin/; targets here +# only wire dependencies and export configuration. +# +# make release build, test, push both arches, tag, commit logs +# make -j2 release same, with the two arches in parallel +# make build build for this machine's arch only +# make test run the image smoke test for this machine's arch + +IMAGE ?= git.eeqj.de/sneak/baseimage +DATE ?= $(shell date +%Y-%m-%d) +ARCHES ?= amd64 arm64 + +# Docker daemon to use per arch. Empty means the local daemon. +DOCKER_HOST_amd64 ?= ssh://root@fsn1app1 +DOCKER_HOST_arm64 ?= + +NATIVE_ARCH := $(shell bin/native-arch) + +export IMAGE DATE ARCHES DOCKER_HOST_amd64 DOCKER_HOST_arm64 + +.PHONY: release manifest commit-logs build test push clean \ + $(ARCHES:%=build-%) $(ARCHES:%=test-%) $(ARCHES:%=push-%) + +release: commit-logs + +commit-logs: manifest + bin/commit-logs + +manifest: $(ARCHES:%=push-%) + bin/manifest + +# Static pattern rules: plain pattern rules are skipped for .PHONY targets. +$(ARCHES:%=push-%): push-%: test-% + bin/push $* + +$(ARCHES:%=test-%): test-%: build-% + bin/test $* + +$(ARCHES:%=build-%): build-%: + bin/build $* + +build: build-$(NATIVE_ARCH) +test: test-$(NATIVE_ARCH) +push: push-$(NATIVE_ARCH) + +clean: + bin/clean diff --git a/README.md b/README.md index b04c57a..b729601 100644 --- a/README.md +++ b/README.md @@ -131,32 +131,31 @@ Installation leaves nothing behind that production does not need: Development headers (`-dev` packages) are deliberately kept: they are what child images need to build things. -## Building - -Native architecture only: +## Building and releasing ```sh -docker build -t git.eeqj.de/sneak/baseimage:latest . +make release # build, test, push both arches, tag, commit logs +make -j2 release # same, with the two arches in parallel +make build # build for this machine's arch only +make test # smoke-test this machine's build ``` -### Release build +Each architecture is built natively on a daemon of that architecture: arm64 +on the local daemon, amd64 on `fsn1app1` via `DOCKER_HOST`. The daemon per +arch is set by `DOCKER_HOST_amd64` and `DOCKER_HOST_arm64` in the +`Makefile` and can be overridden on the command line. The release date +defaults to today and can be overridden with `DATE=YYYY-MM-DD`. -Each architecture is built natively on a host of that architecture (arm64 -locally, amd64 on `fsn1app1` via `DOCKER_HOST=ssh://root@fsn1app1`), pushed -under a per-arch tag, and then joined into a multi-arch manifest: +`make release` runs, per arch, `bin/build`, `bin/test`, and `bin/push`, +then `bin/manifest` to join the per-arch tags into `latest` and the dated +tag, then `bin/commit-logs` to commit `logs/YYYY-MM-DD/` and push the repo. +`bin/test` checks that the toolchains resolve, yarn and pnpm work offline, +nothing is left in `/tmp`, and a runit service is stopped cleanly by +`docker stop` with exit code 0. -```sh -# on each host, for its own arch -docker build --progress=plain -t baseimage:dev . 2>&1 | tee logs/YYYY-MM-DD/build-ARCH.log -docker tag baseimage:dev git.eeqj.de/sneak/baseimage:ARCH -docker push git.eeqj.de/sneak/baseimage:ARCH - -# once both per-arch tags are in the registry -docker buildx imagetools create \ - -t git.eeqj.de/sneak/baseimage:latest \ - -t git.eeqj.de/sneak/baseimage:YYYY-MM-DD \ - git.eeqj.de/sneak/baseimage:amd64 git.eeqj.de/sneak/baseimage:arm64 -``` +Registry login is done once on the local docker client with +`docker login git.eeqj.de`; the client forwards the credentials to the +remote daemon on push. Tags: @@ -184,6 +183,9 @@ Interactive shells (login shells, or anything that sources ## Layout +- `Makefile` — target wiring and configuration only; no logic. +- `bin/` — one script per step: `build`, `test`, `push`, `manifest`, + `commit-logs`, `clean`, plus `lib.sh` (shared setup) and `native-arch`. - `Dockerfile` — base, environment, one `COPY`, one `ADD`, one `RUN`. - `build.sh` — everything the `RUN` does, in order: apt, Go, runsvinit, golangci-lint, Hugo, nvm/Node/corepack/wrangler, pyenv/Python, cleanup, diff --git a/bin/build b/bin/build new file mode 100755 index 0000000..484229e --- /dev/null +++ b/bin/build @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Build the image for one arch on that arch's daemon, logging to logs/DATE/. +set -euo pipefail +cd "$(dirname "$0")/.." +. bin/lib.sh + +arch="${1:?usage: bin/build }" +use_arch_host "$arch" + +docker build --progress=plain -t "$(dev_tag "$arch")" . 2>&1 | + tee "$LOGDIR/build-$arch.log" diff --git a/bin/clean b/bin/clean new file mode 100755 index 0000000..ef1f3ef --- /dev/null +++ b/bin/clean @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Remove dev-tagged images from every arch's daemon. +set -euo pipefail +cd "$(dirname "$0")/.." +. bin/lib.sh + +for arch in ${ARCHES:-amd64 arm64}; do + use_arch_host "$arch" || continue + docker rmi -f "$(dev_tag "$arch")" 2>/dev/null || true +done diff --git a/bin/commit-logs b/bin/commit-logs new file mode 100755 index 0000000..4236a30 --- /dev/null +++ b/bin/commit-logs @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Commit logs/DATE/ and push to origin. +set -euo pipefail +cd "$(dirname "$0")/.." +. bin/lib.sh + +git add "$LOGDIR" +if git diff --cached --quiet; then + echo "no new logs to commit" + exit 0 +fi +git commit -m "Release $DATE: build and push logs" +git push origin HEAD diff --git a/bin/lib.sh b/bin/lib.sh new file mode 100644 index 0000000..65ff046 --- /dev/null +++ b/bin/lib.sh @@ -0,0 +1,32 @@ +# Shared setup for bin/ scripts. Source after `set -euo pipefail`. +# +# Expects IMAGE and DATE in the environment (the Makefile exports them) and +# takes the target arch as $1 where relevant. + +: "${IMAGE:=git.eeqj.de/sneak/baseimage}" +: "${DATE:=$(date +%Y-%m-%d)}" + +LOGDIR="logs/$DATE" +mkdir -p "$LOGDIR" + +# Point docker at the daemon for the given arch. DOCKER_HOST_ empty or +# unset means the local daemon. +use_arch_host() { + local arch="$1" var host + var="DOCKER_HOST_${arch}" + host="${!var:-}" + if [ -n "$host" ]; then + export DOCKER_HOST="$host" + else + unset DOCKER_HOST + fi + local actual + actual="$(docker version --format '{{.Server.Arch}}')" + if [ "$actual" != "$arch" ]; then + echo "error: daemon for $arch (${host:-local}) reports arch $actual" >&2 + return 1 + fi +} + +dev_tag() { echo "baseimage:dev-$1"; } +arch_tag() { echo "$IMAGE:$1"; } diff --git a/bin/manifest b/bin/manifest new file mode 100755 index 0000000..b69bb28 --- /dev/null +++ b/bin/manifest @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Join the per-arch tags into multi-arch manifests IMAGE:latest and +# IMAGE:DATE, and record what was published. +set -euo pipefail +cd "$(dirname "$0")/.." +. bin/lib.sh +unset DOCKER_HOST + +refs=() +for arch in ${ARCHES:-amd64 arm64}; do + refs+=("$(arch_tag "$arch")") +done + +docker buildx imagetools create \ + -t "$IMAGE:latest" -t "$IMAGE:$DATE" "${refs[@]}" + +docker buildx imagetools inspect "$IMAGE:$DATE" | tee "$LOGDIR/manifest.txt" diff --git a/bin/native-arch b/bin/native-arch new file mode 100755 index 0000000..c23f3b7 --- /dev/null +++ b/bin/native-arch @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Print this machine's arch in docker naming (amd64 or arm64). +case "$(uname -m)" in + x86_64 | amd64) echo amd64 ;; + aarch64 | arm64) echo arm64 ;; + *) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;; +esac diff --git a/bin/push b/bin/push new file mode 100755 index 0000000..3f42c9d --- /dev/null +++ b/bin/push @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Tag the built image IMAGE: and push it from that arch's daemon. +set -euo pipefail +cd "$(dirname "$0")/.." +. bin/lib.sh + +arch="${1:?usage: bin/push }" +use_arch_host "$arch" + +docker tag "$(dev_tag "$arch")" "$(arch_tag "$arch")" +docker push "$(arch_tag "$arch")" 2>&1 | tee "$LOGDIR/push-$arch.log" diff --git a/bin/test b/bin/test new file mode 100755 index 0000000..a00d8c7 --- /dev/null +++ b/bin/test @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Smoke-test a built image: toolchains resolve, yarn/pnpm work offline, +# nothing left in /tmp, and runsvinit stops a runit service cleanly on +# docker stop with exit code 0. +set -euo pipefail +cd "$(dirname "$0")/.." +. bin/lib.sh + +arch="${1:?usage: bin/test }" +use_arch_host "$arch" +img="$(dev_tag "$arch")" +name="baseimage-test-$arch-$$" + +echo "== $img: toolchains and cleanup" +docker run --rm "$img" bash -ec ' + # cleanup checks first: node writes a compile cache into /tmp when run + test -z "$(ls -A /tmp)" + test ! -e /usr/share/doc/bash + test ! -e /tmp/build.sh + go version; node --version; python3 --version; hugo version | head -1 + COREPACK_ENABLE_NETWORK=0 yarn --version + COREPACK_ENABLE_NETWORK=0 pnpm --version +' + +echo "== $img: runit service lifecycle" +docker rm -f "$name" >/dev/null 2>&1 || true +docker run -d --name "$name" "$img" bash -ec ' + mkdir -p /etc/sv/demo + printf "%s\n" "#!/bin/bash" "exec 2>&1" \ + "trap \"echo demo: got TERM; sleep 1; echo demo: clean exit; exit 0\" TERM" \ + "echo demo: started" "while true; do sleep 1; done" > /etc/sv/demo/run + chmod +x /etc/sv/demo/run + ln -s /etc/sv/demo /etc/service/demo + exec /usr/local/sbin/runsvinit +' >/dev/null +sleep 3 +docker exec "$name" sv status demo +docker stop "$name" >/dev/null +code="$(docker inspect "$name" --format '{{.State.ExitCode}}')" +logs="$(docker logs "$name" 2>&1)" +docker rm "$name" >/dev/null +echo "$logs" +if [ "$code" != 0 ] || ! grep -q 'demo: clean exit' <<<"$logs"; then + echo "error: graceful stop failed (exit $code)" >&2 + exit 1 +fi +echo "== $img: ok"