diff --git a/.dockerignore b/.dockerignore index aaf647f..4beaf77 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,50 @@ +# Mirrors .gitignore, with one deliberate exception: .gitignore itself stays +# in the build context, because prettier 3 reads it as a default ignore file +# and dropping it would change what `make fmt-check` sees inside the image. + +# VCS .git + +# OS +.DS_Store +Thumbs.db + +# Editors +*.swp +*.swo +*~ +*.bak +.idea/ +.vscode/ +*.sublime-* + +# Node node_modules + +# TypeScript / build artifacts dist build +*.tsbuildinfo coverage -.DS_Store +.nyc_output/ + +# Vitest +.vitest-cache/ + +# Environment / secrets .env .env.* +*.pem +*.key + +# Compiled binary (built by make build-bin); around 100 MB +bin/quak + +# quak runtime data (in case anyone runs the CLI from inside the repo) +.quak/ + +# Local per-developer tool state, including agent worktrees. Correctness, +# not context size: a worktree copied in here has its own test/ tree, which +# vitest globs alongside the real one, so the containerised suite runs N+1 +# times over and still reports success. +.claude/ diff --git a/.gitignore b/.gitignore index 8ee1d68..7820f68 100644 --- a/.gitignore +++ b/.gitignore @@ -36,5 +36,6 @@ bin/quak # quak runtime data (in case anyone runs the CLI from inside the repo) .quak/ -# Local Claude Code settings (per-developer) +# Local per-developer tool settings and scratch state, including the +# worktrees agents check out under this directory .claude/ diff --git a/Dockerfile b/Dockerfile index 747bba4..0caabf2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,37 @@ -# node 22-alpine, 2026-02-22 -FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 - +# Lint stage — fast feedback on formatting and lint issues +# node 22.22.0 on Alpine 3.23.3 (node:22-alpine), 2026-08-09 +FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS lint WORKDIR /app COPY script/ script/ COPY package.json yarn.lock ./ RUN script/bootstrap 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 +FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS check +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 package.json yarn.lock ./ +RUN script/bootstrap +COPY . . + +# CHECK_EPOCH is a cache buster: without it Docker serves `make check` from +# 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 +# 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. +ARG CHECK_EPOCH +RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN make check + +ARG CHECK_EPOCH +RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN make build diff --git a/README.md b/README.md index bcdfe49..8df251c 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,9 @@ alpine. We provide: - `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own extension) - `script/docker` — build the Docker image, tagged via `script/projectname` - (byte-identical across repos) -- `script/cibuild` — cd to the repo root and `docker build .` (what CI runs; the - image build runs `make check` and `make build`) +- `script/cibuild` — cd to the repo root and run the image build (what CI runs; + the build runs `make fmt-check` and `make lint` in a first stage, then + `make check` and `make build` in a second) - `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 TDD red-phase commit can land @@ -103,6 +103,15 @@ alpine. We provide: `make hooks` installs the pre-commit hook that runs `script/precommit`. +Both `script/docker` and `script/cibuild` pass +`--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 +`make check` layer from cache, so the suite would never run and the build would +still exit 0. A changing epoch invalidates the check and build layers on every +invocation while leaving the dependency layers below them cached, and the +missing-argument guard means a bare `docker build .` fails loudly instead of +quietly reporting a green it did not earn. + ## Rationale Ente is one of very few photo services with a credible end-to-end encryption @@ -153,8 +162,9 @@ All work on quak is test-driven. No exceptions. 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 deliberate so the TDD red-phase commit (failing tests, no implementation yet) - can land. The full `make check` runs as part of `docker build .`, which is - what CI executes, so a red branch still cannot reach `main`. + can land. The full `make check` runs as part of the image build, which is + what CI executes via `script/cibuild`, so a red branch still cannot reach + `main`. ## Design @@ -409,7 +419,7 @@ code is non-zero if any files failed. - [x] Retry policy: no retry on 4xx, exponential backoff on 5xx and network errors - [ ] Update the API reference section below to match the current implementation -- [ ] `make docker` green +- [x] `make docker` green - [ ] Tag `v1.0.0` Future (desktop client, separate repo): diff --git a/TODO.md b/TODO.md index 7d2f8b1..5671aff 100644 --- a/TODO.md +++ b/TODO.md @@ -18,6 +18,16 @@ Update the README API reference section to match the current implementation. # Completed Steps +- 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 + stage takes a `COPY --from=lint` dependency on it before running `make check` + and `make build`. `CHECK_EPOCH` and a fail-closed guard stop Docker serving + those two layers from cache, which is what let a build report success without + running the suite. `script/projectname` says `quak`, so the image is tagged + `quak`; `script/bootstrap` updates apt lists before installing, so a Debian + base works; `.dockerignore` no longer ships the compiled binary, the caches or + agent worktrees into the build context, and keeps `.gitignore` in it for + prettier. - 2026-08-09: Fixed the TypeScript build. `rootDir` is the repo root, so `bin/` compiles alongside `src/` instead of failing with TS6059; output is `dist/src/` and `dist/bin/`, which is where `main`, `types` and `bin.quak` now @@ -56,7 +66,6 @@ Update the README API reference section to match the current implementation. # Future Steps -- Make `make docker` green. - Tag v1.0.0. - Future desktop client, separate repo: - Electron app skeleton consuming this library. diff --git a/script/bootstrap b/script/bootstrap index 3c5ce34..8b87a0c 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -19,6 +19,7 @@ YARN_VERSION="1.22.22" PKGMGR="" SUDO="" +APT_UPDATED="" detect_pkgmgr() { [ -n "$PKGMGR" ] && return 0 @@ -42,12 +43,24 @@ detect_pkgmgr() { fi } +# A fresh Debian image ships no package lists at all, so apt-get install +# fails with "E: Unable to locate package make" until they are fetched. +# Done once per run, since the lists do not go stale mid-bootstrap. +apt_update_once() { + [ -n "$APT_UPDATED" ] && return 0 + $SUDO env DEBIAN_FRONTEND=noninteractive apt-get update + APT_UPDATED="yes" +} + # pkg_install pkg_install() { detect_pkgmgr case "$PKGMGR" in nix) nix-env -iA "nixpkgs.$1" ;; - apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;; + apt) + apt_update_once + $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" + ;; brew) brew install "$3" ;; apk) apk add --no-cache "$4" ;; esac diff --git a/script/cibuild b/script/cibuild index 75cc3e6..51224da 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,13 +1,17 @@ #!/bin/sh -# script/cibuild: run the CI build. The Dockerfile runs script/check, so -# a successful build implies all checks pass. +# script/cibuild: run the CI build. The Dockerfile runs script/check and +# 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 +# the checks ran now, not that a previous run was remembered. The layers +# below the epoch (bootstrap, yarn install) are unaffected and stay +# cached. A build that omits the argument fails by design. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - docker build . + docker build --build-arg CHECK_EPOCH="$(date +%s)" . } main "$@" diff --git a/script/docker b/script/docker index 9b9ea86..0bd2a83 100755 --- a/script/docker +++ b/script/docker @@ -1,6 +1,9 @@ #!/bin/sh # script/docker: build the Docker image tagged with the project name. # Identical in all repos; the tag comes from script/projectname. +# 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 +# quietly serve the check and build layers from cache. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -8,7 +11,8 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build -t "$("$SCRIPT_DIR/projectname")" . + docker build --build-arg CHECK_EPOCH="$(date +%s)" \ + -t "$("$SCRIPT_DIR/projectname")" . } main "$@" diff --git a/script/projectname b/script/projectname index 9e49dce..579f3c0 100755 --- a/script/projectname +++ b/script/projectname @@ -6,7 +6,7 @@ set -eu main() { - echo "quack" + echo "quak" } main "$@" diff --git a/test/packaging/build-context.test.ts b/test/packaging/build-context.test.ts new file mode 100644 index 0000000..9ffa5a1 --- /dev/null +++ b/test/packaging/build-context.test.ts @@ -0,0 +1,49 @@ +// The Docker build context is load-bearing in two directions, and both +// failures are silent. +// +// Excluding too little: a worktree left under `.claude/` is copied into the +// image, vitest globs its `test/` tree as well as the real one, and the +// containerised `make check` runs the whole suite twice over while reporting +// success. A compiled `bin/quak` is ~100 MB of context nobody needs. +// +// Excluding too much: Prettier 3 reads `.gitignore` as a default ignore file, +// so dropping it from the context silently changes which files +// `make fmt-check` looks at inside the image compared to the host. +// +// Neither shows up as a build failure, so they are asserted here. +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 patterns = (name: string): string[] => + readFileSync(join(repoRoot, name), "utf-8") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line !== "" && !line.startsWith("#")); + +const dockerignore = patterns(".dockerignore"); + +describe(".dockerignore", () => { + // Everything here is either generated, enormous, or secret. `.claude/` is + // the correctness one: see the header comment and issue #25. + it.each([ + ".claude/", + ".quak/", + "bin/quak", + "node_modules", + "coverage", + "dist", + ".vitest-cache/", + ".nyc_output/", + "*.tsbuildinfo", + ])("keeps %s out of the build context", (pattern) => { + expect(dockerignore).toContain(pattern); + }); + + it("leaves .gitignore in the build context for prettier", () => { + expect(dockerignore).not.toContain(".gitignore"); + }); +}); diff --git a/test/packaging/projectname.test.ts b/test/packaging/projectname.test.ts new file mode 100644 index 0000000..b93d1b4 --- /dev/null +++ b/test/packaging/projectname.test.ts @@ -0,0 +1,30 @@ +// `script/projectname` is the single source of the project's name for every +// script that needs one — `script/docker` builds its image tag from it, which +// is the whole reason that file exists. Nothing checked that it agreed with +// `package.json`, and after the repo was renamed it did not: the script still +// said "quack", so `make docker` produced an image tagged after a name this +// project has not used since May. +// +// The script is executed rather than read, because what matters is the string +// it prints, not the source it prints it from. +import { describe, expect, it } from "vitest"; +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; + +const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); + +const pkg = JSON.parse( + readFileSync(join(repoRoot, "package.json"), "utf-8"), +) as { name: string }; + +describe("script/projectname", () => { + it("prints the name package.json declares", () => { + const printed = execFileSync(join(repoRoot, "script/projectname"), { + cwd: repoRoot, + encoding: "utf-8", + }).trim(); + expect(printed).toBe(pkg.name); + }); +});