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}` );
