This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/dev/pr-12473-9a4d3e13a5821d2ca12f18476613afb623d454eb
in repository https://gitbox.apache.org/repos/asf/seatunnel.git

commit 0a31b1cf94445da864acd11b65f6fcc8d7f22e28
Author: Goutam Adwant <[email protected]>
AuthorDate: Mon Sep 28 15:17:55 2026 +0000

    [Fix][CI] Keep the mirrored Build check accurate for re-runs and timeouts 
(#12473)
---
 .github/workflows/update_build_status.yml | 226 ++++++++++++++++++++++++++++--
 1 file changed, 215 insertions(+), 11 deletions(-)

diff --git a/.github/workflows/update_build_status.yml 
b/.github/workflows/update_build_status.yml
index b7cd76eeeb..5c2f16399b 100644
--- a/.github/workflows/update_build_status.yml
+++ b/.github/workflows/update_build_status.yml
@@ -23,6 +23,11 @@ on:
   schedule:
   - cron: "*/15 * * * *"
 
+# Overlapping ticks must not both create a Build check run for the same re-run.
+concurrency:
+  group: update-build-status
+  cancel-in-progress: false
+
 jobs:
   update:
     name: Update build status
@@ -38,6 +43,15 @@ jobs:
           script: |
             const SEARCH_WINDOW_DAYS = 7;
             const BATCH_SIZE = 10;
+            // Annotation GitHub adds to a job stopped by timeout-minutes. If 
the wording changes,
+            // such runs are reported as `cancelled` again, as before this 
check existed.
+            const TIMEOUT_ANNOTATION = 'exceeded the maximum execution time';
+            const MAX_TIMEOUT_LOOKUPS = 10;
+            // Upper bound on Build check-run pages read per PR and tick; 
beyond it the PR is skipped.
+            const MAX_CHECK_RUN_PAGES = 10;
+            // continue-on-error jobs in backend.yml (called as `Run` by 
build_main.yml): their
+            // result does not decide the run's result.
+            const NON_BLOCKING_JOB_NAMES = ['Run / Dead links'];
 
             function getSearchSince() {
               const windowStart = new Date(Date.now() - SEARCH_WINDOW_DAYS * 
24 * 60 * 60 * 1000);
@@ -111,12 +125,14 @@ jobs:
             }
 
             function shouldSkipPatch(checkRun, workflowRun) {
+              // details_url is not compared: GitHub keeps it pointing at the 
check run itself
+              // for check runs created by GitHub Actions, so it never equals 
the fork run URL
+              // and every Build check was patched on every tick.
               const sameStatus = checkRun.status === workflowRun.status;
               const sameConclusion =
                 workflowRun.status !== 'completed' || checkRun.conclusion === 
workflowRun.conclusion;
-              const sameDetailsUrl = checkRun.details_url === 
workflowRun.details_url;
 
-              return sameStatus && sameConclusion && sameDetailsUrl;
+              return sameStatus && sameConclusion;
             }
 
             function deriveCompletedConclusion(jobs, fallbackConclusion) {
@@ -184,6 +200,86 @@ jobs:
               return jobs;
             }
 
