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.