feat(ci): merge community PRs into develop on a maintainer's LGTM/merge comment #3
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 Maintainer Merge Command | |
| # A community maintainer merges a PR INTO develop that touches ONLY their own | |
| # community's directory (src/assistants/<id>/**) by posting a comment that | |
| # contains BOTH "LGTM" and "merge" (e.g. "LGTM, please merge"). Nothing merges on | |
| # open -- the comment is the deliberate, explicit signal. (Chosen over GitHub | |
| # "Approve", which reviewers give during review without intending to ship now.) | |
| # | |
| # Scope is develop only. Releases (develop -> main) are done by an OSA admin, so | |
| # main never diverges from the integrated develop branch. | |
| # | |
| # ----------------------------------------------------------------------------- | |
| # Trigger + security model | |
| # ----------------------------------------------------------------------------- | |
| # issue_comment runs in the BASE-repo context and HAS access to secrets (so we | |
| # can mint the GitHub App token), even for comments on fork PRs. It is therefore | |
| # as privileged as pull_request_target, and we treat it the same way: | |
| # - We NEVER check out or run PR-head code. Everything is read via the API. | |
| # - Authorization (the maintainers list) is read from the BASE branch version | |
| # of config.yaml at the PR's base sha, never from the PR head, so a PR cannot | |
| # grant itself power. | |
| # - The PR must touch ONLY src/assistants/<one community>/** (rename old paths | |
| # checked too) and must NOT edit the maintainers field; the community id is | |
| # whitelisted before it is used. | |
| # - The COMMENTER must be a maintainer of that community. | |
| # - The merge is pinned to the exact head commit the comment was made on | |
| # (--match-head-commit), so a push AFTER the comment requires a fresh | |
| # "LGTM ... merge". (protect-dev keeps stale approvals, so we pin ourselves.) | |
| # - --auto means the merge waits for required checks; we never bypass CI. | |
| # | |
| # Token model: all writes (approve, merge, comment) use a SHORT-LIVED GitHub App | |
| # installation token (secrets COMMUNITY_MERGE_APP_ID / COMMUNITY_MERGE_APP_PRIVATE_KEY), | |
| # minted only when a command is authorized. GITHUB_TOKEN stays read-only. | |
| # See .context/community-admin-merge.md for the one-time App setup + usage. | |
| on: | |
| issue_comment: | |
| types: [created] | |
| # Minimal standing permissions; the eligibility step reads PR data with | |
| # github.token (needs pull-requests:read). Every WRITE happens through the App | |
| # installation token minted below, NOT through GITHUB_TOKEN. | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| concurrency: | |
| group: community-maintainer-merge-${{ github.event.issue.number }} | |
| cancel-in-progress: false | |
| jobs: | |
| merge-on-command: | |
| # Only comments on pull requests (issue_comment also fires on plain issues). | |
| if: github.event.issue.pull_request != null | |
| runs-on: ubuntu-latest | |
| steps: | |
| # Cheap first gate: does the comment carry the "LGTM ... merge" command? | |
| # Case-insensitive, both keywords required. Skips the rest (and the Python | |
| # setup) for ordinary comments. COMMENT_BODY via env (never interpolated). | |
| - name: Detect command | |
| id: cmd | |
| env: | |
| COMMENT_BODY: ${{ github.event.comment.body }} | |
| run: | | |
| set -euo pipefail | |
| body="$(printf '%s' "$COMMENT_BODY" | tr '[:upper:]' '[:lower:]')" | |
| # Guard the most explicit negations so "LGTM, but do not merge yet" | |
| # does not fire. (Bounded anyway: maintainer-only, CI-gated, cancellable.) | |
| negated=false | |
| case "$body" in | |
| *"do not merge"*|*"don't merge"*|*"dont merge"*) negated=true ;; | |
| esac | |
| if [[ "$body" == *lgtm* && "$body" == *merge* && "$negated" == false ]]; then | |
| echo "command=true" >> "$GITHUB_OUTPUT" | |
| echo "Detected LGTM/merge command." | |
| else | |
| echo "command=false" >> "$GITHUB_OUTPUT" | |
| echo "No actionable LGTM/merge command (keywords absent or negated); nothing to do." | |
| fi | |
| - name: Set up Python | |
| if: steps.cmd.outputs.command == 'true' | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.12' | |
| - name: Install dependencies | |
| if: steps.cmd.outputs.command == 'true' | |
| # Pinned: this step parses YAML in a privileged context (App token is | |
| # minted only in a later step, but pin anyway for supply-chain hygiene). | |
| run: pip install "pyyaml==6.0.2" | |
| # ---------------------------------------------------------------------- | |
| # Eligibility. Writes outputs: eligible (true/false), community, head_sha, | |
| # method, commenter. Performs NO writes; uses only the read-only | |
| # github.token. Default posture is NOT eligible. | |
| # ---------------------------------------------------------------------- | |
| - name: Evaluate eligibility | |
| id: eval | |
| if: steps.cmd.outputs.command == 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ github.event.issue.number }} | |
| COMMENTER_LOGIN: ${{ github.event.comment.user.login }} | |
| run: | | |
| set -euo pipefail | |
| not_eligible() { | |
| echo "::notice::Not merging: $1" | |
| { echo "eligible=false"; echo "community="; } >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| } | |
| # 1. Fetch the PR (data only; we never run its code). | |
| PR_JSON="$(gh api "/repos/$REPO/pulls/$PR_NUMBER")" | |
| BASE_REF="$(jq -r '.base.ref' <<<"$PR_JSON")" | |
| BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")" | |
| HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")" | |
| STATE="$(jq -r '.state' <<<"$PR_JSON")" | |
| IS_DRAFT="$(jq -r '.draft' <<<"$PR_JSON")" | |
| [ "$STATE" = "open" ] || not_eligible "PR is not open ($STATE)" | |
| [ "$IS_DRAFT" = "false" ] || not_eligible "PR is a draft" | |
| # Community maintainers may merge into develop ONLY. Releases to main | |
| # (develop -> main) are done by an OSA admin, so a community PR targeting | |
| # main is never merged here (a main PR can also be stale vs develop). | |
| [ "$BASE_REF" = "develop" ] || \ | |
| not_eligible "base branch '$BASE_REF' is not develop (community PRs target develop; an OSA admin merges develop to main)" | |
| # 2. Changed files via the paginated /files API (both new and rename-old | |
| # paths). Every path must be under ONE src/assistants/<id>/ tree. | |
| mapfile -t CHANGED < <( | |
| gh api --paginate "/repos/$REPO/pulls/$PR_NUMBER/files" \ | |
| -q '.[] | (.filename, (.previous_filename // empty))' | sort -u | |
| ) | |
| [ "${#CHANGED[@]}" -gt 0 ] || not_eligible "PR changes zero files" | |
| echo "Changed files:"; printf ' %s\n' "${CHANGED[@]}" | |
| COMMUNITY="" | |
| for f in "${CHANGED[@]}"; do | |
| if [[ "$f" == *"/../"* || "$f" == "../"* || "$f" == *"/.." || "$f" == /* ]]; then | |
| not_eligible "path contains a parent-directory or absolute segment: $f" | |
| fi | |
| 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 | |
| [ -n "$COMMUNITY" ] || not_eligible "could not determine a single community id" | |
| 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. Read the maintainers list ONLY from the BASE config (base sha), via | |
| # the API. Never the PR head. Missing/unparseable -> not eligible. | |
| if ! gh api -H "Accept: application/vnd.github.raw+json" \ | |
| "/repos/$REPO/contents/$CONFIG_PATH?ref=$BASE_SHA" \ | |
| > /tmp/base_config.yaml 2>/dev/null; then | |
| not_eligible "no $CONFIG_PATH on base branch (new community needs human review)" | |
| fi | |
| python - <<'PY' > /tmp/base_maintainers.txt || PARSE_RC=$? | |
| import sys, yaml | |
| 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()) | |
| for n in sorted(set(names)): | |
| print(n) | |
| PY | |
| PARSE_RC=${PARSE_RC:-0} | |
| [ "$PARSE_RC" -eq 0 ] || not_eligible "base config.yaml maintainers could not be parsed" | |
| # 4. SECURITY: if config.yaml is changed, the maintainers field must be | |
| # unchanged (changing who holds power needs human review). Compare | |
| # the normalized base vs head maintainers list. | |
| if printf '%s\n' "${CHANGED[@]}" | grep -qxF "$CONFIG_PATH"; then | |
| echo "config.yaml changed; verifying the maintainers field is unchanged" | |
| 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 (delete/rename needs human review)" | |
| fi | |
| python - <<'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} | |
| [ "$HEAD_RC" -eq 0 ] || not_eligible "head config.yaml maintainers could not be parsed" | |
| 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 | |
| # 5. The COMMENTER must be a maintainer of this community (base list). | |
| # GitHub usernames are case-insensitive; compare lowercased. | |
| COMMENTER="$(printf '%s' "$COMMENTER_LOGIN" | tr '[:upper:]' '[:lower:]')" | |
| echo "Commenter (normalized): $COMMENTER" | |
| if ! grep -qxF "$COMMENTER" /tmp/base_maintainers.txt; then | |
| not_eligible "commenter '$COMMENTER' is not a maintainer of '$COMMUNITY'" | |
| fi | |
| echo "::notice::Merging PR #$PR_NUMBER ($COMMUNITY) into develop on @$COMMENTER command; head $HEAD_SHA" | |
| { | |
| echo "eligible=true" | |
| echo "community=$COMMUNITY" | |
| echo "head_sha=$HEAD_SHA" | |
| echo "commenter=$COMMENTER" | |
| } >> "$GITHUB_OUTPUT" | |
| # Mint the GitHub App installation token. Only reached when authorized. | |
| - 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 }} | |
| # Record an approval (audit trail) attributing the merge to the commenter. | |
| - name: Approve PR | |
| if: steps.eval.outputs.eligible == 'true' | |
| env: | |
| GH_TOKEN: ${{ steps.app_token.outputs.token }} | |
| REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ github.event.issue.number }} | |
| COMMUNITY: ${{ steps.eval.outputs.community }} | |
| COMMENTER: ${{ steps.eval.outputs.commenter }} | |
| 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="Merge approved on @$COMMENTER's LGTM/merge command. Changes are scoped to the **$COMMUNITY** community directory; required status checks still gate the merge." | |
| # Enable squash auto-merge into develop, PINNED to the head the command was | |
| # made on. --auto waits for required checks; --match-head-commit means a | |
| # push after the command cancels this and requires a fresh "LGTM ... merge". | |
| - name: Merge on command (pinned to the commented head) | |
| if: steps.eval.outputs.eligible == 'true' | |
| env: | |
| GH_TOKEN: ${{ steps.app_token.outputs.token }} | |
| REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ github.event.issue.number }} | |
| HEAD_SHA: ${{ steps.eval.outputs.head_sha }} | |
| run: | | |
| set -euo pipefail | |
| echo "Squash-merging PR #$PR_NUMBER into develop, pinned to $HEAD_SHA" | |
| gh pr merge "$PR_NUMBER" --repo "$REPO" --auto --squash --match-head-commit "$HEAD_SHA" | |
| # Find-or-update a single confirmation comment (no spam on repeat commands). | |
| - name: Comment result | |
| if: steps.eval.outputs.eligible == 'true' | |
| continue-on-error: true | |
| uses: actions/github-script@v7 | |
| env: | |
| COMMUNITY: ${{ steps.eval.outputs.community }} | |
| COMMENTER: ${{ steps.eval.outputs.commenter }} | |
| with: | |
| github-token: ${{ steps.app_token.outputs.token }} | |
| script: | | |
| const community = process.env.COMMUNITY; | |
| const commenter = process.env.COMMENTER; | |
| const marker = '<!-- community-maintainer-merge -->'; | |
| const body = `${marker}\n` + | |
| `Queued for merge on @${commenter}'s **LGTM / merge** command.\n\n` + | |
| `This PR only touches \`src/assistants/${community}/\`, so it has been ` + | |
| `approved and will merge automatically once all required checks pass. ` + | |
| `The merge is pinned to the current commit; pushing new commits cancels ` + | |
| `it and needs a fresh \`LGTM ... merge\` comment.`; | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.payload.issue.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.issue.number, body, | |
| }); | |
| } |