Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .well-known/agents-shipgate.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,16 @@
"external_integration_surfaces": ["preflight", "capability_lock", "capability_lock_diff", "capability_standard", "governance_benchmark_catalog", "governance_benchmark_result"],
"gating_signal": "release_decision.decision",
"merge_verdicts": ["mergeable", "human_review_required", "insufficient_evidence", "blocked", "unknown"],
"check_run_policies": ["advisory", "blocked-fails", "require-mergeable"],
"github_action_pr_workflow": {
"recommended_inputs": {
"ci_mode": "advisory",
"diff_base": "target",
"check_annotations": "true",
"pr_comment": "true"
},
"branch_protection_check_run_policy": "require-mergeable"
},
"applicability_values": ["verified", "not_applicable", "unknown"],
"release_decisions": ["passed", "review_required", "insufficient_evidence", "blocked"],
"merge_verdict_labels": {
Expand Down
18 changes: 15 additions & 3 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,12 @@ inputs:
Create/update a GitHub Check Run named by check_run_name with the
merge verdict as the check conclusion (mergeable=success,
blocked=failure, otherwise neutral) and up to 50 line-level
annotations from report.sarif. Requires `checks: write` permission
on the workflow.
annotations from the PR projection. Requires `checks: write`
permission on the workflow.
check_run_policy:
required: false
default: advisory
description: "Check Run conclusion policy: advisory, blocked-fails, or require-mergeable."
check_run_name:
required: false
default: "Agents Shipgate"
Expand Down Expand Up @@ -395,7 +399,14 @@ runs:
return;
}
const STICKY = "<!-- agents-shipgate-pr-comment -->";
const body = fs.readFileSync(commentPath, "utf8").slice(0, 6000);
let body = fs.readFileSync(commentPath, "utf8").slice(0, 6000);
const serverUrl = process.env.GITHUB_SERVER_URL || "https://github.com";
const runId = process.env.GITHUB_RUN_ID;
const runUrl = `${serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
const artifactLine = `\n\nWorkflow artifacts: ${runUrl}`;
if (body.length + artifactLine.length <= 6000) {
body = `${body}${artifactLine}`;
}
// Upsert via sticky marker so re-runs update the existing comment instead of spamming the PR.
// Paginate the lookup — single-page (per_page=100) misses the marker on PRs with >100
// earlier comments before Shipgate's first run, which would silently regress to append-only.
Expand Down Expand Up @@ -428,6 +439,7 @@ runs:
env:
OUTPUT_DIR: ${{ inputs.output_dir }}
CHECK_RUN_NAME: ${{ inputs.check_run_name }}
CHECK_RUN_POLICY: ${{ inputs.check_run_policy }}
run: |
python "${GITHUB_ACTION_PATH}/scripts/github_check_run.py"

Expand Down
5 changes: 5 additions & 0 deletions docs/shipgate-strategic-engineering-review.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Shipgate Strategic Engineering Review

> 历史说明:本文是 2026-06-09 基于 `0.11.0` / commit `ef58a57`
> 的战略审计快照。当前 `main` 已经实现 GitHub Check Run、Actions
> annotations、`verifier.json` 和 PR capability-review comment;文中将这些
> 能力标为"缺失"的段落应按历史记录阅读,而不是当前实现状态。

> 审计日期:2026-06-09 · 审计对象:`ThreeMoonsLab/agents-shipgate` · commit `ef58a57` · 版本 `0.11.0`
>
> 证据标注约定:**【仓库实证】** 直接观察到;**【意图明确但未完成】** 有代码/文档意图但未落地;**【缺失/未实现】** 经搜索确认不存在;**【战略建议】** 基于明确论点的建议。
Expand Down
16 changes: 10 additions & 6 deletions examples/github-actions/10-check-run-annotations.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
# Surface the merge verdict as a native GitHub Check Run with line-level
# annotations from the SARIF report. Branch protection can then require
# annotations from the PR projection. Branch protection can then require
# the "Agents Shipgate" check directly — no custom exit-code wiring.
#
# Conclusion mapping: mergeable → success, blocked → failure, everything
# else (human_review_required / insufficient_evidence / unknown) →
# neutral, so human-routed verdicts never hard-fail the check.
# This recipe uses check_run_policy=require-mergeable, so only PRs with
# can_merge_without_human=true produce a successful Check Run.
#
# check_run_policy is newer than v0.13.0. Until the next release is tagged,
# this example targets main and intentionally omits shipgate_version so the
# action installs the CLI from the same ref. After release, pin both the action
# ref and shipgate_version to that release.
name: Agents Shipgate (check run)

on:
Expand All @@ -23,11 +27,11 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: ThreeMoonsLab/agents-shipgate@v0.13.0
- uses: ThreeMoonsLab/agents-shipgate@main
with:
config: shipgate.yaml
ci_mode: advisory
diff_base: target
pr_comment: 'true'
check_run: 'true'
shipgate_version: '0.13.0'
check_run_policy: require-mergeable
12 changes: 11 additions & 1 deletion examples/github-actions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Copy-paste-ready workflows. Each one is a complete file — drop it into `.githu
| [`07-block-on-blocked-verdict.yml`](07-block-on-blocked-verdict.yml) | Intermediate verifier policy: allow human-review PRs, but fail blocked verdicts. |
| [`08-require-mergeable.yml`](08-require-mergeable.yml) | Strict verifier policy: fail unless no human authority gap remains. |
| [`09-risk-labels-and-reviewers.yml`](09-risk-labels-and-reviewers.yml) | Label PRs by risk signal (`agent-capability-change`, `trust-root-touched`, `shipgate-blocked`) and request boundary owners as reviewers. |
| [`10-check-run-annotations.yml`](10-check-run-annotations.yml) | Native Check Run with line-level SARIF annotations; branch protection can require the "Agents Shipgate" check directly. Needs `checks: write`. |
| [`10-check-run-annotations.yml`](10-check-run-annotations.yml) | Native Check Run with merge-relevant line annotations; branch protection can require the "Agents Shipgate" check directly. Needs `checks: write`. |
| [`11-fail-on-insufficient-evidence.yml`](11-fail-on-insufficient-evidence.yml) | Evidence policy: fail when static evidence is too weak to gate confidently. |
| [`12-host-grant-drift.yml`](12-host-grant-drift.yml) | Scheduled drift gate: fail when current coding-agent host grants (MCP servers, permission rules, hooks, workflow scopes) no longer match the acknowledged `.agents-shipgate/host-grants.json` baseline. Catches authority changes that land outside PR review. |

