Skip to content

[AIROCMLIR-597][CI] Extract Jenkinsfile helpers into helpers/*.groovy and load them in a Bootstrap stage #313

[AIROCMLIR-597][CI] Extract Jenkinsfile helpers into helpers/*.groovy and load them in a Bootstrap stage

[AIROCMLIR-597][CI] Extract Jenkinsfile helpers into helpers/*.groovy and load them in a Bootstrap stage #313

name: Claude Auto Review (perimeter-banner)
# Companion to claude_auto_review.yml -- the Layer-2 automation (doc §6, §9).
# On every PR open/synchronize/reopen, if the PR's diff vs the default branch
# touches the security perimeter (.github/workflows/, .github/scripts/,
# .claude/), it ensures + applies the `modifies-ci-paths` label and posts a
# one-time banner (deduped by a hidden marker + author filter) warning
# maintainers to audit before applying `claude-review`. Removes the label if a
# later push drops the perimeter changes.
#
# Safe under pull_request_target (same pattern as fork_notify): NO
# actions/checkout (PR code never runs; we only fetch refs over HTTPS into a
# temp dir and read them with `git diff --name-only` -- no PR-controlled code
# path executes), read-only API queries + label management on a hardcoded name
# + a fixed-template comment; PR-author strings are interpolated as text,
# never re-evaluated as shell; default token is permissions:{}, all calls use
# the in-step App token.
on:
pull_request_target:
types: [opened, synchronize, reopened]
# Default-deny at workflow level.
permissions: {}
# Only the latest head's perimeter status matters; cancel stale runs.
concurrency:
group: claude-perimeter-banner-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
detect:
runs-on: ubuntu-latest
timeout-minutes: 5
# Default-deny: all calls use the in-step App token.
permissions: {}
steps:
# App token so the banner posts under the unique bot identity, which the
# dedup filter (EXPECTED_AUTHOR) keys on and a PR commenter can't
# impersonate. Request only the scopes the steps below exercise:
# - contents:read git fetch of the default branch + PR head.
# - issues:write create the repository label (gh label create)
# and use the Issues REST label/comment endpoints.
# - pull-requests:write apply/remove the label and post the banner on a
# PR-backed issue: GitHub enforces Pull requests
# write for these operations even though the URL
# is under /issues (issues:write alone returns
# 403 "Resource not accessible by integration").
# See doc §9, §11.
- name: Generate App token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.ROCMLIR_PR_REVIEWER_APP_ID }}
private-key: ${{ secrets.ROCMLIR_PR_REVIEWER_PRIVATE_KEY }}
permission-contents: read
permission-issues: write
permission-pull-requests: write
- name: Detect perimeter modifications, label, banner-comment
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
# Hidden marker for the one-time-banner dedup. Bump the suffix to
# force a re-post.
BANNER_MARKER: '<!-- claude-perimeter-banner:v1 -->'
# MUST stay in sync with the Layer-3 perimeter block in
# claude_auto_review.yml -- same perimeter definition. See doc §15.
PERIMETER_REGEX: '^(\.github/workflows/|\.github/scripts/|\.claude/)'
# Compare baseline -- same as Layer 3's, so the two agree regardless
# of the PR's base branch. See doc §9, §15.
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
# Server-determined PR head SHA (40-char hex; shape comes from
# the GitHub event, not from PR-controlled content). Used only
# as a quoted argv to `git fetch / merge-base / diff` below;
# never interpolated into a URL, never passed to `eval`, never
# concatenated into a shell command. The `git` argv path is
# exec-style (no shell re-parse), so the SHA's bytes are
# opaque to the shell -- no injection surface even if the
# SHA contained shell metacharacters (it can't, but the
# safety doesn't depend on that).
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
# Bot identity for the dedup filter so a PR author can't suppress the
# banner by pasting the marker (user.login can't be impersonated).
# Mirror of BOT_LOGIN in claude_auto_review.yml. See doc §11, §15.
EXPECTED_AUTHOR: 'rocmlir-pr-reviewer[bot]'
run: |
set -euo pipefail
# ---- 1. Get the changed-file list with `git diff` (NOT the Compare API) ----
# No actions/checkout -- PR code never runs. Fetch the two refs we
# need into a temp dir, compute the diff locally. The Compare API
# caps `.files` at 300 (>300-file PRs could hide a perimeter file
# past entry 300). `git diff` has no cap and uses the same
# baseline, three-dot range, and `--no-renames` /
# `-c core.quotePath=false` flags as Layer 3 in
# claude_auto_review.yml -- only mechanical differences are
# `git -C "$tmp"` (no workspace checkout under
# pull_request_target) and `${HEAD_SHA}` vs `HEAD`. Compare vs
# DEFAULT branch (not PR base) so the result agrees with Layer 3
# regardless of PR base. Flags rationale: doc §15.1.
tmp=$(mktemp -d)
# Clean up the tempdir on any exit path (success, failure, or
# signal). The tempdir holds no secrets (the auth header is
# scoped to each `git_fetch_with_auth` call via env vars and
# never lands in .git/config -- see auth block below), but on
# self-hosted runners /tmp persists across jobs, so an explicit
# cleanup prevents unbounded .git/objects accumulation across
# repeated banner runs.
trap 'rm -rf "$tmp"' EXIT
git -C "$tmp" init --quiet
# Match actions/checkout's Basic auth scheme exactly
# (base64("x-access-token:<TOKEN>")) -- the canonical, most
# reliably supported form of git-over-HTTPS auth for GitHub
# App installation tokens. Scope the header to each `git fetch`
# via per-invocation GIT_CONFIG_* env vars (an env-var
# equivalent of `git -c key=val` -- both produce one-shot
# config that lives only for that git invocation, but env
# vars survive the shell-function boundary that prepended
# `git -c` would lose) instead of persisting it in
# .git/config -- the token never lands on disk, so a missed
# cleanup trap can't leak it. Mirrors rocmlirTriton's auth
# block for cross-repo parity.
if ! auth_header=$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n'); then
echo "::error::Failed to encode git fetch auth header"
exit 1
fi
if [ -z "$auth_header" ]; then
echo "::error::Encoded git fetch auth header is empty"
exit 1
fi
# Register the base64-encoded credential as a workflow secret
# so it never appears in plaintext in run logs. GH_TOKEN
# itself is already masked (it's a registered secret from
# steps.token.outputs.token), but the runner's secret-tracking
# does NOT follow values through pipes -- the base64 derivative
# is a fresh string from the runner's perspective. actions/
# checkout addresses this with `core.setSecret(basicCredential)`;
# `::add-mask::` is the shell-step equivalent. Defense-in-depth
# against any future debugging that turns on `set -x` (the
# runner intercepts the `::add-mask::` workflow command BEFORE
# logging the line, so this echo itself is safe).
echo "::add-mask::${auth_header}"
# Keep GH_TOKEN available in the step env for the gh CLI calls
# below; this step also posts labels and comments after
# computing the diff.
git_fetch_with_auth() {
GIT_CONFIG_COUNT=1 \
GIT_CONFIG_KEY_0='http.https://github.com/.extraheader' \
GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth_header}" \
git -C "$tmp" fetch "$@"
}
git -C "$tmp" remote add origin "https://github.com/${REPO}.git"
# Shallow fetch + bounded deepen loop until the merge-base is
# reachable (`git diff A...B` requires it). Mirrors the same idiom
# in claude_auto_review.yml's `deepen` step. HEAD_SHA is in the
# refspec so the grafted root has a known parent for merge-base.
git_fetch_with_auth --no-tags --depth=50 origin \
"${DEFAULT_BRANCH}" "${HEAD_SHA}"
for _ in 1 2 3 4 5 6 7 8 9 10; do
if git -C "$tmp" merge-base \
"origin/${DEFAULT_BRANCH}" "${HEAD_SHA}" >/dev/null 2>&1; then
break
fi
git_fetch_with_auth --no-tags --deepen=500 origin \
"${DEFAULT_BRANCH}" "${HEAD_SHA}"
done
# Hard fail if merge-base still unreachable, so we don't silently
# fall back to a wrong baseline.
git -C "$tmp" merge-base \
"origin/${DEFAULT_BRANCH}" "${HEAD_SHA}" >/dev/null
changed=$(git -C "$tmp" -c core.quotePath=false diff --name-only --no-renames \
"origin/${DEFAULT_BRANCH}...${HEAD_SHA}")
perimeter=$(printf '%s\n' "$changed" \
| grep -E "$PERIMETER_REGEX" \
|| true)
# ---- 2. Branch on perimeter touched / not touched ----
if [ -z "$perimeter" ]; then
# No longer touches perimeter: remove the stale label. REST DELETE
# (not `gh pr edit --remove-label`) gives a clean 404 for the
# already-absent case; any other error fails the workflow.
if output=$(gh api --method DELETE \
"/repos/${REPO}/issues/${PR_NUMBER}/labels/modifies-ci-paths" \
2>&1); then
echo "Removed stale modifies-ci-paths label from PR #${PR_NUMBER}."
elif grep -qE 'HTTP 404|Not Found' <<<"$output"; then
echo "PR #${PR_NUMBER} does not touch the CI security perimeter and the modifies-ci-paths label is already absent; nothing to do."
else
echo "::error::Failed to remove stale modifies-ci-paths label from PR #${PR_NUMBER}"
printf '%s\n' "$output" >&2
exit 1
fi
exit 0
fi
# ---- 3. Perimeter touched: ensure label exists ----
# `--force` is upsert (create or update, both 2xx); any error fails
# the step (no `|| true` fail-open).
if ! out=$(gh label create modifies-ci-paths --repo "${REPO}" \
--color D93F0B \
--description "PR modifies the Claude review CI security perimeter; audit before applying claude-review" \
--force 2>&1); then
echo "::error::Failed to ensure modifies-ci-paths label exists on ${REPO}"
printf '%s\n' "$out" >&2
exit 1
fi
# ---- 4. Apply the label to the PR issue (idempotent) ----
gh api --method POST \
"/repos/${REPO}/issues/${PR_NUMBER}/labels" \
-f labels[]=modifies-ci-paths >/dev/null
echo "Applied modifies-ci-paths label to PR #${PR_NUMBER}."
# ---- 5. Check if banner comment already exists ----
# Filter on BOTH author == bot AND body contains marker -- the
# author check stops a PR commenter from suppressing the banner by
# pasting the marker. `gh api --paginate` walks the Link header
# until exhausted, so a busy PR with >100 comments can't hide an
# already-posted banner past page 1. `--jq` is applied per page;
# we collect every matching id (rather than the prior `first(...)`
# / fixed-page approach) and pick the first via bash parameter
# expansion -- NOT `head -n1`, which would SIGPIPE under pipefail
# on multi-match (same idiom as the perimeter awk loop below).
# See doc §9.
all_existing=$(MARKER="${BANNER_MARKER}" \
AUTHOR="${EXPECTED_AUTHOR}" \
gh api --paginate \
"repos/${REPO}/issues/${PR_NUMBER}/comments?per_page=100" \
--jq '.[]
| select(.user.login == env.AUTHOR
and (.body | contains(env.MARKER)))
| .id')
existing="${all_existing%%$'\n'*}"
if [ -n "$existing" ]; then
echo "Banner already posted on PR #${PR_NUMBER} (comment id=${existing}); skipping."
exit 0
fi
# ---- 6. Post the banner comment ----
# PR-controlled paths are interpolated as heredoc TEXT (never
# re-evaluated as shell). To stop markdown injection, render each as
# backtick-stripped inline-code (not a fenced block) and cap the list
# at 50 entries. awk (not `head -n 50`) avoids a SIGPIPE failure under
# pipefail. See doc §9.
perimeter_total=$(printf '%s\n' "$perimeter" | grep -c .)
perimeter_md=$(printf '%s\n' "$perimeter" \
| awk 'length($0) > 0 { n++; if (n <= 50) { gsub(/`/, ""); print "- `" $0 "`" } }')
perimeter_overflow=""
if [ "$perimeter_total" -gt 50 ]; then
perimeter_overflow=$'\n\n_...and '"$((perimeter_total - 50))"' more perimeter file(s) not shown._'
fi
# --raw-field (NOT --field): the body is always a string, and
# --raw-field disables gh's magic value interpretation (leading `@`
# would read a file, bare true/false/null/ints would be type-coerced)
# -- defensive since the body embeds PR-controlled perimeter paths.
gh api --method POST \
"/repos/${REPO}/issues/${PR_NUMBER}/comments" \
--raw-field body="$(cat <<EOF
${BANNER_MARKER}
### :warning: This PR modifies the Claude-review CI security perimeter
The following files in this PR control whether and how the
\`claude-review\` workflow protects its LLM Gateway secrets at
runtime (see \`.github/workflows/CLAUDE_AUTO_REVIEW.md\`):
$perimeter_md$perimeter_overflow
**Before applying the \`claude-review\` label on this PR**, please:
1. Audit the diff in these paths line-by-line. A malicious or
accidental change could disable the \`--allowedTools\`
restriction, the overlay step, the sanitizer, or the
review/post job split -- any of which would expose the
secrets in env to the PR-modified workflow.
2. If the changes are legitimate and you want a Claude review,
do NOT apply the \`claude-review\` label. Instead, run via
**Actions → Claude Auto Review → Run workflow** and enter
this PR's number. The dispatch path runs from the trusted,
code-owner-approved version of the workflow on
\`${DEFAULT_BRANCH:-develop}\`, so a malicious PR-side
modification cannot affect the run.
(This banner is automated. The \`modifies-ci-paths\` label
will be removed automatically if a future push removes the
perimeter modifications. The Layer-3 in-workflow guard will
additionally fail the \`claude-review\` label-triggered run
on this PR if the label is applied while perimeter changes
are present.)
EOF
)" >/dev/null
echo "Posted perimeter banner comment on PR #${PR_NUMBER}."