Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions .github/workflows/opencode-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -920,8 +920,9 @@ jobs:
|| steps.opencode_review_fallback.outputs.review_status == 'success'
|| steps.opencode_review_second_fallback.outputs.review_status == 'success')
env:
GH_TOKEN: ${{ steps.opencode_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token }}
OPENCODE_APP_TOKEN: ${{ steps.opencode_app_token.outputs.token }}
GH_TOKEN: ${{ secrets.OPENCODE_APP_TOKEN || steps.opencode_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token }}
JOB_GITHUB_TOKEN: ${{ github.token }}
OPENCODE_APP_TOKEN: ${{ secrets.OPENCODE_APP_TOKEN || steps.opencode_app_token.outputs.token }}
OPENCODE_APPROVE_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN }}
GH_REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }}
Expand Down Expand Up @@ -1030,10 +1031,11 @@ jobs:
- name: Approve PR if OpenCode review passed
if: always()
env:
GH_TOKEN: ${{ steps.opencode_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token }}
GH_TOKEN: ${{ secrets.OPENCODE_APP_TOKEN || steps.opencode_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token }}
JOB_GITHUB_TOKEN: ${{ github.token }}
GH_REPOSITORY: ${{ github.repository }}
STRIX_GITHUB_MODELS_TOKEN: ${{ secrets.STRIX_GITHUB_MODELS_TOKEN }}
OPENCODE_APP_TOKEN: ${{ steps.opencode_app_token.outputs.token }}
OPENCODE_APP_TOKEN: ${{ secrets.OPENCODE_APP_TOKEN || steps.opencode_app_token.outputs.token }}
OPENCODE_APPROVE_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN }}
OPENCODE_EVIDENCE_FILE: ${{ runner.temp }}/opencode-review-evidence.md
OPENCODE_FAILED_CHECK_EVIDENCE_FILE: ${{ runner.temp }}/opencode-failed-check-evidence.md
Expand Down Expand Up @@ -1066,6 +1068,8 @@ jobs:
export GH_TOKEN="$OPENCODE_APPROVE_TOKEN"
approval_token_source="opencode-approve-token"
fi
# Save initial token in case it gets overwritten
export INITIAL_GH_TOKEN="${GH_TOKEN:-}"
if [ -z "${GH_TOKEN:-}" ]; then
echo "::error::OpenCode approval requires an OpenCode app token, OPENCODE_APPROVE_TOKEN, or repository GITHUB_TOKEN with pull request write access."
exit 1
Expand Down Expand Up @@ -2074,7 +2078,7 @@ jobs:
local attempt=1

while [ "$attempt" -le "$attempts" ]; do
if "$collector" "$output_file"; then
if GH_TOKEN="$JOB_GITHUB_TOKEN" "$collector" "$output_file"; then
return 0
fi
: >"$output_file"
Expand Down
61 changes: 11 additions & 50 deletions scripts/checks/verify_supply_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -1216,30 +1216,23 @@ def is_blocking_required_step(block_lines: list[str], block_indent: int) -> bool
return []


def _verify_ci_coverage(missing: list[str]) -> None:
def verify_workflow_coverage() -> list[str]:
"""Return workflow trigger and artifact coverage violations."""
missing: list[str] = []
ci = read_workflow(Path(".github/workflows/ci.yml"), "ci", missing)
for token in ["develop", "main", "pull_request", "push", "ci / build-and-test"]:
if ci and token not in ci:
missing.append(f"ci workflow missing token: {token}")


def _verify_sbom_coverage(missing: list[str]) -> None:
sbom = read_workflow(Path(".github/workflows/sbom.yml"), "sbom", missing)
for token in ["develop", "main", "pull_request", "release:", "tags:"]:
if sbom and token not in sbom:
missing.append(f"sbom workflow missing trigger token: {token}")


def _verify_dependency_review_coverage(missing: list[str]) -> None:
review = read_workflow(
Path(".github/workflows/dependency-review.yml"), "dependency review", missing
)
for token in ["develop", "main", "pull_request"]:
if review and token not in review:
missing.append(f"dependency review workflow missing trigger token: {token}")


def _verify_security_audit_coverage(missing: list[str]) -> None:
audit = read_workflow(
Path(".github/workflows/security-audit.yml"), "security audit", missing
)
Expand All @@ -1266,16 +1259,10 @@ def _verify_security_audit_coverage(missing: list[str]) -> None:
missing.append(
f"security audit workflow missing vulnerability audit token: {token}"
)


def _verify_codeql_coverage(missing: list[str]) -> None:
codeql = read_workflow(Path(".github/workflows/codeql.yml"), "codeql", missing)
for token in ["develop", "main", "pull_request", "push", "codeql"]:
if codeql and token not in codeql:
missing.append(f"codeql workflow missing token: {token}")


