From cc8be750a4068ee0ad44f9c8da16aedf93b41c58 Mon Sep 17 00:00:00 2001 From: Jonathan Zhang Date: Sun, 3 May 2026 14:49:57 -0700 Subject: [PATCH 1/3] feat(automerge): add risk-bypass via approval label or Codex pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new bypass paths for the risk-tier gate on Claude-authored PRs. Today the gate forces manual click-merge on anything touching auth, secrets, migrations, billing, or production infra (Dockerfile / CI workflows / IaC / deploy scripts) — these add escape hatches that preserve the trust signal while removing the click-merge round-trip. **Option A — `auto-merge-approved` label.** A new input `risk_bypass_label` (default `auto-merge-approved`). When that label is present on a Claude-authored risky PR, the risk gate is overridden and `gh pr merge --auto --squash` is enabled. Distinct from the existing `auto-merge` label, which is a Claude- authorship DETECTION signal — that one only marks the PR as Claude's, risk gate still runs. Workflow: human sees the "blocked — risk-tier paths touched" comment, applies the label from the PR list page (one click, no PR-detail navigation, no merge-button click). The caller already listens on `pull_request.labeled`, so the workflow re-runs immediately and flips auto-merge on. **Option B — Codex Review SUCCESS.** A new input `codex_check_name` (default `review / Codex Review`). When that status check is SUCCESS on the PR's head SHA, the risk gate is overridden. Reads from the check-runs API first, falls back to the commit-status API for callers that post Codex output as a status rather than a check-run. Bypass triggers naturally on the next workflow run after Codex completes — i.e. on `pull_request.synchronize` (any push) or `pull_request.labeled` (any label change). Fully-automatic re-trigger via `workflow_run` was scoped out for now (workflow_run events come without `pull_request` payload, requiring a separate PR- lookup job that wouldn't fit cleanly into the existing reusable shape — follow-up for v1.1). The "blocked" comment is updated to mention both bypass options so the human sees the path forward inline. Marker string unchanged so existing comment-update logic continues to find and edit prior comments rather than spamming new ones. Lint clean: `actionlint` passes, no shellcheck issues. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/claude-author-automerge.yml | 118 ++++++++++++++++-- 1 file changed, 111 insertions(+), 7 deletions(-) diff --git a/.github/workflows/claude-author-automerge.yml b/.github/workflows/claude-author-automerge.yml index f4df383..d0a3039 100644 --- a/.github/workflows/claude-author-automerge.yml +++ b/.github/workflows/claude-author-automerge.yml @@ -33,6 +33,16 @@ on: required: false type: string default: "auto-merge" + risk_bypass_label: + description: "Label that bypasses the risk-tier path check on Claude-authored PRs (Option A). Apply from the PR list with one click instead of click-merging through the PR detail page." + required: false + type: string + default: "auto-merge-approved" + codex_check_name: + description: "Status-check name for Codex Review. When this check is SUCCESS on a Claude-authored PR, the risk-tier check is bypassed (Option B). Set empty to disable Codex-trusted bypass." + required: false + type: string + default: "review / Codex Review" jobs: automerge: @@ -140,24 +150,106 @@ jobs: echo "No risk-tier paths matched." fi + # Option A: explicit human approval via label. + # When a Claude PR matches the risk-tier classifier, you can apply the + # `auto-merge-approved` label from the PR list page (one click, no PR + # detail navigation) to bypass the risk gate. Confirms "yes I read it, + # auto-merge it" without requiring the actual click-merge round-trip. + - name: Check risk bypass label (Option A) + id: bypass_label + if: steps.detect.outputs.claude_authored == '1' && steps.risk.outputs.risky == '1' + env: + BYPASS_LABEL: ${{ inputs.risk_bypass_label }} + LABELS_JSON: ${{ toJson(github.event.pull_request.labels) }} + run: | + set -euo pipefail + if [ -z "$BYPASS_LABEL" ]; then + echo "bypass=0" >> "$GITHUB_OUTPUT" + echo "Bypass label disabled (input empty)." + exit 0 + fi + if echo "$LABELS_JSON" | jq -e --arg L "$BYPASS_LABEL" '.[] | select(.name == $L)' >/dev/null 2>&1; then + echo "bypass=1" >> "$GITHUB_OUTPUT" + echo "Bypass label '$BYPASS_LABEL' present — risk-tier gate overridden." + else + echo "bypass=0" >> "$GITHUB_OUTPUT" + fi + + # Option B: Codex review trust. + # When the configured Codex status check is SUCCESS, treat the PR as + # second-model-reviewed and bypass the risk-tier gate. The check name + # is configurable so callers can wire whatever Codex caller they + # use (default "review / Codex Review", which matches the + # pr-codex-review.yml caller in this same repo). + - name: Check Codex Review status (Option B) + id: bypass_codex + if: steps.detect.outputs.claude_authored == '1' && steps.risk.outputs.risky == '1' && steps.bypass_label.outputs.bypass != '1' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + CHECK_NAME: ${{ inputs.codex_check_name }} + run: | + set -euo pipefail + if [ -z "$CHECK_NAME" ]; then + echo "bypass=0" >> "$GITHUB_OUTPUT" + echo "Codex bypass disabled (codex_check_name input empty)." + exit 0 + fi + # Look at the head SHA's check-runs and the PR's status checks. Codex + # callers commonly post as a check-run; some post as a commit status. + # Match either. We require an explicit SUCCESS — neutral / pending / + # failure all leave the risk gate in place. + codex_state=$(gh api "/repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \ + --jq ".check_runs[] | select(.name == \"$CHECK_NAME\") | .conclusion" | head -1) + if [ -z "$codex_state" ]; then + codex_state=$(gh api "/repos/${{ github.repository }}/commits/$HEAD_SHA/status" \ + --jq ".statuses[] | select(.context == \"$CHECK_NAME\") | .state" | head -1) + fi + echo "Codex check '$CHECK_NAME' state: '${codex_state:-}'" + if [ "$codex_state" = "success" ] || [ "$codex_state" = "SUCCESS" ]; then + echo "bypass=1" >> "$GITHUB_OUTPUT" + echo "Codex Review passed — risk-tier gate overridden." + else + echo "bypass=0" >> "$GITHUB_OUTPUT" + fi + - name: Enable auto-merge - if: steps.detect.outputs.claude_authored == '1' && steps.risk.outputs.risky != '1' + if: | + steps.detect.outputs.claude_authored == '1' && ( + steps.risk.outputs.risky != '1' || + steps.bypass_label.outputs.bypass == '1' || + steps.bypass_codex.outputs.bypass == '1' + ) env: GH_TOKEN: ${{ github.token }} PR_URL: ${{ github.event.pull_request.html_url }} METHOD: ${{ inputs.merge_method }} REASON: ${{ steps.detect.outputs.reason }} + RISKY: ${{ steps.risk.outputs.risky }} + BYPASS_LABEL: ${{ steps.bypass_label.outputs.bypass }} + BYPASS_CODEX: ${{ steps.bypass_codex.outputs.bypass }} run: | set -euo pipefail - echo "Enabling auto-merge ($METHOD) — detection: $REASON" + if [ "$BYPASS_LABEL" = "1" ]; then + echo "Enabling auto-merge ($METHOD) — detection: $REASON; risk bypassed via approval label" + elif [ "$BYPASS_CODEX" = "1" ]; then + echo "Enabling auto-merge ($METHOD) — detection: $REASON; risk bypassed via Codex pass" + else + echo "Enabling auto-merge ($METHOD) — detection: $REASON" + fi gh pr merge --auto --"$METHOD" "$PR_URL" - name: Comment + skip when risky - if: steps.detect.outputs.claude_authored == '1' && steps.risk.outputs.risky == '1' + if: | + steps.detect.outputs.claude_authored == '1' && steps.risk.outputs.risky == '1' && + steps.bypass_label.outputs.bypass != '1' && steps.bypass_codex.outputs.bypass != '1' env: GH_TOKEN: ${{ github.token }} PR: ${{ github.event.pull_request.number }} MATCHED: ${{ steps.risk.outputs.matched }} + BYPASS_LABEL: ${{ inputs.risk_bypass_label }} + CODEX_CHECK: ${{ inputs.codex_check_name }} run: | set -euo pipefail marker="" @@ -169,16 +261,28 @@ jobs: echo "" echo "This Claude-authored PR modifies files matching the risk-tier patterns" echo "defined in the global CLAUDE.md policy (auth / secrets / migrations /" - echo "billing / production infra). Manual click-merge required." + echo "billing / production infra). Manual click-merge required, OR use one of" + echo "the bypass paths below." echo "" echo "Matched files:" echo '```' printf "%b" "$MATCHED" echo '```' echo "" - echo "Override only after review: add the \`auto-merge\` label. The risk-tier check" - echo "still runs — override does not bypass it. If a path is misclassified, fix it" - echo "in \`topcoder1/ci-workflows/.github/workflows/claude-author-automerge.yml\`." + echo "**Bypass options (no PR detail navigation needed):**" + echo "" + if [ -n "$BYPASS_LABEL" ]; then + echo "- Apply the \`$BYPASS_LABEL\` label to this PR. The workflow will re-run on" + echo " the label event and enable auto-merge. One click from the PR list page." + fi + if [ -n "$CODEX_CHECK" ]; then + echo "- Wait for the \`$CODEX_CHECK\` status check to pass. If Codex Review is" + echo " installed on this repo and it returns SUCCESS, the workflow auto-bypasses" + echo " the risk gate on its next run (e.g. on push of a fixup commit)." + fi + echo "" + echo "If a path is misclassified, fix the regex in" + echo "\`topcoder1/ci-workflows/.github/workflows/claude-author-automerge.yml\`." } > /tmp/automerge-comment.md if [ -n "$existing" ]; then gh api -X PATCH "/repos/${{ github.repository }}/issues/comments/$existing" \ From 97ab808bc3f2f25b9735f401c45f125515e1a892 Mon Sep 17 00:00:00 2001 From: Jonathan Zhang Date: Sun, 3 May 2026 15:15:29 -0700 Subject: [PATCH 2/3] fix(automerge): use jq --arg for Codex bypass query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught by Claude Review on this PR. The previous gh-api-with-inline-jq form interpolated $CHECK_NAME into the jq filter via shell expansion: gh api /commits/$SHA/check-runs --jq ".check_runs[] | select(.name == \"$CHECK_NAME\") ..." If CHECK_NAME ever contained a double-quote (custom callers, future variants), the shell interpolation would produce a broken jq filter that silently returned nothing — bypass=0 with no warning. Default "review / Codex Review" works in practice but the failure mode is exactly the kind of "silent fall-through" the Webcat 2026-04-27 incident wrote up as a category to avoid. Fix: pipe gh api through `jq --arg name "$CHECK_NAME"` to bind the variable safely regardless of contents. Same shape applied to both the check-runs query and the commit-status fallback. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/claude-author-automerge.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-author-automerge.yml b/.github/workflows/claude-author-automerge.yml index d0a3039..c0543ba 100644 --- a/.github/workflows/claude-author-automerge.yml +++ b/.github/workflows/claude-author-automerge.yml @@ -200,11 +200,20 @@ jobs: # callers commonly post as a check-run; some post as a commit status. # Match either. We require an explicit SUCCESS — neutral / pending / # failure all leave the risk gate in place. + # + # Pipe through `jq --arg` instead of `gh api --jq` with shell- + # interpolated quotes. If CHECK_NAME ever contains a double-quote + # (custom callers, future variants), inline interpolation would + # produce a broken jq filter that silently returns nothing — + # bypass=0, no warning. `--arg` binds the variable safely + # regardless of contents. Caught by Claude Review on PR #25. codex_state=$(gh api "/repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \ - --jq ".check_runs[] | select(.name == \"$CHECK_NAME\") | .conclusion" | head -1) + | jq -r --arg name "$CHECK_NAME" \ + '.check_runs[] | select(.name == $name) | .conclusion' | head -1) if [ -z "$codex_state" ]; then codex_state=$(gh api "/repos/${{ github.repository }}/commits/$HEAD_SHA/status" \ - --jq ".statuses[] | select(.context == \"$CHECK_NAME\") | .state" | head -1) + | jq -r --arg name "$CHECK_NAME" \ + '.statuses[] | select(.context == $name) | .state' | head -1) fi echo "Codex check '$CHECK_NAME' state: '${codex_state:-}'" if [ "$codex_state" = "success" ] || [ "$codex_state" = "SUCCESS" ]; then From 4f858350f77ec4515dfd4e5ba653a817cbe241c5 Mon Sep 17 00:00:00 2001 From: Jonathan Zhang Date: Sun, 3 May 2026 15:21:23 -0700 Subject: [PATCH 3/3] feat(lint): prettier --check only on changed files in PR mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On pull_request events, intersect the markdown_glob with files in the PR diff (against base ref) and run prettier --check only on those. On push events (or when prettier_changed_only=false), keep the full repo scan to catch drift. Why: today, one badly-formatted file on main poisons every subsequent PR's `lint / prettier (markdown)` check across the fleet. Discovered during topcoder1/wxa_webcat#179 — the lint job flagged 4 files (2 authored by the PR, 2 pre-existing on main). Changed-files mode keeps PR feedback fast and prevents the cascade. New input: prettier_changed_only: bool, default true Implementation notes: - Uses bash extglob+globstar (Ubuntu runner has bash 5+) for path matching; no 3rd-party action dependency. - Accepts the glob with and without leading `**/` so `**/*.md` matches both `foo.md` and `dir/foo.md`. - Filename list is NUL-delimited through xargs so paths with spaces don't split into multiple args. - Push-to-main always runs full scan — drift gets caught even if no PR happens to touch the offending file. Tested: - actionlint passes - bash glob matching against synthetic file list (incl. spaces) filters correctly: keeps .md, drops .py/.yml, preserves spaces --- .github/workflows/lint.yml | 96 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 4 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index dfade34..7381924 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -78,6 +78,11 @@ on: required: false type: string default: "." + prettier_changed_only: + description: "On pull_request events, run prettier --check only on files in the diff (intersected with markdown_glob). On push events, always scan the full glob to catch drift. Default true. Set false to scan the full glob on every event." + required: false + type: boolean + default: true permissions: contents: read @@ -124,6 +129,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + # Need history to compute the PR diff against the base ref. Shallow + # clone (depth=1, the default) lacks the merge-base. fetch-depth=0 + # is heavy but reliable; for very large repos, switch to a + # `git fetch origin --depth=N` strategy in a follow-up. + fetch-depth: 0 - uses: actions/setup-node@v4 with: @@ -149,16 +160,93 @@ jobs: fi shell: bash - - name: prettier --check ${{ inputs.markdown_glob || '**/*.md' }} + - name: Compute file list for prettier + id: files + # Two modes: + # 1. pull_request + prettier_changed_only=true (default): + # diff against base ref → intersect with markdown_glob → check + # only those files. Keeps PR feedback fast and prevents one + # stale file on main from poisoning every subsequent PR. + # 2. push (always) OR pull_request with prettier_changed_only=false: + # check the full markdown_glob. Push-to-main runs catch drift + # that PR-mode missed (e.g. a file modified on main directly). + # Output `files` is a space-separated list, or empty if no matches. + run: | + GLOB="${{ inputs.markdown_glob || '**/*.md' }}" + CHANGED_ONLY="${{ inputs.prettier_changed_only || 'true' }}" + EVENT="${{ github.event_name }}" + + if [ "$EVENT" = "pull_request" ] && [ "$CHANGED_ONLY" != "false" ]; then + BASE="${{ github.event.pull_request.base.ref }}" + git fetch --quiet --no-tags origin "$BASE" + # diff: base merge-base -> HEAD. Filter Added/Modified/Renamed + # (drop Deleted; prettier --check on a deleted file would error). + CHANGED=$(git diff --name-only --diff-filter=AMR "origin/$BASE...HEAD") + # Intersect changed files with the glob using bash extglob+globstar. + # We also accept the glob without a leading "**/" so that + # "**/*.md" matches both "foo.md" and "dir/foo.md". + shopt -s extglob globstar nullglob + FILTERED="" + ALT_GLOB="$GLOB" + if [ "${GLOB#**/}" != "$GLOB" ]; then + ALT_GLOB="${GLOB#**/}" + fi + while IFS= read -r f; do + [ -z "$f" ] && continue + # shellcheck disable=SC2053 + if [[ "$f" == $GLOB || "$f" == $ALT_GLOB ]]; then + FILTERED+="$f"$'\n' + fi + done <<< "$CHANGED" + FILTERED="${FILTERED%$'\n'}" + if [ -z "$FILTERED" ]; then + echo "No changed files match glob '$GLOB' — skipping prettier." + echo "files=" >> "$GITHUB_OUTPUT" + else + echo "Will prettier-check $(echo "$FILTERED" | wc -l) changed file(s):" + echo "$FILTERED" + # Pass as space-separated; filenames with spaces will break here. + # If that ever becomes a real issue, switch to a tempfile + xargs. + { + echo 'files<> "$GITHUB_OUTPUT" + fi + else + echo "Full-glob mode (event=$EVENT, changed_only=$CHANGED_ONLY)." + echo "files=$GLOB" >> "$GITHUB_OUTPUT" + fi + env: + GLOB: ${{ inputs.markdown_glob || '**/*.md' }} + shell: bash + + - name: prettier --check + if: steps.files.outputs.files != '' # Use the project's prettier (with its plugins) when node_modules is # populated; otherwise fall back to a one-off prettier@3 install. + # Files come from the previous step — either a single glob string + # (full-mode) or a newline-separated explicit list (changed-only). + # Null-delimited xargs is used so filenames with spaces don't split. run: | - GLOB="${{ inputs.markdown_glob || '**/*.md' }}" + set -eo pipefail if [ -x node_modules/.bin/prettier ]; then - node_modules/.bin/prettier --check "$GLOB" + PRETTIER=(node_modules/.bin/prettier) + else + PRETTIER=(npx --yes prettier@3) + fi + if [ "$FILES" = "$GLOB" ]; then + # Full-glob mode — pass the glob string verbatim; prettier + # expands it itself. + "${PRETTIER[@]}" --check "$GLOB" else - npx --yes prettier@3 --check "$GLOB" + # Changed-files mode — newline-delimited explicit list. Convert + # to NUL-delimited for xargs so filenames with spaces don't split. + printf '%s\n' "$FILES" | tr '\n' '\0' | xargs -0 "${PRETTIER[@]}" --check fi + env: + FILES: ${{ steps.files.outputs.files }} + GLOB: ${{ inputs.markdown_glob || '**/*.md' }} shell: bash shellcheck: