@davidvornholt/standards 0.18.0 → 0.20.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.
Files changed (38) hide show
  1. package/README.md +5 -4
  2. package/package.json +12 -1
  3. package/src/cli.ts +283 -73
  4. package/src/creds-add-cloudflare-commit.ts +2 -5
  5. package/src/creds-add-github.ts +82 -30
  6. package/src/creds-add-policy.ts +106 -26
  7. package/src/creds-add-preflight.ts +68 -0
  8. package/src/creds-add.ts +29 -20
  9. package/src/creds-args.ts +5 -0
  10. package/src/creds-cloudflare-zone.ts +40 -0
  11. package/src/creds-commands.ts +21 -9
  12. package/src/creds-github-app-api.ts +132 -0
  13. package/src/creds-github-apps.ts +107 -0
  14. package/src/creds-login-github-manifest.ts +38 -15
  15. package/src/creds-login-github.ts +78 -24
  16. package/src/creds-plan-renew.ts +2 -5
  17. package/src/creds-sops-value.ts +89 -0
  18. package/src/creds-sops.ts +0 -53
  19. package/src/creds-store-github.ts +121 -0
  20. package/src/creds-store-lock-holder.ts +98 -0
  21. package/src/creds-store-lock-inspection.ts +125 -0
  22. package/src/creds-store-lock-lease.ts +32 -0
  23. package/src/creds-store-lock.ts +27 -51
  24. package/src/creds-store.ts +14 -43
  25. package/src/github-command-shared.ts +8 -1
  26. package/src/github-live-drift.ts +3 -3
  27. package/src/managed-files.ts +116 -14
  28. package/src/managed-path-ownership.ts +85 -0
  29. package/src/poller-closing-reference.ts +179 -0
  30. package/src/poller-fix-outcome.ts +9 -0
  31. package/src/poller-fix-publication.ts +5 -12
  32. package/src/poller-fix-run.ts +2 -3
  33. package/src/poller-fix-validation.ts +7 -8
  34. package/src/poller-outcome.ts +4 -1
  35. package/src/poller-protected-paths.ts +1 -25
  36. package/src/poller-protocol.ts +0 -42
  37. package/src/poller-review-publication.ts +2 -8
  38. package/src/poller-review-validation.ts +5 -12
