Skip to content

test: community-admin auto-merge (HED scoped, comment only) #1

test: community-admin auto-merge (HED scoped, comment only)

test: community-admin auto-merge (HED scoped, comment only) #1

name: Community Admin PR Auto-Merge
# Lets a community maintainer auto-merge a PR that ONLY touches their own
# community's directory (src/assistants/<id>/**). When eligible, this workflow
# approves the PR and enables auto-merge (squash into develop, merge-commit into
# main) so the normal required status checks (ruff + tests) still gate the merge.
#
# -----------------------------------------------------------------------------
# Trigger choice: pull_request_target (NOT pull_request)
# -----------------------------------------------------------------------------
# We need to approve + merge, which requires a privileged token. On a plain
# `pull_request` event from a fork, secrets are NOT available, so we could not
# mint the GitHub App token. `pull_request_target` runs in the context of the
# BASE repo and therefore HAS access to secrets even for fork PRs.
#
# The well-known danger of pull_request_target is that it can be tricked into
# executing attacker-controlled PR-head code with secrets in scope. We avoid
# that entirely:
# - We NEVER check out the PR head. The only checkout is the BASE branch
# (the trusted, already-reviewed code), and we never run anything from the
# PR. The PR diff is read via the GitHub API only.
# - Authorization (the maintainers list) is read from the BASE branch version
# of config.yaml, never from the PR head, so a PR cannot grant itself power.
# - If the PR's diff touches the `maintainers:` field of any config.yaml, the
# PR is ineligible and goes to human review.
# Given these constraints, pull_request_target is the correct and safe choice.
#
# -----------------------------------------------------------------------------
# Token model
# -----------------------------------------------------------------------------
# GITHUB_TOKEN cannot approve a PR (GitHub prohibits a workflow's own token from
# submitting a review on the PR that triggered it) and, more importantly, we
# deliberately keep its permissions minimal (read-only) so this workflow has no
# standing write power. All write actions (approve +
# merge) are performed with a SHORT-LIVED installation token minted from a
# dedicated GitHub App via actions/create-github-app-token. The App is the only
# identity allowed to merge here, and it is independent from the CI_ADMIN_TOKEN
# PAT used by the version-automation workflows. Required secrets:
# - COMMUNITY_MERGE_APP_ID
# - COMMUNITY_MERGE_APP_PRIVATE_KEY
# See .context/community-admin-merge.md for the one-time App setup.
on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review]
branches: [develop, main]
# Minimal standing permissions. contents:read for the base checkout; the
# eligibility step reads the PR's file list with github.token, which needs
# pull-requests:read. Every WRITE (approve, merge, comment) happens through the
# GitHub App installation token minted below, NOT through GITHUB_TOKEN.
permissions:
contents: read
pull-requests: read
# One in-flight evaluation per PR; cancel stale runs on rapid synchronize.
concurrency:
group: community-admin-merge-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
evaluate-and-merge:
# Skip drafts outright; they are not ready and ready_for_review will fire
# the real evaluation when the author marks the PR ready.
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
# Checkout the BASE branch ONLY. This is trusted, already-merged code.
# We never check out github.event.pull_request.head; the PR diff is read
# via the API in later steps. We only ever `git show` BASE_SHA:config.yaml,
# and `ref: base.sha` guarantees that commit is present locally.
- name: Checkout base branch (trusted)
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: pip install pyyaml
# ----------------------------------------------------------------------
# Eligibility evaluation. This step writes ONE output: `eligible`
# (true/false) plus `community` and `reason` for logging/comments. It
# performs NO writes and uses only the read-only github.token to list the
# PR's changed files. Default posture is NOT eligible: any ambiguity,
# parse failure, or unexpected condition results in eligible=false.
# ----------------------------------------------------------------------
- name: Evaluate eligibility
id: eval
# All PR-controlled values are passed via env (never interpolated into
# the script body) so a crafted login/sha/ref cannot inject shell.
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
AUTHOR_LOGIN: ${{ github.event.pull_request.user.login }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# Helper: emit outputs and exit "not eligible" cleanly (no-op, exit 0).
not_eligible() {
echo "::notice::Not eligible for auto-merge: $1"
{
echo "eligible=false"
echo "community="
echo "reason=$1"
} >> "$GITHUB_OUTPUT"
exit 0
}
# 1. Get the PR's changed files from the GitHub API (metadata only; we
# never merge or execute PR-head code). We use the paginated
# /pulls/{n}/files endpoint, NOT `gh pr diff --name-only`, for two
# reasons that are load-bearing for security:
# - It emits BOTH `.filename` (new path) and `.previous_filename`
# (a rename's OLD path). Checking both prevents a rename that
# moves a file FROM outside the community dir INTO it (the old
# path would be out of scope) from slipping the scope check.
# - `--paginate` returns ALL files; `gh pr diff` truncates large
# PRs at 300 files, which could hide out-of-scope files past the
# cutoff.
mapfile -t CHANGED < <(
gh api --paginate "/repos/$REPO/pulls/$PR_NUMBER/files" \
-q '.[] | (.filename, (.previous_filename // empty))' | sort -u
)
if [ "${#CHANGED[@]}" -eq 0 ]; then
not_eligible "PR changes zero files"
fi
echo "Changed files:"
printf ' %s\n' "${CHANGED[@]}"
# 2. Every changed path must match ^src/assistants/<id>/... and all
# paths must resolve to the SAME single community id. A PR spanning
# multiple communities, or touching anything outside a community
# dir, is not eligible.
COMMUNITY=""
for f in "${CHANGED[@]}"; do
# Defense in depth: git reports normalized repo-relative paths, but
# reject anything containing a `..` segment or a leading slash just
# in case, so a path can never escape the community subtree.
if [[ "$f" == *"/../"* || "$f" == "../"* || "$f" == *"/.." || "$f" == /* ]]; then
not_eligible "path contains a parent-directory or absolute segment: $f"
fi
# Strict regex: literal prefix, one path segment (no slash, no `..`)
# as the id, then a slash and at least one more character (a real
# file under the dir, including files at the community root).
if [[ "$f" =~ ^src/assistants/([^/]+)/.+$ ]]; then
id="${BASH_REMATCH[1]}"
else
not_eligible "path outside a single community dir: $f"
fi
if [ -z "$COMMUNITY" ]; then
COMMUNITY="$id"
elif [ "$COMMUNITY" != "$id" ]; then
not_eligible "PR spans multiple communities ($COMMUNITY, $id)"
fi
done
if [ -z "$COMMUNITY" ]; then
not_eligible "could not determine a single community id"
fi
# Defense in depth: the path regex allows any non-slash characters in
# the id. Constrain it to a safe identifier (lowercase letters, digits,
# hyphen, underscore) before it is interpolated into git refs / paths,
# so a crafted path can never carry shell or ref metacharacters here.
if ! [[ "$COMMUNITY" =~ ^[a-z0-9_-]+$ ]]; then
not_eligible "community id has unexpected characters: $COMMUNITY"
fi
echo "Target community: $COMMUNITY"
CONFIG_PATH="src/assistants/$COMMUNITY/config.yaml"
# 3. The BASE branch must actually define this community with a
# config.yaml. If the base has no such config (e.g. a brand-new
# community introduced by this very PR), there is no trusted
# maintainers list to authorize against -> human review.
if ! git cat-file -e "$BASE_SHA:$CONFIG_PATH" 2>/dev/null; then
not_eligible "no $CONFIG_PATH on base branch (new community needs human review)"
fi
# 4. SECURITY: read the maintainers list ONLY from the BASE branch
# version of config.yaml. Never from the PR head. A parse failure
# (malformed YAML, missing/empty maintainers) means not eligible.
git show "$BASE_SHA:$CONFIG_PATH" > /tmp/base_config.yaml || \
not_eligible "could not read base config.yaml"
# Extract a normalized (lowercased, sorted, newline-joined) maintainers
# list. Exit code 2 from the helper = parse/shape failure.
python - "$COMMUNITY" <<'PY' > /tmp/base_maintainers.txt || PARSE_RC=$?
import sys, yaml
community = sys.argv[1]
try:
with open("/tmp/base_config.yaml") as fh:
data = yaml.safe_load(fh)
except Exception as exc: # noqa: BLE001 - any parse error => ineligible
sys.stderr.write(f"base config.yaml failed to parse: {exc}\n")
sys.exit(2)
if not isinstance(data, dict):
sys.stderr.write("base config.yaml is not a mapping\n")
sys.exit(2)
maint = data.get("maintainers")
if not isinstance(maint, list) or not maint:
sys.stderr.write("base config.yaml has no non-empty maintainers list\n")
sys.exit(2)
names = []
for m in maint:
if not isinstance(m, str) or not m.strip():
sys.stderr.write("maintainers entry is not a non-empty string\n")
sys.exit(2)
names.append(m.strip().lower())
# Normalized, de-duplicated, sorted for stable comparison.
for n in sorted(set(names)):
print(n)
PY
PARSE_RC=${PARSE_RC:-0}
if [ "$PARSE_RC" -ne 0 ]; then
not_eligible "base config.yaml maintainers could not be parsed"
fi
# 5. SECURITY: if config.yaml is among the changed files, ensure the
# PR does NOT modify the maintainers field. Changing who holds
# admin power must go through human review. Compare the normalized
# maintainers list of base vs head of config.yaml.
if printf '%s\n' "${CHANGED[@]}" | grep -qxF "$CONFIG_PATH"; then
echo "config.yaml is modified; verifying maintainers field is unchanged"
# Fetch the HEAD version of config.yaml via the API (raw contents at
# the PR head sha, from $HEAD_SHA in env). We parse it ONLY to compare
# the maintainers field; we never execute it. A failure to fetch/parse
# the head config => not eligible (conservative default). This also
# catches a config.yaml that the PR deleted or renamed away (the fetch
# 404s), so admin-list removal cannot ride through.
if ! gh api \
-H "Accept: application/vnd.github.raw+json" \
"/repos/$REPO/contents/$CONFIG_PATH?ref=$HEAD_SHA" \
> /tmp/head_config.yaml 2>/dev/null; then
not_eligible "could not read head config.yaml for maintainers diff"
fi
python - "$COMMUNITY" <<'PY' > /tmp/head_maintainers.txt || HEAD_RC=$?
import sys, yaml
try:
with open("/tmp/head_config.yaml") as fh:
data = yaml.safe_load(fh)
except Exception as exc: # noqa: BLE001 - any parse error => ineligible
sys.stderr.write(f"head config.yaml failed to parse: {exc}\n")
sys.exit(2)
if not isinstance(data, dict):
sys.stderr.write("head config.yaml is not a mapping\n")
sys.exit(2)
maint = data.get("maintainers")
if not isinstance(maint, list):
sys.stderr.write("head config.yaml has no maintainers list\n")
sys.exit(2)
names = []
for m in maint:
if not isinstance(m, str) or not m.strip():
sys.stderr.write("head maintainers entry is not a non-empty string\n")
sys.exit(2)
names.append(m.strip().lower())
for n in sorted(set(names)):
print(n)
PY
HEAD_RC=${HEAD_RC:-0}
if [ "$HEAD_RC" -ne 0 ]; then
not_eligible "head config.yaml maintainers could not be parsed"
fi
if ! diff -q /tmp/base_maintainers.txt /tmp/head_maintainers.txt >/dev/null; then
not_eligible "PR modifies the maintainers field (needs human review)"
fi
echo "maintainers field unchanged; OK to proceed"
fi
# 6. Verify the PR author is in the BASE-branch maintainers list.
# GitHub usernames are case-insensitive, so compare lowercased.
# AUTHOR_LOGIN comes from env (never interpolated into the script).
AUTHOR="$(printf '%s' "$AUTHOR_LOGIN" | tr '[:upper:]' '[:lower:]')"
echo "PR author (normalized): $AUTHOR"
if ! grep -qxF "$AUTHOR" /tmp/base_maintainers.txt; then
not_eligible "author '$AUTHOR' is not a maintainer of '$COMMUNITY'"
fi
# All checks passed.
echo "::notice::PR #$PR_NUMBER is eligible for auto-merge (community=$COMMUNITY, author=$AUTHOR)"
{
echo "eligible=true"
echo "community=$COMMUNITY"
echo "reason=author is a maintainer; changes scoped to $COMMUNITY"
} >> "$GITHUB_OUTPUT"
# ----------------------------------------------------------------------
# Mint the GitHub App installation token. Only reached when eligible.
# Short-lived; scoped to this repo by the App installation.
# ----------------------------------------------------------------------
- name: Mint GitHub App token
if: steps.eval.outputs.eligible == 'true'
id: app_token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.COMMUNITY_MERGE_APP_ID }}
private-key: ${{ secrets.COMMUNITY_MERGE_APP_PRIVATE_KEY }}
# Approve the PR using the App token. A clear, attributable review body.
- name: Approve PR
if: steps.eval.outputs.eligible == 'true'
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
COMMUNITY: ${{ steps.eval.outputs.community }}
run: |
set -euo pipefail
gh api \
--method POST \
-H "Accept: application/vnd.github+json" \
"/repos/$REPO/pulls/$PR_NUMBER/reviews" \
-f event=APPROVE \
-f body="Auto-approved by community-admin auto-merge: changes are scoped to the **$COMMUNITY** community directory and the author is a listed maintainer. Required status checks still gate the merge."
# Enable auto-merge with the App token. --auto means the merge happens only
# once all required checks (ruff + tests) pass, so we never bypass CI. The
# merge method is chosen by target branch to satisfy the branch rulesets:
# develop allows squash (feature-branch convention); main is merge-commit
# only (the develop->main release convention). Using --squash on a main PR
# would be rejected by the protect-main ruleset.
- name: Enable auto-merge
if: steps.eval.outputs.eligible == 'true'
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
set -euo pipefail
if [ "$BASE_REF" = "main" ]; then
METHOD="--merge"
else
METHOD="--squash"
fi
echo "Merging PR #$PR_NUMBER into $BASE_REF with $METHOD"
gh pr merge "$PR_NUMBER" --repo "$REPO" --auto "$METHOD"
# Post (find-or-update) a confirmation comment so we do not spam a new
# comment on every synchronize event. Uses the App token's identity.
- name: Comment confirmation
if: steps.eval.outputs.eligible == 'true'
continue-on-error: true
uses: actions/github-script@v7
with:
github-token: ${{ steps.app_token.outputs.token }}
script: |
const community = process.env.COMMUNITY;
const marker = '<!-- community-admin-merge -->';
const body = `${marker}\n` +
`Auto-merge enabled for the **${community}** community.\n\n` +
`This PR only touches \`src/assistants/${community}/\` and was opened by a ` +
`listed maintainer, so it has been approved and queued for auto-merge. ` +
`It will merge automatically once all required checks (ruff + tests) pass.`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body,
});
}
env:
COMMUNITY: ${{ steps.eval.outputs.community }}