Expand Down Expand Up @@ -94,6 +94,16 @@ The Action also emits GitHub Actions job annotations by default for
source-backed blockers and review items. Disable with `check_annotations:
'false'`, or tune the cap with `check_annotation_limit`.

When `check_run: 'true'` is enabled, the Check Run uses the same PR projection
as job annotations. `check_run_policy: advisory` preserves the default
mergeable/success, blocked/failure, human-routed/neutral behavior.
`check_run_policy: blocked-fails` keeps human-routed verdicts neutral but fails
`blocked` and `unknown` so setup failures do not look successful. For direct
branch protection, use `check_run_policy: require-mergeable`; only
`can_merge_without_human == true` succeeds. `check_run_policy` is newer than
v0.13.0; until the next release is tagged, the Check Run policy example targets
`main` and omits `shipgate_version` so the action installs from that ref.

`verify` writes static capability artifacts to the workflow artifact when
available: `capabilities.lock.json`, `base.capabilities.lock.json`, and
`capability-lock-diff.json`. These are review artifacts only; they do not
Expand Down
123 changes: 14 additions & 109 deletions scripts/github_action_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,38 @@
from pathlib import Path
from typing import Any

from agents_shipgate.report.pr_projection import (
PR_PROJECTION_SCHEMA_VERSION,
item_to_action_annotation,
select_pr_items,
)

ANNOTATION_SCHEMA_VERSION = "0.1"


