#!/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 # " / ()", 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 "$@"