Skip to content

Commit d87c343

Browse files
committed
feat(badges): complete Track 2 PR merge badge automation engine
- Add independent merged-PR defense-in-depth safety check in workflow - Enforce authorized Track 2 ecosystem repository allowlist - Implement deterministic author DCO attribution and reject unverified trailers - Audit and eliminate plaintext email in reason strings, errors, and reports - Ensure fail-closed error handling on race-safe tracking label creation - Expand automated unit and integration regression test suite to 36 tests Signed-off-by: Parth Gartan <parthgartan26feb@gmail.com>
1 parent 8ffbe7d commit d87c343

9 files changed

Lines changed: 505 additions & 88 deletions

‎.github/workflows/award-project-badge.yml‎

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,22 @@ jobs:
3030
name: Evaluate and Award Badges
3131
runs-on: ubuntu-latest
3232
steps:
33+
- name: Validate authorized repository allowlist
34+
env:
35+
TARGET_REPO: ${{ github.repository }}
36+
run: |
37+
set -euo pipefail
38+
NORMALIZED_REPO=$(echo "${TARGET_REPO}" | tr '[:upper:]' '[:lower:]')
39+
case "${NORMALIZED_REPO}" in
40+
"layer5io/sistent"|"meshery/meshery"|"meshery/meshery-operator"|"meshery/meshsync"|"layer5io/docs"|"meshery/meshery.io"|"layer5io/layer5")
41+
echo "Repository '${TARGET_REPO}' is authorized for Track 2 badge automation."
42+
;;
43+
*)
44+
echo "::error::Repository '${TARGET_REPO}' is not an authorized Track 2 participating repository. Failing workflow."
45+
exit 1
46+
;;
47+
esac
48+
3349
- name: Checkout trusted recognition engine
3450
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 pinned SHA
3551
with:
@@ -39,16 +55,34 @@ jobs:
3955
sparse-checkout: |
4056
utils
4157
42-
- name: Collect PR metadata
58+
- name: Verify PR status and collect metadata
59+
id: collect-meta
4360
env:
4461
GH_TOKEN: ${{ github.token }}
4562
TARGET_REPO: ${{ github.repository }}
4663
PR_NUMBER: ${{ inputs.pr_number }}
4764
run: |
4865
set -euo pipefail
49-
echo "Collecting metadata for PR #${PR_NUMBER} in ${TARGET_REPO}..."
66+
echo "Fetching Pull Request #${PR_NUMBER} in ${TARGET_REPO}..."
5067
51-
gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > .pr-info.json
68+
if ! gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > .pr-info.json 2>/dev/null; then
69+
echo "::warning::Pull Request #${PR_NUMBER} not found in ${TARGET_REPO}. Exiting without dispatch."
70+
echo "## ⚠️ Badge Evaluation Skipped" >> "$GITHUB_STEP_SUMMARY"
71+
echo "Pull Request #${PR_NUMBER} was not found in \`${TARGET_REPO}\`. Zero badges awarded." >> "$GITHUB_STEP_SUMMARY"
72+
echo "skip=true" >> "$GITHUB_OUTPUT"
73+
exit 0
74+
fi
75+
76+
IS_MERGED=$(jq -r '.merged // false' .pr-info.json)
77+
if [ "${IS_MERGED}" != "true" ]; then
78+
echo "::warning::Pull Request #${PR_NUMBER} in ${TARGET_REPO} is not merged (merged=${IS_MERGED}). Skipping badge evaluation."
79+
echo "## ⚠️ Badge Evaluation Skipped" >> "$GITHUB_STEP_SUMMARY"
80+
echo "Pull Request #${PR_NUMBER} in \`${TARGET_REPO}\` is not in a merged state. Zero badges awarded." >> "$GITHUB_STEP_SUMMARY"
81+
echo "skip=true" >> "$GITHUB_OUTPUT"
82+
exit 0
83+
fi
84+
85+
echo "Pull Request #${PR_NUMBER} verified as merged. Fetching files, commits, and labels..."
5286
gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/files" --paginate --slurp | jq 'add // []' > .pr-files.json
5387
gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/commits" --paginate --slurp | jq 'add // []' > .pr-commits.json
5488
gh api "repos/${TARGET_REPO}/issues/${PR_NUMBER}/labels" --paginate --slurp | jq 'add // []' > .existing-labels.json
@@ -60,8 +94,10 @@ jobs:
6094
const commits = JSON.parse(fs.readFileSync(".pr-commits.json"));
6195
fs.writeFileSync(".pr-metadata.json", JSON.stringify({ pr, files, commits }, null, 2));
6296
'
97+
echo "skip=false" >> "$GITHUB_OUTPUT"
6398
6499
- name: Run badge award orchestrator
100+
if: ${{ steps.collect-meta.outputs.skip == 'false' }}
65101
env:
66102
TARGET_REPO: ${{ github.repository }}
67103
run: |
@@ -74,6 +110,7 @@ jobs:
74110
--dispatch-out=".dispatch-context.json"
75111
76112
- name: Publish evaluation step summary
113+
if: ${{ steps.collect-meta.outputs.skip == 'false' }}
77114
run: |
78115
node -e '
79116
const fs = require("fs");
@@ -82,7 +119,7 @@ jobs:
82119
'
83120
84121
- name: Verify Slack credentials for production run
85-
if: ${{ inputs.dry_run == false }}
122+
if: ${{ steps.collect-meta.outputs.skip == 'false' && inputs.dry_run == false }}
86123
env:
87124
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
88125
run: |
@@ -98,6 +135,7 @@ jobs:
98135
fi
99136
100137
- name: Sequentially dispatch awards and apply tracking labels
138+
if: ${{ steps.collect-meta.outputs.skip == 'false' }}
101139
env:
102140
GH_TOKEN: ${{ github.token }}
103141
TARGET_REPO: ${{ github.repository }}
@@ -196,17 +234,23 @@ jobs:
196234
echo "Label '${LABEL_NAME}' already exists (race condition resolved)."
197235
else
198236
echo "::error::Fatal 422 error creating label '${LABEL_NAME}': $(cat "${CREATE_RESP_FILE}")"
237+
rm -f "${CREATE_RESP_FILE}"
199238
exit 1
200239
fi
201240
else
202241
echo "::error::Failed to create label '${LABEL_NAME}' (HTTP ${HTTP_CODE}): $(cat "${CREATE_RESP_FILE}")"
242+
rm -f "${CREATE_RESP_FILE}"
203243
exit 1
204244
fi
245+
rm -f "${CREATE_RESP_FILE}"
205246
fi
206247
207248
# Apply label to PR
208249
echo "Applying tracking label '${LABEL_NAME}' to PR #${PR_NUMBER}..."
209-
gh issue edit "${PR_NUMBER}" --repo "${TARGET_REPO}" --add-label "${LABEL_NAME}"
250+
if ! gh issue edit "${PR_NUMBER}" --repo "${TARGET_REPO}" --add-label "${LABEL_NAME}"; then
251+
echo "::error::Slack award dispatched for badge '${BADGE_SLUG}' but GitHub label application failed. Manual tracking label intervention required."
252+
exit 1
253+
fi
210254
echo "Applied tracking label '${LABEL_NAME}'."
211255
fi
212256
done

