Skip to content
Open
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
127 changes: 120 additions & 7 deletions .github/workflows/claude-author-automerge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -140,24 +150,115 @@ 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.
#
# 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 -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 -r --arg name "$CHECK_NAME" \
'.statuses[] | select(.context == $name) | .state' | head -1)
fi
echo "Codex check '$CHECK_NAME' state: '${codex_state:-<absent>}'"
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="<!-- claude-author-automerge:risk-tier -->"
Expand All @@ -169,16 +270,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" \
Expand Down
96 changes: 92 additions & 4 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <base-ref> --depth=N` strategy in a follow-up.
fetch-depth: 0

- uses: actions/setup-node@v4
with:
Expand All @@ -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' }}"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: false || 'true' clobbers the opt-out.

In GitHub Actions expression language false || 'true' evaluates to the string 'true' (because boolean false is falsy). So when a consumer explicitly sets prettier_changed_only: false to revert to full-scan mode, CHANGED_ONLY becomes "true", and the subsequent [ "$CHANGED_ONLY" != "false" ] check stays true — changed-only mode is never disabled.

Since the input is declared type: boolean with default: true, it always has a value; the || 'true' fallback is both unnecessary and destructive.

Suggested change
CHANGED_ONLY="${{ inputs.prettier_changed_only || 'true' }}"
CHANGED_ONLY="${{ inputs.prettier_changed_only }}"

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<<EOF'
echo "$FILTERED"
echo 'EOF'
} >> "$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:
Expand Down
Loading