@davidvornholt/standards 0.17.3 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +10 -1
- package/src/cli.ts +307 -82
- package/src/creds-login-github-manifest.ts +14 -7
- package/src/dependabot-directory-character-class.ts +139 -0
- package/src/dependabot-directory-normalize.ts +33 -0
- package/src/dependabot-directory-overlap.ts +139 -0
- package/src/dependabot-update.ts +32 -4
- package/src/github-api.ts +0 -80
- package/src/github-apply.ts +3 -2
- package/src/github-bypass-actors.ts +56 -0
- package/src/github-command-shared.ts +9 -2
- package/src/github-diff.ts +2 -109
- package/src/github-graphql.ts +198 -0
- package/src/github-live-drift.ts +57 -6
- package/src/github-ruleset-api.ts +57 -0
- package/src/github-ruleset-diff.ts +181 -0
- package/src/github-ruleset-visibility.ts +100 -0
- package/src/github-settings-parse.ts +12 -10
- package/src/github-settings.ts +3 -2
- package/src/managed-files.ts +116 -14
- package/src/managed-path-ownership.ts +85 -0
package/src/github-diff.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Drift comparison between declared GitHub settings (github-settings.ts) and
|
|
2
|
-
// the live state returned by the GitHub API. Pure logic; no network.
|
|
2
|
+
// the live state returned by the GitHub API. Pure logic; no network. Ruleset
|
|
3
|
+
// comparison lives in github-ruleset-diff.ts.
|
|
3
4
|
|
|
4
5
|
import { isRecord } from './github-settings-parse';
|
|
5
6
|
|
|
@@ -41,114 +42,6 @@ export const subsetMatches = (declared: unknown, live: unknown): boolean => {
|
|
|
41
42
|
return declared === live;
|
|
42
43
|
};
|
|
43
44
|
|
|
44
|
-
const RULESET_COMPARED_KEYS = [
|
|
45
|
-
'target',
|
|
46
|
-
'enforcement',
|
|
47
|
-
'conditions',
|
|
48
|
-
'bypass_actors',
|
|
49
|
-
] as const;
|
|
50
|
-
|
|
51
|
-
const diffRules = (
|
|
52
|
-
name: string,
|
|
53
|
-
declared: Readonly<Record<string, unknown>>,
|
|
54
|
-
live: Readonly<Record<string, unknown>>,
|
|
55
|
-
): ReadonlyArray<string> => {
|
|
56
|
-
const drifted: Array<string> = [];
|
|
57
|
-
const declaredRules = Array.isArray(declared.rules)
|
|
58
|
-
? declared.rules.filter(isRecord)
|
|
59
|
-
: [];
|
|
60
|
-
const liveRules = Array.isArray(live.rules)
|
|
61
|
-
? live.rules.filter(isRecord)
|
|
62
|
-
: [];
|
|
63
|
-
const liveByType = new Map(
|
|
64
|
-
liveRules.map((rule) => [String(rule.type), rule]),
|
|
65
|
-
);
|
|
66
|
-
const declaredTypes = new Set(declaredRules.map((rule) => String(rule.type)));
|
|
67
|
-
for (const rule of declaredRules) {
|
|
68
|
-
const type = String(rule.type);
|
|
69
|
-
const liveRule = liveByType.get(type);
|
|
70
|
-
const declaredWithoutType = Object.fromEntries(
|
|
71
|
-
Object.entries(rule).filter(([key]) => key !== 'type'),
|
|
72
|
-
);
|
|
73
|
-
if (liveRule === undefined) {
|
|
74
|
-
drifted.push(`ruleset "${name}": missing rule "${type}"`);
|
|
75
|
-
} else if (!subsetMatches(declaredWithoutType, liveRule)) {
|
|
76
|
-
drifted.push(
|
|
77
|
-
`ruleset "${name}": rule "${type}" differs from the declared configuration`,
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
for (const type of liveByType.keys()) {
|
|
82
|
-
if (!declaredTypes.has(type)) {
|
|
83
|
-
drifted.push(`ruleset "${name}": has undeclared extra rule "${type}"`);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
return drifted;
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
// Some ruleset fields — bypass_actors in particular — are only included in
|
|
90
|
-
// API responses for admin viewers. A declared key that is absent on the live
|
|
91
|
-
// side is unverifiable for this token, not drift: the same policy as
|
|
92
|
-
// repository merge settings, so callers can fail with a targeted
|
|
93
|
-
// missing-visibility message instead of a bogus value mismatch.
|
|
94
|
-
export const diffRuleset = (
|
|
95
|
-
declared: Readonly<Record<string, unknown>>,
|
|
96
|
-
live: Readonly<Record<string, unknown>>,
|
|
97
|
-
): SettingsDiff => {
|
|
98
|
-
const name = String(declared.name);
|
|
99
|
-
const drifted: Array<string> = [];
|
|
100
|
-
const unverifiable: Array<string> = [];
|
|
101
|
-
for (const key of RULESET_COMPARED_KEYS) {
|
|
102
|
-
if (declared[key] !== undefined) {
|
|
103
|
-
if (live[key] === undefined) {
|
|
104
|
-
unverifiable.push(`ruleset "${name}": ${key}`);
|
|
105
|
-
} else if (!subsetMatches(declared[key], live[key])) {
|
|
106
|
-
drifted.push(
|
|
107
|
-
`ruleset "${name}": ${key} differs from the declared configuration`,
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
drifted.push(...diffRules(name, declared, live));
|
|
113
|
-
return { drifted, unverifiable };
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
// Live rulesets must be exactly the declared set: additions, removals, and
|
|
117
|
-
// in-place edits are all drift.
|
|
118
|
-
export const diffRulesets = (
|
|
119
|
-
declared: ReadonlyArray<Readonly<Record<string, unknown>>>,
|
|
120
|
-
live: ReadonlyArray<Readonly<Record<string, unknown>>>,
|
|
121
|
-
): SettingsDiff => {
|
|
122
|
-
const drifted: Array<string> = [];
|
|
123
|
-
const unverifiable: Array<string> = [];
|
|
124
|
-
const liveByName = new Map(
|
|
125
|
-
live.map((ruleset) => [String(ruleset.name), ruleset]),
|
|
126
|
-
);
|
|
127
|
-
const declaredNames = new Set(
|
|
128
|
-
declared.map((ruleset) => String(ruleset.name)),
|
|
129
|
-
);
|
|
130
|
-
for (const ruleset of declared) {
|
|
131
|
-
const liveRuleset = liveByName.get(String(ruleset.name));
|
|
132
|
-
if (liveRuleset === undefined) {
|
|
133
|
-
drifted.push(
|
|
134
|
-
`ruleset "${ruleset.name}" is declared but missing on GitHub`,
|
|
135
|
-
);
|
|
136
|
-
} else {
|
|
137
|
-
const diff = diffRuleset(ruleset, liveRuleset);
|
|
138
|
-
drifted.push(...diff.drifted);
|
|
139
|
-
unverifiable.push(...diff.unverifiable);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
for (const name of liveByName.keys()) {
|
|
143
|
-
if (!declaredNames.has(name)) {
|
|
144
|
-
drifted.push(
|
|
145
|
-
`ruleset "${name}" exists on GitHub but is not declared; declare it in .github/settings.local.json or delete it`,
|
|
146
|
-
);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
return { drifted, unverifiable };
|
|
150
|
-
};
|
|
151
|
-
|
|
152
45
|
// Repo merge settings are only visible to admin tokens; report invisible keys
|
|
153
46
|
// as unverifiable instead of drifted so callers fail the gate with a
|
|
154
47
|
// missing-visibility message that names the token fix, not a bogus value
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// GraphQL fallbacks for repository state REST hides from tokens that can
|
|
2
|
+
// legitimately read it. Each returns only what GraphQL actually answered, so a
|
|
3
|
+
// remaining gap stays unverifiable instead of becoming a false pass.
|
|
4
|
+
|
|
5
|
+
import { apiError, HTTP_OK, request } from './github-api';
|
|
6
|
+
import { isRecord } from './github-settings-parse';
|
|
7
|
+
|
|
8
|
+
// The `repository` node GraphQL answered with, or why there is no answer.
|
|
9
|
+
type GraphqlAnswer = {
|
|
10
|
+
readonly repository: Readonly<Record<string, unknown>> | null;
|
|
11
|
+
readonly failure: string | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const errorSummary = (errors: ReadonlyArray<unknown>): string =>
|
|
15
|
+
errors
|
|
16
|
+
.map((error) =>
|
|
17
|
+
isRecord(error) && typeof error.message === 'string'
|
|
18
|
+
? error.message
|
|
19
|
+
: JSON.stringify(error),
|
|
20
|
+
)
|
|
21
|
+
.join('; ');
|
|
22
|
+
|
|
23
|
+
// GraphQL reports a permission failure as HTTP 200 with a partial `data` and a
|
|
24
|
+
// non-empty `errors` array, so a body carrying errors is no answer at all: a
|
|
25
|
+
// suppressed field that survives as a plausible value — `0` for a count — must
|
|
26
|
+
// never be read as a verified one.
|
|
27
|
+
const queryRepository = async (
|
|
28
|
+
token: string | null,
|
|
29
|
+
repo: string,
|
|
30
|
+
selection: string,
|
|
31
|
+
): Promise<GraphqlAnswer> => {
|
|
32
|
+
const [owner, name] = repo.split('/');
|
|
33
|
+
if (owner === undefined || name === undefined) {
|
|
34
|
+
return {
|
|
35
|
+
repository: null,
|
|
36
|
+
failure: `"${repo}" is not an owner/name repository`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const response = await request(token, 'POST', '/graphql', {
|
|
40
|
+
query: `query { repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { ${selection} } }`,
|
|
41
|
+
});
|
|
42
|
+
if (response.status !== HTTP_OK || !isRecord(response.body)) {
|
|
43
|
+
return {
|
|
44
|
+
repository: null,
|
|
45
|
+
failure: apiError('querying the GraphQL API', response),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const { errors } = response.body;
|
|
49
|
+
if (Array.isArray(errors) && errors.length > 0) {
|
|
50
|
+
return {
|
|
51
|
+
repository: null,
|
|
52
|
+
failure: `HTTP ${response.status} ${errorSummary(errors)}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const data = isRecord(response.body.data) ? response.body.data : null;
|
|
56
|
+
if (data === null || !isRecord(data.repository)) {
|
|
57
|
+
return {
|
|
58
|
+
repository: null,
|
|
59
|
+
failure: `HTTP ${response.status} the response carried no repository`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return { repository: data.repository, failure: null };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const GRAPHQL_MERGE_FIELDS: ReadonlyMap<string, string> = new Map([
|
|
66
|
+
['allow_auto_merge', 'autoMergeAllowed'],
|
|
67
|
+
['allow_merge_commit', 'mergeCommitAllowed'],
|
|
68
|
+
['allow_rebase_merge', 'rebaseMergeAllowed'],
|
|
69
|
+
['allow_squash_merge', 'squashMergeAllowed'],
|
|
70
|
+
['delete_branch_on_merge', 'deleteBranchOnMerge'],
|
|
71
|
+
['merge_commit_message', 'mergeCommitMessage'],
|
|
72
|
+
['merge_commit_title', 'mergeCommitTitle'],
|
|
73
|
+
['squash_merge_commit_message', 'squashMergeCommitMessage'],
|
|
74
|
+
['squash_merge_commit_title', 'squashMergeCommitTitle'],
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
// REST omits repository merge settings for read-only viewers (they surface
|
|
78
|
+
// only with write access — community discussion #153258), but GraphQL serves
|
|
79
|
+
// the same values, with identical enum spellings, to any token that can read
|
|
80
|
+
// the repository. This keeps a read-only token sufficient for the check.
|
|
81
|
+
// Returns only the keys GraphQL answered; the rest stay unverifiable.
|
|
82
|
+
export const fetchMergeSettingsViaGraphql = async (
|
|
83
|
+
token: string | null,
|
|
84
|
+
repo: string,
|
|
85
|
+
keys: ReadonlyArray<string>,
|
|
86
|
+
): Promise<Readonly<Record<string, unknown>>> => {
|
|
87
|
+
const mapped = keys.filter((key) => GRAPHQL_MERGE_FIELDS.has(key));
|
|
88
|
+
if (mapped.length === 0) {
|
|
89
|
+
return {};
|
|
90
|
+
}
|
|
91
|
+
const { repository } = await queryRepository(
|
|
92
|
+
token,
|
|
93
|
+
repo,
|
|
94
|
+
mapped.map((key) => GRAPHQL_MERGE_FIELDS.get(key)).join(' '),
|
|
95
|
+
);
|
|
96
|
+
if (repository === null) {
|
|
97
|
+
return {};
|
|
98
|
+
}
|
|
99
|
+
return Object.fromEntries(
|
|
100
|
+
mapped
|
|
101
|
+
.map((key) => [key, repository[GRAPHQL_MERGE_FIELDS.get(key) ?? '']])
|
|
102
|
+
.filter(([, value]) => value !== undefined && value !== null),
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// One page is every ruleset any consumer declares. Nothing rests on that:
|
|
107
|
+
// counts are only ever consulted for rulesets the REST loader listed, and a
|
|
108
|
+
// ruleset GraphQL did not answer for stays unverifiable.
|
|
109
|
+
const GRAPHQL_RULESET_PAGE = 100;
|
|
110
|
+
|
|
111
|
+
// Asking for one actor makes the count self-corroborating at no extra cost:
|
|
112
|
+
// `totalCount === 0` must yield an empty node list and `totalCount >= 1`
|
|
113
|
+
// exactly one node.
|
|
114
|
+
const BYPASS_ACTOR_PROBE = 1;
|
|
115
|
+
|
|
116
|
+
// A repository-owned ruleset node paired with its bypass-actor count, keyed by
|
|
117
|
+
// the id REST reports for the same ruleset, or null for anything else.
|
|
118
|
+
// Org-inherited rulesets are outside this declaration's authority, the same
|
|
119
|
+
// filter the REST loader applies. A node list that disagrees with its own
|
|
120
|
+
// `totalCount` means the connection is being filtered rather than merely
|
|
121
|
+
// redacted, so it supplies no count; a null *element* is the documented
|
|
122
|
+
// identity-withholding shape, so only the length is checked.
|
|
123
|
+
const countedRuleset = (node: unknown): readonly [number, number] | null => {
|
|
124
|
+
if (!isRecord(node) || typeof node.databaseId !== 'number') {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
if (!isRecord(node.source) || node.source.__typename !== 'Repository') {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const actors = isRecord(node.bypassActors) ? node.bypassActors : null;
|
|
131
|
+
if (
|
|
132
|
+
actors === null ||
|
|
133
|
+
typeof actors.totalCount !== 'number' ||
|
|
134
|
+
!Array.isArray(actors.nodes)
|
|
135
|
+
) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const total = actors.totalCount;
|
|
139
|
+
return total >= 0 &&
|
|
140
|
+
actors.nodes.length === Math.min(total, BYPASS_ACTOR_PROBE)
|
|
141
|
+
? [node.databaseId, total]
|
|
142
|
+
: null;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Nothing promises an id appears once in the node list, and a repeated key
|
|
146
|
+
// would silently take the last node's count. Dropping the id entirely degrades
|
|
147
|
+
// it to unverifiable instead.
|
|
148
|
+
const withoutRepeatedIds = (
|
|
149
|
+
entries: ReadonlyArray<readonly [number, number]>,
|
|
150
|
+
): ReadonlyMap<number, number> => {
|
|
151
|
+
const seen = new Set<number>();
|
|
152
|
+
const repeated = new Set<number>();
|
|
153
|
+
for (const [id] of entries) {
|
|
154
|
+
if (seen.has(id)) {
|
|
155
|
+
repeated.add(id);
|
|
156
|
+
}
|
|
157
|
+
seen.add(id);
|
|
158
|
+
}
|
|
159
|
+
return new Map(entries.filter(([id]) => !repeated.has(id)));
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export type BypassActorCounts = {
|
|
163
|
+
// Keyed by ruleset id, never by name: two live rulesets may share a name, and
|
|
164
|
+
// joining on it would let a harmless ruleset's count verify a bypassed
|
|
165
|
+
// ruleset's list.
|
|
166
|
+
readonly counts: ReadonlyMap<number, number>;
|
|
167
|
+
readonly failure: string | null;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// REST omits ruleset `bypass_actors` for viewers without repository
|
|
171
|
+
// Administration access, and omits it from GitHub App installation tokens
|
|
172
|
+
// whatever the App's permissions — so a brokered CI token can never read the
|
|
173
|
+
// list there. GraphQL answers `bypassActors` on the same repository-owned
|
|
174
|
+
// rulesets and serves an exact `totalCount` to those tokens, withholding only
|
|
175
|
+
// the actor identities. Returns the count per ruleset id; an id GraphQL did
|
|
176
|
+
// not answer for stays absent, so the caller keeps it unverifiable.
|
|
177
|
+
export const fetchBypassActorCountsViaGraphql = async (
|
|
178
|
+
token: string | null,
|
|
179
|
+
repo: string,
|
|
180
|
+
): Promise<BypassActorCounts> => {
|
|
181
|
+
const answer = await queryRepository(
|
|
182
|
+
token,
|
|
183
|
+
repo,
|
|
184
|
+
`rulesets(first: ${GRAPHQL_RULESET_PAGE}, includeParents: false) { nodes { databaseId source { __typename } bypassActors(first: ${BYPASS_ACTOR_PROBE}) { totalCount nodes { __typename } } } }`,
|
|
185
|
+
);
|
|
186
|
+
const rulesets =
|
|
187
|
+
answer.repository !== null && isRecord(answer.repository.rulesets)
|
|
188
|
+
? answer.repository.rulesets
|
|
189
|
+
: null;
|
|
190
|
+
const nodes =
|
|
191
|
+
rulesets !== null && Array.isArray(rulesets.nodes) ? rulesets.nodes : [];
|
|
192
|
+
return {
|
|
193
|
+
counts: withoutRepeatedIds(
|
|
194
|
+
nodes.map(countedRuleset).filter((entry) => entry !== null),
|
|
195
|
+
),
|
|
196
|
+
failure: answer.failure,
|
|
197
|
+
};
|
|
198
|
+
};
|
package/src/github-live-drift.ts
CHANGED
|
@@ -4,8 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
apiError,
|
|
7
|
-
fetchLiveRulesets,
|
|
8
|
-
fetchMergeSettingsViaGraphql,
|
|
9
7
|
HTTP_FORBIDDEN,
|
|
10
8
|
HTTP_OK,
|
|
11
9
|
HTTP_UNAUTHORIZED,
|
|
@@ -13,14 +11,27 @@ import {
|
|
|
13
11
|
resolveGithubRepo,
|
|
14
12
|
resolveToken,
|
|
15
13
|
} from './github-api';
|
|
14
|
+
import { resolveHiddenBypassActors } from './github-bypass-actors';
|
|
16
15
|
import {
|
|
16
|
+
ADMIN_VISIBILITY_ADVICE,
|
|
17
17
|
enforceableRepositorySettings,
|
|
18
18
|
optOutEligibilityProblem,
|
|
19
19
|
unverifiableProblem,
|
|
20
20
|
} from './github-command-shared';
|
|
21
|
-
import { diffRepositorySettings
|
|
21
|
+
import { diffRepositorySettings } from './github-diff';
|
|
22
|
+
import {
|
|
23
|
+
fetchBypassActorCountsViaGraphql,
|
|
24
|
+
fetchMergeSettingsViaGraphql,
|
|
25
|
+
} from './github-graphql';
|
|
22
26
|
import { diffLabels, fetchLiveLabels } from './github-labels';
|
|
23
27
|
import { GithubListResponseError } from './github-paginate';
|
|
28
|
+
import { fetchLiveRulesets } from './github-ruleset-api';
|
|
29
|
+
import { diffRulesets } from './github-ruleset-diff';
|
|
30
|
+
import {
|
|
31
|
+
GRAPHQL_NOT_CONSULTED,
|
|
32
|
+
isHiddenBypassActors,
|
|
33
|
+
rulesetVisibilityProblems,
|
|
34
|
+
} from './github-ruleset-visibility';
|
|
24
35
|
import { type GithubSettings, isRecord } from './github-settings-parse';
|
|
25
36
|
|
|
26
37
|
const LABEL_VISIBILITY_PROBLEM =
|
|
@@ -73,10 +84,29 @@ const repositoryDrift = async (
|
|
|
73
84
|
});
|
|
74
85
|
return [
|
|
75
86
|
...rediff.drifted,
|
|
76
|
-
...unverifiableProblem(
|
|
87
|
+
...unverifiableProblem(
|
|
88
|
+
'repository setting(s)',
|
|
89
|
+
rediff.unverifiable,
|
|
90
|
+
ADMIN_VISIBILITY_ADVICE,
|
|
91
|
+
),
|
|
77
92
|
];
|
|
78
93
|
};
|
|
79
94
|
|
|
95
|
+
// The live rulesets GraphQL answered a bypass-actor count for, named as the
|
|
96
|
+
// REST response names them so the advice can say whether a hidden list was
|
|
97
|
+
// counted-and-matched or never answered for at all.
|
|
98
|
+
const countedRulesetNames = (
|
|
99
|
+
live: ReadonlyArray<Readonly<Record<string, unknown>>>,
|
|
100
|
+
counts: ReadonlyMap<number, number>,
|
|
101
|
+
): ReadonlySet<string> =>
|
|
102
|
+
new Set(
|
|
103
|
+
live
|
|
104
|
+
.filter(
|
|
105
|
+
(ruleset) => typeof ruleset.id === 'number' && counts.has(ruleset.id),
|
|
106
|
+
)
|
|
107
|
+
.map((ruleset) => String(ruleset.name)),
|
|
108
|
+
);
|
|
109
|
+
|
|
80
110
|
const rulesetDrift = async (
|
|
81
111
|
token: string | null,
|
|
82
112
|
repo: string,
|
|
@@ -90,9 +120,30 @@ const rulesetDrift = async (
|
|
|
90
120
|
return [live.problem ?? 'unable to read rulesets'];
|
|
91
121
|
}
|
|
92
122
|
const diff = diffRulesets(declared.rulesets, live.rulesets);
|
|
123
|
+
if (!diff.unverifiable.some(isHiddenBypassActors)) {
|
|
124
|
+
return [
|
|
125
|
+
...diff.drifted,
|
|
126
|
+
...rulesetVisibilityProblems(diff.unverifiable, GRAPHQL_NOT_CONSULTED),
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
// REST hides bypass actors from every non-admin token and from installation
|
|
130
|
+
// tokens outright; retry the hidden lists as GraphQL counts before failing
|
|
131
|
+
// the gate.
|
|
132
|
+
const answered = await fetchBypassActorCountsViaGraphql(token, repo);
|
|
133
|
+
const rediff = diffRulesets(
|
|
134
|
+
declared.rulesets,
|
|
135
|
+
resolveHiddenBypassActors(
|
|
136
|
+
declared.rulesets,
|
|
137
|
+
live.rulesets,
|
|
138
|
+
answered.counts,
|
|
139
|
+
),
|
|
140
|
+
);
|
|
93
141
|
return [
|
|
94
|
-
...
|
|
95
|
-
...
|
|
142
|
+
...rediff.drifted,
|
|
143
|
+
...rulesetVisibilityProblems(rediff.unverifiable, {
|
|
144
|
+
countedNames: countedRulesetNames(live.rulesets, answered.counts),
|
|
145
|
+
failure: answered.failure,
|
|
146
|
+
}),
|
|
96
147
|
];
|
|
97
148
|
};
|
|
98
149
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Loading the live repository rulesets. This lives outside github-api.ts
|
|
2
|
+
// because the exhaustive pagination it needs (github-paginate.ts) is itself
|
|
3
|
+
// built on github-api.ts, and calling it from there would be a mutual import.
|
|
4
|
+
|
|
5
|
+
import { apiError, HTTP_OK, request } from './github-api';
|
|
6
|
+
import { listAllPages } from './github-paginate';
|
|
7
|
+
import { isRecord } from './github-settings-parse';
|
|
8
|
+
|
|
9
|
+
export type LiveRulesets = {
|
|
10
|
+
readonly rulesets: ReadonlyArray<Record<string, unknown>> | null;
|
|
11
|
+
readonly problem: string | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Only repository-sourced rulesets are managed; org-level rulesets a consumer
|
|
15
|
+
// inherits are outside this declaration's authority. The list really
|
|
16
|
+
// paginates — GitHub serves 30 per page by default and `includes_parents`
|
|
17
|
+
// defaults to true — so a single-page read would hide an undeclared ruleset
|
|
18
|
+
// that happens to land on page two, and the "not declared" branch would never
|
|
19
|
+
// fire for it.
|
|
20
|
+
//
|
|
21
|
+
// listAllPages throws, but the drift collector tells a ruleset read failure
|
|
22
|
+
// apart from a label read failure by the returned problem string, not by the
|
|
23
|
+
// exception, so the throw is converted here rather than propagated.
|
|
24
|
+
export const fetchLiveRulesets = async (
|
|
25
|
+
token: string | null,
|
|
26
|
+
repo: string,
|
|
27
|
+
): Promise<LiveRulesets> => {
|
|
28
|
+
let summaries: ReadonlyArray<unknown>;
|
|
29
|
+
try {
|
|
30
|
+
summaries = await listAllPages(
|
|
31
|
+
token,
|
|
32
|
+
`/repos/${repo}/rulesets`,
|
|
33
|
+
'listing rulesets',
|
|
34
|
+
);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return {
|
|
37
|
+
rulesets: null,
|
|
38
|
+
problem: error instanceof Error ? error.message : String(error),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const repoOwned = summaries
|
|
42
|
+
.filter(isRecord)
|
|
43
|
+
.filter((ruleset) => ruleset.source_type === 'Repository');
|
|
44
|
+
const detailed = await Promise.all(
|
|
45
|
+
repoOwned.map((ruleset) =>
|
|
46
|
+
request(token, 'GET', `/repos/${repo}/rulesets/${ruleset.id}`),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
const failed = detailed.find((response) => response.status !== HTTP_OK);
|
|
50
|
+
if (failed !== undefined) {
|
|
51
|
+
return { rulesets: null, problem: apiError('reading a ruleset', failed) };
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
rulesets: detailed.map((response) => response.body).filter(isRecord),
|
|
55
|
+
problem: null,
|
|
56
|
+
};
|
|
57
|
+
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Drift comparison for repository rulesets: the declared set
|
|
2
|
+
// (github-settings.ts) against live API state. Pure logic; no network. The
|
|
3
|
+
// general value comparison lives in github-diff.ts.
|
|
4
|
+
|
|
5
|
+
import { subsetMatches } from './github-diff';
|
|
6
|
+
import { isRecord } from './github-settings-parse';
|
|
7
|
+
|
|
8
|
+
export const BYPASS_ACTORS_KEY = 'bypass_actors';
|
|
9
|
+
|
|
10
|
+
// A declared ruleset field the live side did not carry. It stays structured so
|
|
11
|
+
// the caller owns both the wording and the test for *which* field it is;
|
|
12
|
+
// formatting it here and recovering the signal by parsing the message suffix
|
|
13
|
+
// would let a reformat silently disable the bypass-actor fallback.
|
|
14
|
+
export type UnverifiableRulesetField = {
|
|
15
|
+
readonly key: string;
|
|
16
|
+
readonly name: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type RulesetDiff = {
|
|
20
|
+
readonly drifted: ReadonlyArray<string>;
|
|
21
|
+
readonly unverifiable: ReadonlyArray<UnverifiableRulesetField>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const RULESET_COMPARED_KEYS = [
|
|
25
|
+
'target',
|
|
26
|
+
'enforcement',
|
|
27
|
+
'conditions',
|
|
28
|
+
BYPASS_ACTORS_KEY,
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
const diffRules = (
|
|
32
|
+
name: string,
|
|
33
|
+
declared: Readonly<Record<string, unknown>>,
|
|
34
|
+
live: Readonly<Record<string, unknown>>,
|
|
35
|
+
): ReadonlyArray<string> => {
|
|
36
|
+
const drifted: Array<string> = [];
|
|
37
|
+
const declaredRules = Array.isArray(declared.rules)
|
|
38
|
+
? declared.rules.filter(isRecord)
|
|
39
|
+
: [];
|
|
40
|
+
const liveRules = Array.isArray(live.rules)
|
|
41
|
+
? live.rules.filter(isRecord)
|
|
42
|
+
: [];
|
|
43
|
+
const liveByType = new Map(
|
|
44
|
+
liveRules.map((rule) => [String(rule.type), rule]),
|
|
45
|
+
);
|
|
46
|
+
const declaredTypes = new Set(declaredRules.map((rule) => String(rule.type)));
|
|
47
|
+
for (const rule of declaredRules) {
|
|
48
|
+
const type = String(rule.type);
|
|
49
|
+
const liveRule = liveByType.get(type);
|
|
50
|
+
const declaredWithoutType = Object.fromEntries(
|
|
51
|
+
Object.entries(rule).filter(([key]) => key !== 'type'),
|
|
52
|
+
);
|
|
53
|
+
if (liveRule === undefined) {
|
|
54
|
+
drifted.push(`ruleset "${name}": missing rule "${type}"`);
|
|
55
|
+
} else if (!subsetMatches(declaredWithoutType, liveRule)) {
|
|
56
|
+
drifted.push(
|
|
57
|
+
`ruleset "${name}": rule "${type}" differs from the declared configuration`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const type of liveByType.keys()) {
|
|
62
|
+
if (!declaredTypes.has(type)) {
|
|
63
|
+
drifted.push(`ruleset "${name}": has undeclared extra rule "${type}"`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return drifted;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Whoever hits bypass-actor drift holds, by construction, a token that cannot
|
|
70
|
+
// name the actors, so the two lengths are the only facts worth printing — and
|
|
71
|
+
// when the live list is stand-ins for a GraphQL count, they are the only facts
|
|
72
|
+
// that exist. Equal lengths mean the identities differ, where a count would
|
|
73
|
+
// say nothing.
|
|
74
|
+
const bypassActorCountDetail = (
|
|
75
|
+
key: string,
|
|
76
|
+
declared: unknown,
|
|
77
|
+
live: unknown,
|
|
78
|
+
): string =>
|
|
79
|
+
key === BYPASS_ACTORS_KEY &&
|
|
80
|
+
Array.isArray(declared) &&
|
|
81
|
+
Array.isArray(live) &&
|
|
82
|
+
declared.length !== live.length
|
|
83
|
+
? ` (GitHub reports ${live.length} bypass actor(s); ${declared.length} declared)`
|
|
84
|
+
: '';
|
|
85
|
+
|
|
86
|
+
// Some ruleset fields — bypass_actors in particular — are only included in
|
|
87
|
+
// API responses for admin viewers. A declared key that is absent on the live
|
|
88
|
+
// side is unverifiable for this token, not drift: the same policy as
|
|
89
|
+
// repository merge settings, so callers can fail with a targeted
|
|
90
|
+
// missing-visibility message instead of a bogus value mismatch. The live side
|
|
91
|
+
// is not always an API response — github-bypass-actors.ts synthesises
|
|
92
|
+
// unmatchable stand-in actors from a GraphQL count — so a `bypass_actors` list
|
|
93
|
+
// arriving here may encode a count GitHub answered rather than actors it
|
|
94
|
+
// named.
|
|
95
|
+
export const diffRuleset = (
|
|
96
|
+
declared: Readonly<Record<string, unknown>>,
|
|
97
|
+
live: Readonly<Record<string, unknown>>,
|
|
98
|
+
): RulesetDiff => {
|
|
99
|
+
const name = String(declared.name);
|
|
100
|
+
const drifted: Array<string> = [];
|
|
101
|
+
const unverifiable: Array<UnverifiableRulesetField> = [];
|
|
102
|
+
for (const key of RULESET_COMPARED_KEYS) {
|
|
103
|
+
if (declared[key] !== undefined) {
|
|
104
|
+
if (live[key] === undefined) {
|
|
105
|
+
unverifiable.push({ key, name });
|
|
106
|
+
} else if (!subsetMatches(declared[key], live[key])) {
|
|
107
|
+
drifted.push(
|
|
108
|
+
`ruleset "${name}": ${key} differs from the declared configuration${bypassActorCountDetail(key, declared[key], live[key])}`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
drifted.push(...diffRules(name, declared, live));
|
|
114
|
+
return { drifted, unverifiable };
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const groupLiveByName = (
|
|
118
|
+
live: ReadonlyArray<Readonly<Record<string, unknown>>>,
|
|
119
|
+
): ReadonlyMap<string, ReadonlyArray<Readonly<Record<string, unknown>>>> => {
|
|
120
|
+
const groups = new Map<string, Array<Readonly<Record<string, unknown>>>>();
|
|
121
|
+
for (const ruleset of live) {
|
|
122
|
+
const group = groups.get(String(ruleset.name));
|
|
123
|
+
if (group === undefined) {
|
|
124
|
+
groups.set(String(ruleset.name), [ruleset]);
|
|
125
|
+
} else {
|
|
126
|
+
group.push(ruleset);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return groups;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// GitHub does not require repository ruleset names to be unique, and the
|
|
133
|
+
// declaration addresses rulesets by name alone. Picking one of a colliding
|
|
134
|
+
// pair would compare the harmless one, pass, and never mention the other — a
|
|
135
|
+
// bypassed default branch reported as converged — so a collision fails the
|
|
136
|
+
// gate outright and names the ids that make it ambiguous.
|
|
137
|
+
const nameCollisionDrift = (
|
|
138
|
+
name: string,
|
|
139
|
+
group: ReadonlyArray<Readonly<Record<string, unknown>>>,
|
|
140
|
+
): string =>
|
|
141
|
+
`ruleset name "${name}" is used by ${group.length} rulesets on GitHub (ids ${group.map((ruleset) => String(ruleset.id)).join(', ')}); the declaration addresses rulesets by name, so none of them can be verified until all but one is renamed or deleted`;
|
|
142
|
+
|
|
143
|
+
// Live rulesets must be exactly the declared set: additions, removals, and
|
|
144
|
+
// in-place edits are all drift.
|
|
145
|
+
export const diffRulesets = (
|
|
146
|
+
declared: ReadonlyArray<Readonly<Record<string, unknown>>>,
|
|
147
|
+
live: ReadonlyArray<Readonly<Record<string, unknown>>>,
|
|
148
|
+
): RulesetDiff => {
|
|
149
|
+
const drifted: Array<string> = [];
|
|
150
|
+
const unverifiable: Array<UnverifiableRulesetField> = [];
|
|
151
|
+
const liveByName = groupLiveByName(live);
|
|
152
|
+
const declaredNames = new Set(
|
|
153
|
+
declared.map((ruleset) => String(ruleset.name)),
|
|
154
|
+
);
|
|
155
|
+
for (const ruleset of declared) {
|
|
156
|
+
const group = liveByName.get(String(ruleset.name)) ?? [];
|
|
157
|
+
const [only] = group;
|
|
158
|
+
// A colliding name is reported below and compared against nothing: no live
|
|
159
|
+
// ruleset in the group can be the declared one without picking a winner.
|
|
160
|
+
if (only === undefined) {
|
|
161
|
+
drifted.push(
|
|
162
|
+
`ruleset "${ruleset.name}" is declared but missing on GitHub`,
|
|
163
|
+
);
|
|
164
|
+
} else if (group.length === 1) {
|
|
165
|
+
const diff = diffRuleset(ruleset, only);
|
|
166
|
+
drifted.push(...diff.drifted);
|
|
167
|
+
unverifiable.push(...diff.unverifiable);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (const [name, group] of liveByName) {
|
|
171
|
+
if (group.length > 1) {
|
|
172
|
+
drifted.push(nameCollisionDrift(name, group));
|
|
173
|
+
}
|
|
174
|
+
if (!declaredNames.has(name)) {
|
|
175
|
+
drifted.push(
|
|
176
|
+
`ruleset "${name}" exists on GitHub but is not declared; declare it in .github/settings.local.json or delete it`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { drifted, unverifiable };
|
|
181
|
+
};
|