‎.github/workflows/test-badge-evaluator.yml‎

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,52 @@ jobs:
2121
name: Dry-Run Historical PR Evaluation
2222
runs-on: ubuntu-latest
2323
steps:
24+
- name: Validate authorized repository allowlist
25+
env:
26+
TARGET_REPO: ${{ inputs.repository }}
27+
run: |
28+
set -euo pipefail
29+
NORMALIZED_REPO=$(echo "${TARGET_REPO}" | tr '[:upper:]' '[:lower:]')
30+
case "${NORMALIZED_REPO}" in
31+
"layer5io/sistent"|"meshery/meshery"|"meshery/meshery-operator"|"meshery/meshsync"|"layer5io/docs"|"meshery/meshery.io"|"layer5io/layer5")
32+
echo "Repository '${TARGET_REPO}' is authorized for Track 2 badge automation."
33+
;;
34+
*)
35+
echo "::error::Repository '${TARGET_REPO}' is not an authorized Track 2 participating repository."
36+
exit 1
37+
;;
38+
esac
39+
2440
- name: Checkout recognition repository
2541
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 pinned SHA
2642

27-
- name: Collect target PR metadata
43+
- name: Verify PR status and collect metadata
44+
id: collect-meta
2845
env:
2946
GH_TOKEN: ${{ github.token }}
3047
TARGET_REPO: ${{ inputs.repository }}
3148
PR_NUMBER: ${{ inputs.pr_number }}
3249
run: |
3350
set -euo pipefail
34-
echo "Evaluating PR #${PR_NUMBER} from external repository: ${TARGET_REPO}..."
51+
echo "Evaluating PR #${PR_NUMBER} from repository: ${TARGET_REPO}..."
52+
53+
if ! gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > .pr-info.json 2>/dev/null; then
54+
echo "::warning::Pull Request #${PR_NUMBER} not found in ${TARGET_REPO}."
55+
echo "## ⚠️ Badge Evaluation Skipped" >> "$GITHUB_STEP_SUMMARY"
56+
echo "Pull Request #${PR_NUMBER} was not found in \`${TARGET_REPO}\`." >> "$GITHUB_STEP_SUMMARY"
57+
echo "skip=true" >> "$GITHUB_OUTPUT"
58+
exit 0
59+
fi
60+
61+
IS_MERGED=$(jq -r '.merged // false' .pr-info.json)
62+
if [ "${IS_MERGED}" != "true" ]; then
63+
echo "::warning::Pull Request #${PR_NUMBER} in ${TARGET_REPO} is not in a merged state (merged=${IS_MERGED})."
64+
echo "## ⚠️ Badge Evaluation Skipped" >> "$GITHUB_STEP_SUMMARY"
65+
echo "Pull Request #${PR_NUMBER} in \`${TARGET_REPO}\` is not in a merged state." >> "$GITHUB_STEP_SUMMARY"
66+
echo "skip=true" >> "$GITHUB_OUTPUT"
67+
exit 0
68+
fi
3569
36-
gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > .pr-info.json
3770
gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/files" --paginate --slurp | jq 'add // []' > .pr-files.json
3871
gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/commits" --paginate --slurp | jq 'add // []' > .pr-commits.json
3972
gh api "repos/${TARGET_REPO}/issues/${PR_NUMBER}/labels" --paginate --slurp | jq 'add // []' > .existing-labels.json
@@ -45,8 +78,10 @@ jobs:
4578
const commits = JSON.parse(fs.readFileSync(".pr-commits.json"));
4679
fs.writeFileSync(".pr-metadata.json", JSON.stringify({ pr, files, commits }, null, 2));
4780
'
81+
echo "skip=false" >> "$GITHUB_OUTPUT"
4882
4983
- name: Run award orchestrator in dry-run mode
84+
if: ${{ steps.collect-meta.outputs.skip == 'false' }}
5085
env:
5186
TARGET_REPO: ${{ inputs.repository }}
5287
run: |
@@ -58,6 +93,7 @@ jobs:
5893
--out=".evaluation-result.json"
5994
6095
- name: Publish step summary
96+
if: ${{ steps.collect-meta.outputs.skip == 'false' }}
6197
run: |
6298
node -e '
6399
const fs = require("fs");
@@ -66,6 +102,7 @@ jobs:
66102
'
67103
68104
- name: Print sanitized dry-run report
105+
if: ${{ steps.collect-meta.outputs.skip == 'false' }}
69106
run: |
70107
echo "=== SANITIZED DRY-RUN EVALUATION REPORT ==="
71108
cat .evaluation-result.json | jq .

