@davidvornholt/standards 0.9.0 → 0.10.1

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/src/github-api.ts CHANGED
@@ -111,6 +111,53 @@ export const loadDeclared = async (
111
111
  );
112
112
  };
113
113
 
114
+ const GRAPHQL_MERGE_FIELDS: ReadonlyMap<string, string> = new Map([
115
+ ['allow_auto_merge', 'autoMergeAllowed'],
116
+ ['allow_merge_commit', 'mergeCommitAllowed'],
117
+ ['allow_rebase_merge', 'rebaseMergeAllowed'],
118
+ ['allow_squash_merge', 'squashMergeAllowed'],
119
+ ['delete_branch_on_merge', 'deleteBranchOnMerge'],
120
+ ['merge_commit_message', 'mergeCommitMessage'],
121
+ ['merge_commit_title', 'mergeCommitTitle'],
122
+ ['squash_merge_commit_message', 'squashMergeCommitMessage'],
123
+ ['squash_merge_commit_title', 'squashMergeCommitTitle'],
124
+ ]);
125
+
126
+ // REST omits repository merge settings for read-only viewers (they surface
127
+ // only with write access — community discussion #153258), but GraphQL serves
128
+ // the same values, with identical enum spellings, to any token that can read
129
+ // the repository. This keeps a read-only PAT sufficient for the check.
130
+ // Returns only the keys GraphQL answered; the rest stay unverifiable.
131
+ export const fetchMergeSettingsViaGraphql = async (
132
+ token: string | null,
133
+ repo: string,
134
+ keys: ReadonlyArray<string>,
135
+ ): Promise<Readonly<Record<string, unknown>>> => {
136
+ const mapped = keys.filter((key) => GRAPHQL_MERGE_FIELDS.has(key));
137
+ const [owner, name] = repo.split('/');
138
+ if (mapped.length === 0 || owner === undefined || name === undefined) {
139
+ return {};
140
+ }
141
+ const fields = mapped.map((key) => GRAPHQL_MERGE_FIELDS.get(key)).join(' ');
142
+ const response = await request(token, 'POST', '/graphql', {
143
+ query: `query { repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { ${fields} } }`,
144
+ });
145
+ if (response.status !== HTTP_OK || !isRecord(response.body)) {
146
+ return {};
147
+ }
148
+ const data = isRecord(response.body.data) ? response.body.data : null;
149
+ const repository =
150
+ data !== null && isRecord(data.repository) ? data.repository : null;
151
+ if (repository === null) {
152
+ return {};
153
+ }
154
+ return Object.fromEntries(
155
+ mapped
156
+ .map((key) => [key, repository[GRAPHQL_MERGE_FIELDS.get(key) ?? '']])
157
+ .filter(([, value]) => value !== undefined && value !== null),
158
+ );
159
+ };
160
+
114
161
  export type LiveRulesets = {
115
162
  readonly rulesets: ReadonlyArray<Record<string, unknown>> | null;
116
163
  readonly problem: string | null;
@@ -11,8 +11,11 @@ import {
11
11
  request,
12
12
  } from './github-api';
13
13
  import { diffRepositorySettings, diffRuleset } from './github-diff';
14
- import type { GithubSettings } from './github-settings-parse';
14
+ import { type GithubSettings, isRecord } from './github-settings-parse';
15
15
 
16
+ // GitHub answers a PATCH containing a plan-unavailable setting with HTTP 200
17
+ // and the old value, so the response body — the updated repository — is
18
+ // diffed again instead of trusting the status code.
16
19
  export const applyRepositorySettings = async (
17
20
  token: string,
18
21
  repo: string,
@@ -24,9 +27,22 @@ export const applyRepositorySettings = async (
24
27
  return [];
25
28
  }
26
29
  const patched = await request(token, 'PATCH', `/repos/${repo}`, repository);
27
- if (patched.status !== HTTP_OK) {
30
+ if (patched.status !== HTTP_OK || !isRecord(patched.body)) {
28
31
  throw new Error(apiError('updating repository settings', patched));
29
32
  }
33
+ const persisted = diffRepositorySettings(repository, patched.body);
34
+ const ignored = [
35
+ ...persisted.drifted,
36
+ ...persisted.unverifiable.map(
37
+ (key) =>
38
+ `repository setting "${key}" is missing from the update response`,
39
+ ),
40
+ ];
41
+ if (ignored.length > 0) {
42
+ throw new Error(
43
+ `GitHub returned HTTP 200 but ignored part of the update: ${ignored.join('; ')}. A silently ignored setting is usually unavailable on this GitHub plan; if this plan cannot protect branches, declare the ruleset-enforcement opt-out instead`,
44
+ );
45
+ }
30
46
  return ['updated repository merge settings'];
31
47
  };
32
48
 
@@ -37,8 +53,8 @@ export const applySummary = (
37
53
  ): string => {
38
54
  if (rulesetsSkipped) {
39
55
  return actions.length === 0
40
- ? `standards github: repository settings already converged for ${repo}; rulesets skipped`
41
- : `standards github: repository settings apply complete for ${repo}; rulesets skipped`;
56
+ ? `standards github: enforceable settings already converged for ${repo}; plan-gated settings skipped`
57
+ : `standards github: enforceable settings apply complete for ${repo}; plan-gated settings skipped`;
42
58
  }
43
59
  return actions.length === 0
44
60
  ? 'standards github: already converged; no changes'
@@ -1,11 +1,45 @@
1
- import type { GithubSettings } from './github-settings-parse';
1
+ import {
2
+ ENFORCEMENT_OPT_OUT,
3
+ type GithubSettings,
4
+ } from './github-settings-parse';
5
+
6
+ // Repository settings that only function alongside branch protection, which
7
+ // the ruleset-enforcement opt-out declares unavailable on the plan. GitHub
8
+ // answers a PATCH for them with HTTP 200 and silently keeps the old value, so
9
+ // they must be skipped, never applied-and-trusted.
10
+ export const PLAN_GATED_REPOSITORY_KEYS: ReadonlySet<string> = new Set([
11
+ 'allow_auto_merge',
12
+ ]);
13
+
14
+ export const enforceableRepositorySettings = (
15
+ declared: GithubSettings,
16
+ ): Readonly<Record<string, unknown>> =>
17
+ declared.rulesetEnforcement === ENFORCEMENT_OPT_OUT
18
+ ? Object.fromEntries(
19
+ Object.entries(declared.repository).filter(
20
+ ([key]) => !PLAN_GATED_REPOSITORY_KEYS.has(key),
21
+ ),
22
+ )
23
+ : declared.repository;
2
24
 
3
25
  export const optOutEligibilityProblem = (
4
26
  repo: string,
5
27
  declared: GithubSettings,
6
28
  liveRepository: Readonly<Record<string, unknown>>,
7
29
  ): string | null =>
8
- declared.rulesetEnforcement === 'unavailable-on-plan' &&
30
+ declared.rulesetEnforcement === ENFORCEMENT_OPT_OUT &&
9
31
  liveRepository.private !== true
10
32
  ? `.github/settings.local.json "rulesetEnforcement" may only be declared for a private repository; ${repo} is public`
11
33
  : null;
34
+
35
+ // 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.
37
+ export const unverifiableProblem = (
38
+ scope: string,
39
+ items: ReadonlyArray<string>,
40
+ ): ReadonlyArray<string> =>
41
+ items.length === 0
42
+ ? []
43
+ : [
44
+ `${scope} not visible to this token, so the gate cannot verify: ${items.join('; ')}. 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`,
45
+ ];
@@ -1,11 +1,10 @@
1
1
  // `standards github --check` verifies the live GitHub repository against the
2
- // declared settings and fails closed on any drift or API error. `standards
3
- // github --apply` converges the live repository; it needs an admin token, so
4
- // it runs locally rather than in CI.
2
+ // declared settings and fails closed on any drift, API error, or declared
3
+ // state the token cannot see. `standards github --apply` converges the live
4
+ // repository; it needs an admin token, so it runs locally rather than in CI.
5
5
 
6
6
  import {
7
7
  apiError,
8
- fetchLiveRulesets,
9
8
  HTTP_OK,
10
9
  loadDeclared,
11
10
  request,
@@ -17,14 +16,17 @@ import {
17
16
  applyRulesets,
18
17
  applySummary,
19
18
  } from './github-apply';
20
- import { optOutEligibilityProblem } from './github-command-shared';
21
- import { diffRepositorySettings, diffRulesets } from './github-diff';
22
- import { type GithubSettings, isRecord } from './github-settings-parse';
19
+ import {
20
+ enforceableRepositorySettings,
21
+ optOutEligibilityProblem,
22
+ } from './github-command-shared';
23
+ import { collectLiveDrift } from './github-live-drift';
24
+ import { isRecord } from './github-settings-parse';
23
25
 
24
26
  // Printed on every check and apply while the opt-out is declared: the skip
25
27
  // must stay louder than the comfort of a green gate.
26
28
  const UNENFORCEABLE_NOTICE =
27
- 'standards github: rulesets are declared unenforceable on this GitHub plan (.github/settings.local.json "rulesetEnforcement"); the default branch is NOT protected. After upgrading the plan, remove the declaration, then run `bun standards github --apply`.';
29
+ 'standards github: rulesets are declared unenforceable on this GitHub plan (.github/settings.local.json "rulesetEnforcement"); the default branch is NOT protected, and plan-gated repository settings ("allow_auto_merge") are skipped. After upgrading the plan, remove the declaration, then run `bun standards github --apply`.';
28
30
 
29
31
  const reportProblems = (problems: ReadonlyArray<string>): void => {
30
32
  console.error(
@@ -33,74 +35,6 @@ const reportProblems = (problems: ReadonlyArray<string>): void => {
33
35
  console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
34
36
  };
35
37
 
36
- const repositoryDrift = async (
37
- token: string | null,
38
- repo: string,
39
- declared: GithubSettings,
40
- ): Promise<ReadonlyArray<string>> => {
41
- const repoResponse = await request(token, 'GET', `/repos/${repo}`);
42
- if (repoResponse.status !== HTTP_OK || !isRecord(repoResponse.body)) {
43
- return [apiError(`reading repository ${repo}`, repoResponse)];
44
- }
45
- const eligibilityProblem = optOutEligibilityProblem(
46
- repo,
47
- declared,
48
- repoResponse.body,
49
- );
50
- if (eligibilityProblem !== null) {
51
- return [eligibilityProblem];
52
- }
53
- const diff = diffRepositorySettings(declared.repository, repoResponse.body);
54
- if (diff.unverifiable.length > 0) {
55
- console.log(
56
- `standards github: repository setting(s) not visible to this token, verify with admin auth: ${diff.unverifiable.join(', ')}`,
57
- );
58
- }
59
- return diff.drifted;
60
- };
61
-
62
- const rulesetDrift = async (
63
- token: string | null,
64
- repo: string,
65
- declared: GithubSettings,
66
- ): Promise<ReadonlyArray<string>> => {
67
- if (declared.rulesetEnforcement === 'unavailable-on-plan') {
68
- return [];
69
- }
70
- const live = await fetchLiveRulesets(token, repo);
71
- if (live.rulesets === null) {
72
- return [live.problem ?? 'unable to read rulesets'];
73
- }
74
- const diff = diffRulesets(declared.rulesets, live.rulesets);
75
- if (diff.unverifiable.length > 0) {
76
- console.log(
77
- `standards github: ruleset field(s) not visible to this token, verify with admin auth: ${diff.unverifiable.join('; ')}`,
78
- );
79
- }
80
- return diff.drifted;
81
- };
82
-
83
- const collectLiveDrift = async (
84
- consumer: string,
85
- declared: GithubSettings,
86
- ): Promise<ReadonlyArray<string>> => {
87
- const repo = resolveGithubRepo(consumer);
88
- if (repo === null) {
89
- return ['cannot determine the GitHub repository from the origin remote'];
90
- }
91
- const token = resolveToken();
92
- try {
93
- return [
94
- ...(await repositoryDrift(token, repo, declared)),
95
- ...(await rulesetDrift(token, repo, declared)),
96
- ];
97
- } catch (error) {
98
- return [
99
- `GitHub API unreachable: ${error instanceof Error ? error.message : String(error)}`,
100
- ];
101
- }
102
- };
103
-
104
38
  export const runGithubCheck = async (consumer: string): Promise<boolean> => {
105
39
  const declared = await loadDeclared(consumer);
106
40
  if (declared.merged?.rulesetEnforcement === 'unavailable-on-plan') {
@@ -119,7 +53,7 @@ export const runGithubCheck = async (consumer: string): Promise<boolean> => {
119
53
  }
120
54
  console.log(
121
55
  declared.merged?.rulesetEnforcement === 'unavailable-on-plan'
122
- ? 'standards github: live repository settings match the declared configuration (rulesets skipped)'
56
+ ? 'standards github: live repository settings match the declared configuration (plan-gated settings skipped)'
123
57
  : 'standards github: live GitHub settings match the declared configuration',
124
58
  );
125
59
  return true;
@@ -165,7 +99,7 @@ export const runGithubApply = async (consumer: string): Promise<boolean> => {
165
99
  ...(await applyRepositorySettings(
166
100
  token,
167
101
  repo,
168
- declared.merged.repository,
102
+ enforceableRepositorySettings(declared.merged),
169
103
  repoResponse.body,
170
104
  )),
171
105
  ];
@@ -89,8 +89,8 @@ const diffRules = (
89
89
  // Some ruleset fields — bypass_actors in particular — are only included in
90
90
  // API responses for admin viewers. A declared key that is absent on the live
91
91
  // side is unverifiable for this token, not drift: the same policy as
92
- // repository merge settings, so a non-admin CI token does not fail the gate
93
- // on state it cannot see.
92
+ // repository merge settings, so callers can fail with a targeted
93
+ // missing-visibility message instead of a bogus value mismatch.
94
94
  export const diffRuleset = (
95
95
  declared: Readonly<Record<string, unknown>>,
96
96
  live: Readonly<Record<string, unknown>>,
@@ -150,9 +150,9 @@ export const diffRulesets = (
150
150
  };
151
151
 
152
152
  // Repo merge settings are only visible to admin tokens; report invisible keys
153
- // as unverifiable instead of drifted so a non-admin CI token does not fail the
154
- // gate, while still surfacing that only a local admin check gives full
155
- // coverage.
153
+ // as unverifiable instead of drifted so callers fail the gate with a
154
+ // missing-visibility message that names the token fix, not a bogus value
155
+ // mismatch.
156
156
  export const diffRepositorySettings = (
157
157
  declared: Readonly<Record<string, unknown>>,
158
158
  live: Readonly<Record<string, unknown>>,
@@ -0,0 +1,99 @@
1
+ // Drift collection for `standards github --check`: compares the live GitHub
2
+ // repository against the merged declaration and fails closed on state the
3
+ // token cannot see. Command wiring lives in github-commands.ts.
4
+
5
+ import {
6
+ apiError,
7
+ fetchLiveRulesets,
8
+ fetchMergeSettingsViaGraphql,
9
+ HTTP_OK,
10
+ request,
11
+ resolveGithubRepo,
12
+ resolveToken,
13
+ } from './github-api';
14
+ import {
15
+ enforceableRepositorySettings,
16
+ optOutEligibilityProblem,
17
+ unverifiableProblem,
18
+ } from './github-command-shared';
19
+ import { diffRepositorySettings, diffRulesets } from './github-diff';
20
+ import { type GithubSettings, isRecord } from './github-settings-parse';
21
+
22
+ const repositoryDrift = async (
23
+ token: string | null,
24
+ repo: string,
25
+ declared: GithubSettings,
26
+ ): Promise<ReadonlyArray<string>> => {
27
+ const repoResponse = await request(token, 'GET', `/repos/${repo}`);
28
+ if (repoResponse.status !== HTTP_OK || !isRecord(repoResponse.body)) {
29
+ return [apiError(`reading repository ${repo}`, repoResponse)];
30
+ }
31
+ const eligibilityProblem = optOutEligibilityProblem(
32
+ repo,
33
+ declared,
34
+ repoResponse.body,
35
+ );
36
+ if (eligibilityProblem !== null) {
37
+ return [eligibilityProblem];
38
+ }
39
+ const declaredRepository = enforceableRepositorySettings(declared);
40
+ const diff = diffRepositorySettings(declaredRepository, repoResponse.body);
41
+ if (diff.unverifiable.length === 0) {
42
+ return diff.drifted;
43
+ }
44
+ // REST hides merge settings from read-only tokens; retry the invisible
45
+ // keys over GraphQL before failing the gate.
46
+ const fallback = await fetchMergeSettingsViaGraphql(
47
+ token,
48
+ repo,
49
+ diff.unverifiable,
50
+ );
51
+ const rediff = diffRepositorySettings(declaredRepository, {
52
+ ...repoResponse.body,
53
+ ...fallback,
54
+ });
55
+ return [
56
+ ...rediff.drifted,
57
+ ...unverifiableProblem('repository setting(s)', rediff.unverifiable),
58
+ ];
59
+ };
60
+
61
+ const rulesetDrift = async (
62
+ token: string | null,
63
+ repo: string,
64
+ declared: GithubSettings,
65
+ ): Promise<ReadonlyArray<string>> => {
66
+ if (declared.rulesetEnforcement === 'unavailable-on-plan') {
67
+ return [];
68
+ }
69
+ const live = await fetchLiveRulesets(token, repo);
70
+ if (live.rulesets === null) {
71
+ return [live.problem ?? 'unable to read rulesets'];
72
+ }
73
+ const diff = diffRulesets(declared.rulesets, live.rulesets);
74
+ return [
75
+ ...diff.drifted,
76
+ ...unverifiableProblem('ruleset field(s)', diff.unverifiable),
77
+ ];
78
+ };
79
+
80
+ export const collectLiveDrift = async (
81
+ consumer: string,
82
+ declared: GithubSettings,
83
+ ): Promise<ReadonlyArray<string>> => {
84
+ const repo = resolveGithubRepo(consumer);
85
+ if (repo === null) {
86
+ return ['cannot determine the GitHub repository from the origin remote'];
87
+ }
88
+ const token = resolveToken();
89
+ try {
90
+ return [
91
+ ...(await repositoryDrift(token, repo, declared)),
92
+ ...(await rulesetDrift(token, repo, declared)),
93
+ ];
94
+ } catch (error) {
95
+ return [
96
+ `GitHub API unreachable: ${error instanceof Error ? error.message : String(error)}`,
97
+ ];
98
+ }
99
+ };
@@ -25,6 +25,9 @@ export type ParsedGithubSettings = {
25
25
  export const isRecord = (value: unknown): value is Record<string, unknown> =>
26
26
  typeof value === 'object' && value !== null && !Array.isArray(value);
27
27
 
28
+ export const isNonEmptyString = (value: unknown): value is string =>
29
+ typeof value === 'string' && value.length > 0;
30
+
28
31
  export const isNamedRuleset = (
29
32
  value: unknown,
30
33
  ): value is Readonly<Record<string, unknown>> & { readonly name: string } =>
@@ -30,6 +30,7 @@ const SOURCE_ROOT_EXPECTATIONS: ReadonlyArray<RootScriptExpectation> = [
30
30
  name: 'check',
31
31
  commands: [
32
32
  `${SOURCE_CLI} structure --profile source`,
33
+ `${SOURCE_CLI} dependabot --check`,
33
34
  `${SOURCE_CLI} github --check`,
34
35
  'turbo run lint check-types test',
35
36
  ],
@@ -39,6 +40,7 @@ const SOURCE_ROOT_EXPECTATIONS: ReadonlyArray<RootScriptExpectation> = [
39
40
  name: 'check:fix',
40
41
  commands: [
41
42
  `${SOURCE_CLI} structure --profile source`,
43
+ `${SOURCE_CLI} dependabot --write`,
42
44
  `${SOURCE_CLI} github --check`,
43
45
  'turbo run lint:fix check-types test',
44
46
  ],
@@ -0,0 +1,67 @@
1
+ // Deterministic block-style YAML for JSON-shaped data. Bun ships a YAML
2
+ // serializer, but it emits flow style, and generated files must stay readable
3
+ // in consumer diffs. Like cli.ts, this module is zero-dependency so `bunx` can
4
+ // execute the published package.
5
+
6
+ import { isRecord } from './github-settings-parse';
7
+
8
+ const isScalar = (value: unknown): boolean =>
9
+ value === null ||
10
+ typeof value === 'string' ||
11
+ typeof value === 'number' ||
12
+ typeof value === 'boolean';
13
+
14
+ const BARE_KEY = /^[A-Za-z0-9_-]+$/u;
15
+
16
+ const emitKey = (key: string): string =>
17
+ BARE_KEY.test(key) ? key : JSON.stringify(key);
18
+
19
+ // Strings are always double-quoted; JSON string syntax is valid YAML.
20
+ const emitScalar = (value: unknown): string =>
21
+ typeof value === 'string' ? JSON.stringify(value) : String(value);
22
+
23
+ const emitEntry = (
24
+ key: string,
25
+ value: unknown,
26
+ indent: string,
27
+ ): Array<string> => {
28
+ if (isScalar(value)) {
29
+ return [`${indent}${key}: ${emitScalar(value)}`];
30
+ }
31
+ if (Array.isArray(value)) {
32
+ return value.length === 0
33
+ ? [`${indent}${key}: []`]
34
+ : [
35
+ `${indent}${key}:`,
36
+ ...value.flatMap((item) => emitItem(item, `${indent} `)),
37
+ ];
38
+ }
39
+ if (isRecord(value)) {
40
+ return Object.keys(value).length === 0
41
+ ? [`${indent}${key}: {}`]
42
+ : [`${indent}${key}:`, ...emitEntries(value, `${indent} `)];
43
+ }
44
+ throw new Error(`Unsupported YAML value under key "${key}"`);
45
+ };
46
+
47
+ const emitEntries = (
48
+ record: Record<string, unknown>,
49
+ indent: string,
50
+ ): Array<string> =>
51
+ Object.entries(record).flatMap(([key, value]) =>
52
+ emitEntry(emitKey(key), value, indent),
53
+ );
54
+
55
+ const emitItem = (item: unknown, indent: string): Array<string> => {
56
+ if (isScalar(item)) {
57
+ return [`${indent}- ${emitScalar(item)}`];
58
+ }
59
+ if (isRecord(item) && Object.keys(item).length > 0) {
60
+ const lines = emitEntries(item, `${indent} `);
61
+ return [`${indent}- ${(lines[0] ?? '').trimStart()}`, ...lines.slice(1)];
62
+ }
63
+ throw new Error('Unsupported YAML sequence item');
64
+ };
65
+
66
+ export const emitYamlDocument = (document: Record<string, unknown>): string =>
67
+ `${emitEntries(document, '').join('\n')}\n`;
@@ -0,0 +1,25 @@
1
+ import { parseDocument } from 'yaml';
2
+
3
+ export const parseYaml = (
4
+ raw: string,
5
+ label: string,
6
+ ): { readonly value: unknown; readonly problem: string | null } => {
7
+ try {
8
+ const unmergedDocument = parseDocument(raw, {
9
+ merge: false,
10
+ uniqueKeys: true,
11
+ });
12
+ if (unmergedDocument.errors.length === 0) {
13
+ const document = parseDocument(raw, { merge: true, uniqueKeys: true });
14
+ if (document.errors.length === 0) {
15
+ return { value: document.toJS() as unknown, problem: null };
16
+ }
17
+ }
18
+ } catch {
19
+ // The caller reports one stable configuration error for parser failures.
20
+ }
21
+ return {
22
+ value: null,
23
+ problem: `${label} must contain valid YAML with unique mapping keys`,
24
+ };
25
+ };