-
-
Notifications
You must be signed in to change notification settings - Fork 629
Refuse releases on red main and surface main CI status #3407
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
53d2286
5417c93
25c0269
a0cf43a
e2bf5da
b8ff2d4
34f55ab
a7be25d
ceb5ea1
f3e1571
ce0565e
ad0a639
3076a51
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| # 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wrapped bash commands skip hookMedium Severity PreToolUse matching requires 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 | ||
|
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" | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a fresh cache exists for the local 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 \ | ||
|
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. | ||
| # | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
A low-cost mitigation: pass |
||
| # `--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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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[]' \ | ||
|
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)", | ||
|
justin808 marked this conversation as resolved.
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 | ||
| ) | ||
|
justin808 marked this conversation as resolved.
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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| # 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 | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This regex requires
HEADormainto be followed by[[:space:]]or end-of-string. A push written asgit push origin HEAD:main(common pattern from detached HEAD state or when Claude wants to push to main without being checked out on it) hasHEADfollowed by:, so it doesn't match. The bare-git pushfallback on line 29 also won't catch it unless the main worktree is onmain.This adds
HEAD:mainas an explicit match alongsidemainandHEAD.