Skip to content

Commit 0e2b293

Browse files
fix(ci): durable IPR verify without PR-comments API (#1669)
Split verify (read-only signatures check + retries) from sign (CLA Assistant after API health wait). Use pull_request so the PR workflow applies; avoid Unicorn HTML failures on comments.
1 parent d99e97a commit 0e2b293

2 files changed

Lines changed: 172 additions & 23 deletions

File tree

Lines changed: 168 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,185 @@
11
name: IPR Agreement
22

3+
# Two paths (durability under GitHub API Unicorn/HTML 503s — #1669):
4+
# A) pull_request_target → read-only check against signatures/ipr-signatures.json
5+
# (no PR-comments API; that is what CLA Assistant was dying on).
6+
# B) issue_comment sign → wait for API health (retries), then CLA Assistant.
7+
#
8+
# Do NOT check out PR head (ADR-003 / dangerous-triggers allowlist).
9+
310
on:
411
issue_comment:
512
types: [created]
6-
pull_request_target:
7-
types: [opened, closed, synchronize]
13+
# pull_request (not pull_request_target): verify is read-only, and the workflow
14+
# file from this PR must run on synchronize — pull_request_target always loads
15+
# the base-branch copy, so durability fixes would never apply until merge.
16+
pull_request:
17+
types: [opened, synchronize, reopened]
818

9-
permissions:
10-
actions: write
11-
contents: write
12-
pull-requests: write
13-
statuses: write
19+
permissions: {}
1420

1521
jobs:
22+
# ── A: verify already-signed contributors (push / open) ───────────────────
1623
ipr-check:
24+
if: github.event_name == 'pull_request'
25+
runs-on: ubuntu-latest
26+
permissions:
27+
contents: read
28+
pull-requests: read
29+
steps:
30+
- name: Verify IPR signatures (read-only, with retries)
31+
env:
32+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
33+
PR_NUMBER: ${{ github.event.pull_request.number }}
34+
REPO: ${{ github.repository }}
35+
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
36+
run: |
37+
set -euo pipefail
38+
39+
# Retry wrapper for transient GitHub Unicorn/503 HTML responses.
40+
gh_retry() {
41+
local attempt=1
42+
local max=5
43+
local sleep_s
44+
while true; do
45+
if "$@"; then
46+
return 0
47+
fi
48+
if [ "${attempt}" -ge "${max}" ]; then
49+
echo "command failed after ${max} attempts: $*" >&2
50+
return 1
51+
fi
52+
sleep_s=$((attempt * 15))
53+
echo "attempt ${attempt}/${max} failed; retrying in ${sleep_s}s..." >&2
54+
sleep "${sleep_s}"
55+
attempt=$((attempt + 1))
56+
done
57+
}
58+
59+
tmp="$(mktemp -d)"
60+
trap 'rm -rf "${tmp}"' EXIT
61+
export TMPDIR_IPR="${tmp}"
62+
63+
echo "Fetching signatures from ipr-signatures branch..."
64+
gh_retry gh api \
65+
"repos/${REPO}/contents/signatures/ipr-signatures.json?ref=ipr-signatures" \
66+
--jq .content | tr -d '\n' | base64 --decode > "${tmp}/sigs.json"
67+
68+
echo "Fetching PR commit authors..."
69+
gh_retry gh api --paginate \
70+
"repos/${REPO}/pulls/${PR_NUMBER}/commits" > "${tmp}/commits.json"
71+
72+
python3 - <<'PY'
73+
import json
74+
import os
75+
import re
76+
import sys
77+
from pathlib import Path
78+
79+
tmp = Path(os.environ["TMPDIR_IPR"])
80+
sigs_doc = json.loads((tmp / "sigs.json").read_text(encoding="utf-8"))
81+
commits = json.loads((tmp / "commits.json").read_text(encoding="utf-8"))
82+
if not isinstance(commits, list):
83+
print("expected commits list from GitHub API", file=sys.stderr)
84+
sys.exit(2)
85+
86+
signed = {
87+
(c.get("name") or "").lower()
88+
for c in sigs_doc.get("signedContributors") or []
89+
if c.get("name")
90+
}
91+
allow_res = [
92+
re.compile(r"^bot.*$", re.I),
93+
re.compile(r"^dependabot.*$", re.I),
94+
re.compile(r"^renovate.*$", re.I),
95+
re.compile(r"^github-actions.*$", re.I),
96+
]
97+
98+
def allowed(login: str) -> bool:
99+
return any(r.match(login) for r in allow_res)
100+
101+
authors: set[str] = set()
102+
pr_author = (os.environ.get("PR_AUTHOR") or "").strip()
103+
if pr_author:
104+
authors.add(pr_author)
105+
for commit in commits:
106+
for key in ("author", "committer"):
107+
login = (commit.get(key) or {}).get("login")
108+
if login:
109+
authors.add(login)
110+
111+
if not authors:
112+
print("no PR authors/committers found — cannot verify IPR", file=sys.stderr)
113+
sys.exit(2)
114+
115+
missing = sorted(
116+
a for a in authors if not allowed(a) and a.lower() not in signed
117+
)
118+
print(f"authors={sorted(authors)}")
119+
print(f"signed_count={len(signed)} missing={missing}")
120+
if missing:
121+
print(
122+
"IPR Policy not signed by: "
123+
+ ", ".join(missing)
124+
+ ". Comment on the PR with exactly: I have read the IPR Policy",
125+
file=sys.stderr,
126+
)
127+
sys.exit(1)
128+
print("All contributors have signed the IPR Policy.")
129+
PY
130+
131+
# ── B: record a new signature (comment path only) ─────────────────────────
132+
ipr-sign:
133+
if: >-
134+
github.event_name == 'issue_comment'
135+
&& github.event.issue.pull_request
136+
&& github.event.comment.body == 'I have read the IPR Policy'
17137
runs-on: ubuntu-latest
138+
permissions:
139+
actions: write
140+
contents: write
141+
pull-requests: write
142+
statuses: write
18143
steps:
19-
- name: CLA Assistant
20-
if: (github.event.comment.body == 'I have read the IPR Policy' || github.event_name == 'pull_request_target')
144+
- name: Wait for GitHub API (retry on Unicorn/503)
145+
env:
146+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
147+
run: |
148+
set -euo pipefail
149+
attempt=1
150+
max=6
151+
while true; do
152+
code="$(curl -sS -o /tmp/gh_api_body -w "%{http_code}" \
153+
-H "Authorization: Bearer ${GH_TOKEN}" \
154+
-H "Accept: application/vnd.github+json" \
155+
"https://api.github.com/rate_limit" || true)"
156+
if [ "${code}" = "200" ]; then
157+
# Reject HTML error pages even with odd status
158+
if head -c 15 /tmp/gh_api_body | grep -q '{'; then
159+
echo "GitHub API healthy (HTTP ${code})"
160+
exit 0
161+
fi
162+
fi
163+
if [ "${attempt}" -ge "${max}" ]; then
164+
echo "GitHub API still unhealthy after ${max} attempts (last HTTP ${code})" >&2
165+
head -c 200 /tmp/gh_api_body >&2 || true
166+
exit 1
167+
fi
168+
sleep_s=$((attempt * 15))
169+
echo "API not ready (HTTP ${code}); retry ${attempt}/${max} in ${sleep_s}s..." >&2
170+
sleep "${sleep_s}"
171+
attempt=$((attempt + 1))
172+
done
173+
174+
- name: CLA Assistant (record signature)
21175
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
22176
env:
23177
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
24178
with:
25-
path-to-signatures: 'signatures/ipr-signatures.json'
26-
path-to-document: 'https://github.com/prebid/salesagent/blob/main/IPR_POLICY.md'
27-
branch: 'ipr-signatures'
28-
allowlist: 'bot*,dependabot*,renovate*,github-actions*'
29-
179+
path-to-signatures: "signatures/ipr-signatures.json"
180+
path-to-document: "https://github.com/prebid/salesagent/blob/main/IPR_POLICY.md"
181+
branch: "ipr-signatures"
182+
allowlist: "bot*,dependabot*,renovate*,github-actions*"
30183
custom-notsigned-prcomment: |
31184
## IPR Policy Agreement Required
32185
@@ -44,11 +197,8 @@ jobs:
44197
```
45198
46199
You can read the full [IPR Policy here](https://github.com/prebid/salesagent/blob/main/IPR_POLICY.md).
47-
48-
custom-pr-sign-comment: 'I have read the IPR Policy'
49-
200+
custom-pr-sign-comment: "I have read the IPR Policy"
50201
custom-allsigned-prcomment: |
51202
All contributors have agreed to the [IPR Policy](https://github.com/prebid/salesagent/blob/main/IPR_POLICY.md). Thank you!
52-
53203
lock-pullrequest-aftermerge: false
54204
use-dco-flag: false

.github/zizmor.yml

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ rules:
1515
# ─────────────────────────────────────────────────────────────────────────────
1616
# dangerous-triggers — pull_request_target permitted ONLY for metadata-only
1717
# workflows that do NOT check out PR source code (ADR-003).
18-
# ipr-agreement.yml: reads PR author/comment, writes CLA status — no checkout.
1918
# pr-title-check.yml: reads PR title metadata only — no checkout.
19+
# ipr-agreement.yml: verify uses pull_request (read-only); sign uses
20+
# issue_comment only — no pull_request_target (removed #1669).
2021
# ─────────────────────────────────────────────────────────────────────────────
2122
dangerous-triggers:
2223
ignore:
23-
- ipr-agreement.yml
2424
- pr-title-check.yml
2525

2626
# ─────────────────────────────────────────────────────────────────────────────
@@ -36,9 +36,8 @@ rules:
3636
# ─────────────────────────────────────────────────────────────────────────────
3737
# excessive-permissions:
3838
#
39-
# ipr-agreement.yml: requires contents/pull-requests/statuses: write for the
40-
# CLA-assistant action to commit signatures and post status checks.
41-
# Minimum required permissions; no narrowing possible without breaking the action.
39+
# ipr-agreement.yml: ipr-sign job needs contents/pull-requests/statuses: write
40+
# for CLA-assistant to commit signatures (verify job is read-only).
4241
#
4342
# release-please.yml: job-level permissions only (workflow default is contents +
4443
# pull-requests). No workflow-level packages: write — excessive-permissions no

0 commit comments

Comments
 (0)