@davidvornholt/standards 0.19.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 +11 -1
- package/src/cli.ts +1 -1
- 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/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
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
|
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
const CLOSING_KEYWORD_PATTERN = String.raw`(?:close(?:s|d)?|fix(?:es|ed)?|resolve(?:s|d)?):?[ \t]+#`;
|
|
2
|
+
const FENCE_OPEN_PATTERN = /^ {0,3}(?<marker>`{3,}|~{3,})(?<info>[^\r\n]*)$/u;
|
|
3
|
+
const BODY_LINE_PATTERN = /\r?\n/u;
|
|
4
|
+
const INDENTED_CODE_PATTERN = /^ {0,3}\t|^ {4}/u;
|
|
5
|
+
const COMMENT_START = '<!--';
|
|
6
|
+
const COMMENT_END = '-->';
|
|
7
|
+
|
|
8
|
+
type BacktickRun = {
|
|
9
|
+
readonly start: number;
|
|
10
|
+
readonly end: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const isEscaped = (line: string, index: number): boolean => {
|
|
14
|
+
let backslashes = 0;
|
|
15
|
+
for (
|
|
16
|
+
let cursor = index - 1;
|
|
17
|
+
cursor >= 0 && line[cursor] === '\\';
|
|
18
|
+
cursor -= 1
|
|
19
|
+
) {
|
|
20
|
+
backslashes += 1;
|
|
21
|
+
}
|
|
22
|
+
return backslashes % 2 === 1;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const nextBacktickRun = (line: string, from: number): BacktickRun | null => {
|
|
26
|
+
let start = line.indexOf('`', from);
|
|
27
|
+
while (start !== -1) {
|
|
28
|
+
let end = start + 1;
|
|
29
|
+
while (line[end] === '`') {
|
|
30
|
+
end += 1;
|
|
31
|
+
}
|
|
32
|
+
if (!isEscaped(line, start)) {
|
|
33
|
+
return { start, end };
|
|
34
|
+
}
|
|
35
|
+
start = line.indexOf('`', end);
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const matchingBacktickRun = (
|
|
41
|
+
line: string,
|
|
42
|
+
opener: BacktickRun,
|
|
43
|
+
): BacktickRun | null => {
|
|
44
|
+
const ticks = opener.end - opener.start;
|
|
45
|
+
let candidate = nextBacktickRun(line, opener.end);
|
|
46
|
+
while (candidate !== null) {
|
|
47
|
+
if (candidate.end - candidate.start === ticks) {
|
|
48
|
+
return candidate;
|
|
49
|
+
}
|
|
50
|
+
candidate = nextBacktickRun(line, candidate.end);
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const nextActiveCommentStart = (line: string, from: number): number => {
|
|
56
|
+
let cursor = from;
|
|
57
|
+
while (cursor < line.length) {
|
|
58
|
+
const comment = line.indexOf(COMMENT_START, cursor);
|
|
59
|
+
const opener = nextBacktickRun(line, cursor);
|
|
60
|
+
if (
|
|
61
|
+
comment !== -1 &&
|
|
62
|
+
!isEscaped(line, comment) &&
|
|
63
|
+
(opener === null || comment < opener.start)
|
|
64
|
+
) {
|
|
65
|
+
return comment;
|
|
66
|
+
}
|
|
67
|
+
if (opener === null) {
|
|
68
|
+
return -1;
|
|
69
|
+
}
|
|
70
|
+
const closer = matchingBacktickRun(line, opener);
|
|
71
|
+
cursor = closer === null ? opener.end : closer.end;
|
|
72
|
+
}
|
|
73
|
+
return -1;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const commentIsOpenAfter = (line: string, initiallyOpen: boolean): boolean => {
|
|
77
|
+
let open = initiallyOpen;
|
|
78
|
+
let cursor = 0;
|
|
79
|
+
while (cursor < line.length) {
|
|
80
|
+
const marker = open
|
|
81
|
+
? line.indexOf(COMMENT_END, cursor)
|
|
82
|
+
: nextActiveCommentStart(line, cursor);
|
|
83
|
+
if (marker === -1) {
|
|
84
|
+
return open;
|
|
85
|
+
}
|
|
86
|
+
open = !open;
|
|
87
|
+
cursor = marker + (open ? COMMENT_START.length : COMMENT_END.length);
|
|
88
|
+
}
|
|
89
|
+
return open;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const isIndentedCodeLine = (
|
|
93
|
+
lines: ReadonlyArray<string>,
|
|
94
|
+
index: number,
|
|
95
|
+
): boolean => {
|
|
96
|
+
if (!INDENTED_CODE_PATTERN.test(lines[index] ?? '')) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
|
|
100
|
+
const previous = lines[cursor] ?? '';
|
|
101
|
+
if (previous.trim().length === 0) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
if (!INDENTED_CODE_PATTERN.test(previous)) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const commentStateAfterContentLine = (
|
|
112
|
+
lines: ReadonlyArray<string>,
|
|
113
|
+
index: number,
|
|
114
|
+
initiallyOpen: boolean,
|
|
115
|
+
): boolean => {
|
|
116
|
+
if (!initiallyOpen && isIndentedCodeLine(lines, index)) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
return commentIsOpenAfter(lines[index] ?? '', initiallyOpen);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const fenceCloseForLine = (line: string): RegExp | null => {
|
|
123
|
+
const groups = FENCE_OPEN_PATTERN.exec(line)?.groups;
|
|
124
|
+
const marker = groups?.marker;
|
|
125
|
+
if (
|
|
126
|
+
marker === undefined ||
|
|
127
|
+
(marker[0] === '`' && (groups?.info ?? '').includes('`'))
|
|
128
|
+
) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
return new RegExp(`^ {0,3}${marker[0]}{${marker.length},}[ \\t]*$`, 'u');
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
type MarkdownState = {
|
|
135
|
+
readonly fenceClose: RegExp | null;
|
|
136
|
+
readonly commentOpen: boolean;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const markdownStateAfterContentLine = (
|
|
140
|
+
lines: ReadonlyArray<string>,
|
|
141
|
+
index: number,
|
|
142
|
+
state: MarkdownState,
|
|
143
|
+
): MarkdownState => {
|
|
144
|
+
const line = lines[index] ?? '';
|
|
145
|
+
if (state.fenceClose !== null) {
|
|
146
|
+
return {
|
|
147
|
+
fenceClose: state.fenceClose.test(line) ? null : state.fenceClose,
|
|
148
|
+
commentOpen: state.commentOpen,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const fenceClose = state.commentOpen ? null : fenceCloseForLine(line);
|
|
152
|
+
return {
|
|
153
|
+
fenceClose,
|
|
154
|
+
commentOpen:
|
|
155
|
+
fenceClose === null
|
|
156
|
+
? commentStateAfterContentLine(lines, index, state.commentOpen)
|
|
157
|
+
: state.commentOpen,
|
|
158
|
+
};
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export const hasClosingReferenceToIssue = (
|
|
162
|
+
body: string,
|
|
163
|
+
issueNumber: number,
|
|
164
|
+
): boolean => {
|
|
165
|
+
const reference = new RegExp(
|
|
166
|
+
`^${CLOSING_KEYWORD_PATTERN}${issueNumber}$`,
|
|
167
|
+
'iu',
|
|
168
|
+
);
|
|
169
|
+
const lines = body.split(BODY_LINE_PATTERN);
|
|
170
|
+
if (!reference.test(lines.at(-1) ?? '')) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
let state: MarkdownState = { fenceClose: null, commentOpen: false };
|
|
174
|
+
const content = lines.slice(0, -1);
|
|
175
|
+
for (const [index] of content.entries()) {
|
|
176
|
+
state = markdownStateAfterContentLine(content, index, state);
|
|
177
|
+
}
|
|
178
|
+
return state.fenceClose === null && !state.commentOpen;
|
|
179
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CodexRunResult } from './poller-codex';
|
|
1
2
|
import type { FixPublication } from './poller-fix-publication';
|
|
2
3
|
import { createComment } from './poller-github-write';
|
|
3
4
|
import {
|
|
@@ -6,8 +7,16 @@ import {
|
|
|
6
7
|
type JobLabels,
|
|
7
8
|
releaseLabels,
|
|
8
9
|
} from './poller-job-shared';
|
|
10
|
+
import { readFixOutcome } from './poller-outcome';
|
|
9
11
|
import type { FixOutcome } from './poller-protocol';
|
|
10
12
|
|
|
13
|
+
export const readFixRunOutcome = (
|
|
14
|
+
run: CodexRunResult,
|
|
15
|
+
workDir: string,
|
|
16
|
+
issueNumber: number,
|
|
17
|
+
): Promise<FixOutcome | null> =>
|
|
18
|
+
run.succeeded ? readFixOutcome(workDir, issueNumber) : Promise.resolve(null);
|
|
19
|
+
|
|
11
20
|
export const handleNonFixedOutcome = async (
|
|
12
21
|
job: FixPublication,
|
|
13
22
|
labels: JobLabels,
|
|
@@ -16,10 +16,7 @@ import {
|
|
|
16
16
|
type JobLabels,
|
|
17
17
|
releaseLabels,
|
|
18
18
|
} from './poller-job-shared';
|
|
19
|
-
import {
|
|
20
|
-
changedWorkspaceQualityManifests,
|
|
21
|
-
lockedPathsOf,
|
|
22
|
-
} from './poller-protected-paths';
|
|
19
|
+
import { lockedPathsOf } from './poller-protected-paths';
|
|
23
20
|
import {
|
|
24
21
|
APPROVED_FOR_REVIEW,
|
|
25
22
|
type FixOutcome,
|
|
@@ -150,14 +147,10 @@ export const finishFixedJob = async (
|
|
|
150
147
|
return `#${issue.number}: failed (fixed without commits)`;
|
|
151
148
|
}
|
|
152
149
|
const paths = changedPaths(workspace.dir, workspace.baseSha);
|
|
153
|
-
const forbidden =
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
workspace.baseSha,
|
|
158
|
-
paths,
|
|
159
|
-
),
|
|
160
|
-
];
|
|
150
|
+
const forbidden = forbiddenDiffPaths(
|
|
151
|
+
paths,
|
|
152
|
+
await lockedPathsOf(workspace.dir),
|
|
153
|
+
);
|
|
161
154
|
if (forbidden.length > 0) {
|
|
162
155
|
await failJob(
|
|
163
156
|
deps,
|
package/src/poller-fix-run.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { hasLabel } from './github-label-identity';
|
|
|
5
5
|
import { approvalIsCurrent, issueRevision } from './poller-approval';
|
|
6
6
|
import { startClaim, withClaimReleasedOnFailure } from './poller-claim';
|
|
7
7
|
import { runCodex } from './poller-codex';
|
|
8
|
-
import { handleNonFixedOutcome } from './poller-fix-outcome';
|
|
8
|
+
import { handleNonFixedOutcome, readFixRunOutcome } from './poller-fix-outcome';
|
|
9
9
|
import { readSealedFixOutput } from './poller-fix-output';
|
|
10
10
|
import {
|
|
11
11
|
type FixPublication,
|
|
@@ -21,7 +21,6 @@ import {
|
|
|
21
21
|
type JobResult,
|
|
22
22
|
jobPreamble,
|
|
23
23
|
} from './poller-job-shared';
|
|
24
|
-
import { readFixOutcome } from './poller-outcome';
|
|
25
24
|
import { fixPrompt } from './poller-prompts';
|
|
26
25
|
import {
|
|
27
26
|
APPROVED_FOR_FIX,
|
|
@@ -170,7 +169,7 @@ export const runFixJob = async (
|
|
|
170
169
|
config,
|
|
171
170
|
});
|
|
172
171
|
await validateFixClaim(job);
|
|
173
|
-
const outcome =
|
|
172
|
+
const outcome = await readFixRunOutcome(run, workspace.dir, issue.number);
|
|
174
173
|
if (outcome === null) {
|
|
175
174
|
await failJob(
|
|
176
175
|
deps,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
+
import { hasClosingReferenceToIssue } from './poller-closing-reference';
|
|
2
3
|
import type { SealedFixOutput } from './poller-fix-output';
|
|
3
4
|
import type { JobDeps } from './poller-job-shared';
|
|
4
5
|
import {
|
|
@@ -7,10 +8,7 @@ import {
|
|
|
7
8
|
createValidationWorktree,
|
|
8
9
|
isAncestor,
|
|
9
10
|
} from './poller-output-integrity';
|
|
10
|
-
import {
|
|
11
|
-
changedWorkspaceQualityManifests,
|
|
12
|
-
lockedPathsOf,
|
|
13
|
-
} from './poller-protected-paths';
|
|
11
|
+
import { lockedPathsOf } from './poller-protected-paths';
|
|
14
12
|
import { forbiddenDiffPaths } from './poller-protocol';
|
|
15
13
|
|
|
16
14
|
export const validateSealedFixOutput = async (
|
|
@@ -27,6 +25,7 @@ export const validateSealedFixOutput = async (
|
|
|
27
25
|
output.repo !== job.deps.repo ||
|
|
28
26
|
output.issueNumber !== job.issueNumber ||
|
|
29
27
|
output.approvalId !== job.approvalId ||
|
|
28
|
+
!hasClosingReferenceToIssue(output.body, job.issueNumber) ||
|
|
30
29
|
!isAncestor(job.cloneDir, output.baseSha, job.defaultBranch) ||
|
|
31
30
|
!isAncestor(job.cloneDir, output.baseSha, output.generatedHead) ||
|
|
32
31
|
commitCountBetween(job.cloneDir, output.baseSha, output.generatedHead) !==
|
|
@@ -49,10 +48,10 @@ export const validateSealedFixOutput = async (
|
|
|
49
48
|
),
|
|
50
49
|
);
|
|
51
50
|
try {
|
|
52
|
-
const forbidden =
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
const forbidden = forbiddenDiffPaths(
|
|
52
|
+
paths,
|
|
53
|
+
await lockedPathsOf(workspace.dir),
|
|
54
|
+
);
|
|
56
55
|
if (forbidden.length > 0) {
|
|
57
56
|
throw new Error(
|
|
58
57
|
`sealed fix output modified protected paths:\n${forbidden.join('\n')}`,
|
package/src/poller-outcome.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { existsSync, rmSync } from 'node:fs';
|
|
|
7
7
|
import { readFile } from 'node:fs/promises';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { isNonEmptyString, isRecord } from './github-settings-parse';
|
|
10
|
+
import { hasClosingReferenceToIssue } from './poller-closing-reference';
|
|
10
11
|
import {
|
|
11
12
|
type FixOutcome,
|
|
12
13
|
OUTCOME_FILE,
|
|
@@ -59,6 +60,7 @@ const PR_TITLE_PATTERN = /^[a-z]+(?:\([^)]+\))?!?: .+/u;
|
|
|
59
60
|
|
|
60
61
|
export const readFixOutcome = async (
|
|
61
62
|
workDir: string,
|
|
63
|
+
issueNumber: number,
|
|
62
64
|
): Promise<FixOutcome | null> => {
|
|
63
65
|
const raw = await readOutcomeRaw(workDir);
|
|
64
66
|
if (
|
|
@@ -78,7 +80,8 @@ export const readFixOutcome = async (
|
|
|
78
80
|
!(
|
|
79
81
|
isNonEmptyString(raw.prTitle) &&
|
|
80
82
|
PR_TITLE_PATTERN.test(raw.prTitle) &&
|
|
81
|
-
isNonEmptyString(raw.prBody)
|
|
83
|
+
isNonEmptyString(raw.prBody) &&
|
|
84
|
+
hasClosingReferenceToIssue(raw.prBody, issueNumber)
|
|
82
85
|
)
|
|
83
86
|
) {
|
|
84
87
|
return null;
|
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
import { existsSync
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { isRecord } from './github-settings-parse';
|
|
5
|
-
import { changesWorkspaceQualityScripts } from './poller-protocol';
|
|
6
|
-
import { runGit } from './poller-workspace';
|
|
7
|
-
|
|
8
|
-
const WORKSPACE_MANIFEST = /^(?:apps|packages)\/[^/]+\/package\.json$/u;
|
|
9
5
|
|
|
10
6
|
export const lockedPathsOf = async (
|
|
11
7
|
workDir: string,
|
|
@@ -29,23 +25,3 @@ export const lockedPathsOf = async (
|
|
|
29
25
|
);
|
|
30
26
|
}
|
|
31
27
|
};
|
|
32
|
-
|
|
33
|
-
export const changedWorkspaceQualityManifests = (
|
|
34
|
-
workDir: string,
|
|
35
|
-
baseSha: string,
|
|
36
|
-
paths: ReadonlyArray<string>,
|
|
37
|
-
): ReadonlyArray<string> =>
|
|
38
|
-
paths.filter((path) => {
|
|
39
|
-
if (!WORKSPACE_MANIFEST.test(path)) {
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
try {
|
|
43
|
-
return changesWorkspaceQualityScripts(
|
|
44
|
-
path,
|
|
45
|
-
runGit(['-C', workDir, 'show', `${baseSha}:${path}`], null),
|
|
46
|
-
readFileSync(join(workDir, path), 'utf8'),
|
|
47
|
-
);
|
|
48
|
-
} catch {
|
|
49
|
-
return true;
|
|
50
|
-
}
|
|
51
|
-
});
|
package/src/poller-protocol.ts
CHANGED
|
@@ -76,8 +76,6 @@ const GATE_WIRING_FILES: ReadonlySet<string> = new Set([
|
|
|
76
76
|
const HOST_SECRETS_FILE = /(?:^|\/)secrets\.yaml$/u;
|
|
77
77
|
const WORKSPACE_GATE_CONFIG =
|
|
78
78
|
/(?:^|\/)(?:biome\.jsonc|turbo\.json|tsconfig(?:\.[^.]+)?\.json|vitest\.config\.[cm]?[jt]s|playwright\.config\.[cm]?[jt]s)$/u;
|
|
79
|
-
const QUALITY_SCRIPT = /^(?:check|lint|test|typecheck)(?::|$)/u;
|
|
80
|
-
const WORKSPACE_MANIFEST = /^(?:apps|packages)\/[^/]+\/package\.json$/u;
|
|
81
79
|
|
|
82
80
|
const isEncryptedSecret = (path: string): boolean =>
|
|
83
81
|
!path.endsWith('.example.yaml') &&
|
|
@@ -101,46 +99,6 @@ export const forbiddenDiffPaths = (
|
|
|
101
99
|
);
|
|
102
100
|
};
|
|
103
101
|
|
|
104
|
-
const qualityScripts = (
|
|
105
|
-
manifest: unknown,
|
|
106
|
-
): Readonly<Record<string, string>> => {
|
|
107
|
-
if (
|
|
108
|
-
typeof manifest !== 'object' ||
|
|
109
|
-
manifest === null ||
|
|
110
|
-
!('scripts' in manifest) ||
|
|
111
|
-
typeof manifest.scripts !== 'object' ||
|
|
112
|
-
manifest.scripts === null
|
|
113
|
-
) {
|
|
114
|
-
return {};
|
|
115
|
-
}
|
|
116
|
-
return Object.fromEntries(
|
|
117
|
-
Object.entries(manifest.scripts)
|
|
118
|
-
.filter(
|
|
119
|
-
(entry): entry is [string, string] =>
|
|
120
|
-
QUALITY_SCRIPT.test(entry[0]) && typeof entry[1] === 'string',
|
|
121
|
-
)
|
|
122
|
-
.sort(([left], [right]) => left.localeCompare(right)),
|
|
123
|
-
);
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
export const changesWorkspaceQualityScripts = (
|
|
127
|
-
path: string,
|
|
128
|
-
before: string,
|
|
129
|
-
after: string,
|
|
130
|
-
): boolean => {
|
|
131
|
-
if (path === 'package.json' || !WORKSPACE_MANIFEST.test(path)) {
|
|
132
|
-
return false;
|
|
133
|
-
}
|
|
134
|
-
try {
|
|
135
|
-
return (
|
|
136
|
-
JSON.stringify(qualityScripts(JSON.parse(before) as unknown)) !==
|
|
137
|
-
JSON.stringify(qualityScripts(JSON.parse(after) as unknown))
|
|
138
|
-
);
|
|
139
|
-
} catch {
|
|
140
|
-
return true;
|
|
141
|
-
}
|
|
142
|
-
};
|
|
143
|
-
|
|
144
102
|
export const branchNameForIssue = (
|
|
145
103
|
issueNumber: number,
|
|
146
104
|
approvalId: string,
|