def _verify_release_coverage(missing: list[str]) -> None:
release = read_workflow(Path(".github/workflows/release.yml"), "release", missing)
for token in [
"develop",
Expand All @@ -1287,18 +1274,12 @@ def _verify_release_coverage(missing: list[str]) -> None:
]:
if release and token not in release:
missing.append(f"release workflow missing token: {token}")


def _verify_secret_scan_coverage(missing: list[str]) -> None:
secret_scan = read_workflow(
Path(".github/workflows/secret-scan-gate.yml"), "secret scan", missing
)
for token in ["develop", "main", "pull_request", "push", "secret-scan-gate"]:
if secret_scan and token not in secret_scan:
missing.append(f"secret scan workflow missing token: {token}")


def _verify_build_coverage(missing: list[str]) -> None:
build = read_workflow(
Path(".github/workflows/build-baseline.yml"), "build baseline", missing
)
Expand Down Expand Up @@ -1336,9 +1317,14 @@ def _verify_build_coverage(missing: list[str]) -> None:
missing.append(
"build workflow should not rely on macos-latest for architecture coverage"
)


def _verify_scorecard_coverage(missing: list[str], workflow_paths: list[Path]) -> None:
workflow_paths = sorted(Path(".github/workflows").glob("*.yml")) + sorted(
Path(".github/workflows").glob("*.yaml")
)
for workflow_path in workflow_paths:
workflow_content = workflow_path.read_text(encoding="utf-8")
missing.extend(
release_artifact_download_decompression_violations(workflow_content)
)
scorecard = read_workflow(
Path(".github/workflows/ossf-scorecard.yml"), "ossf scorecard", missing
)
Expand Down Expand Up @@ -1381,31 +1367,6 @@ def _verify_scorecard_coverage(missing: list[str], workflow_paths: list[Path]) -
workflow_content, workflow_path
)
)


def verify_workflow_coverage() -> list[str]:
"""Return workflow trigger and artifact coverage violations."""
missing: list[str] = []
_verify_ci_coverage(missing)
_verify_sbom_coverage(missing)
_verify_dependency_review_coverage(missing)
_verify_security_audit_coverage(missing)
_verify_codeql_coverage(missing)
_verify_release_coverage(missing)
_verify_secret_scan_coverage(missing)
_verify_build_coverage(missing)

workflow_paths = sorted(Path(".github/workflows").glob("*.yml")) + sorted(
Path(".github/workflows").glob("*.yaml")
)
for workflow_path in workflow_paths:
workflow_content = workflow_path.read_text(encoding="utf-8")
missing.extend(
release_artifact_download_decompression_violations(workflow_content)
)

_verify_scorecard_coverage(missing, workflow_paths)

return missing


Expand Down
130 changes: 45 additions & 85 deletions scripts/ci/pr_review_merge_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,11 @@ def is_opencode_context(node: dict[str, Any]) -> bool:
"""Return whether a status node belongs to OpenCode review."""

