@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.
- package/README.md +5 -4
- package/package.json +12 -1
- package/src/cli.ts +283 -73
- package/src/creds-add-cloudflare-commit.ts +2 -5
- package/src/creds-add-github.ts +82 -30
- package/src/creds-add-policy.ts +106 -26
- package/src/creds-add-preflight.ts +68 -0
- package/src/creds-add.ts +29 -20
- package/src/creds-args.ts +5 -0
- package/src/creds-cloudflare-zone.ts +40 -0
- package/src/creds-commands.ts +21 -9
- package/src/creds-github-app-api.ts +132 -0
- package/src/creds-github-apps.ts +107 -0
- package/src/creds-login-github-manifest.ts +38 -15
- package/src/creds-login-github.ts +78 -24
- package/src/creds-plan-renew.ts +2 -5
- package/src/creds-sops-value.ts +89 -0
- package/src/creds-sops.ts +0 -53
- package/src/creds-store-github.ts +121 -0
- package/src/creds-store-lock-holder.ts +98 -0
- package/src/creds-store-lock-inspection.ts +125 -0
- package/src/creds-store-lock-lease.ts +32 -0
- package/src/creds-store-lock.ts +27 -51
- package/src/creds-store.ts +14 -43
- package/src/github-command-shared.ts +8 -1
- package/src/github-live-drift.ts +3 -3
- package/src/managed-files.ts +116 -14
- package/src/managed-path-ownership.ts +85 -0
- package/src/poller-closing-reference.ts +179 -0
- package/src/poller-fix-outcome.ts +9 -0
- package/src/poller-fix-publication.ts +5 -12
- package/src/poller-fix-run.ts +2 -3
- package/src/poller-fix-validation.ts +7 -8
- package/src/poller-outcome.ts +4 -1
- package/src/poller-protected-paths.ts +1 -25
- package/src/poller-protocol.ts +0 -42
- package/src/poller-review-publication.ts +2 -8
- 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
|
+
};
|
package/src/creds-store-lock.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
// Broker store lock: a
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
7
|
+
import { mkdir } from 'node:fs/promises';
|
|
8
8
|
import { dirname } from 'node:path';
|
|
9
|
-
import {
|
|
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
|
|
59
|
-
//
|
|
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<
|
|
66
|
-
|
|
67
|
-
|
|
39
|
+
): Promise<HeldBrokerLock | null> => {
|
|
40
|
+
const held = await tryAcquireBrokerLock(lockPath, staleMs);
|
|
41
|
+
if (held !== null) {
|
|
42
|
+
return held;
|
|
68
43
|
}
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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
|
|
93
|
-
while (
|
|
68
|
+
let held: HeldBrokerLock | null = null;
|
|
69
|
+
while (held === null) {
|
|
94
70
|
// biome-ignore lint/performance/noAwaitInLoops: lock acquisition retries are inherently sequential.
|
|
95
|
-
|
|
71
|
+
held = await attemptAcquire(lockPath, staleMs, retryMs, deadline);
|
|
96
72
|
}
|
|
97
73
|
try {
|
|
98
74
|
return await operation();
|
|
99
75
|
} finally {
|
|
100
|
-
await
|
|
76
|
+
await releaseBrokerLock(held);
|
|
101
77
|
}
|
|
102
78
|
};
|
package/src/creds-store.ts
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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
|
|
23
|
+
readonly github: ReadonlyArray<GithubBrokerApp>;
|
|
23
24
|
readonly cloudflare: ReadonlyArray<CloudflareBrokerAccount>;
|
|
24
25
|
};
|
|
25
|
-
export const EMPTY_BROKER_STORE: BrokerStore = { github:
|
|
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:
|
|
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 ===
|
|
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
|
|
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
|
package/src/github-live-drift.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
90
|
+
MERGE_SETTINGS_VISIBILITY_ADVICE,
|
|
91
91
|
),
|
|
92
92
|
];
|
|
93
93
|
};
|