Skip to content

Commit ff52770

Browse files
fix(ci): trust generated Agentic CI PRs (#643)
* fix(ci): trust generated agentic CI PRs Signed-off-by: Andre Manoel <amanoel@nvidia.com> * fix(ci): authorize generated PR checks Signed-off-by: Andre Manoel <amanoel@nvidia.com> * fix(ci): pin authorized agentic checks Signed-off-by: Andre Manoel <amanoel@nvidia.com> * fix(ci): narrow agentic CI trust * fix(ci): reject stale agentic authorizations * fix(ci): serialize agentic authorization --------- Signed-off-by: Andre Manoel <amanoel@nvidia.com>
1 parent abb4a24 commit ff52770

5 files changed

Lines changed: 403 additions & 3 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
name: "Agentic CI Authorization Checks"
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
pr_number:
7+
description: "Agentic CI PR number"
8+
required: true
9+
type: string
10+
expected_head_sha:
11+
description: "PR head SHA authorized by the maintainer"
12+
required: true
13+
type: string
14+
15+
permissions:
16+
contents: read
17+
pull-requests: read
18+
19+
concurrency:
20+
group: agentic-ci-authorized-checks-${{ inputs.pr_number }}
21+
cancel-in-progress: true
22+
23+
defaults:
24+
run:
25+
shell: bash
26+
27+
jobs:
28+
pr:
29+
timeout-minutes: 5
30+
runs-on: ubuntu-latest
31+
outputs:
32+
title_b64: ${{ steps.metadata.outputs.title_b64 }}
33+
trusted: ${{ steps.metadata.outputs.trusted }}
34+
steps:
35+
- name: Load PR metadata
36+
id: metadata
37+
env:
38+
EXPECTED_HEAD_SHA: ${{ inputs.expected_head_sha }}
39+
GH_TOKEN: ${{ github.token }}
40+
PR_NUMBER: ${{ inputs.pr_number }}
41+
REPO: ${{ github.repository }}
42+
run: |
43+
if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
44+
echo "::error::Invalid PR number: ${PR_NUMBER}"
45+
exit 1
46+
fi
47+
48+
PR_JSON=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}")
49+
PR_AUTHOR=$(printf '%s' "$PR_JSON" | jq -r '.user.login')
50+
HEAD_REPO=$(printf '%s' "$PR_JSON" | jq -r '.head.repo.full_name')
51+
HEAD_REF=$(printf '%s' "$PR_JSON" | jq -r '.head.ref')
52+
HEAD_SHA=$(printf '%s' "$PR_JSON" | jq -r '.head.sha')
53+
TITLE=$(printf '%s' "$PR_JSON" | jq -r '.title')
54+
PR_BODY=$(printf '%s' "$PR_JSON" | jq -r '.body // ""')
55+
56+
if [ "$HEAD_SHA" != "$EXPECTED_HEAD_SHA" ]; then
57+
echo "::error::PR head moved from ${EXPECTED_HEAD_SHA} to ${HEAD_SHA}."
58+
exit 1
59+
fi
60+
61+
if [ "$GITHUB_SHA" != "$HEAD_SHA" ]; then
62+
echo "::error::Workflow SHA ${GITHUB_SHA} does not match PR head ${HEAD_SHA}."
63+
exit 1
64+
fi
65+
66+
TRUSTED=false
67+
printf '%s' "$PR_BODY" > /tmp/pr-body-raw.txt
68+
# Commit authors can be spoofed; trust only PR metadata GitHub controls.
69+
if [ "$PR_AUTHOR" = "github-actions[bot]" ] && \
70+
[ "$HEAD_REPO" = "$REPO" ] && \
71+
[[ "$HEAD_REF" == agentic-ci/* ]] && \
72+
grep -Eq '<!-- agentic-ci finding=[^[:space:]]+ suite=[^[:space:]]+ -->' /tmp/pr-body-raw.txt; then
73+
TRUSTED=true
74+
fi
75+
76+
echo "trusted=${TRUSTED}" >> "$GITHUB_OUTPUT"
77+
echo "title_b64=$(printf '%s' "$TITLE" | base64 -w0)" >> "$GITHUB_OUTPUT"
78+
79+
DCOAssistant:
80+
needs: pr
81+
if: always()
82+
timeout-minutes: 5
83+
runs-on: ubuntu-latest
84+
steps:
85+
- name: Validate authorization
86+
env:
87+
PR_RESULT: ${{ needs.pr.result }}
88+
TRUSTED: ${{ needs.pr.outputs.trusted }}
89+
run: |
90+
if [ "$PR_RESULT" != "success" ] || [ "$TRUSTED" != "true" ]; then
91+
echo "::error::This PR is not an authorized Agentic CI PR."
92+
exit 1
93+
fi
94+
echo "Trusted Agentic CI PR authorized by a maintainer."
95+
96+
semantic-pull-request:
97+
name: semantic-pull-request / semantic-pull-request
98+
needs: pr
99+
if: always()
100+
timeout-minutes: 5
101+
runs-on: ubuntu-latest
102+
steps:
103+
- name: Validate PR title
104+
env:
105+
PR_RESULT: ${{ needs.pr.result }}
106+
TITLE_B64: ${{ needs.pr.outputs.title_b64 }}
107+
TRUSTED: ${{ needs.pr.outputs.trusted }}
108+
run: |
109+
if [ "$PR_RESULT" != "success" ] || [ "$TRUSTED" != "true" ]; then
110+
echo "::error::This PR is not an authorized Agentic CI PR."
111+
exit 1
112+
fi
113+
114+
TITLE=$(printf '%s' "$TITLE_B64" | base64 -d)
115+
TYPES='feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert|cp'
116+
REGEX="^(${TYPES})(\\([^)]+\\))?!?: .+"
117+
if ! [[ "$TITLE" =~ $REGEX ]]; then
118+
echo "::error::PR title is not semantic: ${TITLE}"
119+
exit 1
120+
fi
121+
122+
if [ "${#TITLE}" -gt 80 ]; then
123+
echo "::error::PR title is longer than 80 characters: ${#TITLE}"
124+
exit 1
125+
fi
126+
127+
check:
128+
needs: pr
129+
if: always()
130+
timeout-minutes: 5
131+
runs-on: ubuntu-latest
132+
steps:
133+
- name: Validate linked issue authorization
134+
env:
135+
PR_RESULT: ${{ needs.pr.result }}
136+
TRUSTED: ${{ needs.pr.outputs.trusted }}
137+
run: |
138+
if [ "$PR_RESULT" != "success" ] || [ "$TRUSTED" != "true" ]; then
139+
echo "::error::This PR is not an authorized Agentic CI PR."
140+
exit 1
141+
fi
142+
echo "Trusted Agentic CI PRs do not require a linked issue."
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
name: "Authorize Agentic CI"
2+
3+
on:
4+
issue_comment:
5+
types: [created]
6+
7+
permissions:
8+
actions: write
9+
contents: read
10+
issues: write
11+
pull-requests: read
12+
13+
defaults:
14+
run:
15+
shell: bash
16+
17+
concurrency:
18+
group: authorize-agentic-ci-${{ github.event.issue.number }}
19+
cancel-in-progress: false
20+
21+
jobs:
22+
authorize:
23+
if: >-
24+
github.repository_owner == 'NVIDIA-NeMo'
25+
&& github.event.issue.pull_request != null
26+
&& github.event.comment.body == '/authorize-agentic-ci'
27+
runs-on: ubuntu-latest
28+
steps:
29+
- name: Check commenter permission
30+
env:
31+
GH_TOKEN: ${{ github.token }}
32+
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
33+
PR_NUMBER: ${{ github.event.issue.number }}
34+
REPO: ${{ github.repository }}
35+
run: |
36+
PERMISSION=$(gh api "repos/${REPO}/collaborators/${COMMENT_AUTHOR}/permission" \
37+
--jq '.permission' 2>/dev/null || echo "none")
38+
echo "Comment author ${COMMENT_AUTHOR} has ${PERMISSION} permission."
39+
40+
case "$PERMISSION" in
41+
admin|maintain|write)
42+
;;
43+
*)
44+
gh issue comment "$PR_NUMBER" --repo "$REPO" --body \
45+
"Only maintainers with write access can authorize Agentic CI checks."
46+
exit 1
47+
;;
48+
esac
49+
50+
- name: Load PR metadata
51+
id: pr
52+
env:
53+
GH_TOKEN: ${{ github.token }}
54+
PR_NUMBER: ${{ github.event.issue.number }}
55+
REPO: ${{ github.repository }}
56+
run: |
57+
PR_JSON=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}")
58+
59+
PR_AUTHOR=$(printf '%s' "$PR_JSON" | jq -r '.user.login')
60+
HEAD_REPO=$(printf '%s' "$PR_JSON" | jq -r '.head.repo.full_name')
61+
HEAD_REF=$(printf '%s' "$PR_JSON" | jq -r '.head.ref')
62+
HEAD_SHA=$(printf '%s' "$PR_JSON" | jq -r '.head.sha')
63+
STATE=$(printf '%s' "$PR_JSON" | jq -r '.state')
64+
PR_BODY=$(printf '%s' "$PR_JSON" | jq -r '.body // ""')
65+
66+
TRUSTED=false
67+
printf '%s' "$PR_BODY" > /tmp/pr-body-raw.txt
68+
# Commit authors can be spoofed; trust only PR metadata GitHub controls.
69+
if [ "$PR_AUTHOR" = "github-actions[bot]" ] && \
70+
[ "$HEAD_REPO" = "$REPO" ] && \
71+
[[ "$HEAD_REF" == agentic-ci/* ]] && \
72+
grep -Eq '<!-- agentic-ci finding=[^[:space:]]+ suite=[^[:space:]]+ -->' /tmp/pr-body-raw.txt; then
73+
TRUSTED=true
74+
fi
75+
76+
echo "author=${PR_AUTHOR}" >> "$GITHUB_OUTPUT"
77+
echo "head_ref=${HEAD_REF}" >> "$GITHUB_OUTPUT"
78+
echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
79+
echo "state=${STATE}" >> "$GITHUB_OUTPUT"
80+
echo "trusted=${TRUSTED}" >> "$GITHUB_OUTPUT"
81+
82+
- name: Validate Agentic CI PR
83+
env:
84+
COMMENT_ID: ${{ github.event.comment.id }}
85+
GH_TOKEN: ${{ github.token }}
86+
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
87+
PR_NUMBER: ${{ github.event.issue.number }}
88+
REPO: ${{ github.repository }}
89+
STATE: ${{ steps.pr.outputs.state }}
90+
TRUSTED: ${{ steps.pr.outputs.trusted }}
91+
run: |
92+
if [ "$STATE" != "open" ]; then
93+
gh issue comment "$PR_NUMBER" --repo "$REPO" --body \
94+
"Agentic CI checks were not authorized because this PR is not open."
95+
exit 1
96+
fi
97+
98+
if [ "$TRUSTED" != "true" ]; then
99+
gh issue comment "$PR_NUMBER" --repo "$REPO" --body \
100+
"Agentic CI checks were not authorized because this PR does not match the trusted Agentic CI metadata."
101+
exit 1
102+
fi
103+
104+
if [ -z "$COMMENT_ID" ]; then
105+
gh issue comment "$PR_NUMBER" --repo "$REPO" --body \
106+
"Agentic CI checks were not authorized because the authorization comment ID was missing."
107+
exit 1
108+
fi
109+
110+
COMMENT_FOUND=false
111+
for ATTEMPT in 1 2 3; do
112+
gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/timeline?per_page=100" \
113+
-H "Accept: application/vnd.github+json" \
114+
--jq '.[] | [.event, ((.id // .sha // "") | tostring)] | @tsv' > /tmp/pr-timeline.tsv
115+
if awk -F '\t' -v comment_id="$COMMENT_ID" '
116+
$1 == "commented" && $2 == comment_id { found = 1 }
117+
END { exit found ? 0 : 1 }
118+
' /tmp/pr-timeline.tsv; then
119+
COMMENT_FOUND=true
120+
break
121+
fi
122+
sleep 2
123+
done
124+
if [ "$COMMENT_FOUND" != "true" ]; then
125+
gh issue comment "$PR_NUMBER" --repo "$REPO" --body \
126+
"Agentic CI checks were not authorized because the authorization comment was not found in the PR timeline."
127+
exit 1
128+
fi
129+
130+
HEAD_EVENT_AFTER_COMMENT=$(awk -F '\t' -v comment_id="$COMMENT_ID" '
131+
$1 == "commented" && $2 == comment_id { seen_comment = 1; next }
132+
seen_comment && ($1 == "committed" || $1 == "head_ref_force_pushed" || $1 == "head_ref_deleted" || $1 == "head_ref_restored") {
133+
print $1 " " $2
134+
exit
135+
}
136+
' /tmp/pr-timeline.tsv)
137+
if [ -n "$HEAD_EVENT_AFTER_COMMENT" ]; then
138+
{
139+
echo "Agentic CI checks were not authorized because the PR head changed after the authorization comment."
140+
echo
141+
echo "Latest PR head: \`${HEAD_SHA}\`"
142+
echo "Detected update: \`${HEAD_EVENT_AFTER_COMMENT}\`"
143+
echo
144+
echo "Please review the latest commit and comment \`/authorize-agentic-ci\` again."
145+
} > /tmp/agentic-ci-auth-stale.md
146+
gh issue comment "$PR_NUMBER" --repo "$REPO" --body-file /tmp/agentic-ci-auth-stale.md
147+
exit 1
148+
fi
149+
150+
BLOCKED=$(gh pr diff "$PR_NUMBER" --repo "$REPO" --name-only \
151+
| grep -E '^\.github/' || true)
152+
if [ -n "$BLOCKED" ]; then
153+
{
154+
echo "Agentic CI checks were not authorized because this PR changes privileged repository files:"
155+
echo
156+
printf '%s\n' "$BLOCKED" | sed 's/^/- `/' | sed 's/$/`/'
157+
} > /tmp/agentic-ci-auth-failed.md
158+
gh issue comment "$PR_NUMBER" --repo "$REPO" --body-file /tmp/agentic-ci-auth-failed.md
159+
exit 1
160+
fi
161+
162+
echo "Authorizing checks for ${HEAD_SHA}."
163+
164+
- name: Dispatch checks
165+
env:
166+
GH_TOKEN: ${{ github.token }}
167+
HEAD_REF: ${{ steps.pr.outputs.head_ref }}
168+
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
169+
PR_NUMBER: ${{ github.event.issue.number }}
170+
REPO: ${{ github.repository }}
171+
run: |
172+
gh workflow run ci.yml --repo "$REPO" --ref "$HEAD_REF" \
173+
-f expected_head_sha="$HEAD_SHA"
174+
gh workflow run agentic-ci-authorized-checks.yml --repo "$REPO" --ref "$HEAD_REF" \
175+
-f pr_number="$PR_NUMBER" \
176+
-f expected_head_sha="$HEAD_SHA"
177+
178+
gh issue comment "$PR_NUMBER" --repo "$REPO" --body \
179+
"Authorized Agentic CI checks for \`${HEAD_SHA}\`. Launched CI and authorization checks."

0 commit comments

Comments
 (0)