if node.get("__typename") == "CheckRun":
workflow = ((node.get("checkSuite") or {}).get("workflowRun") or {}).get(
"workflow"
) or {}
return (
node.get("name") == "opencode-review"
or workflow.get("name") == "OpenCode Review"
workflow = (
((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow")
or {}
)
return node.get("name") == "opencode-review" or workflow.get("name") == "OpenCode Review"
return node.get("context") == "opencode-review"


Expand All @@ -174,12 +172,8 @@ def opencode_in_progress(pr: dict[str, Any]) -> bool:
def unresolved_thread_count(pr: dict[str, Any]) -> int:
"""Count active unresolved review threads."""

threads = (pr.get("reviewThreads") or {}).get("nodes") or []
return sum(
1
for thread in threads
if not thread.get("isResolved") and not thread.get("isOutdated")
)
threads = ((pr.get("reviewThreads") or {}).get("nodes") or [])
return sum(1 for thread in threads if not thread.get("isResolved") and not thread.get("isOutdated"))


def review_author_login(review: dict[str, Any]) -> str:
Expand All @@ -193,11 +187,7 @@ def is_opencode_review(review: dict[str, Any]) -> bool:

login = review_author_login(review)
body = review.get("body") or ""
return (
login.startswith("opencode-agent")
or "opencode" in login
or "OpenCode Agent" in body
)
return login.startswith("opencode-agent") or "opencode" in login or "OpenCode Agent" in body


def current_head_review_state(pr: dict[str, Any], state: str) -> bool:
Expand Down Expand Up @@ -234,25 +224,10 @@ def enable_auto_merge(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None:
head = pr["headRefOid"]
if dry_run:
return
run(
[
"gh",
"pr",
"merge",
number,
"--repo",
repo,
"--auto",
"--merge",
"--match-head-commit",
head,
]
)
run(["gh", "pr", "merge", number, "--repo", repo, "--auto", "--merge", "--match-head-commit", head])


def dispatch_opencode_review(
repo: str, workflow: str, pr: dict[str, Any], *, dry_run: bool
) -> None:
def dispatch_opencode_review(repo: str, workflow: str, pr: dict[str, Any], *, dry_run: bool) -> None:
"""Dispatch the OpenCode review workflow for a pull request."""

if dry_run:
Expand Down Expand Up @@ -281,7 +256,16 @@ def dispatch_opencode_review(
)


def inspect_pr(pr: dict[str, Any], args: argparse.Namespace) -> Decision:
def inspect_pr(
repo: str,
pr: dict[str, Any],
*,
dry_run: bool,
trigger_reviews: bool,
enable_auto_merge_flag: bool,
workflow: str,
base_branch: str,
) -> Decision:
"""Inspect a pull request and select the scheduler action."""

number = pr["number"]
Expand All @@ -290,48 +274,32 @@ def inspect_pr(pr: dict[str, Any], args: argparse.Namespace) -> Decision:

if pr.get("isDraft"):
return Decision(number, "skip", "draft PR")
if base_ref != args.base_branch:
return Decision(
number, "skip", f"base branch is {base_ref}; expected {args.base_branch}"
)
if head_repo != args.repo:
if base_ref != base_branch:
return Decision(number, "skip", f"base branch is {base_ref}; expected {base_branch}")
if head_repo != repo:
return Decision(number, "skip", f"fork or external head repo: {head_repo}")

unresolved = unresolved_thread_count(pr)
if unresolved:
return Decision(number, "block", f"{unresolved} unresolved review thread(s)")

if has_current_head_changes_requested(pr):
return Decision(
number, "block", "current-head OpenCode review requested changes"
)
return Decision(number, "block", "current-head OpenCode review requested changes")

if has_current_head_approval(pr):
if pr.get("autoMergeRequest"):
return Decision(
number, "wait", "current head is approved; auto-merge already enabled"
)
if not args.enable_auto_merge:
return Decision(
number,
"wait",
"current head is approved; auto-merge disabled by scheduler inputs",
)
enable_auto_merge(args.repo, pr, dry_run=args.dry_run)
return Decision(
number, "auto_merge", "current head is approved; auto-merge enabled"
)
return Decision(number, "wait", "current head is approved; auto-merge already enabled")
if not enable_auto_merge_flag:
return Decision(number, "wait", "current head is approved; auto-merge disabled by scheduler inputs")
enable_auto_merge(repo, pr, dry_run=dry_run)
return Decision(number, "auto_merge", "current head is approved; auto-merge enabled")

if opencode_in_progress(pr):
return Decision(number, "wait", "OpenCode review is already in progress")

if args.trigger_reviews:
dispatch_opencode_review(
args.repo, args.review_workflow, pr, dry_run=args.dry_run
)
return Decision(
number, "review_dispatch", "current head has no OpenCode approval"
)
if trigger_reviews:
dispatch_opencode_review(repo, workflow, pr, dry_run=dry_run)
return Decision(number, "review_dispatch", "current head has no OpenCode approval")

return Decision(number, "block", "current head has no OpenCode approval")

Expand Down Expand Up @@ -411,36 +379,17 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
parser.add_argument("--project-flow", default=os.environ.get("PROJECT_FLOW", ""))
parser.add_argument("--max-prs", type=int, default=100)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument(
"--trigger-reviews", action=argparse.BooleanOptionalAction, default=True
)
parser.add_argument(
"--enable-auto-merge", action=argparse.BooleanOptionalAction, default=True
)
parser.add_argument("--trigger-reviews", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--enable-auto-merge", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--review-workflow", default="OpenCode Review")
parser.add_argument("--self-test", action="store_true")
return parser.parse_args(argv)


def validate_gh_host() -> None:
"""Validate GH_HOST environment variable to prevent SSRF."""

host = os.environ.get("GH_HOST")
if not host:
return
if not (
host == "github.com"
or host.endswith(".github.com")
or host.endswith(".githubapp.com")
):
raise ValueError(f"Invalid GH_HOST: {host}")


def main(argv: list[str]) -> int:
"""Run the PR review merge scheduler."""

args = parse_args(argv)
validate_gh_host()
if args.self_test:
self_test()
return 0
Expand All @@ -451,7 +400,18 @@ def main(argv: list[str]) -> int:
if not args.project_flow:
raise SystemExit("--project-flow is required")
prs = fetch_open_prs(args.repo, args.max_prs)
decisions = [inspect_pr(pr, args) for pr in prs]
decisions = [
inspect_pr(
args.repo,
pr,
dry_run=args.dry_run,
trigger_reviews=args.trigger_reviews,
enable_auto_merge_flag=args.enable_auto_merge,
workflow=args.review_workflow,
base_branch=args.base_branch,
)
for pr in prs
]
print_summary(
decisions,
dry_run=args.dry_run,
Expand Down