@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidvornholt/standards",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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-ruleset-diff';
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 { isRecord } from './github-settings-parse';
7
-
8
- export const BYPASS_ACTORS_KEY = 'bypass_actors';
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 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
- }
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
- BYPASS_ACTORS_KEY,
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
  };