11name : 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+
310on :
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
1521jobs :
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
0 commit comments