+            // A completed Build check that was written after the workflow run 
last changed
+            // already holds the final result, so skip the job and annotation 
lookups.
+            function isCheckFinalForRun(checkRun, workflowRun) {
+              if (
+                checkRun.status !== 'completed'
+                || checkRun.conclusion === 'action_required'
+                || workflowRun.status !== 'completed'
+              ) {
+                return false;
+              }
+              const checkCompletedAt = Date.parse(checkRun.completed_at || '');
+              const runUpdatedAt = Date.parse(workflowRun.updated_at || '');
+              return !Number.isNaN(checkCompletedAt)
+                && !Number.isNaN(runUpdatedAt)
+                && checkCompletedAt >= runUpdatedAt;
+            }
+
+            function jobDurationMs(job) {
+              const startedAt = Date.parse(job.started_at || '');
+              const completedAt = Date.parse(job.completed_at || '');
+              return Number.isNaN(startedAt) || Number.isNaN(completedAt) ? 0 
: completedAt - startedAt;
+            }
+
+            function isNonBlockingJob(job) {
+              return NON_BLOCKING_JOB_NAMES.includes(job.name);
+            }
+
+            // A job that reaches its timeout-minutes ends as `cancelled`, and 
GitHub then reports
+            // the whole run as `cancelled`, even when other jobs failed. 
Report a failed job as
+            // `failure` and a timed-out job as `timed_out` so the Build check 
does not look as if
+            // the run was stopped by hand. Non-blocking jobs are left out.
+            async function refineCancelledConclusion(workflowRunParams, 
allJobs) {
+              const jobs = allJobs.filter((job) => !isNonBlockingJob(job));
+              if (
+                jobs.some(
+                  (job) => job.conclusion === 'failure' || job.conclusion === 
'startup_failure'
+                )
+              ) {
+                return 'failure';
+              }
+              // Only a non-blocking job was cancelled: report what the other 
jobs concluded.
+              // Only when every blocking job finished, as in the 
queued-parent path; an unfinished
+              // job has no conclusion yet and must not be skipped into a 
success.
+              if (
+                jobs.every((job) => job.status === 'completed')
+                && !jobs.some((job) => job.conclusion === 'cancelled')
+              ) {
+                return deriveCompletedConclusion(jobs, 'cancelled');
+              }
+
+              // A timed-out job has usually run the longest, so check those 
first. Past
+              // MAX_TIMEOUT_LOOKUPS jobs the run stays `cancelled`.
+              const cancelledJobs = jobs
+                .filter((job) => job.conclusion === 'cancelled')
+                .sort((left, right) => jobDurationMs(right) - 
jobDurationMs(left))
+                .slice(0, MAX_TIMEOUT_LOOKUPS);
+              for (const job of cancelledJobs) {
+                const annotations = (
+                  await github.request(
+                    'GET 
/repos/{owner}/{repo}/check-runs/{check_run_id}/annotations',
+                    {
+                      owner: workflowRunParams.owner,
+                      repo: workflowRunParams.repo,
+                      check_run_id: job.id,
+                      per_page: 100
+                    }
+                  )
+                ).data;
+                if (
+                  annotations.some(
+                    (annotation) => (annotation.message || 
'').includes(TIMEOUT_ANNOTATION)
+                  )
+                ) {
+                  return 'timed_out';
+                }
+              }
+
+              return 'cancelled';
+            }
+
             async function normalizeWorkflowRun(workflowRunParams, 
workflowRun) {
               const normalizedWorkflowRun = {
                 status: workflowRun.status,
@@ -192,6 +288,12 @@ jobs:
               };
 
               if (workflowRun.status !== 'queued') {
+                if (workflowRun.status === 'completed' && 
workflowRun.conclusion === 'cancelled') {
+                  normalizedWorkflowRun.conclusion = await 
refineCancelledConclusion(
+                    workflowRunParams,
+                    await listWorkflowJobs(workflowRunParams)
+                  );
+                }
                 return normalizedWorkflowRun;
               }
 
@@ -206,10 +308,17 @@ jobs:
 
               if (jobs.every((job) => job.status === 'completed')) {
                 normalizedWorkflowRun.status = 'completed';
+                const blockingJobs = jobs.filter((job) => 
!isNonBlockingJob(job));
                 normalizedWorkflowRun.conclusion = deriveCompletedConclusion(
-                  jobs,
+                  blockingJobs.length > 0 ? blockingJobs : jobs,
                   workflowRun.conclusion
                 );
+                if (normalizedWorkflowRun.conclusion === 'cancelled') {
+                  normalizedWorkflowRun.conclusion = await 
refineCancelledConclusion(
+                    workflowRunParams,
+                    jobs
+                  );
+                }
                 return normalizedWorkflowRun;
               }
 
@@ -236,19 +345,53 @@ jobs:
                 const checkRuns = await github.request(
                   'GET /repos/{owner}/{repo}/commits/{ref}/check-runs',
                   {
+                    // The default filter returns one check run per name. List 
all Build check
+                    // runs so the one created for a re-run (see below) is 
always seen.
+                    check_name: 'Build',
+                    filter: 'all',
                     owner: context.repo.owner,
                     repo: context.repo.repo,
-                    ref: pr.head.sha
+                    ref: pr.head.sha,
+                    per_page: 100
                   }
                 );
 
-                const buildCheckRuns = checkRuns.data.check_runs.filter(
-                  (checkRun) => checkRun.name === 'Build'
-                );
-                const buildCheckRun =
-                  buildCheckRuns.find(
-                    (checkRun) => checkRun.conclusion !== 'action_required'
-                  ) || buildCheckRuns[0];
+                const listedCheckRuns = [...checkRuns.data.check_runs];
+                for (
+                  let page = 2;
+                  page <= MAX_CHECK_RUN_PAGES && listedCheckRuns.length < 
checkRuns.data.total_count;
+                  page += 1
+                ) {
+                  const pageCheckRuns = (
+                    await github.request('GET 
/repos/{owner}/{repo}/commits/{ref}/check-runs', {
+                      check_name: 'Build',
+                      filter: 'all',
+                      owner: context.repo.owner,
+                      repo: context.repo.repo,
+                      ref: pr.head.sha,
+                      per_page: 100,
+                      page
+                    })
+                  ).data.check_runs || [];
+                  if (pageCheckRuns.length === 0) {
+                    break;
+                  }
+                  listedCheckRuns.push(...pageCheckRuns);
+                }
+                // Without the newest check a new one would be created on 
every tick.
+                if (listedCheckRuns.length < checkRuns.data.total_count) {
+                  console.log(
+                    `  Skip PR #${pr.number}: listed ${listedCheckRuns.length} 
of ${checkRuns.data.total_count} Build check runs`
+                  );
+                  return;
+                }
+
+                // Newest first: a rerun of the fork run adds a new Build 
check run (see below).
+                // The newest one is the one the PR shows.
+                const buildCheckRuns = listedCheckRuns
+                  .filter((checkRun) => checkRun.name === 'Build')
+                  .sort((left, right) => right.id - left.id);
+                const buildCheckRun = buildCheckRuns[0];
 
                 if (!buildCheckRun) {
                   console.log(`  Skip PR #${pr.number}: no eligible Build 
check run`);
@@ -290,6 +433,13 @@ jobs:
                   }
                 }
 
