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 a9ac3c7979 [MINOR] fix(ci): Validate Fork CI suites against trusted
workflow snapshots (#14276)
a9ac3c7979 is described below
commit a9ac3c7979fc1d2cfca4435d30e97a612e5c06d8
Author: Qi Yu <[email protected]>
AuthorDate: Tue Oct 27 16:01:20 2026 +0800
[MINOR] fix(ci): Validate Fork CI suites against trusted workflow snapshots
(#14276)
### What changes were proposed in this pull request?
Validate Fork CI caller jobs against `required_ci.needs` of the upstream
commit the run tested: the base-branch commit merged into the head for
fork runs (already verified to be on the base branch), or the dispatch
commit for trusted runs. A suite added later does not fail an earlier
run, but a run that tested a base with the suite must have run it; a
missing suite reports "merge the latest base branch and push". After a
full rerun, the newest Required CI attempt selects the tested base.
Share workflow contents reads with migration detection, retry transient
reads on the next polling pass, and retain the existing execution,
context and trust checks.
Also require trusted verification when changed CI definitions report
only a fail-fast watcher failure.
### Why are the changes needed?
The publisher currently applies the latest hard-coded suite list to
historical runs. Adding TMS on main (#14222) therefore turned successful
Fork CI runs into `Fork CI needs attention` errors, even though their
workflows did not contain TMS. Re-running the same old workflow cannot
satisfy the newer list. On 2026-10-27 this affected 13 open PRs: #14241,
#14233, #14214, #14210, #14197, #14194, #13950, #13929 (a trusted run
dispatched before TMS), #13914, #13904, #13880, #13586 and #12585.
Related to #14197.
After merge, these statuses do not all recover at once: a completed
status whose run is unchanged is treated as settled while the PR was
updated in the last six hours. A committer should dispatch **Update Fork
CI Status** with each PR number above; it only calls the GitHub API.
### Does this PR introduce _any_ user-facing change?
Historical successful Fork CI runs keep their correct status when
required callers change on main. No application API or configuration
changes.
### How was this patch tested?
- `node --test .github/scripts/tests/*.test.cjs`: 108 tests passed.
- Read-only replay of `mirror()` against the live GitHub API (no status
writes) for all 13 affected PRs: each returns `All required suites
passed`, including the trusted run of #13929.
- Regression tests cover added/removed callers, ordinary versus trusted
definition snapshots, a newer tested base with and without a later
suite, a full rerun against a newer base, a trusted run missing a suite,
malformed workflow contents, API failures, shared reads, recovery from
transient base and trusted lookup failures across polling passes, a
missing `eligibility` caller, contents GitHub does not return inline,
and the changed-definition watcher-only path.
- The six initial regression tests fail against the original
implementation. Mutation checks: the latest hard-coded suite list fails
9 tests, the PR base snapshot instead of the tested base fails 2, the PR
base for trusted runs fails 4, reporting a changed-definition
watcher-only failure as an error fails 1, and dropping the eligibility
check, the inline-content check or the base read retry fails 1 each.
- Replayed PR #14197's actual run/job metadata with its historical
workflow: the latest list reports missing TMS; the matching historical
list reports success.
- `node --check` for changed scripts and `git diff --check` passed. No
live commit statuses were modified during validation.
---
.github/scripts/fork-ci-status.cjs | 91 +++++++++++--
.github/scripts/tests/fork-ci.test.cjs | 232 ++++++++++++++++++++++++++++++++-
.github/scripts/update-fork-ci.cjs | 23 +++-
CONTRIBUTING.md | 2 +
4 files changed, 328 insertions(+), 20 deletions(-)
diff --git a/.github/scripts/fork-ci-status.cjs
b/.github/scripts/fork-ci-status.cjs
index 6d905e88be..54fb8086c3 100644
--- a/.github/scripts/fork-ci-status.cjs
+++ b/.github/scripts/fork-ci-status.cjs
@@ -14,10 +14,43 @@
*/
'use strict';
const { TRUSTED_REQUIRED } = require('./publish-ci-status.cjs');
+const { readFileSync } = require('node:fs');
+const path = require('node:path');
const UPSTREAM = { owner: 'apache', repo: 'gravitino' };
-const SUITES = ['build', 'backend', 'spark', 'flink', 'trino',
'iceberg_rest_trino', 'python',
- 'frontend', 'access_control', 'idp_basic', 'mcp', 'maintenance', 'tms',
'web_ui', 'charts',
- 'contrib_catalog', 'asf_allowlist'];
+
+// Only the block-list syntax used by Required CI is accepted. Unsupported
workflow
+// changes must fail closed rather than silently dropping required callers.
SUITES below
+// parses the default branch's fork-ci.yml when this module loads, so an
unsupported
+// format there stops the publisher for every PR; the script tests parse it
first.
+function requiredSuites(source) {
+ const lines = source.split(/\r?\n/);
+ const starts = lines.flatMap((line, i) => /^ required_ci:/.test(line) ? [i]
: []);
+ if (starts.length !== 1 || lines[starts[0]].trim() !== 'required_ci:') {
+ throw new Error('Expected one required_ci job in Fork CI workflow');
+ }
+ const block = [];
+ for (const line of lines.slice(starts[0] + 1)) {
+ if (/^\s*(#.*)?$/.test(line)) continue;
+ if (/^\S|^ {1,2}\S/.test(line)) break;
+ block.push(line);
+ }
+ const needs = block.flatMap((line, i) => /^ needs:/.test(line) ? [i] :
[]);
+ if (needs.length !== 1 || block[needs[0]].trim() !== 'needs:') {
+ throw new Error('Expected a block-list of required_ci.needs');
+ }
+ const names = [];
+ for (const line of block.slice(needs[0] + 1)) {
+ if (/^ {0,4}\S/.test(line)) break;
+ const match = /^ - ([a-zA-Z_][a-zA-Z0-9_-]*)[ \t]*$/.exec(line);
+ if (!match) throw new Error('Unsupported required_ci.needs entry');
+ names.push(match[1]);
+ }
+ if (!names.includes('eligibility') || names.length < 2 || new
Set(names).size !== names.length) {
+ throw new Error('Invalid required_ci.needs callers');
+ }
+ return names.filter(name => name !== 'eligibility');
+}
+const SUITES = requiredSuites(readFileSync(path.join(__dirname,
'../workflows/fork-ci.yml'), 'utf8'));
// The fail-fast watcher in fork-ci.yml never fails the run
(continue-on-error), but runs that
// started before that change can still fail on the watcher's own API errors.
const FAIL_FAST = 'Stop on first failure';
@@ -29,7 +62,7 @@ const MISSING_WORKFLOW = 'Your fork has no Fork CI workflow,
so pushes to this b
const requiredAction = note => ({ status: 'completed', conclusion:
'action_required',
title: 'Fork CI needs attention', note });
-function evaluate(pr, run, jobs, baseSha = pr.base.sha) {
+function evaluateExecution(pr, run, jobs, baseSha) {
const required = jobs.find(job => job.name === requiredName(pr, baseSha));
if (run.status !== 'completed') {
// A workflow can remain queued while some of its matrix jobs already have
runners.
@@ -54,9 +87,17 @@ function evaluate(pr, run, jobs, baseSha = pr.base.sha) {
return requiredAction('The run did not finish Required CI for this exact
head, base branch and base SHA. ' +
'Update the branch and push again, or ask a committer to dispatch a
trusted Fork CI run.');
}
- for (const suite of SUITES) {
+}
+
+function evaluateSuites(jobs, suites, trusted = false) {
+ for (const suite of suites) {
const children = jobs.filter(job => job.name.startsWith(`${suite} / `));
- if (!children.length || !children.some(job => job.conclusion ===
'success') ||
+ if (!children.length) {
+ // A trusted run executes the upstream workflow that defines its suites,
so only a fork can lack one.
+ return requiredAction(trusted ? `The trusted run has no ${suite} jobs.
Dispatch trusted Fork CI again.`
+ : `The run has no ${suite} jobs. If the base branch added this suite,
merge the latest base branch and push.`);
+ }
+ if (!children.some(job => job.conclusion === 'success') ||
children.some(job => !['success',
'skipped'].includes(job.conclusion))) {
return requiredAction(`The ${suite} caller did not report successful
jobs. Open the run and verify its suites.`);
}
@@ -64,9 +105,25 @@ function evaluate(pr, run, jobs, baseSha = pr.base.sha) {
return { status: 'completed', conclusion: 'success', title: 'All required
suites passed' };
}
-function createService(github) {
+function evaluate(pr, run, jobs, baseSha = pr.base.sha, suites = SUITES) {
+ return evaluateExecution(pr, run, jobs, baseSha) || evaluateSuites(jobs,
suites);
+}
+
+function createService(github, { readWorkflow = sha =>
github.rest.repos.getContent({ ...UPSTREAM,
+ path: '.github/workflows/fork-ci.yml', ref: sha }).then(response =>
response.data) } = {}) {
const definitions = new Map();
const trees = new Map();
+ const suitesBySha = new Map();
+ async function suitesAt(sha) {
+ if (!suitesBySha.has(sha)) suitesBySha.set(sha, (async () => {
+ const data = await readWorkflow(sha);
+ if (data.encoding !== 'base64' || typeof data.content !== 'string') {
+ throw new Error(`Cannot read trusted Fork CI workflow contents at
${sha}; retry the status refresh`);
+ }
+ return requiredSuites(Buffer.from(data.content,
'base64').toString('utf8'));
+ })());
+ return suitesBySha.get(sha);
+ }
async function ciTree(owner, repo, sha) {
const key = `${owner}/${repo}@${sha}`;
if (!trees.has(key)) trees.set(key, (async () => {
@@ -120,7 +177,10 @@ function createService(github) {
// branch moves on; strict branch protection, not this check, requires an
up-to-date branch.
const prefix = `Required CI / ${pr.base.ref} / `;
const suffix = ` / ${pr.head.sha}`;
- const job = jobs.find(job => job.name.startsWith(prefix) &&
job.name.endsWith(suffix));
+ // A rerun of the whole run repeats eligibility, which can name a newer
base commit. jobsFor keeps
+ // both names, so take the newest attempt: it selects the tested callers
and Required CI result.
+ const job = jobs.filter(job => job.name.startsWith(prefix) &&
job.name.endsWith(suffix))
+ .sort((a, b) => (b.run_attempt || 1) - (a.run_attempt || 1) || b.id -
a.id)[0];
const sha = job?.name.slice(prefix.length, -suffix.length);
return acceptedBase(pr, sha);
}
@@ -177,7 +237,16 @@ function createService(github) {
// A manual upstream run uses the workflow definitions at run.head_sha,
not the PR definitions.
const jobs = selected.trusted ? trustedJobs : await jobsFor(owner, repo,
run);
const base = selected.trusted ? run.head_sha : !sameDefinitions ?
pr.base.sha : await testedBase(pr, jobs);
- let result = evaluate(pr, run, jobs, base);
+ let result = evaluateExecution(pr, run, jobs, base);
+ const needsSuiteChecks = !result;
+ if (needsSuiteChecks) {
+ // Require the callers of the commit the run tested: the upstream
dispatch commit for trusted
+ // runs, or the base-branch commit merged into the head for fork runs. A
suite added on the
+ // base branch after that commit does not fail the run, but one the
tested code already had
+ // must have run, even when the head's own workflow predates it.
+ result = !selected.trusted && !sameDefinitions ?
requiredAction(TRUSTED_REQUIRED)
+ : evaluateSuites(jobs, await suitesAt(base), selected.trusted);
+ }
if (selected.trusted && result.conclusion === 'success' && await
acceptedBase(pr, base) !== base) {
result = requiredAction('Trusted CI tested a commit outside the current
base branch. Dispatch trusted Fork CI again from the current default branch.');
}
@@ -186,7 +255,7 @@ function createService(github) {
'After reviewing these changes, a committer must dispatch Fork CI from
the current upstream default branch with this PR number.';
// Approval of CI edits is separate from execution progress. Never turn
a running fork
// into a completed error, or hide an actual failure behind the trust
requirement.
- result = run.status === 'completed' && run.conclusion === 'success'
+ result = run.status === 'completed' && (run.conclusion === 'success' ||
needsSuiteChecks)
? { ...requiredAction(note), title: TRUSTED_REQUIRED }
: { ...result, note: `${result.title}. ${note}` };
}
@@ -194,4 +263,4 @@ function createService(github) {
}
return { mirror, unchanged, findRun };
}
-module.exports = { FAIL_FAST, FORK_EVENTS, SUITES, requiredName, evaluate,
createService };
+module.exports = { requiredSuites, FAIL_FAST, FORK_EVENTS, SUITES,
requiredName, evaluate, createService };
diff --git a/.github/scripts/tests/fork-ci.test.cjs
b/.github/scripts/tests/fork-ci.test.cjs
index 65c2bbadc0..d0776c5f2b 100644
--- a/.github/scripts/tests/fork-ci.test.cjs
+++ b/.github/scripts/tests/fork-ci.test.cjs
@@ -19,8 +19,9 @@ const assert = require('node:assert/strict');
const { mkdtemp, readFile, rm } = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
-const { FAIL_FAST, SUITES, requiredName, evaluate, createService } =
require('../fork-ci-status.cjs');
+const { requiredSuites, FAIL_FAST, SUITES, requiredName, evaluate,
createService } = require('../fork-ci-status.cjs');
const { resolve } = require('../resolve-fork-ci.cjs');
+const { statusState, TRUSTED_REQUIRED } = require('../publish-ci-status.cjs');
const { yamlEntries, prepare } = require('../allowlist-pr.cjs');
const A = 'a'.repeat(40), B = 'b'.repeat(40), C = 'c'.repeat(40);
const pr = () => ({ number: 7, state: 'open', head: { sha: A, repo: { owner: {
login: 'fork' }, name: 'repo', full_name: 'fork/repo' } },
@@ -62,6 +63,7 @@ const runsOf = (github, endpoint, args) => endpoint ===
github.rest.actions.list
? endpoint(args).then(response => response.data.workflow_runs) : undefined;
function serviceMock({ changed = false, trusted = false, forkJobs =
jobs(pr()), trustedJobs = jobs(pr()), trustedSha = B } = {}) {
const github = { rest: {
+ repos: { getContent: async () => ({ data: { encoding: 'base64', content:
Buffer.from(workflowFor(SUITES)).toString('base64') } }) },
git: { getTree: async ({ owner, tree_sha }) => ({ data: { tree:
tree_sha.length === 40
? [{ path: '.github', sha: 'github-tree' }, { path: '.asf.yaml', sha:
'policy' }]
: ['workflows', 'actions', 'scripts'].map(name => ({ path: name, sha:
changed && owner === 'fork' ? 'changed-' + name : name })) } }) },
@@ -71,6 +73,80 @@ function serviceMock({ changed = false, trusted = false,
forkJobs = jobs(pr()),
github.paginate = async (endpoint, args) => runsOf(github, endpoint, args)
?? (args.run_id === 2 ? trustedJobs : forkJobs);
return github;
}
+const workflowFor = suites => `jobs:
+ required_ci:
+ needs:
+ - eligibility
+${suites.map(suite => ` - ${suite}\n`).join('')} runs-on: ubuntu-latest
+`;
+const historicalSuites = SUITES.filter(suite => suite !== 'tms');
+function workflowSource(github, suites, expectedRef) {
+ github.rest.repos.getContent = async args => {
+ assert.deepEqual(args, { owner: 'apache', repo: 'gravitino', path:
'.github/workflows/fork-ci.yml', ref: expectedRef });
+ return { data: { encoding: 'base64', content:
Buffer.from(workflowFor(suites)).toString('base64') } };
+ };
+}
+test('adding TMS upstream does not retroactively fail an older successful fork
run', async () => {
+ const github = serviceMock({ forkJobs: jobs(pr()).filter(job =>
!job.name.startsWith('tms / ')) });
+ workflowSource(github, historicalSuites, B);
+ assert.equal((await createService(github).mirror(pr())).result.conclusion,
'success');
+});
+test('historical required callers cannot disappear when upstream removes or
renames a suite', async () => {
+ const github = serviceMock();
+ workflowSource(github, [...SUITES, 'historical_caller'], B);
+ assert.equal((await createService(github).mirror(pr())).result.conclusion,
'action_required');
+});
+test('a fork run must include the callers of the newer base commit it tested',
async () => {
+ // The head's workflow predates TMS, but the run merged a base commit that
requires it.
+ const forkJobs = jobs(pr()).filter(job => !job.name.startsWith('tms / '));
+ forkJobs[0].name = requiredName(pr(), C);
+ const github = serviceMock({ forkJobs });
+ github.rest.repos.compareCommitsWithBasehead = async () => ({ data: {
status: 'ahead' } });
+ workflowSource(github, SUITES, C);
+ const { result } = await createService(github).mirror(pr());
+ assert.equal(result.conclusion, 'action_required');
+ assert.match(result.note, /no tms jobs.*merge the latest base branch and
push/);
+});
+test('a fork run that tested a newer base commit without a later suite
passes', async () => {
+ const forkJobs = jobs(pr()).filter(job => !job.name.startsWith('tms / '));
+ forkJobs[0].name = requiredName(pr(), C);
+ const github = serviceMock({ forkJobs });
+ github.rest.repos.compareCommitsWithBasehead = async () => ({ data: {
status: 'ahead' } });
+ workflowSource(github, historicalSuites, C);
+ assert.equal((await createService(github).mirror(pr())).result.conclusion,
'success');
+});
+test('trusted runs use their dispatched workflow SHA rather than the frozen PR
base', async () => {
+ const trustedJobs = jobs(pr()); trustedJobs[0].name = requiredName(pr(), C);
+ const github = serviceMock({ trusted: true, trustedSha: C, trustedJobs });
+ github.rest.repos.compareCommitsWithBasehead = async () => ({ data: {
status: 'ahead' } });
+ workflowSource(github, [...SUITES, 'new_caller'], C);
+ assert.equal((await createService(github).mirror(pr())).result.conclusion,
'action_required');
+});
+test('workflow contents GitHub does not return inline cannot publish success',
async () => {
+ // GitHub omits the content of files larger than 1 MB.
+ const github = serviceMock();
+ github.rest.repos.getContent = async () => ({ data: { encoding: 'none',
content: '' } });
+ await assert.rejects(createService(github).mirror(pr()), /Cannot read
trusted Fork CI workflow contents at b{40}/);
+});
+test('a workflow source API failure cannot publish success', async () => {
+ const github = serviceMock();
+ github.rest.repos.getContent = async () => { throw new Error('API
unavailable'); };
+ await assert.rejects(createService(github).mirror(pr()), /API unavailable/);
+});
+test('changed definitions cannot self-attest through a watcher-only failure',
async () => {
+ const github = serviceMock({ changed: true, forkJobs: [...jobs(pr()), {
name: FAIL_FAST, conclusion: 'failure' }] });
+ const list = github.rest.actions.listWorkflowRuns;
+ github.rest.actions.listWorkflowRuns = async args => {
+ const response = await list(args);
+ for (const run of response.data.workflow_runs) run.conclusion = 'failure';
+ return response;
+ };
+ const { result } = await createService(github).mirror(pr());
+ // Not success, and shown as pending trusted verification rather than as an
error.
+ assert.equal(result.conclusion, 'action_required');
+ assert.equal(result.title, TRUSTED_REQUIRED);
+ assert.equal(statusState(result), 'pending');
+});
test('changed workflow definitions cannot self-attest a successful fork run',
async () => {
const service = createService(serviceMock({ changed: true }));
assert.equal((await service.mirror(pr())).result.conclusion,
'action_required');
@@ -149,7 +225,7 @@ test('trusted runs on an advanced base are found by branch
and verified against
for (const comparison of ['ahead', 'identical', 'diverged']) {
const tested = pr();tested.base.sha = C;
const github = serviceMock({ changed: true, trusted: true, trustedSha: C,
trustedJobs: jobs(tested) });
- github.rest.repos = { compareCommitsWithBasehead: async args => {
+ github.rest.repos = { ...github.rest.repos, compareCommitsWithBasehead:
async args => {
assert.equal(args.basehead, `${C}...main`);return { data: { status:
comparison } };
} };
const list = github.rest.actions.listWorkflowRuns;
@@ -279,7 +355,7 @@ function publisherMock({ current = pr(), latest, rerun =
run(), failTrees = fals
const listPulls = () => {};
let reads = 0;
github.rest.pulls = { list: listPulls, get: async () => ({ data: ++reads ===
1 ? pr() : current }) };
- github.rest.repos = { getContent: async () => ({ data: {} }),
+ github.rest.repos = { ...github.rest.repos,
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)]); } };
@@ -371,6 +447,32 @@ test('scheduled publication ignores poisoned cache state
and recomputes definiti
assert.ok(!workflow.includes('actions/cache/'));
}));
+test('a full rerun on a newer base is judged by its newest Required CI job',
async () => {
+ // Attempt 1 failed against base B; the rerun repeated eligibility against
base C and passed.
+ const p = pr();
+ const github = serviceMock();
+ github.rest.actions.listWorkflowRuns = async ({ event }) => ({ data: {
+ workflow_runs: event !== 'workflow_dispatch' ? [{ ...run(), run_attempt: 2
}] : []
+ } });
+ github.rest.repos.compareCommitsWithBasehead = async () => ({ data: {
status: 'ahead' } });
+ github.paginate = async (endpoint, args) => runsOf(github, endpoint, args)
?? [
+ { name: requiredName(p), id: 1, run_attempt: 1, conclusion: 'failure' },
+ { name: requiredName(p, C), id: 2, run_attempt: 2, conclusion: 'success' },
+ ...SUITES.map((suite, i) => ({ name: `${suite} / changes`, id: 10 + i,
run_attempt: 2, conclusion: 'success' }))];
+ workflowSource(github, SUITES, C);
+ const mirror = await createService(github).mirror(p);
+ assert.equal(mirror.base, C);
+ assert.equal(mirror.result.conclusion, 'success');
+});
+test('a trusted run without a required suite asks for another trusted
dispatch', async () => {
+ const trustedJobs = jobs(pr()).filter(job => !job.name.startsWith('tms /
')); trustedJobs[0].name = requiredName(pr(), C);
+ const github = serviceMock({ trusted: true, trustedSha: C, trustedJobs });
+ github.rest.repos.compareCommitsWithBasehead = async () => ({ data: {
status: 'ahead' } });
+ workflowSource(github, SUITES, C);
+ const { result } = await createService(github).mirror(pr());
+ assert.equal(result.conclusion, 'action_required');
+ assert.match(result.note, /trusted run has no tms jobs\. Dispatch trusted
Fork CI again/);
+});
test('partial reruns retain successful callers but use each job newest
result', async () => {
const p = pr();
const github = serviceMock();
@@ -419,7 +521,7 @@ test('a run against a newer commit of the PR base branch is
accepted', async ()
const tested = pr(); tested.base.sha = C;
const github = serviceMock({ forkJobs: jobs(tested) });
let basehead;
- github.rest.repos = { compareCommitsWithBasehead: async args => { basehead
= args.basehead; return { data: { status } }; } };
+ github.rest.repos = { ...github.rest.repos, compareCommitsWithBasehead:
async args => { basehead = args.basehead; return { data: { status } }; } };
const mirror = await createService(github).mirror(pr());
assert.equal(mirror.result.conclusion, conclusion);
assert.equal(basehead, `${C}...main`);
@@ -1180,3 +1282,125 @@ test('history cleanup failure leaves the current result
published and remains ob
assert.equal(statuses[0].sha, A);assert.equal(statuses[0].state, 'success');
assert.match(failures[0], /#7/);
}));
+
+
+test('current required callers match every reusable Fork CI workflow job',
async () => {
+ const source = await readFile(path.join(__dirname,
'../../workflows/fork-ci.yml'), 'utf8');
+ const callers = [...source.matchAll(/^ ([\w-]+):\n(?:(?!
[\w-]+:)[^\n]*\n)*? uses: \.\/\.github\/workflows\//gm)].map(match =>
match[1]);
+ assert.ok(callers.length > 10);
+ assert.deepEqual([...requiredSuites(source)].sort(), callers.sort());
+});
+test('unsupported or empty required caller lists fail closed', () => {
+ const valid = workflowFor(['build']);
+ assert.deepEqual(requiredSuites(valid), ['build']);
+ assert.deepEqual(requiredSuites(valid.replaceAll('\n', '\r\n')), ['build']);
+ for (const source of ['', valid.replace('required_ci:', 'other:'),
+ valid.replace('needs:', 'needs: [eligibility, build]'),
+ valid.replace(' - build', ' - ${{ inputs.suites }}'),
+ valid.replace(' - build', ' - build'),
+ valid.replace(' - eligibility\n', ''),
+ workflowFor(['build', 'mcp']).replace(' - eligibility\n', ''),
+ valid.replace(' - build\n', ''),
+ valid.replace(' - build', ' - build\n - build'),
+ valid + valid]) {
+ assert.throws(() => requiredSuites(source));
+ }
+});
+test('trusted workflow suite metadata is cached by immutable definition SHA',
async () => {
+ const github = serviceMock(); let reads = 0;
+ const getContent = github.rest.repos.getContent;
+ github.rest.repos.getContent = async args => { reads++; return
getContent(args); };
+ const service = createService(github);
+ await service.mirror(pr()); await service.mirror(pr());
+ assert.equal(reads, 1);
+});
+test('running, failed and untrusted runs do not depend on workflow contents
API', async () => {
+ for (const kind of ['running', 'failed', 'untrusted']) {
+ const github = serviceMock({ changed: kind === 'untrusted' });
+ github.rest.repos.getContent = async () => { throw new Error('must not
fetch workflow'); };
+ const list = github.rest.actions.listWorkflowRuns;
+ github.rest.actions.listWorkflowRuns = async args => {
+ const response = await list(args);
+ for (const run of response.data.workflow_runs) {
+ if (kind === 'running') run.status = 'in_progress';
+ if (kind === 'failed') run.conclusion = 'failure';
+ }
+ return response;
+ };
+ const { result } = await createService(github).mirror(pr());
+ assert.equal(kind === 'running' ? result.status : result.conclusion,
+ kind === 'running' ? 'in_progress' : kind === 'failed' ? 'failure' :
'action_required');
+ }
+});
+
+test('workflow lookup and parse failures leave the published status untouched
and observable', async () => withPublisher(async ({ ctx, core, failures }) => {
+ for (const data of [undefined, { encoding: 'base64', content:
Buffer.from('jobs: {}').toString('base64') },
+ { encoding: 'none' }]) {
+ const { github, statuses } = publisherMock();
+ const getContent = github.rest.repos.getContent;
+ github.rest.repos.getContent = async args => {
+ if (args.ref !== B) return getContent(args);
+ if (!data) throw Object.assign(new Error('API unavailable'), { status:
503 });
+ return { data };
+ };
+ const before = failures.length;
+ await update({ github, context: ctx, core, sleep: async () => {} });
+ assert.deepEqual(statuses, []);
+ assert.ok(failures.length > before);
+ }
+}));
+
+test('publisher shares the immutable workflow read between migration detection
and suite validation', async () => withPublisher(async ({ ctx, core }) => {
+ const { github, statuses } = publisherMock();
+ const getContent = github.rest.repos.getContent; const reads = [];
+ github.rest.repos.getContent = async args => { reads.push(args.ref); return
getContent(args); };
+ await update({ github, context: ctx, core, sleep: async () => {} });
+ assert.equal(statuses[0].state, 'success');
+ assert.deepEqual(reads, [B]);
+}));
+
+test('a transient base workflow lookup failure recovers on the next polling
pass', async () => withPublisher(async ({ ctx, core, failures }) => {
+ ctx.eventName = 'schedule'; delete ctx.payload.inputs;
+ const { github, statuses } = publisherMock();
+ github.rest.rateLimit.get = async () => ({ data: { resources: { core: {
remaining: 15000, limit: 15000 } } } });
+ // The PR base read serves both migration detection and suite validation.
+ let baseReads = 0;
+ const getContent = github.rest.repos.getContent;
+ github.rest.repos.getContent = async args => {
+ if (args.ref === B && ++baseReads === 1) throw Object.assign(new
Error('Temporary API failure'), { status: 503 });
+ return getContent(args);
+ };
+ let time = Date.parse('2026-01-03T00:00:00Z');
+ await update({ github, context: ctx, core, clock: () => time, sleep: async
ms => { time += ms; } });
+ assert.equal(baseReads, 2);
+ assert.equal(statuses.length, 1); assert.equal(statuses[0].state, 'success');
+ assert.deepEqual(failures, []);
+}));
+test('a transient trusted workflow lookup failure recovers on the next polling
pass', async () => withPublisher(async ({ ctx, core, failures }) => {
+ ctx.eventName = 'schedule'; delete ctx.payload.inputs;
+ const { github, statuses } = publisherMock();
+ github.rest.rateLimit.get = async () => ({ data: { resources: { core: {
remaining: 15000, limit: 15000 } } } });
+ const list = github.rest.actions.listWorkflowRuns;
+ github.rest.actions.listWorkflowRuns = async args => args.event ===
'workflow_dispatch'
+ ? { data: { workflow_runs: [{ ...run(), id: 2, head_sha: C, head_branch:
'main',
+ display_title: 'Trusted Fork CI #7', created_at: '2026-01-02T00:00:00Z'
}] } } : list(args);
+ github.rest.repos.compareCommitsWithBasehead = async () => ({ data: {
status: 'ahead' } });
+ const paginate = github.paginate;
+ github.paginate = async (endpoint, args) => {
+ if (args.run_id === 2) {
+ const result = jobs(pr()); result[0].name = requiredName(pr(), C);
return result;
+ }
+ return paginate(endpoint, args);
+ };
+ let trustedReads = 0;
+ const getContent = github.rest.repos.getContent;
+ github.rest.repos.getContent = async args => {
+ if (args.ref === C && ++trustedReads === 1) throw Object.assign(new
Error('Temporary API failure'), { status: 503 });
+ return getContent(args);
+ };
+ let time = Date.parse('2026-01-03T00:00:00Z');
+ await update({ github, context: ctx, core, clock: () => time, sleep: async
ms => { time += ms; } });
+ assert.equal(trustedReads, 2);
+ assert.equal(statuses.length, 1); assert.equal(statuses[0].state, 'success');
+ assert.deepEqual(failures, []);
+}));
diff --git a/.github/scripts/update-fork-ci.cjs
b/.github/scripts/update-fork-ci.cjs
index 7d52c3fcf1..ed45f92fdb 100644
--- a/.github/scripts/update-fork-ci.cjs
+++ b/.github/scripts/update-fork-ci.cjs
@@ -49,7 +49,20 @@ async function update({ github, context, core, sleep = ms =>
new Promise(resolve
}
// Never restore decisions or scheduling state from Actions caches: PR code
dispatched
// on the default branch can write those caches using its runner runtime
token.
- let service = createService(github);
+ // Workflow contents are immutable at a commit SHA. Share them across
migration detection,
+ // suite validation and polling passes without caching mutable run or trust
decisions.
+ // context.repo is apache/gravitino in production; a sandbox reads its own
definitions.
+ const workflowContents = new Map();
+ function readWorkflow(sha) {
+ if (!workflowContents.has(sha)) workflowContents.set(sha,
+ github.rest.repos.getContent({ ...context.repo, path:
'.github/workflows/fork-ci.yml', ref: sha })
+ .then(response => response.data).catch(error => {
+ workflowContents.delete(sha); // Retry transient lookup failures on
the next pass.
+ throw error;
+ }));
+ return workflowContents.get(sha);
+ }
+ let service = createService(github, { readWorkflow });
const availableBases = new Map();
const rate = (await github.rest.rateLimit.get()).data.resources.core;
// Bound work even while settled successes are watched. Rotate the starting
PR to avoid starving
@@ -71,9 +84,9 @@ async function update({ github, context, core, sleep = ms =>
new Promise(resolve
async function migrated(pr) {
// Release branches without this CI migration keep their own pull_request
workflows.
if (!availableBases.has(pr.base.sha)) {
- availableBases.set(pr.base.sha, github.rest.repos.getContent({
...context.repo,
- path: '.github/workflows/fork-ci.yml', ref: pr.base.sha }).then(() =>
true, error => {
+ availableBases.set(pr.base.sha, readWorkflow(pr.base.sha).then(() =>
true, error => {
if (error.status === 404) return false;
+ availableBases.delete(pr.base.sha); // Retry transient lookup failures
on the next pass.
throw error;
}));
}
@@ -140,7 +153,7 @@ async function update({ github, context, core, sleep = ms
=> new Promise(resolve
processed = 0;
visited = new Set();
refreshed = new Set();
- service = createService(github);
+ service = createService(github, { readWorkflow });
// Fork runs, including reruns, finish without an upstream event. Such PRs
were updated recently,
// so probe those first. A completed result changes only with a push or a
trusted run, which both
// signal a refresh, or with a fork rerun, so refresh it only when the
fork run id or attempt changed.
@@ -153,7 +166,7 @@ async function update({ github, context, core, sleep = ms
=> new Promise(resolve
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 status write.
+ if (calls + 15 > recentBudget) break; // One PR may need trees, workflow
contents, runs, paginated jobs and a status write.
const pr = recent[(recentStart + offset) % recent.length];
probed++;
await visit(pr, async () => {
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 909a5cf28c..ec07cf4f7c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -127,6 +127,8 @@ Fork runs cannot notify `apache/gravitino` when they
finish, so a sweep polls re
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 [...]
+The status publisher checks required suites against the upstream commit the
run tested: the base-branch commit merged into the head for fork runs, or the
dispatch commit for trusted runs. Adding a suite on the default branch does not
invalidate earlier successful runs. A later run that tests a base commit with
the new suite needs it, so merge the latest base branch and push if the status
reports a missing suite.
+
Known limitations of Fork CI:
* New/edited PR notifications wake a default-branch publisher. Fork run
completions and reruns, including reruns of successful runs, are polled on a
five-minute schedule, subject to GitHub scheduling delays and a bounded API
budget. All open PRs, including settled successes, remain watched. The starting
PR rotates on each scheduled interval; large queues can take longer. Committers
can force-refresh a particular PR using **Update Fork CI Status** with its PR
number.