From 2e8b27f92a719227a39b680d314f01c20335898a Mon Sep 17 00:00:00 2001 From: Molecule AI SDK Lead Date: Thu, 14 May 2026 09:59:57 +0000 Subject: [PATCH 1/2] chore: add gitea-merge-queue.yml workflow Adds automated merge queue for PRs labeled merge-queue. Pattern mirrors SDK/MCP/CLI repos (molecule-core PR #860). Runs every 5 minutes; processes one queued PR per tick. Uses AUTO_SYNC_TOKEN for non-bypass merge. Co-Authored-By: SDK Lead Agent --- .gitea/scripts/gitea-merge-queue.py | 369 +++++++++++++++++++++++++ .gitea/workflows/gitea-merge-queue.yml | 36 +++ .gitea/workflows/sop-checklist.yml | 130 +++++++++ 3 files changed, 535 insertions(+) create mode 100644 .gitea/scripts/gitea-merge-queue.py create mode 100644 .gitea/workflows/gitea-merge-queue.yml create mode 100644 .gitea/workflows/sop-checklist.yml diff --git a/.gitea/scripts/gitea-merge-queue.py b/.gitea/scripts/gitea-merge-queue.py new file mode 100644 index 0000000..95ef897 --- /dev/null +++ b/.gitea/scripts/gitea-merge-queue.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +"""gitea-merge-queue — conservative serialized merge bot for Gitea. + +Gitea 1.22.6 has auto-merge (`pull_auto_merge`) but no GitHub-style merge +queue. This script provides the missing serialized policy in user space: + +1. Pick the oldest open PR carrying QUEUE_LABEL. +2. Refuse to act unless main is green. +3. Refuse fork PRs; the queue may only mutate same-repo branches. +4. If the PR branch does not contain current main, call Gitea's + /pulls/{n}/update endpoint and stop. CI must rerun on the updated head. +5. If the updated PR head has all required contexts green, merge with the + non-bypass merge actor token. + +The script is intentionally one-PR-per-run. Workflow/cron concurrency should +serialize invocations so two green PRs cannot merge against the same main. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + + +def _env(key: str, *, default: str = "") -> str: + return os.environ.get(key, default) + + +GITEA_TOKEN = _env("GITEA_TOKEN") +GITEA_HOST = _env("GITEA_HOST") +REPO = _env("REPO") +WATCH_BRANCH = _env("WATCH_BRANCH", default="main") +QUEUE_LABEL = _env("QUEUE_LABEL", default="merge-queue") +HOLD_LABEL = _env("HOLD_LABEL", default="merge-queue-hold") +UPDATE_STYLE = _env("UPDATE_STYLE", default="merge") +REQUIRED_CONTEXTS_RAW = _env( + "REQUIRED_CONTEXTS", + default=( + "CI / all-required (pull_request)," + "sop-checklist / all-items-acked (pull_request)" + ), +) + +OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "") +API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else "" + + +class ApiError(RuntimeError): + pass + + +@dataclasses.dataclass(frozen=True) +class MergeDecision: + ready: bool + action: str + reason: str + + +def _require_runtime_env() -> None: + for key in ("GITEA_TOKEN", "GITEA_HOST", "REPO", "WATCH_BRANCH", "QUEUE_LABEL"): + if not os.environ.get(key): + sys.stderr.write(f"::error::missing required env var: {key}\n") + sys.exit(2) + if UPDATE_STYLE not in {"merge", "rebase"}: + sys.stderr.write("::error::UPDATE_STYLE must be merge or rebase\n") + sys.exit(2) + + +def api( + method: str, + path: str, + *, + body: dict | None = None, + query: dict[str, str] | None = None, + expect_json: bool = True, +) -> tuple[int, Any]: + url = f"{API}{path}" + if query: + url = f"{url}?{urllib.parse.urlencode(query)}" + data = None + headers = { + "Authorization": f"token {GITEA_TOKEN}", + "Accept": "application/json", + } + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, method=method, data=data, headers=headers) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + raw = resp.read() + status = resp.status + except urllib.error.HTTPError as exc: + raw = exc.read() + status = exc.code + + if not (200 <= status < 300): + snippet = raw[:500].decode("utf-8", errors="replace") if raw else "" + raise ApiError(f"{method} {path} -> HTTP {status}: {snippet}") + if not raw: + return status, None + try: + return status, json.loads(raw) + except json.JSONDecodeError as exc: + if expect_json: + raise ApiError(f"{method} {path} -> HTTP {status} non-JSON: {exc}") from exc + return status, {"_raw": raw.decode("utf-8", errors="replace")} + + +def required_contexts(raw: str) -> list[str]: + return [part.strip() for part in raw.split(",") if part.strip()] + + +def status_state(status: dict) -> str: + return str(status.get("status") or status.get("state") or "").lower() + + +def latest_statuses_by_context(statuses: list[dict]) -> dict[str, dict]: + latest: dict[str, dict] = {} + for status in statuses: + context = status.get("context") + if isinstance(context, str) and context not in latest: + latest[context] = status + return latest + + +def required_contexts_green( + latest_statuses: dict[str, dict], + contexts: list[str], +) -> tuple[bool, list[str]]: + missing_or_bad: list[str] = [] + for context in contexts: + status = latest_statuses.get(context) + state = status_state(status or {}) + if state != "success": + missing_or_bad.append(f"{context}={state or 'missing'}") + return not missing_or_bad, missing_or_bad + + +def label_names(issue: dict) -> set[str]: + return { + label["name"] + for label in issue.get("labels", []) + if isinstance(label, dict) and isinstance(label.get("name"), str) + } + + +def choose_next_queued_issue( + issues: list[dict], + *, + queue_label: str, + hold_label: str = "", +) -> dict | None: + candidates = [] + for issue in issues: + labels = label_names(issue) + if queue_label not in labels: + continue + if hold_label and hold_label in labels: + continue + if "pull_request" not in issue: + continue + candidates.append(issue) + candidates.sort(key=lambda issue: (issue.get("created_at") or "", int(issue["number"]))) + return candidates[0] if candidates else None + + +def pr_contains_base_sha(commits: list[dict], base_sha: str) -> bool: + for commit in commits: + sha = commit.get("sha") or commit.get("id") + if sha == base_sha: + return True + return False + + +def pr_has_current_base(pr: dict, commits: list[dict], main_sha: str) -> bool: + if pr.get("merge_base") == main_sha: + return True + return pr_contains_base_sha(commits, main_sha) + + +def evaluate_merge_readiness( + *, + main_status: dict, + pr_status: dict, + required_contexts: list[str], + pr_has_current_base: bool, +) -> MergeDecision: + main_state = str(main_status.get("state") or "").lower() + if main_state != "success": + return MergeDecision(False, "pause", f"main status is {main_state or 'missing'}") + if not pr_has_current_base: + return MergeDecision(False, "update", "PR head does not contain current main") + + pr_state = str(pr_status.get("state") or "").lower() + if pr_state != "success": + return MergeDecision(False, "wait", f"PR combined status is {pr_state or 'missing'}") + + latest = latest_statuses_by_context(pr_status.get("statuses") or []) + ok, missing_or_bad = required_contexts_green(latest, required_contexts) + if not ok: + return MergeDecision(False, "wait", "required contexts not green: " + ", ".join(missing_or_bad)) + return MergeDecision(True, "merge", "ready") + + +def get_branch_head(branch: str) -> str: + _, body = api("GET", f"/repos/{OWNER}/{NAME}/branches/{branch}") + commit = body.get("commit") if isinstance(body, dict) else None + sha = commit.get("id") if isinstance(commit, dict) else None + if not isinstance(sha, str) or len(sha) < 7: + raise ApiError(f"branch {branch} response missing commit id") + return sha + + +def get_combined_status(sha: str) -> dict: + _, body = api("GET", f"/repos/{OWNER}/{NAME}/commits/{sha}/status") + if not isinstance(body, dict): + raise ApiError(f"status for {sha} response not object") + return body + + +def list_queued_issues() -> list[dict]: + _, body = api( + "GET", + f"/repos/{OWNER}/{NAME}/issues", + query={ + "state": "open", + "type": "pulls", + "labels": QUEUE_LABEL, + "limit": "50", + }, + ) + if not isinstance(body, list): + raise ApiError("queued issues response not list") + return body + + +def get_pull(pr_number: int) -> dict: + _, body = api("GET", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}") + if not isinstance(body, dict): + raise ApiError(f"PR #{pr_number} response not object") + return body + + +def get_pull_commits(pr_number: int) -> list[dict]: + _, body = api("GET", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/commits") + if not isinstance(body, list): + raise ApiError(f"PR #{pr_number} commits response not list") + return body + + +def post_comment(pr_number: int, body: str, *, dry_run: bool) -> None: + print(f"::notice::comment PR #{pr_number}: {body.splitlines()[0][:160]}") + if dry_run: + return + api("POST", f"/repos/{OWNER}/{NAME}/issues/{pr_number}/comments", body={"body": body}) + + +def update_pull(pr_number: int, *, dry_run: bool) -> None: + print(f"::notice::updating PR #{pr_number} with base branch via style={UPDATE_STYLE}") + if dry_run: + return + api( + "POST", + f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/update", + query={"style": UPDATE_STYLE}, + expect_json=False, + ) + + +def merge_pull(pr_number: int, *, dry_run: bool) -> None: + payload = { + "Do": "merge", + "MergeTitleField": f"Merge PR #{pr_number} via Gitea merge queue", + "MergeMessageField": ( + "Serialized merge by gitea-merge-queue after current-main, " + "SOP, and required CI checks were green." + ), + } + print(f"::notice::merging PR #{pr_number}") + if dry_run: + return + api("POST", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/merge", body=payload, expect_json=False) + + +def process_once(*, dry_run: bool = False) -> int: + contexts = required_contexts(REQUIRED_CONTEXTS_RAW) + main_sha = get_branch_head(WATCH_BRANCH) + main_status = get_combined_status(main_sha) + if str(main_status.get("state") or "").lower() != "success": + print(f"::notice::queue paused: {WATCH_BRANCH}@{main_sha[:8]} is not green") + return 0 + + issue = choose_next_queued_issue( + list_queued_issues(), + queue_label=QUEUE_LABEL, + hold_label=HOLD_LABEL, + ) + if not issue: + print("::notice::merge queue empty") + return 0 + + pr_number = int(issue["number"]) + pr = get_pull(pr_number) + if pr.get("state") != "open": + print(f"::notice::PR #{pr_number} is not open; skipping") + return 0 + if pr.get("base", {}).get("ref") != WATCH_BRANCH: + post_comment(pr_number, f"merge-queue: skipped; base branch is not `{WATCH_BRANCH}`.", dry_run=dry_run) + return 0 + if pr.get("head", {}).get("repo_id") != pr.get("base", {}).get("repo_id"): + post_comment(pr_number, "merge-queue: skipped; fork PRs are not supported by the serialized queue.", dry_run=dry_run) + return 0 + + head_sha = pr.get("head", {}).get("sha") + if not isinstance(head_sha, str) or len(head_sha) < 7: + raise ApiError(f"PR #{pr_number} missing head sha") + commits = get_pull_commits(pr_number) + current_base = pr_has_current_base(pr, commits, main_sha) + pr_status = get_combined_status(head_sha) + decision = evaluate_merge_readiness( + main_status=main_status, + pr_status=pr_status, + required_contexts=contexts, + pr_has_current_base=current_base, + ) + + print(f"::notice::PR #{pr_number} decision={decision.action}: {decision.reason}") + if decision.action == "update": + update_pull(pr_number, dry_run=dry_run) + post_comment( + pr_number, + ( + f"merge-queue: updated this branch with `{WATCH_BRANCH}` at " + f"`{main_sha[:12]}`. Waiting for CI on the refreshed head." + ), + dry_run=dry_run, + ) + return 0 + if decision.ready: + latest_main_sha = get_branch_head(WATCH_BRANCH) + if latest_main_sha != main_sha: + print( + f"::notice::main moved {main_sha[:8]} -> {latest_main_sha[:8]}; " + "deferring to next tick" + ) + return 0 + merge_pull(pr_number, dry_run=dry_run) + return 0 + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + _require_runtime_env() + return process_once(dry_run=args.dry_run) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.gitea/workflows/gitea-merge-queue.yml b/.gitea/workflows/gitea-merge-queue.yml new file mode 100644 index 0000000..e204639 --- /dev/null +++ b/.gitea/workflows/gitea-merge-queue.yml @@ -0,0 +1,36 @@ +name: gitea-merge-queue + +on: + schedule: + - cron: '*/5 * * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: gitea-merge-queue-${{ github.repository }} + cancel-in-progress: false + +jobs: + queue: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out queue script from main + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Process one queued PR + env: + GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }} + GITEA_HOST: git.moleculesai.app + REPO: ${{ github.repository }} + WATCH_BRANCH: ${{ github.event.repository.default_branch }} + QUEUE_LABEL: merge-queue + HOLD_LABEL: merge-queue-hold + UPDATE_STYLE: merge + REQUIRED_CONTEXTS: >- + CI / validate (pull_request) + run: python3 .gitea/scripts/gitea-merge-queue.py diff --git a/.gitea/workflows/sop-checklist.yml b/.gitea/workflows/sop-checklist.yml new file mode 100644 index 0000000..fe86219 --- /dev/null +++ b/.gitea/workflows/sop-checklist.yml @@ -0,0 +1,130 @@ +# sop-checklist — peer-ack merge gate for SOP-checklist items. +# +# RFC#351 Step 2 of 6 (implementation MVP). +# +# === DESIGN === +# +# Goal: each PR must answer 7 SOP-checklist questions in its body, +# and each item must have at least one /sop-ack comment from +# a non-author peer in the required team. BP requires the +# `sop-checklist / all-items-acked (pull_request)` status to merge. +# +# Triggers: +# - `pull_request_target`: opened, edited, synchronize, reopened +# → fires when PR opens, body is edited (refire — RFC#351 §4), +# or new code is pushed (head.sha changes → stale status would +# be auto-discarded by BP via dismiss_stale_reviews, but the +# status itself is per-SHA so we re-post on the new head). +# - `issue_comment`: created, edited, deleted +# → fires on any new comment so /sop-ack / /sop-revoke take +# effect immediately (Gitea 1.22.6 doesn't refire on +# pull_request_review per feedback_pull_request_review_no_refire, +# so issue_comment is the canonical refire channel). +# +# Trust boundary (mirrors RFC#324 §A4 + sop-tier-check security note): +# `pull_request_target` (not `pull_request`) — workflow def is loaded +# from BASE branch, so a PR cannot rewrite this workflow to exfiltrate +# the token. The `actions/checkout` step pins `ref: base.sha` so the +# script ALSO comes from BASE. PR-HEAD code is never executed in the +# runner. +# +# Token scope: +# - read:repository, read:organization for PR + comments + team probes +# - write:repository for POST /statuses/{sha} +# - The token owner MUST be a member of every team referenced by the +# config's required_teams (else /teams/{id}/members/{login} returns +# 403 — see review-check.sh same-gotcha doc). For the MVP we use +# the dev-lead token (a member of engineers, managers, qa, security) +# via a repo secret `SOP_CHECKLIST_GATE_TOKEN`. Provisioning of that +# secret is a follow-up authorization step (separate from this PR). +# +# Failure mode: tier-aware (RFC#351 open question 2): +# - tier:high → state=failure (hard-fail; BP blocks merge) +# - tier:medium → state=failure (hard-fail; same) +# - tier:low → state=pending (soft-fail; BP can choose to require +# this context or skip for low-tier PRs) +# - missing/no-tier → state=failure (default-mode: hard — never lower +# the bar per feedback_fix_root_not_symptom) +# +# Slash-command contract (RFC#351 v1 + §A1.1-style notes from RFC#324): +# +# /sop-ack [optional note] +# — register a peer-ack for one checklist item. +# — slug accepts kebab-case, snake_case, or natural-spaces +# (all normalize to canonical kebab-case). +# — numeric 1..7 maps via config.items[*].numeric_alias. +# — most-recent (user, slug) directive wins. +# +# /sop-revoke [reason] +# — invalidate the commenter's own prior /sop-ack for this slug. +# — does NOT affect other peers' acks on the same slug. +# — most-recent (user, slug) directive wins, so a later /sop-ack +# re-restores the ack. +# +# The eval is read-only + idempotent (read PR + comments + team +# membership, compute, post status). Re-running on any event is safe — +# the new status overwrites the previous one for the same context. + +name: sop-checklist + +# Cancel any in-progress runs for the same PR to prevent +# stale runs from overwriting newer status contexts. +concurrency: + group: ${{ github.repository }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +# bp-required: yes ← emits sop-checklist / all-items-acked (pull_request) + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened, labeled, unlabeled] + issue_comment: + types: [created, edited, deleted] + +permissions: + contents: read + pull-requests: read + # NOTE: `statuses: write` is the GitHub-Actions name for POST /statuses. + # Gitea 1.22.6 may not gate on this permission key (it just checks the + # token), but listing it explicitly documents intent for the next + # platform-version upgrade. + statuses: write + +jobs: + all-items-acked: + # Run on pull_request_target events always. On issue_comment events, + # only when the comment is on a PR (issue_comment fires for issues + # too) and the body contains one of the slash-commands. + if: | + github.event_name == 'pull_request_target' || + (github.event_name == 'issue_comment' && + github.event.issue.pull_request != null && + (contains(github.event.comment.body, '/sop-ack') || + contains(github.event.comment.body, '/sop-revoke') || + contains(github.event.comment.body, '/sop-n/a'))) + runs-on: ubuntu-latest + steps: + - name: Check out BASE ref (trust boundary — never PR-head) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # For pull_request_target, the default branch is the trust + # anchor. For issue_comment the PR base may differ from the + # default branch (PR targeting `staging`), so we use the + # default-branch ref explicitly — same approach as + # qa-review.yml so the script source is always trusted. + ref: ${{ github.event.repository.default_branch }} + + - name: Run sop-checklist + env: + GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} + OWNER: ${{ github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + run: | + set -euo pipefail + python3 .gitea/scripts/sop-checklist.py \ + --owner "$OWNER" \ + --repo "$REPO_NAME" \ + --pr "$PR_NUMBER" \ + --config .gitea/sop-checklist-config.yaml \ + --gitea-host git.moleculesai.app -- 2.52.0 From d4567232c933c600e5f575e511a1da4eec6a4b32 Mon Sep 17 00:00:00 2001 From: Molecule AI SDK Lead Date: Thu, 14 May 2026 10:00:07 +0000 Subject: [PATCH 2/2] chore: remove sop-checklist.yml (not present in plugin repos) Co-Authored-By: SDK Lead Agent --- .gitea/workflows/sop-checklist.yml | 130 ----------------------------- 1 file changed, 130 deletions(-) delete mode 100644 .gitea/workflows/sop-checklist.yml diff --git a/.gitea/workflows/sop-checklist.yml b/.gitea/workflows/sop-checklist.yml deleted file mode 100644 index fe86219..0000000 --- a/.gitea/workflows/sop-checklist.yml +++ /dev/null @@ -1,130 +0,0 @@ -# sop-checklist — peer-ack merge gate for SOP-checklist items. -# -# RFC#351 Step 2 of 6 (implementation MVP). -# -# === DESIGN === -# -# Goal: each PR must answer 7 SOP-checklist questions in its body, -# and each item must have at least one /sop-ack comment from -# a non-author peer in the required team. BP requires the -# `sop-checklist / all-items-acked (pull_request)` status to merge. -# -# Triggers: -# - `pull_request_target`: opened, edited, synchronize, reopened -# → fires when PR opens, body is edited (refire — RFC#351 §4), -# or new code is pushed (head.sha changes → stale status would -# be auto-discarded by BP via dismiss_stale_reviews, but the -# status itself is per-SHA so we re-post on the new head). -# - `issue_comment`: created, edited, deleted -# → fires on any new comment so /sop-ack / /sop-revoke take -# effect immediately (Gitea 1.22.6 doesn't refire on -# pull_request_review per feedback_pull_request_review_no_refire, -# so issue_comment is the canonical refire channel). -# -# Trust boundary (mirrors RFC#324 §A4 + sop-tier-check security note): -# `pull_request_target` (not `pull_request`) — workflow def is loaded -# from BASE branch, so a PR cannot rewrite this workflow to exfiltrate -# the token. The `actions/checkout` step pins `ref: base.sha` so the -# script ALSO comes from BASE. PR-HEAD code is never executed in the -# runner. -# -# Token scope: -# - read:repository, read:organization for PR + comments + team probes -# - write:repository for POST /statuses/{sha} -# - The token owner MUST be a member of every team referenced by the -# config's required_teams (else /teams/{id}/members/{login} returns -# 403 — see review-check.sh same-gotcha doc). For the MVP we use -# the dev-lead token (a member of engineers, managers, qa, security) -# via a repo secret `SOP_CHECKLIST_GATE_TOKEN`. Provisioning of that -# secret is a follow-up authorization step (separate from this PR). -# -# Failure mode: tier-aware (RFC#351 open question 2): -# - tier:high → state=failure (hard-fail; BP blocks merge) -# - tier:medium → state=failure (hard-fail; same) -# - tier:low → state=pending (soft-fail; BP can choose to require -# this context or skip for low-tier PRs) -# - missing/no-tier → state=failure (default-mode: hard — never lower -# the bar per feedback_fix_root_not_symptom) -# -# Slash-command contract (RFC#351 v1 + §A1.1-style notes from RFC#324): -# -# /sop-ack [optional note] -# — register a peer-ack for one checklist item. -# — slug accepts kebab-case, snake_case, or natural-spaces -# (all normalize to canonical kebab-case). -# — numeric 1..7 maps via config.items[*].numeric_alias. -# — most-recent (user, slug) directive wins. -# -# /sop-revoke [reason] -# — invalidate the commenter's own prior /sop-ack for this slug. -# — does NOT affect other peers' acks on the same slug. -# — most-recent (user, slug) directive wins, so a later /sop-ack -# re-restores the ack. -# -# The eval is read-only + idempotent (read PR + comments + team -# membership, compute, post status). Re-running on any event is safe — -# the new status overwrites the previous one for the same context. - -name: sop-checklist - -# Cancel any in-progress runs for the same PR to prevent -# stale runs from overwriting newer status contexts. -concurrency: - group: ${{ github.repository }}-${{ github.event.pull_request.number }} - cancel-in-progress: true - -# bp-required: yes ← emits sop-checklist / all-items-acked (pull_request) - -on: - pull_request_target: - types: [opened, edited, synchronize, reopened, labeled, unlabeled] - issue_comment: - types: [created, edited, deleted] - -permissions: - contents: read - pull-requests: read - # NOTE: `statuses: write` is the GitHub-Actions name for POST /statuses. - # Gitea 1.22.6 may not gate on this permission key (it just checks the - # token), but listing it explicitly documents intent for the next - # platform-version upgrade. - statuses: write - -jobs: - all-items-acked: - # Run on pull_request_target events always. On issue_comment events, - # only when the comment is on a PR (issue_comment fires for issues - # too) and the body contains one of the slash-commands. - if: | - github.event_name == 'pull_request_target' || - (github.event_name == 'issue_comment' && - github.event.issue.pull_request != null && - (contains(github.event.comment.body, '/sop-ack') || - contains(github.event.comment.body, '/sop-revoke') || - contains(github.event.comment.body, '/sop-n/a'))) - runs-on: ubuntu-latest - steps: - - name: Check out BASE ref (trust boundary — never PR-head) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - # For pull_request_target, the default branch is the trust - # anchor. For issue_comment the PR base may differ from the - # default branch (PR targeting `staging`), so we use the - # default-branch ref explicitly — same approach as - # qa-review.yml so the script source is always trusted. - ref: ${{ github.event.repository.default_branch }} - - - name: Run sop-checklist - env: - GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} - OWNER: ${{ github.repository_owner }} - REPO_NAME: ${{ github.event.repository.name }} - run: | - set -euo pipefail - python3 .gitea/scripts/sop-checklist.py \ - --owner "$OWNER" \ - --repo "$REPO_NAME" \ - --pr "$PR_NUMBER" \ - --config .gitea/sop-checklist-config.yaml \ - --gitea-host git.moleculesai.app -- 2.52.0