@@ -0,0 +1,121 @@
1
+ import { isNonEmptyString, isRecord } from './github-settings-parse';
2
+
3
+ export type GithubBrokerApp = {
4
+ readonly owner: string | null;
5
+ readonly appId: number;
6
+ readonly slug: string;
7
+ readonly htmlUrl: string;
8
+ readonly clientId: string;
9
+ readonly privateKey: string;
10
+ };
11
+
12
+ export const isGithubBrokerApp = (value: unknown): value is GithubBrokerApp =>
13
+ isRecord(value) &&
14
+ (value.owner === null || isNonEmptyString(value.owner)) &&
15
+ typeof value.appId === 'number' &&
16
+ Number.isInteger(value.appId) &&
17
+ isNonEmptyString(value.slug) &&
18
+ isNonEmptyString(value.htmlUrl) &&
19
+ isNonEmptyString(value.clientId) &&
20
+ isNonEmptyString(value.privateKey);
21
+
22
+ const parseGithubApp = (
23
+ raw: unknown,
24
+ owner: string | null,
25
+ ): GithubBrokerApp => {
26
+ if (!isRecord(raw)) {
27
+ throw new Error('invalid github: run `standards creds login github`');
28
+ }
29
+ const app = {
30
+ owner,
31
+ appId: raw.app_id,
32
+ slug: raw.slug,
33
+ htmlUrl: raw.html_url,
34
+ clientId: raw.client_id,
35
+ privateKey: raw.private_key,
36
+ };
37
+ if (!isGithubBrokerApp(app)) {
38
+ throw new Error('invalid github: run `standards creds login github`');
39
+ }
40
+ return app;
41
+ };
42
+
43
+ export const validateGithubApps = (
44
+ apps: ReadonlyArray<GithubBrokerApp>,
45
+ path: string,
46
+ ): ReadonlyArray<GithubBrokerApp> => {
47
+ const owners = new Set<string>();
48
+ const appIds = new Set<number>();
49
+ for (const app of apps) {
50
+ if (!isGithubBrokerApp(app)) {
51
+ throw new Error(
52
+ `${path}: invalid GitHub App; run \`standards creds login github\``,
53
+ );
54
+ }
55
+ if (app.owner === null) {
56
+ if (apps.length !== 1) {
57
+ throw new Error(
58
+ `${path}: the ownerless legacy GitHub App must be migrated before another App can be stored`,
59
+ );
60
+ }
61
+ } else {
62
+ const owner = app.owner.toLowerCase();
63
+ if (owners.has(owner)) {
64
+ throw new Error(
65
+ `${path}: duplicate GitHub owner ${app.owner}; keep one broker App per account`,
66
+ );
67
+ }
68
+ owners.add(owner);
69
+ }
70
+ if (appIds.has(app.appId)) {
71
+ throw new Error(
72
+ `${path}: duplicate GitHub App id ${app.appId}; keep each App once`,
73
+ );
74
+ }
75
+ appIds.add(app.appId);
76
+ }
77
+ return apps;
78
+ };
79
+
80
+ export const parseGithubApps = (
81
+ raw: unknown,
82
+ path: string,
83
+ ): ReadonlyArray<GithubBrokerApp> => {
84
+ if (raw === undefined || raw === null) {
85
+ return [];
86
+ }
87
+ if (!Array.isArray(raw)) {
88
+ return [parseGithubApp(raw, null)];
89
+ }
90
+ const apps = raw.map((entry) => {
91
+ if (!(isRecord(entry) && isNonEmptyString(entry.owner))) {
92
+ throw new Error(
93
+ 'invalid github owner: run `standards creds login github`',
94
+ );
95
+ }
96
+ return parseGithubApp(entry, entry.owner);
97
+ });
98
+ return validateGithubApps(apps, path);
99
+ };
100
+
101
+ const appDocument = (
102
+ app: GithubBrokerApp,
103
+ ): Readonly<Record<string, unknown>> => ({
104
+ ...(app.owner === null ? {} : { owner: app.owner }),
105
+ // biome-ignore lint/style/useNamingConvention: The persisted broker YAML contract uses snake_case.
106
+ app_id: app.appId,
107
+ slug: app.slug,
108
+ // biome-ignore lint/style/useNamingConvention: The persisted broker YAML contract uses snake_case.
109
+ html_url: app.htmlUrl,
110
+ // biome-ignore lint/style/useNamingConvention: The persisted broker YAML contract uses snake_case.
111
+ client_id: app.clientId,
112
+ // biome-ignore lint/style/useNamingConvention: The persisted broker YAML contract uses snake_case.
113
+ private_key: app.privateKey,
114
+ });
115
+
116
+ export const githubStoreDocument = (
117
+ apps: ReadonlyArray<GithubBrokerApp>,
118
+ ): unknown =>
119
+ apps.length === 1 && apps[0]?.owner === null
120
+ ? appDocument(apps[0])
121
+ : apps.map(appDocument);
@@ -0,0 +1,98 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, rename, rmdir, unlink, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import type { Worker } from 'node:worker_threads';
5
+ import { startBrokerLockLease } from './creds-store-lock-lease';
6
+ import { isRecord } from './github-settings-parse';
7
+
8
+ const OWNER_ONLY_DIR_MODE = 0o700;
9
+ const OWNER_ONLY_FILE_MODE = 0o600;
10
+ const HOLDER_PREFIX = 'holder-';
11
+ const HOLDER_SUFFIX = '.json';
12
+ const LEASE_DIVISOR = 3;
13
+
14
+ export type HeldBrokerLock = {
15
+ readonly holderPath: string;
16
+ readonly lease: Worker;
17
+ readonly lockPath: string;
18
+ };
19
+
20
+ const isErrorCode = (error: unknown, code: string): boolean =>
21
+ isRecord(error) && error.code === code;
22
+
23
+ const cleanupCandidate = async (
24
+ candidatePath: string,
25
+ holderPath: string,
26
+ ): Promise<void> => {
27
+ await unlink(holderPath).catch(() => undefined);
28
+ await rmdir(candidatePath).catch(() => undefined);
29
+ };
30
+
31
+ export const tryAcquireBrokerLock = async (
32
+ lockPath: string,
33
+ staleMs: number,
34
+ ): Promise<HeldBrokerLock | null> => {
35
+ const generation = randomUUID();
36
+ const candidatePath = `${lockPath}.pending-${generation}`;
37
+ const candidateHolderPath = join(
38
+ candidatePath,
39
+ `${HOLDER_PREFIX}${generation}${HOLDER_SUFFIX}`,
40
+ );
41
+ await mkdir(candidatePath, { mode: OWNER_ONLY_DIR_MODE });
42
+ try {
43
+ await writeFile(candidateHolderPath, JSON.stringify({ generation }), {
44
+ flag: 'wx',
45
+ mode: OWNER_ONLY_FILE_MODE,
46
+ });
47
+ try {
48
+ await rename(candidatePath, lockPath);
49
+ } catch (error) {
50
+ if (isErrorCode(error, 'EEXIST') || isErrorCode(error, 'ENOTEMPTY')) {
51
+ await cleanupCandidate(candidatePath, candidateHolderPath);
52
+ return null;
53
+ }
54
+ throw error;
55
+ }
56
+ } catch (error) {
57
+ await cleanupCandidate(candidatePath, candidateHolderPath);
58
+ throw error;
59
+ }
60
+ const holderPath = join(
61
+ lockPath,
62
+ `${HOLDER_PREFIX}${generation}${HOLDER_SUFFIX}`,
63
+ );
64
+ try {
65
+ const lease = startBrokerLockLease(
66
+ holderPath,
67
+ Math.max(1, Math.floor(staleMs / LEASE_DIVISOR)),
68
+ );
69
+ return { holderPath, lease, lockPath };
70
+ } catch (error) {
71
+ await unlink(holderPath).catch(() => undefined);
72
+ await rmdir(lockPath).catch(() => undefined);
73
+ throw error;
74
+ }
75
+ };
76
+
77
+ export const releaseBrokerLock = async ({
78
+ holderPath,
79
+ lease,
80
+ lockPath,
81
+ }: HeldBrokerLock): Promise<void> => {
82
+ await lease.terminate();
83
+ try {
84
+ await unlink(holderPath);
85
+ } catch (error) {
86
+ if (isErrorCode(error, 'ENOENT')) {
87
+ return;
88
+ }
89
+ throw error;
90
+ }
91
+ try {
92
+ await rmdir(lockPath);
93
+ } catch (error) {
94
+ if (!isErrorCode(error, 'ENOENT')) {
95
+ throw error;
96
+ }
97
+ }
98
+ };
@@ -0,0 +1,125 @@
1
+ import { readdir, readFile, rmdir, stat, unlink } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { isRecord } from './github-settings-parse';
4
+
5
+ const HOLDER_PREFIX = 'holder-';
6
+ const HOLDER_SUFFIX = '.json';
7
+
8
+ export type BrokerLockAvailability = 'blocked' | 'live' | 'retry';
9
+
10
+ const isErrorCode = (error: unknown, code: string): boolean =>
11
+ isRecord(error) && error.code === code;
12
+
13
+ type ExistingGeneration =
14
+ | { readonly kind: 'missing' }
15
+ | { readonly kind: 'unknown' }
16
+ | {
17
+ readonly entryPath: string | null;
18
+ readonly kind: 'incomplete';
19
+ readonly modifiedAt: number;
20
+ }
21
+ | {
22
+ readonly entryPath: string;
23
+ readonly kind: 'valid';
24
+ readonly modifiedAt: number;
25
+ };
26
+
27
+ const directoryModifiedAt = async (
28
+ lockPath: string,
29
+ ): Promise<number | null> => {
30
+ try {
31
+ return (await stat(lockPath)).mtimeMs;
32
+ } catch {
33
+ return null;
34
+ }
35
+ };
36
+
37
+ const readExistingGeneration = async (
38
+ lockPath: string,
39
+ ): Promise<ExistingGeneration> => {
40
+ let names: ReadonlyArray<string>;
41
+ try {
42
+ names = await readdir(lockPath);
43
+ } catch (error) {
44
+ return isErrorCode(error, 'ENOENT')
45
+ ? { kind: 'missing' }
46
+ : { kind: 'unknown' };
47
+ }
48
+ if (names.length === 0) {
49
+ const modifiedAt = await directoryModifiedAt(lockPath);
50
+ return modifiedAt === null
51
+ ? { kind: 'missing' }
52
+ : { entryPath: null, kind: 'incomplete', modifiedAt };
53
+ }
54
+ const entryName = names.length === 1 ? names[0] : undefined;
55
+ if (entryName === undefined) {
56
+ return { kind: 'unknown' };
57
+ }
58
+ const entryPath = join(lockPath, entryName);
59
+ let modifiedAt: number;
60
+ try {
61
+ modifiedAt = (await stat(entryPath)).mtimeMs;
62
+ } catch {
63
+ return { kind: 'unknown' };
64
+ }
65
+ const incomplete = {
66
+ entryPath,
67
+ kind: 'incomplete' as const,
68
+ modifiedAt,
69
+ };
70
+ if (
71
+ !(entryName.startsWith(HOLDER_PREFIX) && entryName.endsWith(HOLDER_SUFFIX))
72
+ ) {
73
+ return incomplete;
74
+ }
75
+ try {
76
+ const decoded: unknown = JSON.parse(await readFile(entryPath, 'utf8'));
77
+ const generation = entryName.slice(
78
+ HOLDER_PREFIX.length,
79
+ -HOLDER_SUFFIX.length,
80
+ );
81
+ return isRecord(decoded) && decoded.generation === generation
82
+ ? { entryPath, kind: 'valid', modifiedAt }
83
+ : incomplete;
84
+ } catch {
85
+ return incomplete;
86
+ }
87
+ };
88
+
89
+ const removeObservedGeneration = async (
90
+ lockPath: string,
91
+ entryPath: string | null,
92
+ ): Promise<boolean> => {
93
+ if (entryPath !== null) {
94
+ try {
95
+ await unlink(entryPath);
96
+ } catch (error) {
97
+ return isErrorCode(error, 'ENOENT');
98
+ }
99
+ }
100
+ try {
101
+ await rmdir(lockPath);
102
+ return true;
103
+ } catch (error) {
104
+ return isErrorCode(error, 'ENOENT');
105
+ }
106
+ };
107
+
108
+ export const inspectBrokerLock = async (
109
+ lockPath: string,
110
+ staleMs: number,
111
+ ): Promise<BrokerLockAvailability> => {
112
+ const generation = await readExistingGeneration(lockPath);
113
+ if (generation.kind === 'missing') {
114
+ return 'retry';
115
+ }
116
+ if (generation.kind === 'unknown') {
117
+ return 'blocked';
118
+ }
119
+ if (Date.now() - generation.modifiedAt < staleMs) {
120
+ return generation.kind === 'valid' ? 'live' : 'blocked';
121
+ }
122
+ return (await removeObservedGeneration(lockPath, generation.entryPath))
123
+ ? 'retry'
124
+ : 'blocked';
125
+ };
@@ -0,0 +1,32 @@
1
+ import { utimes } from 'node:fs/promises';
2
+ import {
3
+ isMainThread,
4
+ parentPort,
5
+ Worker,
6
+ workerData,
7
+ } from 'node:worker_threads';
8
+
9
+ type LeaseWorkerData = {
10
+ readonly holderPath: string;
11
+ readonly intervalMs: number;
12
+ };
13
+
14
+ if (!isMainThread) {
15
+ const { holderPath, intervalMs } = workerData as LeaseWorkerData;
16
+ const timer = setInterval(
17
+ () => utimes(holderPath, new Date(), new Date()).catch(() => undefined),
18
+ intervalMs,
19
+ );
20
+ parentPort?.on('close', () => clearInterval(timer));
21
+ }
22
+
23
+ export const startBrokerLockLease = (
24
+ holderPath: string,
25
+ intervalMs: number,
26
+ ): Worker => {
27
+ const lease = new Worker(import.meta.url, {
28
+ workerData: { holderPath, intervalMs } satisfies LeaseWorkerData,
29
+ });
30
+ lease.unref();
31
+ return lease;
32
+ };
@@ -1,12 +1,17 @@
1
- // Broker store lock: a mkdir-based mutex serializing read-modify-write
2
- // cycles on broker.yaml. Store writes are sub-second, so a lock directory
3
- // older than the staleness window can only belong to a dead process and is
4
- // broken automatically; the timeout error names the directory and the manual
5
- // remediation for the pathological rest.
1
+ // Broker store lock: a fully initialized generation is atomically renamed into
2
+ // place before its operation starts. A worker-thread lease remains live while
3
+ // synchronous SOPS/Nix blocks the main event loop. Cleanup unlinks the exact
4
+ // generation token before removing the directory, so an old owner cannot
5
+ // remove a replacement.
6
6
 
