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 b182a168d0 [MINOR] improvement(ci): report Fork CI and ASF Allowlist
Check only as commit statuses (#14202)
b182a168d0 is described below
commit b182a168d0f81ac94fb943256321e73e68b278bb
Author: Qi Yu <[email protected]>
AuthorDate: Mon Oct 26 09:56:56 2026 +0800
[MINOR] improvement(ci): report Fork CI and ASF Allowlist Check only as
commit statuses (#14202)
### What changes were proposed in this pull request?
- Report **Fork CI** and **ASF Allowlist Check** only as commit
statuses. The publishers no longer create check runs, so each check
appears once on the PR instead of also as `Conflict Marker Check / Fork
CI (pull_request)` and `Conflict Marker Check / ASF Allowlist Check
(pull_request)`.
- The Fork CI status link now names the run attempt it reports,
`https://github.com/{owner}/{repo}/actions/runs/{id}/attempts/{n}` (plus
`#fork-run-{id}-{n}` for a trusted result, recording the fork run it was
chosen over). The publisher uses that link instead of the check run's
JSON record: it never replaces a newer attempt or a finished result with
a running one, and sweeps leave a status settled while the runs it
depends on are unchanged.
- Retiring a cancelled previous head now reads that head's status: only
a status still pending on exactly that fork attempt becomes `Fork CI
cancelled for a previous commit`. A retired status is no longer pending,
so it needs no marker.
- Failed and cancelled results add `; re-run it in your fork` when it
fits the 140-character description. Without a run, the status links to
the fork's Fork CI page instead of the PR.
- Passes start on a fixed one-minute cadence. A pass took 30-40 seconds
of API calls on apache/gravitino, so sleeping a full minute afterwards
made passes about 1.6 minutes apart.
- Every `workflow_run` other than a trusted run refreshes only the PRs
with that run's head, once. A finished Fork CI push run (for example on
`main` after a merge) used to start an 11-minute polling sweep in the
signal queue, blocking PR signals; on apache/gravitino one such job ran
from 11:57 to 12:08 on 2026-10-23.
- Remove `checks: write` from both publishers and update CONTRIBUTING.
### Why are the changes needed?
After #14192, every PR shows Fork CI and ASF Allowlist Check twice: once
as the commit status and once as a check run that GitHub attaches to an
arbitrary workflow suite (here Conflict Marker Check). The check runs
only stored metadata for the publisher, and they could disappear from
the PR's latest-check view anyway.
Fix: N/A
### Does this PR introduce _any_ user-facing change?
Contributors and reviewers see one **Fork CI** and one **ASF Allowlist
Check** row. **Details** opens the exact fork run attempt. The Checks
tab no longer has a Fork CI page with a summary; that page's **Re-run**
button only refreshed the status.
### How was this patch tested?
- 90 Node tests in `.github/scripts/tests`, rewritten around statuses:
links to the exact attempt and no check run, the lifecycle states and
descriptions, the re-run hint and its length limit, stale-attempt and
running-over-finished guards, legacy links without an attempt, sweep
settlement for fork and trusted results (including a fork rerun under a
trusted result), retirement of previous heads, the fixed cadence, and
`workflow_run` push events refreshing only their head without sleeping.
Ten mutations of the new logic were each rejected by a test. actionlint
reports only the known `concurrency.queue` diagnostic.
- Sandbox (timelyflow/gravitino with contributor fork
yuqi1129/gravitino):
[timelyflow/gravitino#38](https://github.com/timelyflow/gravitino/pull/38)
lists only `Fork CI` and `ASF Allowlist Check` as statuses; its check
runs are only `conflict-marker-check` and `signal`. The status linked
`…/runs/39950637328/attempts/1`, showed `Failed: build / changes (+1
more); re-run it in your fork` for a failing run, and turned to `All
required suites passed` 32 seconds after the run finished.
---------
Co-authored-by: yuqi <[email protected]>
---
.github/scripts/allowlist-pr.cjs | 6 +-
.github/scripts/publish-ci-status.cjs | 49 ++-
.github/scripts/retire-fork-ci.cjs | 36 +-
.github/scripts/tests/fork-ci.test.cjs | 478 ++++++++++++--------------
.github/scripts/tests/retire-fork-ci.test.cjs | 70 ++--
.github/scripts/update-fork-ci.cjs | 148 +++-----
.github/workflows/asf-allowlist-trusted.yml | 3 +-
.github/workflows/fork-ci-status-update.yml | 6 +-
CONTRIBUTING.md | 12 +-
9 files changed, 375 insertions(+), 433 deletions(-)
diff --git a/.github/scripts/allowlist-pr.cjs b/.github/scripts/allowlist-pr.cjs
index 7b0f994851..fcc9fb7c7c 100644
--- a/.github/scripts/allowlist-pr.cjs
+++ b/.github/scripts/allowlist-pr.cjs
@@ -84,11 +84,7 @@ async function publish({ github, context, core }) {
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: 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.' } });
+ // 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 });
diff --git a/.github/scripts/publish-ci-status.cjs
b/.github/scripts/publish-ci-status.cjs
index b9af636517..b4458b8911 100644
--- a/.github/scripts/publish-ci-status.cjs
+++ b/.github/scripts/publish-ci-status.cjs
@@ -13,14 +13,41 @@
* limitations under the License.
*/
'use strict';
+const UPSTREAM = { owner: 'apache', repo: 'gravitino' };
-// 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 };
+// Commit statuses are the only signal on the PR: check runs created with
GITHUB_TOKEN are
+// attached to an arbitrary Actions suite, show up as duplicates such as
+// "Conflict Marker Check / Fork CI", and can disappear from the PR's
latest-check view.
+// The status link therefore also records which run and attempt the status
mirrors:
+// https://github.com/{owner}/{repo}/actions/runs/{id}/attempts/{attempt}, plus
+// #fork-run-{id}-{attempt} for a trusted result, naming the fork run seen at
that time.
+const RUN_URL =
/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/actions\/runs\/(\d+)(?:\/attempts\/(\d+))?(?:#fork-run-(\d+)-(\d+))?$/;
+
+function runUrl(owner, repo, run, fork) {
+ return
`https://github.com/${owner}/${repo}/actions/runs/${run.id}/attempts/${run.run_attempt
|| 1}` +
+ (fork ? `#fork-run-${fork.id}-${fork.run_attempt || 1}` : '');
+}
+
+// Returns the run a status links to, or undefined for a status without a run.
Links written
+// before attempts were recorded have no run_attempt.
+function parseRunUrl(url) {
+ const match = RUN_URL.exec(url || '');
+ if (!match) return undefined;
+ const [, owner, repo, id, attempt, forkId, forkAttempt] = match;
+ return { owner, repo, run_id: Number(id), run_attempt: attempt &&
Number(attempt),
+ trusted: owner === UPSTREAM.owner && repo === UPSTREAM.repo,
+ fork_run_id: forkId && Number(forkId), fork_run_attempt: forkAttempt &&
Number(forkAttempt) };
+}
+
+async function latestStatus({ github, repo, sha, context }) {
const { data } = await github.rest.repos.getCombinedStatusForRef({ ...repo,
ref: sha, per_page: 100 });
- const previous = data.statuses.find(status => status.context === context);
+ return data.statuses.find(status => status.context === context);
+}
+
+// Pass the latest status as previous when it was just read; undefined reads
it here.
+async function publishStatus({ github, repo, sha, context, state, description,
targetUrl, previous }) {
+ const params = { ...repo, sha, context, state, description:
description.slice(0, 140), target_url: targetUrl };
+ if (previous === undefined) previous = await latestStatus({ github, repo,
sha, context });
if (previous?.state === state && previous.description === params.description
&& previous.target_url === targetUrl) return;
await github.rest.repos.createCommitStatus(params);
}
@@ -28,12 +55,16 @@ async function publishStatus({ github, repo, sha, context,
state, description, t
// 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.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 };
+// Whether a published status reports a finished execution, including pending
trusted verification.
+function statusCompleted(status) {
+ return status.state !== 'pending' || status.description === TRUSTED_REQUIRED;
+}
+
+module.exports = { TRUSTED_REQUIRED, latestStatus, parseRunUrl, publishStatus,
runUrl, statusCompleted, statusState };
diff --git a/.github/scripts/retire-fork-ci.cjs
b/.github/scripts/retire-fork-ci.cjs
index 7749498a69..fd64db8106 100644
--- a/.github/scripts/retire-fork-ci.cjs
+++ b/.github/scripts/retire-fork-ci.cjs
@@ -13,9 +13,10 @@
* limitations under the License.
*/
'use strict';
-const { publishStatus } = require('./publish-ci-status.cjs');
+const { latestStatus, parseRunUrl, publishStatus, runUrl, statusCompleted } =
require('./publish-ci-status.cjs');
const { FORK_EVENTS } = require('./fork-ci-status.cjs');
+// A push cancels the fork run of the previous head, whose status would
otherwise stay pending.
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 };
@@ -33,36 +34,21 @@ async function retireCancelled({ github, repo, pr }) {
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 latest = await latestStatus({ github, repo, sha: run.head_sha,
context: 'Fork CI' });
+ const shown = parseRunUrl(latest?.target_url);
+ // Only finish a status still waiting on exactly this fork attempt. A
retired status is no
+ // longer pending, so later sweeps skip it with this one read. Never
replace a trusted
+ // result, a verdict, or a newer attempt; links without an attempt predate
attempt records.
+ if (!latest || statusCompleted(latest) || !shown || shown.owner !==
fork.owner || shown.repo !== fork.repo ||
+ shown.run_id !== run.id || (shown.run_attempt && shown.run_attempt !==
run.run_attempt)) 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 }) } });
+ await publishStatus({ github, repo, sha: run.head_sha, context: 'Fork CI',
state: 'error',
+ description: 'Fork CI cancelled for a previous commit', targetUrl:
runUrl(fork.owner, fork.repo, run), previous: latest });
}
}
module.exports = { retireCancelled };
diff --git a/.github/scripts/tests/fork-ci.test.cjs
b/.github/scripts/tests/fork-ci.test.cjs
index e42a4c6688..65c2bbadc0 100644
--- a/.github/scripts/tests/fork-ci.test.cjs
+++ b/.github/scripts/tests/fork-ci.test.cjs
@@ -266,38 +266,34 @@ test('immutable CI definition comparisons are cached by
repository, head and bas
});
const { update } = require('../update-fork-ci.cjs');
+// The status link records the fork run and attempt it mirrors.
+const url = (id = 1, attempt = 1, owner = 'fork', repo = 'repo') =>
+ `https://github.com/${owner}/${repo}/actions/runs/${id}/attempts/${attempt}`;
+const forkStatus = (state, description, target_url = url()) => ({ context:
'Fork CI', state, description, target_url });
function publisherMock({ current = pr(), latest, rerun = run(), failTrees =
false } = {}) {
const github = serviceMock();
- const writes = [];
const counters = { runs: 0 };
+ // Every status written, and the statuses GitHub returns per commit (newest
first per context).
const statuses = [];
- let combinedStatuses = [];
- const listForRef = () => {};
+ const stored = new Map([[A, latest ? [{ ...latest }] : []]]);
const listPulls = () => {};
let reads = 0;
github.rest.pulls = { list: listPulls, get: async () => ({ data: ++reads ===
1 ? pr() : current }) };
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)]; }
};
+ getCombinedStatusForRef: async ({ ref }) => ({ data: { statuses:
stored.get(ref) || [] } }),
+ createCommitStatus: async args => { statuses.push(args);
+ stored.set(args.sha, [{ ...args }, ...(stored.get(args.sha) ||
[]).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 });
- 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 !== '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 ? stored : jobs(pr()));
+ github.paginate = async (endpoint, args) => runsOf(github, endpoint, args)
?? (endpoint === listPulls ? [pr()] : jobs(pr()));
github.hook = { before() {} };
- return { github, writes, counters, statuses };
+ return { github, counters, statuses, stored };
}
+
async function withPublisher(fn) {
const directory = await mkdtemp(path.join(os.tmpdir(),
'fork-ci-publisher-test-'));
const stateFile = path.join(directory, 'state.json');
@@ -309,44 +305,56 @@ async function withPublisher(fn) {
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 }) => {
- const { github, writes } = publisherMock();
- await update({ github, context: ctx, core, stateFile, sleep: async () => {}
});
- assert.equal(writes.length, 1);
- const check = writes[0].create;
- assert.equal(check.conclusion, 'success');assert.equal(check.head_sha, A);
- assert.equal(JSON.parse(check.output.text).base_sha, B);
- assert.equal(JSON.parse(check.output.text).run_attempt, 1);
+test('publisher links the status to the exact fork run attempt and creates no
check run', async () => withPublisher(async ({ ctx, core }) => {
+ const { github, statuses } = publisherMock({ rerun: { ...run(), run_attempt:
3 } });
+ await update({ github, context: ctx, core, sleep: async () => {} });
+ assert.deepEqual(statuses, [{ owner: 'apache', repo: 'gravitino', sha: A,
context: 'Fork CI', state: 'success',
+ description: 'All required suites passed', target_url: url(1, 3) }]);
+ // A check run would be attached to an arbitrary suite and show up twice on
the PR.
+ assert.equal(github.rest.checks, undefined);
}));
-test('a push or retarget during publication cannot publish stale success',
async () => withPublisher(async ({ ctx, stateFile, core }) => {
+
+test('a push or retarget during publication cannot publish stale success',
async () => withPublisher(async ({ ctx, core }) => {
const changed = pr(); changed.base.sha = C;
- const { github, writes } = publisherMock({ current: changed });
- await update({ github, context: ctx, core, stateFile, sleep: async () => {}
});
- assert.equal(writes.length, 0);
+ const { github, statuses } = publisherMock({ current: changed });
+ await update({ github, context: ctx, core, sleep: async () => {} });
+ assert.equal(statuses.length, 0);
}));
-test('manual publication refreshes a cached final success and observes a
failed rerun', async () => withPublisher(async ({ ctx, stateFile, core }) => {
- const old = { id: 55, app: { slug: 'github-actions' }, status: 'completed',
conclusion: 'success',
- output: { text: JSON.stringify({ run_id: 1, run_attempt: 1, base_sha: B,
base_ref: 'main' }) } };
- const fs = require('node:fs/promises');
- await fs.writeFile(stateFile, JSON.stringify({ cursor: 0, prs: { 7: { sha:
A, base: B, base_ref: 'main',
- updated_at: pr().updated_at, status: 'completed', checked_at: new
Date().toISOString(), completed_at: new Date().toISOString() } } }));
- const { github, writes, counters } = publisherMock({ latest: old, rerun: {
...run(), run_attempt: 2, conclusion: 'failure' } });
- await update({ github, context: ctx, core, stateFile, sleep: async () => {}
});
- assert.ok(counters.runs > 0); assert.equal(writes[0].create.conclusion,
'failure');
- assert.equal(JSON.parse(writes[0].create.output.text).run_attempt, 2);
+
+test('manual publication refreshes a final success and observes a failed
rerun', async () => withPublisher(async ({ ctx, core }) => {
+ const { github, statuses, counters } = publisherMock({ latest:
forkStatus('success', 'All required suites passed'),
+ rerun: { ...run(), run_attempt: 2, conclusion: 'failure' } });
+ await update({ github, context: ctx, core, sleep: async () => {} });
+ assert.ok(counters.runs > 0);
+ assert.equal(statuses[0].state, 'failure');
assert.equal(statuses[0].target_url, url(1, 2));
}));
+
test('publisher failures remain observable and are retried instead of cached',
async () => withPublisher(async ({ ctx, stateFile, core, failures }) => {
- const { github, writes } = publisherMock({ failTrees: true });
+ const { github, statuses } = publisherMock({ failTrees: true });
await update({ github, context: ctx, core, stateFile, sleep: async () => {}
});
- assert.equal(writes.length, 0); assert.equal(failures.length, 1);
+ assert.equal(statuses.length, 0); assert.equal(failures.length, 1);
await assert.rejects(readFile(stateFile, 'utf8'), { code: 'ENOENT' });
}));
-test('PR signals prioritize their head and bypass settled state', async () =>
withPublisher(async ({ ctx, stateFile, core }) => {
+
+test('PR signals prioritize their head and bypass settled state', async () =>
withPublisher(async ({ ctx, core }) => {
ctx.eventName = 'workflow_run';delete ctx.payload.inputs;
ctx.payload.workflow_run = { name: 'Fork CI PR Signal', head_sha: A, event:
'pull_request' };
- const { github, writes } = publisherMock();
- await update({ github, context: ctx, core, stateFile, sleep: async () => {}
});
- assert.equal(writes.length, 1);
+ const { github, statuses } = publisherMock();
+ await update({ github, context: ctx, core, sleep: async () => {} });
+ assert.equal(statuses.length, 1);
+}));
+
+test('a finished Fork CI push run refreshes only its head once, without
polling the signal queue', async () => withPublisher(async ({ ctx, core,
outputs }) => {
+ ctx.eventName = 'workflow_run';delete ctx.payload.inputs;
+ for (const [head, expected] of [[A, 1], [C, 0]]) {
+ ctx.payload.workflow_run = { name: 'Fork CI', head_sha: head, event:
'push', display_title: 'Fork CI main' };
+ const { github, statuses } = publisherMock();
+ github.rest.rateLimit.get = async () => ({ data: { resources: { core: {
remaining: 15000, limit: 15000 } } } });
+ let sleeps = 0;
+ await update({ github, context: ctx, core, sleep: async () => { sleeps++;
} });
+ assert.equal(sleeps, 0); assert.equal(statuses.length, expected);
+ assert.equal(outputs.chain, undefined);
+ }
}));
test('scheduled publication ignores poisoned cache state and recomputes
definitions', async () => withPublisher(async ({ ctx, stateFile, core }) => {
@@ -355,10 +363,10 @@ test('scheduled publication ignores poisoned cache state
and recomputes definiti
sha: A, base: B, base_ref: 'main', repository: 'fork/repo',
definitions_equal: true,
updated_at: pr().updated_at, status: 'completed', checked_at: new
Date().toISOString(), completed_at: new Date().toISOString()
} } }));
- const { github, writes } = publisherMock();
+ const { github, statuses } = publisherMock();
github.rest.git = serviceMock({ changed: true }).rest.git;
await update({ github, context: ctx, core, stateFile, sleep: async () => {}
});
- assert.equal(writes[0].create.conclusion, 'action_required');
+ assert.equal(statuses[0].description, 'Fork CI passed; trusted verification
required');
const workflow = await readFile(path.join(__dirname,
'../../workflows/fork-ci-status-update.yml'), 'utf8');
assert.ok(!workflow.includes('actions/cache/'));
}));
@@ -419,27 +427,19 @@ test('a run against a newer commit of the PR base branch
is accepted', async ()
}
});
-test('scheduled sweeps probe recently updated PRs first and refresh only
running or rerun checks', async () => withPublisher(async ({ ctx, stateFile,
core }) => {
+test('scheduled sweeps probe recently updated PRs first and refresh only
running or rerun results', async () => withPublisher(async ({ ctx, core }) => {
ctx.eventName = 'schedule'; delete ctx.payload.inputs;
- const { github, writes } = publisherMock();
+ const { github, statuses, stored } = publisherMock();
const recent = new Date().toISOString();
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' }] : [] } });
+ // Any completed result, not only success, is final until its fork run id or
attempt changes.
+ stored.set(settled.head.sha, [forkStatus('error', 'Fork CI cancelled')]);
const failed = { ...pr(), updated_at: recent };
const all = [...old, settled, failed];
- const listPulls = github.rest.pulls.list;
- const listForRef = github.rest.checks.listForRef;
- 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: { 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);
+ const listPulls = github.rest.pulls.list, paginate = github.paginate;
+ github.paginate = async (endpoint, args) => endpoint === listPulls ? all :
paginate(endpoint, args);
github.rest.pulls.get = async ({ pull_number }) => ({ data: all.find(p =>
p.number === pull_number) });
const runsFor = [];
const list = github.rest.actions.listWorkflowRuns;
@@ -449,19 +449,20 @@ test('scheduled sweeps probe recently updated PRs first
and refresh only running
response.data.workflow_runs = response.data.workflow_runs.map(run => ({
...run, head_sha: args.head_sha }));
return response;
};
- await update({ github, context: ctx, core, stateFile, sleep: async () => {}
});
- // Probe both the fork and trusted runs; neither changed, so no full refresh
is needed.
- const written = writes.map(write => write.create || write.update);
+ await update({ github, context: ctx, core, sleep: async () => {} });
+ // The settled PR is probed once (fork and trusted runs unchanged) and never
rewritten.
assert.equal(runsFor.filter(sha => sha === settled.head.sha).length, 1);
- assert.equal(written[0].conclusion, 'success');
- assert.match(written[0].output.summary, /^\[See fork CI
results\]\(https:\/\/github\.com\/fork\/repo\/actions\/runs\/1\)/);
+ assert.equal(statuses.filter(s => s.sha === settled.head.sha).length, 0);
+ const current = statuses.find(s => s.sha === A);
+ assert.equal(current.state, 'success'); assert.equal(current.target_url,
url());
}));
-test('a failed fork run is not summarized as verified', async () =>
withPublisher(async ({ ctx, stateFile, core }) => {
- const { github, writes } = publisherMock({ rerun: { ...run(), conclusion:
'failure' } });
- await update({ github, context: ctx, core, stateFile, sleep: async () => {}
});
- assert.equal(writes[0].create.conclusion, 'failure');
- assert.doesNotMatch(writes[0].create.output.summary, /Verified/);
+test('a failed fork run reports failure and points the author to re-run it in
the fork', async () => withPublisher(async ({ ctx, core }) => {
+ const { github, statuses } = publisherMock({ rerun: { ...run(), conclusion:
'failure' } });
+ await update({ github, context: ctx, core, sleep: async () => {} });
+ assert.equal(statuses[0].state, 'failure');
+ assert.equal(statuses[0].description, 'Fork CI failure; re-run it in your
fork');
+ assert.equal(statuses[0].target_url, url());
}));
test('signals queue in order while scheduled sweeps poll in their own group',
async () => {
@@ -474,30 +475,27 @@ test('signals queue in order while scheduled sweeps poll
in their own group', as
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 }) => {
+test('scheduled sweeps refresh a settled status when its fork run is rerun',
async () => withPublisher(async ({ ctx, core }) => {
ctx.eventName = 'schedule'; delete ctx.payload.inputs;
- const green = { id: 55, app: { slug: 'github-actions' }, status:
'completed', conclusion: 'success',
- output: { text: JSON.stringify({ owner: 'fork', repo: 'repo', run_id: 1,
run_attempt: 1, fork_run_id: 1, fork_run_attempt: 1 }) } };
const recent = { ...pr(), updated_at: new Date().toISOString() };
- const { github, writes } = publisherMock({ latest: green, rerun: { ...run(),
run_attempt: 2, conclusion: 'failure' } });
+ const { github, statuses } = publisherMock({ latest: forkStatus('success',
'All required suites passed'),
+ rerun: { ...run(), run_attempt: 2, conclusion: 'failure' } });
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, stateFile, sleep: async () => {}
});
- assert.equal(writes.length, 1);
- assert.equal(writes[0].create.conclusion, 'failure');
- assert.equal(JSON.parse(writes[0].create.output.text).fork_run_attempt, 2);
+ await update({ github, context: ctx, core, sleep: async () => {} });
+ assert.equal(statuses.length, 1);
+ assert.equal(statuses[0].state, 'failure');
assert.equal(statuses[0].target_url, url(1, 2));
}));
-test('a publisher that observed an older attempt does not overwrite a newer
result', async () => withPublisher(async ({ ctx, stateFile, core }) => {
+test('a publisher that observed an older attempt does not overwrite a newer
result', async () => withPublisher(async ({ ctx, core }) => {
for (const [latest, observed] of [
- [{ status: 'completed', conclusion: 'failure', attempt: 2 }, { conclusion:
'success', run_attempt: 1 }],
- [{ status: 'completed', conclusion: 'success', attempt: 1 }, { status:
'in_progress', conclusion: null, run_attempt: 1 }]]) {
- const check = { id: 55, app: { slug: 'github-actions' }, status:
latest.status, conclusion: latest.conclusion,
- output: { text: JSON.stringify({ owner: 'fork', repo: 'repo', run_id: 1,
run_attempt: latest.attempt }) } };
- const { github, writes } = publisherMock({ latest: check, rerun: {
...run(), ...observed } });
- await update({ github, context: ctx, core, stateFile, sleep: async () =>
{} });
- assert.equal(writes.length, 0);
+ [forkStatus('failure', 'Failed: build / build', url(1, 2)), { conclusion:
'success', run_attempt: 1 }],
+ [forkStatus('success', 'All required suites passed', url(1, 1)), { status:
'in_progress', conclusion: null, run_attempt: 1 }],
+ [forkStatus('success', 'All required suites passed', url(5, 1)), { id: 1,
conclusion: 'failure' }]]) {
+ const { github, statuses } = publisherMock({ latest, rerun: { ...run(),
...observed } });
+ await update({ github, context: ctx, core, sleep: async () => {} });
+ assert.equal(statuses.length, 0);
}
}));
@@ -582,28 +580,36 @@ test('every one-minute pass probes all recently updated
PRs at the apache quota'
// 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;
+ const listPulls = github.rest.pulls.list, 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);
- };
+ github.rest.repos.getCombinedStatusForRef = async ({ ref }) => { count();
perPass.at(-1).add(ref);
+ return { data: { statuses: [forkStatus('success', 'All required suites
passed')] } }; };
+ github.paginate = async (endpoint, args) => { count(); return endpoint ===
listPulls ? recent : 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('passes start on a fixed one-minute cadence, however long a pass takes',
async () => withPublisher(async ({ ctx, core }) => {
+ ctx.eventName = 'schedule'; delete ctx.payload.inputs;
+ const { github } = publisherMock();
+ github.rest.rateLimit.get = async () => ({ data: { resources: { core: {
remaining: 15000, limit: 15000 } } } });
+ let t = 0;
+ const starts = [];
+ const listPulls = github.rest.pulls.list, paginate = github.paginate;
+ // Each pass spends 35 seconds on API calls, as on apache/gravitino.
+ github.paginate = async (endpoint, args) => {
+ if (endpoint === listPulls) { starts.push(t); t += 35000; }
+ return paginate(endpoint, args);
+ };
+ await update({ github, context: ctx, core, clock: () => t, sleep: async ms
=> { assert.ok(ms >= 0); t += ms; } });
+ assert.deepEqual(starts, Array.from({ length: 11 }, (_, i) => i * 60000));
+}));
+
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);
@@ -706,16 +712,16 @@ test('closed-PR checks never cancel upstream runs and
tolerate API errors', asyn
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('failed and cancelled fork results suggest re-running in the fork when it
fits', async () => withPublisher(async ({ ctx, core }) => {
+ for (const [rerun, forkJobs, expected] of [
+ [{ ...run(), conclusion: 'cancelled' }, jobs(pr()), 'Fork CI cancelled;
re-run it in your fork'],
+ [{ ...run(), conclusion: 'failure' }, [...jobs(pr()), { name:
'x'.repeat(130), conclusion: 'failure' }], `Failed:
${'x'.repeat(130)}`.slice(0, 140)],
+ [run(), jobs(pr()), 'All required suites passed']]) {
+ const { github, statuses } = publisherMock({ rerun });
+ github.paginate = (paginate => async (endpoint, args) => args?.run_id ?
forkJobs : paginate(endpoint, args))(github.paginate);
+ await update({ github, context: ctx, core, sleep: async () => {} });
+ assert.equal(statuses[0].description, expected);
+ }
}));
test('transient API errors never fail the watcher or the run', async () => {
@@ -782,10 +788,10 @@ test('a fork without the Fork CI workflow asks the author
to update the branch',
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 }) => {
+test('sweeps publish the missing-workflow status 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();
+ const { github, statuses } = publisherMock();
github.rest.actions.listWorkflowRuns = async ({ event }) => {
if (event !== 'workflow_dispatch') throw notFound();
return { data: { workflow_runs: [] } };
@@ -796,9 +802,10 @@ test('sweeps publish the missing-workflow check once and
then leave it settled',
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);
+ assert.match(statuses[0].description, /never run in your fork/);
+ // There is no run to open; link to the fork's Fork CI page, where Actions
can be enabled.
+ assert.equal(statuses[0].target_url,
'https://github.com/fork/repo/actions/workflows/fork-ci.yml');
}));
test('scheduled sweeps scale their budget with the hourly quota', async () =>
withPublisher(async ({ ctx, stateFile, core }) => {
@@ -807,20 +814,15 @@ test('scheduled sweeps scale their budget with the hourly
quota', async () => wi
const { github } = publisherMock();
const recent = Array.from({ length: 60 }, (_, i) => ({ ...pr(), number:
200 + i, updated_at: new Date().toISOString(),
head: { ...pr().head, sha: (200 + i).toString(16).padStart(40, 'f') }
}));
- const listPulls = github.rest.pulls.list, listForRef =
github.rest.checks.listForRef, paginate = github.paginate;
- // Count paginated requests, which is what each probe makes.
+ const listPulls = github.rest.pulls.list, paginate = github.paginate;
+ // Count requests, which is what each probe makes.
let count = () => {};
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;
- if (endpoint === listForRef) { seen.add(args.ref); return [{ id: 1, app:
{ slug: 'github-actions' }, status: 'completed',
- conclusion: 'success', output: { text: JSON.stringify({ run_id: 1,
run_attempt: 1 }) } }]; }
- return paginate(endpoint, args);
- };
+ github.rest.repos.getCombinedStatusForRef = async ({ ref }) => { count();
seen.add(ref);
+ return { data: { statuses: [forkStatus('success', 'All required suites
passed')] } }; };
+ github.paginate = async (endpoint, args) => { count(); return endpoint ===
listPulls ? recent : paginate(endpoint, args); };
// 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++; } });
@@ -840,25 +842,19 @@ test('a missing fork run waits instead of asking the
author to act', async () =>
assert.match(result.note, /enable Actions in your fork/);
});
-test('an incomplete run listing does not replace a recorded run', async () =>
withPublisher(async ({ ctx, stateFile, core }) => {
- const green = { id: 55, app: { slug: 'github-actions' }, status:
'completed', conclusion: 'success',
- output: { text: JSON.stringify({ owner: 'fork', repo: 'repo', run_id: 1,
run_attempt: 1 }) } };
- const { github, writes } = publisherMock({ latest: green });
+test('an incomplete run listing does not replace a status that links a run',
async () => withPublisher(async ({ ctx, core }) => {
+ const { github, statuses } = publisherMock({ latest: forkStatus('success',
'All required suites passed') });
github.rest.actions.listWorkflowRuns = async () => ({ data: { workflow_runs:
[] } });
- await update({ github, context: ctx, core, stateFile, sleep: async () => {}
});
- assert.equal(writes.length, 0);
+ await update({ github, context: ctx, core, sleep: async () => {} });
+ assert.equal(statuses.length, 0);
}));
-test('successive short scheduled sweeps mirror a fork run from running to
success', async () => withPublisher(async ({ ctx, stateFile, core }) => {
+test('successive scheduled sweeps mirror a fork run from running to success',
async () => withPublisher(async ({ ctx, 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 } });
+ const { github, statuses } = publisherMock({ rerun: { ...run(), status:
'in_progress', conclusion: null } });
const listPulls = github.rest.pulls.list, paginate = github.paginate, list =
github.rest.actions.listWorkflowRuns;
- let latest = null;
- github.paginate = async (endpoint, args) => endpoint === listPulls ? [recent]
- : endpoint === github.rest.checks.listForRef ? (latest ? [latest] : []) :
paginate(endpoint, args);
- github.rest.checks.create = async args => { writes.push({ create: args });
latest = { id: writes.length, app: { slug: 'github-actions' }, ...args }; };
- github.rest.checks.update = async args => { writes.push({ update: args });
Object.assign(latest, args); };
+ 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 } } } });
let t = Date.now(), finished = false;
@@ -867,11 +863,10 @@ test('successive short scheduled sweeps mirror a fork run
from running to succes
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; } });
+ await update({ github, context: ctx, core, 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');
+ await update({ github, context: ctx, core, clock: () => t, sleep: async ms
=> { t += ms; } });
+ assert.deepEqual(statuses.map(s => s.state), ['pending', 'success']);
}));
test('queued workflow summaries use the actual job state for the PR
description', () => {
@@ -885,28 +880,26 @@ test('queued workflow summaries use the actual job state
for the PR description'
});
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']]) {
+ for (const [status, conclusion, expected, description] of [
+ ['queued', null, 'pending', 'Fork CI is queued, waiting for a runner'],
['in_progress', null, 'pending', 'Fork CI is running'],
+ ['completed', 'success', 'success', 'All required suites passed'],
+ ['completed', 'failure', 'failure', 'Fork CI failure; re-run it in your
fork'],
+ ['completed', 'timed_out', 'failure', 'Fork CI timed_out; re-run it in
your fork'],
+ ['completed', 'cancelled', 'error', 'Fork CI cancelled; re-run it in your
fork']]) {
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' });
+ assert.deepEqual(statuses, [{ owner: 'apache', repo: 'gravitino', sha: A,
+ context: 'Fork CI', state: expected, description, target_url: url() }]);
}
}));
-test('missing fork runs stay pending and link to the PR instead of a
nonexistent run', async () => withPublisher(async ({ ctx, core }) => {
+test('missing fork runs stay pending and link to the fork Fork CI page 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);
+ assert.equal(statuses[0].target_url,
'https://github.com/fork/repo/actions/workflows/fork-ci.yml');
}));
test('changed CI definitions stay pending, not red, until trusted CI verifies
them', async () => withPublisher(async ({ ctx, core }) => {
@@ -919,70 +912,55 @@ test('changed CI definitions stay pending, not red, until
trusted CI verifies th
}));
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 } });
+ for (const [status, conclusion, state, description] of [
+ ['queued', null, 'pending', 'Fork CI is queued, waiting for a runner'],
+ ['in_progress', null, 'pending', 'Fork CI is running'],
+ ['completed', 'failure', 'failure', 'Fork CI failure; re-run it in your
fork'],
+ ['completed', 'cancelled', 'error', 'Fork CI cancelled; re-run it in your
fork'],
+ ['completed', 'success', 'pending', 'Fork CI passed; trusted verification
required']]) {
+ const { github, 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);
+ assert.deepEqual([statuses[0].state, statuses[0].description,
statuses[0].target_url], [state, description, url()]);
}
}));
-test('scheduled publishers repair legacy premature terminal checks while the
same attempt runs', async () => withPublisher(async ({ ctx, core }) => {
+test('scheduled publishers repair legacy statuses without an attempt while the
run is still running', 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 } });
+ // Older publishers linked the run without its attempt and completed CI
edits prematurely.
+ const legacy = forkStatus('error', 'Fork CI needs attention',
'https://github.com/fork/repo/actions/runs/1');
+ const { github, statuses } = publisherMock({ latest: legacy, 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');
+ assert.equal(statuses.length, 1);
+ assert.equal(statuses[0].state, 'pending');
assert.equal(statuses[0].target_url, url());
}));
-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('a completed execution cannot regress to running on a stale API
response', async () => withPublisher(async ({ ctx, core }) => {
+ for (const latest of [forkStatus('pending', 'Fork CI passed; trusted
verification required'),
+ forkStatus('failure', 'Failed: build / build')]) {
+ const { github, 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(statuses.length, 0);
+ }
}));
-test('scheduled probes repair same-state statuses with stale run links or
descriptions', async () => withPublisher(async ({ ctx, core }) => {
+test('scheduled probes repair statuses that link another run or attempt',
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 }] } });
+ for (const wrong of [url(0, 1),
'https://github.com/fork/repo/actions/runs/1']) {
+ const { github, statuses } = publisherMock({ latest: forkStatus('success',
'All required suites passed', 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');
+ assert.equal(statuses[0].target_url, url());
}
}));
@@ -990,14 +968,8 @@ test('scheduled probes clear settled success when trusted
CI is dispatched or re
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}` }] }
});
+ const shown = wasTrusted ? `${url(2, 1, 'apache',
'gravitino')}#fork-run-1-1` : url();
+ const { github, statuses } = publisherMock({ latest: forkStatus('success',
'All required suites passed', shown) });
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' }] } });
@@ -1005,9 +977,30 @@ test('scheduled probes clear settled success when trusted
CI is dispatched or re
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');
+ // A trusted result also records the fork run it was chosen over.
+ assert.equal(statuses[0].target_url, `${url(2, wasTrusted ? 2 : 1,
'apache', 'gravitino')}#fork-run-1-1`);
+ }
+}));
+
+test('a trusted result stays settled until its fork run changes', async () =>
withPublisher(async ({ ctx, core }) => {
+ ctx.eventName = 'schedule'; delete ctx.payload.inputs;
+ const recent = { ...pr(), updated_at: new Date().toISOString() };
+ for (const [forkAttempt, writes] of [[1, 0], [2, 1]]) {
+ const shown = `${url(2, 1, 'apache', 'gravitino')}#fork-run-1-1`;
+ const { github, statuses } = publisherMock({ latest: forkStatus('success',
'All required suites passed', shown) });
+ github.rest.actions.listWorkflowRuns = async ({ event }) => ({ data: {
workflow_runs: event !== 'workflow_dispatch'
+ ? [{ ...run(), run_attempt: forkAttempt }]
+ : [{ ...run(), id: 2, head_sha: B, head_branch: 'main', 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 });
+ let refreshed = false;
+ const getTree = github.rest.git.getTree;
+ github.rest.git.getTree = async args => { refreshed = true; return
getTree(args); };
+ await update({ github, context: ctx, core, sleep: async () => {} });
+ assert.equal(refreshed, forkAttempt === 2);
+ assert.equal(statuses.length, writes);
}
}));
@@ -1037,41 +1030,32 @@ test('trusted verification of CI edits mirrors running,
failure and success', as
}
});
-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 });
+test('an identical result is not written to the status history again', async
() => withPublisher(async ({ ctx, core }) => {
+ const { github, statuses } = publisherMock();
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');
+ await update({ github, context: ctx, core, sleep: async () => {} });
+ assert.equal(statuses.length, 1);
}));
-test('a push, retarget or newer attempt also suppresses stale visible
statuses', async () => withPublisher(async ({ ctx, core }) => {
+test('a push, retarget or newer attempt suppresses stale 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 });
+ const { github, statuses } = publisherMock({ latest: forkStatus('failure',
'Failed: build / build', url(1, 2)) });
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();
+test('a failed status write is reported and retried by the next publisher',
async () => withPublisher(async ({ ctx, core, failures }) => {
+ const { github, 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);
@@ -1121,25 +1105,24 @@ test('allowlist scans publish their actual verdict as a
commit status and ignore
}
});
-test('only trusted publishers receive the status-writing permission', async ()
=> {
+test('only trusted publishers receive the status-writing permission, and
nobody creates check runs', 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);
+ assert.doesNotMatch(yaml, /checks: write/);
}
const signal = await readFile(path.join(__dirname,
'../../workflows/fork-ci-status.yml'), 'utf8');
assert.doesNotMatch(signal, /statuses: write/);
+ for (const script of ['update-fork-ci.cjs', 'retire-fork-ci.cjs',
'allowlist-pr.cjs', 'fork-ci-status.cjs', 'publish-ci-status.cjs']) {
+ assert.doesNotMatch(await readFile(path.join(__dirname, '..', script),
'utf8'), /\.checks\./, script);
+ }
});
-
-test('scheduled probes repair a settled metadata check whose commit status was
never published', async () => withPublisher(async ({ ctx, core }) => {
+test('scheduled probes publish a status that was never written', 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 { github, statuses } = publisherMock();
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 });
@@ -1148,7 +1131,6 @@ test('scheduled probes repair a settled metadata check
whose commit status was n
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);
@@ -1156,40 +1138,34 @@ test('allowlist verdicts share one serialized queue',
async () => {
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);
+test('the publisher reads the status last, so a result a concurrent writer
just stored wins', async () => withPublisher(async ({ ctx, core }) => {
+ // This publisher still sees the run in progress; a concurrent signal stores
success meanwhile.
+ const { github, statuses, stored } = publisherMock({ rerun: { ...run(),
status: 'in_progress', conclusion: null } });
+ const get = github.rest.pulls.get;
+ let reads = 0;
+ github.rest.pulls.get = async args => {
+ if (++reads === 2) stored.set(A, [forkStatus('success', 'All required
suites passed')]);
+ return get(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');
+ assert.equal(statuses.length, 0);
+ assert.equal(stored.get(A)[0].state, 'success');
}));
-test('the publisher settles cancelled previous-head checks after publishing
the current head', async () => withPublisher(async ({ ctx, core, failures }) =>
{
+test('the publisher settles cancelled previous-head statuses after publishing
the current head', async () => withPublisher(async ({ ctx, core, failures }) =>
{
const p = pr();p.head.ref = 'topic';
- const { github, writes, statuses } = publisherMock();
+ const { github, statuses, stored } = 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;
+ stored.set(C, [forkStatus('pending', 'Fork CI is running', url(10, 1))]);
+ const list = github.rest.actions.listWorkflowRuns;
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']]);
+ assert.deepEqual(statuses.map(s => [s.sha, s.state, s.target_url]), [[A,
'success', url()], [C, 'error', url(10, 1)]]);
+ assert.equal(statuses[1].description, 'Fork CI cancelled for a previous
commit');
}));
test('history cleanup failure leaves the current result published and remains
observable', async () => withPublisher(async ({ ctx, core, failures }) => {
diff --git a/.github/scripts/tests/retire-fork-ci.test.cjs
b/.github/scripts/tests/retire-fork-ci.test.cjs
index ad4677ec9f..5620d401c2 100644
--- a/.github/scripts/tests/retire-fork-ci.test.cjs
+++ b/.github/scripts/tests/retire-fork-ci.test.cjs
@@ -17,77 +17,79 @@ 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);
+const url = (id, attempt) =>
`https://github.com/fork/repo/actions/runs/${id}/attempts/${attempt}`;
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 = [];
+ // The previous head's status still waits on the run its push cancelled.
+ const shown = { context: 'Fork CI', state: 'pending', description: 'Fork CI
is running', target_url: url(10, 1) };
+ const statuses = [], reads = [];
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 };
+ repos: { getCombinedStatusForRef: async args => { reads.push(args.ref);
return { data: { statuses: [shown] } }; },
+ createCommitStatus: async args => { statuses.push(args);
Object.assign(shown, { state: args.state,
+ description: args.description, target_url: args.target_url }); } }
+ } };
+ return { github, repo, pr, run, shown, actual, current, statuses, reads };
}
-test('confirmed cancelled previous runs close their metadata check and visible
status', async () => {
+test('confirmed cancelled previous runs get an error status on their head',
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' });
+ assert.deepEqual(f.reads, [B]);
+ assert.deepEqual(f.statuses, [{ owner: 'apache', repo: 'gravitino', sha: B,
context: 'Fork CI', state: 'error',
+ description: 'Fork CI cancelled for a previous commit', target_url:
url(10, 1) }]);
});
-test('history retirement preserves other heads, runs, attempts, trusted
results and terminal verdicts', async () => {
+test('history retirement preserves other heads, runs, attempts, trusted
results and 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.shown.target_url =
'https://github.com/other/repo/actions/runs/10/attempts/1',
+ f => f.shown.target_url =
'https://github.com/apache/gravitino/actions/runs/10/attempts/1',
+ f => f.shown.target_url = url(11, 1), f => f.shown.target_url = url(10, 2),
+ f => f.shown.target_url =
'https://github.com/fork/repo/actions/workflows/fork-ci.yml',
+ f => f.shown.state = 'success', f => f.shown.state = 'failure',
+ f => f.shown.description = 'Fork CI passed; trusted verification required',
+ f => f.shown.context = 'ASF Allowlist Check',
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();change(f);
+ await retireCancelled(f);assert.equal(f.statuses.length, 0,
change.toString());
}
- 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 () => {
+test('statuses linked before attempts were recorded are retired too', async ()
=> {
+ const f = fixture();f.shown.target_url =
'https://github.com/fork/repo/actions/runs/10';
+ await retireCancelled(f);
+ assert.equal(f.statuses[0].state,
'error');assert.equal(f.statuses[0].target_url, url(10, 1));
+});
+test('a failed retirement is retried by the next publisher', 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');
+ 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 () => {
+test('retired statuses are settled with one status read: no attempt, PR or
status write', 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;
+ f.github.rest.actions.getWorkflowRunAttempt =
unexpected;f.github.rest.pulls.get = unexpected;
await retireCancelled(f);
- assert.equal(f.writes.length, 2);assert.equal(f.statuses.length, 1);
+ assert.equal(f.statuses.length, 1);assert.deepEqual(f.reads, [B, B]);
});
test('a fork without the Fork CI workflow has no history to retire', async ()
=> {
- const { github, repo, pr, writes, statuses } = fixture();
+ const { github, repo, pr, 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);
+ 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/);
});
@@ -95,5 +97,5 @@ test('a fork without the Fork CI workflow has no history to
retire', async () =>
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');
+ assert.equal(f.statuses[0].state, 'error');
});
diff --git a/.github/scripts/update-fork-ci.cjs
b/.github/scripts/update-fork-ci.cjs
index 9d140e93b1..7d52c3fcf1 100644
--- a/.github/scripts/update-fork-ci.cjs
+++ b/.github/scripts/update-fork-ci.cjs
@@ -14,7 +14,7 @@
*/
'use strict';
const { createService } = require('./fork-ci-status.cjs');
-const { publishStatus, statusState } = require('./publish-ci-status.cjs');
+const { latestStatus, parseRunUrl, publishStatus, runUrl, statusCompleted,
statusState } = require('./publish-ci-status.cjs');
const { retireCancelled } = require('./retire-fork-ci.cjs');
const RECENT_MS = 6 * 60 * 60 * 1000;
@@ -29,10 +29,13 @@ const PASS_MS = 60 * 1000;
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 || '');
+ // Every other workflow_run, a PR signal or a finished Fork CI push run in
this repository (for
+ // example a same-repository PR branch, or main after a merge), refreshes
the PRs with its head
+ // once. It must never become a polling sweep: it runs in the signal queue
and would block it.
+ const signal = context.eventName === 'workflow_run' && !trustedSignal;
// 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;
@@ -64,13 +67,7 @@ async function update({ github, context, core, sleep = ms =>
new Promise(resolve
let calls = 0;
github.hook.before('request', () => { calls++; });
const failures = new Set();
- const recordOf = check => { try { return JSON.parse(check?.output?.text ||
'{}'); } catch { return {}; } };
- async function latestCheck(pr) {
- const checks = await github.paginate(github.rest.checks.listForRef, {
- ...context.repo, ref: pr.head.sha, check_name: 'Fork CI', per_page: 100,
filter: 'all'
- });
- return checks.filter(check => check.app?.slug ===
'github-actions').sort((a, b) => b.id - a.id)[0];
- }
+ const forkStatus = pr => latestStatus({ github, repo: context.repo, sha:
pr.head.sha, context: 'Fork CI' });
async function migrated(pr) {
// Release branches without this CI migration keep their own pull_request
workflows.
if (!availableBases.has(pr.base.sha)) {
@@ -82,71 +79,38 @@ async function update({ github, context, core, sleep = ms
=> new Promise(resolve
}
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.';
+ // Without a run, link to the fork's Fork CI page, where the author can
enable Actions or start one.
+ function waitingUrl(pr) {
+ return pr.head.repo
+ ?
`https://github.com/${pr.head.repo.owner.login}/${pr.head.repo.name}/actions/workflows/fork-ci.yml`
: pr.html_url;
+ }
+ // apache/gravitino cannot re-run jobs in a contributor's fork; the status
link opens the run.
+ function describe(result, mirror) {
+ const hint = `${result.title}; re-run it in your fork`;
+ return mirror.run && !mirror.trusted && ['failure',
'error'].includes(statusState(result)) && hint.length <= 140
+ ? hint : result.title;
}
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' ? 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, 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}` +
rerunHelp(pr, mirror.trusted ? undefined : run && details),
- text: JSON.stringify(record) };
+ const target = run ? runUrl(mirror.owner, mirror.repo, run, mirror.trusted
&& mirror.fork) : waitingUrl(pr);
// 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 });
if (current.head.sha !== pr.head.sha || current.base.sha !== pr.base.sha
|| current.base.ref !== pr.base.ref || current.state !== 'open') return;
- const params = { ...context.repo, ...result, output, details_url: details
};
- delete params.title;
- delete params.note;
- // Signals and sweeps can publish concurrently. Read the check last and
never replace a newer
- // attempt of a run from the same repository, or a completed result with a
running one.
- const latest = await latestCheck(pr);
- 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 &&
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);
+ // Signals and sweeps can publish concurrently. Read the status last and
never replace a newer
+ // attempt of a run from the same repository, or a completed result with a
running one. Any
+ // remaining interleaving is repaired by the next pass, which compares the
status with the runs.
+ const latest = await forkStatus(pr);
+ const shown = parseRunUrl(latest?.target_url);
+ // A run already shown for this commit still exists; a listing that missed
it is incomplete.
+ if (!run && shown) return;
+ if (run && shown && shown.owner === mirror.owner && shown.repo ===
mirror.repo && (shown.run_id > run.id ||
+ (shown.run_id === run.id && (shown.run_attempt > run.run_attempt ||
+ (shown.run_attempt === run.run_attempt && statusCompleted(latest) &&
result.status !== 'completed'))))) return;
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 });
+ state: statusState(result), description: describe(result, mirror),
targetUrl: target, previous: latest ?? null });
}
async function visit(pr, work) {
processed++;
@@ -170,7 +134,7 @@ async function update({ github, context, core, sleep = ms
=> new Promise(resolve
let refreshed = new Set();
let prs = [];
let recentCursor = null;
- async function pass(budget, verifyStatuses) {
+ async function pass(budget) {
const now = clock();
prs = await listPrs();
processed = 0;
@@ -182,46 +146,33 @@ async function update({ github, context, core, sleep = ms
=> new Promise(resolve
// 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 need up to five calls, so rotate them too and keep a third of
the budget for older PRs.
+ // Probes need up to three 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 : (recentCursor ??
- Math.floor(now / slotMs) * Math.max(1, Math.floor(recentBudget / 5))) %
recent.length;
+ Math.floor(now / slotMs) * Math.max(1, Math.floor(recentBudget / 3))) %
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.
+ if (calls + 15 > recentBudget) break; // One PR may need trees, runs,
paginated jobs and a status write.
const pr = recent[(recentStart + offset) % recent.length];
probed++;
await visit(pr, async () => {
- const latest = await latestCheck(pr);
- if (latest?.status === 'completed') {
- if (!pr.head.repo) return;
- const record = recordOf(latest);
- // A fork without fork-ci.yml (404) has no run to watch; its check
already says so.
+ const latest = await forkStatus(pr);
+ if (latest && statusCompleted(latest) && pr.head.repo) {
+ const shown = parseRunUrl(latest.target_url);
+ // A fork without fork-ci.yml (404) has no run to watch; its status
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.
+ // Fork reruns, Run workflow, trusted reruns and new dispatches
start without a PR-head
+ // signal. The result is settled only while every run it depends on
is unchanged.
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;
- }
+ const same = (a, id, attempt) => a?.id === id && a.run_attempt ===
attempt && a.status === 'completed';
+ const settled = !shown ? !fork && !trusted
+ : shown.trusted ? same(trusted, shown.run_id, shown.run_attempt) &&
+ (fork ? same(fork, shown.fork_run_id, shown.fork_run_attempt) :
!shown.fork_run_id)
+ : same(fork, shown.run_id, shown.run_attempt) && !trusted;
+ if (settled) return;
}
await refresh(pr);
});
@@ -240,14 +191,17 @@ async function update({ github, context, core, sleep = ms
=> new Promise(resolve
core.info(`Visited ${processed}/${prs.length} PRs (probed
${probed}/${recent.length} recently updated); ` +
`${calls}/${budget} API calls; starting index ${start}.`);
}
- const deadline = clock() + LOOP_MS;
+ // Start passes on a fixed PASS_MS cadence: the time a pass spends on API
calls does not
+ // delay the next one.
+ const started = clock();
for (let i = 0; i < passes; i++) {
if (i) {
- if (clock() + PASS_MS > deadline) break;
- await sleep(PASS_MS);
+ const next = Math.max(clock(), started + i * PASS_MS);
+ if (next >= started + LOOP_MS) break;
+ await sleep(next - clock());
calls = 0;
}
- await pass(Math.floor(budget / passes), i === 0);
+ await pass(Math.floor(budget / passes));
}
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 7703cc8d0f..e626c7421d 100644
--- a/.github/workflows/asf-allowlist-trusted.yml
+++ b/.github/workflows/asf-allowlist-trusted.yml
@@ -38,14 +38,13 @@ jobs:
if: >-
github.repository == 'apache/gravitino' &&
(github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.event == 'pull_request')
- # A required check: keep it off the shared ubuntu-latest pool, where small
jobs can queue for hours.
+ # Keep this quick check off the shared ubuntu-latest pool, where small
jobs can queue for hours.
# The allowlist action installs its own Python, so it supports ubuntu-slim.
runs-on: ubuntu-slim
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
- checks: write
statuses: write
steps:
- uses: actions/checkout@v4
diff --git a/.github/workflows/fork-ci-status-update.yml
b/.github/workflows/fork-ci-status-update.yml
index 9abef0c9e0..39c151c5a2 100644
--- a/.github/workflows/fork-ci-status-update.yml
+++ b/.github/workflows/fork-ci-status-update.yml
@@ -25,7 +25,7 @@ on:
workflow_dispatch:
inputs:
pull-request:
- description: 'Optional PR number to force-refresh, including settled
checks'
+ description: 'Optional PR number to force-refresh, including settled
statuses'
required: false
type: string
sweep:
@@ -37,7 +37,7 @@ permissions: {}
# 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.
+# both re-read the status before writing it, and the next pass repairs any
interleaving.
jobs:
update:
if: >-
@@ -55,7 +55,6 @@ jobs:
timeout-minutes: 14
permissions:
actions: read
- checks: write
statuses: write
contents: read
pull-requests: read
@@ -84,7 +83,6 @@ jobs:
chain: ${{ steps.sweep.outputs.chain }}
permissions:
actions: read
- checks: write
statuses: write
contents: read
pull-requests: read
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8033898c22..452c6f08ec 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -107,7 +107,7 @@ Pull request tests run with GitHub Actions in your fork,
not in `apache/gravitin
* **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.
+The **Fork CI** status on the PR links to the exact run attempt it reports,
where you can re-run it. The **Update Fork CI Status** workflow only refreshes
the reported result; it does not run tests.
| Action | Fork CI
| PR status
|
| ------------------------------------------------------ |
--------------------------------------------------------- |
--------------------------------------------------------- |
@@ -115,17 +115,17 @@ The **Fork CI** check on the PR links to the run and to
**Run workflow**. Its **
| 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 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 **Fork CI** status 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.
+If **Fork CI** 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.
+Status synchronization marks the pending status of a previous commit as an
error once its fork run, among the fork branch's ten most recent runs, is
confirmed cancelled.
-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 [...]
+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. Each publisher reads the
current status just before writing it and never replaces a newer run attempt,
or a finished result with a runni [...]
-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 [...]
+The publishers report **Fork CI** and **ASF Allowlist Check** only as commit
statuses, each listed once on the PR and linking to the corresponding run; the
**Fork CI** link names the run attempt, which also records what the status
reports. Commit statuses do not appear on the PR's **Checks** tab. 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.
Failed tests report failure an [...]
Known limitations of Fork CI: