@davidvornholt/standards 0.17.3 → 0.18.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 +9 -1
- package/src/cli.ts +26 -11
- 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
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Turns unverifiable ruleset fields into gate failures. This module owns both
|
|
2
|
+
// the wording of an invisible field and the test for which field it is, so the
|
|
3
|
+
// two cannot drift apart: an earlier version formatted the message in one
|
|
4
|
+
// module and recovered the signal by parsing the suffix in another, where a
|
|
5
|
+
// reformat would have silently stopped the bypass-actor fallback from firing.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
ADMIN_VISIBILITY_ADVICE,
|
|
9
|
+
unverifiableProblem,
|
|
10
|
+
} from './github-command-shared';
|
|
11
|
+
import {
|
|
12
|
+
BYPASS_ACTORS_KEY,
|
|
13
|
+
type UnverifiableRulesetField,
|
|
14
|
+
} from './github-ruleset-diff';
|
|
15
|
+
|
|
16
|
+
const SCOPE = 'ruleset field(s)';
|
|
17
|
+
|
|
18
|
+
export const isHiddenBypassActors = (
|
|
19
|
+
field: UnverifiableRulesetField,
|
|
20
|
+
): boolean => field.key === BYPASS_ACTORS_KEY;
|
|
21
|
+
|
|
22
|
+
const fieldLabel = (field: UnverifiableRulesetField): string =>
|
|
23
|
+
`ruleset "${field.name}": ${field.key}`;
|
|
24
|
+
|
|
25
|
+
// What the GraphQL bypass-actor fallback established, so the advice can tell a
|
|
26
|
+
// failed request from a permission gap. `countedNames` holds the live rulesets
|
|
27
|
+
// GraphQL did answer a count for.
|
|
28
|
+
export type BypassCountOutcome = {
|
|
29
|
+
readonly countedNames: ReadonlySet<string>;
|
|
30
|
+
readonly failure: string | null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Rulesets can be unverifiable for reasons other than a hidden bypass list, in
|
|
34
|
+
// which case the fallback is never called.
|
|
35
|
+
export const GRAPHQL_NOT_CONSULTED: BypassCountOutcome = {
|
|
36
|
+
countedNames: new Set(),
|
|
37
|
+
failure: null,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// The generic "read access to repository administration" remedy is
|
|
41
|
+
// unactionable for brokered CI here: REST withholds this field from a GitHub
|
|
42
|
+
// App installation token at every permission level, so widening the App only
|
|
43
|
+
// broadens the blast radius while the gate stays red.
|
|
44
|
+
const INSTALLATION_DEAD_END =
|
|
45
|
+
"A GitHub App installation token cannot read bypass_actors over REST at any permission level, so widening the App's permissions will not help";
|
|
46
|
+
|
|
47
|
+
const COUNT_MATCHED_ADVICE = `GitHub reports the declared number of bypass actors but withholds their identities, so the repository is probably fine and only a local run with admin auth can confirm it. ${INSTALLATION_DEAD_END}`;
|
|
48
|
+
|
|
49
|
+
const NOTHING_ANSWERED_ADVICE = `Neither REST nor the GraphQL bypass-actor count answered for the ruleset(s) listed, so nothing at all is known about who may bypass them; verify locally with admin auth. ${INSTALLATION_DEAD_END}`;
|
|
50
|
+
|
|
51
|
+
// The two no-failure remedies differ per ruleset, so the fields are partitioned
|
|
52
|
+
// rather than reported under whichever advice one of them happened to match: a
|
|
53
|
+
// ruleset GraphQL never answered for must never be described as probably fine
|
|
54
|
+
// because a sibling ruleset was counted.
|
|
55
|
+
const hiddenBypassActorsProblems = (
|
|
56
|
+
fields: ReadonlyArray<UnverifiableRulesetField>,
|
|
57
|
+
outcome: BypassCountOutcome,
|
|
58
|
+
): ReadonlyArray<string> => {
|
|
59
|
+
if (outcome.failure !== null) {
|
|
60
|
+
return unverifiableProblem(
|
|
61
|
+
SCOPE,
|
|
62
|
+
fields.map(fieldLabel),
|
|
63
|
+
`The GraphQL bypass-actor count did not answer (${outcome.failure}), so this is a failed request rather than a permission gap: re-run the check, and if it keeps failing verify locally with admin auth`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
const counted = fields.filter((field) =>
|
|
67
|
+
outcome.countedNames.has(field.name),
|
|
68
|
+
);
|
|
69
|
+
const unanswered = fields.filter(
|
|
70
|
+
(field) => !outcome.countedNames.has(field.name),
|
|
71
|
+
);
|
|
72
|
+
return [
|
|
73
|
+
...unverifiableProblem(
|
|
74
|
+
SCOPE,
|
|
75
|
+
counted.map(fieldLabel),
|
|
76
|
+
COUNT_MATCHED_ADVICE,
|
|
77
|
+
),
|
|
78
|
+
...unverifiableProblem(
|
|
79
|
+
SCOPE,
|
|
80
|
+
unanswered.map(fieldLabel),
|
|
81
|
+
NOTHING_ANSWERED_ADVICE,
|
|
82
|
+
),
|
|
83
|
+
];
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const rulesetVisibilityProblems = (
|
|
87
|
+
unverifiable: ReadonlyArray<UnverifiableRulesetField>,
|
|
88
|
+
outcome: BypassCountOutcome,
|
|
89
|
+
): ReadonlyArray<string> => {
|
|
90
|
+
const hidden = unverifiable.filter(isHiddenBypassActors);
|
|
91
|
+
const others = unverifiable.filter((field) => !isHiddenBypassActors(field));
|
|
92
|
+
return [
|
|
93
|
+
...unverifiableProblem(
|
|
94
|
+
SCOPE,
|
|
95
|
+
others.map(fieldLabel),
|
|
96
|
+
ADMIN_VISIBILITY_ADVICE,
|
|
97
|
+
),
|
|
98
|
+
...hiddenBypassActorsProblems(hidden, outcome),
|
|
99
|
+
];
|
|
100
|
+
};
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// module is zero-dependency so `bunx` can execute the published package.
|
|
1
|
+
// Parse one GitHub settings file: canonical `.github/settings.json` or the
|
|
2
|
+
// repo-owned `.github/settings.local.json` extension. Seam merging, drift
|
|
3
|
+
// comparison, and API interaction live in dedicated modules. This module stays
|
|
4
|
+
// zero-dependency so `bunx` can execute the published package.
|
|
6
5
|
|
|
7
6
|
import { labelIdentity } from './github-label-identity';
|
|
8
7
|
|
|
@@ -139,13 +138,15 @@ const parseDeclarationList = (
|
|
|
139
138
|
options: ListField,
|
|
140
139
|
problems: Array<string>,
|
|
141
140
|
): ReadonlyArray<unknown> | null => {
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
if (raw === undefined) {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
if (!Array.isArray(raw)) {
|
|
144
145
|
problems.push(`${options.label} "${options.field}" must be an array`);
|
|
145
146
|
return null;
|
|
146
147
|
}
|
|
147
|
-
problems.push(...options.listProblems(
|
|
148
|
-
return
|
|
148
|
+
problems.push(...options.listProblems(raw, options.label));
|
|
149
|
+
return raw;
|
|
149
150
|
};
|
|
150
151
|
|
|
151
152
|
export const parseSettings = (
|
|
@@ -162,7 +163,7 @@ export const parseSettings = (
|
|
|
162
163
|
problems.push(`${label} has unknown key "${key}"`);
|
|
163
164
|
}
|
|
164
165
|
}
|
|
165
|
-
const repository = raw.repository
|
|
166
|
+
const repository = 'repository' in raw ? raw.repository : {};
|
|
166
167
|
if (!isRecord(repository)) {
|
|
167
168
|
problems.push(`${label} "repository" must be an object`);
|
|
168
169
|
}
|
|
@@ -179,6 +180,7 @@ export const parseSettings = (
|
|
|
179
180
|
// Only the opt-out value is accepted: enforcement is the default, and an
|
|
180
181
|
// explicit "enforced" would be a second way to spell the same state.
|
|
181
182
|
if (
|
|
183
|
+
allowedKeys.has('rulesetEnforcement') &&
|
|
182
184
|
raw.rulesetEnforcement !== undefined &&
|
|
183
185
|
raw.rulesetEnforcement !== ENFORCEMENT_OPT_OUT
|
|
184
186
|
) {
|
package/src/github-settings.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Load and merge canonical GitHub settings with the repo-owned extension.
|
|
2
2
|
// Parsing lives in github-settings-parse.ts, merge policy in
|
|
3
|
-
// github-settings-merge.ts, drift in github-diff.ts
|
|
4
|
-
// github-api.ts; this stays
|
|
3
|
+
// github-settings-merge.ts, drift in github-diff.ts and
|
|
4
|
+
// github-ruleset-diff.ts, and API work in github-api.ts; this stays
|
|
5
|
+
// dependency-free.
|
|
5
6
|
|
|
6
7
|
import { mergeSettings } from './github-settings-merge';
|
|
7
8
|
import {
|