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

yuqi1129 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new 25c3fb1289 [MINOR] fix(ci): keep Fork CI and allowlist statuses 
visible (#14192)
25c3fb1289 is described below

commit 25c3fb1289293843f1a1eb73b844caeecd3b2b4d
Author: Qi Yu <[email protected]>
AuthorDate: Fri Oct 23 19:18:35 2026 +0800

    [MINOR] fix(ci): keep Fork CI and allowlist statuses visible (#14192)
    
    ### What changes were proposed in this pull request?
    
    Publish Fork CI and ASF Allowlist Check as commit statuses with direct
    run links, retaining custom checks for run/attempt metadata. Repair
    missing or stale statuses even when the metadata check is unchanged. PR
    signals and dispatches are processed in order in one queue (`queue:
    max`). Sweeps poll every minute for 11 minutes, in their own job and
    concurrency group, so signals never wait behind them. Each polling sweep
    then dispatches the next one from a separate `rearm` job, the only job
    with `actions: write` (it checks out and runs no repository code), so
    polling is continuous instead of following GitHub's 10-36 minute
    schedule gaps; the schedule restarts the chain if it breaks. Sweeps
    spend at most 40% of the hourly API quota; with a small quota they stay
    single-pass scheduled sweeps without a chain. Within ASF limits this is
    about one `ubuntu-slim` runner, roughly 4% of the weekly allowance;
    every publisher derives the commit status from the stored check, and the
    next pass repairs any interleaving.
    
    Make results trustworthy for contributors:
    
    - The fail-fast watcher retries transient API errors and never fails the
    run (`continue-on-error`). A run that failed only because of the watcher
    is judged by its suites. On #13778 a single `Server Error` in the
    watcher turned a run whose suites all passed into `Failed: Stop on first
    failure`.
    - A fork that never ran `fork-ci.yml` (its branch predates Fork CI) gets
    an `action_required` check asking to merge the latest base branch.
    Previously the 404 left such PRs without any check and failed every
    scheduled sweep (#14118, #14177-#14187: 609 `Not Found` warnings in 25
    sweeps).
    - A successful fork run that edits CI definitions is pending, not red,
    while it waits for trusted verification.
    
    Let contributors restart and stop Fork CI themselves. `apache/gravitino`
    cannot start or cancel runs in a contributor's fork, so reopening a PR
    cannot restart CI as it did before the migration:
    
    - **Run workflow** in the fork (no PR number) starts a new run of the
    selected branch against the latest base branch, like a push, and cancels
    the branch's run in progress (push and dispatch share one
    cancel-in-progress group). The publisher mirrors these runs too. No new
    commit, rebase or merge is needed; **Re-run all jobs** also tests the
    latest base branch.
    - The fail-fast watcher checks the upstream PR every minute and cancels
    its fork run once the PR is closed.
    - The **Fork CI** check explains these options with links to the run and
    to **Run workflow**, and notes that its own **Re-run** button only
    refreshes the status. CONTRIBUTING describes push, Run workflow, re-run,
    close and reopen.
    
    Keep CI execution progress separate from approval of CI definition
    changes. A running fork remains pending; a successful fork that edits CI
    definitions explicitly requests trusted verification. Preserve actual
    failures, repair premature terminal checks, and observe both fork and
    trusted reruns before retaining a completed result.
    
    Finalize unfinished checks for confirmed canceled previous heads, retry
    interrupted history-status publication, and remember completed
    retirements to avoid repeatedly querying their attempts. History work
    examines at most ten recent branch runs and follows current-head
    publication.
    
    Resolve trusted runs against the default branch's current SHA rather
    than the PR's older base snapshot. Verify their exact contributor head
    and tested base, so an older trusted result cannot obscure a newer
    head's progress.
    
    ### Why are the changes needed?
    
    On #13506, a running custom check was absent from the latest-check view.
    On #14190, an existing successful allowlist check did not satisfy the
    expected commit-status context. On #14192, a fork still running was
    prematurely marked `action_required` because the PR changed CI
    definitions. Real-environment testing also reproduced stale run links
    and trusted dispatch rejection after the base branch advanced.
    
    Commit statuses are append-only, so a publisher writes the status from
    the check as stored, not from its own snapshot. During rollout, drain
    publisher runs created before this change: those runs retain their old
    concurrency and permission configuration. GitHub queues up to 100 runs
    per group; sweeps recover missed Fork CI signals, and committers can
    manually refresh either publisher. Scheduling and runner delays still
    affect update latency.
    
    Document close/reopen behavior and the exact trigger steps in
    `CONTRIBUTING.md`: cancel in the fork to stop a run, reopen to refresh
    results, and rerun in the fork or push a new/empty commit to start CI.
    **Update Fork CI Status** only refreshes results.
    
    ### Does this PR introduce _any_ user-facing change?
    
    PRs report queued/running progress with the corresponding run link.
    Failed jobs remain visible. Successful forks with CI edits report **Fork
    CI passed; trusted verification required** until verified by a trusted
    run. Completed old check-run links retain historical results; unfinished
    previous-head checks are finalized after their exact fork attempt
    confirms cancellation. The current PR rollup follows the latest check
    and commit status.
    
    ### How was this patch tested?
    
    - 86 Node tests pass. Polling is faster than main (one-minute passes in
    back-to-back sweeps instead of two-minute passes in sweeps 10-36 minutes
    apart), and each pass still probes every recently updated PR at the
    apache quota: the trusted-run listing is shared within a pass, settled
    statuses are verified once per sweep, and history retirement runs only
    after a refresh (a regression test fails if any of these is reverted).
    New regressions: transient watcher API errors and failed cancel
    requests, watcher-only failures judged by the suites, missing fork
    workflow (mirror, sweep probe and history retirement), status derived
    from a check stored by a concurrent writer, six polling passes covering
    all 60 recently updated PRs, and pending trusted verification. Each fix
    was reverted once to confirm a test fails.
    - Read-only run of the new `mirror()` against the real API: #13778
    changes from `Failed: Stop on first failure` to `All required suites
    passed`, #14187 from an unhandled 404 to the missing-workflow check;
    #14128 and #13506 are unchanged.
    - Live self-service test with the smoke PR #14150 (head = this PR plus
    an empty commit): the push started [a fork
    run](https://github.com/yuqi1129/gravitino/actions/runs/39938622027);
    **Run workflow** in the fork without a PR number started [a new
    run](https://github.com/yuqi1129/gravitino/actions/runs/39938650089)
    whose eligibility resolved the PR and cancelled the push run. After
    #14150 was closed at 10:40:30, the watcher reported `Cancelling the run
    because apache/gravitino#14150 was closed` at 10:41:27 and the run was
    cancelled.
    - Live chain probe in the sandbox (timelyflow/gravitino, same job layout
    capped at three hops, then removed): two chains started together ran
    seven sweeps strictly one at a time with 3-7 second gaps; each `rearm`
    dispatched the next sweep with `GITHUB_TOKEN`, and the chain stopped
    when the sweep output `chain=false`
    ([first](https://github.com/timelyflow/gravitino/actions/runs/39934761179),
    [last](https://github.com/timelyflow/gravitino/actions/runs/39935318794)).
    - Real GitHub probe in yuqi1129/gravitino: three pushes of a job with
    job-level `concurrency.queue: max` ran one after another
    ([1](https://github.com/yuqi1129/gravitino/actions/runs/39932213234),
    [2](https://github.com/yuqi1129/gravitino/actions/runs/39932222889),
    [3](https://github.com/yuqi1129/gravitino/actions/runs/39932233971)); a
    failing `continue-on-error` job left the run successful; in a group
    without `queue`, a newer pending job replaced the older one, as sweeps
    intend.
    - Before the latest fix commit, 69 Node tests passed. Independent
    fresh-context review of documentation head
    `c44aa723e1ed13cc935294242bad6063fc38551b` found no Blocker/Important.
    The publisher code is unchanged from `0ab290ae`, whose seven critical
    mutations were rejected. Regression mutations reject the old
    premature-error logic, stale default-branch resolution and
    settled-success shortcut that ignored trusted reruns.
    - Changed YAML passes actionlint 1.7.12 with its unsupported
    `concurrency.queue` diagnostic excluded. Real queued publishers executed
    successfully.
    - Actual GitHub Actions and GitHub Actions app tokens were used in
    timelyflow/gravitino PR #18 with contributor pushes to
    yuqi1129/gravitino. Caller jobs were short test fixtures; publisher code
    matched this PR, with repository identity adapted. The final
    scheduled-handler probe used the final publisher without moving the test
    base.
    
    | Real scenario | Observed result | Evidence |
    | --- | --- | --- |
    | CI edits while fork jobs run | Reproduced the old completed/error
    result; repaired the same attempt to `IN_PROGRESS` / `PENDING`, with the
    same fork URL in the PR rollup |
    [fork](https://github.com/yuqi1129/gravitino/actions/runs/39912299769),
    [legacy
    
publisher](https://github.com/timelyflow/gravitino/actions/runs/39912376668),
    [repair](https://github.com/timelyflow/gravitino/actions/runs/39912544166)
    |
    | Cancellation with failed Required CI | Reports failure and names the
    failed job |
    
[publisher](https://github.com/timelyflow/gravitino/actions/runs/39912797462)
    |
    | Advanced base / merge conflict | Reproduced stale-base dispatch
    rejection; corrected resolution reached frozen merge preflight and
    reported the actual conflict | [old
    
rejection](https://github.com/timelyflow/gravitino/actions/runs/39912794573),
    [conflict](https://github.com/timelyflow/gravitino/actions/runs/39913212924)
    |
    | New contributor head / successful CI edits | Previous-head trusted
    results no longer hide progress; fork success still requests trusted
    verification | [new
    fork](https://github.com/yuqi1129/gravitino/actions/runs/39913693166),
    
[publisher](https://github.com/timelyflow/gravitino/actions/runs/39913854040)
    |
    | Trusted success / full rerun | Exact-head trusted success is green;
    attempt 2 returns the PR to pending, then green | [trusted
    attempts](https://github.com/timelyflow/gravitino/actions/runs/39913976321),
    [running
    refresh](https://github.com/timelyflow/gravitino/actions/runs/39914440218),
    [recovery](https://github.com/timelyflow/gravitino/actions/runs/39914573361)
    |
    | Same-color stale URL | Injected success pointing to an older run;
    scheduled handler corrected the URL without recreating the metadata
    check |
    
[injection](https://github.com/timelyflow/gravitino/actions/runs/39914635708),
    [repair](https://github.com/timelyflow/gravitino/actions/runs/39914691920)
    |
    
    Additional push/close/reopen lifecycle tests used real GitHub events and
    the same publisher code:
    
    | Scenario | Observed result | Evidence |
    | --- | --- | --- |
    | Push B while A is running | GitHub automatically canceled A and
    started B; automatic PR synchronization pointed to B's pending status |
    [A,
    canceled](https://github.com/yuqi1129/gravitino/actions/runs/39918238583),
    [B](https://github.com/yuqi1129/gravitino/actions/runs/39918372924) |
    | Close while B runs, then reopen | B continued and succeeded while
    closed; reopening automatically published success for the same run and
    attempt, with no new fork run | [B attempt
    
1](https://github.com/yuqi1129/gravitino/actions/runs/39918372924/attempts/1)
    |
    | Cancel B while closed, then reopen | Reopening automatically reported
    attempt 2's failed Required CI; it did not silently restart or retain
    the old success. Manually rerunning failed jobs recovered success in
    attempt 3 | [B attempts
    2–3](https://github.com/yuqi1129/gravitino/actions/runs/39918372924) |
    | Previously stuck old-head check | Reproduced A's old upstream check
    remaining running after cancellation; the fixed publisher finalized it
    as cancelled/error while B remained successful |
    
[retirement](https://github.com/timelyflow/gravitino/actions/runs/39920132510)
    |
    | Settled history cost | A subsequent real publisher fetched zero
    historical workflow attempts, rather than repeatedly fetching
    already-retired attempts | [settled
    probe](https://github.com/timelyflow/gravitino/actions/runs/39920184500)
    |
    
    Upstream `GITHUB_TOKEN` is repository-scoped and cannot directly control
    contributors' fork runs. Closing a PR does **not** cancel a
    push-triggered fork run; while closed, PR status publishing stops.
    Reopening refreshes its existing run and does **not** start CI. Running
    CI again requires a fork rerun or another push. These are current
    capabilities, not a claim that close/reopen remotely controls
    contributors' Actions.
    
    Earlier live tests also covered [failure and partial-rerun
    recovery](https://github.com/yuqi1129/gravitino/actions/runs/39902849966),
    [allowlist
    
rejection](https://github.com/timelyflow/gravitino/actions/runs/39903961810),
    and [status-write permission
    failure](https://github.com/timelyflow/gravitino/actions/runs/39904765246)
    followed by [scheduled-handler
    repair](https://github.com/timelyflow/gravitino/actions/runs/39907658537).
    The final repair commit also triggered the actual [full Fork
    CI](https://github.com/yuqi1129/gravitino/actions/runs/39915782013); its
    [build / changes contract-test
    
step](https://github.com/yuqi1129/gravitino/actions/runs/39915782013/job/120289143994)
    passed. The run was then canceled to avoid long application builds; full
    application suites were not completed by these publishing tests.
    
    Scheduled-handler tests used explicit dispatch wrappers and do not
    establish native cron delivery. Native cron delivery and authenticated
    browser screenshot validation remain unverified. Actual check runs,
    commit statuses, PR rollups and links were inspected. The production PR
    continues to use the default branch's old publisher until this change is
    merged.
    
    Accepted review tradeoffs: settled history still needs one branch-run
    list and up to ten check lookups per visited PR. Its marker avoids
    repeated attempt/PR/status requests, rather than eliminating all history
    reads. Unsettled history needs additional validation and writes;
    current-head publication happens first.
    
    Recent probes and full publication re-read trusted-run listings to
    reduce stale observations. The current upstream list is small and sweeps
    retain their API budget; paginated calls can exceed individual request
    estimates, so this is not a strict per-PR request cap.
    
    Temporary default branch, PR base and contributor refs are restored
    after testing; temporary branches are removed and scoped runs are
    canceled. No secrets are added.
    
    
    The lifecycle repair commit also triggered [full Fork
    CI](https://github.com/yuqi1129/gravitino/actions/runs/39920441092); its
    [build / changes contract
    
tests](https://github.com/yuqi1129/gravitino/actions/runs/39920441092/job/120304312035)
    passed all 69 tests before the remaining long suites were canceled.
    
    
    The latest documentation-only commit
    `c44aa723e1ed13cc935294242bad6063fc38551b` adds a lifecycle table, fork
    Actions rerun instructions and an empty-commit push example.
    Publisher/workflow/test code is unchanged from `0ab290ae`; Markdown
    structure, shell-example syntax and `git diff --check` were verified.
    Independent review reran all 69 Node tests on this exact documentation
    head.
    
    ---------
    
    Co-authored-by: yuqi <[email protected]>
---
 .github/scripts/allowlist-pr.cjs              |   7 +-
 .github/scripts/fail-fast.cjs                 |  60 ++-
 .github/scripts/fork-ci-status.cjs            |  97 +++-
 .github/scripts/publish-ci-status.cjs         |  39 ++
 .github/scripts/resolve-fork-ci.cjs           |  14 +-
 .github/scripts/retire-fork-ci.cjs            |  68 +++
 .github/scripts/tests/fork-ci.test.cjs        | 680 +++++++++++++++++++++++++-
 .github/scripts/tests/retire-fork-ci.test.cjs |  99 ++++
 .github/scripts/update-fork-ci.cjs            | 119 ++++-
 .github/workflows/asf-allowlist-trusted.yml   |   6 +
 .github/workflows/fork-ci-status-update.yml   |  77 ++-
 .github/workflows/fork-ci.yml                 |  12 +-
 CONTRIBUTING.md                               |  30 +-
 13 files changed, 1215 insertions(+), 93 deletions(-)

diff --git a/.github/scripts/allowlist-pr.cjs b/.github/scripts/allowlist-pr.cjs
index bdbdb337a7..7b0f994851 100644
--- a/.github/scripts/allowlist-pr.cjs
+++ b/.github/scripts/allowlist-pr.cjs
@@ -15,6 +15,7 @@
 'use strict';
 const fs = require('node:fs/promises');
 const path = require('node:path');
+const { publishStatus } = require('./publish-ci-status.cjs');
 const MAX_FILE = 1024 * 1024;
 const MAX_TOTAL = 8 * MAX_FILE;
 
@@ -82,11 +83,15 @@ async function publish({ github, context, core }) {
     const { data: current } = await github.rest.pulls.get({ ...context.repo, 
pull_number: pr.number });
     if (current.state !== 'open' || current.head.sha !== pr.sha) continue;
     const success = process.env.SCAN_OUTCOME === 'success';
+    const details = 
`${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
     await github.rest.checks.create({ ...context.repo, name: 'ASF Allowlist 
Check', head_sha: pr.sha,
       status: 'completed', conclusion: success ? 'success' : 'failure',
-      details_url: 
`${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
+      details_url: details,
       output: { title: success ? 'Workflow actions are allowlisted' : 
'Workflow allowlist scan failed',
         summary: 'Scanned PR YAML as data with the reviewed default-branch 
workflow. No PR code was executed.' } });
+    await publishStatus({ github, repo: context.repo, sha: pr.sha, context: 
'ASF Allowlist Check',
+      state: success ? 'success' : 'failure',
+      description: success ? 'Workflow actions are allowlisted' : 'Workflow 
allowlist scan failed', targetUrl: details });
   }
   if (process.env.SCAN_OUTCOME !== 'success' && prs.length) 
core.setFailed('ASF allowlist scan failed.');
 }
diff --git a/.github/scripts/fail-fast.cjs b/.github/scripts/fail-fast.cjs
index c3c1c55b10..eb10923459 100644
--- a/.github/scripts/fail-fast.cjs
+++ b/.github/scripts/fail-fast.cjs
@@ -14,26 +14,72 @@
  */
 'use strict';
 const STOPPED = ['failure', 'timed_out'];
+const UPSTREAM = { owner: 'apache', repo: 'gravitino' };
+
+// apache/gravitino cannot cancel runs in a contributor's fork, so a fork run 
stops itself once
+// its pull request is closed. Returns the PR number when that happened.
+function closedPullRequest({ github, context, core }) {
+  const branch = context.ref.replace(/^refs\/heads\//, '');
+  const inFork = context.repo.owner !== UPSTREAM.owner || context.repo.repo 
!== UPSTREAM.repo;
+  let number;
+  return async () => {
+    if (!inFork || !context.ref.startsWith('refs/heads/')) return undefined;
+    try {
+      if (!number) {
+        // The run can start before the PR is opened, so keep looking until it 
exists.
+        const { data } = await github.rest.pulls.list({ ...UPSTREAM, state: 
'open',
+          head: `${context.repo.owner}:${branch}`, per_page: 10 });
+        number = data.find(pr => pr.head.repo?.full_name === 
`${context.repo.owner}/${context.repo.repo}`)?.number;
+        return undefined;
+      }
+      const { data } = await github.rest.pulls.get({ ...UPSTREAM, pull_number: 
number });
+      return data.state === 'open' ? undefined : number;
+    } catch (error) {
+      core.warning(`Could not read the upstream pull request: 
${error.message}.`);
+      return undefined;
+    }
+  };
+}
 
 // Jobs of one run can only be re-run after the whole run completes, and the 
slowest suite takes
 // hours. Cancel the run as soon as any job fails, so the author can re-run it 
right away.
 async function watch({ github, context, core, self, interval = 60000, sleep = 
ms => new Promise(resolve => setTimeout(resolve, ms)) }) {
   const attempt = Number(process.env.GITHUB_RUN_ATTEMPT || 1);
+  const closed = closedPullRequest({ github, context, core });
   let idle = 0;
   // Dependent jobs appear only once their needs finish, so require two quiet 
polls before exiting.
   while (idle < 2) {
-    const jobs = (await 
github.paginate(github.rest.actions.listJobsForWorkflowRunAttempt, {
-      ...context.repo, run_id: context.runId, attempt_number: attempt, 
per_page: 100
-    })).filter(job => job.name !== self);
+    let jobs;
+    try {
+      jobs = (await 
github.paginate(github.rest.actions.listJobsForWorkflowRunAttempt, {
+        ...context.repo, run_id: context.runId, attempt_number: attempt, 
per_page: 100
+      })).filter(job => job.name !== self);
+    } catch (error) {
+      // The watcher only observes the suites. A transient API error must not 
fail a run whose
+      // suites pass, so keep polling until the job times out.
+      core.warning(`Could not list the run's jobs: ${error.message}. Retrying 
in a minute.`);
+      await sleep(interval);
+      continue;
+    }
     const failed = jobs.filter(job => 
STOPPED.includes(job.conclusion)).map(job => job.name);
     const running = jobs.filter(job => job.status !== 'completed').map(job => 
job.name);
     core.info(`${jobs.length - running.length}/${jobs.length} jobs completed, 
` +
       `${failed.length} failed` + (running.length ? `; waiting for 
${running.slice(0, 5).join(', ')}` +
       (running.length > 5 ? ` and ${running.length - 5} more` : '') : ''));
-    if (failed.length) {
-      core.error(`Cancelling the run because these jobs failed: 
${failed.join(', ')}. Re-run failed jobs once it is cancelled.`);
-      await github.rest.actions.cancelWorkflowRun({ ...context.repo, run_id: 
context.runId });
-      return failed;
+    const closedPr = !failed.length && running.length ? await closed() : 
undefined;
+    if (failed.length || closedPr) {
+      if (failed.length) {
+        core.error(`Cancelling the run because these jobs failed: 
${failed.join(', ')}. Re-run failed jobs once it is cancelled.`);
+      } else {
+        core.notice(`Cancelling the run because apache/gravitino#${closedPr} 
was closed. ` +
+          'After reopening it, re-run all jobs or start a new run with Run 
workflow.');
+      }
+      try {
+        await github.rest.actions.cancelWorkflowRun({ ...context.repo, run_id: 
context.runId });
+        return failed;
+      } catch (error) {
+        core.warning(`Could not cancel the run: ${error.message}. Retrying in 
a minute.`);
+      }
     }
     idle = running.length ? 0 : idle + 1;
     if (idle < 2) await sleep(interval);
diff --git a/.github/scripts/fork-ci-status.cjs 
b/.github/scripts/fork-ci-status.cjs
index c7fb9af971..14e530d936 100644
--- a/.github/scripts/fork-ci-status.cjs
+++ b/.github/scripts/fork-ci-status.cjs
@@ -13,20 +13,37 @@
  * limitations under the License.
  */
 'use strict';
+const { TRUSTED_REQUIRED } = require('./publish-ci-status.cjs');
 const UPSTREAM = { owner: 'apache', repo: 'gravitino' };
 const SUITES = ['build', 'backend', 'spark', 'flink', 'trino', 
'iceberg_rest_trino', 'python',
   'frontend', 'access_control', 'idp_basic', 'mcp', 'maintenance', 'web_ui', 
'charts',
   'contrib_catalog', 'asf_allowlist'];
+// The fail-fast watcher in fork-ci.yml never fails the run 
(continue-on-error), but runs that
+// started before that change can still fail on the watcher's own API errors.
+const FAIL_FAST = 'Stop on first failure';
+const FORK_EVENTS = ['push', 'workflow_dispatch'];
 const requiredName = (pr, baseSha = pr.base.sha) => `Required CI / 
${pr.base.ref} / ${baseSha} / ${pr.head.sha}`;
+const MISSING_WORKFLOW = 'Your fork has no Fork CI workflow, so pushes to this 
branch do not run CI. ' +
+  'Merge or rebase the branch onto the latest base branch, which adds 
.github/workflows/fork-ci.yml, ' +
+  'make sure Actions are enabled in your fork, and push again.';
 const requiredAction = note => ({ status: 'completed', conclusion: 
'action_required',
   title: 'Fork CI needs attention', note });
 
 function evaluate(pr, run, jobs, baseSha = pr.base.sha) {
   const required = jobs.find(job => job.name === requiredName(pr, baseSha));
-  if (run.status !== 'completed') return { status: 'in_progress', title: 'Fork 
CI is running' };
-  if (run.conclusion !== 'success') {
+  if (run.status !== 'completed') {
+    // A workflow can remain queued while some of its matrix jobs already have 
runners.
+    const running = run.status === 'in_progress' || jobs.some(job => 
job.status === 'in_progress');
+    return { status: running ? 'in_progress' : 'queued',
+      title: running ? 'Fork CI is running' : 'Fork CI is queued, waiting for 
a runner' };
+  }
+  const failed = jobs.filter(job => job.name !== FAIL_FAST && ['failure', 
'timed_out'].includes(job.conclusion))
+    .map(job => job.name);
+  // A run that failed only because of the watcher is judged by its suites 
below.
+  const watcherOnly = run.conclusion === 'failure' && !failed.length &&
+    jobs.some(job => job.name === FAIL_FAST && ['failure', 
'timed_out'].includes(job.conclusion));
+  if (run.conclusion !== 'success' && !watcherOnly) {
     // Fork CI cancels its own run on the first failed job, so report that 
job, not the cancellation.
-    const failed = jobs.filter(job => ['failure', 
'timed_out'].includes(job.conclusion)).map(job => job.name);
     if (failed.length) return { status: 'completed', conclusion: 'failure',
       title: `Failed: ${failed[0]}${failed.length > 1 ? ` (+${failed.length - 
1} more)` : ''}`.slice(0, 200) };
     return { status: 'completed',
@@ -91,6 +108,12 @@ function createService(github) {
     }
     return [...latest.values()];
   }
+  async function acceptedBase(pr, sha) {
+    if (!sha || sha === pr.base.sha || !/^[0-9a-f]{40}$/.test(sha)) return 
pr.base.sha;
+    const { data } = await github.rest.repos.compareCommitsWithBasehead({ 
...UPSTREAM,
+      basehead: `${sha}...${pr.base.ref}`, per_page: 1 });
+    return ['ahead', 'identical'].includes(data.status) ? sha : pr.base.sha;
+  }
   async function testedBase(pr, jobs) {
     // pr.base.sha is not refreshed by later pushes, so runs usually test a 
newer base commit.
     // Accept that commit only if it is on the PR base branch. The check can 
stay green after the
@@ -99,42 +122,76 @@ function createService(github) {
     const suffix = ` / ${pr.head.sha}`;
     const job = jobs.find(job => job.name.startsWith(prefix) && 
job.name.endsWith(suffix));
     const sha = job?.name.slice(prefix.length, -suffix.length);
-    if (!sha || sha === pr.base.sha || !/^[0-9a-f]{40}$/.test(sha)) return 
pr.base.sha;
-    const { data } = await github.rest.repos.compareCommitsWithBasehead({ 
...UPSTREAM,
-      basehead: `${sha}...${pr.base.ref}`, per_page: 1 });
-    return ['ahead', 'identical'].includes(data.status) ? sha : pr.base.sha;
+    return acceptedBase(pr, sha);
   }
+  // Trusted runs of every PR share one listing per base branch. A service 
lives for one
+  // publisher pass, so the next pass re-reads it.
+  const trustedListings = new Map();
   async function findRun(pr, trusted) {
     const target = trusted ? UPSTREAM : { owner: pr.head.repo.owner.login, 
repo: pr.head.repo.name };
-    const all = await github.paginate(github.rest.actions.listWorkflowRuns, { 
...target, workflow_id: 'fork-ci.yml',
-      event: trusted ? 'workflow_dispatch' : 'push', head_sha: trusted ? 
pr.base.sha : pr.head.sha, per_page: 100 });
+    // Fork runs start from a push or from "Run workflow" in the fork; both 
test the fork's own code.
+    const list = () => github.paginate(github.rest.actions.listWorkflowRuns, { 
...target, workflow_id: 'fork-ci.yml',
+      ...(trusted ? { event: 'workflow_dispatch', branch: pr.base.ref } : { 
head_sha: pr.head.sha }), per_page: 100 });
+    if (trusted && !trustedListings.has(pr.base.ref)) 
trustedListings.set(pr.base.ref, list());
+    const all = trusted ? await trustedListings.get(pr.base.ref) : await 
list();
     const runs = all.filter(run => run.path === 
'.github/workflows/fork-ci.yml' &&
-      run.head_sha === (trusted ? pr.base.sha : pr.head.sha) &&
+      (trusted || FORK_EVENTS.includes(run.event)) &&
+      (trusted ? run.head_branch === pr.base.ref : run.head_sha === 
pr.head.sha) &&
       (!trusted || run.display_title === `Trusted Fork CI 
#${pr.number}`)).sort((a, b) => b.id - a.id);
     return runs[0] && { ...target, run: runs[0], trusted };
   }
   async function mirror(pr) {
     if (!pr.head.repo) return { result: requiredAction('The PR head repository 
no longer exists.') };
     // Read trusted metadata only. Nothing in the contributor repository is 
checked out or executed.
-    let ordinary = await findRun(pr, false);
+    let ordinary;
+    let missingWorkflow = false;
+    try {
+      ordinary = await findRun(pr, false);
+    } catch (error) {
+      // The fork never ran fork-ci.yml, e.g. the branch predates the Fork CI 
migration.
+      if (error.status !== 404) throw error;
+      missingWorkflow = true;
+    }
     const sameDefinitions = await unchanged(pr);
-    const trusted = await findRun(pr, true);
+    let trusted = await findRun(pr, true);
+    let trustedJobs;
+    if (trusted) {
+      trustedJobs = await jobsFor(trusted.owner, trusted.repo, trusted.run);
+      const exact = trustedJobs.some(job => job.name === requiredName(pr, 
trusted.run.head_sha));
+      // The display title identifies only the PR number. A previous 
contributor head can
+      // have a trusted result too; do not let it obscure this head's running 
fork jobs.
+      if (!exact && (trusted.run.status === 'completed' ||
+          trustedJobs.some(job => job.name.startsWith('Required CI / ')) ||
+          (ordinary && trusted.run.created_at < ordinary.run.created_at))) 
trusted = undefined;
+    }
     let selected = trusted && (!ordinary || (trusted.run.updated_at || 
trusted.run.created_at) >= (ordinary.run.updated_at || ordinary.run.created_at) 
|| !sameDefinitions)
       ? trusted : ordinary;
     // Run listings can lag behind, e.g. right after a push or during GitHub 
incidents, so keep
     // waiting instead of asking the author to act.
-    if (!selected) return { result: { status: 'queued', title: 'Waiting for 
Fork CI',
+    if (!selected && missingWorkflow) return { result: { 
...requiredAction(MISSING_WORKFLOW),
+      title: 'Fork CI has never run in your fork; merge the latest base branch 
and push' } };
+    if (!selected) return { result: { status: 'queued', title: 'Waiting for 
Fork CI; enable Actions in your fork if no run starts',
       note: 'No Fork CI run was found for this commit yet. If none starts, 
enable Actions in your fork and push again, ' +
         'or ask a committer to dispatch trusted Fork CI.' } };
     const { owner, repo, run } = selected;
-    if (!selected.trusted && !sameDefinitions) return { ...selected, fork: 
ordinary?.run,
-      result: requiredAction('CI workflow/action/script definitions or branch 
protection differ from the base. ' +
-        'A committer must dispatch Fork CI from the current upstream default 
branch with this PR number.') };
     // A manual upstream run uses the workflow definitions at run.head_sha, 
not the PR definitions.
-    const jobs = await jobsFor(owner, repo, run);
-    const base = selected.trusted ? pr.base.sha : await testedBase(pr, jobs);
-    return { ...selected, fork: ordinary?.run, base, result: evaluate(pr, run, 
jobs, base) };
+    const jobs = selected.trusted ? trustedJobs : await jobsFor(owner, repo, 
run);
+    const base = selected.trusted ? run.head_sha : !sameDefinitions ? 
pr.base.sha : await testedBase(pr, jobs);
+    let result = evaluate(pr, run, jobs, base);
+    if (selected.trusted && result.conclusion === 'success' && await 
acceptedBase(pr, base) !== base) {
+      result = requiredAction('Trusted CI tested a commit outside the current 
base branch. Dispatch trusted Fork CI again from the current default branch.');
+    }
+    if (!selected.trusted && !sameDefinitions) {
+      const note = 'CI workflow/action/script definitions or branch protection 
differ from the base. ' +
+        'After reviewing these changes, a committer must dispatch Fork CI from 
the current upstream default branch with this PR number.';
+      // Approval of CI edits is separate from execution progress. Never turn 
a running fork
+      // into a completed error, or hide an actual failure behind the trust 
requirement.
+      result = run.status === 'completed' && run.conclusion === 'success'
+        ? { ...requiredAction(note), title: TRUSTED_REQUIRED }
+        : { ...result, note: `${result.title}. ${note}` };
+    }
+    return { ...selected, fork: ordinary?.run, base, result };
   }
   return { mirror, unchanged, findRun };
 }
-module.exports = { SUITES, requiredName, evaluate, createService };
+module.exports = { FAIL_FAST, FORK_EVENTS, SUITES, requiredName, evaluate, 
createService };
diff --git a/.github/scripts/publish-ci-status.cjs 
b/.github/scripts/publish-ci-status.cjs
new file mode 100644
index 0000000000..b9af636517
--- /dev/null
+++ b/.github/scripts/publish-ci-status.cjs
@@ -0,0 +1,39 @@
+/*
+ * 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.
+ */
+'use strict';
+
+// Custom checks created with GITHUB_TOKEN can be attached to an older Actions 
suite and
+// disappear from the PR's latest-check view. Publish a commit status as the 
visible signal;
+// retain the check run separately for the immutable run/attempt metadata used 
by the updater.
+async function publishStatus({ github, repo, sha, context, state, description, 
targetUrl }) {
+  const params = { ...repo, sha, context, state, description: 
description.slice(0, 140), target_url: targetUrl };
+  const { data } = await github.rest.repos.getCombinedStatusForRef({ ...repo, 
ref: sha, per_page: 100 });
+  const previous = data.statuses.find(status => status.context === context);
+  if (previous?.state === state && previous.description === params.description 
&& previous.target_url === targetUrl) return;
+  await github.rest.repos.createCommitStatus(params);
+}
+
+// Waiting for a committer is not a failure of the contributor's change.
+const TRUSTED_REQUIRED = 'Fork CI passed; trusted verification required';
+
+// Accepts a computed result ({ status, conclusion, title }) or a check run 
(title in output).
+function statusState(result) {
+  if (result.status !== 'completed') return 'pending';
+  if ((result.title ?? result.output?.title) === TRUSTED_REQUIRED) return 
'pending';
+  if (result.conclusion === 'success') return 'success';
+  return result.conclusion === 'failure' || result.conclusion === 'timed_out' 
? 'failure' : 'error';
+}
+
+module.exports = { TRUSTED_REQUIRED, publishStatus, statusState };
diff --git a/.github/scripts/resolve-fork-ci.cjs 
b/.github/scripts/resolve-fork-ci.cjs
index 483d747594..305f2de7a1 100644
--- a/.github/scripts/resolve-fork-ci.cjs
+++ b/.github/scripts/resolve-fork-ci.cjs
@@ -22,19 +22,27 @@ async function resolve({ github, context, core }) {
   let baseRef = context.ref.replace(/^refs\/heads\//, '');
   let baseSha = headSha;
   let sync = false;
-  if (context.eventName === 'workflow_dispatch') {
+  // In a fork, "Run workflow" without a PR number starts a run for the 
selected branch, like a push.
+  const trustedDispatch = context.eventName === 'workflow_dispatch' &&
+    (repository === UPSTREAM || !!context.payload.inputs?.['pull-request']);
+  if (trustedDispatch) {
     if (repository !== UPSTREAM) throw new Error('Trusted runs must be 
dispatched in apache/gravitino.');
     const number = context.payload.inputs?.['pull-request'];
     if (!/^[1-9][0-9]*$/.test(String(number))) throw new Error('A pull request 
number is required.');
     const { data: pr } = await github.rest.pulls.get({ ...context.repo, 
pull_number: Number(number) });
     if (pr.state !== 'open' || !pr.head.repo) throw new Error('The pull 
request must be open with an existing head repository.');
     baseRef = pr.base.ref;
-    if (baseRef !== context.payload.repository.default_branch || context.sha 
!== pr.base.sha) {
+    if (baseRef !== context.payload.repository.default_branch || context.ref 
!== `refs/heads/${baseRef}`) {
+      throw new Error('Dispatch the trusted workflow from the current default 
branch; release branches use their own CI.');
+    }
+    // Pull requests retain an older base.sha after the base branch advances.
+    const { data: base } = await github.rest.repos.getBranch({ 
...context.repo, branch: baseRef });
+    if (context.sha !== base.commit.sha) {
       throw new Error('Dispatch the trusted workflow from the current default 
branch; release branches use their own CI.');
     }
     headSha = pr.head.sha;
     headRepository = pr.head.repo.full_name;
-    baseSha = pr.base.sha;
+    baseSha = base.commit.sha;
     sync = true;
   } else if (repository === UPSTREAM && (baseRef === 'main' || 
baseRef.startsWith('branch-'))) {
     // Post-merge CI tests the pushed upstream commit itself.
diff --git a/.github/scripts/retire-fork-ci.cjs 
b/.github/scripts/retire-fork-ci.cjs
new file mode 100644
index 0000000000..7749498a69
--- /dev/null
+++ b/.github/scripts/retire-fork-ci.cjs
@@ -0,0 +1,68 @@
+/*
+ * 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.
+ */
+'use strict';
+const { publishStatus } = require('./publish-ci-status.cjs');
+const { FORK_EVENTS } = require('./fork-ci-status.cjs');
+
+async function retireCancelled({ github, repo, pr }) {
+  if (!pr.head.repo || !pr.head.ref) return;
+  const fork = { owner: pr.head.repo.owner.login, repo: pr.head.repo.name };
+  // Bound history work independently of branch age. Most pushes supersede 
just one run.
+  let data;
+  try {
+    ({ data } = await github.rest.actions.listWorkflowRuns({ ...fork, 
workflow_id: 'fork-ci.yml',
+      branch: pr.head.ref, per_page: 10 }));
+  } catch (error) {
+    // The fork never ran fork-ci.yml, so there is no history to retire.
+    if (error.status === 404) return;
+    throw error;
+  }
+  for (const run of data.workflow_runs) {
+    if (run.head_sha === pr.head.sha || run.head_branch !== pr.head.ref ||
+        run.path !== '.github/workflows/fork-ci.yml' || 
!FORK_EVENTS.includes(run.event) ||
+        run.status !== 'completed' || run.conclusion !== 'cancelled') continue;
+    const checks = await github.paginate(github.rest.checks.listForRef, {
+      ...repo, ref: run.head_sha, check_name: 'Fork CI', filter: 'all', 
per_page: 100 });
+    const latest = checks.filter(check => check.app?.slug === 
'github-actions').sort((a, b) => b.id - a.id)[0];
+    let record;
+    try { record = JSON.parse(latest?.output?.text || '{}'); } catch { 
continue; }
+    // Never replace a trusted result, a newer attempt, or another run's 
metadata.
+    if (!latest || record.owner !== fork.owner || record.repo !== fork.repo || 
record.trusted ||
+        record.run_id !== run.id || record.run_attempt !== run.run_attempt ||
+        (latest.status === 'completed' && !record.superseded_by)) continue;
+    // A retired attempt is immutable. Remember a successful status write on 
the trusted
+    // check so later sweeps do not keep fetching its attempt, PR and status 
forever.
+    if (latest.status === 'completed' && latest.conclusion === 'cancelled' && 
record.retired_status_published) continue;
+    const { data: actual } = await github.rest.actions.getWorkflowRunAttempt({ 
...fork,
+      run_id: run.id, attempt_number: run.run_attempt });
+    if (actual.head_sha !== run.head_sha || actual.status !== 'completed' || 
actual.conclusion !== 'cancelled') continue;
+    const { data: current } = await github.rest.pulls.get({ ...repo, 
pull_number: pr.number });
+    if (current.state !== 'open' || current.head.sha !== pr.head.sha ||
+        current.head.repo?.full_name !== pr.head.repo.full_name) return;
+    const title = 'Fork CI cancelled for a previous commit';
+    const details = 
`https://github.com/${fork.owner}/${fork.repo}/actions/runs/${run.id}`;
+    const output = { title, summary: `[See fork CI results](${details})\n\n` +
+      `This cancelled run tested a previous PR commit. Current head: 
${pr.head.sha}.`,
+      text: JSON.stringify({ ...record, run_status: 'completed', 
run_conclusion: 'cancelled', superseded_by: pr.head.sha }) };
+    if (latest.status !== 'completed') await github.rest.checks.update({ 
...repo,
+      check_run_id: latest.id, status: 'completed', conclusion: 'cancelled', 
output });
+    // Retry this independently if a previous write completed the check but 
lost its status.
+    await publishStatus({ github, repo, sha: run.head_sha, context: 'Fork CI',
+      state: 'error', description: title, targetUrl: details });
+    await github.rest.checks.update({ ...repo, check_run_id: latest.id,
+      output: { ...output, text: JSON.stringify({ ...JSON.parse(output.text), 
retired_status_published: true }) } });
+  }
+}
+module.exports = { retireCancelled };
diff --git a/.github/scripts/tests/fork-ci.test.cjs 
b/.github/scripts/tests/fork-ci.test.cjs
index 7d922fbd87..e42a4c6688 100644
--- a/.github/scripts/tests/fork-ci.test.cjs
+++ b/.github/scripts/tests/fork-ci.test.cjs
@@ -19,13 +19,13 @@ const assert = require('node:assert/strict');
 const { mkdtemp, readFile, rm } = require('node:fs/promises');
 const os = require('node:os');
 const path = require('node:path');
-const { SUITES, requiredName, evaluate, createService } = 
require('../fork-ci-status.cjs');
+const { FAIL_FAST, SUITES, requiredName, evaluate, createService } = 
require('../fork-ci-status.cjs');
 const { resolve } = require('../resolve-fork-ci.cjs');
 const { yamlEntries, prepare } = require('../allowlist-pr.cjs');
 const A = 'a'.repeat(40), B = 'b'.repeat(40), C = 'c'.repeat(40);
 const pr = () => ({ number: 7, state: 'open', head: { sha: A, repo: { owner: { 
login: 'fork' }, name: 'repo', full_name: 'fork/repo' } },
   base: { ref: 'main', sha: B }, updated_at: '2026-01-01T00:00:00Z', html_url: 
'https://github.com/apache/gravitino/pull/7' });
-const run = () => ({ id: 1, run_attempt: 1, path: 
'.github/workflows/fork-ci.yml', head_sha: A,
+const run = () => ({ id: 1, run_attempt: 1, path: 
'.github/workflows/fork-ci.yml', head_sha: A, event: 'push',
   status: 'completed', conclusion: 'success', created_at: 
'2026-01-01T00:00:00Z' });
 const jobs = p => [{ name: requiredName(p), conclusion: 'success' },
   ...SUITES.map(suite => ({ name: `${suite} / changes`, conclusion: 'success' 
}))];
@@ -65,8 +65,8 @@ function serviceMock({ changed = false, trusted = false, 
forkJobs = jobs(pr()),
     git: { getTree: async ({ owner, tree_sha }) => ({ data: { tree: 
tree_sha.length === 40
       ? [{ path: '.github', sha: 'github-tree' }, { path: '.asf.yaml', sha: 
'policy' }]
       : ['workflows', 'actions', 'scripts'].map(name => ({ path: name, sha: 
changed && owner === 'fork' ? 'changed-' + name : name })) } }) },
-    actions: { listWorkflowRuns: async ({ event }) => ({ data: { 
workflow_runs: event === 'push' ? [run()] : trusted
-      ? [{ ...run(), id: 2, head_sha: trustedSha, display_title: 'Trusted Fork 
CI #7', created_at: '2026-01-02T00:00:00Z' }] : [] } }) }
+    actions: { listWorkflowRuns: async ({ event }) => ({ data: { 
workflow_runs: event !== 'workflow_dispatch' ? [run()] : trusted
+      ? [{ ...run(), id: 2, head_sha: trustedSha, head_branch: 'main', 
display_title: 'Trusted Fork CI #7', created_at: '2026-01-02T00:00:00Z' }] : [] 
} }) }
   } };
   github.paginate = async (endpoint, args) => runsOf(github, endpoint, args) 
?? (args.run_id === 2 ? trustedJobs : forkJobs);
   return github;
@@ -132,6 +132,55 @@ test('trusted dispatch rejects an arbitrary ref or a 
non-upstream repository', a
   await assert.rejects(resolve({ context: upstream, core: coreFor({}), github: 
{ rest: { pulls: { get: async () => ({ data: pr() }) } } } }), /current default 
branch/);
 });
 
+test('trusted dispatch resolves the current default branch after the PR base 
snapshot ages', async () => {
+  const ctx = context('apache/gravitino');ctx.eventName = 'workflow_dispatch';
+  ctx.ref = 'refs/heads/main';ctx.sha = C;ctx.payload.inputs = { 
'pull-request': '7' };
+  const output = {};
+  const github = { rest: { pulls: { get: async () => ({ data: pr() }) },
+    repos: { getBranch: async () => ({ data: { commit: { sha: C } } }) } } };
+  await resolve({ context: ctx, core: coreFor(output), github });
+  assert.equal(output['base-sha'], C);
+  assert.equal(output['head-sha'], A);
+  ctx.sha = B;
+  await assert.rejects(resolve({ context: ctx, core: coreFor({}), github }), 
/current default branch/);
+});
+
+test('trusted runs on an advanced base are found by branch and verified 
against their actual tested SHA', async () => {
+  for (const comparison of ['ahead', 'identical', 'diverged']) {
+    const tested = pr();tested.base.sha = C;
+    const github = serviceMock({ changed: true, trusted: true, trustedSha: C, 
trustedJobs: jobs(tested) });
+    github.rest.repos = { compareCommitsWithBasehead: async args => {
+      assert.equal(args.basehead, `${C}...main`);return { data: { status: 
comparison } };
+    } };
+    const list = github.rest.actions.listWorkflowRuns;
+    github.rest.actions.listWorkflowRuns = async args => {
+      if (args.event === 'workflow_dispatch') { assert.equal(args.branch, 
'main');assert.equal(args.head_sha, undefined); }
+      return list(args);
+    };
+    const mirrored = await createService(github).mirror(pr());
+    assert.equal(mirrored.base, C);
+    assert.equal(mirrored.result.conclusion, comparison === 'diverged' ? 
'action_required' : 'success');
+  }
+});
+
+test('a trusted result for a previous contributor head cannot hide current 
fork progress', async () => {
+  const previous = pr();previous.head.sha = C;
+  for (const trustedStatus of ['completed', 'in_progress']) {
+    const github = serviceMock({ changed: true, trusted: true, trustedJobs: 
jobs(previous) });
+    const list = github.rest.actions.listWorkflowRuns;
+    github.rest.actions.listWorkflowRuns = async args => {
+      const response = await list(args);
+      Object.assign(response.data.workflow_runs[0], args.event !== 
'workflow_dispatch'
+        ? { status: 'in_progress', conclusion: null } : { status: 
trustedStatus });
+      return response;
+    };
+    const mirrored = await createService(github).mirror(pr());
+    assert.equal(mirrored.trusted, false);
+    assert.equal(mirrored.result.status, 'in_progress');
+    assert.equal(mirrored.result.conclusion, undefined);
+  }
+});
+
 test('allowlist scan rejects paths, symlinks, oversized files and truncated 
listings', () => {
   const file = { path: 'workflows/build.yml', mode: '100644', type: 'blob', 
size: 10 };
   assert.equal(yamlEntries({ tree: [file] }).length, 1);
@@ -162,7 +211,7 @@ test('a newer fork rerun failure is not hidden by an older 
trusted success', asy
   const list = github.rest.actions.listWorkflowRuns;
   github.rest.actions.listWorkflowRuns = async args => {
     const response = await list(args);
-    if (args.event === 'push') Object.assign(response.data.workflow_runs[0], { 
conclusion: 'failure', updated_at: '2026-01-03T00:00:00Z' });
+    if (args.event !== 'workflow_dispatch') 
Object.assign(response.data.workflow_runs[0], { conclusion: 'failure', 
updated_at: '2026-01-03T00:00:00Z' });
     return response;
   };
   assert.equal((await createService(github).mirror(pr())).result.conclusion, 
'failure');
@@ -221,32 +270,43 @@ function publisherMock({ current = pr(), latest, rerun = 
run(), failTrees = fals
   const github = serviceMock();
   const writes = [];
   const counters = { runs: 0 };
+  const statuses = [];
+  let combinedStatuses = [];
   const listForRef = () => {};
   const listPulls = () => {};
   let reads = 0;
   github.rest.pulls = { list: listPulls, get: async () => ({ data: ++reads === 
1 ? pr() : current }) };
-  github.rest.repos = { getContent: async () => ({ data: {} }) };
+  github.rest.repos = { getContent: async () => ({ data: {} }),
+    getCombinedStatusForRef: async () => ({ data: { statuses: combinedStatuses 
} }),
+    createCommitStatus: async args => { statuses.push(args); combinedStatuses 
= [{ ...args }, ...combinedStatuses.filter(s => s.context !== args.context)]; } 
};
   github.rest.rateLimit = { get: async () => ({ data: { resources: { core: { 
remaining: 900, limit: 1000 } } } }) };
+  // Writes are visible to later reads, as on GitHub: the status is derived 
from the check as stored.
+  let stored = latest ? [{ ...latest }] : [];
   github.rest.checks = { listForRef,
-    create: async args => { writes.push({ create: args }); },
-    update: async args => { writes.push({ update: args }); } };
+    create: async args => { writes.push({ create: args });
+      stored = [...stored, { id: 1000 + writes.length, app: { slug: 
'github-actions' }, ...args }]; },
+    update: async args => { writes.push({ update: args });
+      const check = stored.find(c => c.id === args.check_run_id);
+      if (check) Object.assign(check, args, args.status !== 'completed' ? { 
conclusion: null } : {}); } };
   github.rest.actions.listWorkflowRuns = async args => {
     counters.runs++;
-    return { data: { workflow_runs: args.event === 'push' ? [rerun] : [] } };
+    return { data: { workflow_runs: args.event !== 'workflow_dispatch' ? 
[rerun] : [] } };
   };
   if (failTrees) github.rest.git.getTree = async () => { throw new Error('API 
unavailable'); };
   github.paginate = async (endpoint, args) => runsOf(github, endpoint, args) 
?? (endpoint === listPulls ? [pr()]
-    : endpoint === listForRef ? (latest ? [latest] : []) : jobs(pr()));
+    : endpoint === listForRef ? stored : jobs(pr()));
   github.hook = { before() {} };
-  return { github, writes, counters };
+  return { github, writes, counters, statuses };
 }
 async function withPublisher(fn) {
   const directory = await mkdtemp(path.join(os.tmpdir(), 
'fork-ci-publisher-test-'));
   const stateFile = path.join(directory, 'state.json');
   const ctx = context('apache/gravitino');ctx.eventName = 
'workflow_dispatch';ctx.payload.inputs = { 'pull-request': '7' };
   const failures = [];
-  const core = { info() {}, warning() {}, setFailed: message => 
failures.push(message) };
-  try { await fn({ ctx, stateFile, core, failures }); }
+  const outputs = {};
+  const core = { info() {}, warning() {}, setFailed: message => 
failures.push(message),
+    setOutput: (name, value) => { outputs[name] = value; } };
+  try { await fn({ ctx, stateFile, core, failures, outputs }); }
   finally { await rm(directory, { recursive: true, force: true }); }
 }
 test('publisher binds the check to the exact base and current run attempt', 
async () => withPublisher(async ({ ctx, stateFile, core }) => {
@@ -307,7 +367,7 @@ test('partial reruns retain successful callers but use each 
job newest result',
   const p = pr();
   const github = serviceMock();
   github.rest.actions.listWorkflowRuns = async ({ event }) => ({ data: {
-    workflow_runs: event === 'push' ? [{ ...run(), run_attempt: 2 }] : []
+    workflow_runs: event !== 'workflow_dispatch' ? [{ ...run(), run_attempt: 2 
}] : []
   } });
   let rerunConclusion = 'success';
   github.paginate = async (endpoint, args) => {
@@ -366,6 +426,9 @@ test('scheduled sweeps probe recently updated PRs first and 
refresh only running
   const old = Array.from({ length: 20 }, (_, i) => ({ ...pr(), number: 100 + i,
     head: { ...pr().head, sha: (100 + i).toString(16).padStart(40, 'd') } }));
   const settled = { ...pr(), number: 8, updated_at: recent, head: { 
...pr().head, sha: 'e'.repeat(40) } };
+  github.rest.repos.getCombinedStatusForRef = async ({ ref }) => ({ data: { 
statuses:
+    ref === settled.head.sha ? [{ context: 'Fork CI', state: 'error', 
description: 'Fork CI cancelled',
+      target_url: 'https://github.com/fork/repo/actions/runs/1' }] : [] } });
   const failed = { ...pr(), updated_at: recent };
   const all = [...old, settled, failed];
   const listPulls = github.rest.pulls.list;
@@ -373,7 +436,8 @@ test('scheduled sweeps probe recently updated PRs first and 
refresh only running
   const paginate = github.paginate;
   // Any completed result, not only success, is final until the fork run id or 
attempt changes.
   const check = completed => ({ id: 1, app: { slug: 'github-actions' }, 
status: completed ? 'completed' : 'in_progress',
-    conclusion: completed ? 'cancelled' : null, output: { text: 
JSON.stringify({ run_id: 1, run_attempt: 1 }) } });
+    conclusion: completed ? 'cancelled' : null, output: { title: 'Fork CI 
cancelled', text: JSON.stringify({ owner: 'fork', repo: 'repo', run_id: 1, 
run_attempt: 1,
+      run_status: completed ? 'completed' : 'in_progress' }) } });
   github.paginate = async (endpoint, args) => endpoint === listPulls ? all
     : endpoint === listForRef ? [check(args.ref === settled.head.sha)] : 
paginate(endpoint, args);
   github.rest.pulls.get = async ({ pull_number }) => ({ data: all.find(p => 
p.number === pull_number) });
@@ -386,7 +450,7 @@ test('scheduled sweeps probe recently updated PRs first and 
refresh only running
     return response;
   };
   await update({ github, context: ctx, core, stateFile, sleep: async () => {} 
});
-  // The settled check is probed for its fork run once, which is unchanged, so 
it is not refreshed.
+  // Probe both the fork and trusted runs; neither changed, so no full refresh 
is needed.
   const written = writes.map(write => write.create || write.update);
   assert.equal(runsFor.filter(sha => sha === settled.head.sha).length, 1);
   assert.equal(written[0].conclusion, 'success');
@@ -400,9 +464,14 @@ test('a failed fork run is not summarized as verified', 
async () => withPublishe
   assert.doesNotMatch(writes[0].create.output.summary, /Verified/);
 }));
 
-test('PR signals do not share a concurrency group', async () => {
+test('signals queue in order while scheduled sweeps poll in their own group', 
async () => {
   const workflow = await readFile(path.join(__dirname, 
'../../workflows/fork-ci-status-update.yml'), 'utf8');
-  assert.match(workflow, /github\.event\.workflow_run\.head_sha \|\| 'sweep'/);
+  // No workflow-level group: a polling sweep must never block queued PR 
signals.
+  assert.doesNotMatch(workflow, /^concurrency:/m);
+  assert.match(workflow, /^  update:\n(?:.*\n)*?.*github\.event_name != 
'schedule'(?:.*\n)*?\s+group: fork-ci-status-update\n\s+queue: 
max\n\s+cancel-in-progress: false\n/m);
+  assert.match(workflow, /^  sweep:\n\s+if: >-\n(?:.*\n)*?.*github\.event_name 
== 'schedule' \|\| \(github\.event_name == 'workflow_dispatch' && 
inputs\.sweep\)\)\n(?:.*\n)*?\s+group: 
fork-ci-status-sweep\n\s+cancel-in-progress: false\n/m);
+  // A newer schedule replaces a pending sweep rather than piling up behind 
11-minute sweeps.
+  assert.doesNotMatch(workflow.slice(workflow.indexOf('\n  sweep:')), 
/queue:/);
 });
 
 test('scheduled sweeps refresh a settled check when its fork run is rerun', 
async () => withPublisher(async ({ ctx, stateFile, core }) => {
@@ -456,10 +525,12 @@ const { watch } = require('../fail-fast.cjs');
 function watcherMock(polls) {
   const calls = { cancel: 0, polls: 0 };
   const github = { rest: { actions: { listJobsForWorkflowRunAttempt() {},
-    cancelWorkflowRun: async args => { calls.cancel++; calls.run_id = 
args.run_id; } } },
+    cancelWorkflowRun: async args => { calls.cancel++; calls.run_id = 
args.run_id; } },
+  // No upstream PR for the branch unless a test opens one.
+  pulls: { list: async () => ({ data: [] }), get: async () => ({ data: { 
state: 'open' } }) } },
   paginate: async (endpoint, args) => { calls.attempt = args.attempt_number; 
return polls[Math.min(calls.polls++, polls.length - 1)]; } };
   const errors = [];
-  return { github, calls, errors, context: { repo: { owner: 'fork', repo: 
'repo' }, runId: 42 },
+  return { github, calls, errors, context: { repo: { owner: 'fork', repo: 
'repo' }, ref: 'refs/heads/topic', runId: 42 },
     core: { error: message => errors.push(message), info: message => 
errors.push(message) }, self: 'Stop on first failure', sleep: async () => {} };
 }
 const job = (name, status, conclusion = null) => ({ name, status, conclusion 
});
@@ -505,6 +576,231 @@ test('only the fail-fast job can cancel the run', async 
() => {
   assert.match(workflow, /fail_fast:\n(?:.*\n)*?\s+runs-on: ubuntu-latest\n/);
 });
 
+test('every one-minute pass probes all recently updated PRs at the apache 
quota', async () => withPublisher(async ({ ctx, core }) => {
+  ctx.eventName = 'schedule'; delete ctx.payload.inputs;
+  const { github } = publisherMock();
+  // apache/gravitino has had 13-17 PRs updated within six hours; leave 
headroom.
+  const recent = Array.from({ length: 17 }, (_, i) => ({ ...pr(), number: 300 
+ i, updated_at: new Date().toISOString(),
+    head: { ...pr().head, sha: (300 + i).toString(16).padStart(40, 'e') } }));
+  const listPulls = github.rest.pulls.list, listForRef = 
github.rest.checks.listForRef, paginate = github.paginate;
+  let count = () => {};
+  github.hook = { before(_, hook) { count = hook; } };
+  github.rest.rateLimit.get = async () => ({ data: { resources: { core: { 
remaining: 15000, limit: 15000 } } } });
+  const record = { owner: 'fork', repo: 'repo', run_id: 1, run_attempt: 1, 
run_status: 'completed', fork_run_id: 1, fork_run_attempt: 1 };
+  const settled = { id: 1, app: { slug: 'github-actions' }, status: 
'completed', conclusion: 'success',
+    output: { title: 'All required suites passed', text: 
JSON.stringify(record) } };
+  github.rest.repos.getCombinedStatusForRef = async () => { count(); return { 
data: { statuses: [{ context: 'Fork CI', state: 'success',
+    description: 'All required suites passed', target_url: 
'https://github.com/fork/repo/actions/runs/1' }] } }; };
+  const perPass = [new Set()];
+  github.paginate = async (endpoint, args) => {
+    count();
+    if (endpoint === listPulls) return recent;
+    if (endpoint === listForRef) { perPass.at(-1).add(args.ref); return 
[settled]; }
+    return paginate(endpoint, args);
+  };
+  let t = Date.now();
+  await update({ github, context: ctx, core, clock: () => t, sleep: async ms 
=> { t += ms; perPass.push(new Set()); } });
+  assert.equal(perPass.length, 11);
+  for (const seen of perPass) assert.equal(seen.size, recent.length);
+}));
+
+test('each polling sweep dispatches the next; only that job can write Actions 
state', async () => {
+  const workflow = await readFile(path.join(__dirname, 
'../../workflows/fork-ci-status-update.yml'), 'utf8');
+  assert.match(workflow, /^      sweep:\n\s+description: .*\n\s+required: 
false\n\s+type: boolean\n/m);
+  assert.match(workflow, /!\(github\.event_name == 'workflow_dispatch' && 
inputs\.sweep\)/);
+  assert.match(workflow, /outputs:\n\s+chain: \$\{\{ 
steps\.sweep\.outputs\.chain \}\}\n/);
+  const rearm = workflow.slice(workflow.indexOf('\n  rearm:'));
+  assert.match(rearm, /needs: sweep\n\s+if: \$\{\{ !cancelled\(\) && 
needs\.sweep\.outputs\.chain == 'true' \}\}/);
+  assert.match(rearm, /permissions:\n\s+actions: write\n\s+steps:/);
+  assert.doesNotMatch(rearm, /checkout|require\(/);
+  assert.match(rearm, /context\.ref !== 
`refs\/heads\/\$\{repository\.default_branch\}`/);
+  assert.match(rearm, /createWorkflowDispatch\(\{[^}]*workflow_id: 
'fork-ci-status-update\.yml', ref: repository\.default_branch, inputs: \{ 
sweep: 'true' \}/);
+  // The publishers themselves never get write access to Actions.
+  assert.equal(workflow.match(/^\s+actions: write$/gm).length, 1);
+});
+
+test('a dispatched sweep polls like a scheduled one and continues the chain 
only when polling', async () => {
+  const run = async (eventName, inputs, limit) => {
+    let observed;
+    await withPublisher(async ({ ctx, core, outputs }) => {
+      ctx.eventName = eventName; ctx.payload.inputs = inputs;
+      const { github } = publisherMock();
+      github.rest.rateLimit.get = async () => ({ data: { resources: { core: { 
remaining: limit, limit } } } });
+      let sleeps = 0;
+      await update({ github, context: ctx, core, sleep: async () => { 
sleeps++; } });
+      observed = { chain: outputs.chain, passes: sleeps + 1 };
+    });
+    return observed;
+  };
+  assert.deepEqual(await run('workflow_dispatch', { sweep: 'true', 
'pull-request': '' }, 15000), { chain: 'true', passes: 11 });
+  assert.deepEqual(await run('workflow_dispatch', { sweep: true }, 15000), { 
chain: 'true', passes: 11 });
+  assert.deepEqual(await run('schedule', undefined, 15000), { chain: 'true', 
passes: 11 });
+  // A single-pass sweep at a small quota must not run back to back.
+  assert.deepEqual(await run('schedule', undefined, 1000), { chain: 'false', 
passes: 1 });
+  // A manual refresh is one pass and never starts a chain.
+  assert.deepEqual(await run('workflow_dispatch', { 'pull-request': '7' }, 
15000), { chain: undefined, passes: 1 });
+});
+
+test('Run workflow in a fork tests the selected branch like a push', async () 
=> {
+  const output = {};
+  const ctx = context('fork/repo', true);ctx.eventName = 
'workflow_dispatch';ctx.payload.inputs = { 'pull-request': '' };
+  await resolve({ context: ctx, core: coreFor(output), github: {
+    rest: { pulls: { list() {} }, repos: { get: async () => ({ data: { fork: 
true, source: { full_name: 'apache/gravitino' } } }),
+      getBranch: async () => ({ data: { commit: { sha: B } } }) } }, paginate: 
async () => [pr()] } });
+  assert.equal(output.sync, 'true'); assert.equal(output['base-sha'], B); 
assert.equal(output['head-sha'], A);
+  assert.equal(output['head-repository'], 'fork/repo');
+  // Naming a PR still means a trusted run, which only apache/gravitino may 
dispatch.
+  ctx.payload.inputs = { 'pull-request': '7' };
+  await assert.rejects(resolve({ context: ctx, core: coreFor({}), github: {} 
}), /apache/);
+  const upstream = context('apache/gravitino');upstream.eventName = 
'workflow_dispatch';upstream.payload.inputs = { 'pull-request': '' };
+  await assert.rejects(resolve({ context: upstream, core: coreFor({}), github: 
{} }), /pull request number is required/);
+});
+
+test('a push and Run workflow in the fork share one cancel-in-progress group 
per branch', async () => {
+  const workflow = await readFile(path.join(__dirname, 
'../../workflows/fork-ci.yml'), 'utf8');
+  assert.match(workflow, /pull-request:\n\s+description: .*\n\s+required: 
false\n/);
+  assert.match(workflow, /group: fork-ci-\$\{\{ inputs\.pull-request && 
format\('trusted-\{0\}', inputs\.pull-request\) \|\| github\.ref 
\}\}\n\s+cancel-in-progress: true/);
+  assert.match(workflow, /github\.event_name == 'workflow_dispatch' && 
inputs\.pull-request != '' && format\('Trusted Fork CI #\{0\}'/);
+});
+
+test('the publisher mirrors fork runs started with Run workflow, and only Fork 
CI events', async () => {
+  const github = serviceMock();
+  github.rest.actions.listWorkflowRuns = async ({ event }) => ({ data: { 
workflow_runs: event === 'workflow_dispatch' ? []
+    : [{ ...run(), id: 5, event: 'workflow_dispatch', conclusion: 'failure' }, 
{ ...run(), id: 9, event: 'schedule' }, run()] } });
+  github.paginate = async (endpoint, args) => runsOf(github, endpoint, args) ??
+    (args.run_id === 5 ? [...jobs(pr()), { name: 'build / build', conclusion: 
'failure' }] : jobs(pr()));
+  const mirrored = await createService(github).mirror(pr());
+  assert.equal(mirrored.run.id, 5); assert.equal(mirrored.result.title, 
'Failed: build / build');
+});
+
+test('a fork run stops itself once its pull request is closed', async () => {
+  const running = [job('build / build', 'in_progress')], done = [job('build / 
build', 'completed', 'success')];
+  // Without the closed-PR check the watcher would wait for the jobs to finish 
and never cancel.
+  const mock = watcherMock([running, running, running, running, done, done]);
+  let state = 'open', lookups = 0;
+  mock.github.rest.pulls.list = async args => {
+    assert.deepEqual([args.owner, args.repo, args.head, args.state], 
['apache', 'gravitino', 'fork:topic', 'open']);
+    // The PR is opened after the run started.
+    return { data: lookups++ ? [{ number: 7, head: { repo: { full_name: 
'fork/repo' } } }] : [] };
+  };
+  mock.github.rest.pulls.get = async args => { assert.equal(args.pull_number, 
7); const now = state; state = 'closed'; return { data: { state: now } }; };
+  const notices = [];
+  mock.core.notice = message => notices.push(message);
+  assert.deepEqual(await watch(mock), []);
+  assert.equal(mock.calls.cancel, 1); assert.equal(mock.calls.polls, 4);
+  assert.match(notices[0], /apache\/gravitino#7 was closed.*re-run all jobs or 
start a new run with Run workflow/);
+});
+
+test('closed-PR checks never cancel upstream runs and tolerate API errors', 
async () => {
+  const running = [job('build / build', 'in_progress')], done = [job('build / 
build', 'completed', 'success')];
+  const upstream = watcherMock([running, done, done]);
+  upstream.context.repo = { owner: 'apache', repo: 'gravitino' };
+  upstream.github.rest.pulls.list = async () => { throw new Error('must not 
look up PRs upstream'); };
+  assert.deepEqual(await watch(upstream), []);
+  assert.equal(upstream.calls.cancel, 0);
+  const flaky = watcherMock([running, done, done]);
+  const warnings = [];
+  flaky.core.warning = message => warnings.push(message);
+  flaky.github.rest.pulls.list = async () => { throw new Error('Server 
Error'); };
+  assert.deepEqual(await watch(flaky), []);
+  assert.equal(flaky.calls.cancel, 0); assert.match(warnings[0], /upstream 
pull request: Server Error/);
+});
+
+test('fork results tell the author how to run CI again without a new commit', 
async () => withPublisher(async ({ ctx, core }) => {
+  const { github, writes } = publisherMock({ rerun: { ...run(), conclusion: 
'failure' } });
+  const p = { ...pr(), head: { ...pr().head, ref: 'topic' } };
+  github.rest.pulls.get = async () => ({ data: p });
+  github.paginate = (paginate => async (endpoint, args) => endpoint === 
github.rest.pulls.list ? [p] : paginate(endpoint, args))(github.paginate);
+  await update({ github, context: ctx, core, sleep: async () => {} });
+  const summary = writes[0].create.output.summary;
+  assert.match(summary, /open \[the 
run\]\(https:\/\/github\.com\/fork\/repo\/actions\/runs\/1\) and choose 
\*\*Re-run jobs\*\*/);
+  assert.match(summary, /\[Run 
workflow\]\(https:\/\/github\.com\/fork\/repo\/actions\/workflows\/fork-ci\.yml\)
 on branch `topic`/);
+  assert.match(summary, /Re-run button on this check only refreshes this 
status/);
+}));
+
+test('transient API errors never fail the watcher or the run', async () => {
+  const mock = watcherMock([[job('build / build', 'in_progress')],
+    [job('build / build', 'completed', 'success')], [job('build / build', 
'completed', 'success')]]);
+  const paginate = mock.github.paginate;
+  let errors = 2;
+  mock.github.paginate = async (...args) => {
+    if (errors-- > 0) throw Object.assign(new Error('Server Error'), { status: 
500 });
+    return paginate(...args);
+  };
+  const warnings = [];
+  mock.core.warning = message => warnings.push(message);
+  assert.deepEqual(await watch(mock), []);
+  assert.equal(warnings.length, 2); assert.match(warnings[0], /Server Error\. 
Retrying in a minute/);
+  assert.equal(mock.errors.at(-1), 'All jobs completed without a failure.');
+});
+
+test('a failed cancel request is retried on the next poll', async () => {
+  const failing = [job('backend / BackendIT (h2)', 'completed', 'failure')];
+  const mock = watcherMock([failing, failing]);
+  const cancel = mock.github.rest.actions.cancelWorkflowRun;
+  let first = true;
+  mock.github.rest.actions.cancelWorkflowRun = async args => {
+    if (first) { first = false; throw new Error('Server Error'); }
+    return cancel(args);
+  };
+  mock.core.warning = () => {};
+  assert.deepEqual(await watch(mock), ['backend / BackendIT (h2)']);
+  assert.equal(mock.calls.cancel, 1); assert.equal(mock.calls.polls, 2);
+});
+
+test('the watcher cannot fail the run, and a watcher-only failure is judged by 
the suites', async () => {
+  const workflow = await readFile(path.join(__dirname, 
'../../workflows/fork-ci.yml'), 'utf8');
+  assert.match(workflow, /fail_fast:\n(?:.*\n)*?\s+continue-on-error: true\n/);
+  assert.match(workflow, new RegExp(`self: '${FAIL_FAST}'`));
+  const p = pr();
+  // Runs started before continue-on-error: only the watcher failed, every 
suite passed.
+  const watcherFailed = [...jobs(p), { name: FAIL_FAST, conclusion: 'failure' 
}];
+  const result = evaluate(p, { ...run(), conclusion: 'failure' }, 
watcherFailed);
+  assert.equal(result.conclusion, 'success'); assert.equal(result.title, 'All 
required suites passed');
+  // A real failure is still reported, never the watcher.
+  const both = [...watcherFailed, { name: 'backend / BackendIT (h2)', 
conclusion: 'failure' }];
+  assert.equal(evaluate(p, { ...run(), conclusion: 'failure' }, both).title, 
'Failed: backend / BackendIT (h2)');
+  // Without a passing Required CI job the run still cannot be green.
+  const noRequired = watcherFailed.filter(j => j.name !== requiredName(p));
+  assert.equal(evaluate(p, { ...run(), conclusion: 'failure' }, 
noRequired).conclusion, 'action_required');
+});
+
+const notFound = () => Object.assign(new Error('Not Found'), { status: 404 });
+
+test('a fork without the Fork CI workflow asks the author to update the 
branch', async () => {
+  const github = serviceMock();
+  github.rest.actions.listWorkflowRuns = async ({ event }) => {
+    if (event !== 'workflow_dispatch') throw notFound();
+    return { data: { workflow_runs: [] } };
+  };
+  const { result } = await createService(github).mirror(pr());
+  assert.equal(result.status, 'completed'); assert.equal(result.conclusion, 
'action_required');
+  assert.match(result.title, /never run in your fork; merge the latest base 
branch/);
+  assert.match(result.note, /adds \.github\/workflows\/fork-ci\.yml/);
+  // Other errors are still failures of the publisher, not of the PR.
+  github.rest.actions.listWorkflowRuns = async () => { throw Object.assign(new 
Error('Server Error'), { status: 500 }); };
+  await assert.rejects(createService(github).mirror(pr()), /Server Error/);
+});
+
+test('sweeps publish the missing-workflow check once and then leave it 
settled', async () => withPublisher(async ({ ctx, core, failures }) => {
+  ctx.eventName = 'schedule'; delete ctx.payload.inputs;
+  const recent = { ...pr(), head: { ...pr().head, ref: 'topic' }, updated_at: 
new Date().toISOString() };
+  const { github, writes, statuses } = publisherMock();
+  github.rest.actions.listWorkflowRuns = async ({ event }) => {
+    if (event !== 'workflow_dispatch') throw notFound();
+    return { data: { workflow_runs: [] } };
+  };
+  const listPulls = github.rest.pulls.list, paginate = github.paginate;
+  github.paginate = async (endpoint, args) => endpoint === listPulls ? 
[recent] : paginate(endpoint, args);
+  github.rest.pulls.get = async () => ({ data: recent });
+  github.rest.rateLimit.get = async () => ({ data: { resources: { core: { 
remaining: 15000, limit: 15000 } } } });
+  await update({ github, context: ctx, core, sleep: async () => {} });
+  assert.deepEqual(failures, []);
+  assert.equal(writes.length, 1); assert.equal(writes[0].create.conclusion, 
'action_required');
+  assert.equal(statuses.length, 1); assert.equal(statuses[0].state, 'error');
+  assert.equal(statuses[0].target_url, recent.html_url);
+}));
+
 test('scheduled sweeps scale their budget with the hourly quota', async () => 
withPublisher(async ({ ctx, stateFile, core }) => {
   ctx.eventName = 'schedule'; delete ctx.payload.inputs;
   const probed = async limit => {
@@ -517,6 +813,7 @@ test('scheduled sweeps scale their budget with the hourly 
quota', async () => wi
     github.hook = { before(_, hook) { count = hook; } };
     github.rest.rateLimit.get = async () => ({ data: { resources: { core: { 
remaining: limit, limit } } } });
     const seen = new Set();
+    github.rest.repos.getCombinedStatusForRef = async () => ({ data: { 
statuses: [{ context: 'Fork CI', state: 'success' }] } });
     github.paginate = async (endpoint, args) => {
       count();
       if (endpoint === listPulls) return recent;
@@ -524,15 +821,15 @@ test('scheduled sweeps scale their budget with the hourly 
quota', async () => wi
         conclusion: 'success', output: { text: JSON.stringify({ run_id: 1, 
run_attempt: 1 }) } }]; }
       return paginate(endpoint, args);
     };
-    // Each pass waits two minutes of fake time, which also advances the 
rotation.
+    // Each pass spends its share of the sweep budget, then the sweep sleeps 
until the next pass.
     let t = Date.now(), passes = 0;
     await update({ github, context: ctx, core, stateFile, clock: () => t, 
sleep: async ms => { t += ms; passes++; } });
     return { seen: seen.size, passes: passes + 1 };
   };
-  // A small quota keeps a single pass; a large one keeps polling within the 
loop window.
+  // Small quotas keep one pass; large ones poll every minute within the 
11-minute sweep.
   const small = await probed(1000), large = await probed(15000);
   assert.equal(small.passes, 1); assert.ok(small.seen < 60);
-  assert.equal(large.passes, 6); assert.equal(large.seen, 60);
+  assert.equal(large.passes, 11); assert.equal(large.seen, 60);
 }));
 
 test('a missing fork run waits instead of asking the author to act', async () 
=> {
@@ -552,7 +849,7 @@ test('an incomplete run listing does not replace a recorded 
run', async () => wi
   assert.equal(writes.length, 0);
 }));
 
-test('a scheduled sweep mirrors a fork run that finishes during its polling 
window', async () => withPublisher(async ({ ctx, stateFile, core }) => {
+test('successive short scheduled sweeps mirror a fork run from running to 
success', async () => withPublisher(async ({ ctx, stateFile, core }) => {
   ctx.eventName = 'schedule'; delete ctx.payload.inputs;
   const recent = { ...pr(), updated_at: new Date().toISOString() };
   const { github, writes } = publisherMock({ rerun: { ...run(), status: 
'in_progress', conclusion: null } });
@@ -570,7 +867,340 @@ test('a scheduled sweep mirrors a fork run that finishes 
during its polling wind
     if (finished) response.data.workflow_runs = 
response.data.workflow_runs.map(r => ({ ...r, status: 'completed', conclusion: 
'success' }));
     return response;
   };
-  await update({ github, context: ctx, core, stateFile, clock: () => t, sleep: 
async ms => { t += ms; finished = true; } });
+  await update({ github, context: ctx, core, stateFile, clock: () => t, sleep: 
async ms => { t += ms; } });
+  finished = true; t += 300000;
+  await update({ github, context: ctx, core, stateFile, clock: () => t, sleep: 
async ms => { t += ms; } });
   assert.equal((writes[0].create || writes[0].update).status, 'in_progress');
   assert.equal((writes.at(-1).create || writes.at(-1).update).conclusion, 
'success');
 }));
+
+test('queued workflow summaries use the actual job state for the PR 
description', () => {
+  const queued = { ...run(), status: 'queued', conclusion: null };
+  const waiting = evaluate(pr(), queued, [{ name: 'build / build', status: 
'queued' }]);
+  assert.equal(waiting.status, 'queued');
+  assert.match(waiting.title, /waiting for a runner/);
+  const running = evaluate(pr(), queued, [{ name: 'build / build', status: 
'in_progress' }]);
+  assert.equal(running.status, 'in_progress');
+  assert.equal(running.title, 'Fork CI is running');
+});
+
+test('fork results publish a visible commit status for every lifecycle state', 
async () => withPublisher(async ({ ctx, core }) => {
+  for (const [status, conclusion, expected] of [
+    ['queued', null, 'pending'], ['in_progress', null, 'pending'],
+    ['completed', 'success', 'success'], ['completed', 'failure', 'failure'],
+    ['completed', 'timed_out', 'failure'], ['completed', 'cancelled', 
'error']]) {
+    const { github, statuses } = publisherMock({ rerun: { ...run(), status, 
conclusion } });
+    await update({ github, context: ctx, core, sleep: async () => {} });
+    assert.equal(statuses.length, 1);
+    assert.deepEqual(statuses[0], { owner: 'apache', repo: 'gravitino', sha: A,
+      context: 'Fork CI', state: expected, description: status === 'queued'
+        ? 'Fork CI is queued, waiting for a runner' : status === 'in_progress' 
? 'Fork CI is running'
+          : conclusion === 'success' ? 'All required suites passed' : `Fork CI 
${conclusion}`,
+      target_url: 'https://github.com/fork/repo/actions/runs/1' });
+  }
+}));
+
+test('missing fork runs stay pending and link to the PR instead of a 
nonexistent run', async () => withPublisher(async ({ ctx, core }) => {
+  const { github, statuses } = publisherMock();
+  github.rest.actions.listWorkflowRuns = async () => ({ data: { workflow_runs: 
[] } });
+  await update({ github, context: ctx, core, sleep: async () => {} });
+  assert.equal(statuses[0].state, 'pending');
+  assert.equal(statuses[0].description, 'Waiting for Fork CI; enable Actions 
in your fork if no run starts');
+  assert.equal(statuses[0].target_url, pr().html_url);
+}));
+
+test('changed CI definitions stay pending, not red, until trusted CI verifies 
them', async () => withPublisher(async ({ ctx, core }) => {
+  const { github, statuses } = publisherMock();
+  github.rest.git = serviceMock({ changed: true }).rest.git;
+  await update({ github, context: ctx, core, sleep: async () => {} });
+  // Waiting for a committer is not a failure of the contributor's change.
+  assert.equal(statuses[0].state, 'pending');
+  assert.equal(statuses[0].description, 'Fork CI passed; trusted verification 
required');
+}));
+
+test('CI edits mirror execution progress and failures without self-approving 
success', async () => withPublisher(async ({ ctx, core }) => {
+  for (const [status, conclusion, expectedStatus, expectedConclusion, state] 
of [
+    ['queued', null, 'queued', undefined, 'pending'],
+    ['in_progress', null, 'in_progress', undefined, 'pending'],
+    ['completed', 'failure', 'completed', 'failure', 'failure'],
+    ['completed', 'cancelled', 'completed', 'cancelled', 'error'],
+    ['completed', 'success', 'completed', 'action_required', 'pending']]) {
+    const { github, writes, statuses } = publisherMock({ rerun: { ...run(), 
status, conclusion } });
+    github.rest.git = serviceMock({ changed: true }).rest.git;
+    await update({ github, context: ctx, core, sleep: async () => {} });
+    const check = writes[0].create;
+    assert.equal(check.status, expectedStatus);
+    assert.equal(check.conclusion, expectedConclusion);
+    assert.equal(statuses[0].state, state);
+    assert.match(check.output.summary, /committer must dispatch Fork CI/);
+    assert.equal(JSON.parse(check.output.text).run_status, status);
+    assert.equal(JSON.parse(check.output.text).run_conclusion, conclusion);
+  }
+}));
+
+test('scheduled publishers repair legacy premature terminal checks while the 
same attempt runs', async () => withPublisher(async ({ ctx, core }) => {
+  ctx.eventName = 'schedule'; delete ctx.payload.inputs;
+  const recent = { ...pr(), updated_at: new Date().toISOString() };
+  const latest = { id: 55, app: { slug: 'github-actions' }, status: 
'completed', conclusion: 'action_required',
+    output: { text: JSON.stringify({ owner: 'fork', repo: 'repo', run_id: 1, 
run_attempt: 1,
+      base_ref: 'main', base_sha: B }) } };
+  const { github, writes, statuses } = publisherMock({ latest, rerun: { 
...run(), status: 'in_progress', conclusion: null } });
+  github.rest.git = serviceMock({ changed: true }).rest.git;
+  github.rest.repos.getCombinedStatusForRef = async () => ({ data: { statuses: 
[{ context: 'Fork CI', state: 'error' }] } });
+  const listPulls = github.rest.pulls.list, paginate = github.paginate;
+  github.paginate = async (endpoint, args) => endpoint === listPulls ? 
[recent] : paginate(endpoint, args);
+  github.rest.pulls.get = async () => ({ data: recent });
+  await update({ github, context: ctx, core, sleep: async () => {} });
+  assert.equal(writes.length, 1);
+  assert.equal((writes[0].create || writes[0].update).status, 'in_progress');
+  assert.equal(statuses[0].state, 'pending');
+}));
+
+test('known completed action-required executions cannot regress to running on 
a stale API response', async () => withPublisher(async ({ ctx, core }) => {
+  const latest = { id: 55, app: { slug: 'github-actions' }, status: 
'completed', conclusion: 'action_required',
+    output: { text: JSON.stringify({ owner: 'fork', repo: 'repo', run_id: 1, 
run_attempt: 1, run_status: 'completed' }) } };
+  const { github, writes, statuses } = publisherMock({ latest, rerun: { 
...run(), status: 'in_progress', conclusion: null } });
+  github.rest.git = serviceMock({ changed: true }).rest.git;
+  await update({ github, context: ctx, core, sleep: async () => {} });
+  assert.equal(writes.length, 0);
+  assert.equal(statuses.length, 0);
+}));
+
+test('scheduled probes repair same-state statuses with stale run links or 
descriptions', async () => withPublisher(async ({ ctx, core }) => {
+  ctx.eventName = 'schedule'; delete ctx.payload.inputs;
+  const recent = { ...pr(), updated_at: new Date().toISOString() };
+  const manual = { ...ctx, eventName: 'workflow_dispatch', payload: { inputs: 
{ 'pull-request': '7' } } };
+  const initial = publisherMock();
+  await update({ github: initial.github, context: manual, core, sleep: async 
() => {} });
+  const check = { id: 55, app: { slug: 'github-actions' }, 
...initial.writes[0].create };
+  for (const wrong of [{ target_url: 
'https://github.com/fork/repo/actions/runs/0' }, { description: 'Old success' 
}]) {
+    const { github, statuses } = publisherMock({ latest: check });
+    github.rest.repos.getCombinedStatusForRef = async () => ({ data: { 
statuses: [{ ...initial.statuses[0], ...wrong }] } });
+    const listPulls = github.rest.pulls.list, paginate = github.paginate;
+    github.paginate = async (endpoint, args) => endpoint === listPulls ? 
[recent] : paginate(endpoint, args);
+    github.rest.pulls.get = async () => ({ data: recent });
+    await update({ github, context: ctx, core, sleep: async () => {} });
+    assert.equal(statuses.length, 1);
+    assert.equal(statuses[0].target_url, 
'https://github.com/fork/repo/actions/runs/1');
+    assert.equal(statuses[0].description, 'All required suites passed');
+  }
+}));
+
+test('scheduled probes clear settled success when trusted CI is dispatched or 
rerun', async () => withPublisher(async ({ ctx, core }) => {
+  ctx.eventName = 'schedule'; delete ctx.payload.inputs;
+  const recent = { ...pr(), updated_at: new Date().toISOString() };
+  for (const wasTrusted of [false, true]) {
+    const owner = wasTrusted ? 'apache' : 'fork', repo = wasTrusted ? 
'gravitino' : 'repo';
+    const latest = { id: 55, app: { slug: 'github-actions' }, status: 
'completed', conclusion: 'success',
+      output: { title: 'All required suites passed', text: JSON.stringify({ 
owner, repo,
+        run_id: wasTrusted ? 2 : 1, run_attempt: 1, run_status: 'completed', 
trusted: wasTrusted,
+        base_ref: 'main', base_sha: B, fork_run_id: 1, fork_run_attempt: 1 }) 
} };
+    const { github, writes, statuses } = publisherMock({ latest });
+    github.rest.repos.getCombinedStatusForRef = async () => ({ data: { 
statuses: [{ context: 'Fork CI',
+      state: 'success', description: latest.output.title, target_url: 
`https://github.com/${owner}/${repo}/actions/runs/${wasTrusted ? 2 : 1}` }] } 
});
+    github.rest.actions.listWorkflowRuns = async ({ event }) => ({ data: { 
workflow_runs: event !== 'workflow_dispatch' ? [run()]
+      : [{ ...run(), id: 2, head_sha: B, head_branch: 'main', run_attempt: 
wasTrusted ? 2 : 1,
+        status: 'in_progress', conclusion: null, display_title: 'Trusted Fork 
CI #7', created_at: '2026-01-02T00:00:00Z' }] } });
+    const listPulls = github.rest.pulls.list, paginate = github.paginate;
+    github.paginate = async (endpoint, args) => endpoint === listPulls ? 
[recent] : paginate(endpoint, args);
+    github.rest.pulls.get = async () => ({ data: recent });
+    await update({ github, context: ctx, core, sleep: async () => {} });
+    assert.equal(writes[0].create.status, 'in_progress');
+    assert.equal(statuses[0].state, 'pending');
+    assert.equal(statuses[0].target_url, 
'https://github.com/apache/gravitino/actions/runs/2');
+  }
+}));
+
+test('CI edits keep the failed job visible after fail-fast cancellation', 
async () => {
+  const github = serviceMock({ changed: true, forkJobs: [{ name: 'build / 
build', conclusion: 'failure' }] });
+  github.rest.actions.listWorkflowRuns = async ({ event }) => ({ data: { 
workflow_runs:
+    event !== 'workflow_dispatch' ? [{ ...run(), conclusion: 'cancelled' }] : 
[] } });
+  const { result } = await createService(github).mirror(pr());
+  assert.equal(result.conclusion, 'failure');
+  assert.equal(result.title, 'Failed: build / build');
+  assert.match(result.note, /committer must dispatch/);
+});
+
+test('trusted verification of CI edits mirrors running, failure and success', 
async () => {
+  for (const [status, conclusion] of [['in_progress', null], ['completed', 
'failure'], ['completed', 'success']]) {
+    const github = serviceMock({ changed: true, trusted: true });
+    const list = github.rest.actions.listWorkflowRuns;
+    github.rest.actions.listWorkflowRuns = async args => {
+      const response = await list(args);
+      if (args.event === 'workflow_dispatch') 
Object.assign(response.data.workflow_runs[0], { status, conclusion });
+      return response;
+    };
+    const mirrored = await createService(github).mirror(pr());
+    assert.equal(mirrored.trusted, true);
+    assert.equal(mirrored.result.status, status);
+    assert.equal(mirrored.result.conclusion || null, conclusion);
+  }
+});
+
+test('an unchanged hidden metadata check still gets its missing visible 
status', async () => withPublisher(async ({ ctx, core }) => {
+  const initial = publisherMock();
+  await update({ github: initial.github, context: ctx, core, sleep: async () 
=> {} });
+  const hidden = { id: 55, app: { slug: 'github-actions' }, 
...initial.writes[0].create };
+  const { github, writes, statuses } = publisherMock({ latest: hidden });
+  await update({ github, context: ctx, core, sleep: async () => {} });
+  assert.equal(writes.length, 0, 'an identical check need not be recreated');
+  assert.equal(statuses.length, 1, 'the missing PR status must still be 
repaired');
+  assert.equal(statuses[0].state, 'success');
+}));
+
+test('a push, retarget or newer attempt also suppresses stale visible 
statuses', async () => withPublisher(async ({ ctx, core }) => {
+  for (const change of [p => p.head.sha = C, p => p.base.ref = 'branch-1.3', p 
=> p.base.sha = C, p => p.state = 'closed']) {
+    const changed = pr(); change(changed);
+    const { github, statuses } = publisherMock({ current: changed });
+    await update({ github, context: ctx, core, sleep: async () => {} });
+    assert.equal(statuses.length, 0);
+  }
+  const latest = { id: 55, app: { slug: 'github-actions' }, status: 
'completed', conclusion: 'failure',
+    output: { text: JSON.stringify({ owner: 'fork', repo: 'repo', run_id: 1, 
run_attempt: 2 }) } };
+  const { github, statuses } = publisherMock({ latest });
+  await update({ github, context: ctx, core, sleep: async () => {} });
+  assert.equal(statuses.length, 0);
+}));
+
+test('visible status publication retries a failed write even after the 
metadata check exists', async () => withPublisher(async ({ ctx, core, failures 
}) => {
+  const { github, writes, statuses } = publisherMock();
+  const create = github.rest.repos.createCommitStatus;
+  github.rest.repos.createCommitStatus = async () => { throw new Error('status 
API unavailable'); };
+  await update({ github, context: ctx, core, sleep: async () => {} });
+  assert.match(failures[0], /#7/);
+  assert.equal(statuses.length, 0);
+  const check = { id: 55, app: { slug: 'github-actions' }, ...writes[0].create 
};
+  const paginate = github.paginate;
+  github.paginate = async (endpoint, args) => endpoint === 
github.rest.checks.listForRef ? [check] : paginate(endpoint, args);
+  github.rest.repos.createCommitStatus = create;
+  await update({ github, context: ctx, core, sleep: async () => {} });
+  assert.equal(statuses.length, 1);
+}));
+
+const { publishStatus } = require('../publish-ci-status.cjs');
+test('unchanged commit statuses do not flood the status history; new states 
and URLs do publish', async () => {
+  const { github, statuses } = publisherMock();
+  const params = { github, repo: { owner: 'apache', repo: 'gravitino' }, sha: 
A,
+    context: 'Fork CI', state: 'pending', description: 'Fork CI is running',
+    targetUrl: 'https://github.com/fork/repo/actions/runs/1' };
+  await publishStatus(params);
+  await publishStatus(params);
+  assert.equal(statuses.length, 1);
+  await publishStatus({ ...params, state: 'success' });
+  await publishStatus({ ...params, targetUrl: params.targetUrl + '/attempts/2' 
});
+  assert.equal(statuses.length, 3);
+  await publishStatus({ ...params, description: 'x'.repeat(200) });
+  assert.equal(statuses.at(-1).description.length, 140);
+});
+
+test('allowlist scans publish their actual verdict as a commit status and 
ignore stale heads', async () => {
+  const { publish } = require('../allowlist-pr.cjs');
+  const saved = { SCANNED_PRS: process.env.SCANNED_PRS, SCAN_OUTCOME: 
process.env.SCAN_OUTCOME };
+  const ctx = { ...context('apache/gravitino'), serverUrl: 
'https://github.com', runId: 123 };
+  try {
+    process.env.SCANNED_PRS = JSON.stringify([{ number: 7, sha: A }]);
+    for (const outcome of ['success', 'failure', 'cancelled', 'skipped']) {
+      process.env.SCAN_OUTCOME = outcome;
+      const { github, statuses } = publisherMock();
+      const failures = [];
+      await publish({ github, context: ctx, core: { setFailed: error => 
failures.push(error) } });
+      assert.equal(statuses[0].context, 'ASF Allowlist Check');
+      assert.equal(statuses[0].sha, A);
+      assert.equal(statuses[0].target_url, 
'https://github.com/apache/gravitino/actions/runs/123');
+      assert.equal(statuses[0].state, outcome === 'success' ? 'success' : 
'failure');
+      assert.equal(failures.length, outcome === 'success' ? 0 : 1);
+    }
+    const { github, statuses } = publisherMock();
+    github.rest.pulls.get = async () => ({ data: { ...pr(), head: { 
...pr().head, sha: C } } });
+    await publish({ github, context: ctx, core: { setFailed() {} } });
+    assert.equal(statuses.length, 0);
+  } finally {
+    for (const [key, value] of Object.entries(saved)) {
+      if (value === undefined) delete process.env[key]; else process.env[key] 
= value;
+    }
+  }
+});
+
+test('only trusted publishers receive the status-writing permission', async () 
=> {
+  for (const workflow of ['fork-ci-status-update.yml', 
'asf-allowlist-trusted.yml']) {
+    const yaml = await readFile(path.join(__dirname, '../../workflows', 
workflow), 'utf8');
+    assert.match(yaml, /^permissions: \{\}/m);
+    assert.match(yaml, /^      statuses: write$/m);
+  }
+  const signal = await readFile(path.join(__dirname, 
'../../workflows/fork-ci-status.yml'), 'utf8');
+  assert.doesNotMatch(signal, /statuses: write/);
+});
+
+
+test('scheduled probes repair a settled metadata check whose commit status was 
never published', async () => withPublisher(async ({ ctx, core }) => {
+  ctx.eventName = 'schedule'; delete ctx.payload.inputs;
+  const recent = { ...pr(), updated_at: new Date().toISOString() };
+  const initial = publisherMock();
+  const manual = { ...ctx, eventName: 'workflow_dispatch', payload: { inputs: 
{ 'pull-request': '7' } } };
+  await update({ github: initial.github, context: manual, core, sleep: async 
() => {} });
+  const check = { id: 55, app: { slug: 'github-actions' }, 
...initial.writes[0].create };
+  const { github, statuses } = publisherMock({ latest: check });
+  const listPulls = github.rest.pulls.list, paginate = github.paginate;
+  github.paginate = async (endpoint, args) => endpoint === listPulls ? 
[recent] : paginate(endpoint, args);
+  github.rest.pulls.get = async () => ({ data: recent });
+  await update({ github, context: ctx, core, sleep: async () => {} });
+  assert.equal(statuses.length, 1);
+  assert.equal(statuses[0].state, 'success');
+}));
+
+
+test('allowlist verdicts share one serialized queue', async () => {
+  const yaml = await readFile(path.join(__dirname, 
'../../workflows/asf-allowlist-trusted.yml'), 'utf8');
+  assert.match(yaml, /^  group: asf-allowlist-publish$/m);
+  assert.match(yaml, /^  queue: max$/m);
+  assert.match(yaml, /^  cancel-in-progress: false$/m);
+});
+
+test('the status follows the stored check when a concurrent writer recorded a 
newer result', async () => withPublisher(async ({ ctx, core }) => {
+  // This publisher still sees the run in progress; a concurrent signal has 
just stored success.
+  const { github, statuses } = publisherMock({ rerun: { ...run(), status: 
'in_progress', conclusion: null } });
+  const create = github.rest.checks.create, listForRef = 
github.rest.checks.listForRef, paginate = github.paginate;
+  const newer = { id: 2000, app: { slug: 'github-actions' }, status: 
'completed', conclusion: 'success',
+    output: { title: 'All required suites passed', text: JSON.stringify({ 
owner: 'fork', repo: 'repo', run_id: 1, run_attempt: 1, run_status: 'completed' 
}) } };
+  let raced = false;
+  github.rest.checks.create = async args => { await create(args); raced = 
true; };
+  github.paginate = async (endpoint, args) => endpoint === listForRef && raced 
? [newer] : paginate(endpoint, args);
+  await update({ github, context: ctx, core, sleep: async () => {} });
+  assert.equal(statuses.length, 1);
+  assert.equal(statuses[0].state, 'success');
+  assert.equal(statuses[0].description, 'All required suites passed');
+}));
+
+test('the publisher settles cancelled previous-head checks after publishing 
the current head', async () => withPublisher(async ({ ctx, core, failures }) => 
{
+  const p = pr();p.head.ref = 'topic';
+  const { github, writes, statuses } = publisherMock();
+  github.rest.pulls.get = async () => ({ data: p });
+  const oldRun = { ...run(), id: 10, head_sha: C, head_branch: 'topic', event: 
'push', conclusion: 'cancelled' };
+  const oldCheck = { id: 88, app: { slug: 'github-actions' }, status: 
'in_progress', output: {
+    text: JSON.stringify({ owner: 'fork', repo: 'repo', run_id: 10, 
run_attempt: 1 }) } };
+  const list = github.rest.actions.listWorkflowRuns, paginate = 
github.paginate;
+  github.rest.actions.listWorkflowRuns = async args => args.branch === 'topic'
+    ? { data: { workflow_runs: [oldRun] } } : list(args);
+  github.rest.actions.getWorkflowRunAttempt = async () => ({ data: oldRun });
+  github.paginate = async (endpoint, args) => endpoint === 
github.rest.checks.listForRef && args.ref === C
+    ? [oldCheck] : paginate(endpoint, args);
+  await update({ github, context: ctx, core, sleep: async () => {} });
+  assert.equal(failures.length, 0);
+  assert.equal(writes[0].create.head_sha, A);
+  assert.equal(writes[1].update.check_run_id, 88);
+  assert.equal(writes[1].update.conclusion, 'cancelled');
+  assert.deepEqual(statuses.map(s => [s.sha, s.state]), [[A, 'success'], [C, 
'error']]);
+}));
+
+test('history cleanup failure leaves the current result published and remains 
observable', async () => withPublisher(async ({ ctx, core, failures }) => {
+  const p = pr();p.head.ref = 'topic';
+  const { github, statuses } = publisherMock();github.rest.pulls.get = async 
() => ({ data: p });
+  const list = github.rest.actions.listWorkflowRuns;
+  github.rest.actions.listWorkflowRuns = async args => {
+    if (args.branch === 'topic') throw new Error('History unavailable');
+    return list(args);
+  };
+  await update({ github, context: ctx, core, sleep: async () => {} });
+  assert.equal(statuses[0].sha, A);assert.equal(statuses[0].state, 'success');
+  assert.match(failures[0], /#7/);
+}));
diff --git a/.github/scripts/tests/retire-fork-ci.test.cjs 
b/.github/scripts/tests/retire-fork-ci.test.cjs
new file mode 100644
index 0000000000..ad4677ec9f
--- /dev/null
+++ b/.github/scripts/tests/retire-fork-ci.test.cjs
@@ -0,0 +1,99 @@
+/*
+ * 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.
+ */
+'use strict';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { retireCancelled } = require('../retire-fork-ci.cjs');
+const A = 'a'.repeat(40), B = 'b'.repeat(40);
+function fixture() {
+  const repo = { owner: 'apache', repo: 'gravitino' };
+  const pr = { number: 7, state: 'open', head: { sha: A, ref: 'topic',
+    repo: { owner: { login: 'fork' }, name: 'repo', full_name: 'fork/repo' } } 
};
+  const run = { id: 10, run_attempt: 1, head_sha: B, head_branch: 'topic', 
event: 'push',
+    path: '.github/workflows/fork-ci.yml', status: 'completed', conclusion: 
'cancelled' };
+  const record = { owner: 'fork', repo: 'repo', run_id: 10, run_attempt: 1, 
trusted: false };
+  const check = { id: 20, app: { slug: 'github-actions' }, status: 
'in_progress',
+    output: { text: JSON.stringify(record) } };
+  const writes = [], statuses = [];
+  const actual = { ...run }, current = structuredClone(pr);
+  const github = { rest: {
+    actions: { listWorkflowRuns: async args => { assert.equal(args.per_page, 
10);return { data: { workflow_runs: [run] } }; },
+      getWorkflowRunAttempt: async args => { assert.equal(args.attempt_number, 
1);return { data: actual }; } },
+    checks: { listForRef() {}, update: async args => { 
writes.push(args);Object.assign(check, args); } },
+    pulls: { get: async () => ({ data: current }) },
+    repos: { getCombinedStatusForRef: async () => ({ data: { statuses } }),
+      createCommitStatus: async args => { statuses.push(args); } }
+  }, paginate: async () => [check] };
+  return { github, repo, pr, run, record, check, actual, current, writes, 
statuses };
+}
+test('confirmed cancelled previous runs close their metadata check and visible 
status', async () => {
+  const f = fixture();await retireCancelled(f);
+  assert.equal(f.writes.length, 2);
+  assert.equal(f.writes[0].status, 'completed');
+  assert.equal(f.writes[0].conclusion, 'cancelled');
+  assert.equal(JSON.parse(f.writes[0].output.text).superseded_by, A);
+  assert.deepEqual(f.statuses[0], { owner: 'apache', repo: 'gravitino', sha: 
B, context: 'Fork CI', state: 'error',
+    description: 'Fork CI cancelled for a previous commit', target_url: 
'https://github.com/fork/repo/actions/runs/10' });
+});
+test('history retirement preserves other heads, runs, attempts, trusted 
results and terminal verdicts', async () => {
+  const changes = [
+    f => f.run.head_sha = A, f => f.run.head_branch = 'another', f => 
f.run.event = 'schedule',
+    f => f.run.path = '.github/workflows/other.yml', f => f.run.conclusion = 
'success',
+    f => f.record.owner = 'other', f => f.record.repo = 'other', f => 
f.record.trusted = true,
+    f => f.record.run_id = 11, f => f.record.run_attempt = 2,
+    f => { f.check.status = 'completed';f.check.conclusion = 'success'; },
+    f => f.actual.status = 'in_progress', f => f.actual.conclusion = 
'failure', f => f.actual.head_sha = A,
+    f => f.current.state = 'closed', f => f.current.head.sha = B,
+    f => f.current.head.repo.full_name = 'other/repo'
+  ];
+  for (const change of changes) {
+    const f = fixture();change(f);f.check.output.text = 
JSON.stringify(f.record);
+    await retireCancelled(f);assert.equal(f.writes.length, 
0);assert.equal(f.statuses.length, 0);
+  }
+  const f = fixture();f.check.output.text = 'invalid JSON';await 
retireCancelled(f);assert.equal(f.writes.length, 0);
+});
+test('retirement retries a missing status after its metadata write succeeded', 
async () => {
+  const f = fixture(), create = f.github.rest.repos.createCommitStatus;
+  f.github.rest.repos.createCommitStatus = async () => { throw new 
Error('Status permission failure'); };
+  await assert.rejects(retireCancelled(f), /Status permission failure/);
+  assert.equal(f.check.status, 'completed');assert.equal(f.statuses.length, 0);
+  f.github.rest.repos.createCommitStatus = create;await retireCancelled(f);
+  assert.equal(f.writes.length, 2);assert.equal(f.statuses.length, 
1);assert.equal(f.statuses[0].state, 'error');
+});
+
+test('settled retired attempts do not repeatedly query the attempt, PR or 
status', async () => {
+  const f = fixture();await retireCancelled(f);
+  assert.equal(JSON.parse(f.check.output.text).retired_status_published, true);
+  const unexpected = async () => { throw new Error('Settled history must not 
be fetched again'); };
+  f.github.rest.actions.getWorkflowRunAttempt = unexpected;
+  f.github.rest.pulls.get = 
unexpected;f.github.rest.repos.getCombinedStatusForRef = unexpected;
+  await retireCancelled(f);
+  assert.equal(f.writes.length, 2);assert.equal(f.statuses.length, 1);
+});
+
+test('a fork without the Fork CI workflow has no history to retire', async () 
=> {
+  const { github, repo, pr, writes, statuses } = fixture();
+  github.rest.actions.listWorkflowRuns = async () => { throw Object.assign(new 
Error('Not Found'), { status: 404 }); };
+  await retireCancelled({ github, repo, pr });
+  assert.equal(writes.length, 0); assert.equal(statuses.length, 0);
+  github.rest.actions.listWorkflowRuns = async () => { throw Object.assign(new 
Error('Server Error'), { status: 500 }); };
+  await assert.rejects(retireCancelled({ github, repo, pr }), /Server Error/);
+});
+
+test('a cancelled run started with Run workflow in the fork is retired like a 
push run', async () => {
+  const f = fixture();f.run.event = 'workflow_dispatch';f.actual.event = 
'workflow_dispatch';
+  await retireCancelled(f);
+  assert.equal(f.writes[0].conclusion, 
'cancelled');assert.equal(f.statuses[0].state, 'error');
+});
diff --git a/.github/scripts/update-fork-ci.cjs 
b/.github/scripts/update-fork-ci.cjs
index dabfc130b2..9d140e93b1 100644
--- a/.github/scripts/update-fork-ci.cjs
+++ b/.github/scripts/update-fork-ci.cjs
@@ -14,21 +14,29 @@
  */
 'use strict';
 const { createService } = require('./fork-ci-status.cjs');
+const { publishStatus, statusState } = require('./publish-ci-status.cjs');
+const { retireCancelled } = require('./retire-fork-ci.cjs');
 
 const RECENT_MS = 6 * 60 * 60 * 1000;
 // Fork runs finish without an upstream event, and GitHub fires five-minute 
schedules only every
-// 10-20 minutes on busy repositories. A scheduled sweep therefore keeps 
polling for LOOP_MS, one
-// pass every PASS_MS, staying below the 15-minute job limit of ubuntu-slim.
+// 10-36 minutes on apache/gravitino. A sweep therefore keeps polling for 
LOOP_MS, one pass every
+// PASS_MS, staying below the 15-minute job limit of ubuntu-slim, and then 
dispatches the next
+// sweep (the rearm job), so polling is continuous; the schedule restarts a 
broken chain. Sweeps
+// run in their own concurrency group, so PR signals never wait behind a 
sleeping sweep.
 const LOOP_MS = 11 * 60 * 1000;
-const PASS_MS = 2 * 60 * 1000;
+const PASS_MS = 60 * 1000;
+// Back-to-back sweeps spend at most this share of the hourly API quota.
+const SWEEP_SHARE = 0.4;
 
 async function update({ github, context, core, sleep = ms => new 
Promise(resolve => setTimeout(resolve, ms)), clock = Date.now }) {
   const signal = context.eventName === 'workflow_run' && 
context.payload.workflow_run.name === 'Fork CI PR Signal';
   const trustedSignal = context.eventName === 'workflow_run' &&
     context.payload.workflow_run.event === 'workflow_dispatch' &&
     /^Trusted Fork CI 
#([1-9][0-9]*)$/.exec(context.payload.workflow_run.display_title || '');
-  const manual = context.eventName === 'workflow_dispatch' || !!trustedSignal 
|| signal;
-  const requested = context.payload.inputs?.['pull-request'] || (trustedSignal 
&& trustedSignal[1]);
+  // A sweep dispatched by the previous sweep runs like a scheduled one.
+  const chained = context.eventName === 'workflow_dispatch' && 
String(context.payload.inputs?.sweep) === 'true';
+  const manual = (context.eventName === 'workflow_dispatch' && !chained) || 
!!trustedSignal || signal;
+  const requested = (!chained && context.payload.inputs?.['pull-request']) || 
(trustedSignal && trustedSignal[1]);
   if (requested && !/^[1-9][0-9]*$/.test(requested)) throw new Error('Invalid 
PR number.');
   async function listPrs() {
     const prs = requested
@@ -38,18 +46,21 @@ async function update({ github, context, core, sleep = ms 
=> new Promise(resolve
   }
   // Never restore decisions or scheduling state from Actions caches: PR code 
dispatched
   // on the default branch can write those caches using its runner runtime 
token.
-  const service = createService(github);
+  let service = createService(github);
   const availableBases = new Map();
   const rate = (await github.rest.rateLimit.get()).data.resources.core;
   // Bound work even while settled successes are watched. Rotate the starting 
PR to avoid starving
   // older PRs, and leave quota for signal workflows and other repository 
automation. Organizations on
-  // GitHub Enterprise Cloud, such as apache, get 15,000 requests per hour: 
spend at most about half of
-  // the hourly quota across five-minute sweeps, and never less than 60 calls 
per sweep.
-  const perSweep = Math.max(60, Math.floor(rate.limit / 24));
+  // GitHub Enterprise Cloud, such as apache, get 15,000 requests per hour. 
Sweeps run back to back,
+  // so budget them by time (SWEEP_SHARE of the hourly quota), and never less 
than 60 calls per sweep.
+  const perSweep = Math.max(60, Math.floor(rate.limit * SWEEP_SHARE * LOOP_MS 
/ (60 * 60 * 1000)));
   const budget = manual ? Math.max(0, rate.remaining - 100) : 
Math.min(perSweep, Math.max(0, rate.remaining - 100));
   // Small quotas keep one pass per sweep; larger ones split the sweep budget 
across passes.
   const passes = manual ? 1 : Math.max(1, Math.min(Math.ceil(LOOP_MS / 
PASS_MS), Math.floor(budget / 60)));
   const slotMs = passes > 1 ? PASS_MS : 300000;
+  // Only polling sweeps continue the chain. With a small quota a sweep is a 
single pass, and
+  // dispatching it back to back would exceed the budget; the schedule paces 
those instead.
+  if (!manual) core.setOutput('chain', String(passes > 1));
   let calls = 0;
   github.hook.before('request', () => { calls++; });
   const failures = new Set();
@@ -60,7 +71,7 @@ async function update({ github, context, core, sleep = ms => 
new Promise(resolve
     });
     return checks.filter(check => check.app?.slug === 
'github-actions').sort((a, b) => b.id - a.id)[0];
   }
-  async function refresh(pr) {
+  async function migrated(pr) {
     // Release branches without this CI migration keep their own pull_request 
workflows.
     if (!availableBases.has(pr.base.sha)) {
       availableBases.set(pr.base.sha, github.rest.repos.getContent({ 
...context.repo,
@@ -69,20 +80,36 @@ async function update({ github, context, core, sleep = ms 
=> new Promise(resolve
         throw error;
       }));
     }
-    if (!(await availableBases.get(pr.base.sha))) return;
+    return availableBases.get(pr.base.sha);
+  }
+  // apache/gravitino cannot start runs in a contributor's fork, so tell the 
author how to.
+  function rerunHelp(pr, runUrl) {
+    if (!pr.head.repo || !pr.head.ref) return '';
+    const fork = 
`https://github.com/${pr.head.repo.owner.login}/${pr.head.repo.name}`;
+    return '\n\n**Run CI again without a new commit:** ' +
+      (runUrl ? `open [the run](${runUrl}) and choose **Re-run jobs** once it 
has finished ` +
+        '(re-running all jobs also tests the latest base branch), or ' : '') +
+      `start a new run with [Run 
workflow](${fork}/actions/workflows/fork-ci.yml) on branch \`${pr.head.ref}\` 
in your fork. ` +
+      'Closing the PR cancels a running fork run; reopening it does not start 
one. ' +
+      'The Re-run button on this check only refreshes this status.';
+  }
+  async function refresh(pr) {
+    if (!(await migrated(pr))) return;
+    refreshed.add(pr.number);
     const mirror = await service.mirror(pr);
     const { result, run } = mirror;
-    const note = result.note || (result.status !== 'completed' ? 'Waiting for 
the fork run.'
+    const note = result.note || (result.status !== 'completed' ? result.title
       : result.conclusion === 'success' ? 'Verified all caller suites.' : 
'Open the run to see which jobs did not succeed.');
     const testedBase = mirror.base && mirror.base !== pr.base.sha ? `\nTested 
base: ${mirror.base}` : '';
     const record = { owner: mirror.owner, repo: mirror.repo, run_id: run?.id,
-      run_attempt: run?.run_attempt, base_ref: pr.base.ref, base_sha: 
pr.base.sha, trusted: mirror.trusted || false,
+      run_attempt: run?.run_attempt, run_status: run?.status, run_conclusion: 
run?.conclusion,
+      base_ref: pr.base.ref, base_sha: pr.base.sha, trusted: mirror.trusted || 
false,
       fork_run_id: mirror.fork?.id, fork_run_attempt: mirror.fork?.run_attempt 
};
     const details = run ? 
`https://github.com/${mirror.owner}/${mirror.repo}/actions/runs/${run.id}` : 
pr.html_url;
     const output = { title: result.title,
       // GitHub ignores details_url for checks created with GITHUB_TOKEN, so 
link the run here.
       summary: (run ? `[See fork CI results](${details})\n\n` : '') + note +
-        `\n\nBase: ${pr.base.ref} @ ${pr.base.sha}${testedBase}`,
+        `\n\nBase: ${pr.base.ref} @ ${pr.base.sha}${testedBase}` + 
rerunHelp(pr, mirror.trusted ? undefined : run && details),
       text: JSON.stringify(record) };
     // Re-read before publishing so a push or retarget during the API calls 
cannot get a stale success.
     const { data: current } = await github.rest.pulls.get({ ...context.repo, 
pull_number: pr.number });
@@ -96,23 +123,42 @@ async function update({ github, context, core, sleep = ms 
=> new Promise(resolve
     const oldRecord = recordOf(latest);
     // A run already recorded for this commit still exists; a listing that 
missed it is incomplete.
     if (!record.run_id && oldRecord.run_id) return;
+    // Older publishers completed action_required checks before CI edits 
finished running.
+    // Those checks need repair; a terminal execution result must still resist 
stale API reads.
+    const executionCompleted = oldRecord.run_status === 'completed' ||
+      (!oldRecord.run_status && latest?.status === 'completed' && 
latest.conclusion !== 'action_required');
     if (oldRecord.owner === record.owner && oldRecord.repo === record.repo && 
record.run_id &&
         (oldRecord.run_id > record.run_id || (oldRecord.run_id === 
record.run_id &&
           (oldRecord.run_attempt > record.run_attempt ||
-            (oldRecord.run_attempt === record.run_attempt && latest.status === 
'completed' && result.status !== 'completed'))))) return;
+            (oldRecord.run_attempt === record.run_attempt && 
executionCompleted && result.status !== 'completed'))))) return;
     const sameRun = oldRecord.run_id === record.run_id && 
oldRecord.run_attempt === record.run_attempt && oldRecord.base_sha === 
record.base_sha && oldRecord.base_ref === record.base_ref;
     if (!latest || (latest.status === 'completed' && (!sameRun || 
result.status !== 'completed'))) {
       await github.rest.checks.create({ ...params, name: 'Fork CI', head_sha: 
pr.head.sha });
     } else if (!sameRun || latest.status !== result.status || 
latest.conclusion !== (result.conclusion || null) || latest.output?.summary !== 
output.summary || latest.output?.text !== output.text) {
       await github.rest.checks.update({ ...params, check_run_id: latest.id });
     }
+    // Even an unchanged metadata check may be absent from the PR's 
latest-check view, so
+    // backfill the commit status. Derive it from the check as it is now, not 
from this
+    // publisher's snapshot: a concurrent writer may have recorded a newer 
result meanwhile.
+    // Any remaining interleaving is repaired by the next pass, which compares 
both.
+    const settled = (await latestCheck(pr)) || { ...result, output };
+    const settledRecord = recordOf(settled);
+    await publishStatus({ github, repo: context.repo, sha: pr.head.sha, 
context: 'Fork CI',
+      state: statusState(settled), description: settled.output?.title || 
result.title,
+      targetUrl: settledRecord.run_id
+        ? 
`https://github.com/${settledRecord.owner}/${settledRecord.repo}/actions/runs/${settledRecord.run_id}`
 : pr.html_url });
   }
   async function visit(pr, work) {
     processed++;
     visited.add(pr.number);
     if (pr.state !== 'open') return;
     try {
+      if (!(await migrated(pr))) return;
       await work();
+      // Publish the current head first; history cleanup failures must not 
block its result.
+      // A probe that found the check settled skips it: pushes, which cancel 
previous runs,
+      // always signal a refresh, so probes stay cheap enough to reach every 
recent PR each pass.
+      if (refreshed.has(pr.number)) await retireCancelled({ github, repo: 
context.repo, pr });
       failures.delete(pr.number);
     } catch (error) {
       failures.add(pr.number);
@@ -121,20 +167,26 @@ async function update({ github, context, core, sleep = ms 
=> new Promise(resolve
   }
   let processed = 0;
   let visited = new Set();
+  let refreshed = new Set();
   let prs = [];
-  async function pass(budget) {
+  let recentCursor = null;
+  async function pass(budget, verifyStatuses) {
     const now = clock();
     prs = await listPrs();
     processed = 0;
     visited = new Set();
+    refreshed = new Set();
+    service = createService(github);
     // Fork runs, including reruns, finish without an upstream event. Such PRs 
were updated recently,
     // so probe those first. A completed result changes only with a push or a 
trusted run, which both
     // signal a refresh, or with a fork rerun, so refresh it only when the 
fork run id or attempt changed.
     const recent = manual ? [] : prs.filter(pr => now - 
Date.parse(pr.updated_at) < RECENT_MS)
       .sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at));
-    // Probes take two calls, so rotate them too and keep a third of the 
budget for older PRs.
+    // Probes need up to five calls, so rotate them too and keep a third of 
the budget for older PRs.
+    // Later passes of the same sweep resume where the previous pass stopped.
     const recentBudget = Math.floor(budget * 2 / 3);
-    const recentStart = !recent.length ? 0 : (Math.floor(now / slotMs) * 
Math.max(1, Math.floor(recentBudget / 4))) % recent.length;
+    const recentStart = !recent.length ? 0 : (recentCursor ??
+      Math.floor(now / slotMs) * Math.max(1, Math.floor(recentBudget / 5))) % 
recent.length;
     let probed = 0;
     for (let offset = 0; offset < recent.length; offset++) {
       if (calls + 15 > recentBudget) break; // One PR may need trees, runs, 
paginated jobs and a check write.
@@ -145,13 +197,36 @@ async function update({ github, context, core, sleep = ms 
=> new Promise(resolve
         if (latest?.status === 'completed') {
           if (!pr.head.repo) return;
           const record = recordOf(latest);
-          const fork = (await service.findRun(pr, false))?.run;
-          if (!fork || (fork.id === (record.fork_run_id ?? record.run_id) &&
-              fork.run_attempt === (record.fork_run_attempt ?? 
record.run_attempt))) return;
+          // A fork without fork-ci.yml (404) has no run to watch; its check 
already says so.
+          const fork = (await service.findRun(pr, false).catch(error => {
+            if (error.status !== 404) throw error;
+          }))?.run;
+          // Trusted reruns and new dispatches also start without a PR-head 
signal. A
+          // settled fork alone cannot prove the selected trusted execution is 
settled.
+          const trusted = (await service.findRun(pr, true))?.run;
+          const trustedSettled = record.trusted
+            ? trusted?.id === record.run_id && trusted.run_attempt === 
record.run_attempt && trusted.status === 'completed'
+            : !trusted;
+          // A completed check without a run (no fork workflow) changes only 
with a push or a run.
+          if (trustedSettled && (record.run_status === 'completed' || 
!record.run_id) && (!fork || (fork.status === 'completed' &&
+              fork.id === (record.fork_run_id ?? record.run_id) &&
+              fork.run_attempt === (record.fork_run_attempt ?? 
record.run_attempt)))) {
+            // A completed metadata check does not prove the visible status 
was written:
+            // the status API can fail after the check write, or this may be a 
pre-migration PR.
+            // Verify that once per sweep, so later passes probe as cheaply as 
on main.
+            if (!verifyStatuses) return;
+            const { data } = await github.rest.repos.getCombinedStatusForRef({
+              ...context.repo, ref: pr.head.sha, per_page: 100 });
+            const details = record.run_id
+              ? 
`https://github.com/${record.owner}/${record.repo}/actions/runs/${record.run_id}`
 : pr.html_url;
+            if (data.statuses.some(status => status.context === 'Fork CI' && 
status.state === statusState(latest) &&
+                status.description === latest.output?.title?.slice(0, 140) && 
status.target_url === details)) return;
+          }
         }
         await refresh(pr);
       });
     }
+    recentCursor = recentStart + probed;
     // Rotate the rest using trusted wall-clock time, so old PRs are 
eventually visited without
     // accepting a contributor-writable cursor. Advance by about one sweep's 
capacity.
     const step = Math.max(1, Math.floor(budget / 20));
@@ -172,7 +247,7 @@ async function update({ github, context, core, sleep = ms 
=> new Promise(resolve
       await sleep(PASS_MS);
       calls = 0;
     }
-    await pass(Math.floor(budget / passes));
+    await pass(Math.floor(budget / passes), i === 0);
   }
   if (manual && processed < prs.length) core.setFailed('API budget exhausted; 
dispatch again for a specific PR after the quota resets.');
   if (failures.size) core.setFailed(`Unable to refresh: ${[...failures].map(n 
=> `#${n}`).join(', ')}`);
diff --git a/.github/workflows/asf-allowlist-trusted.yml 
b/.github/workflows/asf-allowlist-trusted.yml
index d4c50b6058..7703cc8d0f 100644
--- a/.github/workflows/asf-allowlist-trusted.yml
+++ b/.github/workflows/asf-allowlist-trusted.yml
@@ -27,6 +27,11 @@ on:
         required: true
         type: string
 permissions: {}
+# Serialize scan verdicts too: an older scan must not append success after a 
newer failure.
+concurrency:
+  group: asf-allowlist-publish
+  queue: max
+  cancel-in-progress: false
 jobs:
   scan:
     name: Scan PR workflow files
@@ -41,6 +46,7 @@ jobs:
       contents: read
       pull-requests: read
       checks: write
+      statuses: write
     steps:
       - uses: actions/checkout@v4
         with:
diff --git a/.github/workflows/fork-ci-status-update.yml 
b/.github/workflows/fork-ci-status-update.yml
index 5a421dc010..9abef0c9e0 100644
--- a/.github/workflows/fork-ci-status-update.yml
+++ b/.github/workflows/fork-ci-status-update.yml
@@ -28,28 +28,35 @@ on:
         description: 'Optional PR number to force-refresh, including settled 
checks'
         required: false
         type: string
+      sweep:
+        description: 'Run a polling sweep instead (the previous sweep 
dispatches this)'
+        required: false
+        type: boolean
+        default: false
 permissions: {}
-# A group keeps only one pending run, so each PR signal gets its own group: a 
burst of
-# signals must not cancel each other. Sweeps share one group.
-concurrency:
-  group: >-
-    fork-ci-status-update-${{ github.event.workflow_run.name == 'Fork CI PR 
Signal' &&
-    github.event.workflow_run.head_sha || 'sweep' }}
-  cancel-in-progress: false
+# Commit statuses are append-only with no compare-and-swap. Signals and 
dispatches share one
+# queue, so a burst of PR events is processed in order and none replaces 
another. Sweeps poll
+# every minute for 11 minutes in their own group, so signals never wait behind 
a sleeping sweep;
+# both re-read the check before writing the status, and the next pass repairs 
any interleaving.
 jobs:
   update:
     if: >-
-      github.repository == 'apache/gravitino' &&
+      github.repository == 'apache/gravitino' && github.event_name != 
'schedule' &&
+      !(github.event_name == 'workflow_dispatch' && inputs.sweep) &&
       (github.event_name != 'workflow_run' ||
        github.event.workflow_run.event == 'pull_request' ||
        github.event.workflow_run.event == 'workflow_dispatch' ||
        github.event.workflow_run.event == 'push')
+    concurrency:
+      group: fork-ci-status-update
+      queue: max
+      cancel-in-progress: false
     runs-on: ubuntu-slim
-    # Scheduled sweeps poll for 11 minutes; ubuntu-slim stops jobs after 15.
     timeout-minutes: 14
     permissions:
       actions: read
       checks: write
+      statuses: write
       contents: read
       pull-requests: read
     steps:
@@ -62,3 +69,55 @@ jobs:
           retries: 3
           script: |
             await require('./.github/scripts/update-fork-ci.cjs').update({ 
github, context, core });
+  sweep:
+    if: >-
+      github.repository == 'apache/gravitino' &&
+      (github.event_name == 'schedule' || (github.event_name == 
'workflow_dispatch' && inputs.sweep))
+    # One sweep at a time; a newer schedule replaces a pending one instead of 
queueing.
+    concurrency:
+      group: fork-ci-status-sweep
+      cancel-in-progress: false
+    runs-on: ubuntu-slim
+    # Sweeps poll for 11 minutes; ubuntu-slim stops jobs after 15.
+    timeout-minutes: 14
+    outputs:
+      chain: ${{ steps.sweep.outputs.chain }}
+    permissions:
+      actions: read
+      checks: write
+      statuses: write
+      contents: read
+      pull-requests: read
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          ref: ${{ github.event.repository.default_branch }}
+          persist-credentials: false
+      - id: sweep
+        uses: actions/github-script@v7
+        with:
+          retries: 3
+          script: |
+            await require('./.github/scripts/update-fork-ci.cjs').update({ 
github, context, core });
+  rearm:
+    # Fork runs cannot notify apache/gravitino, and GitHub starts five-minute 
schedules only every
+    # 10-36 minutes, so each polling sweep dispatches the next one. The group 
holds one running
+    # and one pending sweep; a pending sweep that a newer one replaces is 
cancelled and sets no
+    # output, so chains never multiply. The schedule restarts the chain if it 
breaks.
+    needs: sweep
+    if: ${{ !cancelled() && needs.sweep.outputs.chain == 'true' }}
+    runs-on: ubuntu-slim
+    timeout-minutes: 5
+    # Dispatching a workflow needs actions: write. This job checks out and 
runs no repository code.
+    permissions:
+      actions: write
+    steps:
+      - uses: actions/github-script@v7
+        with:
+          retries: 3
+          script: |
+            const { data: repository } = await 
github.rest.repos.get(context.repo);
+            // Only the reviewed default-branch workflow continues the chain.
+            if (context.ref !== `refs/heads/${repository.default_branch}`) 
return;
+            await github.rest.actions.createWorkflowDispatch({ ...context.repo,
+              workflow_id: 'fork-ci-status-update.yml', ref: 
repository.default_branch, inputs: { sweep: 'true' } });
diff --git a/.github/workflows/fork-ci.yml b/.github/workflows/fork-ci.yml
index 78e3f8fc43..a76ba34227 100644
--- a/.github/workflows/fork-ci.yml
+++ b/.github/workflows/fork-ci.yml
@@ -17,7 +17,7 @@
 
 # Run the complete CI entry point from branch pushes in the contributor's 
repository.
 name: Fork CI
-run-name: "${{ github.event_name == 'workflow_dispatch' && format('Trusted 
Fork CI #{0}', inputs.pull-request) || format('Fork CI {0}', github.ref_name) 
}}"
+run-name: "${{ github.event_name == 'workflow_dispatch' && inputs.pull-request 
!= '' && format('Trusted Fork CI #{0}', inputs.pull-request) || format('Fork CI 
{0}', github.ref_name) }}"
 
 on:
   push:
@@ -25,8 +25,8 @@ on:
   workflow_dispatch:
     inputs:
       pull-request:
-        description: 'PR to test using the reviewed workflows on the default 
branch (committers only)'
-        required: true
+        description: 'In apache/gravitino: PR to test using the reviewed 
default-branch workflows (committers only). In a fork: leave empty to test the 
selected branch.'
+        required: false
         type: string
 
 permissions:
@@ -34,8 +34,10 @@ permissions:
   actions: read
   pull-requests: read
 
+# A push and a "Run workflow" in the fork share the branch's group, so either 
one replaces a
+# running test of that branch.
 concurrency:
-  group: fork-ci-${{ github.event_name }}-${{ inputs.pull-request || 
github.ref }}
+  group: fork-ci-${{ inputs.pull-request && format('trusted-{0}', 
inputs.pull-request) || github.ref }}
   cancel-in-progress: true
 
 jobs:
@@ -260,6 +262,8 @@ jobs:
     # Not ubuntu-slim: it limits jobs to 15 minutes, and this job lasts as 
long as the slowest suite.
     runs-on: ubuntu-latest
     timeout-minutes: 360
+    # The watcher only observes the suites; its own failure or timeout must 
not fail the run.
+    continue-on-error: true
     permissions:
       contents: read
       actions: write
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c0a9cb000c..8033898c22 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -99,7 +99,33 @@ Pull request tests run with GitHub Actions in your fork, not 
in `apache/gravitin
 3. Open the pull request. Its **Fork CI** check verifies the suite jobs and 
the exact tested head, base branch and base SHA. A pre-PR run is accepted only 
if its inferred base matches the PR. After retargeting a PR, push again or ask 
a committer for a trusted run. The separate **ASF Allowlist Check** scans the 
PR's workflow YAML using reviewed upstream code.
 4. Keep your branch up to date before merging. Strict branch protection 
requires an up-to-date branch. If the base changes, update your branch and run 
CI again. The **Fork CI** check can stay green after the base branch moves on; 
strict branch protection, not the check, blocks the merge until the branch is 
updated.
 
-If the check asks you to enable Fork CI, enable Actions in your fork, merge or 
rebase your branch onto the latest base branch, and push again. Fork CI cancels 
the rest of the run as soon as one job fails, so you can re-run failed jobs in 
your fork right away; this also re-runs the jobs it cancelled. Reviewers cannot 
re-run them for you. Closing and reopening a pull request only refreshes the 
check from the latest fork run; to run CI again, re-run the workflow in your 
fork or push again.  [...]
+### Running Fork CI again
+
+`apache/gravitino` cannot start or cancel runs in your fork, so you control 
Fork CI from your fork. To run CI again without changing code, use any of these:
+
+* **Run workflow:** In your fork, open **Actions → Fork CI → Run workflow**, 
select your PR branch and leave the PR number empty. This starts a new run for 
the branch's current commit against the latest base branch, and cancels a run 
of that branch that is still in progress. It also works when an old run can no 
longer be re-run. If **Run workflow** is missing, use **Sync fork** on your 
fork's default branch first.
+* **Re-run the run:** Open the run for your PR's current commit and choose 
**Re-run all jobs**, which also tests the latest base branch, or **Re-run jobs 
→ Re-run failed jobs** to retry only failed and cancelled jobs against the same 
base. A run can be re-run only after it has finished; Fork CI cancels the rest 
of a run as soon as a job fails. See [GitHub's rerun 
instructions](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs).
+* **Push a commit:** Any push to the PR branch starts a new run and cancels 
the previous one. An empty commit (`git commit --allow-empty -m "Trigger Fork 
CI"`) is enough.
+
+The **Fork CI** check on the PR links to the run and to **Run workflow**. Its 
**Re-run** button and the **Update Fork CI Status** workflow only refresh the 
reported result; neither runs tests.
+
+| Action                                                 | Fork CI             
                                      | PR status                               
                  |
+| ------------------------------------------------------ | 
--------------------------------------------------------- | 
--------------------------------------------------------- |
+| Push to the PR branch, or **Run workflow** in the fork | Cancels the 
branch's running run and starts a new one     | Tracks the new run              
                          |
+| Close the PR                                           | The running fork 
run cancels itself within about a minute | Synchronization stops while the PR 
is closed              |
+| Reopen the PR                                          | Starts nothing      
                                      | Shows the latest fork run's result; run 
CI again as above |
+
+If no fork run starts, enable Actions in your fork, update your branch from 
the latest base branch, and push again. A branch created before Fork CI was 
introduced does not contain `.github/workflows/fork-ci.yml`, so pushing it runs 
no CI; the check then asks you to merge or rebase onto the latest base branch. 
Please fix failures before requesting a review.
+
+If the check requests trusted verification for CI-definition changes, a 
committer must dispatch the trusted upstream **Fork CI** run described below; 
running CI in the fork does not satisfy that requirement.
+
+### CI status synchronization
+
+Status synchronization closes unfinished checks for confirmed canceled runs 
among the fork branch's ten most recent runs.
+
+Fork runs cannot notify `apache/gravitino` when they finish, so a sweep polls 
recently updated PRs every minute for 11 minutes and then starts the next 
sweep, which keeps polling continuous; the five-minute schedule restarts the 
chain if it breaks. PR events are processed in order in a separate queue of up 
to 100 runs, so they never wait behind a sweep. Before writing the commit 
status, each publisher re-reads the check and publishes what it records, and 
the next pass repairs any interle [...]
+
+The publishers report **Fork CI** and **ASF Allowlist Check** as commit 
statuses with direct links to the corresponding runs. Additional check runs 
retain diagnostic metadata; GitHub can omit those checks from the PR's 
latest-check view after another signal run. Queued and running CI both have a 
pending status, with the description distinguishing whether jobs have started. 
This also applies to PRs that edit CI definitions: their check explains that 
trusted verification will be needed, wh [...]
 
 Known limitations of Fork CI:
 
@@ -107,7 +133,7 @@ Known limitations of Fork CI:
 * A pull request last pushed before this migration needs an update and another 
push. Old runs without the tested-context metadata cannot satisfy the new check.
 * Coverage comments remain available for release branches. For PRs to `main`, 
inspect the fork run's `build / coverage` log and `coverage-report` artifact.
 * Release branches (`branch-*`) keep their own upstream CI until this change 
is backported.
-* CI workflow, action, helper-script or `.asf.yaml` changes cannot approve 
themselves. After reviewing those changes, a committer runs **Fork CI** in 
`apache/gravitino`, from the current default branch, and supplies the PR 
number. This explicit exception uses upstream runner resources, reviewed 
default-branch workflow definitions, a read-only token and no repository 
secrets; it tests the exact contributor head against the current base. Any 
later push or base change requires another match [...]
+* CI workflow, action, helper-script or `.asf.yaml` changes cannot approve 
themselves. After reviewing those changes, a committer runs **Fork CI** in 
`apache/gravitino`, from the current default branch, and supplies the PR 
number. This explicit exception uses upstream runner resources, reviewed 
default-branch workflow definitions, a read-only token and no repository 
secrets; it tests the exact contributor head against the current base. The run 
resolves the branch's current SHA even when  [...]
 * The independent allowlist scanner downloads bounded YAML data outside its 
trusted checkout and never executes PR code. It reports the **ASF Allowlist 
Check** on every PR, including PRs that do not change `.github/`. A committer 
can re-run **Trusted ASF Allowlist** with the PR number.
 
 **Fork CI** and **ASF Allowlist Check** are not required status checks, so a 
trusted run stuck in the shared runner queue does not block an urgent merge. 
Branch protection still requires an up-to-date branch and an approving review. 
Release-branch protection is unchanged.

Reply via email to