+                if (isCheckFinalForRun(buildCheckRun, workflowRun)) {
+                  console.log(
+                    `  Skip PR #${pr.number}: Build check run 
${buildCheckRun.id} is already final for workflow run 
${workflowRunParams.run_id}`
+                  );
+                  return;
+                }
+
                 let normalizedWorkflowRun;
                 try {
                   normalizedWorkflowRun = await normalizeWorkflowRun(
@@ -301,6 +451,59 @@ jobs:
                   return;
                 }
 
+                const runStartedAt = Date.parse(workflowRun.run_started_at || 
'');
+                const hasCheckForCurrentAttempt =
+                  buildCheckRun.conclusion !== 'action_required'
+                  && !Number.isNaN(runStartedAt)
+                  && Date.parse(buildCheckRun.started_at || '') >= 
runStartedAt;
+                // A re-run that started and finished between two ticks (or a 
detection-failed
+                // check from before the run started): the check was completed 
before the current
+                // attempt started.
+                const isCheckFromEarlierAttempt =
+                  Date.parse(buildCheckRun.completed_at || '') < runStartedAt;
+                if (
+                  buildCheckRun.status === 'completed'
+                  && (normalizedWorkflowRun.status !== 'completed' || 
isCheckFromEarlierAttempt)
+                  && !hasCheckForCurrentAttempt
+                ) {
+                  // The fork run was re-run. GitHub accepts a PATCH that 
moves a completed check
+                  // run back to in_progress but keeps it completed, so the PR 
kept showing the
+                  // previous attempt's result until the re-run finished. 
Start a new Build check
+                  // run for the same workflow run instead; later ticks update 
the newest one.
+                  // started_at is the attempt's start, which 
hasCheckForCurrentAttempt relies on.
+                  console.log(
+                    `  Create Build check run for PR #${pr.number}: check run 
${buildCheckRun.id} is completed; workflow run ${workflowRunParams.run_id} 
attempt ${workflowRun.run_attempt} is ${normalizedWorkflowRun.status}`
+                  );
+                  const createParams = {
+                    owner: context.repo.owner,
+                    repo: context.repo.repo,
+                    name: 'Build',
+                    head_sha: pr.head.sha,
+                    status: ['in_progress', 
'completed'].includes(normalizedWorkflowRun.status)
+                      ? normalizedWorkflowRun.status
+                      : 'queued',
+                    output: {
+                      title: 'Test results',
+                      summary: `[See test results](${workflowRun.html_url})`,
+                      text: JSON.stringify({
+                        owner: workflowRunParams.owner,
+                        repo: workflowRunParams.repo,
+                        run_id: workflowRunParams.run_id
+                      })
+                    },
+                    details_url: normalizedWorkflowRun.details_url
+                  };
+                  if (!Number.isNaN(runStartedAt)) {
+                    createParams.started_at = workflowRun.run_started_at;
+                  }
+                  if (normalizedWorkflowRun.status === 'completed') {
+                    createParams.conclusion = normalizedWorkflowRun.conclusion;
+                    createParams.completed_at = new Date().toISOString();
+                  }
+                  await github.request('POST 
/repos/{owner}/{repo}/check-runs', createParams);
+                  return;
+                }
+
                 if (shouldSkipPatch(buildCheckRun, normalizedWorkflowRun)) {
                   console.log(
                     `  Skip PR #${pr.number}: Build check run 
${buildCheckRun.id} already matches 
${normalizedWorkflowRun.status}/${normalizedWorkflowRun.conclusion}`
@@ -326,6 +529,7 @@ jobs:
 
                 if (normalizedWorkflowRun.status === 'completed') {
                   patchParams.conclusion = normalizedWorkflowRun.conclusion;
+                  patchParams.completed_at = new Date().toISOString();
                   console.log(
                     `  Patch PR #${pr.number} check run ${buildCheckRun.id}: 
${buildCheckRun.status}/${buildCheckRun.conclusion} -> 
${normalizedWorkflowRun.status}/${normalizedWorkflowRun.conclusion}`
                   );

Reply via email to