diff --git a/.dockerignore b/.dockerignore index a01a41c..66a74a5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,6 @@ +# .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier +# that keeps the check stages from replaying a cached pass. See the lint +# stage of the Dockerfile. .git/ bin/ *.md diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 5d9892e..6f21cdd 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -11,5 +11,53 @@ jobs: steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23 + with: + # The fingerprint step below needs history to find the last commit + # that touched the Docker build context. + fetch-depth: 0 + + - name: Neutralize superseded run statuses + # Gitea cancels the in-flight run when another commit is pushed to the + # same branch and records the cancellation as `failure`, so a commit + # that was never tested reads red. The cancellation is unconditional + # server-side for push events and cannot be disabled from a workflow + # file, so the superseding run rewrites those statuses to `skipped`. + # Only the exact cancellation status is touched; a real failure is + # left alone. + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + set -eu + api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}" + ctx='check / check (push)' + for sha in $(git rev-list --max-count=20 "${GITHUB_SHA}^" || true); do + latest="$(curl -sf "${api}/commits/${sha}/status" | jq -r \ + --arg c "$ctx" \ + '[.statuses[] | select(.context == $c)][0] // empty + | "\(.status)|\(.description)"')" || continue + [ "$latest" = 'failure|Has been cancelled' ] || continue + curl -sf -X POST "${api}/statuses/${sha}" \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H 'Content-Type: application/json' \ + -d "$(jq -nc --arg c "$ctx" '{ + context: $c, + state: "skipped", + description: "Superseded by a newer commit; never tested" + }')" >/dev/null + echo "neutralized superseded status on ${sha}" + done + + - name: Fingerprint the build context + # `.dockerignore` keeps docs out of the build context, so a docs-only + # commit legitimately replays the whole image from cache and stays + # cheap. Every other commit writes a new fingerprint into the context, + # which invalidates the `COPY . .` layer of both check stages: a + # commit that was never linted, formatted-checked, tested and built + # cannot report success from cache. + run: | + set -eu + fp="$(git log -1 --format=%H -- . ':!*.md' ':!LICENSE' ':!.editorconfig')" + printf '%s\n' "${fp:-$GITHUB_SHA}" > .ci-fingerprint + - name: Build Docker image (runs make check) run: script/cibuild diff --git a/.gitignore b/.gitignore index d615704..16f68ad 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,7 @@ data/ # Temporary files tmp/ -temp/ \ No newline at end of file +temp/ + +# CI cache barrier, written into the build context by the check workflow +.ci-fingerprint \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 8d62594..f43c43a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,11 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download -# Copy source code +# Copy source code. In CI the context also carries .ci-fingerprint, whose +# value changes with every commit that touches the build context (see +# .gitea/workflows/check.yml). That invalidates this layer, so the checks +# below cannot report success by replaying a cached pass. Do not add it to +# .dockerignore. COPY . . # Run formatting check and linter @@ -36,7 +40,8 @@ WORKDIR /build COPY go.mod go.sum ./ RUN go mod download -# Copy source code +# Copy source code, including the .ci-fingerprint cache barrier described in +# the lint stage above. COPY . . # Run tests and build diff --git a/README.md b/README.md index 12e1338..9b764f9 100644 --- a/README.md +++ b/README.md @@ -1109,6 +1109,34 @@ binary is statically linked and runs on Alpine. `docker build .` is the CI gate — if it passes, the code is formatted, linted, tested, and compiled. +#### CI gate honesty + +A layer cache lets `docker build .` exit 0 in seconds with the lint and +test stages replayed rather than executed, which would make a green +check meaningless. The `check` workflow therefore writes +`.ci-fingerprint` into the build context before building. Its value is +the hash of the last commit that touched the build context, so: + +- Any commit that changes code (including a squash merge whose tree + matches an already-built branch) gets a new fingerprint, invalidates + the `COPY . .` layer of both check stages, and really runs + `make fmt-check`, `make lint`, `make test`, and `make build`. A run + that reports success ran them. +- A docs-only commit leaves the fingerprint unchanged — `.dockerignore` + excludes `*.md` and `LICENSE` from the context anyway — so the image + replays from cache and costs seconds. + +The module download layer sits above `COPY . .` and stays cached either +way. + +The workflow's first step covers a second way the gate lied: Gitea +cancels an in-flight run when a newer commit lands on the same branch +and records that cancellation as a `failure` status, marking a commit +red that was never tested. Cancellation is unconditional server-side for +push events, so the superseding run rewrites the exact +`Has been cancelled` status to `skipped`. Genuine failures are never +touched. + ## TODO See [TODO.md](TODO.md).