Mark superseded commits honestly instead of skipped (closes #152)
Some checks failed
check / check (push) Superseded by a newer commit; never tested

Gitea records a cancelled run as failure, and the #119 repair rewrote
that to skipped. Gitea's Combine() folds skipped into success, so a
commit nothing ever tested reported a combined green — observed on three
commits on next, including the very change a prior integration review
had failed a PR for.

Superseded commits are now marked failure with an honest description, so
never-tested no longer reads as passed and git bisect archaeology can
tell "passed", "failed" and "never ran" apart. Option 1, re-running the
superseded commit, was verified unreachable for automation on Gitea
1.25.4: no rerun endpoint, dispatches takes a ref not a SHA and lands
under a different context, and CancelPreviousJobs is unconditional.

The rewrite moves out of the workflow into script/ci-mark-superseded so
the tested artifact is the shipped one, and every failure path in it is
loud: an unparseable or empty ANCESTOR_LIMIT, an unreadable ancestor
status, and a shallow clone all abort rather than exiting 0 having
marked nothing. Each has a regression test. The status context is
derived rather than hardcoded, which also closes #147 item 2; item 1
remains open.

Independently reviewed four times. The final reviewer confirmed the
shallow-clone test is genuinely shallow — a file:// URL is load-bearing,
since git silently ignores --depth on a local path — and that deleting
the guard fails that one test out of 331 and cannot pass for the wrong
reason. They also reproduced deterministically that go test's cache
serves a stale PASS after a script-only edit, which internal/ciscript's
doc.go now records.
This commit was merged in pull request #161.
This commit is contained in:
2026-08-18 00:31:55 +02:00
parent bef9986542
commit 7702f38168
8 changed files with 750 additions and 34 deletions

152
script/ci-mark-superseded Executable file
View File

@@ -0,0 +1,152 @@
#!/bin/sh
# script/ci-mark-superseded: record an honest status on commits whose CI
# run Gitea cancelled because a newer commit landed on the same branch.
# Gitea writes `failure` / "Has been cancelled" for such a run, which
# reads as a test result on a commit nothing ever tested. Cancellation is
# unconditional server-side for push events, so the superseding run
# rewrites those statuses to `failure` with a description that says the
# commit was never tested. `skipped` cannot be used: Gitea's combined
# status folds `skipped` into `success`, so a never-tested commit would
# report green. Genuine failures and successes are never touched.
#
# Called by the Gitea Actions workflow, which supplies GITHUB_API_URL,
# GITHUB_REPOSITORY, GITHUB_SHA, GITHUB_WORKFLOW, GITHUB_JOB,
# GITHUB_EVENT_NAME and GITEA_TOKEN. ANCESTOR_LIMIT (default 20) caps how
# far back the walk looks; a value that is set but not a positive integer
# aborts rather than silently disabling the walk.
set -eu
SUPERSEDED_DESC='Superseded by a newer commit; never tested'
# Gitea builds the commit-status context as
# "<workflow name> / <job name> (<event>)", so derive it rather than
# hardcoding the result.
#
# The derivation is deliberately not byte-exact with Gitea's own rule and
# must not be "fixed" into a silent fallback. Gitea uses the job's `name:`
# (falling back to the job id) and the workflow's `name:` (falling back to
# the workflow filename), while the runner exports GITHUB_JOB as the job
# *id* and GITHUB_WORKFLOW as the parsed workflow `name:`. So giving the
# job a display `name:`, or dropping the workflow's `name:`, makes the
# derived context stop matching --- and require_own_context below then
# turns every push red with a message. That loud failure is the point
# (https://git.eeqj.de/sneak/webhooker/issues/147 item 2); guessing at a
# fallback would restore the silent no-op it replaced.
context() {
printf '%s / %s (%s)' \
"$GITHUB_WORKFLOW" "$GITHUB_JOB" "$GITHUB_EVENT_NAME"
}
# ANCESTOR_LIMIT is a documented knob, so a value that is set but
# unusable must fail loudly instead of defaulting
# (https://git.eeqj.de/sneak/webhooker/issues/80). Passing it straight to
# git would print `fatal: not an integer` into a discarded exit status
# and mark nothing.
ancestor_limit() {
# `-` and not `:-`: an explicitly empty value is set-but-unusable
# config, so it aborts like any other bad value rather than silently
# running at the default.
_limit="${ANCESTOR_LIMIT-20}"
case "$_limit" in
'' | *[!0-9]* | 0*)
echo "ANCESTOR_LIMIT must be a positive integer," \
"got '${_limit}'" >&2
return 1
;;
esac
printf '%s' "$_limit"
}
# The status Gitea created for this very job proves which context string
# it uses. If the derived one is missing, the workflow or the job was
# renamed and the match below would silently stop firing, restoring the
# false-red bug with no signal. Fail loudly instead.
require_own_context() {
if ! _body="$(curl -sf --retry 3 --retry-delay 2 --max-time 30 \
"${1}/commits/${GITHUB_SHA}/status")"; then
echo "cannot read commit statuses for ${GITHUB_SHA}" >&2
return 1
fi
_found="$(printf '%s' "$_body" | jq -r '(.statuses // [])[].context')"
if printf '%s\n' "$_found" | grep -qxF "$2"; then
return 0
fi
echo "no commit status with context '${2}' on ${GITHUB_SHA}:" >&2
echo "workflow or job renamed? contexts present:" >&2
printf '%s\n' "$_found" >&2
return 1
}
# Latest status for our context on a commit, as "state|description".
# The read is retried and bounded, and a read that still fails aborts the
# step: a laundered commit that cannot be read is not the same as one
# with nothing to do, and piping curl into jq would discard the
# difference.
status_of() {
if ! _sbody="$(curl -sf --retry 3 --retry-delay 2 --max-time 30 \
"${1}/commits/${2}/status")"; then
echo "cannot read commit statuses for ${2}" >&2
return 1
fi
printf '%s' "$_sbody" | jq -r --arg c "$3" \
'[(.statuses // [])[] | select(.context == $c)][0] // empty
| "\(.status)|\(.description)"'
}
mark_superseded() {
curl -sf -X POST "${1}/statuses/${2}" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H 'Content-Type: application/json' \
-d "$(jq -nc --arg c "$3" --arg d "$SUPERSEDED_DESC" \
'{context: $c, state: "failure", description: $d}')" \
>/dev/null
}
main() {
_api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
_ctx="$(context)"
_limit="$(ancestor_limit)"
require_own_context "$_api" "$_ctx"
# A shallow clone cannot resolve the parent, so it looks exactly like
# a root commit to rev-parse below and would exit 0 having walked
# nothing (or, at depth > 1, only the ancestors that happen to be
# present). The workflow checks out with `fetch-depth: 0`; verify
# that here rather than depend on it silently.
if [ "$(git rev-parse --is-shallow-repository)" = 'true' ]; then
echo "shallow repository: the ancestor walk needs full history" >&2
return 1
fi
# A root commit legitimately has no ancestors and is not an error.
# A SHA this repository does not have lands here too, since its
# parent is equally unresolvable, but require_own_context above has
# already aborted on the 404 for it. The walk itself carries no
# `|| true`, so a rev-list failure aborts.
if ! git rev-parse -q --verify "${GITHUB_SHA}^" >/dev/null; then
echo "no ancestor of ${GITHUB_SHA} to check"
return 0
fi
_walk="$(git rev-list --max-count="$_limit" "${GITHUB_SHA}^")"
for _sha in $_walk; do
_latest="$(status_of "$_api" "$_sha" "$_ctx")"
# A run that was cancelled, or one an earlier revision of this
# script laundered into `skipped`. Anything else stands.
case "$_latest" in
'failure|Has been cancelled' | "skipped|${SUPERSEDED_DESC}") ;;
*) continue ;;
esac
mark_superseded "$_api" "$_sha" "$_ctx"
echo "marked superseded: ${_sha}"
done
}
main "$@"