7
- import { mkdir, rmdir, stat } from 'node:fs/promises';
7
+ import { mkdir } from 'node:fs/promises';
8
8
  import { dirname } from 'node:path';
9
- import { isRecord } from './github-settings-parse';
9
+ import {
10
+ type HeldBrokerLock,
11
+ releaseBrokerLock,
12
+ tryAcquireBrokerLock,
13
+ } from './creds-store-lock-holder';
14
+ import { inspectBrokerLock } from './creds-store-lock-inspection';
10
15
 
11
16
  const OWNER_ONLY_DIR_MODE = 0o700;
12
17
 
@@ -23,59 +28,30 @@ const DEFAULT_STALE_MS = 30_000;
23
28
  const sleep = (ms: number): Promise<void> =>
24
29
  new Promise((resolve) => setTimeout(resolve, ms));
25
30
 
26
- const tryAcquire = async (lockPath: string): Promise<boolean> => {
27
- try {
28
- await mkdir(lockPath, { mode: OWNER_ONLY_DIR_MODE });
29
- return true;
30
- } catch (error) {
31
- if (isRecord(error) && error.code === 'EEXIST') {
32
- return false;
33
- }
34
- throw error;
35
- }
36
- };
37
-
38
- // True when the contender may immediately retry: the lock vanished, or it
39
- // was stale and this process removed it (losing the removal race to another
40
- // contender also lands here — the retry's mkdir arbitrates).
41
- const breakIfStale = async (
42
- lockPath: string,
43
- staleMs: number,
44
- ): Promise<boolean> => {
45
- try {
46
- const info = await stat(lockPath);
47
- if (Date.now() - info.mtimeMs < staleMs) {
48
- return false;
49
- }
50
- await rmdir(lockPath);
51
- return true;
52
- } catch {
53
- return true;
54
- }
55
- };
56
-
57
31
  // One acquisition attempt: acquired, or cleared a stale/vanished lock (retry
