Skip to content

ci: verify the shared hook lib on Windows - #1975

Merged
kyle-sexton merged 3 commits into
mainfrom
ci/windows-hook-utils-job
Aug 8, 2026
Merged

ci: verify the shared hook lib on Windows#1975
kyle-sexton merged 3 commits into
mainfrom
ci/windows-hook-utils-job

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

No linked issue

Summary

Every CI job in this repository runs on ubuntu-24.04. lib/hook-utils.sh carries OSTYPE-gated branches that a Linux runner never executes:

  • case-insensitive path folding for the Windows filesystem
  • cygpath short-name (8.3) resolution

Those branches have only ever been exercised by whatever a maintainer happened to run on a dev box. This adds the job that makes them a gate.

Why only one suite

This is deliberately narrow, and the narrowness is the point.

I checked which code is genuinely platform-dependent rather than assuming. lib/hook-utils.sh has 18 platform-conditional sites with explicit OSTYPE branches. lib/powershell/ps-command.sh — which classifies PowerShell commands, and is therefore the intuitive candidate for a Windows job — has zero. It is pure bash string manipulation and behaves identically on both platforms, so Linux already tests it faithfully.

Adding platform-agnostic suites to a Windows runner would buy no coverage while paying Windows' process-creation cost. Measured on a Windows dev box: ~140ms per process spawn, against ~3ms on Linux. A hook invocation spawns 7 externals, so it costs ~2.0s on Windows; the largest suite has 319 cases and takes ~11 minutes there. That cost is exactly why the full suite is unusable locally on Windows, and why it should not be replicated in CI.

The comment block on the job states this rule so the job does not accrete suites over time.

Cost

Free. GitHub Actions is free for public repositories on standard GitHub-hosted runners, Windows included; only larger runners are charged — per GitHub's billing docs, fetched for this change:

GitHub Actions usage is free for self-hosted runners and for public repositories that use standard GitHub-hosted runners.

Verification

actionlint clean. The job's real verification is its first run on this PR — if lib/hook-utils.test.sh has latent Windows assumptions, this is where we find out, which is the point of adding it.

Related

  • Came out of investigating why the guardrails hook suites take ~50 minutes to run serially on a Windows dev box. Two follow-ups are queued from the same investigation: cutting lib/hook-utils.sh's per-invocation subprocess spawns from 7 to ~1 (which also addresses two open hook-timeout reports from a consuming project), and an affected-suite selector so a local run does not mean running all 60 suites.
  • Related finding, not addressed here: block-noncanonical-commit.test.sh uses hard timeout 30 wall-clock ceilings, which fail spuriously when the box is loaded. Surfaced while running suites in parallel.

`lib/hook-utils.sh` carries OSTYPE-gated branches that a Linux runner never
executes: case-insensitive path folding for the Windows filesystem, and
`cygpath` short-name (8.3) resolution. Every CI job in this repository runs
on ubuntu-24.04, so those branches have only ever been exercised by whatever
a maintainer happened to run on a dev box. This adds the job that makes them
a gate.

Deliberately scoped to one suite. The rest of the corpus is platform-agnostic
bash string logic that Linux exercises identically — `lib/powershell/
ps-command.sh` classifies PowerShell commands yet carries zero OSTYPE /
cygpath / uname branches, so Linux tests it faithfully. Adding a
platform-agnostic suite here would buy no coverage while paying Windows'
process-creation cost, measured at ~140ms per spawn against ~3ms on Linux.

Free: GitHub Actions is free for public repositories on standard
GitHub-hosted runners, Windows included; only larger runners are charged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 7, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 36s —— View job


Review: ci: verify the shared hook lib on Windows

Verdict: solid idea, well-justified by the PR body — but as written, the new job doesn't actually gate anything.

🔴 The new job isn't wired into the required check

ci-status is this repo's single required-check aggregator — every other job defined in the file appears in its needs: list, and the comment above it says exactly that: "Derived from the needs graph above — the single source of truth for the lane list. Adding a lane to needs automatically extends this check."

hook-utils-windows is the only job in the file that's missing from that list. Concretely:

  • ci-status has no needs dependency on hook-utils-windows, so it doesn't wait for it and its ${{ join(needs.*.result, ' ') }} aggregation never sees its outcome.
  • If the Windows job fails, ci-status (and therefore branch protection) can still go green — the PR would merge on a red Windows leg.

This directly contradicts the PR's own stated goal — "This adds the job that makes them a gate" — and the "Verification" section's framing of it as something that would be "found" if it fails. Right now it's an informational job, not a gate, on par with the deliberately-advisory link-check.yml lane, but nothing marks it as such (no continue-on-error: true + explanatory comment the way other intentionally-non-blocking steps in this file are annotated).

Fix: add - hook-utils-windows to ci-status's needs: list at L994-L1017 (alongside hook-utils-sync, since they cover the same lib). If leaving it advisory is actually intentional (e.g. wanting to observe a few runs before gating on a new platform), that should be said explicitly in a comment on the job, the same way this file documents every other deliberate exception.

Fix this →

Everything else checks out

  • Scope discipline (L278-L290): the PR body's claim that lib/powershell/ps-command.sh has zero OSTYPE/cygpath/uname branches checks out against the current tree — restricting the Windows job to hook-utils.test.sh alone is justified, not just asserted.
  • Consistency: actions/checkout pinned to the same SHA (3d3c42e5... / v7.0.1) used by every other job in the file; persist-credentials: false matches repo convention.
  • shell: bash default is correct — Windows runners ship Git Bash, and this is a bash test suite.
  • timeout-minutes: 20: reasonable headroom given the PR's own ~2s/invocation, ~11-minute worst-case estimate (and this suite, at 1525 lines, is smaller than the 319-case suite the estimate was based on).
  • Comment block is verbose but matches this file's established style of documenting why each lane exists and its boundaries (see hygiene, zizmor, etc.) — including the "keep this job small" guardrail against future scope creep, which is a good call given the file's own comment about avoiding lane accretion.
  • No .claude/plugin-contract surface touched — CLAUDE.md's fresh-docs mandate doesn't apply here (pure CI workflow, no plugin manifest/hook-contract/skill-frontmatter change).
    ·

@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 50s —— View job


Security Review

  • Fetch PR diff (.github/workflows/ci.yml)
  • Review new Windows CI job for security issues
  • Post findings

Scope: this PR's only change is adding one new job, hook-utils-windows, to .github/workflows/ci.yml (checked at f309474).

No security issues found.

Checked specifically for the categories in scope for this lane (logic/architecture/trust-boundary reasoning, not what zizmor already covers statically):

  • Trigger/permission context — the workflow trigger (pull_request, not pull_request_target) and top-level permissions: contents: read are unchanged by this PR. The new job doesn't request elevated permissions, doesn't touch secrets, and doesn't introduce a pull_request_target/workflow_run pattern running untrusted code with privileged token/secret access.
  • Script injection via the github context — the job's only run: step is a static string, run: bash lib/hook-utils.test.sh, with no ${{ github.* }} (or any other) interpolation into a shell command, so there's no script-injection surface here.
  • Checkout hygieneactions/checkout is used with persist-credentials: false, which is the safer choice (avoids leaving a scoped git credential available to the test process on a Windows runner executing repo-controlled shell scripts).
  • Secrets/credentials — none are referenced, passed as env, or otherwise handled by this job.
  • Blast radius if the executed test script were hostile — bounded by the unchanged contents: read permission and the absence of secrets in this job's environment; consistent with the risk profile of the repo's other existing test/lint jobs that already execute repo code on pull_request.

Supply-chain/pin verification (actions/checkout pinned to a full commit SHA with a version comment) is left to zizmor's advisory lane per instructions.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Claude has reviewed this PR 3 times. The lane skips further automatic reviews after 5; deleting this comment resets the count.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f309474be7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread .github/workflows/ci.yml
The runner policy forbids the floating `*-latest` labels outright
(policy.json forbiddenHostedRunnerLabels), for the same reason the Linux
lanes pin ubuntu-24.04: an image roll should be a reviewed commit, not a
silent Tuesday. Verified locally against both gates — actionlint clean,
`runner-policy.mjs --root .` passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 11s —— View job


Security Review

  • Fetch current PR diff (.github/workflows/ci.yml)
  • Review new/changed lines for security issues
  • Post findings

Scope: this PR's only change is the new hook-utils-windows job in .github/workflows/ci.yml, added across two commits — f309474 (add the job) and 5662d66 (pin runs-on to windows-2025 instead of windows-latest).

No security issues found.

The second commit is the only delta since the last security pass on this PR, and it's a supply-chain hardening, not a new risk: it swaps the floating windows-latest label for the pinned windows-2025 image. Confirmed against this repo's own runner policy — policy.json lists windows-latest under forbiddenHostedRunnerLabels and windows-2025 under approvedHostedRunnerLabels, so this change brings the job into compliance with the same "no floating image labels" rule the Linux lanes already follow (ubuntu-24.04 is similarly pinned).

