Skip to content

Commit 641c58f

Browse files
roryqiyuqi1129yuqi1129
authored
[#12544] feat(ci): run pull request tests with contributor fork resources (#13049)
### What changes were proposed in this pull request? Move pull request tests to contributor fork pushes and aggregate all reusable suites into Fork CI. - Resolve the exact head, base branch and base SHA once, and forward that immutable context through all suites. - Verify upstream workflow/action/script and `.asf.yaml` definitions before accepting fork results. CI-definition changes require an explicit committer-dispatched upstream run using reviewed default-branch workflows. - Publish checks from trusted default-branch workflows. Independently scan bounded PR YAML as data for the required ASF Allowlist Check; never execute PR code in the privileged publisher/scanner. - Keep successful checks watched, detect failed/running reruns, support partial reruns and force-refresh, paginate base discovery, and retain standalone-repository CI. - Consolidate duplicate PR/push matrices and retain strict branch protection. Update contributor and migration guidance. ### Why are the changes needed? Pull request tests currently consume Apache-hosted Actions resources. Ordinary PR tests run in the contributor fork; upstream workflows verify and report their results. CI-definition changes use an explicit read-only upstream test run after committer review. Fix: #12544 Known non-blocking limitation: coverage source links in exceptional trusted manual runs point to the upstream base, while the measurements analyze the merged code. Ordinary fork-run links use the contributor commit. Source navigation for the manual path is deferred; required-check and coverage calculation behavior are unaffected. Strict/up-to-date protection is retained, so advancing the base requires updating the branch and running CI again. Rollout must coordinate the required-check transition under existing checks. Trusted publishers use `workflow_run` because ASF disables `pull_request_target` from November 2026. First-party `actions/*`, `github/*` and `apache/*` tags follow the ASF pinning exemption; external third-party actions still require approved SHAs. ### Does this PR introduce _any_ user-facing change? No public API or configuration changes. Contributors must enable Actions in their fork and push their PR branch. The required checks become Fork CI and ASF Allowlist Check. Existing PRs need a fresh push after migration; release branches retain their existing CI until backported. ### How was this patch tested? - 24 Node regression tests covering fake results, immutable context/definitions, trusted dispatch, failed and partial reruns, manual refresh, publication races, poisoned caches, standalone CI, branch pagination, allowlist input limits, and reusable-workflow propagation. - actionlint v1.7.12, workflow YAML parsing, shell syntax checks, and `git diff --check`. - Mutation checks: removing definition verification or trusting restored completed state makes the corresponding security regression fail. - Executed the actual composite-action merge script in a local Git smoke test: the merge contains the frozen base and contributor head and excludes a later base update. - Earlier live fork run 34431409678 completed all original suites. The new head still requires live CI; privileged upstream publishers/scanners cannot be exercised end to end until deployed on the default branch. --------- Co-authored-by: yuqi1129 <yuqi4733@protonmail.com> Co-authored-by: yuqi <yuqi@datastrato.com>
1 parent 16af3f1 commit 641c58f

41 files changed

Lines changed: 2589 additions & 191 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.asf.yaml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ github:
4747
main:
4848
required_status_checks:
4949
strict: true
50+
# Mirrored from the contributor fork by the Fork CI Status workflows.
51+
contexts:
52+
- Fork CI
53+
- ASF Allowlist Check
5054
required_pull_request_reviews:
5155
dismiss_stale_reviews: true
5256
required_approving_review_count: 1
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
name: Checkout and sync
19+
description: Check out the upstream base branch and merge the contributor branch for fork CI.
20+
21+
inputs:
22+
base-ref:
23+
description: Fixed upstream branch selected by Fork CI.
24+
required: false
25+
default: ''
26+
base-sha:
27+
description: Fixed upstream commit selected by Fork CI.
28+
required: false
29+
default: ''
30+
head-repository:
31+
description: Repository containing the contributor commit.
32+
required: false
33+
default: ''
34+
head-sha:
35+
description: Fixed contributor commit to test.
36+
required: false
37+
default: ''
38+
sync:
39+
description: Whether to merge the contributor commit onto the fixed base.
40+
required: false
41+
default: ''
42+
persist-credentials:
43+
description: Whether to keep the token in the git config of the checkout.
44+
required: false
45+
default: 'false'
46+
47+
outputs:
48+
base-ref:
49+
description: Upstream branch used as the comparison base.
50+
value: ${{ steps.base.outputs.base_ref }}
51+
base-sha:
52+
description: Upstream commit checked out before merging contributor changes.
53+
value: ${{ steps.upstream.outputs.base_sha }}
54+
head-sha:
55+
description: Commit tested after contributor changes are merged.
56+
value: ${{ steps.head.outputs.head_sha }}
57+
synced:
58+
description: Whether contributor changes were merged onto the upstream base.
59+
value: ${{ steps.base.outputs.should_sync }}
60+
pull-request:
61+
description: Whether a pull request is tested, through Fork CI or a pull_request event.
62+
value: ${{ steps.base.outputs.should_sync == 'true' || github.event_name == 'pull_request' }}
63+
64+
runs:
65+
using: composite
66+
steps:
67+
- name: Resolve pull request base branch
68+
id: base
69+
shell: bash
70+
env:
71+
EVENT_BASE_REF: ${{ github.base_ref }}
72+
GH_TOKEN: ${{ github.token }}
73+
HEAD_OWNER: ${{ github.repository_owner }}
74+
HEAD_BRANCH: ${{ github.ref_name }}
75+
HEAD_REPOSITORY: ${{ inputs.head-repository || github.repository }}
76+
HEAD_SHA: ${{ inputs.head-sha || github.sha }}
77+
FIXED_BASE_REF: ${{ inputs.base-ref }}
78+
FIXED_BASE_SHA: ${{ inputs.base-sha }}
79+
FIXED_SYNC: ${{ inputs.sync }}
80+
run: |
81+
set -euo pipefail
82+
if [[ -n "${FIXED_BASE_SHA}" ]]; then
83+
[[ "${FIXED_BASE_SHA}" =~ ^[0-9a-f]{40}$ ]] || exit 1
84+
git check-ref-format "refs/heads/${FIXED_BASE_REF}"
85+
[[ "${FIXED_SYNC}" == "true" || "${FIXED_SYNC}" == "false" ]] || exit 1
86+
echo "base_ref=${FIXED_BASE_REF}" >> "${GITHUB_OUTPUT}"
87+
echo "should_sync=${FIXED_SYNC}" >> "${GITHUB_OUTPUT}"
88+
else
89+
# Legacy direct/manual/nightly callers test their own checkout. Fork CI always
90+
# supplies the context resolved once by eligibility; suites never re-query PRs.
91+
echo "base_ref=${EVENT_BASE_REF:-${GITHUB_REF_NAME}}" >> "${GITHUB_OUTPUT}"
92+
echo "should_sync=false" >> "${GITHUB_OUTPUT}"
93+
fi
94+
95+
- name: Checkout upstream base branch
96+
if: steps.base.outputs.should_sync == 'true'
97+
uses: actions/checkout@v4
98+
with:
99+
fetch-depth: 0
100+
repository: apache/gravitino
101+
ref: ${{ inputs.base-sha }}
102+
persist-credentials: ${{ inputs.persist-credentials }}
103+
104+
- name: Resolve upstream base commit
105+
id: upstream
106+
shell: bash
107+
run: echo "base_sha=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}"
108+
109+
- name: Merge contributor branch
110+
if: steps.base.outputs.should_sync == 'true'
111+
shell: bash
112+
env:
113+
BASE_REF: ${{ steps.base.outputs.base_ref }}
114+
HEAD_REPOSITORY: ${{ inputs.head-repository || github.repository }}
115+
HEAD_SHA: ${{ inputs.head-sha || github.sha }}
116+
run: |
117+
set -euo pipefail
118+
119+
[[ "${HEAD_SHA}" =~ ^[0-9a-f]{40}$ ]] || exit 1
120+
[[ "${HEAD_REPOSITORY}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || exit 1
121+
# Fetch the pushed commit rather than the branch tip, which may have moved since.
122+
git fetch "https://github.com/${HEAD_REPOSITORY}.git" "${HEAD_SHA}"
123+
if ! git \
124+
-c user.name='Apache Gravitino CI' \
125+
-c user.email='dev@gravitino.apache.org' \
126+
merge --no-commit --no-ff "${HEAD_SHA}"; then
127+
git diff --name-only --diff-filter=U | sed 's/^/Conflict: /'
128+
echo "::error::${HEAD_SHA} does not merge cleanly into ${BASE_REF}. Merge or rebase onto the latest ${BASE_REF} and push again."
129+
exit 1
130+
fi
131+
git \
132+
-c user.name='Apache Gravitino CI' \
133+
-c user.email='dev@gravitino.apache.org' \
134+
commit --allow-empty -m 'Merge contributor branch for CI'
135+
136+
- name: Resolve tested commit
137+
id: head
138+
shell: bash
139+
run: echo "head_sha=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}"

‎.github/scripts/allowlist-pr.cjs‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
'use strict';
16+
const fs = require('node:fs/promises');
17+
const path = require('node:path');
18+
const MAX_FILE = 1024 * 1024;
19+
const MAX_TOTAL = 8 * MAX_FILE;
20+
21+
function yamlEntries(tree) {
22+
if (tree.truncated) throw new Error('Refusing a truncated .github tree.');
23+
return tree.tree.filter(entry => /\.ya?ml$/.test(entry.path)).map(entry => {
24+
if (!['100644', '100755'].includes(entry.mode) || entry.type !== 'blob' ||
25+
entry.path.split('/').some(part => part === '..' || part === '.' || !part) ||
26+
path.isAbsolute(entry.path) || entry.path.includes('\\') || entry.size > MAX_FILE) {
27+
throw new Error('Unsafe or oversized workflow data path.');
28+
}
29+
return entry;
30+
});
31+
}
32+
async function prepare({ github, context, core, fetchImpl = fetch, directory = path.join(process.env.RUNNER_TEMP, 'fork-ci-allowlist') }) {
33+
let prs;
34+
if (context.eventName === 'workflow_dispatch') {
35+
const number = context.payload.inputs?.['pull-request'];
36+
if (!/^[1-9][0-9]*$/.test(String(number))) throw new Error('A PR number is required.');
37+
prs = [(await github.rest.pulls.get({ ...context.repo, pull_number: Number(number) })).data];
38+
} else {
39+
const sha = context.payload.workflow_run.head_sha;
40+
if (!/^[0-9a-f]{40}$/.test(sha)) throw new Error('Invalid signal SHA.');
41+
// listPullRequestsAssociatedWithCommit does not return PRs whose head commit is in a fork.
42+
const run = context.payload.workflow_run;
43+
prs = (await github.paginate(github.rest.pulls.list, { ...context.repo, state: 'open',
44+
head: `${run.head_repository.owner.login}:${run.head_branch}`, per_page: 100 }))
45+
.filter(pr => pr.head.sha === sha);
46+
}
47+
prs = prs.filter(pr => pr.state === 'open' && pr.head.repo);
48+
if (!prs.length) { core.setOutput('prs', '[]'); return; }
49+
if (prs.some(pr => pr.head.sha !== prs[0].head.sha)) throw new Error('Inconsistent signal heads.');
50+
const pr = prs[0];
51+
const target = { owner: pr.head.repo.owner.login, repo: pr.head.repo.name };
52+
const { data: root } = await github.rest.git.getTree({ ...target, tree_sha: pr.head.sha });
53+
const entry = root.tree.find(e => e.path === '.github');
54+
if (!entry || entry.type !== 'tree') throw new Error('The PR has no .github directory.');
55+
const { data: tree } = await github.rest.git.getTree({ ...target, tree_sha: entry.sha, recursive: '1' });
56+
await fs.rm(directory, { recursive: true, force: true });
57+
let total = 0;
58+
for (const file of yamlEntries(tree)) {
59+
const url = `https://raw.githubusercontent.com/${target.owner}/${target.repo}/${pr.head.sha}/.github/` +
60+
file.path.split('/').map(encodeURIComponent).join('/');
61+
const response = await fetchImpl(url, { redirect: 'error', signal: AbortSignal.timeout(30000) });
62+
if (!response.ok) throw new Error(`Could not read workflow data: HTTP ${response.status}.`);
63+
const chunks = [];
64+
let size = 0;
65+
for await (const chunk of response.body) {
66+
size += chunk.length;
67+
total += chunk.length;
68+
if (size > MAX_FILE || total > MAX_TOTAL) throw new Error('Workflow data size limit exceeded.');
69+
chunks.push(chunk);
70+
}
71+
const destination = path.join(directory, '.github', file.path);
72+
await fs.mkdir(path.dirname(destination), { recursive: true });
73+
await fs.writeFile(destination, Buffer.concat(chunks));
74+
}
75+
// These are data files outside the trusted checkout. Never source, install or execute them.
76+
core.setOutput('prs', JSON.stringify(prs.map(pr => ({ number: pr.number, sha: pr.head.sha }))));
77+
core.setOutput('scan-glob', `${directory}/.github/**/*.y*ml`);
78+
}
79+
async function publish({ github, context, core }) {
80+
const prs = JSON.parse(process.env.SCANNED_PRS || '[]');
81+
for (const pr of prs) {
82+
const { data: current } = await github.rest.pulls.get({ ...context.repo, pull_number: pr.number });
83+
if (current.state !== 'open' || current.head.sha !== pr.sha) continue;
84+
const success = process.env.SCAN_OUTCOME === 'success';
85+
await github.rest.checks.create({ ...context.repo, name: 'ASF Allowlist Check', head_sha: pr.sha,
86+
status: 'completed', conclusion: success ? 'success' : 'failure',
87+
details_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
88+
output: { title: success ? 'Workflow actions are allowlisted' : 'Workflow allowlist scan failed',
89+
summary: 'Scanned PR YAML as data with the reviewed default-branch workflow. No PR code was executed.' } });
90+
}
91+
if (process.env.SCAN_OUTCOME !== 'success' && prs.length) core.setFailed('ASF allowlist scan failed.');
92+
}
93+
module.exports = { yamlEntries, prepare, publish };

‎.github/scripts/fork-ci-status.cjs‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
'use strict';
16+
const UPSTREAM = { owner: 'apache', repo: 'gravitino' };
17+
const SUITES = ['build', 'backend', 'spark', 'flink', 'trino', 'iceberg_rest_trino', 'python',
18+
'frontend', 'access_control', 'idp_basic', 'mcp', 'maintenance', 'web_ui', 'charts',
19+
'contrib_catalog', 'asf_allowlist'];
20+
const requiredName = (pr, baseSha = pr.base.sha) => `Required CI / ${pr.base.ref} / ${baseSha} / ${pr.head.sha}`;
21+
const requiredAction = note => ({ status: 'completed', conclusion: 'action_required',
22+
title: 'Fork CI needs attention', note });
23+
24+
function evaluate(pr, run, jobs, baseSha = pr.base.sha) {
25+
const required = jobs.find(job => job.name === requiredName(pr, baseSha));
26+
if (run.status !== 'completed') return { status: 'in_progress', title: 'Fork CI is running' };
27+
if (run.conclusion !== 'success') return { status: 'completed',
28+
conclusion: ['failure', 'cancelled', 'timed_out', 'action_required'].includes(run.conclusion)
29+
? run.conclusion : 'action_required', title: `Fork CI ${run.conclusion}` };
30+
if (!required || required.conclusion !== 'success') {
31+
return requiredAction('The run did not finish Required CI for this exact head, base branch and base SHA. ' +
32+
'Update the branch and push again, or ask a committer to dispatch a trusted Fork CI run.');
33+
}
34+
for (const suite of SUITES) {
35+
const children = jobs.filter(job => job.name.startsWith(`${suite} / `));
36+
if (!children.length || !children.some(job => job.conclusion === 'success') ||
37+
children.some(job => !['success', 'skipped'].includes(job.conclusion))) {
38+
return requiredAction(`The ${suite} caller did not report successful jobs. Open the run and verify its suites.`);
39+
}
40+
}
41+
return { status: 'completed', conclusion: 'success', title: 'All required suites passed' };
42+
}
43+
44+
function createService(github) {
45+
const definitions = new Map();
46+
const trees = new Map();
47+
async function ciTree(owner, repo, sha) {
48+
const key = `${owner}/${repo}@${sha}`;
49+
if (!trees.has(key)) trees.set(key, (async () => {
50+
const { data: root } = await github.rest.git.getTree({ owner, repo, tree_sha: sha });
51+
const entries = Object.fromEntries(root.tree.map(e => [e.path, e]));
52+
const githubTree = entries['.github'];
53+
const result = { policy: entries['.asf.yaml']?.sha || null };
54+
if (!githubTree) return result;
55+
const { data: dirs } = await github.rest.git.getTree({ owner, repo, tree_sha: githubTree.sha });
56+
for (const name of ['workflows', 'actions', 'scripts']) result[name] = dirs.tree.find(e => e.path === name)?.sha || null;
57+
return result;
58+
})());
59+
return trees.get(key);
60+
}
61+
async function unchanged(pr) {
62+
const key = `${pr.head.repo.full_name}@${pr.head.sha}:${pr.base.sha}`;
63+
if (definitions.has(key)) return definitions.get(key);
64+
const [base, head] = await Promise.all([
65+
ciTree(UPSTREAM.owner, UPSTREAM.repo, pr.base.sha),
66+
ciTree(pr.head.repo.owner.login, pr.head.repo.name, pr.head.sha)
67+
]);
68+
const same = JSON.stringify(base) === JSON.stringify(head);
69+
definitions.set(key, same);
70+
return same;
71+
}
72+
async function jobsFor(owner, repo, run) {
73+
// Partial re-runs retain earlier successful jobs. Take each named job's newest
74+
// execution, rather than requiring every caller to execute in the latest attempt.
75+
const jobs = await github.paginate('GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs',
76+
{ owner, repo, run_id: run.id, filter: 'all', per_page: 100 });
77+
const latest = new Map();
78+
for (const job of jobs) {
79+
if ((job.run_attempt || 1) > run.run_attempt) continue;
80+
const previous = latest.get(job.name);
81+
if (!previous || (job.run_attempt || 1) > (previous.run_attempt || 1) ||
82+
((job.run_attempt || 1) === (previous.run_attempt || 1) && job.id > previous.id)) {
83+
latest.set(job.name, job);
84+
}
85+
}
86+
return [...latest.values()];
87+
}
88+
async function testedBase(pr, jobs) {
89+
// pr.base.sha is not refreshed by later pushes, so runs usually test a newer base commit.
90+
// Accept that commit only if it is on the PR base branch. The check can stay green after the
91+
// branch moves on; strict branch protection, not this check, requires an up-to-date branch.
92+
const prefix = `Required CI / ${pr.base.ref} / `;
93+
const suffix = ` / ${pr.head.sha}`;
94+
const job = jobs.find(job => job.name.startsWith(prefix) && job.name.endsWith(suffix));
95+
const sha = job?.name.slice(prefix.length, -suffix.length);
96+
if (!sha || sha === pr.base.sha || !/^[0-9a-f]{40}$/.test(sha)) return pr.base.sha;
97+
const { data } = await github.rest.repos.compareCommitsWithBasehead({ ...UPSTREAM,
98+
basehead: `${sha}...${pr.base.ref}`, per_page: 1 });
99+
return ['ahead', 'identical'].includes(data.status) ? sha : pr.base.sha;
100+
}
101+
async function findRun(pr, trusted) {
102+
const target = trusted ? UPSTREAM : { owner: pr.head.repo.owner.login, repo: pr.head.repo.name };
103+
const all = await github.paginate(github.rest.actions.listWorkflowRuns, { ...target, workflow_id: 'fork-ci.yml',
104+
event: trusted ? 'workflow_dispatch' : 'push', head_sha: trusted ? pr.base.sha : pr.head.sha, per_page: 100 });
105+
const runs = all.filter(run => run.path === '.github/workflows/fork-ci.yml' &&
106+
run.head_sha === (trusted ? pr.base.sha : pr.head.sha) &&
107+
(!trusted || run.display_title === `Trusted Fork CI #${pr.number}`)).sort((a, b) => b.id - a.id);
108+
return runs[0] && { ...target, run: runs[0], trusted };
109+
}
110+
async function mirror(pr) {
111+
if (!pr.head.repo) return { result: requiredAction('The PR head repository no longer exists.') };
112+
// Read trusted metadata only. Nothing in the contributor repository is checked out or executed.
113+
let ordinary = await findRun(pr, false);
114+
const sameDefinitions = await unchanged(pr);
115+
const trusted = await findRun(pr, true);
116+
let selected = trusted && (!ordinary || (trusted.run.updated_at || trusted.run.created_at) >= (ordinary.run.updated_at || ordinary.run.created_at) || !sameDefinitions)
117+
? trusted : ordinary;
118+
if (!selected) return { result: requiredAction('Enable Actions in the fork and push the branch, or dispatch trusted Fork CI.') };
119+
const { owner, repo, run } = selected;
120+
if (!selected.trusted && !sameDefinitions) return { ...selected, fork: ordinary?.run,
121+
result: requiredAction('CI workflow/action/script definitions or branch protection differ from the base. ' +
122+
'A committer must dispatch Fork CI from the current upstream default branch with this PR number.') };
123+
// A manual upstream run uses the workflow definitions at run.head_sha, not the PR definitions.
124+
const jobs = await jobsFor(owner, repo, run);
125+
const base = selected.trusted ? pr.base.sha : await testedBase(pr, jobs);
126+
return { ...selected, fork: ordinary?.run, base, result: evaluate(pr, run, jobs, base) };
127+
}
128+
return { mirror, unchanged, findRun };
129+
}
130+
module.exports = { SUITES, requiredName, evaluate, createService };

0 commit comments

Comments
 (0)