58
- // immediately), or waited one retry slot; a live lock past the deadline
59
- // throws with the remediation hint.
32
+ // immediately), or waited one retry slot. Proven-live generations do not
33
+ // consume the recovery deadline; incomplete or ambiguous generations do.
60
34
  const attemptAcquire = async (
61
35
  lockPath: string,
62
36
  staleMs: number,
63
37
  retryMs: number,
64
38
  deadline: number,
65
- ): Promise<boolean> => {
66
- if (await tryAcquire(lockPath)) {
67
- return true;
39
+ ): Promise<HeldBrokerLock | null> => {
40
+ const held = await tryAcquireBrokerLock(lockPath, staleMs);
41
+ if (held !== null) {
42
+ return held;
68
43
  }
69
- if (await breakIfStale(lockPath, staleMs)) {
70
- return false;
44
+ const availability = await inspectBrokerLock(lockPath, staleMs);
45
+ if (availability === 'retry') {
46
+ return null;
71
47
  }
72
- if (Date.now() >= deadline) {
48
+ if (availability === 'blocked' && Date.now() >= deadline) {
73
49
  throw new Error(
74
50
  `lock timeout: another creds process holds ${lockPath}; if none is running, remove that directory and retry`,
75
51
  );
76
52
  }
77
53
  await sleep(retryMs);
78
- return false;
54
+ return null;
79
55
  };
80
56
 
81
57
  export const withBrokerLock = async <T>(
@@ -89,14 +65,14 @@ export const withBrokerLock = async <T>(
89
65
  const lockPath = `${path}.lock`;
90
66
  await mkdir(dirname(path), { recursive: true, mode: OWNER_ONLY_DIR_MODE });
91
67
  const deadline = Date.now() + timeoutMs;
92
- let acquired = false;
93
- while (!acquired) {
68
+ let held: HeldBrokerLock | null = null;
69
+ while (held === null) {
94
70
  // biome-ignore lint/performance/noAwaitInLoops: lock acquisition retries are inherently sequential.
95
- acquired = await attemptAcquire(lockPath, staleMs, retryMs, deadline);
71
+ held = await attemptAcquire(lockPath, staleMs, retryMs, deadline);
96
72
  }
97
73
  try {
98
74
  return await operation();
99
75
  } finally {
100
- await rmdir(lockPath);
76
+ await releaseBrokerLock(held);
101
77
  }
102
78
  };
@@ -6,23 +6,24 @@ import { basename, dirname, join } from 'node:path';
6
6
  import process from 'node:process';
7
7
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
8
8
  import { isCloudflareAccountId } from './creds-cloudflare-account';
9
+ import {
10
+ type GithubBrokerApp,
11
+ githubStoreDocument,
12
+ parseGithubApps,
13
+ validateGithubApps,
14
+ } from './creds-store-github';
9
15
  import { withBrokerLock } from './creds-store-lock';
10
16
  import { isNonEmptyString, isRecord } from './github-settings-parse';
11
- export type GithubBrokerApp = {
12
- readonly appId: number;
13
- readonly slug: string;
14
- readonly htmlUrl: string;
15
- readonly clientId: string;
16
- readonly privateKey: string;
17
- };
17
+
18
+ export type { GithubBrokerApp } from './creds-store-github';
18
19
  export type CloudflareBrokerAccount = Readonly<
19
20
  Record<'accountId' | 'token', string>
20
21
  >;
21
22
  export type BrokerStore = {
22
- readonly github: GithubBrokerApp | null;
23
+ readonly github: ReadonlyArray<GithubBrokerApp>;
23
24
  readonly cloudflare: ReadonlyArray<CloudflareBrokerAccount>;
24
25
  };
25
- export const EMPTY_BROKER_STORE: BrokerStore = { github: null, cloudflare: [] };
26
+ export const EMPTY_BROKER_STORE: BrokerStore = { github: [], cloudflare: [] };
26
27
  const OWNER_ONLY_FILE_MODE = 0o600;
27
28
  const OWNER_ONLY_DIR_MODE = 0o700;
28
29
  const FILE_MODE_MODULUS = 0o1000;
@@ -48,29 +49,6 @@ export const resolveBrokerPath = (): string =>
48
49
  process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'),
49
50
  'standards/broker.yaml',
50
51
  );
51
- const parseGithub = (raw: unknown): GithubBrokerApp | null => {
52
- if (raw === undefined || raw === null) {
53
- return null;
54
- }
55
- if (
56
- !isRecord(raw) ||
57
- typeof raw.app_id !== 'number' ||
58
- !Number.isInteger(raw.app_id) ||
59
- !isNonEmptyString(raw.slug) ||
60
- !isNonEmptyString(raw.html_url) ||
61
- !isNonEmptyString(raw.client_id) ||
62
- !isNonEmptyString(raw.private_key)
63
- ) {
64
- throw new Error('invalid github: run `standards creds login github`');
65
- }
66
- return {
67
- appId: raw.app_id,
68
- slug: raw.slug,
69
- htmlUrl: raw.html_url,
70
- clientId: raw.client_id,
71
- privateKey: raw.private_key,
72
- };
73
- };
74
52
  const validateCloudflareAccounts = (
75
53
  accounts: ReadonlyArray<CloudflareBrokerAccount>,
76
54
  path: string,
@@ -131,7 +109,7 @@ export const readBrokerStore = async (path: string): Promise<BrokerStore> => {
131
109
  throw new Error(`${path} must contain a YAML mapping`);
132
110
  }
133
111
  return {
134
- github: parseGithub(raw.github),
112
+ github: parseGithubApps(raw.github, path),
135
113
  cloudflare: parseCloudflare(raw.cloudflare, path),
136
114
  };
137
115
  };
@@ -142,21 +120,14 @@ export const updateBrokerStore = async (
142
120
  ): Promise<void> =>
143
121
  withBrokerLock(path, async () => {
144
122
  const store = await update(await readBrokerStore(path));
123
+ validateGithubApps(store.github, path);
145
124
  validateCloudflareAccounts(store.cloudflare, path);
146
125
  await writeBrokerStoreUnlocked(path, store, sync);
147
126
  });
148
127
  const storeDocument = (store: BrokerStore): unknown => ({
149
- ...(store.github === null
128
+ ...(store.github.length === 0
150
129
  ? {}
151
- : {
152
- github: {
153
- app_id: store.github.appId,
154
- slug: store.github.slug,
155
- html_url: store.github.htmlUrl,
156
- client_id: store.github.clientId,
157
- private_key: store.github.privateKey,
158
- },
159
- }),
130
+ : { github: githubStoreDocument(store.github) }),
160
131
  ...(store.cloudflare.length === 0
161
132
  ? {}
162
133
  : {
@@ -33,7 +33,14 @@ export const optOutEligibilityProblem = (
33
33
  : null;
34
34
 
35
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';
36
+ 'Use a user-scoped token with read access to repository administration, or verify locally with admin auth; a CI token cannot hold that access, so widening what CI reads with is not the fix';
37
+
38
+ // Merge settings have a different remedy from everything else this advice used
39
+ // to cover. GraphQL serves them to read-only tokens by design, so reaching this
40
+ // message means the fallback request itself failed — pointing the reader at a
41
+ // permission they cannot obtain would send them after the wrong cause.
42
+ export const MERGE_SETTINGS_VISIBILITY_ADVICE =
43
+ 'REST serves repository merge settings only to write-level viewers, and the GraphQL fallback that answers them for a read-only token did not respond, so this is more likely a failed request than a permission gap: re-run the check, and if it keeps failing verify locally with admin auth';
37
44
 
38
45
  // Declared state the token cannot see is a gate failure, not a pass with a
39
46
  // log line: a gate that cannot perform its comparison fails closed. The advice
@@ -13,8 +13,8 @@ import {
13
13
  } from './github-api';
14
14
  import { resolveHiddenBypassActors } from './github-bypass-actors';
15
15
  import {
16
- ADMIN_VISIBILITY_ADVICE,
17
16
  enforceableRepositorySettings,
17
+ MERGE_SETTINGS_VISIBILITY_ADVICE,
18
18
  optOutEligibilityProblem,
19
19
  unverifiableProblem,
20
20
  } from './github-command-shared';
@@ -35,7 +35,7 @@ import {
35
35
  import { type GithubSettings, isRecord } from './github-settings-parse';
36
36
 
37
37
  const LABEL_VISIBILITY_PROBLEM =
38
- 'declared labels not visible to this token, so the gate cannot verify them. In CI, use a valid ci.github_settings_read_token from secrets/ci.yaml with read-only "Issues" access (or "Pull requests" read); locally use a token with one of those permissions';
38
+ 'declared labels not visible to this token, so the gate cannot verify them. Label reads need issues: read (or pull-requests: read); the canonical github-settings job already grants issues: read, so in CI this points at a stale synced workflow or an organization policy restricting the workflow token rather than anything to change in the declaration. Locally, use a token with one of those permissions';
39
39
  const PERMISSION_DENIAL_MESSAGES: ReadonlySet<string> = new Set([
40
40
  'Resource not accessible by integration',
41
41
  'Resource not accessible by personal access token',
@@ -87,7 +87,7 @@ const repositoryDrift = async (
87
87
  ...unverifiableProblem(
88
88
  'repository setting(s)',
89
89
  rediff.unverifiable,
90
- ADMIN_VISIBILITY_ADVICE,
90
+ MERGE_SETTINGS_VISIBILITY_ADVICE,
91
91
  ),
92
92
  ];
93
93
  };