Re-checked the categories in scope for this lane against the full current diff:

  • Trigger/permission contextpull_request (not pull_request_target/workflow_run), top-level permissions: contents: read unchanged. The new job requests no elevated permissions and has no secrets in its environment.
  • Script injection via the github context — the only run: step is a static string (bash lib/hook-utils.test.sh); no ${{ github.* }} interpolation anywhere in the job.
  • Checkout hygieneactions/checkout pinned to the same SHA as every other job, with persist-credentials: false.
  • Config/settings changes — the runs-on change is a policy-config value, not a permissions:/settings widening; it moves the job from a forbidden label to an approved one, i.e. tightens rather than loosens.
  • Supply-chain/pin verification (action pins, runs-on image floating) — left to zizmor's advisory lane per instructions; noted the windows-2025 pin above only because it's a repo-specific policy check zizmor doesn't run, not a general supply-chain finding.

No CRITICAL, IMPORTANT, or SUGGESTION findings.

@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 2s —— View job


Code Review

Verdict: the previously-flagged gap is still open — as of HEAD (5662d66), the new job still isn't wired into the required check, so it doesn't gate merges yet. Everything else in the diff is solid.

🔴 hook-utils-windows is still missing from ci-status's needs:

This was already flagged by an earlier review pass on this PR and by Codex — it has not been addressed in the current HEAD. Confirmed against the diff (cff36aa..5662d66): the second commit only changes runs-on: windows-latestwindows-2025; the needs: list at ci.yml#L996-L1021 is untouched and still omits hook-utils-windows.

Concretely:

  • ci-status is the single required-check aggregator, built by joining needs.*.result (L1034); every other job in the file appears in that list, and the comment above it says adding a lane to needs is what extends the check.
  • hook-utils-windows (L259-L292) is the one job in the file not in that list. If it fails, ci-status doesn't wait for it and can still go green, so branch protection would let a Windows-red PR merge.
  • This directly contradicts the PR's stated goal ("This adds the job that makes them a gate") and the "Verification" section's framing that a failure here is "found."

Fix: add - hook-utils-windows to the needs: list, next to hook-utils-sync (they cover the same lib). If leaving it advisory for a few runs before gating is actually intended, that should be stated explicitly as a comment on the job — the same way this file documents every other deliberate exception (e.g. the ci-status fail-closed note directly above it) — rather than left implicit.

Fix this →

Everything else checks out

  • Scope discipline: the PR body's claim that lib/powershell/ps-command.sh has zero OSTYPE/cygpath/uname branches holds against the current tree — restricting the Windows job to hook-utils.test.sh alone is justified, not just asserted.
  • Runner pin: windows-2025 (not windows-latest) matches this repo's own forbiddenHostedRunnerLabels/approvedHostedRunnerLabels policy and the same pinning convention the Linux lanes already use for ubuntu-24.04.
  • Checkout: same pinned SHA (3d3c42e5... / v7.0.1) and persist-credentials: false as every other job.
  • shell: bash default is correct for a bash suite on a Windows runner (Git Bash ships on GitHub-hosted Windows images).
  • timeout-minutes: 20: reasonable given the PR's own ~2s/invocation estimate against a 1525-line suite smaller than the 319-case suite the estimate was based on.
  • Comment block: verbose but matches this file's established style of documenting why a lane exists and its boundaries, including a "keep this job small" guardrail against future scope creep — a good call given the file's stated policy against lane accretion.
  • No .claude/plugin-contract surface touched — CLAUDE.md's fresh-docs mandate doesn't apply (pure CI workflow change, no plugin manifest/hook-contract/skill-frontmatter change).
  • Security-relevant aspects (permissions, injection surface, secrets) are out of scope for this lane per REVIEW.md — left to the security lane, which has already reviewed this PR and found nothing.

Branch ·

hook-utils-windows was absent from the ci-status needs graph. ci-status
is the single required check and derives its verdict from
join(needs.*.result), so the Windows lane was informational: a
Windows-only regression in lib/hook-utils.sh would go red while the
required aggregate reported success — the exact hole the job comment
claimed to close.

Adding it to needs is sufficient, since the aggregate reads the needs
graph rather than a hand-maintained list. Verified no job in that graph
carries a job-level `if:`, so none can report `skipped` into the
`case` arm that treats a non-success result as a failure.

