Make the CI gate execute the checks it reports on (closes #119) #138
@@ -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
|
||||||
|
|||||||
@@ -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
5
.gitignore
vendored
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user