fix(ci): cross-check strict ruleset via GraphQL

This commit is contained in:
chichuan
2026-08-25 11:39:33 +08:00
parent e3124ccea1
commit aeb1b2ced2
6 changed files with 353 additions and 41 deletions
+70 -7
View File
@@ -1013,24 +1013,56 @@ jobs:
const writerRuleset = writerRulesets[0];
const writerIncludes = writerRuleset.conditions?.ref_name?.include || [];
const writerExcludes = writerRuleset.conditions?.ref_name?.exclude || [];
// GitHub's read projection omits parameters when this update-rule
// exception is disabled. Accept only that omission or exact false;
// reject a present null/non-boolean field instead of weakening the gate.
// GitHub's read projection omits the entire parameters property
// when this exception is disabled. Accept only that exact omission
// or a one-field object containing exact false.
function isStrictUpdateRule(rule) {
if (rule?.type !== 'update') {
return false;
}
const updateAllowsFetchAndMerge =
rule.parameters?.update_allows_fetch_and_merge;
if (!Object.prototype.hasOwnProperty.call(rule, 'parameters')) {
return true;
}
const parameters = rule.parameters;
if (
parameters === null ||
typeof parameters !== 'object' ||
Array.isArray(parameters)
) {
return false;
}
const parameterKeys = Object.keys(parameters);
return (
updateAllowsFetchAndMerge === undefined ||
updateAllowsFetchAndMerge === false
parameterKeys.length === 1 &&
parameterKeys[0] === 'update_allows_fetch_and_merge' &&
parameters.update_allows_fetch_and_merge === false
);
}
function isStrictGraphQLUpdateRule(restRuleset, graphRuleset) {
const restRulesetID = Number(restRuleset?.id);
const graphRulesetID = Number(graphRuleset?.databaseId);
const graphRules = graphRuleset?.rules;
const graphRule = graphRules?.nodes?.[0];
return (
Number.isSafeInteger(restRulesetID) &&
restRulesetID > 0 &&
graphRulesetID === restRulesetID &&
graphRuleset.name === restRuleset.name &&
graphRuleset.enforcement === 'ACTIVE' &&
graphRuleset.target === 'BRANCH' &&
graphRules?.totalCount === 1 &&
graphRules.nodes?.length === 1 &&
graphRule?.type === 'UPDATE' &&
graphRule.parameters?.__typename === 'UpdateParameters' &&
graphRule.parameters.updateAllowsFetchAndMerge === false
);
}
if (
writerIncludes.length !== 1 ||
writerIncludes[0] !== 'refs/heads/main' ||
writerExcludes.length !== 0 ||
typeof writerRuleset.node_id !== 'string' ||
!writerRuleset.node_id ||
writerRuleset.rules?.length !== 1 ||
!isStrictUpdateRule(writerRuleset.rules[0]) ||
writerRuleset.current_user_can_bypass !== 'never'
@@ -1040,6 +1072,37 @@ jobs:
);
return;
}
const {node: graphWriterRuleset} = await github.graphql(
`query ReviewerRouterWriterRule($rulesetID: ID!) {
node(id: $rulesetID) {
... on RepositoryRuleset {
databaseId
name
enforcement
target
rules(first: 2) {
totalCount
nodes {
type
parameters {
__typename
... on UpdateParameters {
updateAllowsFetchAndMerge
}
}
}
}
}
}
}`,
{rulesetID: writerRuleset.node_id},
);
if (!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)) {
core.setFailed(
`${writerRulesetName} must expose one strict UPDATE rule with updateAllowsFetchAndMerge=false through GraphQL.`,
);
return;
}
const maxAttempts = 6;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
+138 -14
View File
@@ -446,18 +446,48 @@ jobs:
const writerRuleset = writerRulesets[0];
const writerIncludes = writerRuleset.conditions?.ref_name?.include || [];
const writerExcludes = writerRuleset.conditions?.ref_name?.exclude || [];
// GitHub's read projection omits parameters when this update-rule
// exception is disabled. Accept only that omission or exact false;
// reject a present null/non-boolean field instead of weakening the gate.
// GitHub's read projection omits the entire parameters property
// when this exception is disabled. Accept only that exact omission
// or a one-field object containing exact false.
function isStrictUpdateRule(rule) {
if (rule?.type !== 'update') {
return false;
}
const updateAllowsFetchAndMerge =
rule.parameters?.update_allows_fetch_and_merge;
if (!Object.prototype.hasOwnProperty.call(rule, 'parameters')) {
return true;
}
const parameters = rule.parameters;
if (
parameters === null ||
typeof parameters !== 'object' ||
Array.isArray(parameters)
) {
return false;
}
const parameterKeys = Object.keys(parameters);
return (
updateAllowsFetchAndMerge === undefined ||
updateAllowsFetchAndMerge === false
parameterKeys.length === 1 &&
parameterKeys[0] === 'update_allows_fetch_and_merge' &&
parameters.update_allows_fetch_and_merge === false
);
}
function isStrictGraphQLUpdateRule(restRuleset, graphRuleset) {
const restRulesetID = Number(restRuleset?.id);
const graphRulesetID = Number(graphRuleset?.databaseId);
const graphRules = graphRuleset?.rules;
const graphRule = graphRules?.nodes?.[0];
return (
Number.isSafeInteger(restRulesetID) &&
restRulesetID > 0 &&
graphRulesetID === restRulesetID &&
graphRuleset.name === restRuleset.name &&
graphRuleset.enforcement === 'ACTIVE' &&
graphRuleset.target === 'BRANCH' &&
graphRules?.totalCount === 1 &&
graphRules.nodes?.length === 1 &&
graphRule?.type === 'UPDATE' &&
graphRule.parameters?.__typename === 'UpdateParameters' &&
graphRule.parameters.updateAllowsFetchAndMerge === false
);
}
if (
@@ -466,6 +496,8 @@ jobs:
writerIncludes.length !== 1 ||
writerIncludes[0] !== 'refs/heads/main' ||
writerExcludes.length !== 0 ||
typeof writerRuleset.node_id !== 'string' ||
!writerRuleset.node_id ||
writerRuleset.rules?.length !== 1 ||
!isStrictUpdateRule(writerRuleset.rules[0]) ||
writerRuleset.current_user_can_bypass !== 'pull_requests_only'
@@ -474,6 +506,36 @@ jobs:
'Reviewer Router App requires the exact repository-owned main writer rule with pull-request-only bypass.',
);
}
const {node: graphWriterRuleset} = await github.graphql(
`query ReviewerRouterWriterRule($rulesetID: ID!) {
node(id: $rulesetID) {
... on RepositoryRuleset {
databaseId
name
enforcement
target
rules(first: 2) {
totalCount
nodes {
type
parameters {
__typename
... on UpdateParameters {
updateAllowsFetchAndMerge
}
}
}
}
}
}
}`,
{rulesetID: writerRuleset.node_id},
);
if (!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)) {
throw new Error(
'Reviewer Router App requires GraphQL to expose one strict UPDATE rule with updateAllowsFetchAndMerge=false.',
);
}
for (const ruleset of activeMainRulesets) {
if (ruleset.id === writerRuleset.id) {
continue;
@@ -736,18 +798,48 @@ jobs:
const writerRuleset = writerRulesets[0];
const writerIncludes = writerRuleset.conditions?.ref_name?.include || [];
const writerExcludes = writerRuleset.conditions?.ref_name?.exclude || [];
// GitHub's read projection omits parameters when this update-rule
// exception is disabled. Accept only that omission or exact false;
// reject a present null/non-boolean field instead of weakening the gate.
// GitHub's read projection omits the entire parameters property
// when this exception is disabled. Accept only that exact omission
// or a one-field object containing exact false.
function isStrictUpdateRule(rule) {
if (rule?.type !== 'update') {
return false;
}
const updateAllowsFetchAndMerge =
rule.parameters?.update_allows_fetch_and_merge;
if (!Object.prototype.hasOwnProperty.call(rule, 'parameters')) {
return true;
}
const parameters = rule.parameters;
if (
parameters === null ||
typeof parameters !== 'object' ||
Array.isArray(parameters)
) {
return false;
}
const parameterKeys = Object.keys(parameters);
return (
updateAllowsFetchAndMerge === undefined ||
updateAllowsFetchAndMerge === false
parameterKeys.length === 1 &&
parameterKeys[0] === 'update_allows_fetch_and_merge' &&
parameters.update_allows_fetch_and_merge === false
);
}
function isStrictGraphQLUpdateRule(restRuleset, graphRuleset) {
const restRulesetID = Number(restRuleset?.id);
const graphRulesetID = Number(graphRuleset?.databaseId);
const graphRules = graphRuleset?.rules;
const graphRule = graphRules?.nodes?.[0];
return (
Number.isSafeInteger(restRulesetID) &&
restRulesetID > 0 &&
graphRulesetID === restRulesetID &&
graphRuleset.name === restRuleset.name &&
graphRuleset.enforcement === 'ACTIVE' &&
graphRuleset.target === 'BRANCH' &&
graphRules?.totalCount === 1 &&
graphRules.nodes?.length === 1 &&
graphRule?.type === 'UPDATE' &&
graphRule.parameters?.__typename === 'UpdateParameters' &&
graphRule.parameters.updateAllowsFetchAndMerge === false
);
}
if (
@@ -756,6 +848,8 @@ jobs:
writerIncludes.length !== 1 ||
writerIncludes[0] !== 'refs/heads/main' ||
writerExcludes.length !== 0 ||
typeof writerRuleset.node_id !== 'string' ||
!writerRuleset.node_id ||
writerRuleset.rules?.length !== 1 ||
!isStrictUpdateRule(writerRuleset.rules[0]) ||
writerRuleset.current_user_can_bypass !== 'pull_requests_only'
@@ -764,6 +858,36 @@ jobs:
'Reviewer Router App requires the exact repository-owned main writer rule with pull-request-only bypass.',
);
}
const {node: graphWriterRuleset} = await github.graphql(
`query ReviewerRouterWriterRule($rulesetID: ID!) {
node(id: $rulesetID) {
... on RepositoryRuleset {
databaseId
name
enforcement
target
rules(first: 2) {
totalCount
nodes {
type
parameters {
__typename
... on UpdateParameters {
updateAllowsFetchAndMerge
}
}
}
}
}
}
}`,
{rulesetID: writerRuleset.node_id},
);
if (!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)) {
throw new Error(
'Reviewer Router App requires GraphQL to expose one strict UPDATE rule with updateAllowsFetchAndMerge=false.',
);
}
for (const ruleset of activeMainRulesets) {
if (ruleset.id === writerRuleset.id) {
continue;