Make the image build multi-stage and cache-proof (closes #4)
All checks were successful
check / check (push) Successful in 23s
All checks were successful
check / check (push) Successful in 23s
The image build reported a green it had not earned. `script/cibuild` is a bare `docker build .`, and with `COPY . .` followed by `RUN make check`, an unchanged tree served that layer from cache: the suite never ran and the build still exited 0, while the script's header comment asserted the opposite. CHECK_EPOCH, passed by `script/cibuild` and `script/docker`, changes the cache key of the check and build layers on every invocation. It is guarded, because an unset ARG is the empty string and therefore a stable key: without the guard a plain `docker build .` — the command the policy names, and the one anyone debugging types — would still get the false green. A missing argument is now a hard failure rather than a silent degradation to the behaviour the epoch was added to prevent. The Dockerfile is now two stages: `fmt-check` and `lint` run first, and the check stage takes a `COPY --from=lint` dependency on them, so a formatting mistake fails the build in seconds instead of racing the suite to the finish. Both stages stay pinned to the same digest. The remaining fixes are one-liners that had made the target unusable: `script/projectname` still printed the pre-rename name, so `make docker` tagged its image after a name this project dropped in May; `script/bootstrap` installed without fetching apt's package lists, which cannot work on a Debian base; and `.dockerignore` had drifted far enough from `.gitignore` to ship a ~100 MB compiled binary and any agent worktree under `.claude/` into the build context. The second of those is a correctness problem, not a size one — vitest globs a copied worktree's tests alongside the real ones and runs the suite twice over. `.gitignore` itself stays in the context, because prettier reads it as a default ignore file and dropping it would change what `make fmt-check` sees.
This commit was merged in pull request #28.
This commit is contained in:
@@ -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
|
.git
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
*.bak
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.sublime-*
|
||||||
|
|
||||||
|
# Node
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
# TypeScript / build artifacts
|
||||||
dist
|
dist
|
||||||
build
|
build
|
||||||
|
*.tsbuildinfo
|
||||||
coverage
|
coverage
|
||||||
.DS_Store
|
.nyc_output/
|
||||||
|
|
||||||
|
# Vitest
|
||||||
|
.vitest-cache/
|
||||||
|
|
||||||
|
# Environment / secrets
|
||||||
.env
|
.env
|
||||||
.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/
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -36,5 +36,6 @@ bin/quak
|
|||||||
# quak runtime data (in case anyone runs the CLI from inside the repo)
|
# quak runtime data (in case anyone runs the CLI from inside the repo)
|
||||||
.quak/
|
.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/
|
.claude/
|
||||||
|
|||||||
32
Dockerfile
32
Dockerfile
@@ -1,11 +1,37 @@
|
|||||||
# node 22-alpine, 2026-02-22
|
# Lint stage — fast feedback on formatting and lint issues
|
||||||
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34
|
# node 22.22.0 on Alpine 3.23.3 (node:22-alpine), 2026-08-09
|
||||||
|
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS lint
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY script/ script/
|
COPY script/ script/
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
RUN script/bootstrap
|
RUN script/bootstrap
|
||||||
COPY . .
|
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
|
RUN make check
|
||||||
|
|
||||||
|
ARG CHECK_EPOCH
|
||||||
|
RUN [ -n "$CHECK_EPOCH" ] || exit 1
|
||||||
RUN make build
|
RUN make build
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -92,9 +92,9 @@ alpine. We provide:
|
|||||||
- `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 Docker image, tagged via `script/projectname`
|
||||||
(byte-identical across repos)
|
- `script/cibuild` — cd to the repo root and run the image build (what CI runs;
|
||||||
- `script/cibuild` — cd to the repo root and `docker build .` (what CI runs; the
|
the build runs `make fmt-check` and `make lint` in a first stage, then
|
||||||
image build runs `make check` and `make build`)
|
`make check` and `make build` in a second)
|
||||||
- `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,6 +103,15 @@ 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
|
||||||
|
`--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
|
## Rationale
|
||||||
|
|
||||||
Ente is one of very few photo services with a credible end-to-end encryption
|
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
|
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 `docker build .`, which is
|
can land. The full `make check` runs as part of the image build, which is
|
||||||
what CI executes, so a red branch still cannot reach `main`.
|
what CI executes via `script/cibuild`, so a red branch still cannot reach
|
||||||
|
`main`.
|
||||||
|
|
||||||
## Design
|
## 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
|
- [x] Retry policy: no retry on 4xx, exponential backoff on 5xx and network
|
||||||
errors
|
errors
|
||||||
- [ ] Update the API reference section below to match the current implementation
|
- [ ] Update the API reference section below to match the current implementation
|
||||||
- [ ] `make docker` green
|
- [x] `make docker` green
|
||||||
- [ ] Tag `v1.0.0`
|
- [ ] Tag `v1.0.0`
|
||||||
|
|
||||||
Future (desktop client, separate repo):
|
Future (desktop client, separate repo):
|
||||||
|
|||||||
11
TODO.md
11
TODO.md
@@ -18,6 +18,16 @@ Update the README API reference section to match the current implementation.
|
|||||||
|
|
||||||
# Completed Steps
|
# 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/`
|
- 2026-08-09: Fixed the TypeScript build. `rootDir` is the repo root, so `bin/`
|
||||||
compiles alongside `src/` instead of failing with TS6059; output is
|
compiles alongside `src/` instead of failing with TS6059; output is
|
||||||
`dist/src/` and `dist/bin/`, which is where `main`, `types` and `bin.quak` now
|
`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
|
# Future Steps
|
||||||
|
|
||||||
- Make `make docker` green.
|
|
||||||
- Tag v1.0.0.
|
- Tag v1.0.0.
|
||||||
- Future desktop client, separate repo:
|
- Future desktop client, separate repo:
|
||||||
- Electron app skeleton consuming this library.
|
- Electron app skeleton consuming this library.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ YARN_VERSION="1.22.22"
|
|||||||
|
|
||||||
PKGMGR=""
|
PKGMGR=""
|
||||||
SUDO=""
|
SUDO=""
|
||||||
|
APT_UPDATED=""
|
||||||
|
|
||||||
detect_pkgmgr() {
|
detect_pkgmgr() {
|
||||||
[ -n "$PKGMGR" ] && return 0
|
[ -n "$PKGMGR" ] && return 0
|
||||||
@@ -42,12 +43,24 @@ detect_pkgmgr() {
|
|||||||
fi
|
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 <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
|
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
|
||||||
pkg_install() {
|
pkg_install() {
|
||||||
detect_pkgmgr
|
detect_pkgmgr
|
||||||
case "$PKGMGR" in
|
case "$PKGMGR" in
|
||||||
nix) nix-env -iA "nixpkgs.$1" ;;
|
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" ;;
|
brew) brew install "$3" ;;
|
||||||
apk) apk add --no-cache "$4" ;;
|
apk) apk add --no-cache "$4" ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/cibuild: run the CI build. The Dockerfile runs script/check, so
|
# script/cibuild: run the CI build. The Dockerfile runs script/check and
|
||||||
# a successful build implies all checks pass.
|
# 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
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
docker build .
|
docker build --build-arg CHECK_EPOCH="$(date +%s)" .
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/docker: build the Docker image tagged with the project name.
|
# script/docker: build the Docker image tagged with the project name.
|
||||||
# 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
|
||||||
|
# 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
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
@@ -8,7 +11,8 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
|||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
docker build -t "$("$SCRIPT_DIR/projectname")" .
|
docker build --build-arg CHECK_EPOCH="$(date +%s)" \
|
||||||
|
-t "$("$SCRIPT_DIR/projectname")" .
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
echo "quack"
|
echo "quak"
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user