diff --git a/.github/scripts/verify-ci-checks.sh b/.github/scripts/verify-ci-checks.sh new file mode 100755 index 0000000..cdb7fb9 --- /dev/null +++ b/.github/scripts/verify-ci-checks.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Verify that the LATEST CI check run per check name passed on a commit. +# +# Called by auto-tag.yml's "Verify CI checks passed" step, and runnable +# directly (read-only; needs only `gh` auth) to debug any SHA: +# +# REPO=owner/name HEAD_SHA= bash .github/scripts/verify-ci-checks.sh +# +# Why "latest per (app, name)" instead of "every run on the SHA": the +# check-runs API returns runs from EVERY check suite ever started on the +# SHA. Two triggers landing on the same SHA — e.g. `gh pr create --label` +# fires `opened` and `labeled` simultaneously, and the CI workflow's +# concurrency group cancels one of the twin runs — park a cancelled or +# failed run next to the successful same-named run, and those superseded +# runs never turn green. Counting all of them blocked SynthBench v0.3.1 +# (PR #334) and SynthPanel's first v1.6.0 attempt, forcing manual tags. +# Only the newest run per (app, name) reflects the commit's real status; +# GitHub's own merge box collapses check runs the same way. +# +# CHECK_RUNS_FILTER=all additionally surfaces superseded ATTEMPTS of +# re-run check runs (the API default, `latest`, already collapses those). +# Useful only for reproducing historical states; the verdict grouping +# makes both filters converge on the same latest-run answer. + +set -euo pipefail + +: "${REPO:?REPO (owner/name) is required}" +: "${HEAD_SHA:?HEAD_SHA is required}" +FILTER="${CHECK_RUNS_FILTER:-latest}" + +echo "Verifying CI status for commit: $HEAD_SHA (filter=${FILTER})" + +# --paginate emits one JSON array per page; `jq -s 'add'` flattens them. +# The auto-tag job's own check run is excluded, as before — a prior +# failed tagging attempt on the SHA must not block the recovery re-run. +ALL_RUNS=$(gh api "repos/${REPO}/commits/${HEAD_SHA}/check-runs?filter=${FILTER}" \ + --paginate \ + --jq '[.check_runs[] | select(.name != "auto-tag") + | {name, status, conclusion, started_at, id, app: (.app.slug // "unknown")}]' \ + | jq -s 'add // []') + +TOTAL=$(echo "$ALL_RUNS" | jq 'length') +if [ "$TOTAL" -eq 0 ]; then + echo "::error::No CI check runs found for commit $HEAD_SHA — refusing to tag" + exit 1 +fi + +# Latest run per (app, name): started_at orders the runs; id breaks +# same-second ties (a later-created run always has a higher id). +RUNS=$(echo "$ALL_RUNS" | jq 'group_by([.app, .name]) | map(sort_by(.started_at, .id) | last)') + +KEPT=$(echo "$RUNS" | jq 'length') +if [ "$KEPT" -lt "$TOTAL" ]; then + echo "Ignoring $((TOTAL - KEPT)) superseded run(s) shadowed by a newer run of the same check:" + echo "$ALL_RUNS" | jq 'group_by([.app, .name]) | map(sort_by(.started_at, .id) | .[:-1]) | add' +fi + +echo "Latest check run per name:" +echo "$RUNS" | jq '.' + +INCOMPLETE=$(echo "$RUNS" | jq '[.[] | select(.status != "completed")] | length') +if [ "$INCOMPLETE" -gt 0 ]; then + echo "::error::${INCOMPLETE} check(s) still running — refusing to tag" + echo "$RUNS" | jq '[.[] | select(.status != "completed")]' + exit 1 +fi + +FAILED=$(echo "$RUNS" | jq '[.[] | select(.conclusion != "success" and .conclusion != "skipped" and .conclusion != "neutral")] | length') +if [ "$FAILED" -gt 0 ]; then + echo "::error::${FAILED} check(s) did not pass — refusing to tag" + echo "$RUNS" | jq '[.[] | select(.conclusion != "success" and .conclusion != "skipped" and .conclusion != "neutral")]' + exit 1 +fi + +echo "All CI checks passed ✓" diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml index 092339d..5d04c31 100644 --- a/.github/workflows/auto-tag.yml +++ b/.github/workflows/auto-tag.yml @@ -74,40 +74,19 @@ jobs: echo "skipped=false" >> "$GITHUB_OUTPUT" echo "Bump type: $BUMP" + # Latest run per check name only. The check-runs API returns runs from + # every check suite on the SHA, so a workflow double-triggered on one + # SHA (label events don't move the head) parks superseded red/cancelled + # runs there forever — counting them blocked the first v1.6.0 attempt. + # The logic lives in a standalone script so it can be exercised + # directly against any SHA (see the script header for usage). - name: Verify CI checks passed if: steps.check.outputs.skipped != 'true' env: GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - echo "Verifying CI status for PR head commit: $HEAD_SHA" - - RUNS=$(gh api "repos/${{ github.repository }}/commits/${HEAD_SHA}/check-runs" \ - --paginate --jq '.check_runs[] | select(.name != "auto-tag") | {name: .name, status: .status, conclusion: .conclusion}') - - if [ -z "$RUNS" ]; then - echo "::error::No CI check runs found for commit $HEAD_SHA — refusing to tag" - exit 1 - fi - - echo "Check runs:" - echo "$RUNS" | jq -s '.' - - INCOMPLETE=$(echo "$RUNS" | jq -s '[.[] | select(.status != "completed")] | length') - if [ "$INCOMPLETE" -gt 0 ]; then - echo "::error::${INCOMPLETE} check(s) still running — refusing to tag" - echo "$RUNS" | jq -s '[.[] | select(.status != "completed")]' - exit 1 - fi - - FAILED=$(echo "$RUNS" | jq -s '[.[] | select(.conclusion != "success" and .conclusion != "skipped" and .conclusion != "neutral")] | length') - if [ "$FAILED" -gt 0 ]; then - echo "::error::${FAILED} check(s) did not pass — refusing to tag" - echo "$RUNS" | jq -s '[.[] | select(.conclusion != "success" and .conclusion != "skipped" and .conclusion != "neutral")]' - exit 1 - fi - - echo "All CI checks passed ✓" + run: bash .github/scripts/verify-ci-checks.sh - name: Compute next version if: steps.check.outputs.skipped != 'true' @@ -222,9 +201,11 @@ jobs: # because render_site_markdown.py may rewrite multiple .md # files (docs/, blog/, mcp/, recommended-models/) on a single # bump; constraining to specific paths here would silently - # drop a refreshed file from the bump commit. + # drop a refreshed file from the bump commit. Root server.json + # is the second descriptor render_server_card.py rewrites — + # omitting it here left it drifted on main and bit PR #583. git add src/synth_panel/__version__.py site/index.html site/ \ - site/.well-known/mcp/server-card.json + site/.well-known/mcp/server-card.json server.json if git diff --staged --quiet; then echo "Version artifacts already at ${NEW_TAG} (pre-bumped in PR) — nothing to commit." else diff --git a/.github/workflows/semver-label.yml b/.github/workflows/semver-label.yml index 2a8d2ee..43370f8 100644 --- a/.github/workflows/semver-label.yml +++ b/.github/workflows/semver-label.yml @@ -10,24 +10,22 @@ name: Semver Label # It is wired as a required status check on main separately (branch ruleset, # managed in dataviking-infra) — this file only defines the check. # -# Two design constraints, both forced by auto-tag.yml's "Verify CI checks -# passed" step, which refuses to tag while ANY non-green check run sits on -# the merged head SHA: +# Labels are fetched fresh from the API, not read from the event payload. +# Label events don't move the head SHA, and reruns replay the original +# payload — a payload-based `opened` run that failed before the label was +# added could never be manually rerun to green. # -# 1. Labels are fetched fresh from the API, not read from the event -# payload. Label events don't move the head SHA, and reruns replay the -# original payload — a payload-based run that failed before the label -# was added could never be rerun to green, permanently poisoning the -# SHA for auto-tag. +# A failed `opened` run superseded moments later by a green `labeled` run +# is harmless: both GitHub's merge box and auto-tag.yml's verify step +# (.github/scripts/verify-ci-checks.sh) evaluate only the LATEST run per +# check name on the SHA. This workflow briefly (PR #584) carried a +# self-heal step that re-ran superseded non-green runs of itself so they +# wouldn't block auto-tag — dead weight once the verify step stopped +# counting superseded runs, so it was removed (with its actions:write +# permission). # -# 2. Once the check passes, it re-runs any superseded non-green runs of -# itself on the same SHA (e.g. the `opened` run that correctly failed -# before the label existed, moments later). Thanks to (1), those -# reruns re-fetch the labels and flip green, leaving the SHA clean. -# -# No concurrency/cancel-in-progress: a cancelled run is just another -# non-green conclusion parked on the SHA (this exact failure blocked the -# v1.6.0 recovery), and runs here take ~2 seconds anyway. +# No concurrency/cancel-in-progress: runs take ~2 seconds, and the newest +# run wins everywhere that matters anyway. on: pull_request: @@ -35,7 +33,6 @@ on: permissions: contents: read - actions: write # rerun superseded runs of this workflow (step 2) jobs: semver-label: @@ -73,23 +70,3 @@ jobs: exit 1 ;; esac - - - name: Flip superseded non-green runs of this check green - env: - GH_TOKEN: ${{ github.token }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - set -euo pipefail - RUN_IDS=$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/semver-label.yml/runs?head_sha=${HEAD_SHA}&per_page=100" \ - --jq ".workflow_runs[] | select(.status == \"completed\" and .conclusion != \"success\" and .id != ${GITHUB_RUN_ID}) | .id") - - if [ -z "$RUN_IDS" ]; then - echo "No superseded non-green runs on ${HEAD_SHA} — nothing to heal." - exit 0 - fi - - for id in $RUN_IDS; do - echo "Re-running superseded run ${id} so it re-fetches labels and goes green" - gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/${id}/rerun" \ - || echo "::warning::Could not re-run ${id} (fork PR token lacks actions:write?). Re-run it manually before merge, or Auto Semver Tag will refuse to tag this SHA." - done