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
2 changes: 1 addition & 1 deletion .github/actions/a38-guard/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: >

inputs:
token:
description: GitHub token (contents read, issues write, pull-requests write, statuses write; actions/checks read for lifecycle, actions write for workflow approval)
description: GitHub token (contents write for markPullRequestReadyForReview, issues write, pull-requests write, statuses write; actions/checks read for lifecycle, actions write for workflow approval)
required: true
repository:
description: owner/name override (workflow_dispatch / standalone)
Expand Down
5 changes: 3 additions & 2 deletions docs/a38-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ For the composite action, `A38_RUNTIME_REVISION` is overwritten from `${{ github

Standalone execution accepts the same explicit trusted `A38_RUNTIME_REVISION`. Without it, source-checkout fallback is allowed only when the loaded module is exactly `<root>/src/agent_cli/a38_guard.py`, `<root>/.git` belongs to that root, Git reports the same top-level using explicit `--git-dir` and `--work-tree`, and `HEAD` is lowercase 40-hex. The lookup is anchored to the module source root, removes inherited `GIT_*` variables, and never discovers from the current directory or an enclosing consumer checkout. A non-Git packaged install requires the explicit trusted revision; it never guesses `develop` or another moving ref. Closed PRs, ignored events, and empty all-open scans remain successful no-ops and do not need provenance resolution.

The token requires contents read, pull requests write, issues write and statuses write. Publishing the guard comment on a pull request needs `pull-requests: write` for `GITHUB_TOKEN`; `issues: write` alone is not enough and yields 403. Policy migrations also require permission to read collaborators' effective repository permissions. If that API is unavailable, the migration fails closed. Use a dedicated GitHub App or service account with the necessary repository access for external operation. Tokens are taken from `GH_TOKEN` or `GITHUB_TOKEN` and never printed.
The token requires contents write, pull requests write, issues write and statuses write. `markPullRequestReadyForReview` needs `contents: write` on `GITHUB_TOKEN` or it returns HTTP 200 with `isDraft` unchanged (`Resource not accessible by integration`). Publishing the guard comment on a pull request needs `pull-requests: write` for `GITHUB_TOKEN`; `issues: write` alone is not enough and yields 403. Policy migrations also require permission to read collaborators' effective repository permissions. If that API is unavailable, the migration fails closed. Use a dedicated GitHub App or service account with the necessary repository access for external operation. Tokens are taken from `GH_TOKEN` or `GITHUB_TOKEN` and never printed.

Actions must actually be available for event-driven operation. When Actions are blocked or unavailable, run the same reconciler on a trusted external host:

Expand Down Expand Up @@ -234,7 +234,8 @@ EN/DE comment says the Draft conversion did not take effect. Authorization recor
authenticated bot's numeric user ID can supply these records. Dry run performs
no writes, including audit comments.

The adopting workflow owns runner routing, `actions` and `checks` read access,
The adopting workflow owns runner routing, `contents: write` for
`markPullRequestReadyForReview`, `actions` and `checks` read access,
`pull-requests`/`issues`/`statuses` write access, and `actions: write` for initial
workflow approval. The guard authorizes waiting allowlisted fork runs **before**
it mutates Ready or Draft, so a failed convert-to-draft cannot skip approval.
Expand Down
4 changes: 3 additions & 1 deletion examples/a38-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ concurrency:
cancel-in-progress: false

permissions:
contents: read
contents: write
issues: write
pull-requests: write
statuses: write
actions: write
checks: read

jobs:
guard:
Expand Down
20 changes: 15 additions & 5 deletions src/agent_cli/pr_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,12 +287,22 @@ def _transition(api: Any, node: str, draft: bool) -> Mapping:
+ "(input: {pullRequestId: $id}) { pullRequest { id isDraft headRefOid baseRefOid } } }")
status, data, _ = api.request("POST", "/graphql", body={"query": query, "variables": {"id": node}}, retry=False)
pull = _field(data, "data", operation, "pullRequest")
if status != 200 or _field(data, "errors") or _field(pull, "id") != node:
raise GuardError(f"PR lifecycle mutation failed (HTTP {status})")
if _field(pull, "isDraft") is not draft:
if draft and _field(pull, "isDraft") is False:
errors = _field(data, "errors")
detail = ""
if isinstance(errors, list) and errors and isinstance(errors[0], Mapping):
detail = f": {errors[0].get('message') or 'graphql error'}"
if status != 200 or errors or _field(pull, "id") != node:
raise GuardError(f"PR lifecycle mutation failed (HTTP {status}){detail}")
got = _field(pull, "isDraft")
if got is not draft:
if draft and got is False:
raise LifecycleDraftUnchanged
raise GuardError(f"PR lifecycle mutation failed (HTTP {status})")
if not draft and got is True:
raise GuardError(
f"PR lifecycle mutation failed (HTTP {status}): isDraft unchanged; "
"GITHUB_TOKEN needs contents: write for markPullRequestReadyForReview"
)
raise GuardError(f"PR lifecycle mutation failed (HTTP {status}): isDraft={got!r}")
return pull


Expand Down
35 changes: 34 additions & 1 deletion tests/test_pr_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ def __init__(self):
self.graphql_error = False
self.graphql_noop_draft = False
self.graphql_ready_without_rest = False
self.graphql_noop_ready = False
self.graphql_noop_ready_null = False
self.mutate_during_transition = False
self.fail_comment_once = False

Expand All @@ -50,6 +52,14 @@ def request_fn(self, method, url, body=None):
return 200, {"data": {operation: {"pullRequest": {
"id": "PR_example", "isDraft": False,
"headRefOid": self.pull["head"]["sha"], "baseRefOid": BASE}}}}, {}
if self.graphql_noop_ready and operation == "markPullRequestReadyForReview":
return 200, {"data": {operation: {"pullRequest": {
"id": "PR_example", "isDraft": True,
"headRefOid": self.pull["head"]["sha"], "baseRefOid": BASE}}}}, {}
if self.graphql_noop_ready_null and operation == "markPullRequestReadyForReview":
return 200, {"data": {operation: {"pullRequest": {
"id": "PR_example", "isDraft": None,
"headRefOid": self.pull["head"]["sha"], "baseRefOid": BASE}}}}, {}
self.pull["draft"] = operation == "convertPullRequestToDraft"
self.transitions.append(self.pull["draft"])
if self.mutate_during_transition:
Expand Down Expand Up @@ -474,9 +484,32 @@ def test_graphql_error_is_not_a_successful_transition():
fake = LifecycleAPI()
fake.runs.clear()
fake.graphql_error = True
with pytest.raises(GuardError, match="mutation failed"):
with pytest.raises(GuardError, match="denied"):
reconcile_pull(fake.api(), REPO, 1)
assert not fake.transitions
assert all('"phase": "applied"' not in c["body"] for c in fake.comments)


def test_ready_mutation_http_200_with_unchanged_draft_fails_closed():
fake = LifecycleAPI()
fake.pull["draft"] = True
fake.own_authorization()
fake.graphql_noop_ready = True
with pytest.raises(GuardError, match="contents: write"):
reconcile_pull(fake.api(), REPO, 1)
assert not fake.transitions
assert fake.pull["draft"] is True


def test_ready_mutation_non_boolean_is_draft_fails_closed():
fake = LifecycleAPI()
fake.pull["draft"] = True
fake.own_authorization()
fake.graphql_noop_ready_null = True
with pytest.raises(GuardError, match=r"isDraft=None"):
reconcile_pull(fake.api(), REPO, 1)
assert not fake.transitions
assert fake.pull["draft"] is True
assert all('"phase": "applied"' not in c["body"] for c in fake.comments)


Expand Down
Loading