Run all linting in Docker via Dockerfile.lint (closes #30)
All checks were successful
check / check (push) Successful in 1m2s
All checks were successful
check / check (push) Successful in 1m2s
Linting now happens in one place only: a new root Dockerfile.lint copies the repo into the digest-pinned node image already used by Dockerfile and runs eslint and prettier as build steps, so a successful build is a clean lint. script/lint is reduced to building it, which also works where the docker daemon is remote and bind mounts are impossible. No host lint path survives: the "lint" script is gone from package.json, so there is no second, unpinned way to get a lint verdict. Caching is waived for lint, because a lint build over an unchanged tree returns success in well under a second having linted nothing. LINT_EPOCH is the cache buster and it fails closed exactly as CHECK_EPOCH does: an unset ARG is the empty string, which is a perfectly stable cache key, so the guard rejects it and a bare `docker build -f Dockerfile.lint .` errors out instead of serving a green it did not earn. Both linters sit below the guard, so a fresh epoch forces them to execute while the bootstrap and dependency layers above stay cached. That makes script/lint a docker build, which nothing inside a container may call. script/check calls script/lint, so the Dockerfile image can no longer run make check: the lint stage and its COPY --from=lint ordering hack are deleted, and the remaining stage runs make test and make build under the existing CHECK_EPOCH guard. script/cibuild is now the composite gate and builds the lint image first, so a lint failure is reported before the slower suite runs. The .dockerignore exclusions are unchanged and still apply to the lint build, including the .claude/ exclusion (eslint's flat config does not ignore dot-directories, so a nested worktree in the context would be linted) and the deliberate exception that keeps .gitignore in the context for prettier. A new test asserts no per-Dockerfile ignore file shadows the root one for either image, and test/packaging/lint-docker.test.ts asserts the whole shape: the docker-only lint path, the digest pin, manifests copied before sources, the fail-closed guard with both linters below it, the absence of a lint stage or make check in Dockerfile, and the build order in script/cibuild.
This commit is contained in:
27
Dockerfile
27
Dockerfile
@@ -1,36 +1,27 @@
|
|||||||
# Lint stage — fast feedback on formatting and lint issues
|
# Test and build image: the suite, then the compile.
|
||||||
# node 22.22.0 on Alpine 3.23.3 (node:22-alpine), 2026-08-09
|
#
|
||||||
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS lint
|
# Linting deliberately does not happen here. `script/lint` is a build of
|
||||||
WORKDIR /app
|
# Dockerfile.lint, and `script/check` calls `script/lint`, so running
|
||||||
COPY script/ script/
|
# `make check` in this image would mean running `docker build` inside a
|
||||||
COPY package.json yarn.lock ./
|
# container. Lint runs exactly once, in Dockerfile.lint; script/cibuild
|
||||||
RUN script/bootstrap
|
# builds that first and this second.
|
||||||
COPY . .
|
|
||||||
RUN make fmt-check
|
|
||||||
RUN make lint
|
|
||||||
|
|
||||||
# Check stage — the full suite and the build
|
|
||||||
# node 22.22.0 on Alpine 3.23.3 (node:22-alpine), 2026-08-09
|
# node 22.22.0 on Alpine 3.23.3 (node:22-alpine), 2026-08-09
|
||||||
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS check
|
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS check
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Force BuildKit to run the lint stage before proceeding. Without this the
|
|
||||||
# two stages run in parallel and a lint failure can lose the race.
|
|
||||||
COPY --from=lint /app/yarn.lock /dev/null
|
|
||||||
|
|
||||||
COPY script/ script/
|
COPY script/ script/
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
RUN script/bootstrap
|
RUN script/bootstrap
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# CHECK_EPOCH is a cache buster: without it Docker serves `make check` from
|
# CHECK_EPOCH is a cache buster: without it Docker serves the test layer from
|
||||||
# cache on an unchanged tree, the suite never executes, and the build still
|
# cache on an unchanged tree, the suite never executes, and the build still
|
||||||
# exits 0. The guard makes an absent argument a hard failure — an unset ARG
|
# exits 0. The guard makes an absent argument a hard failure — an unset ARG
|
||||||
# is the empty string, which is a perfectly stable cache key, so a plain
|
# is the empty string, which is a perfectly stable cache key, so a plain
|
||||||
# `docker build .` would otherwise still get the false green. Fail closed.
|
# `docker build .` would otherwise still get the false green. Fail closed.
|
||||||
ARG CHECK_EPOCH
|
ARG CHECK_EPOCH
|
||||||
RUN [ -n "$CHECK_EPOCH" ] || exit 1
|
RUN [ -n "$CHECK_EPOCH" ] || exit 1
|
||||||
RUN make check
|
RUN make test
|
||||||
|
|
||||||
ARG CHECK_EPOCH
|
ARG CHECK_EPOCH
|
||||||
RUN [ -n "$CHECK_EPOCH" ] || exit 1
|
RUN [ -n "$CHECK_EPOCH" ] || exit 1
|
||||||
|
|||||||
35
Dockerfile.lint
Normal file
35
Dockerfile.lint
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Lint image: every lint run happens here, and nowhere else. The repo is
|
||||||
|
# COPYed into a digest-pinned image and the linters run as build steps, so a
|
||||||
|
# successful build IS a clean lint. `script/lint` does nothing but build this
|
||||||
|
# file, which also works where the docker daemon is remote and bind mounts are
|
||||||
|
# impossible. Nothing that runs inside a container may call `script/lint`:
|
||||||
|
# that is why Dockerfile no longer runs `make check`.
|
||||||
|
# node 22.22.0 on Alpine 3.23.3 (node:22-alpine), 2026-08-09
|
||||||
|
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS lint
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Manifests before sources, so the dependency install layer stays cached
|
||||||
|
# until package.json or yarn.lock changes. script/bootstrap ends in
|
||||||
|
# `yarn install --frozen-lockfile`; the lint steps below are deliberately
|
||||||
|
# not cached.
|
||||||
|
COPY script/ script/
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
RUN script/bootstrap
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# LINT_EPOCH is a cache buster, with the same fail-closed contract as
|
||||||
|
# CHECK_EPOCH in Dockerfile. No lint cache is wanted: on an unchanged tree
|
||||||
|
# Docker serves the linter layers in well under a second, having linted
|
||||||
|
# nothing, and the build still exits 0. The guard makes an absent argument a
|
||||||
|
# hard failure — an unset ARG is the empty string, which is a perfectly
|
||||||
|
# stable cache key, so a plain `docker build -f Dockerfile.lint .` would
|
||||||
|
# otherwise get exactly that false green. Every layer below this one is a
|
||||||
|
# child of the guard, so a fresh epoch forces all of them to execute.
|
||||||
|
ARG LINT_EPOCH
|
||||||
|
RUN [ -n "$LINT_EPOCH" ] || exit 1
|
||||||
|
|
||||||
|
# The linters are invoked directly rather than through `make lint`, because
|
||||||
|
# `make lint` is the build of this file.
|
||||||
|
RUN yarn run eslint .
|
||||||
|
RUN yarn run prettier --check .
|
||||||
70
README.md
70
README.md
@@ -86,15 +86,17 @@ alpine. We provide:
|
|||||||
files the compiler wrote, and make the CLI executable (our own extension)
|
files the compiler wrote, and make the CLI executable (our own extension)
|
||||||
- `script/test` — run the test suite (vitest, hard-capped at 30s where `timeout`
|
- `script/test` — run the test suite (vitest, hard-capped at 30s where `timeout`
|
||||||
is available, verbose rerun on failure)
|
is available, verbose rerun on failure)
|
||||||
- `script/lint` — run eslint and a prettier check
|
- `script/lint` — run eslint and a prettier check, by building
|
||||||
|
`Dockerfile.lint`; requires docker (see Linting below)
|
||||||
- `script/fmt` — format all files with prettier (writes)
|
- `script/fmt` — format all files with prettier (writes)
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
|
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
|
||||||
extension)
|
extension)
|
||||||
- `script/docker` — build the Docker image, tagged via `script/projectname`
|
- `script/docker` — build the test and build image, tagged via
|
||||||
- `script/cibuild` — cd to the repo root and run the image build (what CI runs;
|
`script/projectname`
|
||||||
the build runs `make fmt-check` and `make lint` in a first stage, then
|
- `script/cibuild` — cd to the repo root and build both images (what CI runs):
|
||||||
`make check` and `make build` in a second)
|
`script/lint` first, then the `Dockerfile` image, which runs `make test` and
|
||||||
|
`make build`
|
||||||
- `script/precommit` — run by the git pre-commit hook (our own extension); runs
|
- `script/precommit` — run by the git pre-commit hook (our own extension); runs
|
||||||
`script/lint` and `script/fmt-check` but deliberately not the tests, so the
|
`script/lint` and `script/fmt-check` but deliberately not the tests, so the
|
||||||
TDD red-phase commit can land
|
TDD red-phase commit can land
|
||||||
@@ -103,14 +105,34 @@ alpine. We provide:
|
|||||||
|
|
||||||
`make hooks` installs the pre-commit hook that runs `script/precommit`.
|
`make hooks` installs the pre-commit hook that runs `script/precommit`.
|
||||||
|
|
||||||
Both `script/docker` and `script/cibuild` pass
|
### Linting
|
||||||
`--build-arg CHECK_EPOCH="$(date +%s)"`. The Dockerfile refuses to build without
|
|
||||||
it. This is deliberate: on an unchanged tree Docker would otherwise serve the
|
Linting runs in a container, one way, everywhere. `script/lint` builds
|
||||||
`make check` layer from cache, so the suite would never run and the build would
|
`Dockerfile.lint`, which copies the repo into a digest-pinned node image and
|
||||||
still exit 0. A changing epoch invalidates the check and build layers on every
|
runs eslint and prettier as build steps, so a successful build is a clean lint.
|
||||||
invocation while leaving the dependency layers below them cached, and the
|
There is no host lint path: docker is required to lint, and that also works
|
||||||
missing-argument guard means a bare `docker build .` fails loudly instead of
|
where the docker daemon is remote and bind mounts are impossible.
|
||||||
quietly reporting a green it did not earn.
|
|
||||||
|
Lint happens in exactly one place, which constrains the rest of the build.
|
||||||
|
`script/check` calls `script/lint`, so `make check` cannot run inside a
|
||||||
|
container without asking for docker inside docker. The image built from
|
||||||
|
`Dockerfile` therefore runs `make test` and `make build` and does not lint;
|
||||||
|
`script/cibuild` builds `Dockerfile.lint` first and that image second, so CI
|
||||||
|
gets both verdicts.
|
||||||
|
|
||||||
|
### Build epochs
|
||||||
|
|
||||||
|
`script/lint` passes `--build-arg LINT_EPOCH="$(date +%s)"`, and `script/docker`
|
||||||
|
and `script/cibuild` pass `--build-arg CHECK_EPOCH="$(date +%s)"`. Both
|
||||||
|
Dockerfiles refuse to build without their argument. This is deliberate: on an
|
||||||
|
unchanged tree Docker would otherwise serve the linter and test layers from
|
||||||
|
cache, so nothing would run and the build would still exit 0 — a lint build over
|
||||||
|
an untouched tree returns success in well under a second, having linted nothing.
|
||||||
|
A changing epoch invalidates every layer below the guard on every invocation
|
||||||
|
while leaving the dependency layers above them cached, and the missing-argument
|
||||||
|
guard means a bare `docker build .` fails loudly instead of quietly reporting a
|
||||||
|
green it did not earn: an unset build argument is the empty string, which is a
|
||||||
|
perfectly stable cache key.
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
@@ -145,8 +167,10 @@ All work on quak is test-driven. No exceptions.
|
|||||||
3. Subsequent commits add the implementation and any refactors needed to make
|
3. Subsequent commits add the implementation and any refactors needed to make
|
||||||
the tests pass.
|
the tests pass.
|
||||||
4. A feature branch can only be merged into `main` when `make check` is green.
|
4. A feature branch can only be merged into `main` when `make check` is green.
|
||||||
`main` is always green. The Dockerfile runs `make check` and `make build`, so
|
`main` is always green. CI runs `script/cibuild`, which lints via
|
||||||
neither a red branch nor one that does not compile can pass CI.
|
`Dockerfile.lint` and then runs `make test` and `make build` in the
|
||||||
|
`Dockerfile` image, so neither a red branch nor one that does not compile can
|
||||||
|
pass CI.
|
||||||
5. Tests are the canonical API documentation for this library. Every test file
|
5. Tests are the canonical API documentation for this library. Every test file
|
||||||
is commented thoroughly enough that a reader who has never seen quak can
|
is commented thoroughly enough that a reader who has never seen quak can
|
||||||
learn how to use it from the tests alone. Comments explain why a behavior
|
learn how to use it from the tests alone. Comments explain why a behavior
|
||||||
@@ -162,9 +186,8 @@ All work on quak is test-driven. No exceptions.
|
|||||||
8. The pre-commit hook installed by `make hooks` runs `script/precommit`, which
|
8. The pre-commit hook installed by `make hooks` runs `script/precommit`, which
|
||||||
runs the lint and format checks but not the full `make check`. This is
|
runs the lint and format checks but not the full `make check`. This is
|
||||||
deliberate so the TDD red-phase commit (failing tests, no implementation yet)
|
deliberate so the TDD red-phase commit (failing tests, no implementation yet)
|
||||||
can land. The full `make check` runs as part of the image build, which is
|
can land. The suite runs as part of the image build, which is what CI
|
||||||
what CI executes via `script/cibuild`, so a red branch still cannot reach
|
executes via `script/cibuild`, so a red branch still cannot reach `main`.
|
||||||
`main`.
|
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
@@ -191,7 +214,8 @@ quak/
|
|||||||
quak.ts CLI entrypoint (commander.js)
|
quak.ts CLI entrypoint (commander.js)
|
||||||
test/ unit + integration tests (vitest)
|
test/ unit + integration tests (vitest)
|
||||||
Makefile
|
Makefile
|
||||||
Dockerfile
|
Dockerfile test suite and compile
|
||||||
|
Dockerfile.lint eslint and prettier, as build steps
|
||||||
package.json
|
package.json
|
||||||
tsconfig.json
|
tsconfig.json
|
||||||
```
|
```
|
||||||
@@ -478,9 +502,11 @@ documents:
|
|||||||
implementation. Tests are the canonical API documentation and must be
|
implementation. Tests are the canonical API documentation and must be
|
||||||
commented thoroughly. `main` is always green.
|
commented thoroughly. `main` is always green.
|
||||||
|
|
||||||
- **Required checks before every commit:** `make lint` (eslint + prettier check)
|
- **Required checks before every commit:** `make lint` (eslint + prettier check,
|
||||||
and `make fmt-check` must pass. The pre-commit hook enforces this.
|
which builds `Dockerfile.lint` and therefore needs docker) and
|
||||||
`make check` (which also runs tests) must pass before merging to `main`.
|
`make fmt-check` must pass. The pre-commit hook enforces this. `make check`
|
||||||
|
(which also runs tests) must pass before merging to `main`. Never invoke
|
||||||
|
eslint or prettier directly; linting runs in the container only.
|
||||||
|
|
||||||
- **Formatting:** prettier with 4-space indents and `proseWrap: always` for
|
- **Formatting:** prettier with 4-space indents and `proseWrap: always` for
|
||||||
markdown. Use `make fmt` to format. Use `yarn` not `npm`.
|
markdown. Use `make fmt` to format. Use `yarn` not `npm`.
|
||||||
|
|||||||
11
TODO.md
11
TODO.md
@@ -18,6 +18,17 @@ Update the README API reference section to match the current implementation.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-08-10: Moved all linting into Docker. `script/lint` builds a new root
|
||||||
|
`Dockerfile.lint`, which copies the repo into the digest-pinned node image and
|
||||||
|
runs eslint and prettier as build steps, so a successful build is a clean
|
||||||
|
lint; no host lint path remains and `yarn lint` is gone from `package.json`. A
|
||||||
|
fail-closed `LINT_EPOCH` guard stops Docker serving the linter layers from
|
||||||
|
cache, which is how a lint build returns success in under a second having
|
||||||
|
linted nothing. The lint stage inside `Dockerfile` and its `COPY --from=lint`
|
||||||
|
ordering hack are gone: that image now runs `make test` and `make build` only,
|
||||||
|
because `script/check` calls `script/lint` and running it in a container would
|
||||||
|
mean docker inside docker. `script/cibuild` builds the lint image first, then
|
||||||
|
the test and build image.
|
||||||
- 2026-08-09: Made `make docker` green and policy-conformant. Multi-stage
|
- 2026-08-09: Made `make docker` green and policy-conformant. Multi-stage
|
||||||
Dockerfile: a lint stage runs `make fmt-check` and `make lint`, and the check
|
Dockerfile: a lint stage runs `make fmt-check` and `make lint`, and the check
|
||||||
stage takes a `COPY --from=lint` dependency on it before running `make check`
|
stage takes a `COPY --from=lint` dependency on it before running `make check`
|
||||||
|
|||||||
@@ -24,7 +24,6 @@
|
|||||||
"build": "script/build",
|
"build": "script/build",
|
||||||
"quak": "node ./dist/bin/quak.js",
|
"quak": "node ./dist/bin/quak.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"lint": "eslint .",
|
|
||||||
"fmt": "prettier --write .",
|
"fmt": "prettier --write .",
|
||||||
"fmt-check": "prettier --check ."
|
"fmt-check": "prettier --check ."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/check: run all checks (test, lint, fmt-check). Our own
|
# script/check: run all checks (test, lint, fmt-check). Our own
|
||||||
# extension to scripts-to-rule-them-all. Must not modify any files.
|
# extension to scripts-to-rule-them-all. Must not modify any files.
|
||||||
|
#
|
||||||
|
# script/lint builds Dockerfile.lint, so this script requires docker and
|
||||||
|
# must never be run from inside a container: that is why the Dockerfile
|
||||||
|
# image runs script/test and script/build rather than this.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/cibuild: run the CI build. The Dockerfile runs script/check and
|
# script/cibuild: run the CI build, which is both images in a defined order.
|
||||||
# script/build, and CHECK_EPOCH differs on every invocation, so those two
|
#
|
||||||
# layers cannot be served from Docker's cache: a green build here means
|
# First script/lint, which builds Dockerfile.lint and is the one and only
|
||||||
# the checks ran now, not that a previous run was remembered. The layers
|
# place linting happens — it goes first so a lint failure is reported before
|
||||||
# below the epoch (bootstrap, yarn install) are unaffected and stay
|
# the slower suite runs. Then the Dockerfile image, which runs script/test
|
||||||
# cached. A build that omits the argument fails by design.
|
# and script/build. CHECK_EPOCH and LINT_EPOCH differ on every invocation, so
|
||||||
|
# neither the linters nor the suite can be served from Docker's cache: a
|
||||||
|
# green build here means the checks ran now, not that a previous run was
|
||||||
|
# remembered. The layers below the epochs (bootstrap, yarn install) are
|
||||||
|
# unaffected and stay cached. A build that omits the arguments fails by
|
||||||
|
# design.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
"$SCRIPT_DIR/lint"
|
||||||
docker build --build-arg CHECK_EPOCH="$(date +%s)" .
|
docker build --build-arg CHECK_EPOCH="$(date +%s)" .
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
# Identical in all repos; the tag comes from script/projectname.
|
# Identical in all repos; the tag comes from script/projectname.
|
||||||
# CHECK_EPOCH is passed for the same reason script/cibuild passes it: the
|
# CHECK_EPOCH is passed for the same reason script/cibuild passes it: the
|
||||||
# Dockerfile refuses to build without it, so that no path to an image can
|
# Dockerfile refuses to build without it, so that no path to an image can
|
||||||
# quietly serve the check and build layers from cache.
|
# quietly serve the test and build layers from cache. This builds the test
|
||||||
|
# and build image only; linting is a separate image, built by script/lint.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|||||||
17
script/lint
17
script/lint
@@ -1,13 +1,24 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/lint: run the linter (eslint plus a prettier check).
|
# script/lint: run the linters. eslint and prettier are never run against
|
||||||
|
# the working tree from here: linting runs via docker only, one way,
|
||||||
|
# everywhere — script/lint builds Dockerfile.lint, which COPYs the repo into
|
||||||
|
# the pinned node image and runs the linters as build steps. That works even
|
||||||
|
# when the docker daemon is remote and bind mounts are impossible.
|
||||||
|
#
|
||||||
|
# LINT_EPOCH is passed on every invocation because no lint cache is wanted:
|
||||||
|
# on an unchanged tree Docker would otherwise serve the linter layers, having
|
||||||
|
# linted nothing, and still exit 0. Dockerfile.lint refuses to build without
|
||||||
|
# the argument, so no path to a lint result can quietly come from cache.
|
||||||
|
#
|
||||||
|
# Nothing that runs inside a container may call this script; see the header
|
||||||
|
# of Dockerfile.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
yarn run eslint .
|
docker build --build-arg LINT_EPOCH="$(date +%s)" -f Dockerfile.lint .
|
||||||
yarn run prettier --check .
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -4,8 +4,11 @@
|
|||||||
#
|
#
|
||||||
# Runs lint and fmt-check but deliberately NOT the tests, so the TDD
|
# Runs lint and fmt-check but deliberately NOT the tests, so the TDD
|
||||||
# red-phase commit (failing tests, no implementation yet) can land. CI
|
# red-phase commit (failing tests, no implementation yet) can land. CI
|
||||||
# runs make check via docker build, which catches any branch that
|
# runs script/cibuild, which builds both images and so catches any
|
||||||
# ships red.
|
# branch that ships red.
|
||||||
|
#
|
||||||
|
# script/lint is a docker build (Dockerfile.lint); docker is required to
|
||||||
|
# commit, which is the point of linting one way, everywhere.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
//
|
//
|
||||||
// Neither shows up as a build failure, so they are asserted here.
|
// Neither shows up as a build failure, so they are asserted here.
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
@@ -46,4 +46,19 @@ describe(".dockerignore", () => {
|
|||||||
it("leaves .gitignore in the build context for prettier", () => {
|
it("leaves .gitignore in the build context for prettier", () => {
|
||||||
expect(dockerignore).not.toContain(".gitignore");
|
expect(dockerignore).not.toContain(".gitignore");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Both images are built from this same context, and the lint image runs
|
||||||
|
// eslint and prettier across it. BuildKit lets a `<dockerfile>.dockerignore`
|
||||||
|
// shadow the root one for a single build; such a file would silently give
|
||||||
|
// the lint build a different, unreviewed context — and eslint's flat config
|
||||||
|
// does not ignore dot-directories, so a stray `.claude/` worktree would be
|
||||||
|
// linted.
|
||||||
|
it.each(["Dockerfile", "Dockerfile.lint"])(
|
||||||
|
"is not shadowed by a per-Dockerfile ignore file for %s",
|
||||||
|
(name) => {
|
||||||
|
expect(existsSync(join(repoRoot, `${name}.dockerignore`))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
184
test/packaging/lint-docker.test.ts
Normal file
184
test/packaging/lint-docker.test.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
// Linting runs in Docker, one way, everywhere: `script/lint` builds
|
||||||
|
// `Dockerfile.lint`, which COPYs the repo into a digest-pinned image and runs
|
||||||
|
// eslint and prettier as build steps, so a successful build IS a clean lint.
|
||||||
|
//
|
||||||
|
// Three things can quietly undo that, and none of them shows up as a build
|
||||||
|
// failure, which is why they are asserted here:
|
||||||
|
//
|
||||||
|
// 1. Recursion. `script/check` calls `script/lint`, and `script/lint` is now a
|
||||||
|
// `docker build`. Anything that runs `make check` inside a container is
|
||||||
|
// therefore asking for Docker inside Docker, and CI breaks. The image built
|
||||||
|
// from `Dockerfile` runs the suite and the compile only; lint happens once,
|
||||||
|
// in `Dockerfile.lint`.
|
||||||
|
// 2. Cache. A lint build over an unchanged tree returns success in well under a
|
||||||
|
// second having linted nothing. The `LINT_EPOCH` guard is what forces the
|
||||||
|
// linter layers to execute, and it has to fail closed: an unset build
|
||||||
|
// argument is the empty string, which is a perfectly stable cache key, so an
|
||||||
|
// invocation that omits it must be rejected rather than served a cached
|
||||||
|
// green.
|
||||||
|
// 3. A host lint path surviving alongside the container one, which would let a
|
||||||
|
// lint result come from an unpinned local toolchain.
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
|
||||||
|
|
||||||
|
const read = (name: string): string =>
|
||||||
|
readFileSync(join(repoRoot, name), "utf-8");
|
||||||
|
|
||||||
|
// The executable lines of a shell script or Dockerfile: comments carry the
|
||||||
|
// reasoning and frequently name the very commands these tests forbid, so they
|
||||||
|
// would otherwise trigger every assertion below.
|
||||||
|
const instructions = (name: string): string[] =>
|
||||||
|
read(name)
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line !== "" && !line.startsWith("#"));
|
||||||
|
|
||||||
|
const lintScript = instructions("script/lint");
|
||||||
|
const dockerfileLint = instructions("Dockerfile.lint");
|
||||||
|
const dockerfile = instructions("Dockerfile");
|
||||||
|
const cibuild = instructions("script/cibuild");
|
||||||
|
|
||||||
|
const has = (lines: string[], pattern: RegExp): boolean =>
|
||||||
|
lines.some((line) => pattern.test(line));
|
||||||
|
|
||||||
|
describe("script/lint", () => {
|
||||||
|
it("lints by building Dockerfile.lint", () => {
|
||||||
|
expect(has(lintScript, /docker build .*-f Dockerfile\.lint/)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The whole point of the ruling: no invocation of a linter against the
|
||||||
|
// working tree survives, so a lint verdict can only come from the pinned
|
||||||
|
// image.
|
||||||
|
it("runs no linter on the host", () => {
|
||||||
|
expect(has(lintScript, /eslint|prettier/)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Without a fresh epoch the build is served from cache in under a second,
|
||||||
|
// having linted nothing, and still exits 0.
|
||||||
|
it("passes a fresh LINT_EPOCH on every run", () => {
|
||||||
|
expect(
|
||||||
|
has(lintScript, /--build-arg LINT_EPOCH="\$\(date \+%s\)"/),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Dockerfile.lint", () => {
|
||||||
|
// Tag references are server-mutable, so they are remote code execution.
|
||||||
|
it("pins its base image by digest", () => {
|
||||||
|
expect(has(dockerfileLint, /^FROM \S+@sha256:[0-9a-f]{64}/)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs eslint as a build step", () => {
|
||||||
|
expect(has(dockerfileLint, /^RUN .*eslint \./)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs prettier as a build step", () => {
|
||||||
|
expect(has(dockerfileLint, /^RUN .*prettier --check \./)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// An unset ARG is the empty string, and an empty string is a perfectly
|
||||||
|
// stable cache key. Rejecting it is what stops a bare
|
||||||
|
// `docker build -f Dockerfile.lint .` from reporting a green it did not
|
||||||
|
// earn.
|
||||||
|
it("refuses to build without LINT_EPOCH", () => {
|
||||||
|
expect(has(dockerfileLint, /^ARG LINT_EPOCH$/)).toBe(true);
|
||||||
|
expect(
|
||||||
|
has(dockerfileLint, /^RUN \[ -n "\$LINT_EPOCH" \] \|\| exit 1$/),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The guard only forces execution of the layers below it, so both linters
|
||||||
|
// have to sit after it. Layer order is the mechanism, not a style choice.
|
||||||
|
it("puts both linters below the epoch guard", () => {
|
||||||
|
const guard = dockerfileLint.findIndex((line) =>
|
||||||
|
/^RUN \[ -n "\$LINT_EPOCH" \]/.test(line),
|
||||||
|
);
|
||||||
|
const linters = dockerfileLint
|
||||||
|
.map((line, index) => ({ line, index }))
|
||||||
|
.filter(({ line }) => /^RUN .*(eslint|prettier)/.test(line));
|
||||||
|
|
||||||
|
expect(linters.length).toBeGreaterThan(0);
|
||||||
|
for (const { line, index } of linters) {
|
||||||
|
expect(
|
||||||
|
index,
|
||||||
|
`${line} must run below the LINT_EPOCH guard`,
|
||||||
|
).toBeGreaterThan(guard);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dependency installation is the slow layer and has nothing to do with the
|
||||||
|
// sources, so it caches separately: manifests first, sources afterwards.
|
||||||
|
it("copies the manifests before the sources", () => {
|
||||||
|
const manifests = dockerfileLint.findIndex((line) =>
|
||||||
|
/^COPY package\.json yarn\.lock/.test(line),
|
||||||
|
);
|
||||||
|
const sources = dockerfileLint.findIndex((line) =>
|
||||||
|
/^COPY \. \.$/.test(line),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(manifests).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(sources).toBeGreaterThan(manifests);
|
||||||
|
});
|
||||||
|
|
||||||
|
// script/lint is a docker build; a lint step that shelled out to it would
|
||||||
|
// recurse.
|
||||||
|
it("does not call script/lint or make lint", () => {
|
||||||
|
expect(has(dockerfileLint, /make lint|script\/lint/)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Dockerfile", () => {
|
||||||
|
// `make check` runs script/lint, which is a docker build, so an image that
|
||||||
|
// ran it would need a Docker daemon inside the container.
|
||||||
|
it("does not run make check, make lint or script/lint", () => {
|
||||||
|
expect(
|
||||||
|
has(dockerfile, /make check|make lint|script\/(check|lint)/),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The replaced lint stage took a `COPY --from=lint` dependency to order
|
||||||
|
// itself before the check stage. Dockerfile.lint is that stage now, and
|
||||||
|
// two definitions of how to lint is one too many.
|
||||||
|
it("has no lint stage", () => {
|
||||||
|
expect(has(dockerfile, /AS lint\b|--from=lint\b/)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still runs the suite and the build under the epoch guard", () => {
|
||||||
|
expect(has(dockerfile, /^RUN make test$/)).toBe(true);
|
||||||
|
expect(has(dockerfile, /^RUN make build$/)).toBe(true);
|
||||||
|
expect(
|
||||||
|
has(dockerfile, /^RUN \[ -n "\$CHECK_EPOCH" \] \|\| exit 1$/),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("script/cibuild", () => {
|
||||||
|
// CI has to get both verdicts. Lint goes first so the fast failure is
|
||||||
|
// reported before the suite runs.
|
||||||
|
it("builds the lint image before the test and build image", () => {
|
||||||
|
const lint = cibuild.findIndex((line) => /\/lint"/.test(line));
|
||||||
|
const check = cibuild.findIndex((line) =>
|
||||||
|
/docker build .*CHECK_EPOCH/.test(line),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(lint).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(check).toBeGreaterThan(lint);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("package.json", () => {
|
||||||
|
// `yarn lint` was a second, unpinned way to get a lint verdict, from
|
||||||
|
// whatever eslint the working tree happened to have installed.
|
||||||
|
it("exposes no host lint script", () => {
|
||||||
|
const pkg = JSON.parse(read("package.json")) as {
|
||||||
|
scripts: Record<string, string>;
|
||||||
|
};
|
||||||
|
expect(pkg.scripts.lint).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user