Skip to content

Commit 303c699

Browse files
committed
chore(sync): cascade fleet template@567eab0
Auto-applied by socket-wheelhouse sync-scaffolding into cascade-socket-registry-8582. 41 file(s) touched: - .claude/hooks/actionlint-on-workflow-edit/README.md - .claude/hooks/actionlint-on-workflow-edit/index.mts - .claude/hooks/actionlint-on-workflow-edit/package.json - .claude/hooks/actionlint-on-workflow-edit/test/index.test.mts - .claude/hooks/actionlint-on-workflow-edit/tsconfig.json - .claude/hooks/consumer-grep-reminder/README.md - .claude/hooks/consumer-grep-reminder/index.mts - .claude/hooks/consumer-grep-reminder/package.json - .claude/hooks/consumer-grep-reminder/test/index.test.mts - .claude/hooks/consumer-grep-reminder/tsconfig.json - .claude/hooks/inline-script-defer-guard/README.md - .claude/hooks/inline-script-defer-guard/index.mts - .claude/hooks/inline-script-defer-guard/package.json - .claude/hooks/inline-script-defer-guard/test/index.test.mts - .claude/hooks/inline-script-defer-guard/tsconfig.json - .claude/hooks/node-modules-staging-guard/README.md - .claude/hooks/node-modules-staging-guard/index.mts - .claude/hooks/node-modules-staging-guard/package.json - .claude/hooks/node-modules-staging-guard/test/index.test.mts - .claude/hooks/node-modules-staging-guard/tsconfig.json ... and 21 more
1 parent ae27e55 commit 303c699

41 files changed

