@davidvornholt/standards 0.19.0 → 0.21.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 (56) hide show
  1. package/README.md +7 -5
  2. package/package.json +19 -1
  3. package/src/cli.ts +2 -2
  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 +14 -0
  10. package/src/creds-cloudflare-account.ts +2 -2
  11. package/src/creds-cloudflare-id.ts +10 -0
  12. package/src/creds-cloudflare-zone.ts +40 -0
  13. package/src/creds-commands.ts +34 -11
  14. package/src/creds-github-app-api.ts +132 -0
  15. package/src/creds-github-apps.ts +107 -0
  16. package/src/creds-login-github-manifest.ts +38 -15
  17. package/src/creds-login-github.ts +78 -24
  18. package/src/creds-naming.ts +13 -0
  19. package/src/creds-plan-renew.ts +2 -5
  20. package/src/creds-plan-report.ts +87 -0
  21. package/src/creds-plan-run.ts +2 -11
  22. package/src/creds-plan-types.ts +19 -0
  23. package/src/creds-plan-unmanaged.ts +102 -0
  24. package/src/creds-plan.ts +25 -21
  25. package/src/creds-revoke.ts +162 -0
  26. package/src/creds-sops-value.ts +89 -0
  27. package/src/creds-sops.ts +0 -53
  28. package/src/creds-store-github.ts +121 -0
  29. package/src/creds-store-lock-holder.ts +105 -0
  30. package/src/creds-store-lock-inspection.ts +125 -0
  31. package/src/creds-store-lock-lease.ts +32 -0
  32. package/src/creds-store-lock.ts +28 -51
  33. package/src/creds-store.ts +14 -43
  34. package/src/dev-env-compose.ts +120 -0
  35. package/src/dev-env-destination-duplicates.ts +10 -10
  36. package/src/dev-env-destination-gitignore.ts +22 -0
  37. package/src/dev-env-destination.ts +52 -30
  38. package/src/dev-env-document.ts +19 -6
  39. package/src/dev-env-dotenv.ts +12 -3
  40. package/src/dev-env-reconciliation.ts +170 -0
  41. package/src/dev-env-secrets.ts +41 -0
  42. package/src/dev-env-transaction-recovery.ts +74 -11
  43. package/src/dev-env-transaction.ts +61 -15
  44. package/src/dev-env.ts +115 -63
  45. package/src/github-command-shared.ts +8 -1
  46. package/src/github-live-drift.ts +3 -3
  47. package/src/poller-closing-reference.ts +179 -0
  48. package/src/poller-fix-outcome.ts +9 -0
  49. package/src/poller-fix-publication.ts +5 -12
  50. package/src/poller-fix-run.ts +2 -3
  51. package/src/poller-fix-validation.ts +7 -8
  52. package/src/poller-outcome.ts +4 -1
  53. package/src/poller-protected-paths.ts +1 -25
  54. package/src/poller-protocol.ts +0 -42
  55. package/src/poller-review-publication.ts +2 -8
  56. package/src/poller-review-validation.ts +5 -12
package/src/dev-env.ts CHANGED
@@ -1,89 +1,107 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
1
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
3
2
  import { join } from 'node:path';
3
+ import { composeDevEnv } from './dev-env-compose';
4
4
  import {
5
+ type DevEnvRemoval,
5
6
  type DevEnvWrite,
6
7
  devEnvDestinationProblems,
7
8
  } from './dev-env-destination';
9
+ import { devEnvGitIgnoreProblem } from './dev-env-destination-gitignore';
8
10
  import { parseDevEnvDocument } from './dev-env-document';
9
11
  import { renderDotenv } from './dev-env-dotenv';
10
- import { writeDevEnvFiles } from './dev-env-transaction';
11
- import { runSops } from './sops-exec';
12
+ import { planDevEnvRemovals } from './dev-env-reconciliation';
13
+ import { DEV_SECRETS_FILE, readDevSecrets } from './dev-env-secrets';
14
+ import { applyDevEnvChanges } from './dev-env-transaction';
15
+ import { parseYaml } from './yaml-parse';
12
16
 
13
- export const DEV_SECRETS_FILE = 'secrets/dev.yaml';
17
+ export const DEV_CONFIG_FILE = 'config/dev.yaml';
18
+ export const DEV_LOCAL_FILE = 'config/dev.local.yaml';
14
19
 
15
- const SOPS_ARGS = ['--decrypt', '--output-type', 'json', DEV_SECRETS_FILE];
20
+ export type DevEnvPlainInput = { readonly raw: unknown } | null;
16
21
 
17
- type DecryptResult =
18
- | { readonly ok: true; readonly value: unknown }
19
- | { readonly ok: false; readonly problem: string };
22
+ export type DevEnvInputs = {
23
+ readonly config: DevEnvPlainInput;
24
+ readonly secrets: unknown;
25
+ readonly local: DevEnvPlainInput;
26
+ };
27
+
28
+ const isMissingPathError = (error: unknown): boolean =>
29
+ (error as { readonly code?: unknown } | null)?.code === 'ENOENT';
20
30
 
21
- // sops emits JSON here so the CLI never parses the encrypted YAML itself.
22
- const decryptDevSecrets = (consumer: string): DecryptResult => {
23
- const result = runSops(SOPS_ARGS, consumer);
24
- if (result.status !== 0) {
25
- const detail = result.errorMessage ?? result.stderr.trim();
31
+ const readPlainLayer = (consumer: string, rel: string) => {
32
+ const path = join(consumer, rel);
33
+ try {
34
+ lstatSync(path);
35
+ } catch (error) {
36
+ if (isMissingPathError(error)) {
37
+ return { input: null, present: false, problems: [] };
38
+ }
39
+ const detail = error instanceof Error ? error.message : String(error);
26
40
  return {
27
- ok: false,
28
- problem: detail
29
- ? `could not decrypt ${DEV_SECRETS_FILE}: ${detail}`
30
- : `could not decrypt ${DEV_SECRETS_FILE}`,
41
+ input: null,
42
+ present: true,
43
+ problems: [`could not inspect ${rel}: ${detail}`],
31
44
  };
32
45
  }
46
+ let raw: string;
33
47
  try {
34
- return { ok: true, value: JSON.parse(result.stdout) as unknown };
48
+ raw = readFileSync(path, 'utf8');
35
49
  } catch (error) {
50
+ const detail = error instanceof Error ? error.message : String(error);
36
51
  return {
37
- ok: false,
38
- problem: `could not parse decrypted ${DEV_SECRETS_FILE} as JSON: ${
39
- error instanceof Error ? error.message : String(error)
40
- }`,
52
+ input: null,
53
+ present: true,
54
+ problems: [`could not read ${rel}: ${detail}`],
41
55
  };
42
56
  }
43
- };
44
-
45
- // Writing decrypted values into the tree is only safe when git will never
46
- // track them. `git check-ignore` is authoritative; anything but a clear
47
- // "ignored" answer fails closed, including running outside a git checkout.
48
- const gitIgnoreProblem = (consumer: string, rel: string): string | null => {
49
- const result = spawnSync('git', ['check-ignore', '-q', '--', rel], {
50
- cwd: consumer,
51
- });
52
- if (result.error !== undefined || result.status === null) {
53
- return `cannot run git to verify ${rel} is gitignored`;
54
- }
55
- if (result.status === 0) {
56
- return null;
57
- }
58
- if (result.status === 1) {
59
- return `${rel} is not gitignored; ignore it before generating dev env files`;
57
+ const parsed = parseYaml(raw, rel);
58
+ if (parsed.problem !== null) {
59
+ return { input: null, present: true, problems: [parsed.problem] };
60
60
  }
61
- return `cannot verify ${rel} is gitignored (git check-ignore exited ${result.status})`;
61
+ return { input: { raw: parsed.value ?? {} }, present: true, problems: [] };
62
62
  };
63
63
 
64
+ const documentProblems = (
65
+ source: string,
66
+ input: DevEnvPlainInput,
67
+ ): ReadonlyArray<string> =>
68
+ input === null ? [] : parseDevEnvDocument(input.raw, source).problems;
69
+
64
70
  export type DevEnvPlan = {
65
71
  readonly writes: ReadonlyArray<DevEnvWrite>;
72
+ readonly removals: ReadonlyArray<DevEnvRemoval>;
66
73
  readonly problems: ReadonlyArray<string>;
67
74
  };
68
75
 
69
- export const planDevEnvWrites = (
76
+ export const planDevEnvChanges = (
70
77
  consumer: string,
71
- raw: unknown,
78
+ inputs: DevEnvInputs,
72
79
  ): DevEnvPlan => {
73
- const document = parseDevEnvDocument(raw, DEV_SECRETS_FILE);
74
- const problems: Array<string> = [...document.problems];
80
+ const layer = (source: string, input: DevEnvPlainInput) =>
81
+ input === null
82
+ ? null
83
+ : { source, document: parseDevEnvDocument(input.raw, source) };
84
+ const composed = composeDevEnv(
85
+ layer(DEV_CONFIG_FILE, inputs.config),
86
+ {
87
+ source: DEV_SECRETS_FILE,
88
+ document: parseDevEnvDocument(inputs.secrets, DEV_SECRETS_FILE),
89
+ },
90
+ layer(DEV_LOCAL_FILE, inputs.local),
91
+ );
92
+ const problems: Array<string> = [...composed.problems];
75
93
  const writes: Array<DevEnvWrite> = [];
76
- for (const target of document.targets) {
94
+ for (const target of composed.targets) {
77
95
  const workspaceDir = `${target.group}/${target.workspace}`;
78
96
  const rel = `${workspaceDir}/.env.local`;
79
97
  if (existsSync(join(consumer, workspaceDir, 'package.json'))) {
80
- const ignoreProblem = gitIgnoreProblem(consumer, rel);
98
+ const ignoreProblem = devEnvGitIgnoreProblem(consumer, rel);
81
99
  if (ignoreProblem === null) {
82
100
  writes.push({
83
101
  rel,
84
102
  content: renderDotenv(
85
103
  `${target.group}.${target.workspace}`,
86
- DEV_SECRETS_FILE,
104
+ target.sources,
87
105
  target.env,
88
106
  ),
89
107
  });
@@ -92,36 +110,62 @@ export const planDevEnvWrites = (
92
110
  }
93
111
  } else {
94
112
  problems.push(
95
- `${DEV_SECRETS_FILE} defines ${target.group}.${target.workspace}, but ${workspaceDir}/package.json does not exist`,
113
+ `${target.sources.join(' + ')} defines ${target.group}.${target.workspace}, but ${workspaceDir}/package.json does not exist`,
96
114
  );
97
115
  }
98
116
  }
99
- return { writes, problems };
117
+ const removalPlan = planDevEnvRemovals(consumer, writes);
118
+ return {
119
+ writes,
120
+ removals: removalPlan.removals,
121
+ problems: [...problems, ...removalPlan.problems],
122
+ };
100
123
  };
101
124
 
102
125
  export const runDevEnv = async (consumer: string): Promise<boolean> => {
103
- if (!existsSync(join(consumer, DEV_SECRETS_FILE))) {
104
- console.error(
105
- `standards dev-env: ${DEV_SECRETS_FILE} not found; create it with \`just secrets edit dev\``,
106
- );
107
- return false;
108
- }
109
- const decrypted = decryptDevSecrets(consumer);
110
- if (!decrypted.ok) {
111
- console.error(`standards dev-env: ${decrypted.problem}`);
126
+ const config = readPlainLayer(consumer, DEV_CONFIG_FILE);
127
+ const secrets = readDevSecrets(consumer);
128
+ const local = readPlainLayer(consumer, DEV_LOCAL_FILE);
129
+ const localIgnoreProblem = local.present
130
+ ? devEnvGitIgnoreProblem(consumer, DEV_LOCAL_FILE)
131
+ : null;
132
+ const inputProblems = [
133
+ ...config.problems,
134
+ ...(secrets.ok ? [] : [secrets.problem]),
135
+ ...local.problems,
136
+ ...(localIgnoreProblem === null ? [] : [localIgnoreProblem]),
137
+ ...documentProblems(DEV_CONFIG_FILE, config.input),
138
+ ...(secrets.ok
139
+ ? parseDevEnvDocument(secrets.value, DEV_SECRETS_FILE).problems
140
+ : []),
141
+ ...documentProblems(DEV_LOCAL_FILE, local.input),
142
+ ];
143
+ if (inputProblems.length > 0 || !secrets.ok) {
144
+ console.error(`standards dev-env: ${inputProblems.length} problem(s):`);
145
+ console.error(inputProblems.map((problem) => ` - ${problem}`).join('\n'));
112
146
  return false;
113
147
  }
114
- const plan = planDevEnvWrites(consumer, decrypted.value);
148
+ const plan = planDevEnvChanges(consumer, {
149
+ config: config.input,
150
+ secrets: secrets.value,
151
+ local: local.input,
152
+ });
115
153
  const problems = [
116
154
  ...plan.problems,
117
- ...(await devEnvDestinationProblems(consumer, plan.writes)),
155
+ ...(await devEnvDestinationProblems(consumer, [
156
+ ...plan.writes,
157
+ ...plan.removals,
158
+ ])),
118
159
  ];
119
160
  if (problems.length > 0) {
120
161
  console.error(`standards dev-env: ${problems.length} problem(s):`);
121
162
  console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
122
163
  return false;
123
164
  }
124
- const generated = await writeDevEnvFiles(consumer, plan.writes);
165
+ const generated = await applyDevEnvChanges(consumer, [
166
+ ...plan.writes,
167
+ ...plan.removals,
168
+ ]);
125
169
  if (!generated.ok) {
126
170
  console.error('standards dev-env: generation failed:');
127
171
  console.error(
@@ -140,8 +184,16 @@ export const runDevEnv = async (consumer: string): Promise<boolean> => {
140
184
  for (const write of plan.writes) {
141
185
  console.log(` wrote ${write.rel}`);
142
186
  }
187
+ for (const removal of plan.removals) {
188
+ console.log(` removed ${removal.rel}`);
189
+ }
190
+ const inputFiles = [
191
+ ...(config.input === null ? [] : [DEV_CONFIG_FILE]),
192
+ DEV_SECRETS_FILE,
193
+ ...(local.input === null ? [] : [DEV_LOCAL_FILE]),
194
+ ];
143
195
  console.log(
144
- `standards dev-env: generated ${plan.writes.length} env file(s) from ${DEV_SECRETS_FILE}`,
196
+ `standards dev-env: generated ${plan.writes.length} and removed ${plan.removals.length} env file(s) from ${inputFiles.join(' + ')}`,
145
197
  );
146
198
  return true;
147
199
  };
@@ -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
  };
@@ -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
- ...forbiddenDiffPaths(paths, await lockedPathsOf(workspace.dir)),
155
- ...changedWorkspaceQualityManifests(
156
- workspace.dir,
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,
@@ -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 = run.succeeded ? await readFixOutcome(workspace.dir) : null;
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
- ...forbiddenDiffPaths(paths, await lockedPathsOf(workspace.dir)),
54
- ...changedWorkspaceQualityManifests(workspace.dir, output.baseSha, paths),
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')}`,
@@ -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, readFileSync } from 'node:fs';
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
- });