@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
|
@@ -10,13 +10,20 @@ import { isRecord } from './github-settings-parse';
|
|
|
10
10
|
|
|
11
11
|
export const MANIFEST_STATE_BYTES = 32;
|
|
12
12
|
|
|
13
|
-
// Root-credential ceiling: everything the broker may ever delegate.
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
// Root-credential ceiling: everything the broker may ever delegate. The
|
|
14
|
+
// `permission-*` inputs a workflow passes when minting are a request, not a
|
|
15
|
+
// constraint — anything holding the durable private key can request the whole
|
|
16
|
+
// ceiling — so this, not those inputs, is the blast radius of the App identity
|
|
17
|
+
// sitting in every consumer's secrets/ci.yaml. It therefore lists only grants a
|
|
18
|
+
// workflow actually uses today; widening it is a deliberate edit here plus a
|
|
19
|
+
// settings-page change on the existing App, not a default to grow into.
|
|
20
|
+
const DEFAULT_PERMISSIONS = {
|
|
21
|
+
actions: 'read',
|
|
22
|
+
contents: 'write',
|
|
23
|
+
issues: 'write',
|
|
24
|
+
metadata: 'read',
|
|
25
|
+
pull_requests: 'write',
|
|
26
|
+
} as const;
|
|
20
27
|
|
|
21
28
|
export const buildAppManifest = (
|
|
22
29
|
name: string,
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
export type CharacterMatcher =
|
|
2
|
+
| { readonly kind: 'literal'; readonly value: number }
|
|
3
|
+
| { readonly kind: 'non-slash' }
|
|
4
|
+
| {
|
|
5
|
+
readonly kind: 'class';
|
|
6
|
+
readonly ranges: ReadonlyArray<readonly [number, number]>;
|
|
7
|
+
readonly negated: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type CharacterRange = readonly [number, number];
|
|
11
|
+
|
|
12
|
+
type ClassValue = {
|
|
13
|
+
readonly escaped: boolean;
|
|
14
|
+
readonly value: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const BEFORE_SLASH = '/'.codePointAt(0) ?? 0;
|
|
18
|
+
const AFTER_SLASH = BEFORE_SLASH + 1;
|
|
19
|
+
const HYPHEN = '-'.codePointAt(0) ?? 0;
|
|
20
|
+
const MAXIMUM_CODE_POINT = 0x10_ff_ff;
|
|
21
|
+
|
|
22
|
+
const NON_SLASH_RANGES: ReadonlyArray<CharacterRange> = [
|
|
23
|
+
[0, BEFORE_SLASH - 1],
|
|
24
|
+
[AFTER_SLASH, MAXIMUM_CODE_POINT],
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const intersectRanges = (
|
|
28
|
+
left: ReadonlyArray<CharacterRange>,
|
|
29
|
+
right: ReadonlyArray<CharacterRange>,
|
|
30
|
+
): ReadonlyArray<CharacterRange> => {
|
|
31
|
+
const intersection: Array<CharacterRange> = [];
|
|
32
|
+
for (const [leftStart, leftEnd] of left) {
|
|
33
|
+
for (const [rightStart, rightEnd] of right) {
|
|
34
|
+
const start = Math.max(leftStart, rightStart);
|
|
35
|
+
const end = Math.min(leftEnd, rightEnd);
|
|
36
|
+
if (start <= end) {
|
|
37
|
+
intersection.push([start, end]);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return intersection;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const subtractRanges = (
|
|
45
|
+
ranges: ReadonlyArray<CharacterRange>,
|
|
46
|
+
excluded: ReadonlyArray<CharacterRange>,
|
|
47
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Range subtraction keeps the finite character-class intersection exact.
|
|
48
|
+
): ReadonlyArray<CharacterRange> => {
|
|
49
|
+
let remaining = [...ranges];
|
|
50
|
+
for (const [excludedStart, excludedEnd] of excluded) {
|
|
51
|
+
const next: Array<CharacterRange> = [];
|
|
52
|
+
for (const [start, end] of remaining) {
|
|
53
|
+
if (excludedEnd < start || excludedStart > end) {
|
|
54
|
+
next.push([start, end]);
|
|
55
|
+
} else {
|
|
56
|
+
if (start < excludedStart) {
|
|
57
|
+
next.push([start, excludedStart - 1]);
|
|
58
|
+
}
|
|
59
|
+
if (excludedEnd < end) {
|
|
60
|
+
next.push([excludedEnd + 1, end]);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
remaining = next;
|
|
65
|
+
}
|
|
66
|
+
return remaining;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const matcherRanges = (
|
|
70
|
+
matcher: CharacterMatcher,
|
|
71
|
+
): ReadonlyArray<CharacterRange> => {
|
|
72
|
+
if (matcher.kind === 'literal') {
|
|
73
|
+
return [[matcher.value, matcher.value]];
|
|
74
|
+
}
|
|
75
|
+
if (matcher.kind === 'non-slash') {
|
|
76
|
+
return NON_SLASH_RANGES;
|
|
77
|
+
}
|
|
78
|
+
return matcher.negated
|
|
79
|
+
? subtractRanges(NON_SLASH_RANGES, matcher.ranges)
|
|
80
|
+
: intersectRanges(NON_SLASH_RANGES, matcher.ranges);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const matchersIntersect = (
|
|
84
|
+
left: CharacterMatcher,
|
|
85
|
+
right: CharacterMatcher,
|
|
86
|
+
): boolean =>
|
|
87
|
+
intersectRanges(matcherRanges(left), matcherRanges(right)).length > 0;
|
|
88
|
+
|
|
89
|
+
export const parseCharacterClass = (
|
|
90
|
+
characters: ReadonlyArray<string>,
|
|
91
|
+
start: number,
|
|
92
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Ruby character classes require escape, negation, and range state in one parser.
|
|
93
|
+
): { readonly end: number; readonly matcher: CharacterMatcher } | null => {
|
|
94
|
+
let cursor = start + 1;
|
|
95
|
+
const negated = characters[cursor] === '!' || characters[cursor] === '^';
|
|
96
|
+
if (negated) {
|
|
97
|
+
cursor += 1;
|
|
98
|
+
}
|
|
99
|
+
const values: Array<ClassValue> = [];
|
|
100
|
+
for (
|
|
101
|
+
;
|
|
102
|
+
cursor < characters.length && characters[cursor] !== ']';
|
|
103
|
+
cursor += 1
|
|
104
|
+
) {
|
|
105
|
+
let escaped = false;
|
|
106
|
+
if (characters[cursor] === '\\' && characters[cursor + 1] !== undefined) {
|
|
107
|
+
cursor += 1;
|
|
108
|
+
escaped = true;
|
|
109
|
+
}
|
|
110
|
+
values.push({
|
|
111
|
+
escaped,
|
|
112
|
+
value: characters[cursor]?.codePointAt(0) ?? 0,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
if (characters[cursor] !== ']') {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const ranges: Array<CharacterRange> = [];
|
|
119
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
120
|
+
const startValue = values[index]?.value ?? 0;
|
|
121
|
+
const separator = values[index + 1];
|
|
122
|
+
const endValue = values[index + 2]?.value ?? 0;
|
|
123
|
+
if (
|
|
124
|
+
index + 2 < values.length &&
|
|
125
|
+
separator?.value === HYPHEN &&
|
|
126
|
+
!separator.escaped
|
|
127
|
+
) {
|
|
128
|
+
if (startValue <= endValue) {
|
|
129
|
+
ranges.push([startValue, endValue]);
|
|
130
|
+
} else {
|
|
131
|
+
ranges.push([startValue, startValue], [endValue, endValue]);
|
|
132
|
+
}
|
|
133
|
+
index += 2;
|
|
134
|
+
} else {
|
|
135
|
+
ranges.push([startValue, startValue]);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { end: cursor, matcher: { kind: 'class', ranges, negated } };
|
|
139
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const applySegment = (
|
|
2
|
+
segments: Array<string>,
|
|
3
|
+
segment: string,
|
|
4
|
+
absolute: boolean,
|
|
5
|
+
): void => {
|
|
6
|
+
if (segment !== '' && segment !== '.') {
|
|
7
|
+
if (segment !== '..') {
|
|
8
|
+
segments.push(segment);
|
|
9
|
+
} else if (segments.at(-1) !== undefined && segments.at(-1) !== '..') {
|
|
10
|
+
segments.pop();
|
|
11
|
+
} else if (!absolute) {
|
|
12
|
+
segments.push(segment);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const normalizeDependabotDirectory = (directory: string): string => {
|
|
18
|
+
const absolute = directory.startsWith('/');
|
|
19
|
+
const segments: Array<string> = [];
|
|
20
|
+
for (const segment of directory.split('/')) {
|
|
21
|
+
applySegment(segments, segment, absolute);
|
|
22
|
+
}
|
|
23
|
+
let normalized = segments.join('/');
|
|
24
|
+
if (normalized === '') {
|
|
25
|
+
normalized = absolute ? '/' : '.';
|
|
26
|
+
}
|
|
27
|
+
return normalized.startsWith('/') ? normalized : `/${normalized}`;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const isDependabotGlob = (directory: string): boolean =>
|
|
31
|
+
directory.includes('*') ||
|
|
32
|
+
directory.includes('?') ||
|
|
33
|
+
(directory.includes('[') && directory.includes(']'));
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CharacterMatcher,
|
|
3
|
+
matchersIntersect,
|
|
4
|
+
parseCharacterClass,
|
|
5
|
+
} from './dependabot-directory-character-class';
|
|
6
|
+
|
|
7
|
+
type Edge = {
|
|
8
|
+
readonly from: number;
|
|
9
|
+
readonly to: number;
|
|
10
|
+
readonly matcher: CharacterMatcher | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type Automaton = {
|
|
14
|
+
readonly accept: number;
|
|
15
|
+
readonly edges: ReadonlyArray<Edge>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const compileDirectory = (
|
|
19
|
+
directory: string,
|
|
20
|
+
supportsGlobs: boolean,
|
|
21
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Ruby glob tokens have distinct NFA transitions that are clearest in one ordered parser.
|
|
22
|
+
): Automaton => {
|
|
23
|
+
const characters = [...directory];
|
|
24
|
+
const edges: Array<Edge> = [];
|
|
25
|
+
let current = 0;
|
|
26
|
+
let states = 1;
|
|
27
|
+
const append = (matcher: CharacterMatcher): void => {
|
|
28
|
+
const next = states;
|
|
29
|
+
states += 1;
|
|
30
|
+
edges.push({ from: current, to: next, matcher });
|
|
31
|
+
current = next;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
for (let index = 0; index < characters.length; index += 1) {
|
|
35
|
+
const character = characters[index];
|
|
36
|
+
if (!supportsGlobs) {
|
|
37
|
+
append({ kind: 'literal', value: character?.codePointAt(0) ?? 0 });
|
|
38
|
+
} else if (character === '\\' && characters[index + 1] !== undefined) {
|
|
39
|
+
index += 1;
|
|
40
|
+
append({
|
|
41
|
+
kind: 'literal',
|
|
42
|
+
value: characters[index]?.codePointAt(0) ?? 0,
|
|
43
|
+
});
|
|
44
|
+
} else if (
|
|
45
|
+
character === '*' &&
|
|
46
|
+
characters[index + 1] === '*' &&
|
|
47
|
+
characters[index + 2] === '/' &&
|
|
48
|
+
(index === 0 || characters[index - 1] === '/')
|
|
49
|
+
) {
|
|
50
|
+
const next = states;
|
|
51
|
+
const insideDirectory = states + 1;
|
|
52
|
+
states += 2;
|
|
53
|
+
edges.push(
|
|
54
|
+
{ from: current, to: next, matcher: null },
|
|
55
|
+
{ from: current, to: insideDirectory, matcher: { kind: 'non-slash' } },
|
|
56
|
+
{
|
|
57
|
+
from: insideDirectory,
|
|
58
|
+
to: insideDirectory,
|
|
59
|
+
matcher: { kind: 'non-slash' },
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
from: insideDirectory,
|
|
63
|
+
to: current,
|
|
64
|
+
matcher: { kind: 'literal', value: '/'.codePointAt(0) ?? 0 },
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
current = next;
|
|
68
|
+
index += 2;
|
|
69
|
+
} else if (character === '*') {
|
|
70
|
+
while (characters[index + 1] === '*') {
|
|
71
|
+
index += 1;
|
|
72
|
+
}
|
|
73
|
+
const next = states;
|
|
74
|
+
states += 1;
|
|
75
|
+
edges.push(
|
|
76
|
+
{ from: current, to: next, matcher: null },
|
|
77
|
+
{ from: current, to: current, matcher: { kind: 'non-slash' } },
|
|
78
|
+
);
|
|
79
|
+
current = next;
|
|
80
|
+
} else if (character === '?') {
|
|
81
|
+
append({ kind: 'non-slash' });
|
|
82
|
+
} else if (character === '[') {
|
|
83
|
+
const parsed = parseCharacterClass(characters, index);
|
|
84
|
+
if (parsed === null) {
|
|
85
|
+
append({ kind: 'literal', value: '['.codePointAt(0) ?? 0 });
|
|
86
|
+
} else {
|
|
87
|
+
append(parsed.matcher);
|
|
88
|
+
index = parsed.end;
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
append({ kind: 'literal', value: character?.codePointAt(0) ?? 0 });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { accept: current, edges };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const directoriesOverlap = (
|
|
98
|
+
leftDirectory: string,
|
|
99
|
+
leftSupportsGlobs: boolean,
|
|
100
|
+
rightDirectory: string,
|
|
101
|
+
rightSupportsGlobs: boolean,
|
|
102
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: The product walk must process both automata's epsilon and consuming transitions together.
|
|
103
|
+
): boolean => {
|
|
104
|
+
const left = compileDirectory(leftDirectory, leftSupportsGlobs);
|
|
105
|
+
const right = compileDirectory(rightDirectory, rightSupportsGlobs);
|
|
106
|
+
const pending: Array<readonly [number, number]> = [[0, 0]];
|
|
107
|
+
const visited = new Set<string>();
|
|
108
|
+
for (const [leftState, rightState] of pending) {
|
|
109
|
+
const key = `${leftState}:${rightState}`;
|
|
110
|
+
if (!visited.has(key)) {
|
|
111
|
+
visited.add(key);
|
|
112
|
+
if (leftState === left.accept && rightState === right.accept) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
const leftEdges = left.edges.filter((edge) => edge.from === leftState);
|
|
116
|
+
const rightEdges = right.edges.filter((edge) => edge.from === rightState);
|
|
117
|
+
pending.push(
|
|
118
|
+
...leftEdges
|
|
119
|
+
.filter((edge) => edge.matcher === null)
|
|
120
|
+
.map((edge) => [edge.to, rightState] as const),
|
|
121
|
+
...rightEdges
|
|
122
|
+
.filter((edge) => edge.matcher === null)
|
|
123
|
+
.map((edge) => [leftState, edge.to] as const),
|
|
124
|
+
);
|
|
125
|
+
for (const leftEdge of leftEdges) {
|
|
126
|
+
for (const rightEdge of rightEdges) {
|
|
127
|
+
if (
|
|
128
|
+
leftEdge.matcher !== null &&
|
|
129
|
+
rightEdge.matcher !== null &&
|
|
130
|
+
matchersIntersect(leftEdge.matcher, rightEdge.matcher)
|
|
131
|
+
) {
|
|
132
|
+
pending.push([leftEdge.to, rightEdge.to]);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
};
|
package/src/dependabot-update.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isDependabotGlob,
|
|
3
|
+
normalizeDependabotDirectory,
|
|
4
|
+
} from './dependabot-directory-normalize';
|
|
5
|
+
import { directoriesOverlap } from './dependabot-directory-overlap';
|
|
1
6
|
import { isNonEmptyString, isRecord } from './github-settings-parse';
|
|
2
7
|
|
|
3
8
|
export type UpdateBlock = Record<string, unknown>;
|
|
@@ -6,6 +11,8 @@ export type UpdateTarget = {
|
|
|
6
11
|
readonly ecosystem: string;
|
|
7
12
|
readonly targetBranch: string | null;
|
|
8
13
|
readonly directories: ReadonlyArray<string>;
|
|
14
|
+
readonly normalizedDirectories: ReadonlyArray<string>;
|
|
15
|
+
readonly globDirectories: ReadonlySet<string>;
|
|
9
16
|
};
|
|
10
17
|
|
|
11
18
|
const listOfNonEmptyStrings = (
|
|
@@ -72,6 +79,7 @@ export const updateTarget = (
|
|
|
72
79
|
block: UpdateBlock,
|
|
73
80
|
label: string,
|
|
74
81
|
problems: Array<string>,
|
|
82
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Target validation deliberately gathers every independent configuration problem.
|
|
75
83
|
): UpdateTarget | null => {
|
|
76
84
|
const ecosystem = block['package-ecosystem'];
|
|
77
85
|
if (!isNonEmptyString(ecosystem)) {
|
|
@@ -103,6 +111,9 @@ export const updateTarget = (
|
|
|
103
111
|
? [directory as string]
|
|
104
112
|
: (directories as ReadonlyArray<string>);
|
|
105
113
|
const normalized = [...new Set(rawDirectories)].sort();
|
|
114
|
+
const normalizedDirectories = [
|
|
115
|
+
...new Set(normalized.map(normalizeDependabotDirectory)),
|
|
116
|
+
].sort();
|
|
106
117
|
if (
|
|
107
118
|
!hasDirectory &&
|
|
108
119
|
Array.isArray(directories) &&
|
|
@@ -120,6 +131,10 @@ export const updateTarget = (
|
|
|
120
131
|
ecosystem,
|
|
121
132
|
targetBranch: targetBranch ?? null,
|
|
122
133
|
directories: normalized,
|
|
134
|
+
normalizedDirectories,
|
|
135
|
+
globDirectories: new Set(
|
|
136
|
+
hasDirectories ? normalizedDirectories.filter(isDependabotGlob) : [],
|
|
137
|
+
),
|
|
123
138
|
};
|
|
124
139
|
};
|
|
125
140
|
|
|
@@ -135,9 +150,13 @@ export const sameUpdateTarget = (
|
|
|
135
150
|
right: UpdateTarget,
|
|
136
151
|
): boolean =>
|
|
137
152
|
sameUpdateScope(left, right) &&
|
|
138
|
-
left.
|
|
139
|
-
left.
|
|
140
|
-
(directory, index) => directory === right.
|
|
153
|
+
left.normalizedDirectories.length === right.normalizedDirectories.length &&
|
|
154
|
+
left.normalizedDirectories.every(
|
|
155
|
+
(directory, index) => directory === right.normalizedDirectories[index],
|
|
156
|
+
) &&
|
|
157
|
+
left.globDirectories.size === right.globDirectories.size &&
|
|
158
|
+
[...left.globDirectories].every((directory) =>
|
|
159
|
+
right.globDirectories.has(directory),
|
|
141
160
|
);
|
|
142
161
|
|
|
143
162
|
export const overlapsUpdateTarget = (
|
|
@@ -145,7 +164,16 @@ export const overlapsUpdateTarget = (
|
|
|
145
164
|
right: UpdateTarget,
|
|
146
165
|
): boolean =>
|
|
147
166
|
sameUpdateScope(left, right) &&
|
|
148
|
-
left.
|
|
167
|
+
left.normalizedDirectories.some((leftDirectory) =>
|
|
168
|
+
right.normalizedDirectories.some((rightDirectory) =>
|
|
169
|
+
directoriesOverlap(
|
|
170
|
+
leftDirectory,
|
|
171
|
+
left.globDirectories.has(leftDirectory),
|
|
172
|
+
rightDirectory,
|
|
173
|
+
right.globDirectories.has(rightDirectory),
|
|
174
|
+
),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
149
177
|
|
|
150
178
|
export const updateTargetDescription = (target: UpdateTarget): string => {
|
|
151
179
|
const branch =
|
package/src/github-api.ts
CHANGED
|
@@ -112,83 +112,3 @@ export const loadDeclared = async (
|
|
|
112
112
|
existsSync(localPath) ? await readFile(localPath, 'utf8') : null,
|
|
113
113
|
);
|
|
114
114
|
};
|
|
115
|
-
|
|
116
|
-
const GRAPHQL_MERGE_FIELDS: ReadonlyMap<string, string> = new Map([
|
|
117
|
-
['allow_auto_merge', 'autoMergeAllowed'],
|
|
118
|
-
['allow_merge_commit', 'mergeCommitAllowed'],
|
|
119
|
-
['allow_rebase_merge', 'rebaseMergeAllowed'],
|
|
120
|
-
['allow_squash_merge', 'squashMergeAllowed'],
|
|
121
|
-
['delete_branch_on_merge', 'deleteBranchOnMerge'],
|
|
122
|
-
['merge_commit_message', 'mergeCommitMessage'],
|
|
123
|
-
['merge_commit_title', 'mergeCommitTitle'],
|
|
124
|
-
['squash_merge_commit_message', 'squashMergeCommitMessage'],
|
|
125
|
-
['squash_merge_commit_title', 'squashMergeCommitTitle'],
|
|
126
|
-
]);
|
|
127
|
-
|
|
128
|
-
// REST omits repository merge settings for read-only viewers (they surface
|
|
129
|
-
// only with write access — community discussion #153258), but GraphQL serves
|
|
130
|
-
// the same values, with identical enum spellings, to any token that can read
|
|
131
|
-
// the repository. This keeps a read-only PAT sufficient for the check.
|
|
132
|
-
// Returns only the keys GraphQL answered; the rest stay unverifiable.
|
|
133
|
-
export const fetchMergeSettingsViaGraphql = async (
|
|
134
|
-
token: string | null,
|
|
135
|
-
repo: string,
|
|
136
|
-
keys: ReadonlyArray<string>,
|
|
137
|
-
): Promise<Readonly<Record<string, unknown>>> => {
|
|
138
|
-
const mapped = keys.filter((key) => GRAPHQL_MERGE_FIELDS.has(key));
|
|
139
|
-
const [owner, name] = repo.split('/');
|
|
140
|
-
if (mapped.length === 0 || owner === undefined || name === undefined) {
|
|
141
|
-
return {};
|
|
142
|
-
}
|
|
143
|
-
const fields = mapped.map((key) => GRAPHQL_MERGE_FIELDS.get(key)).join(' ');
|
|
144
|
-
const response = await request(token, 'POST', '/graphql', {
|
|
145
|
-
query: `query { repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { ${fields} } }`,
|
|
146
|
-
});
|
|
147
|
-
if (response.status !== HTTP_OK || !isRecord(response.body)) {
|
|
148
|
-
return {};
|
|
149
|
-
}
|
|
150
|
-
const data = isRecord(response.body.data) ? response.body.data : null;
|
|
151
|
-
const repository =
|
|
152
|
-
data !== null && isRecord(data.repository) ? data.repository : null;
|
|
153
|
-
if (repository === null) {
|
|
154
|
-
return {};
|
|
155
|
-
}
|
|
156
|
-
return Object.fromEntries(
|
|
157
|
-
mapped
|
|
158
|
-
.map((key) => [key, repository[GRAPHQL_MERGE_FIELDS.get(key) ?? '']])
|
|
159
|
-
.filter(([, value]) => value !== undefined && value !== null),
|
|
160
|
-
);
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
export type LiveRulesets = {
|
|
164
|
-
readonly rulesets: ReadonlyArray<Record<string, unknown>> | null;
|
|
165
|
-
readonly problem: string | null;
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
// Only repository-sourced rulesets are managed; org-level rulesets a consumer
|
|
169
|
-
// inherits are outside this declaration's authority.
|
|
170
|
-
export const fetchLiveRulesets = async (
|
|
171
|
-
token: string | null,
|
|
172
|
-
repo: string,
|
|
173
|
-
): Promise<LiveRulesets> => {
|
|
174
|
-
const list = await request(token, 'GET', `/repos/${repo}/rulesets`);
|
|
175
|
-
if (list.status !== HTTP_OK || !Array.isArray(list.body)) {
|
|
176
|
-
return { rulesets: null, problem: apiError('listing rulesets', list) };
|
|
177
|
-
}
|
|
178
|
-
const repoOwned = list.body
|
|
179
|
-
.filter(isRecord)
|
|
180
|
-
.filter((ruleset) => ruleset.source_type === 'Repository');
|
|
181
|
-
const detailed = await Promise.all(
|
|
182
|
-
repoOwned.map((ruleset) =>
|
|
183
|
-
request(token, 'GET', `/repos/${repo}/rulesets/${ruleset.id}`),
|
|
184
|
-
),
|
|
185
|
-
);
|
|
186
|
-
const failed = detailed.find((response) => response.status !== HTTP_OK);
|
|
187
|
-
if (failed !== undefined) {
|
|
188
|
-
return { rulesets: null, problem: apiError('reading a ruleset', failed) };
|
|
189
|
-
}
|
|
190
|
-
return {
|
|
191
|
-
rulesets: detailed.map((response) => response.body).filter(isRecord),
|
|
192
|
-
problem: null,
|
|
193
|
-
};
|
|
194
|
-
};
|
package/src/github-apply.ts
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
apiError,
|
|
7
|
-
fetchLiveRulesets,
|
|
8
7
|
HTTP_CREATED,
|
|
9
8
|
HTTP_NO_CONTENT,
|
|
10
9
|
HTTP_OK,
|
|
11
10
|
request,
|
|
12
11
|
} from './github-api';
|
|
13
|
-
import { diffRepositorySettings
|
|
12
|
+
import { diffRepositorySettings } from './github-diff';
|
|
13
|
+
import { fetchLiveRulesets } from './github-ruleset-api';
|
|
14
|
+
import { diffRuleset } from './github-ruleset-diff';
|
|
14
15
|
import { type GithubSettings, isRecord } from './github-settings-parse';
|
|
15
16
|
|
|
16
17
|
// GitHub answers a PATCH containing a plan-unavailable setting with HTTP 200
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Reconciles ruleset bypass-actor lists REST hides, using the counts GraphQL
|
|
2
|
+
// answers for tokens that cannot read the actors themselves. Pure logic; the
|
|
3
|
+
// GraphQL call lives in github-graphql.ts.
|
|
4
|
+
|
|
5
|
+
import { BYPASS_ACTORS_KEY } from './github-ruleset-diff';
|
|
6
|
+
|
|
7
|
+
// Stand-ins for bypass actors GraphQL counted but would not name. They exist
|
|
8
|
+
// only to carry the count into the ordinary length comparison, and are attached
|
|
9
|
+
// exactly when the count and the declared length disagree or both are zero — so
|
|
10
|
+
// their content is never observed: subsetMatches short-circuits at the length
|
|
11
|
+
// check, two empty lists compare no element, and a declared ruleset with no
|
|
12
|
+
// bypass_actors array never reaches that comparison. Nothing else looks inside
|
|
13
|
+
// them; bypassActorCountDetail reads only `.length`, and github-live-drift.ts
|
|
14
|
+
// is the sole importer, so a stand-in cannot reach a PATCH body. The guarantee
|
|
15
|
+
// is not local: it also relies on declared ruleset names being unique, which
|
|
16
|
+
// github-settings-parse.ts and github-settings-merge.ts enforce.
|
|
17
|
+
const unreadableBypassActors = (count: number): ReadonlyArray<unknown> =>
|
|
18
|
+
Array.from({ length: count }, (_unused, index) => ({
|
|
19
|
+
unreadableBypassActor: index,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
// A count of zero pins the live list to the empty list exactly. A count that
|
|
23
|
+
// disagrees with the declared length is drift no identity could explain, so
|
|
24
|
+
// that many unreadable actors stand in and the ordinary comparison reports it.
|
|
25
|
+
// A matching non-zero count proves nothing about *which* actors bypass, so the
|
|
26
|
+
// field stays hidden and the gate fails closed on it. A list REST already
|
|
27
|
+
// answered is authoritative and never replaced.
|
|
28
|
+
//
|
|
29
|
+
// The counts are joined by ruleset id, the one identifier both APIs agree on:
|
|
30
|
+
// names are not unique on the live side, so joining on them could pin one
|
|
31
|
+
// ruleset's list from another ruleset's count. A live ruleset carrying no
|
|
32
|
+
// numeric id gets no count and stays hidden.
|
|
33
|
+
export const resolveHiddenBypassActors = (
|
|
34
|
+
declared: ReadonlyArray<Readonly<Record<string, unknown>>>,
|
|
35
|
+
live: ReadonlyArray<Readonly<Record<string, unknown>>>,
|
|
36
|
+
counts: ReadonlyMap<number, number>,
|
|
37
|
+
): ReadonlyArray<Readonly<Record<string, unknown>>> => {
|
|
38
|
+
const declaredCounts = new Map(
|
|
39
|
+
declared
|
|
40
|
+
.filter((ruleset) => Array.isArray(ruleset[BYPASS_ACTORS_KEY]))
|
|
41
|
+
.map((ruleset) => [
|
|
42
|
+
String(ruleset.name),
|
|
43
|
+
(ruleset[BYPASS_ACTORS_KEY] as ReadonlyArray<unknown>).length,
|
|
44
|
+
]),
|
|
45
|
+
);
|
|
46
|
+
return live.map((ruleset) => {
|
|
47
|
+
const count =
|
|
48
|
+
typeof ruleset.id === 'number' ? counts.get(ruleset.id) : undefined;
|
|
49
|
+
if (ruleset[BYPASS_ACTORS_KEY] !== undefined || count === undefined) {
|
|
50
|
+
return ruleset;
|
|
51
|
+
}
|
|
52
|
+
return count > 0 && count === declaredCounts.get(String(ruleset.name))
|
|
53
|
+
? ruleset
|
|
54
|
+
: { ...ruleset, [BYPASS_ACTORS_KEY]: unreadableBypassActors(count) };
|
|
55
|
+
});
|
|
56
|
+
};
|
|
@@ -32,14 +32,21 @@ export const optOutEligibilityProblem = (
|
|
|
32
32
|
? `.github/settings.local.json "rulesetEnforcement" may only be declared for a private repository; ${repo} is public`
|
|
33
33
|
: null;
|
|
34
34
|
|
|
35
|
+
export const ADMIN_VISIBILITY_ADVICE =
|
|
36
|
+
'Use a token with read access to repository administration (in CI: ci.github_settings_read_token in secrets/ci.yaml), or verify locally with admin auth';
|
|
37
|
+
|
|
35
38
|
// Declared state the token cannot see is a gate failure, not a pass with a
|
|
36
|
-
// log line: a gate that cannot perform its comparison fails closed.
|
|
39
|
+
// log line: a gate that cannot perform its comparison fails closed. The advice
|
|
40
|
+
// is the caller's, because not every invisible field has the same remedy —
|
|
41
|
+
// ruleset bypass actors in particular are unreachable over REST for an
|
|
42
|
+
// installation token at any permission level.
|
|
37
43
|
export const unverifiableProblem = (
|
|
38
44
|
scope: string,
|
|
39
45
|
items: ReadonlyArray<string>,
|
|
46
|
+
advice: string,
|
|
40
47
|
): ReadonlyArray<string> =>
|
|
41
48
|
items.length === 0
|
|
42
49
|
? []
|
|
43
50
|
: [
|
|
44
|
-
`${scope} not visible to this token, so the gate cannot verify: ${items.join('; ')}.
|
|
51
|
+
`${scope} not visible to this token, so the gate cannot verify: ${items.join('; ')}. ${advice}`,
|
|
45
52
|
];
|