From 1059f027ecb74db158e34daea4921b56be489813 Mon Sep 17 00:00:00 2001 From: Raees Khan Date: Thu, 2 Jul 2026 11:10:57 +0200 Subject: [PATCH 1/3] Poll GitHub Actions results from forked repositories as PR checks (#164) --- .github/workflows/sync-fork-checks.yml | 275 +++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 .github/workflows/sync-fork-checks.yml diff --git a/.github/workflows/sync-fork-checks.yml b/.github/workflows/sync-fork-checks.yml new file mode 100644 index 0000000000..d1ce858d73 --- /dev/null +++ b/.github/workflows/sync-fork-checks.yml @@ -0,0 +1,275 @@ +# +# Copyright (c) 2026 SAP SE. All rights reserved. +# +# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. +# +# The contents of this file are subject to the terms of either the Universal Permissive License +# v 1.0 as shown at https://oss.oracle.com/licenses/upl +# +# or the following license: +# +# Redistribution and use in source and binary forms, with or without modification, are permitted +# provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this list of conditions +# and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, this list of +# conditions and the following disclaimer in the documentation and/or other materials provided with +# the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its contributors may be used to +# endorse or promote products derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR +# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +# FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY +# WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# + +# Fires in the upstream repo context when a PR from a fork is opened or updated. +# Polls the fork repo's Actions API for the "Validation" +# run on the PR head commit, then mirrors every individual job as a native +# Check Run on the upstream PR — matching the appearance of the fork's own +# checks tab. No submit branches are created; no CI is re-run upstream. +name: 'Mirror Checks from Source Fork' + +on: + pull_request_target: + types: + - opened + - synchronize + - reopened + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + checks: write + pull-requests: read + +jobs: + mirror: + name: 'Sync Actions from Fork' + runs-on: ubuntu-24.04 + # Only act on fork PRs — same-repo PRs get CI directly from main.yml. + if: github.event.pull_request.head.repo.full_name != github.repository + steps: + - name: 'Poll fork CI and mirror per-job results' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + FORK_REPO: ${{ github.event.pull_request.head.repo.full_name }} + UPSTREAM_REPO: ${{ github.repository }} + run: | + # ── Phase 1: Wait for the fork workflow run to appear ─────────────── + # The fork may not have triggered its CI yet immediately after push. + MAX_WAIT=20 + ATTEMPT=0 + RUN_ID="" + + while [[ $ATTEMPT -lt $MAX_WAIT ]]; do + ATTEMPT=$((ATTEMPT + 1)) + echo "Waiting for workflow run on fork (attempt ${ATTEMPT}/${MAX_WAIT})..." + + RUN_ID=$(gh api \ + "repos/${FORK_REPO}/actions/runs?head_sha=${HEAD_SHA}&per_page=10" \ + --jq '.workflow_runs[] | select(.name == "Validation") | .id' \ + 2>/dev/null | head -1) + + if [[ -n "$RUN_ID" && "$RUN_ID" != "null" ]]; then + echo "Found workflow run on fork: ${RUN_ID}" + break + fi + + # Fallback for reopened PRs: no new run may exist for HEAD_SHA, + # so locate the latest pull_request run for this PR number/branch. + RUN_ID=$(gh api \ + "repos/${FORK_REPO}/actions/runs?event=pull_request&branch=${HEAD_REF}&per_page=50" \ + --jq '.workflow_runs[] + | select(.name == "Validation") + | select(any(.pull_requests[]?; .number == ('"${PR_NUMBER}"' | tonumber))) + | .id' \ + 2>/dev/null | head -1) + + if [[ -n "$RUN_ID" && "$RUN_ID" != "null" ]]; then + echo "Found workflow run on fork via PR fallback: ${RUN_ID}" + break + fi + + sleep 60 + done + + if [[ -z "$RUN_ID" || "$RUN_ID" == "null" ]]; then + echo "No workflow run found on fork within ${MAX_WAIT} minutes — giving up." + exit 1 + fi + + # ── Phase 2: Mirror each fork job as an upstream Check Run ────────── + # job name → upstream check run ID + declare -A JOB_CHECK_RUN_IDS + # job name → '1' once the check run has been finalized + declare -A JOB_DONE + # job name → fork job HTML URL + declare -A JOB_URLS + + MAX_POLL=180 + POLL=0 + OVERALL_CONCLUSION="" + + while [[ $POLL -lt $MAX_POLL ]]; do + POLL=$((POLL + 1)) + echo "Poll ${POLL}/${MAX_POLL} — syncing fork job statuses..." + + JOBS_JSON=$(gh api \ + "repos/${FORK_REPO}/actions/runs/${RUN_ID}/jobs?per_page=100" \ + 2>/dev/null) + + if [[ -z "$JOBS_JSON" ]]; then + sleep 60 + continue + fi + + JOB_COUNT=$(echo "$JOBS_JSON" | jq '.jobs | length') + + for i in $(seq 0 $((JOB_COUNT - 1))); do + JOB_NAME=$(echo "$JOBS_JSON" | jq -r ".jobs[$i].name") + JOB_STATUS=$(echo "$JOBS_JSON" | jq -r ".jobs[$i].status") + JOB_CONCLUSION=$(echo "$JOBS_JSON" | jq -r ".jobs[$i].conclusion") + JOB_URL=$(echo "$JOBS_JSON" | jq -r ".jobs[$i].html_url") + + # Create a Check Run for this job the first time we see it. + JOB_URLS[$JOB_NAME]="$JOB_URL" + if [[ -z "${JOB_CHECK_RUN_IDS[$JOB_NAME]}" ]]; then + echo " Creating check run: ${JOB_NAME}" + CR_ID=$(gh api \ + -X POST \ + "repos/${UPSTREAM_REPO}/check-runs" \ + -f name="${JOB_NAME}" \ + -f head_sha="${HEAD_SHA}" \ + -f status="in_progress" \ + -f output[title]="Waiting for fork job: ${JOB_NAME}" \ + -f output[summary]="Polling fork repository for job '${JOB_NAME}' on commit ${HEAD_SHA}. [View job](${JOB_URL})" \ + --jq '.id') + JOB_CHECK_RUN_IDS[$JOB_NAME]=$CR_ID + echo " → check run ID: ${CR_ID}" + fi + + # Finalize any job that has completed since the last poll. + if [[ "$JOB_STATUS" == "completed" && -z "${JOB_DONE[$JOB_NAME]}" ]]; then + CR_ID="${JOB_CHECK_RUN_IDS[$JOB_NAME]}" + # Guard against null conclusion (e.g. cancelled mid-queue). + [[ -z "$JOB_CONCLUSION" || "$JOB_CONCLUSION" == "null" ]] && JOB_CONCLUSION="cancelled" + + if [[ "$JOB_CONCLUSION" == "success" ]]; then + TITLE="Passed: ${JOB_NAME}" + SUMMARY="Job **${JOB_NAME}** passed on the fork. [View job](${JOB_URL})" + else + TITLE="${JOB_CONCLUSION^}: ${JOB_NAME}" + SUMMARY="Job **${JOB_NAME}** reported **${JOB_CONCLUSION}** on the fork. [View job](${JOB_URL})" + fi + + gh api \ + -X PATCH \ + "repos/${UPSTREAM_REPO}/check-runs/${CR_ID}" \ + -f status="completed" \ + -f conclusion="${JOB_CONCLUSION}" \ + -f output[title]="${TITLE}" \ + -f output[summary]="${SUMMARY}" \ + -f details_url="${JOB_URL}" + + JOB_DONE[$JOB_NAME]=1 + echo " ✓ Finalized ${JOB_NAME}: ${JOB_CONCLUSION}" + fi + done + + # Check whether the overall run has finished. + RUN_JSON=$(gh api \ + "repos/${FORK_REPO}/actions/runs/${RUN_ID}" \ + --jq '{status: .status, conclusion: .conclusion}' \ + 2>/dev/null) + + RUN_STATUS=$(echo "$RUN_JSON" | jq -r '.status') + OVERALL_CONCLUSION=$(echo "$RUN_JSON" | jq -r '.conclusion') + + if [[ "$RUN_STATUS" == "completed" ]]; then + echo "Workflow run on fork completed with conclusion: ${OVERALL_CONCLUSION}" + # Do one final job sync to catch any jobs that finished in this last window. + JOBS_JSON=$(gh api \ + "repos/${FORK_REPO}/actions/runs/${RUN_ID}/jobs?per_page=100" \ + 2>/dev/null) + JOB_COUNT=$(echo "$JOBS_JSON" | jq '.jobs | length') + for i in $(seq 0 $((JOB_COUNT - 1))); do + JOB_NAME=$(echo "$JOBS_JSON" | jq -r ".jobs[$i].name") + JOB_STATUS=$(echo "$JOBS_JSON" | jq -r ".jobs[$i].status") + JOB_CONCLUSION=$(echo "$JOBS_JSON" | jq -r ".jobs[$i].conclusion") + JOB_URL=$(echo "$JOBS_JSON" | jq -r ".jobs[$i].html_url") + if [[ -z "${JOB_CHECK_RUN_IDS[$JOB_NAME]}" ]]; then + CR_ID=$(gh api \ + -X POST \ + "repos/${UPSTREAM_REPO}/check-runs" \ + -f name="${JOB_NAME}" \ + -f head_sha="${HEAD_SHA}" \ + -f status="in_progress" \ + -f output[title]="Waiting for fork job: ${JOB_NAME}" \ + -f output[summary]="Polling fork repository for job '${JOB_NAME}' on commit ${HEAD_SHA}. [View job](${JOB_URL})" \ + --jq '.id') + JOB_CHECK_RUN_IDS[$JOB_NAME]=$CR_ID + fi + if [[ "$JOB_STATUS" == "completed" && -z "${JOB_DONE[$JOB_NAME]}" ]]; then + CR_ID="${JOB_CHECK_RUN_IDS[$JOB_NAME]}" + [[ -z "$JOB_CONCLUSION" || "$JOB_CONCLUSION" == "null" ]] && JOB_CONCLUSION="cancelled" + if [[ "$JOB_CONCLUSION" == "success" ]]; then + TITLE="Passed: ${JOB_NAME}" + SUMMARY="Job **${JOB_NAME}** passed on the fork. [View job](${JOB_URL})" + else + TITLE="${JOB_CONCLUSION^}: ${JOB_NAME}" + SUMMARY="Job **${JOB_NAME}** reported **${JOB_CONCLUSION}** on the fork. [View job](${JOB_URL})" + fi + gh api \ + -X PATCH \ + "repos/${UPSTREAM_REPO}/check-runs/${CR_ID}" \ + -f status="completed" \ + -f conclusion="${JOB_CONCLUSION}" \ + -f output[title]="${TITLE}" \ + -f output[summary]="${SUMMARY}" \ + -f details_url="${JOB_URL}" + JOB_DONE[$JOB_NAME]=1 + echo " ✓ Finalized ${JOB_NAME}: ${JOB_CONCLUSION}" + fi + done + break + fi + + sleep 60 + done + + # ── Phase 3: Handle timeout ───────────────────────────────────────── + if [[ -z "$OVERALL_CONCLUSION" || "$OVERALL_CONCLUSION" == "null" ]]; then + OVERALL_CONCLUSION="timed_out" + echo "Timed out — marking remaining open check runs." + for JOB_NAME in "${!JOB_CHECK_RUN_IDS[@]}"; do + if [[ -z "${JOB_DONE[$JOB_NAME]}" ]]; then + CR_ID="${JOB_CHECK_RUN_IDS[$JOB_NAME]}" + gh api \ + -X PATCH \ + "repos/${UPSTREAM_REPO}/check-runs/${CR_ID}" \ + -f status="completed" \ + -f conclusion="timed_out" \ + -f output[title]="Timed out: ${JOB_NAME}" \ + -f output[summary]="The polling window expired before job '${JOB_NAME}' completed. [View job](${JOB_URLS[$JOB_NAME]})" + fi + done + fi + + # Exit non-zero so the upstream PR shows a red check if CI did not pass. + if [[ "$OVERALL_CONCLUSION" != "success" ]]; then + exit 1 + fi From 0efaa4060afc6fa1ff0a6756464f5974d233233a Mon Sep 17 00:00:00 2001 From: Raees Khan Date: Mon, 6 Jul 2026 15:11:01 +0200 Subject: [PATCH 2/3] fixed reopening of PRs created from forked repo and cancelled jobs (#166) --- .github/workflows/sync-fork-checks.yml | 247 +++++++++++-------------- 1 file changed, 106 insertions(+), 141 deletions(-) diff --git a/.github/workflows/sync-fork-checks.yml b/.github/workflows/sync-fork-checks.yml index d1ce858d73..f7da373929 100644 --- a/.github/workflows/sync-fork-checks.yml +++ b/.github/workflows/sync-fork-checks.yml @@ -33,9 +33,15 @@ # Fires in the upstream repo context when a PR from a fork is opened or updated. # Polls the fork repo's Actions API for the "Validation" -# run on the PR head commit, then mirrors every individual job as a native -# Check Run on the upstream PR — matching the appearance of the fork's own +# run on the PR head commit, then mirrors every individual job as a commit +# status on the PR head SHA — matching the appearance of the fork's own # checks tab. No submit branches are created; no CI is re-run upstream. +# +# Commit statuses (rather than API-created check runs) are used deliberately: +# a status is keyed purely by SHA + context, so it reliably re-appears on the +# PR when it is closed and reopened without a new push. API-created check runs +# depend on a check-suite→PR association that GitHub does not re-establish on +# reopen, which is why they render on first open but disappear on reopen. name: 'Mirror Checks from Source Fork' on: @@ -46,12 +52,11 @@ on: - reopened concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true permissions: - checks: write - pull-requests: read + statuses: write jobs: mirror: @@ -60,15 +65,36 @@ jobs: # Only act on fork PRs — same-repo PRs get CI directly from main.yml. if: github.event.pull_request.head.repo.full_name != github.repository steps: - - name: 'Poll fork CI and mirror per-job results' + - name: 'Poll fork CI and mirror per-job results as commit statuses' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} - HEAD_REF: ${{ github.event.pull_request.head.ref }} - PR_NUMBER: ${{ github.event.pull_request.number }} FORK_REPO: ${{ github.event.pull_request.head.repo.full_name }} UPSTREAM_REPO: ${{ github.repository }} run: | + # Post (or overwrite) a commit status on the PR head SHA. Statuses are + # keyed purely by SHA + context, so — unlike API-created check runs — + # they reliably re-appear on the PR when it is closed and reopened + # without a new push. + post_status() { + local context="$1" state="$2" desc="$3" url="$4" + gh api -X POST "repos/${UPSTREAM_REPO}/statuses/${HEAD_SHA}" \ + -f state="${state}" \ + -f context="${context}" \ + -f description="${desc:0:140}" \ + -f target_url="${url}" >/dev/null + } + + # Map a fork job conclusion to a commit-status state. + # Allowed status states: error | failure | pending | success. + map_state() { + case "$1" in + success) echo "success" ;; + failure) echo "failure" ;; + *) echo "error" ;; + esac + } + # ── Phase 1: Wait for the fork workflow run to appear ─────────────── # The fork may not have triggered its CI yet immediately after push. MAX_WAIT=20 @@ -89,21 +115,6 @@ jobs: break fi - # Fallback for reopened PRs: no new run may exist for HEAD_SHA, - # so locate the latest pull_request run for this PR number/branch. - RUN_ID=$(gh api \ - "repos/${FORK_REPO}/actions/runs?event=pull_request&branch=${HEAD_REF}&per_page=50" \ - --jq '.workflow_runs[] - | select(.name == "Validation") - | select(any(.pull_requests[]?; .number == ('"${PR_NUMBER}"' | tonumber))) - | .id' \ - 2>/dev/null | head -1) - - if [[ -n "$RUN_ID" && "$RUN_ID" != "null" ]]; then - echo "Found workflow run on fork via PR fallback: ${RUN_ID}" - break - fi - sleep 60 done @@ -112,83 +123,57 @@ jobs: exit 1 fi - # ── Phase 2: Mirror each fork job as an upstream Check Run ────────── - # job name → upstream check run ID - declare -A JOB_CHECK_RUN_IDS - # job name → '1' once the check run has been finalized - declare -A JOB_DONE - # job name → fork job HTML URL - declare -A JOB_URLS + # ── Phase 2: Mirror each fork job as a commit status ─────────────── + declare -A JOB_DONE # job name → '1' once finalized + declare -A JOB_PENDING # job name → '1' once a pending status posted + declare -A JOB_URLS # job name → fork job HTML URL MAX_POLL=180 POLL=0 OVERALL_CONCLUSION="" - while [[ $POLL -lt $MAX_POLL ]]; do - POLL=$((POLL + 1)) - echo "Poll ${POLL}/${MAX_POLL} — syncing fork job statuses..." - - JOBS_JSON=$(gh api \ + # Sync the current fork job states into upstream commit statuses. + sync_jobs() { + local jobs_json job_count i job_name job_status job_conclusion job_url state + jobs_json=$(gh api \ "repos/${FORK_REPO}/actions/runs/${RUN_ID}/jobs?per_page=100" \ 2>/dev/null) - - if [[ -z "$JOBS_JSON" ]]; then - sleep 60 - continue - fi - - JOB_COUNT=$(echo "$JOBS_JSON" | jq '.jobs | length') - - for i in $(seq 0 $((JOB_COUNT - 1))); do - JOB_NAME=$(echo "$JOBS_JSON" | jq -r ".jobs[$i].name") - JOB_STATUS=$(echo "$JOBS_JSON" | jq -r ".jobs[$i].status") - JOB_CONCLUSION=$(echo "$JOBS_JSON" | jq -r ".jobs[$i].conclusion") - JOB_URL=$(echo "$JOBS_JSON" | jq -r ".jobs[$i].html_url") - - # Create a Check Run for this job the first time we see it. - JOB_URLS[$JOB_NAME]="$JOB_URL" - if [[ -z "${JOB_CHECK_RUN_IDS[$JOB_NAME]}" ]]; then - echo " Creating check run: ${JOB_NAME}" - CR_ID=$(gh api \ - -X POST \ - "repos/${UPSTREAM_REPO}/check-runs" \ - -f name="${JOB_NAME}" \ - -f head_sha="${HEAD_SHA}" \ - -f status="in_progress" \ - -f output[title]="Waiting for fork job: ${JOB_NAME}" \ - -f output[summary]="Polling fork repository for job '${JOB_NAME}' on commit ${HEAD_SHA}. [View job](${JOB_URL})" \ - --jq '.id') - JOB_CHECK_RUN_IDS[$JOB_NAME]=$CR_ID - echo " → check run ID: ${CR_ID}" + [[ -z "$jobs_json" ]] && return + + job_count=$(echo "$jobs_json" | jq '.jobs | length') + for i in $(seq 0 $((job_count - 1))); do + job_name=$(echo "$jobs_json" | jq -r ".jobs[$i].name") + job_status=$(echo "$jobs_json" | jq -r ".jobs[$i].status") + job_conclusion=$(echo "$jobs_json" | jq -r ".jobs[$i].conclusion") + job_url=$(echo "$jobs_json" | jq -r ".jobs[$i].html_url") + JOB_URLS[$job_name]="$job_url" + + # Post a pending status the first time we see a job. + if [[ -z "${JOB_PENDING[$job_name]}" && -z "${JOB_DONE[$job_name]}" ]]; then + echo " Pending status: ${job_name}" + post_status "${job_name}" "pending" \ + "Waiting for fork job to complete." "${job_url}" + JOB_PENDING[$job_name]=1 fi # Finalize any job that has completed since the last poll. - if [[ "$JOB_STATUS" == "completed" && -z "${JOB_DONE[$JOB_NAME]}" ]]; then - CR_ID="${JOB_CHECK_RUN_IDS[$JOB_NAME]}" + if [[ "$job_status" == "completed" && -z "${JOB_DONE[$job_name]}" ]]; then # Guard against null conclusion (e.g. cancelled mid-queue). - [[ -z "$JOB_CONCLUSION" || "$JOB_CONCLUSION" == "null" ]] && JOB_CONCLUSION="cancelled" - - if [[ "$JOB_CONCLUSION" == "success" ]]; then - TITLE="Passed: ${JOB_NAME}" - SUMMARY="Job **${JOB_NAME}** passed on the fork. [View job](${JOB_URL})" - else - TITLE="${JOB_CONCLUSION^}: ${JOB_NAME}" - SUMMARY="Job **${JOB_NAME}** reported **${JOB_CONCLUSION}** on the fork. [View job](${JOB_URL})" - fi - - gh api \ - -X PATCH \ - "repos/${UPSTREAM_REPO}/check-runs/${CR_ID}" \ - -f status="completed" \ - -f conclusion="${JOB_CONCLUSION}" \ - -f output[title]="${TITLE}" \ - -f output[summary]="${SUMMARY}" \ - -f details_url="${JOB_URL}" - - JOB_DONE[$JOB_NAME]=1 - echo " ✓ Finalized ${JOB_NAME}: ${JOB_CONCLUSION}" + [[ -z "$job_conclusion" || "$job_conclusion" == "null" ]] && job_conclusion="cancelled" + state=$(map_state "$job_conclusion") + echo " ✓ Finalized ${job_name}: ${job_conclusion} → ${state}" + post_status "${job_name}" "${state}" \ + "Fork job reported '${job_conclusion}'." "${job_url}" + JOB_DONE[$job_name]=1 fi done + } + + while [[ $POLL -lt $MAX_POLL ]]; do + POLL=$((POLL + 1)) + echo "Poll ${POLL}/${MAX_POLL} — syncing fork job statuses..." + + sync_jobs # Check whether the overall run has finished. RUN_JSON=$(gh api \ @@ -201,50 +186,8 @@ jobs: if [[ "$RUN_STATUS" == "completed" ]]; then echo "Workflow run on fork completed with conclusion: ${OVERALL_CONCLUSION}" - # Do one final job sync to catch any jobs that finished in this last window. - JOBS_JSON=$(gh api \ - "repos/${FORK_REPO}/actions/runs/${RUN_ID}/jobs?per_page=100" \ - 2>/dev/null) - JOB_COUNT=$(echo "$JOBS_JSON" | jq '.jobs | length') - for i in $(seq 0 $((JOB_COUNT - 1))); do - JOB_NAME=$(echo "$JOBS_JSON" | jq -r ".jobs[$i].name") - JOB_STATUS=$(echo "$JOBS_JSON" | jq -r ".jobs[$i].status") - JOB_CONCLUSION=$(echo "$JOBS_JSON" | jq -r ".jobs[$i].conclusion") - JOB_URL=$(echo "$JOBS_JSON" | jq -r ".jobs[$i].html_url") - if [[ -z "${JOB_CHECK_RUN_IDS[$JOB_NAME]}" ]]; then - CR_ID=$(gh api \ - -X POST \ - "repos/${UPSTREAM_REPO}/check-runs" \ - -f name="${JOB_NAME}" \ - -f head_sha="${HEAD_SHA}" \ - -f status="in_progress" \ - -f output[title]="Waiting for fork job: ${JOB_NAME}" \ - -f output[summary]="Polling fork repository for job '${JOB_NAME}' on commit ${HEAD_SHA}. [View job](${JOB_URL})" \ - --jq '.id') - JOB_CHECK_RUN_IDS[$JOB_NAME]=$CR_ID - fi - if [[ "$JOB_STATUS" == "completed" && -z "${JOB_DONE[$JOB_NAME]}" ]]; then - CR_ID="${JOB_CHECK_RUN_IDS[$JOB_NAME]}" - [[ -z "$JOB_CONCLUSION" || "$JOB_CONCLUSION" == "null" ]] && JOB_CONCLUSION="cancelled" - if [[ "$JOB_CONCLUSION" == "success" ]]; then - TITLE="Passed: ${JOB_NAME}" - SUMMARY="Job **${JOB_NAME}** passed on the fork. [View job](${JOB_URL})" - else - TITLE="${JOB_CONCLUSION^}: ${JOB_NAME}" - SUMMARY="Job **${JOB_NAME}** reported **${JOB_CONCLUSION}** on the fork. [View job](${JOB_URL})" - fi - gh api \ - -X PATCH \ - "repos/${UPSTREAM_REPO}/check-runs/${CR_ID}" \ - -f status="completed" \ - -f conclusion="${JOB_CONCLUSION}" \ - -f output[title]="${TITLE}" \ - -f output[summary]="${SUMMARY}" \ - -f details_url="${JOB_URL}" - JOB_DONE[$JOB_NAME]=1 - echo " ✓ Finalized ${JOB_NAME}: ${JOB_CONCLUSION}" - fi - done + # Do one final job sync to catch jobs that finished in this last window. + sync_jobs break fi @@ -254,17 +197,12 @@ jobs: # ── Phase 3: Handle timeout ───────────────────────────────────────── if [[ -z "$OVERALL_CONCLUSION" || "$OVERALL_CONCLUSION" == "null" ]]; then OVERALL_CONCLUSION="timed_out" - echo "Timed out — marking remaining open check runs." - for JOB_NAME in "${!JOB_CHECK_RUN_IDS[@]}"; do + echo "Timed out — marking unfinished jobs as errored." + for JOB_NAME in "${!JOB_PENDING[@]}"; do if [[ -z "${JOB_DONE[$JOB_NAME]}" ]]; then - CR_ID="${JOB_CHECK_RUN_IDS[$JOB_NAME]}" - gh api \ - -X PATCH \ - "repos/${UPSTREAM_REPO}/check-runs/${CR_ID}" \ - -f status="completed" \ - -f conclusion="timed_out" \ - -f output[title]="Timed out: ${JOB_NAME}" \ - -f output[summary]="The polling window expired before job '${JOB_NAME}' completed. [View job](${JOB_URLS[$JOB_NAME]})" + post_status "${JOB_NAME}" "error" \ + "Polling window expired before the fork job completed." \ + "${JOB_URLS[$JOB_NAME]}" fi done fi @@ -273,3 +211,30 @@ jobs: if [[ "$OVERALL_CONCLUSION" != "success" ]]; then exit 1 fi + + # Runs only when the workflow is cancelled — either superseded by the + # concurrency group (a new push/reopen for the same PR) or cancelled + # manually by a maintainer from the Actions UI. The poll step above is + # killed abruptly and never reaches its cleanup, so any statuses it left + # as 'pending' would otherwise block the PR forever. This step re-reads + # the current statuses on the head SHA and flips any that are still + # 'pending' to 'error'. + - name: 'Mark unfinished mirrored statuses as errored on cancellation' + if: cancelled() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + UPSTREAM_REPO: ${{ github.repository }} + run: | + echo "Run cancelled — marking any pending mirrored statuses as errored." + # The combined status endpoint returns the latest status per context. + gh api "repos/${UPSTREAM_REPO}/commits/${HEAD_SHA}/status" \ + --jq '.statuses[] | select(.state == "pending") | .context' \ + 2>/dev/null | while IFS= read -r context; do + [[ -z "$context" ]] && continue + echo " Marking cancelled: ${context}" + gh api -X POST "repos/${UPSTREAM_REPO}/statuses/${HEAD_SHA}" \ + -f state="error" \ + -f context="${context}" \ + -f description="Mirror run was cancelled before this job completed." >/dev/null + done From 30d54320c32f3b78f9cce6ea063a1d5cb8d90e1a Mon Sep 17 00:00:00 2001 From: Raees Khan Date: Tue, 7 Jul 2026 11:18:43 +0200 Subject: [PATCH 3/3] added hyperlink to the cancelled mirror jobs and update checks message (#170) --- .github/workflows/sync-fork-checks.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sync-fork-checks.yml b/.github/workflows/sync-fork-checks.yml index f7da373929..a8bbd52e97 100644 --- a/.github/workflows/sync-fork-checks.yml +++ b/.github/workflows/sync-fork-checks.yml @@ -152,7 +152,7 @@ jobs: if [[ -z "${JOB_PENDING[$job_name]}" && -z "${JOB_DONE[$job_name]}" ]]; then echo " Pending status: ${job_name}" post_status "${job_name}" "pending" \ - "Waiting for fork job to complete." "${job_url}" + "Fork job in progress…" "${job_url}" JOB_PENDING[$job_name]=1 fi @@ -228,13 +228,16 @@ jobs: run: | echo "Run cancelled — marking any pending mirrored statuses as errored." # The combined status endpoint returns the latest status per context. + # Carry over each pending status's target_url so the "Details" link + # back to the fork job is preserved on the errored status. gh api "repos/${UPSTREAM_REPO}/commits/${HEAD_SHA}/status" \ - --jq '.statuses[] | select(.state == "pending") | .context' \ - 2>/dev/null | while IFS= read -r context; do + --jq '.statuses[] | select(.state == "pending") | [.context, .target_url] | @tsv' \ + 2>/dev/null | while IFS=$'\t' read -r context url; do [[ -z "$context" ]] && continue echo " Marking cancelled: ${context}" gh api -X POST "repos/${UPSTREAM_REPO}/statuses/${HEAD_SHA}" \ -f state="error" \ -f context="${context}" \ - -f description="Mirror run was cancelled before this job completed." >/dev/null + -f description="Mirror run was cancelled before this job completed." \ + -f target_url="${url}" >/dev/null done