All checks were successful
check / check (pull_request) Successful in 2m57s
script/cibuild was a bare `docker build .` with no cache control. The
Dockerfile does `COPY . .` and then `RUN make fmt-check` / `RUN make
lint` in the lint stage and `COPY . .` / `RUN make test` in the builder
stage. On an unchanged tree Docker served those RUN layers from cache,
so the checks never executed, and the build still exited 0 -- the exit
code, which is the one signal automation trusts, was wrong, and wrong
in the direction that matters: the longer a branch sits unchanged, the
more likely its "verification" is a replay, which is exactly its state
just before a merge.
The fix is an `ARG CHECK_EPOCH` declared immediately above the check
RUNs, with script/cibuild passing a fresh value on every invocation.
ARG scope is per-stage in Docker, so the lint stage and the builder
stage each declare their own; covering only one would leave half the
gate fake.
Placement is the substance of the change. The ARG sits below the
`apk add`, `COPY go.mod go.sum`, and `go mod download` layers in both
stages, so only the check layers are invalidated: earlier and every
build would be cold, later and the checks would stay cached.
The epoch is assigned to its own variable rather than substituted
inline into the --build-arg:
epoch="$(date +%s)"
docker build --build-arg CHECK_EPOCH="$epoch" .
Under `set -eu` a command substitution that fails inside an argument
does not abort the script. Inline, a failing `date` would leave
CHECK_EPOCH an empty string; an empty string is a constant; and a
constant CHECK_EPOCH is precisely the cached-check false green this
commit exists to eliminate -- so the guard would have carried a silent
path to the defect it guards against. As a bare assignment, `set -e`
aborts before any build starts.
The guarantee is conditional, and README.md and the Dockerfile now say
so instead of claiming the check layers can never be cached. They are
keyed on CHECK_EPOCH, so they re-run for any value not yet built
against this tree -- but a build that omits --build-arg gets the empty
default, and on an unchanged tree every build after the first then
replays them, executes nothing, and exits 0. That state was produced
by measurement rather than reasoned about. Issue #91 tracks the
upstream hardening that would make the missing-arg case fail loudly,
along with the expanded ARG form, a per-invocation epoch, and
script/docker.
The bare unreferenced ARG form is kept deliberately, not because it
matches upstream -- upstream has since settled on expanding the value
into the check command. A declared-but-unreferenced ARG does enter
BuildKit's cache key, which is measured on this host rather than
assumed, and upstream records that repos on the bare form need no
rework. Moving to the expanded form is hardening, tracked in #91.
The script/cibuild header comment claimed the Dockerfile runs
script/check via make check. It does not: it runs make fmt-check and
make lint in the lint stage and make test in the builder stage.
Corrected.
Measurements are recorded once, in the verification comment on PR #89:
a back-to-back script/cibuild pair on an unchanged tree, and the
counterfactual that withholds --build-arg and reproduces the original
false green on its second run. They are deliberately not restated here
or in TODO.md, so there is a single record that cannot disagree with
itself.
.golangci.yml is unchanged (sha256 021cc83f4e6f...643346bcb), as is the
lint-stage FROM line that is the single source of truth for the linter
version, script/lint's pinned-image logic, and
.gitea/workflows/check.yml, whose only step is script/cibuild.
99 lines
3.6 KiB
Docker
99 lines
3.6 KiB
Docker
# Lint stage
|
|
#
|
|
# This FROM line is the single source of truth for the linter version:
|
|
# script/lint parses the image reference out of it and runs that exact
|
|
# image, so a local `make lint` and CI use the same linter. Bump the
|
|
# linter here (tag AND digest) and nowhere else.
|
|
#
|
|
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
|
|
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 AS lint
|
|
|
|
RUN apk add --no-cache make build-base
|
|
|
|
WORKDIR /src
|
|
|
|
# Copy go mod files first for better layer caching
|
|
COPY go.mod go.sum ./
|
|
RUN go mod download
|
|
|
|
# Copy source code
|
|
COPY . .
|
|
|
|
# Run formatting check and linter.
|
|
#
|
|
# CHECK_EPOCH must stay immediately above these RUNs. These layers are
|
|
# keyed on its value, so they are cache-eligible only for a value
|
|
# already built against this same tree. script/cibuild passes a fresh
|
|
# value on every invocation, which is what makes its green mean the
|
|
# checks really executed.
|
|
#
|
|
# The guarantee is conditional on that fresh value, not absolute. A
|
|
# build that omits --build-arg -- a bare `docker build .` -- gets an
|
|
# empty CHECK_EPOCH, and an empty string is a constant: the first such
|
|
# build runs the checks, and every one after it on an unchanged tree
|
|
# replays these layers from cache, never executing a check and still
|
|
# exiting 0, a green nothing earned. Gate through script/cibuild.
|
|
# Making the missing-arg case fail loudly instead is tracked in #91.
|
|
#
|
|
# CHECK_EPOCH is deliberately not referenced by the commands below: a
|
|
# declared-but-unreferenced ARG does enter BuildKit's cache key, which
|
|
# is measured on this host rather than assumed (PR #89). Upstream
|
|
# sneak/prompts #26 prefers expanding the value into the command so
|
|
# that the miss is contractual rather than dependent on that behavior
|
|
# staying as it is; adopting that here is tracked in #91. Do not delete
|
|
# this ARG as dead code -- the gate depends on it.
|
|
#
|
|
# ARG scope is per-stage, so the builder stage declares its own.
|
|
# Everything above this line (apk, go.mod, `go mod download`) is
|
|
# deliberately outside the busted range and keeps caching.
|
|
ARG CHECK_EPOCH
|
|
RUN make fmt-check
|
|
RUN make lint
|
|
|
|
# Build stage
|
|
# golang:1.26.1-alpine, 2026-03-17
|
|
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder
|
|
|
|
# Depend on lint stage passing
|
|
COPY --from=lint /src/go.sum /dev/null
|
|
|
|
ARG VERSION=dev
|
|
|
|
# Install build dependencies for CGO (mattn/go-sqlite3) and sqlite3 CLI (tests)
|
|
RUN apk add --no-cache make build-base sqlite
|
|
|
|
WORKDIR /src
|
|
|
|
# Copy go mod files first for better layer caching
|
|
COPY go.mod go.sum ./
|
|
RUN go mod download
|
|
|
|
# Copy source code
|
|
COPY . .
|
|
|
|
# Run tests. See the CHECK_EPOCH comment in the lint stage, including
|
|
# the conditions the guarantee depends on; ARG scope is per-stage, so
|
|
# this stage needs its own declaration, and it must stay immediately
|
|
# above the check RUN.
|
|
ARG CHECK_EPOCH
|
|
RUN make test
|
|
|
|
# Build (pure Go, no CGO required since we use modernc.org/sqlite)
|
|
RUN CGO_ENABLED=0 go build -ldflags "-X 'sneak.berlin/go/vaultik/internal/globals.Version=${VERSION}' -X 'sneak.berlin/go/vaultik/internal/globals.Commit=$(git rev-parse HEAD 2>/dev/null || echo unknown)' -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate=$(git show -s --format=%cs HEAD 2>/dev/null || echo unknown)'" -o /vaultik ./cmd/vaultik
|
|
|
|
# Runtime stage
|
|
# alpine:3.21, 2026-02-25
|
|
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
|
|
|
RUN apk add --no-cache ca-certificates sqlite
|
|
|
|
# Copy binary from builder
|
|
COPY --from=builder /vaultik /usr/local/bin/vaultik
|
|
|
|
# Create non-root user
|
|
RUN adduser -D -H -s /sbin/nologin vaultik
|
|
|
|
USER vaultik
|
|
|
|
ENTRYPOINT ["/usr/local/bin/vaultik"]
|