Docker-Canonical Build and License Generation
Status: implemented. Revised after two rounds of review, then rebased onto
origin/main and re-verified there — see §11 for what changed and why.
The cross-host generation matrix was dropped by decision, and replaced by a cheaper cross-host build-system workflow (§7.2).
Upstream tracking issue: apache/skywalking#13996 (“Introduce Docker-based build system for consistent license-file generation”), milestone BanyanDB 0.12.0. Parent chore: #13995.
Summary
Contributors on different host operating systems produce slightly different generated license artifacts — line endings, entry order, file ordering — which shows up as noisy diffs and CI churn. This design makes a pinned Linux build environment the canonical way to produce the committed license artifacts, and adds a byte-level verifier that proves the output is identical regardless of the host OS.
Four separable pieces, in dependency order:
- Fix the checkout contract (§3) —
.gitattributescurrently contains a typo that leaves line-ending behaviour undefined. - A byte-level verifier (§4) — a path+hash manifest over the complete generated surface, comparable across runs, across hosts, and against the committed blobs. Built first, because it is what makes every other claim falsifiable.
- A pinned build environment (§5, §6) — a digest-pinned image, and make wrappers that run the existing recipes in it against a container-side copy of the tree.
- CI wiring (§7) — the canonical command in the ubuntu
checkjob, with the native and container outputs compared to each other.
Out of scope, deliberately split into separate issues:
- Release-archive reproducibility (
release-binary/release-sourcetimestamps, ordering, ownership, gzip metadata).SOURCE_DATE_EPOCHis set in the image as a precondition, butscripts/release.shdoes not yet implement archive normalization and this design does not attempt it. - Making Docker canonical for every committed artifact (
check,pre-push, lint, generate). Those pull in generators, linters and toolchains this image does not carry. - Replacing the
license-eyecommit pin with a release tag. A SHA is already an immutable pin.
Acceptance criteria this design commits to
| Criterion | Evidence |
|---|---|
| Identical output regardless of host OS | §3 removes EOL variance at checkout, §4 at generation, §6.1 removes the mount as a variable; §4.3 fails on any CR byte on every host |
| CI uses the Docker path for license generation | §7.1 |
| No host-OS instructions beyond “have Docker” | §8 — the canonical path needs only Docker; the native path defers to the fixed .gitattributes instead of per-OS advice |
Native make targets still work |
§6.3 — unchanged recipes, exercised by the ubuntu native job in §7.1 |
| CI fails on license drift | §4.3 verifier via make check-req on every job, plus the native-vs-canonical manifest comparison in §7.1 |
1. What the pipeline actually does
| Piece | Location (post 0e3ee0ec) |
Behaviour |
|---|---|---|
| Header check/fix | Makefile license-check / license-fix — one root license-eye header check against the root .licenserc.yaml |
Already deterministic |
| Dependency licenses, Go | Makefile license-dep → dist/licenses/, dist/LICENSE |
Source of the variance |
| Dependency licenses, JS | mcp/Makefile, canopy/Makefile → mcp/licenses/, canopy/licenses/ |
Source of the variance |
| Tool | scripts/build/license.mk → go install github.com/apache/skywalking-eyes/cmd/license-eye@55373684d1b70e5f8fd9fc8ec114a89ad11a56a3 |
Commit-pinned, per-host install |
| Existing guard | ci.yml: make license-dep, then make check-format |
Not a byte guard — see §2.3 |
| Docker infrastructure | scripts/build/docker.mk (per-project release images), test/docker/ (e2e compose) |
No build environment today |
The embedded Vue UI in ui/ was permanently removed upstream in 0e3ee0ec
(“Chore/remove embedded UI (#1380)”), so only mcp and canopy remain on the JS side. This design
targets the post-removal tree.
1.1 The complete generated surface
These six groups are the only things license-dep produces, and the verifier in §4 covers
exactly them:
dist/LICENSE
dist/licenses/**
mcp/LICENSE
mcp/licenses/**
canopy/LICENSE
canopy/licenses/**
The repository-root LICENSE and NOTICE are not generated by these recipes and must not be in
any drift guard — a check that includes them passes vacuously and gives false confidence.
1.2 What the pinned license-eye already does
Verified against the pinned revision in the local module cache, not assumed:
- It sorts.
pkg/deps/summary.gosorts dependencies by name and groups by license ID;pkg/deps/result.gosorts the printed report. Both use Go string comparison, which is locale-independent. ThereforeLC_ALLhas no effect on its output ordering, and a post-hocsortoverdist/licenses/would corrupt its structure. Neither is needed. - It installs JS dependencies itself.
pkg/deps/npm.gorunsnpm ciand thennpm prune --productionas part of resolution. There is no separate install prerequisite to satisfy, in the Makefiles or in the image. - It logs install failures instead of returning them. This matters: a failed
npm cican silently yield a partial license set that still exits zero. §4.3 covers how the verifier catches that, because it is invisible to an exit-code check.
1.3 Real sources of variance
- Line endings in the working tree. See §3 — the current
.gitattributesrule does not do what it appears to do. - Raw license bodies are copied verbatim.
license-eyecopies LICENSE text out of the Go module cache and out ofnode_modules. Whatever bytes are in those trees reach committed output. - npm and Go dependency trees differ per host.
npm cibehaviour varies with the Node version and the platform; the Go module cache is platform-independent but its contents are fetched by the host’s Go. - Host Go version at tool-install time.
go installoflicense-eyeruns on the host. - Package install failures are invisible (§1.2) — a risk on every host, not a cross-OS one.
2. Why the existing guard is insufficient
2.1 It runs on one host
ci.yml runs make license-dep and make check-format on ubuntu-latest only. A contributor on
Windows or macOS can produce a different diff and never find out from CI.
2.2 check-format is a modifying operation
Makefile check-format runs make format (which depends on tidy), then
git add --renormalize ., then captures a diff and fails if git status -s is non-empty. It is a
consistency check, not a byte-equality check: it lets Git apply its own normalization rules
during staging, its captured diff can omit what it just staged, and it says nothing about a file
that was generated and then deleted again.
2.3 The guard uses Git semantics where raw bytes are the requirement
git diff --exit-code compares blobs after normalization. The issue asks for byte-identical
output. A verifier must hash the bytes on disk.
3. Fix the checkout contract first
.gitattributes:19 currently reads:
* text eof=lf
eof is not a Git attribute. git check-attr confirms the working tree has eof: lf and
eol: unspecified — the intended LF policy is not in effect, and the typo masks the difference.
The rule should be:
* text=auto eol=lf
Two changes, and only the first is the bug fix. eof=lf → eol=lf is the fix. text → text=auto
is a safety improvement in its own right: the old * text forced normalization on every file, and
letting Git detect binary content is safer for the unlisted binary fixtures (.seg, .snp, .tzif
testdata) that nothing in the binary list covers.
Consequences, and why this is its own phase:
eol=lfnormalizes the working tree to LF on checkout on every platform, which is what the issue actually needs. It does not disable normalization during staging: that istext, which still converts a CRLF file to LF in the blob. The consequence is precise and worth stating, because it is why the LF assertion in §4.3 exists — a generated file can arrive with CRLF in the working tree while the blob it becomes is LF, so the difference is invisible to any blob comparison and visible only by reading the bytes on disk.- Switching the rule on an existing tree can produce a large one-time renormalization diff.
Measured before committing:
git add --renormalize .changed nothing, because the index contained noi/crlfori/mixedentries. The change is a pure checkout-behaviour fix, so it does not need to be quarantined into its own commit after all. The remaining risk is a contributor with a CRLF working tree, and §4.3’s LF assertion is what surfaces one. - The existing
binarypatterns (*.png,*.gz, …) are unaffected.
This replaces the “scan the repo and reject CRLF” idea the first draft proposed. Rejection is not normalization: it tells a Windows contributor what broke without fixing it, and a whole-repository scan also flags files that never reach a generated artifact. The check that is kept is a targeted LF assertion over the six output groups in §4.3.
4. The verifier (built first)
4.1 scripts/ci/check/license_manifest.sh (new)
Purely native, no Docker, no network. For a given root it walks the six groups from §1.1 and emits a sorted manifest:
<sha256-of-raw-bytes> <relative-path>
Sorted by path with LC_ALL=C. Raw bytes — no Git filters, no normalization, no text mode.
Three comparison modes:
| Mode | Command shape | Answers |
|---|---|---|
| Repeatability | generate twice into two trees, diff the manifests |
Is the generator itself nondeterministic on one host? |
| Against committed | compare the manifest against git ls-tree + git cat-file of a commit (default HEAD, any ref accepted) |
Has the committed state drifted from a fresh generation? |
| Cross-host | not a CI mode: §3 and §4.1 remove the host as a variable, and check-eol runs on the contributor’s own host |
Would different hosts produce the same bytes? Not measured; see §7.2 |
The second mode is also what catches the silent npm ci failure of §1.2: a dependency whose license
file fails to generate is a deletion relative to the committed tree, which the manifest reports.
4.2 Coverage of the modes
The manifest must compare complete filename sets, not just matching files, so that added and
deleted files are both caught. The same applies to the copy-back: the wrapper removes each generated
output set before extracting, because extracting over the top would keep any file the container did
not produce — which is exactly what a silent npm ci failure inside license-eye leaves behind,
since it exits 0. A diff of two sorted manifests does this by construction; a
per-file hash loop does not, and would silently pass when a file disappears.
4.3 make check-license-outputs (new)
Runs the “against committed” comparison, plus a targeted LF assertion over the same six groups: any
CR byte in a generated file is a failure, with the path printed. Wired into make check-req so it
runs everywhere, including jobs that never invoke the generator — a stale committed artifact is
still a stale committed artifact.
5. The build environment
5.1 Location
Everything lives under scripts/build/, never under a root build/:
.gitignoreignores/build, andMakefilecleanrunsrm -rf build. A Dockerfile, an entrypoint and a pin file under rootbuild/would be deleted bymake cleanand never committed in the first place.- Paths:
scripts/build/dockerfiles/build.Dockerfile,scripts/build/dockerfiles/entrypoint.sh,scripts/build/version.mk. BuildKit cache goes tobin/.buildkit(bin/is already the gitignored tool directory).
5.2 The Dockerfile
Single stage named tools, so the default target and --target tools agree and no stage-naming
mistake is possible. Base is debian with apt pinned to a snapshot date, because a base-image
digest does not freeze the contents of the apt repository at the time of the issue’s reading of
“pinned”:
# syntax=docker/dockerfile:1.7
# No version numbers anywhere below, and no defaults on any of these: the
# Dockerfile never decides a version. See §5.4.
ARG DEBIAN_IMAGE # debian:bookworm-slim@sha256:... from scripts/build/version.mk
ARG DEBIAN_SNAPSHOT # from scripts/build/version.mk
ARG GO_VERSION # from go.mod
ARG NODE_VERSION # from mcp/ and canopy/ package.json
ARG LICENSE_EYE_VERSION # from scripts/build/version.mk
ARG SOURCE_DATE_EPOCH=1700000000
FROM ${DEBIAN_IMAGE} AS tools
# Real comments, on their own lines — a `#` after an ARG value is not a Dockerfile comment.
ENV DEBIAN_FRONTEND=noninteractive \
SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} \
TZ=UTC LC_ALL=C LANG=C \
PATH=/usr/local/go/bin:/usr/local/bin:/usr/bin:/bin \
GOPATH=/go GOMODCACHE=/go/pkg/mod GOCACHE=/root/.cache/go-build \
GOTOOLCHAIN=local \
GOFLAGS=-buildvcs=false
# apt is pinned to a snapshot, and validity checks are disabled because a snapshot
# is by definition "expired". This is what makes `make`, `git` and `ca-certificates`
# reproducible rather than "whatever the mirror serves today".
RUN printf 'deb [check-valid-until=no] https://snapshot.debian.org/archive/debian/%s bookworm main\n' \
"${DEBIAN_SNAPSHOT}" > /etc/apt/sources.list \
&& rm -f /etc/apt/sources.list.d/debian.sources \
&& apt-get -o Acquire::Check-Valid-Until=false update \
&& apt-get install -y --no-install-recommends make git ca-certificates jq xz-utils \
&& rm -rf /var/lib/apt/lists/*
# Go and Node are installed from their official tarballs. Copying /usr/local/go
# out of a golang image, or node's binary out of a node image, silently loses
# PATH, GOPATH and the runtime shared libraries the binary needs; installing the
# tarball is the boring, correct way.
#
# Each download is verified against the checksum the vendor publishes for that
# exact file, fetched over TLS at build time. Nothing about a toolchain bump is
# recorded in this repository, so a version bump touches go.mod or the two
# package.json files and nothing else. A version with no published checksum fails here rather than
# installing unverified bytes.
ARG GO_VERSION
RUN set -e; \
go_file="go${GO_VERSION}.linux-amd64.tar.gz"; \
curl -fsSLo /tmp/go.tgz "https://go.dev/dl/${go_file}"; \
expected="$(curl -fsSL 'https://go.dev/dl/?mode=json&include=all' \
| jq -r --arg f "$go_file" \
'.[] | select(.version == "go'"${GO_VERSION}"'") | .files[] | select(.filename == $f) | .sha256')"; \
test -n "$expected" && test "$expected" != "null"; \
echo "${expected} /tmp/go.tgz" | sha256sum -c -; \
tar -C /usr/local -xzf /tmp/go.tgz; rm /tmp/go.tgz
ARG NODE_VERSION
RUN set -e; \
node_file="node-v${NODE_VERSION}-linux-x64.tar.xz"; \
curl -fsSLo /tmp/node.tar.xz "https://nodejs.org/dist/v${NODE_VERSION}/${node_file}"; \
expected="$(curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" \
| awk -v f="$node_file" '$2 == f { print $1 }')"; \
test -n "$expected"; \
echo "${expected} /tmp/node.tar.xz" | sha256sum -c -; \
mkdir -p /usr/local/lib/nodejs; \
tar -C /usr/local/lib/nodejs -xJf /tmp/node.tar.xz --strip-components=1; \
ln -s /usr/local/lib/nodejs/bin/node /usr/local/bin/node; \
ln -s /usr/local/lib/nodejs/bin/npm /usr/local/bin/npm; \
ln -s /usr/local/lib/nodejs/bin/npx /usr/local/bin/npx; \
rm /tmp/node.tar.xz
ARG LICENSE_EYE_VERSION
RUN --mount=type=cache,target=/go/pkg/mod \
GOBIN=/usr/local/bin go install github.com/apache/skywalking-eyes/cmd/license-eye@${LICENSE_EYE_VERSION}
# The entrypoint is normalized to LF and made executable during the build, so a CRLF
# shebang or a lost mode bit from the host checkout can never make the image unusable.
COPY scripts/build/dockerfiles/entrypoint.sh /usr/local/bin/entrypoint
RUN chmod 0755 /usr/local/bin/entrypoint \
&& sed -i 's/\r$//' /usr/local/bin/entrypoint
# Smoke-test the toolchain at build time, not at first use.
RUN go version && node --version && npm --version && license-eye --version
ENTRYPOINT ["/usr/local/bin/entrypoint"]
Two deliberate divergences from CI, both stated rather than glossed over:
GOTOOLCHAIN=local. CI’ssetup-build-envexplicitly resetsGOTOOLCHAIN=autoaftersetup-go.localis chosen so ago.modbump newer than the image fails loudly instead of silently downloading a different toolchain. §5.4’s cross-check makes that failure legible.- A single canonical platform,
linux/amd64, set in the build and run invocations. Apple Silicon would otherwise resolve a different image manifest and potentially a different dependency platform. A multi-arch digest alone does not make dependency selection identical.
5.3 The entrypoint
$1 = the make target(s) to run, e.g. "license-dep"
- Cross-check the image’s Go against
go.modand its Node against the mcp and canopyengines.node; exit non-zero with a clear message on mismatch (§5.4). - Confirm
/workis populated andLICENSE_EYE=/usr/local/bin/license-eyeis honoured. - Run
make -C /work "$@"withLICENSE_EYEoverridden (§6.2). - Copy the six output groups from
/workto/out, creating empty files where a group is absent so that deletions are represented. - Print the manifest (§4.1) for
/outon stdout, so the host can tee it without reimplementing the walk.
5.4 Version sourcing and the pin file
| Toolchain | Source of truth | Also read by |
|---|---|---|
| Go | go.mod |
setup-go@v6 (go-version-file: 'go.mod') |
| Node | engines.node in mcp/package.json and canopy/package.json |
all setup-node steps, via node-version-file: canopy/package.json (§5.5) |
| license-eye | LICENSE_EYE_VERSION in scripts/build/version.mk |
host installs, image build |
| Build platform | BUILD_PLATFORM in scripts/build/version.mk |
image build |
| Base image digest, apt snapshot | DEBIAN_IMAGE, DEBIAN_SNAPSHOT in scripts/build/version.mk |
image build |
scripts/build/version.mk is the single place these are written down; scripts/build/dockerize.mk
only reads and forwards them, with no literal of its own:
GO_VERSION := $(shell sed -n 's/^go //p' $(mk_dir)go.mod)
NODE_VERSION := resolved from mcp and canopy engines.node
DISTRO ?= bookworm
PLATFORM ?= linux/amd64
SOURCE_DATE_EPOCH ?= 1700000000
LICENSE_EYE_VERSION := $(shell sed -n 's/^LICENSE_EYE_VERSION := //p' $(mk_dir)scripts/build/version.mk)
Tags are derived, never stored. A go.mod bump needs no Dockerfile edit and no lock-file edit: the
Go and Node tarballs are verified against the checksum each vendor publishes for that exact file, so
a version bump touches go.mod or the two package.json files and nothing else. Only the base-image digest and the
apt snapshot are recorded rather than resolved, because neither can be derived from a URL the way a
tarball checksum can.
This is a deliberate weakening of one property: a hash recorded in the repository would also catch a
compromised origin, whereas fetching the expected hash at build time only catches corruption and
truncation. It is the trust model the rest of this build already accepts — go install verifies
license-eye through the module proxy, and license-eye then runs npm ci against the npm registry —
and it is worth one honest sentence rather than a silent swap.
make bump-build-image re-resolves the base-image digest via docker buildx imagetools inspect,
writing version.mk atomically (temp file + mv) so a failed lookup cannot leave it half-updated.
It is only needed when moving the Debian base image or the apt snapshot — never for a toolchain
bump. Wiring it into Dependabot is out of scope, because Dependabot’s docker ecosystem tracks the
Dockerfile and this design deliberately keeps no image reference in it.
5.5 Node version consolidation
24.6.0 is currently a literal in four places: .github/actions/setup-build-env/action.yml,
prepare.yml, canopy.yml, flaky-test.yml. Note that the last two use setup-node@v4 while the
first uses v6, and that canopy/package.json and mcp/package.json carry their own engine
constraints. Consolidating on node-version-file turned out not to require moving
canopy.yml and flaky-test.yml off setup-node@v4: v4 accepts node-version-file as well. The
four call sites were switched in place and the majors left alone, since changing them is unrelated
churn. The engines reconciliation is a real obligation and is enforced by
scripts/ci/check/node_version.sh (P3), which fails on an exact-pin mismatch, on a floor the pin
does not satisfy, and — deliberately — on a range form it does not understand, because a check that
quietly passes an unhandled range is worse than no check.
6. Make wrappers and workspace isolation
6.1 Why not a bind mount of the worktree
A bind mount of the live worktree is the obvious approach and it is wrong here, for four independent reasons:
license-eyerunsnpm ciandnpm prune --productioninside the container against the mounted tree. On a Windows or macOS host that replaces the host’snode_moduleswith Linux packages and symlinks; on Linux it leaves root-owned files that obstruct subsequent native work. Apackage-lock.jsonbackup protects the lockfile and nothing else.- The host path is unquoted in the first draft, and drive letters, spaces, shell path conversion and daemon locality all break or silently misbehave.
- A linked Git worktree puts
.gitin a file pointing outside the mounted directory, so container-sidegit ls-filescannot resolve the tree at all. This is not hypothetical — it is the layout of this very checkout. makeand the recipes assume a POSIX host toolchain, which a Windows container would not provide.
6.2 Tar in, tar out
The container never mounts the worktree. The host streams a copy in and takes the outputs back:
host --(tar, excluding .git, bin, node_modules, dist)--> container /work
host <--(tar of the six output groups only)--------------- container /out
Consequences, all intended:
- The host’s
bin/, dependency trees and lockfile bytes are never touched.package-lock.jsonis transported read-only, so the existingmktemp/EXIT-trap save-restore inmcp/Makefileandcanopy/Makefilehas nothing to protect against and can be simplified or left alone. - Uncommitted source edits — including a dependency bump the contributor has not committed — are
included, because the copy is a filesystem copy, not
git archive. (git archivewould have silently excluded exactly the inputs a contributor is trying to re-license.) - Git is never invoked in the container, so the linked-worktree problem does not exist.
- Paths with spaces and drive letters are a
tarquoting problem, not a Docker volume problem. - The cost is a full source copy per run, which is acceptable for a
license-dep-scale operation and is avoided entirely in CI by generating from a fresh checkout.
6.3 The wrappers, and the contract they enforce
make docker-run TARGET=<target> runs any target in the pinned environment under two rules that
are checked, not merely documented:
-
Git metadata is opt-in, with
GIT=1. By default the tree is streamed without.git, socheck,check-formatandpre-push(which ends incheck) cannot run there. Unenforced they fail deep insidego mod tidywith a message about unpublished internal modules — nothing like the real cause. The wrapper refuses them up front and names the alternative.With
GIT=1a self-contained.gitis assembled and streamed as well, and they run:make docker-run GIT=1 TARGET="generate pre-push"completes in ~14 minutes. The assembly is not a copy, because a linked worktree cannot be copied:.gitis a 4 KB file pointing at<common>/worktrees/<name>, the objects live in<common>, and the index lives in the worktree’s own gitdir. So the common directory is streamed and this worktree’sHEADandindexare layered on top, withcommondirdropped. Verified against a real linked worktree: the reconstructed repository produces the samestatus,diffandgit add --renormalizeresult as the original.Two consequences, both intentional.
GIT=1runs these as checks, not fixes:check-format’sgit add --renormalize .edits the container’s copy of the index, which is discarded on exit, so a contributor who wants the tree fixed must run it on the host. AndGIT=1stops excluding the generated license artifacts, because otherwisegit statuswould report every one of them as deleted. -
A target that compiles Go needs the generated protos first.
api/proto/**is build output, not source:make generateproduces it, and CI receives it as aprepare-job artifact, so a plain checkout does not contain it. Without it,go mod tidycannot resolve the module’s own packages — the first thing to break, and a confusing one. Passinggenerateas the first target produces them in the same container run:make docker-run TARGET="generate lint vuln-check"Measured in the pinned environment:
make generate tidycompletes in ~2m40s, wheretidyalone fails. The cost is that the image carries only the license toolchain, sobuf, the protoc plugins,golangci-lint,revive,ginkgoandgovulncheckare installed at run time over the network, at the versions pinned inversion.mk— reproducible in version, but not hermetic, and the bulk of that 2m40s. Baking them in would fix both at the price of a much larger image; see §10.8.
# Resource defaults mirror the existing `make test-docker` target (2 CPUs, 4 GB),
# so the container path is no heavier than the repo's established local-test path.
RUN_CPUS ?= 2
RUN_MEMORY ?= 4g
RUN_MEMORY_SWAP ?= $(RUN_MEMORY)
RUN_SHM_SIZE ?= 256m
DOCKER_RUN_FLAGS = --rm --platform $(PLATFORM) \
--cpus=$(RUN_CPUS) --memory=$(RUN_MEMORY) --memory-swap=$(RUN_MEMORY_SWAP) \
--shm-size=$(RUN_SHM_SIZE) \
-e GOMAXPROCS=$(RUN_CPUS) \
-e NODE_OPTIONS=--max-old-space-size=2048 \
-e SOURCE_DATE_EPOCH=$(SOURCE_DATE_EPOCH) -e TZ=UTC -e LC_ALL=C \
-e LICENSE_EYE=/usr/local/bin/license-eye
# TARGET must be a *target-specific variable*: `foo: bar ; TARGET=x` would be a
# shell recipe, not an assignment, and the container would never receive it.
docker-license-dep: TARGET=license-dep
docker-license-check: TARGET=license-check
docker-license-dep docker-license-check: $(BUILD_IMAGE) | $(STAGE_DIR)
@tar -C $(mk_dir) --exclude=.git --exclude=bin --exclude=node_modules \
--exclude=dist -cf - . | docker run -i $(DOCKER_RUN_FLAGS) \
-v $(STAGE_DIR):/out $(BUILD_IMAGE):$(BUILD_IMAGE_TAG)
@tar -C $(STAGE_DIR) -cf - . | tar -C $(mk_dir) -xf -
@$(MAKE) check-license-outputs
LICENSE_EYE=/usr/local/bin/license-eyeis passed as a command-line Make variable inside the container. It overridesscripts/build/license.mkand propagates to every recursive$(MAKE), so no recipe changes and no symlink is ever written into the host’sbin/.- The build itself:
docker buildx build --platform $(PLATFORM) --load -f scripts/build/dockerfiles/build.Dockerfile --build-arg GO_IMAGE=$(GO_IMAGE) --build-arg NODE_IMAGE=... --build-arg DEBIAN_IMAGE=$(DEBIAN_IMAGE) --build-arg GO_VERSION=$(GO_VERSION) --build-arg LICENSE_EYE_VERSION=$(LICENSE_EYE_VERSION) --cache-from type=local,src=bin/.buildkit --cache-to type=local,dest=bin/.buildkit,mode=max -t $(BUILD_IMAGE):$(BUILD_IMAGE_TAG) .Every--build-argis explicit. A bare--build-arg Xreads the shell environment, and a plain Make variable is not exported to the environment by default. - Runtime caches (
/go/pkg/mod,~/.npm) are a separate concern from the image’s BuildKit cache mounts:--loaddoes not carry build-time cache mounts intodocker run. Add a runtime volume for them only after a cold-cache run is proven correct. - The native path is untouched.
make license-depon a healthy host still works exactly as before; §7.1 runs both and compares them.
6.4 Resource limits
The defaults are 2 CPUs and 4 GB of memory, chosen to match the existing make test-docker
target rather than to invent a second convention. Rationale for a limit at all: a container that
sees the whole developer machine’s core count will size the Go build and npm’s heap from it, and
the resulting peak memory is both unpleasant on a laptop and non-reproducible across machines.
| Knob | Default | Why |
|---|---|---|
--cpus |
2 |
Matches test-docker; bounds the Go build’s parallelism |
--memory / --memory-swap |
4g / 4g |
Equal values disable swap, so the limit is a hard ceiling rather than a threshold |
--shm-size |
256m |
npm’s default 64 MB /dev/shm is a known source of silent failures under concurrency |
GOMAXPROCS |
2 |
Without it, Go reads the host CPU count and fights the cgroup quota, producing a different build layout and thrashing |
NODE_OPTIONS |
--max-old-space-size=2048 |
Keeps V8’s heap inside the cgroup instead of letting it grow until the OOM killer wins |
The last two are the ones that matter for determinism rather than just comfort: a Go or Node process
sizing itself from host resources can make timing host-dependent, and the entrypoint’s
SOURCE_DATE_EPOCH guarantee only helps if nothing else varies. All six are overridable
(make docker-license-dep RUN_CPUS=4 RUN_MEMORY=8g) for constrained machines, and the overrides
are recorded in the job log so a non-default run is visible.
The image build is limited separately, because docker buildx limits do not come from docker run
flags:
- With the default Docker driver, the build shares the daemon’s resources and cannot be capped per
invocation. If a hard cap is required, use a container builder:
docker buildx create --driver docker-container --driver-opt memory=4g,cpu-quota=200000 --name banyandb-build. go installof license-eye is the only memory-hungry build step;--build-argoverrides or a largercpu-quotaare the escape hatch if it OOMs under the cap.- Cold builds are the worst case for the memory ceiling (the module cache is empty). A cold-cache run must therefore be part of the first CI validation (P4 in §9), otherwise the limit is only proven on warm caches.
7. CI wiring
7.1 The ubuntu check job
Both paths run, each verified by the same manifest, and the two manifests are compared to each other:
- name: License artifacts (native baseline)
run: make license-dep
- name: Capture native manifest
run: make license-manifest > native.manifest
- name: License artifacts (Docker, canonical)
run: make docker-license-dep
- name: Capture canonical manifest
run: make license-manifest > canonical.manifest
- name: Native and Docker output must be byte-identical
run: diff -u native.manifest canonical.manifest
The container runs under the §6.4 limits in CI as well, deliberately: ubuntu-latest has more cores
and memory than a developer’s laptop, and letting the container size itself from the runner would
mean the canonical path is validated under a different resource regime than contributors use it
under.
This is not redundant with §7.2, and it resolves a real failure mode: a broken image must not be
able to hide a license change, and a license change must not be able to hide a broken image.
check-license-outputs (§4.3) runs in check-req, so it also guards jobs that never generate.
ci.yml currently passes setup-docker: 'false' to setup-build-env; the check job needs
either a Buildx-capable builder or a documented fallback to the default Docker driver, whose
--cache-to type=local support depends on its image-store configuration. The cache key must
include scripts/build/version.mk, go.mod, the two package.json files, the entrypoint and
the platform.
7.2 Cross-host coverage: build system yes, generation no
Two revisions got this wrong in opposite directions, so the current position is worth stating precisely.
Generation on every host — not done, by decision. An earlier revision specified a
test-license-determinism.yml matrix that regenerated the artifacts on macos-latest and
windows-latest and compared manifests. It was dropped, because after §3 and §6.1 the pipeline has
no host-specific behaviour left to test:
- The working tree is streamed into the container over a pipe, so there is no bind mount, no
path translation, no daemon locality and no
node_modulesto be rewritten (§6.1). - Line endings are neutralized at the checkout by
eol=lf(§3), and at generation bylicense-normalize-eol(§4), socore.autocrlfis not a variable in the output.
What remains is Docker’s own container and image handling, which is Docker’s to get right and is
covered by its own test suite. A matrix here would have been paying for Docker’s regressions
rather than for ours, and it is not free: macos-latest and windows-latest provide a Docker
client but no Linux engine, so the jobs would have needed Docker Desktop with a Linux VM —
fragile, slow, and a recurring source of runner breakage.
The one host-specific defect this issue was actually about — CRLF reaching a committed artifact —
is caught by check-license-outputs (§4.3), which runs on every host via make check-req and
fails on any CR byte. That is a stronger guard than a matrix, because it also fires for a
contributor who never pushes a branch.
Build system on every host — done. Dropping the generation matrix initially left nothing at all
running on macOS or Windows, which is the wrong trade: CONTRIBUTING tells those contributors to run
the same commands as everyone else, and that should be demonstrated rather than asserted.
.github/workflows/test-build-system.yml runs on all three hosts and checks:
- the Node resolver and the license verifier, in their failing modes as well as their passing ones, so a green step cannot mean the check silently did nothing;
- that the pinned image builds on that host, with one cheap container run that also asserts the image carries the Go, Node and license-eye versions this repository declares;
- that the wrapper’s driver logic holds there — right platform, no version literals, clean tree.
It is gated from ci.yml’s result job, so a macOS or Windows failure blocks a PR. shell: bash
throughout, because the verifier is a shell script and a Windows runner would otherwise use cmd.
What is still not covered: the generation itself on macOS and Windows. If a Docker transport
problem exists there, this workflow is what will surface it, since the image build and the wrapper
are exercised even though generation is not. The native make license-dep remains a fallback.
7.3 Windows-native note
A native Windows host is supported. The image is Linux, but Docker Desktop runs Linux containers directly on Windows, so the container is not the obstacle. What the wrapper actually needs from the host is small, and is checked rather than assumed:
| Host requirement | Windows | Why |
|---|---|---|
docker |
Docker Desktop | runs the Linux image |
GNU make |
any install | already a documented requirement for every platform |
tar with --exclude |
bsdtar, ships with Windows 10+ | streams the tree in and the artifacts out |
a POSIX id |
not required | see below |
bash, for the post-generation check |
WSL2 or Git Bash, else skipped with a notice | the check is a guard; CI runs it unconditionally |
--user $(id -u):$(id -g) exists to keep the staging directory owned by the invoking user on a
POSIX host, where a root-owned artifact cannot be copied back without sudo and poisons the tree
that npm ci runs in. Neither applies on Windows: NTFS/DrvFs has no uid ownership, so the
copied-back files belong to the Windows user whatever uid wrote them. The flag is therefore applied
only when a POSIX id is found, and its absence is not an error.
This was wrong in the first implementation, which rejected any host without id and pointed at
WSL2. That conflated “the container needs Linux” with “the host tooling needs POSIX”. The native
path remains a convenience and, with §3 in place, needs no per-OS core.autocrlf instruction; if
someone does hit CRLF on a native run, the LF assertion in §4.3 names the file and the fix is §3.
The canonical path requires only Docker. The native path is a convenience; with §3 in place it no
longer needs per-OS core.autocrlf instructions, which is what the issue’s acceptance criteria
demand. If a contributor hits CRLF on a native run, the LF assertion in §4.3 prints the offending
path and the fix is the one in §3, not a per-OS workaround.
8. Documentation
CONTRIBUTING.md— the license section becomes “have Docker; runmake docker-license-dep”. Nativemake license-depstays documented as the fast path, without OS-specific configuration.README.md— one line in the build/development section pointing at the canonical command, as the issue explicitly requests.Makefile— a comment in the##@ Build targetsblock namingmake docker-license-dep,make bump-build-imageandmake check-license-outputs, and stating that Docker is canonical for anything that produces committed license artifacts.- Image maintenance — the §5.4 procedure, in
scripts/build/README.md.
9. Phasing
| Phase | Content | Independently shippable |
|---|---|---|
| P1 | .gitattributes eof=lf → eol=lf (no renormalization needed — see §11) |
Yes — it is a real bug fix |
| P2 | license_manifest.sh + check-license-outputs + check-req wiring + tests |
Yes — makes all later claims falsifiable |
| P3 | Node consolidation on the two package.json files (§5.5) |
Yes |
| P4 | Dockerfile, entrypoint, version.mk entries, dockerize.mk, make docker-license-dep, resource defaults (§6.4) |
Yes |
| P5 | ci.yml canonical step + native/Docker manifest comparison (§7.1) |
Yes |
| P6 | Documentation | Yes |
10. Risks and open questions
- Archive reproducibility is untouched.
SOURCE_DATE_EPOCHis set in the image, butscripts/release.shstill does not normalize archive member times, order, ownership or gzip metadata. Split into its own issue rather than implying it is covered here. - Silent partial resolution.
license-eyelogsnpm cifailures instead of failing (§1.2). The manifest’s “against committed” mode is the mitigation, and it only works because the six groups are enumerated. If a new JS project is added and forgotten in §1.1, its licenses become unverified — a.licenserc.yaml-adjacent manifest, or a check that the enumerated groups cover everyLICENSE/licensesdirectory in the tree, would close that hole. - Canonical platform. Pinning
linux/amd64makes arm64 contributors run an emulated amd64 image, which is slower but deterministic. The alternative — allowing the native platform — reopens manifest-divergence risk. This design takes determinism and accepts the cost; revisit if the cost becomes real. - Source copy per run. §6.2 copies the tree. For a repository this size that is seconds, but it is per invocation and should be measured before the targets are advertised as a fast dev loop.
- Generation is not exercised on macOS or Windows. By decision (§7.2); the build system is
checked there, generation is not. The compensating control is
check-license-outputs, which runs on whatever host the contributor is on. - Base-image maintenance is manual.
make bump-build-imageis a documented procedure, not automation, and it now only covers the Debian digest and the apt snapshot — a Go or Node bump is a one-line edit togo.modor to eitherpackage.json. Deliberate for this design; it is the obvious follow-up if the image outlives the issue. - The 2 CPU / 4 GB default is inherited, not measured. It matches
test-dockerand is deliberately conservative, but no one has profiledlicense-depunder it. If a cold-cache run OOMs or thrashes, the first move is to raiseRUN_MEMORY, not to remove the limits — an uncapped container is what made this class of problem host-dependent in the first place. - The container is a check environment for the full pre-push, not a fix environment. With
GIT=1the wholepre-pushchain runs there (§6.3), but it runs as a check: the renormalizationcheck-formatwould stage is discarded with the container, and the seven tools it installs come from the network at run time. A contributor wanting a fixed tree still runsmake pre-pushon the host. - Baking the remaining tools is unpriced work. The ~2m40s figure in §6.3 is for the run-time
install path. An image with
buf, the protoc plugins,golangci-lint,revive,ginkgoandgovulncheckbaked in would be hermetic and substantially faster, at a materially larger size. Nobody has measured that size, nor the CI minutes it would save.
11. Implementation notes — what changed from the design
Reconciling the design against the code found five things worth recording. Two are bugs the design would have shipped, and one of them was caught only because the verifier was built first.
11.1 CRLF normalization belongs in the generator, not the container
The design put CRLF normalization in the entrypoint. That is wrong, and §7.1 caught it: if only the
container normalizes, make license-dep and make docker-license-dep produce different bytes, so
the native-vs-canonical comparison fails for reasons that have nothing to do with the host OS.
It now lives in the shared license-dep target (license_manifest.sh normalize, wired as the
license-normalize-eol prerequisite). Both paths normalize; both produce the same bytes; a
Windows contributor’s native run matches CI.
11.2 The verifier found a real, previously invisible defect
Four committed artifacts carried CR bytes, copied verbatim out of node_modules:
dist/licenses/ui-licenses/license-sass.txt
dist/licenses/ui-licenses/license-tslib.txt
mcp/licenses/license-json-schema-typed.txt
canopy/licenses/license-color-name.txt
The committed blobs were already LF; the CRLF was only in working trees, invisible to
git diff and to check-format (which compares blobs). This is precisely the class of difference
the issue is about, and it only became visible once a raw-byte LF assertion existed.
11.3 debian:bookworm-slim cannot fetch a snapshot over https
The slim base image ships no CA store, so it cannot complete a TLS handshake with
snapshot.debian.org — it would need the very package that would give it one. The snapshot is
therefore fetched over http. That is not a downgrade: apt verifies the Release and Packages
signatures against the archive keyring in the digest-pinned base image, and package payloads are
checksummed against those signed indexes. apt-get update fails outright on a mismatch. Go and
Node tarballs are fetched over https and verified against pinned SHA-256 sums.
11.4 The container must run as the host uid
Otherwise the artifacts written into the mounted staging directory are root-owned and the copy-back
needs sudo — which fails outright on a WSL DrvFs mount, and would leave root-owned files in the
tree. --user $(id -u):$(id -g) fixes both. Three consequences followed:
- The image has no
WORKDIR: one baked at build time is created as root and is unwritable by the invoking uid. The entrypoint creates its own scratch directory under/tmp. - The image’s caches move to
/tmpwhen non-root, since/goand/rootare not writable. - A host without
idis rejected up front with a pointer at WSL, rather than failing later inside atar.
11.5 COPY_EXCLUDES cannot exclude dist wholesale
canopy/Makefile reads dist/LICENSE.tpl as an input template. Excluding dist broke it.
Only the two generated paths inside dist are excluded; --exclude globs the whole path, so
dist/LICENSE does not match dist/LICENSE.tpl.
11.6 Verified
| Check | Result |
|---|---|
make license-manifest, native |
742 artifacts |
make license-manifest, container |
byte-identical to native |
| Two independent container runs | byte-identical |
manifest-committed vs generated |
byte-identical |
make check-license-outputs |
clean |
make check-node-version |
clean |
license-eye header check |
4498 files, 0 invalid |
go test ./scripts/ci/check/ |
pass |
make docker-run TARGET=print-build-args |
pass |
| Cold-cache rebuild | identical image id |
Nothing has to be added to .licenserc.yaml’s ignore list for this: an earlier revision carried a
generated .node-version, which needed a header exemption because a comment would have made the
file unparseable. Reading engines.node from canopy/package.json removes the file, and with it
the exemption.