The job comment now says where the gating actually comes from, so the
next lane added here does not repeat this.

runner-policy passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 55s —— View job


Code Review

  • Gather context (diff, CLAUDE.md, prior review threads)
  • Check whether the previously-flagged ci-status gating gap is now fixed
  • Review remaining diff for correctness/maintainability
  • Post findings

Verdict: the gap flagged in every prior review pass on this PR (mine and Codex's) is fixed as of dab65cd. The job now actually gates.

Previously-flagged gap: resolved

hook-utils-windows is now in ci-status's needs: list at ci.yml#L1004, alongside hook-utils-sync. The aggregate at L1039 joins needs.*.result and fails closed (!cancelled() guard, non-success result → exit 1), so a red Windows leg now blocks the required check as the PR body originally claimed. Confirmed this is the only functional change in the commit — the diff (5662d66..dab65cd) is the one-line needs: addition plus an updated comment block on the job (L259-L285) that now explicitly states the dependency between the job existing and it being listed in the needs graph — a good addition, since it documents the exact failure mode that was just fixed, guarding against regression.

Everything else holds from prior passes

  • Scope discipline: restricting the Windows job to hook-utils.test.sh alone is still justified — lib/powershell/ps-command.sh has zero OSTYPE/cygpath/uname branches, confirmed against the current tree.
  • Runner pin: windows-2025, not windows-latest — matches the repo's runner-policy/policy.json approvedHostedRunnerLabels/forbiddenHostedRunnerLabels, consistent with how the Linux lanes pin ubuntu-24.04.
  • Checkout: same pinned SHA (3d3c42e5... / v7.0.1) and persist-credentials: false as every other job in the file.
  • shell: bash default is correct for a bash suite on a Windows runner (Git Bash ships on GitHub-hosted Windows images).
  • timeout-minutes: 20: reasonable given the PR's own ~2s/invocation estimate against a 1525-line suite, smaller than the 319-case suite the estimate was based on.
  • Comment block: verbose but consistent with this file's convention of documenting why each lane exists and its boundaries, including a "keep this job small" guardrail against future scope creep.
  • No .claude/plugin-contract surface touched — CLAUDE.md's fresh-docs mandate doesn't apply (pure CI workflow change).
  • Security aspects (permissions, injection surface, secrets) are out of scope for this lane per the repo's review-lane split — already covered by the security lane, which found nothing across all three commits.

No outstanding findings.

Branch ·

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 55s —— View job


Security Review

  • Fetch latest PR diff (post-dab65cd changes)
  • Review new/changed lines for security issues
  • Post findings

Scope: this PR's only change remains .github/workflows/ci.yml. Since the last security pass, one new commit landed — dab65cd — which addresses the "not actually gating" finding from the code-review lane.

No security issues found.

dab65cd's delta is two hunks, both non-security:

  • Adds - hook-utils-windows to ci-status's needs: list. This is a control-flow fix, not a permission or trust-boundary change — it makes the required-check aggregation wait on the new job's result rather than ignoring it. If anything it closes a gap (a red Windows lane could previously merge clean), so there's no new risk surface introduced by widening what's required.
  • Rewords the job's explanatory comment block at L266-L270 to state where the gating actually comes from. Comment-only, no executable change.

Re-confirmed against the categories in scope for this lane, across the full cumulative diff (f309474..dab65cd):

  • Trigger/permission context — still pull_request, top-level permissions: contents: read unchanged. ci-status's own permissions block is untouched by this commit.
  • Script injection via the github context — no ${{ github.* }} interpolation anywhere in the touched lines; the only run: step (bash lib/hook-utils.test.sh) is unchanged from the prior review.
  • Checkout hygiene — unchanged: pinned actions/checkout SHA, persist-credentials: false.
  • Supply-chain/pin verification — no action or runner-image pins touched by this commit (that was 5662d66, already reviewed clean). Left to zizmor's advisory lane per instructions.

No CRITICAL, IMPORTANT, or SUGGESTION findings.

Branch ·

@kyle-sexton
kyle-sexton merged commit a18e3ce into main Aug 8, 2026
33 checks passed
@kyle-sexton
kyle-sexton deleted the ci/windows-hook-utils-job branch August 8, 2026 05:24
kyle-sexton added a commit that referenced this pull request Aug 8, 2026
)

Closes #1978

## Summary

