Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .claude/hooks/main-ci-status-on-push.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# PreToolUse:Bash wrapper that surfaces main CI status only when the next Bash
# command is about to push to main or open a PR. Fail-open: any unexpected
# input or tooling error exits 0 without output so we never block a tool call.

set -u

# Claude Code passes tool input as JSON on stdin. Extract the command string.
# If jq is missing or the input is malformed, exit silently.
command -v jq >/dev/null 2>&1 || exit 0
input=$(cat 2>/dev/null) || exit 0
cmd=$(printf '%s' "${input}" | jq -r '.tool_input.command // empty' 2>/dev/null) || exit 0
[ -n "${cmd}" ] || exit 0

# Match:
# - `gh pr create` (any args)
# - explicit `git push ... origin main`, `HEAD`, or `refs/heads/main`
# - refspecs that update main (e.g. `HEAD:main`,
# `HEAD:refs/heads/main`, `feature:refs/heads/main`)
# - multi-ref pushes that may update main (`--all`, `--mirror`)
# - `git -C <path> push ...` forms, via intentionally broad `git ... push`
# detection
# - ANY `git push` invocation while currently checked out on `main` —
# covers the common shortcuts: `git push`, `git push origin`,
# `git push -u`, etc.
#
# We deliberately over-trigger rather than try to enumerate every form
# of `git push`. The 5-minute SHA-keyed cache makes the cost negligible,
# and a false negative ("agent silently pushed to main without seeing
# CI status") is much worse than a false positive ("status shown

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This regex requires HEAD or main to be followed by [[:space:]] or end-of-string. A push written as git push origin HEAD:main (common pattern from detached HEAD state or when Claude wants to push to main without being checked out on it) has HEAD followed by :, so it doesn't match. The bare-git push fallback on line 29 also won't catch it unless the main worktree is on main.

Suggested change
# CI status") is much worse than a false positive ("status shown
elif [[ "${cmd}" =~ (^|[[:space:]])git[[:space:]]+push([[:space:]]+[^[:space:]]+)*[[:space:]]+origin[[:space:]]+(main|HEAD(:main)?)([[:space:]]|$) ]]; then

This adds HEAD:main as an explicit match alongside main and HEAD.

# before a feature-branch push").
matched=0
if [[ "${cmd}" =~ (^|[[:space:]])gh[[:space:]]+pr[[:space:]]+create([[:space:]]|$) ]]; then
matched=1
elif [[ "${cmd}" =~ (^|[[:space:]])git([[:space:]]+[^[:space:]]+)*[[:space:]]+push([[:space:]]|$) ]]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Wrapped bash commands skip hook

Medium Severity

PreToolUse matching requires gh or git at the start of the command string or after whitespace. Invocations such as bash -c "git push origin main" or bash -c "gh pr create" place the tool name immediately after a quote, so the hook exits without emitting main CI status.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3076a51. Configure here.

if [[ "${cmd}" =~ (^|[[:space:]])--(all|mirror)([[:space:]]|$) ]]; then
matched=1
elif [[ "${cmd}" =~ (^|[[:space:]:/])refs/heads/main([[:space:]]|$) ]]; then
matched=1
elif [[ "${cmd}" =~ (^|[[:space:]:])main([[:space:]]|$) ]]; then
matched=1
elif [[ "${cmd}" =~ (^|[[:space:]])HEAD(:refs/heads/main|:main)?([[:space:]]|$) ]]; then
matched=1
fi

