Skip to content

Commit 31e0cb8

Browse files
refactor: harden and upgrade bot-verified-commits workflow (hiero-ledger#1482) (hiero-ledger#1494)
Signed-off-by: cheese-cakee <farzanaman99@gmail.com> Signed-off-by: exploreriii <133720349+exploreriii@users.noreply.github.com> Co-authored-by: exploreriii <133720349+exploreriii@users.noreply.github.com>
1 parent e925c36 commit 31e0cb8

3 files changed

Lines changed: 409 additions & 51 deletions

File tree

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
// .github/scripts/bot-verified-commits.js
2+
// Verifies that all commits in a pull request are GPG-signed.
3+
// Posts a one-time VerificationBot comment if unverified commits are found.
4+
5+
// Sanitizes string input to prevent injection (uses Unicode property escape per Biome lint)
6+
function sanitizeString(input) {
7+
if (typeof input !== 'string') return '';
8+
return input.replace(/\p{Cc}/gu, '').trim();
9+
}
10+
11+
// Escapes markdown special characters and breaks @mentions to prevent injection
12+
// Required per CodeRabbit review: commit messages are user-controlled and can cause
13+
// markdown injection or unwanted @mentions that spam teams
14+
function sanitizeMarkdown(input) {
15+
return sanitizeString(input)
16+
.replace(/[\x60*_~[\]()]/g, '\\$&') // Escape markdown special chars (backtick via hex)
17+
.replace(/@/g, '@\u200b'); // Break @mentions with zero-width space
18+
}
19+
20+
21+
// Validates URL format and returns fallback if invalid
22+
function sanitizeUrl(input, fallback) {
23+
const cleaned = sanitizeString(input);
24+
return /^https?:\/\/[^\s]+$/i.test(cleaned) ? cleaned : fallback;
25+
}
26+
27+
// Configuration via environment variables (sanitized)
28+
const CONFIG = {
29+
BOT_NAME: sanitizeString(process.env.BOT_NAME) || 'VerificationBot',
30+
BOT_LOGIN: sanitizeString(process.env.BOT_LOGIN) || 'github-actions',
31+
COMMENT_MARKER: sanitizeString(process.env.COMMENT_MARKER) || '[commit-verification-bot]',
32+
SIGNING_GUIDE_URL: sanitizeUrl(
33+
process.env.SIGNING_GUIDE_URL,
34+
'https://github.com/hiero-ledger/hiero-sdk-python/blob/main/docs/sdk_developers/signing.md'
35+
),
36+
README_URL: sanitizeUrl(
37+
process.env.README_URL,
38+
'https://github.com/hiero-ledger/hiero-sdk-python/blob/main/README.md'
39+
),
40+
DISCORD_URL: sanitizeUrl(
41+
process.env.DISCORD_URL,
42+
'https://github.com/hiero-ledger/hiero-sdk-python/blob/main/docs/discord.md'
43+
),
44+
TEAM_NAME: sanitizeString(process.env.TEAM_NAME) || 'Hiero Python SDK Team',
45+
MAX_PAGES: (() => {
46+
const parsed = Number.parseInt(process.env.MAX_PAGES ?? '5', 10);
47+
return Number.isInteger(parsed) && parsed > 0 ? parsed : 5;
48+
})(),
49+
DRY_RUN: process.env.DRY_RUN === 'true',
50+
};
51+
52+
// Validates PR number is a positive integer
53+
function validatePRNumber(prNumber) {
54+
const num = parseInt(prNumber, 10);
55+
return Number.isInteger(num) && num > 0 ? num : null;
56+
}
57+
58+
// Fetches commits with bounded pagination and counts unverified ones
59+
async function getCommitVerificationStatus(github, owner, repo, prNumber) {
60+
console.log(`[${CONFIG.BOT_NAME}] Fetching commits for PR #${prNumber}...`);
61+
62+
const commits = [];
63+
let page = 0;
64+
let truncated = false;
65+
66+
try {
67+
for await (const response of github.paginate.iterator(
68+
github.rest.pulls.listCommits,
69+
{ owner, repo, pull_number: prNumber, per_page: 100 }
70+
)) {
71+
commits.push(...response.data);
72+
if (++page >= CONFIG.MAX_PAGES) {
73+
truncated = true;
74+
console.warn(`[${CONFIG.BOT_NAME}] Reached MAX_PAGES (${CONFIG.MAX_PAGES}) limit`);
75+
break;
76+
}
77+
}
78+
} catch (error) {
79+
console.error(`[${CONFIG.BOT_NAME}] Failed to list commits`, {
80+
owner,
81+
repo,
82+
prNumber,
83+
status: error?.status,
84+
message: error?.message,
85+
});
86+
throw error;
87+
}
88+
89+
const unverifiedCommits = commits.filter(
90+
commit => commit.commit?.verification?.verified !== true
91+
);
92+
93+
console.log(`[${CONFIG.BOT_NAME}] Found ${commits.length} total, ${unverifiedCommits.length} unverified`);
94+
95+
// Fail-closed: if truncated and no unverified found, treat as potentially unverified
96+
const unverifiedCount = truncated && unverifiedCommits.length === 0
97+
? 1
98+
: unverifiedCommits.length;
99+
100+
return {
101+
total: commits.length,
102+
unverified: unverifiedCount,
103+
unverifiedCommits,
104+
truncated,
105+
};
106+
}
107+
108+
// Checks if bot already posted a verification comment (marker-based detection)
109+
// Uses bounded pagination and early return for efficiency
110+
async function hasExistingBotComment(github, owner, repo, prNumber) {
111+
console.log(`[${CONFIG.BOT_NAME}] Checking for existing bot comments...`);
112+
113+
// Support both with and without [bot] suffix for GitHub Actions bot account
114+
const botLogins = new Set([
115+
CONFIG.BOT_LOGIN,
116+
`${CONFIG.BOT_LOGIN}[bot]`,
117+
'github-actions[bot]',
118+
]);
119+
120+
let page = 0;
121+
try {
122+
for await (const response of github.paginate.iterator(
123+
github.rest.issues.listComments,
124+
{ owner, repo, issue_number: prNumber, per_page: 100 }
125+
)) {
126+
// Early return if marker found
127+
if (response.data.some(comment =>
128+
botLogins.has(comment.user?.login) &&
129+
typeof comment.body === 'string' &&
130+
comment.body.includes(CONFIG.COMMENT_MARKER)
131+
)) {
132+
console.log(`[${CONFIG.BOT_NAME}] Existing bot comment: true`);
133+
return true;
134+
}
135+
if (++page >= CONFIG.MAX_PAGES) {
136+
// Fail-safe: assume comment exists to prevent duplicates
137+
console.warn(
138+
`[${CONFIG.BOT_NAME}] Reached MAX_PAGES (${CONFIG.MAX_PAGES}) limit; assuming existing comment to avoid duplicates`
139+
);
140+
return true;
141+
}
142+
}
143+
} catch (error) {
144+
console.error(`[${CONFIG.BOT_NAME}] Failed to list comments`, {
145+
owner,
146+
repo,
147+
prNumber,
148+
status: error?.status,
149+
message: error?.message,
150+
});
151+
throw error;
152+
}
153+
154+
console.log(`[${CONFIG.BOT_NAME}] Existing bot comment: false`);
155+
return false;
156+
}
157+
158+
// Builds the verification failure comment with unverified commit details
159+
function buildVerificationComment(
160+
commitsUrl,
161+
unverifiedCommits = [],
162+
unverifiedCount = unverifiedCommits.length,
163+
truncated = false
164+
) {
165+
// Build list of unverified commits (show first 10 max)
166+
const maxDisplay = 10;
167+
const commitList = unverifiedCommits.length
168+
? unverifiedCommits.slice(0, maxDisplay).map(c => {
169+
const sha = c.sha?.substring(0, 7) || 'unknown';
170+
const msg = sanitizeMarkdown(c.commit?.message?.split('\n')[0] || 'No message').substring(0, 50);
171+
return `- \`${sha}\` ${msg}`;
172+
}).join('\n')
173+
: (truncated ? '- Unable to enumerate commits due to pagination limit.' : '');
174+
175+
const moreCommits = unverifiedCommits.length > maxDisplay
176+
? `\n- ...and ${unverifiedCommits.length - maxDisplay} more`
177+
: '';
178+
179+
const countText = truncated ? `at least ${unverifiedCount}` : `${unverifiedCount}`;
180+
const truncationNote = truncated
181+
? '\n\n> ⚠️ Verification scanned only the first pages of commits due to pagination limits. Please review the commits tab.'
182+
: '';
183+
184+
return `${CONFIG.COMMENT_MARKER}
185+
Hi, this is ${CONFIG.BOT_NAME}.
186+
Your pull request cannot be merged as it has **${countText} unverified commit(s)**:
187+
188+
${commitList}${moreCommits}${truncationNote}
189+
190+
View your commit verification status: [Commits Tab](${sanitizeString(commitsUrl)}).
191+
192+
To achieve verified status, please read:
193+
- [Signing guide](${CONFIG.SIGNING_GUIDE_URL})
194+
- [README](${CONFIG.README_URL})
195+
- [Discord](${CONFIG.DISCORD_URL})
196+
197+
Remember, you require a GPG key and each commit must be signed with:
198+
\`git commit -S -s -m "Your message here"\`
199+
200+
Thank you for contributing!
201+
202+
From the ${CONFIG.TEAM_NAME}`;
203+
}
204+
205+
// Posts verification failure comment on the PR with error handling
206+
async function postVerificationComment(
207+
github,
208+
owner,
209+
repo,
210+
prNumber,
211+
commitsUrl,
212+
unverifiedCommits,
213+
unverifiedCount,
214+
truncated
215+
) {
216+
// Skip posting in dry-run mode
217+
if (CONFIG.DRY_RUN) {
218+
console.log(`[${CONFIG.BOT_NAME}] DRY_RUN enabled; skipping comment.`);
219+
return true;
220+
}
221+
222+
console.log(`[${CONFIG.BOT_NAME}] Posting verification failure comment...`);
223+
224+
try {
225+
226+
await github.rest.issues.createComment({
227+
owner,
228+
repo,
229+
issue_number: prNumber,
230+
body: buildVerificationComment(commitsUrl, unverifiedCommits, unverifiedCount, truncated),
231+
});
232+
console.log(`[${CONFIG.BOT_NAME}] Comment posted on PR #${prNumber}`);
233+
return true;
234+
} catch (error) {
235+
console.error(`[${CONFIG.BOT_NAME}] Failed to post comment`, {
236+
owner,
237+
repo,
238+
prNumber,
239+
status: error?.status,
240+
message: error?.message,
241+
});
242+
return false;
243+
}
244+
}
245+
246+
// Main workflow handler with full validation and error handling
247+
async function main({ github, context }) {
248+
const owner = sanitizeString(context.repo?.owner);
249+
const repo = sanitizeString(context.repo?.repo);
250+
// Support PR_NUMBER env var for workflow_dispatch, fallback to context payload
251+
const prNumber = validatePRNumber(
252+
process.env.PR_NUMBER || context.payload?.pull_request?.number
253+
);
254+
const repoPattern = /^[A-Za-z0-9_.-]+$/;
255+
256+
// Validate repo context
257+
if (!repoPattern.test(owner) || !repoPattern.test(repo)) {
258+
console.error(`[${CONFIG.BOT_NAME}] Invalid repo context`, { owner, repo });
259+
return { success: false, unverifiedCount: 0 };
260+
}
261+
262+
console.log(`[${CONFIG.BOT_NAME}] Starting verification for ${owner}/${repo} PR #${prNumber}`);
263+
264+
if (!prNumber) {
265+
console.log(`[${CONFIG.BOT_NAME}] Invalid PR number`);
266+
return { success: false, unverifiedCount: 0 };
267+
}
268+
269+
try {
270+
// Get commit verification status
271+
const { total, unverified, unverifiedCommits, truncated } =
272+
await getCommitVerificationStatus(github, owner, repo, prNumber);
273+
274+
// All commits verified - success
275+
if (unverified === 0) {
276+
console.log(`[${CONFIG.BOT_NAME}] ✅ All ${total} commits are verified`);
277+
return { success: true, unverifiedCount: 0 };
278+
}
279+
280+
// Some commits unverified
281+
console.log(`[${CONFIG.BOT_NAME}] ❌ Found ${unverified} unverified commits`);
282+
283+
// Check for existing comment to avoid duplicates
284+
const existingComment = await hasExistingBotComment(github, owner, repo, prNumber);
285+
286+
if (existingComment) {
287+
console.log(`[${CONFIG.BOT_NAME}] Bot already commented. Skipping duplicate.`);
288+
} else {
289+
const commitsUrl = `https://github.com/${owner}/${repo}/pull/${prNumber}/commits`;
290+
await postVerificationComment(
291+
github,
292+
owner,
293+
repo,
294+
prNumber,
295+
commitsUrl,
296+
unverifiedCommits,
297+
unverified,
298+
truncated
299+
);
300+
}
301+
302+
return { success: false, unverifiedCount: unverified };
303+
} catch (error) {
304+
console.error(`[${CONFIG.BOT_NAME}] Verification failed`, {
305+
owner,
306+
repo,
307+
prNumber,
308+
message: error?.message,
309+
status: error?.status,
310+
});
311+
return { success: false, unverifiedCount: 0 };
312+
}
313+
}
314+
315+
// Exports
316+
module.exports = main;
317+
module.exports.getCommitVerificationStatus = getCommitVerificationStatus;
318+
module.exports.hasExistingBotComment = hasExistingBotComment;
319+
module.exports.postVerificationComment = postVerificationComment;
320+
module.exports.CONFIG = CONFIG;

0 commit comments

Comments
 (0)