This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-6959-589f601d48025ada64836bbeb3adfc183282bb5c in repository https://gitbox.apache.org/repos/asf/texera.git
commit cc1c332987a53d528c83f5fbed556a7918b43945 Author: Yicong Huang <[email protected]> AuthorDate: Mon Jul 27 18:53:08 2026 -0700 ci: move backport preflight out of Required Checks and report conflicts as neutral (#6959) ### What changes were proposed in this PR? Follow-up to #6941, improving the backport CI/PRs so they stop reading as failures: - **A backport conflict no longer shows a red X.** Moved backport preflight/build out of `required-checks.yml` into a dedicated **Backport Checks** workflow, and report a conflict as a **`neutral`** (grey) check instead of a failing job. Still advisory, still never blocks merge (backport was never a required context). - **Shared precheck.** Extracted `precheck` into a reusable workflow so the label→stack mapping lives in one place. - **Nicer auto-opened draft backport PRs:** opened as `github-actions[bot]`, follow the PR template, title no longer links back to the original PR (trailing `(#N)` stripped), and the body carries the original PR's linked issue/discussion. ### Any related issues, documentation, discussions? Closes #6954. Follow-up to #6941. ### How was this PR tested? `actionlint` and `node --check` clean on all workflows; the apply-check shell/jq and the title/related-section logic were exercised locally. Full behavior runs on a real backport-labeled PR. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 4.8) --- .github/workflows/backport-auto-label.yml | 17 +- .github/workflows/backport-checks.yml | 140 +++++++++ .github/workflows/backport-publish.yml | 129 +++++++++ .github/workflows/direct-backport-push.yml | 214 +++++++++++--- .../{required-checks.yml => precheck.yml} | 230 +++------------ .github/workflows/required-checks.yml | 315 +-------------------- 6 files changed, 491 insertions(+), 554 deletions(-) diff --git a/.github/workflows/backport-auto-label.yml b/.github/workflows/backport-auto-label.yml index f10959b8c4..2e4c22d077 100644 --- a/.github/workflows/backport-auto-label.yml +++ b/.github/workflows/backport-auto-label.yml @@ -62,11 +62,14 @@ jobs: uses: actions/github-script@v9 env: ENTRIES: ${{ steps.targets.outputs.entries }} + BOT_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - # Add the label with the fine-grained PAT (falling back to the - # default token) so the resulting `labeled` event retriggers the - # backport pre-merge check in Required Checks. Labels applied by the - # default GITHUB_TOKEN do not trigger downstream workflows. + # Label ops use the fine-grained PAT (falling back to the default + # token) so the resulting `labeled` event retriggers the backport + # pre-merge check — labels applied by the default GITHUB_TOKEN do not + # trigger downstream workflows. The review request is sent by the bot + # (GITHUB_TOKEN, via BOT_TOKEN below) so it is attributed to + # github-actions[bot] rather than the PAT owner; it needs no trigger. github-token: ${{ secrets.AUTO_MERGE_TOKEN || secrets.GITHUB_TOKEN }} script: | const entries = JSON.parse(process.env.ENTRIES || "[]"); @@ -190,7 +193,11 @@ jobs: if (manager && manager !== author) { try { - await github.rest.pulls.requestReviewers({ + // Send the review request as github-actions[bot] (BOT_TOKEN), + // not the PAT owner — it needs no downstream trigger, so it + // does not have to use the label PAT. + const bot = require("@actions/github").getOctokit(process.env.BOT_TOKEN); + await bot.rest.pulls.requestReviewers({ owner, repo, pull_number: pr.number, diff --git a/.github/workflows/backport-checks.yml b/.github/workflows/backport-checks.yml new file mode 100644 index 0000000000..7288928ef3 --- /dev/null +++ b/.github/workflows/backport-checks.yml @@ -0,0 +1,140 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Backport preflight, kept out of Required Checks so a conflicting backport is +# never a red X on the PR and never reads like a required check failed. For +# each release/* label: +# 1. apply-check: a git-only dry-run cherry-pick onto the release branch. +# 2. backport: for targets that apply cleanly, build the backported tree so +# "applies but does not compile on the release branch" is caught pre-merge. +# Neither job fails on a bad backport — the apply outcome is written to an +# artifact and the Backport Preflight Publish workflow (workflow_run) turns it +# into a `backport-preflight (<target>)` check that is `neutral` (grey) on a +# conflict and `success` on a clean apply. Publishing lives in that companion +# because this workflow runs on `pull_request`, whose token is read-only on +# fork PRs (renovate, external contributors) and so cannot create check runs. +# Post-merge, Direct Backport Push reads the neutral/success check and the +# backport build legs to decide push-straight vs. open-a-draft-PR. +name: Backport Checks + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - labeled + - unlabeled + +permissions: + checks: write + contents: read + pull-requests: read + +concurrency: + group: backport-checks-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + # Reused so the release/* target list (and the label -> stack mapping the + # backport build needs) is derived exactly as the main build derives it. + precheck: + uses: ./.github/workflows/precheck.yml + + # Fast git-only pre-flight: does the change even cherry-pick onto each + # release branch? One job loops the targets (each is seconds), records + # applied/conflict per target, and emits the clean-target list so a + # conflicting target never spins up the expensive backport build below. + apply-check: + needs: precheck + if: ${{ needs.precheck.outputs.backport_targets != '[]' }} + runs-on: ubuntu-latest + outputs: + buildable: ${{ steps.check.outputs.buildable }} + steps: + - name: Checkout PR head + uses: actions/checkout@v7 + with: + ref: refs/pull/${{ github.event.pull_request.number }}/head + fetch-depth: 0 + - name: Dry-run cherry-pick each target + id: check + env: + TARGETS: ${{ needs.precheck.outputs.backport_targets }} + RANGE: ${{ format('{0}..{1}', github.event.pull_request.base.sha, github.event.pull_request.head.sha) }} + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -uo pipefail + mkdir -p preflight + results='[]' + buildable='[]' + for target in $(echo "$TARGETS" | jq -r '.[]'); do + # Clear any leftover cherry-pick from the previous target so the + # script's `git checkout -B` is not blocked by an unmerged index. + git cherry-pick --abort 2>/dev/null || true + git rebase --abort 2>/dev/null || true + if bash ./.github/scripts/prepare-backport-checkout.sh "$target" "$RANGE"; then + echo "[preflight] ${target}: applies cleanly" + applied=true + buildable=$(echo "$buildable" | jq -c --arg t "$target" '. + [$t]') + else + echo "[preflight] ${target}: conflicts" + applied=false + fi + results=$(echo "$results" | jq -c --arg t "$target" --argjson a "$applied" '. + [{target: $t, applied: $a}]') + done + git cherry-pick --abort 2>/dev/null || true + echo "buildable=${buildable}" >> "$GITHUB_OUTPUT" + printf '%s\n' "$results" > preflight/results.json + jq -nc --argjson pr "$PR_NUMBER" --arg sha "$HEAD_SHA" '{pr: $pr, head_sha: $sha}' > preflight/meta.json + echo "Results: ${results}" + echo "Buildable: ${buildable}" + - name: Upload preflight results + uses: actions/upload-artifact@v4 + with: + name: backport-preflight + path: preflight/ + retention-days: 3 + + # Build the backported tree for targets that applied cleanly, so a clean + # cherry-pick that nonetheless fails to compile on the release branch is + # caught before merge (Direct Backport Push reads these legs to decide + # push-straight vs. open-a-draft-PR). Same stack selection as the main build. + backport: + needs: [precheck, apply-check] + if: ${{ needs.apply-check.outputs.buildable != '' && needs.apply-check.outputs.buildable != '[]' }} + strategy: + fail-fast: false + matrix: + target: ${{ fromJson(needs.apply-check.outputs.buildable) }} + uses: ./.github/workflows/build.yml + with: + checkout_ref: refs/pull/${{ github.event.pull_request.number }}/head + backport_target_branch: ${{ matrix.target }} + backport_commit_range: ${{ format('{0}..{1}', github.event.pull_request.base.sha, github.event.pull_request.head.sha) }} + job_name_suffix: "" + run_frontend: ${{ needs.precheck.outputs.run_frontend == 'true' }} + run_amber: ${{ needs.precheck.outputs.run_amber == 'true' }} + run_amber_integration: ${{ needs.precheck.outputs.run_amber_integration == 'true' }} + run_platform: ${{ needs.precheck.outputs.run_platform == 'true' }} + run_platform_integration: ${{ needs.precheck.outputs.run_platform_integration == 'true' }} + run_pyamber: ${{ needs.precheck.outputs.run_pyamber == 'true' }} + run_agent_service: ${{ needs.precheck.outputs.run_agent_service == 'true' }} + run_infra: ${{ needs.precheck.outputs.run_infra == 'true' }} + run_pyright_language_service: ${{ needs.precheck.outputs.run_pyright_language_service == 'true' }} + secrets: inherit diff --git a/.github/workflows/backport-publish.yml b/.github/workflows/backport-publish.yml new file mode 100644 index 0000000000..fde3467d64 --- /dev/null +++ b/.github/workflows/backport-publish.yml @@ -0,0 +1,129 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Companion to Backport Checks. That workflow runs on `pull_request`, whose +# token is read-only on fork PRs and so cannot create check runs; this one runs +# on `workflow_run` in the base-repo context with `checks: write`, so it can. +# It reads the preflight artifact and publishes one `backport-preflight +# (<target>)` check per target: `success` when the change cherry-picks cleanly +# onto that release branch, `neutral` (grey — never a red X, never a merge +# blocker) when it conflicts. A conflicting backport is expected and handled +# after merge by Direct Backport Push, which opens a draft backport PR. +name: Backport Preflight Publish + +on: + workflow_run: + workflows: + - Backport Checks + types: + - completed + +permissions: + actions: read + checks: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Download preflight artifact + id: download + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: backport-preflight + path: preflight + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Publish neutral/success preflight checks + uses: actions/github-script@v9 + with: + script: | + const fs = require("fs"); + const { owner, repo } = context.repo; + + // The artifact is absent when Backport Checks requested no targets + // (a non-backport PR, or every release/* label removed). Fall back + // to the workflow_run's head SHA and an empty result set so the + // stale-check cleanup below still runs. + let results = []; + let headSha = context.payload.workflow_run.head_sha; + try { + results = JSON.parse(fs.readFileSync("preflight/results.json", "utf8")); + } catch (e) { + core.info(`No preflight results artifact (${e.code || e.message}); nothing to publish.`); + } + try { + const meta = JSON.parse(fs.readFileSync("preflight/meta.json", "utf8")); + if (meta.head_sha) headSha = meta.head_sha; + } catch (e) { + core.info("No preflight meta; using workflow_run head SHA."); + } + if (!headSha) { + core.warning("No head SHA resolved; skipping."); + return; + } + + for (const r of results) { + const applied = r.applied === true; + await github.rest.checks.create({ + owner, + repo, + name: `backport-preflight (${r.target})`, + head_sha: headSha, + status: "completed", + conclusion: applied ? "success" : "neutral", + output: { + title: applied + ? `Cherry-picks cleanly onto ${r.target}` + : `Conflicts on ${r.target} — needs a manual backport`, + summary: applied + ? `The change applies cleanly onto \`${r.target}\`. If this PR merges it is cherry-picked straight to the release branch.` + : `The change does not apply cleanly onto \`${r.target}\`. This is advisory and does not block merging: after merge, Direct Backport Push opens a draft backport PR for manual conflict resolution. Remove the \`${r.target}\` label to decline the backport.`, + }, + }); + core.info(`Published backport-preflight (${r.target}) = ${applied ? "success" : "neutral"}.`); + } + + // Cancel stale preflight checks for targets no longer requested + // (e.g. a release/* label was removed): a leftover grey/green mark + // for a target that is gone is misleading. + const current = new Set(results.map((r) => r.target)); + const checks = await github.paginate(github.rest.checks.listForRef, { + owner, + repo, + ref: headSha, + per_page: 100, + }); + for (const check of checks) { + const m = check.name.match(/^backport-preflight \((.+)\)$/); + if (!m || current.has(m[1])) continue; + if (check.status === "completed" && check.conclusion === "cancelled") continue; + try { + await github.rest.checks.update({ + owner, + repo, + check_run_id: check.id, + status: "completed", + conclusion: "cancelled", + }); + core.info(`Cancelled stale ${check.name}.`); + } catch (e) { + core.warning(`Failed to cancel ${check.name}: ${e.message}`); + } + } diff --git a/.github/workflows/direct-backport-push.yml b/.github/workflows/direct-backport-push.yml index 58031e26e1..2ce8815e70 100644 --- a/.github/workflows/direct-backport-push.yml +++ b/.github/workflows/direct-backport-push.yml @@ -23,6 +23,7 @@ on: permissions: actions: read + checks: read contents: write issues: write pull-requests: write @@ -147,33 +148,53 @@ jobs: return null; } - // Collect every job across the PR head's Required Checks runs so - // both the pre-flight apply-check and the full backport build can - // be inspected per target. + // Collect the backport build legs for the PR head. They run in + // Backport Checks now; during rollout, older PRs still have their + // legs (and the old apply-check job) in Required Checks, so gather + // jobs from both workflows. async function jobsForPr(pullRequest) { - const buildRuns = await github.paginate( - github.rest.actions.listWorkflowRuns, - { + const workflowFiles = ["backport-checks.yml", "required-checks.yml"]; + const allJobs = []; + for (const wf of workflowFiles) { + let runs = []; + try { + runs = await github.paginate(github.rest.actions.listWorkflowRuns, { + owner, + repo, + workflow_id: wf, + head_sha: pullRequest.head.sha, + per_page: 100, + }); + } catch (e) { + core.info(`No ${wf} runs for ${pullRequest.head.sha} (${e.status ?? "?"}).`); + continue; + } + for (const run of runs) { + const jobs = await github.paginate( + github.rest.actions.listJobsForWorkflowRun, + { owner, repo, run_id: run.id, per_page: 100 } + ); + allJobs.push(...jobs); + } + } + return allJobs; + } + + // The apply-check outcome is now a published check run, + // `backport-preflight (<target>)`: success = clean, neutral = + // conflict. Read the PR head's check runs to find them. + async function checkRunsForPr(pullRequest) { + try { + return await github.paginate(github.rest.checks.listForRef, { owner, repo, - workflow_id: "required-checks.yml", - head_sha: pullRequest.head.sha, + ref: pullRequest.head.sha, per_page: 100, - } - ); - if (buildRuns.length === 0) { - core.warning(`No Required Checks runs found for ${pullRequest.head.sha}.`); + }); + } catch (e) { + core.warning(`listForRef ${pullRequest.head.sha} failed: ${e.message}`); return []; } - const allJobs = []; - for (const run of buildRuns) { - const jobs = await github.paginate( - github.rest.actions.listJobsForWorkflowRun, - { owner, repo, run_id: run.id, per_page: 100 } - ); - allJobs.push(...jobs); - } - return allJobs; } const isDone = (job) => job.status === "completed"; @@ -193,10 +214,17 @@ jobs: // job could push a conflict or open a spurious PR, so we // leave it (the scheduled reconciliation, if any, or a // re-push will pick it up once checks are done). - function classifyTargets(allJobs, requestedTargets) { + function classifyTargets(allJobs, checkRuns, requestedTargets) { const push = []; const pr = []; for (const target of requestedTargets) { + // Preferred apply signal: the published preflight check + // (success = clean, neutral = conflict). + const applyCheck = checkRuns.find( + (c) => c.name === `backport-preflight (${target})` + ); + // Rollout fallback: PRs whose last run predates the split still + // expose the old red/green apply-check job in Required Checks. const applyJob = allJobs.find( (job) => job.name === `backport-apply-check (${target})` ); @@ -204,7 +232,16 @@ jobs: job.name.startsWith(`backport (${target}) / `) ); - if (applyJob && isDone(applyJob) && !isGreen(applyJob)) { + // Resolve the apply signal into clean | conflict | unknown. + let apply = "unknown"; + if (applyCheck && applyCheck.status === "completed") { + if (applyCheck.conclusion === "success") apply = "clean"; + else if (applyCheck.conclusion === "neutral") apply = "conflict"; + } else if (applyJob && isDone(applyJob)) { + apply = isGreen(applyJob) ? "clean" : "conflict"; + } + + if (apply === "conflict") { pr.push(target); // cherry-pick conflicts continue; } @@ -213,12 +250,11 @@ jobs: continue; } - const applyOk = applyJob && isDone(applyJob) && isGreen(applyJob); const anyBuildDone = buildJobs.some(isDone); - // Normal path: apply-check finished green. The `!applyJob` - // clause keeps older runs (before apply-check existed) working - // off the build legs alone during rollout. - if (applyOk || (!applyJob && anyBuildDone)) { + // Normal path: apply finished clean. The `unknown` + build-legs + // clause keeps older runs (before the apply signal existed) + // working off the build legs alone during rollout. + if (apply === "clean" || (apply === "unknown" && anyBuildDone)) { push.push(target); } else { core.warning(`PR: no completed backport signal for ${target}; skipping.`); @@ -272,7 +308,8 @@ jobs: } const allJobs = await jobsForPr(pullRequest); - const { push, pr } = classifyTargets(allJobs, requestedTargets); + const checkRuns = await checkRunsForPr(pullRequest); + const { push, pr } = classifyTargets(allJobs, checkRuns, requestedTargets); core.info( `PR #${pullRequest.number}: push=[${push.join(", ")}] pr=[${pr.join(", ")}]` ); @@ -788,7 +825,12 @@ jobs: SUBJECT: ${{ steps.branch.outputs.subject }} FEATURE_ABSENT: ${{ steps.branch.outputs.feature_absent }} with: - github-token: ${{ secrets.AUTO_MERGE_TOKEN || secrets.GITHUB_TOKEN }} + # Open the draft as github-actions[bot], not the PAT owner, by using + # the default GITHUB_TOKEN. Trade-off: a GITHUB_TOKEN-opened PR does + # not trigger pull_request CI — acceptable because this is a draft for + # manual conflict/build resolution, so CI fires once the human pushes + # their fix to the branch. + github-token: ${{ secrets.GITHUB_TOKEN }} script: | const { MERGE_SHA, TARGET_BRANCH, PR_NUMBER, MANAGER, @@ -834,8 +876,12 @@ jobs: // the backport PR reads e.g. `fix(amber, v1.2): ...`. Falls back to // suffixing the version if the subject is not conventional. function backportTitle(subject, version) { - const m = subject.match(/^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.*)$/); - if (!m) return `${subject} (${version})`; + // Drop the trailing "(#N)" GitHub appends to the squash subject + // so the backport title does not render as a link back to the + // original PR. + const clean = subject.replace(/\s*\(#\d+\)\s*$/, ""); + const m = clean.match(/^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.*)$/); + if (!m) return `${clean} (${version})`; const [, type, scope, bang, rest] = m; const newScope = scope ? `${scope}, ${version}` : version; return `${type}(${newScope})${bang || ""}: ${rest}`; @@ -855,17 +901,84 @@ jobs: const conflictList = CONFLICT_FILES.trim() ? CONFLICT_FILES.trim().split(/\s+/).map((f) => `- \`${f}\``).join("\n") : ""; + const statusLine = hadConflict + ? `The cherry-pick **conflicted** and was committed with conflict markers. ` + + `Resolve the conflicts on this branch, then mark this PR ready for review.` + : `The cherry-pick applied cleanly but the backported tree failed its ` + + `pre-merge build. Fix the build on this branch, then mark this PR ready for review.`; + + // Carry the original PR's linked issues (its GitHub-linked + // closing references) and any discussion / mailing-list links + // into the backport so the release-branch reviewer has the same + // context. Plain `#N` mentions, never `Closes` — the backport + // must not re-close an issue the original PR already resolved. + let relatedSection = `Backport of #${prNumber}.`; + try { + const linked = await github.graphql( + `query ($owner: String!, $repo: String!, $num: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $num) { + body + closingIssuesReferences(first: 20) { nodes { number } } + } + } + }`, + { owner, repo, num: prNumber } + ); + const original = linked.repository.pullRequest; + const bodyText = original.body || ""; + const issues = (original.closingIssuesReferences?.nodes || []).map( + (n) => n.number + ); + const discussions = [ + ...bodyText.matchAll( + /https:\/\/(?:github\.com\/[^\s)]+\/discussions\/\d+|lists\.apache\.org\/[^\s)]+)/g + ), + ].map((m) => m[0]); + // Security dependency PRs (e.g. Renovate) usually link no issue; + // fall back to the CVE / GHSA advisories named in the body so + // the backport still points at what it fixes. + const cves = [ + ...new Set([...bodyText.matchAll(/\bCVE-\d{4}-\d{4,7}\b/g)].map((m) => m[0])), + ]; + const ghsas = [ + ...new Set( + [...bodyText.matchAll(/\bGHSA(?:-[a-z0-9]{4}){3}\b/gi)].map((m) => m[0]) + ), + ]; + const advisories = cves.length ? cves : ghsas; + const parts = [`Backport of #${prNumber}.`]; + if (issues.length) { + parts.push(`Originally linked ${issues.map((n) => `#${n}`).join(", ")}.`); + } else if (advisories.length) { + parts.push(`Fixes ${advisories.join(", ")} (see #${prNumber} for details).`); + } + parts.push(...discussions); + relatedSection = parts.join(" "); + } catch (e) { + core.warning(`Could not read linked issues from #${prNumber}: ${e.message}`); + } + const body = [ - `Automated backport of #${prNumber} to \`${TARGET_BRANCH}\`.`, + `### What changes were proposed in this PR?`, ``, - hadConflict - ? `⚠️ The cherry-pick **conflicted** and was committed with conflict markers. ` + - `Resolve the conflicts on this branch, then mark the PR ready for review.` - : `The cherry-pick applied cleanly but the backported tree failed its ` + - `pre-merge build. Fix the build on this branch, then mark the PR ready for review.`, - conflictList ? `\n**Conflicting files:**\n${conflictList}` : ``, + `Automated backport of #${prNumber} to \`${TARGET_BRANCH}\`.`, ``, `Source: ${MERGE_SHA} · [automation run](${runUrl})`, + ``, + `### Any related issues, documentation, discussions?`, + ``, + relatedSection, + ``, + `### How was this PR tested?`, + ``, + `Release-branch CI runs on this branch once the ` + + `${hadConflict ? "conflicts are resolved" : "build is fixed"} and ` + + `this PR is marked ready for review.`, + ``, + `### Was this PR authored or co-authored using generative AI tooling?`, + ``, + `No.`, ].join("\n"); pr = (await github.rest.pulls.create({ @@ -873,6 +986,19 @@ jobs: title, body, draft: true, })).data; core.info(`Opened draft backport PR #${pr.number}.`); + + // Post the how-to-finish instructions as a comment on the new + // backport PR (kept out of the templated body). + const statusComment = conflictList + ? `${statusLine}\n\n**Conflicting files:**\n${conflictList}` + : statusLine; + try { + await github.rest.issues.createComment({ + owner, repo, issue_number: pr.number, body: statusComment, + }); + } catch (e) { + core.warning(`Could not comment on backport PR #${pr.number}: ${e.message}`); + } } // Assign the original author (best-effort: external authors may not @@ -906,13 +1032,17 @@ jobs: // Link the backport PR back from the original PR so it is not lost. const prUrl = `${context.serverUrl}/${owner}/${repo}/pull/${pr.number}`; + const reason = hadConflict + ? "the cherry-pick conflicts" + : "the backported tree failed its pre-merge build"; try { await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body: - `Backport to \`${TARGET_BRANCH}\` needs manual work — opened ` + - `draft PR #${pr.number} (${prUrl})` + - (author ? `, assigned to @${author}` : "") + `.`, + `Backport PR opened: draft #${pr.number} (${prUrl}) to ` + + `\`${TARGET_BRANCH}\`` + + (author ? `, assigned to @${author}` : "") + + ` — needs manual work because ${reason}.`, }); } catch (e) { core.warning(`Could not comment on #${prNumber}: ${e.message}`); diff --git a/.github/workflows/required-checks.yml b/.github/workflows/precheck.yml similarity index 55% copy from .github/workflows/required-checks.yml copy to .github/workflows/precheck.yml index 6194d621cd..c98589060b 100644 --- a/.github/workflows/required-checks.yml +++ b/.github/workflows/precheck.yml @@ -15,47 +15,45 @@ # specific language governing permissions and limitations # under the License. -name: Required Checks +# Reusable pre-flight that both Required Checks (main build) and Backport +# Checks (backport preflight/build) call, so the label -> stack mapping and +# the labeler-wait live in exactly one place. On PR events it waits for the +# Pull Request Labeler to finish, then maps the PR's labels to the build +# stacks each requires and to the release/* backport targets. Non-PR events +# (push / merge_group / workflow_dispatch) run every stack and request no +# backports. +name: Precheck on: - push: - branches: - - 'ci-enable/**' - - 'main' - - 'release/**' - pull_request: - types: - - opened - - reopened - - synchronize - - labeled - - unlabeled - merge_group: - workflow_dispatch: + workflow_call: + outputs: + run_frontend: + value: ${{ jobs.decide.outputs.run_frontend }} + run_amber: + value: ${{ jobs.decide.outputs.run_amber }} + run_amber_integration: + value: ${{ jobs.decide.outputs.run_amber_integration }} + run_platform: + value: ${{ jobs.decide.outputs.run_platform }} + run_platform_integration: + value: ${{ jobs.decide.outputs.run_platform_integration }} + run_pyamber: + value: ${{ jobs.decide.outputs.run_pyamber }} + run_agent_service: + value: ${{ jobs.decide.outputs.run_agent_service }} + run_infra: + value: ${{ jobs.decide.outputs.run_infra }} + run_pyright_language_service: + value: ${{ jobs.decide.outputs.run_pyright_language_service }} + backport_targets: + value: ${{ jobs.decide.outputs.backport_targets }} permissions: - checks: write - contents: read + checks: read pull-requests: read -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/heads/release/') }} - jobs: - # Precheck decides which downstream jobs run for this event: - # - On PR events, wait for the Pull Request Labeler workflow to finish so - # the labels it applies (frontend, docs, infra, ...) are available, then - # gate run_* outputs on those labels. - # - run_frontend / run_amber / run_platform / run_pyamber / - # run_agent_service: gate the main build stacks. Each labeler-applied - # label maps to the stacks it requires (LABEL_STACKS below); the run - # set is the union across all PR labels. Empty union (e.g. docs-only - # PRs) skips every stack. Push and workflow_dispatch - # events run every stack. - # - backport_targets: JSON array of release/* labels currently on the PR. - # Drives the backport matrix; empty array means no backport runs. - precheck: + decide: name: Precheck runs-on: ubuntu-latest outputs: @@ -231,167 +229,3 @@ jobs: core.info(`Backport targets: ${targets.join(", ")}`); } core.setOutput("backport_targets", JSON.stringify(targets)); - - cleanup-stale-backport: - if: ${{ github.event_name == 'pull_request' && github.event.action == 'unlabeled' && startsWith(github.event.label.name, 'release/') }} - runs-on: ubuntu-latest - steps: - - name: Cancel obsolete backport check_runs for the removed target - uses: actions/github-script@v9 - with: - script: | - const { owner, repo } = context.repo; - const target = context.payload.label.name; - const headSha = context.payload.pull_request.head.sha; - const prefix = `backport (${target}) `; - - const checks = await github.paginate( - github.rest.checks.listForRef, - { owner, repo, ref: headSha, per_page: 100 } - ); - - for (const check of checks) { - if (!check.name.startsWith(prefix)) continue; - if (check.status === "completed" && check.conclusion === "cancelled") continue; - try { - await github.rest.checks.update({ - owner, - repo, - check_run_id: check.id, - status: "completed", - conclusion: "cancelled", - }); - core.info(`Cancelled check ${check.name}`); - } catch (e) { - core.warning(`Failed to update check ${check.id} (${check.name}): ${e.message}`); - } - } - - build: - needs: precheck - uses: ./.github/workflows/build.yml - with: - run_frontend: ${{ needs.precheck.outputs.run_frontend == 'true' }} - run_amber: ${{ needs.precheck.outputs.run_amber == 'true' }} - run_amber_integration: ${{ needs.precheck.outputs.run_amber_integration == 'true' }} - run_platform: ${{ needs.precheck.outputs.run_platform == 'true' }} - run_platform_integration: ${{ needs.precheck.outputs.run_platform_integration == 'true' }} - run_pyamber: ${{ needs.precheck.outputs.run_pyamber == 'true' }} - run_agent_service: ${{ needs.precheck.outputs.run_agent_service == 'true' }} - run_infra: ${{ needs.precheck.outputs.run_infra == 'true' }} - run_pyright_language_service: ${{ needs.precheck.outputs.run_pyright_language_service == 'true' }} - secrets: inherit - - # Fast pre-flight: does the change even cherry-pick onto each release branch? - # This is a git-only job (no build), so a conflict is reported in seconds and - # gates the expensive backport build below — a conflicting target never spins - # up the full stack matrix. Post-merge, Direct Backport Push reads this job's - # result as the authoritative "conflicts / applies" signal. - backport-apply-check: - needs: precheck - if: ${{ needs.precheck.outputs.backport_targets != '[]' }} - strategy: - fail-fast: false - matrix: - target: ${{ fromJson(needs.precheck.outputs.backport_targets) }} - runs-on: ubuntu-latest - steps: - - name: Checkout PR head - uses: actions/checkout@v7 - with: - ref: refs/pull/${{ github.event.pull_request.number }}/head - fetch-depth: 0 - - name: Dry-run cherry-pick onto ${{ matrix.target }} - run: | - bash ./.github/scripts/prepare-backport-checkout.sh \ - "${{ matrix.target }}" \ - "${{ format('{0}..{1}', github.event.pull_request.base.sha, github.event.pull_request.head.sha) }}" - - # Collect the targets whose pre-flight cherry-pick succeeded. always() so a - # conflict on one target still lets the others build; an all-conflict run - # yields an empty list and skips the backport build entirely. - backport-buildable: - needs: [precheck, backport-apply-check] - if: ${{ always() && needs.precheck.outputs.backport_targets != '[]' }} - runs-on: ubuntu-latest - outputs: - targets: ${{ steps.collect.outputs.targets }} - steps: - - name: Collect targets that applied cleanly - id: collect - uses: actions/github-script@v9 - env: - REQUESTED: ${{ needs.precheck.outputs.backport_targets }} - with: - script: | - const requested = JSON.parse(process.env.REQUESTED || "[]"); - const { owner, repo } = context.repo; - const jobs = await github.paginate( - github.rest.actions.listJobsForWorkflowRun, - { owner, repo, run_id: context.runId, per_page: 100 } - ); - const buildable = requested.filter((target) => { - const job = jobs.find( - (j) => j.name === `backport-apply-check (${target})` - ); - return job && job.conclusion === "success"; - }); - core.info(`Buildable backport targets: ${JSON.stringify(buildable)}`); - core.setOutput("targets", JSON.stringify(buildable)); - - backport: - needs: [precheck, backport-buildable] - if: ${{ needs.backport-buildable.outputs.targets != '' && needs.backport-buildable.outputs.targets != '[]' }} - strategy: - fail-fast: false - matrix: - target: ${{ fromJson(needs.backport-buildable.outputs.targets) }} - uses: ./.github/workflows/build.yml - with: - checkout_ref: refs/pull/${{ github.event.pull_request.number }}/head - backport_target_branch: ${{ matrix.target }} - backport_commit_range: ${{ format('{0}..{1}', github.event.pull_request.base.sha, github.event.pull_request.head.sha) }} - job_name_suffix: "" - run_frontend: ${{ needs.precheck.outputs.run_frontend == 'true' }} - run_amber: ${{ needs.precheck.outputs.run_amber == 'true' }} - run_amber_integration: ${{ needs.precheck.outputs.run_amber_integration == 'true' }} - run_platform: ${{ needs.precheck.outputs.run_platform == 'true' }} - run_platform_integration: ${{ needs.precheck.outputs.run_platform_integration == 'true' }} - run_pyamber: ${{ needs.precheck.outputs.run_pyamber == 'true' }} - run_agent_service: ${{ needs.precheck.outputs.run_agent_service == 'true' }} - run_infra: ${{ needs.precheck.outputs.run_infra == 'true' }} - run_pyright_language_service: ${{ needs.precheck.outputs.run_pyright_language_service == 'true' }} - secrets: inherit - - required-checks: - # Do not rename this job — its display name is referenced in .asf.yaml. - name: Required Checks - # The backport job is deliberately NOT a dependency here: it is an advisory - # signal, not a merge gate. A red pre-merge backport (a cherry-pick that - # conflicts, or a backported tree that fails to build) must not block the - # PR from landing on main — instead Direct Backport Push opens a draft - # backport PR for the author after merge. Keeping backport out of `needs` - # also lets this aggregator report as soon as build finishes. - needs: [precheck, build] - if: always() - runs-on: ubuntu-latest - steps: - - name: Verify all required checks succeeded or were skipped - run: | - declare -A results=( - [precheck]="${{ needs.precheck.result }}" - [build]="${{ needs.build.result }}" - ) - failed=0 - for job in "${!results[@]}"; do - r="${results[$job]}" - echo "${job}: ${r}" - if [[ "$r" != "success" && "$r" != "skipped" ]]; then - failed=1 - fi - done - if (( failed )); then - echo "::error::One or more required checks did not succeed." - exit 1 - fi - echo "All required checks succeeded or were skipped." diff --git a/.github/workflows/required-checks.yml b/.github/workflows/required-checks.yml index 6194d621cd..f2ee2efb89 100644 --- a/.github/workflows/required-checks.yml +++ b/.github/workflows/required-checks.yml @@ -43,229 +43,13 @@ concurrency: cancel-in-progress: ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/heads/release/') }} jobs: - # Precheck decides which downstream jobs run for this event: - # - On PR events, wait for the Pull Request Labeler workflow to finish so - # the labels it applies (frontend, docs, infra, ...) are available, then - # gate run_* outputs on those labels. - # - run_frontend / run_amber / run_platform / run_pyamber / - # run_agent_service: gate the main build stacks. Each labeler-applied - # label maps to the stacks it requires (LABEL_STACKS below); the run - # set is the union across all PR labels. Empty union (e.g. docs-only - # PRs) skips every stack. Push and workflow_dispatch - # events run every stack. - # - backport_targets: JSON array of release/* labels currently on the PR. - # Drives the backport matrix; empty array means no backport runs. + # Decide which build stacks this event needs (label union on PR events; all + # stacks on push/merge_group/dispatch). Shared with Backport Checks via the + # reusable precheck workflow so the label -> stack mapping lives in one place + # (.github/workflows/precheck.yml). The backport_targets output is unused + # here — backport preflight/build moved to the Backport Checks workflow. precheck: - name: Precheck - runs-on: ubuntu-latest - outputs: - run_frontend: ${{ steps.decide.outputs.run_frontend }} - run_amber: ${{ steps.decide.outputs.run_amber }} - run_amber_integration: ${{ steps.decide.outputs.run_amber_integration }} - run_platform: ${{ steps.decide.outputs.run_platform }} - run_platform_integration: ${{ steps.decide.outputs.run_platform_integration }} - run_pyamber: ${{ steps.decide.outputs.run_pyamber }} - run_agent_service: ${{ steps.decide.outputs.run_agent_service }} - run_infra: ${{ steps.decide.outputs.run_infra }} - run_pyright_language_service: ${{ steps.decide.outputs.run_pyright_language_service }} - backport_targets: ${{ steps.decide.outputs.backport_targets }} - steps: - - name: Wait for Pull Request Labeler - if: github.event_name == 'pull_request' - uses: actions/github-script@v9 - with: - script: | - const ref = context.payload.pull_request.head.sha; - const maxAttempts = 30; - for (let i = 0; i < maxAttempts; i++) { - const { data } = await github.rest.checks.listForRef({ - owner: context.repo.owner, - repo: context.repo.repo, - ref, - check_name: "labeler", - }); - const check = data.check_runs[0]; - if (check && check.status === "completed") { - core.info(`labeler ${check.conclusion}`); - return; - } - core.info(`labeler not ready (attempt ${i + 1}/${maxAttempts})`); - await new Promise((r) => setTimeout(r, 10000)); - } - core.warning("labeler did not complete within 5 minutes; proceeding with current labels."); - - - name: Decide which jobs to run - id: decide - uses: actions/github-script@v9 - with: - script: | - const eventName = context.eventName; - let labels = []; - - if (eventName === "pull_request") { - // Re-fetch labels: the labeler may have just added some. - const { data: pr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.pull_request.number, - }); - labels = pr.labels.map((l) => l.name); - core.info(`PR labels: ${labels.join(", ") || "(none)"}`); - } - - // Map each labeler-applied label to the stacks it requires. The - // run set is the union across all PR labels. Labels not listed - // here (release/*, feature, fix, refactor) contribute no stacks. - // dependencies is intentionally a no-op: every dep manifest the - // labeler matches lives under a component dir and is already - // covered by that component's label — including - // pyright-language-service/package.json, kept honest by the - // `pyright-language-service` component label below. - // - // `infra` and `pyright-language-service` are 1:1 self-mapping - // labels (each fires only its own like-named stack); they are - // omitted from the illustrative table below to keep it narrow. - // - // `platform-integration` mirrors `amber-integration`: a dedicated - // `platform-integration` label fires only the integration stack - // (a spec-only change) without the full `platform` job. It is - // omitted from the table below to keep it narrow. No labeler rule - // applies it yet — platform has no integration specs (#6047) — so - // today it is a forward hook (applied manually until then). - // - // label | frontend | amber | amber-integ | platform | pyamber | agent-service - // ------------------|----------|-------|-------------|----------|---------|-------------- - // frontend | x | | | | | - // pyamber | | | x | | x | - // engine | | x | x | | | - // amber-integration | | | x | | | - // platform | | | | x | | - // agent-service | | | | | | x - // common | | x | x | x | | (root - // scala - // build/lint - // config) - // ddl-change | | x | x | x | | - // ci | x | x | x | x | x | x - // docs / deps / | | | | | | - // release/* | | | | | | - // * / branch | | | | | | - // - // amber-integration runs the Scala tests tagged - // @org.apache.texera.amber.tags.IntegrationTest (e2e specs that - // spawn Python UDF workers). The labeler attaches `pyamber` to - // any *.py change (including amber/src/{main,test}/python/**), - // so `engine` does not need to fire the pyamber stack itself — - // pure-Python amber changes pick up `pyamber` directly. The - // `amber-integration` label catches *IntegrationSpec.scala - // edits so a test-only change does not trigger the full - // Scala-only amber stack. - const LABEL_STACKS = { - frontend: ["frontend"], - pyamber: ["amber-integration", "pyamber"], - engine: ["amber", "amber-integration"], - "amber-integration": ["amber-integration"], - platform: ["platform", "platform-integration"], - "platform-integration": ["platform-integration"], - "agent-service": ["agent-service"], - "pyright-language-service": ["pyright-language-service"], - common: ["amber", "amber-integration", "platform", "platform-integration"], - "ddl-change": ["amber", "amber-integration", "platform", "platform-integration"], - infra: ["infra"], - ci: ["frontend", "amber", "amber-integration", "platform", "platform-integration", "pyamber", "agent-service", "infra", "pyright-language-service"], - }; - - let runFrontend = true; - let runAmber = true; - let runAmberIntegration = true; - let runPlatform = true; - let runPlatformIntegration = true; - let runPyamber = true; - let runAgentService = true; - let runInfra = true; - let runPyrightLanguageService = true; - - if (eventName === "pull_request") { - const stacks = new Set(); - for (const label of labels) { - for (const stack of LABEL_STACKS[label] || []) { - stacks.add(stack); - } - } - runFrontend = stacks.has("frontend"); - runAmber = stacks.has("amber"); - runAmberIntegration = stacks.has("amber-integration"); - runPlatform = stacks.has("platform"); - runPlatformIntegration = stacks.has("platform-integration"); - runPyamber = stacks.has("pyamber"); - runAgentService = stacks.has("agent-service"); - runInfra = stacks.has("infra"); - runPyrightLanguageService = stacks.has("pyright-language-service"); - core.info( - `Stacks selected by label union: ${[...stacks].sort().join(", ") || "(none)"}` - ); - } - - core.setOutput("run_frontend", runFrontend ? "true" : "false"); - core.setOutput("run_amber", runAmber ? "true" : "false"); - core.setOutput("run_amber_integration", runAmberIntegration ? "true" : "false"); - core.setOutput("run_platform", runPlatform ? "true" : "false"); - core.setOutput("run_platform_integration", runPlatformIntegration ? "true" : "false"); - core.setOutput("run_pyamber", runPyamber ? "true" : "false"); - core.setOutput("run_agent_service", runAgentService ? "true" : "false"); - core.setOutput("run_infra", runInfra ? "true" : "false"); - core.setOutput("run_pyright_language_service", runPyrightLanguageService ? "true" : "false"); - - // Backport targets: all current release/* labels on the PR, unless - // `no-backport-needed` vetoes backporting entirely (e.g. a fix for - // a feature that does not exist on any release branch). The veto - // wins even if release/* labels are also present, so adding it - // mid-review cancels the backport apply-check/build on the re-run. - let targets = [...new Set(labels.filter((n) => /^release\/.+$/.test(n)))].sort(); - if (labels.includes("no-backport-needed")) { - core.info("no-backport-needed present; skipping all backport targets."); - targets = []; - } else if (targets.length === 0) { - core.info("No backport targets on PR."); - } else { - core.info(`Backport targets: ${targets.join(", ")}`); - } - core.setOutput("backport_targets", JSON.stringify(targets)); - - cleanup-stale-backport: - if: ${{ github.event_name == 'pull_request' && github.event.action == 'unlabeled' && startsWith(github.event.label.name, 'release/') }} - runs-on: ubuntu-latest - steps: - - name: Cancel obsolete backport check_runs for the removed target - uses: actions/github-script@v9 - with: - script: | - const { owner, repo } = context.repo; - const target = context.payload.label.name; - const headSha = context.payload.pull_request.head.sha; - const prefix = `backport (${target}) `; - - const checks = await github.paginate( - github.rest.checks.listForRef, - { owner, repo, ref: headSha, per_page: 100 } - ); - - for (const check of checks) { - if (!check.name.startsWith(prefix)) continue; - if (check.status === "completed" && check.conclusion === "cancelled") continue; - try { - await github.rest.checks.update({ - owner, - repo, - check_run_id: check.id, - status: "completed", - conclusion: "cancelled", - }); - core.info(`Cancelled check ${check.name}`); - } catch (e) { - core.warning(`Failed to update check ${check.id} (${check.name}): ${e.message}`); - } - } + uses: ./.github/workflows/precheck.yml build: needs: precheck @@ -282,96 +66,9 @@ jobs: run_pyright_language_service: ${{ needs.precheck.outputs.run_pyright_language_service == 'true' }} secrets: inherit - # Fast pre-flight: does the change even cherry-pick onto each release branch? - # This is a git-only job (no build), so a conflict is reported in seconds and - # gates the expensive backport build below — a conflicting target never spins - # up the full stack matrix. Post-merge, Direct Backport Push reads this job's - # result as the authoritative "conflicts / applies" signal. - backport-apply-check: - needs: precheck - if: ${{ needs.precheck.outputs.backport_targets != '[]' }} - strategy: - fail-fast: false - matrix: - target: ${{ fromJson(needs.precheck.outputs.backport_targets) }} - runs-on: ubuntu-latest - steps: - - name: Checkout PR head - uses: actions/checkout@v7 - with: - ref: refs/pull/${{ github.event.pull_request.number }}/head - fetch-depth: 0 - - name: Dry-run cherry-pick onto ${{ matrix.target }} - run: | - bash ./.github/scripts/prepare-backport-checkout.sh \ - "${{ matrix.target }}" \ - "${{ format('{0}..{1}', github.event.pull_request.base.sha, github.event.pull_request.head.sha) }}" - - # Collect the targets whose pre-flight cherry-pick succeeded. always() so a - # conflict on one target still lets the others build; an all-conflict run - # yields an empty list and skips the backport build entirely. - backport-buildable: - needs: [precheck, backport-apply-check] - if: ${{ always() && needs.precheck.outputs.backport_targets != '[]' }} - runs-on: ubuntu-latest - outputs: - targets: ${{ steps.collect.outputs.targets }} - steps: - - name: Collect targets that applied cleanly - id: collect - uses: actions/github-script@v9 - env: - REQUESTED: ${{ needs.precheck.outputs.backport_targets }} - with: - script: | - const requested = JSON.parse(process.env.REQUESTED || "[]"); - const { owner, repo } = context.repo; - const jobs = await github.paginate( - github.rest.actions.listJobsForWorkflowRun, - { owner, repo, run_id: context.runId, per_page: 100 } - ); - const buildable = requested.filter((target) => { - const job = jobs.find( - (j) => j.name === `backport-apply-check (${target})` - ); - return job && job.conclusion === "success"; - }); - core.info(`Buildable backport targets: ${JSON.stringify(buildable)}`); - core.setOutput("targets", JSON.stringify(buildable)); - - backport: - needs: [precheck, backport-buildable] - if: ${{ needs.backport-buildable.outputs.targets != '' && needs.backport-buildable.outputs.targets != '[]' }} - strategy: - fail-fast: false - matrix: - target: ${{ fromJson(needs.backport-buildable.outputs.targets) }} - uses: ./.github/workflows/build.yml - with: - checkout_ref: refs/pull/${{ github.event.pull_request.number }}/head - backport_target_branch: ${{ matrix.target }} - backport_commit_range: ${{ format('{0}..{1}', github.event.pull_request.base.sha, github.event.pull_request.head.sha) }} - job_name_suffix: "" - run_frontend: ${{ needs.precheck.outputs.run_frontend == 'true' }} - run_amber: ${{ needs.precheck.outputs.run_amber == 'true' }} - run_amber_integration: ${{ needs.precheck.outputs.run_amber_integration == 'true' }} - run_platform: ${{ needs.precheck.outputs.run_platform == 'true' }} - run_platform_integration: ${{ needs.precheck.outputs.run_platform_integration == 'true' }} - run_pyamber: ${{ needs.precheck.outputs.run_pyamber == 'true' }} - run_agent_service: ${{ needs.precheck.outputs.run_agent_service == 'true' }} - run_infra: ${{ needs.precheck.outputs.run_infra == 'true' }} - run_pyright_language_service: ${{ needs.precheck.outputs.run_pyright_language_service == 'true' }} - secrets: inherit - required-checks: # Do not rename this job — its display name is referenced in .asf.yaml. name: Required Checks - # The backport job is deliberately NOT a dependency here: it is an advisory - # signal, not a merge gate. A red pre-merge backport (a cherry-pick that - # conflicts, or a backported tree that fails to build) must not block the - # PR from landing on main — instead Direct Backport Push opens a draft - # backport PR for the author after merge. Keeping backport out of `needs` - # also lets this aggregator report as soon as build finishes. needs: [precheck, build] if: always() runs-on: ubuntu-latest