Lines changed: 2969 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# actionlint-on-workflow-edit
2+
3+
PostToolUse Edit/Write hook that runs local `actionlint` against any
4+
`.github/workflows/*.y*ml` file after the edit. Reports any actionlint
5+
errors via stderr; never blocks (the edit already landed).
6+
7+
## Why
8+
9+
GitHub Actions' YAML parser fails silently — a malformed workflow shows
10+
"0 jobs" on the next push with no error in the UI. `actionlint` catches
11+
the same YAML / shell / SHA-pin issues locally, instantly. The fleet
12+
already has actionlint installed on dev machines (homebrew default
13+
`/opt/homebrew/bin/actionlint`).
14+
15+
## What it covers
16+
17+
Any Edit/Write to a file matching `.github/workflows/*.y*ml`. Runs
18+
`actionlint <file>`. If exit code is non-zero, surfaces stdout + stderr
19+
to Claude via this hook's stderr. If `actionlint` isn't on PATH, no-op.
20+
21+
## Not a blocker
22+
23+
This hook is reporting-only. Blocking is covered by:
24+
25+
- `workflow-uses-comment-guard` (SHA-pin comment format)
26+
- `workflow-yaml-multiline-body-guard` (multi-line `--body "..."`)
27+
- `pull-request-target-guard` (privileged context misuse)
28+
29+
If a future block-worthy actionlint check is identified, promote it to
30+
its own PreToolUse hook with a focused detection pattern.
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
#!/usr/bin/env node
2+
// Claude Code PostToolUse hook — actionlint-on-workflow-edit.
3+
//
4+
// After an Edit/Write touches `.github/workflows/*.y*ml`, invoke local
5+
// `actionlint` (if installed) against the file. Surface any errors as
6+
// stderr so Claude sees the problem before the next turn.
7+
//
8+
// PostToolUse (not PreToolUse) so the edit lands first and actionlint
9+
// reads the on-disk state. No block — reporting only. The block surface
10+
// is covered by sibling hooks (`workflow-uses-comment-guard`,
11+
// `workflow-yaml-multiline-body-guard`, `pull-request-target-guard`).
12+
//
13+
// No-op when actionlint isn't on PATH — most fleet machines have it via
14+
// brew, CI runners have it preinstalled, but downstreams may not.
15+
16+
import { spawnSync } from 'node:child_process'
17+
import process from 'node:process'
18+
19+
import { readStdin } from '../_shared/transcript.mts'
20+
21+
interface ToolInput {
22+
readonly tool_name?: string | undefined
23+
readonly tool_input?: { readonly file_path?: string | undefined } | undefined
24+
}
25+
26+
function isWorkflowYaml(filePath: string): boolean {
27+
return /[\\/]\.github[\\/]workflows[\\/][^\\/]+\.ya?ml$/.test(filePath)
28+
}
29+
30+
function actionlintAvailable(): boolean {
31+
const r = spawnSync('command', ['-v', 'actionlint'], {
32+
encoding: 'utf8',
33+
timeout: 2_000,
34+
})
35+
return r.status === 0 && (r.stdout?.trim().length ?? 0) > 0
36+
}
37+
38+
async function main(): Promise<void> {
39+
let raw: string
40+
try {
41+
raw = await readStdin()
42+
} catch {
43+
process.exit(0)
44+
}
45+
if (!raw) {
46+
process.exit(0)
47+
}
48+
let payload: ToolInput
49+
try {
50+
payload = JSON.parse(raw) as ToolInput
51+
} catch {
52+
process.exit(0)
53+
}
54+
if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') {
55+
process.exit(0)
56+
}
57+
const filePath = payload.tool_input?.file_path
58+
if (!filePath || !isWorkflowYaml(filePath)) {
59+
process.exit(0)
60+
}
61+
62+
if (!actionlintAvailable()) {
63+
process.exit(0)
64+
}
65+
66+
const r = spawnSync('actionlint', [filePath], {
67+
encoding: 'utf8',
68+
timeout: 10_000,
69+
})
70+
if (r.status === 0) {
71+
process.exit(0)
72+
}
73+
74+
// actionlint failed — surface its output to stderr so Claude reads it.
75+
process.stderr.write(
76+
[
77+
'[actionlint-on-workflow-edit] actionlint reported errors',
78+
'',
79+
` File: ${filePath}`,
80+
'',
81+
' Output:',
82+
...(r.stdout ?? '')
83+
.trim()
84+
.split('\n')
85+
.map(l => ` ${l}`),
86+
...(r.stderr
87+
? r.stderr
88+
.trim()
89+
.split('\n')
90+
.map(l => ` ${l}`)
91+
: []),
92+
'',
93+
' Fix the workflow before relying on it firing in CI. actionlint',
94+
" catches the same YAML / shell / SHA-pin issues GitHub Actions'",
95+
' parser would (silently) reject as "0 jobs."',
96+
'',
97+
].join('\n'),
98+
)
99+
// PostToolUse — emit warning to stderr but don't block the edit
100+
// (the edit already happened). Exit 0 so Claude sees the stderr.
101+
process.exit(0)
102+
}
103+
104+
main().catch(e => {
105+
process.stderr.write(
106+
`[actionlint-on-workflow-edit] hook error (allowing): ${(e as Error).message}\n`,
107+
)
108+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "hook-actionlint-on-workflow-edit",
3+
"private": true,
4+
"type": "module",
5+
"main": "./index.mts",
6+
"exports": {
7+
".": "./index.mts"
8+
},
9+
"scripts": {
10+
"test": "node --test test/*.test.mts"
11+
},
12+
"devDependencies": {
13+
"@types/node": "catalog:"
14+
}
15+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// node --test specs for the actionlint-on-workflow-edit hook.
2+
3+
import { spawn, spawnSync } from 'node:child_process'
4+
import path from 'node:path'
5+
import { fileURLToPath } from 'node:url'
6+
import test from 'node:test'
7+
import assert from 'node:assert/strict'
8+
9+
const here = path.dirname(fileURLToPath(import.meta.url))
10+
const HOOK = path.join(here, '..', 'index.mts')
11+
12+
type Result = { code: number; stderr: string }
13+
14+
async function runHook(payload: Record<string, unknown>): Promise<Result> {
15+
const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' })
16+
child.stdin.end(JSON.stringify(payload))
17+
let stderr = ''
18+
child.stderr.on('data', chunk => {
19+
stderr += chunk.toString('utf8')
20+
})
21+
return new Promise(resolve => {
22+
child.on('exit', code => {
23+
resolve({ code: code ?? 0, stderr })
24+
})
25+
})
26+
}
27+
28+
const actionlintInstalled = (() => {
29+
const r = spawnSync('command', ['-v', 'actionlint'])
30+
return r.status === 0
31+
})()
32+
33+
test('non-workflow file passes silently', async () => {
34+
const r = await runHook({
35+
tool_name: 'Write',
36+
tool_input: { file_path: '/tmp/foo.txt' },
37+
})
38+
assert.strictEqual(r.code, 0)
39+
assert.strictEqual(r.stderr, '')
40+
})
41+
42+
test('non-Edit/Write tool passes silently', async () => {
43+
const r = await runHook({
44+
tool_name: 'Bash',
45+
tool_input: { command: 'echo hi' },
46+
})
47+
assert.strictEqual(r.code, 0)
48+
})
49+
50+
test('workflow edit always exits 0 (PostToolUse — reporting only)', async () => {
51+
// We don't need actionlint installed to verify the exit code; the
52+
// hook short-circuits to 0 on actionlint-not-found.
53+
const r = await runHook({
54+
tool_name: 'Edit',
55+
tool_input: { file_path: '/tmp/some.github/workflows/x.yml' },
56+
})
57+
assert.strictEqual(r.code, 0)
58+
})
59+
60+
test('workflow edit with installed actionlint runs the tool (smoke)', async t => {
61+
if (!actionlintInstalled) {
62+
t.skip('actionlint not on PATH')
63+
return
64+
}
65+
// Smoke test only — provide a path to a nonexistent file; actionlint
66+
// will error but the hook itself exits 0. We just check it doesn't
67+
// crash.
68+
const r = await runHook({
69+
tool_name: 'Edit',
70+
tool_input: {
71+
file_path: '/this/path/does/not/exist/.github/workflows/x.yml',
72+
},
73+
})
74+
assert.strictEqual(r.code, 0)
75+
})
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"declarationMap": false,
4+
"erasableSyntaxOnly": true,
5+
"module": "nodenext",
6+
"moduleResolution": "nodenext",
7+
"noEmit": true,
8+
"rewriteRelativeImportExtensions": true,
9+
"skipLibCheck": true,
10+
"sourceMap": false,
11+
"strict": true,
12+
"target": "esnext",
13+
"types": ["node"],
14+
"verbatimModuleSyntax": true
15+
}
16+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# consumer-grep-reminder
2+
3+
PreToolUse Edit hook (reminder, NOT a block) that fires when an edit
4+
removes a CSS class, HTML attribute, or named export AND the repo has
5+
consumer-bearing subtrees (`upstream/`, `vendor/`, `third_party/`,
6+
`external/`, `deps/`, `additions/source-patched/`).
7+
8+
## Why
9+
10+
Past incident: an agent stripped a CSS class because repo-root grep
11+
found 0 hits. The project's upstream bundle (in `upstream/`) hydrated
12+
from that class — the rendered page went blank in production.
13+
14+
Repo-root grep doesn't see code in `upstream/` / `vendor/` / etc. when
15+
those are gitignored or submodules. This hook surfaces the reminder to
16+
grep those subtrees BEFORE relying on a "0 consumers" finding.
17+
18+
## What it surfaces
19+
20+
| Edit pattern | Reminder? |
21+
| -------------------------------------------------------- | --------- |
22+
| Removes `.my-class-name` (hyphenated CSS class) | yes |
23+
| Removes `data-foo` / `aria-bar` (HTML attribute literal) | yes |
24+
| Removes `export const foo` / `export function foo` | yes |
25+
| Removes any of the above when NO consumer subtree exists | no |
26+
| Pure additions (no removals) | no |
27+
| Non-Edit tools | no |
28+
29+
## Not a block
30+
31+
False-positive surface is real — not every CSS class removal is a
32+
hydration target. The reminder lets the agent verify with a grep
33+
against the listed subtrees, then continue. The user can also ignore
34+
the reminder if they've already verified.
35+
36+
## Suggested response
37+
38+
When this fires, run something like:
39+
40+
```bash
41+
rg -nF '.removed-class' upstream/ vendor/ third_party/
42+
```
43+
44+
If the grep finds hits, the removal needs coordination with the
45+
upstream bundle. If 0 hits, proceed.

0 commit comments

Comments
 (0)