Every hook in this marketplace sources `lib/hook-utils.sh`, and
buffering the hook payload spawned
three external processes to do work bash can do in-process. On Windows
Git Bash, where process
creation is `fork()` emulation, each spawn costs roughly 140 ms — paid
on every tool call, in all 16
plugins that carry the library.

## Fix

- **`hook::resolve_read_slice`**: the `awk` float division becomes
fixed-point shell arithmetic,
printing the same three-decimal form `read -t` is given. `printf -v`,
not `$( )`, because a command
substitution forks the shell even for a builtin — the fork is the cost
being removed.
- **`hook::buffer_stdin`**: `printf | tr -d '\r'` becomes
`${input//$'\r'/}`, and the post-loop
`jq -e .` validity probe is skipped when `hook::json_complete` already
parsed the identical
CR-stripped buffer with jq inside the read loop. `json_complete` returns
non-zero both for an
incomplete buffer and for absent/broken jq, so the flag is set only on
its success path and the
  jq-absent fail-open is untouched.
- **New `hook::jq_fields`**: extracts several fields from one payload in
a single jq process, for
hooks that read two or three fields from the same envelope and currently
pay a fork plus an exec
for each. It uses `// ""` rather than `// empty` so an absent field
keeps its slot instead of
silently shifting every later index onto the wrong filter, reads
NUL-separated values through a
process substitution (command substitution strips NUL), and strips CR
**after** the read — the
Windows jq build writes stdout in text mode and expands every LF it
emits to CRLF, so a value
  cleaned inside jq arrives dirty anyway.

No hook call sites change in this PR. The plugins that read a second
field already gate it behind an
early exit or a telemetry probe, so converting them would add work on
the common path; the batch
helper's win is in the guardrails git guards, which read
`.tool_input.command` and `.tool_name`
unconditionally — and those files are in flight in #1974. The helper
ships now because the lib sync
gate makes every library change cost a version bump in all 16 carrying
plugins; adding it later would
pay that a second time.

## Verification

**Measured, quiet box, 15 alternating pairs** of the same
`block-dangerous-git` invocation against
each library version (alternating so machine-load drift hits both arms
equally):

| lib | mean | min | max |
| --- | --- | --- | --- |
| `main` | 1672 ms | 1316 ms | 2443 ms |
| this branch | 1401 ms | 1120 ms | 1760 ms |

~270 ms per invocation, and the slow tail shrinks with the mean. That is
less than the
3 × 140 ms the spawn-count model predicts; the measured number is the
one to trust.

**Gates run locally:**

- `lib/hook-utils.test.sh` — new coverage for the slice format
(including the fallbacks a
non-numeric bound and a `0.000` quotient must take) and for
`hook::jq_fields` (multi-line and
CR-carrying values, absent-field slot retention, unparsable payload,
no-filter call,
non-string values). The two `buffer_stdin` timing assertions that fail
intermittently here fail
the same way on `main` (1–3 failures per run on both sides) — they are
wall-clock-ceiling tests
  on a loaded Windows box, the same class as the ceilings tracked for
  `block-noncanonical-commit.test.sh`.
- `plugins/guardrails/hooks/block-dangerous-git.test.sh` — the black-box
hook contract suite, run
serially (never concurrently: its wall-clock assertions fail spuriously
under parallelism).
- `scripts/sync-hook-utils.sh --check` — all 16 plugin copies match.
- `scripts/sync-hook-utils.sh --check-bump origin/main` — every carrying
plugin bumped.
- `scripts/check-changelog-parity.sh --check-bump origin/main` and
`--check-order`.
- `scripts/check-shell-portability.sh --paths`, `shellcheck -x`, `shfmt
-d -i 2`,
  `markdownlint-cli2`, `scripts/check-manifest-duplicate-keys.py`.

**Fresh-docs mandate**: no WebFetch was required for this change and
none was performed. The edit is
internal implementation of a shell library — it touches no hook contract
surface, no manifest field
beyond the mechanical `version` bumps the sync gate itself demands, and
no documented harness
behavior. The 16 touched manifests are version lines only.

## Related

- Refs #1974 — carries the guardrails PowerShell false-positive fix and
touches
`plugins/guardrails/CHANGELOG.md` and `plugin.json`. Both PRs bump
guardrails to `0.19.1`, so
  whichever merges second needs a one-line rebase onto `0.19.2`.
- Refs #1975 — adds the Windows CI job for `lib/hook-utils.test.sh`; it
is the coverage that would
  have caught a Windows-only regression in this file.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant