test: community-admin auto-merge (HED scoped, comment only) #1
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }} |