‎docs/TRACK_2_BADGE_AUTOMATION.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,9 @@ flowchart TD
4747
```
4848

4949
### Zero Untrusted Code Execution & Permissions
50-
1. **Privileged Base Context**: Caller workflows execute via `pull_request_target: types: [closed]` where `github.event.pull_request.merged == true`. Fork PR code is **never** checked out.
51-
2. **Minimal Permissions**: The workflow operates strictly with:
50+
1. **Privileged Base Context & Defense-in-Depth**: Caller workflows execute via `pull_request_target: types: [closed]` where `github.event.pull_request.merged == true`. Fork PR code is **never** checked out. As defense-in-depth, the reusable workflow independently queries the GitHub API to verify the PR exists and is actually merged before evaluating badges or dispatching awards.
51+
2. **Authorized Repository Allowlist**: The reusable workflow validates that the calling repository is an authorized Track 2 participating repository (`layer5io/sistent`, `meshery/meshery`, `meshery/meshery-operator`, `meshery/meshsync`, `layer5io/docs`, `meshery/meshery.io`, `layer5io/layer5`). Unlisted repositories fail closed.
52+
3. **Minimal Permissions**: The workflow operates strictly with:
5253
```yaml
5354
permissions:
5455
contents: read

‎utils/award-orchestrator.js‎

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
const fs = require('fs');
22
const path = require('path');
3-
const { evaluateBadges, normalizeLabels, normalizeFiles } = require('./badge-evaluator');
3+
const {
4+
evaluateBadges,
5+
normalizeLabels,
6+
normalizeFiles,
7+
isSupportedRepository,
8+
SUPPORTED_REPOSITORIES
9+
} = require('./badge-evaluator');
410
const { resolveIdentity, maskEmail } = require('./identity-resolver');
511

