diff --git a/.github/workflows/sync-fork-checks.yml b/.github/workflows/sync-fork-checks.yml new file mode 100644 index 0000000000..a8bbd52e97 --- /dev/null +++ b/.github/workflows/sync-fork-checks.yml @@ -0,0 +1,243 @@ +# +# 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 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: + pull_request_target: + types: + - opened + - synchronize + - reopened + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + statuses: write + +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 as commit statuses' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + 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 + 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 + + 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 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="" + + # 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) + [[ -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" \ + "Fork job in progress…" "${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 + # Guard against null conclusion (e.g. cancelled mid-queue). + [[ -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 \ + "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 jobs that finished in this last window. + sync_jobs + 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 unfinished jobs as errored." + for JOB_NAME in "${!JOB_PENDING[@]}"; do + if [[ -z "${JOB_DONE[$JOB_NAME]}" ]]; then + post_status "${JOB_NAME}" "error" \ + "Polling window expired before the fork job completed." \ + "${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 + + # 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. + # 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, .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." \ + -f target_url="${url}" >/dev/null + done