Make the CI gate execute the checks it reports on (closes #119) #138

Merged
clawbot merged 1 commits from issue-119-real-ci-gate into next 2026-08-12 13:00:52 +02:00
5 changed files with 90 additions and 3 deletions

View File

@@ -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/ .git/
bin/ bin/
*.md *.md

View File

@@ -11,5 +11,53 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23 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) - name: Build Docker image (runs make check)
run: script/cibuild run: script/cibuild

5
.gitignore vendored
View File

@@ -41,4 +41,7 @@ data/
# Temporary files # Temporary files
tmp/ tmp/
temp/ temp/
# CI cache barrier, written into the build context by the check workflow
.ci-fingerprint

View File

@@ -12,7 +12,11 @@ WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download 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 . . COPY . .
# Run formatting check and linter # Run formatting check and linter
@@ -36,7 +40,8 @@ WORKDIR /build
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# Copy source code # Copy source code, including the .ci-fingerprint cache barrier described in
# the lint stage above.
COPY . . COPY . .
# Run tests and build # Run tests and build

View File

@@ -1104,6 +1104,34 @@ binary is statically linked and runs on Alpine.
`docker build .` is the CI gate — if it passes, the code is formatted, `docker build .` is the CI gate — if it passes, the code is formatted,
linted, tested, and compiled. 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 ## TODO
See [TODO.md](TODO.md). See [TODO.md](TODO.md).