@davidvornholt/standards 0.27.0 → 0.28.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
CHANGED
|
@@ -182,6 +182,10 @@ bun standards github --apply
|
|
|
182
182
|
|
|
183
183
|
The command merges `.github/settings.json` with `.github/settings.local.json`, then compares or converges repository settings, declared rulesets, and canonical labels. Undeclared live labels are left alone. Undeclared repository-owned rulesets are removed by `--apply`.
|
|
184
184
|
|
|
185
|
+
Starting with 0.28.0, every declared ruleset must explicitly state `target`, `enforcement`, and `bypass_actors`; branch and tag rulesets must also state `conditions.ref_name.include` and `conditions.ref_name.exclude`. Use `"bypass_actors": []` when nobody may bypass. Incomplete declarations fail before GitHub is contacted, with an error naming each missing field. Add the intended values to existing local rulesets before upgrading; no defaults or warning period apply.
|
|
186
|
+
|
|
187
|
+
Push rulesets apply to every push and must omit `conditions`, as described in [GitHub's push-ruleset documentation](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets#push-rulesets).
|
|
188
|
+
|
|
185
189
|
`--apply` needs admin authentication and rechecks the API response instead of treating an accepted request as success. A repository whose plan cannot enforce rulesets may declare `"rulesetEnforcement": "unavailable-on-plan"` in the local settings file.
|
|
186
190
|
|
|
187
191
|
## creds
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// answers for tokens that cannot read the actors themselves. Pure logic; the
|
|
3
3
|
// GraphQL call lives in github-graphql.ts.
|
|
4
4
|
|
|
5
|
-
import { BYPASS_ACTORS_KEY } from './github-
|
|
5
|
+
import { BYPASS_ACTORS_KEY } from './github-settings-parse';
|
|
6
6
|
|
|
7
7
|
// Stand-ins for bypass actors GraphQL counted but would not name. They exist
|
|
8
8
|
// only to carry the count into the ordinary length comparison, and are attached
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// general value comparison lives in github-diff.ts.
|
|
4
4
|
|
|
5
5
|
import { subsetMatches } from './github-diff';
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
import {
|
|
7
|
+
BYPASS_ACTORS_KEY,
|
|
8
|
+
isRecord,
|
|
9
|
+
rulesetComparedKeys,
|
|
10
|
+
} from './github-settings-parse';
|
|
9
11
|
|
|
10
12
|
// A declared ruleset field the live side did not carry. It stays structured so
|
|
11
13
|
// the caller owns both the wording and the test for *which* field it is;
|
|
@@ -21,13 +23,6 @@ export type RulesetDiff = {
|
|
|
21
23
|
readonly unverifiable: ReadonlyArray<UnverifiableRulesetField>;
|
|
22
24
|
};
|
|
23
25
|
|
|
24
|
-
const RULESET_COMPARED_KEYS = [
|
|
25
|
-
'target',
|
|
26
|
-
'enforcement',
|
|
27
|
-
'conditions',
|
|
28
|
-
BYPASS_ACTORS_KEY,
|
|
29
|
-
] as const;
|
|
30
|
-
|
|
31
26
|
const diffRules = (
|
|
32
27
|
name: string,
|
|
33
28
|
declared: Readonly<Record<string, unknown>>,
|
|
@@ -99,15 +94,13 @@ export const diffRuleset = (
|
|
|
99
94
|
const name = String(declared.name);
|
|
100
95
|
const drifted: Array<string> = [];
|
|
101
96
|
const unverifiable: Array<UnverifiableRulesetField> = [];
|
|
102
|
-
for (const key of
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
);
|
|
110
|
-
}
|
|
97
|
+
for (const key of rulesetComparedKeys(declared.target)) {
|
|
98
|
+
if (live[key] === undefined) {
|
|
99
|
+
unverifiable.push({ key, name });
|
|
100
|
+
} else if (!subsetMatches(declared[key], live[key])) {
|
|
101
|
+
drifted.push(
|
|
102
|
+
`ruleset "${name}": ${key} differs from the declared configuration${bypassActorCountDetail(key, declared[key], live[key])}`,
|
|
103
|
+
);
|
|
111
104
|
}
|
|
112
105
|
}
|
|
113
106
|
drifted.push(...diffRules(name, declared, live));
|
|
@@ -8,10 +8,8 @@ import {
|
|
|
8
8
|
ADMIN_VISIBILITY_ADVICE,
|
|
9
9
|
unverifiableProblem,
|
|
10
10
|
} from './github-command-shared';
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
type UnverifiableRulesetField,
|
|
14
|
-
} from './github-ruleset-diff';
|
|
11
|
+
import type { UnverifiableRulesetField } from './github-ruleset-diff';
|
|
12
|
+
import { BYPASS_ACTORS_KEY } from './github-settings-parse';
|
|
15
13
|
|
|
16
14
|
const SCOPE = 'ruleset field(s)';
|
|
17
15
|
|
|
@@ -45,6 +45,89 @@ export const isNamedRuleset = (
|
|
|
45
45
|
): value is Readonly<Record<string, unknown>> & { readonly name: string } =>
|
|
46
46
|
isRecord(value) && typeof value.name === 'string' && value.name.length > 0;
|
|
47
47
|
|
|
48
|
+
export const BYPASS_ACTORS_KEY = 'bypass_actors';
|
|
49
|
+
const RULESET_COMPARED_KEYS = [
|
|
50
|
+
'target',
|
|
51
|
+
'enforcement',
|
|
52
|
+
'conditions',
|
|
53
|
+
BYPASS_ACTORS_KEY,
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
// Push rules cover every push to the repository and have no ref-name scope.
|
|
57
|
+
// The parser and comparison share the same field contract.
|
|
58
|
+
export const rulesetComparedKeys = (target: unknown) =>
|
|
59
|
+
target === 'push'
|
|
60
|
+
? RULESET_COMPARED_KEYS.filter((key) => key !== 'conditions')
|
|
61
|
+
: RULESET_COMPARED_KEYS;
|
|
62
|
+
|
|
63
|
+
const isStringList = (value: unknown): value is ReadonlyArray<string> =>
|
|
64
|
+
Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
65
|
+
|
|
66
|
+
const isOneOf = (value: unknown, choices: ReadonlyArray<string>): boolean =>
|
|
67
|
+
typeof value === 'string' && choices.includes(value);
|
|
68
|
+
|
|
69
|
+
const rulesetConditionsProblem = (
|
|
70
|
+
ruleset: Readonly<Record<string, unknown>>,
|
|
71
|
+
prefix: string,
|
|
72
|
+
): string | null => {
|
|
73
|
+
if (ruleset.conditions === undefined) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (ruleset.target === 'push') {
|
|
77
|
+
return `${prefix} must omit "conditions" for target "push", which applies to every push`;
|
|
78
|
+
}
|
|
79
|
+
const refName = isRecord(ruleset.conditions)
|
|
80
|
+
? ruleset.conditions.ref_name
|
|
81
|
+
: undefined;
|
|
82
|
+
return isRecord(refName) &&
|
|
83
|
+
isStringList(refName.include) &&
|
|
84
|
+
isStringList(refName.exclude)
|
|
85
|
+
? null
|
|
86
|
+
: `${prefix} "conditions" must declare "ref_name.include" and "ref_name.exclude" as arrays of strings`;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const rulesetFieldProblems = (
|
|
90
|
+
ruleset: Readonly<Record<string, unknown>>,
|
|
91
|
+
label: string,
|
|
92
|
+
): ReadonlyArray<string> => {
|
|
93
|
+
const problems: Array<string> = [];
|
|
94
|
+
const prefix = `${label} ruleset "${String(ruleset.name)}"`;
|
|
95
|
+
for (const key of rulesetComparedKeys(ruleset.target)) {
|
|
96
|
+
if (ruleset[key] === undefined) {
|
|
97
|
+
const advice =
|
|
98
|
+
key === BYPASS_ACTORS_KEY ? '; use [] if nobody may bypass' : '';
|
|
99
|
+
problems.push(`${prefix} must declare "${key}"${advice}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (
|
|
103
|
+
ruleset.target !== undefined &&
|
|
104
|
+
!isOneOf(ruleset.target, ['branch', 'tag', 'push'])
|
|
105
|
+
) {
|
|
106
|
+
problems.push(`${prefix} "target" must be "branch", "tag", or "push"`);
|
|
107
|
+
}
|
|
108
|
+
if (
|
|
109
|
+
ruleset.enforcement !== undefined &&
|
|
110
|
+
!isOneOf(ruleset.enforcement, ['active', 'disabled', 'evaluate'])
|
|
111
|
+
) {
|
|
112
|
+
problems.push(
|
|
113
|
+
`${prefix} "enforcement" must be "active", "disabled", or "evaluate"`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (
|
|
117
|
+
ruleset[BYPASS_ACTORS_KEY] !== undefined &&
|
|
118
|
+
!Array.isArray(ruleset[BYPASS_ACTORS_KEY])
|
|
119
|
+
) {
|
|
120
|
+
problems.push(
|
|
121
|
+
`${prefix} "${BYPASS_ACTORS_KEY}" must be an array; use [] if nobody may bypass`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const conditionsProblem = rulesetConditionsProblem(ruleset, prefix);
|
|
125
|
+
if (conditionsProblem !== null) {
|
|
126
|
+
problems.push(conditionsProblem);
|
|
127
|
+
}
|
|
128
|
+
return problems;
|
|
129
|
+
};
|
|
130
|
+
|
|
48
131
|
const LABEL_COLOR = /^[0-9a-f]{6}$/u;
|
|
49
132
|
const LABEL_DECLARATION_KEY_COUNT = 3;
|
|
50
133
|
|
|
@@ -98,6 +181,9 @@ const rulesetListProblems = (
|
|
|
98
181
|
} else {
|
|
99
182
|
names.add(ruleset.name);
|
|
100
183
|
}
|
|
184
|
+
if (isNamedRuleset(ruleset)) {
|
|
185
|
+
problems.push(...rulesetFieldProblems(ruleset, label));
|
|
186
|
+
}
|
|
101
187
|
}
|
|
102
188
|
return problems;
|
|
103
189
|
};
|