def build_annotations(output_dir: Path, *, limit: int = 50) -> dict[str, Any]:
report_path = output_dir / "report.json"
verifier_path = output_dir / "verifier.json"
payload = _load_json(report_path)
findings = payload.get("findings") or []
release_decision = payload.get("release_decision") or {}
selected = _selected_findings(findings, release_decision)
verifier = _load_json(verifier_path)
selected = select_pr_items(payload, verifier, limit=10_000)
normalized_limit = max(0, limit)
annotations: list[dict[str, Any]] = []
omitted_no_source = 0
omitted_by_limit = 0
for finding in selected:
source = _best_source(finding)
if source is None:
for item in selected:
if item.source_path is None:
omitted_no_source += 1
continue
if len(annotations) >= normalized_limit:
omitted_by_limit += 1
continue
annotations.append(_annotation(finding, source))
annotations.append(item_to_action_annotation(item))
return {
"annotation_schema_version": ANNOTATION_SCHEMA_VERSION,
"pr_projection_schema_version": PR_PROJECTION_SCHEMA_VERSION,
"source_report": str(report_path),
"source_verifier": str(verifier_path),
"limit": normalized_limit,
"annotations": annotations,
"omitted": {
Expand Down Expand Up @@ -64,104 +71,6 @@ def emit_github_annotations(payload: dict[str, Any]) -> None:
print(f"::{level} {prop_text}::{message}")


def _selected_findings(
findings: list[Any],
release_decision: dict[str, Any],
) -> list[dict[str, Any]]:
active = [
finding
for finding in findings
if isinstance(finding, dict) and not finding.get("suppressed")
]
by_id = {finding.get("id"): finding for finding in active if finding.get("id")}
by_fingerprint = {
finding.get("fingerprint"): finding
for finding in active
if finding.get("fingerprint")
}
selected: list[dict[str, Any]] = []
seen: set[int] = set()
for kind in ("blockers", "review_items"):
for item in release_decision.get(kind) or []:
if not isinstance(item, dict):
continue
finding = by_id.get(item.get("id")) or by_fingerprint.get(
item.get("fingerprint")
)
if finding is not None and id(finding) not in seen:
selected.append(finding)
seen.add(id(finding))
if selected:
return selected
return sorted(active, key=_finding_sort_key)


def _finding_sort_key(finding: dict[str, Any]) -> tuple[int, str, str]:
return (
{"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}.get(
str(finding.get("severity") or ""),
9,
),
str(finding.get("check_id") or ""),
str(finding.get("title") or ""),
)


def _best_source(finding: dict[str, Any]) -> dict[str, Any] | None:
for key in ("source", "policy_evidence_source"):
source = finding.get(key)
if not isinstance(source, dict):
continue
if source.get("path"):
return source
return None


def _annotation(finding: dict[str, Any], source: dict[str, Any]) -> dict[str, Any]:
path = str(source.get("path") or "")
selector = _selector(source, path)
title = f"{finding.get('check_id')}: {finding.get('title')}"
recommendation = str(finding.get("recommendation") or "")
message = recommendation or str(finding.get("title") or finding.get("check_id") or "")
if selector:
message = f"{message} Source: {selector}"
return {
"level": _annotation_level(str(finding.get("severity") or "")),
"path": path,
"start_line": source.get("start_line"),
"end_line": source.get("end_line"),
"start_column": source.get("start_column"),
"selector": selector,
"title": _truncate(title, 160),
"message": _truncate(message, 1000),
"check_id": finding.get("check_id"),
"severity": finding.get("severity"),
"finding_id": finding.get("id"),
"fingerprint": finding.get("fingerprint"),
}


def _annotation_level(severity: str) -> str:
if severity in {"critical", "high"}:
return "error"
if severity == "medium":
return "warning"
return "notice"


def _selector(source: dict[str, Any], path: str) -> str:
pointer = source.get("pointer")
if pointer is not None:
return f"{path}#{pointer}"
location = source.get("location")
if location:
return str(location)
ref = source.get("ref")
if ref:
return str(ref)
return path


def _escape_data(value: object) -> str:
return (
str(value)
Expand All @@ -179,10 +88,6 @@ def _escape_property(value: object) -> str:
)


def _truncate(value: str, limit: int) -> str:
return value if len(value) <= limit else value[: limit - 1] + "..."


def _load_json(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
Expand Down
25 changes: 25 additions & 0 deletions scripts/github_action_outputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ def append_step_summary(output_dir: Path, values: dict[str, object]) -> None:
return

payload = _load_json(output_dir / "report.json")
verifier_payload = _load_json(output_dir / "verifier.json")
agent_result = _load_json(output_dir / "agent-result.json")
release_decision = payload.get("release_decision") or {}
summary = payload.get("summary") or {}
Expand All @@ -169,8 +170,23 @@ def append_step_summary(output_dir: Path, values: dict[str, object]) -> None:
blocker_count = len(release_decision.get("blockers") or [])
review_item_count = len(release_decision.get("review_items") or [])
would_fail_ci = str(bool(fail_policy.get("would_fail_ci"))).lower()
first_next_action = verifier_payload.get("first_next_action") or {}
with open(step_summary, "a", encoding="utf-8") as summary_file:
summary_file.write("## Agents Shipgate\n\n")
if verifier_payload:
summary_file.write(
f"- Merge verdict: `{clean(verifier_payload.get('merge_verdict'))}`\n"
)
summary_file.write(
"- Can merge without human: "
f"`{clean(values.get('can_merge_without_human'))}`\n"
)
if isinstance(first_next_action, dict) and first_next_action:
actor = clean(first_next_action.get("actor"))
kind = clean(first_next_action.get("kind"))
action = "/".join(part for part in (actor, kind) if part)
if action:
summary_file.write(f"- First next action: `{action}`\n")
if agent_result:
summary_file.write(
f"- Decision: `{clean(agent_result.get('decision'))}`\n"
Expand Down Expand Up @@ -254,6 +270,15 @@ def append_step_summary(output_dir: Path, values: dict[str, object]) -> None:
f"- Tool-surface diff: {clean(tool_surface_diff.get('notes')[0])}\n"
)
summary_file.write(f"- Report JSON: `{clean(values.get('report_json'))}`\n")
if values.get("verifier_json"):
summary_file.write(
f"- Verifier JSON: `{clean(values.get('verifier_json'))}`\n"
)
if values.get("pr_comment_markdown"):
summary_file.write(
"- PR comment Markdown: "
f"`{clean(values.get('pr_comment_markdown'))}`\n"
)


def write_github_outputs(values: dict[str, object]) -> None:
Expand Down
Loading