From 79fb992c49f7de0f96afdeca049859e06e795674 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Sun, 27 Sep 2026 20:23:07 +0900 Subject: [PATCH] fix(ci): gate the identical-tree fallback on the dedup signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge review of #403 found a P1: if ci.yml's decision job fell back to a full main run (e.g. after a transient API failure) and any main job then failed, the unconditional identical-tree fallback in release.yml verify-ci (and the equivalent in release-readiness.yml Gate ci-matrix) still accepted the earlier green PR-head checks because the trees were identical — a release could proceed despite the tagged commit's own failed CI. The fallback now runs only when every one of the five gate check runs on the evaluated commit is absent or completed/skipped (neutral counts as skipped) — the observed dedup signature (merge commit 4d23291c, run 36315089713: lint and pdf-parity completed/skipped, the three per-OS test names absent). Any real own result — a failure, a cancellation, a pending or in-progress run, or a partial success — disables the fallback and restores the pre-#403 behavior: release.yml fails with its original message, and readiness keeps its existing fail/pending evidence text byte-identical. When the dedup signature holds and the fallback is attempted but the PR head is not green or the trees differ, the #403 message naming the missing identical-tree green PR head still applies. Refs #402 --- .github/workflows/release-readiness.yml | 43 +++++++++++++++++++++---- .github/workflows/release.yml | 42 ++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release-readiness.yml b/.github/workflows/release-readiness.yml index b05b8292..79416f59 100644 --- a/.github/workflows/release-readiness.yml +++ b/.github/workflows/release-readiness.yml @@ -833,8 +833,10 @@ jobs: # `pending` and fails the job; it is never a pass. One exception: ci.yml deduplicates a # main push whose tree a merged PR head already verified, so such a commit has no own # check runs — the identical-tree fallback below accepts the PR head's green runs, with - # the evidence naming the PR head it relied on. Controller step: it queries the API, it - # runs nothing from the evaluated ref. + # the evidence naming the PR head it relied on. The fallback applies ONLY when every + # gate job on the evaluated commit is absent or skipped (the dedup signature): a failed, + # cancelled, succeeded-but-partial or pending own job is never overridden by earlier PR + # evidence. Controller step: it queries the API, it runs nothing from the evaluated ref. - name: Gate ci-matrix id: gate-ci-matrix if: ${{ !cancelled() && steps.trust.outputs.trusted == 'yes' }} @@ -857,6 +859,11 @@ jobs: : >"$CHECK_RUNS_FILE" status=pass problems="" + # The PR-head fallback is gated on the dedup signature: every one of the five + # names absent or completed/skipped (neutral counts as skipped). Any real own + # result — success included, when partial — disables it, so a failed or pending + # own run is never overridden by earlier PR evidence. + fallback_ok=true for name in "${expected[@]}"; do state="$(jq -r --arg n "$name" \ '[.check_runs[] | select(.name == $n)] | sort_by(.completed_at) | .[-1] | if . == null then "absent" else "\(.status)/\(.conclusion // "pending")" end' \ @@ -864,14 +871,32 @@ jobs: echo "$name: $state" printf '%s\t%s\n' "$name" "$state" >>"$CHECK_RUNS_FILE" case "$state" in - completed/success) ;; + completed/success) + fallback_ok=false + ;; + absent|completed/skipped|completed/neutral) + # The dedup signature; the fallback stays available. The outcome + # classification is unchanged from before #403: completed/skipped was + # already a fail, absent already a pending. + case "$state" in + absent) + [ "$status" = fail ] || status=pending + ;; + *) + status=fail + ;; + esac + problems="${problems:+$problems, }$name=$state" + ;; completed/*) status=fail + fallback_ok=false problems="${problems:+$problems, }$name=$state" ;; *) - # absent, queued or in_progress: no conclusion exists to cite. + # queued or in_progress: no conclusion exists to cite. [ "$status" = fail ] || status=pending + fallback_ok=false problems="${problems:+$problems, }$name=$state" ;; esac @@ -882,9 +907,13 @@ jobs: evidence="check-runs API for $READINESS_SHA: $problems" # Identical-tree fallback (same rule as release.yml verify-ci): a main commit # deduplicated by ci.yml's decision job has no own check runs; accept the merged - # PR head's green runs when the trees match. Inline, not a repository script: - # this is a controller step and must not execute code from the evaluated ref. - if pulls="$(gh api "repos/$GITHUB_REPOSITORY/commits/$READINESS_SHA/pulls?per_page=100")"; then + # PR head's green runs when the trees match. Only when the dedup signature + # holds — never over a failed, partial or pending own run. Inline, not a + # repository script: this is a controller step and must not execute code from + # the evaluated ref. + if [ "$fallback_ok" != "true" ]; then + echo "own run has real job results (failed, partial or pending); the PR-head fallback is disabled" + elif pulls="$(gh api "repos/$GITHUB_REPOSITORY/commits/$READINESS_SHA/pulls?per_page=100")"; then pr="$(jq -c --arg sha "$READINESS_SHA" \ '[.[] | select(.merged_at != null and .merge_commit_sha == $sha)] | sort_by(.number) | last // empty' \ <<<"$pulls")" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index caee3c52..f7aeaac7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,7 +38,10 @@ jobs: # duplicated ~25 runner-minutes per release; reading the check-runs API gives the same # guarantee. ci.yml may deduplicate a main push whose tree a merged PR head already # verified, leaving the commit with no own check runs — the identical-tree fallback below - # accepts that PR head's green runs. The job names below must stay in sync with ci.yml. + # accepts that PR head's green runs, but ONLY when every gate job on the tagged commit is + # absent or skipped (the dedup signature): a failed, cancelled or pending own job must + # never be overridden by earlier PR evidence. The job names below must stay in sync with + # ci.yml. verify-ci: runs-on: ubuntu-latest timeout-minutes: 10 @@ -67,12 +70,45 @@ jobs: done return "$failed" } - if check_green "$GITHUB_SHA"; then + runs="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/check-runs?filter=latest&per_page=100")" + all_green=true + fallback_ok=true + for name in "${expected[@]}"; do + state="$(jq -r --arg n "$name" \ + '[.check_runs[] | select(.name == $n)] | sort_by(.completed_at) | .[-1] | if . == null then "absent" else "\(.status)/\(.conclusion // "pending")" end' \ + <<<"$runs")" + echo "$name: $state" + case "$state" in + completed/success) + # A real own result exists. It counts toward the pass above, but the dedup + # signature is all five absent/skipped, so a partial own result also + # disqualifies the PR-head fallback. + fallback_ok=false + ;; + absent|completed/skipped|completed/neutral) + # The dedup signature: ci.yml's decision job skipped the matrix, so the + # commit legitimately has no own runs — the PR-head fallback stays available. + all_green=false + ;; + *) + # An own job failed, was cancelled, or is still pending: refuse. Falling + # back to the PR head here would let a release proceed on a failed tree. + all_green=false + fallback_ok=false + ;; + esac + done + if [ "$all_green" = "true" ]; then echo "CI evidence: own check runs" exit 0 fi + if [ "$fallback_ok" != "true" ]; then + echo "::error::Tagged commit $GITHUB_SHA has no green CI. Let the CI workflow pass on main, then re-run this workflow." + exit 1 + fi # Identical-tree fallback: a main commit deduplicated by ci.yml's decision job has - # no own check runs; accept the merged PR head's green runs when the trees match. + # no own check runs (only absent/skipped, verified above); accept the merged PR + # head's green runs when the trees match. pulls="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls?per_page=100")" pr="$(jq -c --arg sha "$GITHUB_SHA" \ '[.[] | select(.merged_at != null and .merge_commit_sha == $sha)] | sort_by(.number) | last // empty' \