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.
153 lines
6.1 KiB
Bash
Executable File
153 lines
6.1 KiB
Bash
Executable File
#!/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 "$@"
|