Skip to content

Add integration with "external" boards for gathering answers #19

Add integration with "external" boards for gathering answers

Add integration with "external" boards for gathering answers #19

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 human comments on pull requests. issue_comment also fires on plain
# issues (filtered out), and on the App's own confirmation comment (which
# mentions "LGTM / merge") -- skipping Bot authors stops that self-trigger.
if: >-
github.event.issue.pull_request != null &&
github.event.comment.user.type != 'Bot'
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,
});
}