-
Notifications
You must be signed in to change notification settings - Fork 194
refactor: harden and upgrade bot-verified-commits workflow (#1482) #1494
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
exploreriii
merged 13 commits into
hiero-ledger:main
from
cheese-cakee:harden-verified-commits-workflow
Jan 29, 2026
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
72f8d4c
refactor: harden and upgrade bot-verified-commits workflow (#1482)
cheese-cakee 60c895e
refactor: address CodeRabbit review feedback
cheese-cakee db2c41d
fix: validate MAX_PAGES as positive integer with safe default
cheese-cakee 741d8a3
fix: address all review feedback for verified commits workflow
cheese-cakee 6ff82d9
fix: address remaining CodeRabbit feedback
cheese-cakee e4169e7
fix: escape markdown in commit messages per CodeRabbit review
cheese-cakee ecaf655
fix: use hex escape for backtick to fix Codacy parser issue
cheese-cakee d0edeec
feat: add dry-run mode for testing workflow without posting comments
cheese-cakee cdb6c63
chore: add changelog entry for #1482
cheese-cakee 155d3d4
fix: use multiline run syntax to fix YAML parsing error
cheese-cakee 590cf16
chore: upgrade step-security/harden-runner to v2.14.0
cheese-cakee 7ab3979
fix: address CodeRabbit review feedback for verified-commits workflow
cheese-cakee 8380f72
Merge branch 'main' into harden-verified-commits-workflow
exploreriii File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,320 @@ | ||
| // .github/scripts/bot-verified-commits.js | ||
| // Verifies that all commits in a pull request are GPG-signed. | ||
| // Posts a one-time VerificationBot comment if unverified commits are found. | ||
|
|
||
| // Sanitizes string input to prevent injection (uses Unicode property escape per Biome lint) | ||
| function sanitizeString(input) { | ||
| if (typeof input !== 'string') return ''; | ||
| return input.replace(/\p{Cc}/gu, '').trim(); | ||
| } | ||
|
|
||
| // Escapes markdown special characters and breaks @mentions to prevent injection | ||
| // Required per CodeRabbit review: commit messages are user-controlled and can cause | ||
| // markdown injection or unwanted @mentions that spam teams | ||
| function sanitizeMarkdown(input) { | ||
exploreriii marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return sanitizeString(input) | ||
| .replace(/[\x60*_~[\]()]/g, '\\$&') // Escape markdown special chars (backtick via hex) | ||
| .replace(/@/g, '@\u200b'); // Break @mentions with zero-width space | ||
| } | ||
|
|
||
|
|
||
| // Validates URL format and returns fallback if invalid | ||
| function sanitizeUrl(input, fallback) { | ||
| const cleaned = sanitizeString(input); | ||
| return /^https?:\/\/[^\s]+$/i.test(cleaned) ? cleaned : fallback; | ||
| } | ||
|
|
||
| // Configuration via environment variables (sanitized) | ||
| const CONFIG = { | ||
| BOT_NAME: sanitizeString(process.env.BOT_NAME) || 'VerificationBot', | ||
| BOT_LOGIN: sanitizeString(process.env.BOT_LOGIN) || 'github-actions', | ||
| COMMENT_MARKER: sanitizeString(process.env.COMMENT_MARKER) || '[commit-verification-bot]', | ||
| SIGNING_GUIDE_URL: sanitizeUrl( | ||
| process.env.SIGNING_GUIDE_URL, | ||
| 'https://github.com/hiero-ledger/hiero-sdk-python/blob/main/docs/sdk_developers/signing.md' | ||
| ), | ||
| README_URL: sanitizeUrl( | ||
| process.env.README_URL, | ||
| 'https://github.com/hiero-ledger/hiero-sdk-python/blob/main/README.md' | ||
| ), | ||
| DISCORD_URL: sanitizeUrl( | ||
| process.env.DISCORD_URL, | ||
| 'https://github.com/hiero-ledger/hiero-sdk-python/blob/main/docs/discord.md' | ||
| ), | ||
| TEAM_NAME: sanitizeString(process.env.TEAM_NAME) || 'Hiero Python SDK Team', | ||
| MAX_PAGES: (() => { | ||
| const parsed = Number.parseInt(process.env.MAX_PAGES ?? '5', 10); | ||
| return Number.isInteger(parsed) && parsed > 0 ? parsed : 5; | ||
| })(), | ||
| DRY_RUN: process.env.DRY_RUN === 'true', | ||
| }; | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // Validates PR number is a positive integer | ||
| function validatePRNumber(prNumber) { | ||
| const num = parseInt(prNumber, 10); | ||
| return Number.isInteger(num) && num > 0 ? num : null; | ||
| } | ||
|
|
||
| // Fetches commits with bounded pagination and counts unverified ones | ||
| async function getCommitVerificationStatus(github, owner, repo, prNumber) { | ||
| console.log(`[${CONFIG.BOT_NAME}] Fetching commits for PR #${prNumber}...`); | ||
|
|
||
| const commits = []; | ||
| let page = 0; | ||
| let truncated = false; | ||
|
|
||
| try { | ||
| for await (const response of github.paginate.iterator( | ||
| github.rest.pulls.listCommits, | ||
| { owner, repo, pull_number: prNumber, per_page: 100 } | ||
| )) { | ||
| commits.push(...response.data); | ||
| if (++page >= CONFIG.MAX_PAGES) { | ||
| truncated = true; | ||
| console.warn(`[${CONFIG.BOT_NAME}] Reached MAX_PAGES (${CONFIG.MAX_PAGES}) limit`); | ||
| break; | ||
| } | ||
| } | ||
| } catch (error) { | ||
| console.error(`[${CONFIG.BOT_NAME}] Failed to list commits`, { | ||
| owner, | ||
| repo, | ||
| prNumber, | ||
| status: error?.status, | ||
| message: error?.message, | ||
| }); | ||
| throw error; | ||
| } | ||
|
|
||
| const unverifiedCommits = commits.filter( | ||
| commit => commit.commit?.verification?.verified !== true | ||
exploreriii marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ); | ||
|
|
||
| console.log(`[${CONFIG.BOT_NAME}] Found ${commits.length} total, ${unverifiedCommits.length} unverified`); | ||
|
|
||
| // Fail-closed: if truncated and no unverified found, treat as potentially unverified | ||
| const unverifiedCount = truncated && unverifiedCommits.length === 0 | ||
| ? 1 | ||
| : unverifiedCommits.length; | ||
|
|
||
| return { | ||
| total: commits.length, | ||
| unverified: unverifiedCount, | ||
| unverifiedCommits, | ||
| truncated, | ||
| }; | ||
exploreriii marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| // Checks if bot already posted a verification comment (marker-based detection) | ||
| // Uses bounded pagination and early return for efficiency | ||
| async function hasExistingBotComment(github, owner, repo, prNumber) { | ||
| console.log(`[${CONFIG.BOT_NAME}] Checking for existing bot comments...`); | ||
|
|
||
| // Support both with and without [bot] suffix for GitHub Actions bot account | ||
| const botLogins = new Set([ | ||
| CONFIG.BOT_LOGIN, | ||
| `${CONFIG.BOT_LOGIN}[bot]`, | ||
| 'github-actions[bot]', | ||
| ]); | ||
|
|
||
| let page = 0; | ||
| try { | ||
| for await (const response of github.paginate.iterator( | ||
| github.rest.issues.listComments, | ||
| { owner, repo, issue_number: prNumber, per_page: 100 } | ||
| )) { | ||
| // Early return if marker found | ||
| if (response.data.some(comment => | ||
| botLogins.has(comment.user?.login) && | ||
| typeof comment.body === 'string' && | ||
| comment.body.includes(CONFIG.COMMENT_MARKER) | ||
| )) { | ||
| console.log(`[${CONFIG.BOT_NAME}] Existing bot comment: true`); | ||
| return true; | ||
| } | ||
| if (++page >= CONFIG.MAX_PAGES) { | ||
| // Fail-safe: assume comment exists to prevent duplicates | ||
| console.warn( | ||
| `[${CONFIG.BOT_NAME}] Reached MAX_PAGES (${CONFIG.MAX_PAGES}) limit; assuming existing comment to avoid duplicates` | ||
| ); | ||
| return true; | ||
| } | ||
| } | ||
| } catch (error) { | ||
| console.error(`[${CONFIG.BOT_NAME}] Failed to list comments`, { | ||
| owner, | ||
| repo, | ||
| prNumber, | ||
| status: error?.status, | ||
| message: error?.message, | ||
| }); | ||
| throw error; | ||
| } | ||
|
|
||
| console.log(`[${CONFIG.BOT_NAME}] Existing bot comment: false`); | ||
exploreriii marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return false; | ||
| } | ||
exploreriii marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // Builds the verification failure comment with unverified commit details | ||
| function buildVerificationComment( | ||
| commitsUrl, | ||
| unverifiedCommits = [], | ||
| unverifiedCount = unverifiedCommits.length, | ||
| truncated = false | ||
| ) { | ||
| // Build list of unverified commits (show first 10 max) | ||
| const maxDisplay = 10; | ||
| const commitList = unverifiedCommits.length | ||
| ? unverifiedCommits.slice(0, maxDisplay).map(c => { | ||
| const sha = c.sha?.substring(0, 7) || 'unknown'; | ||
| const msg = sanitizeMarkdown(c.commit?.message?.split('\n')[0] || 'No message').substring(0, 50); | ||
| return `- \`${sha}\` ${msg}`; | ||
| }).join('\n') | ||
| : (truncated ? '- Unable to enumerate commits due to pagination limit.' : ''); | ||
|
|
||
| const moreCommits = unverifiedCommits.length > maxDisplay | ||
| ? `\n- ...and ${unverifiedCommits.length - maxDisplay} more` | ||
| : ''; | ||
|
|
||
| const countText = truncated ? `at least ${unverifiedCount}` : `${unverifiedCount}`; | ||
| const truncationNote = truncated | ||
| ? '\n\n> ⚠️ Verification scanned only the first pages of commits due to pagination limits. Please review the commits tab.' | ||
| : ''; | ||
|
|
||
| return `${CONFIG.COMMENT_MARKER} | ||
| Hi, this is ${CONFIG.BOT_NAME}. | ||
| Your pull request cannot be merged as it has **${countText} unverified commit(s)**: | ||
|
|
||
| ${commitList}${moreCommits}${truncationNote} | ||
|
|
||
| View your commit verification status: [Commits Tab](${sanitizeString(commitsUrl)}). | ||
|
|
||
| To achieve verified status, please read: | ||
| - [Signing guide](${CONFIG.SIGNING_GUIDE_URL}) | ||
| - [README](${CONFIG.README_URL}) | ||
| - [Discord](${CONFIG.DISCORD_URL}) | ||
|
|
||
| Remember, you require a GPG key and each commit must be signed with: | ||
| \`git commit -S -s -m "Your message here"\` | ||
|
|
||
| Thank you for contributing! | ||
|
|
||
| From the ${CONFIG.TEAM_NAME}`; | ||
| } | ||
|
|
||
| // Posts verification failure comment on the PR with error handling | ||
| async function postVerificationComment( | ||
| github, | ||
| owner, | ||
| repo, | ||
| prNumber, | ||
| commitsUrl, | ||
| unverifiedCommits, | ||
| unverifiedCount, | ||
| truncated | ||
| ) { | ||
| // Skip posting in dry-run mode | ||
| if (CONFIG.DRY_RUN) { | ||
| console.log(`[${CONFIG.BOT_NAME}] DRY_RUN enabled; skipping comment.`); | ||
| return true; | ||
| } | ||
|
|
||
| console.log(`[${CONFIG.BOT_NAME}] Posting verification failure comment...`); | ||
|
|
||
| try { | ||
|
|
||
| await github.rest.issues.createComment({ | ||
| owner, | ||
| repo, | ||
| issue_number: prNumber, | ||
| body: buildVerificationComment(commitsUrl, unverifiedCommits, unverifiedCount, truncated), | ||
| }); | ||
| console.log(`[${CONFIG.BOT_NAME}] Comment posted on PR #${prNumber}`); | ||
| return true; | ||
| } catch (error) { | ||
| console.error(`[${CONFIG.BOT_NAME}] Failed to post comment`, { | ||
exploreriii marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| owner, | ||
| repo, | ||
| prNumber, | ||
| status: error?.status, | ||
| message: error?.message, | ||
| }); | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| // Main workflow handler with full validation and error handling | ||
| async function main({ github, context }) { | ||
| const owner = sanitizeString(context.repo?.owner); | ||
| const repo = sanitizeString(context.repo?.repo); | ||
| // Support PR_NUMBER env var for workflow_dispatch, fallback to context payload | ||
| const prNumber = validatePRNumber( | ||
| process.env.PR_NUMBER || context.payload?.pull_request?.number | ||
| ); | ||
| const repoPattern = /^[A-Za-z0-9_.-]+$/; | ||
|
|
||
| // Validate repo context | ||
| if (!repoPattern.test(owner) || !repoPattern.test(repo)) { | ||
| console.error(`[${CONFIG.BOT_NAME}] Invalid repo context`, { owner, repo }); | ||
| return { success: false, unverifiedCount: 0 }; | ||
| } | ||
|
|
||
| console.log(`[${CONFIG.BOT_NAME}] Starting verification for ${owner}/${repo} PR #${prNumber}`); | ||
|
|
||
| if (!prNumber) { | ||
| console.log(`[${CONFIG.BOT_NAME}] Invalid PR number`); | ||
| return { success: false, unverifiedCount: 0 }; | ||
| } | ||
|
|
||
| try { | ||
| // Get commit verification status | ||
| const { total, unverified, unverifiedCommits, truncated } = | ||
| await getCommitVerificationStatus(github, owner, repo, prNumber); | ||
|
|
||
| // All commits verified - success | ||
| if (unverified === 0) { | ||
| console.log(`[${CONFIG.BOT_NAME}] ✅ All ${total} commits are verified`); | ||
| return { success: true, unverifiedCount: 0 }; | ||
| } | ||
|
|
||
| // Some commits unverified | ||
| console.log(`[${CONFIG.BOT_NAME}] ❌ Found ${unverified} unverified commits`); | ||
|
|
||
| // Check for existing comment to avoid duplicates | ||
| const existingComment = await hasExistingBotComment(github, owner, repo, prNumber); | ||
|
|
||
| if (existingComment) { | ||
| console.log(`[${CONFIG.BOT_NAME}] Bot already commented. Skipping duplicate.`); | ||
| } else { | ||
| const commitsUrl = `https://github.com/${owner}/${repo}/pull/${prNumber}/commits`; | ||
| await postVerificationComment( | ||
| github, | ||
| owner, | ||
| repo, | ||
| prNumber, | ||
| commitsUrl, | ||
| unverifiedCommits, | ||
| unverified, | ||
| truncated | ||
| ); | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return { success: false, unverifiedCount: unverified }; | ||
| } catch (error) { | ||
| console.error(`[${CONFIG.BOT_NAME}] Verification failed`, { | ||
| owner, | ||
| repo, | ||
| prNumber, | ||
| message: error?.message, | ||
| status: error?.status, | ||
| }); | ||
| return { success: false, unverifiedCount: 0 }; | ||
| } | ||
| } | ||
|
|
||
| // Exports | ||
| module.exports = main; | ||
| module.exports.getCommitVerificationStatus = getCommitVerificationStatus; | ||
| module.exports.hasExistingBotComment = hasExistingBotComment; | ||
| module.exports.postVerificationComment = postVerificationComment; | ||
| module.exports.CONFIG = CONFIG; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.