diff --git a/.github/workflows/abi-docs.yml b/.github/workflows/abi-docs.yml index 043e1685..2bc0d597 100644 --- a/.github/workflows/abi-docs.yml +++ b/.github/workflows/abi-docs.yml @@ -10,7 +10,11 @@ on: paths: - 'contracts/src/**' - 'scripts/gen-abi-docs.mjs' + - 'abis/**' + - 'scripts/check-abi-bundle.mjs' - 'docs/abi/**' + - 'abis/**' + - 'scripts/check-abi-bundle.mjs' - 'package.json' - 'pnpm-lock.yaml' - '.github/workflows/abi-docs.yml' @@ -19,6 +23,8 @@ on: - 'contracts/src/**' - 'scripts/gen-abi-docs.mjs' - 'docs/abi/**' + - 'abis/**' + - 'scripts/check-abi-bundle.mjs' - 'package.json' - 'pnpm-lock.yaml' - '.github/workflows/abi-docs.yml' @@ -52,3 +58,10 @@ jobs: - name: Verify ABI docs are up to date (pnpm gen:abi-docs:check) run: pnpm gen:abi-docs:check + + # gen:abi-docs:check only recomputes docs/abi/*.md. It never reads + # abis/*.json -- the bundle sync_to_sdk.sh copies into aastar-sdk. That is + # how abis/BLSAggregator.json drifted four functions behind the contract + # for six days with this job green (#410, #411). + - name: ABI bundles match the compiled contracts + run: node scripts/check-abi-bundle.mjs diff --git a/.github/workflows/merge-preflight.yml b/.github/workflows/merge-preflight.yml new file mode 100644 index 00000000..22f5acf9 --- /dev/null +++ b/.github/workflows/merge-preflight.yml @@ -0,0 +1,64 @@ +# ============================================================================= +# The preflight has to RUN, not merely exist. +# +# scripts/merge-preflight.sh was added as a command someone would remember to +# type — and the failure it guards is forgetting. #408 was merged by someone who +# knew the rule, minutes after reading a `gh pr view` that had already gone +# stale. A rule that is "sometimes done" is done "often forgotten". Raised by +# pr-daemon on #412. +# +# Both legs are observable at push time: the approval-vs-head comparison is why +# it re-reads the head on every push rather than trusting an earlier reading. +# NOT a required check, deliberately. Its three legs are each enforced by GitHub +# itself — required contexts for the checks, dismiss_stale_reviews for +# approval==head, reviewDecision for CHANGES_REQUESTED — and two of them this job +# cannot even verify from a GITHUB_TOKEN. Listing it as required would have made +# a green tick claim the approval legs were checked here. They are not. +# +# It runs to REPORT, and to make the script exercised rather than merely present +# (which was its original gap). The refusal lives in branch protection and in the +# strict pre-merge run of the same script. +# ============================================================================= +name: merge-preflight-report + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + pull_request_review: + types: [submitted, dismissed] + +permissions: + contents: read + pull-requests: read + checks: read + statuses: read + +jobs: + preflight-report: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # --ci, because a required check has to be able to go green. Without it + # this job can NEVER pass: a fresh PR has no approval yet, its sibling + # checks are still running while it runs, and it counted ITSELF — first as + # a pending check, then, once red, as a failing one. Verified on chain: on + # cb0af21d `preflight` was the ONLY failure among eleven runs, and its own + # log read `FAIL still running: ..., preflight, ...`. + # + # No SELF_NAME here on purpose. The run DERIVES its own check-run name from + # GITHUB_RUN_ID (a check run's details_url is .../runs//job/), so + # renaming the job or adding a `name:` cannot desynchronise it. An earlier + # version passed the name in and asserted it existed — which proves a run + # by that name exists, not that it is this one. This head carried TWO runs + # named `preflight`. + # + # --ci reports the two TRANSIENT conditions (no approval yet, siblings + # still running) without failing on them, and still fails on what is never + # transient: an approval naming a DIFFERENT sha (#408) and a check that + # actually failed (#400/#405). Re-approval re-runs this via + # pull_request_review, so the approval leg recovers without a push. + - name: Approval names this SHA, and nothing is failing + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: ./scripts/merge-preflight.sh --ci ${{ github.event.pull_request.number }} diff --git a/scripts/check-abi-bundle.mjs b/scripts/check-abi-bundle.mjs new file mode 100755 index 00000000..246d3f26 --- /dev/null +++ b/scripts/check-abi-bundle.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +// ============================================================================= +// check-abi-bundle.mjs +// +// `gen-abi-docs.mjs --check` recomputes docs/abi/*.md and nothing else. It never +// reads abis/*.json — the bundle sync_to_sdk.sh copies into aastar-sdk, i.e. the +// artifact downstream actually imports. So the file with consumers sat outside +// the gate while the human-readable docs sat inside it, and abis/BLSAggregator.json +// drifted four functions behind the contract for six days with CI green. Raised +// by pr-daemon; issue #411. +// +// Names are not enough. #400 added a field to an EXISTING getter +// (guardianSlashCases, 7 outputs -> 8) without adding or removing a function, so a +// name-level diff sees nothing. This compares the full shape: inputs, outputs, +// stateMutability. +// ============================================================================= +import { readFileSync, existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +const ROOT = process.cwd(); +const ABIS = join(ROOT, "abis"); +const OUT = join(ROOT, "out"); + +const shape = (f) => JSON.stringify({ + t: f.type, n: f.name ?? "", + i: (f.inputs ?? []).map((x) => x.type), + o: (f.outputs ?? []).map((x) => x.type), + m: f.stateMutability ?? "", +}); + +// Only first-party contracts are comparable. abis/ also carries EntryPoint, +// SimpleAccount and SimpleAccountFactory, which are account-abstraction v0.7 +// artifacts deliberately pinned to what is DEPLOYED (EntryPoint v0.7 lives at +// 0x0000000071727De22E5E9d8BAf0edAc6f37da032). Comparing those against whatever +// `out/` happens to hold reports drift that is intentional — the first version +// of this script did exactly that and flagged all three. A gate that cries wolf +// on pinned externals gets ignored, and then it is not a gate. +const FIRST_PARTY = new Set( + walkSol(join(ROOT, "contracts", "src")).map((f) => f.replace(/\.sol$/, "")) +); +function walkSol(dir) { + if (!existsSync(dir)) return []; + return readdirSync(dir, { withFileTypes: true }).flatMap((e) => + e.isDirectory() ? walkSol(join(dir, e.name)) : e.name.endsWith(".sol") ? [e.name] : [] + ); +} + +function compiledAbi(name) { + const p = join(OUT, `${name}.sol`, `${name}.json`); + return existsSync(p) ? JSON.parse(readFileSync(p, "utf8")).abi : null; +} + +let stale = 0, checked = 0, skipped = [], external = []; +for (const file of readdirSync(ABIS).filter((f) => f.endsWith(".json"))) { + const name = file.replace(/\.json$/, ""); + if (name === "abi.config") continue; + const raw = JSON.parse(readFileSync(join(ABIS, file), "utf8")); + const committed = Array.isArray(raw) ? raw : raw.abi; + const compiled = compiledAbi(name); + // No artifact means this bundle has no contract to compare against. Say so + // rather than counting it as agreement: an empty comparison and a passing one + // are the same reading otherwise. + if (!FIRST_PARTY.has(name)) { external.push(name); continue; } + // A first-party bundle whose artifact is missing is NOT a pass. The first + // version pushed it to `skipped` and exited 0, so a partial or stale `out/` + // turned every uncompared bundle into a silent agreement — the same + // fail-open this script exists to close, reproduced inside it. + if (!compiled) { console.error(`FAIL: abis/${file} — no compiled artifact at out/${name}.sol/${name}.json`); stale++; continue; } + if (!committed) { console.error(`FAIL: abis/${file} — unreadable or empty ABI`); stale++; continue; } + checked++; + const c = new Set(committed.map(shape)); + const o = new Set(compiled.map(shape)); + const missing = [...o].filter((x) => !c.has(x)); + const extra = [...c].filter((x) => !o.has(x)); + if (missing.length || extra.length) { + stale++; + console.error(`STALE: abis/${file}`); + for (const m of missing.slice(0, 6)) console.error(` only in compiled: ${m}`); + for (const e of extra.slice(0, 6)) console.error(` only in abis/ : ${e}`); + } +} +console.log(`compared ${checked} bundles against out/`); +if (skipped.length) console.log(`no artifact, NOT compared: ${skipped.join(", ")}`); +if (external.length) console.log(`external / pinned to a deployment, NOT compared: ${external.join(", ")}`); +if (checked === 0) { console.error("FAIL: nothing was compared — run `forge build` first"); process.exit(2); } +if (stale) { console.error(`\n${stale} bundle(s) stale — run scripts/extract_v3_abis.sh and commit.`); process.exit(1); } +console.log("abis/ matches the compiled contracts (full shape, not just names)"); diff --git a/scripts/merge-preflight.sh b/scripts/merge-preflight.sh new file mode 100755 index 00000000..bc887afa --- /dev/null +++ b/scripts/merge-preflight.sh @@ -0,0 +1,329 @@ +#!/usr/bin/env bash +# ============================================================================= +# merge-preflight.sh +# +# Refuses a merge that violates "the final SHA was approved AND every check is +# green". Both halves were violated on this repo in the same week: +# +# #400 #402 #404 #405 merged with a FAILING check. `mergeStateStatus` said +# UNSTABLE, which MEANS "a check failed but merging is +# not blocked" — read as "mergeable". +# #408 merged a commit nobody reviewed. The approval named +# b327086b, a background push moved the branch to +# 4b5084e7, and `gh pr merge` takes the BRANCH. +# +# EVERY LOOKUP FAILS CLOSED. The first version read `gh api` output into a +# variable and tested it for emptiness — so an auth error, a rate limit or a +# typo'd path produced "" and was indistinguishable from "nothing is failing". +# A gate whose instrument failure looks like success is the defect it exists to +# catch. Each call's exit status is now checked before its output is believed. +# +# Exit 0 = safe to merge. Anything else = do not merge. +# ============================================================================= +set -uo pipefail +# --ci downgrades the two conditions that are TRANSIENT during a PR's life: +# * no approval yet — every PR starts unapproved; review comes after CI +# * sibling checks still running — this job runs alongside them +# In --ci mode those are reported and do not fail, so the job can reach green. +# What still fails in BOTH modes is what is never transient: an approval that +# names a DIFFERENT sha (the #408 shape) and a check that actually FAILED +# (the #400/#405 shape). +# +# It also stops counting ITSELF. Run as a check-run, it is `in_progress`, so it +# read its own name as a pending check and failed on it — and once red it read +# its own failure as "a failing check" and stayed red. Verified on chain: on +# cb0af21d, `preflight` was the ONLY failure among eleven runs. +CI_MODE=0 +if [ "${1:-}" = "--ci" ]; then CI_MODE=1; shift; fi +# Defaults to the job id GitHub exports, so the common case needs no hand-kept +# string at all. Whatever it ends up as, --ci ASSERTS it below against the real +# check-run names: "configured correctly" and "impossible to misconfigure" are +# different properties, and this gate exists to insist on the second. Raised by +# pr-daemon on #412. +# The literal is the JOB ID in .github/workflows/merge-preflight.yml. Renaming the +# job there and not here desynchronises them silently: grep -vxF matches whole +# lines, so a stale value excludes nothing and the run counts ITSELF as failing or +# pending. That already happened once — the job became `preflight-report` while +# this still said `preflight`. Under Actions GITHUB_JOB supplies it and the run-id +# lookup overrides it anyway; this literal only bites the STRICT pre-merge run, +# which is the path designated as the actual gate. Raised by pr-daemon. +SELF_NAME="${SELF_NAME:-${GITHUB_JOB:-preflight-report}}" +PR="${1:?usage: merge-preflight.sh [--ci] }" +REPO="${REPO:-AAStarCommunity/SuperPaymaster}" +fail=0 + +# api +# +# Assigns into the CALLER's variable with printf -v and returns non-zero on +# failure. It must not be used as `x=$(api ...)`: command substitution runs in a +# subshell, so an `exit` inside it exits only the subshell and the caller sails on +# with an empty string. The first version did exactly that — a broken lookup +# printed FAIL to stderr and the run continued to PASS on empty values, which is +# the fail-open this gate exists to close, reproduced inside the gate. Caught by +# breaking one lookup and watching the exit code come back 1 for an unrelated +# reason instead of refusing on the lookup. +api() { + local __var="$1" __jq="$2"; shift 2 + local __out __rc + __out=$(gh api "$@" --jq "$__jq" 2>/dev/null); __rc=$? + if [ $__rc -ne 0 ]; then + echo "FAIL lookup failed (gh api $*) — refusing on an unread value, not passing" + fail=1 + return 1 + fi + printf -v "$__var" '%s' "$__out" + return 0 +} + +head=$(gh pr view "$PR" --repo "$REPO" --json headRefOid -q .headRefOid 2>/dev/null | tr -d ' \n') +[ -n "$head" ] || { echo "FAIL could not read the PR head"; exit 3; } +echo "head at this moment : $head" + +# --- 1. the approval must name THIS commit, and nothing may have superseded it - +api revs '[.[]|{s:.state,c:.commit_id,t:.submitted_at}]|tostring' \ + "repos/$REPO/pulls/$PR/reviews" --paginate || { echo "PREFLIGHT FAIL — do not merge $PR"; exit 4; } +appr=$(printf '%s' "$revs" | python3 -c ' +import json,sys +r=json.loads(sys.stdin.read().replace("][",",")) +a=[x for x in r if x["s"]=="APPROVED"] +print(a[-1]["c"] if a else "")') +last_cr=$(printf '%s' "$revs" | python3 -c ' +import json,sys +r=json.loads(sys.stdin.read().replace("][",",")) +a=[x for x in r if x["s"]=="APPROVED"] +c=[x for x in r if x["s"]=="CHANGES_REQUESTED"] +# A change request submitted AFTER the newest approval still stands. +print(c[-1]["t"] if c and (not a or c[-1]["t"] > a[-1]["t"]) else "")') + +if [ -z "$appr" ]; then + if [ "$CI_MODE" -eq 1 ]; then + echo "INFO no approval yet — transient, not failed in --ci" + else + echo "FAIL no APPROVED review found"; fail=1 + fi +elif [ "$appr" != "$head" ]; then + echo "FAIL the approval names $appr, the branch is at $head" + echo " An approval is a statement about a SHA. Merging takes a branch." + # Transient in --ci for the same reason "no approval yet" is: at PUSH time no + # approval can possibly name the new head. Failing here left a RED check run + # on every head, and a superseded failure keeps GitHub's rollup FAILURE even + # after a later run succeeds — so a required check would need a manual re-run + # on every PR. Observed: 2831c1bb had preflight failure@14:20 and success@14:22 + # and stayed BLOCKED with reviewDecision=APPROVED. + # + # This leg is NOT dropped, it is MOVED to where it can be enforced without a + # race: branch protection's dismiss_stale_reviews retracts the approval the + # moment the branch moves, which is the same property GitHub-side and without a + # check run to re-run. Strict mode (no --ci) still fails here, so the pre-merge + # command keeps the belt. + # This leg was MOVED to branch protection's dismiss_stale_reviews. Verify that + # where it CAN be verified, and say so plainly where it cannot — rather than + # inferring it from something that only correlates. + # + # Two inferences were tried and both are unsound. "Newest review is DISMISSED" + # breaks the moment a reviewer submits CHANGES_REQUESTED. "Any DISMISSED review + # exists" is worse: a human dismissing a review by hand produces an identical + # row (measured — both DISMISSED rows on this PR carry full ~3.8KB bodies, same + # as any review), and the row survives the setting being turned off afterwards. + # A historical event cannot evidence a current setting. Found by Codex. + if [ "$CI_MODE" -eq 1 ]; then + # GITHUB_TOKEN has no `administration` scope, so a job cannot read branch + # protection. Do not manufacture a substitute: report the leg, name what is + # supposed to enforce it, and state that this run did NOT confirm it. + echo " (not enforced by this job. dismiss_stale_reviews is supposed to" + echo " enforce it; a GitHub Actions token cannot read branch protection," + echo " so THIS RUN HAS NOT CONFIRMED THAT. The strict pre-merge run does.)" + else + fail=1 + # Strict mode runs with a token that can read it, so check the guarantee has + # a home instead of trusting that someone left it there. + # The PR's own base, not a hardcoded main — this script is run against PRs + # targeting release branches too, and reading the wrong branch's protection + # would report a setting that does not govern this merge. + base=$(gh pr view "$PR" --repo "$REPO" --json baseRefName -q '.baseRefName' 2>/dev/null | tr -d ' \n') + if [ -z "$base" ]; then + # Do NOT fall back to main. Substituting a guess for an unread value is the + # fail-open this script refuses everywhere else: it would report main's + # setting for a PR that may target a release branch, and the right value + # from the wrong branch reads exactly like the right answer. Found by Codex. + echo " (could not read this PR's base branch; not reporting a" + echo " dismiss_stale_reviews value that might govern a different branch)" + elif dsr=$(gh api "repos/$REPO/branches/$base/protection" \ + --jq '.required_pull_request_reviews.dismiss_stale_reviews' 2>/dev/null); then + [ "$dsr" = "true" ] \ + && echo " (dismiss_stale_reviews=true on $base, so pushes retract approvals)" \ + || echo " AND dismiss_stale_reviews=$dsr on $base — nothing enforces this at all." + else + echo " (could not read branch protection; not claiming it is configured)" + fi + fi +else + echo "OK approved SHA == head" +fi +if [ -n "$last_cr" ]; then + echo "FAIL a CHANGES_REQUESTED ($last_cr) is newer than the newest approval" + # Advisory in --ci, and this one is not a subtlety: requesting changes is NORMAL + # REVIEW, not a defect in the commit. Failing on it made a required check go red + # because someone reviewed the PR, and it STAYED red after the author pushed a + # fix, since re-approval necessarily comes later. A required check that reports + # "something is wrong with this commit" when what happened is "a human read it" + # trains people to ignore it. Found by Codex. + # + # GitHub already enforces this without a check run, verified rather than assumed: + # this PR reads reviewDecision=CHANGES_REQUESTED, mergeStateStatus=BLOCKED. So + # the property holds either way; only the reporting changes. + # + # Third leg moved out of --ci for the same reason as the other two: a condition + # that is TRANSIENT by construction cannot be a required check, because a check + # run records a moment and a required check demands a steady state. + # Do not ASSERT which review state is blocking — it varies. At d1fcfbdd this + # PR read REVIEW_REQUIRED (approval count was the blocker) and minutes later + # CHANGES_REQUESTED (the CR was). Naming one of them in a comment made the + # justification wrong half the time even though the behaviour was safe. Ask. + # Raised by pr-daemon. + if [ "$CI_MODE" -eq 1 ]; then + rd=$(gh pr view "$PR" --repo "$REPO" --json reviewDecision -q '.reviewDecision' 2>/dev/null | tr -d ' \n') + echo " (transient in --ci; GitHub reports reviewDecision=${rd:-})" + fi + [ "$CI_MODE" -eq 1 ] || fail=1 +fi + +# --- 2. no check-run and no commit STATUS may be failing ---------------------- +# These are two different APIs. check-runs covers GitHub Actions; the Status API +# covers everything else, and a red status is invisible to the first one. +api total '.total_count' "repos/$REPO/commits/$head/check-runs" \ + || { echo "PREFLIGHT FAIL — do not merge $PR"; exit 4; } +if [ "${total:-0}" -eq 0 ]; then + echo "FAIL no check runs for $head — 'all green' and 'never ran' are not the same reading"; fail=1 +else + api bad '[.check_runs[]|select(.conclusion=="failure" or .conclusion=="timed_out" or .conclusion=="cancelled" or .conclusion=="action_required")|.name]|join("\n")' \ + "repos/$REPO/commits/$head/check-runs" --paginate \ + || { echo "PREFLIGHT FAIL — do not merge $PR"; exit 4; } + api pend '[.check_runs[]|select(.status!="completed")|.name]|join("\n")' \ + "repos/$REPO/commits/$head/check-runs" --paginate \ + || { echo "PREFLIGHT FAIL — do not merge $PR"; exit 4; } + # Identify THIS run, do not merely look for its name. The previous version + # asserted that SELF_NAME appeared among the head's check-run names — which + # proves a run with that name exists, not that it is mine. This head carried + # TWO check runs named `preflight` from two pushes, so presence was already + # satisfiable by something other than the current job. Found by Codex. + # + # A check run's `details_url` is .../actions/runs//job/, so + # in Actions the name can be DERIVED from the run id rather than configured. + # Deriving it removes the misconfiguration instead of shouting about it: rename + # the job, add a `name:`, and this still resolves to whatever GitHub actually + # called it. + if [ "$CI_MODE" -eq 1 ] && [ -n "${GITHUB_RUN_ID:-}" ]; then + api mine "[.check_runs[]|select(.details_url|test(\"/runs/${GITHUB_RUN_ID}/\"))|.name]|join(\"\\n\")" \ + "repos/$REPO/commits/$head/check-runs" --paginate \ + || { echo "PREFLIGHT FAIL — do not merge $PR"; exit 4; } + n_mine=$(printf '%s\n' "$mine" | grep -c '^..*$') + if [ -z "$mine" ]; then + echo "FAIL no check run on $head belongs to GITHUB_RUN_ID=$GITHUB_RUN_ID." + echo " Cannot identify this job's own check run, so it cannot exclude" + echo " itself, so every judgement below would be self-poisoned." + fail=1 + elif [ "$n_mine" -ne 1 ]; then + # GITHUB_RUN_ID names the WORKFLOW RUN, and every job in it shares that id + # in its details_url. With one job the match is unique by accident, not by + # construction — add a second job here and this silently becomes a + # multi-line SELF_NAME that excludes nothing. Refuse rather than guess + # which of them is me. Found by Codex. + echo "FAIL GITHUB_RUN_ID=$GITHUB_RUN_ID owns $n_mine check runs on this head:" + # Quoted and line-oriented: check-run names contain spaces ("Stage 2 — + # forge test + fuzz"), and an unquoted expansion word-splits them into + # nonsense exactly when the operator most needs to read the list. + printf '%s\n' "$mine" | sed 's/^/ /' + echo " That id identifies the workflow RUN, not this JOB. Make the" + echo " exclusion job-precise before this workflow grows a second job." + fail=1 + else + SELF_NAME="$mine" + echo "OK self identified from GITHUB_RUN_ID=$GITHUB_RUN_ID -> '$SELF_NAME' (sole job in the run)" + fi + elif [ "$CI_MODE" -eq 1 ]; then + # Outside Actions there is no run id to key on. Fall back to the name, and + # say plainly that this is the weaker check — it establishes that A run by + # that name exists, not that it is this one. + api names '[.check_runs[].name]|join("\n")' \ + "repos/$REPO/commits/$head/check-runs" --paginate \ + || { echo "PREFLIGHT FAIL — do not merge $PR"; exit 4; } + if ! printf '%s\n' "$names" | grep -qxF "$SELF_NAME"; then + echo "FAIL SELF_NAME='$SELF_NAME' is not among this commit's check runs." + echo " check runs here: $(printf '%s' "$names" | paste -sd, -)" + fail=1 + else + echo "INFO no GITHUB_RUN_ID; matched SELF_NAME='$SELF_NAME' by NAME only" + echo " (weaker: proves a run by that name exists, not that it is this one)" + fi + fi + + # Strict mode has no GITHUB_RUN_ID, so SELF_NAME is a literal here. Check it + # names something real before relying on it: a stale literal silently excludes + # nothing, which is how a rename turns this gate against itself. + if [ "$CI_MODE" -ne 1 ]; then + api allnames '[.check_runs[].name]|join("\n")' \ + "repos/$REPO/commits/$head/check-runs" --paginate \ + || { echo "PREFLIGHT FAIL — do not merge $PR"; exit 4; } + if ! printf '%s\n' "$allnames" | grep -qxF "$SELF_NAME"; then + echo "FAIL SELF_NAME='$SELF_NAME' matches no check run on this head." + echo " It must equal the job id in merge-preflight.yml. A stale value" + echo " excludes nothing and this run then counts itself." + fail=1 + fi + fi + + # Drop our own run by NAME, deliberately, even though the identification just + # above is by run id. Excluding by run id would keep a SUPERSEDED failing run + # of this same job in `bad` for ever — which is the self-holding trap from two + # rounds ago wearing different clothes: red once, red always. Name-exclusion + # drops every run of this job, including the stale failures a re-push replaces. + # This is a deliberate widening immediately after a deliberate narrowing, so it + # is written down: the identification must be precise, the exclusion must not. + # Raised by pr-daemon. + bad=$(printf '%s\n' "$bad" | grep -vxF "$SELF_NAME" | grep -v '^$' | paste -sd, -) + pend=$(printf '%s\n' "$pend" | grep -vxF "$SELF_NAME" | grep -v '^$' | paste -sd, -) + [ -n "$bad" ] && { echo "FAIL failing checks: $bad"; fail=1; } + if [ -n "$pend" ]; then + if [ "$CI_MODE" -eq 1 ]; then + echo "INFO still running (transient, not failed in --ci): $pend" + else + echo "FAIL still running: $pend"; fail=1 + fi + fi + [ -z "$bad" ] && [ -z "$pend" ] && echo "OK $total check runs, none failing or pending (excluding $SELF_NAME)" +fi + +api st '.state' "repos/$REPO/commits/$head/status" \ + || { echo "PREFLIGHT FAIL — do not merge $PR"; exit 4; } +api nst '.statuses|length' "repos/$REPO/commits/$head/status" \ + || { echo "PREFLIGHT FAIL — do not merge $PR"; exit 4; } +if [ "$nst" -gt 0 ] && [ "$st" != "success" ]; then + echo "FAIL commit status is '$st' across $nst status(es) — a different API from check-runs"; fail=1 +else + echo "OK commit statuses: $nst reported, state=$st" +fi + +# "Passed" and "safe to merge" are different claims. With sibling checks still +# running, this run establishes only that nothing has failed YET — saying safe is +# the same over-claim this whole gate argues against. Raised by pr-daemon. +if [ "$fail" -ne 0 ]; then + echo "PREFLIGHT FAIL — do not merge $PR" +elif [ -n "${pend:-}" ]; then + echo "PREFLIGHT PASS (checks still running: ${pend}) — nothing has failed yet;" + echo " merge is gated by the required contexts, not by this line" +else + if [ "$CI_MODE" -eq 1 ]; then + # In --ci this run has DOWNGRADED legs that GitHub enforces instead, so it + # cannot speak for mergeability — reviewDecision and the required contexts + # do. Saying "safe to merge" here would be the same over-claim the whole + # gate argues against, one line from the end. + echo "REPORT ONLY — nothing this job can see has failed at $head." + echo " This is NOT a merge gate: the approval legs above are" + echo " enforced by dismiss_stale_reviews and reviewDecision and" + echo " are NOT verified here. Run without --ci before merging." + else + echo "PREFLIGHT PASS — safe to merge $PR at $head" + fi +fi +exit "$fail"