612
/**
@@ -135,14 +141,32 @@ function buildSummaryMarkdown({
135141
dcoReason,
136142
allEligibleBadges,
137143
alreadyAwardedBadges,
138-
pendingAwards
144+
pendingAwards,
145+
isSupportedRepo,
146+
isMerged
139147
}) {
140148
const lines = [];
141149
lines.push(`## 🎖️ Contributor Badge Evaluation Summary`);
142150
lines.push('');
143151
lines.push(`- **Target Repository**: \`${repo}\``);
144152
lines.push(`- **PR Author**: \`@${prAuthor || 'unknown'}\``);
145153

154+
if (!isSupportedRepo) {
155+
lines.push('');
156+
lines.push(`> [!WARNING]`);
157+
lines.push(`> Repository \`${repo}\` is not an authorized Track 2 participating repository. Badge evaluation rejected.`);
158+
lines.push('');
159+
return lines.join('\n');
160+
}
161+
162+
if (isMerged === false) {
163+
lines.push('');
164+
lines.push(`> [!WARNING]`);
165+
lines.push(`> Pull request is not in a merged state. Badge assignment is strictly limited to merged pull requests.`);
166+
lines.push('');
167+
return lines.join('\n');
168+
}
169+
146170
if (maskedEmail) {
147171
lines.push(`- **Recipient Identity**: \`${maskedEmail}\` (${dcoVerified ? '✅ DCO Verified' : '⚠️ DCO Unverified'})`);
148172
} else {
@@ -207,6 +231,8 @@ function getSanitizedReport(internalResult) {
207231
maskedEmail: internalResult.maskedEmail,
208232
dcoVerified: internalResult.dcoVerified,
209233
dcoReason: internalResult.dcoReason,
234+
isSupportedRepo: internalResult.isSupportedRepo,
235+
isMerged: internalResult.isMerged,
210236
allEligibleBadges: internalResult.allEligibleBadges,
211237
alreadyAwardedBadges: internalResult.alreadyAwardedBadges,
212238
unawardedBadges: internalResult.unawardedBadges,
@@ -241,6 +267,17 @@ function orchestrateAwards({ prMetadata = {}, existingLabels = [], repoOverride
241267
''
242268
).trim();
243269

270+
// Validate supported repository allowlist
271+
const isSupportedRepo = isSupportedRepository(repo);
272+
273+
// Validate merged status if present in metadata
274+
let isMerged = true;
275+
if (prMetadata.pr && typeof prMetadata.pr.merged === 'boolean') {
276+
isMerged = prMetadata.pr.merged;
277+
} else if (typeof prMetadata.merged === 'boolean') {
278+
isMerged = prMetadata.merged;
279+
}
280+
244281
// Extract author
245282
const prAuthor = (
246283
prMetadata.prAuthor ||
@@ -262,6 +299,38 @@ function orchestrateAwards({ prMetadata = {}, existingLabels = [], repoOverride
262299
const rawCommits = prMetadata.commits || [];
263300
const dedupedCommits = deduplicateCommits(rawCommits);
264301

302+
// If repository is unsupported or PR is unmerged, fail closed immediately
303+
if (!isSupportedRepo || isMerged === false) {
304+
const summaryMarkdown = buildSummaryMarkdown({
305+
repo,
306+
prAuthor,
307+
maskedEmail: '',
308+
dcoVerified: false,
309+
dcoReason: !isSupportedRepo ? 'Unsupported repository' : 'PR is not merged',
310+
allEligibleBadges: [],
311+
alreadyAwardedBadges: [],
312+
pendingAwards: [],
313+
isSupportedRepo,
314+
isMerged
315+
});
316+
317+
return {
318+
repo,
319+
prAuthor,
320+
recipientEmail: null,
321+
maskedEmail: '',
322+
dcoVerified: false,
323+
dcoReason: !isSupportedRepo ? 'Unsupported repository' : 'PR is not merged',
324+
isSupportedRepo,
325+
isMerged,
326+
allEligibleBadges: [],
327+
alreadyAwardedBadges: [],
328+
unawardedBadges: [],
329+
pendingAwards: [],
330+
summaryMarkdown
331+
};
332+
}
333+
265334
// Evaluate badge eligibility
266335
const { eligibleBadges } = evaluateBadges({
267336
repository: repo,
@@ -311,7 +380,9 @@ function orchestrateAwards({ prMetadata = {}, existingLabels = [], repoOverride
311380
dcoReason: identity.reason,
312381
allEligibleBadges: eligibleBadges,
313382
alreadyAwardedBadges,
314-
pendingAwards
383+
pendingAwards,
384+
isSupportedRepo,
385+
isMerged
315386
});
316387

317388
return {
@@ -321,6 +392,8 @@ function orchestrateAwards({ prMetadata = {}, existingLabels = [], repoOverride
321392
maskedEmail: maskedRecipientEmail,
322393
dcoVerified: identity.dcoVerified,
323394
dcoReason: identity.reason,
395+
isSupportedRepo,
396+
isMerged,
324397
allEligibleBadges: eligibleBadges,
325398
alreadyAwardedBadges,
326399
unawardedBadges,

0 commit comments

Comments
 (0)