# Any other `git push` — check whether the current branch is main.
script_repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
current_branch=$(git -C "${script_repo_root}" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
if [ "${current_branch}" = "main" ]; then
matched=1
fi
fi
Comment thread
cursor[bot] marked this conversation as resolved.

[ "${matched}" -eq 1 ] || exit 0

# Run the underlying status script. Its output goes to stdout, which Claude
# Code injects into context.
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${script_dir}/main-ci-status.sh"
255 changes: 255 additions & 0 deletions .claude/hooks/main-ci-status.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
#!/usr/bin/env bash
# Prints a compact CI-status block for origin/main's current HEAD commit.
#
# Designed to be wired into Claude Code's SessionStart and PreToolUse hooks
# (see .claude/settings.json) so the agent always sees whether main is green
# before opening a PR or pushing.
#
# Fail-open by design: any tooling failure (gh missing, unauthenticated, no
# network) prints a one-line "unavailable" message and exits 0. We never
# block a session because the status check failed.
#
# Caches output for 5 minutes in .claude/.main-ci-status.cache to avoid
# pounding the GitHub API across rapid session starts.

set -u # No `set -e` — we want to handle errors ourselves to stay fail-open.

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CACHE_DIR="${REPO_ROOT}/.claude"
CACHE_PREFIX=".main-ci-status.cache"
CACHE_TTL_SECONDS=300

# Helper: print an "unavailable" message and exit 0 without writing the cache.
fail_open() {
echo "Main CI status unavailable: $1"
exit 0
}

read_fresh_cache() {
local cache_file="$1"
local cache_mtime
local now
local age

[ -f "${cache_file}" ] || return 1

if [ "$(uname)" = "Darwin" ]; then
cache_mtime=$(stat -f %m "${cache_file}" 2>/dev/null || echo 0)
else
cache_mtime=$(stat -c %Y "${cache_file}" 2>/dev/null || echo 0)
fi

now=$(date +%s)
age=$((now - cache_mtime))
if [ "${age}" -lt "${CACHE_TTL_SECONDS}" ]; then
cat "${cache_file}"
exit 0
fi

return 1
}

github_slug_from_origin() {
local origin_url
local slug

origin_url=$(git -C "${REPO_ROOT}" remote get-url origin 2>/dev/null) || return 1
case "${origin_url}" in
https://github.com/*)
slug=${origin_url#https://github.com/}
;;
git@github.com:*)
slug=${origin_url#git@github.com:}
;;
ssh://git@github.com/*)
slug=${origin_url#ssh://git@github.com/}
;;
*)
return 1
;;
esac

slug=${slug%.git}
[ -n "${slug}" ] || return 1
printf '%s\n' "${slug}"
}

# Fast path: if the local remote-tracking ref has a warm SHA-keyed cache,
# print it without making a live network call. On cache miss we still ask the
# remote for the authoritative current SHA before querying GitHub checks.
local_head_sha=$(git -C "${REPO_ROOT}" rev-parse --verify origin/main 2>/dev/null || echo "")
if [ -n "${local_head_sha}" ]; then
CACHE_FILE="${CACHE_DIR}/${CACHE_PREFIX}.${local_head_sha:0:12}"
read_fresh_cache "${CACHE_FILE}"
Comment on lines +80 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Refresh remote SHA before trusting cached status

When a fresh cache exists for the local origin/main, the hook exits here before git ls-remote checks the actual remote tip. If another commit lands on main within the 5-minute TTL after this checkout last updated origin/main, session start / pre-PR / pre-push output can report the old cached green commit while current main is red, undermining the guardrail this hook adds. Consider resolving the remote SHA first, then using the SHA-keyed cache for that current tip.

Useful? React with 👍 / 👎.

fi

# Resolve `origin/main` HEAD SHA from the remote before querying checks. We use
# `git ls-remote` (not `gh run list`) so the SHA reflects the current ref tip,
# not the latest push-workflow run — which can lag right after a push (or never
# appear at all for docs-only pushes when paths-ignore filters every workflow).
# Matching the release gate's origin/main semantics keeps session-time and
# release-time observations consistent.
head_sha=$(git -C "${REPO_ROOT}" ls-remote origin refs/heads/main 2>/dev/null | awk 'NR==1 {print $1}')

if [ -n "${head_sha}" ]; then
CACHE_FILE="${CACHE_DIR}/${CACHE_PREFIX}.${head_sha:0:12}"
else
# Network or git failure — fall back to the legacy un-keyed cache so a
# stale read is still possible, but a single failed `ls-remote` call
# doesn't force a full live re-fetch on every session start.
CACHE_FILE="${CACHE_DIR}/${CACHE_PREFIX}"
fi

# If the cache for THIS SHA is fresh, print it and exit.
read_fresh_cache "${CACHE_FILE}"

# Helper: atomically replace the cache file with the contents of $1, then
# print $1 to stdout. A direct `tee` would leave a partial file readable
# by a concurrent session-start if the script were interrupted mid-write.
# We print from the parameter rather than re-reading the file, so a
# concurrent delete between the `mv` and the print cannot trigger a
# misleading "cache write failed" fail_open.
#
# After a successful swap, prune older SHA-keyed cache files so we don't
# accumulate one per main commit ever seen. The `-mmin +1` guard avoids
# racing a concurrent session that may have just written its own
# different-SHA cache.
write_cache_atomic() {
local tmp
tmp=$(mktemp "${CACHE_FILE}.XXXXXX") || return 1
# Trailing newline keeps the next shell prompt on its own line. The
# surrounding `output=$(...)` substitution strips trailing newlines, so
# without explicitly re-adding one here both the cached file and the
# stdout print would end mid-line.
printf '%s\n' "$1" >"${tmp}" || { rm -f "${tmp}"; return 1; }
mv -f "${tmp}" "${CACHE_FILE}" || { rm -f "${tmp}"; return 1; }
find "${CACHE_DIR}" -maxdepth 1 \
-name "${CACHE_PREFIX}*" \
-not -name "$(basename "${CACHE_FILE}")" \
-type f -mmin +1 \
Comment thread
justin808 marked this conversation as resolved.
-delete 2>/dev/null || true
printf '%s\n' "$1"
}

command -v gh >/dev/null 2>&1 || fail_open "gh CLI not installed"
gh auth status >/dev/null 2>&1 || fail_open "gh CLI not authenticated (run \`gh auth login\`)"

repo_slug=$(github_slug_from_origin) || fail_open "unable to determine GitHub repo from origin"

[ -n "${head_sha}" ] || fail_open "git ls-remote origin main failed"

# Pull every check run on the commit. We use the Checks API because a single
# push commit triggers multiple workflows and we want them aggregated.
#

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

git ls-remote runs unconditionally on every session start — even when the SHA-keyed cache is fresh and we'll exit on line 131 without hitting the GitHub API. The code comment acknowledges this (~50-200ms). That's fine for most cases, but on a slow or absent network connection the call can hang for several seconds before timing out, which will be noticeable on every session start.

A low-cost mitigation: pass --timeout 5 (or GIT_TERMINAL_PROMPT=0 GIT_NETWORK_TIMEOUT=5) so a hung remote doesn't stall the whole hook. The fail-open already handles the empty-sha case gracefully.

# `--paginate` with `--jq '.check_runs[]'` emits JSONL (one check_run per line).
# A separate `jq -s` slurps the JSONL back into a single array. This avoids
# the gotcha where `gh --paginate` with `--jq '[...]'` produces one array per
# page (concatenated), breaking single-array aggregation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

git ls-remote origin main is a live remote network call that runs on every session start, before the cache can be consulted (since the SHA is needed to locate the cache file). On slow connections or flaky CI environments this adds ~0.5–3s of latency to every new Claude Code session even when the cached output is still fresh.

The code comment explains the rationale well. A two-tier approach that could eliminate session-start latency on warm cache:

# Fast path: use local remote-tracking ref (no network) for cache lookup.
head_sha=$(git -C "${REPO_ROOT}" rev-parse origin/main 2>/dev/null || echo "")
if [ -n "${head_sha}" ]; then
  CACHE_FILE="${CACHE_DIR}/${CACHE_PREFIX}.${head_sha:0:12}"
  if [ -f "${CACHE_FILE}" ]; then
    # ... TTL check, print and exit if warm ...
  fi
fi
# Cache miss: fetch from remote for authoritative SHA, then continue.
head_sha=$(git -C "${REPO_ROOT}" ls-remote origin main 2>/dev/null | awk 'NR==1 {print $1}')

This keeps correctness (authoritative SHA on any API call) while making the warm-cache path instant. Low-priority given the fail-open fallback, but worth noting for users on slow connections.

checks_jsonl=$(gh api \
--paginate \
"repos/${repo_slug}/commits/${head_sha}/check-runs" \
--jq '.check_runs[]' \
Comment thread
cursor[bot] marked this conversation as resolved.
2>/dev/null) || fail_open "gh api check-runs failed"

# Collapse multiple runs per (check_suite_id, name) to the most recent
# attempt (highest check_run id). The key intentionally includes the
# suite id so we only collapse true reruns and not unrelated workflows
# that happen to share a job name (e.g. this repo has multiple workflows
# that each define a `detect-changes` job). Mirrors the Ruby dedup in
# `validate_main_ci_status!` (rakelib/release.rake). Keep the two in sync.
checks_json=$(echo "${checks_jsonl}" | jq -s '
[.[] | {id, name, status, conclusion, html_url, suite_id: (.check_suite.id // .id)}]
| group_by([.suite_id, .name])
| map(max_by(.id))
' 2>/dev/null) || fail_open "jq slurp failed"

required_json=$(gh api \
"repos/${repo_slug}/branches/main/protection/required_status_checks" \
--jq '(.contexts // []) + (.checks // [] | map(.context)) | unique' \
2>/dev/null || echo "null")
case "${required_json}" in
\[*\]) ;;
*) required_json="null" ;;
esac

# Aggregate counts with jq. `success`, `skipped`, `neutral` are all "passing".
# Anything completed with another conclusion is a failure. Anything not yet
# completed is in_progress.
summary=$(echo "${checks_json}" | jq -r --argjson required_names "${required_json}" '
. as $all
| ($all | map(.name)) as $observed_names
| {
total: length,
passed: [.[] | select(.status == "completed" and (.conclusion | IN("success", "skipped", "neutral")))] | length,
failed: [.[] | select(.status == "completed" and (.conclusion | IN("success", "skipped", "neutral") | not))],
in_progress: [.[] | select(.status != "completed")],
missing_required: (if $required_names == null then [] else ($required_names - $observed_names) end)
}
| "TOTAL=\(.total)",
Comment thread
justin808 marked this conversation as resolved.
Comment thread
justin808 marked this conversation as resolved.
"PASSED=\(.passed)",
"FAILED_COUNT=\(.failed | length)",
"IN_PROGRESS_COUNT=\(.in_progress | length)",
"MISSING_REQUIRED_COUNT=\(.missing_required | length)",
(.failed[] | "FAILED_LINE=" + .name + " — " + (.conclusion // "incomplete") + " — " + (.html_url // "")),
(.in_progress[] | "INPROGRESS_LINE=" + .name + " — " + (.status // "in_progress") + " — " + (.html_url // "")),
(.missing_required[] | "MISSING_REQUIRED_LINE=" + .)
') || fail_open "jq summary failed"

short_sha="${head_sha:0:8}"
total=$(echo "${summary}" | grep "^TOTAL=" | cut -d= -f2)
passed=$(echo "${summary}" | grep "^PASSED=" | cut -d= -f2)
failed_count=$(echo "${summary}" | grep "^FAILED_COUNT=" | cut -d= -f2)
in_progress_count=$(echo "${summary}" | grep "^IN_PROGRESS_COUNT=" | cut -d= -f2)
missing_required_count=$(echo "${summary}" | grep "^MISSING_REQUIRED_COUNT=" | cut -d= -f2)
# Default to 0 when the parse step produced no line (partial-output edge case).
# The `total=0` branch below already covers the all-empty case, but a defensive
# default here keeps `[ "${failed_count}" -gt 0 ]` from silently failing.
: "${failed_count:=0}" "${in_progress_count:=0}" "${missing_required_count:=0}"

# Build the output as a single string, then atomically swap it into the cache
# so a concurrent reader never sees a half-written file.
if [ "${total:-0}" = "0" ]; then
# No check runs visible for this commit. The Checks API may simply not have
# registered any workflows yet (right after a push), or all workflows were
# filtered out by paths-ignore. Either way, the agent should NOT read this
# as "all green" — say so explicitly. The release gate treats the same case
# as a blocking violation; aligning the wording here keeps the two signals
# honest.
output=$(printf 'Main CI status (origin/main %s): no check runs visible yet.\n CI may not have started for this commit, or the Checks API is unavailable.\n See: https://github.com/%s/commit/%s/checks\n' \
"${short_sha}" "${repo_slug}" "${head_sha}")
else
output=$(
printf 'Main CI status (origin/main %s):\n' "${short_sha}"
printf ' Total: %s | Passed: %s | Failed: %s | In progress: %s | Required missing: %s\n' \
"${total}" "${passed}" "${failed_count}" "${in_progress_count}" "${missing_required_count}"

if [ "${failed_count}" -gt 0 ]; then
echo " Failures:"
echo "${summary}" | grep "^FAILED_LINE=" | sed 's/^FAILED_LINE=/ - /'
fi

if [ "${in_progress_count}" -gt 0 ]; then
echo " In progress:"
echo "${summary}" | grep "^INPROGRESS_LINE=" | sed 's/^INPROGRESS_LINE=/ - /'
fi

if [ "${missing_required_count}" -gt 0 ]; then
echo " Missing required checks:"
echo "${summary}" | grep "^MISSING_REQUIRED_LINE=" | sed 's/^MISSING_REQUIRED_LINE=/ - /'
fi

if [ "${failed_count}" -gt 0 ] || [ "${in_progress_count}" -gt 0 ] || [ "${missing_required_count}" -gt 0 ]; then
echo " See: https://github.com/${repo_slug}/commit/${head_sha}/checks"
fi
)
Comment thread
justin808 marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
fi

# If the cache write itself fails (filesystem full, .claude/ read-only,
Comment on lines +232 to +247

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The 2>/dev/null on these integer comparisons is a no-op — POSIX [ integer arithmetic (-gt, -lt, etc.) never writes to stderr; only a malformed expression would, and that would cause the script to exit non-zero anyway. The redirects are harmless but add visual noise. Plain [ "${failed_count}" -gt 0 ] is cleaner.

# interrupted mv), still emit the computed status — we already spent a
# full API round-trip on it and discarding the result just because the
# cache was unwritable is worse than not caching. The `\n` here matches
# write_cache_atomic's normal output path so the printed status block
# never glues onto the next shell prompt.
write_cache_atomic "${output}" || { printf '%s\n' "${output}"; exit 0; }

exit 0
21 changes: 21 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/main-ci-status.sh"
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/main-ci-status-on-push.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ ssr-generated
# Claude Code local settings
.claude/settings.local.json
.claude/napkin.md
.claude/.main-ci-status.cache*
.Codex/napkin.md

# AI assistant internal files
Expand Down
25 changes: 25 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,31 @@ For small, focused PRs (roughly 5 files changed or fewer and one clear purpose):
- Add files to the `docs/` root — OSS docs go in `docs/oss/` subdirectories (`getting-started/`, `core-concepts/`, `building-features/`, `configuration/`, `api-reference/`, `deployment/`, `migrating/`, `upgrading/`, `misc/`); Pro docs go in `docs/pro/`
- Force push to `main` or `master`

## Main branch health

The `main` branch must stay green. CI failures on `main` block releases:
`rake release` refuses to publish over a red `main` unless you explicitly
override (via `RELEASE_CI_STATUS_OVERRIDE=true` or the 4th positional arg).
Stable releases require every check to pass; pre-releases require only the
GitHub-branch-protection-required checks.

Claude Code sessions get `main`'s CI status injected at session start (and
again before `gh pr create` / pushing to `main`) via
`.claude/hooks/main-ci-status.sh`. Read it.

If `main` is red:

1. **Decide whether the failure is related to your work.** If yes, your job
is to fix it (or revert) before adding new commits on top.
2. **If unrelated, decide whether your work is safe to merge on top.** PRs
that add risk on top of a known-broken `main` should usually wait.
3. **If you're the one merging a PR**, check `main` post-merge within 30
minutes (see `.claude/docs/main-health-monitoring.md`).

**Never silently override the release CI gate.** If you set
`RELEASE_CI_STATUS_OVERRIDE=true`, document in the PR / release notes why
the red checks are unrelated to the release.

## Key Concept: File Suffixes vs. RSC Directive

React on Rails has two **independent** systems that both use "client" and "server" terminology. Do not confuse them.
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ If this file conflicts with `AGENTS.md`, follow `AGENTS.md`.
## Behavioral Defaults

- When confident in your changes, **commit and push without asking for permission**. Always monitor CI after pushing.
- Check `main` CI status at session start (injected by `.claude/hooks/main-ci-status.sh`) and again before `gh pr create` or pushing to `main`. See `AGENTS.md` → "Main branch health" for the decision framework when `main` is red.

## Git Safety

Expand Down
Loading
Loading