vouchington-tooling 0.3.4 → 0.3.6

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 CHANGED
@@ -7,7 +7,7 @@ npm install vouchington-tooling
7
7
  # optional, only if you import vouchington-tooling/sql-ast
8
8
  npm install @libpg-query/parser
9
9
  # optional, only for vouchington-tooling/agent-blackboard and agent-blackboard CLI commands
10
- npm install agent-blackboard@^0.3.1
10
+ npm install agent-blackboard@^0.5.0
11
11
  ```
12
12
 
13
13
  ## CLI
@@ -88,7 +88,7 @@ GitHub reports a merged PR whose `baseRefName` is `main`; a merged PR into anoth
88
88
  as not merged to main, and a missing base is unavailable.
89
89
 
90
90
  Agent Blackboard support is optional: only the `agent-blackboard` subpath and its CLI commands
91
- need `agent-blackboard@^0.3.1`. Snapshot cleanup accepts only package-generated temporary paths.
91
+ need `agent-blackboard@^0.5.0`. Snapshot cleanup accepts only package-generated temporary paths.
92
92
  Programmatic callers launched from a different workspace directory pass their own module URL as
93
93
  `dependencies: { resolveFrom: import.meta.url }`; the CLI defaults to the current package context.
94
94
  It captures a target under a private tombstone, validates partition names, permissions, JSONL,
@@ -154,7 +154,8 @@ import { parseCsvRows, streamCsvRows } from 'vouchington-tooling/csv'
154
154
  import { readResponseBody } from 'vouchington-tooling/http-body'
155
155
  import { runAstGrepRule } from 'vouchington-tooling/ast-grep-rule'
156
156
  import { parseReviewPayload, remapReviewComments } from 'vouchington-tooling/gha-review-payload'
157
- import { runPostReview } from 'vouchington-tooling/gha-post-review'
157
+ import { postReviewWithTokenFromEnv, runPostReview } from 'vouchington-tooling/gha-post-review'
158
+ import { postClaudeReviewFromEnv } from 'vouchington-tooling/gha-claude-post-review'
158
159
  import { nextPageUrlFromLinkHeader } from 'vouchington-tooling/http-link-pagination'
159
160
  import {
160
161
  cmdUpload,
@@ -168,6 +169,7 @@ import { runCiLocal } from 'vouchington-tooling/ci-local'
168
169
  import { rateLimitDelay } from 'vouchington-tooling/gha-rate-limit'
169
170
  import { parseCheckpoint } from 'vouchington-tooling/gha-pr-checkpoint'
170
171
  import { checkWorkspaceGatesPolicy } from 'vouchington-tooling/workspace-gates'
172
+ import { checkGhaWorkspacePolicy } from 'vouchington-tooling/gha-workspace-policy'
171
173
  import { validateNugetUpdate } from 'vouchington-tooling/nuget-central-version'
172
174
  import { normalizeSwiftSource } from 'vouchington-tooling/swift-semantic-equal'
173
175
  import { parseUniqueSwiftBinaryTargetChecksum } from 'vouchington-tooling/swift-source-offset'
@@ -211,8 +213,9 @@ touch. Recording and report log reads are synchronous: on contention they block
211
213
  event loop for up to one second before failing explicitly. Avoid these APIs on hot request paths.
212
214
 
213
215
  The artifact, review-payload, HTTP body, and pagination APIs validate untrusted inputs at their
214
- boundaries. Review posting lives in `gha-post-review` and talks to GitHub only through caller-supplied
215
- credentials (job token or a minted Claude GitHub App token).
216
+ boundaries. Neutral review posting lives in `gha-post-review` and requires a caller-supplied GitHub
217
+ token. Claude App OIDC is an explicitly selected `gha-claude-post-review` adapter; the legacy
218
+ token-source router remains deprecated for one release line.
216
219
 
217
220
  `vitest-diagnostics` reads Node diagnostic report JSON from a caller-selected directory. It sorts
218
221
  filenames, tolerates partial files, returns only a bounded field allowlist, and never emits raw
@@ -0,0 +1,3 @@
1
+ export { CLAUDE_APP_TOKEN_EXCHANGE_URL, CLAUDE_OIDC_AUDIENCE, CLAUDE_POSTER_PERMISSIONS, GITHUB_INSTALLATION_TOKEN_URL, createActionsClaudeTokenIo, mintClaudeAppToken, oidcTokenRequest, revokeClaudeAppToken, withClaudeAppToken, } from '../gha-post-review/claude-token.mts';
2
+ export type { ClaudeTokenIo, FetchLike } from '../gha-post-review/claude-token.mts';
3
+ export { postClaudeReviewFromEnv } from '../gha-post-review/github.mts';
@@ -0,0 +1,2 @@
1
+ export { CLAUDE_APP_TOKEN_EXCHANGE_URL, CLAUDE_OIDC_AUDIENCE, CLAUDE_POSTER_PERMISSIONS, GITHUB_INSTALLATION_TOKEN_URL, createActionsClaudeTokenIo, mintClaudeAppToken, oidcTokenRequest, revokeClaudeAppToken, withClaudeAppToken, } from '../gha-post-review/claude-token.mjs';
2
+ export { postClaudeReviewFromEnv } from '../gha-post-review/github.mjs';
@@ -17,6 +17,13 @@ export declare function createGhPostReviewIo(options: {
17
17
  expectedHeadSha?: string;
18
18
  expectedBaseSha?: string;
19
19
  }): PostReviewIo;
20
+ /** @deprecated Use postReviewWithTokenFromEnv or postClaudeReviewFromEnv explicitly. */
20
21
  export declare function postReviewFromEnv(env?: NodeJS.ProcessEnv, exec?: GhExec, claudeIo?: import("./claude-token.mts").ClaudeTokenIo): Promise<{
21
22
  posted: boolean;
22
23
  }>;
24
+ export declare function postReviewWithTokenFromEnv(env?: NodeJS.ProcessEnv, exec?: GhExec, token?: string | undefined): {
25
+ posted: boolean;
26
+ };
27
+ export declare function postClaudeReviewFromEnv(env?: NodeJS.ProcessEnv, exec?: GhExec, claudeIo?: import("./claude-token.mts").ClaudeTokenIo): Promise<{
28
+ posted: boolean;
29
+ }>;
@@ -103,12 +103,19 @@ export function createGhPostReviewIo(options) {
103
103
  },
104
104
  };
105
105
  }
106
+ /** @deprecated Use postReviewWithTokenFromEnv or postClaudeReviewFromEnv explicitly. */
106
107
  export async function postReviewFromEnv(env = process.env, exec = createGhExec(), claudeIo = createActionsClaudeTokenIo(env)) {
108
+ const token = resolveReviewPostToken(env);
109
+ if (token.source === 'github-token')
110
+ return postReviewWithTokenFromEnv(env, exec, token.token);
111
+ return await postClaudeReviewFromEnv(env, exec, claudeIo);
112
+ }
113
+ function createPostWithToken(env, exec) {
107
114
  const repository = requireEnv('GITHUB_REPOSITORY', env);
108
115
  const prNumber = requireEnv('PR_NUMBER', env);
109
116
  const payloadPath = requireEnv('CODE_REVIEW_PAYLOAD_PATH', env);
110
117
  const payloadBytes = readRegularReviewPayload(payloadPath, 'required');
111
- const postWithToken = (token) => runPostReview(payloadPath, createGhPostReviewIo({
118
+ return (token) => runPostReview(payloadPath, createGhPostReviewIo({
112
119
  repository,
113
120
  prNumber,
114
121
  payloadPath,
@@ -118,8 +125,16 @@ export async function postReviewFromEnv(env = process.env, exec = createGhExec()
118
125
  expectedHeadSha: env.EXPECTED_HEAD_SHA ?? '',
119
126
  expectedBaseSha: env.EXPECTED_BASE_SHA ?? '',
120
127
  }));
121
- const token = resolveReviewPostToken(env);
122
- if (token.source === 'github-token')
123
- return postWithToken(token.token);
124
- return await withClaudeAppToken(claudeIo, postWithToken);
128
+ }
129
+ function withTokenEnv(exec, env, token) {
130
+ const tokenEnv = { ...env, GH_TOKEN: token, GITHUB_TOKEN: token };
131
+ return (args, options) => exec(args, { ...options, env: { ...options?.env, ...tokenEnv } });
132
+ }
133
+ export function postReviewWithTokenFromEnv(env = process.env, exec = createGhExec(), token = env.GH_TOKEN || env.GITHUB_TOKEN) {
134
+ if (!token)
135
+ throw new ReviewPayloadError('GH_TOKEN or GITHUB_TOKEN is required.');
136
+ return createPostWithToken(env, withTokenEnv(exec, env, token))(token);
137
+ }
138
+ export async function postClaudeReviewFromEnv(env = process.env, exec = createGhExec(), claudeIo = createActionsClaudeTokenIo(env)) {
139
+ return await withClaudeAppToken(claudeIo, (token) => createPostWithToken(env, withTokenEnv(exec, env, token))(token));
125
140
  }
@@ -2,8 +2,12 @@ export { MAX_COMMENTS, MAX_PAYLOAD_BYTES, PostReviewError, runPostReview } from
2
2
  export type { PostResult, PostReviewIo, PullFile, ReviewComment, SanitizedReview } from './post.mts';
3
3
  export { requireEnv, resolveReviewPostToken } from './token.mts';
4
4
  export type { ReviewPostToken } from './token.mts';
5
+ /** @deprecated Import Claude helpers from vouchington-tooling/gha-claude-post-review. */
5
6
  export { CLAUDE_APP_TOKEN_EXCHANGE_URL, CLAUDE_OIDC_AUDIENCE, CLAUDE_POSTER_PERMISSIONS, GITHUB_INSTALLATION_TOKEN_URL, createActionsClaudeTokenIo, mintClaudeAppToken, oidcTokenRequest, revokeClaudeAppToken, withClaudeAppToken, } from './claude-token.mts';
7
+ /** @deprecated Import Claude helpers from vouchington-tooling/gha-claude-post-review. */
6
8
  export type { ClaudeTokenIo, FetchLike } from './claude-token.mts';
7
- export { createGhExec, createGhPostReviewIo, postReviewFromEnv, postWithGh, writePostedOutput, } from './github.mts';
9
+ export { createGhExec, createGhPostReviewIo, postReviewWithTokenFromEnv, postWithGh, writePostedOutput, } from './github.mts';
10
+ /** @deprecated Select an explicit posting adapter. */
11
+ export { postClaudeReviewFromEnv, postReviewFromEnv } from './github.mts';
8
12
  export type { GhExec } from './github.mts';
9
13
  export { runPostReviewCli } from './cli.mts';
@@ -1,5 +1,8 @@
1
1
  export { MAX_COMMENTS, MAX_PAYLOAD_BYTES, PostReviewError, runPostReview } from './post.mjs';
2
2
  export { requireEnv, resolveReviewPostToken } from './token.mjs';
3
+ /** @deprecated Import Claude helpers from vouchington-tooling/gha-claude-post-review. */
3
4
  export { CLAUDE_APP_TOKEN_EXCHANGE_URL, CLAUDE_OIDC_AUDIENCE, CLAUDE_POSTER_PERMISSIONS, GITHUB_INSTALLATION_TOKEN_URL, createActionsClaudeTokenIo, mintClaudeAppToken, oidcTokenRequest, revokeClaudeAppToken, withClaudeAppToken, } from './claude-token.mjs';
4
- export { createGhExec, createGhPostReviewIo, postReviewFromEnv, postWithGh, writePostedOutput, } from './github.mjs';
5
+ export { createGhExec, createGhPostReviewIo, postReviewWithTokenFromEnv, postWithGh, writePostedOutput, } from './github.mjs';
6
+ /** @deprecated Select an explicit posting adapter. */
7
+ export { postClaudeReviewFromEnv, postReviewFromEnv } from './github.mjs';
5
8
  export { runPostReviewCli } from './cli.mjs';
@@ -4,5 +4,6 @@ export type ReviewPostToken = {
4
4
  } | {
5
5
  source: 'claude-app';
6
6
  };
7
+ /** @deprecated Select an explicit posting adapter instead of the legacy token-source router. */
7
8
  export declare function resolveReviewPostToken(env?: NodeJS.ProcessEnv): ReviewPostToken;
8
9
  export declare function requireEnv(name: string, env?: NodeJS.ProcessEnv): string;
@@ -1,4 +1,5 @@
1
1
  import { ReviewPayloadError } from '../gha-review-payload/index.mjs';
2
+ /** @deprecated Select an explicit posting adapter instead of the legacy token-source router. */
2
3
  export function resolveReviewPostToken(env = process.env) {
3
4
  const source = env.CODE_REVIEW_TOKEN_SOURCE || 'claude-app';
4
5
  if (source === 'github-token') {
@@ -0,0 +1,2 @@
1
+ import { type GhaFileKind } from './shared.mts';
2
+ export declare function checkDockerWorkspaceUserDocument(file: string, document: unknown, kind: GhaFileKind, errors: string[]): void;
@@ -0,0 +1,208 @@
1
+ import { visitRunSteps } from './shared.mjs';
2
+ const WORKSPACE_RE = /(?:\$\{?GITHUB_WORKSPACE\}?|\$\{\{\s*github\.workspace\s*\}\})/iu;
3
+ const DOCKER_GLOBAL_FLAG_OPTIONS = new Set(['--debug', '-D', '--help', '--tls', '--tlsverify']);
4
+ const DOCKER_GLOBAL_VALUE_OPTIONS = new Set([
5
+ '--config',
6
+ '--context',
7
+ '-c',
8
+ '--host',
9
+ '-H',
10
+ '--log-level',
11
+ '-l',
12
+ '--tlscacert',
13
+ '--tlscert',
14
+ '--tlskey',
15
+ ]);
16
+ const FLAG_OPTIONS = new Set([
17
+ '--detach',
18
+ '-d',
19
+ '--init',
20
+ '--interactive',
21
+ '-i',
22
+ '--privileged',
23
+ '--publish-all',
24
+ '-P',
25
+ '--read-only',
26
+ '--rm',
27
+ '--tty',
28
+ '-t',
29
+ ]);
30
+ export function checkDockerWorkspaceUserDocument(file, document, kind, errors) {
31
+ visitRunSteps(document, kind === 'action', (scope, index, step) => {
32
+ if (typeof step.run !== 'string')
33
+ return;
34
+ for (const block of dockerRunBlocks(step.run)) {
35
+ const options = dockerOptions(block);
36
+ if (!options)
37
+ continue;
38
+ const writable = options.volumes.filter((volume) => WORKSPACE_RE.test(volume) && !isReadOnlyVolume(volume));
39
+ if (writable.length === 0 || hasHostUserMapping(options.user))
40
+ continue;
41
+ errors.push(`::error file=${file}::${file}: ${scope} step ${index} runs Docker with a writable ` +
42
+ `GITHUB_WORKSPACE mount (${writable.join(', ')}) but no --user host UID:GID mapping. ` +
43
+ 'Use --user "$(id -u):$(id -g)" or make the workspace mount read-only.');
44
+ }
45
+ });
46
+ }
47
+ function shellTokens(input) {
48
+ const tokens = [];
49
+ let token = '';
50
+ let quote = '';
51
+ let substitutionDepth = 0;
52
+ let githubExpression = false;
53
+ for (let index = 0; index < input.length; index += 1) {
54
+ const character = input[index];
55
+ if (githubExpression) {
56
+ token += character;
57
+ if (character === '}' && input[index + 1] === '}') {
58
+ token += '}';
59
+ index += 1;
60
+ githubExpression = false;
61
+ }
62
+ continue;
63
+ }
64
+ if (substitutionDepth > 0) {
65
+ token += character;
66
+ if (character === '(')
67
+ substitutionDepth += 1;
68
+ if (character === ')')
69
+ substitutionDepth -= 1;
70
+ continue;
71
+ }
72
+ if (character === '$' && input[index + 1] === '{' && input[index + 2] === '{') {
73
+ token += '${{';
74
+ index += 2;
75
+ githubExpression = true;
76
+ continue;
77
+ }
78
+ if (character === '$' && input[index + 1] === '(') {
79
+ token += '$(';
80
+ substitutionDepth = 1;
81
+ index += 1;
82
+ continue;
83
+ }
84
+ if (quote) {
85
+ if (character === '\\' && input[index + 1] !== undefined)
86
+ token += input[++index];
87
+ else if (character === quote)
88
+ quote = '';
89
+ else
90
+ token += character;
91
+ continue;
92
+ }
93
+ if (character === '"' || character === "'") {
94
+ quote = character;
95
+ continue;
96
+ }
97
+ if (/\s/u.test(character)) {
98
+ if (token)
99
+ tokens.push(token);
100
+ token = '';
101
+ continue;
102
+ }
103
+ if (character === '#' && !token)
104
+ break;
105
+ if (character === ';' || character === '|' || character === '&') {
106
+ if (token)
107
+ tokens.push(token);
108
+ token = '';
109
+ if ((character === '&' || character === '|') && input[index + 1] === character)
110
+ index += 1;
111
+ tokens.push(character);
112
+ continue;
113
+ }
114
+ if (character === '\\' && input[index + 1] !== undefined)
115
+ token += input[++index];
116
+ else
117
+ token += character;
118
+ }
119
+ if (token)
120
+ tokens.push(token);
121
+ return tokens;
122
+ }
123
+ function dockerRunBlocks(run) {
124
+ const blocks = [];
125
+ const lines = run.split('\n');
126
+ for (let index = 0; index < lines.length; index += 1) {
127
+ if (!/\bdocker\b/u.test(lines[index]))
128
+ continue;
129
+ let block = lines[index];
130
+ while (block.trimEnd().endsWith('\\') && index + 1 < lines.length) {
131
+ index += 1;
132
+ block = `${block.trimEnd().slice(0, -1)} ${lines[index]}`;
133
+ }
134
+ blocks.push(block);
135
+ }
136
+ return blocks;
137
+ }
138
+ function findDockerCommand(tokens) {
139
+ let commandStart = true;
140
+ for (const [index, token] of tokens.entries()) {
141
+ if (token === ';' || token === '|' || token === '&') {
142
+ commandStart = true;
143
+ continue;
144
+ }
145
+ if (!commandStart)
146
+ continue;
147
+ if (/^[A-Za-z_][A-Za-z0-9_]*=.*/u.test(token))
148
+ continue;
149
+ if (token === 'docker')
150
+ return index;
151
+ commandStart = false;
152
+ }
153
+ return -1;
154
+ }
155
+ function dockerOptions(block) {
156
+ const tokens = shellTokens(block);
157
+ const dockerIndex = findDockerCommand(tokens);
158
+ if (dockerIndex < 0)
159
+ return undefined;
160
+ let start = dockerIndex + 1;
161
+ while (start < tokens.length && tokens[start].startsWith('-')) {
162
+ const option = tokens[start];
163
+ const name = option.includes('=') ? option.slice(0, option.indexOf('=')) : option;
164
+ if (DOCKER_GLOBAL_FLAG_OPTIONS.has(name) || option.includes('='))
165
+ start += 1;
166
+ else if (DOCKER_GLOBAL_VALUE_OPTIONS.has(name))
167
+ start += 2;
168
+ else
169
+ return undefined;
170
+ }
171
+ if (tokens[start] === 'container')
172
+ start += 1;
173
+ if (tokens[start] !== 'run')
174
+ return undefined;
175
+ start += 1;
176
+ const volumes = [];
177
+ let user;
178
+ for (let index = start; index < tokens.length; index += 1) {
179
+ const option = tokens[index];
180
+ if (!option.startsWith('-'))
181
+ break;
182
+ const equals = option.indexOf('=');
183
+ const compact = option.startsWith('-v') || option.startsWith('-u');
184
+ const name = equals > 0
185
+ ? option.slice(0, equals)
186
+ : compact && option.length > 2
187
+ ? option.slice(0, 2)
188
+ : option;
189
+ const inlineValue = equals > 0
190
+ ? option.slice(equals + 1)
191
+ : compact && option.length > 2
192
+ ? option.slice(2)
193
+ : undefined;
194
+ const bundledFlags = /^-[diPt]{2,}$/u.test(option);
195
+ const value = inlineValue ?? (FLAG_OPTIONS.has(name) || bundledFlags ? undefined : tokens[++index]);
196
+ if ((name === '-v' || name === '--volume' || name === '--mount') && value !== undefined)
197
+ volumes.push(value);
198
+ if ((name === '-u' || name === '--user') && value !== undefined)
199
+ user = value;
200
+ }
201
+ return { user, volumes };
202
+ }
203
+ function hasHostUserMapping(value) {
204
+ return value !== undefined && /^\$\(\s*id\s+-u\s*\):\$\(\s*id\s+-g\s*\)$/u.test(value);
205
+ }
206
+ function isReadOnlyVolume(value) {
207
+ return /(?:^|:)ro(?:$|,)/u.test(value) || /(?:^|,)readonly(?:$|,)/u.test(value);
208
+ }
@@ -0,0 +1,8 @@
1
+ import type { SharedContext } from '../shared-context/index.mts';
2
+ export interface GhaWorkspacePolicyOptions {
3
+ workflowDirectories?: readonly string[];
4
+ actionDirectories?: readonly string[];
5
+ }
6
+ export declare function checkGhaWorkspacePolicy(ctx: SharedContext, options?: GhaWorkspacePolicyOptions): Promise<{
7
+ errors: string[];
8
+ }>;
@@ -0,0 +1,17 @@
1
+ import { checkDockerWorkspaceUserDocument } from './docker-workspace-user.mjs';
2
+ import { ghaFileKind, loadWorkflowDocument } from './shared.mjs';
3
+ import { checkNoSparseCheckoutDocument } from './sparse-checkout.mjs';
4
+ export async function checkGhaWorkspacePolicy(ctx, options = {}) {
5
+ const errors = [];
6
+ if (!ctx.isInsideGitRepo)
7
+ return { errors };
8
+ for (const file of ctx.trackedFiles) {
9
+ const kind = ghaFileKind(file, options);
10
+ if (!kind)
11
+ continue;
12
+ const document = loadWorkflowDocument(ctx, file, errors);
13
+ checkNoSparseCheckoutDocument(file, document, kind, errors);
14
+ checkDockerWorkspaceUserDocument(file, document, kind, errors);
15
+ }
16
+ return { errors };
17
+ }
@@ -0,0 +1,6 @@
1
+ import type { SharedContext } from '../shared-context/index.mts';
2
+ import type { GhaWorkspacePolicyOptions } from './index.mts';
3
+ export type GhaFileKind = 'workflow' | 'action';
4
+ export declare function ghaFileKind(file: string, options: GhaWorkspacePolicyOptions): GhaFileKind | undefined;
5
+ export declare function loadWorkflowDocument(ctx: SharedContext, file: string, errors: string[]): unknown;
6
+ export declare function visitRunSteps(document: unknown, isAction: boolean, visitor: (scope: string, index: number, step: Record<string, unknown>) => void): void;
@@ -0,0 +1,54 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parse as load } from 'yaml';
4
+ export function ghaFileKind(file, options) {
5
+ const workflowDirectories = options.workflowDirectories ?? ['.github/workflows'];
6
+ const actionDirectories = options.actionDirectories ?? ['.github/actions'];
7
+ if (workflowDirectories.some((directory) => file.startsWith(`${directory.replace(/\/$/u, '')}/`)) &&
8
+ /\.ya?ml$/u.test(file))
9
+ return 'workflow';
10
+ if (actionDirectories.some((directory) => file.startsWith(`${directory.replace(/\/$/u, '')}/`)) &&
11
+ /\/action\.ya?ml$/u.test(file))
12
+ return 'action';
13
+ return undefined;
14
+ }
15
+ export function loadWorkflowDocument(ctx, file, errors) {
16
+ try {
17
+ const content = ctx.readTrackedFile?.(file) ?? readFileSync(join(ctx.repoRoot, file), 'utf8');
18
+ return load(content);
19
+ }
20
+ catch (error) {
21
+ errors.push(`::error file=${file}::${file}: invalid YAML (${error.message})`);
22
+ return undefined;
23
+ }
24
+ }
25
+ export function visitRunSteps(document, isAction, visitor) {
26
+ if (!document || typeof document !== 'object')
27
+ return;
28
+ if (isAction) {
29
+ const runs = document.runs;
30
+ if (!runs || typeof runs !== 'object')
31
+ return;
32
+ visitSteps('action', runs.steps, visitor);
33
+ return;
34
+ }
35
+ const jobs = document.jobs;
36
+ if (!jobs || typeof jobs !== 'object')
37
+ return;
38
+ for (const [jobId, value] of Object.entries(jobs)) {
39
+ if (!value || typeof value !== 'object')
40
+ continue;
41
+ const job = value;
42
+ if (typeof job.uses === 'string')
43
+ continue;
44
+ visitSteps(`job "${jobId}"`, job.steps, visitor);
45
+ }
46
+ }
47
+ function visitSteps(scope, value, visitor) {
48
+ if (!Array.isArray(value))
49
+ return;
50
+ value.forEach((step, index) => {
51
+ if (step && typeof step === 'object')
52
+ visitor(scope, index, step);
53
+ });
54
+ }
@@ -0,0 +1,2 @@
1
+ import { type GhaFileKind } from './shared.mts';
2
+ export declare function checkNoSparseCheckoutDocument(file: string, document: unknown, kind: GhaFileKind, errors: string[]): void;
@@ -0,0 +1,28 @@
1
+ import { visitRunSteps } from './shared.mjs';
2
+ const SPARSE_CHECKOUT_KEYS = ['sparse-checkout', 'sparse-checkout-cone-mode'];
3
+ const SPARSE_CHECKOUT_COMMANDS = [
4
+ /\bgit\b[^\n;&|]*\bsparse-checkout\s+(?:init|set|add|reapply)\b/iu,
5
+ /\bgit\b[^\n;&|]*\bconfig\b[^\n;&|]*\bcore\.sparseCheckout(?:Cone)?(?:\s*=\s*|\s+)(?:"|')?(?:true|yes|on|1)(?:"|')?(?=\s|;|$)/iu,
6
+ ];
7
+ export function checkNoSparseCheckoutDocument(file, document, kind, errors) {
8
+ visitRunSteps(document, kind === 'action', (scope, index, step) => {
9
+ const run = step.run;
10
+ const normalizedRun = typeof run === 'string' ? run.replace(/\\\r?\n\s*/gu, ' ') : '';
11
+ if (SPARSE_CHECKOUT_COMMANDS.some((pattern) => pattern.test(normalizedRun))) {
12
+ errors.push(`::error file=${file}::${file}: ${scope} step ${index} enables sparse checkout. ` +
13
+ 'Use a full checkout; commands that disable or unset sparse-checkout state remain allowed.');
14
+ }
15
+ if (typeof step.uses !== 'string' || !step.uses.startsWith('actions/checkout@'))
16
+ return;
17
+ const withValue = step.with;
18
+ if (!withValue || typeof withValue !== 'object')
19
+ return;
20
+ for (const key of SPARSE_CHECKOUT_KEYS) {
21
+ if (!(key in withValue))
22
+ continue;
23
+ errors.push(`::error file=${file}::${file}: ${scope} step ${index} passes "${key}" to ` +
24
+ 'actions/checkout. Persistent runners reuse workspace directories, so leaked ' +
25
+ 'sparse-checkout state can silently narrow a later checkout. Check out the full tree.');
26
+ }
27
+ });
28
+ }
package/dist/index.d.mts CHANGED
@@ -15,6 +15,8 @@ export type { ForeignKey, SqlCreateIndexMetadata, SqlCreateTableColumn, SqlCreat
15
15
  export { dollarQuoteEnd, lineOf, maskSqlQuotedText, readDollarQuoteDelimiter, readStringLiteral, splitSqlStatements, sqlFragments, stripSqlComments, } from './sql-scanner/index.mts';
16
16
  export { auditCiJobRuntime, parseWorkflowNameMatch } from './gha-runtime-audit/index.mts';
17
17
  export type { GhApiExecutor, RuntimeAuditOptions, RuntimeAuditResult, RuntimeAuditWorkflowFilter, RuntimeJobResult, RuntimeSample, } from './gha-runtime-audit/index.mts';
18
+ export { checkGhaWorkspacePolicy } from './gha-workspace-policy/index.mts';
19
+ export type { GhaWorkspacePolicyOptions } from './gha-workspace-policy/index.mts';
18
20
  export { createVitestBlobManifest, createVitestReportAttempt, inspectVitestBlobBundle, parseVitestBlobManifest, parseVitestReportAttempt, readVitestReportAttempts, serializeVitestBlobManifest, serializeVitestReportAttempt, VITEST_BLOB_MANIFEST_FILENAME, VITEST_BLOB_MANIFEST_VERSION, VITEST_REPORT_ATTEMPT_PREFIX, VITEST_REPORT_ATTEMPT_VERSION, vitestBlobBundlePaths, writeVitestBlobManifest, writeVitestReportAttempt, } from './vitest-blob-manifest/index.mts';
19
21
  export type { InspectedVitestBlobBundle, VitestBlobIdentity, VitestBlobManifest, VitestReportAttempt, VitestReportAttemptIdentity, } from './vitest-blob-manifest/index.mts';
20
22
  export { prepareVitestReports } from './vitest-blob-manifest/reports.mts';
@@ -45,8 +47,12 @@ export { parseAstGrepRuleArgs, runAstGrepRule } from './ast-grep-rule/index.mts'
45
47
  export type { AstGrepRuleInvocation, RunAstGrepRuleOptions } from './ast-grep-rule/index.mts';
46
48
  export { indexReviewFiles, MAX_REVIEW_COMMENTS, MAX_REVIEW_PAYLOAD_BYTES, nearestReviewLine, parsePatchCommentable, parseReviewFilesJson, parseReviewPayload, readRegularReviewPayload, remapReviewComments, ReviewPayloadError, reviewCommentSubject, rewriteSnappedSuggestion, snapReviewNote, stageReviewPayload, writeStagedOutput, } from './gha-review-payload/index.mts';
47
49
  export type { CommentableIndex, CommentableLine, LineKind, PayloadRequirement, ReviewComment, ReviewFile, ReviewSide, SanitizedReview, } from './gha-review-payload/index.mts';
48
- export { CLAUDE_OIDC_AUDIENCE, createActionsClaudeTokenIo, mintClaudeAppToken, PostReviewError, requireEnv, resolveReviewPostToken, revokeClaudeAppToken, runPostReview, runPostReviewCli, withClaudeAppToken, } from './gha-post-review/index.mts';
49
- export type { ClaudeTokenIo, PostResult, PostReviewIo, PullFile, ReviewPostToken, } from './gha-post-review/index.mts';
50
+ export { PostReviewError, requireEnv, runPostReview, runPostReviewCli, postReviewWithTokenFromEnv, } from './gha-post-review/index.mts';
51
+ export type { PostResult, PostReviewIo, PullFile } from './gha-post-review/index.mts';
52
+ /** @deprecated Import Claude helpers from vouchington-tooling/gha-claude-post-review. */
53
+ export { CLAUDE_OIDC_AUDIENCE, createActionsClaudeTokenIo, mintClaudeAppToken, resolveReviewPostToken, revokeClaudeAppToken, withClaudeAppToken, } from './gha-post-review/index.mts';
54
+ /** @deprecated Import Claude helpers from vouchington-tooling/gha-claude-post-review. */
55
+ export type { ClaudeTokenIo, ReviewPostToken } from './gha-post-review/index.mts';
50
56
  export { nextPageCursorFromLinkHeader, nextPageUrlFromLinkHeader, validatePaginationRequestUrl, } from './http-link-pagination/index.mts';
51
57
  export { cmdDownloadCoverage, cmdDownloadVitestBlobs, cmdUpload, mintPresignedControl, transportObjectKeys, } from './coverage-transport/index.mts';
52
58
  export type { ExpectedTransportIdentity, ObjectSigner, PresignIdentity, TransportControl, } from './coverage-transport/index.mts';
package/dist/index.mjs CHANGED
@@ -8,6 +8,7 @@ export { EphemeralListenerAttemptsExhaustedError, isRunnerReservedPort, listenOn
8
8
  export { extractAlterTableAddColumnLocations, extractCreateIndexMetadata, extractCreateTableMetadata, extractDefaultFunction, extractDropIndexMetadata, extractFuncCallArgColumnNames, extractMigrationConstraintMetadata, initSqlAst, lineOfUtf8ByteOffset, MissingSqlAstParserError, parseSql, } from './sql-ast/index.mjs';
9
9
  export { dollarQuoteEnd, lineOf, maskSqlQuotedText, readDollarQuoteDelimiter, readStringLiteral, splitSqlStatements, sqlFragments, stripSqlComments, } from './sql-scanner/index.mjs';
10
10
  export { auditCiJobRuntime, parseWorkflowNameMatch } from './gha-runtime-audit/index.mjs';
11
+ export { checkGhaWorkspacePolicy } from './gha-workspace-policy/index.mjs';
11
12
  export { createVitestBlobManifest, createVitestReportAttempt, inspectVitestBlobBundle, parseVitestBlobManifest, parseVitestReportAttempt, readVitestReportAttempts, serializeVitestBlobManifest, serializeVitestReportAttempt, VITEST_BLOB_MANIFEST_FILENAME, VITEST_BLOB_MANIFEST_VERSION, VITEST_REPORT_ATTEMPT_PREFIX, VITEST_REPORT_ATTEMPT_VERSION, vitestBlobBundlePaths, writeVitestBlobManifest, writeVitestReportAttempt, } from './vitest-blob-manifest/index.mjs';
12
13
  export { prepareVitestReports } from './vitest-blob-manifest/reports.mjs';
13
14
  export { findWorkspaceLinkMismatches, formatReleaseAgeFailure, INSTALL_TERMINATION_FAILED, isReleaseAgeViolation, parseInstallOptions, parseReleaseAgeViolations, runInstallLifecycle, } from './pnpm-install/index.mjs';
@@ -25,7 +26,9 @@ export { escapeSpreadsheetFormula, parseCsvRows, streamCsvRows, stripCsvBom } fr
25
26
  export { MissingResponseBodyError, readResponseBody, readResponseBodyAsBuffer, ResponseBodyTooLargeError, } from './http-body/index.mjs';
26
27
  export { parseAstGrepRuleArgs, runAstGrepRule } from './ast-grep-rule/index.mjs';
27
28
  export { indexReviewFiles, MAX_REVIEW_COMMENTS, MAX_REVIEW_PAYLOAD_BYTES, nearestReviewLine, parsePatchCommentable, parseReviewFilesJson, parseReviewPayload, readRegularReviewPayload, remapReviewComments, ReviewPayloadError, reviewCommentSubject, rewriteSnappedSuggestion, snapReviewNote, stageReviewPayload, writeStagedOutput, } from './gha-review-payload/index.mjs';
28
- export { CLAUDE_OIDC_AUDIENCE, createActionsClaudeTokenIo, mintClaudeAppToken, PostReviewError, requireEnv, resolveReviewPostToken, revokeClaudeAppToken, runPostReview, runPostReviewCli, withClaudeAppToken, } from './gha-post-review/index.mjs';
29
+ export { PostReviewError, requireEnv, runPostReview, runPostReviewCli, postReviewWithTokenFromEnv, } from './gha-post-review/index.mjs';
30
+ /** @deprecated Import Claude helpers from vouchington-tooling/gha-claude-post-review. */
31
+ export { CLAUDE_OIDC_AUDIENCE, createActionsClaudeTokenIo, mintClaudeAppToken, resolveReviewPostToken, revokeClaudeAppToken, withClaudeAppToken, } from './gha-post-review/index.mjs';
29
32
  export { nextPageCursorFromLinkHeader, nextPageUrlFromLinkHeader, validatePaginationRequestUrl, } from './http-link-pagination/index.mjs';
30
33
  export { cmdDownloadCoverage, cmdDownloadVitestBlobs, cmdUpload, mintPresignedControl, transportObjectKeys, } from './coverage-transport/index.mjs';
31
34
  export { EPOCH_PRUNED_AT, normalizeDeployedLayer, pruneDeployedRuntimeDeps, restoreDeployedWorkspacePackages, } from './pnpm-deploy/index.mjs';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vouchington-tooling",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Vouchington CLI and extractable tooling libraries.",
5
5
  "homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
6
6
  "bugs": {
@@ -76,6 +76,11 @@
76
76
  "import": "./dist/gha-runtime-audit/index.mjs",
77
77
  "default": "./dist/gha-runtime-audit/index.mjs"
78
78
  },
79
+ "./gha-workspace-policy": {
80
+ "types": "./dist/gha-workspace-policy/index.d.mts",
81
+ "import": "./dist/gha-workspace-policy/index.mjs",
82
+ "default": "./dist/gha-workspace-policy/index.mjs"
83
+ },
79
84
  "./vitest-blob-manifest": {
80
85
  "types": "./dist/vitest-blob-manifest/index.d.mts",
81
86
  "import": "./dist/vitest-blob-manifest/index.mjs",
@@ -161,6 +166,11 @@
161
166
  "import": "./dist/gha-post-review/index.mjs",
162
167
  "default": "./dist/gha-post-review/index.mjs"
163
168
  },
169
+ "./gha-claude-post-review": {
170
+ "types": "./dist/gha-claude-post-review/index.d.mts",
171
+ "import": "./dist/gha-claude-post-review/index.mjs",
172
+ "default": "./dist/gha-claude-post-review/index.mjs"
173
+ },
164
174
  "./http-link-pagination": {
165
175
  "types": "./dist/http-link-pagination/index.d.mts",
166
176
  "import": "./dist/http-link-pagination/index.mjs",
@@ -250,7 +260,7 @@
250
260
  },
251
261
  "devDependencies": {
252
262
  "@types/picomatch": "^4.0.3",
253
- "agent-blackboard": "^0.3.1"
263
+ "agent-blackboard": "^0.5.0"
254
264
  },
255
265
  "optionalDependencies": {
256
266
  "@libpg-query/parser": "^18.0.0"
@@ -65,11 +65,53 @@ workspace_sentinel_key() {
65
65
  shasum -a 256 | awk '{print $1}'
66
66
  }
67
67
 
68
- if ! git reset --hard HEAD; then
68
+ clear_sparse_checkout_state() {
69
+ "${full_checkout_git[@]}" sparse-checkout disable >/dev/null 2>&1 || true
70
+ local key
71
+ for key in core.sparseCheckout core.sparseCheckoutCone; do
72
+ git config --worktree --unset-all "${key}" || true
73
+ git config --local --unset-all "${key}" || true
74
+ if git config --worktree --get "${key}" >/dev/null 2>&1 ||
75
+ git config --local --get "${key}" >/dev/null 2>&1; then
76
+ echo "::error::Sparse checkout config ${key} remains enabled after cleanup."
77
+ return 1
78
+ fi
79
+ done
80
+ }
81
+
82
+ index_requires_recovery() {
83
+ "${full_checkout_git[@]}" update-index --refresh >/dev/null 2>&1 || return 0
84
+ if "${full_checkout_git[@]}" ls-files -v | grep -q '^S '; then
85
+ return 0
86
+ fi
87
+ "${full_checkout_git[@]}" diff-index --quiet --ignore-submodules=all HEAD -- || return 0
88
+ return 1
89
+ }
90
+
91
+ recover_full_index() {
92
+ local started_at=${SECONDS}
93
+ local tracked_paths
94
+ tracked_paths="$("${full_checkout_git[@]}" ls-tree -r --name-only HEAD | wc -l | tr -d ' ')"
95
+ rm -f -- "$(git rev-parse --git-path index)"
96
+ "${full_checkout_git[@]}" read-tree --empty
97
+ "${full_checkout_git[@]}" reset --hard HEAD
98
+ echo "::warning::Full workspace index recovery restored ${tracked_paths} tracked paths in $((SECONDS - started_at))s."
99
+ }
100
+
101
+ full_checkout_git=(git -c core.sparseCheckout=false -c core.sparseCheckoutCone=false)
102
+ clear_sparse_checkout_state
103
+ reset_failed=false
104
+ if ! "${full_checkout_git[@]}" reset --hard HEAD; then
69
105
  echo "::warning::git reset --hard failed; reclaiming workspace write bits and retrying once."
70
106
  restore_workspace_write_bits ||
71
107
  echo "::warning::Could not restore workspace write bits completely; retrying git reset with the repaired subset."
72
- git reset --hard HEAD
108
+ if ! "${full_checkout_git[@]}" reset --hard HEAD; then
109
+ reset_failed=true
110
+ fi
111
+ fi
112
+ if [ "${reset_failed}" = "true" ] || index_requires_recovery; then
113
+ echo "::warning::Git index corruption remained after bounded cleanup; starting full recovery."
114
+ recover_full_index
73
115
  fi
74
116
  excludes=()
75
117
  # Trust-gate: a fork pull request is the only untrusted case.
@@ -153,6 +195,10 @@ if ! git clean -ffdx "${excludes[@]+"${excludes[@]}"}"; then
153
195
  echo "::warning::Could not restore workspace write bits completely; retrying git clean with the repaired subset."
154
196
  git clean -ffdx "${excludes[@]+"${excludes[@]}"}"
155
197
  fi
198
+ if index_requires_recovery; then
199
+ echo "::warning::Git index corruption detected after clean; starting full recovery."
200
+ recover_full_index
201
+ fi
156
202
  # Runs that preserve node_modules retain them for install speed.
157
203
  # Drop TypeScript incremental build info inside those preserved trees
158
204
  # so stale virtual-store paths do not survive dependency graph changes.
@@ -0,0 +1,111 @@
1
+ ---
2
+ name: dependabot
3
+ description: Configure or audit Dependabot coverage, cooldowns, package-family grouping, trusted CI, and conservative auto-merge policy for a repository.
4
+ ---
5
+
6
+ # Dependabot policy
7
+
8
+ Read every applicable `AGENTS.md` and `CLAUDE.md`, then the repository's dependency, CI, release,
9
+ and branch-protection documentation. The consumer wrapper owns the accepted cooldown, package roots,
10
+ first-party publishers, manual-update exceptions, runner policy, required checks, and secret source.
11
+
12
+ ## Cover the dependency graph
13
+
14
+ 1. Inventory tracked manifests, lockfiles, Dockerfiles, workflow actions, infrastructure roots, and
15
+ any companion dependency bot. Add one Dependabot update entry for every supported ecosystem and
16
+ manifest root, without creating competing ownership for a dependency already assigned elsewhere.
17
+ 2. Schedule version checks daily. Set `cooldown.default-days` to the consumer's release delay; use
18
+ seven days unless local policy explicitly chooses another value. Cooldown applies to version
19
+ updates, not security updates.
20
+ 3. Set `open-pull-requests-limit: 5` on every update entry. This is a per-entry version-update
21
+ limit, not a repository-wide or security-update limit. A consumer may use a lower value when a
22
+ serialized repair or manual validation path requires it.
23
+ 4. Keep security updates enabled and immediate. Do not use a custom age gate to delay vulnerability
24
+ fixes.
25
+
26
+ ## Group package families
27
+
28
+ Build groups around packages that form one compatibility family or release train, not around version
29
+ types. Inspect manifests, peer dependencies, lockfiles, and upstream release practices to identify
30
+ packages that should move together. For example:
31
+
32
+ ```yaml
33
+ cooldown:
34
+ default-days: 2
35
+ exclude: ['@acme/*', 'acme-cli']
36
+ groups:
37
+ first-party:
38
+ patterns: ['@acme/*', 'acme-cli']
39
+ oxc:
40
+ patterns: ['oxlint', 'oxfmt', 'oxlint-tsgolint']
41
+ vitest:
42
+ patterns: ['vitest', '@vitest/*', '@vitejs/*']
43
+ react:
44
+ patterns: ['react', 'react-dom']
45
+ react-security:
46
+ applies-to: 'security-updates'
47
+ patterns: ['react', 'react-dom']
48
+ react-email:
49
+ patterns: ['react-email', '@react-email/*']
50
+ ```
51
+
52
+ - Name each group after the package, toolchain, framework, or verified release family it represents.
53
+ Prefer narrow namespace or prefix wildcards such as `@vitest/*` when that wildcard maps to one
54
+ compatibility family, and put more specific families before broader ones.
55
+ - Do not create generic groups such as `other-minor-and-patch`, `minor-and-patch`,
56
+ `security-minor-and-patch`, or `security-updates`. Do not use the bare catch-all
57
+ `patterns: ['*']` merely to ensure every update belongs to a group. Narrow family wildcards are
58
+ expected. Leave unrelated packages ungrouped so Dependabot opens independently reviewable pull
59
+ requests for them.
60
+ - Omit `update-types` from package-family groups so each family receives major, minor, and patch
61
+ updates. Major updates remain human-reviewed under the auto-merge policy; excluding them from a
62
+ family group only makes the release train harder to update coherently.
63
+ - Groups default to `applies-to: version-updates`. When the same package-family relationship also
64
+ makes a joint security upgrade safe, define a separately named family group such as
65
+ `react-security` with the same package patterns and `applies-to: security-updates`. Never combine
66
+ unrelated vulnerability fixes into a generic security group.
67
+ - Keep verified first-party packages in a dedicated `first-party` group. For a namespace wholly
68
+ controlled by the owner, prefer its scoped wildcard such as `@acme/*`; keep unscoped packages as
69
+ explicit names. Use the same patterns in `cooldown.exclude` so first-party updates have zero-day
70
+ eligibility while third-party packages retain the configured release delay. A grouped pull
71
+ request is auto-mergeable only when every included update is eligible.
72
+
73
+ ## Exempt verified first-party releases
74
+
75
+ Use `cooldown.exclude` only for first-party packages whose owning repository and default-branch
76
+ release workflow have been verified. The workflow must publish that package through OIDC with
77
+ `id-token: write` and without a long-lived registry token. A namespace wildcard is appropriate only
78
+ when the owner controls the entire namespace and intends future packages to inherit the exemption;
79
+ otherwise use exact package names. Keep the consumer-owned registry, documentation, cooldown
80
+ exclusions, and first-party group synchronized.
81
+
82
+ ## Preserve the trust boundary
83
+
84
+ Use a trusted default-branch `pull_request_target` workflow only for metadata inspection and the
85
+ minimal auto-merge mutation. Revalidate the live pull request, Dependabot identity, default base,
86
+ same-repository `dependabot/` head, and immutable base/head SHAs. Never check out or execute pull
87
+ request code in that privileged workflow, and pin external actions according to local policy.
88
+
89
+ Auto-merge only verified semantic-version patches and stable minor updates whose old and new major
90
+ versions are at least 1. Majors, pre-1.0 minors, prereleases, downgrades, malformed or inconsistent
91
+ metadata, and consumer-declared manual ecosystems require human action. Enable platform auto-merge;
92
+ do not auto-approve. Required checks and branch rules remain the merge gate.
93
+
94
+ Use a dedicated `DEPENDABOT_AUTOMERGE_TOKEN` stored as a Dependabot secret, not only as an Actions
95
+ secret. Grant only the repository Contents and Pull requests access needed to enable or disable
96
+ auto-merge, and fail an eligible mutation visibly when the token is absent or underprivileged.
97
+
98
+ Dependabot-triggered workflows cannot assume ordinary Actions secrets or trusted OIDC access. Copy
99
+ only narrowly scoped credentials required for mandatory tests into Dependabot secret scope. For
100
+ cloud, deployment, production, or otherwise privileged checks, keep credential-free validation
101
+ running and make the trusted portion explicitly skipped or not applicable for Dependabot. Required
102
+ fan-in jobs must distinguish that intentional state from an unexpected missing credential.
103
+
104
+ ## Validate and roll out
105
+
106
+ Parse the final YAML, run the repository's workflow checker and policy tests, and inspect the diff
107
+ for uncovered manifests, overlapping groups, broad exemptions, secret exposure, or pull request code
108
+ crossing a privileged boundary. Verify repository settings for dependency security updates and
109
+ auto-merge. After the configuration reaches the default branch, inspect Dependabot logs for every
110
+ configured root and verify representative patch, minor, major, security, manual, and missing-secret
111
+ paths.
@@ -28,6 +28,16 @@ runner labels, permissions, action pins, concurrency, secrets, and required-chec
28
28
  deployments and services. A scheduled reconciliation workflow may repair missed events, but it
29
29
  must inspect a snapshot once and exit; it must not wait for convergence.
30
30
 
31
+ ## Persistent workspaces
32
+
33
+ Check out the full tree and prevent sparse-checkout configuration with a YAML-aware policy over
34
+ tracked workflow and action files. Correct the producer of unsafe workspace state: writable workspace
35
+ bind mounts use a non-root identity whose ownership and write access are compatible with the runner.
36
+ Do not make each job traverse the whole workspace before checkout to repair permissions. Drain and
37
+ migrate an already contaminated runner once. At runtime, repair may cover bounded known generated
38
+ paths; a workspace-wide fallback must stay failure-gated, same-filesystem, directory-only, and batched,
39
+ with path-count and timing evidence in its diagnostics.
40
+
31
41
  ## Reject polling designs
32
42
 
33
43
  Do not add a sleep-and-read loop, repeated run/check/deployment/lease/service queries, a CLI waiter,
@@ -16,6 +16,22 @@ Apply this portable baseline unless a stricter repository-local rule overrides i
16
16
  pull-request content.
17
17
  - Load [github-actions-authoring](../github-actions-authoring/SKILL.md) when changing orchestration.
18
18
  Do not poll remote workflow, deployment, lease, service, or health state.
19
+ - Required checks must be actual workflow jobs that execute or aggregate the work they report.
20
+ Workflow code must not create or publish check runs or commit statuses merely to synthesize a
21
+ required context, copy another workflow's conclusion, or bypass the event graph. A purpose-built
22
+ external CI or analysis integration may report its own result; do not use its API as a relay for
23
+ work owned by GitHub Actions.
24
+ - On `main`, or the consumer's configured default branch, split test jobs by domain such as web and
25
+ backend instead of hiding unrelated suites in one monolithic test job. Keep domain job names stable
26
+ when they are required checks, and use a real bounded fan-in job only when the merge contract needs
27
+ one combined result.
28
+ - Serialize test runs for the same pull request or default branch with a stable concurrency group. For
29
+ pull requests, use `cancel-in-progress: true` so a superseded head replaces the active run. On
30
+ `main`, or the consumer's configured default branch, use `cancel-in-progress: false` so the active
31
+ run finishes before the newest pending revision starts. GitHub may replace an older pending main
32
+ run with the newest pending revision; preserving every intermediate queued revision is not required.
33
+ When one workflow handles both events, make `cancel-in-progress` conditional on the pull-request
34
+ event while keeping the PR number or branch ref in the concurrency group.
19
35
  - Give every concrete job a timeout of no more than 30 minutes. A caller job that invokes a reusable
20
36
  workflow through top-level `jobs.<job_id>.uses` cannot accept `timeout-minutes`; enforce the bound
21
37
  on every concrete job inside the called workflow. If the underlying operation cannot terminate
@@ -27,6 +43,16 @@ Apply this portable baseline unless a stricter repository-local rule overrides i
27
43
  operation in another service.
28
44
  - Use GitHub-hosted runners only for public repositories. Private repositories use the consumer's
29
45
  approved self-hosted or disposable runner labels.
46
+ - Persistent workspaces must check out the full tree. Do not configure sparse checkout; enforce that
47
+ prohibition with a YAML-aware check over intended tracked workflow and action files, with fixtures
48
+ for accepted and rejected shapes.
49
+ - Fix workspace ownership at the producer. A container with a writable workspace bind mount must use
50
+ a non-root identity whose ownership and write access are compatible with the runner workspace. Do not
51
+ add an unconditional, pre-checkout, workspace-wide permission or ownership traversal to recover
52
+ persistent runners. Migrate an already contaminated workspace once while the runner is drained.
53
+ Normal runtime repair may cover bounded known generated paths. A necessary workspace-wide fallback
54
+ must stay failure-gated, same-filesystem, directory-only, and batched, and record path count and timing
55
+ evidence.
30
56
  - Pin every repository-backed external `uses:` reference—anything other than a local `./...`
31
57
  action—to a full lowercase 40-character Git SHA followed immediately by its machine-maintainable
32
58
  version comment, such as `# v4.2.0`, so Dependabot can update both. Pin `docker://...` actions to an
@@ -43,8 +69,9 @@ Apply this portable baseline unless a stricter repository-local rule overrides i
43
69
  conflict. Identify trusted and untrusted inputs and every credential boundary.
44
70
  2. Give each job the least permissions it needs. Keep untrusted pull-request content out of shell
45
71
  interpolation, privileged tokens, and write-capable steps.
46
- 3. Apply the portable pinning, runner, trigger, and timeout baseline plus any stricter consumer
47
- policy. Keep checkout refs, artifact boundaries, caches, and concurrency behavior explicit.
72
+ 3. Apply the portable check, test-topology, concurrency, pinning, runner, trigger, and timeout
73
+ baseline plus any stricter consumer policy. Keep checkout refs, artifact boundaries, caches, and
74
+ concurrency behavior explicit.
48
75
  4. Validate changed YAML with the local workflow checker and run the affected workflow tests or
49
76
  scripts. Update local CI documentation when behavior or operator expectations change.
50
77
  5. Review the final diff for privilege escalation, accidental secret exposure, unsafe quoting,
@@ -4,7 +4,7 @@
4
4
  {
5
5
  "name": "agent-workflow",
6
6
  "plugin": "vouchington-workflow",
7
- "pluginVersion": "0.5.1",
7
+ "pluginVersion": "0.6.2",
8
8
  "path": "agent-workflow/SKILL.md"
9
9
  },
10
10
  {
@@ -17,9 +17,16 @@
17
17
  {
18
18
  "name": "blackboard",
19
19
  "plugin": "vouchington-workflow",
20
- "pluginVersion": "0.5.1",
20
+ "pluginVersion": "0.6.2",
21
21
  "path": "blackboard/SKILL.md"
22
22
  },
23
+ {
24
+ "name": "dependabot",
25
+ "plugin": "vouchington-workflow",
26
+ "pluginVersion": "0.6.2",
27
+ "path": "dependabot/SKILL.md",
28
+ "prerequisites": ["github-actions-checklist"]
29
+ },
23
30
  {
24
31
  "name": "dotnet-test-authoring",
25
32
  "plugin": "vouchington-testing",
@@ -29,26 +36,26 @@
29
36
  {
30
37
  "name": "git-commit-checklist",
31
38
  "plugin": "vouchington-workflow",
32
- "pluginVersion": "0.5.1",
39
+ "pluginVersion": "0.6.2",
33
40
  "path": "git-commit-checklist/SKILL.md"
34
41
  },
35
42
  {
36
43
  "name": "github-actions-authoring",
37
44
  "plugin": "vouchington-workflow",
38
- "pluginVersion": "0.5.1",
45
+ "pluginVersion": "0.6.2",
39
46
  "path": "github-actions-authoring/SKILL.md",
40
47
  "prerequisites": ["github-actions-checklist"]
41
48
  },
42
49
  {
43
50
  "name": "github-actions-checklist",
44
51
  "plugin": "vouchington-workflow",
45
- "pluginVersion": "0.5.1",
52
+ "pluginVersion": "0.6.2",
46
53
  "path": "github-actions-checklist/SKILL.md"
47
54
  },
48
55
  {
49
56
  "name": "github-issue",
50
57
  "plugin": "vouchington-workflow",
51
- "pluginVersion": "0.5.1",
58
+ "pluginVersion": "0.6.2",
52
59
  "path": "github-issue/SKILL.md"
53
60
  },
54
61
  {
@@ -61,25 +68,25 @@
61
68
  {
62
69
  "name": "npm-publishing",
63
70
  "plugin": "vouchington-workflow",
64
- "pluginVersion": "0.5.1",
71
+ "pluginVersion": "0.6.2",
65
72
  "path": "npm-publishing/SKILL.md"
66
73
  },
67
74
  {
68
75
  "name": "organize-github-issues",
69
76
  "plugin": "vouchington-workflow",
70
- "pluginVersion": "0.5.1",
77
+ "pluginVersion": "0.6.2",
71
78
  "path": "organize-github-issues/SKILL.md"
72
79
  },
73
80
  {
74
81
  "name": "package-json-checklist",
75
82
  "plugin": "vouchington-workflow",
76
- "pluginVersion": "0.5.1",
83
+ "pluginVersion": "0.6.2",
77
84
  "path": "package-json-checklist/SKILL.md"
78
85
  },
79
86
  {
80
87
  "name": "planning",
81
88
  "plugin": "vouchington-workflow",
82
- "pluginVersion": "0.5.1",
89
+ "pluginVersion": "0.6.2",
83
90
  "path": "planning/SKILL.md"
84
91
  },
85
92
  {
@@ -103,43 +110,43 @@
103
110
  {
104
111
  "name": "pr-description",
105
112
  "plugin": "vouchington-workflow",
106
- "pluginVersion": "0.5.1",
113
+ "pluginVersion": "0.6.2",
107
114
  "path": "pr-description/SKILL.md"
108
115
  },
109
116
  {
110
117
  "name": "retrospective",
111
118
  "plugin": "vouchington-workflow",
112
- "pluginVersion": "0.5.1",
119
+ "pluginVersion": "0.6.2",
113
120
  "path": "retrospective/SKILL.md"
114
121
  },
115
122
  {
116
123
  "name": "retrospective-distill",
117
124
  "plugin": "vouchington-workflow",
118
- "pluginVersion": "0.5.1",
125
+ "pluginVersion": "0.6.2",
119
126
  "path": "retrospective-distill/SKILL.md"
120
127
  },
121
128
  {
122
129
  "name": "review-ci-logs",
123
130
  "plugin": "vouchington-workflow",
124
- "pluginVersion": "0.5.1",
131
+ "pluginVersion": "0.6.2",
125
132
  "path": "review-ci-logs/SKILL.md"
126
133
  },
127
134
  {
128
135
  "name": "review-github-issue-taxonomy",
129
136
  "plugin": "vouchington-workflow",
130
- "pluginVersion": "0.5.1",
137
+ "pluginVersion": "0.6.2",
131
138
  "path": "review-github-issue-taxonomy/SKILL.md"
132
139
  },
133
140
  {
134
141
  "name": "revisit-followups",
135
142
  "plugin": "vouchington-workflow",
136
- "pluginVersion": "0.5.1",
143
+ "pluginVersion": "0.6.2",
137
144
  "path": "revisit-followups/SKILL.md"
138
145
  },
139
146
  {
140
147
  "name": "static-analysis-checklist",
141
148
  "plugin": "vouchington-workflow",
142
- "pluginVersion": "0.5.1",
149
+ "pluginVersion": "0.6.2",
143
150
  "path": "static-analysis-checklist/SKILL.md"
144
151
  },
145
152
  {
@@ -14,9 +14,15 @@ Use when investigating CI failures, repeated workflow noise, or misleading diagn
14
14
  Inspect failed steps and representative large entries, then remove temporary artifacts.
15
15
  3. Classify findings as a real error, misleading output, downstream cascade, necessary diagnostic,
16
16
  or volume-only concern. Identify the first repository-owned root cause.
17
- 4. Prefer one bounded fix that preserves non-zero exits, primary errors, artifacts, summaries, and
17
+ 4. For persistent-workspace failures, identify the producer of sparse state or unsafe ownership before
18
+ proposing cleanup. Reject unconditional pre-checkout workspace traversals. Bounded known generated
19
+ paths may be repaired routinely; a workspace-wide fallback must be failure-gated, same-filesystem,
20
+ directory-only, and batched. Require path-count and timing evidence, plus a drained one-time migration
21
+ and producer-side fix such as a compatible non-root writable workspace mount when runner state is
22
+ contaminated.
23
+ 5. Prefer one bounded fix that preserves non-zero exits, primary errors, artifacts, summaries, and
18
24
  diagnostic evidence. Do not hide stderr, globally quiet output, or add retries to mask a cause.
19
- 5. Add focused regression evidence, run local workflow validation, compare before and after output
25
+ 6. Add focused regression evidence, run local workflow validation, compare before and after output
20
26
  where meaningful, and report deferred findings without creating issues unless authorized.
21
27
 
22
28
  This skill supplies no workflow names, log-retention policy, CI provider command, retry policy, or
@@ -10,13 +10,16 @@ documentation, rule inventory, and fixture conventions before selecting an imple
10
10
 
11
11
  1. State the invariant and search for an existing analyzer, rule, or guard that owns it. Prefer the
12
12
  narrowest established owner over a parallel scanner.
13
- 2. Add positive and negative fixtures before the rule. Cover path routing, parser boundaries, and
14
- every allowed exception.
15
- 3. Keep discovery limited to tracked, intended files. Make suppressions and allowlists narrow,
13
+ 2. For workflow or action policy, parse YAML instead of text matching and inspect only intended tracked
14
+ configuration files. A persistent-workspace guard must reject sparse-checkout inputs and unsafe
15
+ writable workspace mounts while allowing unrelated YAML keys and ordinary read-only mounts.
16
+ 3. Add meaningful accepted and rejected fixtures before the rule. Cover path routing, parser
17
+ boundaries, equivalent YAML forms, and every allowed exception.
18
+ 4. Keep discovery limited to tracked, intended files. Make suppressions and allowlists narrow,
16
19
  justified, and mechanically checked for freshness where practical.
17
- 4. Run the focused fixture test and analyzer, then the local aggregate checks. Keep diagnostics
20
+ 5. Run the focused fixture test and analyzer, then the local aggregate checks. Keep diagnostics
18
21
  actionable and deterministic.
19
- 5. Update the local inventory or documentation and delete superseded migration artifacts only when
22
+ 6. Update the local inventory or documentation and delete superseded migration artifacts only when
20
23
  the replacement invariant is demonstrably enforced.
21
24
 
22
25
  This skill does not name analyzer roots, fixture locations, commands, suppression syntax, or