Skip to content

Commit 873d91c

Browse files
committed
fix(hooks): release-workflow-guard — multi-root dry-run resolution
Sync from socket-btm. When CLAUDE_PROJECT_DIR points at a different repo than the one whose hook is running, the dry-run bypass would fail. Fix collects every plausible project root (env + script-derived + cwd), dedupes, returns them all so the existing fall-through loop finds the workflow file.
1 parent b069535 commit 873d91c

1 file changed

Lines changed: 45 additions & 22 deletions

File tree

  • .claude/hooks/release-workflow-guard

.claude/hooks/release-workflow-guard/index.mts

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -315,33 +315,56 @@ function workflowDeclaresDryRunInput(
315315
// intentionally excluded so a same-named workflow in the current
316316
// checkout can't false-positive a cross-repo dispatch.
317317
function resolveSearchRoots(command: string): string[] {
318-
// Resolution order: $CLAUDE_PROJECT_DIR (Claude Code sets this when
319-
// it remembers to) → derive from this hook script's path (the hook
320-
// lives at <project>/.claude/hooks/release-workflow-guard/index.mts,
321-
// so go three levels up from __dirname) → $PWD as last resort.
322-
// The script-path derivation is the most robust because it doesn't
323-
// depend on the runner exporting env vars correctly.
324-
let projectDir = process.env['CLAUDE_PROJECT_DIR']
325-
if (!projectDir) {
326-
// process.argv[1] is the absolute path of this hook script when
327-
// invoked via `node <path>`. Walk up to the repo root.
328-
const scriptPath = process.argv[1]
329-
if (scriptPath) {
330-
// .claude/hooks/release-workflow-guard/index.mts → ../../../ = repo
331-
const candidate = path.resolve(scriptPath, '..', '..', '..', '..')
332-
if (existsSync(path.join(candidate, '.github', 'workflows'))) {
333-
projectDir = candidate
334-
}
318+
// Resolution: collect every plausible project root (env + script
319+
// derivation + cwd), dedupe, and return the list. Downstream
320+
// consumers (workflowDeclaresDryRunInput / classifyWorkflow) iterate
321+
// and fall through, so multiple candidates is strictly safer than
322+
// picking a single one — the env var can point at the *parent* of
323+
// the actual checkout when Claude Code resolves a different repo
324+
// than the one whose hook is running.
325+
const candidates: string[] = []
326+
const envDir = process.env['CLAUDE_PROJECT_DIR']
327+
if (envDir) {
328+
candidates.push(envDir)
329+
}
330+
// process.argv[1] is the absolute path of this hook script when
331+
// invoked via `node <path>`. Walk up to the repo root:
332+
// .claude/hooks/release-workflow-guard/index.mts → ../../../ = repo
333+
const scriptPath = process.argv[1]
334+
if (scriptPath) {
335+
const candidate = path.resolve(scriptPath, '..', '..', '..', '..')
336+
if (existsSync(path.join(candidate, '.github', 'workflows'))) {
337+
candidates.push(candidate)
335338
}
336339
}
337-
if (!projectDir) {
338-
projectDir = process.cwd()
340+
candidates.push(process.cwd())
341+
// Dedupe while preserving order.
342+
const seen = new Set<string>()
343+
const unique: string[] = []
344+
for (const c of candidates) {
345+
if (!seen.has(c)) {
346+
seen.add(c)
347+
unique.push(c)
348+
}
339349
}
340350
const repoMatch = GH_REPO_FLAG_RE.exec(command)
341-
if (!repoMatch || path.basename(projectDir) === repoMatch[1]!) {
342-
return [projectDir]
351+
if (!repoMatch) {
352+
return unique
353+
}
354+
// Cross-repo dispatch: redirect every candidate to its sibling clone
355+
// of the same name. If none of them have a sibling matching, the
356+
// empty list naturally blocks (workflowDeclaresDryRunInput returns
357+
// false → bypass denied → block-the-default).
358+
const repoName = repoMatch[1]!
359+
const redirected: string[] = []
360+
for (const c of unique) {
361+
if (path.basename(c) === repoName) {
362+
redirected.push(c)
363+
} else {
364+
redirected.push(path.join(path.dirname(c), repoName))
365+
}
343366
}
344-
return [path.join(path.dirname(projectDir), repoMatch[1]!)]
367+
return redirected
345368
}
346369

347370
function isVerifiableDryRun(

0 commit comments

Comments
 (0)